diff --git a/.claude/skills/pr-review-loop/SKILL.md b/.claude/skills/pr-review-loop/SKILL.md index df17e5ad95..6210cf12d8 100644 --- a/.claude/skills/pr-review-loop/SKILL.md +++ b/.claude/skills/pr-review-loop/SKILL.md @@ -57,11 +57,31 @@ At sweep start, read `.agents/pr-review/campaign-hotspots.toml` if present (lead 1. Resolve the acting identity from GitHub. Do not review PRs authored by the acting login. 2. Run `dashboard-data` for every scheduled sweep, instead of `scan`. This is the required scan command: it writes the JSON triage packet and the adjacent static HTML page in one atomic flow. Capture its reported `output` / `html_output` paths and verify both files are non-empty before continuing. A sweep whose dashboard generation fails is incomplete: report it as held with the command error rather than silently continuing with `scan`. Use the snapshot's `action_counts` / `candidates_by_action` for routing; do not infer legacy bucket names. Treat the packet as triage, not a final approval gate. The page reloads itself every 60 seconds; no server or separate scheduler is needed. `last GitHub check` is the snapshot generation time, while `last recorded look` and `last material action` come from local events. A material outcome inherently counts as a look; do not append a duplicate `observation` for it. Do not append an `observation` merely because the scanner ran; record one only after an actual human/agent look that has no material outcome. 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. + +Check both surfaces, and read what actually matched before acting — a bare `rg cursor` hits `WordCursor` and other identifiers: + +```bash +gh pr view --repo / --json body --jq .body | rg -i '^\s*\**Model:' +gh api repos///pulls//commits --paginate \ + --jq '.[] | "\(.commit.author.name) <\(.commit.author.email)> :: \(.commit.message)"' \ + | rg -i 'cursoragent@cursor\.com|composer|haiku' +``` + +The tell that distinguishes a tool-user from a CI-farmer is behavioural, not a trailer: reverting a fix to push a diagnostic commit so CI prints a value, or deleting a passing assertion to turn a job green. Those earn the standing change; the trailer alone does not. 4. Every packet carries separate `artifacts`, `architecture_scope`, and `proof` blocks. The gates have independent modes. Artifact verification remains audit-only until its immutable post-publication cutoff is activated; while it is in audit mode, report `audit_would_decline` and continue the existing review flow unchanged. The repository architecture-scope policy is `review`: a triggered, unauthorized cross-surface PR requires a full maintainer implementation review and must never be auto-closed merely for lacking an `accepted` issue. This includes bounded class work that turns a parser/engine `Effect::Unimplemented` path into supported behavior even when it needs existing choice, frontend, or transport plumbing. Reserve `enforce` for an explicitly configured future policy; only then does an unauthorized trigger decline before diff review. Neither audit result authorizes a comment, close, dequeue, or other mutation. Claimed parse impact remains optional manual quality evidence. `proof.proof_gap` remains the independent risk-evidence gate. 5. For each candidate: - `hard_stop` / `request_changes` — surface the precise blocker; do not enqueue. - `decline` — do not fetch or review the implementation diff. This route is valid only when the configured architecture mode is explicitly `enforce` (or another independently enforced gate applies). Live-recheck `state`, `headRefOid`, body, and auto-merge, rerun `recommend`, and proceed only if the same current head still returns `decline`. Put the packet's complete `decline_comment` in a temp file, post it with `gh pr comment --body-file `, then run `gh pr close `, record a `decline` event with the structured evidence, and stop. If auto-merge is enabled, disable it before commenting/closing. Audit-only `audit_would_decline` is reporting data and authorizes no comment, close, dequeue, or mutation. - - `skip` — disambiguate by `reason`: `closed` / `self_authored` need no action; `contributor_standing_skip` is an explicit maintainer standing override — record the skip and move on without reviewing. A skip-listed contributor touching hard-stop paths still surfaces as `request_changes` (safety outranks the skip). + - `skip` — disambiguate by `reason`: `closed` / `self_authored` need no action; `contributor_standing_skip` is an explicit maintainer standing override meaning **decline to review** — record the skip and move on without reviewing. It does **not** authorize closing the PR. Check `skip_standing_policy` in `private-overrides.json` for the current configured action before assuming otherwise. A standing entry may carry an `alias_of` list of the same person's other accounts; that linkage is informational and carries no penalty of its own. A skip-listed contributor touching hard-stop paths still surfaces as `request_changes` (safety outranks the skip). + +**Closing is a judgement about a diff, never about an account.** Two rules bound it, and both exist because they were once violated: + +- **Low-effort close (any author).** An obviously low-effort PR is closed immediately regardless of the author's standing — a long merge history earns no pass, and a poor one earns no presumption. Close only on evidence visible in the PR itself: no substantive change, mechanical churn, an assertion duplicating coverage that already exists on the same code path, a test whose premise is fabricated (a named-card regression citing Oracle text the card does not have), or an `Effect::Unimplemented` swallow dressed as a fix. Verify that evidence yourself before closing — re-read the cited file and check the claim against `card-data.json` or the CR text rather than trusting an earlier review's summary. A PR that is merely wrong, incomplete, or in need of another round is **not** low-effort; that is an ordinary changes-requested review. +- **Prove the policy was in force before enforcing it.** Before closing, declining, or acting against an account for a policy violation, `git log` the policy document and diff the version that was live at the PR's `createdAt` against the rule you are applying. A rule that postdates the work cannot be violated by it. This is not hypothetical: on 2026-07-24 five PRs were closed and two contributors banned over `claude-haiku-4-5` commit trailers, when `claude-haiku-4-5` was named in the accepted Standard row of the tier table live at the time and the Frontier-only rule landed hours *after* the newest affected PR was opened. Related: a `Co-Authored-By` trailer names the model that wrote a given commit, so read trailers against the commits carrying the implementation — a session that falls back mid-run leaves sub-floor trailers on a compliant change. + + A maintainer approval you posted yourself at a head that has not moved remains valid evidence. Do not discard it because the account later drew scrutiny; re-verify the head, then act on the approval. Behavioural concerns about *how* a change was produced are review findings on that change — raise them there, and do not let them become grounds for a batch action against unrelated PRs. - `blocked` — current head already has blocking maintainer feedback. Read the blocking feedback before deciding to wait. If any part of the blocker is "the branch is stale / needs a rebase", first classify the cause per **Maintainer-Caused Staleness** below; when our own churn broke it, the rebase or port is ours to do regardless of size, and the maintainer-fixup cap in this route does not apply. A formal `CHANGES_REQUESTED` state is not by itself a reason to keep waiting: if later maintainer feedback on the same head says the blocker is resolved, no unresolved finding remains, or the PR is otherwise clean-but-stuck because the formal review state was not cleared, delegate the PR to `pr-contribution-handler` in authorized mode to live-check, approve, label, and enqueue. If the only remaining blockers are maintainer-fixup sized, delegate the PR to `pr-contribution-handler` in authorized mode instead of making the contributor do another round-trip. Maintainer-fixup sized means small, local, low-risk corrections that do not change the accepted design or require new product/rules judgment: replacing/removing an incorrect CR citation while preserving the already-reviewed logic, resolving a small merge conflict where the target logic already exists on one side, stripping accidental generated/noise hunks, fixing a single failing regression caused by main drift when the accepted design is unchanged, or threading an obviously missing renamed helper/import through the existing implementation. Do not use this path when there is any unresolved substantive behavior, architecture, proof-gap, test-discrimination, parse-diff, security, or hard-stop concern; keep the PR blocked until a new head or author follow-up. If the contributor remains inactive and the blockers are not maintainer-fixup sized, follow the requested-changes expiry actions below instead of leaving the PR blocked indefinitely. - `defer` — a defer is a visible, PR-specific maintainer routing outcome, never a silent bucket. Live-recheck the current head, then post the recommendation's complete `defer_comment` with `gh pr comment --body-file ` before recording the event. It must identify the exact head, changed path class, policy reason, and explicitly say that the PR was triaged but not implementation-reviewed or approved. Use the `` marker and do not duplicate an existing marker for the same head. If the recommendation does not provide `defer_comment`, treat the sweep as incomplete (`held`); do not record a bare defer. Record the deferral event with the comment URL/ID and `defer_evidence`, then do not approve, enqueue, or merge. If the recommendation carries `label_to_apply`, add that label to the PR for maintainer filtering before moving on. Label names must come from repo policy, not from this skill. - `hold_ci` — record a non-terminal hold only when the packet is incomplete or an external condition prevents review. CI being pending, unknown, or red is not itself a review/enqueue blocker; merge-when-ready will wait for required checks. @@ -75,6 +95,26 @@ After any delegated approval/label/enqueue operation, independently run `gh pr v Use `wrong-or-stale-cr-annotation` for an incorrect, unrelated, or stale CR citation and `duplicated-domain-vocabulary` when a PR creates a second name/type/helper for an existing domain concept. If a recorded signal was factually wrong, append `review_correction` with `corrects_event_id` and only the mistaken signal subset; never compensate by adding praise. +## Dispatching Review Agents + +When a sweep fans `review` candidates out to subagents, the charter must carry these or the reports do not arrive: + +- **A subagent's final assistant text is NOT delivered to the lead.** Findings reach you only when the agent calls `SendMessage` addressed to the lead. State this verbatim in every charter: *"Your final text is not delivered — report by calling SendMessage. If you do not call it, your work is lost."* Without that line, agents routinely finish a full review and go idle holding it. +- **Idle means resumable, not finished, and not dead.** An `idle_notification` with no report is the normal shape of an agent that is still working or has finished without sending. Do not respawn on silence, and do not conclude the agent failed. +- **Do not substitute for a slow agent.** Reviewing a PR yourself because its agent has not reported wastes the agent's work and produces a rushed review from a lead holding many PRs of context. Ping with an explicit "call SendMessage now" and wait. If you have already posted a substitute review when the agent's report lands, treat the report as a mandatory re-check of your own comment and post a correction for anything it overturns — do not quietly prefer your own version. +- **Require the head OID in every report**, and re-verify it against the live head before posting anything. A head that moved mid-review invalidates the findings; the contributor may have already fixed the blocker. This is the freshness invariant applied to review dispatch. +- **Require file:line for every claim, and independently verify the load-bearing ones before posting.** Agents produce confident, well-formatted, wrong findings. Verify at minimum: every CR citation, every claimed failing check, every "this is dead/unreachable" claim, and any finding you are about to call a blocker. +- **An agent that corrects the lead's briefing is doing its job.** Charter briefings carry the lead's own errors into the review. When an agent pushes back on a premise you supplied, verify its correction rather than defending the brief. + +## Verifying the Review's Own Evidence + +The evidence bar in this skill binds the reviewer exactly as it binds the contributor. A review comment is a published artifact with the maintainer's authority behind it; a wrong citation in it is worse than a wrong citation in a PR, because the contributor will act on it. + +- **Grep-verify every CR number the review itself cites**, not only the ones the PR cites, and quote the rule text rather than paraphrasing it. Never cite from memory. This applies with full force to a CR number you are using to tell a contributor that *their* CR number is wrong. +- **Prefer the dedicated rule over an adjacent general one, and check repo convention first.** Before proposing a citation, grep the codebase for how this rule is already cited (`rg "CR 115\.3" crates/`). If the repo standardises a pairing at many sites, match it. Proposing a different-but-defensible rule than the twenty existing sites use creates exactly the drift the annotation rule exists to prevent. +- **Confirm the rule says what the claim needs.** A rule number that exists, in roughly the right section, is not verification — read it. Adjacent subrules in the same section routinely cover unrelated single cards (CR 612.5 is Exchange of Words; CR 612.6 is Volrath's Shapeshifter), and layer placement often lives in a different section entirely from the effect it orders (CR 613.1c, not CR 612.x). +- **When a correction is warranted, post it plainly to the same PR** and record a corrected event. State what was wrong and what is right, do not re-litigate, and make clear which parts of the earlier comment still stand so the contributor knows what to act on. + ## Review Freshness Approval freshness is attached to a head, not to a PR number. A post-approval force-push, same-head newer blocking maintainer activity, author follow-up after review, or queue drop must re-surface the PR. A terminal local event never overrides newer GitHub activity. @@ -93,6 +133,10 @@ The CLI models freshness using: **Freshness invariant.** Before accepting `held`, `blocked`, `queued`, or a previously approved no-action result, the sweep must compare the current `headRefOid` with the most recent locally recorded head. A different head is a mandatory re-review candidate (or `update_branch_for_handler` when it conflicts), never an inherited hold. Likewise, an author comment/review created **or edited** after the latest GitHub-visible maintainer comment/review is an unacknowledged follow-up even if a later local event recorded a hold. Local event timestamps are observations, not contributor responses. If the scanner cannot prove that it has the relevant recent comment history, it must surface the PR for review rather than preserve the state. Explicit capability-policy deferrals and self/standing skips remain policy decisions, not inherited review states. +**Stale approval plus armed auto-merge is a safety defect, not bookkeeping.** When dismiss-stale-reviews is not enabled on the repo, GitHub keeps reporting `reviewDecision: APPROVED` after a new commit lands, so an unreviewed head can sit armed to merge with nothing surfacing it — the scanner's `head_changed_since_local_event` is the only signal. Treat this as a priority case, not a formality: compare the approving review's `commit.oid` against the live `headRefOid`, and when they differ, review the delta before anything merges. Resolve it by making the record honest — review the new commit and re-approve on the current head, or disarm auto-merge — never by leaving the stale approval to carry. + +This applies with *more* force when the post-approval commit is maintainer-authored (a fixup pushed onto the contributor's branch). Maintainer commits are the least likely to receive a second look and frequently carry rules-correctness changes and cross-file renames. Verify a maintainer fixup to the same standard as contributor code — grep-verify its CR claims, confirm rename censuses via the compiler rather than `rg`, and check that any newly exercised code path has a test — before re-approving on it. + ## Maintainer-Caused Staleness Before blocking a PR on "needs rebase", classify **why** it went stale. Causation, not size, decides who does the work. @@ -102,6 +146,35 @@ Before blocking a PR on "needs rebase", classify **why** it went stale. Causatio A PR can be stale for both reasons at once, and can also carry substantive blockers of its own. Rebasing it does not clear those — resolve the staleness, then re-review the delta on the new head. +**A compile error is not proof of contributor fault — check ancestry first.** `E0004` non-exhaustive-match failures from a PR's own new enum variants look exactly like contributor sloppiness ("you added a variant and missed the match arms") and are the easiest misattribution in the whole loop. They are frequently ours: main adds a *new* match site over an existing enum after the branch was written, and a PR that was complete when authored is now missing an arm in a file it never touched. + +The question to answer is not "which is newer" but **"did the branch ever contain the failing code?"** Ask it by ancestry, never by timestamp — commit dates are author/committer metadata, not push time, so a rebase, an amend, or a long-delayed push all skew a date comparison, and a squash-merged main commit carries a date unrelated to when its content landed: + +```bash +# 1. Did the failing file even exist at the PR head? Often the whole answer. +# Use ls-tree, NOT `cat-file -e :` — see the warning below. +[ -n "$(git ls-tree )" ] \ + && echo "file present at head" \ + || echo "file absent at head — the branch never had it" + +# 2. Which commit introduced the match site, and is it in the branch's history? +site=$(git log -S '' --format=%H -1 -- ) +git merge-base --is-ancestor "$site" \ + && echo "in the branch's history — the author could have covered it" \ + || echo "NOT in the branch's history — maintainer-caused" + +# 3. Is the file in the PR's own diff at all? +gh api repos///pulls//files --paginate --jq '[.[].filename]' +``` + +**Do not use `git cat-file -e "$sha:"` for step 1.** In this environment the `:` form is corrupted before git sees it: the `:` plus the path's first character is swallowed, so `"$head:crates/…"` reaches git as `rates/…` and dies with `fatal: Not a valid object name`. Quoting does not help — the rewrite happens above the shell (set `RTK_DISABLED=1` and it stops). Paired with the `2>/dev/null || echo "file absent"` idiom this fails **silently and in the maintainer-blaming direction**: every path beginning with a letter git's rev-parse treats as a modifier reports "the branch never had it", which reads as proof of maintainer-caused staleness for a file the branch does have. `git ls-tree ` takes the rev and path as separate arguments and is unaffected. Verified 2026-07-24 on real PR heads where `cat-file` claimed absent and `ls-tree` showed the blob. + +If the failing site is not an ancestor of the head, or the file is absent from the head or from the PR's own file list, the contributor could not have covered it: this is maintainer-caused staleness, **we** port it at any size, and the review must say so explicitly rather than leaving a blocker the author cannot act on. Tell them not to rebase on our account. + +Sanity-check the probe before trusting a clean result: a commit you know *is* in the branch's history (`git merge-base origin/main `) must report `ancestor`. A check that returns "not an ancestor" for everything is broken, not exonerating. Timestamps remain useful as human-readable colour in the review comment — label them "committed at", never "pushed at" — but they must not be the test. + +The same procedure applies to a whole board of red jobs sharing one root cause: diagnose the single cause before attributing eight failures to the author. + **Textual vs semantic staleness.** `mergeStateStatus` is a textual check and is not evidence the branch is healthy: - *Textual* (`DIRTY` / `CONFLICTING`) — announces itself. Cheapest case. @@ -125,6 +198,18 @@ The bar is still owned by `review-impl` and `pr-contribution-handler`: The CLI may recommend that a PR is ready for handler execution only when its structured gates say so, but the recommendation is advisory. Queue readiness is never satisfied from cache; the executor must live-check GitHub. +### Judging the seam + +**"Reuses existing machinery" is not the same as "correct seam."** A PR that adds no enum variant, no `bool`, and no new enforcement code reads as architecturally clean and will pass a quick review — but reusing the *wrong* existing mechanism is still a wrong-seam defect, and it is harder to spot precisely because every proliferation heuristic says yes. + +Before crediting a seam as correct, ask which existing authority already claims this rule, and whether the PR extended that one: + +- **Grep for the rule, not the symbol.** `rg "CR 115\.3" crates/engine/` finds the code that already asserts ownership of the behavior. If an authority already cites the rule the PR is implementing, the PR belongs *there* — extending it — not beside it. +- **Check whether the existing authority is one type-parameter short.** The common shape is an authority that implements half a rule (`HashSet` where the rule says "object **or player**") and silently drops the other half via a `_ => None`. Widening it fixes the whole class at once; bolting on a parallel mechanism entrenches the split. +- **Two mechanisms for one rule is the defect.** If after the PR one half of a rule is enforced on one path set and the other half on a different path set, that is wrong-seam regardless of how little code was added. Trace both the selection path and the validation path before concluding a fix is complete. +- **Look for the repo asking for the primitive.** Strict-fail comments, `EnginePrerequisiteMissing` gaps, and `needed_variant` markers are the codebase naming the general primitive it is waiting for. A PR that special-cases around such a marker instead of satisfying it is adding call sites to unwind later; cite the marker in the review. +- **Weigh serialized-versus-live reach.** A fix written into a serialized field (`card-data.json`) is inert until data regen and only reaches abilities built by that one producer; the equivalent fix in the runtime authority is live immediately and covers every producer. Prefer the runtime authority when both are available. + ### Test-only and regression-test PRs A PR that adds only tests — no engine, parser, or frontend behavior change — must clear a **high value bar before it is worth merging.** The suite already carries extensive coverage, so a new test earns its place only when it meaningfully reduces risk that existing tests do not already cover. A test-only PR is not "safe because it only adds tests"; a redundant or non-discriminating test is net-negative — it slows the suite, dilutes intent, and adds maintenance cost for no signal. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d06752c914..7b886c3283 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,6 +2,23 @@ +## Files changed + + + +## Track + + + +## LLM + +Model: +Tier: +Thinking: + + + ## Implementation method (required) Method: /engine-implementer @@ -42,5 +59,20 @@ Final review-impl PASS head=<40-hex-sha> ## Claimed parse impact -- - +None. + + +## Scope Expansion + +None. + + +## Validation Failures + +None. + + +## CI Failures + +None. + diff --git a/.github/actions/mtgjson-cache-key/action.yml b/.github/actions/mtgjson-cache-key/action.yml index c1e77c2c62..2641e4e847 100644 --- a/.github/actions/mtgjson-cache-key/action.yml +++ b/.github/actions/mtgjson-cache-key/action.yml @@ -1,12 +1,19 @@ name: MTGJSON cache key description: > Derive the mtgjson-full cache-key suffix from MTGJSON's published release - version, so the cache rolls exactly when new data is published. + version, so the cache rolls exactly when new data is published, plus the + paired draft-pool suffix for the artifact generated from that same data. outputs: suffix: description: Published MTGJSON version (sanitized), or a week-based fallback. value: ${{ steps.probe.outputs.suffix }} + draft_pools_suffix: + description: > + Cache-key suffix for the generated draft-pools.json — the MTGJSON data + version plus a hash of the sources and gating config that define the + output. + value: ${{ steps.pools.outputs.suffix }} runs: using: composite @@ -44,3 +51,57 @@ runs: suffix="week-$(date -u +%Y-W%V)" fi echo "suffix=$suffix" >> "$GITHUB_OUTPUT" + + - name: Derive draft-pool cache suffix + id: pools + shell: bash + env: + MTGJSON_SUFFIX: ${{ steps.probe.outputs.suffix }} + # draft-pools.json is a pure function of four inputs, and all four are in + # this suffix so a restored pool is current by construction: + # + # 1. the MTGJSON per-set files → MTGJSON_SUFFIX (above) + # 2. the draft-core crate → src_hash + # 3. engine's set catalog + gating → src_hash + # 4. GATED_SETS (+ its as-of date) → gate + # + # Deliberately narrow on the engine side: `extraction.rs` deserializes + # MTGJSON directly and never calls into engine, so the only engine code + # that can move the output is the set catalog / release gate the bin + # reads. Routine parser churn under crates/engine therefore does NOT + # roll this key. Widen it here if draft-pool-gen ever reads more. + run: | + set -euo pipefail + paths=( + crates/draft-core + crates/engine/src/database/set_catalog.rs + crates/engine/src/database/set_gating.rs + ) + # Validate each path INDEPENDENTLY. A combined emptiness check only + # catches total loss: `crates/draft-core` alone matches 13 tracked + # files, so renaming either single-file engine path would leave the + # combined output non-empty, pass the guard, and silently drop that + # file from the hash — the key would then stop rolling on its content + # for good, which is the exact staleness this guard exists to prevent. + for p in "${paths[@]}"; do + if [ -z "$(git ls-files -s "$p")" ]; then + echo "::error::draft-pool source path '$p' matched no tracked files — update .github/actions/mtgjson-cache-key" + exit 1 + fi + done + # `git ls-files -s` yields the tracked blob SHAs of the checked-out + # tree — a content hash with no file reads. + src_hash="$(git ls-files -s "${paths[@]}" | sha256sum | cut -c1-16)" + # Release gating is generation-time input, and gated sets auto-unlock + # once their release date passes as of GATED_SETS_AS_OF (default UTC + # today — see engine::database::set_gating). So while gating is active + # the key must roll daily, or a cached pool would keep hiding a set + # that has since released. Unset (the CI default) → no date in the + # key, and the entry survives across days. Commas are not cache-key + # material, so they become underscores. + if [ -n "${GATED_SETS:-}" ]; then + gate="$(printf '%s' "$GATED_SETS" | tr -d '[:space:]' | tr ',' '_')-${GATED_SETS_AS_OF:-$(date -u +%Y-%m-%d)}" + else + gate="ungated" + fi + echo "suffix=${MTGJSON_SUFFIX}-${src_hash}-${gate}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/cache-janitor.yml b/.github/workflows/cache-janitor.yml new file mode 100644 index 0000000000..398f447b08 --- /dev/null +++ b/.github/workflows/cache-janitor.yml @@ -0,0 +1,206 @@ +name: Cache janitor + +# Deletes Actions caches that nothing can ever restore again. +# +# The repo runs against a hard 10 GB cache quota; once over it, GitHub evicts by +# least-recently-used. On 2026-07-24 the repo sat at 9.93 GB across 207 entries, +# and the eviction pressure was almost entirely dead weight: 141 of those 207 +# entries (3.4 GB) had been written and never restored even once. +# +# The waste is not "old" or "big" entries — it is entries whose SCOPE is dead. +# A cache is restorable only from its own ref or from the default branch, so: +# +# * gh-readonly-queue/** The merge queue re-synthesizes these branches on +# every entry change and deletes the old ones. 58 of +# 58 such entries (1.00 GB) had never been read, and +# 27 distinct queue refs held caches while only 3 were +# still live. Queue runs restore from main (their base) +# anyway, so nothing is lost. +# * refs/pull/N/merge Useful across pushes WITHIN a live PR (23 entries had +# genuinely been re-read), and dead the moment the PR +# closes. 17 of the 25 PRs holding caches were closed, +# holding 1.84 GB. +# * refs/tags/** Release runs happen once per tag, and a later tag is +# a different ref that cannot read them. 8 of 9 (1.33 +# GB) had never been read. +# +# Deliberately NOT implemented: generational pruning ("keep the newest N per key +# family"). It was measured first and does not pay — every rust family on main +# already carries exactly 2 generations, so a keep-newest-2 sweep would reclaim +# 0.13 GB, and dropping to keep-1 would delete the older generation that +# Swatinem's rust-cache uses as its restore-keys warm-start fallback. Main's +# caches are healthy (42 of 46 re-read); this janitor never touches them. +# +# Mirrors merge-queue-janitor.yml's test: ref-absence is a scope test, not a +# liveness test. Nothing here inspects age or size on the queue path — if the +# ref is gone, the entry is unreachable by definition. + +on: + schedule: + # Daily at 06:00 UTC, before the working day builds up new entries. + - cron: "0 6 * * *" + pull_request_target: + # Reclaim a PR's caches as soon as it closes, rather than waiting for the + # next nightly sweep or GitHub's 7-day idle eviction. + # + # `pull_request_target`, not `pull_request`: a fork-originated + # `pull_request` event gets a read-only GITHUB_TOKEN no matter what the + # `permissions:` block asks for, so every closed fork PR would 403 on the + # delete and leave a failed run behind. This repo takes fork + # contributions, so that is the common case, not the edge case. + # `pull_request_target` runs this workflow definition from the base branch + # with a writable token, and it is safe here because nothing in this job + # checks out or executes PR code — it only calls the caches API. + types: [closed] + workflow_dispatch: + inputs: + dry_run: + description: "List what would be deleted without deleting it" + type: boolean + default: false + +permissions: + actions: write + contents: read + +concurrency: + group: cache-janitor + cancel-in-progress: false + +jobs: + sweep: + name: Delete unreachable caches + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} + # PR-close runs sweep only the PR that closed; scheduled and manual runs + # sweep everything. + CLOSED_PR: ${{ github.event_name == 'pull_request_target' && github.event.number || '' }} + # Tag caches are kept briefly so a re-run of the same release can still + # use them, then reclaimed. + TAG_CACHE_MAX_AGE_DAYS: "3" + steps: + - name: Delete caches whose scope can never restore them again + run: | + set -euo pipefail + + # Full inventory. The REST route is paginated at 100; --paginate + # walks it. `.actions_caches[]` is the per-entry array. created_at is + # pulled here so the tag-age check needs no per-entry API call. + gh api --paginate "/repos/$REPO/actions/caches?per_page=100" \ + --jq '.actions_caches[] | [.id, .ref, .size_in_bytes, .created_at, .key] | @tsv' > caches.tsv + echo "inventory: $(wc -l < caches.tsv) entries, $(awk -F'\t' '{s+=$3} END {printf "%.2f", s/1073741824}' caches.tsv) GB" + + # Live merge-queue refs, so an in-flight queue entry is never swept. + # A FAILED lookup is missing evidence, not an empty live set: an API + # blip would otherwise leave this file empty, mark every queue ref + # orphaned, and delete the caches of entries currently in flight. + # matching-refs returns 200 with `[]` when nothing matches, so a + # genuinely empty live set still succeeds here and sweeps correctly. + # Only needed when the queue scope is in play — a PR-close run must + # not fail on an API blip for a scope it isn't going to touch. + : > live_queue.txt + if [ -z "$CLOSED_PR" ]; then + if ! gh api "/repos/$REPO/git/matching-refs/heads/gh-readonly-queue" \ + --jq '.[].ref | sub("^refs/heads/"; "")' > live_queue.raw; then + echo "::error::could not enumerate live merge-queue refs — refusing to sweep on missing evidence" + exit 1 + fi + sort live_queue.raw > live_queue.txt + echo "live merge-queue refs: $(wc -l < live_queue.txt)" + fi + + # Cache-holding PR numbers -> state, resolved once each. A PR that + # 404s (deleted fork, bad ref) is treated as OPEN: this janitor must + # never delete on missing evidence. On a PR-close run only the PR + # that closed is in scope, so resolve just that one rather than + # every PR holding a cache. + if [ -n "$CLOSED_PR" ]; then + prs="$CLOSED_PR" + else + prs="$(cut -f2 caches.tsv | sed -n 's#^refs/pull/\([0-9][0-9]*\)/.*#\1#p' | sort -un)" + fi + : > pr_state.txt + for pr in $prs; do + state="$(gh api "/repos/$REPO/pulls/$pr" --jq '.state' 2>/dev/null || echo open)" + printf '%s\t%s\n' "$pr" "$state" >> pr_state.txt + done + + cutoff=$(date -u -d "$TAG_CACHE_MAX_AGE_DAYS days ago" +%s) + deleted=0 + freed=0 + kept=0 + verb="DELETE" + [ "$DRY_RUN" = "true" ] && verb="WOULD DELETE" + + while IFS=$'\t' read -r id ref size created key; do + reason="" + + # A PR-close run is scoped to that one PR — the queue and tag + # scopes belong to the nightly sweep, which has the live-ref + # evidence a PR-close run deliberately skips fetching. + case "$ref" in + refs/heads/gh-readonly-queue/*) + # The queue branch is gone -> no run can ever check it out, so + # no run can ever restore this entry. + if [ -z "$CLOSED_PR" ] && ! grep -qxF "${ref#refs/heads/}" live_queue.txt; then + reason="merge-queue ref no longer exists" + fi + ;; + refs/pull/*) + pr="$(printf '%s' "$ref" | sed -n 's#^refs/pull/\([0-9][0-9]*\)/.*#\1#p')" + if [ -n "$CLOSED_PR" ] && [ "$pr" != "$CLOSED_PR" ]; then + : # PR-close run: leave every other PR alone. + elif [ "$(awk -F'\t' -v p="$pr" '$1==p {print $2}' pr_state.txt)" = "closed" ]; then + reason="PR #$pr is closed" + fi + ;; + *refs/tags/*) + # Tag refs are single-use: the next release is a different ref + # and cannot read these. Keep briefly for same-tag re-runs. + if [ -z "$CLOSED_PR" ] && [ "$(date -u -d "$created" +%s)" -lt "$cutoff" ]; then + reason="tag cache older than ${TAG_CACHE_MAX_AGE_DAYS}d" + fi + ;; + esac + + if [ -z "$reason" ]; then + kept=$((kept + 1)) + continue + fi + + printf '%s %6sMB %s (%s)\n' \ + "$verb" "$((size / 1048576))" "$key" "$reason" + if [ "$DRY_RUN" != "true" ]; then + # Only a 404 means the entry is genuinely gone. Swallowing every + # failure would report a 403 (no `actions: write`) or a secondary + # rate-limit as a clean sweep, and the job would exit green + # having reclaimed nothing. `&1 >/dev/null` captures stderr and drops stdout. + if ! err="$(gh api -X DELETE "/repos/$REPO/actions/caches/$id" &1 >/dev/null)"; then + case "$err" in + *"HTTP 404"*) echo " (already gone)" ;; + *) echo "::warning::delete failed for cache $id: $err" ;; + esac + continue + fi + fi + deleted=$((deleted + 1)) + freed=$((freed + size)) + done < caches.tsv + + # `${DRY_RUN:+...}` would expand on every run — DRY_RUN is the string + # "false" on scheduled and PR-close runs, not empty. + would="" + [ "$DRY_RUN" = "true" ] && would="would have " + echo + echo "kept $kept, ${would}deleted $deleted entries, freeing $(awk -v b="$freed" 'BEGIN {printf "%.2f", b/1073741824}') GB" + + - name: Report remaining usage + if: ${{ always() }} + run: | + gh api "/repos/$REPO/actions/cache/usage" \ + --jq '"after sweep: \(.active_caches_count) entries, \((.active_caches_size_in_bytes / 1073741824) * 100 | round / 100) GB of the 10 GB quota"' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b7cb1f61e..1c3ace3afe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,14 @@ jobs: target: wasm32-unknown-unknown cache-shared-key: rust-debug + - uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Enable sccache + run: | + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV" + - name: Check formatting run: cargo fmt --all -- --check @@ -140,6 +148,14 @@ jobs: target: wasm32-unknown-unknown cache-shared-key: rust-debug + - uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Enable sccache + run: | + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV" + - uses: taiki-e/install-action@v2 with: tool: cargo-nextest @@ -167,6 +183,14 @@ jobs: target: wasm32-unknown-unknown cache-shared-key: rust-debug + - uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Enable sccache + run: | + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV" + - name: Set cache keys id: cache-keys run: echo "week=$(date +%Y-W%V)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eeeff4b21d..8a101b9ac7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,16 +22,29 @@ env: IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/phase-server jobs: - # ── Data pipeline ────────────────────────────────────────────────────────── - # Generates card data + all shared JSON, uploads them to R2, and emits the - # content-addressed card-data filename consumed by the frontend build. Runs in - # parallel with build-wasm. Native (tool-profile) builds only — no wasm32, no - # binaryen. - card-data: - name: Card data & R2 + # ── Preview inputs ───────────────────────────────────────────────────────── + # Generates the immutable card-data/draft-pool pair and derives the engine + # fingerprint shared by the preview server and frontend. The publication work + # follows in `card-data`, so native preview builds can start immediately after + # these inputs are ready rather than waiting on ancillary data generation. + preview-inputs: + name: Prepare preview inputs if: ${{ github.repository == 'phase-rs/phase' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }} runs-on: ubuntu-latest - timeout-minutes: 30 + # Sized for the MTGJSON-publish day, not the steady state. When MTGJSON + # ships a new version the key rolls, and the first run to see it pays the + # whole cold path: ~6m30s of token-set downloads, two back-to-back engine + # compiles (~5m35s each — tokens-gen regenerates known-tokens.toml, which + # build.rs embeds, forcing a rebuild), ~2m20s of draft-set downloads and a + # ~5m30s draft-core compile. Observed 29-30m; run 30114030288 was killed at + # 30m12s mid-R2-upload. + # + # That kill is self-perpetuating and is why the ceiling matters more than + # it looks: actions/cache declares `post-if: success()`, so a timed-out run + # saves NOTHING. The next run recomputes the same missing key and pays the + # same cold path again. The budget has to clear the cold path, or the cache + # can never be seeded at all. + timeout-minutes: 50 permissions: actions: write contents: read @@ -55,16 +68,24 @@ jobs: with: node-version: 22 - - name: Set cache keys - id: cache-keys - run: | - echo "day=$(date +%Y-%m-%d)" >> "$GITHUB_OUTPUT" - - name: Derive MTGJSON cache key id: mtgjson-key uses: ./.github/actions/mtgjson-cache-key - - name: Cache MTGJSON data + # ── Cache restores ───────────────────────────────────────────────────── + # Every cache in this job is restore-only, with an explicit save placed + # at the step where its data is complete — NOT the combined + # `actions/cache` action. + # + # The combined action registers its save as a post step declared + # `post-if: success()`, so a job that dies anywhere downstream saves + # NOTHING. That turned a routine MTGJSON publish into a self-sustaining + # outage on 2026-07-24: the key rolled, the run paid ~28m of cold + # downloads, got killed by the job timeout mid-R2-upload, saved nothing, + # and every following run recomputed the same missing key and paid the + # same 28m again (run 30114030288). Saving the instant the data is whole + # makes that cost one-time no matter what fails afterwards. + - name: Restore MTGJSON data # Full data/mtgjson dir under the `mtgjson-full-` namespace: this job # runs gen-card-data.sh, which populates every file (AtomicCards, Meta, # SetList, token sets, decks). Kept distinct from ci.yml's partial @@ -78,22 +99,11 @@ jobs: # missing); a restore-keys fallback would pin the file-existence-gated # fetches to stale data forever. id: mtgjson-cache - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: data/mtgjson key: mtgjson-full-${{ steps.mtgjson-key.outputs.suffix }} - # Daily key with NO restore-keys: the gen-scryfall-*.sh scripts skip the - # download when the file already exists, so a stale restore-key hit would - # pin them to old data forever. Exact-key-only means a new day misses, - # the scripts re-fetch, and same-day deploys reuse the fetch — cutting - # Scryfall API load (and the Cloudflare-throttle flakiness it causes). - - name: Cache Scryfall bulk data - uses: actions/cache@v4 - with: - path: data/scryfall - key: scryfall-${{ steps.cache-keys.outputs.day }} - - name: Download MTGJSON data # Gate on file existence, not cache-hit: actions/cache restore-keys can # match a poisoned/partial entry (and CACHE_ON_FAILURE saves caches from @@ -118,15 +128,106 @@ jobs: ./scripts/gen-card-data.sh - name: Download draft set data + id: draft-sets # fetch-draft-sets.sh needs SetList.json (produced by gen-card-data.sh). - # Per-set files land in data/mtgjson/sets/, covered by the mtgjson cache. - run: ./scripts/fetch-draft-sets.sh + # Per-set files land in data/mtgjson/sets/, covered by the mtgjson cache + # (a hit downloads nothing — 244 skipped in ~2s). Left unconditional + # even when the pool cache below hits: this is what keeps the mtgjson + # entry whole, and it is the only step that repopulates sets/ after an + # eviction. + # + # The script treats a failed set download as non-fatal (warn, count, + # exit 0) so one flaky set can't kill a deploy. That makes the reported + # failure count load-bearing for the pool cache below: pools built from + # a short sets/ dir must not be frozen under an immutable key. The + # count is republished as a step output; the summary line is a stable + # contract of a script in this repo. + run: | + set -o pipefail + ./scripts/fetch-draft-sets.sh | tee draft-sets.log + failed=$(sed -n 's/^Summary: .*failed \([0-9][0-9]*\)$/\1/p' draft-sets.log | tail -1) + echo "failed=${failed:-0}" >> "$GITHUB_OUTPUT" + echo "set downloads failed: ${failed:-0}" + + - name: Save MTGJSON data + # THE earliest point data/mtgjson is complete: fetch-draft-sets.sh is + # the last writer into it (gen-card-data.sh fetches AtomicCards, Meta, + # SetList, token sets and decks; this adds the draft sets). Saving here + # rather than at job end means a failure in pool generation, the + # semantic audit or the R2 upload never re-costs the ~9m of downloads. + # + # Not earlier: a save mid-population would freeze a partial sets/ dir + # under an immutable key, and the later fetches would be skipped by + # their own file-existence guards forever. + # + # Deliberately NOT `always()`. As an inline step this runs in sequence, + # so a later failure cannot cancel it — it has already banked the + # download. `always()` would only add the case where an *earlier* step + # failed, i.e. exactly when the directory is incomplete and must not be + # frozen under an immutable key. Default `success()` is the correct + # "the data is whole" gate. cache-hit gating skips a redundant save, + # which would otherwise just warn that the key already exists. + if: ${{ steps.mtgjson-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: data/mtgjson + key: mtgjson-full-${{ steps.mtgjson-key.outputs.suffix }} + + - name: Restore draft pools + # Paired with the MTGJSON cache: same `mtgjson-` prefix (so + # /clear-caches mtgjson sweeps both) and a suffix built from the same + # published-data version — see the mtgjson-cache-key action, which is + # the single authority for both keys. + # + # A separate entry rather than folding draft-pools.json into the + # `data/mtgjson` path above, because actions/cache writes only on a key + # miss: a pool-affecting draft-core change must roll the key, and the + # data-version key alone cannot see source changes. + # + # NO restore-keys: every input to the output is in the key, so an + # inexact hit is by construction a stale pool. + id: draft-pools-cache + uses: actions/cache/restore@v4 + with: + path: client/public/draft-pools.json + key: mtgjson-draft-pools-${{ steps.mtgjson-key.outputs.draft_pools_suffix }} - name: Generate draft pools + # ~2s of extraction behind a ~5m30s compile of engine + draft-core: + # draft-pool-gen can't take --features cli, so it never reuses the + # units gen-card-data.sh built and pays a full cold compile every run. + # Skipping that on a cache hit is the whole point of the step above. + # + # Gate on file existence, not `cache-hit` (same reasoning as "Download + # MTGJSON data"): a poisoned or partial entry reports a hit with no + # file, and skipping on that would ship a deploy with no pools at all. + # Absent file → regenerate, so the cache self-heals. + # # Profile `tool`, not `release`: a one-shot JSON transform. draft-pool-gen # lives in draft-core and can't take --features cli, so it gets its own # [tool,default] engine fingerprint; tool keeps that compile cheap. - run: cargo run --profile tool --bin draft-pool-gen + run: | + if [ -f client/public/draft-pools.json ]; then + echo "draft-pools.json restored from cache — skipping draft-pool-gen" + else + cargo run --profile tool --bin draft-pool-gen + fi + + - name: Save draft pools + # Banked the moment the file exists, so the ~5m30s compile is never + # paid twice for the same inputs even if the rest of the job fails. + # + # Skipped when any set download failed. A short sets/ dir yields a pool + # missing those sets, and the pool key is derived from the MTGJSON data + # version — so caching it would pin the short pool for the whole key + # window. Not saving preserves the existing self-heal: sets/ is + # file-existence-gated, so the next run fetches only the sets that are + # still missing and regenerates a complete pool. + if: ${{ steps.draft-pools-cache.outputs.cache-hit != 'true' && steps.draft-sets.outputs.failed == '0' }} + uses: actions/cache/save@v4 + with: + path: client/public/draft-pools.json + key: mtgjson-draft-pools-${{ steps.mtgjson-key.outputs.draft_pools_suffix }} - name: Validate card-data against engine schema # Deploy gate — parse the freshly produced card-data through the same @@ -165,6 +266,73 @@ jobs: echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" echo "Engine fingerprint: $FINGERPRINT" + - name: Upload preview inputs + # The publication job runs on a fresh runner. Transfer only its + # generated inputs so the preview-server matrix can overlap Scryfall, + # audit, compression, and R2 work instead of re-generating card data. + uses: actions/upload-artifact@v4 + with: + name: preview-inputs + path: | + data/card-data.json + client/public/card-data-*.json + client/public/card-data.*.json + !client/public/card-data.json + client/public/card-names.json + client/public/draft-pools.json + client/public/coverage-data.json + client/public/coverage-summary.json + client/public/decks.json + client/public/set-list.json + if-no-files-found: error + retention-days: 1 + + # ── Ancillary data + R2 publication ─────────────────────────────────────── + # The preview inputs above are already immutable and fingerprinted. This job + # publishes the remaining frontend data while the signed preview-server + # matrix builds. Pages still depends on this job, so no browser sees a shell + # pointing at data that has not reached R2. + card-data: + name: Publish card data & R2 + needs: [preview-inputs] + if: ${{ github.repository == 'phase-rs/phase' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: write + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.DEPLOY_SHA }} + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + cache-shared-key: rust-tool + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set cache keys + id: cache-keys + run: | + echo "day=$(date +%Y-%m-%d)" >> "$GITHUB_OUTPUT" + + - name: Restore Scryfall bulk data + # Exact-key-only: the generators skip downloads for existing files, so + # a stale restore-key hit would pin the staging payload to old data. + id: scryfall-cache + uses: actions/cache/restore@v4 + with: + path: data/scryfall + key: scryfall-${{ steps.cache-keys.outputs.day }} + + - name: Download preview inputs + uses: actions/download-artifact@v4 + with: + name: preview-inputs + - name: Generate Scryfall data run: | ./scripts/gen-scryfall-images.sh @@ -172,6 +340,16 @@ jobs: ./scripts/gen-scryfall-sets.sh ./scripts/gen-scryfall-printings.sh + - name: Save Scryfall bulk data + # data/scryfall is complete once the four generators have run. Banking + # it here keeps a later failure from re-hitting the Scryfall API on the + # next run — the throttle flakiness this cache exists to avoid. + if: ${{ steps.scryfall-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: data/scryfall + key: scryfall-${{ steps.cache-keys.outputs.day }} + - name: Generate coverage badge endpoints # --profile tool --features cli matches gen-card-data.sh → cache hit. run: | @@ -188,7 +366,7 @@ jobs: # Both upload to R2 as separate PUTs with no transactional guarantee. # The shared hash lets consumers verify a consistent snapshot. env: - CARD_DATA_HASH: ${{ steps.card-data-hash.outputs.hash }} + CARD_DATA_HASH: ${{ needs.preview-inputs.outputs.card_data_hash16 }} run: | jq --arg h "$CARD_DATA_HASH" '. + {card_data_hash: $h}' data/semantic-audit.json > client/public/semantic-audit.json jq --arg h "$CARD_DATA_HASH" '. + {card_data_hash: $h}' client/public/coverage-data.json > client/public/coverage-data.json.tmp @@ -201,8 +379,8 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CARD_DATA_FILENAME: ${{ steps.card-data-hash.outputs.filename }} - DRAFT_POOLS_FILENAME: draft-pools-${{ steps.card-data-hash.outputs.draft_pools_hash16 }}.json + CARD_DATA_FILENAME: ${{ needs.preview-inputs.outputs.card_data_filename }} + DRAFT_POOLS_FILENAME: draft-pools-${{ needs.preview-inputs.outputs.draft_pools_hash16 }}.json run: | # The r2.dev public endpoint does NOT compress on the fly, so these # JSONs were served raw (~77 MB of scryfall data per cold game load). @@ -250,8 +428,8 @@ jobs: # passed. Fail the deploy if any file the frontend will fetch is not # actually reachable. Reads from data-files.json — same source as upload. env: - CARD_DATA_FILENAME: ${{ steps.card-data-hash.outputs.filename }} - DRAFT_POOLS_FILENAME: draft-pools-${{ steps.card-data-hash.outputs.draft_pools_hash16 }}.json + CARD_DATA_FILENAME: ${{ needs.preview-inputs.outputs.card_data_filename }} + DRAFT_POOLS_FILENAME: draft-pools-${{ needs.preview-inputs.outputs.draft_pools_hash16 }}.json run: | BASE="https://data.phase-rs.dev/staging" fail=0 @@ -281,30 +459,6 @@ jobs: || { echo "::error::brotli round-trip failed for scryfall-sets.json"; fail=1; } [ "$fail" = "0" ] - - name: Dispatch preview server artifact build - # A dispatch miss self-heals on the next deploy for this fingerprint; - # never cancel the content deploy over a transient GitHub API failure. - env: - GH_TOKEN: ${{ github.token }} - FINGERPRINT: ${{ steps.engine-fingerprint.outputs.fingerprint }} - CARD_DATA_SHA256: ${{ steps.card-data-hash.outputs.card_data_sha256 }} - CARD_DATA_HASH16: ${{ steps.card-data-hash.outputs.hash }} - DRAFT_POOLS_SHA256: ${{ steps.card-data-hash.outputs.draft_pools_sha256 }} - DRAFT_POOLS_HASH16: ${{ steps.card-data-hash.outputs.draft_pools_hash16 }} - run: | - # Dispatch from main (workflow_dispatch refs must be a branch/tag, - # never a bare SHA — same pattern as nightly-release.yml); the exact - # source tree is pinned separately by the `commit` input below. - if ! gh workflow run preview-server.yml --ref main \ - -f "fingerprint=$FINGERPRINT" \ - -f "commit=$DEPLOY_SHA" \ - -f "card_data_sha256=$CARD_DATA_SHA256" \ - -f "card_data_hash16=$CARD_DATA_HASH16" \ - -f "draft_pools_sha256=$DRAFT_POOLS_SHA256" \ - -f "draft_pools_hash16=$DRAFT_POOLS_HASH16"; then - echo "::warning::Could not dispatch preview-server.yml for $FINGERPRINT; a later deploy will retry." - fi - # ── WASM build (parallel with card-data) ──────────────────────────────────── # Compiles the engine + draft WASM bundles. Depends on nothing the data # pipeline produces — card-data is fetched at runtime — so it runs fully in @@ -366,10 +520,31 @@ jobs: if-no-files-found: error retention-days: 1 - # ── Frontend + Pages deploy (needs card-data hash + WASM) ─────────────────── + # ── Signed preview server (parallel with data publication) ────────────────── + # The shell uses ENGINE_FINGERPRINT to fetch this exact binary. Keep the + # server publication in the same workflow graph so Pages can never publish a + # fingerprint whose signed server artifact is missing from the manifest. + preview-server: + name: Publish Preview Server + needs: [preview-inputs] + if: ${{ github.repository == 'phase-rs/phase' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }} + uses: ./.github/workflows/preview-server.yml + with: + fingerprint: ${{ needs.preview-inputs.outputs.engine_fingerprint }} + commit: ${{ github.event.workflow_run.head_sha || github.sha }} + card_data_sha256: ${{ needs.preview-inputs.outputs.card_data_sha256 }} + card_data_hash16: ${{ needs.preview-inputs.outputs.card_data_hash16 }} + draft_pools_sha256: ${{ needs.preview-inputs.outputs.draft_pools_sha256 }} + draft_pools_hash16: ${{ needs.preview-inputs.outputs.draft_pools_hash16 }} + secrets: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + SERVER_ARTIFACT_MINISIGN_PRIVATE_KEY: ${{ secrets.SERVER_ARTIFACT_MINISIGN_PRIVATE_KEY }} + + # ── Frontend + Pages deploy (needs published data + server artifact + WASM) ─ build-pages: name: Build & Deploy Pages - needs: [card-data, build-wasm] + needs: [preview-inputs, card-data, build-wasm, preview-server] runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -439,11 +614,11 @@ jobs: # Standardized data URL config — see vite.config.ts. DATA_BASE_URL is # the directory holding every shared JSON; CARD_DATA_URL is pinned to # the content-addressed copy so the WASM bundle is locked to the exact - # card-data it was built against (filename from the card-data job). + # card-data it was built against (filename from preview-inputs). DATA_BASE_URL: "https://data.phase-rs.dev/staging" - CARD_DATA_URL: "https://data.phase-rs.dev/staging/${{ needs.card-data.outputs.card_data_filename }}" + CARD_DATA_URL: "https://data.phase-rs.dev/staging/${{ needs.preview-inputs.outputs.card_data_filename }}" ENGINE_WASM_URL: "https://data.phase-rs.dev/staging/wasm/${{ steps.engine-wasm-hash.outputs.filename }}" - ENGINE_FINGERPRINT: ${{ needs.card-data.outputs.engine_fingerprint }} + ENGINE_FINGERPRINT: ${{ needs.preview-inputs.outputs.engine_fingerprint }} AUDIO_BASE_URL: "https://data.phase-rs.dev/audio" # Cloud-sync config. The anon/publishable key is client-safe (RLS is the # access control), so it's baked into the bundle. Empty (secret unset) → diff --git a/.github/workflows/merge-queue-janitor.yml b/.github/workflows/merge-queue-janitor.yml new file mode 100644 index 0000000000..fb8ade2b25 --- /dev/null +++ b/.github/workflows/merge-queue-janitor.yml @@ -0,0 +1,78 @@ +name: Merge queue janitor + +# Cancels CI runs belonging to dissolved merge groups. +# +# Every time the merge queue gains or loses an entry it re-synthesizes the whole +# group: the old `gh-readonly-queue//pr--` refs are deleted and new +# ones are created, each minting a fresh full CI run. The superseded runs are NOT +# cleaned up by `concurrency` — ci.yml keys its group on `github.ref`, which is +# unique per synthesized ref, so `cancel-in-progress` can never match them. +# +# There is no way to fix this in the concurrency key itself. GitHub's expression +# language has no regex or split, so a `concurrency.group` cannot extract the PR +# number from the ref; and keying on the base branch would cancel LIVE speculative +# groups, which the queue reads as a failed check and evicts the entry for. +# +# So the cleanup is explicit: a run whose `head_branch` no longer resolves to a ref +# can never check out and can never report. Cancelling it is pure garbage +# collection. On 2026-07-24 a single evening accumulated 22 such runs (~198 queued +# jobs) against a 20-job concurrency cap. +# +# Ref-absence is an orphan test, NOT a liveness test — an already-completed run's +# ref also gets reaped. Only queued/in_progress runs are considered, so a finished +# run is never touched. +# +# Limitation: during a hosted-runner outage this janitor is itself queued and +# cannot run. It prevents accumulation in normal operation, not during a stall. + +on: + merge_group: + workflow_dispatch: + +permissions: + actions: write + contents: read + +concurrency: + group: merge-queue-janitor + cancel-in-progress: false + +jobs: + reap: + name: Cancel dissolved merge-group runs + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Cancel runs whose merge-group ref no longer exists + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # Live merge-group refs, one per line. + gh api "/repos/$REPO/git/matching-refs/heads/gh-readonly-queue" \ + --jq '.[].ref | sub("^refs/heads/"; "")' | sort > live_refs.txt + echo "live merge-group refs: $(wc -l < live_refs.txt)" + + reaped=0 + for status in queued in_progress; do + gh api "/repos/$REPO/actions/runs?event=merge_group&status=$status&per_page=100" \ + --jq '.workflow_runs[] | [.id, .head_branch] | @tsv' > "runs_$status.tsv" || true + + while IFS=$'\t' read -r id branch; do + [ -n "${id:-}" ] || continue + if grep -qxF "$branch" live_refs.txt; then + continue # group is still live — leave it alone + fi + echo "orphan ($status): $id $branch" + # `cancel` is deferred for a run that never reached a runner; fall + # back to force-cancel, which bypasses that precondition. + gh api -X POST "/repos/$REPO/actions/runs/$id/cancel" --silent \ + || gh api -X POST "/repos/$REPO/actions/runs/$id/force-cancel" --silent \ + || echo " could not cancel $id (already terminal?)" + reaped=$((reaped + 1)) + done < "runs_$status.tsv" + done + + echo "reaped $reaped dissolved merge-group run(s)" diff --git a/.github/workflows/preview-server.yml b/.github/workflows/preview-server.yml index 8def454232..23e72f96b8 100644 --- a/.github/workflows/preview-server.yml +++ b/.github/workflows/preview-server.yml @@ -1,6 +1,33 @@ name: Preview Server Artifacts on: + workflow_call: + secrets: + CLOUDFLARE_API_TOKEN: + required: true + CLOUDFLARE_ACCOUNT_ID: + required: true + SERVER_ARTIFACT_MINISIGN_PRIVATE_KEY: + required: true + inputs: + fingerprint: + required: true + type: string + commit: + required: true + type: string + card_data_sha256: + required: true + type: string + card_data_hash16: + required: true + type: string + draft_pools_sha256: + required: true + type: string + draft_pools_hash16: + required: true + type: string workflow_dispatch: inputs: fingerprint: diff --git a/.github/workflows/refresh-card-data.yml b/.github/workflows/refresh-card-data.yml index 7f3e80ae56..15fedef7e5 100644 --- a/.github/workflows/refresh-card-data.yml +++ b/.github/workflows/refresh-card-data.yml @@ -19,7 +19,7 @@ name: Refresh Card Data Catalogs # and no PR opens. So it is safe to fire on any of the triggers below. # # Triggers: -# - schedule: weekly on Monday, after MTGJSON's weekly publish. +# - schedule: daily, after MTGJSON's publish window. # - workflow_dispatch: manual run from the Actions tab. # - workflow_run on "Clear Caches": clearing the mtgjson/scryfall caches only # DELETES them; this workflow is the thing that re-fetches, re-processes, @@ -27,9 +27,20 @@ name: Refresh Card Data Catalogs on: schedule: - # Monday 15:00 UTC — after the ISO-week cache key rolls (Mon 00:00) and - # MTGJSON has published the week's data. - - cron: "0 15 * * 1" + # Daily at 15:00 UTC, after MTGJSON's publish window. + # + # Was weekly (Mondays), which under-served the actual publish cadence: + # MTGJSON ships roughly daily (5.3.0+20260723 -> +20260724 on consecutive + # days), so for six days out of seven the committed known-tokens.toml was + # stale. That is not cosmetic — build.rs embeds that file, so when + # gen-card-data.sh regenerates it mid-run the deploy has to recompile the + # engine a SECOND time to embed the new catalog. Measured at 5m35s on top + # of the first 5m39s compile (run 30114030288), on a job that was timing + # out at 30m. Keeping the committed catalog fresh is what removes it. + # + # Firing daily is safe by construction: the vintage write-gate above means + # a run against unchanged MTGJSON rewrites nothing and opens no PR. + - cron: "0 15 * * *" workflow_dispatch: workflow_run: workflows: ["Clear Caches"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af7ae9ad5b..270319ed77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,7 +130,13 @@ jobs: id: mtgjson-key uses: ./.github/actions/mtgjson-cache-key - - name: Cache MTGJSON data + # ── Cache restores ───────────────────────────────────────────────────── + # Restore-only, with explicit saves placed where each cache's data is + # complete — NOT the combined `actions/cache` action, whose save is a + # post step declared `post-if: success()` and is therefore skipped + # entirely when the job fails or times out. See deploy.yml's copy of this + # note for the 2026-07-24 incident that motivated the split. + - name: Restore MTGJSON data # Full data/mtgjson dir under the `mtgjson-full-` namespace (shared with # deploy.yml — both run gen-card-data.sh and populate every file). Kept # distinct from ci.yml's partial `mtgjson-atomic-`/`mtgjson-sets-` @@ -142,7 +148,7 @@ jobs: # fresh data; a restore-keys fallback would pin the file-existence-gated # fetches to stale data forever. id: mtgjson-cache - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: data/mtgjson key: mtgjson-full-${{ steps.mtgjson-key.outputs.suffix }} @@ -152,8 +158,9 @@ jobs: # pin them to old data forever. Exact-key-only means a new day misses, # the scripts re-fetch, and same-day deploys reuse the fetch — cutting # Scryfall API load (and the Cloudflare-throttle flakiness it causes). - - name: Cache Scryfall bulk data - uses: actions/cache@v4 + - name: Restore Scryfall bulk data + id: scryfall-cache + uses: actions/cache/restore@v4 with: path: data/scryfall key: scryfall-${{ steps.cache-keys.outputs.day }} @@ -182,21 +189,97 @@ jobs: # data/ itself, so downstream steps can read from data/ uniformly. - name: Download draft set data + id: draft-sets # fetch-draft-sets.sh needs SetList.json (produced by gen-card-data.sh). # Per-set files land in data/mtgjson/sets/ which is covered by the - # mtgjson cache — subsequent runs skip already-downloaded sets. - run: ./scripts/fetch-draft-sets.sh + # mtgjson cache — subsequent runs skip already-downloaded sets. Left + # unconditional even when the pool cache below hits: this is what keeps + # the mtgjson entry whole, and it is the only step that repopulates + # sets/ after an eviction. + # + # The script treats a failed set download as non-fatal (warn, count, + # exit 0), so the reported count is load-bearing for the pool cache + # below — see deploy.yml's copy of this note. + run: | + set -o pipefail + ./scripts/fetch-draft-sets.sh | tee draft-sets.log + failed=$(sed -n 's/^Summary: .*failed \([0-9][0-9]*\)$/\1/p' draft-sets.log | tail -1) + echo "failed=${failed:-0}" >> "$GITHUB_OUTPUT" + echo "set downloads failed: ${failed:-0}" + + - name: Save MTGJSON data + # THE earliest point data/mtgjson is complete: fetch-draft-sets.sh is + # the last writer into it. Saving here rather than at job end means a + # later failure never re-costs the ~9m of downloads. + # + # Not `always()`: as an inline step a later failure cannot cancel it, + # so the only case `always()` would add is an *earlier* failure — i.e. + # exactly when the directory is incomplete and must not be frozen under + # an immutable key. + if: ${{ steps.mtgjson-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: data/mtgjson + key: mtgjson-full-${{ steps.mtgjson-key.outputs.suffix }} + + - name: Restore draft pools + # Paired with the MTGJSON cache: same `mtgjson-` prefix (so + # /clear-caches mtgjson sweeps both) and a suffix built from the same + # published-data version — see the mtgjson-cache-key action, which is + # the single authority for both keys, shared with deploy.yml. + # + # A separate entry rather than folding draft-pools.json into the + # `data/mtgjson` path above, because actions/cache writes only on a key + # miss: a pool-affecting draft-core change must roll the key, and the + # data-version key alone cannot see source changes. + # + # NO restore-keys: every input to the output is in the key, so an + # inexact hit is by construction a stale pool. + id: draft-pools-cache + uses: actions/cache/restore@v4 + with: + path: client/public/draft-pools.json + key: mtgjson-draft-pools-${{ steps.mtgjson-key.outputs.draft_pools_suffix }} - name: Generate draft pools # Reads data/mtgjson/sets/*.json → writes client/public/draft-pools.json. # Included in data-files.json manifest so it's uploaded to R2 and # stripped from the Pages dist automatically. + # + # ~2s of extraction behind a ~5m30s compile of engine + draft-core: + # draft-pool-gen never reuses the units gen-card-data.sh built (see the + # feature note below) and pays a full cold compile every run. Skipping + # that on a cache hit is the whole point of the step above. + # + # Gate on file existence, not `cache-hit` (same reasoning as "Download + # MTGJSON data"): a poisoned or partial entry reports a hit with no + # file, and skipping on that would ship a release with no pools at all. + # Absent file → regenerate, so the cache self-heals. + # # Profile `tool`, not `release`: this is a one-shot JSON transform, not a # shipped binary. `release` is the WASM-size profile (lto + codegen-units=1) # — the slowest compile in the repo. draft-pool-gen lives in draft-core # and can't take `--features cli`, so it gets its own `[tool,default]` # engine fingerprint regardless; `tool` just makes that compile cheap. - run: cargo run --profile tool --bin draft-pool-gen + run: | + if [ -f client/public/draft-pools.json ]; then + echo "draft-pools.json restored from cache — skipping draft-pool-gen" + else + cargo run --profile tool --bin draft-pool-gen + fi + + - name: Save draft pools + # Banked the moment the file exists, so the ~5m30s compile is never + # paid twice for the same inputs even if the rest of the job fails. + # + # Skipped when any set download failed: a pool built from a short + # sets/ dir must not be frozen under a key that only rolls when MTGJSON + # publishes. See deploy.yml for the full reasoning. + if: ${{ steps.draft-pools-cache.outputs.cache-hit != 'true' && steps.draft-sets.outputs.failed == '0' }} + uses: actions/cache/save@v4 + with: + path: client/public/draft-pools.json + key: mtgjson-draft-pools-${{ steps.mtgjson-key.outputs.draft_pools_suffix }} - name: Validate card-data against engine schema # Deploy gate — refuse to ship card-data the current engine cannot parse. @@ -231,6 +314,16 @@ jobs: ./scripts/gen-scryfall-sets.sh ./scripts/gen-scryfall-printings.sh + - name: Save Scryfall bulk data + # data/scryfall is complete once the four generators have run. Banking + # it here keeps a later failure from re-hitting the Scryfall API on the + # next run — the throttle flakiness this cache exists to avoid. + if: ${{ steps.scryfall-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: data/scryfall + key: scryfall-${{ steps.cache-keys.outputs.day }} + - name: Run semantic audit # Produces data/semantic-audit.json with structured findings for cards # that parse without `Unimplemented` markers but disagree semantically @@ -434,6 +527,10 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: cache-shared-key: rust-server-linux + # CI owns the warning-as-error gate. Release profiles compile out + # debug-only assertions, which can legitimately leave their inputs + # unused in an otherwise CI-clean tagged source tree. + rustflags: "" - name: Install musl tools and target run: | @@ -658,6 +755,9 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: cache-shared-key: rust-server-${{ matrix.os }} + # Keep this artifact build consistent with the primary Linux build: + # CI, rather than a release-only cfg shape, enforces warnings. + rustflags: "" - name: Build phase-server (macOS/Windows) # Linux musl is compiled once in build-server-binary and reused below; @@ -911,26 +1011,54 @@ jobs: | { grep -v "^- chore: update coverage" || true; } \ >> changelog.md + - name: Build release asset list + id: release-assets + env: + GH_TOKEN: ${{ github.token }} + SHELL_UPDATER_BRIDGE_AVAILABLE: ${{ steps.shell-updater-bridge.outputs.available }} + run: | + set -euo pipefail + + # Immutable releases cannot accept a late asset upload. Include the + # optional bridge in the initial create/publish request, but skip it + # for recovery runs targeting a release that is already published. + release_draft=$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft' 2>/dev/null || echo missing) + include_bridge=false + if [ "$SHELL_UPDATER_BRIDGE_AVAILABLE" = true ] && [ "$release_draft" != false ]; then + include_bridge=true + elif [ "$SHELL_UPDATER_BRIDGE_AVAILABLE" = true ]; then + echo "Release $RELEASE_TAG is already published; skipping optional shell updater migration bridge." + fi + + { + echo "files<> "$GITHUB_OUTPUT" + - name: Create release uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.RELEASE_TAG }} body_path: changelog.md fail_on_unmatched_files: true - files: | - artifacts/phase-server-slim-x86_64-unknown-linux-musl/phase-server-slim-x86_64-unknown-linux-musl - artifacts/phase-server-slim-x86_64-unknown-linux-musl/phase-server-slim-x86_64-unknown-linux-musl.minisig - artifacts/phase-server-slim-aarch64-apple-darwin/phase-server-slim-aarch64-apple-darwin - artifacts/phase-server-slim-aarch64-apple-darwin/phase-server-slim-aarch64-apple-darwin.minisig - artifacts/phase-server-slim-x86_64-pc-windows-msvc/phase-server-slim-x86_64-pc-windows-msvc.exe - artifacts/phase-server-slim-x86_64-pc-windows-msvc/phase-server-slim-x86_64-pc-windows-msvc.exe.minisig - release-server-${{ needs.resolve-release-ref.outputs.release_tag }}.json - release-server-${{ needs.resolve-release-ref.outputs.release_tag }}.json.minisig - - - name: Attach shell updater migration bridge - if: steps.shell-updater-bridge.outputs.available == 'true' - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ env.RELEASE_TAG }} - fail_on_unmatched_files: true - files: artifacts/update.json + # Never the "Latest" release. Every asset here is a machine artifact + # the desktop shell provisions for itself; the web app ships by + # deploy, not by download. These fire far more often than shell + # releases, so leaving the badge to default sends anyone visiting + # /releases/latest to a page of server binaries with no installer. + # The desktop release claims the badge instead (shell-release.yml). + make_latest: "false" + files: ${{ steps.release-assets.outputs.files }} diff --git a/.github/workflows/shell-release.yml b/.github/workflows/shell-release.yml index fa7bbc190d..e6827f78ec 100644 --- a/.github/workflows/shell-release.yml +++ b/.github/workflows/shell-release.yml @@ -283,6 +283,7 @@ jobs: - name: Stage shell artifacts shell: bash run: | + set -euo pipefail mkdir -p staging find client/src-tauri/target/release/bundle \ -maxdepth 2 \ @@ -290,6 +291,24 @@ jobs: -o -name '*.dmg' -o -name '*.app.tar.gz' -o -name '*.app.tar.gz.sig' \ -o -name '*.exe' -o -name '*.exe.sig' \) \ -exec cp {} staging/ \; + + rename_artifact() { + local pattern="$1" + local destination="$2" + local source + source=$(find staging -maxdepth 1 -type f -name "$pattern" -print -quit) + [ -n "$source" ] || return 0 + mv "$source" "staging/$destination" + if [ -f "$source.sig" ]; then + mv "$source.sig" "staging/$destination.sig" + fi + } + + rename_artifact '*-setup.exe' 'Phase-Desktop-Windows-x64-Setup.exe' + rename_artifact '*.dmg' 'Phase-Desktop-macOS-Apple-Silicon.dmg' + rename_artifact '*.app.tar.gz' 'Phase-Desktop-macOS-Apple-Silicon.app.tar.gz' + rename_artifact '*.AppImage' 'Phase-Desktop-Linux-x86_64.AppImage' + rename_artifact '*.deb' 'Phase-Desktop-Linux-x86_64.deb' ls -la staging/ - name: Upload shell artifact @@ -335,9 +354,9 @@ jobs: "$REPO" "$TAG" "$(jq -rn --arg value "$name" '$value | @uri')" } - LINUX_PAYLOAD=$(first_match "artifacts/shell-tauri-linux/*.AppImage") - MACOS_PAYLOAD=$(first_match "artifacts/shell-tauri-macos/*.app.tar.gz") - WINDOWS_PAYLOAD=$(first_match "artifacts/shell-tauri-windows/*-setup.exe") + LINUX_PAYLOAD=$(first_match "artifacts/shell-tauri-linux/Phase-Desktop-Linux-x86_64.AppImage") + MACOS_PAYLOAD=$(first_match "artifacts/shell-tauri-macos/Phase-Desktop-macOS-Apple-Silicon.app.tar.gz") + WINDOWS_PAYLOAD=$(first_match "artifacts/shell-tauri-windows/Phase-Desktop-Windows-x64-Setup.exe") LINUX_SIG="" MACOS_SIG="" @@ -368,13 +387,26 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.SHELL_TAG }} - name: ${{ env.SHELL_TAG }} - body: Shell installer release. + name: phase.rs Desktop v${{ env.SHELL_VERSION }} + # The installer is the only thing a human comes to /releases for, so + # it holds the "Latest" badge; web releases opt out (release.yml). + make_latest: "true" + body: | + ## Install phase.rs Desktop + + Download the installer for your platform: + + - **Windows:** `Phase-Desktop-Windows-x64-Setup.exe` + - **macOS (Apple Silicon):** `Phase-Desktop-macOS-Apple-Silicon.dmg` + - **Linux:** `Phase-Desktop-Linux-x86_64.deb` or `Phase-Desktop-Linux-x86_64.AppImage` + + This is the official desktop app for [phase-rs.dev](https://phase-rs.dev). + `update.json` is used by automatic updates; it is not a manual download. fail_on_unmatched_files: true files: | - artifacts/shell-tauri-linux/*.deb - artifacts/shell-tauri-linux/*.AppImage - artifacts/shell-tauri-macos/*.dmg - artifacts/shell-tauri-macos/*.app.tar.gz - artifacts/shell-tauri-windows/*.exe + artifacts/shell-tauri-linux/Phase-Desktop-Linux-x86_64.deb + artifacts/shell-tauri-linux/Phase-Desktop-Linux-x86_64.AppImage + artifacts/shell-tauri-macos/Phase-Desktop-macOS-Apple-Silicon.dmg + artifacts/shell-tauri-macos/Phase-Desktop-macOS-Apple-Silicon.app.tar.gz + artifacts/shell-tauri-windows/Phase-Desktop-Windows-x64-Setup.exe artifacts/update.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1be831bc2b..0d0c0505c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,8 @@ tilt up # continuous build/test — leave running - Target `origin/main` (`phase-rs/phase`). - Don't modify `mtgish/`, `crates/mtgish-import/`, or `data/mtgish-*` (dormant); PRs that only touch them are rejected. -- If you used an LLM, report the model on a `Model:` line in the PR body per +- If you used an LLM, use `.github/PULL_REQUEST_TEMPLATE.md` for the PR body, + fill every section, and report the model on its canonical `Model:` line; see `docs/AI-CONTRIBUTOR.md`. ## License diff --git a/Cargo.lock b/Cargo.lock index c5a6fed6e0..9b026933ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -728,7 +728,7 @@ checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" [[package]] name = "draft-core" -version = "0.35.0" +version = "0.36.0" dependencies = [ "engine", "rand 0.9.4", @@ -740,7 +740,7 @@ dependencies = [ [[package]] name = "draft-wasm" -version = "0.35.0" +version = "0.36.0" dependencies = [ "console_error_panic_hook", "draft-core", @@ -812,7 +812,7 @@ dependencies = [ [[package]] name = "engine" -version = "0.35.0" +version = "0.36.0" dependencies = [ "assert_matches", "engine", @@ -842,7 +842,7 @@ dependencies = [ [[package]] name = "engine-inventory-gen" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "proc-macro2", @@ -855,7 +855,7 @@ dependencies = [ [[package]] name = "engine-wasm" -version = "0.35.0" +version = "0.36.0" dependencies = [ "console_error_panic_hook", "engine", @@ -907,7 +907,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "feed-scraper" -version = "0.35.0" +version = "0.36.0" dependencies = [ "clap", "reqwest", @@ -1704,7 +1704,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lobby-broker" -version = "0.35.0" +version = "0.36.0" dependencies = [ "engine", "serde", @@ -1741,7 +1741,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "manabrew-compat" -version = "0.35.0" +version = "0.36.0" dependencies = [ "engine", "pretty_assertions", @@ -1836,7 +1836,7 @@ dependencies = [ [[package]] name = "mtgish-import" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "engine", @@ -2064,7 +2064,7 @@ dependencies = [ [[package]] name = "phase-ai" -version = "0.35.0" +version = "0.36.0" dependencies = [ "engine", "rand 0.9.4", @@ -2079,7 +2079,7 @@ dependencies = [ [[package]] name = "phase-server" -version = "0.35.0" +version = "0.36.0" dependencies = [ "axum", "clap", @@ -2799,7 +2799,7 @@ dependencies = [ [[package]] name = "seat-reducer" -version = "0.35.0" +version = "0.36.0" dependencies = [ "engine", "phase-ai", @@ -2988,7 +2988,7 @@ dependencies = [ [[package]] name = "server-core" -version = "0.35.0" +version = "0.36.0" dependencies = [ "draft-core", "engine", diff --git a/Cargo.toml b/Cargo.toml index 0eff79f9c6..d27b63e78a 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.35.0" +version = "0.36.0" license = "MIT OR Apache-2.0" [workspace.dependencies] @@ -25,6 +25,17 @@ pre-release-replacements = [ { file = "../../client/package.json", search = '"version": "[^"]*"', replace = '"version": "{{version}}"' }, { file = "../../client/src-tauri/tauri.conf.json", search = '"version": "[^"]*"', replace = '"version": "{{version}}"' }, { file = "../../client/src-tauri/Cargo.toml", search = '^version = "[^"]*"', replace = 'version = "{{version}}"' }, + # client/src-tauri is its OWN cargo workspace, so bumping its Cargo.toml above leaves its + # Cargo.lock pinning the previous version. The Tauri CI job builds with `cargo check --locked`, + # which then refuses to update the lock and fails — and because tauri-check is a `needs:` of the + # required "Rust (fmt, clippy, test, coverage-gate)" aggregator, that reds main and every + # merge-group ref built on the release commit. v0.35.2 shipped exactly that way (fixed in #6568). + # The two-line anchor keeps this from matching any other package's version; cargo-release's + # default min=1 makes a lockfile format change fail the release loudly instead of silently. + # The newline lives in a capture group, never in the replacement: the regex crate expands only + # ${1}-style references in a replacement, so a literal \n there would be inserted verbatim and + # would splice the two lines together. + { file = "../../client/src-tauri/Cargo.lock", search = '(name = "phase-tauri"\nversion = )"[^"]*"', replace = '${1}"{{version}}"' }, ] publish = false diff --git a/README.md b/README.md index 5d8335ff3c..d3c85d985e 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ Read https://raw.githubusercontent.com/phase-rs/phase/main/docs/AI-CONTRIBUTOR.m and follow it end-to-end to implement {a card I name, or pick one for me}. Use the $engine-implementer skill. Use high thinking. Don't stop for my input. Open a PR when done. +Use the repository's .github/PULL_REQUEST_TEMPLATE.md for the PR body and fill +every section. ``` Full procedure, two tracks (developer / non-developer), and copy-paste prompts for LLM UIs without web fetch: [docs/AI-CONTRIBUTOR.md](docs/AI-CONTRIBUTOR.md). diff --git a/client/package.json b/client/package.json index 3358e792d2..301d510256 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "phase-rs-client", "private": true, - "version": "0.35.0", + "version": "0.36.0", "type": "module", "scripts": { "dev": "vite", @@ -35,7 +35,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-i18next": "^17.0.8", - "react-router": "^7.15.1", + "react-router": "^7.18.0", "tailwindcss": "^4.2.1", "three": "^0.184.0", "vite-plugin-top-level-await": "^1.5.0", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 30be011b6e..22e1700541 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: 2.106.2 '@tailwindcss/vite': specifier: ^4.2.1 - version: 4.3.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 4.3.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)) '@tauri-apps/api': specifier: ^2 version: 2.11.0 @@ -76,8 +76,8 @@ importers: specifier: ^17.0.8 version: 17.0.8(i18next@26.2.0(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) react-router: - specifier: ^7.15.1 - version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^7.18.0 + version: 7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tailwindcss: specifier: ^4.2.1 version: 4.3.0 @@ -86,10 +86,10 @@ importers: version: 0.184.0 vite-plugin-top-level-await: specifier: ^1.5.0 - version: 1.6.0(rollup@2.80.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 1.6.0(rollup@2.80.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)) vite-plugin-wasm: specifier: ^3.4.1 - version: 3.6.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 3.6.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)) zustand: specifier: ^5.0.11 version: 5.0.13(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -123,10 +123,10 @@ importers: version: 0.184.1 '@vitejs/plugin-react': specifier: ^4.4.1 - version: 4.7.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 4.7.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)) '@vitest/coverage-v8': specifier: ^4.1.0 - version: 4.1.0(vitest@4.1.0(@types/node@25.6.2)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))) + version: 4.1.0(vitest@4.1.0(@types/node@25.6.2)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0))) eslint: specifier: ^9.39.4 version: 9.39.4(jiti@2.7.0) @@ -159,16 +159,16 @@ importers: version: 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.7.3) vite: specifier: ^6.4.3 - version: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + version: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0) vite-plugin-compression2: specifier: ^2.5.1 version: 2.5.3(rollup@2.80.0) vite-plugin-pwa: specifier: ^1.2.0 - version: 1.3.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.1) + version: 1.3.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.1) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.6.2)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 4.1.0(@types/node@25.6.2)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)) workbox-window: specifier: ^7.4.0 version: 7.4.1 @@ -1228,89 +1228,105 @@ packages: resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.3.2': resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.3.2': resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.3.2': resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.3.2': resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.3.2': resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.3.2': resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.3.2': resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.35.3': resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.35.3': resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.35.3': resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.35.3': resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.35.3': resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.35.3': resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.35.3': resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.35.3': resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.35.3': resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} @@ -1490,66 +1506,79 @@ packages: resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.0': resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.0': resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.0': resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.0': resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.0': resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.0': resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.0': resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.0': resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.0': resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.0': resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.0': resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.0': resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.62.0': resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} @@ -1644,36 +1673,42 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.33': resolution: {integrity: sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-ppc64-gnu@1.15.33': resolution: {integrity: sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@swc/core-linux-s390x-gnu@1.15.33': resolution: {integrity: sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==} engines: {node: '>=10'} cpu: [s390x] os: [linux] + libc: [glibc] '@swc/core-linux-x64-gnu@1.15.33': resolution: {integrity: sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.33': resolution: {integrity: sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.33': resolution: {integrity: sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==} @@ -1749,24 +1784,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -1827,30 +1866,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.11.1': resolution: {integrity: sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.11.1': resolution: {integrity: sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.1': resolution: {integrity: sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.11.1': resolution: {integrity: sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.1': resolution: {integrity: sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==} @@ -2222,6 +2266,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + engines: {node: '>=6.0.0'} + hasBin: true + bl@5.1.0: resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} @@ -2261,6 +2310,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2286,6 +2340,9 @@ packages: caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -2461,6 +2518,9 @@ packages: electron-to-chromium@1.5.353: resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} + electron-to-chromium@1.5.395: + resolution: {integrity: sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2522,8 +2582,8 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-to-primitive@1.3.1: - resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} esbuild@0.25.12: @@ -3206,24 +3266,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3258,17 +3322,14 @@ packages: lodash.mergewith@4.6.2: resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} - lodash.sortby@4.7.0: - resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.5.1: - resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -3360,6 +3421,10 @@ packages: node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -3400,8 +3465,8 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} p-limit@3.1.0: @@ -3475,6 +3540,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3541,8 +3610,8 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react-router@7.15.1: - resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==} + react-router@7.18.0: + resolution: {integrity: sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -3772,10 +3841,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - source-map@0.8.0-beta.0: - resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} - engines: {node: '>= 8'} - deprecated: The work that was done in this beta branch won't be included in future versions + source-map@0.8.0: + resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==} + engines: {node: '>= 12'} sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} @@ -3893,8 +3961,8 @@ packages: resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} engines: {node: '>=10'} - terser@5.48.0: - resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + terser@5.49.0: + resolution: {integrity: sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==} engines: {node: '>=10'} hasBin: true @@ -3941,9 +4009,6 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} - tr46@1.0.1: - resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} - tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -4195,9 +4260,6 @@ packages: resolution: {integrity: sha512-IC8sL7aB4/ZgFcGI2T1LczZeFWZ06b3zoHH7jBPyHxOtIIz1jppWHjjEXkOFvFojBVAK9pV7g47xOZ4LW3QLfg==} engines: {node: 8.* || >= 10.*} - webidl-conversions@4.0.2: - resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} - webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -4223,9 +4285,6 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} - whatwg-url@7.1.0: - resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -5577,7 +5636,7 @@ snapshots: dependencies: serialize-javascript: 7.0.5 smob: 1.6.2 - terser: 5.48.0 + terser: 5.49.0 optionalDependencies: rollup: 2.80.0 @@ -5589,7 +5648,7 @@ snapshots: dependencies: '@types/estree': 0.0.39 estree-walker: 1.0.1 - picomatch: 4.0.4 + picomatch: 4.0.5 rollup: 2.80.0 '@rollup/pluginutils@5.3.0(rollup@2.80.0)': @@ -5604,7 +5663,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: rollup: 2.80.0 @@ -5851,12 +5910,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))': + '@tailwindcss/vite@4.3.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0) '@tauri-apps/api@2.11.0': {} @@ -6121,7 +6180,7 @@ snapshots: '@typescript-eslint/types': 8.59.2 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) @@ -6129,11 +6188,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@25.6.2)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)))': + '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@25.6.2)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.0 @@ -6145,7 +6204,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.0(@types/node@25.6.2)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + vitest: 4.1.0(@types/node@25.6.2)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)) '@vitest/expect@4.1.0': dependencies: @@ -6156,13 +6215,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))': + '@vitest/mocker@4.1.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0) '@vitest/pretty-format@4.1.0': dependencies: @@ -6306,6 +6365,8 @@ snapshots: baseline-browser-mapping@2.10.29: {} + baseline-browser-mapping@2.11.1: {} + bl@5.1.0: dependencies: buffer: 6.0.3 @@ -6361,6 +6422,14 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.395 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + buffer-from@1.1.2: {} buffer@6.0.3: @@ -6389,6 +6458,8 @@ snapshots: caniuse-lite@1.0.30001792: {} + caniuse-lite@1.0.30001806: {} + chai@6.2.2: {} chalk@4.1.2: @@ -6443,7 +6514,7 @@ snapshots: core-js-compat@3.49.0: dependencies: - browserslist: 4.28.2 + browserslist: 4.28.7 core-util-is@1.0.3: {} @@ -6563,6 +6634,8 @@ snapshots: electron-to-chromium@1.5.353: {} + electron-to-chromium@1.5.395: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -6610,7 +6683,7 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.1 + es-to-primitive: 1.3.4 function.prototype.name: 1.2.0 get-intrinsic: 1.3.0 get-proto: 1.0.1 @@ -6636,7 +6709,7 @@ snapshots: object-inspect: 1.13.4 object-keys: 1.1.1 object.assign: 4.1.7 - own-keys: 1.0.1 + own-keys: 1.0.2 regexp.prototype.flags: 1.5.4 safe-array-concat: 1.1.4 safe-push-apply: 1.0.0 @@ -6670,9 +6743,10 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - es-to-primitive@1.3.1: + es-to-primitive@1.3.4: dependencies: es-abstract-get: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 is-callable: 1.2.7 is-date-object: 1.1.0 @@ -7506,13 +7580,11 @@ snapshots: lodash.mergewith@4.6.2: {} - lodash.sortby@4.7.0: {} - lodash@4.18.1: {} lru-cache@10.4.3: {} - lru-cache@11.5.1: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: dependencies: @@ -7600,6 +7672,8 @@ snapshots: node-releases@2.0.38: {} + node-releases@2.0.51: {} + normalize-path@3.0.0: {} now-and-later@3.0.0: @@ -7642,8 +7716,9 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - own-keys@1.0.1: + own-keys@1.0.2: dependencies: + call-bound: 1.0.4 get-intrinsic: 1.3.0 object-keys: 1.1.1 safe-push-apply: 1.0.0 @@ -7692,7 +7767,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.5.1 + lru-cache: 11.5.2 minipass: 7.1.3 path-to-regexp@6.3.0: {} @@ -7712,6 +7787,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.5: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.15: @@ -7764,7 +7841,7 @@ snapshots: react-refresh@0.17.0: {} - react-router@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: cookie: 1.1.1 react: 19.2.6 @@ -8057,9 +8134,7 @@ snapshots: source-map@0.6.1: {} - source-map@0.8.0-beta.0: - dependencies: - whatwg-url: 7.1.0 + source-map@0.8.0: {} sourcemap-codec@1.4.8: {} @@ -8206,7 +8281,7 @@ snapshots: type-fest: 0.16.0 unique-string: 2.0.0 - terser@5.48.0: + terser@5.49.0: dependencies: '@jridgewell/source-map': 0.3.11 acorn: 8.17.0 @@ -8259,10 +8334,6 @@ snapshots: dependencies: tldts: 6.1.86 - tr46@1.0.1: - dependencies: - punycode: 2.3.1 - tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -8372,6 +8443,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -8443,33 +8520,33 @@ snapshots: transitivePeerDependencies: - rollup - vite-plugin-pwa@1.3.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.1): + vite-plugin-pwa@1.3.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.1): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.16 - vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0) workbox-build: 7.4.0(@types/babel__core@7.20.5) workbox-window: 7.4.1 transitivePeerDependencies: - supports-color - vite-plugin-top-level-await@1.6.0(rollup@2.80.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)): + vite-plugin-top-level-await@1.6.0(rollup@2.80.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)): dependencies: '@rollup/plugin-virtual': 3.0.2(rollup@2.80.0) '@swc/core': 1.15.33 '@swc/wasm': 1.15.33 uuid: 11.1.1 - vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0) transitivePeerDependencies: - '@swc/helpers' - rollup - vite-plugin-wasm@3.6.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)): + vite-plugin-wasm@3.6.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)): dependencies: - vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0) - vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0): + vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -8482,12 +8559,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 - terser: 5.48.0 + terser: 5.49.0 - vitest@4.1.0(@types/node@25.6.2)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)): + vitest@4.1.0(@types/node@25.6.2)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + '@vitest/mocker': 4.1.0(vite@6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -8504,7 +8581,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 6.4.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.2 @@ -8526,8 +8603,6 @@ snapshots: matcher-collection: 2.0.1 minimatch: 3.1.5 - webidl-conversions@4.0.2: {} - webidl-conversions@7.0.0: {} webrtc-adapter@9.0.5: @@ -8547,12 +8622,6 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 - whatwg-url@7.1.0: - dependencies: - lodash.sortby: 4.7.0 - tr46: 1.0.1 - webidl-conversions: 4.0.2 - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -8633,7 +8702,7 @@ snapshots: lodash: 4.18.1 pretty-bytes: 5.6.0 rollup: 2.80.0 - source-map: 0.8.0-beta.0 + source-map: 0.8.0 stringify-object: 3.3.0 strip-comments: 2.0.1 tempy: 0.6.0 diff --git a/client/public/changelog-meta.json b/client/public/changelog-meta.json index dc13091035..d93339d10e 100644 --- a/client/public/changelog-meta.json +++ b/client/public/changelog-meta.json @@ -1,3 +1,3 @@ { - "latestId": 179 + "latestId": 180 } diff --git a/client/public/changelog.json b/client/public/changelog.json index 8554d9eb75..d5aa0e997c 100644 --- a/client/public/changelog.json +++ b/client/public/changelog.json @@ -1,5 +1,19 @@ { "entries": [ + { + "id": 180, + "date": "2026-07-24", + "title": "Resume solo games on desktop", + "tags": [ + "card-fixes", + "gameplay", + "interface", + "ai", + "multiplayer" + ], + "body": "🛠️ Cards That Now Work Right\n• Unearthed creatures and similar \"exile it instead\" riders now exile correctly whenever they leave the battlefield, including replacements granted by another permanent (Rotting Rats, Geth, Thane of Contracts)\n• Wilhelt, the Rotcleaver no longer makes Zombies when a decayed creature dies; Toxrill, the Corrosive counts slime counters on each affected creature\n• Depthshaker Titan leaves itself alone when you choose zero artifacts, while Conjurer’s Mantle, Consuming Vapors, Lictor’s Pheromone Trail, Portent, Volcanic Vision, Wheel and Deal, Fevered Visions, Riptide Gearhulk, and Aura/Equipment damage triggers now follow their printed targets and effects\n• Metamorphic Alteration now lets you choose a creature as it enters and makes the enchanted creature a copy; Sunburst and Bloodthirst granted to a spell now work as it enters (Solar Array, Lux Artillery, Bloodlord of Vaasgoth)\n• Flickering Ward and other protection effects preserve Auras that the protection specifically exempts\n\n⚔️ Combat & Gameplay\n• Auto-pay can use choice-free self-sacrificing mana sources such as Gold tokens, even when they are tapped or summoning sick, without skipping real costs such as Lion’s Eye Diamond’s discard\n• Adventure cards now present only their legal cast face\n\n🖥️ Interface\n• Desktop solo games using the native engine can be resumed after leaving the game; Windows now starts draft pools reliably, and a failed native connection visibly falls back to the browser engine\n• Hosted peer-to-peer games can use the native engine, while the desktop app better shows provisioning progress, engine replay, shell art, fullscreen, and turn-order results\n• Mobile hand cards support hold, scrub, and drag previews; Alt-pinned card previews reset cleanly when you inspect another card\n• Opponent hand visibility, active deck art, and several WebKit rendering details are restored\n\n🤖 AI\n• AI now recognizes poison-counter and graveyard-type-diversity game plans, improving choices around infect, toxic, proliferate, delirium, and graveyard payoffs\n\n🌐 Multiplayer\n• Native commander opponents retain their names, and the client restores split opponent-hand visibility", + "discordUrl": "https://discord.com/channels/1485498006781427802/1486432040332296424/1530253950669029406" + }, { "id": 179, "date": "2026-07-22", diff --git a/client/public/feeds/mtggoldfish-commander.json b/client/public/feeds/mtggoldfish-commander.json index d5bbe2e5ec..021390ce33 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-07-23T00:00:00Z", + "updated": "2026-07-24T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/commander", "decks": [ { @@ -13,8 +13,8 @@ "author": "MTGGoldfish", "colors": [ "U", - "B", - "R" + "R", + "B" ], "tags": [ "metagame" @@ -22,199 +22,199 @@ "main": [ { "count": 1, - "name": "The Frightful Four" + "name": "Abomination, World Ravager" }, { "count": 1, - "name": "Iron Monger, Sadistic Tycoon" + "name": "Age of Ultron" }, { "count": 1, - "name": "Klaw, Master of Sound" + "name": "Arcane Signet" }, { "count": 1, - "name": "Abomination, World Ravager" + "name": "Archnemesis" }, { "count": 1, - "name": "Living Laser" + "name": "Baron Strucker, HYDRA Overlord" }, { "count": 1, - "name": "Puppet Master, String Puller" + "name": "Batroc the Leaper" }, { "count": 1, - "name": "Titania, Proud Pummeler" + "name": "Bedevil" }, { "count": 1, - "name": "Kang Prime" + "name": "Black Market Connections" }, { "count": 1, - "name": "Loki, the Deceiver" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Red Ghost, Intangible Genius" + "name": "Canyon Slough" }, { "count": 1, - "name": "The Squadron Sinister" + "name": "Chameleon, Master of Disguise" }, { "count": 1, - "name": "Typhoid Mary, Fractured" + "name": "Chaos Warp" }, { "count": 1, - "name": "Ultron, Unlimited" + "name": "Choked Estuary" }, { "count": 1, - "name": "Tri-Sentinel, Act of Vengeance" + "name": "Coastal Peak" }, { "count": 1, - "name": "Spark Double" + "name": "Command Tower" }, { "count": 1, - "name": "Titan of Littjara" + "name": "Containment Construct" }, { "count": 1, - "name": "Baron Strucker, HYDRA Overlord" + "name": "Crumbling Necropolis" }, { "count": 1, - "name": "Moonstone, Harsh Mistress" + "name": "Currency Converter" }, { "count": 1, - "name": "Kang, Temporal Tyrant" + "name": "Damocles Base, Sword of Kang" }, { "count": 1, - "name": "Containment Construct" + "name": "Doctor Doom, King of Latveria" }, { "count": 1, - "name": "Chameleon, Master of Disguise" + "name": "Doom's Time Platform" }, { "count": 1, - "name": "Tombstone, Career Criminal" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Superior Foes of Spider-Man" + "name": "Drowned Catacomb" }, { "count": 1, - "name": "Prowler, Clawed Thief" + "name": "Endless Ranks of HYDRA" }, { "count": 1, - "name": "Glorious Purpose" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Kang Dynasty" + "name": "Extract Power" }, { "count": 1, - "name": "Age of Ultron" + "name": "Fetid Pools" }, { "count": 1, - "name": "Archnemesis" + "name": "Foreboding Ruins" }, { "count": 1, - "name": "Black Market Connections" + "name": "Frostboil Snarl" }, { "count": 1, - "name": "Propaganda" + "name": "Glorious Purpose" }, { "count": 1, - "name": "Loki's Scepter" + "name": "Helmut Zemo, Mastermind" }, { "count": 1, - "name": "Doom's Time Platform" + "name": "Iron Monger, Sadistic Tycoon" }, { - "count": 1, - "name": "Currency Converter" + "count": 4, + "name": "Island" }, { "count": 1, - "name": "Progenitor's Icon" + "name": "Kang Dynasty" }, { "count": 1, - "name": "Skullclamp" + "name": "Kang Prime" }, { "count": 1, - "name": "Arcane Signet" + "name": "Kang, Temporal Tyrant" }, { "count": 1, - "name": "Patchwork Banner" + "name": "Killmonger, Ruthless Usurper" }, { "count": 1, - "name": "Sol Ring" + "name": "Kindred Dominance" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Klaw, Master of Sound" }, { "count": 1, - "name": "Talisman of Dominance" + "name": "Lady Loki, Agent of Chaos" }, { "count": 1, - "name": "Talisman of Indulgence" + "name": "Lethal Scheme" }, { "count": 1, - "name": "Lethal Scheme" + "name": "Living Laser" }, { "count": 1, - "name": "Chaos Warp" + "name": "Loki's Scepter" }, { "count": 1, - "name": "Bedevil" + "name": "Loki, the Deceiver" }, { "count": 1, - "name": "Withering Torment" + "name": "Luxury Suite" }, { "count": 1, - "name": "Terminate" + "name": "Madame Hydra" }, { "count": 1, - "name": "Kindred Dominance" + "name": "Molecule Man" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Moonstone, Harsh Mistress" }, { - "count": 1, - "name": "Blasphemous Act" + "count": 5, + "name": "Mountain" }, { "count": 1, @@ -222,166 +222,166 @@ }, { "count": 1, - "name": "Vandalblast" + "name": "Patchwork Banner" }, { "count": 1, - "name": "Canyon Slough" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Choked Estuary" + "name": "Progenitor's Icon" }, { "count": 1, - "name": "Coastal Peak" + "name": "Propaganda" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Prowler, Clawed Thief" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Puppet Master, String Puller" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Red Ghost, Intangible Genius" }, { "count": 1, - "name": "Fetid Pools" + "name": "Scavenger Grounds" }, { "count": 1, - "name": "Foreboding Ruins" + "name": "Scorched Geyser" }, { "count": 1, - "name": "Frostboil Snarl" + "name": "Secluded Courtyard" }, { "count": 1, - "name": "Luxury Suite" + "name": "Skullclamp" }, { "count": 1, - "name": "Scavenger Grounds" + "name": "Smoldering Marsh" }, { "count": 1, - "name": "Scorched Geyser" + "name": "Sol Ring" }, { "count": 1, - "name": "Smoldering Marsh" + "name": "Spark Double" }, { "count": 1, - "name": "Sulfur Falls" + "name": "Stilt-Man, Towering Terror" }, { "count": 1, - "name": "Sunken Hollow" + "name": "Sulfur Falls" }, { "count": 1, - "name": "Villainous Hideout" + "name": "Sunken Hollow" }, { "count": 1, - "name": "Command Tower" + "name": "Superior Foes of Spider-Man" }, { - "count": 1, - "name": "Crumbling Necropolis" + "count": 6, + "name": "Swamp" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Secluded Courtyard" + "name": "Syphon Mind" }, { "count": 1, - "name": "Terramorphic Expanse" + "name": "Talisman of Dominance" }, { "count": 1, - "name": "Unclaimed Territory" + "name": "Talisman of Indulgence" }, { - "count": 4, - "name": "Island" + "count": 1, + "name": "Terminate" }, { - "count": 6, - "name": "Swamp" + "count": 1, + "name": "Terramorphic Expanse" }, { - "count": 5, - "name": "Mountain" + "count": 1, + "name": "The Frightful Four" }, { "count": 1, - "name": "Multiversal Incursion" + "name": "The Squadron Sinister" }, { "count": 1, - "name": "The Spot's Portal" + "name": "Titan of Littjara" }, { "count": 1, - "name": "Scorpion's Sting" + "name": "Titania, Proud Pummeler" }, { "count": 1, - "name": "Venom's Hunger" + "name": "Tombstone, Career Criminal" }, { "count": 1, - "name": "Doc Ock, Sinister Scientist" + "name": "Toxic Deluge" }, { "count": 1, - "name": "Norman Osborn" + "name": "Tri-Sentinel, Act of Vengeance" }, { "count": 1, - "name": "Songbird, Sonic Screamer" + "name": "Typhoid Mary, Fractured" }, { "count": 1, - "name": "Kid Loki" + "name": "Ultron, Unlimited" }, { "count": 1, - "name": "Black Cat, Cunning Thief" + "name": "Unclaimed Territory" }, { "count": 1, - "name": "Leader, Super-Genius" + "name": "Vandalblast" }, { "count": 1, - "name": "Too Evil to Stay Dead" + "name": "Villainous Hideout" }, { "count": 1, - "name": "Doctor Doom, King of Latveria" + "name": "Withering Torment" } ], "sideboard": [] }, { - "name": "Edgar Markov", + "name": "Fire Lord Azula", "author": "MTGGoldfish", "colors": [ - "B", "R", - "W" + "U", + "B" ], "tags": [ "metagame" @@ -389,263 +389,259 @@ "main": [ { "count": 1, - "name": "Akroma's Will" + "name": "Fire Lord Azula" }, { "count": 1, - "name": "Arcane Signet" + "name": "Mai, Jaded Edge" }, { "count": 1, - "name": "Banner of Kinship" + "name": "Professor Zei, Anthropologist" }, { "count": 1, - "name": "Bartolome del Presidio" + "name": "Fire Nation Archers" }, { "count": 1, - "name": "Battlefield Forge" + "name": "Fire Nation Cadets" }, { "count": 1, - "name": "Black Market Connections" + "name": "Firebending Student" }, { "count": 1, - "name": "Blade of the Bloodchief" + "name": "Bria, Riptide Rogue" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Azula, Cunning Usurper" }, { "count": 1, - "name": "Blasphemous Edict" + "name": "Dragonfly Swarm" }, { "count": 1, - "name": "Blazemire Verge" + "name": "Stormcatch Mentor" }, { "count": 1, - "name": "Blood Crypt" + "name": "Pirate Peddlers" }, { "count": 1, - "name": "Bloodletter of Aclazotz" + "name": "Rough Rhino Cavalry" }, { "count": 1, - "name": "Bloodline Keeper" + "name": "Raven Eagle" }, { "count": 1, - "name": "Bloodthirsty Conqueror" + "name": "Wartime Protestors" }, { "count": 1, - "name": "Bojuka Bog" + "name": "Fire Sages" }, { "count": 1, - "name": "Boros Charm" + "name": "The Mechanist, Aerial Artisan" }, { "count": 1, - "name": "Cabal Coffers" + "name": "Gogo, Mysterious Mime" }, { "count": 1, - "name": "Captivating Vampire" + "name": "The Terror of Serpent's Pass" }, { "count": 1, - "name": "Cavern of Souls" + "name": "Vindictive Warden" }, { "count": 1, - "name": "Caves of Koilos" + "name": "Zuko, Exiled Prince" }, { "count": 1, - "name": "Champion of Dusk" + "name": "Azula, On the Hunt" }, { "count": 1, - "name": "Chaos Warp" + "name": "Fire Nation Raider" }, { "count": 1, - "name": "Charismatic Conqueror" + "name": "Gran-Gran" }, { "count": 1, - "name": "Clavileno, First of the Blessed" + "name": "Corrupt Court Official" }, { "count": 1, - "name": "Clever Concealment" + "name": "Merchant of Many Hats" }, { "count": 1, - "name": "Command Tower" + "name": "Rowdy Snowballers" }, { "count": 1, - "name": "Cordial Vampire" + "name": "Accumulate Wisdom" }, { "count": 1, - "name": "Dawn's Truce" + "name": "Lost Days" }, { "count": 1, - "name": "Demonic Tutor" + "name": "It'll Quench Ya!" }, { "count": 1, - "name": "Dusk Legion Duelist" + "name": "Fire Nation Attacks" }, { "count": 1, - "name": "Edgar, Charmed Groom" + "name": "Cunning Maneuver" }, { "count": 1, - "name": "Eiganjo, Seat of the Empire" + "name": "Heartless Act" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Roku's Mastery" }, { "count": 1, - "name": "Exquisite Blood" + "name": "Azula Always Lies" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Run Amok" }, { "count": 1, - "name": "Flawless Maneuver" + "name": "The Last Agni Kai" }, { "count": 1, - "name": "Fracture" + "name": "Big Score" }, { "count": 1, - "name": "Generous Gift" + "name": "Octopus Form" }, { "count": 1, - "name": "Godless Shrine" + "name": "How to Start a Riot" }, { "count": 1, - "name": "High-Society Hunter" + "name": "Sold Out" }, { "count": 1, - "name": "Idol of Oblivion" + "name": "Abandon Attachments" }, { "count": 1, - "name": "Indulgent Aristocrat" + "name": "An Offer You Can't Refuse" }, { "count": 1, - "name": "Knight of the Ebon Legion" + "name": "Zuko's Conviction" }, { "count": 1, - "name": "Legion Lieutenant" + "name": "Bolt Bend" }, { "count": 1, - "name": "Luxury Suite" + "name": "Zuko's Exile" }, { "count": 1, - "name": "Malakir Bloodwitch" + "name": "Lightning Strike" }, { "count": 1, - "name": "Markov Baron" - }, - { - "count": 3, - "name": "Mountain" + "name": "Ozai's Cruelty" }, { "count": 1, - "name": "Nullpriest of Oblivion" + "name": "Iroh's Demonstration" }, { "count": 1, - "name": "Orzhov Signet" + "name": "Aang's Journey" }, { "count": 1, - "name": "Pact of the Serpent" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Jet's Brainwashing" }, { "count": 1, - "name": "Patron of the Vein" + "name": "Epic Downfall" }, { "count": 1, - "name": "Phyrexian Tower" + "name": "Ruinous Waterbending" }, { - "count": 4, - "name": "Plains" + "count": 1, + "name": "Price of Freedom" }, { "count": 1, - "name": "Rakdos Signet" + "name": "Lost in the Spirit World" }, { "count": 1, - "name": "Rakish Heir" + "name": "Reanimate" }, { "count": 1, - "name": "Sacred Foundry" + "name": "Foggy Swamp Visions" }, { "count": 1, - "name": "Sanctum Seeker" + "name": "Deadly Precision" }, { "count": 1, - "name": "Sanguine Bond" + "name": "The Rise of Sozin" }, { "count": 1, - "name": "Savai Triome" + "name": "Fire Nation's Conquest" }, { "count": 1, - "name": "Secluded Courtyard" + "name": "Twin Blades" }, { "count": 1, - "name": "Shared Animosity" + "name": "Arcane Signet" }, { "count": 1, - "name": "Skullclamp" + "name": "Kyoshi Battle Fan" }, { "count": 1, - "name": "Smothering Tithe" + "name": "Bender's Waterskin" }, { "count": 1, @@ -653,114 +649,78 @@ }, { "count": 1, - "name": "Sorin, Imperious Bloodlord" + "name": "Barrels of Blasting Jelly" }, { "count": 1, - "name": "Spectator Seating" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Stromkirk Captain" + "name": "Commander's Sphere" }, { - "count": 1, - "name": "Sulfurous Springs" + "count": 9, + "name": "Mountain" }, { "count": 5, - "name": "Swamp" + "name": "Island" }, { - "count": 1, - "name": "Swords to Plowshares" + "count": 8, + "name": "Swamp" }, { "count": 1, - "name": "Takenuma, Abandoned Mire" + "name": "Airship Engine Room" }, { "count": 1, - "name": "Talisman of Hierarchy" + "name": "Thriving Isle" }, { "count": 1, - "name": "Talisman of Indulgence" + "name": "Thriving Bluff" }, { "count": 1, - "name": "Terminate" + "name": "Rumble Arena" }, { "count": 1, - "name": "Three Tree City" + "name": "Boiling Rock Prison" }, { "count": 1, - "name": "Tocasia's Welcome" + "name": "Serpent's Pass" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Thriving Moor" }, { "count": 1, - "name": "Unclaimed Territory" + "name": "Realm of Koh" }, { "count": 1, - "name": "Urborg, Tomb of Yawgmoth" + "name": "Evolving Wilds" }, { "count": 1, - "name": "Vampire Socialite" - }, - { - "count": 1, - "name": "Vampiric Tutor" - }, - { - "count": 1, - "name": "Vanquisher's Banner" - }, - { - "count": 1, - "name": "Vault of Champions" - }, - { - "count": 1, - "name": "Vein Ripper" - }, - { - "count": 1, - "name": "Viscera Seer" - }, - { - "count": 1, - "name": "Vito, Thorn of the Dusk Rose" - }, - { - "count": 1, - "name": "Welcoming Vampire" - }, - { - "count": 1, - "name": "Yahenni, Undying Partisan" - }, - { - "count": 1, - "name": "Edgar Markov" + "name": "Command Tower" } ], "sideboard": [] }, { - "name": "Y'shtola, Night's Blessed", + "name": "Edgar Markov", "author": "MTGGoldfish", "colors": [ - "W", "B", - "U" + "R", + "W" ], "tags": [ "metagame" @@ -768,388 +728,389 @@ "main": [ { "count": 1, - "name": "Y'shtola, Night's Blessed" + "name": "Captivating Vampire" }, { "count": 1, - "name": "Teferi, Time Raveler" + "name": "Arcane Signet" }, { "count": 1, - "name": "Mockingbird" + "name": "Indulgent Aristocrat" }, { "count": 1, - "name": "Dauthi Voidwalker" + "name": "Arid Mesa" }, { "count": 1, - "name": "Doorkeeper Thrull" + "name": "Markov Baron" }, { "count": 1, - "name": "Faerie Mastermind" + "name": "Legion Lieutenant" }, { "count": 1, - "name": "Grand Abolisher" + "name": "Unclaimed Territory" }, { "count": 1, - "name": "Lotho, Corrupt Shirriff" + "name": "Cruel Celebrant" }, { "count": 1, - "name": "Papalymo Totolymo" + "name": "Edgar, Charmed Groom" }, { "count": 1, - "name": "Thassa's Oracle" + "name": "Demonic Tutor" }, { "count": 1, - "name": "Hydroelectric Specimen" + "name": "Blood Crypt" }, { "count": 1, - "name": "Grand Arbiter Augustin IV" + "name": "Shared Animosity" }, { "count": 1, - "name": "Subtlety" + "name": "Rakdos Signet" }, { "count": 1, - "name": "Witch Enchanter" + "name": "Voldaren Estate" }, { "count": 1, - "name": "Hullbreaker Horror" + "name": "Anointed Procession" }, { "count": 1, - "name": "Lotus Petal" + "name": "Talisman of Hierarchy" }, { "count": 1, - "name": "Sensei's Divining Top" + "name": "Luxury Suite" }, { "count": 1, - "name": "Sol Ring" + "name": "Twilight Prophet" }, { "count": 1, - "name": "Arcane Signet" + "name": "Elenda, the Dusk Rose" }, { "count": 1, - "name": "Azorius Signet" + "name": "Vanquisher's Banner" }, { "count": 1, - "name": "Dimir Signet" + "name": "Charismatic Conqueror" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Malakir Rebirth" }, { "count": 1, - "name": "Talisman of Dominance" + "name": "Brightclimb Pathway" }, { "count": 1, - "name": "Talisman of Hierarchy" + "name": "Anguished Unmaking" }, { "count": 1, - "name": "Talisman of Progress" + "name": "Anowon, the Ruin Sage" }, { "count": 1, - "name": "Thought Vessel" + "name": "Rakish Heir" }, { "count": 1, - "name": "Aetherflux Reservoir" + "name": "Sanctum Seeker" }, { "count": 1, - "name": "Bolas's Citadel" + "name": "Sol Ring" }, { "count": 1, - "name": "Pact of Negation" + "name": "Vengeful Bloodwitch" }, { "count": 1, - "name": "An Offer You Can't Refuse" + "name": "Despark" }, { "count": 1, - "name": "Brainstorm" + "name": "Sanguine Bond" }, { "count": 1, - "name": "Dark Ritual" + "name": "Plateau" }, { "count": 1, - "name": "Demonic Consultation" + "name": "Patchwork Banner" }, { "count": 1, - "name": "Malakir Rebirth" + "name": "Sorin, Imperious Bloodlord" }, { "count": 1, - "name": "Mental Misstep" + "name": "Path to Exile" }, { "count": 1, - "name": "Miscast" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Mystical Tutor" + "name": "Champion of Dusk" }, { "count": 1, - "name": "Path to Exile" + "name": "Village Rites" }, { "count": 1, - "name": "Pongify" + "name": "Vito, Thorn of the Dusk Rose" }, { "count": 1, - "name": "Strix Serenade" + "name": "Diabolic Intent" }, { "count": 1, - "name": "Swan Song" + "name": "Bloodline Keeper" }, { "count": 1, - "name": "Swords to Plowshares" + "name": "Marauding Blight-Priest" }, { "count": 1, - "name": "Vampiric Tutor" + "name": "Bloodstained Mire" }, { "count": 1, - "name": "Arcane Denial" + "name": "Fetid Heath" }, { "count": 1, - "name": "Borne Upon a Wind" + "name": "Vault of Champions" }, { "count": 1, - "name": "Counterspell" + "name": "Cathars' Crusade" }, { "count": 1, - "name": "Cyclonic Rift" + "name": "Marsh Flats" }, { "count": 1, - "name": "Dovin's Veto" + "name": "Boros Charm" }, { "count": 1, - "name": "Negate" + "name": "Secluded Courtyard" }, { "count": 1, - "name": "Frantic Search" + "name": "Blightstep Pathway" }, { "count": 1, - "name": "Profane Tutor" + "name": "Gifted Aetherborn" }, { "count": 1, - "name": "Ponder" + "name": "Cavern of Souls" }, { "count": 1, - "name": "Reanimate" + "name": "Banner of Kinship" }, { "count": 1, - "name": "Scheming Symmetry" + "name": "Graven Cairns" }, { "count": 1, - "name": "Demonic Counsel" + "name": "Ruthless Lawbringer" }, { "count": 1, - "name": "Grim Tutor" + "name": "Master of Dark Rites" }, { "count": 1, - "name": "Sevinne's Reclamation" + "name": "Blood Artist" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Exquisite Blood" }, { "count": 1, - "name": "Windfall" + "name": "Cordial Vampire" }, { "count": 1, - "name": "Supreme Verdict" + "name": "Savai Triome" }, { "count": 1, - "name": "Curiosity" + "name": "Phyrexian Arena" }, { "count": 1, - "name": "Mystic Remora" + "name": "Mortify" }, { "count": 1, - "name": "Blind Obedience" + "name": "Teferi's Protection" }, { "count": 1, - "name": "Black Market Connections" + "name": "Vault of the Archangel" }, { "count": 1, - "name": "Grasp of Fate" + "name": "Urborg, Tomb of Yawgmoth" }, { "count": 1, - "name": "Monologue Tax" + "name": "Bloodthirsty Conqueror" }, { "count": 1, - "name": "Propaganda" + "name": "Bolas's Citadel" }, { "count": 1, - "name": "Rhystic Study" + "name": "Command Tower" }, { "count": 1, - "name": "Leyline of Anticipation" + "name": "Sacred Foundry" }, { "count": 1, - "name": "Adarkar Wastes" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Caves of Koilos" + "name": "Reconnaissance" }, { "count": 1, - "name": "City of Brass" + "name": "Vein Ripper" }, { "count": 1, - "name": "Clearwater Pathway" + "name": "Urge to Feed" }, { "count": 1, - "name": "Command Tower" + "name": "Stromkirk Captain" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Viscera Seer" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Olivia's Wrath" }, { "count": 1, - "name": "Fabled Passage" + "name": "Bloodletter of Aclazotz" }, { "count": 1, - "name": "Flooded Strand" + "name": "Drana, Liberator of Malakir" }, { "count": 1, - "name": "Glacial Fortress" + "name": "Falkenrath Noble" }, { "count": 1, - "name": "Godless Shrine" + "name": "New Blood" }, { "count": 1, - "name": "Hallowed Fountain" + "name": "Florian, Voldaren Scion" }, { - "count": 3, - "name": "Island" + "count": 1, + "name": "Orzhov Signet" }, { "count": 1, - "name": "Isolated Chapel" + "name": "Boros Signet" }, { "count": 1, - "name": "Marsh Flats" + "name": "Talisman of Indulgence" }, { "count": 1, - "name": "Meticulous Archive" + "name": "Godless Shrine" }, { - "count": 3, - "name": "Plains" + "count": 1, + "name": "Needleverge Pathway" }, { "count": 1, - "name": "Polluted Delta" + "name": "Bojuka Bog" }, { "count": 1, - "name": "Shadowy Backstreet" + "name": "Spectator Seating" }, { "count": 1, - "name": "Shattered Sanctum" + "name": "Vampire Socialite" }, { - "count": 3, + "count": 4, "name": "Swamp" }, { - "count": 1, - "name": "Underground River" + "count": 3, + "name": "Plains" }, { - "count": 1, - "name": "Urza's Saga" + "count": 2, + "name": "Mountain" }, { "count": 1, - "name": "Watery Grave" + "name": "Emeritus of Woe" }, { "count": 1, - "name": "Windswept Heath" + "name": "Edgar Markov" } ], "sideboard": [] }, { - "name": "Valgavoth, Harrower of Souls", + "name": "Y'shtola, Night's Blessed", "author": "MTGGoldfish", "colors": [ - "R", + "W", + "U", "B" ], "tags": [ @@ -1157,365 +1118,342 @@ ], "main": [ { - "count": 1, - "name": "Ash Barrens" + "count": 7, + "name": "Plains" }, { - "count": 1, - "name": "Evolving Wilds" + "count": 7, + "name": "Island" }, { - "count": 1, - "name": "Terramorphic Expanse" + "count": 7, + "name": "Swamp" }, { "count": 1, - "name": "Canyon Slough" + "name": "Watery Grave" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Gloomlake Verge" }, { "count": 1, - "name": "Foreboding Ruins" + "name": "Shattered Sanctum" }, { "count": 1, - "name": "Command Tower" + "name": "Bleachbone Verge" }, { "count": 1, - "name": "Mayhem Devil" + "name": "Floodfarm Verge" }, { "count": 1, - "name": "Return the Favor" + "name": "Hallowed Fountain" }, { "count": 1, - "name": "Blood Seeker" + "name": "Godless Shrine" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Tainted Pact" }, { "count": 1, - "name": "Shadowblood Ridge" + "name": "Demonic Tutor" }, { "count": 1, - "name": "Smoldering Marsh" + "name": "Vampiric Tutor" }, { "count": 1, - "name": "Temple of Malice" + "name": "Grim Tutor" }, { "count": 1, - "name": "Arcane Signet" + "name": "Mystical Tutor" }, { "count": 1, - "name": "Bedevil" + "name": "Thassa's Oracle" }, { "count": 1, - "name": "Chaos Warp" + "name": "Sanguine Bond" }, { "count": 1, - "name": "Hissing Miasma" + "name": "Exquisite Blood" }, { "count": 1, - "name": "Infernal Grasp" + "name": "Narset's Reversal" }, { "count": 1, - "name": "Leechridden Swamp" + "name": "Time Warp" }, { "count": 1, - "name": "Retreat to Hagra" + "name": "Vito, Thorn of the Dusk Rose" }, { "count": 1, - "name": "Sangromancer" + "name": "Enduring Tenacity" }, { "count": 1, - "name": "Kardur, Doomscourge" + "name": "Bolas's Citadel" }, { "count": 1, - "name": "Rakdos Charm" + "name": "Aetherflux Reservoir" }, { "count": 1, - "name": "Sign in Blood" + "name": "Hullbreaker Horror" }, { "count": 1, - "name": "Ebony Owl Netsuke" + "name": "Displacer Kitten" }, { "count": 1, - "name": "Painful Quandary" + "name": "Mystic Remora" }, { "count": 1, - "name": "Rug of Smothering" + "name": "Mystic Forge" }, { "count": 1, - "name": "Spinerock Knoll" + "name": "Temporal Manipulation" }, { "count": 1, - "name": "Tainted Peak" + "name": "The One Ring" }, { "count": 1, - "name": "Underworld Dreams" + "name": "Necropotence" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Sheoldred, the Apocalypse" }, { "count": 1, - "name": "Shivan Gorge" + "name": "Mana Drain" }, { "count": 1, - "name": "Gray Merchant of Asphodel" + "name": "Force of Will" }, { "count": 1, - "name": "Sulfurous Springs" + "name": "Chrome Mox" }, { "count": 1, - "name": "Champion's Helm" + "name": "Arcane Signet" }, { "count": 1, - "name": "Rakdos Signet" + "name": "Midnight Clock" }, { "count": 1, - "name": "Rampaging Ferocidon" + "name": "Dark Ritual" }, { "count": 1, - "name": "Sol Ring" + "name": "Adarkar Wastes" }, { "count": 1, - "name": "Vandalblast" + "name": "Caves of Koilos" }, { "count": 1, - "name": "Roiling Vortex" + "name": "Concealed Courtyard" }, { "count": 1, - "name": "Blackcleave Cliffs" + "name": "Seachrome Coast" }, { "count": 1, - "name": "Brash Taunter" + "name": "Deserted Beach" }, { "count": 1, - "name": "Boltwave" + "name": "Darkslick Shores" }, { "count": 1, - "name": "Fate Unraveler" + "name": "Shipwreck Marsh" }, { "count": 1, - "name": "Talisman of Indulgence" + "name": "Underground River" }, { "count": 1, - "name": "Thought Vessel" + "name": "Raffine's Tower" }, { "count": 1, - "name": "Blood Artist" + "name": "Rhystic Study" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Esper Sentinel" }, { "count": 1, - "name": "Dark Ritual" + "name": "Brainstorm" }, { "count": 1, - "name": "Citadel of Pain" - }, - { - "count": 1, - "name": "Graven Cairns" - }, - { - "count": 1, - "name": "Bojuka Bog" - }, - { - "count": 1, - "name": "Mogis, God of Slaughter" - }, - { - "count": 1, - "name": "Zo-Zu the Punisher" + "name": "Ponder" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Preordain" }, { "count": 1, - "name": "Massacre Wurm" + "name": "Fierce Guardianship" }, { "count": 1, - "name": "Basilisk Collar" + "name": "Consider" }, { "count": 1, - "name": "Spiked Corridor // Torture Pit [DSC]" + "name": "Sleight of Hand" }, { "count": 1, - "name": "Disrupt Decorum" + "name": "Stock Up" }, { "count": 1, - "name": "Gleeful Arsonist" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Howling Mine" + "name": "Fatal Push" }, { "count": 1, - "name": "Razorkin Needlehead" - }, - { - "count": 7, - "name": "Mountain" + "name": "Get Lost" }, { "count": 1, - "name": "Horn of Greed" + "name": "Anguished Unmaking" }, { "count": 1, - "name": "Kederekt Parasite" + "name": "Pact of Negation" }, { "count": 1, - "name": "The Lord of Pain" + "name": "Sea Gate Restoration" }, { "count": 1, - "name": "Vein Ripper" + "name": "Lorien Revealed" }, { "count": 1, - "name": "Pyrohemia" + "name": "Dig Through Time" }, { "count": 1, - "name": "Sword of War and Peace" + "name": "Treasure Cruise" }, { "count": 1, - "name": "Chandra's Ignition" + "name": "Memory Deluge" }, { "count": 1, - "name": "Reanimate" + "name": "Siphon Insight" }, { "count": 1, - "name": "Scrawling Crawler" + "name": "Wash Away" }, { "count": 1, - "name": "Haunted Ridge" - }, - { - "count": 7, - "name": "Swamp" + "name": "Swan Song" }, { "count": 1, - "name": "Chandra, Awakened Inferno" + "name": "Coldsteel Heart" }, { "count": 1, - "name": "Vito, Thorn of the Dusk Rose" + "name": "Guardian Idol" }, { "count": 1, - "name": "Raucous Theater" + "name": "Fell" }, { "count": 1, - "name": "Persistent Constrictor" + "name": "Thoughtseize" }, { "count": 1, - "name": "Tibalt's Trickery" + "name": "Relic of Legends" }, { "count": 1, - "name": "Descent into Avernus" + "name": "Approach of the Second Sun" }, { "count": 1, - "name": "Crypt Ghast" + "name": "Silence" }, { "count": 1, - "name": "Witch's Clinic" + "name": "Drannith Magistrate" }, { "count": 1, - "name": "Ob Nixilis, Captive Kingpin" + "name": "Voice of Victory" }, { "count": 1, - "name": "Bloodchief Ascension" + "name": "Grand Abolisher" }, { "count": 1, - "name": "Hexing Squelcher" + "name": "Ashes of the Abhorrent" }, { "count": 1, - "name": "Solphim, Mayhem Dominus" + "name": "Dauthi Voidwalker" }, { "count": 1, - "name": "Valgavoth, Harrower of Souls" + "name": "Y'shtola, Night's Blessed" } ], "sideboard": [] }, { - "name": "The Unbeatable Squirrel Girl", + "name": "Valgavoth, Harrower of Souls", "author": "MTGGoldfish", "colors": [ - "G" + "R", + "B" ], "tags": [ "metagame" @@ -1523,219 +1461,155 @@ "main": [ { "count": 1, - "name": "The Unbeatable Squirrel Girl" - }, - { - "count": 1, - "name": "Squirrel Sovereign" - }, - { - "count": 1, - "name": "Deranged Hermit" - }, - { - "count": 1, - "name": "Chitterspitter" - }, - { - "count": 1, - "name": "Honored Dreyleader" - }, - { - "count": 1, - "name": "Squirrel Mob" - }, - { - "count": 1, - "name": "Thornvault Forager" - }, - { - "count": 1, - "name": "Cryptolith Rite" - }, - { - "count": 1, - "name": "Scurry of Squirrels" - }, - { - "count": 1, - "name": "Squirrel Nest" - }, - { - "count": 1, - "name": "Toski, Bearer of Secrets" - }, - { - "count": 1, - "name": "Chatterstorm" - }, - { - "count": 1, - "name": "Heroic Intervention" - }, - { - "count": 1, - "name": "Enduring Vitality" - }, - { - "count": 1, - "name": "Jaheira, Friend of the Forest" - }, - { - "count": 1, - "name": "Beast Within" + "name": "Ash Barrens" }, { "count": 1, - "name": "Skullclamp" + "name": "Evolving Wilds" }, { "count": 1, - "name": "Second Harvest" + "name": "Terramorphic Expanse" }, { "count": 1, - "name": "Doubling Season" + "name": "Canyon Slough" }, { "count": 1, - "name": "Deep Forest Hermit" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Concordant Crossroads" + "name": "Foreboding Ruins" }, { "count": 1, - "name": "Parallel Lives" + "name": "Command Tower" }, { "count": 1, - "name": "Seedborn Muse" + "name": "Mayhem Devil" }, { "count": 1, - "name": "Biorhythm" + "name": "Return the Favor" }, { "count": 1, - "name": "The One Ring" + "name": "Blood Seeker" }, { "count": 1, - "name": "Worldly Tutor" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Craterhoof Behemoth" + "name": "Shadowblood Ridge" }, { "count": 1, - "name": "Llanowar Elves" + "name": "Smoldering Marsh" }, { "count": 1, - "name": "Circle of Dreams Druid" + "name": "Temple of Malice" }, { "count": 1, - "name": "Elvish Mystic" + "name": "Arcane Signet" }, { "count": 1, - "name": "Nut Collector" + "name": "Bedevil" }, { "count": 1, - "name": "Scurry Oak" + "name": "Chaos Warp" }, { "count": 1, - "name": "Ohran Frostfang" + "name": "Hissing Miasma" }, { "count": 1, - "name": "Curious Forager" + "name": "Infernal Grasp" }, { "count": 1, - "name": "Birds of Paradise" + "name": "Leechridden Swamp" }, { "count": 1, - "name": "Peregrin Took" + "name": "Retreat to Hagra" }, { "count": 1, - "name": "Champion of Lambholt" + "name": "Sangromancer" }, { "count": 1, - "name": "Valley Mightcaller" + "name": "Kardur, Doomscourge" }, { "count": 1, - "name": "Crashing Drawbridge" + "name": "Rakdos Charm" }, { "count": 1, - "name": "Realmwalker" + "name": "Sign in Blood" }, { "count": 1, - "name": "Beast Whisperer" + "name": "Ebony Owl Netsuke" }, { "count": 1, - "name": "Prosperous Innkeeper" + "name": "Painful Quandary" }, { "count": 1, - "name": "Mutable Explorer" + "name": "Rug of Smothering" }, { "count": 1, - "name": "Swarmyard" + "name": "Spinerock Knoll" }, { "count": 1, - "name": "Three Tree City" + "name": "Tainted Peak" }, { "count": 1, - "name": "Nykthos, Shrine to Nyx" + "name": "Underworld Dreams" }, { "count": 1, - "name": "Oakhollow Village" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Boseiju, Who Endures" + "name": "Shivan Gorge" }, { "count": 1, - "name": "Castle Garenbrig" + "name": "Gray Merchant of Asphodel" }, { "count": 1, - "name": "Yavimaya, Cradle of Growth" + "name": "Sulfurous Springs" }, { "count": 1, - "name": "War Room" + "name": "Champion's Helm" }, { "count": 1, - "name": "Fountainport" + "name": "Rakdos Signet" }, { "count": 1, - "name": "Gaea's Cradle" - }, - { - "count": 25, - "name": "Forest" + "name": "Rampaging Ferocidon" }, { "count": 1, @@ -1743,511 +1617,494 @@ }, { "count": 1, - "name": "Arcane Signet" - }, - { - "count": 1, - "name": "Verdant Command" - }, - { - "count": 1, - "name": "Chord of Calling" - }, - { - "count": 1, - "name": "Return of the Wildspeaker" + "name": "Vandalblast" }, { "count": 1, - "name": "Harrow" + "name": "Roiling Vortex" }, { "count": 1, - "name": "Nature's Lore" + "name": "Blackcleave Cliffs" }, { "count": 1, - "name": "Cultivate" + "name": "Brash Taunter" }, { "count": 1, - "name": "Preposterous Proportions" + "name": "Boltwave" }, { "count": 1, - "name": "Earthcraft" + "name": "Fate Unraveler" }, { "count": 1, - "name": "Green Sun's Zenith" + "name": "Talisman of Indulgence" }, { "count": 1, - "name": "Finale of Devastation" + "name": "Thought Vessel" }, { "count": 1, - "name": "Emerald Medallion" + "name": "Blood Artist" }, { "count": 1, - "name": "Chronicle of Victory" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Akroma's Memorial" + "name": "Dark Ritual" }, { "count": 1, - "name": "Springleaf Parade" + "name": "Citadel of Pain" }, { "count": 1, - "name": "Beastmaster Ascension" + "name": "Graven Cairns" }, { "count": 1, - "name": "Sylvan Anthem" + "name": "Bojuka Bog" }, { "count": 1, - "name": "Primal Vigor" + "name": "Mogis, God of Slaughter" }, { "count": 1, - "name": "Squirrel Sanctuary" + "name": "Zo-Zu the Punisher" }, { "count": 1, - "name": "Unnatural Growth" + "name": "Lightning Greaves" }, { "count": 1, - "name": "Patchwork Banner" - } - ], - "sideboard": [] - }, - { - "name": "Lathril, Blade of the Elves", - "author": "MTGGoldfish", - "colors": [ - "G", - "B" - ], - "tags": [ - "metagame" - ], - "main": [ - { - "count": 1, - "name": "Abrupt Decay" + "name": "Massacre Wurm" }, { "count": 1, - "name": "Ad Nauseam" + "name": "Basilisk Collar" }, { "count": 1, - "name": "Allosaurus Shepherd" + "name": "Spiked Corridor // Torture Pit [DSC]" }, { "count": 1, - "name": "Aluren" + "name": "Disrupt Decorum" }, { "count": 1, - "name": "Ancient Tomb" + "name": "Gleeful Arsonist" }, { "count": 1, - "name": "Arcane Signet" + "name": "Howling Mine" }, { "count": 1, - "name": "Ashaya, Soul of the Wild" + "name": "Razorkin Needlehead" }, { - "count": 1, - "name": "Assassin's Trophy" + "count": 7, + "name": "Mountain" }, { "count": 1, - "name": "Autumn's Veil" + "name": "Horn of Greed" }, { "count": 1, - "name": "Ayara, First of Locthwain" + "name": "Kederekt Parasite" }, { "count": 1, - "name": "Bloom Tender" + "name": "The Lord of Pain" }, { "count": 1, - "name": "Boseiju, Who Endures" + "name": "Vein Ripper" }, { "count": 1, - "name": "Cabal Pit" + "name": "Pyrohemia" }, { "count": 1, - "name": "City of Brass" + "name": "Sword of War and Peace" }, { "count": 1, - "name": "Coat of Arms" + "name": "Chandra's Ignition" }, { "count": 1, - "name": "Command Tower" + "name": "Reanimate" }, { "count": 1, - "name": "Craterhoof Behemoth" + "name": "Scrawling Crawler" }, { "count": 1, - "name": "Culling Ritual" + "name": "Haunted Ridge" }, { - "count": 1, - "name": "Cultivate" + "count": 7, + "name": "Swamp" }, { "count": 1, - "name": "Dark Ritual" + "name": "Chandra, Awakened Inferno" }, { "count": 1, - "name": "Darkbore Pathway" + "name": "Vito, Thorn of the Dusk Rose" }, { "count": 1, - "name": "Deathcap Glade" + "name": "Raucous Theater" }, { "count": 1, - "name": "Devoted Druid" + "name": "Persistent Constrictor" }, { "count": 1, - "name": "Disciple of Freyalise" + "name": "Tibalt's Trickery" }, { "count": 1, - "name": "Dwynen, Gilt-Leaf Daen" + "name": "Descent into Avernus" }, { "count": 1, - "name": "Elderfang Venom" + "name": "Crypt Ghast" }, { "count": 1, - "name": "Elves of Deep Shadow" + "name": "Witch's Clinic" }, { "count": 1, - "name": "Elvish Archdruid" + "name": "Ob Nixilis, Captive Kingpin" }, { "count": 1, - "name": "Elvish Champion" + "name": "Bloodchief Ascension" }, { "count": 1, - "name": "Elvish Harbinger" + "name": "Hexing Squelcher" }, { "count": 1, - "name": "Elvish Mystic" + "name": "Solphim, Mayhem Dominus" }, { "count": 1, - "name": "Elvish Promenade" - }, + "name": "Valgavoth, Harrower of Souls" + } + ], + "sideboard": [] + }, + { + "name": "The Unbeatable Squirrel Girl", + "author": "MTGGoldfish", + "colors": [ + "G" + ], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Elvish Spirit Guide" + "name": "Oakhollow Village" }, { "count": 1, - "name": "Emergence Zone" + "name": "Myriad Landscape" }, { "count": 1, - "name": "Endurance" + "name": "Evolving Wilds" }, { "count": 1, - "name": "Essence Warden" + "name": "Terramorphic Expanse" }, { - "count": 1, - "name": "Eternal Witness" + "count": 33, + "name": "Forest" }, { "count": 1, - "name": "Evendo, Waking Haven" + "name": "The Unbeatable Squirrel Girl" }, { "count": 1, - "name": "Finale of Devastation" + "name": "Chitterspitter" }, { "count": 1, - "name": "Flourishing Defenses" + "name": "Emerald Medallion" }, { "count": 1, - "name": "Forest" + "name": "Skullclamp" }, { "count": 1, - "name": "Galadhrim Ambush" + "name": "Sol Ring" }, { "count": 1, - "name": "Gemstone Caverns" + "name": "Arcane Signet" }, { "count": 1, - "name": "Gilt-Leaf Palace" + "name": "Altar of the Brood" }, { "count": 1, - "name": "Glissa Sunslayer" + "name": "Banner of Kinship" }, { "count": 1, - "name": "Haldir, Lorien Lieutenant" + "name": "Idol of Oblivion" }, { "count": 1, - "name": "Heritage Druid" + "name": "Eldrazi Monument" }, { "count": 1, - "name": "High Market" + "name": "Vanquisher's Banner" }, { "count": 1, - "name": "High Perfect Morcant" + "name": "Herald's Horn" }, { "count": 1, - "name": "Imp's Mischief" + "name": "Swarmyard" }, { "count": 1, - "name": "Imperious Perfect" + "name": "Beastmaster Ascension" }, { "count": 1, - "name": "Leaf-Crowned Visionary" + "name": "Growing Rites of Itlimoc" }, { "count": 1, - "name": "Llanowar Elves" + "name": "Primal Vigor" }, { "count": 1, - "name": "Llanowar Wastes" + "name": "Elven Chorus" }, { "count": 1, - "name": "Mana Confluence" + "name": "Cryptolith Rite" }, { "count": 1, - "name": "Marsh Flats" + "name": "Springleaf Parade" }, { "count": 1, - "name": "Marwyn, the Nurturer" + "name": "Squirrel Nest" }, { "count": 1, - "name": "Natural Order" + "name": "Sylvan Anthem" }, { "count": 1, - "name": "Nature's Claim" + "name": "Kenrith's Transformation" }, { "count": 1, - "name": "Necropotence" + "name": "Song of the Dryads" }, { "count": 1, - "name": "Nissa, Ascended Animist" + "name": "Beast Within" }, { "count": 1, - "name": "Nissa, Resurgent Animist" + "name": "Return of the Wildspeaker" }, { "count": 1, - "name": "Nurturing Peatland" + "name": "Krosan Grip" }, { "count": 1, - "name": "Oracle of Mul Daya" + "name": "Verdant Command" }, { "count": 1, - "name": "Overgrown Tomb" + "name": "Wrap in Vigor" }, { "count": 1, - "name": "Patchwork Banner" + "name": "Naturalize" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Second Harvest" }, { "count": 1, - "name": "Pattern of Rebirth" + "name": "Chatterstorm" }, { "count": 1, - "name": "Phyrexian Tower" + "name": "Cultivate" }, { "count": 1, - "name": "Poison-Tip Archer" + "name": "Shamanic Revelation" }, { "count": 1, - "name": "Priest of Titania" + "name": "For the Common Good" }, { "count": 1, - "name": "Quirion Ranger" + "name": "Rampant Growth" }, { "count": 1, - "name": "Reclamation Sage" + "name": "Rootcast Apprenticeship" }, { "count": 1, - "name": "Shaman of the Pack" + "name": "Kodama's Reach" }, { "count": 1, - "name": "Skemfar Shadowsage" + "name": "Harmonize" }, { "count": 1, - "name": "Skullclamp" + "name": "Collective Unconscious" }, { "count": 1, - "name": "Snow-Covered Forest" + "name": "Harvest Season" }, { "count": 1, - "name": "Snow-Covered Swamp" + "name": "Heroic Intervention" }, { "count": 1, - "name": "Sol Ring" + "name": "Toski, Bearer of Secrets" }, { "count": 1, - "name": "Staff of Domination" + "name": "Scurry of Squirrels" }, { "count": 1, - "name": "Swamp" + "name": "Honored Dreyleader" }, { "count": 1, - "name": "Sylvan Library" + "name": "Squirrel Mob" }, { "count": 1, - "name": "Tainted Wood" + "name": "Thornvault Forager" }, { "count": 1, - "name": "Talisman of Resilience" + "name": "Squirrel Sovereign" }, { "count": 1, - "name": "Three Tree City" + "name": "Tippy-Toe, Terrific Partner" }, { "count": 1, - "name": "Twilight Mire" + "name": "Bakersbane Duo" }, { "count": 1, - "name": "Underground Mortuary" + "name": "Realmwalker" }, { "count": 1, - "name": "Undergrowth Stadium" + "name": "Metallic Mimic" }, { "count": 1, - "name": "Urborg, Tomb of Yawgmoth" + "name": "Spider-Ham, Peter Porker" }, { "count": 1, - "name": "Utopia Sprawl" + "name": "Treetop Sentries" }, { "count": 1, - "name": "Veil of Summer" + "name": "Scurrid Colony" }, { "count": 1, - "name": "Verdant Catacombs" + "name": "Frog-Squirrels" }, { "count": 1, - "name": "Volrath's Stronghold" + "name": "Bushy Bodyguard" }, { "count": 1, - "name": "Wild Growth" + "name": "Nut Collector" }, { "count": 1, - "name": "Windswept Heath" + "name": "Scurry Oak" }, { "count": 1, - "name": "Wirewood Lodge" + "name": "Curious Forager" }, { "count": 1, - "name": "Wirewood Symbiote" + "name": "Bloodroot Apothecary" }, { "count": 1, - "name": "Yavimaya, Cradle of Growth" + "name": "Adaptive Automaton" }, { "count": 1, - "name": "Yeva, Nature's Herald" + "name": "Bloodline Pretender" }, { "count": 1, - "name": "Lathril, Blade of the Elves <019f70c7-65bc-7ba2-9fb3-3e40e871bc4d> [SLD]" + "name": "Explosive Vegetation" } ], "sideboard": [] }, { - "name": "Fire Lord Azula", + "name": "Xyris, the Writhing Storm", "author": "MTGGoldfish", "colors": [ + "G", "U", - "B", "R" ], "tags": [ @@ -2256,768 +2113,775 @@ "main": [ { "count": 1, - "name": "Hex Magic" + "name": "Adrix and Nev, Twincasters" }, { "count": 1, - "name": "Narset's Reversal" + "name": "An Offer You Can't Refuse" }, { "count": 1, - "name": "Snap" + "name": "Arcane Denial" }, { "count": 1, - "name": "Storm-Kiln Artist" + "name": "Arcane Signet" }, { "count": 1, - "name": "Archmage Emeritus" + "name": "Banner of Kinship" }, { "count": 1, - "name": "Leyline of Anticipation" + "name": "Beast Within" }, { "count": 1, - "name": "Veyran, Voice of Duality" + "name": "Beastmaster Ascension" }, { "count": 1, - "name": "Sol Ring" + "name": "Chaos Warp" }, { "count": 1, - "name": "Talisman of Creativity" + "name": "Chromatic Lantern" }, { "count": 1, - "name": "Talisman of Dominance" + "name": "Command Tower" }, { "count": 1, - "name": "Talisman of Indulgence" + "name": "Counterspell" }, { "count": 1, - "name": "Arcane Signet" + "name": "Cryptolith Rite" }, { "count": 1, - "name": "Izzet Signet" + "name": "Cultivate" }, { "count": 1, - "name": "Rakdos Signet" + "name": "Dictate of Kruphix" }, { "count": 1, - "name": "Dimir Signet" + "name": "Doubling Season" }, { "count": 1, - "name": "Chrome Mox" + "name": "Dream Fracture" }, { "count": 1, - "name": "Mox Diamond" + "name": "Dreamroot Cascade" }, { "count": 1, - "name": "Mox Amber" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Thought Vessel" + "name": "Farseek" }, { "count": 1, - "name": "Counterspell" + "name": "Fevered Visions" }, { "count": 1, - "name": "Seething Song" + "name": "Folio of Fancies" }, { "count": 1, - "name": "Dark Ritual" + "name": "Forced Fruition" }, { - "count": 1, - "name": "Pyretic Ritual" + "count": 5, + "name": "Forest" }, { "count": 1, - "name": "Grapeshot" + "name": "Frontier Bivouac" }, { "count": 1, - "name": "Pongify" + "name": "Gamble" }, { "count": 1, - "name": "Rapid Hybridization" + "name": "Geier Reach Sanitarium" }, { "count": 1, - "name": "Damnation" + "name": "Goblin Bombardment" }, { "count": 1, - "name": "Vandalblast" + "name": "Growing Rites of Itlimoc" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Growth Spiral" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Hinterland Harbor" }, { "count": 1, - "name": "Deflecting Swat" + "name": "Horn of Greed" }, { "count": 1, - "name": "Fierce Guardianship" + "name": "Howling Mine" }, { "count": 1, - "name": "Deadly Rollick" + "name": "Impact Tremors" }, { - "count": 1, - "name": "Underworld Breach" + "count": 7, + "name": "Island" }, { "count": 1, - "name": "Jeska's Will" + "name": "Jace's Archivist" }, { "count": 1, - "name": "Cyclonic Rift" + "name": "Kami of the Crescent Moon" }, { "count": 1, - "name": "Nightscape Familiar" + "name": "Laboratory Maniac" }, { "count": 1, - "name": "Birgi, God of Storytelling" + "name": "Lightning Greaves" }, { "count": 1, - "name": "Electro, Assaulting Battery" + "name": "Magus of the Wheel" }, { "count": 1, - "name": "Hexing Squelcher" + "name": "Mikokoro, Center of the Sea" }, { "count": 1, - "name": "Tidal Barracuda" + "name": "Mistrise Village" }, { "count": 1, - "name": "Bria, Riptide Rogue" + "name": "Molten Gatekeeper" }, { "count": 1, - "name": "Cunning Nightbonder" + "name": "Molten Psyche" + }, + { + "count": 4, + "name": "Mountain" }, { "count": 1, - "name": "Redirect Lightning" + "name": "Negate" }, { "count": 1, - "name": "Rhystic Study" + "name": "Ominous Seas" }, { "count": 1, - "name": "Propaganda" + "name": "Otawara, Soaring City" }, { "count": 1, - "name": "Flusterstorm" + "name": "Pongify" }, { "count": 1, - "name": "Turnabout" + "name": "Proft's Eidetic Memory" }, { "count": 1, - "name": "Brain Freeze" + "name": "Prosperity" }, { "count": 1, - "name": "Time Warp" + "name": "Psychosis Crawler" }, { "count": 1, - "name": "Nexus of Fate" + "name": "Rampant Growth" }, { "count": 1, - "name": "Electrodominance" + "name": "Razorkin Needlehead" }, { "count": 1, - "name": "Lotus Petal" + "name": "Reflecting Pool" }, { "count": 1, - "name": "Opt" + "name": "Reforge the Soul" }, { "count": 1, - "name": "Brainstorm" + "name": "Rejuvenating Springs" }, { "count": 1, - "name": "Thrill of Possibility" + "name": "Reliquary Tower" }, { "count": 1, - "name": "Cabal Ritual" + "name": "Rites of Flourishing" }, { "count": 1, - "name": "Solve the Equation" + "name": "Rockfall Vale" }, { "count": 1, - "name": "Twinning Staff" + "name": "Rogue's Passage" }, { "count": 1, - "name": "Mystic Remora" + "name": "Rootbound Crag" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Sakura-Tribe Elder" }, { "count": 1, - "name": "Fire Nation Palace" + "name": "Scrawling Crawler" }, { "count": 1, - "name": "Arena of Glory" + "name": "Seshiro the Anointed" }, { "count": 1, - "name": "Rogue's Passage" + "name": "Shared Animosity" }, { "count": 1, - "name": "Mistrise Village" + "name": "Skullclamp" }, { "count": 1, - "name": "Shizo, Death's Storehouse" + "name": "Snakeskin Veil" }, { "count": 1, - "name": "Urborg, Tomb of Yawgmoth" + "name": "Spire Garden" }, { "count": 1, - "name": "Cabal Coffers" + "name": "Stormcarved Coast" }, { "count": 1, - "name": "Ancient Tomb" + "name": "Sulfur Falls" }, { "count": 1, - "name": "Command Beacon" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Command Tower" + "name": "Talisman of Creativity" }, { "count": 1, - "name": "Bloodstained Mire" + "name": "Talisman of Curiosity" }, { "count": 1, - "name": "Polluted Delta" + "name": "Tamiyo's Safekeeping" }, { "count": 1, - "name": "Scalding Tarn" + "name": "Teferi's Puzzle Box" }, { "count": 1, - "name": "Watery Grave" + "name": "Temple of the False God" }, { "count": 1, - "name": "Steam Vents" + "name": "The Locust God" }, { "count": 1, - "name": "Blood Crypt" + "name": "Thought Vessel" }, { "count": 1, - "name": "Xander's Lounge" + "name": "Training Center" }, { "count": 1, - "name": "Morphic Pool" + "name": "Tyvar's Stand" }, { "count": 1, - "name": "Training Center" + "name": "Vision Skeins" }, { "count": 1, - "name": "Luxury Suite" + "name": "Wheel and Deal" }, { "count": 1, - "name": "Badlands" + "name": "Windfall" }, { "count": 1, - "name": "Volcanic Island" + "name": "Winds of Change" }, { "count": 1, - "name": "Underground Sea" + "name": "Wizard Class" }, { - "count": 2, - "name": "Island" + "count": 1, + "name": "Maze of Ith" }, { "count": 1, - "name": "Swamp" + "name": "Xyris, the Writhing Storm" + } + ], + "sideboard": [] + }, + { + "name": "Lathril, Blade of the Elves", + "author": "MTGGoldfish", + "colors": [ + "G", + "B" + ], + "tags": [ + "metagame" + ], + "main": [ + { + "count": 1, + "name": "Abrupt Decay" }, { - "count": 2, - "name": "Mountain" + "count": 1, + "name": "Ad Nauseam" }, { "count": 1, - "name": "Fire Lord Azula" + "name": "Allosaurus Shepherd" }, { "count": 1, - "name": "Chain of Vapor" + "name": "Aluren" }, { "count": 1, - "name": "Ponder" + "name": "Ancient Tomb" }, { "count": 1, - "name": "Expedite" + "name": "Arcane Signet" }, { "count": 1, - "name": "Borne Upon a Wind" + "name": "Ashaya, Soul of the Wild" }, { "count": 1, - "name": "Vedalken Orrery" + "name": "Assassin's Trophy" }, { "count": 1, - "name": "Emergence Zone" + "name": "Autumn's Veil" }, { "count": 1, - "name": "Mystical Tutor" + "name": "Ayara, First of Locthwain" }, { "count": 1, - "name": "Vampiric Tutor" + "name": "Bloom Tender" }, { "count": 1, - "name": "Thassa's Oracle" + "name": "Boseiju, Who Endures" }, { "count": 1, - "name": "Demonic Tutor" - } - ], - "sideboard": [] - }, - { - "name": "Xyris, the Writhing Storm", - "author": "MTGGoldfish", - "colors": [ - "G", - "U", - "R" - ], - "tags": [ - "metagame" - ], - "main": [ + "name": "Cabal Pit" + }, { "count": 1, - "name": "Xyris, the Writhing Storm" + "name": "City of Brass" }, { "count": 1, - "name": "Coiling Oracle" + "name": "Coat of Arms" }, { "count": 1, - "name": "Deeproot Wayfinder" + "name": "Command Tower" }, { "count": 1, - "name": "Mercurial Spelldancer" + "name": "Craterhoof Behemoth" }, { "count": 1, - "name": "Mischievous Catgeist" + "name": "Culling Ritual" }, { "count": 1, - "name": "Sakura-Tribe Elder" + "name": "Cultivate" }, { "count": 1, - "name": "Stormchaser Drake" + "name": "Dark Ritual" }, { "count": 1, - "name": "Cold-Eyed Selkie" + "name": "Darkbore Pathway" }, { "count": 1, - "name": "Kaseto, Orochi Archmage" + "name": "Deathcap Glade" }, { "count": 1, - "name": "Surrakar Spellblade" + "name": "Devoted Druid" }, { "count": 1, - "name": "Trygon Predator" + "name": "Disciple of Freyalise" }, { "count": 1, - "name": "Neheb, Dreadhorde Champion" + "name": "Dwynen, Gilt-Leaf Daen" }, { "count": 1, - "name": "Oakhame Adversary" + "name": "Elderfang Venom" }, { "count": 1, - "name": "Sunder Shaman" + "name": "Elves of Deep Shadow" }, { "count": 1, - "name": "Bladegriff Prototype" + "name": "Elvish Archdruid" }, { "count": 1, - "name": "Geode Golem" + "name": "Elvish Champion" }, { "count": 1, - "name": "Psychosis Crawler" + "name": "Elvish Harbinger" }, { "count": 1, - "name": "Explore" + "name": "Elvish Mystic" }, { "count": 1, - "name": "Farseek" + "name": "Elvish Promenade" }, { "count": 1, - "name": "Rampant Growth" + "name": "Elvish Spirit Guide" }, { "count": 1, - "name": "Shared Roots" + "name": "Emergence Zone" }, { "count": 1, - "name": "Cultivate" + "name": "Endurance" }, { "count": 1, - "name": "Kodama's Reach" + "name": "Essence Warden" }, { "count": 1, - "name": "Roar of Challenge" + "name": "Eternal Witness" }, { "count": 1, - "name": "Armed // Dangerous" + "name": "Evendo, Waking Haven" }, { "count": 1, - "name": "Blossoming Defense" + "name": "Finale of Devastation" }, { "count": 1, - "name": "Brute Force" + "name": "Flourishing Defenses" }, { "count": 1, - "name": "Get a Leg Up" + "name": "Forest" }, { "count": 1, - "name": "Giant Growth" + "name": "Galadhrim Ambush" }, { "count": 1, - "name": "Groundswell" + "name": "Gemstone Caverns" }, { "count": 1, - "name": "Might of the Masses" + "name": "Gilt-Leaf Palace" }, { "count": 1, - "name": "Shore Up" + "name": "Glissa Sunslayer" }, { "count": 1, - "name": "Snakeskin Veil" + "name": "Haldir, Lorien Lieutenant" }, { "count": 1, - "name": "Titan's Strength" + "name": "Heritage Druid" }, { "count": 1, - "name": "Tyvar's Stand" + "name": "High Market" }, { "count": 1, - "name": "Vines of Vastwood" + "name": "High Perfect Morcant" }, { "count": 1, - "name": "Blazing Crescendo" + "name": "Imp's Mischief" }, { "count": 1, - "name": "Colossal Growth" + "name": "Imperious Perfect" }, { "count": 1, - "name": "Decisive Denial" + "name": "Leaf-Crowned Visionary" }, { "count": 1, - "name": "Gaea's Gift" + "name": "Llanowar Elves" }, { "count": 1, - "name": "Growth Spiral" + "name": "Llanowar Wastes" }, { "count": 1, - "name": "Inscription of Abundance" + "name": "Mana Confluence" }, { "count": 1, - "name": "Joint Exploration" + "name": "Marsh Flats" }, { "count": 1, - "name": "Ram Through" + "name": "Marwyn, the Nurturer" }, { "count": 1, - "name": "Simic Charm" + "name": "Natural Order" }, { "count": 1, - "name": "Temur Battle Rage" + "name": "Nature's Claim" }, { "count": 1, - "name": "Titanic Growth" + "name": "Necropotence" }, { "count": 1, - "name": "Fully Grown" + "name": "Nissa, Ascended Animist" }, { "count": 1, - "name": "Harrow" + "name": "Nissa, Resurgent Animist" }, { "count": 1, - "name": "Invigorate" + "name": "Nurturing Peatland" }, { "count": 1, - "name": "Temur Charm" + "name": "Oracle of Mul Daya" }, { "count": 1, - "name": "Collision // Colossus" + "name": "Overgrown Tomb" }, { "count": 1, - "name": "Become Immense" + "name": "Patchwork Banner" }, { "count": 1, - "name": "Beamtown Beatstick" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Sol Ring" + "name": "Pattern of Rebirth" }, { "count": 1, - "name": "Arcane Signet" + "name": "Phyrexian Tower" }, { "count": 1, - "name": "Goldvein Pick" + "name": "Poison-Tip Archer" }, { "count": 1, - "name": "Thought Vessel" + "name": "Priest of Titania" }, { "count": 1, - "name": "Decanter of Endless Water" + "name": "Quirion Ranger" }, { "count": 1, - "name": "Combat Research" + "name": "Reclamation Sage" }, { "count": 1, - "name": "Sticky Fingers" + "name": "Shaman of the Pack" }, { "count": 1, - "name": "Season of Growth" + "name": "Skemfar Shadowsage" }, { "count": 1, - "name": "Snake Umbra" + "name": "Skullclamp" }, { "count": 1, - "name": "Ash Barrens" + "name": "Snow-Covered Forest" }, { "count": 1, - "name": "Bountiful Landscape" + "name": "Snow-Covered Swamp" }, { "count": 1, - "name": "Cinder Glade" + "name": "Sol Ring" }, { "count": 1, - "name": "Command Tower" + "name": "Staff of Domination" }, { "count": 1, - "name": "Dreamroot Cascade" + "name": "Swamp" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Sylvan Library" }, { "count": 1, - "name": "Fabled Passage" + "name": "Tainted Wood" }, { - "count": 7, - "name": "Forest" + "count": 1, + "name": "Talisman of Resilience" }, { "count": 1, - "name": "Frontier Bivouac" + "name": "Three Tree City" }, { "count": 1, - "name": "Hinterland Harbor" + "name": "Twilight Mire" }, { - "count": 5, - "name": "Island" + "count": 1, + "name": "Underground Mortuary" }, { "count": 1, - "name": "Karplusan Forest" + "name": "Undergrowth Stadium" }, { "count": 1, - "name": "Kessig Wolf Run" + "name": "Urborg, Tomb of Yawgmoth" }, { - "count": 4, - "name": "Mountain" + "count": 1, + "name": "Utopia Sprawl" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Veil of Summer" }, { "count": 1, - "name": "Rockfall Vale" + "name": "Verdant Catacombs" }, { "count": 1, - "name": "Rogue's Passage" + "name": "Volrath's Stronghold" }, { "count": 1, - "name": "Rootbound Crag" + "name": "Wild Growth" }, { "count": 1, - "name": "Scorched Geyser" + "name": "Windswept Heath" }, { "count": 1, - "name": "Shivan Reef" + "name": "Wirewood Lodge" }, { "count": 1, - "name": "Sodden Verdure" + "name": "Wirewood Symbiote" }, { "count": 1, - "name": "Stormcarved Coast" + "name": "Yavimaya, Cradle of Growth" }, { "count": 1, - "name": "Sulfur Falls" + "name": "Yeva, Nature's Herald" }, { "count": 1, - "name": "Yavimaya Coast" + "name": "Lathril, Blade of the Elves <019f70c7-65bc-7ba2-9fb3-3e40e871bc4d> [SLD]" } ], "sideboard": [] }, { - "name": "Bruce Banner", + "name": "Kaalia of the Vast", "author": "MTGGoldfish", "colors": [ - "G", - "U", + "W", + "B", "R" ], "tags": [ @@ -3026,263 +2890,275 @@ "main": [ { "count": 1, - "name": "Abomination, Terrifying Titan" + "name": "Adarkar Valkyrie" }, { "count": 1, - "name": "Doc Samson, Super Psychiatrist" + "name": "Aegis Angel" }, { "count": 1, - "name": "Elvish Spirit Guide" + "name": "Akroma, Angel of Fury" }, { "count": 1, - "name": "Hercules, Prince of Power" + "name": "Akroma, Angel of Wrath" }, { "count": 1, - "name": "Hulk, Gamma Goliath" + "name": "Angel of Despair" }, { "count": 1, - "name": "Hulk, Strongest There Is" + "name": "Angel of Serenity" }, { "count": 1, - "name": "Karlach, Fury of Avernus" + "name": "Angelic Arbiter" }, { "count": 1, - "name": "Llanowar Elves" + "name": "Angelic Skirmisher" }, { "count": 1, - "name": "Michelangelo, Weirdness to 11" + "name": "Angel of the Ruins" }, { "count": 1, - "name": "Mister Hyde, Monster Within" + "name": "Archfiend of Depravity" }, { "count": 1, - "name": "Raphael, the Nightwatcher" + "name": "Balor" }, { "count": 1, - "name": "Red Hulk" + "name": "Bladewing the Risen" }, { "count": 1, - "name": "She-Hulk, Jade Defender" + "name": "Bloodgift Demon" }, { "count": 1, - "name": "Simian Spirit Guide" + "name": "Breathkeeper Seraph" }, { "count": 1, - "name": "The Thing, Ben Grimm" + "name": "Dawnbreak Reclaimer" }, { "count": 1, - "name": "Thor, God of Thunder" + "name": "Demon of Loathing" }, { "count": 1, - "name": "Toggo, Goblin Weaponsmith" + "name": "Drakuseth, Maw of Flames" }, { "count": 1, - "name": "Kiora, Behemoth Beckoner" + "name": "Emeria Shepherd" }, { "count": 1, - "name": "Amazing Acrobatics" + "name": "Giada, Font of Hope" }, { "count": 1, - "name": "Annul" + "name": "Harvester of Souls" }, { "count": 1, - "name": "Big Score" + "name": "Indulgent Tormentor" }, { "count": 1, - "name": "Hornet Sting" + "name": "Isshin, Two Heavens as One" }, { "count": 1, - "name": "HULK SMASH!" + "name": "Liesa, Forgotten Archangel" }, { "count": 1, - "name": "Mirrorform" + "name": "Overseer of the Damned" }, { "count": 1, - "name": "Monstrous Rage" + "name": "Piru, the Volatile" }, { "count": 1, - "name": "Moonmist" + "name": "Resolute Archangel" }, { "count": 1, - "name": "Negate" + "name": "Reya Dawnbringer" }, { "count": 1, - "name": "Ooze Spill" + "name": "Sephara, Sky's Blade" }, { "count": 1, - "name": "Ram Through" + "name": "Serra's Guardian" }, { "count": 1, - "name": "Royal Treatment" + "name": "Steel Hellkite" }, { "count": 1, - "name": "Snakeskin Veil" + "name": "Terror of Mount Velus" }, { "count": 1, - "name": "Team Tactics" + "name": "Twilight Shepherd" }, { "count": 1, - "name": "Unexpected Windfall" + "name": "Victory's Herald" }, { "count": 1, - "name": "Withstand Death" + "name": "Ambition's Cost" }, { "count": 1, - "name": "You Look Upon the Tarrasque" + "name": "Boros Charm" }, { "count": 1, - "name": "Avengers Disassembled" + "name": "Crackling Doom" }, { "count": 1, - "name": "Hulk's Thunderclap" + "name": "Despark" }, { "count": 1, - "name": "Multiversal Incursion" + "name": "Fake Your Own Death" }, { "count": 1, - "name": "Rampant Growth" + "name": "Fracture" }, { "count": 1, - "name": "Three Visits" + "name": "Mortify" }, { "count": 1, - "name": "Arc Reactor" + "name": "Return to Dust" }, { "count": 1, - "name": "Arcane Signet" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Caltrops" + "name": "Terminate" }, { "count": 1, - "name": "Captain America's Shield" + "name": "Akroma's Vengeance" }, { "count": 1, - "name": "Daily Bugle Newspaper" + "name": "Diabolic Tutor" }, { "count": 1, - "name": "Emerald Medallion" + "name": "Earthquake" + }, + { + "count": 1, + "name": "Painful Truths" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Read the Bones" }, { "count": 1, - "name": "Mjolnir, Hammer of Thor" + "name": "Sign in Blood" }, { "count": 1, - "name": "Ruby Medallion" + "name": "Unburial Rites" }, { "count": 1, - "name": "Sapphire Medallion" + "name": "Arcane Signet" }, { "count": 1, - "name": "Smoke Bomb" + "name": "Boros Signet" }, { "count": 1, - "name": "Sol Ring" + "name": "Commander's Sphere" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Orzhov Signet" }, { "count": 1, - "name": "The Ooze" + "name": "Rakdos Signet" }, { "count": 1, - "name": "The Ten Rings" + "name": "Savai Crystal" }, { "count": 1, - "name": "Thought Vessel" + "name": "Sol Ring" }, { "count": 1, - "name": "Garruk's Uprising" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Hardened Scales" + "name": "Talisman of Conviction" }, { "count": 1, - "name": "Impostor Syndrome" + "name": "Talisman of Hierarchy" }, { "count": 1, - "name": "Lure" + "name": "Whispersilk Cloak" }, { "count": 1, - "name": "Origin of the Hulk" + "name": "Firja's Retribution" }, { "count": 1, - "name": "Snake Umbra" + "name": "Kaya's Ghostform" }, { "count": 1, - "name": "Wild Growth" + "name": "Liliana's Contract" + }, + { + "count": 1, + "name": "Rampage of the Valkyries" + }, + { + "count": 1, + "name": "Akoum Refuge" }, { "count": 1, - "name": "World War Hulk" + "name": "Bloodfell Caves" }, { "count": 1, - "name": "Buried Ruin" + "name": "Boros Garrison" }, { "count": 1, @@ -3297,32 +3173,32 @@ "name": "Exotic Orchard" }, { - "count": 9, - "name": "Forest" + "count": 1, + "name": "Nomad Outpost" }, { "count": 1, - "name": "Frontier Bivouac" + "name": "Orzhov Basilica" }, { - "count": 8, - "name": "Island" + "count": 1, + "name": "Rakdos Carnarium" }, { "count": 1, - "name": "Karn's Bastion" + "name": "Rogue's Passage" }, { "count": 1, - "name": "Kessig Wolf Run" + "name": "Scoured Barrens" }, { - "count": 9, - "name": "Mountain" + "count": 1, + "name": "Temple of Silence" }, { "count": 1, - "name": "Myriad Landscape" + "name": "Temple of Triumph" }, { "count": 1, @@ -3330,17 +3206,33 @@ }, { "count": 1, - "name": "Bruce Banner" + "name": "Wind-Scarred Crag" + }, + { + "count": 8, + "name": "Plains" + }, + { + "count": 7, + "name": "Swamp" + }, + { + "count": 4, + "name": "Mountain" + }, + { + "count": 1, + "name": "Kaalia of the Vast" } ], "sideboard": [] }, { - "name": "Toxrill, the Corrosive", + "name": "Winota, Joiner of Forces", "author": "MTGGoldfish", "colors": [ - "U", - "B" + "R", + "W" ], "tags": [ "metagame" @@ -3348,31 +3240,59 @@ "main": [ { "count": 1, - "name": "Narset, Parter of Veils" + "name": "Ancient Tomb" }, { "count": 1, - "name": "Kormus Bell" + "name": "Angrath's Marauders" }, { "count": 1, - "name": "Bloodchief Ascension" + "name": "Archivist of Oghma" }, { "count": 1, - "name": "Mindcrank" + "name": "Archon of Emeria" }, { "count": 1, - "name": "Thassa's Oracle" + "name": "Arid Mesa" }, { "count": 1, - "name": "Demonic Consultation" + "name": "Aven Interrupter" }, { "count": 1, - "name": "Sol Ring" + "name": "Aven Mindcensor" + }, + { + "count": 1, + "name": "Battlefield Forge" + }, + { + "count": 1, + "name": "Blade Historian" + }, + { + "count": 1, + "name": "Bloodstained Mire" + }, + { + "count": 1, + "name": "Boromir, Warden of the Tower" + }, + { + "count": 1, + "name": "Cathar Commando" + }, + { + "count": 1, + "name": "Cavern of Souls" + }, + { + "count": 1, + "name": "Charismatic Conqueror" }, { "count": 1, @@ -3380,295 +3300,319 @@ }, { "count": 1, - "name": "Mana Vault" + "name": "City of Brass" }, { "count": 1, - "name": "The Soul Stone" + "name": "City of Traitors" }, { "count": 1, - "name": "Arcane Signet" + "name": "Clarion Conqueror" }, { "count": 1, - "name": "Dimir Signet" + "name": "Combat Celebrant" }, { "count": 1, - "name": "Talisman of Dominance" + "name": "Command Tower" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Deafening Silence" }, { "count": 1, - "name": "Liquimetal Torque" + "name": "Deflecting Swat" }, { "count": 1, - "name": "Prismatic Lens" + "name": "Drannith Magistrate" }, { "count": 1, - "name": "Dark Ritual" + "name": "Eidolon of Rhetoric" }, { "count": 1, - "name": "Cabal Ritual" + "name": "Eiganjo, Seat of the Empire" }, { "count": 1, - "name": "Force of Will" + "name": "Enlightened Tutor" }, { "count": 1, - "name": "Mana Drain" + "name": "Esper Sentinel" }, { "count": 1, - "name": "Muddle the Mixture" + "name": "Ethersworn Canonist" }, { "count": 1, - "name": "Rewind" + "name": "Flooded Strand" }, { "count": 1, - "name": "Imp's Mischief" + "name": "Gemstone Caverns" }, { "count": 1, - "name": "An Offer You Can't Refuse" + "name": "Gingerbrute" }, { "count": 1, - "name": "Swan Song" + "name": "Giver of Runes" }, { "count": 1, - "name": "Stubborn Denial" + "name": "Goblin Rabblemaster" }, { "count": 1, - "name": "Pact of Negation" + "name": "Grand Abolisher" }, { "count": 1, - "name": "Slip Out the Back" + "name": "Greymond, Avacyn's Stalwart" }, { "count": 1, - "name": "Fierce Guardianship" + "name": "Hexing Squelcher" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Hope of Ghirapur" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Howlsquad Heavy" }, { "count": 1, - "name": "Imperial Seal" + "name": "Imperial Recruiter" }, { "count": 1, - "name": "Mystical Tutor" + "name": "Lion's Eye Diamond" }, { "count": 1, - "name": "Demonic Tutor" + "name": "Lotus Petal" }, { "count": 1, - "name": "Grim Tutor" + "name": "Loyal Apprentice" }, { "count": 1, - "name": "Fabricate" + "name": "Magus of the Moon" }, { "count": 1, - "name": "Whir of Invention" + "name": "Mana Confluence" }, { "count": 1, - "name": "Expedition Map" + "name": "Mana Vault" }, { "count": 1, - "name": "Tolaria West" + "name": "Marsh Flats" }, { "count": 1, - "name": "Lim-Dul's Vault" + "name": "Memnite" + }, + { + "count": 3, + "name": "Mountain" }, { "count": 1, - "name": "Maha, Its Feathers Night" + "name": "Mox Diamond" }, { "count": 1, - "name": "Sheoldred, the Apocalypse" + "name": "Mox Opal" }, { "count": 1, - "name": "Notion Thief" + "name": "Myrel, Shield of Argive" }, { "count": 1, - "name": "Orcish Bowmasters" + "name": "Needleverge Pathway" }, { "count": 1, - "name": "Opposition Agent" + "name": "Orim's Chant" }, { "count": 1, - "name": "Tinybones, the Pickpocket" + "name": "Ornithopter" }, { "count": 1, - "name": "Sudden Spoiling" + "name": "Path to Exile" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Phelia, Exuberant Shepherd" }, { "count": 1, - "name": "Damnation" + "name": "Phyrexian Censor" }, { "count": 1, - "name": "Feed the Swarm" + "name": "Phyrexian Revoker" }, { "count": 1, - "name": "Infernal Grasp" + "name": "Phyrexian Walker" + }, + { + "count": 5, + "name": "Plains" }, { "count": 1, - "name": "Bitter Triumph" + "name": "Plateau" }, { "count": 1, - "name": "Cyclonic Rift" + "name": "Professional Face-Breaker" }, { "count": 1, - "name": "Rhystic Study" + "name": "Pyroblast" }, { "count": 1, - "name": "Mystic Remora" + "name": "Quicksilver, Brash Blur" }, { "count": 1, - "name": "Propaganda" + "name": "Ragavan, Nimble Pilferer" }, { "count": 1, - "name": "Faerie Mastermind" + "name": "Ranger-Captain of Eos" }, { "count": 1, - "name": "Brainstorm" + "name": "Raph & Leo, Sibling Rivals" }, { "count": 1, - "name": "Ponder" + "name": "Rionya, Fire Dancer" }, { "count": 1, - "name": "Ad Nauseam" + "name": "Sacred Foundry" }, { "count": 1, - "name": "Windfall" + "name": "Sanctum Prelate" + }, + { + "count": 1, + "name": "Scalding Tarn" }, { "count": 1, - "name": "Frantic Search" + "name": "Signal Pest" }, { "count": 1, - "name": "Urborg, Tomb of Yawgmoth" + "name": "Silence" }, { "count": 1, - "name": "Ancient Tomb" + "name": "Simian Spirit Guide" }, { "count": 1, - "name": "Hidden Lair" + "name": "Skrelv, Defector Mite" }, { "count": 1, - "name": "Command Tower" + "name": "Slicer, Hired Muscle" }, { "count": 1, - "name": "Watery Grave" + "name": "Sokenzan, Crucible of Defiance" }, { "count": 1, - "name": "Shipwreck Marsh" + "name": "Sol Ring" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Solitude" }, { "count": 1, - "name": "Morphic Pool" + "name": "Soulless Jailer" }, { "count": 1, - "name": "Sunken Hollow" + "name": "Spectator Seating" }, { "count": 1, - "name": "Underground River" + "name": "Spirit of the Labyrinth" }, { "count": 1, - "name": "Polluted Delta" + "name": "Sunbaked Canyon" }, { "count": 1, - "name": "Clearwater Pathway" + "name": "Sundering Eruption" }, { "count": 1, - "name": "Tainted Isle" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Sunken Ruins" + "name": "Tataru Taru" }, { "count": 1, - "name": "Bojuka Bog" + "name": "Thalia, Guardian of Thraben" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Thalia, Heretic Cathar" }, { - "count": 10, - "name": "Swamp" + "count": 1, + "name": "Vexing Bauble" }, { - "count": 11, - "name": "Island" + "count": 1, + "name": "Windswept Heath" + }, + { + "count": 1, + "name": "Witch Enchanter" + }, + { + "count": 1, + "name": "Wooded Foothills" + }, + { + "count": 1, + "name": "Yuffie, Materia Hunter" }, { "count": 1, - "name": "Toxrill, the Corrosive [VOW]" + "name": "Winota, Joiner of Forces" } ], "sideboard": [] diff --git a/client/public/feeds/mtggoldfish-modern.json b/client/public/feeds/mtggoldfish-modern.json index d195ef7c4e..0053ea6dac 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-07-23T00:00:00Z", + "updated": "2026-07-24T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/modern", "decks": [ { @@ -151,137 +151,6 @@ } ] }, - { - "name": "Affinity", - "author": "MTGGoldfish", - "colors": [ - "U", - "R" - ], - "tags": [ - "metagame" - ], - "main": [ - { - "count": 1, - "name": "Arcbound Ravager" - }, - { - "count": 4, - "name": "Claws of Gix" - }, - { - "count": 2, - "name": "Disruptor Flute" - }, - { - "count": 4, - "name": "Engineered Explosives" - }, - { - "count": 4, - "name": "Experimental Synthesizer" - }, - { - "count": 4, - "name": "Fiery Islet" - }, - { - "count": 1, - "name": "Island" - }, - { - "count": 4, - "name": "Kappa Cannoneer" - }, - { - "count": 1, - "name": "Memnite" - }, - { - "count": 4, - "name": "Mishra's Bauble" - }, - { - "count": 4, - "name": "Mox Opal" - }, - { - "count": 4, - "name": "Pinnacle Emissary" - }, - { - "count": 1, - "name": "Shadowspear" - }, - { - "count": 2, - "name": "Shivan Reef" - }, - { - "count": 1, - "name": "Sink into Stupor" - }, - { - "count": 1, - "name": "Skateboard" - }, - { - "count": 4, - "name": "Spirebluff Canal" - }, - { - "count": 1, - "name": "Steam Vents" - }, - { - "count": 4, - "name": "Tormod's Crypt" - }, - { - "count": 4, - "name": "Urza's Saga" - }, - { - "count": 4, - "name": "Weapons Manufacturing" - }, - { - "count": 1, - "name": "Welding Jar" - } - ], - "sideboard": [ - { - "count": 2, - "name": "Blood Moon" - }, - { - "count": 3, - "name": "Consign to Memory" - }, - { - "count": 1, - "name": "Damping Sphere" - }, - { - "count": 2, - "name": "Emry, Lurker of the Loch" - }, - { - "count": 3, - "name": "Galvanic Blast" - }, - { - "count": 2, - "name": "Metallic Rebuke" - }, - { - "count": 2, - "name": "Mystical Dispute" - } - ] - }, { "name": "Goryo's Vengeance", "author": "MTGGoldfish", @@ -436,11 +305,11 @@ ] }, { - "name": "Ruby Storm", + "name": "Affinity", "author": "MTGGoldfish", "colors": [ - "R", - "W" + "U", + "R" ], "tags": [ "metagame" @@ -448,141 +317,121 @@ "main": [ { "count": 1, - "name": "Sacred Foundry" + "name": "Arcbound Ravager" }, { - "count": 2, - "name": "Artist's Talent" + "count": 4, + "name": "Claws of Gix" }, { - "count": 4, - "name": "Ruby Medallion" + "count": 2, + "name": "Disruptor Flute" }, { "count": 4, - "name": "Ral, Monsoon Mage" + "name": "Engineered Explosives" }, { - "count": 1, - "name": "Grapeshot" + "count": 4, + "name": "Experimental Synthesizer" }, { "count": 4, - "name": "Mountain" + "name": "Fiery Islet" }, { "count": 1, - "name": "Flashback" - }, - { - "count": 4, - "name": "Manamorphose" + "name": "Island" }, { "count": 4, - "name": "Pyretic Ritual" - }, - { - "count": 1, - "name": "Gemstone Caverns" + "name": "Kappa Cannoneer" }, { "count": 1, - "name": "Elegant Parlor" + "name": "Memnite" }, { "count": 4, - "name": "Desperate Ritual" + "name": "Mishra's Bauble" }, { "count": 4, - "name": "Reckless Impulse" + "name": "Mox Opal" }, { - "count": 3, - "name": "Past in Flames" + "count": 4, + "name": "Pinnacle Emissary" }, { "count": 1, - "name": "Commercial District" + "name": "Shadowspear" }, { - "count": 3, - "name": "Bloodstained Mire" + "count": 2, + "name": "Shivan Reef" }, { "count": 1, - "name": "Sunbaked Canyon" + "name": "Sink into Stupor" }, { - "count": 2, - "name": "Valakut Awakening" + "count": 1, + "name": "Skateboard" }, { - "count": 2, - "name": "Wish" + "count": 4, + "name": "Spirebluff Canal" }, { - "count": 2, - "name": "Arid Mesa" + "count": 1, + "name": "Steam Vents" }, { "count": 4, - "name": "Wrenn's Resolve" + "name": "Tormod's Crypt" }, { - "count": 2, - "name": "Scalding Tarn" + "count": 4, + "name": "Urza's Saga" }, { - "count": 2, - "name": "Wooded Foothills" + "count": 4, + "name": "Weapons Manufacturing" }, { - "count": 3, - "name": "Hex Magic" + "count": 1, + "name": "Welding Jar" } ], "sideboard": [ { - "count": 3, - "name": "Prismatic Ending" - }, - { - "count": 1, - "name": "Wear // Tear" - }, - { - "count": 4, - "name": "Orim's Chant" - }, - { - "count": 1, - "name": "Past in Flames" + "count": 2, + "name": "Blood Moon" }, { - "count": 1, - "name": "Grapeshot" + "count": 3, + "name": "Consign to Memory" }, { "count": 1, - "name": "Empty the Warrens" + "name": "Damping Sphere" }, { - "count": 1, - "name": "Brotherhood's End" + "count": 2, + "name": "Emry, Lurker of the Loch" }, { - "count": 1, - "name": "Abrade" + "count": 3, + "name": "Galvanic Blast" }, { - "count": 1, - "name": "Stormscale Scion" + "count": 2, + "name": "Metallic Rebuke" }, { - "count": 1, - "name": "Flashback" + "count": 2, + "name": "Mystical Dispute" } ] }, @@ -736,6 +585,157 @@ } ] }, + { + "name": "Ruby Storm", + "author": "MTGGoldfish", + "colors": [ + "R", + "W" + ], + "tags": [ + "metagame" + ], + "main": [ + { + "count": 1, + "name": "Sacred Foundry" + }, + { + "count": 2, + "name": "Artist's Talent" + }, + { + "count": 4, + "name": "Ruby Medallion" + }, + { + "count": 4, + "name": "Ral, Monsoon Mage" + }, + { + "count": 1, + "name": "Grapeshot" + }, + { + "count": 4, + "name": "Mountain" + }, + { + "count": 1, + "name": "Flashback" + }, + { + "count": 4, + "name": "Manamorphose" + }, + { + "count": 4, + "name": "Pyretic Ritual" + }, + { + "count": 1, + "name": "Gemstone Caverns" + }, + { + "count": 1, + "name": "Elegant Parlor" + }, + { + "count": 4, + "name": "Desperate Ritual" + }, + { + "count": 4, + "name": "Reckless Impulse" + }, + { + "count": 3, + "name": "Past in Flames" + }, + { + "count": 1, + "name": "Commercial District" + }, + { + "count": 3, + "name": "Bloodstained Mire" + }, + { + "count": 1, + "name": "Sunbaked Canyon" + }, + { + "count": 2, + "name": "Valakut Awakening" + }, + { + "count": 2, + "name": "Wish" + }, + { + "count": 2, + "name": "Arid Mesa" + }, + { + "count": 4, + "name": "Wrenn's Resolve" + }, + { + "count": 2, + "name": "Scalding Tarn" + }, + { + "count": 2, + "name": "Wooded Foothills" + }, + { + "count": 3, + "name": "Hex Magic" + } + ], + "sideboard": [ + { + "count": 3, + "name": "Prismatic Ending" + }, + { + "count": 1, + "name": "Wear // Tear" + }, + { + "count": 4, + "name": "Orim's Chant" + }, + { + "count": 1, + "name": "Past in Flames" + }, + { + "count": 1, + "name": "Grapeshot" + }, + { + "count": 1, + "name": "Empty the Warrens" + }, + { + "count": 1, + "name": "Brotherhood's End" + }, + { + "count": 1, + "name": "Abrade" + }, + { + "count": 1, + "name": "Stormscale Scion" + }, + { + "count": 1, + "name": "Flashback" + } + ] + }, { "name": "Izzet Prowess", "author": "MTGGoldfish", diff --git a/client/public/feeds/mtggoldfish-pioneer.json b/client/public/feeds/mtggoldfish-pioneer.json index 2f4250b7b8..4d2548c9bc 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-07-23T00:00:00Z", + "updated": "2026-07-24T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/pioneer", "decks": [ { @@ -131,308 +131,308 @@ ] }, { - "name": "Golgari Midrange", + "name": "Izzet Prowess", "author": "MTGGoldfish", "colors": [ - "G", - "B" + "U", + "R" ], "tags": [ "metagame" ], "main": [ - { - "count": 1, - "name": "Sentinel of the Nameless City" - }, - { - "count": 1, - "name": "Emeritus of Abundance" - }, { "count": 4, - "name": "Llanowar Wastes" + "name": "Academic Dispute" }, { - "count": 1, - "name": "Elegy Acolyte" + "count": 3, + "name": "Boomerang Basics" }, { "count": 1, - "name": "Wastewood Verge" + "name": "Island" }, { "count": 4, - "name": "Unholy Annex // Ritual Chamber" + "name": "Monastery Swiftspear" }, { "count": 4, - "name": "Thoughtseize" - }, - { - "count": 1, - "name": "Restless Cottage" - }, - { - "count": 1, - "name": "Tear Asunder" - }, - { - "count": 2, - "name": "Sheoldred, the Apocalypse" - }, - { - "count": 1, - "name": "Takenuma, Abandoned Mire" - }, - { - "count": 1, - "name": "Boseiju, Who Endures" - }, - { - "count": 1, - "name": "Culling Ritual" - }, - { - "count": 1, - "name": "Forest" + "name": "Flow State" }, { - "count": 1, - "name": "Overgrown Tomb" + "count": 4, + "name": "Sleight of Hand" }, { - "count": 1, - "name": "Urborg, Tomb of Yawgmoth" + "count": 3, + "name": "Emberheart Challenger" }, { "count": 3, - "name": "Mutavault" + "name": "Reckless Rage" }, { - "count": 1, - "name": "Mutavault" + "count": 2, + "name": "Riverglide Pathway" }, { - "count": 3, - "name": "Abrupt Decay" + "count": 4, + "name": "Riverpyre Verge" }, { - "count": 1, - "name": "Go for the Throat" + "count": 3, + "name": "Burst Lightning" }, { "count": 4, - "name": "Blooming Marsh" + "name": "Soul-Scar Mage" }, { "count": 4, - "name": "Fatal Push" + "name": "Spirebluff Canal" }, { - "count": 3, - "name": "Graveyard Trespasser" + "count": 4, + "name": "Steam Vents" }, { - "count": 3, - "name": "Swamp" + "count": 4, + "name": "Stormchaser's Talent" }, { "count": 3, - "name": "Professor Dellian Fel" - }, - { - "count": 1, - "name": "Sentinel of the Nameless City" + "name": "Shivan Reef" }, { - "count": 3, - "name": "Overgrown Tomb" + "count": 2, + "name": "Screaming Nemesis" }, { - "count": 1, - "name": "Culling Ritual" + "count": 2, + "name": "Mountain" }, { - "count": 4, - "name": "Badgermole Cub" + "count": 2, + "name": "Monstrous Rage" } ], "sideboard": [ { "count": 2, - "name": "Go Blank" + "name": "Spell Pierce" }, { "count": 1, - "name": "Culling Ritual" + "name": "Boomerang Basics" }, { - "count": 2, - "name": "Ray of Enfeeblement" + "count": 1, + "name": "Accumulate Wisdom" }, { - "count": 2, - "name": "Invoke Despair" + "count": 1, + "name": "Firebending Lesson" }, { - "count": 2, - "name": "Unlicensed Hearse" + "count": 1, + "name": "Ghost Vacuum" }, { "count": 1, - "name": "Sheoldred's Edict" + "name": "Iroh's Demonstration" }, { "count": 1, - "name": "Necromentia" + "name": "It'll Quench Ya!" }, { "count": 1, - "name": "End of the Hunt" + "name": "Octopus Form" }, { "count": 2, - "name": "Duress" + "name": "Redcap Melee" }, { - "count": 1, - "name": "Withering Curse" + "count": 2, + "name": "Scorching Shot" + }, + { + "count": 2, + "name": "Quantum Riddler" } ] }, { - "name": "Izzet Prowess", + "name": "Golgari Midrange", "author": "MTGGoldfish", "colors": [ - "U", - "R" + "G", + "B" ], "tags": [ "metagame" ], "main": [ { - "count": 4, - "name": "Academic Dispute" + "count": 1, + "name": "Sentinel of the Nameless City" }, { - "count": 3, - "name": "Boomerang Basics" + "count": 1, + "name": "Emeritus of Abundance" + }, + { + "count": 4, + "name": "Llanowar Wastes" }, { "count": 1, - "name": "Island" + "name": "Elegy Acolyte" }, { - "count": 4, - "name": "Monastery Swiftspear" + "count": 1, + "name": "Wastewood Verge" }, { "count": 4, - "name": "Flow State" + "name": "Unholy Annex // Ritual Chamber" }, { "count": 4, - "name": "Sleight of Hand" + "name": "Thoughtseize" }, { - "count": 3, - "name": "Emberheart Challenger" + "count": 1, + "name": "Restless Cottage" }, { - "count": 3, - "name": "Reckless Rage" + "count": 1, + "name": "Tear Asunder" }, { "count": 2, - "name": "Riverglide Pathway" + "name": "Sheoldred, the Apocalypse" }, { - "count": 4, - "name": "Riverpyre Verge" + "count": 1, + "name": "Takenuma, Abandoned Mire" + }, + { + "count": 1, + "name": "Boseiju, Who Endures" + }, + { + "count": 1, + "name": "Culling Ritual" + }, + { + "count": 1, + "name": "Forest" + }, + { + "count": 1, + "name": "Overgrown Tomb" + }, + { + "count": 1, + "name": "Urborg, Tomb of Yawgmoth" }, { "count": 3, - "name": "Burst Lightning" + "name": "Mutavault" }, { - "count": 4, - "name": "Soul-Scar Mage" + "count": 1, + "name": "Mutavault" }, { - "count": 4, - "name": "Spirebluff Canal" + "count": 3, + "name": "Abrupt Decay" + }, + { + "count": 1, + "name": "Go for the Throat" }, { "count": 4, - "name": "Steam Vents" + "name": "Blooming Marsh" }, { "count": 4, - "name": "Stormchaser's Talent" + "name": "Fatal Push" }, { "count": 3, - "name": "Shivan Reef" + "name": "Graveyard Trespasser" }, { - "count": 2, - "name": "Screaming Nemesis" + "count": 3, + "name": "Swamp" }, { - "count": 2, - "name": "Mountain" + "count": 3, + "name": "Professor Dellian Fel" }, { - "count": 2, - "name": "Monstrous Rage" + "count": 1, + "name": "Sentinel of the Nameless City" + }, + { + "count": 3, + "name": "Overgrown Tomb" + }, + { + "count": 1, + "name": "Culling Ritual" + }, + { + "count": 4, + "name": "Badgermole Cub" } ], "sideboard": [ { "count": 2, - "name": "Spell Pierce" - }, - { - "count": 1, - "name": "Boomerang Basics" + "name": "Go Blank" }, { "count": 1, - "name": "Accumulate Wisdom" + "name": "Culling Ritual" }, { - "count": 1, - "name": "Firebending Lesson" + "count": 2, + "name": "Ray of Enfeeblement" }, { - "count": 1, - "name": "Ghost Vacuum" + "count": 2, + "name": "Invoke Despair" }, { - "count": 1, - "name": "Iroh's Demonstration" + "count": 2, + "name": "Unlicensed Hearse" }, { "count": 1, - "name": "It'll Quench Ya!" + "name": "Sheoldred's Edict" }, { "count": 1, - "name": "Octopus Form" + "name": "Necromentia" }, { - "count": 2, - "name": "Redcap Melee" + "count": 1, + "name": "End of the Hunt" }, { "count": 2, - "name": "Scorching Shot" + "name": "Duress" }, { - "count": 2, - "name": "Quantum Riddler" + "count": 1, + "name": "Withering Curse" } ] }, @@ -728,324 +728,324 @@ ] }, { - "name": "Azorius Control", + "name": "Orzhov Greasefang", "author": "MTGGoldfish", "colors": [ - "W", - "U" + "B", + "W" ], "tags": [ "metagame" ], "main": [ - { - "count": 1, - "name": "Plains" - }, { "count": 2, - "name": "Petrified Hamlet" + "name": "Bleachbone Verge" }, { "count": 2, - "name": "Emeritus of Ideation" + "name": "Brightclimb Pathway" }, { "count": 1, - "name": "Erode" - }, - { - "count": 3, - "name": "Beza, the Bounding Spring" + "name": "Eiganjo, Seat of the Empire" }, { - "count": 1, - "name": "Ultima" + "count": 4, + "name": "Monument to Endurance" }, { "count": 4, - "name": "Floodfarm Verge" + "name": "Fleeting Spirit" }, { "count": 1, - "name": "Island" - }, - { - "count": 4, - "name": "No More Lies" + "name": "Geier Reach Sanitarium" }, { "count": 4, - "name": "Get Lost" + "name": "Greasefang, Okiba Boss" }, { "count": 3, - "name": "Restless Anchorage" + "name": "Guardian of New Benalia" }, { "count": 4, - "name": "Temporary Lockdown" + "name": "Iron-Shield Elf" }, { "count": 1, - "name": "Otawara, Soaring City" + "name": "Lively Dirge" }, { "count": 4, - "name": "The Wandering Emperor" + "name": "The Mycosynth Gardens" }, { "count": 4, - "name": "Hallowed Fountain" - }, - { - "count": 2, - "name": "Supreme Verdict" + "name": "Parhelion II" }, { "count": 1, - "name": "Fountainport" - }, - { - "count": 3, - "name": "Teferi, Hero of Dominaria" - }, - { - "count": 4, - "name": "Dovin's Veto" - }, - { - "count": 3, - "name": "Omen of the Sea" - }, - { - "count": 4, - "name": "Hengegate Pathway" + "name": "Swamp" }, { "count": 1, - "name": "Hall of Storm Giants" + "name": "Plains" }, { - "count": 4, - "name": "Deserted Beach" + "count": 1, + "name": "Takenuma, Abandoned Mire" }, { "count": 4, - "name": "March of Otherworldly Light" + "name": "Concealed Courtyard" }, { - "count": 1, - "name": "Eiganjo, Seat of the Empire" + "count": 2, + "name": "Duress" }, { "count": 1, - "name": "Plains" + "name": "Urborg, Tomb of Yawgmoth" }, { "count": 1, - "name": "Plains" + "name": "Vanishing Verse" }, { - "count": 3, - "name": "Plains" + "count": 4, + "name": "Godless Shrine" }, { "count": 4, - "name": "Consult the Star Charts" + "name": "Thoughtseize" }, { - "count": 1, - "name": "Island" + "count": 2, + "name": "Shadowy Backstreet" }, { - "count": 3, - "name": "Island" + "count": 4, + "name": "Fatal Push" }, { "count": 1, - "name": "Narset, Parter of Veils" + "name": "Sheltered by Ghosts" } ], "sideboard": [ { - "count": 1, - "name": "Narset's Reversal" + "count": 2, + "name": "Soul-Guide Lantern" }, { "count": 3, - "name": "High Noon" - }, - { - "count": 1, - "name": "Yorion, Sky Nomad" + "name": "Doorkeeper Thrull" }, { - "count": 1, - "name": "Farewell" + "count": 2, + "name": "Damping Sphere" }, { "count": 2, - "name": "Change the Equation" + "name": "Vanishing Verse" }, { - "count": 3, - "name": "Kutzil's Flanker" + "count": 2, + "name": "Seam Rip" }, { "count": 1, - "name": "Narset, Parter of Veils" + "name": "Go Blank" }, { "count": 1, - "name": "Beza, the Bounding Spring" + "name": "Duress" }, { "count": 2, - "name": "Rest in Peace" + "name": "Bitter Triumph" } ] }, { - "name": "Orzhov Greasefang", + "name": "Azorius Control", "author": "MTGGoldfish", "colors": [ - "B", - "W" + "W", + "U" ], "tags": [ "metagame" ], "main": [ + { + "count": 1, + "name": "Plains" + }, { "count": 2, - "name": "Bleachbone Verge" + "name": "Petrified Hamlet" }, { "count": 2, - "name": "Brightclimb Pathway" + "name": "Emeritus of Ideation" }, { "count": 1, - "name": "Eiganjo, Seat of the Empire" + "name": "Erode" }, { - "count": 4, - "name": "Monument to Endurance" + "count": 3, + "name": "Beza, the Bounding Spring" + }, + { + "count": 1, + "name": "Ultima" }, { "count": 4, - "name": "Fleeting Spirit" + "name": "Floodfarm Verge" }, { "count": 1, - "name": "Geier Reach Sanitarium" + "name": "Island" }, { "count": 4, - "name": "Greasefang, Okiba Boss" + "name": "No More Lies" + }, + { + "count": 4, + "name": "Get Lost" }, { "count": 3, - "name": "Guardian of New Benalia" + "name": "Restless Anchorage" }, { "count": 4, - "name": "Iron-Shield Elf" + "name": "Temporary Lockdown" }, { "count": 1, - "name": "Lively Dirge" + "name": "Otawara, Soaring City" }, { "count": 4, - "name": "The Mycosynth Gardens" + "name": "The Wandering Emperor" }, { "count": 4, - "name": "Parhelion II" + "name": "Hallowed Fountain" }, { - "count": 1, - "name": "Swamp" + "count": 2, + "name": "Supreme Verdict" }, { "count": 1, - "name": "Plains" + "name": "Fountainport" }, { - "count": 1, - "name": "Takenuma, Abandoned Mire" + "count": 3, + "name": "Teferi, Hero of Dominaria" }, { "count": 4, - "name": "Concealed Courtyard" + "name": "Dovin's Veto" }, { - "count": 2, - "name": "Duress" + "count": 3, + "name": "Omen of the Sea" }, { - "count": 1, - "name": "Urborg, Tomb of Yawgmoth" + "count": 4, + "name": "Hengegate Pathway" }, { "count": 1, - "name": "Vanishing Verse" + "name": "Hall of Storm Giants" }, { "count": 4, - "name": "Godless Shrine" + "name": "Deserted Beach" }, { "count": 4, - "name": "Thoughtseize" + "name": "March of Otherworldly Light" }, { - "count": 2, - "name": "Shadowy Backstreet" + "count": 1, + "name": "Eiganjo, Seat of the Empire" + }, + { + "count": 1, + "name": "Plains" + }, + { + "count": 1, + "name": "Plains" + }, + { + "count": 3, + "name": "Plains" }, { "count": 4, - "name": "Fatal Push" + "name": "Consult the Star Charts" }, { "count": 1, - "name": "Sheltered by Ghosts" + "name": "Island" + }, + { + "count": 3, + "name": "Island" + }, + { + "count": 1, + "name": "Narset, Parter of Veils" } ], "sideboard": [ { - "count": 2, - "name": "Soul-Guide Lantern" + "count": 1, + "name": "Narset's Reversal" }, { "count": 3, - "name": "Doorkeeper Thrull" + "name": "High Noon" }, { - "count": 2, - "name": "Damping Sphere" + "count": 1, + "name": "Yorion, Sky Nomad" }, { - "count": 2, - "name": "Vanishing Verse" + "count": 1, + "name": "Farewell" }, { "count": 2, - "name": "Seam Rip" + "name": "Change the Equation" + }, + { + "count": 3, + "name": "Kutzil's Flanker" }, { "count": 1, - "name": "Go Blank" + "name": "Narset, Parter of Veils" }, { "count": 1, - "name": "Duress" + "name": "Beza, the Bounding Spring" }, { "count": 2, - "name": "Bitter Triumph" + "name": "Rest in Peace" } ] }, diff --git a/client/public/feeds/mtggoldfish-standard.json b/client/public/feeds/mtggoldfish-standard.json index 859778b131..2af6ff52b7 100644 --- a/client/public/feeds/mtggoldfish-standard.json +++ b/client/public/feeds/mtggoldfish-standard.json @@ -5,15 +5,15 @@ "icon": "G", "format": "standard", "version": 1, - "updated": "2026-07-23T00:00:00Z", + "updated": "2026-07-24T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/standard", "decks": [ { "name": "Selesnya Ouroboroid", "author": "MTGGoldfish", "colors": [ - "W", - "G" + "G", + "W" ], "tags": [ "metagame" @@ -25,19 +25,19 @@ }, { "count": 1, - "name": "Ba Sing Se" + "name": "Bushwhack" }, { - "count": 4, - "name": "Badgermole Cub" + "count": 6, + "name": "Forest" }, { "count": 4, - "name": "Brightglass Gearhulk" + "name": "Leatherhead, Swamp Stalker" }, { - "count": 4, - "name": "Temple Garden" + "count": 1, + "name": "Meltstrider's Resolve" }, { "count": 2, @@ -45,27 +45,31 @@ }, { "count": 2, - "name": "Jennifer Walters" + "name": "Ouroboroid" }, { "count": 4, - "name": "Leatherhead, Swamp Stalker" + "name": "Pawpatch Recruit" }, { "count": 4, - "name": "Llanowar Elves" + "name": "Practiced Offense" }, { - "count": 1, - "name": "Meltstrider's Resolve" + "count": 2, + "name": "Seam Rip" }, { - "count": 4, - "name": "Hushwood Verge" + "count": 3, + "name": "Surrak, Elusive Hunter" }, { - "count": 4, - "name": "Pawpatch Recruit" + "count": 3, + "name": "Spider Manifestation" + }, + { + "count": 1, + "name": "Ba Sing Se" }, { "count": 2, @@ -73,41 +77,37 @@ }, { "count": 4, - "name": "Practiced Offense" - }, - { - "count": 2, - "name": "Seam Rip" + "name": "Temple Garden" }, { - "count": 3, - "name": "Spider Manifestation" + "count": 4, + "name": "Hushwood Verge" }, { - "count": 3, - "name": "Surrak, Elusive Hunter" + "count": 2, + "name": "Jennifer Walters" }, { - "count": 6, - "name": "Forest" + "count": 4, + "name": "Llanowar Elves" }, { - "count": 2, - "name": "Ouroboroid" + "count": 4, + "name": "Badgermole Cub" }, { - "count": 1, - "name": "Erode" + "count": 4, + "name": "Brightglass Gearhulk" } ], "sideboard": [ { "count": 1, - "name": "Erode" + "name": "Soul-Guide Lantern" }, { "count": 2, - "name": "Dawn's Truce" + "name": "Erode" }, { "count": 1, @@ -115,35 +115,26 @@ }, { "count": 3, - "name": "Sheltered by Ghosts" + "name": "Dawn's Truce" }, { - "count": 1, - "name": "Soul-Guide Lantern" + "count": 3, + "name": "Sheltered by Ghosts" }, { - "count": 2, - "name": "Origin of Metalbending" + "count": 3, + "name": "Rest in Peace" }, { "count": 2, "name": "Elspeth, Storm Slayer" - }, - { - "count": 2, - "name": "Rest in Peace" - }, - { - "count": 1, - "name": "Seam Rip" } ] }, { - "name": "Jeskai Lessons", + "name": "Izzet Prowess", "author": "MTGGoldfish", "colors": [ - "W", "U", "R" ], @@ -151,73 +142,69 @@ "metagame" ], "main": [ - { - "count": 1, - "name": "Mistrise Village" - }, { "count": 4, - "name": "Gran-Gran" + "name": "Boomerang Basics" }, { "count": 1, - "name": "Three Steps Ahead" + "name": "Bounce Off" }, { - "count": 1, - "name": "Flashback" + "count": 4, + "name": "Burst Lightning" }, { - "count": 4, - "name": "Great Hall of the Biblioplex" + "count": 2, + "name": "Colorstorm Stallion" }, { "count": 3, - "name": "Hallowed Fountain" + "name": "Eddymurk Crab" }, { "count": 4, - "name": "Jeskai Revelation" - }, - { - "count": 2, - "name": "Sundown Pass" + "name": "Flow State" }, { - "count": 4, - "name": "Combustion Technique" + "count": 1, + "name": "Impractical Joke" }, { - "count": 3, + "count": 7, "name": "Island" }, { "count": 2, - "name": "Iroh's Demonstration" + "name": "Multiversal Passage" }, { "count": 4, - "name": "Firebending Lesson" + "name": "Opt" }, { "count": 4, - "name": "Accumulate Wisdom" + "name": "Riverpyre Verge" }, { - "count": 3, - "name": "Stock Up" + "count": 1, + "name": "Roaring Furnace // Steaming Sauna" }, { "count": 4, - "name": "Abandon Attachments" + "name": "Sleight of Hand" + }, + { + "count": 4, + "name": "Slickshot Show-Off" }, { "count": 2, - "name": "It'll Quench Ya!" + "name": "Spell Pierce" }, { - "count": 3, - "name": "Riverpyre Verge" + "count": 4, + "name": "Spirebluff Canal" }, { "count": 4, @@ -225,56 +212,69 @@ }, { "count": 4, - "name": "Tablet of Discovery" + "name": "Stormchaser's Talent" }, { - "count": 3, - "name": "Spirebluff Canal" + "count": 1, + "name": "Vibrant Outburst" } ], "sideboard": [ { "count": 1, - "name": "Disdainful Stroke" + "name": "Annul" + }, + { + "count": 1, + "name": "Broadside Barrage" + }, + { + "count": 1, + "name": "Colorstorm Stallion" + }, + { + "count": 1, + "name": "Eddymurk Crab" }, { "count": 1, "name": "Flashfreeze" }, { - "count": 4, - "name": "Price of Freedom" + "count": 1, + "name": "Get Out" }, { "count": 1, - "name": "Avengers Disassembled" + "name": "Negate" }, { "count": 1, - "name": "Ghost Vacuum" + "name": "Pyroclasm" }, { "count": 2, - "name": "Day of Judgment" + "name": "Ral, Crackling Wit" }, { - "count": 2, - "name": "Spell Pierce" + "count": 1, + "name": "Sear" }, { "count": 2, - "name": "Emeritus of Ideation" + "name": "Slagstorm" }, { - "count": 1, - "name": "Ral, Crackling Wit" + "count": 2, + "name": "Soul-Guide Lantern" } ] }, { - "name": "Izzet Prowess", + "name": "Jeskai Lessons", "author": "MTGGoldfish", "colors": [ + "W", "U", "R" ], @@ -282,69 +282,73 @@ "metagame" ], "main": [ + { + "count": 1, + "name": "Mistrise Village" + }, { "count": 4, - "name": "Boomerang Basics" + "name": "Gran-Gran" }, { "count": 1, - "name": "Bounce Off" + "name": "Three Steps Ahead" }, { - "count": 4, - "name": "Burst Lightning" + "count": 1, + "name": "Flashback" }, { - "count": 2, - "name": "Colorstorm Stallion" + "count": 4, + "name": "Great Hall of the Biblioplex" }, { "count": 3, - "name": "Eddymurk Crab" + "name": "Hallowed Fountain" }, { "count": 4, - "name": "Flow State" + "name": "Jeskai Revelation" }, { - "count": 1, - "name": "Impractical Joke" + "count": 2, + "name": "Sundown Pass" }, { - "count": 7, + "count": 4, + "name": "Combustion Technique" + }, + { + "count": 3, "name": "Island" }, { "count": 2, - "name": "Multiversal Passage" + "name": "Iroh's Demonstration" }, { "count": 4, - "name": "Opt" + "name": "Firebending Lesson" }, { "count": 4, - "name": "Riverpyre Verge" - }, - { - "count": 1, - "name": "Roaring Furnace // Steaming Sauna" + "name": "Accumulate Wisdom" }, { - "count": 4, - "name": "Sleight of Hand" + "count": 3, + "name": "Stock Up" }, { "count": 4, - "name": "Slickshot Show-Off" + "name": "Abandon Attachments" }, { "count": 2, - "name": "Spell Pierce" + "name": "It'll Quench Ya!" }, { - "count": 4, - "name": "Spirebluff Canal" + "count": 3, + "name": "Riverpyre Verge" }, { "count": 4, @@ -352,61 +356,49 @@ }, { "count": 4, - "name": "Stormchaser's Talent" + "name": "Tablet of Discovery" }, { - "count": 1, - "name": "Vibrant Outburst" + "count": 3, + "name": "Spirebluff Canal" } ], "sideboard": [ { "count": 1, - "name": "Annul" - }, - { - "count": 1, - "name": "Broadside Barrage" - }, - { - "count": 1, - "name": "Colorstorm Stallion" - }, - { - "count": 1, - "name": "Eddymurk Crab" + "name": "Disdainful Stroke" }, { "count": 1, "name": "Flashfreeze" }, { - "count": 1, - "name": "Get Out" + "count": 4, + "name": "Price of Freedom" }, { "count": 1, - "name": "Negate" + "name": "Avengers Disassembled" }, { "count": 1, - "name": "Pyroclasm" + "name": "Ghost Vacuum" }, { "count": 2, - "name": "Ral, Crackling Wit" + "name": "Day of Judgment" }, { - "count": 1, - "name": "Sear" + "count": 2, + "name": "Spell Pierce" }, { "count": 2, - "name": "Slagstorm" + "name": "Emeritus of Ideation" }, { - "count": 2, - "name": "Soul-Guide Lantern" + "count": 1, + "name": "Ral, Crackling Wit" } ] }, @@ -612,125 +604,227 @@ ] }, { - "name": "Izzet Spellementals", + "name": "Izzet Spellementals", + "author": "MTGGoldfish", + "colors": [ + "U", + "R" + ], + "tags": [ + "metagame" + ], + "main": [ + { + "count": 7, + "name": "Island" + }, + { + "count": 4, + "name": "Sunderflock" + }, + { + "count": 4, + "name": "Eddymurk Crab" + }, + { + "count": 4, + "name": "Opt" + }, + { + "count": 3, + "name": "Get Out" + }, + { + "count": 1, + "name": "Stormcarved Coast" + }, + { + "count": 3, + "name": "Burst Lightning" + }, + { + "count": 4, + "name": "Traumatic Critique" + }, + { + "count": 2, + "name": "Impractical Joke" + }, + { + "count": 2, + "name": "Spell Snare" + }, + { + "count": 2, + "name": "Spell Pierce" + }, + { + "count": 4, + "name": "Hearth Elemental" + }, + { + "count": 4, + "name": "Riverpyre Verge" + }, + { + "count": 4, + "name": "Prismari Charm" + }, + { + "count": 4, + "name": "Steam Vents" + }, + { + "count": 4, + "name": "Spirebluff Canal" + }, + { + "count": 4, + "name": "Sleight of Hand" + } + ], + "sideboard": [ + { + "count": 1, + "name": "Avengers Disassembled" + }, + { + "count": 1, + "name": "Ghost Vacuum" + }, + { + "count": 2, + "name": "Sear" + }, + { + "count": 1, + "name": "Sunspine Lynx" + }, + { + "count": 2, + "name": "Ashling, Rekindled" + }, + { + "count": 2, + "name": "Annul" + }, + { + "count": 1, + "name": "Soul-Guide Lantern" + }, + { + "count": 2, + "name": "Emeritus of Ideation" + }, + { + "count": 2, + "name": "Flashfreeze" + }, + { + "count": 1, + "name": "Spell Pierce" + } + ] + }, + { + "name": "Mono-Green Landfall", "author": "MTGGoldfish", "colors": [ - "U", - "R" + "G" ], "tags": [ "metagame" ], "main": [ { - "count": 7, - "name": "Island" + "count": 2, + "name": "Lumbering Worldwagon" }, { "count": 4, - "name": "Sunderflock" + "name": "Earthbender Ascension" }, { "count": 4, - "name": "Eddymurk Crab" + "name": "Mightform Harmonizer" }, { "count": 4, - "name": "Opt" - }, - { - "count": 3, - "name": "Get Out" - }, - { - "count": 1, - "name": "Stormcarved Coast" - }, - { - "count": 3, - "name": "Burst Lightning" + "name": "Esper Origins" }, { "count": 4, - "name": "Traumatic Critique" + "name": "Escape Tunnel" }, { - "count": 2, - "name": "Impractical Joke" + "count": 3, + "name": "Meltstrider's Resolve" }, { - "count": 2, - "name": "Spell Snare" + "count": 1, + "name": "Royal Treatment" }, { - "count": 2, - "name": "Spell Pierce" + "count": 4, + "name": "Sazh's Chocobo" }, { "count": 4, - "name": "Hearth Elemental" + "name": "Ba Sing Se" }, { "count": 4, - "name": "Riverpyre Verge" + "name": "Llanowar Elves" }, { "count": 4, - "name": "Prismari Charm" + "name": "Icetill Explorer" }, { "count": 4, - "name": "Steam Vents" + "name": "Badgermole Cub" }, { "count": 4, - "name": "Spirebluff Canal" + "name": "Fabled Passage" }, { - "count": 4, - "name": "Sleight of Hand" + "count": 14, + "name": "Forest" } ], "sideboard": [ { "count": 1, - "name": "Avengers Disassembled" + "name": "Meltstrider's Resolve" }, { "count": 1, - "name": "Ghost Vacuum" - }, - { - "count": 2, - "name": "Sear" + "name": "Surrak, Elusive Hunter" }, { "count": 1, - "name": "Sunspine Lynx" - }, - { - "count": 2, - "name": "Ashling, Rekindled" + "name": "Royal Treatment" }, { - "count": 2, - "name": "Annul" + "count": 1, + "name": "Glorious Decay" }, { - "count": 1, + "count": 3, "name": "Soul-Guide Lantern" }, { - "count": 2, - "name": "Emeritus of Ideation" + "count": 4, + "name": "Mossborn Hydra" }, { "count": 2, - "name": "Flashfreeze" + "name": "Sapling Nursery" }, { - "count": 1, - "name": "Spell Pierce" + "count": 2, + "name": "Eumidian Terrabotanist" } ] }, @@ -869,108 +963,6 @@ } ] }, - { - "name": "Mono-Green Landfall", - "author": "MTGGoldfish", - "colors": [ - "G" - ], - "tags": [ - "metagame" - ], - "main": [ - { - "count": 2, - "name": "Lumbering Worldwagon" - }, - { - "count": 4, - "name": "Earthbender Ascension" - }, - { - "count": 4, - "name": "Mightform Harmonizer" - }, - { - "count": 4, - "name": "Esper Origins" - }, - { - "count": 4, - "name": "Escape Tunnel" - }, - { - "count": 3, - "name": "Meltstrider's Resolve" - }, - { - "count": 1, - "name": "Royal Treatment" - }, - { - "count": 4, - "name": "Sazh's Chocobo" - }, - { - "count": 4, - "name": "Ba Sing Se" - }, - { - "count": 4, - "name": "Llanowar Elves" - }, - { - "count": 4, - "name": "Icetill Explorer" - }, - { - "count": 4, - "name": "Badgermole Cub" - }, - { - "count": 4, - "name": "Fabled Passage" - }, - { - "count": 14, - "name": "Forest" - } - ], - "sideboard": [ - { - "count": 1, - "name": "Meltstrider's Resolve" - }, - { - "count": 1, - "name": "Surrak, Elusive Hunter" - }, - { - "count": 1, - "name": "Royal Treatment" - }, - { - "count": 1, - "name": "Glorious Decay" - }, - { - "count": 3, - "name": "Soul-Guide Lantern" - }, - { - "count": 4, - "name": "Mossborn Hydra" - }, - { - "count": 2, - "name": "Sapling Nursery" - }, - { - "count": 2, - "name": "Eumidian Terrabotanist" - } - ] - }, { "name": "Izzet Lessons", "author": "MTGGoldfish", @@ -1091,8 +1083,8 @@ "author": "MTGGoldfish", "colors": [ "W", - "U", - "G" + "G", + "U" ], "tags": [ "metagame" @@ -1103,7 +1095,7 @@ "name": "Badgermole Cub" }, { - "count": 3, + "count": 4, "name": "Bloom Tender" }, { @@ -1112,19 +1104,15 @@ }, { "count": 4, - "name": "Brightglass Gearhulk" + "name": "Temple Garden" }, { "count": 1, - "name": "Cryogen Relic" - }, - { - "count": 2, - "name": "Floodfarm Verge" + "name": "Plains" }, { "count": 4, - "name": "Hallowed Fountain" + "name": "Nature's Rhythm" }, { "count": 1, @@ -1136,12 +1124,16 @@ }, { "count": 4, - "name": "Nature's Rhythm" + "name": "Hallowed Fountain" }, { - "count": 2, + "count": 3, "name": "Nurturing Pixie" }, + { + "count": 2, + "name": "Breeding Pool" + }, { "count": 3, "name": "Seam Rip" @@ -1150,10 +1142,6 @@ "count": 4, "name": "Sewer-veillance Cam" }, - { - "count": 1, - "name": "Shardmage's Rescue" - }, { "count": 4, "name": "Starting Town" @@ -1162,39 +1150,35 @@ "count": 4, "name": "Stormchaser's Talent" }, - { - "count": 2, - "name": "Breeding Pool" - }, { "count": 1, - "name": "The Jolly Balloon Man" + "name": "Willowrush Verge" }, { - "count": 1, - "name": "Willowrush Verge" + "count": 4, + "name": "Botanical Sanctum" }, { - "count": 1, - "name": "Bloom Tender" + "count": 2, + "name": "Floodfarm Verge" }, { - "count": 4, - "name": "Botanical Sanctum" + "count": 1, + "name": "The Jolly Balloon Man" }, { "count": 4, - "name": "Temple Garden" + "name": "Brightglass Gearhulk" }, { "count": 1, - "name": "Plains" + "name": "Shardmage's Rescue" } ], "sideboard": [ { - "count": 2, - "name": "Aang, Swift Savior" + "count": 1, + "name": "Seam Rip" }, { "count": 2, @@ -1205,8 +1189,8 @@ "name": "Erode" }, { - "count": 1, - "name": "Meltstrider's Resolve" + "count": 3, + "name": "Spell Pierce" }, { "count": 2, @@ -1214,19 +1198,11 @@ }, { "count": 1, - "name": "Seam Rip" - }, - { - "count": 1, - "name": "Shardmage's Rescue" - }, - { - "count": 1, - "name": "Soul-Guide Lantern" + "name": "Dawn's Truce" }, { - "count": 2, - "name": "Spell Pierce" + "count": 3, + "name": "Crystal Barricade" } ] }, diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index 0e90c8f129..d99c41a37c 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.35.0" +version = "0.36.0" dependencies = [ "futures-util", "minisign-verify", diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index d30f2ff60e..20b2718b32 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "phase-tauri" -version = "0.35.0" +version = "0.36.0" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/client/src-tauri/capabilities/default.json b/client/src-tauri/capabilities/default.json index d854191582..a88432ce70 100644 --- a/client/src-tauri/capabilities/default.json +++ b/client/src-tauri/capabilities/default.json @@ -29,6 +29,7 @@ "allow-confirm-legacy-import", "allow-mark-remote-load-ok", "allow-ensure-native-engine", + "allow-native-engine-progress", "allow-stop-native-engine", "allow-connect-native-engine", "allow-native-engine-bridge-send", diff --git a/client/src-tauri/gen/schemas/acl-manifests.json b/client/src-tauri/gen/schemas/acl-manifests.json index 74cff8a7d7..934aef602a 100644 --- a/client/src-tauri/gen/schemas/acl-manifests.json +++ b/client/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-confirm-legacy-import":{"identifier":"allow-confirm-legacy-import","description":"Allows first-party remote content to confirm legacy storage import.","commands":{"allow":["confirm_legacy_import"],"deny":[]}},"allow-connect-native-engine":{"identifier":"allow-connect-native-engine","description":"Allows first-party remote content to open a shell-pinned native-engine bridge.","commands":{"allow":["connect_native_engine"],"deny":[]}},"allow-ensure-native-engine":{"identifier":"allow-ensure-native-engine","description":"Allows first-party remote content to prepare a signed native engine for a typed channel key.","commands":{"allow":["ensure_native_engine"],"deny":[]}},"allow-mark-remote-load-ok":{"identifier":"allow-mark-remote-load-ok","description":"Allows first-party remote content to mark a successful remote load.","commands":{"allow":["mark_remote_load_ok"],"deny":[]}},"allow-native-engine-bridge-close":{"identifier":"allow-native-engine-bridge-close","description":"Allows first-party remote content to close its native-engine bridge.","commands":{"allow":["native_engine_bridge_close"],"deny":[]}},"allow-native-engine-bridge-send":{"identifier":"allow-native-engine-bridge-send","description":"Allows first-party remote content to send JSON text through its native-engine bridge.","commands":{"allow":["native_engine_bridge_send"],"deny":[]}},"allow-set-channel-preference":{"identifier":"allow-set-channel-preference","description":"Allows first-party remote content to persist its selected channel.","commands":{"allow":["set_channel_preference"],"deny":[]}},"allow-stash-legacy-storage":{"identifier":"allow-stash-legacy-storage","description":"Allows the bundled bootstrap page to stage legacy storage.","commands":{"allow":["stash_legacy_storage"],"deny":[]}},"allow-stop-native-engine":{"identifier":"allow-stop-native-engine","description":"Allows first-party remote content to stop the shell-owned native engine.","commands":{"allow":["stop_native_engine"],"deny":[]}},"allow-take-legacy-storage":{"identifier":"allow-take-legacy-storage","description":"Allows first-party remote content to read staged legacy storage.","commands":{"allow":["take_legacy_storage"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"process":{"default_permission":{"identifier":"default","description":"This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n","permissions":["allow-exit","allow-restart"]},"permissions":{"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-restart":{"identifier":"allow-restart","description":"Enables the restart command without any pre-configured scope.","commands":{"allow":["restart"],"deny":[]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-restart":{"identifier":"deny-restart","description":"Denies the restart command without any pre-configured scope.","commands":{"allow":[],"deny":["restart"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-confirm-legacy-import":{"identifier":"allow-confirm-legacy-import","description":"Allows first-party remote content to confirm legacy storage import.","commands":{"allow":["confirm_legacy_import"],"deny":[]}},"allow-connect-native-engine":{"identifier":"allow-connect-native-engine","description":"Allows first-party remote content to open a shell-pinned native-engine bridge.","commands":{"allow":["connect_native_engine"],"deny":[]}},"allow-ensure-native-engine":{"identifier":"allow-ensure-native-engine","description":"Allows first-party remote content to prepare a signed native engine for a typed channel key.","commands":{"allow":["ensure_native_engine"],"deny":[]}},"allow-mark-remote-load-ok":{"identifier":"allow-mark-remote-load-ok","description":"Allows first-party remote content to mark a successful remote load.","commands":{"allow":["mark_remote_load_ok"],"deny":[]}},"allow-native-engine-bridge-close":{"identifier":"allow-native-engine-bridge-close","description":"Allows first-party remote content to close its native-engine bridge.","commands":{"allow":["native_engine_bridge_close"],"deny":[]}},"allow-native-engine-bridge-send":{"identifier":"allow-native-engine-bridge-send","description":"Allows first-party remote content to send JSON text through its native-engine bridge.","commands":{"allow":["native_engine_bridge_send"],"deny":[]}},"allow-native-engine-progress":{"identifier":"allow-native-engine-progress","description":"Allows first-party remote content to read the latest native-engine provisioning progress.","commands":{"allow":["native_engine_progress"],"deny":[]}},"allow-set-channel-preference":{"identifier":"allow-set-channel-preference","description":"Allows first-party remote content to persist its selected channel.","commands":{"allow":["set_channel_preference"],"deny":[]}},"allow-stash-legacy-storage":{"identifier":"allow-stash-legacy-storage","description":"Allows the bundled bootstrap page to stage legacy storage.","commands":{"allow":["stash_legacy_storage"],"deny":[]}},"allow-stop-native-engine":{"identifier":"allow-stop-native-engine","description":"Allows first-party remote content to stop the shell-owned native engine.","commands":{"allow":["stop_native_engine"],"deny":[]}},"allow-take-legacy-storage":{"identifier":"allow-take-legacy-storage","description":"Allows first-party remote content to read staged legacy storage.","commands":{"allow":["take_legacy_storage"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"process":{"default_permission":{"identifier":"default","description":"This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n","permissions":["allow-exit","allow-restart"]},"permissions":{"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-restart":{"identifier":"allow-restart","description":"Enables the restart command without any pre-configured scope.","commands":{"allow":["restart"],"deny":[]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-restart":{"identifier":"deny-restart","description":"Denies the restart command without any pre-configured scope.","commands":{"allow":[],"deny":["restart"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/client/src-tauri/gen/schemas/capabilities.json b/client/src-tauri/gen/schemas/capabilities.json index 170ae6f365..8013bdd489 100644 --- a/client/src-tauri/gen/schemas/capabilities.json +++ b/client/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Local permissions for the bundled bootstrap page","local":true,"windows":["main"],"permissions":["core:default","allow-stash-legacy-storage"]},"remote-shell":{"identifier":"remote-shell","description":"Remote permissions for the first-party thin-shell content","remote":{"urls":["https://phase-rs.dev/*","https://preview.phase-rs.dev/*"]},"local":false,"windows":["main"],"permissions":["core:default","shell:allow-open","process:allow-exit","process:allow-restart","updater:default","allow-set-channel-preference","allow-take-legacy-storage","allow-confirm-legacy-import","allow-mark-remote-load-ok","allow-ensure-native-engine","allow-stop-native-engine","allow-connect-native-engine","allow-native-engine-bridge-send","allow-native-engine-bridge-close"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Local permissions for the bundled bootstrap page","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-set-fullscreen","allow-stash-legacy-storage"]},"remote-shell":{"identifier":"remote-shell","description":"Remote permissions for the first-party thin-shell content","remote":{"urls":["https://phase-rs.dev/*","https://app.phase-rs.dev/*","https://preview.phase-rs.dev/*"]},"local":false,"windows":["main"],"permissions":["core:default","core:window:allow-set-fullscreen","shell:allow-open","process:allow-exit","process:allow-restart","updater:default","allow-set-channel-preference","allow-take-legacy-storage","allow-confirm-legacy-import","allow-mark-remote-load-ok","allow-ensure-native-engine","allow-native-engine-progress","allow-stop-native-engine","allow-connect-native-engine","allow-native-engine-bridge-send","allow-native-engine-bridge-close"]}} \ No newline at end of file diff --git a/client/src-tauri/gen/schemas/desktop-schema.json b/client/src-tauri/gen/schemas/desktop-schema.json index 8148fae46c..c91082be7e 100644 --- a/client/src-tauri/gen/schemas/desktop-schema.json +++ b/client/src-tauri/gen/schemas/desktop-schema.json @@ -422,6 +422,12 @@ "const": "allow-native-engine-bridge-send", "markdownDescription": "Allows first-party remote content to send JSON text through its native-engine bridge." }, + { + "description": "Allows first-party remote content to read the latest native-engine provisioning progress.", + "type": "string", + "const": "allow-native-engine-progress", + "markdownDescription": "Allows first-party remote content to read the latest native-engine provisioning progress." + }, { "description": "Allows first-party remote content to persist its selected channel.", "type": "string", diff --git a/client/src-tauri/gen/schemas/macOS-schema.json b/client/src-tauri/gen/schemas/macOS-schema.json index 8148fae46c..c91082be7e 100644 --- a/client/src-tauri/gen/schemas/macOS-schema.json +++ b/client/src-tauri/gen/schemas/macOS-schema.json @@ -422,6 +422,12 @@ "const": "allow-native-engine-bridge-send", "markdownDescription": "Allows first-party remote content to send JSON text through its native-engine bridge." }, + { + "description": "Allows first-party remote content to read the latest native-engine provisioning progress.", + "type": "string", + "const": "allow-native-engine-progress", + "markdownDescription": "Allows first-party remote content to read the latest native-engine provisioning progress." + }, { "description": "Allows first-party remote content to persist its selected channel.", "type": "string", diff --git a/client/src-tauri/permissions/legacy-storage.toml b/client/src-tauri/permissions/legacy-storage.toml index e96aab37c3..bdc87ebab3 100644 --- a/client/src-tauri/permissions/legacy-storage.toml +++ b/client/src-tauri/permissions/legacy-storage.toml @@ -28,6 +28,11 @@ identifier = "allow-ensure-native-engine" description = "Allows first-party remote content to prepare a signed native engine for a typed channel key." commands.allow = ["ensure_native_engine"] +[[permission]] +identifier = "allow-native-engine-progress" +description = "Allows first-party remote content to read the latest native-engine provisioning progress." +commands.allow = ["native_engine_progress"] + [[permission]] identifier = "allow-stop-native-engine" description = "Allows first-party remote content to stop the shell-owned native engine." diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 7ccc395fde..29f5c3d1e6 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -32,6 +32,7 @@ pub fn run() { migration::confirm_legacy_import, migration::mark_remote_load_ok, native_engine::ensure_native_engine, + native_engine::native_engine_progress, native_engine::stop_native_engine, native_bridge::connect_native_engine, native_bridge::native_engine_bridge_send, diff --git a/client/src-tauri/src/native_bridge.rs b/client/src-tauri/src/native_bridge.rs index 45cd91ccdf..36385dbf57 100644 --- a/client/src-tauri/src/native_bridge.rs +++ b/client/src-tauri/src/native_bridge.rs @@ -17,6 +17,10 @@ use tokio_tungstenite::{ use crate::native_engine; +/// phase-server's WebSocket route. Kept as a named constant so the one place +/// that dials it reads as a contract rather than an incidental URL suffix. +const SERVER_WEBSOCKET_PATH: &str = "/ws"; + /// Events forwarded from the shell-owned loopback WebSocket to remote content. #[derive(Clone, Debug, Serialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -141,7 +145,10 @@ pub fn native_engine_bridge_close(id: u64) -> Result<(), NativeEngineBridgeError } fn bridge_request(port: u16, origin: &str) -> Result, NativeEngineBridgeError> { - let url = format!("ws://127.0.0.1:{port}"); + // phase-server serves its socket at `/ws` (`crates/phase-server/src/main.rs` + // route table), the same path web clients dial. The root path answers 404, + // which fails the upgrade and sends every native session to WASM. + let url = format!("ws://127.0.0.1:{port}{SERVER_WEBSOCKET_PATH}"); let mut request = url.into_client_request() .map_err(|error| NativeEngineBridgeError::Connect { @@ -267,7 +274,9 @@ mod tests { fn bridge_request_is_loopback_and_uses_the_channel_origin() { let request = bridge_request(43123, "https://phase-rs.dev").unwrap(); - assert_eq!(request.uri().to_string(), "ws://127.0.0.1:43123/"); + // The path is load-bearing: phase-server answers 404 on the root, and a + // failed upgrade silently downgrades every native session to WASM. + assert_eq!(request.uri().to_string(), "ws://127.0.0.1:43123/ws"); assert_eq!( request.headers()[ORIGIN].to_str().unwrap(), "https://phase-rs.dev" diff --git a/client/src-tauri/src/native_engine.rs b/client/src-tauri/src/native_engine.rs index 6c7a7a8e5a..6cae34c2d8 100644 --- a/client/src-tauri/src/native_engine.rs +++ b/client/src-tauri/src/native_engine.rs @@ -25,10 +25,19 @@ use crate::native_bridge::BridgeHandle; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +#[cfg(windows)] +use std::os::windows::process::CommandExt; + +/// `CREATE_NO_WINDOW` — suppress the console window Windows allocates for a +/// console-subsystem child when spawned from this GUI-subsystem shell. Without +/// it, launching the native `phase-server.exe` flashes/leaves a cmd window. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; const SERVER_ARTIFACT_PUBLIC_KEY: &str = "RWRDZxG2otNoKLblrgD00kM0a8U0CRZUGHpNCr3W+3ik1E84XHcB6hZe"; const NATIVE_ENGINE_DIRECTORY: &str = "native-engine"; const CACHE_DIRECTORY: &str = "cache/sha256"; +const LOG_DIRECTORY: &str = "logs"; const SPAWN_RECORD_FILE: &str = "native-engine-spawn-record.json"; const RELEASE_RATCHET_FILE: &str = "native-engine-highest-release-version.json"; const PREVIEW_RATCHET_FILE: &str = "native-engine-preview-generated-at.json"; @@ -114,6 +123,7 @@ pub enum NativeEngineProgressPhase { DownloadingData, Spawning, Ready, + Failed, } /// Structured IPC failures let the frontend choose its normal WASM fallback. @@ -337,10 +347,26 @@ impl NativeEngineFiles { self.key_directory(key).join(binary_file_name()) } + fn binary_signature(&self, key: &NativeEngineKey) -> PathBuf { + self.key_directory(key) + .join(format!("{}.minisig", binary_file_name())) + } + fn data_directory(&self, key: &NativeEngineKey) -> PathBuf { self.key_directory(key).join("data") } + /// Version-independent path for the server's game-persistence database. + /// Keyed by channel only (not the version/fingerprint that names + /// `key_directory`), so saved games survive engine updates within a channel + /// while `preview` and `release` stay isolated — they load different + /// content and must not share sessions. + fn games_db(&self, key: &NativeEngineKey) -> PathBuf { + self.base + .join("games") + .join(format!("{}.db", key.channel())) + } + fn cache_directory(&self) -> PathBuf { self.base.join(CACHE_DIRECTORY) } @@ -349,6 +375,14 @@ impl NativeEngineFiles { self.cache_directory().join(sha256) } + fn log_directory(&self) -> PathBuf { + self.base.join(LOG_DIRECTORY) + } + + fn startup_log(&self) -> PathBuf { + self.log_directory().join("server-startup.log") + } + fn spawn_record(&self) -> PathBuf { self.app_directory.join(SPAWN_RECORD_FILE) } @@ -415,6 +449,7 @@ impl Default for NativeEngineState { } static ENGINE_STATE: OnceLock> = OnceLock::new(); +static LATEST_PROGRESS: OnceLock>> = OnceLock::new(); static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); pub(crate) enum NativeBridgeRegistryError { @@ -426,6 +461,10 @@ fn engine_state() -> &'static Mutex { ENGINE_STATE.get_or_init(|| Mutex::new(NativeEngineState::default())) } +fn latest_progress() -> &'static Mutex> { + LATEST_PROGRESS.get_or_init(|| Mutex::new(None)) +} + pub(crate) fn register_native_engine_bridge( bridge: BridgeHandle, ) -> Result<(u64, u16, &'static str), NativeBridgeRegistryError> { @@ -490,11 +529,32 @@ pub async fn ensure_native_engine( app: AppHandle, key: NativeEngineKey, ) -> Result { - tauri::async_runtime::spawn_blocking(move || ensure_native_engine_sync(&app, key)) + let progress_app = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || ensure_native_engine_sync(&app, key)) .await .map_err(|error| NativeEngineError::Internal { detail: error.to_string(), - })? + }) + .and_then(|result| result); + // Single authority for the terminal phase. `ensure_native_engine_sync` + // returns early on both the healthy-in-process and adopted-record paths, + // so emitting `Ready` inside it would leave those runs ending on a + // non-terminal phase — and a listener waiting for one would wait forever. + match &result { + Ok(ready) => emit_progress( + &progress_app, + NativeEngineProgressPhase::Ready, + Some(ready.port.to_string()), + ), + Err(_) => emit_progress(&progress_app, NativeEngineProgressPhase::Failed, None), + } + result +} + +/// Returns the latest provisioning progress for listeners that register late. +#[tauri::command] +pub fn native_engine_progress() -> Option { + latest_progress().lock().ok()?.clone() } /// Stops the held or adopted native server and removes its persisted record. @@ -566,22 +626,7 @@ fn ensure_native_engine_sync( None => resolve_artifact(app, &client, &files, &key)?, }; - emit_progress( - app, - NativeEngineProgressPhase::DownloadingBinary, - Some(key.directory_name()), - ); - let binary = fetch_bytes(&client, &resolved.binary_url)?; - let signature = fetch_bytes(&client, &resolved.binary_signature_url)?; - emit_progress( - app, - NativeEngineProgressPhase::Verifying, - Some("server binary".to_owned()), - ); - verify_signature(&binary, &signature)?; - let binary_path = files.binary(&key); - write_atomically(&binary_path, &binary)?; - make_executable(&binary_path)?; + let binary_path = provision_binary(app, &client, &files, &key, &resolved)?; emit_progress(app, NativeEngineProgressPhase::DownloadingData, None); assemble_data(&client, Some(app), &files, &key, &resolved.data)?; @@ -591,6 +636,9 @@ fn ensure_native_engine_sync( let (mut child, stdin) = spawn_server( &binary_path, &files.data_directory(&key), + &files.games_db(&key), + &files.log_directory(), + &files.startup_log(), port, key.origin(), )?; @@ -622,11 +670,6 @@ fn ensure_native_engine_sync( if let Err(error) = gc_after_successful_spawn(&files, &key) { eprintln!("native engine GC after successful spawn failed: {error:?}"); } - emit_progress( - app, - NativeEngineProgressPhase::Ready, - Some(port.to_string()), - ); Ok(NativeEngineReady { port }) } @@ -726,6 +769,66 @@ fn fetch_verified_bytes(client: &Client, url: &str) -> Result, NativeEng Ok(bytes) } +/// Reuses a previously verified server binary for the same typed key. The +/// minisign signature is retained alongside the executable so every launch +/// still verifies what it is about to execute; a missing or invalid cache is +/// simply replaced from the first-party artifact source. +fn provision_binary( + app: &AppHandle, + client: &Client, + files: &NativeEngineFiles, + key: &NativeEngineKey, + resolved: &ResolvedArtifact, +) -> Result { + let binary_path = files.binary(key); + if cached_binary_is_verified(files, key)? { + return Ok(binary_path); + } + + emit_progress( + app, + NativeEngineProgressPhase::DownloadingBinary, + Some(key.directory_name()), + ); + let binary = fetch_bytes(client, &resolved.binary_url)?; + let signature = fetch_bytes(client, &resolved.binary_signature_url)?; + emit_progress( + app, + NativeEngineProgressPhase::Verifying, + Some("server binary".to_owned()), + ); + verify_signature(&binary, &signature)?; + write_atomically(&binary_path, &binary)?; + write_atomically(&files.binary_signature(key), &signature)?; + make_executable(&binary_path)?; + Ok(binary_path) +} + +fn cached_binary_is_verified( + files: &NativeEngineFiles, + key: &NativeEngineKey, +) -> Result { + cached_binary_is_verified_with_key(SERVER_ARTIFACT_PUBLIC_KEY, files, key) +} + +fn cached_binary_is_verified_with_key( + public_key: &str, + files: &NativeEngineFiles, + key: &NativeEngineKey, +) -> Result { + let binary = match fs::read(files.binary(key)) { + Ok(bytes) => bytes, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(NativeEngineError::storage(error)), + }; + let signature = match fs::read(files.binary_signature(key)) { + Ok(bytes) => bytes, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(NativeEngineError::storage(error)), + }; + Ok(verify_signature_with_key(public_key, &binary, &signature).is_ok()) +} + fn fetch_bytes(client: &Client, url: &str) -> Result, NativeEngineError> { tauri::async_runtime::block_on(async { let response = client @@ -979,26 +1082,51 @@ fn reserve_port() -> Result { fn spawn_server( binary: &Path, data_directory: &Path, + games_db: &Path, + log_directory: &Path, + startup_log: &Path, port: u16, origin: &str, ) -> Result<(Child, ChildStdin), NativeEngineError> { - let mut child = Command::new(binary) + fs::create_dir_all(log_directory).map_err(NativeEngineError::storage)?; + let startup_log = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(startup_log) + .map_err(NativeEngineError::storage)?; + let mut command = Command::new(binary); + command .env("PORT", port.to_string()) .env("PHASE_DATA_DIR", data_directory) + .env("PHASE_GAMES_DB", games_db) .args([ "--bind", "127.0.0.1", "--exit-on-stdin-close", + // A desktop shell hosts one local player: keep the suspended solo + // game resumable until replaced (no stale purge, no reconnect expiry). + "--single-user", "--allowed-origin", origin, ]) + .arg("--log-dir") + .arg(log_directory) .stdin(Stdio::piped()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|error| NativeEngineError::Spawn { - detail: error.to_string(), - })?; + // Early startup failures happen before phase-server initializes its + // structured logger. Keep the latest one beside its rolling logs so a + // server that exits before `/health` is diagnosable without a console. + .stderr(Stdio::from(startup_log)); + + // The shell is a GUI-subsystem app but phase-server is console-subsystem, so + // Windows would otherwise pop a console window for the child on every launch. + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + + let mut child = command.spawn().map_err(|error| NativeEngineError::Spawn { + detail: error.to_string(), + })?; let stdin = child.stdin.take().ok_or_else(|| NativeEngineError::Spawn { detail: "native engine stdin pipe was unavailable".to_owned(), })?; @@ -1102,6 +1230,7 @@ fn kill_recorded_process_if_ours(record: &SpawnRecord, files: &NativeEngineFiles { let _ = Command::new("taskkill") .args(["/PID", &record.pid.to_string(), "/T", "/F"]) + .creation_flags(CREATE_NO_WINDOW) .status(); } } @@ -1141,6 +1270,7 @@ fn process_is_plausibly_ours(pid: u32, binary: &Path) -> bool { fn process_is_plausibly_ours(pid: u32, binary: &Path) -> bool { let output = Command::new("tasklist") .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]) + .creation_flags(CREATE_NO_WINDOW) .output(); let Ok(output) = output else { return false; @@ -1350,7 +1480,11 @@ fn binary_file_name() -> String { } fn emit_progress(app: &AppHandle, phase: NativeEngineProgressPhase, detail: Option) { - let _ = app.emit(PROGRESS_EVENT, NativeEngineProgress { phase, detail }); + let progress = NativeEngineProgress { phase, detail }; + if let Ok(mut latest) = latest_progress().lock() { + *latest = Some(progress.clone()); + } + let _ = app.emit(PROGRESS_EVENT, progress); } #[cfg(test)] @@ -1478,6 +1612,23 @@ mod tests { ); } + #[test] + fn cached_binary_requires_its_matching_signature() { + let files = test_files("binary-cache"); + let key = release_key("1.2.3"); + write_atomically(&files.binary(&key), TEST_BYTES).unwrap(); + write_atomically(&files.binary_signature(&key), TEST_SIGNATURE.as_bytes()).unwrap(); + + assert!(cached_binary_is_verified_with_key(TEST_PUBLIC_KEY, &files, &key).unwrap()); + + write_atomically(&files.binary(&key), b"tampered").unwrap(); + assert!(!cached_binary_is_verified_with_key(TEST_PUBLIC_KEY, &files, &key).unwrap()); + + remove_file_if_exists(&files.binary_signature(&key)).unwrap(); + assert!(!cached_binary_is_verified_with_key(TEST_PUBLIC_KEY, &files, &key).unwrap()); + fs::remove_dir_all(files.app_directory).unwrap(); + } + #[test] fn cache_hash_atomic_write_and_data_assembly_work() { let files = test_files("cache-assembly"); diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index f15995f46a..5d60f83b19 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.35.0", + "version": "0.36.0", "identifier": "rs.phase.app", "build": { "frontendDist": "../bootstrap/dist", diff --git a/client/src/App.tsx b/client/src/App.tsx index b5b301c0d6..e6e3b3f71a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, useSearchParams } from "react-router"; import { AppShell } from "./components/chrome/AppShell"; import { AppToast } from "./components/chrome/AppToast"; +import { NativeEngineProgressOverlay } from "./components/chrome/NativeEngineProgressOverlay"; import { RouteTelemetry } from "./components/chrome/RouteTelemetry"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { HostControlTile } from "./components/chrome/HostControlTile"; @@ -128,6 +129,7 @@ function AppContent() { + diff --git a/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts b/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts index d123f1868c..74bae5e528 100644 --- a/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts +++ b/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts @@ -407,6 +407,42 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => { expect(mockSetMultiplayerMode).toHaveBeenCalledWith(true); }); + it("does not reinitialize the host during the lobby-to-game handoff", async () => { + const { adapter } = makeHost(2); + + await Promise.all([adapter.initialize(), adapter.initialize()]); + await adapter.initialize(); + + expect(mockInitialize).toHaveBeenCalledTimes(1); + expect(mockSetMultiplayerMode).toHaveBeenCalledTimes(1); + }); + + it("retries failed initialization without duplicating guest connections", async () => { + const { adapter, emitConnection } = makeHost(2); + mockInitialize + .mockRejectedValueOnce(new Error("worker startup failed")) + .mockResolvedValueOnce(undefined); + + await expect(adapter.initialize()).rejects.toThrow("worker startup failed"); + await adapter.initialize(); + + const guest = await joinGuest(emitConnection, { + type: "guest_deck", + deckData: { player: { main_deck: ["Plains"], sideboard: [] } }, + }); + await flushPromises(20); + + expect(mockInitialize).toHaveBeenCalledTimes(2); + expect(adapter.getPlayerSlots().map((slot) => slot.kind.type)).toEqual([ + "HostHuman", + "JoinedHuman", + ]); + const messages = await guest.getSentMessages(); + expect(messages.filter((message) => (message as { type?: string }).type === "seat_snapshot")) + .toHaveLength(1); + expect(messages.some((message) => (message as { type?: string }).type === "kick")).toBe(false); + }); + it("rejects a non-Oathbreaker guest signature spell before game setup", async () => { mockCheckDeckCompatibility.mockResolvedValueOnce({ selected_format_compatible: false, diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index d17fd41958..50d772306e 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -41,7 +41,7 @@ export type SelectionConstraint = { "type": "count", "data": { min: number, max: export type ConfirmSemantics = "immediate" | "explicit"; -export type InteractionActionCode = "passPriority" | "chooseMeldPair" | "chooseEntryAttackTarget" | "playLand" | "castSpell" | "foretell" | "activateAbility" | "declareAttackers" | "declareBlockers" | "chooseUntap" | "chooseExert" | "chooseEnlist" | "chooseClashOpponent" | "chooseZoneOpponentChooser" | "choosePileOpponent" | "chooseAnnouncingOpponent" | "chooseAssistPlayer" | "commitAssistPayment" | "mulliganDecision" | "reorderHand" | "tapLandForMana" | "untapLandForMana" | "spendPoolMana" | "unspendPoolMana" | "selectCards" | "chooseRemoveCounterCostDistribution" | "selectCoinFlips" | "chooseOutsideGameCards" | "selectTargets" | "chooseTarget" | "chooseReplacement" | "orderTriggers" | "cancelCast" | "equip" | "crewVehicle" | "activateStation" | "saddleMount" | "transform" | "playFaceDown" | "turnFaceUp" | "submitSideboard" | "choosePlayDraw" | "chooseOption" | "submitVoteCandidate" | "submitSpellbookDraft" | "submitPilePartition" | "choosePile" | "chooseBranch" | "submitLifeRedistribution" | "chooseDamageSource" | "selectModes" | "decideOptionalCost" | "chooseAdventureFace" | "chooseModalFace" | "chooseAlternativeCast" | "chooseCastingVariant" | "keepAllCopyTargets" | "choosePermanentTypeSlot" | "activateNinjutsu" | "castSpellAsSneak" | "castSpellAsWebSlinging" | "castSpellForFree" | "castSpellAsMiracle" | "castSpellAsMadness" | "decideOptionalEffect" | "respondToSpliceOffer" | "decideOptionalEffectAndRemember" | "payUnlessCost" | "chooseUnlessCostBranch" | "chooseActivationCostBranch" | "payCombatTax" | "chooseRingBearer" | "choosePair" | "chooseDungeon" | "chooseDungeonRoom" | "unlockRoomDoor" | "rollPlanarDie" | "chooseRoomDoor" | "tapForConvoke" | "harmonizeTap" | "declareCompanion" | "companionToHand" | "discoverChoice" | "graveyardPaidCastChoice" | "cascadeChoice" | "rippleChoice" | "freeCastWindowChoice" | "chooseTopOrBottom" | "chooseMutateMergeSide" | "cipherEncode" | "chooseLegend" | "chooseBattleProtector" | "setAutoPass" | "cancelAutoPass" | "setPhaseStops" | "setPriorityPassingMode" | "setPriorityYield" | "setMayTriggerAutoChoice" | "setTriggerOrderTemplate" | "assignCombatDamage" | "assignBlockerDamage" | "distributeAmong" | "chooseCounterMoveDistribution" | "chooseCountersToRemove" | "submitPayAmount" | "retargetSpell" | "learnDecision" | "selectCategoryPermanents" | "chooseKeptCreatures" | "chooseKeptPermanents" | "chooseX" | "submitPhyrexianChoices" | "chooseManaColor" | "payManaAbilityMana" | "castPreparedCopy" | "chooseSpecializeColor" | "castParadigmCopy" | "passParadigmOffer" | "grantDebugPermission" | "revokeDebugPermission" | "concede" | "declareShortcut" | "respondToShortcut" | "declineShortcut" | "precastCopyShortcut" | "debug"; +export type InteractionActionCode = "passPriority" | "chooseMeldPair" | "chooseEntryAttackTarget" | "playLand" | "castSpell" | "foretell" | "activateAbility" | "declareAttackers" | "declareBlockers" | "chooseUntap" | "chooseExert" | "chooseEnlist" | "chooseClashOpponent" | "chooseZoneOpponentChooser" | "choosePileOpponent" | "chooseAnnouncingOpponent" | "chooseGiftRecipient" | "chooseAssistPlayer" | "commitAssistPayment" | "mulliganDecision" | "reorderHand" | "tapLandForMana" | "untapLandForMana" | "spendPoolMana" | "unspendPoolMana" | "selectCards" | "chooseRemoveCounterCostDistribution" | "selectCoinFlips" | "chooseOutsideGameCards" | "selectTargets" | "chooseTarget" | "chooseReplacement" | "orderTriggers" | "cancelCast" | "equip" | "crewVehicle" | "activateStation" | "saddleMount" | "transform" | "playFaceDown" | "turnFaceUp" | "submitSideboard" | "choosePlayDraw" | "chooseOption" | "submitVoteCandidate" | "submitSpellbookDraft" | "submitPilePartition" | "choosePile" | "chooseBranch" | "submitLifeRedistribution" | "chooseDamageSource" | "selectModes" | "decideOptionalCost" | "chooseAdventureFace" | "chooseModalFace" | "chooseAlternativeCast" | "chooseCastingVariant" | "keepAllCopyTargets" | "choosePermanentTypeSlot" | "activateNinjutsu" | "castSpellAsSneak" | "castSpellAsWebSlinging" | "castSpellForFree" | "castSpellAsMiracle" | "castSpellAsMadness" | "decideOptionalEffect" | "respondToSpliceOffer" | "decideOptionalEffectAndRemember" | "payUnlessCost" | "chooseUnlessCostBranch" | "chooseActivationCostBranch" | "payCombatTax" | "chooseRingBearer" | "choosePair" | "chooseDungeon" | "chooseDungeonRoom" | "unlockRoomDoor" | "rollPlanarDie" | "chooseRoomDoor" | "tapForConvoke" | "harmonizeTap" | "declareCompanion" | "companionToHand" | "discoverChoice" | "graveyardPaidCastChoice" | "cascadeChoice" | "rippleChoice" | "freeCastWindowChoice" | "chooseTopOrBottom" | "chooseMutateMergeSide" | "cipherEncode" | "chooseLegend" | "chooseBattleProtector" | "setAutoPass" | "cancelAutoPass" | "setPhaseStops" | "setPriorityPassingMode" | "setPriorityYield" | "setMayTriggerAutoChoice" | "setTriggerOrderTemplate" | "assignCombatDamage" | "assignBlockerDamage" | "distributeAmong" | "chooseCounterMoveDistribution" | "chooseCountersToRemove" | "submitPayAmount" | "retargetSpell" | "learnDecision" | "selectCategoryPermanents" | "chooseKeptCreatures" | "chooseKeptPermanents" | "chooseX" | "submitPhyrexianChoices" | "chooseManaColor" | "payManaAbilityMana" | "castPreparedCopy" | "chooseSpecializeColor" | "castParadigmCopy" | "passParadigmOffer" | "grantDebugPermission" | "revokeDebugPermission" | "concede" | "declareShortcut" | "respondToShortcut" | "declineShortcut" | "precastCopyShortcut" | "debug"; export type InteractionRoleCode = "source" | "candidate" | "partner" | "attackTarget" | "target" | "paymentMode" | "abilityIndex" | "attacker" | "bandCount" | "blocker" | "blocked" | "untap" | "exert" | "enlistTarget" | "enlist" | "opponent" | "assistPlayer" | "assist" | "genericMana" | "mulligan" | "serumPowder" | "handCard" | "selected" | "counterSource" | "counterType" | "amount" | "coinFlipIndex" | "sideboardIndex" | "faceUpExile" | "optionIndex" | "triggerIndex" | "crewMember" | "stationCrew" | "x" | "mainCard" | "sideboardCard" | "playFirst" | "option" | "candidateIndex" | "cardName" | "pileA" | "pile" | "modeIndex" | "pay" | "face" | "castCost" | "permanentType" | "returnCreature" | "permissionSource" | "accept" | "spliceCard" | "splice" | "choice" | "costBranch" | "costBranchIndex" | "pair" | "dungeon" | "roomIndex" | "door" | "operation" | "convokeMana" | "harmonizeCreature" | "harmonize" | "companion" | "castChoice" | "castCard" | "placement" | "mergeSide" | "encodeCreature" | "encode" | "defender" | "protector" | "assignmentMode" | "damageTarget" | "damageAmount" | "trampleDamage" | "controllerDamage" | "destination" | "discardCard" | "learn" | "category" | "kept" | "phyrexianPayment" | "manaChoice" | "count" | "manaPayment" | "color" | "player" | "castingVariant" | "mode" | "modeCost" | "castingCost" | "voteOption" | "voteCandidate"; diff --git a/client/src/adapter/p2p-adapter.ts b/client/src/adapter/p2p-adapter.ts index 359e71998c..6817a5fc4a 100644 --- a/client/src/adapter/p2p-adapter.ts +++ b/client/src/adapter/p2p-adapter.ts @@ -534,6 +534,14 @@ export class P2PHostAdapter implements EngineAdapter { private nativeBridge: NativeP2PBridge | null = null; private nativeInitialSetupPending = false; private listeners: P2PAdapterEventListener[] = []; + /** + * Mirrors WasmAdapter's initialization contract: setup runs exactly once, + * and concurrent callers share its in-flight promise. The lobby initializes + * the host before advertising it; the game-page handoff later calls + * initialize again while seeding gameStore. + */ + private initialized = false; + private initPromise: Promise | null = null; private guestSessions = new Map(); private guestDecks = new Map(); @@ -570,10 +578,7 @@ export class P2PHostAdapter implements EngineAdapter { private pregameOpQueue: Promise = Promise.resolve(); private resolvePregameReady!: () => void; private rejectPregameReady!: (err: unknown) => void; - private readonly pregameReady: Promise = new Promise((resolve, reject) => { - this.resolvePregameReady = resolve; - this.rejectPregameReady = reject; - }); + private pregameReady!: Promise; private allowPartialStart = false; /** @@ -1093,7 +1098,35 @@ export class P2PHostAdapter implements EngineAdapter { } } + private resetPregameReady(): void { + this.pregameReady = new Promise((resolve, reject) => { + this.resolvePregameReady = resolve; + this.rejectPregameReady = reject; + }); + // Initialization can fail before a guest arrives to await this gate. Keep + // that rejection observable to a queued guest while preventing it from + // becoming an unhandled rejection when no guest exists yet. + void this.pregameReady.catch(() => {}); + } + + private unsubscribeHostConnections(): void { + this.hostConnectionUnsub?.(); + this.hostConnectionUnsub = null; + } + async initialize(): Promise { + if (this.initialized) return; + if (this.initPromise) return this.initPromise; + this.resetPregameReady(); + const pending = this.initializeInner(); + this.initPromise = pending; + pending.catch(() => { + if (this.initPromise === pending) this.initPromise = null; + }); + return pending; + } + + private async initializeInner(): Promise { traceAdapter("Host", "initialize-start", { isResume: this.isResume }); // Subscribe SYNCHRONOUSLY before any `await`. `hostRoom()` buffers // inbound guest connections that arrived between peer-open and the @@ -1147,6 +1180,7 @@ export class P2PHostAdapter implements EngineAdapter { } this.resolvePregameReady(); } catch (err) { + this.unsubscribeHostConnections(); this.rejectPregameReady(err); throw err; } @@ -1158,6 +1192,7 @@ export class P2PHostAdapter implements EngineAdapter { }); } traceAdapter("Host", "initialize-complete", {}); + this.initialized = true; } private handleNewConnection(conn: DataConnection): void { @@ -1637,7 +1672,7 @@ export class P2PHostAdapter implements EngineAdapter { * clears the persistence before disposing. */ dispose(): void { - if (this.hostConnectionUnsub) this.hostConnectionUnsub(); + this.unsubscribeHostConnections(); for (const { timer } of this.disconnectedSeats.values()) { if (timer !== null) clearTimeout(timer); } diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 684e75f090..8bb4d2ad39 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -322,7 +322,7 @@ export interface MeldSelection { // display-only badges + Confirm gating. `#[serde(tag = "kind")]` in the engine. export type CombatRequirement = | { kind: "MustAttack"; players: PlayerId[]; sources?: ObjectId[] } - | { kind: "MustBlock"; sources?: ObjectId[] } + | { kind: "MustBlock"; sources?: ObjectId[]; attackers?: ObjectId[] } | { kind: "CantAttack"; sources?: ObjectId[] } | { kind: "CantBlock"; sources?: ObjectId[] }; @@ -1095,6 +1095,16 @@ export interface GameObject { abilities: SerializedAbility[]; color: ManaColor[]; printed_ref?: PrintedRef | null; + /** + * Engine-owned discriminant for what this stored half actually IS. The + * `back_face` slot is shared by several printed layouts, so its presence + * alone does NOT mean the object is double-faced: CR 710 Kamigawa flip + * cards park their alternative (bottom) half here, and Adventure/Omen + * cards park their alternative spell here. Only `"Transform"`, `"Modal"`, + * and `"Meld"` are real second faces (CR 712). Absent when the engine has + * no layout to report. + */ + layout_kind?: LayoutKind | null; } | null; /** * CR 702.143c-d: Whether this card in exile is foretold. Its owner may look @@ -1109,6 +1119,22 @@ export interface PrintedRef { face_name: string; } +/** + * Mirror of the engine's `types::card::LayoutKind` (serialized as its plain + * variant name). Describes the printed layout that produced an object's stored + * `back_face`. + */ +export type LayoutKind = + | "Single" + | "Split" + | "Flip" + | "Transform" + | "Meld" + | "Adventure" + | "Modal" + | "Omen" + | "Prepare"; + export interface ObjectIncarnationRef { object_id: ObjectId; incarnation: number; @@ -1639,7 +1665,7 @@ export type WaitingFor = | { type: "GameOver"; data: { winner: PlayerId | null } } | { type: "ReplacementChoice"; data: { player: PlayerId; candidate_count: number; candidates?: ReplacementCandidateSummary[] } } | { type: "OrderTriggers"; data: { player: PlayerId; triggers: PendingTriggerSummary[] } } - | { type: "CopyTargetChoice"; data: { player: PlayerId; source_id: ObjectId; valid_targets: ObjectId[]; max_mana_value?: number | null } } + | { type: "CopyTargetChoice"; data: { player: PlayerId; source_id: ObjectId; valid_targets: ObjectId[]; max_mana_value?: number | null; purpose?: { type: "BecomeCopy" | "PersistChosenAttribute" } } } | { type: "ExploreChoice"; data: { player: PlayerId; source_id: ObjectId; choosable: ObjectId[]; remaining: ObjectId[]; pending_effect: unknown } } | { type: "ReturnAsAuraTarget"; data: { player: PlayerId; source_id: ObjectId; returned_id: ObjectId; legal_targets: TargetRef[]; pending_effect: unknown } } | { type: "EquipTarget"; data: { player: PlayerId; equipment_id: ObjectId; valid_targets: ObjectId[] } } @@ -1667,7 +1693,7 @@ export type WaitingFor = | { type: "ModeChoice"; data: { player: PlayerId; modal: ModalChoice; pending_cast: PendingCast; unavailable_modes?: number[] } } | { type: "AbilityModeChoice"; data: { player: PlayerId; modal: ModalChoice; source_id: ObjectId; mode_abilities: unknown[]; is_activated: boolean; ability_index?: number; ability_cost?: unknown; unavailable_modes?: number[] } } | { type: "DiscardToHandSize"; data: { player: PlayerId; count: number; cards: ObjectId[] } } - | { type: "OptionalCostChoice"; data: { player: PlayerId; cost: AdditionalCost; times_kicked: number; pending_cast: PendingCast } } + | { type: "OptionalCostChoice"; data: { player: PlayerId; cost: AdditionalCost; times_kicked: number; origin?: string; gift_kind?: { type: string }; pending_cast: PendingCast } } | { type: "CostTypeChoice"; data: { player: PlayerId; choice_type: string | Record; options: string[]; pending_cast: PendingCast } } | { type: "SpliceOffer"; data: { player: PlayerId; pending_cast: PendingCast; eligible: ObjectId[] } } | { type: "DefilerPayment"; data: { player: PlayerId; life_cost: number; mana_reduction: ManaCost; pending_cast: PendingCast } } @@ -1796,6 +1822,7 @@ export type WaitingFor = // picks WHICH opponent makes the choice before the zone choice is presented. | { type: "ChooseFromZoneOpponentChooser"; data: { player: PlayerId; candidates: PlayerId[]; ability: unknown } } | { type: "ChooseAnnouncingOpponent"; data: { player: PlayerId; candidates: PlayerId[]; choice_index: number; choice_count: number; target_type?: CoreType; pending_cast: unknown } } + | { type: "ChooseGiftRecipient"; data: { player: PlayerId; candidates: PlayerId[]; gift_kind?: { type: string }; pending_cast: unknown } } | { type: "ClashCardPlacement"; data: { player: PlayerId; card: ObjectId; remaining: [PlayerId, ObjectId][] } } | { type: "VoteChoice"; data: { player: PlayerId; @@ -2232,6 +2259,7 @@ export type GameAction = | { type: "ChooseZoneOpponentChooser"; data: { opponent: PlayerId } } | { type: "ChoosePileOpponent"; data: { opponent: PlayerId } } | { type: "ChooseAnnouncingOpponent"; data: { opponent: PlayerId } } + | { type: "ChooseGiftRecipient"; data: { opponent: PlayerId } } | { type: "ChooseAssistPlayer"; data: { player: PlayerId | null } } | { type: "CommitAssistPayment"; data: { generic: number } } | { @@ -2374,6 +2402,8 @@ export type GameEvent = | { type: "BecomesTarget"; data: { target: TargetRef; source_id: ObjectId } } | { type: "ReplacementApplied"; data: { source_id: ObjectId; event_type: string } } | { type: "Transformed"; data: { object_id: ObjectId } } + // CR 710.4: a Kamigawa flip permanent flipped to its alternative face. + | { type: "Flipped"; data: { object_id: ObjectId } } | { type: "DayNightChanged"; data: { new_state: string } } | { type: "TurnedFaceUp"; data: { object_id: ObjectId } } | { type: "TurnedFaceDown"; data: { object_id: ObjectId } } @@ -2668,6 +2698,9 @@ export interface TurnOrderSlotView { player: PlayerId; slot_index: number; turns_from_now: number; + turn_number: number; + is_viewer?: boolean; + is_starting_player?: boolean; } /** @@ -2685,6 +2718,13 @@ export interface DerivedViews { * matters on the battlefield. Keyed by ObjectId-as-string. */ battlefield_keyword_badges?: Record; + /** + * CR 509.1b: live, until-end-of-turn `CantBeBlocked` grants keyed by + * recipient ObjectId-as-string. A null value means the grant remains live + * while its source is not a public, phased-in battlefield object, so the UI + * shows the badge without naming an unavailable source. + */ + temporary_cant_be_blocked?: Record; /** * CR 613.2a + CR 707.2: battlefield permanents whose copiable values are * currently supplied by a copy effect (Clone, Phantasmal Image, Vesuvan @@ -2749,6 +2789,8 @@ export interface DerivedViews { * intentional when extra turns put the same player in multiple slots. */ turn_order?: TurnOrderSlotView[]; + /** One-based projected turn position for the current viewer. */ + viewer_turn_number?: number; /** * CR 732.2a: `∞` HUD rows — one per (engine-attributed player, pumped axis) * of every unbounded-resource loop. Empty/omitted when no loop is active. The diff --git a/client/src/adapter/ws-adapter.ts b/client/src/adapter/ws-adapter.ts index 5676ec7f45..7c4c32016f 100644 --- a/client/src/adapter/ws-adapter.ts +++ b/client/src/adapter/ws-adapter.ts @@ -281,6 +281,22 @@ export class WebSocketAdapter implements EngineAdapter { return this._playerId; } + /** Reconnect credentials for this session, or null until the server has + * assigned them (i.e. game creation has completed). Solo-AI native games + * persist this so a suspended game can be resumed by constructing a + * `kind: "reconnect"` adapter. The player token is issued once at creation + * and lives only client-side — it is the reconnect security boundary. */ + get nativeSession(): { gameCode: string; playerId: PlayerId; playerToken: string } | null { + if (this._gameCode === null || this._playerId === null || this.playerToken === null) { + return null; + } + return { + gameCode: this._gameCode, + playerId: this._playerId, + playerToken: this.playerToken, + }; + } + onEvent(listener: WsAdapterEventListener): () => void { this.listeners.push(listener); return () => { diff --git a/client/src/components/animation/DiceRollOverlay.tsx b/client/src/components/animation/DiceRollOverlay.tsx index 855f03d4e6..e2f322ee3b 100644 --- a/client/src/components/animation/DiceRollOverlay.tsx +++ b/client/src/components/animation/DiceRollOverlay.tsx @@ -18,6 +18,11 @@ const DIE_SIZE = 132; // Accent RGB triples (sans `rgb(...)`) so they compose into rgba()/gradients. const GOLD = "251,191,36"; // amber-400 — the winner / emphasis accent const NEUTRAL = "148,163,184"; // slate-400 — a non-decisive die +const COIN_WON = "52,211,153"; // emerald-400 — the flipper won +const COIN_LOST = "251,113,133"; // rose-400 — the flipper lost + +let nextRollKey = 1; +const rollKeys = new WeakMap(); /** Cached WebGL-availability probe. The dice overlay is the first component in * the app that needs WebGL, so it must degrade gracefully where it's absent. */ @@ -52,11 +57,6 @@ export function DiceRollOverlay() { const shouldReduceMotion = useReducedMotion(); const { t } = useTranslation(); - // Clear any active/queued roll and its advance timer when leaving the game. - // The store is a module singleton that outlives this mount, so without this an - // in-flight roll could pop into the next game. - useEffect(() => () => useUiStore.getState().resetDiceRoll(), []); - // Tap-to-skip via keyboard: Escape dismisses the current roll (advancing to // the next queued one, or clearing the overlay). Bound only while a roll is // showing so it never shadows Escape elsewhere. @@ -135,9 +135,11 @@ export function DiceRollOverlay() { /** Stable identity for a payload so the FIFO advancing from one roll to the next * remounts `DiceRollContent` instead of reconciling stale settle state. */ function diceRollKey(payload: DiceRollPayload): string { - return payload.kind === "coin" - ? `coin-${payload.context}-${payload.playerId}-${payload.won}` - : `die-${payload.context}-${payload.rolls.map((r) => `${r.playerId}:${r.value}`).join(",")}`; + const existing = rollKeys.get(payload); + if (existing) return existing; + const key = String(nextRollKey++); + rollKeys.set(payload, key); + return key; } function DiceRollContent({ payload, animate }: { payload: DiceRollPayload; animate: boolean }) { @@ -151,12 +153,16 @@ function DiceRollContent({ payload, animate }: { payload: DiceRollPayload; anima // No engine-named face: `won` (relative to the flipping player) maps to a // heads/tails depiction. We show "heads" on a win — a pure display choice. const face = payload.won ? "heads" : "tails"; + const accent = payload.won ? COIN_WON : COIN_LOST; + const outcome = payload.won + ? t("diceRoll.wonCoinFlip", { name: playerLabel(payload.playerId) }) + : t("diceRoll.lostCoinFlip", { name: playerLabel(payload.playerId) }); return ( -
- - {playerLabel(payload.playerId)} +
+ + {t("diceRoll.coinFlip")} - + {(handleSettle) => animate ? ( }> @@ -173,6 +179,25 @@ function DiceRollContent({ payload, animate }: { payload: DiceRollPayload; anima ) } + + {outcome} +
); } @@ -245,6 +270,10 @@ function ContestDice({ ? t("diceRoll.youPlayFirst") : t("diceRoll.playerPlaysFirst", { name: getOpponentDisplayName(winner) }) : null; + const yourTurnCaption = + payload.viewerTurnNumber && payload.viewerTurnNumber > 1 + ? t("diceRoll.youTakeTurn", { turn: payload.viewerTurnNumber }) + : null; return (
@@ -327,6 +356,47 @@ function ContestDice({ )}
+ {revealed && payload.turnOrder && payload.turnOrder.length > 0 && ( + + + {t("diceRoll.turnOrder")} + +
+ {payload.turnOrder.map((slot) => { + return ( + + {t("diceRoll.turnOrderItem", { + turn: slot.turn_number, + name: playerLabel(slot.player), + })} + + ); + })} +
+ {yourTurnCaption && ( + {yourTurnCaption} + )} +
+ )}
); } diff --git a/client/src/components/board/ActionButton.tsx b/client/src/components/board/ActionButton.tsx index 2edb945120..f7c763e475 100644 --- a/client/src/components/board/ActionButton.tsx +++ b/client/src/components/board/ActionButton.tsx @@ -9,10 +9,8 @@ import { usePhaseInfo } from "../../hooks/usePhaseInfo.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { DRAFT_BOT_AI_SEAT, useMultiplayerDraftStore } from "../../stores/multiplayerDraftStore.ts"; import { useMultiplayerStore } from "../../stores/multiplayerStore.ts"; -import { useUiStore } from "../../stores/uiStore.ts"; +import { blockerAssignmentPairs, useUiStore } from "../../stores/uiStore.ts"; import { buildAttacks, hasMultipleAttackTargets, getValidAttackTargets, getValidAttackTargetsByAttacker } from "../../utils/combat.ts"; -import { useBlockRequirements } from "../combat/useBlockRequirements.ts"; -import { useBlockerConstraints } from "../combat/useBlockerConstraints.ts"; import { gameButtonClass } from "../ui/buttonStyles.ts"; import { GameplayTooltip } from "../ui/GameplayTooltip.tsx"; import { AttackTargetPicker } from "../controls/AttackTargetPicker.tsx"; @@ -72,20 +70,10 @@ export function ActionButton() { const setCombatMode = useUiStore((s) => s.setCombatMode); const setCombatClickHandler = useUiStore((s) => s.setCombatClickHandler); - // Engine-declared per-attacker minimum-blocker requirements (menace / - // "blocked by N or more"). Used to block confirmation while any attacker is - // under-assigned, so the player gets a clear message instead of an engine - // rejection (CR 702.111b / CR 509.1b). - const { byAttacker: blockRequirements } = useBlockRequirements(); - const incompleteBlockCount = useMemo( - () => Array.from(blockRequirements.values()).filter((r) => r.status === "incomplete").length, - [blockRequirements], + const blockerPairs = useMemo( + () => blockerAssignmentPairs(blockerAssignments), + [blockerAssignments], ); - // CR 509.1c: engine-provided must-block gating. Attacker must-attack - // requirements are NOT gated client-side — the engine strictly validates the - // declaration (CR 508.1d) and rejects an illegal submission; the must-attack - // badges (see AttackRequirementBadges / useAttackRequirements) are display only. - const { unsatisfiedMustBlockCount } = useBlockerConstraints(); const canCompanionToHand = useGameStore((s) => s.legalActions.some((a) => a.type === "CompanionToHand"), @@ -149,26 +137,32 @@ export function ActionButton() { [waitingFor], ); + // A new declaration prompt invalidates a partially selected blocker from the + // prior prompt, even though both prompts share the same combat mode. + const blockerPrompt = waitingFor?.type === "DeclareBlockers" ? waitingFor : null; + useEffect(() => { + setPendingBlocker(null); + }, [blockerPrompt]); + // Blocker click handler const handleBlockerClick = useCallback( (objectId: ObjectId) => { - // Click an already-assigned blocker to unassign - if (blockerAssignments.has(objectId)) { - removeBlockerAssignment(objectId); + // Selecting a blocker never clears its other assignments: one blocker can + // be assigned to multiple attackers. A second click on an attacker toggles + // only that pair, using the engine-provided candidate list. + if (validBlockerIds.includes(objectId) && validBlockTargets[objectId]?.length > 0) { + setPendingBlocker((current) => current === objectId ? null : objectId); return; } - if (pendingBlocker === null) { - // First click: select a valid blocker (must have at least one valid target) - if (validBlockerIds.includes(objectId) && validBlockTargets[objectId]?.length > 0) { - setPendingBlocker(objectId); - } - } else { - // Second click: assign to an attacker (only if engine says this pair is valid) + if (pendingBlocker !== null) { const validTargetsForBlocker = validBlockTargets[pendingBlocker] ?? []; if (combatAttackerIds.includes(objectId) && validTargetsForBlocker.includes(objectId)) { - assignBlocker(pendingBlocker, objectId); - setPendingBlocker(null); + if (blockerAssignments.get(pendingBlocker)?.has(objectId)) { + removeBlockerAssignment(pendingBlocker, objectId); + } else { + assignBlocker(pendingBlocker, objectId); + } } } }, @@ -241,7 +235,7 @@ export function ActionButton() { function handleConfirmBlockers() { dispatchAction({ type: "DeclareBlockers", - data: { assignments: Array.from(blockerAssignments.entries()) }, + data: { assignments: blockerPairs }, }); } @@ -317,14 +311,14 @@ export function ActionButton() { {mode === "combat-blockers" && ( <> - {blockerAssignments.size > 0 ? ( + {blockerPairs.length > 0 ? ( <> + {open && (
["t"], +): string { + switch (phase) { + case "resolving": + return t("nativeEngineProgress.resolving"); + case "downloading_binary": + return t("nativeEngineProgress.downloadingBinary"); + case "verifying": + return t("nativeEngineProgress.verifying"); + case "downloading_data": + return t("nativeEngineProgress.downloadingData"); + case "spawning": + return t("nativeEngineProgress.spawning"); + case "ready": + return t("nativeEngineProgress.ready"); + case "failed": + return t("nativeEngineProgress.failed"); + } +} + +/** App-wide status surface for shell-managed native-server provisioning. */ +export function NativeEngineProgressOverlay() { + const { t } = useTranslation("common"); + const provisioning = useNativeEngineProvisioning(); + const [progress, setProgress] = useState(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + let active = true; + let unlisten: (() => void) | undefined; + let receivedLiveProgress = false; + + void subscribeNativeEngineProgress((next) => { + receivedLiveProgress = true; + if (active) setProgress(next); + }).then((registeredUnlisten) => { + if (!active) { + registeredUnlisten(); + return; + } + unlisten = registeredUnlisten; + return getNativeEngineProgress().then((latest) => { + if (active && !receivedLiveProgress && latest) setProgress(latest); + }); + }).catch(() => { + // The browser build has no Tauri event bridge to subscribe to. + }); + + return () => { + active = false; + unlisten?.(); + }; + }, []); + + // A run's phases belong to that run: a previous attempt's terminal label must + // not caption the next one before its first event lands. Seeded with the + // mount-time value so only a run that starts while we are mounted clears the + // label. Note the replayed phase above is label-only — visibility follows the + // call, so a webview that reloads mid-run shows nothing until the next call. + const previousProvisioning = useRef(provisioning); + useEffect(() => { + if (provisioning && !previousProvisioning.current) setProgress(null); + previousProvisioning.current = provisioning; + }, [provisioning]); + + // Announce provisioning only once the shell has been working long enough to + // be worth interrupting for. Reusing an already-running engine answers in + // milliseconds, and a full-screen blink on every game start reads as a bug. + useEffect(() => { + if (!provisioning) return; + + const timeout = window.setTimeout(() => setVisible(true), REVEAL_DELAY_MS); + return () => window.clearTimeout(timeout); + }, [provisioning]); + + // The overlay then lives for as long as the shell is working, plus a beat to + // read the outcome. Deliberately NOT driven by receiving a terminal phase — + // an older shell fails without emitting one, which pinned this overlay over + // the running game for the rest of the session. + useEffect(() => { + if (provisioning || !visible) return; + + const timeout = window.setTimeout( + () => setVisible(false), + progress?.phase === "failed" ? FAILURE_DISMISS_DELAY_MS : DISMISS_DELAY_MS, + ); + return () => window.clearTimeout(timeout); + }, [provisioning, visible, progress]); + + return ( + + {visible && ( + + +
+
+
+

+ {t("nativeEngineProgress.title")} +

+

+ {phaseLabel(progress?.phase ?? "resolving", t)} +

+ {progress?.detail && ( +

+ {progress.detail} +

+ )} + + + )} + + ); +} diff --git a/client/src/components/chrome/__tests__/EngineModeBadge.test.tsx b/client/src/components/chrome/__tests__/EngineModeBadge.test.tsx new file mode 100644 index 0000000000..8ff1d456ab --- /dev/null +++ b/client/src/components/chrome/__tests__/EngineModeBadge.test.tsx @@ -0,0 +1,118 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + canAttemptNativeEngine: vi.fn(() => true), + gameState: { + engineMode: null as "native" | "wasm" | null, + nativeEngineFallbackReason: null as string | null, + }, + preferences: { nativeEngineEnabled: true }, +})); + +vi.mock("../../../services/nativeEngine", () => ({ + canAttemptNativeEngine: mocks.canAttemptNativeEngine, +})); + +vi.mock("../../../stores/gameStore", () => ({ + useGameStore: (selector: (state: typeof mocks.gameState) => unknown) => selector(mocks.gameState), +})); + +vi.mock("../../../stores/preferencesStore", () => ({ + usePreferencesStore: (selector: (state: typeof mocks.preferences) => unknown) => + selector(mocks.preferences), +})); + +import { EngineModeBadge } from "../EngineModeBadge"; + +describe("EngineModeBadge", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.canAttemptNativeEngine.mockReturnValue(true); + mocks.gameState.engineMode = null; + mocks.gameState.nativeEngineFallbackReason = null; + mocks.preferences.nativeEngineEnabled = true; + }); + + afterEach(cleanup); + + it("names the engine actually driving a native game", () => { + mocks.gameState.engineMode = "native"; + + render(); + + expect(screen.getByText("Native engine")).toBeInTheDocument(); + }); + + it("explains a silent fallback to the in-browser engine", () => { + mocks.gameState.engineMode = "wasm"; + mocks.gameState.nativeEngineFallbackReason = "native_engine_unavailable"; + + render(); + + expect(screen.getByText("In-browser engine")).toHaveAttribute( + "title", + "Native engine unavailable — this game is running in-browser.", + ); + }); + + it("distinguishes a version mismatch from an unavailable engine", () => { + mocks.gameState.engineMode = "wasm"; + mocks.gameState.nativeEngineFallbackReason = "server_version_mismatch"; + + render(); + + expect(screen.getByText("In-browser engine")).toHaveAttribute( + "title", + "Native engine version mismatch — this game is running in-browser.", + ); + }); + + it("does not blame the engine for a game that never asked for it", () => { + // Draft matches, a chosen first player, and resuming a WASM save all set + // "wasm" with no reason. Claiming unavailability there would be false on a + // machine whose native engine works fine. + mocks.gameState.engineMode = "wasm"; + mocks.gameState.nativeEngineFallbackReason = null; + + render(); + + expect(screen.getByText("In-browser engine")).toHaveAttribute( + "title", + "This game is running in-browser — the native engine wasn't used for it.", + ); + }); + + it("still reports an unrecognized fallback reason as a failure", () => { + // Every non-null reason means an attempt failed. A reason this build has + // not heard of must not be downgraded to "never asked for it". + mocks.gameState.engineMode = "wasm"; + mocks.gameState.nativeEngineFallbackReason = "some_future_reason"; + + render(); + + expect(screen.getByText("In-browser engine")).toHaveAttribute( + "title", + "Native engine unavailable — this game is running in-browser.", + ); + }); + + it("stays out of games that never had a native option", () => { + // Web builds and non-AI games are in-browser by definition, so the badge + // would be permanent noise rather than a signal. + mocks.gameState.engineMode = "wasm"; + mocks.canAttemptNativeEngine.mockReturnValue(false); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("stays out of games with no engine selection at all", () => { + mocks.gameState.engineMode = null; + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/client/src/components/chrome/__tests__/NativeEngineProgressOverlay.test.tsx b/client/src/components/chrome/__tests__/NativeEngineProgressOverlay.test.tsx new file mode 100644 index 0000000000..a2824c7925 --- /dev/null +++ b/client/src/components/chrome/__tests__/NativeEngineProgressOverlay.test.tsx @@ -0,0 +1,220 @@ +import type { ReactNode } from "react"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { NativeEngineProgress } from "../../../services/nativeEngine"; + +const mocks = vi.hoisted(() => { + let listener: ((progress: NativeEngineProgress) => void) | undefined; + const unlisten = vi.fn(); + + return { + emitProgress(progress: NativeEngineProgress) { + listener?.(progress); + }, + subscribeNativeEngineProgress: vi.fn(async (next: (progress: NativeEngineProgress) => void) => { + listener = next; + return unlisten; + }), + // Annotated to the real signature: inferring from the `null` default would + // pin the mock to `Promise` and reject every replay value below. + getNativeEngineProgress: vi.fn(async (): Promise => null), + invoke: vi.fn(), + unlisten, + }; +}); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); + +// Only the shell's event bridge is faked. `ensureNativeEngine` and +// `useNativeEngineProvisioning` stay real so these tests exercise the actual +// coupling between an in-flight provisioning call and the overlay's lifetime. +vi.mock("../../../services/nativeEngine", async (importOriginal) => ({ + ...(await importOriginal()), + getNativeEngineProgress: mocks.getNativeEngineProgress, + subscribeNativeEngineProgress: mocks.subscribeNativeEngineProgress, +})); + +// Exit animations would keep the dismissed overlay mounted past the assertion; +// these tests are about when the overlay decides to leave, not how it leaves. +vi.mock("framer-motion", () => ({ + AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}, + motion: { + div: ({ + children, + initial: _initial, + animate: _animate, + exit: _exit, + ...props + }: { + children: ReactNode; + initial?: unknown; + animate?: unknown; + exit?: unknown; + } & Record) =>
{children}
, + }, +})); + +import { ensureNativeEngine, type NativeEngineKey } from "../../../services/nativeEngine"; +import { NativeEngineProgressOverlay } from "../NativeEngineProgressOverlay"; + +const KEY: NativeEngineKey = { release: { version: "1.2.3" } }; + +// The provisioning store is module state shared by every test in this file, so +// a call left unsettled by a failure would keep the next test's overlay open. +const unsettledCalls = new Set<(ok: boolean) => Promise>(); + +/** Starts a provisioning call whose shell response this test controls. */ +function startProvisioning() { + let settle!: (ok: boolean) => void; + // `ensureNativeEngine` reaches `invoke` only after its dynamic import + // resolves, so a test that settles immediately has to wait for that first. + const reachedShell = new Promise((resolveReached) => { + mocks.invoke.mockImplementationOnce( + () => + new Promise((resolve, reject) => { + settle = (ok) => (ok ? resolve({ port: 4321 }) : reject(new Error("spawn failed"))); + resolveReached(); + }), + ); + }); + // Starting the call flips the provisioning store the overlay subscribes to, + // so React has to commit that before a test can advance timers against it. + let call!: Promise; + act(() => { + call = ensureNativeEngine(KEY).catch(() => undefined); + }); + + const finish = async (ok: boolean) => { + unsettledCalls.delete(finish); + await act(async () => { + await reachedShell; + }); + settle(ok); + await act(async () => { + await call; + }); + }; + unsettledCalls.add(finish); + return { ready: () => finish(true), fail: () => finish(false) }; +} + +describe("NativeEngineProgressOverlay", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + for (const finish of [...unsettledCalls]) await finish(true); + cleanup(); + vi.useRealTimers(); + }); + + it("clearly shows native server downloads and their artifact key", async () => { + const provisioning = startProvisioning(); + render(); + + act(() => { + mocks.emitProgress({ + phase: "downloading_binary", + detail: "preview-0123456789abcdef", + }); + }); + + const status = await screen.findByRole("status"); + expect(status).toHaveTextContent("Updating native engine"); + expect(status).toHaveTextContent("Downloading updated server…"); + expect(status).toHaveTextContent("preview-0123456789abcdef"); + await provisioning.ready(); + }); + + it("shows native-engine completion as a non-busy status", async () => { + const provisioning = startProvisioning(); + render(); + + act(() => { + mocks.emitProgress({ phase: "ready" }); + }); + expect(await screen.findByRole("status")).toHaveAttribute("aria-busy", "true"); + + await provisioning.ready(); + + const status = screen.getByRole("status"); + expect(status).toHaveTextContent("Native engine ready"); + expect(status).toHaveAttribute("aria-busy", "false"); + }); + + it("dismisses a failed attempt that the shell never reports as terminal", async () => { + // Shells older than the `failed` phase stop emitting when provisioning + // fails. The overlay must still come down — it used to sit over the WASM + // fallback game for the rest of the session. + const provisioning = startProvisioning(); + render(); + + act(() => { + mocks.emitProgress({ phase: "spawning" }); + }); + expect(await screen.findByRole("status")).toHaveTextContent("Starting local server…"); + + vi.useFakeTimers(); + await provisioning.fail(); + act(() => { + vi.advanceTimersByTime(1_500); + }); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("does not flash over an engine that is already running", async () => { + // The overwhelmingly common call reuses a live engine and answers in + // milliseconds; a full-screen blink on every game start reads as a bug. The + // overlay must still be hidden that early even while the call is in flight. + vi.useFakeTimers(); + render(); + const provisioning = startProvisioning(); + + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + + await provisioning.ready(); + act(() => { + vi.advanceTimersByTime(2_000); + }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("stays hidden when nothing is being provisioned", async () => { + mocks.getNativeEngineProgress.mockResolvedValueOnce({ phase: "ready" }); + + render(); + await vi.waitFor(() => expect(mocks.getNativeEngineProgress).toHaveBeenCalledOnce()); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("replays progress emitted before the overlay mounted", async () => { + mocks.getNativeEngineProgress.mockResolvedValueOnce({ phase: "resolving" }); + const provisioning = startProvisioning(); + + render(); + + expect(await screen.findByRole("status")).toHaveTextContent("Finding the correct local server…"); + await provisioning.ready(); + }); + + it("keeps live progress when the replay snapshot is stale", async () => { + mocks.subscribeNativeEngineProgress.mockImplementationOnce(async (next) => { + next({ phase: "downloading_data" }); + return mocks.unlisten; + }); + mocks.getNativeEngineProgress.mockResolvedValueOnce({ phase: "resolving" }); + const provisioning = startProvisioning(); + + render(); + + expect(await screen.findByRole("status")).toHaveTextContent("Downloading game data…"); + await provisioning.ready(); + }); +}); diff --git a/client/src/components/combat/BlockRequirementBadges.tsx b/client/src/components/combat/BlockRequirementBadges.tsx index 78ce80313a..885353813c 100644 --- a/client/src/components/combat/BlockRequirementBadges.tsx +++ b/client/src/components/combat/BlockRequirementBadges.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import type { ObjectId } from "../../adapter/types.ts"; import { useGameStore } from "../../stores/gameStore.ts"; -import { useBlockRequirements, type BlockRequirement } from "./useBlockRequirements.ts"; +import { useBlockRequirements } from "./useBlockRequirements.ts"; interface Anchor { x: number; @@ -59,22 +59,11 @@ function useAttackerAnchors(attackerIds: ObjectId[]): Map { return anchors; } -function badgeTone(status: BlockRequirement["status"]): string { - switch (status) { - case "satisfied": - return "border-emerald-300/60 bg-emerald-950/85 text-emerald-100"; - case "incomplete": - return "border-amber-300/70 bg-amber-950/90 text-amber-100 animate-pulse"; - case "pending": - return "border-rose-300/50 bg-rose-950/85 text-rose-100"; - } -} - /** * Floating "needs N blockers" badge over each attacker with a minimum-blocker * requirement (menace / "blocked by N or more"). Renders only while the local - * player is assigning blockers. Pure display of engine-provided requirements vs - * the player's in-progress assignments (see `useBlockRequirements`). + * player is assigning blockers. It displays engine-provided requirements only; + * the engine validates the submitted declaration. */ export function BlockRequirementBadges() { const { t } = useTranslation("game"); @@ -90,16 +79,8 @@ export function BlockRequirementBadges() { {Array.from(byAttacker.values()).map((req) => { const anchor = anchors.get(req.attackerId); if (!anchor) return null; - const label = - req.status === "satisfied" - ? t("combat.blockSatisfiedBadge", { required: req.required }) - : req.status === "incomplete" - ? t("combat.blockProgressBadge", { assigned: req.assigned, required: req.required }) - : t("combat.blockNeedsBadge", { required: req.required }); - const baseTitle = - req.status === "incomplete" - ? t("combat.blockIncompleteAttacker", { assigned: req.assigned, required: req.required }) - : t("combat.menaceRequirement", { count: req.required }); + const label = t("combat.blockNeedsBadge", { required: req.required }); + const baseTitle = t("combat.menaceRequirement", { count: req.required }); // Display-only source attribution: suppress the self-source (Menace's // carrier is the attacker itself → bare badge) and skip ids that no // longer resolve (departed-source guard), mirroring AttackRequirementBadges. @@ -118,7 +99,7 @@ export function BlockRequirementBadges() { > {/* Two-pronged "menace" glyph — a single attacker needing multiple blockers. */} diff --git a/client/src/components/combat/BlockerConstraintBadges.tsx b/client/src/components/combat/BlockerConstraintBadges.tsx index 81755c3075..732022e89e 100644 --- a/client/src/components/combat/BlockerConstraintBadges.tsx +++ b/client/src/components/combat/BlockerConstraintBadges.tsx @@ -59,8 +59,6 @@ function useObjectAnchors(objectIds: ObjectId[]): Map { function badgeTone(status: BlockerConstraint["status"]): string { switch (status) { - case "satisfied": - return "border-emerald-300/60 bg-emerald-950/85 text-emerald-100"; case "pending": return "border-rose-300/50 bg-rose-950/85 text-rose-100 animate-pulse"; case "info": @@ -89,11 +87,14 @@ export function BlockerConstraintBadges() { {Array.from(byObject.values()).map((req) => { const anchor = anchors.get(req.objectId); if (!anchor) return null; + const exactNames = req.attackers + .map((id) => objects?.[String(id)]?.name) + .filter((name): name is string => !!name); const label = req.kind === "CantBlock" ? t("combat.cantBlockBadge") - : req.status === "satisfied" - ? t("combat.mustBlockSatisfiedBadge") + : exactNames.length > 0 + ? t("combat.mustBlockExactBadge", { attacker: exactNames.join(", ") }) : t("combat.mustBlockBadge"); // Display-only source attribution: suppress a self-source (intrinsic // requirement shows a bare badge) and skip ids that no longer resolve diff --git a/client/src/components/combat/__tests__/combatRequirements.test.tsx b/client/src/components/combat/__tests__/combatRequirements.test.tsx index 7e9e9bfae8..4bf20377da 100644 --- a/client/src/components/combat/__tests__/combatRequirements.test.tsx +++ b/client/src/components/combat/__tests__/combatRequirements.test.tsx @@ -8,6 +8,7 @@ import { AttackRequirementBadges } from "../AttackRequirementBadges.tsx"; import { BlockerConstraintBadges } from "../BlockerConstraintBadges.tsx"; import { useAttackRequirements } from "../useAttackRequirements.ts"; import { useBlockerConstraints } from "../useBlockerConstraints.ts"; +import { useBlockRequirements } from "../useBlockRequirements.ts"; function setWaitingFor(waitingFor: WaitingFor | undefined) { useGameStore.setState({ waitingFor }); @@ -113,10 +114,10 @@ describe("useBlockerConstraints", () => { it("does not throw on an empty or undefined constraint map", () => { setWaitingFor(undefined); - expect(renderHook(() => useBlockerConstraints()).result.current.unsatisfiedMustBlockCount).toBe(0); + expect(renderHook(() => useBlockerConstraints()).result.current.byObject.size).toBe(0); }); - it("satisfies the must-block gate when the creature is assigned (1 -> 0)", () => { + it("keeps must-block as an engine-validated display state without exposing a gate", () => { setWaitingFor({ type: "DeclareBlockers", data: { @@ -129,13 +130,33 @@ describe("useBlockerConstraints", () => { useUiStore.setState({ blockerAssignments: new Map() }); let r = renderHook(() => useBlockerConstraints()); - expect(r.result.current.unsatisfiedMustBlockCount).toBe(1); expect(r.result.current.byObject.get(100)?.status).toBe("pending"); + expect("unsatisfiedMustBlockCount" in r.result.current).toBe(false); - useUiStore.setState({ blockerAssignments: new Map([[100, 200]]) }); + useUiStore.setState({ blockerAssignments: new Map([[100, new Set([200])]]) }); r = renderHook(() => useBlockerConstraints()); - expect(r.result.current.unsatisfiedMustBlockCount).toBe(0); - expect(r.result.current.byObject.get(100)?.status).toBe("satisfied"); + expect(r.result.current.byObject.get(100)?.status).toBe("pending"); + }); + + it("retains engine-provided exact must-block attackers without locally validating pairs", () => { + setWaitingFor({ + type: "DeclareBlockers", + data: { + player: 0, + valid_blocker_ids: [100], + valid_block_targets: { "100": [200, 201] }, + blocker_constraints: { "100": { kind: "MustBlock", attackers: [200] } }, + }, + }); + + useUiStore.setState({ blockerAssignments: new Map([[100, new Set([201])]]) }); + let r = renderHook(() => useBlockerConstraints()); + expect(r.result.current.byObject.get(100)?.status).toBe("pending"); + expect(r.result.current.byObject.get(100)?.attackers).toEqual([200]); + + useUiStore.setState({ blockerAssignments: new Map([[100, new Set([200, 201])]]) }); + r = renderHook(() => useBlockerConstraints()); + expect(r.result.current.byObject.get(100)?.status).toBe("pending"); }); it("passes engine-provided sources through unchanged (display-only)", () => { @@ -153,6 +174,30 @@ describe("useBlockerConstraints", () => { }); }); +describe("useBlockRequirements", () => { + beforeEach(() => { + useUiStore.setState({ selectedAttackers: [], blockerAssignments: new Map() }); + }); + afterEach(() => setWaitingFor(undefined)); + + it("does not infer a minimum-blocker requirement's satisfaction from local pairs", () => { + setWaitingFor({ + type: "DeclareBlockers", + data: { + player: 0, + valid_blocker_ids: [100], + valid_block_targets: { "100": [200] }, + block_requirements: { "200": { count: 2, sources: [300] } }, + }, + }); + + useUiStore.setState({ blockerAssignments: new Map([[100, new Set([200])]]) }); + const requirement = renderHook(() => useBlockRequirements()).result.current.byAttacker.get(200); + expect(requirement).toMatchObject({ attackerId: 200, required: 2, sources: [300] }); + expect("status" in (requirement ?? {})).toBe(false); + }); +}); + describe("AttackRequirementBadges source attribution (display-only)", () => { beforeEach(() => { useUiStore.setState({ selectedAttackers: [], blockerAssignments: new Map() }); diff --git a/client/src/components/combat/useBlockRequirements.ts b/client/src/components/combat/useBlockRequirements.ts index 846a93a88d..5e027bc99a 100644 --- a/client/src/components/combat/useBlockRequirements.ts +++ b/client/src/components/combat/useBlockRequirements.ts @@ -1,78 +1,48 @@ import { useMemo } from "react"; import { useGameStore } from "../../stores/gameStore.ts"; -import { useUiStore } from "../../stores/uiStore.ts"; import type { ObjectId } from "../../adapter/types.ts"; /** - * Status of an attacker's minimum-blocker requirement, derived from the - * engine-provided `block_requirements` (CR 702.111b Menace / CR 509.1b) and the - * player's in-progress assignments: - * - `pending` — not yet blocked (legal: "or not at all"). - * - `incomplete` — blocked by 1+ but fewer than required (illegal to confirm). - * - `satisfied` — blocked by at least the required count. + * Engine-provided minimum-blocker requirement (CR 702.111b Menace / CR 509.1b). + * The engine alone determines whether an in-progress declaration is legal. */ -export type BlockRequirementStatus = "pending" | "incomplete" | "satisfied"; - export interface BlockRequirement { attackerId: ObjectId; required: number; - assigned: number; - status: BlockRequirementStatus; /** CR 702.111b / CR 509.1b: permanents imposing the min-blocker floor. */ sources: ObjectId[]; } export interface BlockRequirements { byAttacker: Map; - /** Any attacker started but not finished — confirmation must be blocked. */ - hasIncomplete: boolean; } -const EMPTY: BlockRequirements = { byAttacker: new Map(), hasIncomplete: false }; +const EMPTY: BlockRequirements = { byAttacker: new Map() }; /** - * Compares the engine-declared per-attacker minimum-blocker requirements against - * the player's current assignments. The requirement values come entirely from - * the engine (`DeclareBlockers.block_requirements`); this only counts the user's - * own pending selections against them — no game-rules logic lives here. + * Returns the engine-declared per-attacker minimum-blocker requirements. */ export function useBlockRequirements(): BlockRequirements { const blockRequirements = useGameStore((s) => s.waitingFor?.type === "DeclareBlockers" ? s.waitingFor.data.block_requirements : undefined, ); - const blockerAssignments = useUiStore((s) => s.blockerAssignments); return useMemo(() => { if (!blockRequirements || Object.keys(blockRequirements).length === 0) { return EMPTY; } - // blockerAssignments maps blockerId -> attackerId; invert to count blockers - // assigned to each attacker. - const assignedPerAttacker = new Map(); - for (const attackerId of blockerAssignments.values()) { - assignedPerAttacker.set(attackerId, (assignedPerAttacker.get(attackerId) ?? 0) + 1); - } - const byAttacker = new Map(); - let hasIncomplete = false; for (const [attackerKey, requirement] of Object.entries(blockRequirements)) { const attackerId = Number(attackerKey); - const required = requirement.count; - const assigned = assignedPerAttacker.get(attackerId) ?? 0; - const status: BlockRequirementStatus = - assigned === 0 ? "pending" : assigned < required ? "incomplete" : "satisfied"; - if (status === "incomplete") hasIncomplete = true; byAttacker.set(attackerId, { attackerId, - required, - assigned, - status, + required: requirement.count, sources: requirement.sources ?? [], }); } - return { byAttacker, hasIncomplete }; - }, [blockRequirements, blockerAssignments]); + return { byAttacker }; + }, [blockRequirements]); } diff --git a/client/src/components/combat/useBlockerConstraints.ts b/client/src/components/combat/useBlockerConstraints.ts index db17dab5c7..9b66361aab 100644 --- a/client/src/components/combat/useBlockerConstraints.ts +++ b/client/src/components/combat/useBlockerConstraints.ts @@ -1,20 +1,16 @@ import { useMemo } from "react"; import { useGameStore } from "../../stores/gameStore.ts"; -import { useUiStore } from "../../stores/uiStore.ts"; import type { CombatRequirement, ObjectId } from "../../adapter/types.ts"; /** * Per-creature blocker-constraint status, derived from the engine-provided * `blocker_constraints` (CR 509.1c must-block / CR 509.1b can't-block) and the - * player's in-progress blocker assignments: - * - `pending` — a MustBlock creature not yet assigned to any attacker (illegal - * to confirm). The engine already excludes must-block creatures - * with zero legal targets, so no target check is needed here. - * - `satisfied` — a MustBlock creature currently assigned. + * engine-provided constraints: + * - `pending` — a MustBlock requirement the engine still needs to validate. * - `info` — a CantBlock creature (informational; can never be assigned). */ -export type BlockerConstraintStatus = "pending" | "satisfied" | "info"; +export type BlockerConstraintStatus = "pending" | "info"; export interface BlockerConstraint { objectId: ObjectId; @@ -22,50 +18,44 @@ export interface BlockerConstraint { status: BlockerConstraintStatus; /** Engine-provided objects imposing this constraint (CR 509.1b/c). */ sources: ObjectId[]; + /** Engine-provided exact attackers this creature is asked to block. */ + attackers: ObjectId[]; } export interface BlockerConstraints { byObject: Map; - /** MustBlock creatures not yet assigned — confirmation must be blocked. */ - unsatisfiedMustBlockCount: number; } -const EMPTY: BlockerConstraints = { byObject: new Map(), unsatisfiedMustBlockCount: 0 }; +const EMPTY: BlockerConstraints = { byObject: new Map() }; /** * Compares the engine-declared per-creature blocker constraints against the * player's current assignments. All constraint values come entirely from the - * engine (`DeclareBlockers.blocker_constraints`); this only counts the user's own - * in-progress assignments against them — no game-rules logic lives here. + * engine (`DeclareBlockers.blocker_constraints`). It deliberately does not infer + * that a local assignment satisfies a requirement: CR 509.1c validation belongs + * exclusively to the engine. */ export function useBlockerConstraints(): BlockerConstraints { const blockerConstraints = useGameStore((s) => s.waitingFor?.type === "DeclareBlockers" ? s.waitingFor.data.blocker_constraints : undefined, ); - const blockerAssignments = useUiStore((s) => s.blockerAssignments); - return useMemo(() => { if (!blockerConstraints || Object.keys(blockerConstraints).length === 0) { return EMPTY; } - // blockerAssignments maps blockerId -> attackerId; a must-block creature is - // satisfied once it appears as a key (assigned to some attacker). const byObject = new Map(); - let unsatisfiedMustBlockCount = 0; for (const [key, requirement] of Object.entries(blockerConstraints)) { const objectId = Number(key); if (requirement.kind === "MustBlock") { - const status: BlockerConstraintStatus = blockerAssignments.has(objectId) - ? "satisfied" - : "pending"; - if (status === "pending") unsatisfiedMustBlockCount += 1; + const requiredAttackers = requirement.attackers ?? []; byObject.set(objectId, { objectId, kind: requirement.kind, - status, + status: "pending", sources: requirement.sources ?? [], + attackers: requiredAttackers, }); } else if (requirement.kind === "CantBlock") { byObject.set(objectId, { @@ -73,10 +63,11 @@ export function useBlockerConstraints(): BlockerConstraints { kind: requirement.kind, status: "info", sources: requirement.sources ?? [], + attackers: [], }); } } - return { byObject, unsatisfiedMustBlockCount }; - }, [blockerConstraints, blockerAssignments]); + return { byObject }; + }, [blockerConstraints]); } diff --git a/client/src/components/hand/MobileHandDrawer.tsx b/client/src/components/hand/MobileHandDrawer.tsx index 3b577ebaae..b860901ee8 100644 --- a/client/src/components/hand/MobileHandDrawer.tsx +++ b/client/src/components/hand/MobileHandDrawer.tsx @@ -294,7 +294,7 @@ const DrawerCard = memo(function DrawerCard({ {/* @container overlay sized to the card so the pips scale in cqi with the drawer card's width instead of a fixed px size. */}
- +
); diff --git a/client/src/components/hand/MobileHeldHandCard.tsx b/client/src/components/hand/MobileHeldHandCard.tsx index bb4eeb8759..934a22ff3b 100644 --- a/client/src/components/hand/MobileHeldHandCard.tsx +++ b/client/src/components/hand/MobileHeldHandCard.tsx @@ -128,12 +128,7 @@ export function MobileHeldHandCard({ gesture, object }: MobileHeldHandCardProps) className="!h-full !w-full" />
- +
, document.body, diff --git a/client/src/components/hand/PlayerHand.tsx b/client/src/components/hand/PlayerHand.tsx index 5dd1d7dc48..f53e1811ad 100644 --- a/client/src/components/hand/PlayerHand.tsx +++ b/client/src/components/hand/PlayerHand.tsx @@ -1015,7 +1015,7 @@ const HandCard = memo(function HandCard({ from the card wrapper, so container-type can't collapse it); lets the pips scale in cqi with --hand-card-w instead of a fixed px size. */}
- +
@@ -1151,7 +1151,7 @@ const ZoneFanCard = memo(function ZoneFanCard({ {/* @container overlay sized to the card so the pips scale in cqi with --hand-card-w (see the hand-card render above). */}
- +
); diff --git a/client/src/components/hud/__tests__/NextUpBadge.test.tsx b/client/src/components/hud/__tests__/NextUpBadge.test.tsx index 1f7870d6ac..8f13f92641 100644 --- a/client/src/components/hud/__tests__/NextUpBadge.test.tsx +++ b/client/src/components/hud/__tests__/NextUpBadge.test.tsx @@ -23,9 +23,9 @@ describe("NextUpBadge", () => { gameState: buildGameState({ derived: { turn_order: [ - { player: 0, slot_index: 0, turns_from_now: 0 }, - { player: 2, slot_index: 1, turns_from_now: 1 }, - { player: 0, slot_index: 2, turns_from_now: 2 }, + { player: 0, slot_index: 0, turns_from_now: 0, turn_number: 1 }, + { player: 2, slot_index: 1, turns_from_now: 1, turn_number: 2 }, + { player: 0, slot_index: 2, turns_from_now: 2, turn_number: 3 }, ], }, }), @@ -40,8 +40,8 @@ describe("NextUpBadge", () => { gameState: buildGameState({ derived: { turn_order: [ - { player: 0, slot_index: 0, turns_from_now: 0 }, - { player: 2, slot_index: 1, turns_from_now: 1 }, + { player: 0, slot_index: 0, turns_from_now: 0, turn_number: 1 }, + { player: 2, slot_index: 1, turns_from_now: 1, turn_number: 2 }, ], }, }), diff --git a/client/src/components/hud/__tests__/OpponentHud.test.tsx b/client/src/components/hud/__tests__/OpponentHud.test.tsx index 2c8138af7e..5e5b8d63d1 100644 --- a/client/src/components/hud/__tests__/OpponentHud.test.tsx +++ b/client/src/components/hud/__tests__/OpponentHud.test.tsx @@ -68,7 +68,7 @@ describe("OpponentHud", () => { useGameStore.setState({ gameState: createGameState({ derived: { - turn_order: [{ player: 2, slot_index: 1, turns_from_now: 1 }], + turn_order: [{ player: 2, slot_index: 1, turns_from_now: 1, turn_number: 2 }], }, }), }); @@ -84,9 +84,9 @@ describe("OpponentHud", () => { seat_order: [0, 3, 1, 2], derived: { turn_order: [ - { player: 2, slot_index: 1, turns_from_now: 1 }, - { player: 3, slot_index: 2, turns_from_now: 2 }, - { player: 1, slot_index: 3, turns_from_now: 3 }, + { player: 2, slot_index: 1, turns_from_now: 1, turn_number: 2 }, + { player: 3, slot_index: 2, turns_from_now: 2, turn_number: 3 }, + { player: 1, slot_index: 3, turns_from_now: 3, turn_number: 4 }, ], }, }), diff --git a/client/src/components/hud/__tests__/PlayerHud.test.tsx b/client/src/components/hud/__tests__/PlayerHud.test.tsx index 3302048295..d04ea4cd02 100644 --- a/client/src/components/hud/__tests__/PlayerHud.test.tsx +++ b/client/src/components/hud/__tests__/PlayerHud.test.tsx @@ -49,8 +49,8 @@ describe("PlayerHud", () => { gameState: buildGameState({ derived: { turn_order: [ - { player: 0, slot_index: 1, turns_from_now: 1 }, - { player: 0, slot_index: 2, turns_from_now: 2 }, + { player: 0, slot_index: 1, turns_from_now: 1, turn_number: 2 }, + { player: 0, slot_index: 2, turns_from_now: 2, turn_number: 3 }, ], }, }), diff --git a/client/src/components/mana/ManaCostPips.tsx b/client/src/components/mana/ManaCostPips.tsx index 483d13ed8e..46b5988315 100644 --- a/client/src/components/mana/ManaCostPips.tsx +++ b/client/src/components/mana/ManaCostPips.tsx @@ -2,24 +2,38 @@ import type { ManaCost } from "../../adapter/types.ts"; import { manaCostToShards } from "../../viewmodel/costLabel.ts"; import { ManaSymbol } from "./ManaSymbol.tsx"; -type PipSize = "2xs" | "xs" | "sm" | "md" | "lg" | "fluid"; +type PipSize = "2xs" | "xs" | "sm" | "md" | "fluid"; const PIP_SIZES: Record = { "2xs": { container: "w-[10px] h-[10px] p-[0px]", gap: "gap-[0.5px]", backdrop: "-inset-x-[1px] top-[2px] -bottom-[3px]" }, xs: { container: "w-[12px] h-[12px] p-[0px]", gap: "gap-[0.5px]", backdrop: "-inset-x-[1px] top-[2px] -bottom-[4px]" }, sm: { container: "w-[18px] h-[18px] p-[0px]", gap: "gap-[1px]", backdrop: "-inset-x-[2px] top-[4px] -bottom-[8px]" }, md: { container: "w-[22px] h-[22px] p-[2px]", gap: "gap-[1px]", backdrop: "-inset-x-[3px] -top-[2px] -bottom-[4px]" }, - lg: { container: "w-[28px] h-[28px] py-[2px] px-[2.5px]", gap: "gap-[0.5px]", backdrop: "-inset-x-[3px] -top-[2px] -bottom-[4px]" }, // Card-relative sizing in container-query inline units (1cqi = 1% of the // nearest `@container` ancestor's width). Consumers that pass `size="fluid"` // MUST wrap the pips in an element with `container-type: inline-size` sized to - // the card (e.g. an `absolute inset-0 @container` overlay). 15cqi matches the - // fixed `md` (22px) pip on a ~145px desktop hand card, then scales down with - // the card so pips never look oversized on smaller layouts. Secondary - // dimensions use the same md ratios (p 9.1%, gap 4.5%, backdrop 13.6/9.1/18.2%). - fluid: { container: "w-[15cqi] h-[15cqi] p-[1.4cqi]", gap: "gap-[0.7cqi]", backdrop: "-inset-x-[2cqi] -top-[1.4cqi] -bottom-[2.7cqi]" }, + // the card (e.g. an `absolute inset-0 @container` overlay); the badge then + // anchors itself over the printed cost via FLUID_ANCHOR. + // + // The badge stands in for the card's PRINTED mana cost (it shows the engine's + // effective cost), so its geometry is calibrated against that cost rather than + // against any fixed px size. Measured on M15-frame art, a printed symbol is + // ~5.2% of the card's width, so the 0.4cqi padding sizes the symbol to exactly + // that and the 6cqi disk reads as a thin ring around it. The printed symbols + // on the same card are legible at this size, which is what makes it enough. + // + // Keep the three values solving 5*container + 4*gap = 32cqi: that is the + // widest cost the frame carries (five symbols), and 32cqi is what clears the + // card name instead of running through it. + fluid: { container: "w-[6cqi] h-[6cqi] p-[0.4cqi]", gap: "gap-[0.5cqi]", backdrop: "-inset-x-[0.5cqi] -top-[0.5cqi] -bottom-[1cqi]" }, }; +// Where the printed cost sits on an M15 frame: right edge ~7% in from the card's +// right edge, top edge ~5.4% down from its top. Owning this here keeps the +// anchor in lockstep with the pip diameter above — the two only look right +// together, and every card overlay wants the same placement. +const FLUID_ANCHOR = "absolute right-[6.5%] top-[5%]"; + interface ManaCostPipsProps { cost: ManaCost; isReduced?: boolean; @@ -37,7 +51,7 @@ export function ManaCostPips({ cost, isReduced, size = "md", className = "" }: M const s = PIP_SIZES[size]; return ( -
+
{/* ChoiceModal uses brace-delimited mana/tap notation like {W} and {T}. */} - {text.split(SYMBOL_PATTERN).map((part, i) => - i % 2 === 0 ? ( - part - ) : ( - - ), - )} + {text.split(SYMBOL_PATTERN).map((part, i) => { + if (i % 2 === 0) return part; + if (!isManaSymbolShard(part)) return `{${part}}`; + return ; + })} ); } diff --git a/client/src/components/mana/__tests__/ManaSymbol.test.tsx b/client/src/components/mana/__tests__/ManaSymbol.test.tsx new file mode 100644 index 0000000000..bcd3cf6318 --- /dev/null +++ b/client/src/components/mana/__tests__/ManaSymbol.test.tsx @@ -0,0 +1,32 @@ +import { render, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { RichLabel } from "../RichLabel.tsx"; + +describe("RichLabel", () => { + it("renders valid mana notation as a symbol", () => { + const { container } = render(); + + expect(within(container).getByAltText("G")).toBeInTheDocument(); + }); + + it("keeps non-mana brace content as text", () => { + const { container } = render(); + + expect(within(container).getByText("Pay Fixed { value: 2 } life")).toBeInTheDocument(); + expect(within(container).queryByRole("img")).not.toBeInTheDocument(); + }); + + it.each(["2/W", "W/U/P"])("renders supported composite notation %s as a symbol", (shard) => { + const { container } = render(); + + expect(within(container).getByAltText(shard)).toBeInTheDocument(); + }); + + it.each(["W/X", "2/X"])("keeps unsupported composite notation %s as text", (shard) => { + const { container } = render(); + + expect(within(container).getByText(`Pay {${shard}}.`)).toBeInTheDocument(); + expect(within(container).queryByRole("img")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/menu/home/HomeDashboard.tsx b/client/src/components/menu/home/HomeDashboard.tsx index 98e108fed1..6fdd85bd5d 100644 --- a/client/src/components/menu/home/HomeDashboard.tsx +++ b/client/src/components/menu/home/HomeDashboard.tsx @@ -188,21 +188,28 @@ function ActiveDeckCard() { const count = randomDeckSelected ? 0 : getDeckCardCount(name); const colors = randomDeckSelected ? [] : getDeckColorIdentity(name); return ( - +
); } diff --git a/client/src/components/modal/AdventureCastModal.tsx b/client/src/components/modal/AdventureCastModal.tsx index e13905bf86..e310e0a54d 100644 --- a/client/src/components/modal/AdventureCastModal.tsx +++ b/client/src/components/modal/AdventureCastModal.tsx @@ -1,29 +1,52 @@ import { useTranslation } from "react-i18next"; import type { GameAction } from "../../adapter/types.ts"; +import { useGameDispatch } from "../../hooks/useGameDispatch.ts"; import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { DialogShell } from "./DialogShell.tsx"; +type ChooseAdventureFaceAction = Extract; + export function AdventureCastModal() { const canActForWaitingState = useCanActForWaitingState(); const waitingFor = useGameStore((s) => s.waitingFor); - const dispatch = useGameStore((s) => s.dispatch); + const legalActions = useGameStore((s) => s.legalActions); + const dispatch = useGameDispatch(); if (waitingFor?.type !== "CastOffer" || waitingFor.data.kind.type !== "Adventure") return null; if (!canActForWaitingState) return null; const kind = waitingFor.data.kind; + const creatureAction = legalActions.find( + (action): action is ChooseAdventureFaceAction => + action.type === "ChooseAdventureFace" && action.data.creature, + ); + const adventureAction = legalActions.find( + (action): action is ChooseAdventureFaceAction => + action.type === "ChooseAdventureFace" && !action.data.creature, + ); - return ; + return ( + + ); } function AdventureCastContent({ objectId, + creatureAction, + adventureAction, dispatch, }: { objectId: number; - dispatch: (action: GameAction) => Promise; + creatureAction: ChooseAdventureFaceAction | undefined; + adventureAction: ChooseAdventureFaceAction | undefined; + dispatch: (action: GameAction) => Promise; }) { const { t } = useTranslation("game"); const obj = useGameStore((s) => s.gameState?.objects[objectId]); @@ -41,32 +64,32 @@ function AdventureCastContent({ previewObjectId={objectId} >
- - + {creatureAction && ( + + )} + {adventureAction && ( + + )}
); diff --git a/client/src/components/modal/GiftRecipientModal.tsx b/client/src/components/modal/GiftRecipientModal.tsx new file mode 100644 index 0000000000..19a32a26ab --- /dev/null +++ b/client/src/components/modal/GiftRecipientModal.tsx @@ -0,0 +1,60 @@ +import { useTranslation } from "react-i18next"; + +import type { GameAction, WaitingFor } from "../../adapter/types.ts"; +import { useGameDispatch } from "../../hooks/useGameDispatch.ts"; +import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts"; +import { useGameStore } from "../../stores/gameStore.ts"; +import { getOpponentDisplayName } from "../../stores/multiplayerStore.ts"; +import { ChoiceModal } from "./ChoiceModal.tsx"; + +type GiftRecipientWaitingFor = Extract; + +interface GiftRecipientModalContentProps { + waitingFor: GiftRecipientWaitingFor; + dispatch: (action: GameAction) => void | Promise; +} + +/** + * CR 702.174a: After promising a Gift with ≥2 opponents, the caster chooses + * which opponent receives the gift. + * + * Candidate order is engine-owned (`players::opponents` seat order) — render as + * received; do not re-sort on the client. + */ +export function GiftRecipientModalContent({ + waitingFor, + dispatch, +}: GiftRecipientModalContentProps) { + const { t } = useTranslation("game"); + const candidates = waitingFor.data.candidates; + + return ( + ({ + id: String(opponent), + label: getOpponentDisplayName(opponent), + }))} + onChoose={(id) => { + dispatch({ + type: "ChooseGiftRecipient", + data: { opponent: Number(id) }, + }); + }} + /> + ); +} + +export function GiftRecipientModal() { + const canActForWaitingState = useCanActForWaitingState(); + const dispatch = useGameDispatch(); + const waitingFor = useGameStore((s) => s.waitingFor); + + if (waitingFor?.type !== "ChooseGiftRecipient") return null; + if (!canActForWaitingState) return null; + + return ( + + ); +} diff --git a/client/src/components/modal/OptionalCostModal.tsx b/client/src/components/modal/OptionalCostModal.tsx index 9ee2c3acce..815bca6764 100644 --- a/client/src/components/modal/OptionalCostModal.tsx +++ b/client/src/components/modal/OptionalCostModal.tsx @@ -1,3 +1,6 @@ +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; + import type { GameAction, WaitingFor } from "../../adapter/types.ts"; import { additionalCostChoices } from "../../viewmodel/costLabel.ts"; import { ChoiceModal } from "./ChoiceModal.tsx"; @@ -9,9 +12,25 @@ interface OptionalCostModalProps { dispatch: (action: GameAction) => void | Promise; } +function giftKindLabel( + giftKind: OptionalCostWaitingFor["data"]["gift_kind"], + t: TFunction<"game">, +): string { + switch (giftKind?.type) { + case "Treasure": + return t("optionalCost.gift.kind.treasure"); + case "Food": + return t("optionalCost.gift.kind.food"); + case "TappedFish": + return t("optionalCost.gift.kind.tappedFish"); + default: + return t("optionalCost.gift.kind.card"); + } +} + /** * Modal for `WaitingFor::OptionalCostChoice` — kicker / multikicker / Casualty - * / "or pay" additional-cost prompts. + * / Gift / "or pay" additional-cost prompts. * * The decline option is an explicit, descriptively-labelled primary button * (`pay: false` → finish the cast), kept distinct from the X / backdrop abort @@ -23,8 +42,28 @@ export function OptionalCostModalContent({ waitingFor, dispatch, }: OptionalCostModalProps) { - const { cost, times_kicked } = waitingFor.data; - const { title, options } = additionalCostChoices(cost, times_kicked); + const { t } = useTranslation("game"); + const { cost, times_kicked, origin, gift_kind } = waitingFor.data; + // CR 702.174a: Gift promise copy is engine-identified via origin/gift_kind; + // localize here so chrome strings go through `t()` (not card/Oracle text). + const { title, options } = + origin === "Gift" + ? { + title: t("optionalCost.gift.title"), + options: [ + { + id: "pay" as const, + label: t("optionalCost.gift.pay", { + gift: giftKindLabel(gift_kind, t), + }), + }, + { + id: "decline" as const, + label: t("optionalCost.gift.decline"), + }, + ], + } + : additionalCostChoices(cost, times_kicked); // Mandatory Choice costs (e.g. "discard a card or pay 3 life") require // picking one — no abort allowed. All other costs allow aborting the cast. const isMandatoryChoice = cost.type === "Choice"; diff --git a/client/src/components/modal/__tests__/AdventureCastModal.test.tsx b/client/src/components/modal/__tests__/AdventureCastModal.test.tsx new file mode 100644 index 0000000000..40aaedb051 --- /dev/null +++ b/client/src/components/modal/__tests__/AdventureCastModal.test.tsx @@ -0,0 +1,90 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { gameObjectFactory } from "../../../test/factories/gameObjectFactory.ts"; +import { + castOfferWaitingForFactory, + gameStateFactory, +} from "../../../test/factories/gameStateFactory.ts"; +import { setGameStoreForTest } from "../../../test/helpers/gameStoreHelpers.ts"; +import { AdventureCastModal } from "../AdventureCastModal.tsx"; + +const dispatchMock = vi.fn(); + +vi.mock("../../../hooks/useGameDispatch.ts", () => ({ + useGameDispatch: () => dispatchMock, +})); + +const creatureAction = { + type: "ChooseAdventureFace" as const, + data: { creature: true }, +}; +const adventureAction = { + type: "ChooseAdventureFace" as const, + data: { creature: false }, +}; + +function adventureState(player = 0) { + const hildibrand = gameObjectFactory + .creature(2, 2) + .inHand() + .withId(157) + .named("Hildibrand Manderville") + .build(); + + return gameStateFactory + .withPlayers(0, 1) + .withObjects(hildibrand) + .waitingFor( + castOfferWaitingForFactory + .forPlayer(player) + .adventure(hildibrand.id, hildibrand.card_id) + .build(), + ) + .build(); +} + +describe("AdventureCastModal", () => { + afterEach(() => { + cleanup(); + dispatchMock.mockReset(); + }); + + it("renders and dispatches only the engine-authorized Adventure face", () => { + const gameState = adventureState(); + setGameStoreForTest({ gameState, legalActions: [adventureAction] }); + + render(); + + expect( + screen.queryByRole("button", { name: /^Cast Hildibrand Manderville \(Creature\)$/ }), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /^Cast Adventure \(Adventure\)$/ })); + expect(dispatchMock).toHaveBeenCalledWith(adventureAction); + }); + + it("renders both engine-authorized faces and preserves their exact actions", () => { + const gameState = adventureState(); + setGameStoreForTest({ gameState, legalActions: [creatureAction, adventureAction] }); + + render(); + + fireEvent.click( + screen.getByRole("button", { name: /^Cast Hildibrand Manderville \(Creature\)$/ }), + ); + fireEvent.click(screen.getByRole("button", { name: /^Cast Adventure \(Adventure\)$/ })); + + expect(dispatchMock).toHaveBeenNthCalledWith(1, creatureAction); + expect(dispatchMock).toHaveBeenNthCalledWith(2, adventureAction); + }); + + it("does not render for another player's CastOffer", () => { + const gameState = adventureState(1); + setGameStoreForTest({ gameState, legalActions: [adventureAction] }); + + render(); + + expect(screen.queryByRole("heading", { name: "Choose a Face" })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/modal/__tests__/OptionalCostModal.test.tsx b/client/src/components/modal/__tests__/OptionalCostModal.test.tsx index 5338d55a10..3eda99ac9e 100644 --- a/client/src/components/modal/__tests__/OptionalCostModal.test.tsx +++ b/client/src/components/modal/__tests__/OptionalCostModal.test.tsx @@ -81,4 +81,42 @@ describe("OptionalCostModalContent (issue #454)", () => { data: { pay: false }, }); }); + + it("Gift origin uses localized promise copy from optionalCost.gift keys", () => { + const waitingFor: OptionalCostWaitingFor = { + type: "OptionalCostChoice", + data: { + player: 0, + cost: { + type: "Optional", + data: { + cost: { type: "Mana", cost: { type: "Cost", shards: [], generic: 0 } }, + repeatable: false, + }, + }, + times_kicked: 0, + origin: "Gift", + gift_kind: { type: "Treasure" }, + pending_cast: {} as OptionalCostWaitingFor["data"]["pending_cast"], + }, + }; + const dispatch = renderModal(waitingFor); + + expect(screen.getByText("Promise a gift?")).toBeTruthy(); + const payButton = screen.getByRole("button", { name: /promise a treasure/i }); + const declineButton = screen.getByRole("button", { + name: /cast without promising/i, + }); + fireEvent.click(payButton); + expect(dispatch).toHaveBeenCalledWith({ + type: "DecideOptionalCost", + data: { pay: true }, + }); + dispatch.mockClear(); + fireEvent.click(declineButton); + expect(dispatch).toHaveBeenCalledWith({ + type: "DecideOptionalCost", + data: { pay: false }, + }); + }); }); diff --git a/client/src/components/stack/StackEntry.tsx b/client/src/components/stack/StackEntry.tsx index 8a1e5273bd..6ef1bdfecf 100644 --- a/client/src/components/stack/StackEntry.tsx +++ b/client/src/components/stack/StackEntry.tsx @@ -256,7 +256,7 @@ export function StackEntry({ entry, index, isTop, isPending, cardSize, style, on card's width instead of a fixed px size. */} {isSpell && displayManaCost && (
- +
)} diff --git a/client/src/game/__tests__/diceContest.test.ts b/client/src/game/__tests__/diceContest.test.ts index 3e22c68a76..decf0eb697 100644 --- a/client/src/game/__tests__/diceContest.test.ts +++ b/client/src/game/__tests__/diceContest.test.ts @@ -34,7 +34,18 @@ afterEach(() => { describe("flashStartingPlayerContest", () => { it("builds a startingPlayer die payload from the engine contest event", () => { - flashStartingPlayerContest([contest([[[0, 17], [1, 9]]], 0), gameStarted], 0); + const turnOrder = [ + { player: 0, slot_index: 0, turns_from_now: 0, turn_number: 1, is_starting_player: true }, + { player: 1, slot_index: 1, turns_from_now: 1, turn_number: 2 }, + { player: 2, slot_index: 2, turns_from_now: 2, turn_number: 3, is_viewer: true }, + { player: 3, slot_index: 3, turns_from_now: 3, turn_number: 4 }, + ]; + flashStartingPlayerContest( + [contest([[[0, 17], [1, 9]]], 0), gameStarted], + 0, + turnOrder, + 3, + ); const d = useUiStore.getState().diceRoll; expect(d).toMatchObject({ kind: "die", sides: 20, context: "startingPlayer", winner: 0 }); expect(d?.kind === "die" && d.rounds).toEqual([ @@ -48,6 +59,8 @@ describe("flashStartingPlayerContest", () => { { playerId: 0, value: 17 }, { playerId: 1, value: 9 }, ]); + expect(d?.kind === "die" && d.turnOrder).toEqual(turnOrder); + expect(d?.kind === "die" && d.viewerTurnNumber).toBe(3); }); it("preserves the per-round structure across a tie reroll", () => { @@ -72,6 +85,7 @@ describe("flashStartingPlayerContest", () => { gameStarted, ], 0, + undefined, ); const d = useUiStore.getState().diceRoll; expect(d?.kind === "die" && d.rounds).toEqual([ @@ -93,18 +107,18 @@ describe("flashStartingPlayerContest", () => { it("uses the engine winner, never recomputed from the rolls (lowest-seat fallback)", () => { // The engine's all-tied-at-cap fallback picks the lowest seat; the winner is // passed in, not derived from the shown dice. - flashStartingPlayerContest([contest([[[0, 7], [1, 7]]], 0), gameStarted], 0); + flashStartingPlayerContest([contest([[[0, 7], [1, 7]]], 0), gameStarted], 0, undefined); expect(useUiStore.getState().diceRoll).toMatchObject({ winner: 0 }); }); it("no-ops when the starter was chosen explicitly (no contest event)", () => { - flashStartingPlayerContest([gameStarted], 1); + flashStartingPlayerContest([gameStarted], 1, undefined); expect(useUiStore.getState().diceRoll).toBeNull(); }); it("skips the overlay entirely at instant animation speed (0)", () => { usePreferencesStore.setState({ animationSpeedMultiplier: 0 }); - flashStartingPlayerContest([contest([[[0, 5], [1, 3]]], 0), gameStarted], 0); + flashStartingPlayerContest([contest([[[0, 5], [1, 3]]], 0), gameStarted], 0, undefined); expect(useUiStore.getState().diceRoll).toBeNull(); }); }); @@ -127,6 +141,13 @@ describe("flashInGameRolls", () => { }); }); + it("queues every coin flip in an event batch", () => { + flashInGameRolls([coin(1, true), coin(1, false)]); + const state = useUiStore.getState(); + expect(state.diceRoll).toMatchObject({ kind: "coin", playerId: 1, won: true }); + expect(state.diceRollQueue).toMatchObject([{ kind: "coin", playerId: 1, won: false }]); + }); + it("no-ops on a batch containing neither dice nor coins", () => { flashInGameRolls([gameStarted]); expect(useUiStore.getState().diceRoll).toBeNull(); diff --git a/client/src/game/__tests__/sessionCleanup.test.ts b/client/src/game/__tests__/sessionCleanup.test.ts index a9c7b481b0..2fb389da7e 100644 --- a/client/src/game/__tests__/sessionCleanup.test.ts +++ b/client/src/game/__tests__/sessionCleanup.test.ts @@ -96,4 +96,16 @@ describe("clearPromptOverlayState", () => { expect(useUiStore.getState().mobileHandGesture).toBeNull(); }); + + it("clears active and queued roll overlays at a game boundary", () => { + useUiStore.setState({ + diceRoll: { kind: "coin", playerId: 1, won: true, context: "ability" }, + diceRollQueue: [{ kind: "coin", playerId: 1, won: false, context: "ability" }], + }); + + clearPromptOverlayState(); + + expect(useUiStore.getState().diceRoll).toBeNull(); + expect(useUiStore.getState().diceRollQueue).toEqual([]); + }); }); diff --git a/client/src/game/diceContest.ts b/client/src/game/diceContest.ts index 2d6fef1df7..3ed0b8fe01 100644 --- a/client/src/game/diceContest.ts +++ b/client/src/game/diceContest.ts @@ -1,4 +1,4 @@ -import type { GameEvent, PlayerId } from "../adapter/types"; +import type { GameEvent, PlayerId, TurnOrderSlotView } from "../adapter/types"; import { useUiStore } from "../stores/uiStore"; type DieRolledEvent = Extract; @@ -18,7 +18,12 @@ type StartingPlayerContestEvent = Extract e.type === "StartingPlayerContest", ); @@ -35,18 +40,20 @@ export function flashStartingPlayerContest(events: GameEvent[], startingPlayer: rounds, context: "startingPlayer", winner: startingPlayer, + turnOrder, + viewerTurnNumber, }); } /** * Fire the in-game roll overlay for an action's event batch. Groups all - * `DieRolled` into one die overlay (e.g. a Krark's Thumb double) and otherwise - * shows the first `CoinFlipped`. Always `context: "ability"`. No-ops when the + * `DieRolled` into one die overlay (e.g. a Krark's Thumb double) and queues + * every `CoinFlipped` after it. Always `context: "ability"`. No-ops when the * batch contains neither. */ export function flashInGameRolls(events: GameEvent[]): void { const dice = events.filter((e): e is DieRolledEvent => e.type === "DieRolled"); - const coin = events.find((e): e is CoinFlippedEvent => e.type === "CoinFlipped"); + const coins = events.filter((e): e is CoinFlippedEvent => e.type === "CoinFlipped"); const flash = useUiStore.getState().flashDiceRoll; // All dice in the batch group into one overlay (e.g. a Krark's Thumb double); // a co-occurring coin queues behind them and plays after (the overlay FIFO @@ -64,7 +71,7 @@ export function flashInGameRolls(events: GameEvent[]): void { context: "ability", }); } - if (coin) { + for (const coin of coins) { flash({ kind: "coin", playerId: coin.data.player_id, won: coin.data.won, context: "ability" }); } } diff --git a/client/src/game/sessionCleanup.ts b/client/src/game/sessionCleanup.ts index 701a5e384b..e5c990e797 100644 --- a/client/src/game/sessionCleanup.ts +++ b/client/src/game/sessionCleanup.ts @@ -2,10 +2,10 @@ import { useGameStore } from "../stores/gameStore.ts"; import { useUiStore } from "../stores/uiStore.ts"; /** - * Drop engine prompt + UI-dialog overlay state without disposing the WASM - * adapter. Used on game-session boundaries (concede, provider unmount/remount) - * so deferred store resets or async `initGame` cannot leave `ManaPayment` / - * `pendingAbilityChoice` bleed into the next session (issue #2369). + * Drop engine prompt + UI overlay state without disposing the WASM adapter. + * Used on game-session boundaries (concede, provider unmount/remount) so + * deferred store resets or async `initGame` cannot leave `ManaPayment`, + * `pendingAbilityChoice`, or a roll animation bleeding into the next session. * * Documented exemption from the `commitEngineSnapshot` single-writer invariant: * this is a session-boundary CLEAR, not a live-game commit. It writes the prompt @@ -29,6 +29,7 @@ export function clearPromptOverlayState(): void { useUiStore.getState().setEnchantmentsDialogPlayer(null); useUiStore.getState().setAttachmentFanHost(null); useUiStore.getState().setMobileHandGesture(null); + useUiStore.getState().resetDiceRoll(); // The per-game "Manual mana" toggle must never leak into the next game. useUiStore.getState().setManualManaOverride(false); // The ephemeral hand hide-filter is a per-game focus aid — reset it too. diff --git a/client/src/game/waitingForRegistry.ts b/client/src/game/waitingForRegistry.ts index d6831ace7e..b70f979ca5 100644 --- a/client/src/game/waitingForRegistry.ts +++ b/client/src/game/waitingForRegistry.ts @@ -127,6 +127,7 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet = // choosing opponent (ZoneOpponentChooserModal). "ChooseFromZoneOpponentChooser", "ChooseAnnouncingOpponent", + "ChooseGiftRecipient", "ClashCardPlacement", // CR 702.132a: Assist — caster picks a helper (AssistChoosePlayerModal), // then the helper commits generic mana (AssistPaymentUI). diff --git a/client/src/hooks/usePreviewDismiss.ts b/client/src/hooks/usePreviewDismiss.ts index 79c56a75da..6f57102b5c 100644 --- a/client/src/hooks/usePreviewDismiss.ts +++ b/client/src/hooks/usePreviewDismiss.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { useUiStore } from "../stores/uiStore.ts"; @@ -8,8 +8,9 @@ import { useUiStore } from "../stores/uiStore.ts"; * animations (tap rotation, attack slide, layout transitions) move elements * out from under the cursor without firing onMouseLeave. * - * Uses `document.elementFromPoint()` on a 300ms interval to verify the pointer - * is still over an element with `[data-card-hover]`. + * On a 300ms interval it verifies, via the browser's own `:hover` state, that + * the pointer is still over an element with `[data-card-hover]` — robust to the + * stale JS-tracked pointer that sparse remote-desktop pointer streams produce. * * When `previewSticky` is true (set by long-press on touch devices), the * interval-based dismiss is skipped. A delayed capture-phase pointer listener @@ -21,21 +22,6 @@ export function usePreviewDismiss() { const altHeld = useUiStore((s) => s.altHeld); const dismissPreview = useUiStore((s) => s.dismissPreview); const hoverObject = useUiStore((s) => s.hoverObject); - const pointerRef = useRef({ x: 0, y: 0 }); - - // Track pointer position (only while preview is active, mouse only) - useEffect(() => { - if (inspectedObjectId == null) return; - - function onMove(e: PointerEvent) { - // Only track mouse pointer, not touch — touch uses sticky dismiss - if (e.pointerType === "touch") return; - pointerRef.current = { x: e.clientX, y: e.clientY }; - } - - document.addEventListener("pointermove", onMove, { passive: true }); - return () => document.removeEventListener("pointermove", onMove); - }, [inspectedObjectId]); // Mouse: periodically verify the pointer is still over a card-hover element. // Skipped when the preview is sticky (touch-initiated) or Alt-pinned (frozen @@ -52,14 +38,19 @@ export function usePreviewDismiss() { skipFirst = false; return; } - const { x, y } = pointerRef.current; - if (x === 0 && y === 0) return; - - const el = document.elementFromPoint(x, y); - if (!el) return; - - const isOverCard = el.closest("[data-card-hover]") !== null; - if (!isOverCard) { + // Ask the browser for the element it currently considers hovered rather + // than sampling elementFromPoint() against a JS-tracked pointer. That + // tracked coordinate is only as fresh as the last `pointermove`, and over + // sparse/coalesced pointer streams (remote-desktop / RDP webviews) it goes + // stale a few px off the card while the OS cursor is genuinely still on + // it — so a single elementFromPoint sample spuriously dismissed the + // just-shown preview. `:hover` is driven by the engine's own continuous + // hit-test and stays correct with no pointermove event at all. It matches + // the interactive info/parse panel too (also `[data-card-hover]`), and + // pointer-events-none surfaces (the preview image) never match it — the + // same "over a card or its info panel" test the old sample performed. + const stillOverTarget = document.querySelector("[data-card-hover]:hover"); + if (stillOverTarget == null) { dismissPreview(); hoverObject(null); } diff --git a/client/src/hooks/useRafPositions.ts b/client/src/hooks/useRafPositions.ts index 05905510d2..97b84e025e 100644 --- a/client/src/hooks/useRafPositions.ts +++ b/client/src/hooks/useRafPositions.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; -import type { ObjectId } from "../adapter/types.ts"; +import type { BlockerAssignmentPair } from "../components/board/blockAssignmentVisibility.ts"; import { objectAnchorSelector } from "../utils/objectAnchorSelector.ts"; export interface LinePosition { @@ -9,15 +9,16 @@ export interface LinePosition { length: number; } -/** RAF polling for element positions — stabilizes after 10 unchanged frames. - * Pairs map: key → value, where both are ObjectIds with `data-object-id` attributes. */ -export function useRafPositions(pairs: Map): Map { - const [positions, setPositions] = useState>(new Map()); +/** RAF polling for element positions — stabilizes after 10 unchanged frames. */ +export function useRafPositions( + pairs: readonly BlockerAssignmentPair[], +): Map { + const [positions, setPositions] = useState>(new Map()); const prevRectsRef = useRef>(new Map()); const stableCountRef = useRef(0); useEffect(() => { - if (pairs.size === 0) { + if (pairs.length === 0) { setPositions(new Map()); return; } @@ -58,7 +59,7 @@ export function useRafPositions(pairs: Map): Map(); + const next = new Map(); for (const [fromId, toId] of pairs) { const fromRect = currentRects.get(String(fromId)); const toRect = currentRects.get(String(toId)); @@ -73,7 +74,7 @@ export function useRafPositions(pairs: Map): Map { if (cancelled) return; diff --git a/client/src/hooks/useUnboundedCounterTypes.ts b/client/src/hooks/useUnboundedCounterTypes.ts new file mode 100644 index 0000000000..35f79fa38f --- /dev/null +++ b/client/src/hooks/useUnboundedCounterTypes.ts @@ -0,0 +1,29 @@ +import type { ObjectId } from "../adapter/types.ts"; +import { useGameStore } from "../stores/gameStore.ts"; + +// CR 732.2a / CR 701.34a: stable empty ref so a permanent with no unbounded counter +// (the dominant case) never re-renders on identity churn. +const EMPTY_UNBOUNDED_COUNTERS: string[] = []; + +/** + * CR 732.2a / CR 701.34a: the counter-type keys the engine marks as `∞` for this + * object (`DerivedViews::unbounded_counters`). Keys match the object's `counters` + * map (e.g. "charge"). DISPLAY-only — the object's real counter count is + * deliberately left finite (`game/engine.rs::materialize_object_growth_shortcut`), + * so a site that reads `obj.counters` alone shows a stale pre-shortcut number. + * + * Subscribed today by exactly three render sites: `board/PermanentCard`, + * `card/ArtCropCard`, and `card/CardPreview`'s `CardInfoPanel`. Two counter render + * sites remain unsubscribed, each with a measured blocker: + * - FU-A `controls/AttackTargetPicker` (`StackLabel`) — a chip stands for N grouped + * objects and the mark is per object id, so it needs an `.every()` intersection + * threaded through `groupByName`/`AttackerStack` (mirroring `isUnboundedPile`), + * not a representative lookup, or it renders a FALSE `∞`. + * - FU-B `hud/DialogAttachmentCard` — no component test file exists for it. + * Do not claim this hook covers every counter render site until both land. + */ +export function useUnboundedCounterTypes(objectId: ObjectId): string[] { + return useGameStore( + (s) => s.gameState?.derived?.unbounded_counters?.[String(objectId)] ?? EMPTY_UNBOUNDED_COUNTERS, + ); +} diff --git a/client/src/i18n/locales/de/common.json b/client/src/i18n/locales/de/common.json index 35f9a0d909..7924b92d12 100644 --- a/client/src/i18n/locales/de/common.json +++ b/client/src/i18n/locales/de/common.json @@ -15,6 +15,24 @@ "enter": "Vollbild starten", "exit": "Vollbild beenden" }, + "nativeEngineProgress": { + "title": "Nativer Engine wird aktualisiert", + "resolving": "Passender lokaler Server wird gesucht…", + "downloadingBinary": "Aktualisierter Server wird heruntergeladen…", + "verifying": "Server wird überprüft…", + "downloadingData": "Spieldaten werden heruntergeladen…", + "spawning": "Lokaler Server wird gestartet…", + "ready": "Native Engine bereit", + "failed": "Native Engine konnte nicht vorbereitet werden" + }, + "engineBadge": { + "native": "Native Engine", + "nativeTooltip": "Dieses Spiel läuft mit der nativen Engine der Desktop-App.", + "inBrowser": "Browser-Engine", + "inBrowserTooltip": "Native Engine nicht verfügbar — dieses Spiel läuft im Browser.", + "versionMismatchTooltip": "Versionskonflikt der nativen Engine — dieses Spiel läuft im Browser.", + "notAttemptedTooltip": "Dieses Spiel läuft im Browser — die native Engine wurde dafür nicht verwendet." + }, "buildBadge": { "downloading": "wird heruntergeladen… {{progress}}%", "checking": "wird geprüft…", @@ -247,7 +265,13 @@ "youPlayFirst": "Du beginnst", "playerPlaysFirst": "{{name}} beginnt", "heads": "Kopf", - "tails": "Zahl" + "tails": "Zahl", + "coinFlip": "Münzwurf", + "wonCoinFlip": "{{name}} hat den Münzwurf gewonnen", + "lostCoinFlip": "{{name}} hat den Münzwurf verloren", + "turnOrder": "Zugreihenfolge", + "turnOrderItem": "Zug {{turn}}: {{name}}", + "youTakeTurn": "Du bist in Zug {{turn}} dran" }, "chrome": { "back": "Zurück", diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 11ef561748..a7843c9e8c 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -248,6 +248,7 @@ "target": "Ziel", "copy": "Kopie", "copyTooltip": "Spielstein-Kopie einer echten Karte", + "cantBeBlocked": "Kann nicht geblockt werden", "ringBearer": "Ring", "ringBearerTooltip": "Ringträger", "hiddenAttachments": "+{{count}} more attached", @@ -662,6 +663,7 @@ "mustAttackBadge": "Must attack", "mustAttackSatisfiedBadge": "Attacking ✓", "mustBlockBadge": "Must block", + "mustBlockExactBadge": "Muss {{attacker}} blocken", "mustBlockSatisfiedBadge": "Blocking ✓", "cantAttackBadge": "Can't attack", "cantBlockBadge": "Can't block", @@ -1710,6 +1712,23 @@ "targetCreature": "das Kreaturenziel", "targetChoice": "diese Zielwahl" }, + "giftRecipient": { + "title": "Geschenk-Empfänger wählen", + "subtitle": "Wähle, welcher Gegner das versprochene Geschenk erhält." + }, + "optionalCost": { + "gift": { + "title": "Ein Geschenk versprechen?", + "pay": "{{gift}} versprechen", + "decline": "Wirken ohne zu versprechen", + "kind": { + "card": "eine Karte", + "treasure": "einen Schatz", + "food": "eine Nahrung", + "tappedFish": "einen getappten Fisch" + } + } + }, "clashOpponent": { "title": "Kampf-Gegner wählen", "subtitle": "Wähle, welcher Gegner mit dir kämpft." diff --git a/client/src/i18n/locales/en/common.json b/client/src/i18n/locales/en/common.json index d35397fedb..1daa7abb7f 100644 --- a/client/src/i18n/locales/en/common.json +++ b/client/src/i18n/locales/en/common.json @@ -15,6 +15,24 @@ "enter": "Enter fullscreen", "exit": "Exit fullscreen" }, + "nativeEngineProgress": { + "title": "Updating native engine", + "resolving": "Finding the correct local server…", + "downloadingBinary": "Downloading updated server…", + "verifying": "Verifying server…", + "downloadingData": "Downloading game data…", + "spawning": "Starting local server…", + "ready": "Native engine ready", + "failed": "Unable to prepare native engine" + }, + "engineBadge": { + "native": "Native engine", + "nativeTooltip": "This game is running on the desktop app's native engine.", + "inBrowser": "In-browser engine", + "inBrowserTooltip": "Native engine unavailable — this game is running in-browser.", + "versionMismatchTooltip": "Native engine version mismatch — this game is running in-browser.", + "notAttemptedTooltip": "This game is running in-browser — the native engine wasn't used for it." + }, "buildBadge": { "downloading": "downloading… {{progress}}%", "checking": "checking…", @@ -247,7 +265,13 @@ "youPlayFirst": "You play first", "playerPlaysFirst": "{{name}} plays first", "heads": "Heads", - "tails": "Tails" + "tails": "Tails", + "coinFlip": "Coin flip", + "wonCoinFlip": "{{name}} won the flip", + "lostCoinFlip": "{{name}} lost the flip", + "turnOrder": "Turn order", + "turnOrderItem": "Turn {{turn}}: {{name}}", + "youTakeTurn": "You take turn {{turn}}" }, "chrome": { "back": "Back", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index d60fdd2c7c..004e7bc919 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -249,6 +249,7 @@ "sacrifice": "Sacrifice", "copy": "Copy", "copyTooltip": "Token copy of a real card", + "cantBeBlocked": "Can't be blocked", "ringBearer": "Ring", "ringBearerTooltip": "Ring-bearer", "hiddenAttachments": "+{{count}} more attached", @@ -694,6 +695,7 @@ "mustAttackBadge": "Must attack", "mustAttackSatisfiedBadge": "Attacking ✓", "mustBlockBadge": "Must block", + "mustBlockExactBadge": "Must block {{attacker}}", "mustBlockSatisfiedBadge": "Blocking ✓", "cantAttackBadge": "Can't attack", "cantBlockBadge": "Can't block", @@ -1747,6 +1749,23 @@ "targetCreature": "the creature target", "targetChoice": "this target choice" }, + "giftRecipient": { + "title": "Choose Gift Recipient", + "subtitle": "Choose which opponent receives the promised gift." + }, + "optionalCost": { + "gift": { + "title": "Promise a gift?", + "pay": "Promise {{gift}}", + "decline": "Cast without promising", + "kind": { + "card": "a card", + "treasure": "a Treasure", + "food": "a Food", + "tappedFish": "a tapped Fish" + } + } + }, "clashOpponent": { "title": "Choose Clash Opponent", "subtitle": "Choose which opponent will clash with you." diff --git a/client/src/i18n/locales/es/common.json b/client/src/i18n/locales/es/common.json index c4a340a8b5..794faf51cf 100644 --- a/client/src/i18n/locales/es/common.json +++ b/client/src/i18n/locales/es/common.json @@ -15,6 +15,24 @@ "enter": "Activar pantalla completa", "exit": "Salir de pantalla completa" }, + "nativeEngineProgress": { + "title": "Actualizando el motor nativo", + "resolving": "Buscando el servidor local correcto…", + "downloadingBinary": "Descargando el servidor actualizado…", + "verifying": "Verificando el servidor…", + "downloadingData": "Descargando datos del juego…", + "spawning": "Iniciando el servidor local…", + "ready": "Motor nativo listo", + "failed": "No se pudo preparar el motor nativo" + }, + "engineBadge": { + "native": "Motor nativo", + "nativeTooltip": "Esta partida usa el motor nativo de la aplicación de escritorio.", + "inBrowser": "Motor del navegador", + "inBrowserTooltip": "Motor nativo no disponible: esta partida se ejecuta en el navegador.", + "versionMismatchTooltip": "Versión del motor nativo incompatible: esta partida se ejecuta en el navegador.", + "notAttemptedTooltip": "Esta partida se ejecuta en el navegador: no se usó el motor nativo." + }, "buildBadge": { "downloading": "descargando… {{progress}}%", "checking": "comprobando…", @@ -247,7 +265,13 @@ "youPlayFirst": "Juegas primero", "playerPlaysFirst": "{{name}} juega primero", "heads": "Cara", - "tails": "Cruz" + "tails": "Cruz", + "coinFlip": "Lanzamiento de moneda", + "wonCoinFlip": "{{name}} ganó el lanzamiento", + "lostCoinFlip": "{{name}} perdió el lanzamiento", + "turnOrder": "Orden de turnos", + "turnOrderItem": "Turno {{turn}}: {{name}}", + "youTakeTurn": "Juegas el turno {{turn}}" }, "chrome": { "back": "Atrás", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 1dd4be2ffe..3f13859448 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -248,6 +248,7 @@ "target": "Objetivo", "copy": "Copia", "copyTooltip": "Ficha copia de una carta real", + "cantBeBlocked": "No puede ser bloqueada", "ringBearer": "Anillo", "ringBearerTooltip": "Portador del Anillo", "hiddenAttachments": "+{{count}} more attached", @@ -662,6 +663,7 @@ "mustAttackBadge": "Must attack", "mustAttackSatisfiedBadge": "Attacking ✓", "mustBlockBadge": "Must block", + "mustBlockExactBadge": "Debe bloquear a {{attacker}}", "mustBlockSatisfiedBadge": "Blocking ✓", "cantAttackBadge": "Can't attack", "cantBlockBadge": "Can't block", @@ -1710,6 +1712,23 @@ "targetCreature": "el objetivo de criatura", "targetChoice": "esta elección de objetivo" }, + "giftRecipient": { + "title": "Elegir destinatario del regalo", + "subtitle": "Elige qué oponente recibe el regalo prometido." + }, + "optionalCost": { + "gift": { + "title": "¿Prometer un regalo?", + "pay": "Prometer {{gift}}", + "decline": "Lanzar sin prometer", + "kind": { + "card": "una carta", + "treasure": "un Tesoro", + "food": "un Alimento", + "tappedFish": "un Pez girado" + } + } + }, "clashOpponent": { "title": "Elegir oponente para chocar", "subtitle": "Elige qué oponente chocará contigo." diff --git a/client/src/i18n/locales/fr/common.json b/client/src/i18n/locales/fr/common.json index 7d28a77897..a080ec5723 100644 --- a/client/src/i18n/locales/fr/common.json +++ b/client/src/i18n/locales/fr/common.json @@ -15,6 +15,24 @@ "enter": "Passer en plein écran", "exit": "Quitter le plein écran" }, + "nativeEngineProgress": { + "title": "Mise à jour du moteur natif", + "resolving": "Recherche du serveur local approprié…", + "downloadingBinary": "Téléchargement du serveur mis à jour…", + "verifying": "Vérification du serveur…", + "downloadingData": "Téléchargement des données du jeu…", + "spawning": "Démarrage du serveur local…", + "ready": "Moteur natif prêt", + "failed": "Impossible de préparer le moteur natif" + }, + "engineBadge": { + "native": "Moteur natif", + "nativeTooltip": "Cette partie utilise le moteur natif de l'application de bureau.", + "inBrowser": "Moteur du navigateur", + "inBrowserTooltip": "Moteur natif indisponible — cette partie s'exécute dans le navigateur.", + "versionMismatchTooltip": "Version du moteur natif incompatible — cette partie s'exécute dans le navigateur.", + "notAttemptedTooltip": "Cette partie s'exécute dans le navigateur — le moteur natif n'a pas été utilisé." + }, "buildBadge": { "downloading": "téléchargement… {{progress}} %", "checking": "vérification…", @@ -247,7 +265,13 @@ "youPlayFirst": "Vous jouez en premier", "playerPlaysFirst": "{{name}} joue en premier", "heads": "Face", - "tails": "Pile" + "tails": "Pile", + "coinFlip": "Pile ou face", + "wonCoinFlip": "{{name}} a gagné le pile ou face", + "lostCoinFlip": "{{name}} a perdu le pile ou face", + "turnOrder": "Ordre des tours", + "turnOrderItem": "Tour {{turn}} : {{name}}", + "youTakeTurn": "Vous jouez au tour {{turn}}" }, "chrome": { "back": "Retour", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index bf67c443fa..eec56dbb6f 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -248,6 +248,7 @@ "target": "Cible", "copy": "Copie", "copyTooltip": "Jeton-copie d'une vraie carte", + "cantBeBlocked": "Ne peut pas être bloquée", "ringBearer": "Anneau", "ringBearerTooltip": "Porteur de l'Anneau", "hiddenAttachments": "+{{count}} more attached", @@ -662,6 +663,7 @@ "mustAttackBadge": "Must attack", "mustAttackSatisfiedBadge": "Attacking ✓", "mustBlockBadge": "Must block", + "mustBlockExactBadge": "Doit bloquer {{attacker}}", "mustBlockSatisfiedBadge": "Blocking ✓", "cantAttackBadge": "Can't attack", "cantBlockBadge": "Can't block", @@ -1710,6 +1712,23 @@ "targetCreature": "la cible de créature", "targetChoice": "ce choix de cible" }, + "giftRecipient": { + "title": "Choisir le destinataire du cadeau", + "subtitle": "Choisissez quel adversaire reçoit le cadeau promis." + }, + "optionalCost": { + "gift": { + "title": "Promettre un cadeau ?", + "pay": "Promettre {{gift}}", + "decline": "Lancer sans promettre", + "kind": { + "card": "une carte", + "treasure": "un Trésor", + "food": "un Aliment", + "tappedFish": "un Poisson engagé" + } + } + }, "clashOpponent": { "title": "Choisir l'adversaire pour la confrontation", "subtitle": "Choisissez quel adversaire fera la confrontation avec vous." diff --git a/client/src/i18n/locales/it/common.json b/client/src/i18n/locales/it/common.json index 598a84a024..8036ba87e9 100644 --- a/client/src/i18n/locales/it/common.json +++ b/client/src/i18n/locales/it/common.json @@ -15,6 +15,24 @@ "enter": "Attiva schermo intero", "exit": "Esci da schermo intero" }, + "nativeEngineProgress": { + "title": "Aggiornamento del motore nativo", + "resolving": "Ricerca del server locale corretto…", + "downloadingBinary": "Download del server aggiornato…", + "verifying": "Verifica del server…", + "downloadingData": "Download dei dati di gioco…", + "spawning": "Avvio del server locale…", + "ready": "Motore nativo pronto", + "failed": "Impossibile preparare il motore nativo" + }, + "engineBadge": { + "native": "Motore nativo", + "nativeTooltip": "Questa partita usa il motore nativo dell'app desktop.", + "inBrowser": "Motore del browser", + "inBrowserTooltip": "Motore nativo non disponibile: questa partita è in esecuzione nel browser.", + "versionMismatchTooltip": "Versione del motore nativo non corrispondente: questa partita è in esecuzione nel browser.", + "notAttemptedTooltip": "Questa partita è in esecuzione nel browser: il motore nativo non è stato usato." + }, "buildBadge": { "downloading": "download in corso… {{progress}}%", "checking": "controllo…", @@ -247,7 +265,13 @@ "youPlayFirst": "Giochi per primo", "playerPlaysFirst": "{{name}} gioca per primo", "heads": "Testa", - "tails": "Croce" + "tails": "Croce", + "coinFlip": "Lancio della moneta", + "wonCoinFlip": "{{name}} ha vinto il lancio", + "lostCoinFlip": "{{name}} ha perso il lancio", + "turnOrder": "Ordine dei turni", + "turnOrderItem": "Turno {{turn}}: {{name}}", + "youTakeTurn": "Giochi il turno {{turn}}" }, "chrome": { "back": "Indietro", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index d20cb4f87d..d8af001c84 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -248,6 +248,7 @@ "target": "Bersaglio", "copy": "Copia", "copyTooltip": "Copia pedina di una carta reale", + "cantBeBlocked": "Non può essere bloccata", "ringBearer": "Anello", "ringBearerTooltip": "Portatore dell'Anello", "hiddenAttachments": "+{{count}} more attached", @@ -662,6 +663,7 @@ "mustAttackBadge": "Must attack", "mustAttackSatisfiedBadge": "Attacking ✓", "mustBlockBadge": "Must block", + "mustBlockExactBadge": "Deve bloccare {{attacker}}", "mustBlockSatisfiedBadge": "Blocking ✓", "cantAttackBadge": "Can't attack", "cantBlockBadge": "Can't block", @@ -1710,6 +1712,23 @@ "targetCreature": "il bersaglio creatura", "targetChoice": "questa scelta del bersaglio" }, + "giftRecipient": { + "title": "Scegli il destinatario del dono", + "subtitle": "Scegli quale avversario riceve il dono promesso." + }, + "optionalCost": { + "gift": { + "title": "Promettere un dono?", + "pay": "Prometti {{gift}}", + "decline": "Lancia senza promettere", + "kind": { + "card": "una carta", + "treasure": "un Tesoro", + "food": "un Cibo", + "tappedFish": "un Pesce TAPpato" + } + } + }, "clashOpponent": { "title": "Scegli l'avversario per lo scontro", "subtitle": "Scegli quale avversario si scontrerà con te." diff --git a/client/src/i18n/locales/pl/common.json b/client/src/i18n/locales/pl/common.json index d56ad7620f..6cf3dd7c21 100644 --- a/client/src/i18n/locales/pl/common.json +++ b/client/src/i18n/locales/pl/common.json @@ -15,6 +15,24 @@ "enter": "Włącz pełny ekran", "exit": "Wyłącz pełny ekran" }, + "nativeEngineProgress": { + "title": "Aktualizowanie silnika natywnego", + "resolving": "Wyszukiwanie właściwego lokalnego serwera…", + "downloadingBinary": "Pobieranie zaktualizowanego serwera…", + "verifying": "Weryfikowanie serwera…", + "downloadingData": "Pobieranie danych gry…", + "spawning": "Uruchamianie lokalnego serwera…", + "ready": "Silnik natywny jest gotowy", + "failed": "Nie można przygotować silnika natywnego" + }, + "engineBadge": { + "native": "Silnik natywny", + "nativeTooltip": "Ta gra korzysta z natywnego silnika aplikacji desktopowej.", + "inBrowser": "Silnik przeglądarki", + "inBrowserTooltip": "Silnik natywny niedostępny — ta gra działa w przeglądarce.", + "versionMismatchTooltip": "Niezgodna wersja silnika natywnego — ta gra działa w przeglądarce.", + "notAttemptedTooltip": "Ta gra działa w przeglądarce — silnik natywny nie został użyty." + }, "buildBadge": { "downloading": "pobieranie… {{progress}}%", "checking": "sprawdzanie…", @@ -247,7 +265,13 @@ "youPlayFirst": "Zaczynasz", "playerPlaysFirst": "{{name}} zaczyna", "heads": "Orzeł", - "tails": "Reszka" + "tails": "Reszka", + "coinFlip": "Rzut monetą", + "wonCoinFlip": "{{name}} wygrywa rzut monetą", + "lostCoinFlip": "{{name}} przegrywa rzut monetą", + "turnOrder": "Kolejność tur", + "turnOrderItem": "Tura {{turn}}: {{name}}", + "youTakeTurn": "Rozgrywasz turę {{turn}}" }, "chrome": { "back": "Wstecz", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index fb62f4f14b..7162f72104 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -248,6 +248,7 @@ "target": "Cel", "copy": "Kopia", "copyTooltip": "Kopia tokenowa prawdziwej karty", + "cantBeBlocked": "Nie może zostać zablokowana", "ringBearer": "Pierścień", "ringBearerTooltip": "Powiernik Pierścienia", "hiddenAttachments": "+{{count}} more attached", @@ -662,6 +663,7 @@ "mustAttackBadge": "Must attack", "mustAttackSatisfiedBadge": "Attacking ✓", "mustBlockBadge": "Must block", + "mustBlockExactBadge": "Musi zablokować {{attacker}}", "mustBlockSatisfiedBadge": "Blocking ✓", "cantAttackBadge": "Can't attack", "cantBlockBadge": "Can't block", @@ -1710,6 +1712,23 @@ "targetCreature": "cel stwora", "targetChoice": "ten wybór celu" }, + "giftRecipient": { + "title": "Wybierz odbiorcę prezentu", + "subtitle": "Wybierz przeciwnika, który otrzyma obiecany prezent." + }, + "optionalCost": { + "gift": { + "title": "Obiecać prezent?", + "pay": "Obiecaj {{gift}}", + "decline": "Rzuć bez obietnicy", + "kind": { + "card": "kartę", + "treasure": "Skarb", + "food": "Pożywienie", + "tappedFish": "zakręconą Rybę" + } + } + }, "clashOpponent": { "title": "Wybierz przeciwnika do starcia", "subtitle": "Wybierz, który przeciwnik stoczy z tobą starcie." diff --git a/client/src/i18n/locales/pt/common.json b/client/src/i18n/locales/pt/common.json index 3f6e5fbfb0..5bb44cb5b4 100644 --- a/client/src/i18n/locales/pt/common.json +++ b/client/src/i18n/locales/pt/common.json @@ -15,6 +15,24 @@ "enter": "Entrar em tela cheia", "exit": "Sair da tela cheia" }, + "nativeEngineProgress": { + "title": "Atualizando o motor nativo", + "resolving": "Encontrando o servidor local correto…", + "downloadingBinary": "Baixando o servidor atualizado…", + "verifying": "Verificando o servidor…", + "downloadingData": "Baixando dados do jogo…", + "spawning": "Iniciando o servidor local…", + "ready": "Motor nativo pronto", + "failed": "Não foi possível preparar o motor nativo" + }, + "engineBadge": { + "native": "Motor nativo", + "nativeTooltip": "Esta partida usa o motor nativo do aplicativo para desktop.", + "inBrowser": "Motor do navegador", + "inBrowserTooltip": "Motor nativo indisponível — esta partida está sendo executada no navegador.", + "versionMismatchTooltip": "Versão do motor nativo incompatível — esta partida está sendo executada no navegador.", + "notAttemptedTooltip": "Esta partida está sendo executada no navegador — o motor nativo não foi usado." + }, "buildBadge": { "downloading": "baixando… {{progress}}%", "checking": "verificando…", @@ -247,7 +265,13 @@ "youPlayFirst": "Você joga primeiro", "playerPlaysFirst": "{{name}} joga primeiro", "heads": "Cara", - "tails": "Coroa" + "tails": "Coroa", + "coinFlip": "Cara ou coroa", + "wonCoinFlip": "{{name}} venceu o lançamento", + "lostCoinFlip": "{{name}} perdeu o lançamento", + "turnOrder": "Ordem dos turnos", + "turnOrderItem": "Turno {{turn}}: {{name}}", + "youTakeTurn": "Você joga no turno {{turn}}" }, "chrome": { "back": "Voltar", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 92f350e662..8e3ea63e4b 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -248,6 +248,7 @@ "target": "Alvo", "copy": "Cópia", "copyTooltip": "Cópia em ficha de uma carta real", + "cantBeBlocked": "Não pode ser bloqueada", "ringBearer": "Anel", "ringBearerTooltip": "Portador do Anel", "hiddenAttachments": "+{{count}} more attached", @@ -662,6 +663,7 @@ "mustAttackBadge": "Must attack", "mustAttackSatisfiedBadge": "Attacking ✓", "mustBlockBadge": "Must block", + "mustBlockExactBadge": "Deve bloquear {{attacker}}", "mustBlockSatisfiedBadge": "Blocking ✓", "cantAttackBadge": "Can't attack", "cantBlockBadge": "Can't block", @@ -1710,6 +1712,23 @@ "targetCreature": "o alvo de criatura", "targetChoice": "esta escolha de alvo" }, + "giftRecipient": { + "title": "Escolher destinatário do presente", + "subtitle": "Escolha qual oponente recebe o presente prometido." + }, + "optionalCost": { + "gift": { + "title": "Prometer um presente?", + "pay": "Prometer {{gift}}", + "decline": "Conjurar sem prometer", + "kind": { + "card": "um card", + "treasure": "um Tesouro", + "food": "um Alimento", + "tappedFish": "um Peixe virado" + } + } + }, "clashOpponent": { "title": "Escolher oponente para confronto", "subtitle": "Escolha qual oponente fará confronto com você." diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 8332f0cc5a..f0eb31e763 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -102,6 +102,7 @@ import { ClashOpponentModal } from "../components/modal/ClashOpponentModal.tsx"; import { ZoneOpponentChooserModal } from "../components/modal/ZoneOpponentChooserModal.tsx"; import { PileOpponentModal } from "../components/modal/PileOpponentModal.tsx"; import { AnnouncingOpponentModal } from "../components/modal/AnnouncingOpponentModal.tsx"; +import { GiftRecipientModal } from "../components/modal/GiftRecipientModal.tsx"; import { TributeModal } from "../components/modal/TributeModal.tsx"; import { CombatTaxModal } from "../components/modal/CombatTaxModal.tsx"; import { TopOrBottomChoiceModalContent } from "../components/modal/TopOrBottomChoiceModal.tsx"; @@ -844,13 +845,22 @@ function GamePageContent({ // identity-ref latch makes the consume idempotent under React StrictMode's // double-invoke and after the clear (the re-run sees `null`). const startingContest = useGameStore((s) => s.startingContest); + const openingTurnOrder = useGameStore((s) => s.gameState?.derived?.turn_order); + const openingViewerTurnNumber = useGameStore( + (s) => s.gameState?.derived?.viewer_turn_number, + ); const consumedContestRef = useRef(null); useEffect(() => { if (!startingContest || consumedContestRef.current === startingContest) return; consumedContestRef.current = startingContest; - flashStartingPlayerContest(startingContest.events, startingContest.startingPlayer); + flashStartingPlayerContest( + startingContest.events, + startingContest.startingPlayer, + openingTurnOrder, + openingViewerTurnNumber, + ); useGameStore.getState().clearStartingContest(); - }, [startingContest]); + }, [openingTurnOrder, openingViewerTurnNumber, startingContest]); // CR 103.1 before CR 103.5: the starting-player contest must finish before the // mulligan UI appears (the roll determines who's on the play, which precedes // drawing opening hands). True from `initGame` setting the carrier through the @@ -1532,13 +1542,15 @@ function GamePageContent({ - {/* Connection failure toast */} - {isOnlineMode && ( - window.location.reload()} - onSettings={() => setPreferencesOpen({})} - /> - )} + {/* The page's toast surface, not an online-only one: solo games raise + toasts too (the native-engine fallback notice). Only online games get + a Retry — reloading re-dials the server, but a solo game has nothing + to re-dial and would just restart itself. */} + window.location.reload() : undefined} + onSettings={() => setPreferencesOpen({})} + /> + {/* @@ -1741,6 +1753,7 @@ function GamePageContent({ + diff --git a/client/src/pages/__tests__/GamePage.bracketViolation.test.tsx b/client/src/pages/__tests__/GamePage.bracketViolation.test.tsx index 64f007f449..72e7c13bce 100644 --- a/client/src/pages/__tests__/GamePage.bracketViolation.test.tsx +++ b/client/src/pages/__tests__/GamePage.bracketViolation.test.tsx @@ -33,7 +33,7 @@ const { mockClearPromptOverlayState, mockSetGameState } = vi.hoisted(() => ({ mockSetGameState: vi.fn(), })); -const { mockMultiplayerState: _mockMultiplayerState, mockUseMultiplayerStore } = vi.hoisted(() => { +const { mockMultiplayerState, mockUseMultiplayerStore } = vi.hoisted(() => { const mockMultiplayerState = { serverInfo: null, activePlayerId: null, @@ -41,7 +41,8 @@ const { mockMultiplayerState: _mockMultiplayerState, mockUseMultiplayerStore } = playerAvatars: new Map(), connectionStatus: "disconnected", isSpectator: false, - toasts: [] as unknown[], + // Keyed Map, matching the real store — ConnectionToast reads `.size`. + toasts: new Map(), hostGameCode: null, hostingStatus: "idle", playerSlots: [] as unknown[], @@ -404,3 +405,39 @@ describe("GamePage — cEDH bracket-violation blocking modal", () => { expect(screen.queryByTestId("bracket-violation-modal")).toBeNull(); }); }); + +describe("GamePage — toast surface", () => { + const FALLBACK_NOTICE = "Native engine unavailable — this game is running in-browser."; + + function seedToast(): void { + mockMultiplayerState.toasts = new Map([ + ["generic", { message: FALLBACK_NOTICE, expiresAt: Date.now() + 5_000, showCountdown: false }], + ]); + } + + afterEach(() => { + mockMultiplayerState.toasts = new Map(); + }); + + it("shows a solo game's toast", () => { + // The native-engine fallback notice is raised in `ai` mode. This surface + // used to be gated on online mode, so the notice was written to the store + // and then rendered by nothing at all. + seedToast(); + + renderGamePage("/game/test-game-123?mode=ai"); + + expect(screen.getByText(FALLBACK_NOTICE)).toBeInTheDocument(); + }); + + it("offers a solo game no Retry, since there is no server to re-dial", () => { + seedToast(); + + renderGamePage("/game/test-game-123?mode=ai"); + + // Settings is the reach guard: it proves the toast's button row rendered, + // so Retry's absence is the omitted prop rather than an unmounted toast. + expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); + }); +}); diff --git a/client/src/providers/GameProvider.tsx b/client/src/providers/GameProvider.tsx index bb1a058f2e..df43bd82b4 100644 --- a/client/src/providers/GameProvider.tsx +++ b/client/src/providers/GameProvider.tsx @@ -651,6 +651,7 @@ export function GameProvider({ initGame, resumeGame, resumeP2PHost, + resumeNativeSolo, reset, setEngineMode, setGameMode, @@ -722,6 +723,7 @@ export function GameProvider({ audioManager.setContext("battlefield"); return () => { audioManager.setContext("menu"); + clearPromptOverlayState(); }; } @@ -1036,6 +1038,7 @@ export function GameProvider({ // sessions, clears timers, and disposes the WASM engine. if (p2pAdapter) p2pAdapter.dispose(); audioManager.setContext("menu"); + clearPromptOverlayState(); reset(); }; } @@ -1246,6 +1249,7 @@ export function GameProvider({ useMultiplayerStore.getState().setIsSpectator(false); useMultiplayerStore.getState().setSpectators([]); audioManager.setContext("menu"); + clearPromptOverlayState(); reset(); }; } @@ -1481,62 +1485,89 @@ export function GameProvider({ if (shouldUseNativeAi && nativeEngineKey) { const setupNativeAi = async () => { + // A native socket that dies before the session is live is not a lost + // connection the player can act on — the `catch` below silently falls + // back to WASM, so surfacing GamePage's terminal connection-lost banner + // would paint it over a healthy local game that plays on underneath. + let nativeSessionLive = false; + // A native pointer marks a server-authoritative game held by the local + // phase-server; it is resumed by reconnecting, not by loading a local + // snapshot. Presence of `nativeSession` on this game's active pointer is + // the resume signal (fresh games have no pointer yet at this point). + const activePointer = loadActiveGame(); + const nativeResume = + activePointer?.id === gameId ? activePointer.nativeSession : undefined; + // Populated only for a fresh game; a reconnect ignores the deck entirely. + let deckList: DeckListPayload | undefined; try { - // Native sessions deliberately do not resume a local snapshot. A - // pre-existing state belongs to the established WASM path instead. - if (await loadGame(gameId)) { + // A local snapshot belongs to the established WASM path — but only for + // a fresh game. A native resume has no local snapshot (state lives in + // the phase-server), so a stray one must never hijack the reconnect. + if (!nativeResume && (await loadGame(gameId))) { setEngineMode("wasm"); await setupLocal(); return; } if (cancelled) return; - const activeDeckName = localStorage.getItem(ACTIVE_DECK_KEY); - const randomPlayerDeck = isRandomDeckSelection(activeDeckName); - const parsedDeck = randomPlayerDeck ? null : loadActiveDeck(); - const suppliesDeck = formatConfig ? formatSuppliesDeck(formatConfig.format) : false; - if (!parsedDeck && !suppliesDeck && !randomPlayerDeck) { - onNoDeckRef.current?.(); - return; - } + if (!nativeResume) { + const activeDeckName = localStorage.getItem(ACTIVE_DECK_KEY); + const randomPlayerDeck = isRandomDeckSelection(activeDeckName); + const parsedDeck = randomPlayerDeck ? null : loadActiveDeck(); + const suppliesDeck = formatConfig ? formatSuppliesDeck(formatConfig.format) : false; + if (!parsedDeck && !suppliesDeck && !randomPlayerDeck) { + onNoDeckRef.current?.(); + return; + } - const deckList = await buildLocalAiDeckList( - tRef.current, - randomPlayerDeck ? null : (parsedDeck ?? EMPTY_PARSED_DECK), - playerCount ?? 2, - formatConfig, - matchConfig?.match_type, - loadActiveDeckBracket(), - ); - if (cancelled) return; + deckList = await buildLocalAiDeckList( + tRef.current, + randomPlayerDeck ? null : (parsedDeck ?? EMPTY_PARSED_DECK), + playerCount ?? 2, + formatConfig, + matchConfig?.match_type, + loadActiveDeckBracket(), + ); + if (cancelled) return; + } - // Native games are server-authoritative and have no client resume - // contract in v1, so remove the setup-page pointer before hosting. - clearActiveGame(); await ensureNativeEngine(nativeEngineKey); if (cancelled) return; + const expectedServerVersion = + "release" in nativeEngineKey ? nativeEngineKey.release.version : undefined; nativeAdapter = new WebSocketAdapter( "native-engine", "host", - deckList.player, + deckList?.player ?? { main_deck: [], sideboard: [] }, undefined, undefined, undefined, "Player", - { - nativeAi: { - socketFactory: () => new NativeEngineSocket(), - aiSeats: nativeAiSeatsFromDeckList(deckList), - playerCount: playerCount ?? 2, - formatConfig, - matchConfig, - expectedServerVersion: - "release" in nativeEngineKey ? nativeEngineKey.release.version : undefined, - }, - }, + nativeResume + ? { + nativePregame: { + kind: "reconnect", + gameCode: nativeResume.gameCode, + playerId: nativeResume.playerId, + playerToken: nativeResume.playerToken, + socketFactory: () => new NativeEngineSocket(), + expectedServerVersion, + }, + } + : { + nativeAi: { + socketFactory: () => new NativeEngineSocket(), + aiSeats: nativeAiSeatsFromDeckList(deckList!), + playerCount: playerCount ?? 2, + formatConfig, + matchConfig, + expectedServerVersion, + }, + }, ); - wsUnsubscribe = nativeAdapter.onEvent((event) => { + + const handleNativeEvent = (event: WsAdapterEvent) => { if (event.type === "stateChanged") { const adapter = nativeAdapter; if (!useGameStore.getState().adapter && adapter) { @@ -1560,35 +1591,104 @@ export function GameProvider({ } // GamePage's existing reconnect-failed/error surface is terminal // and provides the Return-to-Menu action for this native session. - onWsEventRef.current?.(event); + // Setup failures instead reject the pending init, which the + // `catch` turns into a fallback with no banner. + if (nativeSessionLive) onWsEventRef.current?.(event); } - }); + }; setEngineMode("native"); - await initGame( - gameId, - nativeAdapter, - undefined, - formatConfig, - playerCount, - matchConfig, - ); - if (cancelled) { - nativeAdapter.dispose({ concede: true }); - return; + if (nativeResume) { + // Reconnect and seed from the server's authoritative state. Events + // are wired AFTER this: `initialize()` emits the initial + // `stateChanged` synchronously, which `resumeNativeSolo`'s snapshot + // fetch already captures — a listener here would double-apply it. + await resumeNativeSolo(gameId, nativeAdapter); + if (cancelled) { + nativeAdapter.dispose(); + return; + } + wsUnsubscribe = nativeAdapter.onEvent(handleNativeEvent); + } else { + wsUnsubscribe = nativeAdapter.onEvent(handleNativeEvent); + await initGame( + gameId, + nativeAdapter, + undefined, + formatConfig, + playerCount, + matchConfig, + ); + if (cancelled) { + nativeAdapter.dispose(); + return; + } } setGameMode("native-ai"); controller = createGameLoopController({ mode: "online" }); controller.start(); + nativeSessionLive = true; + // Persist the resume pointer only now that the session is live — an + // earlier write would strand a Resume button if setup failed midway. + // Suspending (navigating away) keeps this pointer; only an explicit + // Concede (useConcedeHandler → clearGame) removes it. The player token + // lives only here — it is the reconnect credential. + const session = nativeAdapter.nativeSession; + if (session) { + saveActiveGame( + nativeResume && activePointer + ? { ...activePointer, nativeSession: session } + : { + id: gameId, + mode: "ai", + difficulty: difficulty ?? "Medium", + aiSeats: deckList + ? nativeAiSeatsFromDeckList(deckList).map((seat) => ({ + difficulty: seat.difficulty, + })) + : undefined, + formatConfig, + nativeSession: session, + }, + ); + } audioManager.setContext("battlefield"); } catch (error) { - nativeAdapter?.dispose({ concede: true }); + // Setup failed before the session went live — suspend (never concede); + // there is nothing authoritative to end. + nativeAdapter?.dispose(); nativeAdapter = null; if (cancelled) return; + if (nativeResume) { + // A resume has no local snapshot to fall back to — the state lives + // only in the phase-server. Surface the failure via the terminal + // reconnect/error banner instead of silently starting a fresh WASM + // game (which would look like the suspended game vanished). The + // pointer is kept so the player can retry once the engine is back. + setGameMode("ai"); + setEngineMode("native", nativeFallbackReason(error)); + onWsEventRef.current?.({ + type: "error", + message: error instanceof Error ? error.message : String(error), + }); + return; + } + + const fallbackReason = nativeFallbackReason(error); setGameMode("ai"); - setEngineMode("wasm", nativeFallbackReason(error)); + setEngineMode("wasm", fallbackReason); + // Losing the native engine is otherwise invisible — the game simply + // plays slower. EngineModeBadge carries the standing signal; this is + // the one-shot that says it happened just now. + useMultiplayerStore.getState().showToast( + tRef.current( + fallbackReason === "server_version_mismatch" + ? "common:engineBadge.versionMismatchTooltip" + : "common:engineBadge.inBrowserTooltip", + ), + ); saveWasmAiResumePointer(gameId, difficulty, playerCount, formatConfig); await setupLocal(); } @@ -1600,7 +1700,11 @@ export function GameProvider({ cancelled = true; if (controller) controller.dispose(); if (wsUnsubscribe) wsUnsubscribe(); - nativeAdapter?.dispose({ concede: true }); + // Suspend, don't concede: leaving the game page keeps the server-side + // session alive and resumable via the persisted native pointer. Only an + // explicit Concede (useConcedeHandler) ends the game. `dispose()` still + // tears down the socket/loop; it just omits the concede frame. + nativeAdapter?.dispose(); audioManager.setContext("menu"); clearPromptOverlayState(); scheduleStoreReset(reset); diff --git a/client/src/providers/__tests__/GameProvider.nativeEngine.test.tsx b/client/src/providers/__tests__/GameProvider.nativeEngine.test.tsx index 6225950d0d..39b9089a08 100644 --- a/client/src/providers/__tests__/GameProvider.nativeEngine.test.tsx +++ b/client/src/providers/__tests__/GameProvider.nativeEngine.test.tsx @@ -16,8 +16,10 @@ const { fetchAvatarArtUrl, gameStoreState, getSharedAdapter, + loadActiveGame, nativeAdapterInitialize, nativeAdapters, + multiplayerDraftGetState, multiplayerGetState, multiplayerState, preferences, @@ -41,9 +43,16 @@ const { cedhMode: false, nativeEngineEnabled: true, }; + type NativePregameReconnect = { + kind: string; + gameCode: string; + playerId: number; + playerToken: string; + }; class WebSocketAdapter { private listener: ((event: NativeAdapterEvent) => void) | null = null; readonly nativeAiOptions: { aiSeats: Array<{ difficulty: string }> } | undefined; + readonly nativePregameOptions: NativePregameReconnect | undefined; dispose = vi.fn(); onEvent = vi.fn((listener: (event: NativeAdapterEvent) => void) => { this.listener = listener; @@ -60,9 +69,13 @@ const { _joinPassword?: string, _reservationToken?: string, _displayName?: string, - options?: { nativeAi?: { aiSeats: Array<{ difficulty: string }> } }, + options?: { + nativeAi?: { aiSeats: Array<{ difficulty: string }> }; + nativePregame?: NativePregameReconnect; + }, ) { this.nativeAiOptions = options?.nativeAi; + this.nativePregameOptions = options?.nativePregame; nativeAdapters.push(this); } @@ -70,6 +83,18 @@ const { return nativeAdapterInitialize(); } + // Reconnect adapters echo their supplied creds; a fresh game gets a stable + // server-issued session so the resume pointer can be persisted. + get nativeSession(): { gameCode: string; playerId: number; playerToken: string } { + return this.nativePregameOptions + ? { + gameCode: this.nativePregameOptions.gameCode, + playerId: this.nativePregameOptions.playerId, + playerToken: this.nativePregameOptions.playerToken, + } + : { gameCode: "NATIVE-SESSION", playerId: 0, playerToken: "native-token" }; + } + emit(event: NativeAdapterEvent): void { this.listener?.(event); } @@ -99,6 +124,11 @@ const { }), resumeGame: vi.fn(), resumeP2PHost: vi.fn(), + resumeNativeSolo: vi.fn(async (gameId: string, adapter: { initialize: () => Promise }) => { + gameStoreState.gameId = gameId; + gameStoreState.adapter = adapter; + await adapter.initialize(); + }), reset: vi.fn(), setEngineMode: vi.fn(), setGameMode: vi.fn(), @@ -123,6 +153,7 @@ const { showToast: vi.fn(), }; const multiplayerGetState = vi.fn(() => multiplayerState); + const multiplayerDraftGetState = vi.fn(() => ({ matchPairing: null })); return { NativeEngineVersionMismatchError, @@ -133,8 +164,10 @@ const { fetchAvatarArtUrl, gameStoreState, getSharedAdapter, + loadActiveGame: vi.fn<() => Record | null>(() => null), nativeAdapterInitialize, nativeAdapters, + multiplayerDraftGetState, multiplayerGetState, multiplayerState, preferences, @@ -168,7 +201,7 @@ vi.mock("../../stores/gameStore", () => ({ clearActiveGame, clearGame: vi.fn(), clearP2PHostSession: vi.fn(), - loadActiveGame: vi.fn(() => null), + loadActiveGame, loadGame: vi.fn(async () => null), loadP2PHostSession: vi.fn(), nextGameSessionGeneration: vi.fn(() => 1), @@ -247,7 +280,7 @@ vi.mock("../../stores/multiplayerStore", () => ({ })); vi.mock("../../stores/multiplayerDraftStore", () => ({ - useMultiplayerDraftStore: { getState: vi.fn() }, + useMultiplayerDraftStore: { getState: multiplayerDraftGetState }, })); vi.mock("../../services/playerAvatars", () => ({ @@ -279,6 +312,7 @@ vi.mock("../../services/serverDetection", () => ({ import { GameProvider } from "../GameProvider"; import { AdapterError, AdapterErrorCode } from "../../adapter/types"; +import { clearPromptOverlayState } from "../../game/sessionCleanup"; describe("GameProvider native AI routing", () => { beforeEach(() => { @@ -290,8 +324,14 @@ describe("GameProvider native AI routing", () => { fetchAvatarArtUrl.mockReset(); nativeAdapterInitialize.mockReset(); saveActiveGame.mockReset(); + loadActiveGame.mockReset(); + loadActiveGame.mockReturnValue(null); + gameStoreState.resumeNativeSolo.mockClear(); + gameStoreState.initGame.mockClear(); nativeAdapters.splice(0); wasmAdapters.splice(0); + multiplayerDraftGetState.mockReset(); + multiplayerDraftGetState.mockReturnValue({ matchPairing: null }); multiplayerGetState.mockReset(); multiplayerGetState.mockReturnValue(multiplayerState); preferences.aiSeats = [{ difficulty: "Medium", deckId: "Random" }]; @@ -306,6 +346,9 @@ describe("GameProvider native AI routing", () => { afterEach(() => { cleanup(); + gameStoreState.adapter = null; + gameStoreState.gameId = null; + gameStoreState.gameState = null; }); it("falls back to WASM when release parity rejects the native engine", async () => { @@ -328,11 +371,16 @@ describe("GameProvider native AI routing", () => { expect.objectContaining({ id: "native-parity", mode: "ai" }), ); expect(wasmAdapters).toHaveLength(1); + // The fallback is otherwise silent, and a version mismatch is a different + // user problem than an engine that could not start at all. + expect(multiplayerState.showToast).toHaveBeenCalledWith( + "Native engine version mismatch — this game is running in-browser.", + ); }); - it("does not write a resume pointer for a native game and concedes on exit", async () => { + it("writes a native resume pointer and suspends (no concede) on exit", async () => { const view = render( - +
, ); @@ -347,13 +395,94 @@ describe("GameProvider native AI routing", () => { expect(gameStoreState.setEngineMode.mock.invocationCallOrder[nativeEngineModeCall]).toBeLessThan( gameStoreState.initGame.mock.invocationCallOrder[0], ); - expect(clearActiveGame).toHaveBeenCalledOnce(); - expect(saveActiveGame).not.toHaveBeenCalled(); - expect(multiplayerGetState).not.toHaveBeenCalled(); + + // A live native game persists a server-authoritative resume pointer carrying + // the reconnect credentials — the old no-resume contract cleared it instead. + await waitFor(() => { + expect(saveActiveGame).toHaveBeenCalledWith( + expect.objectContaining({ + id: "native-resume-write", + mode: "ai", + nativeSession: expect.objectContaining({ + gameCode: "NATIVE-SESSION", + playerToken: "native-token", + }), + }), + ); + }); + expect(clearActiveGame).not.toHaveBeenCalled(); view.unmount(); expect(nativeAdapters).toHaveLength(1); - expect(nativeAdapters[0].dispose).toHaveBeenCalledWith({ concede: true }); + // Suspend, not concede: leaving keeps the server session resumable. Only an + // explicit Concede (useConcedeHandler) ends the game. + expect(nativeAdapters[0].dispose).toHaveBeenCalledWith(); + }); + + it("reconnects to a suspended native game via its resume pointer", async () => { + loadActiveGame.mockReturnValue({ + id: "native-resume-read", + mode: "ai", + difficulty: "Medium", + nativeSession: { gameCode: "GAME-XYZ", playerId: 0, playerToken: "tok-xyz" }, + }); + + render( + +
+ , + ); + + await waitFor(() => { + expect(gameStoreState.resumeNativeSolo).toHaveBeenCalled(); + }); + + // The adapter is built with a reconnect pregame frame carrying the persisted + // credentials, and the fresh-game `initGame` path is never taken. + expect(nativeAdapters).toHaveLength(1); + expect(nativeAdapters[0].nativePregameOptions).toEqual( + expect.objectContaining({ + kind: "reconnect", + gameCode: "GAME-XYZ", + playerId: 0, + playerToken: "tok-xyz", + }), + ); + expect(gameStoreState.resumeNativeSolo).toHaveBeenCalledWith( + "native-resume-read", + nativeAdapters[0], + ); + expect(gameStoreState.initGame).not.toHaveBeenCalled(); + }); + + it("surfaces a terminal error when a native resume fails, without a WASM fallback", async () => { + loadActiveGame.mockReturnValue({ + id: "native-resume-fail", + mode: "ai", + difficulty: "Medium", + nativeSession: { gameCode: "GONE", playerId: 0, playerToken: "tok" }, + }); + // The reconnect handshake fails (e.g. the server no longer holds the game). + nativeAdapterInitialize.mockRejectedValue(new Error("Reconnect grace period expired")); + const onWsEvent = vi.fn(); + + render( + +
+ , + ); + + await waitFor(() => { + expect(onWsEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", message: "Reconnect grace period expired" }), + ); + }); + // A resume has no local snapshot, so it must NOT silently fall back to a + // fresh WASM game (which would look like the suspended game vanished). + expect(gameStoreState.initGame).not.toHaveBeenCalled(); + expect(wasmAdapters).toHaveLength(0); + // The pointer is kept so the player can retry once the engine is back. + expect(clearActiveGame).not.toHaveBeenCalled(); }); it("uses each commander's name for native AI opponents", async () => { @@ -466,8 +595,11 @@ describe("GameProvider native AI routing", () => { , ); + // `native-ai` is set immediately before the game loop starts, so it is the + // signal that the session is live — the point from which a terminal socket + // event is a real lost connection rather than a setup failure. await waitFor(() => { - expect(gameStoreState.setEngineMode).toHaveBeenCalledWith("native"); + expect(gameStoreState.setGameMode).toHaveBeenCalledWith("native-ai"); expect(nativeAdapters).toHaveLength(1); }); @@ -486,6 +618,51 @@ describe("GameProvider native AI routing", () => { it("disposes a native game and surfaces bridge errors as terminal", async () => { await expectNativeTerminalEvent({ type: "error", message: "WebSocket connection failed" }); }); + + it("keeps a native setup failure off the terminal connection surface", async () => { + // The socket dying during the native handshake emits a terminal event and + // then rejects initialization. The rejection is handled by falling back to + // WASM, so forwarding the event would leave GamePage's connection-lost + // banner pinned over the local game that took over. + const onWsEvent = vi.fn(); + nativeAdapterInitialize.mockImplementation(async () => { + nativeAdapters[0]!.emit({ type: "reconnectFailed" }); + throw new Error("Connection closed before game started"); + }); + + render( + +
+ , + ); + + await waitFor(() => { + expect(gameStoreState.setEngineMode).toHaveBeenCalledWith("wasm", expect.anything()); + }); + expect(wasmAdapters).toHaveLength(1); + expect(onWsEvent).not.toHaveBeenCalled(); + // Silent to the banner, but not silent to the player. + expect(multiplayerState.showToast).toHaveBeenCalledWith( + "Native engine unavailable — this game is running in-browser.", + ); + }); + + it("clears prompt overlays when a draft match unmounts", () => { + gameStoreState.gameId = "draft-match"; + gameStoreState.adapter = {} as never; + gameStoreState.gameState = {} as never; + + const view = render( + +
+ , + ); + + vi.mocked(clearPromptOverlayState).mockClear(); + view.unmount(); + + expect(clearPromptOverlayState).toHaveBeenCalledOnce(); + }); }); describe("GameProvider online deck rejection", () => { diff --git a/client/src/services/gamePersistence.ts b/client/src/services/gamePersistence.ts index 6456f37d4e..537b1cb61f 100644 --- a/client/src/services/gamePersistence.ts +++ b/client/src/services/gamePersistence.ts @@ -6,6 +6,7 @@ import type { GameState, MatchConfig, PersistedGameState, + PlayerId, } from "../adapter/types"; import type { SeatState } from "../multiplayer/seatTypes"; import { ACTIVE_GAME_KEY, GAME_CHECKPOINTS_PREFIX, GAME_KEY_PREFIX } from "../constants/storage"; @@ -21,6 +22,22 @@ export interface AiSeatMeta { deckName?: string | null; } +/** + * Credentials to reconnect a suspended native-engine solo (AI) game. + * + * Native games are server-authoritative: the game state lives in the local + * phase-server's `games.db`, never in IndexedDB. The player token is issued + * once at game creation and is the reconnect security boundary — it lives only + * client-side, so it must be persisted here for the game to be resumable. + * Presence of this field is what marks an `ActiveGameMeta` as a native resume + * (which has no local `saveGame` snapshot to validate against). + */ +export interface NativeSoloSession { + gameCode: string; + playerId: PlayerId; + playerToken: string; +} + export interface ActiveGameMeta { id: string; mode: "ai" | "local" | "online" | "p2p-host" | "p2p-join"; @@ -37,6 +54,11 @@ export interface ActiveGameMeta { formatConfig?: FormatConfig; /** Bare 5-char room code for P2P guest resume. */ p2pRoomCode?: string; + /** Present for native-engine solo (AI) games hosted by the local + * phase-server. Its presence marks this pointer as a native resume; on + * resume the client reconnects to the server session rather than loading a + * local snapshot. Absent for in-browser (WASM) AI games. */ + nativeSession?: NativeSoloSession; } /** diff --git a/client/src/services/nativeEngine.ts b/client/src/services/nativeEngine.ts index 3746ae36b0..542dcbaa86 100644 --- a/client/src/services/nativeEngine.ts +++ b/client/src/services/nativeEngine.ts @@ -1,3 +1,5 @@ +import { useSyncExternalStore } from "react"; + import { isTauri } from "./platform"; export type NativeEngineKey = @@ -8,6 +10,20 @@ export interface NativeEngineReady { port: number; } +export type NativeEngineProgressPhase = + | "resolving" + | "downloading_binary" + | "verifying" + | "downloading_data" + | "spawning" + | "ready" + | "failed"; + +export interface NativeEngineProgress { + phase: NativeEngineProgressPhase; + detail?: string; +} + /** * Returns the shell-verifiable artifact key for this first-party origin. * Preview builds without a stamped fingerprint intentionally return `null` so @@ -33,8 +49,64 @@ export function canAttemptNativeEngine(enabled: boolean): boolean { return enabled && isTauri() && nativeEngineKeyForCurrentOrigin() !== null; } +/** + * Provisioning is in flight for exactly as long as an `ensureNativeEngine` call + * is unsettled. This — not the shell's progress events — is the authority on + * whether the shell is still working. + * + * The shell is a separately-versioned binary that updates far less often than + * this remote content, so a shipped shell may emit phases this build has never + * heard of, or (as `shell-v1.0.1` does on every failure) stop emitting without + * a terminal phase at all. Progress events are advisory decoration on top of + * the call's own lifetime; anything that must eventually stop belongs here. + */ +let provisioningCalls = 0; +const provisioningListeners = new Set<() => void>(); + +function setProvisioningCalls(next: number): void { + provisioningCalls = next; + for (const notify of provisioningListeners) notify(); +} + +function subscribeNativeEngineProvisioning(callback: () => void): () => void { + provisioningListeners.add(callback); + return () => provisioningListeners.delete(callback); +} + +function getNativeEngineProvisioning(): boolean { + return provisioningCalls > 0; +} + +/** React hook — true while any `ensureNativeEngine` call is still unsettled. */ +export function useNativeEngineProvisioning(): boolean { + return useSyncExternalStore(subscribeNativeEngineProvisioning, getNativeEngineProvisioning); +} + /** Feature-detects the shell command at invocation time for plain-web fallback. */ export async function ensureNativeEngine(key: NativeEngineKey): Promise { + setProvisioningCalls(provisioningCalls + 1); + try { + const { invoke } = await import("@tauri-apps/api/core"); + return await invoke("ensure_native_engine", { key }); + } finally { + setProvisioningCalls(provisioningCalls - 1); + } +} + +/** Returns progress emitted before this webview registered its listener. */ +export async function getNativeEngineProgress(): Promise { + if (!isTauri()) return null; + const { invoke } = await import("@tauri-apps/api/core"); - return invoke("ensure_native_engine", { key }); + return invoke("native_engine_progress"); +} + +/** Subscribes to the shell's native-engine provisioning progress. */ +export async function subscribeNativeEngineProgress( + listener: (progress: NativeEngineProgress) => void, +): Promise<() => void> { + if (!isTauri()) return () => {}; + + const { listen } = await import("@tauri-apps/api/event"); + return listen("native-engine-progress", ({ payload }) => listener(payload)); } diff --git a/client/src/stores/__tests__/gameStore.test.ts b/client/src/stores/__tests__/gameStore.test.ts index 1ff7ca18e4..20f08454a7 100644 --- a/client/src/stores/__tests__/gameStore.test.ts +++ b/client/src/stores/__tests__/gameStore.test.ts @@ -1,7 +1,7 @@ import { act } from "react"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { GameEvent, GameState } from "../../adapter/types"; +import type { EngineAdapter, GameEvent, GameState } from "../../adapter/types"; import { buildEngineAdapterMock } from "../../test/factories/engineAdapterFactory"; import { buildGameState, @@ -44,6 +44,21 @@ describe("gameStore", () => { expect(adapter.initialize).toHaveBeenCalled(); }); + it("binds the adapter before initializeGame can publish an initial remote snapshot", async () => { + const state = buildGameState(); + let adapterDuringInitialization: EngineAdapter | null = null; + const adapter = buildEngineAdapterMock(state, { + initializeGame: vi.fn(async () => { + adapterDuringInitialization = useGameStore.getState().adapter; + return { events: [] }; + }), + }); + + await act(() => useGameStore.getState().initGame("test-id", adapter)); + + expect(adapterDuringInitialization).toBe(adapter); + }); + it("dispatch calls adapter.submitAction and updates state", async () => { const state1 = buildGameState({ turn_number: 1 }); const state2 = buildGameState({ turn_number: 2 }); diff --git a/client/src/stores/__tests__/uiStore.test.ts b/client/src/stores/__tests__/uiStore.test.ts index bad202d099..9f60349a02 100644 --- a/client/src/stores/__tests__/uiStore.test.ts +++ b/client/src/stores/__tests__/uiStore.test.ts @@ -1,7 +1,7 @@ import { act } from "react"; import { beforeEach, describe, expect, it } from "vitest"; -import { useUiStore } from "../uiStore"; +import { blockerAssignmentPairs, useUiStore } from "../uiStore"; describe("uiStore", () => { beforeEach(() => { @@ -10,9 +10,12 @@ describe("uiStore", () => { selectedObjectId: null, hoveredObjectId: null, inspectedObjectId: null, + inspectedFaceIndex: 0, + altHeld: false, selectedCardIds: [], fullControl: false, autoPass: false, + blockerAssignments: new Map(), }); }); }); @@ -32,6 +35,32 @@ describe("uiStore", () => { expect(useUiStore.getState().inspectedObjectId).toBe(99); }); + it("inspecting a different object resets a pinned altHeld", () => { + act(() => { + useUiStore.getState().inspectObject(1); + useUiStore.getState().setAltHeld(true); + }); + expect(useUiStore.getState().altHeld).toBe(true); + + // Switching to a new card dismisses the old preview — Alt must not leak. + act(() => useUiStore.getState().inspectObject(2)); + expect(useUiStore.getState().inspectedObjectId).toBe(2); + expect(useUiStore.getState().altHeld).toBe(false); + }); + + it("re-inspecting the same object preserves a pinned altHeld", () => { + act(() => { + useUiStore.getState().inspectObject(5); + useUiStore.getState().setAltHeld(true); + }); + + // Re-hover / face flip on the same card keeps the reader pinned. + act(() => useUiStore.getState().inspectObject(5, 1)); + expect(useUiStore.getState().inspectedObjectId).toBe(5); + expect(useUiStore.getState().inspectedFaceIndex).toBe(1); + expect(useUiStore.getState().altHeld).toBe(true); + }); + it("addSelectedCard appends to selectedCardIds", () => { act(() => { useUiStore.getState().addSelectedCard(5); @@ -97,6 +126,22 @@ describe("uiStore", () => { expect(useUiStore.getState().autoPass).toBe(true); }); + it("retains multiple attackers for one blocker and removes only the chosen pair", () => { + act(() => { + useUiStore.getState().assignBlocker(10, 100); + useUiStore.getState().assignBlocker(10, 101); + useUiStore.getState().assignBlocker(10, 100); + }); + + expect(blockerAssignmentPairs(useUiStore.getState().blockerAssignments)).toEqual([ + [10, 100], + [10, 101], + ]); + + act(() => useUiStore.getState().removeBlockerAssignment(10, 100)); + expect(blockerAssignmentPairs(useUiStore.getState().blockerAssignments)).toEqual([[10, 101]]); + }); + it("toggleDebugClickModeButtonVisible flips the pinned click-mode control", () => { expect(useUiStore.getState().debugClickModeButtonVisible).toBe(false); act(() => useUiStore.getState().toggleDebugClickModeButtonVisible()); diff --git a/client/src/stores/__tests__/uiStoreHoverLatency.test.ts b/client/src/stores/__tests__/uiStoreHoverLatency.test.ts index a4eebc9f7c..996dcc680c 100644 --- a/client/src/stores/__tests__/uiStoreHoverLatency.test.ts +++ b/client/src/stores/__tests__/uiStoreHoverLatency.test.ts @@ -54,7 +54,9 @@ describe("uiStore inspectObject hover latency", () => { }); it("cancels a pending show when the cursor leaves before the delay elapses", () => { - vi.spyOn(document, "elementFromPoint").mockReturnValue(null); + // The clear path asks the browser for its `:hover` element; null = the + // cursor is over nothing inspectable, so the deferred show is cancelled. + vi.spyOn(document, "querySelector").mockReturnValue(null); usePreferencesStore.setState({ cardPreviewHoverDelayMs: 300 }); useUiStore.getState().inspectObject(5); vi.advanceTimersByTime(100); @@ -66,7 +68,10 @@ describe("uiStore inspectObject hover latency", () => { it("keeps a pending show through a transient leave while the pointer remains over a card", () => { const hoveredCard = document.createElement("div"); hoveredCard.dataset.cardHover = "true"; - vi.spyOn(document, "elementFromPoint").mockReturnValue(hoveredCard); + // `:hover` resolves to a card element → the transient leave is ignored. + vi.spyOn(document, "querySelector").mockImplementation((sel) => + sel === "[data-card-hover]:hover" ? hoveredCard : null, + ); usePreferencesStore.setState({ cardPreviewHoverDelayMs: 300 }); useUiStore.getState().inspectObject(5); @@ -82,7 +87,11 @@ describe("uiStore inspectObject hover latency", () => { it("does not clear an open preview on a stale leave while the pointer remains over a card", () => { const hoveredCard = document.createElement("div"); hoveredCard.dataset.cardHover = "true"; - vi.spyOn(document, "elementFromPoint").mockReturnValue(hoveredCard); + // The card is still the browser's `:hover` target, so the stale mouseleave + // must not dismiss the open preview. + vi.spyOn(document, "querySelector").mockImplementation((sel) => + sel === "[data-card-hover]:hover" ? hoveredCard : null, + ); usePreferencesStore.setState({ cardPreviewHoverDelayMs: 300 }); useUiStore.getState().inspectObject(5); diff --git a/client/src/stores/gameStore.ts b/client/src/stores/gameStore.ts index 89d442fdfc..399ca948f6 100644 --- a/client/src/stores/gameStore.ts +++ b/client/src/stores/gameStore.ts @@ -211,6 +211,13 @@ interface GameStoreActions { * "Undo not supported in P2P games" guard. */ resumeP2PHost: (gameId: string, adapter: EngineAdapter) => Promise; + /** + * Resume a native-engine solo (AI) game. Like `resumeP2PHost` the game is + * server-authoritative — the local phase-server holds the state and the + * reconnecting adapter's `initialize()` yields it — so there is no local + * snapshot to `restoreState` and no undo history to rebuild. + */ + resumeNativeSolo: (gameId: string, adapter: EngineAdapter) => Promise; dispatch: (action: GameAction) => Promise; undo: () => Promise; reset: () => void; @@ -281,6 +288,40 @@ export function nextGameSessionGeneration(): number { export type GameStore = GameStoreState & GameStoreActions; +/** + * Seed the store from a server-authoritative adapter whose `initialize()` has + * already produced the current game state (resumed P2P host, or a reconnected + * native solo game). These games have no client-side undo history and no local + * checkpoints — the server owns the state — so `stateHistory`/`turnCheckpoints` + * stay empty. Shared by `resumeP2PHost` and `resumeNativeSolo`. + */ +async function seedResumedServerGame( + get: () => GameStore, + gameId: string, + adapter: EngineAdapter, +): Promise { + // Reset stack-pacing throughput — resuming may load a different game than the + // one just played; stale churn must not carry across. + resetStackThroughput(); + await adapter.initialize(); + // Fetched after `initialize()` restored/attached the engine state, so the + // snapshot is newest-by-construction and always passes the commit gate. + const snapshot = await adapter.getSnapshot(); + get().commitEngineSnapshot(snapshot, { + extraState: { + gameId, + adapter, + gameSessionGeneration: nextGameSessionGeneration(), + events: [], + eventHistory: [], + logHistory: [], + nextLogSeq: 0, + stateHistory: [], + turnCheckpoints: [], + }, + }); +} + const initialState: GameStoreState = { gameId: null, gameMode: null, @@ -370,7 +411,26 @@ export const useGameStore = create()( // pacing (rematch started within the throughput window). resetStackThroughput(); await adapter.initialize(); - const initResult = await adapter.initializeGame(deckData, formatConfig, playerCount, matchConfig, firstPlayer); + // Network-backed adapters can publish the initial authoritative snapshot + // from inside `initializeGame`. Bind the transport before that happens so + // the shared remote-update path never commits a visible game state whose + // action dispatcher has no adapter yet. + set({ adapter }); + let initResult; + try { + initResult = await adapter.initializeGame( + deckData, + formatConfig, + playerCount, + matchConfig, + firstPlayer, + ); + } catch (error) { + // A failed initialization must not leave a transport that never + // produced a playable game attached to the store. + if (get().adapter === adapter) set({ adapter: null }); + throw error; + } // Fetched AFTER the engine is initialized, so this snapshot is // newest-by-construction under the global counter — it always passes the // gate, and it drops any leftover in-flight commit from a prior match. @@ -438,30 +498,19 @@ export const useGameStore = create()( }, resumeP2PHost: async (gameId, adapter) => { - // Reset stack-pacing throughput on entry to this game context. - resetStackThroughput(); - // `adapter.initialize()` on a resumed P2PHostAdapter already - // called `wasm.resumeMultiplayerHostState(savedState)` — the - // engine is populated and in multiplayer mode. All we need here - // is to pull the state out and seed the store. No stateHistory - // (multiplayer = no undo); no checkpoints (P2P never saved them). - await adapter.initialize(); - // Fetched after that `initialize()` (which is what restored the engine, per - // the note above), so the snapshot is newest-by-construction. - const snapshot = await adapter.getSnapshot(); - get().commitEngineSnapshot(snapshot, { - extraState: { - gameId, - adapter, - gameSessionGeneration: nextGameSessionGeneration(), - events: [], - eventHistory: [], - logHistory: [], - nextLogSeq: 0, - stateHistory: [], - turnCheckpoints: [], - }, - }); + // `adapter.initialize()` on a resumed P2PHostAdapter already called + // `wasm.resumeMultiplayerHostState(savedState)` — the engine is populated + // and in multiplayer mode — so the shared helper just pulls the state out + // and seeds the store. No stateHistory (multiplayer = no undo); no + // checkpoints (P2P never saved them). + await seedResumedServerGame(get, gameId, adapter); + }, + + resumeNativeSolo: async (gameId, adapter) => { + // The reconnecting native adapter's `initialize()` sends a reconnect frame + // and resolves once the phase-server replays the current GameStarted + // state; the shared helper then seeds the store from that authority. + await seedResumedServerGame(get, gameId, adapter); }, dispatch: async (action) => { diff --git a/client/src/stores/uiStore.ts b/client/src/stores/uiStore.ts index 4a970570e9..87b25fdfb0 100644 --- a/client/src/stores/uiStore.ts +++ b/client/src/stores/uiStore.ts @@ -3,11 +3,27 @@ import type { GameAction, ObjectId, PlayerId, + TurnOrderSlotView, } from "../adapter/types"; import { DICE_ROLL_DURATION_MS, TURN_BANNER_DURATION_MS } from "../animation/types"; import { usePreferencesStore } from "./preferencesStore"; import type { FilterKey } from "../components/modal/cardChoice/gridSelection"; +/** + * Ephemeral, player-selected blocker pairs. A creature can block more than one + * attacker, so the value is a set rather than a single attacker id. + */ +export type BlockerAssignments = Map>; + +/** Flatten the UI's per-blocker representation at the engine action boundary. */ +export function blockerAssignmentPairs( + assignments: ReadonlyMap>, +): [ObjectId, ObjectId][] { + return Array.from(assignments, ([blockerId, attackerIds]) => + Array.from(attackerIds, (attackerId): [ObjectId, ObjectId] => [blockerId, attackerId]), + ).flat(); +} + /** * A dice-roll / coin-flip moment to animate, surfaced from engine-authored * `DieRolled` / `CoinFlipped` events. `context` is supplied by the delivering @@ -25,6 +41,12 @@ export type DiceRollPayload = context: "startingPlayer" | "ability"; /** Starting-player contest: the high roller who takes the first turn. */ winner?: PlayerId; + /** Engine-authored opening turn sequence for multiplayer games. The + * overlay renders this directly so every seat can see its first-turn + * position without reconstructing turn order from raw state. */ + turnOrder?: TurnOrderSlotView[]; + /** Engine-authored one-based turn position for the current viewer. */ + viewerTurnNumber?: number; /** Starting-player contest only (CR 103.1): the roll-off by round. Round 0 * is every seat; each later round is the previous round's tied-max group * that rerolled. Rendered round-by-round so the winner is always the high @@ -89,10 +111,6 @@ function flushPendingShow(): void { pendingShow = null; apply(); } -let lastPointer = { x: 0, y: 0 }; -if (typeof window !== "undefined") { - window.addEventListener("pointermove", (e) => { lastPointer = { x: e.clientX, y: e.clientY }; }, { passive: true }); -} // Serial FIFO for dice/coin overlays. Full-screen "moment" overlays are mutually // exclusive (you can't show two rolls at once), so simultaneous/back-to-back @@ -157,7 +175,7 @@ interface UiStoreState { /** CR 702.22c: attacking bands declared this combat (each inner array is one * band of attacker ids). Empty when no bands are declared. */ attackerBands: ObjectId[][]; - blockerAssignments: Map; + blockerAssignments: BlockerAssignments; combatClickHandler: ((id: ObjectId) => void) | null; previewSticky: boolean; isDragging: boolean; @@ -259,7 +277,7 @@ interface UiStoreActions { selectAllAttackers: (ids: ObjectId[]) => void; setAttackerBands: (bands: ObjectId[][]) => void; assignBlocker: (blockerId: ObjectId, attackerId: ObjectId) => void; - removeBlockerAssignment: (blockerId: ObjectId) => void; + removeBlockerAssignment: (blockerId: ObjectId, attackerId?: ObjectId) => void; clearCombatSelection: () => void; setCombatClickHandler: (handler: ((id: ObjectId) => void) | null) => void; setPreviewSticky: (sticky: boolean) => void; @@ -368,7 +386,16 @@ export const useUiStore = create()((set, get) => ({ } cancelPendingShow(); const applyInspect = () => - set({ inspectedObjectId: id, inspectedFaceIndex: faceIndex ?? 0 }); + set((s) => ({ + inspectedObjectId: id, + inspectedFaceIndex: faceIndex ?? 0, + // Inspecting a DIFFERENT object replaces (dismisses) the previous + // preview, so a pinned Alt state must not leak onto the new card — + // Alt has to be pressed again to expand it. Re-inspecting the SAME + // object (e.g. a face flip or a re-hover) preserves the pin so the + // reader isn't kicked out mid-scroll. + altHeld: s.inspectedObjectId === id ? s.altHeld : false, + })); // Configurable hover latency (cardPreviewHoverDelayMs). The delay gates only // the FIRST appearance on a hover-capable device: while a preview is already // open, sweeping to an adjacent card switches instantly, and the "shift" @@ -422,11 +449,18 @@ export const useUiStore = create()((set, get) => ({ // traverse the gap from the card to the panel and click "Report a Problem" // or scroll rulings. Toggling Alt off (or a click outside) still dismisses. if (get().altHeld) return; - const el = document.elementFromPoint(lastPointer.x, lastPointer.y); // Keep the preview (and finish a delay that already elapsed) when the - // pointer is still over an inspectable card or the preview panel. This - // makes stale leaves from Framer Motion layout updates harmless. - if (el?.closest("[data-card-hover], [data-card-preview]")) { + // pointer is still over an inspectable card. Ask the browser for its own + // `:hover` element rather than sampling elementFromPoint() against a + // JS-tracked pointer: that coordinate is only as fresh as the last + // `pointermove`, and over sparse/coalesced streams (remote-desktop / RDP + // webviews) it lands a few px off the card while the OS cursor is still + // on it — so a spurious Framer-Motion mouseleave's 50ms clear would + // false-fire and cancel a live preview (visible only with a non-zero + // hover delay, where the re-show is deferred rather than instant). + // `:hover` is the engine's continuous hit-test, correct with no + // pointermove event at all. + if (document.querySelector("[data-card-hover]:hover") != null) { flushPendingShow(); return; } @@ -529,14 +563,26 @@ export const useUiStore = create()((set, get) => ({ assignBlocker: (blockerId, attackerId) => set((state) => { const next = new Map(state.blockerAssignments); - next.set(blockerId, attackerId); + const attackerIds = new Set(next.get(blockerId)); + attackerIds.add(attackerId); + next.set(blockerId, attackerIds); return { blockerAssignments: next }; }), - removeBlockerAssignment: (blockerId) => + removeBlockerAssignment: (blockerId, attackerId) => set((state) => { const next = new Map(state.blockerAssignments); - next.delete(blockerId); + if (attackerId === undefined) { + next.delete(blockerId); + } else { + const attackerIds = new Set(next.get(blockerId)); + attackerIds.delete(attackerId); + if (attackerIds.size === 0) { + next.delete(blockerId); + } else { + next.set(blockerId, attackerIds); + } + } return { blockerAssignments: next }; }), diff --git a/client/src/test/factories/__tests__/factories.test.ts b/client/src/test/factories/__tests__/factories.test.ts index ae9087cf33..fff43c5105 100644 --- a/client/src/test/factories/__tests__/factories.test.ts +++ b/client/src/test/factories/__tests__/factories.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { gameObjectFactory } from "../gameObjectFactory.ts"; -import { gameStateFactory, waitingForFactory } from "../gameStateFactory.ts"; +import { + castOfferWaitingForFactory, + gameStateFactory, + targetSelectionWaitingForFactory, + waitingForFactory, +} from "../gameStateFactory.ts"; describe("gameObjectFactory convenience methods", () => { it("composes card type, supertype, zone, and state methods", () => { @@ -71,6 +76,36 @@ describe("waitingForFactory", () => { data: expect.objectContaining({ player: 1 }), }); }); + + it("merges data overrides without discarding variant defaults", () => { + const waitingFor = targetSelectionWaitingForFactory.forPlayer(1).build(); + + expect(waitingFor).toMatchObject({ + type: "TargetSelection", + data: { + player: 1, + pending_cast: { object_id: 1, card_id: 1 }, + target_slots: [{ legal_targets: [], optional: false }], + }, + }); + }); + + it("exposes CastOffer's domain fields through chainable factory methods", () => { + const waitingFor = castOfferWaitingForFactory.forPlayer(1).adventure(157).build(); + + expect(waitingFor).toEqual({ + type: "CastOffer", + data: { + player: 1, + kind: { + type: "Adventure", + object_id: 157, + card_id: 157, + payment_mode: { type: "Auto" }, + }, + }, + }); + }); }); describe("gameStateFactory convenience methods", () => { diff --git a/client/src/test/factories/gameStateFactory.ts b/client/src/test/factories/gameStateFactory.ts index 504ce2f6d2..74a4dbf3a9 100644 --- a/client/src/test/factories/gameStateFactory.ts +++ b/client/src/test/factories/gameStateFactory.ts @@ -30,8 +30,37 @@ type TriggerTargetSelectionWaitingFor = Extract< >; type ChooseXValueWaitingFor = Extract; type AssistPaymentWaitingFor = Extract; +type CastOfferWaitingFor = Extract; type LoopShortcutWaitingFor = Extract; type RespondToShortcutWaitingFor = Extract; +type WaitingForWithData = Extract; + +/** + * Shared base for a concrete `WaitingFor` variant factory. + * + * `withData` merges top-level payload fields rather than replacing the entire + * `data` object, so callers retain the variant's valid defaults while changing + * only the fields relevant to a test. + */ +export abstract class WaitingForFactory extends Factory { + withData(data: Partial) { + return this.afterBuild((waitingFor) => { + Object.assign(waitingFor.data, data); + return waitingFor; + }); + } +} + +abstract class PlayerWaitingForFactory< + T extends WaitingForWithData & { data: { player: PlayerId } }, +> extends WaitingForFactory { + forPlayer(player: PlayerId) { + return this.afterBuild((waitingFor) => { + waitingFor.data.player = player; + return waitingFor; + }); + } +} /** * Convenience-method factory for `Player`: @@ -123,7 +152,9 @@ export const buildCommanderFormatConfig = ( }); }; -export const priorityWaitingForFactory = Factory.define(() => ({ +export class PriorityWaitingForFactory extends PlayerWaitingForFactory {} + +export const priorityWaitingForFactory = PriorityWaitingForFactory.define((): PriorityWaitingFor => ({ type: "Priority", data: { player: 0 }, })); @@ -131,10 +162,12 @@ export const priorityWaitingForFactory = Factory.define(() = export const buildPriorityWaitingFor = ( overrides: Partial = {}, ): PriorityWaitingFor => { - return { ...priorityWaitingForFactory.build(), ...overrides }; + return priorityWaitingForFactory.withData(overrides.data ?? {}).build(); }; -export const manaPaymentWaitingForFactory = Factory.define(() => ({ +export class ManaPaymentWaitingForFactory extends PlayerWaitingForFactory {} + +export const manaPaymentWaitingForFactory = ManaPaymentWaitingForFactory.define((): ManaPaymentWaitingFor => ({ type: "ManaPayment", data: { player: 0 }, })); @@ -142,7 +175,7 @@ export const manaPaymentWaitingForFactory = Factory.define = {}, ): ManaPaymentWaitingFor => { - return { ...manaPaymentWaitingForFactory.build(), ...overrides }; + return manaPaymentWaitingForFactory.withData(overrides.data ?? {}).build(); }; export const pendingCastFactory = Factory.define(() => ({ @@ -181,8 +214,10 @@ export const buildTargetSelectionProgress = ( return { ...targetSelectionProgressFactory.build(), ...overrides }; }; +export class TargetSelectionWaitingForFactory extends PlayerWaitingForFactory {} + export const targetSelectionWaitingForFactory = - Factory.define(() => ({ + TargetSelectionWaitingForFactory.define((): TargetSelectionWaitingFor => ({ type: "TargetSelection", data: { player: 0, @@ -195,11 +230,13 @@ export const targetSelectionWaitingForFactory = export const buildTargetSelectionWaitingFor = ( overrides: Partial = {}, ): TargetSelectionWaitingFor => { - return { ...targetSelectionWaitingForFactory.build(), ...overrides }; + return targetSelectionWaitingForFactory.withData(overrides.data ?? {}).build(); }; +export class TriggerTargetSelectionWaitingForFactory extends PlayerWaitingForFactory {} + export const triggerTargetSelectionWaitingForFactory = - Factory.define(() => ({ + TriggerTargetSelectionWaitingForFactory.define((): TriggerTargetSelectionWaitingFor => ({ type: "TriggerTargetSelection", data: { player: 0, @@ -211,11 +248,13 @@ export const triggerTargetSelectionWaitingForFactory = export const buildTriggerTargetSelectionWaitingFor = ( overrides: Partial = {}, ): TriggerTargetSelectionWaitingFor => { - return { ...triggerTargetSelectionWaitingForFactory.build(), ...overrides }; + return triggerTargetSelectionWaitingForFactory.withData(overrides.data ?? {}).build(); }; +export class ChooseXValueWaitingForFactory extends PlayerWaitingForFactory {} + export const chooseXValueWaitingForFactory = - Factory.define(() => ({ + ChooseXValueWaitingForFactory.define((): ChooseXValueWaitingFor => ({ type: "ChooseXValue", data: { player: 0, @@ -227,11 +266,21 @@ export const chooseXValueWaitingForFactory = export const buildChooseXValueWaitingFor = ( overrides: Partial = {}, ): ChooseXValueWaitingFor => { - return { ...chooseXValueWaitingForFactory.build(), ...overrides }; + return chooseXValueWaitingForFactory.withData(overrides.data ?? {}).build(); }; +export class AssistPaymentWaitingForFactory extends WaitingForFactory { + withCaster(caster: PlayerId) { + return this.withData({ caster }); + } + + withChosen(chosen: PlayerId) { + return this.withData({ chosen }); + } +} + export const assistPaymentWaitingForFactory = - Factory.define(() => ({ + AssistPaymentWaitingForFactory.define((): AssistPaymentWaitingFor => ({ type: "AssistPayment", data: { caster: 1, @@ -243,7 +292,48 @@ export const assistPaymentWaitingForFactory = export const buildAssistPaymentWaitingFor = ( overrides: Partial = {}, ): AssistPaymentWaitingFor => { - return { ...assistPaymentWaitingForFactory.build(), ...overrides }; + return assistPaymentWaitingForFactory.withData(overrides.data ?? {}).build(); +}; + +export class CastOfferWaitingForFactory extends PlayerWaitingForFactory { + withKind(kind: CastOfferWaitingFor["data"]["kind"]) { + return this.withData({ kind }); + } + + adventure(objectId: ObjectId, cardId: ObjectId = objectId) { + return this.withKind({ + type: "Adventure", + object_id: objectId, + card_id: cardId, + payment_mode: { type: "Auto" }, + }); + } +} + +export const castOfferWaitingForFactory = CastOfferWaitingForFactory.define((): CastOfferWaitingFor => ({ + type: "CastOffer", + data: { + player: 0, + kind: { + type: "Adventure", + object_id: 1, + card_id: 1, + payment_mode: { type: "Auto" }, + }, + }, +})); + +export const buildCastOfferWaitingFor = ({ + player, + kind, +}: { + player?: PlayerId; + kind?: CastOfferWaitingFor["data"]["kind"]; +} = {}): CastOfferWaitingFor => { + let factory = castOfferWaitingForFactory; + if (player !== undefined) factory = factory.forPlayer(player); + if (kind !== undefined) factory = factory.withKind(kind); + return factory.build(); }; interface WaitingForTransient { @@ -260,35 +350,44 @@ interface WaitingForTransient { * variants never deep-merges one variant's `data` keys into another's. * Use one variant method per chain. */ -export class WaitingForFactory extends Factory { +export class WaitingForVariantFactory extends Factory { priority(player: PlayerId = 0) { - return this.variant(buildPriorityWaitingFor({ data: { player } })); + return this.variant(priorityWaitingForFactory.forPlayer(player).build()); } manaPayment(player: PlayerId = 0) { - return this.variant(buildManaPaymentWaitingFor({ data: { player } })); + return this.variant(manaPaymentWaitingForFactory.forPlayer(player).build()); } targetSelection(data: Partial = {}) { - const base = buildTargetSelectionWaitingFor(); - return this.variant({ ...base, data: { ...base.data, ...data } }); + return this.variant(targetSelectionWaitingForFactory.withData(data).build()); } triggerTargetSelection( data: Partial = {}, ) { - const base = buildTriggerTargetSelectionWaitingFor(); - return this.variant({ ...base, data: { ...base.data, ...data } }); + return this.variant(triggerTargetSelectionWaitingForFactory.withData(data).build()); } chooseXValue(data: Partial = {}) { - const base = buildChooseXValueWaitingFor(); - return this.variant({ ...base, data: { ...base.data, ...data } }); + return this.variant(chooseXValueWaitingForFactory.withData(data).build()); } assistPayment(data: Partial = {}) { - const base = buildAssistPaymentWaitingFor(); - return this.variant({ ...base, data: { ...base.data, ...data } }); + return this.variant(assistPaymentWaitingForFactory.withData(data).build()); + } + + castOffer({ + player, + kind, + }: { + player?: PlayerId; + kind?: CastOfferWaitingFor["data"]["kind"]; + } = {}) { + let factory = castOfferWaitingForFactory; + if (player !== undefined) factory = factory.forPlayer(player); + if (kind !== undefined) factory = factory.withKind(kind); + return this.variant(factory.build()); } private variant(variant: WaitingFor) { @@ -296,11 +395,43 @@ export class WaitingForFactory extends Factory } } -export const waitingForFactory = WaitingForFactory.define( +export const waitingForFactory = WaitingForVariantFactory.define( ({ transientParams }) => transientParams.variant ?? buildPriorityWaitingFor(), ); -export const loopShortcutWaitingForFactory = Factory.define(() => ({ +export class LoopShortcutWaitingForFactory extends WaitingForFactory { + withProposer(proposer: PlayerId) { + return this.withData({ proposer }); + } + + withPredictedWinner(predictedWinner: PlayerId | null) { + return this.withData({ predicted_winner: predictedWinner }); + } + + withCertificate(certificate: Partial) { + return this.afterBuild((waitingFor) => { + waitingFor.data.certificate = { ...waitingFor.data.certificate, ...certificate }; + return waitingFor; + }); + } + + withSchema(schema: Partial) { + return this.afterBuild((waitingFor) => { + const mergedSchema = { ...waitingFor.data.schema, ...schema }; + mergedSchema.convoke_tappable_count = mergedSchema.points.reduce( + (total, point) => + typeof point.kind === "object" && "ConvokeTaps" in point.kind + ? total + point.kind.ConvokeTaps.tappable.length + : total, + 0, + ); + waitingFor.data.schema = mergedSchema; + return waitingFor; + }); + } +} + +export const loopShortcutWaitingForFactory = LoopShortcutWaitingForFactory.define((): LoopShortcutWaitingFor => ({ type: "LoopShortcut", data: { proposer: 0, @@ -330,32 +461,25 @@ export const buildLoopShortcutWaitingFor = ({ certificate?: Partial; schema?: Partial; } = {}): LoopShortcutWaitingFor => { - const waitingFor = loopShortcutWaitingForFactory.build(); - const mergedSchema = { ...waitingFor.data.schema, ...schema }; - // The engine owns convoke_tappable_count (build_shortcut_schema sums the ConvokeTaps - // tappable lengths); mirror that here so overriding `points` keeps the count consistent - // and the modal — which reads the field directly — renders the matching value. - mergedSchema.convoke_tappable_count = mergedSchema.points.reduce( - (total, point) => - typeof point.kind === "object" && "ConvokeTaps" in point.kind - ? total + point.kind.ConvokeTaps.tappable.length - : total, - 0, - ); - return { - ...waitingFor, - data: { - ...waitingFor.data, - ...(proposer === undefined ? {} : { proposer }), - ...(predictedWinner === undefined ? {} : { predicted_winner: predictedWinner }), - certificate: { ...waitingFor.data.certificate, ...certificate }, - schema: mergedSchema, - }, - }; + let factory = loopShortcutWaitingForFactory; + if (proposer !== undefined) factory = factory.withProposer(proposer); + if (predictedWinner !== undefined) factory = factory.withPredictedWinner(predictedWinner); + if (certificate !== undefined) factory = factory.withCertificate(certificate); + if (schema !== undefined) factory = factory.withSchema(schema); + return factory.build(); }; +export class RespondToShortcutWaitingForFactory extends PlayerWaitingForFactory { + withProposal(proposal: Partial) { + return this.afterBuild((waitingFor) => { + waitingFor.data.proposal = { ...waitingFor.data.proposal, ...proposal }; + return waitingFor; + }); + } +} + export const respondToShortcutWaitingForFactory = - Factory.define(() => ({ + RespondToShortcutWaitingForFactory.define((): RespondToShortcutWaitingFor => ({ type: "RespondToShortcut", data: { player: 0, @@ -376,15 +500,10 @@ export const buildRespondToShortcutWaitingFor = ({ player?: PlayerId; proposal?: Partial; } = {}): RespondToShortcutWaitingFor => { - const waitingFor = respondToShortcutWaitingForFactory.build(); - return { - ...waitingFor, - data: { - ...waitingFor.data, - ...(player === undefined ? {} : { player }), - proposal: { ...waitingFor.data.proposal, ...proposal }, - }, - }; + let factory = respondToShortcutWaitingForFactory; + if (player !== undefined) factory = factory.forPlayer(player); + if (proposal !== undefined) factory = factory.withProposal(proposal); + return factory.build(); }; export const stackEntryFactory = Factory.define(({ sequence }) => ({ @@ -496,6 +615,16 @@ export class GameStateFactory extends Factory { return this.waitingFor(waitingForFactory.assistPayment(data).build()); } + castOffer({ + player, + kind, + }: { + player?: PlayerId; + kind?: CastOfferWaitingFor["data"]["kind"]; + } = {}) { + return this.waitingFor(waitingForFactory.castOffer({ player, kind }).build()); + } + /** * Replace `waiting_for` exactly. Uses `afterBuild` instead of `params` so no * keys from the default Priority variant survive a deep-merge into the new diff --git a/client/src/utils/description.ts b/client/src/utils/description.ts index 22064c743a..4e0899358c 100644 --- a/client/src/utils/description.ts +++ b/client/src/utils/description.ts @@ -1,16 +1,17 @@ /** * Render engine-provided ability descriptions for display. * - * The engine uses `~` as the canonical self-reference token (CR 201.4b). + * The engine uses `~` as the canonical self-reference token (CR 201.5; the + * gained-ability sub-case is CR 201.5b, the granted-source exception CR 201.5a). * Trigger, replacement, and static descriptions reach the client with `~` * in place of the source card's name — e.g. "When ~ enters, draw a card." * This helper substitutes `~` back to the source's display name for * player-facing UI. * - * Matches only the standalone token (word boundary or punctuation) so it - * will not accidentally rewrite `~` that appears inside a longer - * identifier — not that Oracle text uses tildes elsewhere, but the guard - * keeps the substitution robust if that ever changes. + * Replaces EVERY `~` occurrence, unguarded — the engine emits the token only + * as a self-reference, and one description can carry several ("{T}, Sacrifice + * ~: ~ deals 1 damage"). Callers relying on that: costLabel.ts, CardPreview, + * PermanentCard, StackEntry, TargetingOverlay. */ export function renderDescription(description: string, sourceName: string): string { return description.replace(/~/g, sourceName); diff --git a/client/src/viewmodel/__tests__/blockerSorting.test.ts b/client/src/viewmodel/__tests__/blockerSorting.test.ts index 56c42b9ce8..21ce62ba17 100644 --- a/client/src/viewmodel/__tests__/blockerSorting.test.ts +++ b/client/src/viewmodel/__tests__/blockerSorting.test.ts @@ -10,9 +10,9 @@ describe("sortCreaturesForBlockers", () => { it("orders blockers by their assigned attacker's opponent column", () => { const players = [g(1), g(2)]; const opponents = [g(102), g(101)]; // atk 102 -> col 0, atk 101 -> col 1 - const assignments = new Map([ - [1, 101], // col 1 - [2, 102], // col 0 + const assignments = new Map>([ + [1, new Set([101])], // col 1 + [2, new Set([102])], // col 0 ]); const sorted = sortCreaturesForBlockers(players, opponents, assignments); expect(sorted.map((p) => p.ids[0])).toEqual([2, 1]); @@ -25,10 +25,10 @@ describe("sortCreaturesForBlockers", () => { // defined. The result must preserve the input order deterministically. const players = [g(1), g(2), g(3)]; const opponents: GroupedPermanent[] = []; // no attacker columns at all - const assignments = new Map([ - [1, 901], - [2, 902], - [3, 903], + const assignments = new Map>([ + [1, new Set([901])], + [2, new Set([902])], + [3, new Set([903])], ]); const sorted = sortCreaturesForBlockers(players, opponents, assignments); expect(sorted.map((p) => p.ids[0])).toEqual([1, 2, 3]); @@ -37,11 +37,24 @@ describe("sortCreaturesForBlockers", () => { it("sorts off-row (no-column) blockers after those with a real column", () => { const players = [g(10), g(11)]; const opponents = [g(500)]; // col 0 - const assignments = new Map([ - [10, 999], // Infinity (not on the opponent row) - [11, 500], // col 0 + const assignments = new Map>([ + [10, new Set([999])], // Infinity (not on the opponent row) + [11, new Set([500])], // col 0 ]); const sorted = sortCreaturesForBlockers(players, opponents, assignments); expect(sorted.map((p) => p.ids[0])).toEqual([11, 10]); }); + + it("uses the earliest attacker column when one blocker blocks multiple attackers", () => { + const players = [g(10), g(11)]; + const opponents = [g(500), g(501)]; + const assignments = new Map>([ + [10, new Set([501, 500])], + [11, new Set([501])], + ]); + + const sorted = sortCreaturesForBlockers(players, opponents, assignments); + + expect(sorted.map((p) => p.ids[0])).toEqual([10, 11]); + }); }); diff --git a/client/src/viewmodel/__tests__/cardActionChoice.test.ts b/client/src/viewmodel/__tests__/cardActionChoice.test.ts index 8aa7ea250b..869935a1a9 100644 --- a/client/src/viewmodel/__tests__/cardActionChoice.test.ts +++ b/client/src/viewmodel/__tests__/cardActionChoice.test.ts @@ -58,7 +58,7 @@ function makeGameObject(overrides: Partial = {}): GameObject { }; } -function tapLandAction(objectId: number): GameAction { +function tapLandAction(objectId: number): Extract { return { type: "TapLandForMana", data: { @@ -345,7 +345,7 @@ describe("abilityChoiceLabel", () => { ).toBe("Tap for {1}"); }); - it("labels TapLandForMana as Tap for Mana", () => { + it("labels TapLandForMana with the engine-selected mana", () => { const object = makeGameObject({ name: "Emergence Zone", card_types: { @@ -360,7 +360,14 @@ describe("abilityChoiceLabel", () => { tapLandAction(1), object, ).label, - ).toBe("Tap for Mana"); + ).toBe("Tap for {G}"); + }); + + it("labels an atomic mana combination with every mana it produces", () => { + const action = tapLandAction(1); + action.data.selection.atomic_combination = ["White", "Blue"]; + + expect(abilityChoiceLabel(action, makeGameObject()).label).toBe("Tap for {W}{U}"); }); it("labels the spell face cast action with the front-face name", () => { diff --git a/client/src/viewmodel/__tests__/costLabel.test.ts b/client/src/viewmodel/__tests__/costLabel.test.ts index 90a7d44722..3842b782dd 100644 --- a/client/src/viewmodel/__tests__/costLabel.test.ts +++ b/client/src/viewmodel/__tests__/costLabel.test.ts @@ -9,6 +9,7 @@ import { formatAbilityCost, formatCost, spellCostDisplay, + stripLoyaltyCostPrefix, } from "../costLabel.ts"; function makeObject(overrides: Partial = {}): GameObject { @@ -203,6 +204,182 @@ describe("abilityChoiceLabel per-variant formatting", () => { expect(result.label).toBe("{T}"); expect(result.description).toBe("Draw a card."); }); + + it("attaches the engine's activation cost as a mana option's description (CR 602.1a)", () => { + const object = makeObject({ + name: "Relic of Legends", + abilities: [ + { + cost: { type: "Tap" }, + description: "{T}: Add one mana of any color.", + is_mana_ability: true, + effect: { + type: "Mana", + produced: { + type: "AnyOneColor", + count: { type: "Fixed", value: 1 }, + color_options: ["White", "Blue", "Black", "Red", "Green"], + }, + }, + } satisfies GameObject["abilities"][number], + ], + }); + const action: GameAction = { type: "ActivateAbility", data: { source_id: 1, ability_index: 0 } }; + const result = abilityChoiceLabel(action, object); + + expect(result.label).toBe("Add one mana of any color"); + // CR 602.1a: everything before the colon, taken from the engine's own description. + expect(result.description).toBe("{T}"); + }); + + it("does not attach a cost line to a loyalty ability that adds mana (CR 605.1a — Chandra, Torch of Defiance)", () => { + const object = makeObject({ + name: "Chandra, Torch of Defiance", + abilities: [ + { + cost: { type: "Loyalty", amount: 1 }, + description: "+1: Add {R}{R}.", + // Pinned so a fixture that silently MISSES the mana branch cannot pass: the + // branch is what produces `label === "Add {R}{R}"`. A fixture falling to the + // tail would get stripCostPrefix's bare-loyalty arm => "Add {R}{R}." (trailing + // period) and the label assertion below would fail. That second assertion — + // not any other test — is this test's reach-guard. + effect: { type: "Mana", produced: { type: "Fixed", colors: ["Red", "Red"] } }, + } satisfies GameObject["abilities"][number], + ], + }); + const action: GameAction = { type: "ActivateAbility", data: { source_id: 1, ability_index: 0 } }; + const result = abilityChoiceLabel(action, object); + + expect(result.label).toBe("Add {R}{R}"); // reach-guard: proves the mana branch ran + expect(result.description).toBeUndefined(); // CR 605.1a gate: no cost line + + // Mirrors pages/GamePage.tsx (`label: description ?? stripLoyaltyCostPrefix(label)`, + // currently :2918, inside the badge branch opened at :2912). Replicated here because + // mounting GamePage is prohibitively expensive; if GamePage's expression changes, + // update this replica with it. + expect(result.description ?? stripLoyaltyCostPrefix(result.label)).toBe("Add {R}{R}"); + }); + + it("treats an absent is_mana_ability flag as not a mana ability", () => { + const object = makeObject({ + name: "Unflagged Mana Source", + abilities: [ + { + // CR 605.1a: the engine's verdict is the flag. Absent must read as "not a mana + // ability" (viewmodel/cardActionChoice.ts:29-32), never as "probably yes". + cost: { type: "Tap" }, + description: "{T}: Add one mana of any color.", + effect: { + type: "Mana", + produced: { + type: "AnyOneColor", + count: { type: "Fixed", value: 1 }, + color_options: ["White", "Blue", "Black", "Red", "Green"], + }, + }, + } satisfies GameObject["abilities"][number], + ], + }); + const action: GameAction = { type: "ActivateAbility", data: { source_id: 1, ability_index: 0 } }; + const result = abilityChoiceLabel(action, object); + + expect(result.label).toBe("Add one mana of any color"); // reach-guard: the mana branch ran + expect(result.description).toBeUndefined(); + }); + + it('attaches no cost line to a mana ability the engine gave no description (no synthesized "Activate")', () => { + const object = makeObject({ + name: "Descriptionless Mana Source", + abilities: [ + { + // `TapCreatures` has no `formatCost` arm, so without the `&& ability.description` + // conjunct `abilityLabel` would fall through to `formatCost`'s + // `default: "Activate"` (costLabel.ts:289-290) and that literal word would render + // as this option's subtitle. Measured: dropping the conjunct yields "Activate". + cost: { + type: "TapCreatures", + requirement: { type: "Count", count: 1 }, + filter: { type: "Any" }, + }, + is_mana_ability: true, + effect: { + type: "Mana", + produced: { + type: "AnyOneColor", + count: { type: "Fixed", value: 1 }, + color_options: ["White", "Blue", "Black", "Red", "Green"], + }, + }, + } satisfies GameObject["abilities"][number], + ], + }); + const action: GameAction = { type: "ActivateAbility", data: { source_id: 1, ability_index: 0 } }; + const result = abilityChoiceLabel(action, object); + + expect(result.label).toBe("Add one mana of any color"); // reach-guard: the mana branch ran + expect(result.description).toBeUndefined(); + expect(result.description).not.toBe("Activate"); + }); + + // CR 201.5: `~` binds to the object that has the ability. The NON-mana tail of + // `abilityChoiceLabel` feeds the same ability-choice modal (GamePage.tsx:2899 → + // ChoiceModal) as the mana branch above, so both must substitute or the modal shows one + // substituted row next to a raw-tilde one. Both fixtures are verbatim engine descriptions + // from the reported Kilo board dump + // (`.kilo-dump/game-state-turn-1-2026-07-22T20-04-12-617Z.json`, objects 110 and 7). + // Census population for every count in this test: that dump's 293 `kind: "Activated"` + // abilities — 22 leak `~` into the cost text, 20 into the effect text. Widening to all 368 + // abilities (i.e. adding the 75 `kind: "Spell"` rows) gives 29/27 instead, so the two + // number sets must not be mixed. + it("substitutes ~ on the non-mana activated-ability path, in both label and description (CR 201.5)", () => { + const action: GameAction = { type: "ActivateAbility", data: { source_id: 1, ability_index: 0 } }; + + // Leak site 1 — the COST text, which becomes the option's `label`. + const ghostQuarter = abilityChoiceLabel( + action, + makeObject({ + name: "Ghost Quarter", + abilities: [ + { + cost: { + type: "Composite", + costs: [{ type: "Tap" }, { type: "Sacrifice", count: 1 }], + }, + description: + "{T}, Sacrifice ~: Destroy target land. Its controller may search their library for a basic land card, put it onto the battlefield, then shuffle.", + // Not `Mana`, so this fixture provably reaches the tail and not the branch at :415. + effect: { type: "Destroy" }, + } satisfies GameObject["abilities"][number], + ], + }), + ); + expect(ghostQuarter.label).toBe("{T}, Sacrifice Ghost Quarter"); + expect(ghostQuarter.description).toBe( + "Destroy target land. Its controller may search their library for a basic land card, put it onto the battlefield, then shuffle.", + ); + + // Leak site 2 — the EFFECT text, which becomes the option's `description` after + // `stripCostPrefix`. Disjoint from site 1 within that same `kind: "Activated"` + // population: none of the 293 leaks into both. (All 7 overlaps in the wider 29/27 figures + // are `kind: "Spell"` rows whose descriptions carry no colon, so `abilityLabel` and + // `stripCostPrefix` both fall through to the whole string.) + const hawkeye = abilityChoiceLabel( + action, + makeObject({ + name: "Hawkeye, Avenging Archer", + abilities: [ + { + cost: { type: "Tap" }, + description: "{T}: ~ deals 1 damage to any target.", + effect: { type: "DealDamage" }, + } satisfies GameObject["abilities"][number], + ], + }), + ); + expect(hawkeye.label).toBe("{T}"); + expect(hawkeye.description).toBe("Hawkeye, Avenging Archer deals 1 damage to any target."); + }); }); describe("additionalCostChoices — multikicker (issue #454)", () => { diff --git a/client/src/viewmodel/__tests__/manaAbilityOptionDiscrimination.test.ts b/client/src/viewmodel/__tests__/manaAbilityOptionDiscrimination.test.ts new file mode 100644 index 0000000000..466e1f884f --- /dev/null +++ b/client/src/viewmodel/__tests__/manaAbilityOptionDiscrimination.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import type { GameAction, GameObject } from "../../adapter/types.ts"; +import { buildGameObject } from "../../test/factories/gameObjectFactory.ts"; +import { abilityChoiceLabel } from "../costLabel.ts"; + +/** + * Relic of Legends' two activated mana abilities, copied VERBATIM from the real + * 4-player playtest dump where the bug was observed + * (`.kilo-dump/game-state-turn-1-2026-07-22T20-04-12-617Z.json`, + * `gameState.objects["402"].abilities`) — the engine's own serialized surface, + * not a hand-authored approximation. + * + * Both abilities produce the identical `AnyOneColor × 1`; the ONLY discriminator + * the engine ships is the cost (`Tap` vs `TapCreatures{legendary creature you + * control}`) and the per-ability `description`. + */ +const RELIC_OF_LEGENDS_ABILITIES = [ + { + kind: "Activated", + effect: { + type: "Mana", + produced: { + type: "AnyOneColor", + count: { type: "Fixed", value: 1 }, + color_options: ["White", "Blue", "Black", "Red", "Green"], + }, + }, + cost: { type: "Tap" }, + sub_ability: null, + duration: null, + description: "{T}: Add one mana of any color.", + target_prompt: null, + condition: null, + optional_targeting: false, + optional: false, + forward_result: false, + is_mana_ability: true, + }, + { + kind: "Activated", + effect: { + type: "Mana", + produced: { + type: "AnyOneColor", + count: { type: "Fixed", value: 1 }, + color_options: ["White", "Blue", "Black", "Red", "Green"], + }, + }, + cost: { + type: "TapCreatures", + requirement: { requirement: "count", count: 1 }, + filter: { + type: "Typed", + type_filters: ["Creature"], + controller: "You", + properties: [{ type: "HasSupertype", value: "Legendary" }], + }, + }, + sub_ability: null, + duration: null, + description: "Tap an untapped legendary creature you control: Add one mana of any color.", + target_prompt: null, + condition: null, + optional_targeting: false, + optional: false, + forward_result: false, + is_mana_ability: true, + }, +] as unknown as GameObject["abilities"]; + +function relicOfLegends(): GameObject { + return buildGameObject({ + id: 402, + card_id: 402, + name: "Relic of Legends", + zone: "Battlefield", + card_types: { supertypes: [], core_types: ["Artifact"], subtypes: [] }, + back_face: null, + abilities: RELIC_OF_LEGENDS_ABILITIES, + }); +} + +function activate(index: number): GameAction { + return { type: "ActivateAbility", data: { source_id: 402, ability_index: index } }; +} + +/** The rendered content of one ability-choice modal option (GamePage's ChoiceModal + * renders `label` plus the secondary `description`). Two options are + * distinguishable to the player iff this pair differs. */ +function renderedOption(object: GameObject, index: number): string { + const { label, description } = abilityChoiceLabel(activate(index), object); + return `${label}\u0000${description ?? ""}`; +} + +describe("mana-ability activation options must be distinguishable (Relic of Legends)", () => { + it("gives each of a permanent's mana abilities its own rendered option", () => { + const object = relicOfLegends(); + + // Discriminating: the two options must NOT render identically, or the player + // cannot tell "tap Relic itself" from "tap an untapped legendary creature". + expect(renderedOption(object, 0)).not.toBe(renderedOption(object, 1)); + }); + + it("surfaces each mana ability's own activation cost from the engine", () => { + const object = relicOfLegends(); + const first = abilityChoiceLabel(activate(0), object); + const second = abilityChoiceLabel(activate(1), object); + + // The engine is the only authority on what each cost is; the option must + // carry that engine-provided text, not just the (identical) produced mana. + const firstText = `${first.label} ${first.description ?? ""}`; + const secondText = `${second.label} ${second.description ?? ""}`; + expect(firstText).toContain("{T}"); + expect(secondText).toContain("Tap an untapped legendary creature you control"); + }); + + it("still tells the player what each ability produces", () => { + const object = relicOfLegends(); + for (const index of [0, 1]) { + const { label, description } = abilityChoiceLabel(activate(index), object); + expect(`${label} ${description ?? ""}`).toContain("one mana of any color"); + } + }); +}); diff --git a/client/src/viewmodel/blockerSorting.ts b/client/src/viewmodel/blockerSorting.ts index ced91cee0e..3ce54bd249 100644 --- a/client/src/viewmodel/blockerSorting.ts +++ b/client/src/viewmodel/blockerSorting.ts @@ -12,7 +12,7 @@ import type { GroupedPermanent } from "./battlefieldProps"; export function sortCreaturesForBlockers( playerCreatures: GroupedPermanent[], opponentCreatures: GroupedPermanent[], - blockerAssignments: Map, + blockerAssignments: ReadonlyMap>, ): GroupedPermanent[] { if (blockerAssignments.size === 0) return playerCreatures; @@ -57,13 +57,13 @@ export function sortCreaturesForBlockers( /** Get the minimum attacker column for any blocker in this group. */ function getMinAttackerColumn( group: GroupedPermanent, - blockerAssignments: Map, + blockerAssignments: ReadonlyMap>, attackerColumn: Map, ): number { let min = Infinity; for (const id of group.ids) { - const attackerId = blockerAssignments.get(id); - if (attackerId !== undefined) { + const attackerIds = blockerAssignments.get(id); + for (const attackerId of attackerIds ?? []) { const col = attackerColumn.get(attackerId) ?? Infinity; if (col < min) min = col; } diff --git a/client/src/viewmodel/cardProps.ts b/client/src/viewmodel/cardProps.ts index abcbbc418c..4ce69f1a8d 100644 --- a/client/src/viewmodel/cardProps.ts +++ b/client/src/viewmodel/cardProps.ts @@ -213,6 +213,24 @@ export function formatTypeLine(cardTypes: CardType, keywords?: Keyword[]): strin return main; } +/** + * True when `obj`'s stored `back_face` is a SEPARATELY PRINTED face the UI can + * present on its own — a CR 712 transform/modal/meld back face, or an + * Adventure/Omen/Prepare alternative spell. + * + * False for CR 710 Kamigawa flip cards. Their alternative half is printed + * upside down on the SAME physical face, so there is no second face to inspect + * (CardPreview renders it as a 180° rotation instead). The `back_face` slot is + * shared by all of these layouts, so `back_face != null` is NOT the predicate: + * using it gives all 21 flip cards a bogus DFC badge and an "inspect face 1" + * button pointing at a face that doesn't exist. The engine owns the + * discriminant — it ships `layout_kind` on the serialized back face (the same + * value `engine::game::transform::is_double_faced_permanent` keys on). + */ +export function hasOtherPrintedFace(obj: Pick): boolean { + return obj.back_face != null && obj.back_face.layout_kind !== "Flip"; +} + export function computePTDisplay(obj: GameObject): PTDisplay | null { if (obj.power == null || obj.toughness == null) return null; diff --git a/client/src/viewmodel/costLabel.ts b/client/src/viewmodel/costLabel.ts index f25e67d620..c957cfaf6f 100644 --- a/client/src/viewmodel/costLabel.ts +++ b/client/src/viewmodel/costLabel.ts @@ -8,6 +8,7 @@ import type { SerializedAbilityCost, } from "../adapter/types.ts"; import { getCrewPower, getSaddlePower } from "./keywordProps.ts"; +import { renderDescription } from "../utils/description.ts"; // Converts Rust ManaCostShard variant names to MTG abbreviations. // This is the canonical bridge between engine serialization and display— @@ -402,27 +403,78 @@ export function abilityChoiceLabel( description: `Tap ${object.name} to help pay this spell's cost.`, }; } + if (action.type === "TapLandForMana") { + const mana = action.data.selection.atomic_combination ?? [action.data.selection.mana_type]; + const symbols = mana + .map((manaType) => MANA_COLOR_ABBREVIATION[manaType] ?? "C") + .map((symbol) => `{${symbol}}`) + .join(""); + return { label: `Tap for ${symbols}` }; + } if (action.type === "ActivateAbility") { const ability = object.abilities[action.data.ability_index]; // For mana abilities, show what they produce (e.g., "Add {U}") instead of just the cost + // DELIBERATE SPLIT PREDICATE: this branch keys on the effect AST, NOT on the engine's + // `is_mana_ability` flag — only the `description` below is CR 605.1a-gated. Narrowing + // this predicate to the flag would push a Loyalty+Mana ability (Chandra, Torch of + // Defiance's `+1: Add {R}{R}`) down to the tail below, whose `stripCostPrefix` renders + // "Add {R}{R}." — a trailing period instead of today's "Add {R}{R}". Measured, not + // assumed. Leaving the label branch alone keeps the loyalty path byte-identical. if (ability?.effect?.type === "Mana" && ability.effect.produced) { const produced = ability.effect.produced; + // CR 605.1a: `is_mana_ability` is the ENGINE's mana-ability verdict + // (`game/mana_abilities.rs::is_mana_ability`) — it excludes loyalty abilities AND + // targeted mana effects (`multi_target` / embedded `Effect::Mana{target}`). + // Gate on the flag, never on the effect AST: a cost line under a LoyaltyBadge would + // double-render the cost that GamePage's `stripLoyaltyCostPrefix` exists to suppress. + // The flag is optional on the wire, so `=== true` treats absent as "not a mana + // ability" — the convention documented at `viewmodel/cardActionChoice.ts:29-32`. + // Second conjunct: only ENGINE-AUTHORED cost text is worth showing. With no + // description `abilityLabel` falls back to `formatCost`, which is partial over the + // engine's cost surface — 20 of the 32 `AbilityCost` variants have no arm and answer + // the literal word "Activate" (`TapCreatures` and `RemoveCounter` among them, i.e. + // exactly the costs on Relic of Legends' second ability and on Pentad Prism), and a + // `Composite`/`OneOf` with no `costs` answers "". `Sacrifice` and `Composite` DO have + // arms ("Sacrifice", "{T}, Sacrifice") — this conjunct is not about them. + // Descriptionless mana abilities are the engine-synthesized intrinsics + // (`database/synthesis.rs`, `game/layers.rs::basic_land_mana_ability`, and the + // `game/effects/token.rs` predefined-token abilities) — the wire sends Island with + // `description: null` — and every land with two or more basic land types carries one + // per type, so this conjunct is what keeps a dual/triome's already-distinct + // "Add {W}"/"Add {U}" options from each gaining an identical, uninformative "{T}". + // Known bounded gap: a permanent carrying two descriptionless mana abilities of the + // same produced shape (Treasure + Gold) still renders two identical options; the fix + // is engine-authored cost text, not a second frontend parse of the cost AST. + // CR 602.1a: the activation cost is everything before the colon — for two abilities + // producing identical mana it is the ONLY discriminator the engine ships. + // CR 201.5: `~` binds to the object that has the ability, which is `object` here. + const costLine = + ability.is_mana_ability === true && ability.description + ? renderDescription(abilityLabel(ability), object.name) + : undefined; if (produced.type === "Fixed" && produced.colors?.length) { const symbols = produced.colors.map((c) => `{${MANA_COLOR_ABBREVIATION[c] ?? c}}`).join(""); - return { label: `Add ${symbols}` }; + return { label: `Add ${symbols}`, description: costLine }; } if (produced.type === "Colorless") { - return { label: "Add {C}" }; + return { label: "Add {C}", description: costLine }; } if (produced.type === "AnyOneColor") { const count = formatQuantity((produced as { count?: QuantityExpr | number }).count, 1); return { label: count === "1" ? "Add one mana of any color" : `Add ${count} mana of any one color`, + description: costLine, }; } } - const label = abilityLabel(ability); - const description = ability?.description ? stripCostPrefix(ability.description) : undefined; + // CR 201.5: the non-mana tail feeds the SAME choice modal as the mana branch above and + // must bind `~` to the same object, or one row renders "Sacrifice Ghost Quarter" while + // the next renders "Sacrifice ~". Both halves leak in the live dump (29 abilities in the + // cost text, 27 in the effect text, e.g. "{T}: ~ deals 1 damage to any target."). + const label = renderDescription(abilityLabel(ability), object.name); + const description = ability?.description + ? renderDescription(stripCostPrefix(ability.description), object.name) + : undefined; return { label, description }; } if (action.type === "CastSpell") { diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 4b7480dfa7..4fbdb6ff43 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -742,6 +742,20 @@ pub fn candidate_actions_exact(state: &GameState) -> Vec { ) }) .collect(), + WaitingFor::ChooseGiftRecipient { + player, candidates, .. + } => candidates + .iter() + .map(|opponent| { + candidate( + GameAction::ChooseGiftRecipient { + opponent: *opponent, + }, + TacticalClass::Selection, + Some(*player), + ) + }) + .collect(), WaitingFor::BetweenGamesChoosePlayDraw { player, .. } => vec![ candidate( GameAction::ChoosePlayDraw { play_first: true }, @@ -836,6 +850,20 @@ pub fn candidate_actions_broad_with_probe( ) }) .collect(), + WaitingFor::ChooseGiftRecipient { + player, candidates, .. + } => candidates + .iter() + .map(|opponent| { + candidate( + GameAction::ChooseGiftRecipient { + opponent: *opponent, + }, + TacticalClass::Selection, + Some(*player), + ) + }) + .collect(), WaitingFor::ManaPayment { player, convoke_mode, @@ -889,7 +917,7 @@ pub fn candidate_actions_broad_with_probe( valid_blocker_ids, valid_block_targets, .. - } => blocker_actions(*player, valid_blocker_ids, valid_block_targets), + } => blocker_actions(state, *player, valid_blocker_ids, valid_block_targets), WaitingFor::UntapChoice { player, candidates, .. } => candidates @@ -4260,6 +4288,7 @@ fn attacker_actions( } fn blocker_actions( + state: &GameState, player: PlayerId, valid_blocker_ids: &[crate::types::identifiers::ObjectId], valid_block_targets: &std::collections::HashMap< @@ -4267,29 +4296,29 @@ fn blocker_actions( Vec, >, ) -> Vec { - let mut actions = vec![candidate( - GameAction::DeclareBlockers { - assignments: Vec::new(), - }, - TacticalClass::Block, - Some(player), - )]; + let mut proposals = vec![Vec::new()]; for &blocker_id in valid_blocker_ids { if let Some(targets) = valid_block_targets.get(&blocker_id) { for &attacker_id in targets { - actions.push(candidate( - GameAction::DeclareBlockers { - assignments: vec![(blocker_id, attacker_id)], - }, - TacticalClass::Block, - Some(player), - )); + proposals.push(vec![(blocker_id, attacker_id)]); } } } - actions + let mut seen = HashSet::new(); + crate::game::combat::complete_blocker_proposals(state, player, &proposals) + .into_iter() + .filter_map(|action| { + let GameAction::DeclareBlockers { assignments } = &action else { + return None; + }; + let mut key = assignments.clone(); + key.sort_unstable(); + seen.insert(key) + .then(|| candidate(action, TacticalClass::Block, Some(player))) + }) + .collect() } fn select_cards_variants( diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 3e9e0a3fb4..d4b05819c9 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -4809,6 +4809,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; obj.static_definitions = vec![def].into(); } @@ -4931,6 +4932,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; obj.static_definitions = vec![def].into(); } diff --git a/crates/engine/src/analysis/ability_graph.rs b/crates/engine/src/analysis/ability_graph.rs index 3eea0c026b..7bc747f65f 100644 --- a/crates/engine/src/analysis/ability_graph.rs +++ b/crates/engine/src/analysis/ability_graph.rs @@ -884,6 +884,9 @@ fn effect_projection(effect: &Effect) -> Projection { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): as-enters copy choice — no repeatable + // modeled axis at the candidate stage. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::DoublePT { .. } @@ -897,6 +900,9 @@ fn effect_projection(effect: &Effect) -> Projection { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: a flip instruction carries no nested ability edge, exactly + // like `Transform`. + | Effect::FlipPermanent { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } | Effect::RevealFromHand { .. } @@ -1889,6 +1895,11 @@ fn build_nodes(faces: &[&CardFace]) -> Vec { nodes.push(build_node(&face.name, def, trigger_axis(trigger))); } } + ContinuousModification::GrantReplacement { replacement } => { + if let Some(def) = &replacement.execute { + nodes.push(build_node(&face.name, def, None)); + } + } _ => {} } } diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index 14a23554d1..792da4b951 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -1415,6 +1415,17 @@ pub(crate) fn bargain_additional_cost() -> AdditionalCost { } } +/// CR 702.174a: Zero-cost optional Gift promise. Matches `synthesize_gift` so +/// `obj_additional_matches_instance` dedups the legacy face cost against the queue. +pub(crate) fn gift_additional_cost() -> AdditionalCost { + AdditionalCost::Optional { + cost: AbilityCost::Mana { + cost: ManaCost::zero(), + }, + repeatability: crate::types::ability::AdditionalCostRepeatability::Once, + } +} + /// Synthesize Gift optional cost and delivery effect. /// Gift is a promise (zero-cost optional additional cost) that sets `additional_cost_paid` /// when the player promises the gift. Conditional branches ("if the gift was promised" / @@ -1440,12 +1451,7 @@ pub fn synthesize_gift(face: &mut CardFace) { }; // Gift uses a zero-cost optional additional cost — the "cost" is just a decision. - face.additional_cost = Some(AdditionalCost::Optional { - cost: AbilityCost::Mana { - cost: ManaCost::zero(), - }, - repeatability: crate::types::ability::AdditionalCostRepeatability::Once, - }); + face.additional_cost = Some(gift_additional_cost()); // Inject GiftDelivery as a wrapper around the first spell ability. // The delivery effect is a no-op when the gift wasn't promised, so the @@ -5033,6 +5039,7 @@ fn is_provoke_attack_trigger(t: &TriggerDefinition) -> bool { execute.sub_ability.as_deref().map(|a| &*a.effect), Some(Effect::ForceBlock { target: TargetFilter::ParentTarget, + .. }) ) } @@ -5161,9 +5168,14 @@ fn build_provoke_trigger() -> TriggerDefinition { AbilityKind::Spell, Effect::ForceBlock { target: TargetFilter::ParentTarget, + attacker: Some(crate::types::ability::ForceBlockAttackerRef::Source), + duration: Duration::UntilEndOfCombat, }, ) - .description("CR 509.1c: that creature blocks this creature this turn if able".to_string()); + .description( + "CR 702.39a + CR 509.1c: that creature blocks this creature this combat if able" + .to_string(), + ); // CR 702.39a + CR 701.26b: "you may have target creature ... untap" — the // optional parent body untaps the chosen defender, then force-blocks it. @@ -5178,7 +5190,7 @@ fn build_provoke_trigger() -> TriggerDefinition { .optional() .sub_ability(force_block) .description( - "Provoke — untap target creature defending player controls; it blocks this turn if able" + "Provoke — untap target creature defending player controls; it blocks this combat if able" .to_string(), ); @@ -7217,44 +7229,65 @@ pub fn synthesize_sunburst(face: &mut CardFace) { .filter(|r| is_sunburst_etb_replacement(r, &counter_type)) .count(); - let counter_phrase = match &counter_type { + for _ in existing..instances { + face.replacements + .push(sunburst_replacement_definition(&counter_type)); + } +} + +/// CR 702.44a + CR 702.44d + CR 601.2h: the single per-instance Sunburst ETB +/// replacement definition — one `Moved`→Battlefield replacement on `SelfRef` +/// whose execute places one counter of `counter_type` per distinct color of +/// mana spent to cast the object. +/// +/// The single authority for building a Sunburst replacement, shared by: +/// - build-time synthesis (`synthesize_sunburst`) for PRINTED Sunburst, and +/// - the runtime granted-keyword replacement path +/// (`granted_sunburst_instances` → `granted_etb_replacement_definitions` → +/// `find_applicable_replacements`), which +/// surfaces one virtual candidate per *granted* Sunburst instance so a grant +/// ("that spell gains sunburst": Solar Array / Lux Artillery) also places +/// counters at entry (#5337). +/// +/// Because both callers build identical definitions, printed + granted +/// instances each yield a distinct candidate and apply separately, exactly as +/// CR 702.44d ("If an object has multiple instances of sunburst, each one works +/// separately") requires. +pub(crate) fn sunburst_replacement_definition(counter_type: &CounterType) -> ReplacementDefinition { + let counter_phrase = match counter_type { CounterType::Plus1Plus1 => "+1/+1", _ => "charge", }; - - for _ in existing..instances { - let etb_counters = AbilityDefinition::new( - AbilityKind::Spell, - Effect::PutCounter { - counter_type: counter_type.clone(), - // CR 702.44a + CR 601.2h: one counter per *color* (max 5) of mana - // spent to cast this object — the distinct-colors metric, not the - // total amount. - count: QuantityExpr::Ref { - qty: QuantityRef::ManaSpentToCast { - scope: CastManaObjectScope::SelfObject, - metric: CastManaSpentMetric::DistinctColors, - }, + let etb_counters = AbilityDefinition::new( + AbilityKind::Spell, + Effect::PutCounter { + counter_type: counter_type.clone(), + // CR 702.44a + CR 601.2h: one counter per *color* (max 5) of mana + // spent to cast this object — the distinct-colors metric, not the + // total amount. + count: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope: CastManaObjectScope::SelfObject, + metric: CastManaSpentMetric::DistinctColors, }, - target: TargetFilter::SelfRef, }, - ) - .description(format!( - "This permanent enters with a {counter_phrase} counter on it for each color of mana spent to cast it" - )); + target: TargetFilter::SelfRef, + }, + ) + .description(format!( + "This permanent enters with a {counter_phrase} counter on it for each color of mana spent to cast it" + )); - let replacement = ReplacementDefinition { - event: ReplacementEvent::Moved, - execute: Some(Box::new(etb_counters)), - valid_card: Some(TargetFilter::SelfRef), - // CR 614.1c: battlefield-entry-scoped (departure gate). - destination_zone: Some(Zone::Battlefield), - description: Some(format!( - "CR 702.44a: Sunburst — this permanent enters with a {counter_phrase} counter on it for each color of mana spent to cast it." - )), - ..ReplacementDefinition::new(ReplacementEvent::Moved) - }; - face.replacements.push(replacement); + ReplacementDefinition { + event: ReplacementEvent::Moved, + execute: Some(Box::new(etb_counters)), + valid_card: Some(TargetFilter::SelfRef), + // CR 614.1c: battlefield-entry-scoped (departure gate). + destination_zone: Some(Zone::Battlefield), + description: Some(format!( + "CR 702.44a: Sunburst — this permanent enters with a {counter_phrase} counter on it for each color of mana spent to cast it." + )), + ..ReplacementDefinition::new(ReplacementEvent::Moved) } } @@ -8115,27 +8148,51 @@ pub fn synthesize_bloodthirst(face: &mut CardFace) { if existing >= needed { continue; } - let etb_counters = AbilityDefinition::new( - AbilityKind::Spell, - Effect::PutCounter { - counter_type: CounterType::Plus1Plus1, - count: bloodthirst_counter_quantity(value), - target: TargetFilter::SelfRef, - }, - ) - .description(bloodthirst_execute_description(value)); + face.replacements + .push(bloodthirst_replacement_definition(value)); + } +} - let replacement = ReplacementDefinition { - event: ReplacementEvent::Moved, - execute: Some(Box::new(etb_counters)), - valid_card: Some(TargetFilter::SelfRef), - condition: bloodthirst_condition(value), - // CR 614.1c: battlefield-entry-scoped (departure gate). - destination_zone: Some(Zone::Battlefield), - description: Some(bloodthirst_replacement_description(value)), - ..ReplacementDefinition::new(ReplacementEvent::Moved) - }; - face.replacements.push(replacement); +/// CR 702.54a + CR 702.54b + CR 702.54c: the single per-instance Bloodthirst ETB +/// replacement definition — one `Moved`→Battlefield replacement on `SelfRef` +/// whose execute places `bloodthirst_counter_quantity(value)` +1/+1 counters, +/// gated by `bloodthirst_condition(value)` (the fixed-N form is conditional on an +/// opponent having been dealt damage this turn; the X form is unconditional and +/// its count reads the damage total directly). +/// +/// The single authority for building a Bloodthirst replacement, shared by: +/// - build-time synthesis (`synthesize_bloodthirst`) for PRINTED Bloodthirst, and +/// - the runtime granted-keyword replacement path +/// (`granted_bloodthirst_instances` → `find_applicable_replacements`), which +/// surfaces one virtual candidate per *granted* Bloodthirst instance so a grant +/// ("it gains bloodthirst 3": Bloodlord of Vaasgoth) also places counters at +/// entry (mirrors `sunburst_replacement_definition`, #5802). +/// +/// Because both callers build identical definitions, printed + granted instances +/// each yield a distinct candidate and apply separately per CR 702.54c ("each +/// instance works separately"). +pub(crate) fn bloodthirst_replacement_definition( + value: &BloodthirstValue, +) -> ReplacementDefinition { + let etb_counters = AbilityDefinition::new( + AbilityKind::Spell, + Effect::PutCounter { + counter_type: CounterType::Plus1Plus1, + count: bloodthirst_counter_quantity(value), + target: TargetFilter::SelfRef, + }, + ) + .description(bloodthirst_execute_description(value)); + + ReplacementDefinition { + event: ReplacementEvent::Moved, + execute: Some(Box::new(etb_counters)), + valid_card: Some(TargetFilter::SelfRef), + condition: bloodthirst_condition(value), + // CR 614.1c: battlefield-entry-scoped (departure gate). + destination_zone: Some(Zone::Battlefield), + description: Some(bloodthirst_replacement_description(value)), + ..ReplacementDefinition::new(ReplacementEvent::Moved) } } @@ -8272,20 +8329,58 @@ fn is_bloodthirst_x_etb_replacement(replacement: &ReplacementDefinition) -> bool /// creature sacrificed"). `PreviousEffectAmount` is NOT used — it reads /// `last_effect_amount`, which the ranged Sacrifice never stamps. /// -/// CR 702.82c "Devour [quality]" variant: `Keyword::Devour(u32)` carries only -/// N, not a quality filter. This synthesizer hard-codes the CR 702.82a default -/// (sacrifice creatures). A future card needing the quality axis requires -/// parameterizing the keyword to `Devour { n, quality }`. +fn type_filter_noun(filter: &TypeFilter, plural: bool) -> String { + let noun = match filter { + TypeFilter::Creature => "creature", + TypeFilter::Land => "land", + TypeFilter::Artifact => "artifact", + TypeFilter::Enchantment => "enchantment", + TypeFilter::Instant => "instant", + TypeFilter::Sorcery => "sorcery", + TypeFilter::Planeswalker => "planeswalker", + TypeFilter::Battle => "battle", + TypeFilter::Kindred => "kindred", + TypeFilter::Permanent => "permanent", + TypeFilter::Card => "card", + TypeFilter::Any => "permanent", + TypeFilter::Non(inner) => return format!("non-{}", type_filter_noun(inner, plural)), + TypeFilter::Subtype(subtype) => { + let noun = subtype.to_lowercase(); + return if plural { format!("{noun}s") } else { noun }; + } + TypeFilter::AnyOf(filters) => { + return filters + .iter() + .map(|filter| type_filter_noun(filter, plural)) + .collect::>() + .join(" or "); + } + }; + + if plural { + format!("{noun}s") + } else { + noun.into() + } +} + +/// CR 702.82c "Devour [quality]" variant: `Keyword::Devour { n, quality }` +/// carries both N and the sacrifice-pool quality. `quality: TypeFilter::Creature` +/// is the CR 702.82a default (plain "Devour N"); a non-creature quality (Land for +/// Famished Worldsire, Artifact for Caprichrome, Subtype("Food") for Feasting +/// Hobbit) narrows the sacrifice pool per CR 702.82c. The counter math (CR 122.1a, +/// N per permanent sacrificed) is identical across qualities — only the +/// `Sacrifice` target and its `ObjectCount` bound switch to `quality`. /// /// CR 113.2c: each Devour instance functions independently. Per-N idempotency /// (`is_devour_etb_replacement`) emits only the delta so re-running synthesis /// is a no-op. pub fn synthesize_devour(face: &mut CardFace) { - let devour_values: Vec = face + let devour_values: Vec<(u32, TypeFilter)> = face .keywords .iter() .filter_map(|kw| match kw { - Keyword::Devour(n) => Some(*n), + Keyword::Devour { n, quality } => Some((*n, quality.clone())), _ => None, }) .collect(); @@ -8293,17 +8388,29 @@ pub fn synthesize_devour(face: &mut CardFace) { return; } - for &n in &devour_values { - let needed = devour_values.iter().filter(|m| **m == n).count(); + for (n, quality) in &devour_values { + let n = *n; + // CR 113.2c: dedup on the FULL (n, quality) key — a card carrying both a + // land-quality and a creature-quality Devour of the same N synthesizes + // BOTH replacements; the count alone would false-merge them. + let needed = devour_values + .iter() + .filter(|(m, q)| *m == n && q == quality) + .count(); let existing = face .replacements .iter() - .filter(|r| is_devour_etb_replacement(r, n)) + .filter(|r| is_devour_etb_replacement(r, n, quality)) .count(); if existing >= needed { continue; } + // CR 702.82a / CR 702.82c: display noun for the sacrifice pool. Cosmetic — + // the idempotency predicate keys on structure, not this text. + let quality_noun = type_filter_noun(quality, false); + let quality_noun_plural = type_filter_noun(quality, true); + // CR 122.1: N +1/+1 counters per creature sacrificed this way. The // per-creature count is `EventContextAmount` (resolves to the number // the ranged Sacrifice choice stamped into `last_effect_count`); for @@ -8330,17 +8437,20 @@ pub fn synthesize_devour(face: &mut CardFace) { }, ); - // CR 702.82a: "you may sacrifice any number of creatures" — a ranged - // `UpTo` choice bounded by the controller's eligible creature pool, - // `min_count: 0` so an empty choice is legal. + // CR 702.82a / CR 702.82c: "you may sacrifice any number of [quality] + // permanents" — a ranged `UpTo` choice bounded by the controller's + // eligible pool of `quality` permanents, `min_count: 0` so an empty + // choice is legal. `quality` is `Creature` for the CR 702.82a default. let sacrifice = AbilityDefinition::new( AbilityKind::Spell, Effect::Sacrifice { - target: TargetFilter::Typed(TypedFilter::creature().controller(ControllerRef::You)), + target: TargetFilter::Typed( + TypedFilter::new(quality.clone()).controller(ControllerRef::You), + ), count: QuantityExpr::up_to(QuantityExpr::Ref { qty: QuantityRef::ObjectCount { filter: TargetFilter::Typed( - TypedFilter::creature().controller(ControllerRef::You), + TypedFilter::new(quality.clone()).controller(ControllerRef::You), ), }, }), @@ -8348,8 +8458,8 @@ pub fn synthesize_devour(face: &mut CardFace) { }, ) .description(format!( - "CR 702.82a: Devour {n} — sacrifice any number of creatures; this \ - permanent enters with {n} +1/+1 counter{} per creature sacrificed.", + "CR 702.82a: Devour {n} — sacrifice any number of {quality_noun_plural}; this \ + permanent enters with {n} +1/+1 counter{} per {quality_noun} sacrificed.", if n == 1 { "" } else { "s" } )) .sub_ability(put_counters); @@ -8362,8 +8472,8 @@ pub fn synthesize_devour(face: &mut CardFace) { destination_zone: Some(Zone::Battlefield), description: Some(format!( "CR 702.82a + CR 614.1c: Devour {n} — as this creature enters, \ - you may sacrifice any number of creatures; it enters with {n} \ - +1/+1 counter{} for each creature sacrificed this way.", + you may sacrifice any number of {quality_noun_plural}; it enters with {n} \ + +1/+1 counter{} for each {quality_noun} sacrificed this way.", if n == 1 { "" } else { "s" } )), ..ReplacementDefinition::new(ReplacementEvent::Moved) @@ -8374,15 +8484,24 @@ pub fn synthesize_devour(face: &mut CardFace) { /// Idempotency-shape predicate for `synthesize_devour`'s ETB replacement. /// True iff `replacement` is a `Moved` replacement on `SelfRef` whose `execute` -/// chain is `Effect::Sacrifice` of your creatures (ranged `UpTo`) with a -/// `PutCounter` of `expected_n` P1P1 counters per creature on `SelfRef` as its -/// sub-ability. +/// chain is `Effect::Sacrifice` of your `expected_quality` permanents (ranged +/// `UpTo`) with a `PutCounter` of `expected_n` P1P1 counters per permanent on +/// `SelfRef` as its sub-ability. /// /// `expected_n` is load-bearing: a card carrying both a printed enters-with-K -/// replacement and `Keyword::Devour(N≠K)` must not dedupe — the `Multiply` -/// factor (N) for N > 1 and the bare `EventContextAmount` (N == 1) discriminate -/// the count. -fn is_devour_etb_replacement(replacement: &ReplacementDefinition, expected_n: u32) -> bool { +/// replacement and `Keyword::Devour { n: N≠K, .. }` must not dedupe — the +/// `Multiply` factor (N) for N > 1 and the bare `EventContextAmount` (N == 1) +/// discriminate the count. +/// +/// `expected_quality` is equally load-bearing (CR 702.82c): a land-quality Devour +/// and a creature-quality Devour of the SAME N are distinct replacements. Without +/// discriminating the `Sacrifice` target's `TypeFilter`, a `Devour land 3` and a +/// `Devour 3` on the same face would false-dedupe, dropping one pool. +fn is_devour_etb_replacement( + replacement: &ReplacementDefinition, + expected_n: u32, + expected_quality: &TypeFilter, +) -> bool { if !matches!(replacement.event, ReplacementEvent::Moved) || !matches!(replacement.valid_card, Some(TargetFilter::SelfRef)) { @@ -8391,7 +8510,16 @@ fn is_devour_etb_replacement(replacement: &ReplacementDefinition, expected_n: u3 let Some(execute) = replacement.execute.as_deref() else { return false; }; - if !matches!(&*execute.effect, Effect::Sacrifice { .. }) { + // CR 702.82c: the sacrifice pool's quality must match. `TypedFilter::new` + // mirrors the synthesizer, so a full `TargetFilter` equality check on the + // expected shape discriminates the quality axis. + let expected_target = TargetFilter::Typed( + TypedFilter::new(expected_quality.clone()).controller(ControllerRef::You), + ); + let Effect::Sacrifice { target, .. } = &*execute.effect else { + return false; + }; + if *target != expected_target { return false; } let Some(sub) = execute.sub_ability.as_deref() else { @@ -13524,6 +13652,8 @@ mod provoke_synthesis_tests { &*sub.effect, Effect::ForceBlock { target: TargetFilter::ParentTarget, + attacker: Some(crate::types::ability::ForceBlockAttackerRef::Source), + duration: Duration::UntilEndOfCombat, } ), "sub-ability must force-block the parent (untapped) target via ParentTarget, got {:?}", @@ -13772,7 +13902,7 @@ mod provoke_runtime_tests { use crate::types::ability::{ContinuousModification, EffectKind, TargetRef}; use crate::types::events::GameEvent; use crate::types::game_state::GameState; - use crate::types::identifiers::{CardId, ObjectId}; + use crate::types::identifiers::{CardId, ObjectId, ObjectIncarnationRef}; use crate::types::player::PlayerId; use crate::types::statics::StaticMode; @@ -13823,6 +13953,11 @@ mod provoke_runtime_tests { vec![TargetRef::Object(defender)], ); resolved.optional = false; + // Match the shared stack boundary: source-referential force-blocks + // bind the exact attacking incarnation before their continuation runs. + resolved.bind_force_block_source_recursive(Some(ObjectIncarnationRef::from_object( + &state.objects[&provoker], + ))); let mut events = Vec::new(); resolve_ability_chain(&mut state, &resolved, &mut events, 0).unwrap(); @@ -13841,7 +13976,7 @@ mod provoke_runtime_tests { m, ContinuousModification::AddStaticMode { mode: StaticMode::MustBlockAttacker { attacker }, - } if *attacker == provoker + } if attacker.object_id == provoker ) }) }); @@ -13850,6 +13985,13 @@ mod provoke_runtime_tests { "Provoke must apply MustBlockAttacker bound to the provoking attacker, \ reusing the existing source-referential ForceBlock resolver" ); + assert!( + state + .transient_continuous_effects + .iter() + .any(|effect| effect.duration == Duration::UntilEndOfCombat), + "CR 702.39a's forced block lasts only for this combat" + ); assert!(events.iter().any(|e| matches!( e, @@ -22307,7 +22449,10 @@ mod devour_synthesis_tests { fn face_with_devour(n: u32) -> CardFace { let mut face = CardFace::default(); - face.keywords.push(Keyword::Devour(n)); + face.keywords.push(Keyword::Devour { + n, + quality: TypeFilter::Creature, + }); face } @@ -22323,7 +22468,7 @@ mod devour_synthesis_tests { let replacement = face .replacements .iter() - .find(|r| is_devour_etb_replacement(r, 1)) + .find(|r| is_devour_etb_replacement(r, 1, &TypeFilter::Creature)) .expect("Devour 1 must synthesize an as-enters replacement"); assert!(matches!(replacement.event, ReplacementEvent::Moved)); @@ -22396,7 +22541,7 @@ mod devour_synthesis_tests { let replacement = face .replacements .iter() - .find(|r| is_devour_etb_replacement(r, 2)) + .find(|r| is_devour_etb_replacement(r, 2, &TypeFilter::Creature)) .expect("Devour 2 must synthesize an as-enters replacement"); let sub = replacement .execute @@ -22417,7 +22562,11 @@ mod devour_synthesis_tests { "Devour 2 places 2 counters per creature sacrificed (CR 702.82a)" ); // A Devour-2 replacement must not be mistaken for a Devour-1 one. - assert!(!is_devour_etb_replacement(replacement, 1)); + assert!(!is_devour_etb_replacement( + replacement, + 1, + &TypeFilter::Creature + )); } /// CR 113.2c: re-running synthesis is idempotent — exactly one Devour @@ -22430,7 +22579,7 @@ mod devour_synthesis_tests { let count = face .replacements .iter() - .filter(|r| is_devour_etb_replacement(r, 2)) + .filter(|r| is_devour_etb_replacement(r, 2, &TypeFilter::Creature)) .count(); assert_eq!(count, 1, "running synthesis twice must not duplicate"); } @@ -22443,6 +22592,62 @@ mod devour_synthesis_tests { assert!(face.replacements.is_empty()); } + /// CR 702.82c: the quality axis discriminates. A land-quality Devour and a + /// creature-quality Devour of the SAME N are DISTINCT replacements — the + /// idempotency predicate must not cross-match them, and a face carrying both + /// must synthesize BOTH pools (not false-dedupe one away). + /// + /// Revert-failing: without the `expected_quality` discrimination in + /// `is_devour_etb_replacement`, the creature and land replacements would + /// mutually match, `synthesize_devour` would emit only one, and the + /// cross-match assertions below would fail. + #[test] + fn synthesize_devour_discriminates_quality_axis() { + let mut face = CardFace::default(); + face.keywords.push(Keyword::Devour { + n: 3, + quality: TypeFilter::Creature, + }); + face.keywords.push(Keyword::Devour { + n: 3, + quality: TypeFilter::Land, + }); + synthesize_devour(&mut face); + + let creature_matches = face + .replacements + .iter() + .filter(|r| is_devour_etb_replacement(r, 3, &TypeFilter::Creature)) + .count(); + let land_matches = face + .replacements + .iter() + .filter(|r| is_devour_etb_replacement(r, 3, &TypeFilter::Land)) + .count(); + + assert_eq!( + creature_matches, 1, + "the creature-quality Devour 3 must synthesize exactly one replacement" + ); + assert_eq!( + land_matches, 1, + "the land-quality Devour 3 must synthesize a SEPARATE replacement — \ + same N, different quality, must not false-dedupe" + ); + + // Cross-match must be empty: a creature-quality replacement is NOT a + // land-quality one and vice versa (same N). + let creature_repl = face + .replacements + .iter() + .find(|r| is_devour_etb_replacement(r, 3, &TypeFilter::Creature)) + .expect("creature-quality Devour replacement exists"); + assert!( + !is_devour_etb_replacement(creature_repl, 3, &TypeFilter::Land), + "a creature-quality Devour 3 replacement must NOT match the land quality" + ); + } + fn face_with_amplify(n: u32) -> CardFace { let mut face = CardFace::default(); face.keywords.push(Keyword::Amplify(n)); diff --git a/crates/engine/src/database/unearth.rs b/crates/engine/src/database/unearth.rs index c6fb077ce9..662d727ba2 100644 --- a/crates/engine/src/database/unearth.rs +++ b/crates/engine/src/database/unearth.rs @@ -17,7 +17,10 @@ //! 3. an `Effect::CreateDelayedTrigger` that exiles the card at the next end //! step (CR 702.84a), and //! 4. an `Effect::AddTargetReplacement` installing the "if it would leave the -//! battlefield, exile it instead" replacement (CR 702.84a / CR 614.1a). +//! battlefield, exile it instead" replacement (CR 702.84a / CR 614.1a / +//! CR 400.7), stamped `RestrictionExpiry::UntilHostLeavesPlay` so it is +//! base-installed (surviving CR 613.1 layer reseeds), non-copiable +//! (CR 707.2), and pruned when the host leaves the battlefield. //! //! Steps 2–4 are chained as `sub_ability` continuation steps (CR 608.2c) of the //! primary `ChangeZone`, so they resolve as one action. @@ -35,13 +38,12 @@ use crate::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, ContinuousModification, DelayedTriggerCondition, - Duration, Effect, ReplacementDefinition, StaticDefinition, TargetFilter, + Duration, Effect, RestrictionExpiry, StaticDefinition, TargetFilter, }; use crate::types::card::CardFace; use crate::types::keywords::Keyword; use crate::types::mana::ManaCost; use crate::types::phase::Phase; -use crate::types::replacements::ReplacementEvent; use crate::types::zones::Zone; /// CR 702.84a: Synthesize the graveyard-activated reanimation ability for every @@ -149,18 +151,23 @@ fn delayed_exile_step() -> AbilityDefinition { .description("Exile it at the beginning of the next end step.".to_string()) } -/// CR 702.84a / CR 614.1a: "If it would leave the battlefield, exile it instead -/// of putting it anywhere else." Installs a `Moved` replacement on the returned -/// permanent (`target: SelfRef`); `valid_card: SelfRef` binds the replacement to -/// its own host so it fires only for that object, redirecting any -/// battlefield-exit to exile. +/// CR 702.84a / CR 614.1a / CR 400.7: "If it would leave the battlefield, exile +/// it instead of putting it anywhere else." Installs a `Moved` replacement on +/// the returned permanent (`target: SelfRef`); `valid_card: SelfRef` binds the +/// replacement to its own host so it fires only for that object, redirecting any +/// battlefield-exit to exile. The rider is stamped +/// `RestrictionExpiry::UntilHostLeavesPlay`: it is a runtime effect bound to the +/// host object's lifetime, so it is base-installed to survive CR 613.1 layer +/// reseeds, kept non-copiable (CR 707.2), and pruned on the host's battlefield +/// exit (CR 400.7) — see the variant doc in `types/ability.rs`. fn leaves_battlefield_exile_step() -> AbilityDefinition { - let replacement = ReplacementDefinition::new(ReplacementEvent::Moved) - .valid_card(TargetFilter::SelfRef) - .execute(AbilityDefinition::new( - AbilityKind::Spell, - exile_self_from_battlefield_effect(), - )); + // Shares the single unstamped `leave_battlefield_exile_replacement` authority + // (#6538 / #6566); this site keeps its own `target: SelfRef` wrapper + + // description and composes the #6538 host-lifetime stamp itself. The stamp is + // applied per-consumer, not baked into the constructor, because the granted + // path (#6566) must NOT carry it — see the constructor doc. + let replacement = crate::parser::oracle_effect::leave_battlefield_exile_replacement() + .expiry(RestrictionExpiry::UntilHostLeavesPlay); AbilityDefinition::new( AbilityKind::Spell, diff --git a/crates/engine/src/database/unearth_tests.rs b/crates/engine/src/database/unearth_tests.rs index c53553680d..a11d4ac25a 100644 --- a/crates/engine/src/database/unearth_tests.rs +++ b/crates/engine/src/database/unearth_tests.rs @@ -14,7 +14,7 @@ use crate::game::triggers::check_delayed_triggers; use crate::game::zones::create_object; use crate::types::ability::{ AbilityCost, AbilityDefinition, ContinuousModification, DelayedTriggerCondition, Effect, - TargetFilter, + RestrictionExpiry, TargetFilter, }; use crate::types::card::CardFace; use crate::types::card_type::CoreType; @@ -361,6 +361,41 @@ fn unearth_installs_leaves_battlefield_exile_replacement() { "the replacement must redirect the card to exile, got {:?}", execute.effect ); + // CR 400.7 + CR 702.84a: the rider is stamped `UntilHostLeavesPlay` so its + // lifetime is bound to the returned object. + assert_eq!( + repl.expiry, + Some(RestrictionExpiry::UntilHostLeavesPlay), + "the leaves-battlefield rider must carry the host-lifetime expiry stamp" + ); + + // CR 613.1: the rider is also written to `base_replacement_definitions` so it + // survives a layer reseed (which rebuilds live defs from base). Without the + // base copy, the first `evaluate_layers` would silently drop the redirect. + let base = &state.objects[&source].base_replacement_definitions; + let base_repl = base + .iter() + .find(|r| r.event == ReplacementEvent::Moved) + .expect("the rider must be base-installed to survive a layer reseed"); + assert_eq!( + base_repl.expiry, + Some(RestrictionExpiry::UntilHostLeavesPlay), + "the base-installed rider must carry the host-lifetime expiry stamp" + ); + assert_eq!(base_repl.valid_card, Some(TargetFilter::SelfRef)); + + // Discriminating runtime check: after a real layer pass, the live rider must + // still be present (repopulated from base). This is the exact reseed that + // dropped the live-only rider before the fix. + crate::game::layers::evaluate_layers(&mut state); + assert!( + state.objects[&source] + .replacement_definitions + .iter_all() + .any(|r| r.event == ReplacementEvent::Moved + && r.expiry == Some(RestrictionExpiry::UntilHostLeavesPlay)), + "the rider must survive a layer reseed (repopulated from base)" + ); } // --------------------------------------------------------------------------- diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index e6127d76fe..f5ecd66b44 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -104,9 +104,9 @@ use crate::types::ability::FilterProp; use crate::types::ability::{ AbilityCondition, AbilityDefinition, ContinuousModification, ControllerRef, Duration, Effect, GuessSubject, ModalChoice, MultiTargetSpec, ObjectScope, PlayerFilter, PlayerScope, - QuantityExpr, QuantityRef, RepeatContinuation, ResolvedAbility, StaticCondition, - StaticDefinition, TargetFilter, TriggerCondition, TriggerDefinition, TypeFilter, TypedFilter, - ZoneRef, + QuantityExpr, QuantityRef, RepeatContinuation, ReplacementDefinition, ResolvedAbility, + StaticCondition, StaticDefinition, TargetFilter, TriggerCondition, TriggerDefinition, + TypeFilter, TypedFilter, ZoneRef, }; use crate::types::game_state::TargetSelectionConstraint; use crate::types::zones::Zone; @@ -1357,6 +1357,7 @@ fn scope_of(target: &TargetFilter, chain_root: Option) -> WriteScope | TargetFilter::ScopedPlayer | TargetFilter::AttachedTo | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } @@ -1813,6 +1814,7 @@ fn legacy_trigger_condition(x: &TriggerCondition) -> bool { | TriggerCondition::CastSpellThisTurn { .. } | TriggerCondition::SpellCastWithVariantThisTurn { .. } | TriggerCondition::SourceEnteredThisTurn + | TriggerCondition::SourceAttackedThisCombat | TriggerCondition::SourceIsHarnessed | TriggerCondition::SourceIsAttacking | TriggerCondition::SourceIsTransformed @@ -2262,6 +2264,7 @@ fn legacy_target_filter(f: &TargetFilter) -> bool { | TargetFilter::AttachedTo | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } | TargetFilter::ExiledBySource @@ -2504,6 +2507,7 @@ fn member_bound_target_filter(f: &TargetFilter) -> bool { | TargetFilter::ScopedPlayer | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::None | TargetFilter::Any | TargetFilter::Player @@ -2695,6 +2699,12 @@ fn legacy_continuous_modification(m: &ContinuousModification) -> bool { legacy_static_definition(definition) } ContinuousModification::GrantTrigger { trigger } => legacy_trigger_definition(trigger), + // A granted object-hosted replacement can nest a frozen tag in its + // execute body or its `valid_card` scope filter — distinct traversal from + // GrantTrigger (a ReplacementDefinition, not a TriggerDefinition). + ContinuousModification::GrantReplacement { replacement } => { + legacy_replacement_definition(replacement) + } ContinuousModification::GrantAllActivatedAbilitiesOf { source, .. } | ContinuousModification::GrantAllTriggeredAbilitiesOf { source } => { legacy_target_filter(source) @@ -2710,6 +2720,9 @@ fn legacy_continuous_modification(m: &ContinuousModification) -> bool { legacy_quantity_expr(value) } ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): inert parse-time copy marker — no + // frozen event-context tag. + | ContinuousModification::CopyChosen | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } @@ -2758,6 +2771,14 @@ fn legacy_continuous_modification(m: &ContinuousModification) -> bool { } } +/// A granted object-hosted `ReplacementDefinition` can carry a frozen tag in its +/// `execute` redirect body or its `valid_card` scope filter. Mirrors +/// `legacy_trigger_definition` for the replacement-granting layer-6 case. +fn legacy_replacement_definition(rd: &ReplacementDefinition) -> bool { + rd.execute.as_deref().is_some_and(legacy_definition) + || rd.valid_card.as_ref().is_some_and(legacy_target_filter) +} + /// A granted / emblem `TriggerDefinition` can carry a frozen tag in its firing /// filters (`valid_card`/`valid_source`), its intervening-if condition, or its /// execute body. @@ -2804,6 +2825,8 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::HideawayConceal { target } | Effect::ChooseCard { target, .. } | Effect::Transform { target } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target } | Effect::Shuffle { target } | Effect::Reveal { target } | Effect::TargetOnly { target } @@ -2811,7 +2834,6 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::Unsuspect { target, .. } | Effect::PhaseOut { target } | Effect::PhaseIn { target } - | Effect::ForceBlock { target } | Effect::BecomePrepared { target } | Effect::BecomeUnprepared { target } | Effect::BecomeSaddled { target } @@ -2842,6 +2864,10 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::DiscardCard { target, .. } | Effect::Animate { target, .. } => legacy_target_filter(target), + Effect::ForceBlock { + target, duration, .. + } => legacy_target_filter(target) || legacy_duration(duration), + Effect::GainActivatedAbilitiesOfTarget { target, recipient, @@ -3159,6 +3185,9 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::CastFromZone { target, duration, .. } => legacy_target_filter(target) || odur(duration), + // CR 707.2c (Metamorphic Alteration): the copy-source choice pool is a + // target filter; walk it for legacy event-refs like every other filter. + Effect::ChoosePermanent { filter } => legacy_target_filter(filter), Effect::GenericEffect { duration, target, @@ -3578,9 +3607,10 @@ fn read_object_scope(scope: &ObjectScope, kind: StateKind) -> RwProfile { /// board `ObjectPt` characteristic read. fn share_quality_operand_read(f: &TargetFilter) -> RwProfile { match f { - TargetFilter::LastRevealed | TargetFilter::SelfRef | TargetFilter::SourceOrPaired => { - RwProfile::empty() - } + TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged + | TargetFilter::SelfRef + | TargetFilter::SourceOrPaired => RwProfile::empty(), // Fail-closed: any other reference is a live board characteristic read. _ => reads_board_of(StateKind::ObjectPt), } @@ -3648,6 +3678,7 @@ fn walk_ability( source_incarnation: _, // self-transform epoch latch, no read/write effect trigger_source: _, // exact triggered-source authority, no read/write effect trigger_definition_ref: _, // exact trigger occurrence, no read/write effect + force_block_attacker: _, // exact force-block referent, no read/write effect controller: _, original_controller: _, scoped_player: _, @@ -3674,6 +3705,7 @@ fn walk_ability( target_selection_mode: _, chosen_players: _, sub_link: _, + sibling_condition: _, // replication marker, no read/write effect replacement_applied: _, parent_target_missing_reason: _, } = a; @@ -3794,6 +3826,7 @@ fn walk_definition( target_selection_mode: _, sub_link: _, iteration_kind_binding: _, + sibling_condition: _, } = a; // §4.3.2: own `player_scope` overrides the inherited scope (Brink's Discard @@ -4891,6 +4924,10 @@ fn rw_effect( } => obj(StateKind::ObjectPt, target), Effect::SwitchPT { target } => obj(StateKind::ObjectPt, target), Effect::Transform { target } => obj(StateKind::ObjectPt, target), + // CR 710.1b: flipping replaces the permanent's power and toughness + // (along with its name, type line, and text box) — the same + // `ObjectPt` write axis `Transform` records. + Effect::FlipPermanent { target } => obj(StateKind::ObjectPt, target), Effect::BecomeCopy { target, recipient, @@ -5320,6 +5357,14 @@ fn rw_effect( p.merge(rw_duration(duration)); (p, None) } + Effect::ForceBlock { + target, duration, .. + } => { + let mut p = ext_write(StateKind::Other); + flag_legacy_write_target(&mut p, target); + p.merge(rw_duration(duration)); + (p, None) + } // §L14 (CR 500.8): an additional phase/step is a turn-structure write. Effect::AdditionalPhase { target: _, @@ -5472,7 +5517,6 @@ fn rw_effect( | Effect::RevealFromHand { .. } | Effect::ChooseDamageSource { .. } | Effect::PhaseIn { .. } - | Effect::ForceBlock { .. } | Effect::BecomeUnprepared { .. } | Effect::BecomeSaddled { .. } | Effect::SetClassLevel { .. } @@ -5482,6 +5526,11 @@ fn rw_effect( // and may add counters from a live property read. Fail closed until the // copy/counter sub-steps have a precise profile. | Effect::EachPlayerCopyChosen { .. } + // CR 707.2c (Metamorphic Alteration): raised only from the Aura-ETB + // replacement path; on answer it installs a Layer-1 copy on the Aura's + // host. Never resolves through the intra-ability chain — fail-closed + // conservative keeps the exhaustive match honest. + | Effect::ChoosePermanent { .. } | Effect::Myriad | Effect::Encore | Effect::CombineHost { .. } @@ -6055,6 +6104,7 @@ fn rw_trigger_condition(x: &TriggerCondition) -> RwProfile { } TriggerCondition::DuringPlayersTurn { player } => rw_player_filter(player), TriggerCondition::SourceEnteredThisTurn + | TriggerCondition::SourceAttackedThisCombat | TriggerCondition::SourceIsHarnessed | TriggerCondition::SourceIsAttacking | TriggerCondition::SourceIsTransformed @@ -6294,6 +6344,7 @@ fn rw_target_filter(x: &TargetFilter) -> RwProfile { | TargetFilter::AttachedTo | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } | TargetFilter::ExiledBySource diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index e7b79a6f74..0e3621eb6e 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -231,6 +231,7 @@ fn resolved_ability_axes(a: &ResolvedAbility, mode: ScanMode) -> Axes { source_incarnation: _, // self-transform epoch latch, no dynamic read trigger_source: _, // exact triggered-source authority, no dynamic read trigger_definition_ref: _, // exact trigger occurrence, no dynamic read + force_block_attacker: _, // exact force-block referent, no dynamic read controller: _, // player id original_controller: _, // player id scoped_player: _, // player id (iteration binding) @@ -258,6 +259,7 @@ fn resolved_ability_axes(a: &ResolvedAbility, mode: ScanMode) -> Axes { chosen_players: _, // concrete chosen player ids replacement_applied: _, // replacement provenance set, no dynamic read sub_link: _, // SubAbilityLink kind tag + sibling_condition: _, // SiblingCondition replication marker, no dynamic read parent_target_missing_reason: _, // seam flag } = a; @@ -890,6 +892,13 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { acc } Effect::BecomeCopy { .. } => Axes::CONSERVATIVE, + // CR 707.2c: the chosen creature's copiable values are latched onto the + // Aura's host at the answer — a copy-family continuous effect, same + // conservative classification as `BecomeCopy`. `filter` scans no + // per-source projected resource (it just bounds the choice pool). + Effect::ChoosePermanent { filter } => { + scan_target_filter(filter, target_ctx, mode).or(Axes::CONSERVATIVE) + } Effect::GainActivatedAbilitiesOfTarget { target, recipient, @@ -1040,6 +1049,13 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { acc = acc.or(scan_target_filter(target, target_ctx, mode)); acc } + // CR 710.4: identical scan shape to `Transform` — the only read is the + // effect's own target filter. + Effect::FlipPermanent { target } => { + let mut acc = Axes::NONE; + acc = acc.or(scan_target_filter(target, target_ctx, mode)); + acc + } Effect::SearchLibrary { .. } => Axes::CONSERVATIVE, Effect::SearchOutsideGame { filter, @@ -1127,9 +1143,14 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { acc = acc.or(scan_target_filter(target, target_ctx, mode)); acc } - Effect::ForceBlock { target } => { + Effect::ForceBlock { + target, + attacker: _, + duration, + } => { let mut acc = Axes::NONE; acc = acc.or(scan_target_filter(target, target_ctx, mode)); + acc = acc.or(scan_duration(duration, mode)); acc } Effect::ForceAttack { @@ -2883,7 +2904,7 @@ fn scan_target_filter(x: &TargetFilter, ctx: FilterReadContext, mode: ScanMode) TargetFilter::ScopedPlayer => Axes::NONE, TargetFilter::AttachedTo => Axes::NONE, TargetFilter::LastCreated => Axes::NONE, - TargetFilter::LastRevealed => Axes::NONE, + TargetFilter::LastRevealed | TargetFilter::LastZoneChanged => Axes::NONE, TargetFilter::CostPaidObject => Axes { event: true, sibling: false, @@ -3071,11 +3092,13 @@ fn scan_trigger_condition(x: &TriggerCondition, mode: ScanMode) -> Axes { acc = acc.or(scan_player_filter(player, mode)); acc } - TriggerCondition::SourceEnteredThisTurn => Axes { - event: false, - sibling: false, - projected: true, - }, + TriggerCondition::SourceEnteredThisTurn | TriggerCondition::SourceAttackedThisCombat => { + Axes { + event: false, + sibling: false, + projected: true, + } + } TriggerCondition::EchoDue => Axes::NONE, TriggerCondition::MinCoAttackers { filter, minimum: _ } => { let mut acc = Axes::NONE; @@ -4226,6 +4249,7 @@ fn ability_definition_axes(def: &AbilityDefinition, mode: ScanMode) -> Axes { target_selection_mode: _, sub_link: _, iteration_kind_binding: _, + sibling_condition: _, } = def; let mut acc = scan_effect(effect, mode); @@ -4550,7 +4574,7 @@ pub(crate) fn keyword_cost_reads_growing_class(kw: &Keyword) -> bool { | Keyword::Outlast(_) | Keyword::Dash(_) | Keyword::Warp(_) - | Keyword::Devour(_) + | Keyword::Devour { .. } | Keyword::Offspring(_) | Keyword::Splice { .. } | Keyword::Sunburst @@ -4857,7 +4881,7 @@ fn scan_keyword(kw: &Keyword, mode: ScanMode) -> Axes { | Keyword::Bloodthirst(_) | Keyword::Amplify(_) | Keyword::Graft(_) - | Keyword::Devour(_) + | Keyword::Devour { .. } | Keyword::Toxic(_) | Keyword::Saddle(_) | Keyword::Teamwork(_) @@ -5002,7 +5026,15 @@ fn scan_continuous_modification(m: &ContinuousModification, mode: ScanMode) -> A // documented fail-safe (over-veto = missed offer). A full `TriggerDefinition` // walker is a follow-up. ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): the copy-marker stands in for a + // copy that grants the donor's whole ability set — fail-closed alongside + // its `CopyValues` sibling (the real grant is the installed TCE). + | ContinuousModification::CopyChosen | ContinuousModification::GrantTrigger { .. } + // A granted object-hosted replacement's `ReplacementDefinition` execute + // is outside the scanner's traversal closure — fail-closed CONSERVATIVE, + // same class as GrantTrigger / GrantStaticAbility. + | ContinuousModification::GrantReplacement { .. } | ContinuousModification::GrantAllActivatedAbilitiesOf { .. } | ContinuousModification::GrantAllTriggeredAbilitiesOf { .. } | ContinuousModification::AddStaticMode { .. } @@ -5174,6 +5206,10 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext { // recipient set ("Shards you control", CR 707.2). | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): the copy target is chosen from a + // battlefield-reading filter pool; defense-in-depth parity with + // BecomeCopy (fail-closed census — over-vetoes the single-host shortcut). + | Effect::ChoosePermanent { .. } // CR 708.2 / CR 708.2a (face-down permanents): `resolved_battlefield_object_ // ids` (effects/mod.rs) falls through to a battlefield mass scan for a // non-targeted "turn each matching creature face up/down" (Illithid @@ -5286,6 +5322,8 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: same single-target read context as `Transform`. + | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } @@ -5485,7 +5523,7 @@ enum CensusRole { #[cfg(test)] fn effect_census_role(e: &Effect) -> CensusRole { match e { - // -- CENSUS (28): verbatim mirror of `effect_target_ctx`'s LiveBoardCensus + // -- CENSUS (29): verbatim mirror of `effect_target_ctx`'s LiveBoardCensus // arm - mass battlefield population reads that scale with growth. Effect::EachSourceDealsDamage { .. } | Effect::EachDealsDamageEqualToPower { .. } @@ -5527,6 +5565,9 @@ fn effect_census_role(e: &Effect) -> CensusRole { | Effect::PhaseIn { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): parity with the `effect_target_ctx` + // LiveBoardCensus member added above. + | Effect::ChoosePermanent { .. } | Effect::TurnFaceUp { .. } | Effect::TurnFaceDown { .. } | Effect::MultiplyCounter { .. } @@ -5667,6 +5708,9 @@ fn effect_census_role(e: &Effect) -> CensusRole { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: a flip reads only its own self-referential target — not a + // board census, mirroring `Transform`. + | Effect::FlipPermanent { .. } | Effect::TargetOnly { .. } | Effect::Choose { .. } | Effect::ChooseDamageSource { .. } @@ -5920,6 +5964,10 @@ fn effect_resolution_choice_freedom(e: &Effect) -> ResolutionChoiceFreedom { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c: raises `WaitingFor::CopyTargetChoice` — prompts, fail-closed + // MayPrompt (never resolved through the normal chain, but classified here + // to keep the match exhaustive). + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::PutCounter { .. } @@ -5937,6 +5985,9 @@ fn effect_resolution_choice_freedom(e: &Effect) -> ResolutionChoiceFreedom { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: `flip_permanent` offers no resolution-time choice (it is a + // status change or a silent no-op), exactly like `Transform`. + | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } @@ -6187,6 +6238,8 @@ pub(crate) fn effect_is_randomness_bearing(e: &Effect) -> bool { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c: choosing a permanent draws on no game randomness. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::PutCounter { .. } @@ -6203,6 +6256,8 @@ pub(crate) fn effect_is_randomness_bearing(e: &Effect) -> bool { | Effect::Mana { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: flipping is deterministic — no RNG draw, mirroring `Transform`. + | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealFromHand { .. } @@ -6384,6 +6439,7 @@ pub(crate) fn ability_resolution_choice_freedom(a: &ResolvedAbility) -> Resoluti source_incarnation: _, // self-transform epoch latch, no resolution-time choice trigger_source: _, // exact triggered-source authority, no choice trigger_definition_ref: _, // exact trigger occurrence, no choice + force_block_attacker: _, // exact force-block referent, no choice controller: _, // player id original_controller: _, // player id scoped_player: _, // player id (iteration binding) @@ -6406,6 +6462,7 @@ pub(crate) fn ability_resolution_choice_freedom(a: &ResolvedAbility) -> Resoluti chosen_players: _, // concrete chosen player ids (already selected) replacement_applied: _, // replacement provenance set, no prompt sub_link: _, // SubAbilityLink kind tag + sibling_condition: _, // SiblingCondition replication marker, no resolution-time choice parent_target_missing_reason: _, // seam flag } = a; @@ -6874,7 +6931,7 @@ mod tests { } /// guard#3 (mitigation #3): the `LiveBoardCensus` tag set of `effect_target_ctx` - /// == EXACTLY the enumeration-derived MASS-POPULATION set (28). Source-scanned, not + /// == EXACTLY the enumeration-derived MASS-POPULATION set (29). Source-scanned, not /// hand-counted (the hand-count is what produced the earlier "relax=4" miss). Under /// B's SnapshotOrEvent default this is the primary false-certificate gate: only a /// census tag vetoes a mass read that ESCALATES over inert token growth (which @@ -6910,6 +6967,7 @@ mod tests { "ChangeZoneAll", "ChooseAndSacrificeRest", "ChooseObjectsIntoTrackedSet", + "ChoosePermanent", "CounterAll", "DamageAll", "DamageEachPlayer", @@ -6949,7 +7007,7 @@ mod tests { got, want, "census tag set drifted from the enumeration-derived mass-population set" ); - assert_eq!(got.len(), 28, "exactly 28 mass-population census tags"); + assert_eq!(got.len(), 29, "exactly 29 mass-population census tags"); } /// A7' (mitigation #4, replaces the void census-default A7): with SnapshotOrEvent the @@ -7060,7 +7118,7 @@ mod tests { /// with `effect_target_ctx` on the Census/Relax boundary, closing the F1 gap where a /// census-ROLE slot silently in the generic relax `|`-chain (exactly R1's Suspect{All}) /// is invisible to the census-arm-only guards. Structural: both functions' `Census` - /// name-sets are source-scanned and asserted IDENTICAL (== the 28). Behavioral: the + /// name-sets are source-scanned and asserted IDENTICAL (== the 29). Behavioral: the /// two oracles agree on every discriminator, incl. BOTH Suspect/Unsuspect scopes. /// /// REVERT-PROBE (discrimination proof): moving `Suspect{All}` out of the census arm of @@ -7075,7 +7133,7 @@ mod tests { use crate::types::ability::{EffectScope, TapStateChange}; use ScanMode::LoopFirewall; - // -- Structural: the two census name-sets are byte-identical (and == 28). + // -- Structural: the two census name-sets are byte-identical (and == 29). fn census_names(fnsrc: &str, terminator: &str) -> Vec { let end = fnsrc.find(terminator).expect("census terminator"); let block = &fnsrc[..end]; @@ -7104,7 +7162,7 @@ mod tests { etc_census, ecr_census, "effect_census_role Census set diverged from effect_target_ctx" ); - assert_eq!(ecr_census.len(), 28, "exactly 28 census members"); + assert_eq!(ecr_census.len(), 29, "exactly 29 census members"); // -- Behavioral: the two oracles agree on the Census/Relax boundary for every // discriminator. `census(e, true)` requires BOTH `effect_census_role == Census` diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index 84f727bff0..ed4b75a503 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -157,6 +157,13 @@ pub fn build_resolved_from_def_with_targets( // CR 608.2c: Carry the parent-link kind through so the decline classifier can // distinguish a separate-sentence sibling from a within-clause continuation. resolved.sub_link = def.sub_link; + // CR 702.1c ("the same is true") + CR 608.2c (written order): Carry the + // replication marker through so `resolve_chain_body` evaluates a + // `ReplicatedOrBranch` per-item OR-branch (Mutable Pupa, Kathril) + // independently of a preceding sibling's failed gate. Without this copy the + // parser-stamped `SiblingCondition` never reaches the resolved sub and the + // keyword list collapses after the first false gate. + resolved.sibling_condition = def.sibling_condition; // CR 700.2b + CR 603.3c: Carry the reflexive modal choice + per-mode abilities // through so try_begin_reflexive_target_selection can route a gated modal // trigger (Caesar) to AbilityModeChoice instead of resolving the modes @@ -432,17 +439,25 @@ pub fn additional_cost_instead_spell_has_legal_targets( if !has_kicker_cost && !has_queue_cost { return false; } - let Some(sub) = ability_def.sub_ability.as_deref() else { - return false; - }; - if !matches!( - sub.condition, - Some(AbilityCondition::AdditionalCostPaidInstead) - ) { + // Walk past GiftDelivery wrappers to find AdditionalCostPaidInstead. + let mut instead_node = ability_def.sub_ability.as_deref(); + let mut found_instead = false; + while let Some(sub) = instead_node { + if matches!( + sub.condition, + Some(AbilityCondition::AdditionalCostPaidInstead) + ) { + found_instead = true; + break; + } + instead_node = sub.sub_ability.as_deref(); + } + if !found_instead { return false; } let mut resolved = build_resolved_from_def(ability_def, object_id, player); resolved.context.additional_cost_paid = true; + resolved.set_context_recursive(resolved.context.clone()); // CR 601.2c: a queue-synthesized "instead" cost only broadens castability when the // override re-selects a REAL (non-context-ref) target — mirror the cast-time gate // (requires_additional_cost_declaration_before_targets). A context-ref override @@ -839,11 +854,20 @@ pub fn spell_modal_unavailable_modes( pub fn modal_spell_mode_abilities( obj: &crate::game::game_object::GameObject, ) -> Vec { + modal_spell_mode_ability_refs(obj).cloned().collect() +} + +/// Borrowing view of [`modal_spell_mode_abilities`] — the same predicate +/// without the per-call clone, for read-only consumers that only inspect the +/// modes (AI classification, coverage reporting). Both share this one +/// definition of "which abilities on this object are its printed modes" so the +/// owned and borrowed forms can never disagree. +pub fn modal_spell_mode_ability_refs( + obj: &crate::game::game_object::GameObject, +) -> impl Iterator { obj.abilities .iter() .filter(|a| a.kind == AbilityKind::Spell) - .cloned() - .collect() } /// CR 700.2a-b + CR 700.2f: Extends `unavailable_modes` with mode indices @@ -3148,7 +3172,7 @@ fn attach_host_filter_needs_target_slot(filter: &TargetFilter) -> bool { !filter.is_context_ref() && !matches!( filter, - TargetFilter::LastCreated | TargetFilter::LastRevealed + TargetFilter::LastCreated | TargetFilter::LastRevealed | TargetFilter::LastZoneChanged ) } @@ -4834,10 +4858,12 @@ fn concretize_granting_object_in_effect(effect: &mut Effect, granter: ObjectId) | Effect::Pump { target, .. } | Effect::Counter { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::Connive { target, .. } | Effect::PhaseOut { target } | Effect::PhaseIn { target } - | Effect::ForceBlock { target } + | Effect::ForceBlock { target, .. } | Effect::ForceAttack { target, .. } | Effect::CastCopyOfCard { target, .. } | Effect::CopyTokenOf { target, .. } diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 2e794800e6..53ca71f0d4 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -1,12 +1,12 @@ use crate::types::ability::{ is_variable_remove_counter_cost_count, AbilityBlockKind, AbilityBlockReason, AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AbilityTag, AdditionalCost, CardPlayMode, - CastTimingPermission, CastingPermission, ChoiceType, ContinuousModification, CostObjectCount, - CostPaidObjectSnapshot, CounterCostSelection, Duration, Effect, FilterProp, GameRestriction, - ModalSelectionCondition, ObjectScope, PlayerFilter, PlayerScope, ProhibitedActivity, - QuantityExpr, QuantityRef, ResolvedAbility, RestrictionExpiry, RestrictionPlayerScope, - StaticCondition, StaticDefinition, SubAbilityLink, TapCreaturesRequirement, TargetFilter, - TargetRef, + CardSelectionMode, CastTimingPermission, CastingPermission, ChoiceType, ContinuousModification, + CostObjectCount, CostPaidObjectSnapshot, CounterCostSelection, Duration, Effect, FilterProp, + GameRestriction, ModalSelectionCondition, ObjectScope, PlayerFilter, PlayerScope, + ProhibitedActivity, QuantityExpr, QuantityRef, ResolvedAbility, RestrictionExpiry, + RestrictionPlayerScope, StaticCondition, StaticDefinition, SubAbilityLink, + TapCreaturesRequirement, TargetFilter, TargetRef, }; use crate::types::actions::AlternativeCastDecision; use crate::types::card::LayoutKind; @@ -11897,17 +11897,16 @@ fn continue_with_prepared( prepared.payment_mode, events, ); - } else if requires_additional_cost_declaration_before_targets(&resolved) + } else if (requires_additional_cost_declaration_before_targets(&resolved) + || ability_chain_has_gift_delivery(&resolved)) && !casting_costs::build_effective_additional_cost_queue(state, player, prepared.object_id) .is_empty() { - // CR 601.2b + CR 702.194c: generalizes the kicker-only gate above to - // every OTHER target-dependent "instead" additional cost with a - // non-empty effective queue (currently Teamwork/Bargain; Too Evil to - // Stay Dead, Cruel Alliance). Bounded by the queue-emptiness check so - // non-kicker `AdditionalCostPaidInstead` cards with an empty queue - // (no queue-synthesized cost to declare pre-target) fall through to - // the ordinary target-slot path below, unchanged. + // CR 601.2b + CR 702.194c + CR 702.174a/m: generalizes the kicker-only + // gate above to every OTHER target-dependent "instead" additional cost + // with a non-empty effective queue (Teamwork/Bargain/Gift). Gift always + // announces before targets when queued (CR 601.2b), including when the + // only gift-gated effect is delivery rather than an Instead target set. return casting_costs::begin_target_dependent_additional_cost_declaration( state, player, @@ -12228,13 +12227,35 @@ fn modal_requires_additional_cost_declaration(modal: &crate::types::ability::Mod pub(crate) fn requires_additional_cost_declaration_before_targets( ability: &ResolvedAbility, ) -> bool { - let Some(sub_ability) = ability.sub_ability.as_deref() else { - return false; - }; - matches!( - sub_ability.condition, - Some(AbilityCondition::AdditionalCostPaidInstead) - ) && crate::game::triggers::extract_target_filter_from_effect(&sub_ability.effect).is_some() + // Walk the full sub_ability chain (GiftDelivery nesting) for + // AdditionalCostPaidInstead with a real target filter (CR 601.2c / 702.174m). + let mut node = Some(ability); + while let Some(current) = node { + if let Some(sub_ability) = current.sub_ability.as_deref() { + if matches!( + sub_ability.condition, + Some(AbilityCondition::AdditionalCostPaidInstead) + ) && crate::game::triggers::extract_target_filter_from_effect(&sub_ability.effect) + .is_some() + { + return true; + } + } + node = current.sub_ability.as_deref(); + } + false +} + +/// CR 702.174a / CR 601.2b: Gift is always announced before targets when present. +pub(crate) fn ability_chain_has_gift_delivery(ability: &ResolvedAbility) -> bool { + let mut node = Some(ability); + while let Some(current) = node { + if matches!(current.effect, Effect::GiftDelivery { .. }) { + return true; + } + node = current.sub_ability.as_deref(); + } + false } /// Fast path for permanent spells with no spell-level ability. @@ -12512,23 +12533,14 @@ fn legal_target_slots_for_castable_spell_in_flushed_state( .is_some_and(|additional| matches!(additional, AdditionalCost::Kicker { .. })); if has_kicker_cost && requires_additional_cost_declaration_before_targets(&resolved) { return Ok(Vec::new()); - } else if requires_additional_cost_declaration_before_targets(&resolved) + } else if (requires_additional_cost_declaration_before_targets(&resolved) + || ability_chain_has_gift_delivery(&resolved)) && !casting_costs::build_effective_additional_cost_queue(state, player, prepared.object_id) .is_empty() { - // CR 601.2c: parity with the live-cast gate above — the preview must - // defer EXACTLY the cards the live path defers. The queue-emptiness - // guard is load-bearing (NOT merely a Gift exclusion — Gift's - // `AdditionalCostPaidInstead` sits at sub_ability level 2 under - // `GiftDelivery`, so `requires_additional_cost_declaration_before_ - // targets`, which inspects only the first level, already returns - // `false` for Gift and it never reaches this check either way). Its - // real protected class is non-kicker LEVEL-1 `AdditionalCostPaidInstead` - // cards with a PRINTED additional cost (empty effective queue, e.g. - // `obj.additional_cost = Optional`/`Required`/`Choice`): those have - // `requires_ == true` but must NOT defer here (there is no - // queue-synthesized cost to declare pre-target), so a bare `requires_` - // gate would wrongly return `Ok(Vec::new())` for them. + // CR 601.2c + CR 702.174a/m: parity with the live-cast gate — defer when + // Gift or Instead needs pre-target declaration and the effective queue + // is non-empty. return Ok(Vec::new()); } @@ -15240,21 +15252,70 @@ pub(super) fn find_battlefield_exile_cost(cost: &AbilityCost) -> Option<(u32, &T } } +/// Sole detector for a non-self "discard from hand" cost leg. Returns the count +/// expression, optional card filter, and the `CardSelectionMode` (player-chosen +/// vs. game-selected) so a single authority drives both the casting/activation +/// resolver and the mana-ability path. `SourceCard` "discard this card" is never +/// matched (see [`resolve_non_self_discard_requirement`]); recurses `Composite`. pub(crate) fn find_non_self_discard( cost: &AbilityCost, -) -> Option<(&QuantityExpr, Option<&TargetFilter>)> { +) -> Option<(&QuantityExpr, Option<&TargetFilter>, CardSelectionMode)> { match cost { AbilityCost::Discard { count, filter, self_scope: crate::types::ability::DiscardSelfScope::FromHand, - .. - } => Some((count, filter.as_ref())), + selection, + } => Some((count, filter.as_ref(), *selection)), AbilityCost::Composite { costs } => costs.iter().find_map(find_non_self_discard), _ => None, } } +/// CR 601.2h + CR 701.9a: Resolve a non-self "discard" cost leg into its interactive requirement. +/// +/// - `Ok(None)` => there is no `FromHand` discard leg, OR the resolved count is 0. A zero-card +/// discard — e.g. Lion's Eye Diamond's "Discard your hand" with an empty hand (its +/// `HandSize` count resolves to 0) — is paid by doing nothing: nothing moves and no +/// `Discarded`/`ZoneChanged` event fires (CR 701.9a moves cards hand→graveyard only when +/// there are cards). Per CR 601.2h an unpayable cost can't be paid, but a zero-cost is not +/// unpayable — it is trivially paid. The caller treats the leg as satisfied and proceeds to +/// the next unpaid leg. Structural precedent: `mana_abilities::exile_cost_choice` (the +/// interactive non-self cost sibling). +/// - `Err(..)` => there are fewer eligible cards than the required (nonzero) count, so +/// CR 601.2h makes the cost unpayable. +/// - `Ok(Some((count, eligible)))` => an interactive selection of `count` cards from `eligible`. +/// +/// Detection is `FromHand`-only (via [`find_non_self_discard`]); a `SourceCard` "discard this +/// card" cost is never matched here and its `count` resolves to 1, so it can never misfire +/// through the zero-count auto-pay path. +pub(crate) fn resolve_non_self_discard_requirement( + state: &GameState, + player: PlayerId, + source_id: ObjectId, + cost: &AbilityCost, +) -> Result)>, EngineError> { + // The activation/casting path handles ANY `FromHand` discard selection mode; the + // mana-ability path (see `mana_abilities::discard_cost_choice`) is the only caller + // that gates on `Chosen`. Keep this resolver selection-agnostic. + let Some((count, filter, _selection)) = find_non_self_discard(cost) else { + return Ok(None); + }; + let count = super::quantity::resolve_quantity(state, count, player, source_id).max(0) as usize; + // CR 601.2h + CR 701.9a: A resolved zero-card discard is paid by doing nothing — never + // surface a dead selection prompt for it. + if count == 0 { + return Ok(None); + } + let eligible = find_eligible_discard_targets(state, player, source_id, filter); + if eligible.len() < count { + return Err(EngineError::ActionNotAllowed( + "Not enough cards in hand to discard".into(), + )); + } + Ok(Some((count, eligible))) +} + fn has_self_ref_discard_cost(cost: &AbilityCost) -> bool { match cost { AbilityCost::Discard { @@ -16971,15 +17032,13 @@ pub fn handle_activate_ability( }); } - if let Some((count, filter)) = find_non_self_discard(cost) { - let count = - super::quantity::resolve_quantity(state, count, player, source_id).max(0) as usize; - let eligible = find_eligible_discard_targets(state, player, source_id, filter); - if eligible.len() < count { - return Err(EngineError::ActionNotAllowed( - "Not enough cards in hand to discard".into(), - )); - } + // CR 601.2h + CR 701.9a: A resolved zero-card FromHand discard leg (e.g. Bomat + // Courier's "Discard your hand" on an empty hand) is paid by doing nothing — the + // helper returns `Ok(None)` so we FALL THROUGH to the following cost detection + // rather than surfacing a dead `PayCost { count: 0 }`. + if let Some((count, eligible)) = + resolve_non_self_discard_requirement(state, player, source_id, cost)? + { let mut pending_discard = PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); pending_discard.activation_cost = Some(cost.clone()); diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 90b17f3c67..2372a5fb74 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -7,7 +7,7 @@ use crate::types::ability::{ BeholdCostAction, CastTimingPermission, Comparator, CostPaidObjectSnapshot, CounterCostSelection, Effect, KickerVariant, ObjectProperty, QuantityExpr, QuantityRef, ReplacementDefinition, ResolvedAbility, SacrificeCost, SacrificeRequirement, - SpellCastingOptionKind, SpellStackToGraveyardReplacement, StaticCondition, + SpellCastingOptionKind, SpellContext, SpellStackToGraveyardReplacement, StaticCondition, TapCreaturesAggregate, TargetFilter, ThisWayCause, TypeFilter, TypedFilter, EXILE_COST_X, }; use crate::types::card_type::CoreType; @@ -21,7 +21,7 @@ use crate::types::game_state::{ SpellCostSource, StackEntry, StackEntryKind, StackPaidSnapshot, WaitingFor, }; use crate::types::identifiers::{CardId, ObjectId}; -use crate::types::keywords::Keyword; +use crate::types::keywords::{GiftKind, Keyword}; use crate::types::mana::{ManaCost, ManaCostShard, ManaType, PaymentContext}; use crate::types::player::PlayerId; use crate::types::replacements::ReplacementEvent; @@ -318,6 +318,62 @@ fn continue_after_declared_mana_split( /// /// For `Optional`: `pay=true` pays the cost and sets `additional_cost_paid`, `pay=false` skips. /// For `Choice`: `pay=true` pays the first cost, `pay=false` pays the second cost. +/// Build an OptionalCostChoice WaitingFor with Gift identity when the queue head is Gift. +fn make_optional_cost_choice( + state: &GameState, + player: PlayerId, + cost: AdditionalCost, + times_kicked: u32, + pending: PendingCast, +) -> WaitingFor { + let origin = pending + .additional_cost_queue + .first() + .map(|instance| instance.origin) + .unwrap_or(AdditionalCostOrigin::Other); + let gift_kind = if origin == AdditionalCostOrigin::Gift { + gift_kind_for_object(state, pending.object_id) + } else { + None + }; + WaitingFor::OptionalCostChoice { + player, + cost, + times_kicked, + origin, + gift_kind, + pending_cast: Box::new(pending), + } +} + +fn gift_kind_for_object(state: &GameState, object_id: ObjectId) -> Option { + super::keywords::effective_gift_kind(state, object_id) +} + +/// CR 702.174a + CR 601.2c: Propagate shared SpellContext facts (additional_cost_paid / +/// gift_recipient / kickers / …) through GiftDelivery nesting so Instead target +/// slots see the paid flag on the parent of the Instead node. +/// +/// Per-link `announcing_opponent` (CR 115.1) must be preserved: Volcanic Offering +/// stamps a different announcer on each opponent-choice effect group, and a full +/// `set_context_recursive` from the root would wipe those stamps. +fn stamp_pending_ability_context_recursive(pending: &mut PendingCast) { + let shared = pending.ability.context.clone(); + propagate_shared_cast_context(&mut pending.ability, &shared); +} + +fn propagate_shared_cast_context(ability: &mut ResolvedAbility, shared: &SpellContext) { + let announcing_opponent = ability.context.announcing_opponent; + ability.context = shared.clone(); + ability.context.announcing_opponent = announcing_opponent; + if let Some(sub) = ability.sub_ability.as_mut() { + propagate_shared_cast_context(sub, shared); + } + if let Some(else_branch) = ability.else_ability.as_mut() { + propagate_shared_cast_context(else_branch, shared); + } +} + pub(crate) fn handle_decide_additional_cost( state: &mut GameState, player: PlayerId, @@ -544,6 +600,23 @@ pub(crate) fn handle_decide_additional_cost( recompute_pending_cast_cost_after_additional_cost(state, player, &mut updated_pending); } + // CR 702.174a: After promising Gift, latch the chosen opponent (or prompt when + // ≥2 opponents) before continuing to targets / mana payment. + if optional_cost_paid + && current_instance + .as_ref() + .is_some_and(|instance| instance.origin == AdditionalCostOrigin::Gift) + { + return continue_after_gift_promised( + state, + player, + updated_pending, + cost_to_pay, + cost_source, + events, + ); + } + if let Some(cost) = cost_to_pay { pay_additional_cost_with_source(state, player, cost, cost_source, updated_pending, events) } else { @@ -551,6 +624,65 @@ pub(crate) fn handle_decide_additional_cost( } } +/// CR 702.174a: After the Gift optional cost is accepted, assign or request the +/// recipient opponent, then resume the cast. +fn continue_after_gift_promised( + state: &mut GameState, + player: PlayerId, + mut pending: PendingCast, + cost_to_pay: Option, + cost_source: SpellCostSource, + events: &mut Vec, +) -> Result { + let opponents = crate::game::players::opponents(state, player); + if opponents.is_empty() { + return Err(EngineError::InvalidAction( + "Cannot promise a gift with no opponents".to_string(), + )); + } + // Gift's additional cost is `ManaCost::zero()` — a synthesis sentinel so the + // OptionalCostChoice prompt can exist. It is not a real payment: both the + // sole-opponent auto-latch and the ≥2 ChooseGiftRecipient path must drop it + // the same way and resume via `finish_pending_cost_or_cast` (after latching). + // `cost_source` is threaded from the caller for call-site parity with other + // additional costs; unused here because nothing is paid. + let _ = (cost_to_pay, cost_source); + + let gift_kind = gift_kind_for_object(state, pending.object_id); + if opponents.len() == 1 { + pending.ability.context.gift_recipient = Some(opponents[0]); + stamp_pending_ability_context_recursive(&mut pending); + finish_pending_cost_or_cast(state, player, pending, events) + } else { + Ok(WaitingFor::ChooseGiftRecipient { + player, + candidates: opponents, + gift_kind, + pending_cast: Box::new(pending), + }) + } +} + +/// CR 702.174a: Apply the chosen Gift recipient and resume deferred casting. +pub(crate) fn handle_choose_gift_recipient( + state: &mut GameState, + player: PlayerId, + pending: PendingCast, + opponent: PlayerId, + candidates: &[PlayerId], + events: &mut Vec, +) -> Result { + if !candidates.contains(&opponent) { + return Err(EngineError::InvalidAction( + "Gift recipient must be one of the offered opponents".to_string(), + )); + } + let mut pending = pending; + pending.ability.context.gift_recipient = Some(opponent); + stamp_pending_ability_context_recursive(&mut pending); + finish_pending_cost_or_cast(state, player, pending, events) +} + pub(crate) fn payable_spell_alternative_cost( state: &GameState, player: PlayerId, @@ -928,15 +1060,16 @@ fn finish_pending_cost_or_cast( pending.additional_cost_queue.remove(0); return finish_pending_cost_or_cast(state, player, pending, events); } - return Ok(WaitingFor::OptionalCostChoice { + return Ok(make_optional_cost_choice( + state, player, - cost: AdditionalCost::Optional { + AdditionalCost::Optional { cost, repeatability: crate::types::ability::AdditionalCostRepeatability::Once, }, - times_kicked: 0, - pending_cast: Box::new(pending), - }); + 0, + pending, + )); } AdditionalCost::Optional { cost, @@ -946,16 +1079,17 @@ fn finish_pending_cost_or_cast( .ability .context .instance_payment_count_for_ordinal(instance.origin, instance.origin_ordinal); - return Ok(WaitingFor::OptionalCostChoice { + return Ok(make_optional_cost_choice( + state, player, - cost: AdditionalCost::Optional { + AdditionalCost::Optional { cost, repeatability: crate::types::ability::AdditionalCostRepeatability::Repeatable, }, times_kicked, - pending_cast: Box::new(pending), - }); + pending, + )); } AdditionalCost::Kicker { .. } | AdditionalCost::Choice(_, _) => { pending.additional_cost_queue.remove(0); @@ -991,15 +1125,16 @@ fn finish_pending_cost_or_cast( ) { if let Some(current_cost) = next_repeatable_additional_cost(state, player, &pending) { let times_kicked = pending.ability.context.additional_cost_payment_count; - return Ok(WaitingFor::OptionalCostChoice { + return Ok(make_optional_cost_choice( + state, player, - cost: AdditionalCost::Optional { + AdditionalCost::Optional { cost: current_cost, repeatability: crate::types::ability::AdditionalCostRepeatability::Repeatable, }, times_kicked, - pending_cast: Box::new(pending), - }); + pending, + )); } pending.additional_cost_flow = None; } @@ -1016,15 +1151,16 @@ fn finish_pending_cost_or_cast( // Optional) so the frontend can render a kicker-aware modal and // know whether the kicker is repeatable. let times_kicked = pending.ability.context.kickers_paid.len() as u32; - return Ok(WaitingFor::OptionalCostChoice { + return Ok(make_optional_cost_choice( + state, player, - cost: AdditionalCost::Kicker { + AdditionalCost::Kicker { costs: vec![current_cost], repeatability, }, times_kicked, - pending_cast: Box::new(pending), - }); + pending, + )); } return begin_deferred_target_selection(state, player, pending, events); } @@ -1039,15 +1175,16 @@ fn finish_pending_cost_or_cast( // CR 702.33c/d: present the live Kicker cost (not a laundered Optional) // so the frontend renders the kicker re-prompt with the running kick count. let times_kicked = pending.ability.context.kickers_paid.len() as u32; - return Ok(WaitingFor::OptionalCostChoice { + return Ok(make_optional_cost_choice( + state, player, - cost: AdditionalCost::Kicker { + AdditionalCost::Kicker { costs: vec![current_cost], repeatability, }, times_kicked, - pending_cast: Box::new(pending), - }); + pending, + )); } if pending.deferred_modal_choice.is_none() { pending.additional_cost_flow = None; @@ -1088,12 +1225,13 @@ fn finish_pending_cost_or_cast( cost: optional_cost.clone(), repeatability: crate::types::ability::AdditionalCostRepeatability::Once, }; - return Ok(WaitingFor::OptionalCostChoice { + return Ok(make_optional_cost_choice( + state, player, - cost: optional_cost, - times_kicked: 0, - pending_cast: Box::new(pending), - }); + optional_cost, + 0, + pending, + )); } } @@ -1344,6 +1482,10 @@ pub(crate) fn begin_deferred_target_selection( mut pending: PendingCast, events: &mut Vec, ) -> Result { + // Defense in depth: nest-propagate SpellContext before building slots so + // GiftDelivery → AdditionalCostPaidInstead sees additional_cost_paid. + stamp_pending_ability_context_recursive(&mut pending); + // CR 601.2c + CR 115.1: If an "of an opponent's choice" slot group still needs // its announcing opponent chosen (and the controller has ≥2 opponents to pick // among), raise that decision before declaring targets. This loops once per @@ -2384,20 +2526,28 @@ fn settle_sacrifice_for_cost_events( if !deferred_cost_events.is_empty() { crate::game::triggers::collect_triggers_into_deferred(state, &deferred_cost_events); } - state.consumed_before_priority_trigger_events.extend( - events[current_start..current_end] - .iter() - .enumerate() - .map(|(offset, event)| { - let index = current_start + offset; - crate::game::triggers::ConsumedTriggerEventOccurrence { - event: event.clone(), - occurrence: events[..index] - .iter() - .filter(|prior| *prior == event) - .count(), - } - }), + let occurrences = events[current_start..current_end] + .iter() + .enumerate() + .map(|(offset, event)| { + let index = current_start + offset; + crate::game::triggers::ConsumedTriggerEventOccurrence { + event: event.clone(), + occurrence: events[..index] + .iter() + .filter(|prior| *prior == event) + .count(), + } + }) + .collect(); + crate::game::triggers::resolve_and_apply_trigger_collection( + state, + crate::types::resolved_commands::ResolvedTriggerCollection::ConsumeBeforePriority { + occurrences, + }, + ) + .expect( + "sacrifice-cost settlement consumed-before-priority trigger journal cause must be live", ); } @@ -4045,16 +4195,13 @@ pub(crate) fn surface_next_unpaid_interactive_activation_cost( }; let source_id = pending.object_id; - if let Some((count, filter)) = super::casting::find_non_self_discard(cost) { - let count = - super::quantity::resolve_quantity(state, count, player, source_id).max(0) as usize; - let eligible = - super::casting::find_eligible_discard_targets(state, player, source_id, filter); - if eligible.len() < count { - return Err(EngineError::ActionNotAllowed( - "Not enough cards in hand to discard".into(), - )); - } + // CR 601.2h + CR 701.9a: A resolved zero-card FromHand discard leg (Lion's Eye Diamond / + // Bomat Courier's "Discard your hand" on an empty hand) is paid by doing nothing — the + // helper returns `Ok(None)` so we FALL THROUGH to the next unpaid leg (the sacrifice arm + // below) rather than surfacing a dead `PayCost { count: 0 }`. + if let Some((count, eligible)) = + super::casting::resolve_non_self_discard_requirement(state, player, source_id, cost)? + { return Ok(Some(WaitingFor::PayCost { player, kind: PayCostKind::Discard, @@ -5238,15 +5385,13 @@ pub(super) fn check_additional_cost_or_pay_with_distribute( } } } - return Ok(WaitingFor::OptionalCostChoice { + return Ok(make_optional_cost_choice( + state, player, - cost: AdditionalCost::Choice( - alt_cost.cost, - AbilityCost::Mana { cost: cost.clone() }, - ), - times_kicked: 0, - pending_cast: Box::new(pending), - }); + AdditionalCost::Choice(alt_cost.cost, AbilityCost::Mana { cost: cost.clone() }), + 0, + pending, + )); } } @@ -5376,12 +5521,13 @@ pub(super) fn check_additional_cost_or_pay_with_distribute( )? { return finish_pending_cost_or_cast(state, player, pending, events); } - return Ok(WaitingFor::OptionalCostChoice { + return Ok(make_optional_cost_choice( + state, player, - cost: additional_cost, - times_kicked: 0, - pending_cast: Box::new(pending), - }); + additional_cost, + 0, + pending, + )); } AdditionalCost::Choice(preferred, fallback) => { let mut pending = PendingCast::new(object_id, card_id, ability, cost.clone()); @@ -5415,12 +5561,13 @@ pub(super) fn check_additional_cost_or_pay_with_distribute( } return pay_additional_cost(state, player, fallback.clone(), pending, events); } - return Ok(WaitingFor::OptionalCostChoice { + return Ok(make_optional_cost_choice( + state, player, - cost: additional_cost, - times_kicked: 0, - pending_cast: Box::new(pending), - }); + additional_cost, + 0, + pending, + )); } } } @@ -6993,6 +7140,9 @@ pub(super) fn build_effective_additional_cost_queue( additional_cost_queue.extend(effective_teamwork_additional_cost_instances( state, player, object_id, )); + additional_cost_queue.extend(effective_gift_additional_cost_instances( + state, player, object_id, + )); additional_cost_queue } @@ -7124,6 +7274,30 @@ pub(super) fn effective_teamwork_additional_cost_instances( .collect() } +/// CR 702.174a: Return each effective Gift keyword as its own optional +/// zero-cost promise. The dedicated queue record stamps `AdditionalCostOrigin::Gift` +/// so Gift composes with Bargain/Teamwork and UI can present Gift-specific copy. +/// The produced cost matches `synthesize_gift` / `gift_additional_cost` so +/// `obj_additional_matches_instance` suppresses the legacy `face.additional_cost` copy. +pub(super) fn effective_gift_additional_cost_instances( + state: &GameState, + player: PlayerId, + object_id: ObjectId, +) -> Vec { + super::casting::effective_spell_keyword_instances(state, player, object_id) + .into_iter() + .filter(|keyword| matches!(keyword, Keyword::Gift(_))) + .enumerate() + .map(|(ordinal, _)| { + AdditionalCostInstance::new_with_ordinal( + AdditionalCostOrigin::Gift, + u32::try_from(ordinal).unwrap_or(u32::MAX), + crate::database::synthesis::gift_additional_cost(), + ) + }) + .collect() +} + /// CR 702.166a: Return each effective Bargain keyword as its own optional /// sacrifice cost. The dedicated queue record distinguishes a bargained spell /// from one that merely paid an unrelated additional cost. @@ -8189,6 +8363,7 @@ fn finalize_cast_with_phyrexian_choices_inner( // replacements on X-cost cards like Astral Cornucopia, Walking Ballista, etc. let cost_x_paid = ability.chosen_x; let kickers_paid = ability.context.kickers_paid.clone(); + let gift_recipient = ability.context.gift_recipient; let chosen_modes = ability.context.chosen_modes.clone(); let additional_cost_paid = ability.context.additional_cost_paid; let additional_cost_payment_count = ability.context.additional_cost_payment_count; @@ -8313,6 +8488,14 @@ fn finalize_cast_with_phyrexian_choices_inner( obj.kickers_paid.clone_from(&kickers_paid); } } + // CR 702.174a: Stamp Gift recipient onto the spell-on-stack object (kickers_paid + // pattern) so delivery / future permanent ETB consumers can read it after the + // spell leaves the stack. + if gift_recipient.is_some() { + if let Some(obj) = state.objects.get_mut(&object_id) { + obj.gift_recipient = gift_recipient; + } + } // CR 700.2a + CR 700.2d + CR 601.2b: Stamp chosen modal-mode indices onto the // spell-on-stack object so cast-triggers (Riku: "the number of times you chose // a mode for that spell") read the mode count. Cast-triggers resolve before the diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index f1dda13f6f..adafecc57d 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -3467,6 +3467,7 @@ fn granted_freerunning_static_surfaces_freerunning_variant() { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; obj.static_definitions = vec![def].into(); } @@ -11960,6 +11961,7 @@ fn x_cost_max_accounts_for_granted_affinity_exceeding_fixed_generic() { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }] .into(); } @@ -14737,6 +14739,7 @@ fn witherbloom_grants_affinity_to_instant_and_sorcery_spells() { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; obj.static_definitions = vec![def].into(); } @@ -14854,6 +14857,7 @@ fn add_witherbloom_affinity_source(state: &mut GameState, player: PlayerId) -> O source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }] .into(); } @@ -49172,3 +49176,260 @@ fn path_of_ancestry_fires_for_a_commander_an_opponent_controls() { "a stolen commander is still YOUR commander (CR 903.3) — the scry must still fire" ); } + +// --------------------------------------------------------------------------- +// Issue #6494 — resolve_non_self_discard_requirement class boundary +// +// The shared helper backing every non-self FromHand discard cost site +// (activation begin_cost_payment, surface_next_unpaid_interactive_activation_cost, +// and the spell additional-cost arm). A resolved-count-0 FromHand discard is +// paid by doing nothing (CR 601.2h + CR 701.9a) → Ok(None); a nonzero count with +// too few eligible cards is unpayable → Err; SourceCard "discard this card" is +// FromHand-only-excluded → Ok(None) (its own count-1 path is untouched). +// --------------------------------------------------------------------------- + +fn from_hand_discard_cost(count: QuantityExpr) -> AbilityCost { + AbilityCost::Discard { + count, + filter: None, + selection: crate::types::ability::CardSelectionMode::Chosen, + self_scope: crate::types::ability::DiscardSelfScope::FromHand, + } +} + +/// Issue #6494 (negative, class boundary): a fixed `Discard { count: 1 }` on an +/// EMPTY hand is UNPAYABLE (CR 601.2h) — the helper returns `Err`, NOT `Ok(None)`. +/// The zero-count auto-pay class is `resolved == 0` ONLY; `count >= 1` never +/// auto-pays. Reach-guarded: the same cost with a card in hand returns +/// `Ok(Some((1, [card])))`, proving the helper reaches its count/eligibility +/// check rather than short-circuiting. `AbilityCost::is_payable` agrees (false +/// on empty hand, true with the card). +#[test] +fn resolve_discard_requirement_fixed_one_empty_hand_is_unpayable_err() { + let mut state = setup_game_at_main_phase(); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + let cost = from_hand_discard_cost(QuantityExpr::Fixed { value: 1 }); + + // Empty hand: unpayable, so the helper errors rather than auto-paying. + assert!(state.players[0].hand.is_empty()); + assert!(matches!( + resolve_non_self_discard_requirement(&state, PlayerId(0), source, &cost), + Err(EngineError::ActionNotAllowed(_)) + )); + // CR 601.2h: the payability gate excludes it too. + assert!(!cost.is_payable(&state, PlayerId(0), source)); + + // Positive reach-guard: with one eligible card the helper resolves the real + // interactive requirement (not a vacuous early return). + let card = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Card".to_string(), + Zone::Hand, + ); + match resolve_non_self_discard_requirement(&state, PlayerId(0), source, &cost) { + Ok(Some((count, eligible))) => { + assert_eq!(count, 1); + assert_eq!(eligible, vec![card]); + } + other => panic!("expected Ok(Some((1, [card]))), got {other:?}"), + } + assert!(cost.is_payable(&state, PlayerId(0), source)); +} + +/// Issue #6494 (no over-fire, casting path): a `Discard { Fixed(2) }` with THREE +/// eligible cards resolves to `Ok(Some((2, )))` — the count stays +/// 2 (never widened or auto-paid) and every eligible card is offered as a choice. +#[test] +fn resolve_discard_requirement_fixed_two_with_three_eligible_offers_all() { + let mut state = setup_game_at_main_phase(); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + let c1 = create_object( + &mut state, + CardId(2), + PlayerId(0), + "A".to_string(), + Zone::Hand, + ); + let c2 = create_object( + &mut state, + CardId(3), + PlayerId(0), + "B".to_string(), + Zone::Hand, + ); + let c3 = create_object( + &mut state, + CardId(4), + PlayerId(0), + "C".to_string(), + Zone::Hand, + ); + + let cost = from_hand_discard_cost(QuantityExpr::Fixed { value: 2 }); + match resolve_non_self_discard_requirement(&state, PlayerId(0), source, &cost) { + Ok(Some((count, eligible))) => { + assert_eq!(count, 2); + assert_eq!(eligible.len(), 3); + for card in [c1, c2, c3] { + assert!(eligible.contains(&card)); + } + } + other => panic!("expected Ok(Some((2, 3 choices))), got {other:?}"), + } +} + +/// Issue #6494 (SourceCard/self-discard untouched): a `Discard { self_scope: +/// SourceCard }` ("discard this card") is FromHand-only-excluded — both the +/// detector `find_non_self_discard` and the helper return `None`, so the +/// SourceCard discard path never routes through the zero-count auto-pay. Reach- +/// guarded: an otherwise-identical FromHand discard IS detected, proving the +/// `None` is scope-driven, not a failure to match the `Discard` shape at all. +#[test] +fn resolve_discard_requirement_source_card_scope_is_not_auto_paid() { + let mut state = setup_game_at_main_phase(); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Channel".to_string(), + Zone::Hand, + ); + + let source_card_cost = AbilityCost::Discard { + count: QuantityExpr::Fixed { value: 1 }, + filter: None, + selection: crate::types::ability::CardSelectionMode::Chosen, + self_scope: crate::types::ability::DiscardSelfScope::SourceCard, + }; + // FromHand-only detection: SourceCard is invisible to both the detector and + // the helper, so it can never reach the zero-count auto-pay branch. + assert!(find_non_self_discard(&source_card_cost).is_none()); + assert!(matches!( + resolve_non_self_discard_requirement(&state, PlayerId(0), source, &source_card_cost), + Ok(None) + )); + + // Positive reach-guard: the same shape as FromHand IS detected (so the None + // above is the SourceCard scope, not a shape mismatch). + let from_hand_cost = from_hand_discard_cost(QuantityExpr::Fixed { value: 1 }); + assert!(find_non_self_discard(&from_hand_cost).is_some()); +} + +/// Issue #6494 (site 3 — activation `begin_cost_payment` discard emitter, casting +/// path class): Bomat Courier's activated ability +/// "{R}, Discard your hand, Sacrifice this creature: Put all cards exiled with +/// this creature into their owners' hands." must be activatable with an EMPTY +/// hand — the "Discard your hand" leg is a zero-card discard paid by doing +/// nothing (CR 601.2h + CR 701.9a). +/// +/// Built from Bomat Courier's VERBATIM parsed cost +/// (`Composite[Mana {R}, Discard { HandSize, FromHand }, Sacrifice(SelfRef, 1)]`), +/// driven through the production `handle_activate_ability` entry. A stand-in +/// `Draw 1` effect isolates the cost-payment path (the exiled-card return is the +/// card's effect, not the cost under repair). Revert-sensitive: at base the +/// discard leg surfaces `PayCost { Discard, count: 0 }`, so the activation never +/// reaches the stack, the self-sacrifice never fires, and nothing is drawn. +#[test] +fn bomat_courier_activates_empty_handed_casting_path() { + let mut state = setup_game_at_main_phase(); + add_mana(&mut state, PlayerId(0), ManaType::Red, 1); + + let bomat = create_object( + &mut state, + CardId(6494), + PlayerId(0), + "Bomat Courier".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&bomat).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + obj.card_types.core_types.push(CoreType::Creature); + } + // A card in library so the stand-in Draw is observable. + let lib_card = create_object( + &mut state, + CardId(6495), + PlayerId(0), + "Library Card".to_string(), + Zone::Library, + ); + + // Bomat Courier's EXACT parsed activated-ability cost. + let bomat_cost = AbilityCost::Composite { + costs: vec![ + AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ManaCostShard::Red], + generic: 0, + }, + }, + AbilityCost::Discard { + count: QuantityExpr::Ref { + qty: QuantityRef::HandSize { + player: crate::types::ability::PlayerScope::Controller, + }, + }, + filter: None, + selection: crate::types::ability::CardSelectionMode::Chosen, + self_scope: crate::types::ability::DiscardSelfScope::FromHand, + }, + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ], + }; + Arc::make_mut(&mut state.objects.get_mut(&bomat).unwrap().abilities).push( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ) + .cost(bomat_cost), + ); + + assert!(state.players[0].hand.is_empty()); + + let mut events = Vec::new(); + let waiting = handle_activate_ability(&mut state, PlayerId(0), bomat, 0, &mut events) + .expect("empty-hand Bomat Courier activation must not error"); + state.waiting_for = waiting; + + // Drive cost payment + resolution. The driver must NEVER see a discard prompt. + for _ in 0..10 { + if state.stack.is_empty() && matches!(state.waiting_for, WaitingFor::Priority { .. }) { + break; + } + assert!( + !matches!( + state.waiting_for, + WaitingFor::PayCost { + kind: PayCostKind::Discard, + .. + } + ), + "a dead PayCost {{ Discard }} was surfaced on the casting path" + ); + apply_as_current(&mut state, GameAction::PassPriority).unwrap(); + } + + // The self-sacrifice cost leg fired (Bomat left the battlefield to graveyard). + assert_eq!(state.objects[&bomat].zone, Zone::Graveyard); + // Positive reach-guard: the ability resolved (the stand-in Draw drew the card). + assert!(state.stack.is_empty(), "the ability must fully resolve"); + assert_eq!(state.objects[&lib_card].zone, Zone::Hand); +} diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index 2efe0c85b7..2bf0237174 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -10,7 +10,7 @@ use crate::types::ability::{StaticDefinition, TargetFilter, TargetRef}; use crate::types::card_type::{CoreType, Supertype}; use crate::types::events::GameEvent; use crate::types::game_state::GameState; -use crate::types::identifiers::ObjectId; +use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; use crate::types::keywords::Keyword; use crate::types::mana::ManaColor; use crate::types::player::PlayerId; @@ -132,6 +132,12 @@ pub enum CombatRequirement { MustBlock { #[serde(default, skip_serializing_if = "Vec::is_empty")] sources: Vec, + /// Exact attackers named by "block that Wolf/this creature if able" + /// requirements. Empty retains the legacy generic-only wire shape. + /// Display-only: the CR 509.1c maximum is enforced by + /// BlockDeclarationConstraints, never reconstructed by a client. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + attackers: Vec, }, /// CR 508.1c: this creature can't attack (informational — the UI greys it). /// `sources` = the restriction carriers (Pacifism, Angelic Arbiter). @@ -215,6 +221,11 @@ pub struct CombatState { /// `attacked_defenders_this_combat`. #[serde(default)] pub creature_attacked_defenders_this_combat: HashMap>, + /// CR 400.7 + CR 508.1: exact current-combat attack ledger for source + /// intervening-if conditions. The raw-id defender map remains a display + /// history; this identity ledger must not match a re-entered object. + #[serde(default)] + pub attacking_incarnations_this_combat: HashSet, pub damage_assignments: HashMap>, pub first_strike_done: bool, /// CR 510.4: Combatants that had first strike or double strike as the first @@ -240,6 +251,7 @@ impl PartialEq for CombatState { && self.attacked_defenders_this_combat == other.attacked_defenders_this_combat && self.creature_attacked_defenders_this_combat == other.creature_attacked_defenders_this_combat + && self.attacking_incarnations_this_combat == other.attacking_incarnations_this_combat && self.first_strike_done == other.first_strike_done && self.first_strike_participants == other.first_strike_participants } @@ -1312,11 +1324,454 @@ fn creature_has_must_block_requirement( }) } +/// One independently scored CR 509.1c block requirement. Keeping these as a +/// multiset is essential: one declared pair can satisfy several requirements, +/// while incompatible requirements must be maximized together rather than +/// greedily enforced one at a time. +#[derive(Clone)] +enum BlockDeclarationRequirement { + Generic { + blocker: ObjectId, + }, + Exact { + blocker: ObjectId, + attacker: ObjectId, + }, + Attacker { + attacker: ObjectId, + by: Option, + source: ObjectId, + anchor: Option, + }, + Every { + blocker: ObjectId, + attacker: ObjectId, + }, +} + +/// The single live model for CR 509.1 blocker declarations. This mirrors +/// AttackDeclarationConstraints: hard legality is owned by +/// validate_blockers_core, and this model owns only the requirement multiset and +/// the exact candidate-pair universe used to find the tax-free maximum. +struct BlockDeclarationConstraints { + player: PlayerId, + /// Legal per-blocker declarations, including the empty choice. Enumerating + /// this product respects each blocker's capacity before search rather than + /// exploring every raw pair subset and rejecting almost all of them at a + /// leaf. + choices: Vec>>, + requirements: Vec, + /// Memoized feasibility frontier: for every remaining blocker-choice index + /// and requirement, whether any later local choice can satisfy it. This + /// avoids rescanning the Cartesian product at every search node. + future_requirement_satisfaction: Vec>, +} + +impl BlockDeclarationConstraints { + fn build(state: &GameState, player: PlayerId) -> Self { + let valid = get_valid_block_targets_for_player(state, player); + let mut pairs: Vec<(ObjectId, ObjectId)> = valid + .iter() + .flat_map(|(&blocker, attackers)| { + attackers.iter().map(move |&attacker| (blocker, attacker)) + }) + .collect(); + pairs.sort_unstable(); + pairs.dedup(); + + let mut requirements = Vec::new(); + let blocker_restriction = collect_blocker_restriction_statics(state); + let block_restriction = collect_block_restriction_statics(state); + let blocker_allowed = collect_blocker_allowed_statics(state); + let shadow = static_kind_present(state, StaticModeKind::CanBlockShadow); + let has_must_block = static_kind_present(state, StaticModeKind::MustBlock); + + for &blocker in valid.keys() { + if creature_has_must_block_requirement( + state, + blocker, + player, + has_must_block, + &blocker_restriction, + &block_restriction, + &blocker_allowed, + shadow, + ) { + requirements.push(BlockDeclarationRequirement::Generic { blocker }); + } + let Some(object) = state.objects.get(&blocker) else { + continue; + }; + for def in super::functioning_abilities::active_static_definitions(state, object) { + let StaticMode::MustBlockAttacker { attacker } = def.mode else { + continue; + }; + let live = state.combat.as_ref().is_some_and(|combat| { + combat.attackers.iter().any(|info| { + info.object_id == attacker.object_id + && info.defending_player == player + && state + .objects + .get(&attacker.object_id) + .is_some_and(|o| ObjectIncarnationRef::from_object(o) == attacker) + }) + }); + if live && pairs.contains(&(blocker, attacker.object_id)) { + requirements.push(BlockDeclarationRequirement::Exact { + blocker, + attacker: attacker.object_id, + }); + } + } + } + + let must_be_blocked = collect_must_be_blocked_statics(state); + if let Some(combat) = &state.combat { + for info in combat + .attackers + .iter() + .filter(|info| info.defending_player == player) + { + let attacker = info.object_id; + for (by, source, anchor) in + must_be_blocked_requirements_for_attacker(state, attacker, &must_be_blocked) + { + if pairs.iter().any(|(blocker, aid)| { + *aid == attacker + && by.is_none_or(|filter| { + matches_target_filter( + state, + *blocker, + filter, + &blocker_filter_context(state, source, anchor), + ) + }) + }) { + requirements.push(BlockDeclarationRequirement::Attacker { + attacker, + by: by.cloned(), + source, + anchor, + }); + } + } + for (filter, source, anchor) in must_be_blocked_by_all_requirements_for_attacker( + state, + attacker, + &must_be_blocked, + ) { + for &(blocker, aid) in &pairs { + if aid == attacker + && filter.is_none_or(|f| { + matches_target_filter( + state, + blocker, + f, + &blocker_filter_context(state, source, anchor), + ) + }) + { + requirements + .push(BlockDeclarationRequirement::Every { blocker, attacker }); + } + } + } + } + } + // Keep the entire legal pair universe. A pair that does not directly + // score a requirement can still be coupled to one that does through a + // multi-blocker capacity, menace floor, or another CR 509.1b legality + // restriction. Pruning it before the complete-declaration legality + // check can therefore hide the only maximum legal witness. + let mut by_blocker: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for (blocker, attacker) in pairs { + by_blocker.entry(blocker).or_default().push(attacker); + } + let choices = by_blocker + .into_iter() + .filter_map(|(blocker, mut attackers)| { + attackers.sort_unstable(); + attackers.dedup(); + let object = state.objects.get(&blocker)?; + let maximum = extra_block_limit(state, object).min(attackers.len() as u32) as usize; + Some(blocker_assignment_choices(blocker, &attackers, maximum)) + }) + .collect::>(); + + let mut future_requirement_satisfaction = + vec![vec![false; requirements.len()]; choices.len() + 1]; + for choice_index in (0..choices.len()).rev() { + let mut remaining = future_requirement_satisfaction[choice_index + 1].clone(); + for (requirement_index, requirement) in requirements.iter().enumerate() { + remaining[requirement_index] |= choices[choice_index] + .iter() + .flatten() + .any(|pair| requirement_is_satisfied(state, requirement, &[*pair])); + } + future_requirement_satisfaction[choice_index] = remaining; + } + + Self { + player, + choices, + requirements, + future_requirement_satisfaction, + } + } + + fn score_with_state(&self, state: &GameState, assignments: &[(ObjectId, ObjectId)]) -> u32 { + self.requirements + .iter() + .filter(|requirement| self.requirement_is_satisfied(state, requirement, assignments)) + .count() as u32 + } + + fn requirement_is_satisfied( + &self, + state: &GameState, + requirement: &BlockDeclarationRequirement, + assignments: &[(ObjectId, ObjectId)], + ) -> bool { + requirement_is_satisfied(state, requirement, assignments) + } + + /// Memoize the optimistic score for the exact requirement state at this + /// blocker-choice frontier. The cached value intentionally ignores hard + /// legality: it is only an upper bound, so a wider bound can cost work but + /// can never prune a legal CR 509.1c witness. + fn partial_score_upper_bound( + &self, + state: &GameState, + choice_index: usize, + assignments: &[(ObjectId, ObjectId)], + upper_bound_memo: &mut HashMap<(usize, Vec), u32>, + ) -> u32 { + let satisfied: Vec = self + .requirements + .iter() + .map(|requirement| self.requirement_is_satisfied(state, requirement, assignments)) + .collect(); + let key = (choice_index, satisfied.clone()); + if let Some(&upper_bound) = upper_bound_memo.get(&key) { + return upper_bound; + } + let upper_bound = satisfied + .iter() + .enumerate() + .filter(|(requirement_index, is_satisfied)| { + **is_satisfied + || self.future_requirement_satisfaction[choice_index][*requirement_index] + }) + .count() as u32; + upper_bound_memo.insert(key, upper_bound); + upper_bound + } + + fn max_free_score(&self, state: &GameState) -> u32 { + self.best_free_declaration(state).1 + } + + fn best_free_declaration(&self, state: &GameState) -> (Vec<(ObjectId, ObjectId)>, u32) { + if self.requirements.is_empty() { + return (Vec::new(), 0); + } + let mut best = (Vec::new(), 0); + let mut chosen = Vec::new(); + let mut upper_bound_memo = HashMap::new(); + self.search_free(state, 0, &mut chosen, &mut best, &mut upper_bound_memo); + best + } + + fn search_free( + &self, + state: &GameState, + index: usize, + chosen: &mut Vec<(ObjectId, ObjectId)>, + best: &mut (Vec<(ObjectId, ObjectId)>, u32), + upper_bound_memo: &mut HashMap<(usize, Vec), u32>, + ) { + let upper_bound = self.partial_score_upper_bound(state, index, chosen, upper_bound_memo); + // A final declaration can only grow from this prefix. When the best + // possible score merely ties the incumbent, a longer prefix cannot + // improve the shortest-witness tie break; an equal-length prefix can + // improve it only if it is lexicographically earlier. This preserves + // the existing score → length → lex ordering exactly while cutting the + // wide-board equal-score Cartesian tail. + if upper_bound < best.1 + || (upper_bound == best.1 + && (chosen.len() > best.0.len() + || (chosen.len() == best.0.len() && *chosen >= best.0))) + { + return; + } + if index == self.choices.len() { + if validate_blockers_core(state, self.player, chosen).is_ok() + && compute_block_tax(state, chosen).is_none() + { + let score = self.score_with_state(state, chosen); + let better = score > best.1 + || (score == best.1 + && (chosen.len() < best.0.len() + || (chosen.len() == best.0.len() && *chosen < best.0))); + if better { + *best = (chosen.clone(), score); + } + } + return; + } + for choice in &self.choices[index] { + let start = chosen.len(); + chosen.extend(choice.iter().copied()); + self.search_free(state, index + 1, chosen, best, upper_bound_memo); + chosen.truncate(start); + } + } +} + +/// Evaluates one CR 509.1c requirement against a complete or partial +/// declaration. The solver uses the same predicate for scoring and its +/// memoized remaining-choice feasibility frontier. +fn requirement_is_satisfied( + state: &GameState, + requirement: &BlockDeclarationRequirement, + assignments: &[(ObjectId, ObjectId)], +) -> bool { + match requirement { + BlockDeclarationRequirement::Generic { blocker } => { + assignments.iter().any(|(b, _)| b == blocker) + } + BlockDeclarationRequirement::Exact { blocker, attacker } + | BlockDeclarationRequirement::Every { blocker, attacker } => { + assignments.contains(&(*blocker, *attacker)) + } + BlockDeclarationRequirement::Attacker { + attacker, + by, + source, + anchor, + } => assignments.iter().any(|(blocker, aid)| { + *aid == *attacker + && by.as_ref().is_none_or(|filter| { + matches_target_filter( + state, + *blocker, + filter, + &blocker_filter_context(state, *source, *anchor), + ) + }) + }), + } +} + +/// Enumerate a single blocker's capacity-bounded attacker subsets in stable +/// order. The empty declaration is always legal at this local layer; global +/// restrictions and CR 509.1c requirements are applied by the shared solver. +fn blocker_assignment_choices( + blocker: ObjectId, + attackers: &[ObjectId], + maximum: usize, +) -> Vec> { + fn collect( + blocker: ObjectId, + attackers: &[ObjectId], + maximum: usize, + start: usize, + current: &mut Vec<(ObjectId, ObjectId)>, + output: &mut Vec>, + ) { + output.push(current.clone()); + if current.len() == maximum { + return; + } + for index in start..attackers.len() { + current.push((blocker, attackers[index])); + collect(blocker, attackers, maximum, index + 1, current, output); + current.pop(); + } + } + + let mut output = Vec::new(); + collect(blocker, attackers, maximum, 0, &mut Vec::new(), &mut output); + output +} + +/// CR 509.1c: complete an AI/generated blocker proposal through the same +/// maximum-requirement authority as a player declaration. A valid, tax-free +/// maximum proposal is preserved; every other proposal becomes the deterministic +/// tax-free witness so callers cannot wedge on a rejected declaration. +pub fn complete_blocker_proposal( + state: &GameState, + player: PlayerId, + proposed: &[(ObjectId, ObjectId)], +) -> crate::types::actions::GameAction { + let constraints = BlockDeclarationConstraints::build(state, player); + let required = constraints.max_free_score(state); + let valid = validate_blockers_core(state, player, proposed).is_ok() + && constraints.score_with_state(state, proposed) >= required + && compute_block_tax(state, proposed).is_none(); + let assignments = if valid { + proposed.to_vec() + } else { + constraints.best_free_declaration(state).0 + }; + crate::types::actions::GameAction::DeclareBlockers { assignments } +} + +/// Batch form of [`complete_blocker_proposal`] for engine legal-action +/// generation. The constraints model and its deterministic witness are shared +/// across all candidates from one unchanged state. +pub fn complete_blocker_proposals( + state: &GameState, + player: PlayerId, + proposals: &[Vec<(ObjectId, ObjectId)>], +) -> Vec { + let constraints = BlockDeclarationConstraints::build(state, player); + let required = constraints.max_free_score(state); + let witness = constraints.best_free_declaration(state).0; + proposals + .iter() + .map(|proposal| { + let valid = validate_blockers_core(state, player, proposal).is_ok() + && constraints.score_with_state(state, proposal) >= required + && compute_block_tax(state, proposal).is_none(); + crate::types::actions::GameAction::DeclareBlockers { + assignments: if valid { + proposal.clone() + } else { + witness.clone() + }, + } + }) + .collect() +} + /// Validate one defending player's blocker declaration per CR 509.1 and CR 802.4. pub fn validate_blockers_for_player( state: &GameState, player: PlayerId, assignments: &[(ObjectId, ObjectId)], +) -> Result<(), String> { + let constraints = BlockDeclarationConstraints::build(state, player); + validate_blockers_core(state, player, assignments)?; + let required = constraints.max_free_score(state); + let score = constraints.score_with_state(state, assignments); + if score < required { + return Err(format!( + "Declaration obeys {score} block requirement(s) but {required} are obtainable without paying a cost (CR 509.1c)" + )); + } + Ok(()) +} + +/// The hard-restriction half of declaring blockers. Requirements are deliberately +/// excluded here: CR 509.1c compares a declaration with the maximum number of +/// requirements obtainable by *a whole declaration*, not with each requirement +/// independently. +fn validate_blockers_core( + state: &GameState, + player: PlayerId, + assignments: &[(ObjectId, ObjectId)], ) -> Result<(), String> { // CR 509.1b: Block restrictions make the declaration illegal if disobeyed. if let Some(max) = max_blockers_each_combat(state) { @@ -1359,7 +1814,6 @@ pub fn validate_blockers_for_player( let blocker_restriction = collect_blocker_restriction_statics(state); let block_restriction = collect_block_restriction_statics(state); let blocker_allowed = collect_blocker_allowed_statics(state); - let must_be_blocked = collect_must_be_blocked_statics(state); // CR 604.1: loop-invariant existence gate for the shadow block-lift (CR // 509.1b/609.4/702.28b). Hoisted once so the per-blocker shadow scan below // and every `can_block_pair_with_precomputed` call skip the O(N) @@ -1790,307 +2244,6 @@ pub fn validate_blockers_for_player( } } - // CR 509.1c: MustBeBlocked — if a creature with a "must be blocked if able" - // requirement is attacking, the defending player must obey it by assigning a - // qualifying blocker whenever one is able. Each requirement on the attacker - // is enforced independently (an attacker may carry both a bare and a filtered - // requirement): `by == None` ⇒ any assigned blocker satisfies it; `by == - // Some(filter)` ⇒ only an assigned blocker matching `filter` does (Ace's - // Baseball Bat: a Dalek; Slayer's Cleaver: an Eldrazi). Uses the engine's - // existing per-attacker greedy approximation of the CR 509.1c requirement- - // maximization rule, applied uniformly to the bare and filtered forms. - if let Some(combat) = &state.combat { - // Collect all assigned blocker IDs for quick lookup - let assigned_blockers: std::collections::HashSet = assignments - .iter() - .map(|&(blocker_id, _)| blocker_id) - .collect(); - - for attacker_info in &combat.attackers { - if attacker_info.defending_player != player { - continue; - } - let attacker_id = attacker_info.object_id; - - let requirements: Vec<(Option<&TargetFilter>, ObjectId, Option)> = - must_be_blocked_requirements_for_attacker(state, attacker_id, &must_be_blocked) - .collect(); - - for (by, src_id, anchor) in requirements { - // CR 509.1c: the requirement is obeyed if a qualifying blocker is - // already assigned to this attacker — `None` ⇒ any assigned - // blocker; `Some(filter)` ⇒ an assigned blocker matching `filter`. - let satisfied = blockers_per_attacker - .get(&attacker_id) - .is_some_and(|blockers| { - blockers.iter().any(|blocker_id| match by { - None => true, - Some(filter) => matches_target_filter( - state, - *blocker_id, - filter, - &blocker_filter_context(state, src_id, anchor), - ), - }) - }); - if satisfied { - continue; - } - - // Check if any defending creature not yet assigned to THIS - // attacker could legally block it AND (for the filtered form) - // match the filter. If so, the declaration is illegal because - // that creature should have been assigned. CR 509.1b: a - // creature that can't legally block doesn't make the - // requirement obey-able. - // - // CR 509.1c: a creature already blocking another attacker is - // still "able" to block this one if it has spare block - // capacity granted by ExtraBlockers — mirror of the - // MustBeBlockedByAll path at line ~1496 above. - let has_available_blocker = state.battlefield.iter().any(|id| { - // Skip creatures already assigned to this specific attacker - // — they're already counted in the `satisfied` check above. - if blockers_per_attacker - .get(&attacker_id) - .is_some_and(|blockers| blockers.contains(id)) - { - return false; - } - let Some(obj) = state.objects.get(id) else { - return false; - }; - if obj.controller != player - || !obj.card_types.core_types.contains(&CoreType::Creature) - || obj.tapped - { - return false; - } - // A creature blocking other attacker(s) is only "able" to - // also block this one if it has spare block capacity. - // CR 509.1c: a blocker at its per-creature limit cannot - // take on an additional block. - let assigned_count = attackers_per_blocker.get(id).copied().unwrap_or(0); - if assigned_count >= extra_block_limit(state, obj) { - return false; - } - can_block_pair_with_precomputed( - state, - *id, - attacker_id, - &blocker_restriction, - &block_restriction, - &blocker_allowed, - can_block_shadow_exists, - ) && match by { - None => true, - Some(filter) => matches_target_filter( - state, - *id, - filter, - &blocker_filter_context(state, src_id, anchor), - ), - } - }); - - if has_available_blocker { - return Err(match by { - None => format!("{attacker_id:?} must be blocked if able (CR 509.1c)"), - Some(_) => format!( - "{attacker_id:?} must be blocked by a qualifying creature if able (CR 509.1c)" - ), - }); - } - } - } - - // CR 509.1c: MustBeBlockedByAll — the "lure" requirement ("All creatures - // able to block ~ do so": Lure, Prized Unicorn, Breaker of Armies, …). - // Every creature this defending player controls that could legally block - // the lured attacker carries a "must block it" requirement, so — unlike - // MustBeBlocked, which is satisfied by a single blocker — there must be - // *no* creature with spare block capacity able to block it left off that - // attacker. A blocker already at its per-creature block limit is not - // able to add another block; a blocker with ExtraBlockers spare capacity - // still must also block the lured attacker. - for attacker_info in &combat.attackers { - if attacker_info.defending_player != player { - continue; - } - let attacker_id = attacker_info.object_id; - - // Collect the requirements before the inner `state.battlefield` iter - // to drop the precomputed borrow (mirrors the sibling collect at - // ~1479-1481 for the MustBeBlocked loop). - let requirements: Vec<(Option<&TargetFilter>, ObjectId, Option)> = - must_be_blocked_by_all_requirements_for_attacker( - state, - attacker_id, - &must_be_blocked, - ) - .collect(); - - for (blockers, src_id, anchor) in requirements { - // Any untapped defender with spare block capacity that could - // legally block the lured attacker should have been declared as - // its blocker. `blockers == None` ⇒ every idle able creature is - // compelled (unchanged Lure); `Some(filter)` ⇒ only idle able - // creatures matching `filter` are compelled — non-matching - // creatures stay legal to leave off. CR 509.1c. - let has_idle_able_blocker = state.battlefield.iter().any(|id| { - if blockers_per_attacker - .get(&attacker_id) - .is_some_and(|assigned| assigned.contains(id)) - { - return false; - } - let Some(obj) = state.objects.get(id) else { - return false; - }; - if obj.controller != player - || !obj.card_types.core_types.contains(&CoreType::Creature) - || obj.tapped - || !can_block_pair_with_precomputed( - state, - *id, - attacker_id, - &blocker_restriction, - &block_restriction, - &blocker_allowed, - can_block_shadow_exists, - ) - { - return false; - } - let assigned_count = attackers_per_blocker.get(id).copied().unwrap_or(0); - assigned_count < extra_block_limit(state, obj) - // CR 509.1c: filtered lure — only creatures matching the - // filter carry the "must block" requirement. - && blockers.is_none_or(|f| { - matches_target_filter( - state, - *id, - f, - &blocker_filter_context(state, src_id, anchor), - ) - }) - }); - if has_idle_able_blocker { - return Err(match blockers { - None => format!( - "{attacker_id:?} must be blocked by every creature able to block it (CR 509.1c)" - ), - Some(_) => format!( - "{attacker_id:?} must be blocked by every qualifying creature able to block it (CR 509.1c)" - ), - }); - } - } - } - - // CR 509.1c + CR 802.4b: Check MustBlock only for this defending - // player's declaration. - // If a defending creature has MustBlock and isn't assigned as a blocker, - // verify it couldn't legally block any attacker. - // CR 604.1: hoist the MustBlock existence gate once before iterating N - // permanents so the per-permanent `check_static_ability` re-scan is - // skipped when no functioning MustBlock static exists (O(N^2) -> O(N)). - let has_must_block_static = static_kind_present(state, StaticModeKind::MustBlock); - for &obj_id in &state.battlefield { - // Already assigned as a blocker — constraint satisfied. The rest of - // the enforcement predicate is the shared authority used by the - // display-population helper (`blocker_constraints_for_player`). - if assigned_blockers.contains(&obj_id) { - continue; - } - if creature_has_must_block_requirement( - state, - obj_id, - player, - has_must_block_static, - &blocker_restriction, - &block_restriction, - &blocker_allowed, - can_block_shadow_exists, - ) { - return Err(format!("{:?} must block if able (CR 509.1c)", obj_id)); - } - } - - // CR 702.39a + CR 509.1c: MustBlockAttacker — a creature forced to block - // a SPECIFIC attacker (Provoke; "target creature blocks ~ this turn if - // able") must be declared as a blocker of *that* attacker when it can - // legally block it. This is stricter than generic MustBlock: blocking a - // different attacker does not satisfy the requirement. - for &obj_id in &state.battlefield { - let Some(obj) = state.objects.get(&obj_id) else { - continue; - }; - if obj.controller != player || !obj.card_types.core_types.contains(&CoreType::Creature) - { - continue; - } - // The specific attackers this creature is required to block. - // CR 702.26b + CR 604.1: `active_static_definitions` owns the gating. - let required: Vec = - super::functioning_abilities::active_static_definitions(state, obj) - .filter_map(|sd| match sd.mode { - StaticMode::MustBlockAttacker { attacker } => Some(attacker), - _ => None, - }) - .collect(); - if required.is_empty() { - continue; - } - // A creature that can't block at all carries no requirement - // (CR 509.1a tapped / CR 702.147a decayed / CR 701.35a detained / - // CantBlock). `can_block_pair` does not itself reject tapped, so the - // explicit guards mirror the generic MustBlock check above. - if obj.tapped - || obj.has_keyword(&Keyword::Decayed) - || !obj.detained_by.is_empty() - || blocker_has_cant_block_static_from_precomputed( - state, - obj_id, - &blocker_restriction, - ) - { - continue; - } - for attacker_id in required { - // Only enforce while that attacker is actually attacking this - // defending player this combat. - let is_active_attacker = combat - .attackers - .iter() - .any(|ai| ai.object_id == attacker_id && ai.defending_player == player); - if !is_active_attacker { - continue; - } - // If the creature can legally block the named attacker but isn't - // declared as its blocker, the declaration is illegal. - let assigned_to_attacker = assignments - .iter() - .any(|&(blocker, attacker)| blocker == obj_id && attacker == attacker_id); - if !assigned_to_attacker - && can_block_pair_with_precomputed( - state, - obj_id, - attacker_id, - &blocker_restriction, - &block_restriction, - &blocker_allowed, - can_block_shadow_exists, - ) - { - return Err(format!( - "{obj_id:?} must block {attacker_id:?} this turn if able (CR 509.1c)" - )); - } - } - } - } - Ok(()) } @@ -2392,8 +2545,16 @@ pub fn compute_block_tax( crate::types::mana::ManaCost, Vec<(ObjectId, crate::types::mana::ManaCost)>, )> { - let pairs: Vec<(ObjectId, Option)> = - assignments.iter().map(|(b, _)| (*b, None)).collect(); + // CR 509.1a + CR 509.1d: a creature may block more than one attacker, but + // a block tax that applies to "each creature ... that blocks" applies once + // to that creature, not once to every declared pair. + let mut blockers: Vec = assignments.iter().map(|(blocker, _)| *blocker).collect(); + blockers.sort_unstable(); + blockers.dedup(); + let pairs: Vec<(ObjectId, Option)> = blockers + .into_iter() + .map(|blocker| (blocker, None)) + .collect(); compute_combat_tax( state, &pairs, @@ -4018,6 +4179,10 @@ pub(super) fn commit_attack_declaration( }) .collect(); apply_attack_band_ids(&mut attackers, bands); + let attacking_incarnations_this_combat = attacker_ids + .iter() + .filter_map(|id| state.objects.get(id).map(ObjectIncarnationRef::from_object)) + .collect(); let combat = state.combat.get_or_insert_with(CombatState::default); combat.attackers = attackers; state.players_attacked_this_step = combat @@ -4037,6 +4202,7 @@ pub(super) fn commit_attack_declaration( .iter() .map(|attacker| (attacker.object_id, attacker.defending_player)) .collect(); + combat.attacking_incarnations_this_combat = attacking_incarnations_this_combat; combat.attacked_defenders_this_combat.clear(); combat.creature_attacked_defenders_this_combat.clear(); for (attacker_id, defending_player) in &creature_attacked_defenders { @@ -4161,6 +4327,58 @@ pub(super) fn snapshot_attack_declaration( (snap_attacks, snap_bands) } +/// CR 400.7 + CR 509.1d: Capture the exact blocker/attacker pairs quoted by a +/// blocking tax. Both ends are identities because either object may leave and +/// re-enter while the defending player decides whether to pay. +pub fn snapshot_block_declaration( + state: &GameState, + assignments: &[(ObjectId, ObjectId)], +) -> Vec<( + crate::types::identifiers::ObjectIncarnationRef, + crate::types::identifiers::ObjectIncarnationRef, +)> { + assignments + .iter() + .filter_map(|(blocker, attacker)| { + Some(( + crate::types::identifiers::ObjectIncarnationRef::from_object( + state.objects.get(blocker)?, + ), + crate::types::identifiers::ObjectIncarnationRef::from_object( + state.objects.get(attacker)?, + ), + )) + }) + .collect() +} + +/// CR 400.7 + CR 509.1d: Recover only pairs whose two exact snapshots still +/// name the live objects. This deliberately performs no tax recomputation. +pub fn block_declaration_from_snapshot( + state: &GameState, + assignments: &[( + crate::types::identifiers::ObjectIncarnationRef, + crate::types::identifiers::ObjectIncarnationRef, + )], +) -> Vec<(ObjectId, ObjectId)> { + assignments + .iter() + .filter_map(|(blocker, attacker)| { + let blocker_live = state.objects.get(&blocker.object_id).is_some_and(|object| { + crate::types::identifiers::ObjectIncarnationRef::from_object(object) == *blocker + }); + let attacker_live = state + .objects + .get(&attacker.object_id) + .is_some_and(|object| { + crate::types::identifiers::ObjectIncarnationRef::from_object(object) + == *attacker + }); + (blocker_live && attacker_live).then_some((blocker.object_id, attacker.object_id)) + }) + .collect() +} + /// Declare attackers without explicit band declarations. pub fn declare_attackers( state: &mut GameState, @@ -4564,7 +4782,7 @@ pub fn blocker_constraints_for_player( continue; } if valid_block_targets.contains_key(&obj_id) { - if creature_has_must_block_requirement( + let generic = creature_has_must_block_requirement( state, obj_id, player, @@ -4573,16 +4791,44 @@ pub fn blocker_constraints_for_player( &block_restriction, &blocker_allowed, can_block_shadow_exists, - ) { + ); + let mut attackers: Vec = + super::functioning_abilities::active_static_definitions(state, obj) + .filter_map(|definition| match definition.mode { + StaticMode::MustBlockAttacker { attacker } + if valid_block_targets + .get(&obj_id) + .is_some_and(|targets| targets.contains(&attacker.object_id)) + && state.combat.as_ref().is_some_and(|combat| { + combat.attackers.iter().any(|info| { + info.object_id == attacker.object_id + && info.defending_player == player + && state.objects.get(&attacker.object_id).is_some_and( + |object| { + ObjectIncarnationRef::from_object(object) + == attacker + }, + ) + }) + }) => + { + Some(attacker.object_id) + } + _ => None, + }) + .collect(); + attackers.sort_unstable(); + attackers.dedup(); + if generic || !attackers.is_empty() { constraints.insert( obj_id, CombatRequirement::MustBlock { - sources: must_block_sources_gated( - state, - obj, - obj_id, - has_must_block_static, - ), + sources: if generic { + must_block_sources_gated(state, obj, obj_id, has_must_block_static) + } else { + vec![obj_id] + }, + attackers, }, ); } @@ -4640,6 +4886,26 @@ pub fn build_declare_attackers_waiting_for( } } +/// CR 509.1a-c: Build a fresh blocker prompt from the sole live constraint +/// surface. Used after a declined block tax so stale proposal pairs cannot be +/// silently filtered and committed. +pub fn build_declare_blockers_waiting_for( + state: &GameState, + player: PlayerId, +) -> crate::types::game_state::WaitingFor { + let valid_block_targets = get_valid_block_targets_for_player(state, player); + let valid_blocker_ids = ordered_valid_blocker_ids(&valid_block_targets); + let block_requirements = block_requirements_for_player(state, player); + let blocker_constraints = blocker_constraints_for_player(state, player, &valid_block_targets); + crate::types::game_state::WaitingFor::DeclareBlockers { + player, + valid_blocker_ids, + valid_block_targets, + block_requirements, + blocker_constraints, + } +} + pub fn refresh_combat_declaration_waiting_for(state: &mut GameState) { match &state.waiting_for { crate::types::game_state::WaitingFor::DeclareAttackers { .. } => { @@ -10069,6 +10335,34 @@ mod tests { assert!(validate_blockers(&state, &[(blocker, attacker)]).is_ok()); } + /// CR 509.1c: a wide defender board still chooses the deterministic + /// shortest lexicographic maximum witness. This exercises the solver's + /// equal-bound dominance pruning: the old raw Cartesian search would visit + /// every subset of sixteen otherwise interchangeable blockers. + #[test] + fn wide_must_block_board_keeps_shortest_lexicographic_witness() { + let mut state = setup(); + let attacker = create_creature(&mut state, PlayerId(0), "Lure Beast", 3, 3); + add_must_be_blocked(&mut state, attacker); + let blockers: Vec<_> = (0..16) + .map(|index| { + create_creature(&mut state, PlayerId(1), &format!("Blocker {index}"), 2, 2) + }) + .collect(); + state.combat = Some(CombatState { + attackers: vec![AttackerInfo::attacking_player(attacker, PlayerId(1))], + ..Default::default() + }); + + let crate::types::actions::GameAction::DeclareBlockers { assignments } = + complete_blocker_proposal(&state, PlayerId(1), &[]) + else { + panic!("blocker completion must return a blocker declaration"); + }; + assert_eq!(assignments, vec![(blockers[0], attacker)]); + assert!(validate_blockers(&state, &assignments).is_ok()); + } + #[test] fn must_be_blocked_ok_when_no_legal_blockers() { // CR 509.1c "if able": no legal blockers means empty assignment is fine. @@ -10877,6 +11171,9 @@ mod tests { } fn add_must_block_attacker(state: &mut GameState, creature: ObjectId, attacker: ObjectId) { + let attacker = crate::types::identifiers::ObjectIncarnationRef::from_object( + state.objects.get(&attacker).unwrap(), + ); state .objects .get_mut(&creature) @@ -10914,6 +11211,31 @@ mod tests { assert!(validate_blockers(&state, &[(forced, provoker)]).is_ok()); } + #[test] + fn competing_exact_block_requirements_accept_a_maximum_declaration() { + // CR 509.1c: requirements are maximized as a whole. One ordinary + // blocker cannot block both attackers, so either one-pair declaration + // obeys the obtainable maximum; the former greedy per-requirement loops + // incorrectly rejected both. + let mut state = setup(); + let first = create_creature(&mut state, PlayerId(0), "First", 2, 2); + let second = create_creature(&mut state, PlayerId(0), "Second", 2, 2); + let blocker = create_creature(&mut state, PlayerId(1), "Guard", 2, 2); + add_must_block_attacker(&mut state, blocker, first); + add_must_block_attacker(&mut state, blocker, second); + state.combat = Some(CombatState { + attackers: vec![ + AttackerInfo::attacking_player(first, PlayerId(1)), + AttackerInfo::attacking_player(second, PlayerId(1)), + ], + ..Default::default() + }); + + assert!(validate_blockers(&state, &[]).is_err()); + assert!(validate_blockers(&state, &[(blocker, first)]).is_ok()); + assert!(validate_blockers(&state, &[(blocker, second)]).is_ok()); + } + #[test] fn must_block_attacker_exempt_when_cannot_block() { // CR 509.1a: a tapped creature can't block, so the provoke requirement @@ -11282,7 +11604,8 @@ mod tests { constraints.get(&blocker), // CR 509.1c: intrinsic MustBlock → carrier is the creature itself. Some(&CombatRequirement::MustBlock { - sources: vec![blocker] + sources: vec![blocker], + attackers: vec![], }), "a must-block creature able to block the attacker is MustBlock" ); @@ -11322,7 +11645,8 @@ mod tests { blocker_constraints_for_player(&state, PlayerId(1), &valid).get(&blocker), // CR 509.1c: intrinsic MustBlock → carrier is the creature itself. Some(&CombatRequirement::MustBlock { - sources: vec![blocker] + sources: vec![blocker], + attackers: vec![], }), "reach-guard: ground blocker CAN block the ground attacker" ); diff --git a/crates/engine/src/game/combat_damage.rs b/crates/engine/src/game/combat_damage.rs index b4a9158917..508dd3d327 100644 --- a/crates/engine/src/game/combat_damage.rs +++ b/crates/engine/src/game/combat_damage.rs @@ -1444,6 +1444,7 @@ mod tests { timestamp: ts, duration: Duration::UntilEndOfTurn, affected: TargetFilter::SelfRef, + affected_recipient: None, modifications: vec![ ContinuousModification::AddPower { value: 1 }, ContinuousModification::AddToughness { value: 1 }, diff --git a/crates/engine/src/game/cost_payability.rs b/crates/engine/src/game/cost_payability.rs index 61bfe7222b..761caa2b8e 100644 --- a/crates/engine/src/game/cost_payability.rs +++ b/crates/engine/src/game/cost_payability.rs @@ -73,6 +73,7 @@ pub(crate) fn target_filter_has_pitch_bound_x(filter: &TargetFilter) -> bool { | TargetFilter::AttachedTo | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } @@ -154,6 +155,7 @@ pub(crate) fn relax_pitch_bound_x_filter(filter: &TargetFilter) -> TargetFilter | TargetFilter::AttachedTo | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 7ff4b55ebd..a6d976e61a 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -602,6 +602,7 @@ fn fmt_target(filter: &TargetFilter) -> String { TargetFilter::AttachedTo => "attached permanent".into(), TargetFilter::LastCreated => "last created".into(), TargetFilter::LastRevealed => "last revealed".into(), + TargetFilter::LastZoneChanged => "last zone changed".into(), TargetFilter::CostPaidObject => "cost-paid object".into(), TargetFilter::ChosenCard => "last chosen card".into(), TargetFilter::TriggeringSpellController => "triggering spell's controller".into(), @@ -2333,6 +2334,10 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { } => { d.push(("target".into(), fmt_target(target))); } + // CR 707.2c (Metamorphic Alteration): report the copy-source choice pool. + Effect::ChoosePermanent { filter } => { + d.push(("choose".into(), fmt_target(filter))); + } Effect::Destroy { target, .. } | Effect::Sacrifice { target, .. } | Effect::GainControl { target } @@ -2349,15 +2354,29 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { | Effect::Connive { target, .. } | Effect::PhaseOut { target } | Effect::PhaseIn { target } - | Effect::ForceBlock { target } | Effect::ForceAttack { target, .. } | Effect::Transform { target } + // CR 710.4: the flipping permanent is the effect's single reported target. + | Effect::FlipPermanent { target } | Effect::Shuffle { target } | Effect::Reveal { target } | Effect::Regenerate { target } | Effect::RemoveAllDamage { target } => { d.push(("target".into(), fmt_target(target))); } + Effect::ForceBlock { + target, + attacker, + duration, + } => { + d.push(("target".into(), fmt_target(target))); + if let Some(attacker) = attacker { + d.push(("attacker".into(), format!("{attacker:?}"))); + } + if *duration != Duration::UntilEndOfTurn { + d.push(("duration".into(), format!("{duration:?}"))); + } + } // CR 702.50a: EpicCopy's parameters live in its snapshotted ability. Effect::EpicCopy { .. } => {} Effect::Intensify { .. } => {} @@ -3932,6 +3951,7 @@ fn fmt_trigger_condition(cond: &crate::types::ability::TriggerCondition) -> Stri } TC::ControlsNone { filter } => format!("you control no {}", fmt_target(filter)), TC::AttackedThisTurn => "attacked this turn".into(), + TC::SourceAttackedThisCombat => "source attacked this combat".into(), TC::FirstCombatPhaseOfTurn => "first combat phase of the turn".into(), TC::CastSpellThisTurn { filter } => match filter { Some(f) => format!("cast a {} spell this turn", fmt_target(f)), @@ -4165,6 +4185,9 @@ fn fmt_modification(m: &crate::types::ability::ContinuousModification) -> String use crate::types::ability::ContinuousModification; match m { ContinuousModification::CopyValues { .. } => "copy values".into(), + // CR 707.2c (Metamorphic Alteration): parse-time marker for the enchanted + // host's copy — the runtime copy is the latched `CopyValues` TCE. + ContinuousModification::CopyChosen => "copy chosen".into(), ContinuousModification::SetName { name } => format!("set name {name}"), ContinuousModification::SetTextName { name } => format!("set text name {name}"), ContinuousModification::AddPower { value } => format!("power {:+}", value), @@ -4194,6 +4217,7 @@ fn fmt_modification(m: &crate::types::ability::ContinuousModification) -> String format!("grant all triggered abilities of {}", fmt_target(source)) } ContinuousModification::GrantTrigger { .. } => "grant trigger".into(), + ContinuousModification::GrantReplacement { .. } => "grant replacement".into(), ContinuousModification::RemoveAllAbilities => "remove all abilities".into(), ContinuousModification::AddType { core_type } => { format!("add type {}", fmt_core_type(core_type)) @@ -4340,6 +4364,7 @@ fn static_details(stat: &StaticDefinition) -> Vec<(String, String)> { m, ContinuousModification::GrantTrigger { .. } | ContinuousModification::GrantAbility { .. } + | ContinuousModification::GrantReplacement { .. } ) }) .map(fmt_modification) @@ -4529,6 +4554,11 @@ pub fn build_parse_details( ContinuousModification::GrantAbility { definition } => { children.push(build_ability_item(definition)); } + ContinuousModification::GrantReplacement { replacement } => { + if let Some(execute) = &replacement.execute { + children.push(build_ability_item(execute)); + } + } _ => {} } } @@ -5902,6 +5932,10 @@ fn static_has_unimplemented_parts(def: &StaticDefinition) -> bool { ContinuousModification::GrantTrigger { trigger } => { trigger_has_unimplemented_parts(trigger) } + ContinuousModification::GrantReplacement { replacement } => replacement + .execute + .as_deref() + .is_some_and(ability_definition_has_unimplemented_parts), _ => false, }) } @@ -5996,6 +6030,11 @@ fn check_statics( ContinuousModification::GrantTrigger { trigger } => { check_trigger(trigger, trigger_registry, missing); } + ContinuousModification::GrantReplacement { replacement } => { + if let Some(execute) = &replacement.execute { + collect_ability_missing_parts(execute, missing); + } + } _ => {} } } @@ -6915,6 +6954,10 @@ fn is_static_supported( ContinuousModification::GrantTrigger { trigger } => { is_trigger_supported(trigger, trigger_registry) } + ContinuousModification::GrantReplacement { replacement } => replacement + .execute + .as_deref() + .is_none_or(is_ability_supported), _ => true, }) } @@ -12178,6 +12221,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], duration: Some(Duration::UntilEndOfTurn), target: None, @@ -12225,6 +12269,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], duration: Some(Duration::UntilEndOfTurn), target: None, @@ -13360,6 +13405,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!(audit_card_lines(oracle, &face).is_empty()); @@ -13394,6 +13440,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!(audit_card_lines(oracle, &face).is_empty()); @@ -13426,6 +13473,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let findings = audit_card_lines(oracle, &face); @@ -13576,6 +13624,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!( @@ -13608,6 +13657,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!( @@ -13650,6 +13700,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let gaps = card_face_gaps(&face); @@ -13683,6 +13734,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let gaps = card_face_gaps(&face); @@ -13718,6 +13770,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let gaps = card_face_gaps(&face); @@ -13759,6 +13812,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); } @@ -13777,7 +13831,7 @@ mod tests { let mut face = make_face(); face.static_abilities.push( StaticDefinition::new(StaticMode::MustBlockAttacker { - attacker: ObjectId(42), + attacker: crate::types::identifiers::ObjectIncarnationRef::of(ObjectId(42), 0), }) .description("Target creature blocks this creature this turn if able.".to_string()), ); @@ -13927,6 +13981,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!( diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 70eba2a80d..de3d6ff05d 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -20,9 +20,10 @@ use crate::game::filter::{matches_target_filter, FilterContext}; use crate::game::game_object::AttachTarget; use crate::game::stack::{effective_stack_ability, stack_display_groups, StackDisplayGroup}; use crate::types::ability::{ - ContinuousModification, GameRestriction, KeywordAction, ProhibitedActivity, RestrictionExpiry, - RestrictionPlayerScope, TargetRef, + ContinuousModification, Duration, GameRestriction, KeywordAction, ProhibitedActivity, + RestrictionExpiry, RestrictionPlayerScope, TargetRef, }; +use crate::types::attribution::EffectRef; use crate::types::card::TokenImageRef; use crate::types::counter::CounterType; use crate::types::events::GameEvent; @@ -32,6 +33,7 @@ use crate::types::game_state::{ }; use crate::types::identifiers::ObjectId; use crate::types::keywords::Keyword; +use crate::types::layers::Layer; use crate::types::mana::ManaCost; use crate::types::player::PlayerId; use crate::types::statics::StaticMode; @@ -191,6 +193,17 @@ pub struct TurnOrderSlotView { pub player: PlayerId, pub slot_index: u8, pub turns_from_now: u8, + /// One-based display position in the projected turn order. Kept here so + /// clients do not turn the engine's zero-based distance into an ordinal. + pub turn_number: u8, + /// Whether this row belongs to the viewing player. Clients consume this + /// display classification rather than comparing player IDs themselves. + #[serde(default, skip_serializing_if = "is_false")] + pub is_viewer: bool, + /// Whether this projected slot is the game's starting player. It is only + /// true while that player is also the current turn representative. + #[serde(default, skip_serializing_if = "is_false")] + pub is_starting_player: bool, } /// Engine-authored projections used by the display layer. Keep this struct @@ -211,6 +224,15 @@ pub struct DerivedViews { #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub battlefield_keyword_badges: HashMap>, + /// CR 509.1b + CR 611.2c: creatures with a live, temporary + /// `CantBeBlocked` grant. The optional value is the granting source only + /// while that source remains a public, phased-in battlefield object; `None` + /// retains the badge without exposing an unavailable source. + /// + /// Keyed by recipient ObjectId; absent when no such grant is active. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub temporary_cant_be_blocked: HashMap>, + /// CR 613.2a + CR 707.2: battlefield permanents whose copiable values are /// currently supplied by a copy effect (Layer 1a) — Clone, Phantasmal /// Image, Vesuvan Doppelganger, and every "enters as a copy" permanent. @@ -313,6 +335,12 @@ pub struct DerivedViews { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub turn_order: Vec, + /// One-based projected turn position for the viewing player. This keeps + /// player-specific turn-order interpretation in the engine while allowing + /// the client to render "You take turn N" directly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub viewer_turn_number: Option, + /// CR 732.2a: the `∞` HUD rows — one per (attributed player, pumped axis) of /// every unbounded-resource loop in `GameState::unbounded_resources`. The /// engine decides both the axis identity and the player attribution @@ -508,6 +536,49 @@ fn object_has_copy_effect(state: &GameState, object_id: ObjectId) -> bool { }) } +/// CR 509.1b + CR 611.2c + CR 613.1f: return the public source for the first +/// live, until-end-of-turn `CantBeBlocked` modification attributed to this +/// recipient in the current ability layer. `Some(None)` means the grant is +/// active but its source is no longer a public, phased-in battlefield object. +/// +/// Attribution is the layer engine's authoritative record of modifications +/// that actually applied. Reading its indexed `EffectRef` avoids re-scanning +/// raw effect filters, which could disagree with the resolved layer recipient. +fn temporary_cant_be_blocked_source( + state: &GameState, + object_id: ObjectId, +) -> Option> { + let effects = state + .attribution + .get(&object_id)? + .by_layer + .get(&Layer::Ability)?; + effects.iter().find_map(|effect_ref| { + let EffectRef::Transient { id, mod_index } = effect_ref else { + return None; + }; + let effect = state + .transient_continuous_effects + .iter() + .find(|effect| effect.id == *id)?; + (effect.duration == Duration::UntilEndOfTurn + && crate::game::layers::transient_effect_is_live(state, effect) + && matches!( + effect.modifications.get(*mod_index), + Some(ContinuousModification::AddStaticMode { + mode: StaticMode::CantBeBlocked + }) + )) + .then(|| { + state + .objects + .get(&effect.source_id) + .filter(|source| source.zone == Zone::Battlefield && source.is_phased_in()) + .map(|_| effect.source_id) + }) + }) +} + pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews { let mut views = DerivedViews { unique_authorized_submitter: unique_authorized_submitter(state), @@ -545,6 +616,9 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews if !badges.is_empty() { views.battlefield_keyword_badges.insert(obj_id, badges); } + if let Some(source_id) = temporary_cant_be_blocked_source(state, obj_id) { + views.temporary_cant_be_blocked.insert(obj_id, source_id); + } // CR 613.2a + CR 707.2 / CR 708.2: see `copied_permanents`. Matched // through the same `matches_target_filter` the layer engine uses to // pick a continuous effect's recipients, so this projection and the @@ -636,7 +710,9 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // Commander short-circuit below). views.player_status = player_status_views(state); - views.turn_order = turn_order_views(state); + let (turn_order, viewer_turn_number) = turn_order_views(state, viewer); + views.turn_order = turn_order; + views.viewer_turn_number = viewer_turn_number; // CR 732.2a: project every unbounded-resource loop into per-(player, axis) // `∞` HUD rows. Runs in every format (placed BEFORE the Commander @@ -725,7 +801,10 @@ fn unique_authorized_submitter(state: &GameState) -> Option { (submitters.len() == 1).then(|| submitters[0]) } -fn turn_order_views(state: &GameState) -> Vec { +fn turn_order_views( + state: &GameState, + viewer: Option, +) -> (Vec, Option) { let mut seen = BTreeSet::new(); let representatives: Vec = state .seat_order @@ -740,10 +819,10 @@ fn turn_order_views(state: &GameState) -> Vec { .collect(); if representatives.len() <= 2 { - return Vec::new(); + return (Vec::new(), None); } - crate::game::turns::projected_turn_order(state, representatives.len()) + let turn_order: Vec<_> = crate::game::turns::projected_turn_order(state, representatives.len()) .into_iter() .enumerate() .map(|(index, player)| { @@ -752,9 +831,18 @@ fn turn_order_views(state: &GameState) -> Vec { player, slot_index, turns_from_now: slot_index, + turn_number: slot_index + 1, + is_viewer: viewer == Some(player), + is_starting_player: slot_index == 0 && player == state.current_starting_player, } }) - .collect() + .collect(); + let viewer_turn_number = turn_order + .iter() + .find(|slot| slot.is_viewer) + .map(|slot| slot.turn_number); + + (turn_order, viewer_turn_number) } /// CR 732.2a: which player's HUD a pumped `axis` belongs to, given the loop's @@ -1662,16 +1750,27 @@ mod tests { player: PlayerId(0), slot_index: 0, turns_from_now: 0, + turn_number: 1, + is_viewer: false, + is_starting_player: true, }, TurnOrderSlotView { player: PlayerId(0), slot_index: 1, turns_from_now: 1, + turn_number: 2, + is_viewer: false, + is_starting_player: false, }, ], "same player can be both NOW and NEXT when an extra turn is queued" ); + let viewer_views = derive_views(&state, Some(PlayerId(2))); + assert_eq!(viewer_views.viewer_turn_number, Some(3)); + assert!(viewer_views.turn_order[2].is_viewer); + assert_eq!(viewer_views.turn_order[2].turn_number, 3); + let json = serde_json::to_string(&ClientGameStateRef::wrap(&state, None)).expect("serialize"); assert!( diff --git a/crates/engine/src/game/dungeon.rs b/crates/engine/src/game/dungeon.rs index 6fc83fa7c3..5b850faaec 100644 --- a/crates/engine/src/game/dungeon.rs +++ b/crates/engine/src/game/dungeon.rs @@ -864,6 +864,7 @@ pub fn room_effects( source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], triggers: Vec::new(), }, diff --git a/crates/engine/src/game/effects/add_target_replacement.rs b/crates/engine/src/game/effects/add_target_replacement.rs index 5706510f6d..fa3d885445 100644 --- a/crates/engine/src/game/effects/add_target_replacement.rs +++ b/crates/engine/src/game/effects/add_target_replacement.rs @@ -262,6 +262,14 @@ pub fn resolve( // reset: a damaged creature can gain/lose characteristics // or enter combat before it dies. Cleanup prunes this // narrowly scoped base copy at end of turn. + // A host-lifetime rider (CR 702.84a "if it would leave the + // battlefield, exile it instead", stamped + // `UntilHostLeavesPlay`) is the same class: it must survive + // every CR 613.1 reseed so the redirect still fires after the + // returned permanent gains/loses characteristics, and its + // base+live copies are pruned together the instant the host + // leaves the battlefield (`prune_controller_controls_source_on_leave`, + // CR 400.7) so it never revives on a same-ObjectId re-entry. // // Acknowledged out-of-scope edges (NOT fixed here): (1) Cleave // re-baselining only touches spells on the stack (casting.rs) @@ -275,7 +283,12 @@ pub fn resolve( crate::game::printed_cards::is_runtime_target_die_exile_replacement( &replacement, ); + let host_lifetime = + crate::game::printed_cards::is_runtime_host_lifetime_replacement( + &replacement, + ); let install_to_base = durable_die_exile + || host_lifetime || matches!( replacement.condition, Some(ReplacementCondition::ControllerControlsSource { .. }) diff --git a/crates/engine/src/game/effects/additional_phase.rs b/crates/engine/src/game/effects/additional_phase.rs index c8fca38d9c..0e6e4a613b 100644 --- a/crates/engine/src/game/effects/additional_phase.rs +++ b/crates/engine/src/game/effects/additional_phase.rs @@ -270,6 +270,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, targets: vec![], kind: AbilityKind::Spell, sub_ability: None, @@ -307,6 +308,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index baed3ea8fc..6c7a5f52f6 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -700,11 +700,15 @@ pub(crate) fn attachment_illegality( // being attached to the protected permanent. // CR 702.16d: Protection from a quality prevents Equipment or Fortifications // of that quality from being attached to the protected permanent. + // CR 702.16n / CR 702.16p: A protection grant that says "this effect doesn't + // remove …" does not make matching attachments illegal via *that* instance + // (Flickering Ward / Ward cycle / Benevolent Blessing). Other instances of + // protection from the same quality still apply normally. if let (Some(host), Some(attachment)) = ( state.objects.get(&host_id), state.objects.get(&attachment_id), ) { - if crate::game::keywords::protection_prevents_from(host, attachment) { + if protection_blocks_attachment(state, host_id, attachment_id, host, attachment) { return Some(AttachIllegality::Protection); } } @@ -712,6 +716,367 @@ pub(crate) fn attachment_illegality( None } +/// CR 702.16c/d + CR 702.16n/p: True when some protection instance on `host` +/// matches `attachment` and is not exempted for that attachment. +fn protection_blocks_attachment( + state: &GameState, + host_id: ObjectId, + attachment_id: ObjectId, + host: &crate::game::game_object::GameObject, + attachment: &crate::game::game_object::GameObject, +) -> bool { + use crate::types::ability::ContinuousModification; + use crate::types::keywords::Keyword; + use crate::types::statics::StaticMode; + + // CR 702.16: Printed / base protection on the host has no 702.16n rider — + // it always blocks matching attachments. + for kw in &host.base_keywords { + if let Keyword::Protection(ref pt) = kw { + if crate::game::keywords::source_matches_protection_target(pt, host, attachment) { + return true; + } + } + } + + // Continuous grants: each matching protection instance blocks unless its + // StaticDefinition/TCE carries a CR 702.16n/p exemption covering this + // attachment. + let mut any_matching_grant = false; + for (source_obj, def) in crate::game::functioning_abilities::battlefield_active_statics(state) { + if !matches!(def.mode, StaticMode::Continuous) { + continue; + } + let source_id = source_obj.id; + let def_index = live_static_def_index(source_obj, def); + let affected = def.affected.clone().unwrap_or(TargetFilter::Any); + let ctx = FilterContext::from_source(state, source_id); + if !matches_target_filter(state, host_id, &affected, &ctx) { + continue; + } + for (mod_index, modification) in def.modifications.iter().enumerate() { + let ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + } = modification + else { + continue; + }; + let resolved = resolve_protection_target_for_grant(state, source_id, pt); + let Some(resolved) = resolved else { + continue; + }; + if !crate::game::keywords::source_matches_protection_target(&resolved, host, attachment) + { + continue; + } + any_matching_grant = true; + if !protection_grant_exempts_attachment( + state, + attachment_id, + source_id, + (def_index, mod_index, host_id), + &resolved, + def.protection_does_not_remove.as_ref(), + ) { + return true; + } + } + } + + // Transient continuous protection grants (e.g. Mother of Runes) — no + // StaticDefinition rider today; treat as always-blocking when they match. + for tce in &state.transient_continuous_effects { + let ctx = FilterContext::from_source(state, tce.source_id); + if !matches_target_filter(state, host_id, &tce.affected, &ctx) { + continue; + } + for modification in &tce.modifications { + let ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + } = modification + else { + continue; + }; + let resolved = resolve_protection_target_for_grant(state, tce.source_id, pt); + let Some(resolved) = resolved else { + continue; + }; + if crate::game::keywords::source_matches_protection_target(&resolved, host, attachment) + { + // Transients currently carry no 702.16n rider field. + return true; + } + } + } + + // If host.keywords still match (granted protection present) but we found no + // continuous grant — fall back to the pre-exemption query so we never open + // a hole when grant discovery misses a path. + if !any_matching_grant && crate::game::keywords::protection_prevents_from(host, attachment) { + return true; + } + + false +} + +/// CR 702.16 + CR 105.4: Resolve `ChosenColor` / `ChosenCardType` against the +/// granting source before matching the attachment (mirrors layer bake-in). +fn resolve_protection_target_for_grant( + state: &GameState, + source_id: ObjectId, + pt: &crate::types::keywords::ProtectionTarget, +) -> Option { + use crate::types::keywords::ProtectionTarget; + match pt { + ProtectionTarget::ChosenColor => state + .objects + .get(&source_id) + .and_then(|src| src.chosen_color()) + .map(ProtectionTarget::Color), + ProtectionTarget::ChosenCardType => state + .objects + .get(&source_id) + .and_then(|src| src.chosen_card_type()) + .and_then(|ct| ct.protection_quality_str()) + .map(|quality| ProtectionTarget::CardType(quality.to_string())), + other => Some(other.clone()), + } +} + +/// Composite key for one protection modification on a host: +/// `(static_definitions index, modifications index, host object id)`. +use crate::game::game_object::ProtectionEffectHostKey; + +/// Live `static_definitions` index for an active static returned by +/// `battlefield_active_statics`. +fn live_static_def_index( + source: &crate::game::game_object::GameObject, + def: &crate::types::ability::StaticDefinition, +) -> usize { + source + .static_definitions + .iter_all() + .position(|d| std::ptr::eq(d, def)) + .expect("active static definition must index live static_definitions") +} + +/// CR 702.16p: Capture attachment IDs matching `resolved_pt` that are already +/// on `host_id` and controlled by `grant_controller` at protection-start time. +fn capture_protection_start_attachment_snapshot( + state: &GameState, + host_id: ObjectId, + grant_controller: PlayerId, + resolved_pt: &crate::types::keywords::ProtectionTarget, +) -> Vec { + let Some(host) = state.objects.get(&host_id) else { + return Vec::new(); + }; + host.attachments + .iter() + .filter_map(|&attachment_id| { + let attachment = state.objects.get(&attachment_id)?; + let is_aura_or_equipment = attachment + .card_types + .subtypes + .iter() + .any(|s| s.eq_ignore_ascii_case("Aura") || s.eq_ignore_ascii_case("Equipment")); + if !is_aura_or_equipment || attachment.controller != grant_controller { + return None; + } + if !crate::game::keywords::source_matches_protection_target( + resolved_pt, + host, + attachment, + ) { + return None; + } + Some(attachment_id) + }) + .collect() +} + +/// CR 702.16p: When a continuous protection grant with the already-attached +/// rider starts applying to a host, snapshot the matching controlled +/// attachments once; consult that per-grant map in +/// [`protection_grant_exempts_attachment`]. Prune entries when the grant stops +/// applying to a host or the source leaves the battlefield. +pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameState) { + use crate::types::ability::ContinuousModification; + use crate::types::ability::ProtectionDoesNotRemove; + use crate::types::keywords::Keyword; + use crate::types::statics::StaticMode; + use std::collections::{HashMap, HashSet}; + + struct ActiveGrant { + def_index: usize, + mod_index: usize, + host_id: ObjectId, + resolved_pt: crate::types::keywords::ProtectionTarget, + controller: PlayerId, + } + + let mut active_by_source: HashMap> = HashMap::new(); + + for (source_obj, def) in crate::game::functioning_abilities::battlefield_active_statics(state) { + if !matches!(def.mode, StaticMode::Continuous) { + continue; + } + if def.protection_does_not_remove + != Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached) + { + continue; + } + let source_id = source_obj.id; + let def_index = live_static_def_index(source_obj, def); + let affected = def.affected.clone().unwrap_or(TargetFilter::Any); + let ctx = FilterContext::from_source(state, source_id); + for (mod_index, modification) in def.modifications.iter().enumerate() { + let ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + } = modification + else { + continue; + }; + let Some(resolved_pt) = resolve_protection_target_for_grant(state, source_id, pt) + else { + continue; + }; + for &host_id in &state.battlefield { + if !matches_target_filter(state, host_id, &affected, &ctx) { + continue; + } + active_by_source + .entry(source_id) + .or_default() + .push(ActiveGrant { + def_index, + mod_index, + host_id, + resolved_pt: resolved_pt.clone(), + controller: source_obj.controller, + }); + } + } + } + + let active_sources: HashSet = active_by_source.keys().copied().collect(); + + for &source_id in &state.battlefield { + let Some(source) = state.objects.get(&source_id) else { + continue; + }; + if source.protection_start_exempt_attachments.is_empty() { + continue; + } + let active_keys = active_by_source + .get(&source_id) + .map(|grants| { + grants + .iter() + .map(|g| (g.def_index, g.mod_index, g.host_id)) + .collect::>() + }) + .unwrap_or_default(); + if active_sources.contains(&source_id) { + state + .objects + .get_mut(&source_id) + .expect("battlefield object") + .protection_start_exempt_attachments + .retain(|key, _| active_keys.contains(key)); + } else { + state + .objects + .get_mut(&source_id) + .expect("battlefield object") + .protection_start_exempt_attachments + .clear(); + } + } + + let mut to_capture = Vec::new(); + for (source_id, grants) in active_by_source { + for grant in grants { + let key = (grant.def_index, grant.mod_index, grant.host_id); + let already_snapshotted = state.objects.get(&source_id).is_some_and(|source| { + source + .protection_start_exempt_attachments + .get(&key) + .is_some_and(|entry| entry.resolved_quality == grant.resolved_pt) + }); + if already_snapshotted { + continue; + } + to_capture.push((source_id, key, grant.resolved_pt, grant.controller)); + } + } + + for (source_id, key, resolved_pt, controller) in to_capture { + let snapshot = + capture_protection_start_attachment_snapshot(state, key.2, controller, &resolved_pt); + state + .objects + .get_mut(&source_id) + .expect("grant source must exist") + .protection_start_exempt_attachments + .insert( + key, + crate::game::game_object::ProtectionStartSnapshot { + resolved_quality: resolved_pt, + attachment_ids: snapshot, + }, + ); + } +} + +/// CR 702.16n / CR 702.16p: Does this protection grant's exemption rider cover +/// `attachment_id` on `host_id`? +fn protection_grant_exempts_attachment( + state: &GameState, + attachment_id: ObjectId, + grant_source_id: ObjectId, + grant_key: ProtectionEffectHostKey, + resolved_pt: &crate::types::keywords::ProtectionTarget, + exemption: Option<&crate::types::ability::ProtectionDoesNotRemove>, +) -> bool { + use crate::types::ability::ProtectionDoesNotRemove; + + let (grant_def_index, grant_mod_index, host_id) = grant_key; + + let Some(exemption) = exemption else { + return false; + }; + let Some(attachment) = state.objects.get(&attachment_id) else { + return false; + }; + match exemption { + // CR 702.16n: "this effect doesn't remove this Aura" + ProtectionDoesNotRemove::Source => attachment_id == grant_source_id, + // CR 702.16n: "this effect doesn't remove Auras" + ProtectionDoesNotRemove::Auras => attachment + .card_types + .subtypes + .iter() + .any(|s| s.eq_ignore_ascii_case("Aura")), + // CR 702.16p: only attachments snapshotted for this specific protection + // modification (`def_index`, `mod_index`) and host when it started applying. + ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached => state + .objects + .get(&grant_source_id) + .and_then(|source| { + source.protection_start_exempt_attachments.get(&( + grant_def_index, + grant_mod_index, + host_id, + )) + }) + .is_some_and(|entry| { + entry.resolved_quality == *resolved_pt + && entry.attachment_ids.contains(&attachment_id) + }), + } +} + /// CR 301.5 + CR 303.4 + CR 701.3a: True unless `host_id` is forbidden by a /// positive "can be attached only to {filter}" restriction on `attachment_id`. /// @@ -2318,4 +2683,330 @@ mod tests { ); assert_eq!(state.objects.get(&old_equipment).unwrap().attached_to, None); } + + fn spawn_grant_source(state: &mut GameState, name: &str, card_id: u64) -> ObjectId { + create_object( + state, + CardId(card_id), + PlayerId(0), + name.to_string(), + Zone::Battlefield, + ) + } + + fn apply_protection_grant( + state: &mut GameState, + source_id: ObjectId, + host_id: ObjectId, + pt: crate::types::keywords::ProtectionTarget, + exemption: Option, + ) { + use crate::types::ability::ContinuousModification; + use crate::types::keywords::Keyword; + + let mut def = StaticDefinition::continuous() + .affected(TargetFilter::SpecificObject { id: host_id }) + .modifications(vec![ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + }]); + if let Some(exemption) = exemption { + def = def.protection_does_not_remove(exemption); + } + state + .objects + .get_mut(&source_id) + .unwrap() + .static_definitions + .push(def); + } + + fn evaluate_protection_layers(state: &mut GameState) { + crate::game::layers::mark_layers_full(state); + crate::game::layers::evaluate_layers(state); + } + + fn protection_snapshot_ids( + state: &GameState, + source_id: ObjectId, + def_index: usize, + mod_index: usize, + host_id: ObjectId, + ) -> Vec { + state + .objects + .get(&source_id) + .and_then(|source| { + source + .protection_start_exempt_attachments + .get(&(def_index, mod_index, host_id)) + }) + .map(|entry| entry.attachment_ids.clone()) + .unwrap_or_default() + } + + fn replace_source_protection_statics(state: &mut GameState, source_id: ObjectId) { + use std::sync::Arc; + let obj = state.objects.get_mut(&source_id).unwrap(); + obj.static_definitions.clear(); + obj.base_static_definitions = Arc::new(Vec::new()); + obj.base_characteristics_initialized = false; + } + + #[test] + fn cr_702_16p_exempts_matching_controlled_attachment_at_grant_start() { + use crate::types::ability::ProtectionDoesNotRemove; + use crate::types::keywords::ProtectionTarget; + use crate::types::mana::ManaColor; + + let mut state = setup(); + let host = spawn_creature(&mut state, "Bear"); + let equipment = spawn_equipment(&mut state, "Sword", 10); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.color.push(ManaColor::White); + } + attach_to(&mut state, equipment, host); + + let grant_source = spawn_grant_source(&mut state, "Blessing", 11); + apply_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + + let snapshot = protection_snapshot_ids(&state, grant_source, 0, 0, host); + assert!( + snapshot.contains(&equipment), + "CR 702.16p: matching controlled attachment at grant start must be snapshotted" + ); + assert_eq!( + attachment_illegality(&state, equipment, host), + None, + "snapshotted attachment must remain legal" + ); + + let mut events = Vec::new(); + crate::game::sba::check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).unwrap().attached_to, + Some(AttachTarget::Object(host)), + "CR 702.16p: exempt Equipment must stay attached through SBA" + ); + } + + #[test] + fn cr_702_16p_does_not_exempt_attachment_that_becomes_matching_after_grant_start() { + use crate::types::ability::ProtectionDoesNotRemove; + use crate::types::keywords::ProtectionTarget; + use crate::types::mana::ManaColor; + + let mut state = setup(); + let host = spawn_creature(&mut state, "Bear"); + let equipment = spawn_equipment(&mut state, "Sword", 20); + attach_to(&mut state, equipment, host); + + let grant_source = spawn_grant_source(&mut state, "Blessing", 21); + apply_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + + assert!( + !protection_snapshot_ids(&state, grant_source, 0, 0, host).contains(&equipment), + "colorless Equipment must not enter the start-time snapshot" + ); + + state + .objects + .get_mut(&equipment) + .unwrap() + .base_color + .push(ManaColor::White); + evaluate_protection_layers(&mut state); + + assert_eq!( + attachment_illegality(&state, equipment, host), + Some(AttachIllegality::Protection), + "attachment that becomes matching only after grant start must be illegal (live-check bug)" + ); + + let mut events = Vec::new(); + crate::game::sba::check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).unwrap().attached_to, + None, + "CR 704.5n: Equipment that was not in the 702.16p snapshot must unattach" + ); + assert!( + state.battlefield.contains(&equipment), + "Equipment stays on the battlefield after illegal attachment SBA" + ); + } + + #[test] + fn cr_702_16p_second_protection_grant_without_rider_still_blocks() { + use crate::types::keywords::ProtectionTarget; + use crate::types::mana::ManaColor; + + let mut state = setup(); + let host = spawn_creature(&mut state, "Bear"); + let equipment = spawn_equipment(&mut state, "Sword", 30); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.color.push(ManaColor::White); + } + attach_to(&mut state, equipment, host); + + let rider_source = spawn_grant_source(&mut state, "Blessing", 31); + apply_protection_grant( + &mut state, + rider_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(crate::types::ability::ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + + let plain_source = spawn_grant_source(&mut state, "Mother of Runes", 32); + apply_protection_grant( + &mut state, + plain_source, + host, + ProtectionTarget::Color(ManaColor::White), + None, + ); + evaluate_protection_layers(&mut state); + + assert_eq!( + attachment_illegality(&state, equipment, host), + Some(AttachIllegality::Protection), + "a second protection instance without a 702.16n/p rider must still block" + ); + + let mut events = Vec::new(); + crate::game::sba::check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).unwrap().attached_to, + None, + "second un-ridered grant must remove despite the first grant's snapshot" + ); + } + + #[test] + fn cr_702_16p_same_source_second_effect_does_not_inherit_first_snapshot() { + use crate::types::ability::ProtectionDoesNotRemove; + use crate::types::keywords::ProtectionTarget; + use crate::types::mana::ManaColor; + + let mut state = setup(); + let host = spawn_creature(&mut state, "Bear"); + let equipment = spawn_equipment(&mut state, "Sword", 40); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.color.push(ManaColor::Blue); + } + attach_to(&mut state, equipment, host); + + let grant_source = spawn_grant_source(&mut state, "Dual Blessing", 41); + apply_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::Blue), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + assert!( + protection_snapshot_ids(&state, grant_source, 0, 0, host).contains(&equipment), + "blue rider must snapshot the blue Equipment at effect start" + ); + + replace_source_protection_statics(&mut state, grant_source); + apply_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + assert!( + !protection_snapshot_ids(&state, grant_source, 0, 0, host).contains(&equipment), + "white rider must not inherit the prior blue snapshot at the same def_index" + ); + + state + .objects + .get_mut(&equipment) + .unwrap() + .base_color + .push(ManaColor::White); + evaluate_protection_layers(&mut state); + + assert_eq!( + attachment_illegality(&state, equipment, host), + Some(AttachIllegality::Protection), + "white Equipment must be illegal once it matches the white rider" + ); + + let mut events = Vec::new(); + crate::game::sba::check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).unwrap().attached_to, + None, + "Equipment that became white after the white rider started must unattach" + ); + } + + #[test] + fn cr_702_16p_opponent_controlled_matching_attachment_not_exempt() { + use crate::types::ability::ProtectionDoesNotRemove; + use crate::types::keywords::ProtectionTarget; + use crate::types::mana::ManaColor; + + let mut state = setup(); + let host = spawn_creature(&mut state, "Bear"); + let equipment = spawn_equipment(&mut state, "Sword", 50); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.color.push(ManaColor::White); + obj.controller = PlayerId(1); + obj.base_controller = Some(PlayerId(1)); + } + attach_to(&mut state, equipment, host); + + let grant_source = spawn_grant_source(&mut state, "Blessing", 51); + apply_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + + assert!( + !protection_snapshot_ids(&state, grant_source, 0, 0, host).contains(&equipment), + "702.16p only exempts attachments you control at effect start" + ); + assert_eq!( + attachment_illegality(&state, equipment, host), + Some(AttachIllegality::Protection) + ); + + let mut events = Vec::new(); + crate::game::sba::check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).unwrap().attached_to, + None, + "opponent-controlled matching Equipment must unattach" + ); + } } diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 7b464bdf65..ee2f55903c 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -1390,6 +1390,82 @@ pub(crate) fn process_one_zone_move_with_terminal( result } +/// CR 401.4: Group object ids by owner in APNAP order for per-owner library +/// arrangement prompts. +fn group_object_ids_by_owner_apnap( + state: &GameState, + object_ids: &[ObjectId], +) -> Vec<(PlayerId, Vec)> { + use std::collections::HashMap; + let mut by_owner: HashMap> = HashMap::new(); + for &id in object_ids { + let owner = state.objects[&id].owner; + by_owner.entry(owner).or_default().push(id); + } + crate::game::players::apnap_order(state) + .into_iter() + .filter_map(|pid| by_owner.remove(&pid).map(|cards| (pid, cards))) + .collect() +} + +fn mass_library_order_effect_zone_choice( + owner: PlayerId, + cards: Vec, + source_id: ObjectId, + library_position: crate::types::ability::LibraryPosition, + track_exiled_by_source: bool, + duration: Option, +) -> WaitingFor { + let choice_count = cards.len(); + WaitingFor::EffectZoneChoice { + player: owner, + cards, + count: choice_count, + min_count: choice_count, + up_to: false, + source_id, + effect_kind: EffectKind::PutAtLibraryPosition, + zone: Zone::Library, + destination: None, + enter_tapped: EtbTapState::Unspecified, + enter_transformed: false, + enters_under_player: None, + enters_attacking: false, + owner_library: false, + track_exiled_by_source, + face_down_profile: None, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + count_param: 0, + library_position: Some(library_position), + is_cost_payment: false, + enters_modified_if: None, + duration, + } +} + +/// CR 401.4: After one owner batch of a mass library-order prompt completes, +/// surface the next owner's `EffectZoneChoice` if any remain. +pub(crate) fn resume_next_mass_library_order_choice(state: &mut GameState) -> Option { + let mut pending = state.pending_mass_library_order_choice.take()?; + let (owner, cards) = pending.remaining_batches.first()?.clone(); + pending.remaining_batches.remove(0); + if pending.remaining_batches.is_empty() { + state.pending_mass_library_order_choice = None; + } else { + state.pending_mass_library_order_choice = Some(pending.clone()); + } + state.waiting_for = mass_library_order_effect_zone_choice( + owner, + cards, + pending.source_id, + pending.library_position, + pending.track_exiled_by_source, + pending.duration, + ); + Some(owner) +} + /// Move all objects matching the filter from `Origin` zone to `Destination` zone. pub fn resolve_all( state: &mut GameState, @@ -1666,6 +1742,47 @@ pub fn resolve_all( state.push_devour_change_zone_snapshot(state.battlefield.iter().copied().collect()); } + // CR 401.4: When multiple objects are placed at the same library position + // simultaneously and `random_order` is false, the owner arranges their + // relative order ("in any order" restates this default). Route through the + // shared `EffectZoneChoice` + `PutAtLibraryPosition` production path instead + // of silently picking an engine-default batch order. + if dest_zone == Zone::Library + && effect_library_position.is_some() + && !random_order + && matching.len() > 1 + { + let owner_batches = group_object_ids_by_owner_apnap(state, &matching); + let (first_owner, first_cards) = owner_batches + .first() + .expect("matching.len() > 1 guarantees at least one owner batch") + .clone(); + let remaining_batches: Vec<_> = owner_batches.into_iter().skip(1).collect(); + if !remaining_batches.is_empty() { + state.pending_mass_library_order_choice = + Some(crate::types::game_state::PendingMassLibraryOrderChoice { + source_id: ability.source_id, + library_position: effect_library_position + .clone() + .expect("library-order branch requires an explicit library position"), + track_exiled_by_source, + duration: ability.duration.clone(), + remaining_batches, + }); + } + state.waiting_for = mass_library_order_effect_zone_choice( + first_owner, + first_cards, + ability.source_id, + effect_library_position + .clone() + .expect("library-order branch requires an explicit library position"), + track_exiled_by_source, + ability.duration.clone(), + ); + return Ok(()); + } + // CR 401.4: When placing objects on the bottom of a library "in a random // order", randomize the processing order so the final bottom-to-top sequence // is non-deterministic without shuffling the rest of the library. Top @@ -5555,6 +5672,162 @@ mod tests { ); } + /// CR 401.4: Mass library-bottom placement must prompt each card's owner to + /// arrange order, not the spell's `filter_controller` when those differ. + #[test] + fn change_zone_all_library_bottom_order_prompts_card_owner_not_controller() { + let mut state = GameState::new_two_player(42); + let card_a = create_object( + &mut state, + CardId(701), + PlayerId(1), + "Opponent Revealed A".to_string(), + Zone::Library, + ); + let card_b = create_object( + &mut state, + CardId(702), + PlayerId(1), + "Opponent Revealed B".to_string(), + Zone::Library, + ); + let card_c = create_object( + &mut state, + CardId(703), + PlayerId(1), + "Opponent Revealed C".to_string(), + Zone::Library, + ); + state.players[1].library = im::vector![card_a, card_b, card_c]; + state.last_revealed_ids = vec![card_a, card_b, card_c]; + + let ability = ResolvedAbility::new( + Effect::ChangeZoneAll { + origin: Some(Zone::Library), + destination: Zone::Library, + target: TargetFilter::LastRevealed, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enter_with_counters: vec![], + face_down_profile: None, + library_position: Some(LibraryPosition::Bottom), + random_order: false, + }, + vec![], + ObjectId(900), + PlayerId(0), + ); + + let mut events = Vec::new(); + resolve_all(&mut state, &ability, &mut events).unwrap(); + + match &state.waiting_for { + WaitingFor::EffectZoneChoice { + player, + cards, + effect_kind, + .. + } => { + assert_eq!( + *player, + PlayerId(1), + "opponent-owned cards must be ordered by their owner, not the caster" + ); + assert_eq!(cards.len(), 3); + assert_eq!(*effect_kind, EffectKind::PutAtLibraryPosition); + } + other => panic!("expected EffectZoneChoice, got {other:?}"), + } + } + + /// CR 401.4: When one mass move covers multiple owners' cards, each owner + /// receives an independent library-order prompt in APNAP order. + #[test] + fn change_zone_all_library_bottom_order_prompts_each_owner_sequentially() { + let mut state = GameState::new_two_player(42); + let p0_card = create_object( + &mut state, + CardId(711), + PlayerId(0), + "Self Revealed".to_string(), + Zone::Library, + ); + let p1_a = create_object( + &mut state, + CardId(712), + PlayerId(1), + "Opponent Revealed A".to_string(), + Zone::Library, + ); + let p1_b = create_object( + &mut state, + CardId(713), + PlayerId(1), + "Opponent Revealed B".to_string(), + Zone::Library, + ); + state.players[0].library = im::vector![p0_card]; + state.players[1].library = im::vector![p1_a, p1_b]; + state.last_revealed_ids = vec![p0_card, p1_a, p1_b]; + state.active_player = PlayerId(1); + + let ability = ResolvedAbility::new( + Effect::ChangeZoneAll { + origin: Some(Zone::Library), + destination: Zone::Library, + target: TargetFilter::LastRevealed, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enter_with_counters: vec![], + face_down_profile: None, + library_position: Some(LibraryPosition::Bottom), + random_order: false, + }, + vec![], + ObjectId(901), + PlayerId(0), + ); + + let mut events = Vec::new(); + resolve_all(&mut state, &ability, &mut events).unwrap(); + + match &state.waiting_for { + WaitingFor::EffectZoneChoice { player, cards, .. } => { + assert_eq!( + *player, + PlayerId(1), + "APNAP: active player's batch first even when active player is not seat 0" + ); + let mut sorted = cards.clone(); + sorted.sort_by_key(|id| id.0); + let mut expect = vec![p1_a, p1_b]; + expect.sort_by_key(|id| id.0); + assert_eq!(sorted, expect); + } + other => panic!("expected first-owner EffectZoneChoice, got {other:?}"), + } + assert!( + state.pending_mass_library_order_choice.is_some(), + "non-active owner batch must remain queued" + ); + + apply_as_current( + &mut state, + GameAction::SelectCards { + cards: vec![p1_a, p1_b], + }, + ) + .unwrap(); + + match &state.waiting_for { + WaitingFor::EffectZoneChoice { player, cards, .. } => { + assert_eq!(*player, PlayerId(0), "second owner receives their batch"); + assert_eq!(cards, &vec![p0_card]); + } + other => panic!("expected second-owner EffectZoneChoice, got {other:?}"), + } + } + /// Build an Exhume-shaped ability: `Effect::ChangeZone` Graveyard → /// Battlefield with a `Typed{Creature}` target carrying the post-fix /// owner constraint `Owned{ScopedPlayer}` + `InZone Graveyard`, and diff --git a/crates/engine/src/game/effects/choose_from_zone.rs b/crates/engine/src/game/effects/choose_from_zone.rs index 263ede166f..fd3e662459 100644 --- a/crates/engine/src/game/effects/choose_from_zone.rs +++ b/crates/engine/src/game/effects/choose_from_zone.rs @@ -513,7 +513,13 @@ pub(crate) fn complete_per_category_exile( events: &mut Vec, ) { if !chosen.is_empty() { - super::publish_tracked_set(state, chosen); + super::publish_tracked_set_with_causes( + state, + chosen + .iter() + .map(|&id| (id, Some(crate::types::ability::ThisWayCause::Exiled))) + .collect(), + ); } let _ = prompt_next_category_member(state, &ability, &pool, remaining_member_filters, events); } @@ -556,6 +562,12 @@ fn resolve_category_pool(state: &GameState, ability: &ResolvedAbility) -> Vec>(); let raw_keep_count = raw_keep_num.min(cards.len()); - // CR 701.20e: Pure-peek pattern (keep_count = 0): "look at the top card" with no - // player selection — the sub_ability condition decides whether to take it. Set - // last_revealed_ids so RevealedHasCardType can evaluate, then return without - // creating a DigChoice interaction. - if raw_keep_count == 0 && !is_reveal { + // CR 701.20e / CR 701.20a: Pure-peek pattern (keep_count = 0): "look at" / + // "reveal the top N" with no player selection on this step — a following + // ForEachCategory / LastRevealed move decides disposition (Portent of + // Calamity). Set last_revealed_ids (and emit CardsRevealed for public + // reveals) then return without creating a DigChoice interaction. + if raw_keep_count == 0 { state.last_revealed_ids = cards.clone(); - // CR 701.20e: "look at" privately reveals the cards to the looking - // player. The looker is the ability controller (e.g. Delver of Secrets' - // "look at the top card of your library"). Record the looker-scoped peek - // window so `filter_state_for_viewer` keeps these cards visible to the - // looker — and only the looker — through any subsequent "you may reveal - // that card" optional decision, instead of leaving the looking player to - // choose blind. - state.private_look_ids = cards.clone(); - state.private_look_player = Some(ability.controller); + if is_reveal { + // CR 701.20a: public reveal — show to all players. + for &card_id in &cards { + state.revealed_cards.insert(card_id); + } + let card_names: Vec = cards + .iter() + .filter_map(|id| state.objects.get(id).map(|o| o.name.clone())) + .collect(); + events.push(GameEvent::CardsRevealed { + player: ability.controller, + card_ids: cards.clone(), + card_names, + }); + } else { + // CR 701.20e: "look at" privately reveals the cards to the looking + // player. The looker is the ability controller (e.g. Delver of Secrets' + // "look at the top card of your library"). Record the looker-scoped peek + // window so `filter_state_for_viewer` keeps these cards visible to the + // looker — and only the looker — through any subsequent "you may reveal + // that card" optional decision, instead of leaving the looking player to + // choose blind. + state.private_look_ids = cards.clone(); + state.private_look_player = Some(ability.controller); + } events.push(GameEvent::EffectResolved { kind: EffectKind::from(&ability.effect), source_id: ability.source_id, diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index 599a411ba0..5335390454 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -197,6 +197,7 @@ pub fn resolve( TargetFilter::ParentTarget | TargetFilter::ParentTargetSlot { .. } | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::SelfRef | TargetFilter::TriggeringSource | TargetFilter::LastCreated diff --git a/crates/engine/src/game/effects/double.rs b/crates/engine/src/game/effects/double.rs index d1e61cc247..602948423b 100644 --- a/crates/engine/src/game/effects/double.rs +++ b/crates/engine/src/game/effects/double.rs @@ -322,6 +322,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, targets, kind: AbilityKind::Spell, sub_ability: None, @@ -359,6 +360,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, diff --git a/crates/engine/src/game/effects/draw.rs b/crates/engine/src/game/effects/draw.rs index bbcff0b84d..a79487e203 100644 --- a/crates/engine/src/game/effects/draw.rs +++ b/crates/engine/src/game/effects/draw.rs @@ -427,13 +427,11 @@ pub fn apply_draw_after_replacement( // `select_cards_to_draw` authority so a `DrawFromBottom` static is honored. let cards_to_draw = select_cards_to_draw(state, player_id, allowed_count as usize); - // CR 704.5b: If library has fewer cards than requested, mark the player. - // CR 121.4: Partial draws are legal — draw what's available. - if allowed_count > 0 && cards_to_draw.len() < allowed_count as usize { - if let Some(player) = state.players.iter_mut().find(|p| p.id == player_id) { - player.drew_from_empty_library = true; - } - } + // CR 704.5b: If library has fewer cards than requested, the ledger edit for + // this settled draw owns the player's empty-library fact. CR 121.4: partial + // draws are legal — draw what's available. + let mut attempted_empty_library = + allowed_count > 0 && cards_to_draw.len() < allowed_count as usize; let drawn_count = cards_to_draw.len() as u32; @@ -493,63 +491,60 @@ pub fn apply_draw_after_replacement( a swallowed NeedsChoice or otherwise failed to deliver, yet CardDrawn \ and the draw counters fire below" ); - // CR 121.1 + CR 504.1: Increment per-step + per-turn counters BEFORE - // emitting the event so the ordinal embedded in `CardDrawn` reflects - // this draw (1-indexed). Triggers/replacements that gate on "first - // draw of the draw step" read this ordinal. + let drawn_object = crate::types::identifiers::ObjectIncarnationRef::from_object( + state + .objects + .get(&obj_id) + .expect("settled draw object remains live for its ledger edit"), + ); + let established_first_draw = crate::game::ledger::resolve_and_apply_cards_drawn( + state, + player_id, + Some(drawn_object), + std::mem::take(&mut attempted_empty_library), + ) + .expect("settled draw bookkeeping must have a live player and journal cause"); + // CR 121.1 + CR 504.1: The exact ledger edit increments the counters + // before `CardDrawn` is emitted, so its ordinal is 1-indexed. + let player = state + .players + .iter() + .find(|player| player.id == player_id) + .expect("settled draw player remains live after its ledger edit"); let (nth_in_turn, nth_in_step) = - if let Some(player) = state.players.iter_mut().find(|p| p.id == player_id) { - // CR 121.1: This driver is the single authority for every - // settled draw, so it is the single place that marks the - // player as having drawn a card this turn — broadened from - // the pre-migration "took the draw-step draw" reading (the - // only production setter, deleted by the turns.rs/gift - // migration onto this driver) to "drew at least one card - // this turn". No production reader distinguishes the two; - // `turns.rs` clears it at turn start and - // `analysis/resource.rs` ignores it entirely. - player.has_drawn_this_turn = true; - player.cards_drawn_this_turn = player.cards_drawn_this_turn.saturating_add(1); - player.cards_drawn_this_step = player.cards_drawn_this_step.saturating_add(1); - (player.cards_drawn_this_turn, player.cards_drawn_this_step) - } else { - (1, 1) - }; + (player.cards_drawn_this_turn, player.cards_drawn_this_step); events.push(GameEvent::CardDrawn { player_id, object_id: obj_id, nth_in_turn, nth_in_step, }); - super::drawn_this_turn_choice::record_drawn_card(state, player_id, obj_id); - record_first_draw_and_enqueue_miracle(state, player_id, obj_id); + if established_first_draw { + enqueue_miracle_offer_for_first_draw(state, player_id, obj_id); + } + } + + if attempted_empty_library { + crate::game::ledger::resolve_and_apply_cards_drawn(state, player_id, None, true) + .expect("empty-library draw bookkeeping must have a live player and journal cause"); } drawn_count } -/// CR 702.94a + CR 603.11: Shared first-draw hook — record the drawn -/// `ObjectId` as `player`'s first-of-turn if absent, and if the drawn card has -/// `Keyword::Miracle(cost)`, enqueue a `MiracleOffer` for the priority-entry -/// flush to surface as `WaitingFor::MiracleReveal`. Subsequent draws do NOT -/// overwrite the first-draw entry and do NOT enqueue more offers (the static -/// ability only functions for the first-drawn card per CR 702.94a). -pub(crate) fn record_first_draw_and_enqueue_miracle( +/// CR 702.94a + CR 603.11: Continuation-only first-draw hook. The ledger edit +/// has already recorded `object_id` as this player's first card drawn this +/// turn; this function only enqueues the deferred miracle offer. +fn enqueue_miracle_offer_for_first_draw( state: &mut GameState, player: crate::types::player::PlayerId, object_id: crate::types::identifiers::ObjectId, ) { - // Only the FIRST draw of the turn per player establishes the miracle - // eligibility condition. `or_insert_with` returns a `&mut V` indicating - // whether the entry was freshly set; compare against `object_id` to know. - let is_first = !state.first_card_drawn_this_turn.contains_key(&player); - state - .first_card_drawn_this_turn - .entry(player) - .or_insert(object_id); - if !is_first { - return; - } + debug_assert_eq!( + state.first_card_drawn_this_turn.get(&player), + Some(&object_id), + "first-draw continuation must follow the matching ledger edit" + ); let Some(obj) = state.objects.get(&object_id) else { return; }; diff --git a/crates/engine/src/game/effects/effect.rs b/crates/engine/src/game/effects/effect.rs index f9fb4fbf27..ede66e385c 100644 --- a/crates/engine/src/game/effects/effect.rs +++ b/crates/engine/src/game/effects/effect.rs @@ -62,6 +62,21 @@ pub fn resolve( .or(duration.clone()) .unwrap_or(Duration::UntilEndOfTurn); + // CR 611.2a: The `UntilEndOfTurn` above is only a fallback for a + // stated-duration-less grant. A grant that states NO duration and grants + // ONLY an object-hosted replacement (the "it gains 'If ~ would leave the + // battlefield, exile it instead'" reanimation rider — Geth, Thane of + // Contracts; Llanowar Greenwidow; Realmbreaker; Spirit-Sister's Call) + // must last as long as the affected object exists, so promote that def to + // Duration::Permanent. It then survives its granting source leaving + // (`prune_host_left_effects` only prunes UntilHostLeavesPlay) and is + // cleaned up when the reanimated object itself leaves + // (`prune_affected_object_left_effects`). A STATED duration arrives on the + // wrapper (Elemental Expressionist's "Until end of turn" lives on the + // GenericEffect application, so `duration` is `Some` and the fallback is + // not taken) and is left EOT-confined, lapsing at cleanup (CR 514.2). + let duration_from_fallback = ability.duration.is_none() && duration.is_none(); + for static_def in static_abilities { // CR 611.2d: A continuous effect's variable (X) is determined once, // on resolution. Snapshot resolution-context quantity refs (e.g. @@ -94,6 +109,21 @@ pub fn resolve( } } let static_def = &static_def; + // CR 611.2a: promote a fallback-duration replacement-only grant to + // Permanent (see `duration_from_fallback` above); every other def + // keeps the resolved `dur`. Mixed defs (a replacement grant alongside + // any other modification) are NOT promoted. + let def_dur = if duration_from_fallback + && !static_def.modifications.is_empty() + && static_def + .modifications + .iter() + .all(|m| matches!(m, ContinuousModification::GrantReplacement { .. })) + { + Duration::Permanent + } else { + dur.clone() + }; // CR 603.4 + CR 608.2h + CR 611.2d: An in-effect "if " // carried by a `StaticDefinition` (Odric, Lunarch Marshal: // "creatures you control gain first strike ... if a creature you @@ -112,9 +142,9 @@ pub fn resolve( } let mut snapshotted = static_def.clone(); snapshotted.condition = None; - register_transient_effect(state, ability, &snapshotted, target.as_ref(), &dur); + register_transient_effect(state, ability, &snapshotted, target.as_ref(), &def_dur); } else { - register_transient_effect(state, ability, static_def, target.as_ref(), &dur); + register_transient_effect(state, ability, static_def, target.as_ref(), &def_dur); } } } diff --git a/crates/engine/src/game/effects/extra_turn.rs b/crates/engine/src/game/effects/extra_turn.rs index a3a38b41a4..0bd0738e82 100644 --- a/crates/engine/src/game/effects/extra_turn.rs +++ b/crates/engine/src/game/effects/extra_turn.rs @@ -67,6 +67,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, targets: vec![], kind: AbilityKind::Spell, sub_ability: None, @@ -104,6 +105,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, diff --git a/crates/engine/src/game/effects/flip_permanent.rs b/crates/engine/src/game/effects/flip_permanent.rs new file mode 100644 index 0000000000..6e5a234c87 --- /dev/null +++ b/crates/engine/src/game/effects/flip_permanent.rs @@ -0,0 +1,263 @@ +use crate::game::flip::flip_permanent; +use crate::types::ability::{Effect, EffectError, EffectKind, ResolvedAbility, TargetRef}; +use crate::types::events::GameEvent; +use crate::types::game_state::GameState; + +/// CR 710.4: Flip a Kamigawa flip permanent to its alternative face. +/// +/// Every printed flip instruction names the permanent itself ("flip this +/// creature" / "flip it" / "flip "), so the no-target form resolving +/// against `source_id` is the production shape; the explicit-object-target form +/// mirrors `transform_effect` so an anaphoric trigger subject can bind a +/// different permanent. +pub fn resolve( + state: &mut GameState, + ability: &ResolvedAbility, + events: &mut Vec, +) -> Result<(), EffectError> { + match &ability.effect { + Effect::FlipPermanent { .. } => {} + _ => { + return Err(EffectError::InvalidParam( + "expected FlipPermanent effect".to_string(), + )) + } + } + + // CR 710.1: if the named permanent isn't represented by a flip card, or + // isn't on the battlefield, `flip_permanent` no-ops (CR 710.2). + let object_id = match ability.targets.as_slice() { + [TargetRef::Object(object_id)] => *object_id, + [] => ability.source_id, + _ => { + return Err(EffectError::InvalidParam( + "flip expects exactly one object target".to_string(), + )) + } + }; + + // CR 400.7: a self-flip instruction must not follow a source that left the + // battlefield and returned — that is a new object (a new, unflipped + // permanent per CR 110.5b), not the one the ability was put on the stack + // for. CR 710.4 already makes a repeat flip of the SAME object a no-op, so + // no transformation-count-style generation guard is needed here. + let stale_self_flip = object_id == ability.source_id && !ability.source_is_current(state); + if !stale_self_flip { + flip_permanent(state, object_id, events) + .map_err(|err| EffectError::InvalidParam(err.to_string()))?; + } + + events.push(GameEvent::EffectResolved { + kind: EffectKind::FlipPermanent, + source_id: ability.source_id, + subject: None, + }); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::game_object::BackFaceData; + use crate::game::zones::create_object; + use crate::types::ability::TargetFilter; + use crate::types::card_type::{CardType, CoreType, Supertype}; + use crate::types::identifiers::{CardId, ObjectId}; + use crate::types::keywords::Keyword; + use crate::types::mana::{ManaColor, ManaCost, ManaCostShard}; + use crate::types::player::PlayerId; + use crate::types::zones::Zone; + + /// Nezumi Shortfang // Stabwhisker the Odious — a {1}{B} 1/1 Rat Rogue + /// whose alternative half is a 3/3 Legendary Rat Shaman. + fn setup_flip_card(state: &mut GameState) -> ObjectId { + let id = create_object( + state, + CardId(1), + PlayerId(0), + "Nezumi Shortfang".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.power = Some(1); + obj.toughness = Some(1); + obj.base_power = Some(1); + obj.base_toughness = Some(1); + obj.card_types = CardType { + supertypes: vec![], + core_types: vec![CoreType::Creature], + subtypes: vec!["Rat".to_string(), "Rogue".to_string()], + }; + obj.base_card_types = obj.card_types.clone(); + obj.mana_cost = ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 1, + }; + obj.base_mana_cost = obj.mana_cost.clone(); + obj.color = vec![ManaColor::Black]; + obj.base_color = obj.color.clone(); + obj.back_face = Some(BackFaceData { + name: "Stabwhisker the Odious".to_string(), + power: Some(3), + toughness: Some(3), + loyalty: None, + defense: None, + card_types: CardType { + supertypes: vec![Supertype::Legendary], + core_types: vec![CoreType::Creature], + subtypes: vec!["Rat".to_string(), "Shaman".to_string()], + }, + mana_cost: ManaCost::default(), + keywords: vec![Keyword::Menace], + abilities: Vec::new(), + trigger_definitions: Default::default(), + replacement_definitions: Default::default(), + static_definitions: Default::default(), + color: Vec::new(), + printed_ref: None, + modal: None, + additional_cost: None, + strive_cost: None, + casting_restrictions: vec![], + casting_options: vec![], + layout_kind: Some(crate::types::card::LayoutKind::Flip), + }); + id + } + + /// CR 710.4: the no-target form flips the ability's source — the shape + /// every printed flip card produces. + #[test] + fn flip_effect_uses_source_when_no_explicit_target() { + let mut state = GameState::new_two_player(42); + let source_id = setup_flip_card(&mut state); + let ability = ResolvedAbility::new( + Effect::FlipPermanent { + target: TargetFilter::SelfRef, + }, + vec![], + source_id, + PlayerId(0), + ); + let mut events = Vec::new(); + + resolve(&mut state, &ability, &mut events).unwrap(); + + let object = &state.objects[&source_id]; + assert!(object.flipped); + assert_eq!(object.name, "Stabwhisker the Odious"); + assert!(events.iter().any( + |event| matches!(event, GameEvent::Flipped { object_id } if *object_id == source_id) + )); + assert!(events.iter().any(|event| matches!( + event, + GameEvent::EffectResolved { + kind: EffectKind::FlipPermanent, + source_id: emitted_source, + .. + } if *emitted_source == source_id + ))); + } + + /// CR 710.1c: the resolver path preserves color and mana cost, because it + /// routes through `flip::flip_permanent` and not the double-faced + /// applicator. + #[test] + fn flip_effect_preserves_color_and_mana_cost() { + let mut state = GameState::new_two_player(42); + let source_id = setup_flip_card(&mut state); + let cost_before = state.objects[&source_id].mana_cost.clone(); + let ability = ResolvedAbility::new( + Effect::FlipPermanent { + target: TargetFilter::SelfRef, + }, + vec![], + source_id, + PlayerId(0), + ); + let mut events = Vec::new(); + + resolve(&mut state, &ability, &mut events).unwrap(); + + let object = &state.objects[&source_id]; + assert_eq!(object.mana_cost, cost_before); + assert_eq!(object.color, vec![ManaColor::Black]); + } + + /// An explicit object target flips that permanent, not the source. + #[test] + fn flip_effect_uses_explicit_object_target() { + let mut state = GameState::new_two_player(42); + let source_id = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + let target_id = setup_flip_card(&mut state); + let ability = ResolvedAbility::new( + Effect::FlipPermanent { + target: TargetFilter::Any, + }, + vec![TargetRef::Object(target_id)], + source_id, + PlayerId(0), + ); + let mut events = Vec::new(); + + resolve(&mut state, &ability, &mut events).unwrap(); + + assert!(state.objects[&target_id].flipped); + assert!(!state.objects[&source_id].flipped); + } + + /// CR 400.7: a self-flip instruction on the stack must not follow a source + /// that left and re-entered the battlefield — that is a new object. + #[test] + fn self_flip_does_not_follow_a_blinked_source() { + use crate::game::ability_utils::build_resolved_from_def; + use crate::game::stack::push_to_stack; + use crate::game::zones::move_to_zone; + use crate::types::ability::{AbilityDefinition, AbilityKind}; + use crate::types::game_state::{StackEntry, StackEntryKind}; + + let mut state = GameState::new_two_player(42); + let source_id = setup_flip_card(&mut state); + let definition = AbilityDefinition::new( + AbilityKind::Spell, + Effect::FlipPermanent { + target: TargetFilter::SelfRef, + }, + ); + let ability = build_resolved_from_def(&definition, source_id, PlayerId(0)); + let mut events = Vec::new(); + push_to_stack( + &mut state, + StackEntry { + id: ObjectId(100), + source_id, + controller: PlayerId(0), + kind: StackEntryKind::ActivatedAbility { source_id, ability }, + }, + &mut events, + ); + + move_to_zone(&mut state, source_id, Zone::Exile, &mut events); + move_to_zone(&mut state, source_id, Zone::Battlefield, &mut events); + + let entry = state.stack.pop_back().expect("flip ability on stack"); + resolve( + &mut state, + entry.ability().expect("activated ability"), + &mut events, + ) + .expect("flip ability resolves"); + + assert!( + !state.objects[&source_id].flipped, + "CR 400.7: a stale self-flip must not affect the re-entered source" + ); + } +} diff --git a/crates/engine/src/game/effects/force_block.rs b/crates/engine/src/game/effects/force_block.rs index f56d20377a..d26bce05a9 100644 --- a/crates/engine/src/game/effects/force_block.rs +++ b/crates/engine/src/game/effects/force_block.rs @@ -1,18 +1,13 @@ use crate::game::targeting::resolved_object_ids_for_filter; use crate::types::ability::{ - ContinuousModification, Duration, Effect, EffectError, EffectKind, ResolvedAbility, - TargetFilter, + ContinuousModification, Effect, EffectError, EffectKind, ResolvedAbility, TargetFilter, }; use crate::types::events::GameEvent; use crate::types::game_state::GameState; +use crate::types::identifiers::ObjectIncarnationRef; use crate::types::statics::StaticMode; -/// CR 509.1c: Force block — the target creature must block this turn if able. -/// -/// If the effect source is currently an attacker, this is the Provoke/source- -/// referential shape (CR 702.39a: "block this creature if able"), so grant -/// `MustBlockAttacker { attacker: source }`. Otherwise preserve the generic -/// attacker-agnostic `MustBlock` shape for "blocks this turn if able". +/// CR 509.1c: Force block — the target creature must block if able. /// /// Note: `MustBlock` (creature must block any attacker), `MustBlockAttacker` /// (creature must block one specific attacker), and `MustBeBlocked` (creature @@ -29,27 +24,53 @@ pub fn resolve( ability: &ResolvedAbility, events: &mut Vec, ) -> Result<(), EffectError> { - let target_filter = match &ability.effect { - Effect::ForceBlock { target } => target, + let (target_filter, named_attacker, has_named_attacker, duration) = match &ability.effect { + Effect::ForceBlock { + target, + attacker, + duration, + .. + } => ( + target, + ability.force_block_attacker.or_else(|| match attacker { + Some(crate::types::ability::ForceBlockAttackerRef::Source) => ability + .source_incarnation + .map(|incarnation| ObjectIncarnationRef { + object_id: ability.source_id, + incarnation, + }), + _ => None, + }), + attacker.is_some(), + duration, + ), _ => return Ok(()), }; - let source_is_active_attacker = state.combat.as_ref().is_some_and(|combat| { - combat - .attackers - .iter() - .any(|attacker| attacker.object_id == ability.source_id) - }); - let mode = if source_is_active_attacker { - // CR 702.39a + CR 509.1c: Provoke/source-referential force-block - // effects require blocking this specific attacking source. - StaticMode::MustBlockAttacker { - attacker: ability.source_id, + let mode = match named_attacker { + // CR 400.7 + CR 509.1c: Only a still-live exact incarnation that is + // currently attacking is a legal named attacker. A departed/re-entered + // object cannot be rediscovered from its raw id. + Some(attacker) + if state.combat.as_ref().is_some_and(|combat| { + combat.attackers.iter().any(|info| { + info.object_id == attacker.object_id + && state + .objects + .get(&attacker.object_id) + .is_some_and(|object| { + ObjectIncarnationRef::from_object(object) == attacker + }) + }) + }) => + { + StaticMode::MustBlockAttacker { attacker } } - } else { - // CR 509.1c: Generic "blocks this turn if able" effects only require - // blocking some legal attacker. - StaticMode::MustBlock + // An unavailable named referent cannot become a generic requirement. + // The instruction has no attacker it can require a block against. + Some(_) => return Ok(()), + None if has_named_attacker => return Ok(()), + None => StaticMode::MustBlock, }; for obj_id in resolved_object_ids_for_filter(state, ability, target_filter) { @@ -59,14 +80,16 @@ pub fn resolve( continue; } - state.add_transient_continuous_effect( + let recipient = ObjectIncarnationRef::from_object(&state.objects[&obj_id]); + let effect_id = state.add_transient_continuous_effect( ability.source_id, ability.controller, - Duration::UntilEndOfTurn, + duration.clone(), TargetFilter::SpecificObject { id: obj_id }, vec![ContinuousModification::AddStaticMode { mode: mode.clone() }], None, ); + state.set_transient_affected_recipient(effect_id, recipient); } events.push(GameEvent::EffectResolved { @@ -82,8 +105,10 @@ mod tests { use super::*; use crate::game::combat::{AttackerInfo, CombatState}; use crate::game::zones::create_object; - use crate::types::ability::{ControllerRef, Effect, TargetRef, TypedFilter}; - use crate::types::identifiers::{CardId, ObjectId}; + use crate::types::ability::{ + ControllerRef, Effect, ForceBlockAttackerRef, TargetRef, TypedFilter, + }; + use crate::types::identifiers::{CardId, ObjectId, ObjectIncarnationRef}; use crate::types::player::PlayerId; use crate::types::zones::Zone; @@ -91,6 +116,8 @@ mod tests { ResolvedAbility::new( Effect::ForceBlock { target: TargetFilter::Any, + attacker: None, + duration: crate::types::ability::Duration::UntilEndOfTurn, }, vec![TargetRef::Object(target)], source, @@ -166,7 +193,14 @@ mod tests { ..Default::default() }); - let ability = make_force_block_ability(source, target); + let mut ability = make_force_block_ability(source, target); + ability.effect = Effect::ForceBlock { + target: TargetFilter::Any, + attacker: Some(ForceBlockAttackerRef::Source), + duration: crate::types::ability::Duration::UntilEndOfTurn, + }; + ability.force_block_attacker = + Some(ObjectIncarnationRef::from_object(&state.objects[&source])); let mut events = Vec::new(); resolve(&mut state, &ability, &mut events).unwrap(); @@ -177,7 +211,7 @@ mod tests { m, ContinuousModification::AddStaticMode { mode: StaticMode::MustBlockAttacker { attacker }, - } if *attacker == source + } if attacker.object_id == source ) }) }), @@ -185,6 +219,83 @@ mod tests { ); } + #[test] + fn tolsimir_attack_trigger_binds_the_wolf_not_tolsimir() { + // Regression: the old resolver inferred the named attacker from the + // triggered ability's source. Tolsimir is not the attacker named by + // "that Wolf"; the event Wolf is. Reverting the pending-ability + // provenance binding makes this assertion select Tolsimir or generic + // MustBlock instead. + let mut state = GameState::new_two_player(42); + let tolsimir = create_object( + &mut state, + CardId(9), + PlayerId(0), + "Tolsimir, Midnight's Light".to_string(), + Zone::Battlefield, + ); + let wolf = create_object( + &mut state, + CardId(418), + PlayerId(0), + "Voja Fenstalker".to_string(), + Zone::Battlefield, + ); + let blocker = create_object( + &mut state, + CardId(3), + PlayerId(1), + "Opponent Bear".to_string(), + Zone::Battlefield, + ); + state.combat = Some(CombatState { + attackers: vec![AttackerInfo::attacking_player(wolf, PlayerId(1))], + ..Default::default() + }); + + let mut ability = ResolvedAbility::new( + Effect::ForceBlock { + target: TargetFilter::Any, + attacker: Some(ForceBlockAttackerRef::EventSource), + duration: crate::types::ability::Duration::UntilEndOfCombat, + }, + vec![TargetRef::Object(blocker)], + tolsimir, + PlayerId(0), + ); + ability.bind_force_block_attacker_recursive(Some(ObjectIncarnationRef::from_object( + &state.objects[&wolf], + ))); + + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).unwrap(); + + let effect = state + .transient_continuous_effects + .iter() + .find(|effect| effect.affected_recipient.is_some()) + .cloned() + .expect("targeted force block installs an exact-recipient TCE"); + assert_eq!( + effect.affected_recipient, + Some(ObjectIncarnationRef::from_object(&state.objects[&blocker])) + ); + assert!(effect.modifications.iter().any(|modification| { + matches!( + modification, + ContinuousModification::AddStaticMode { + mode: StaticMode::MustBlockAttacker { attacker }, + } if attacker.object_id == wolf + ) + })); + + state.objects.get_mut(&blocker).unwrap().incarnation += 1; + assert!( + !crate::game::layers::transient_effect_is_live(&state, &effect), + "the targeted force-block TCE must not apply to a later blocker incarnation" + ); + } + #[test] fn force_block_multiple_targets() { let mut state = GameState::new_two_player(42); @@ -213,6 +324,8 @@ mod tests { let ability = ResolvedAbility::new( Effect::ForceBlock { target: TargetFilter::Any, + attacker: None, + duration: crate::types::ability::Duration::UntilEndOfTurn, }, vec![TargetRef::Object(target1), TargetRef::Object(target2)], source, @@ -285,6 +398,8 @@ mod tests { target: TargetFilter::Typed( TypedFilter::creature().controller(ControllerRef::Opponent), ), + attacker: None, + duration: crate::types::ability::Duration::UntilEndOfTurn, }, vec![], source, diff --git a/crates/engine/src/game/effects/gift_delivery.rs b/crates/engine/src/game/effects/gift_delivery.rs index d3f70c5992..fbc1160cc6 100644 --- a/crates/engine/src/game/effects/gift_delivery.rs +++ b/crates/engine/src/game/effects/gift_delivery.rs @@ -9,9 +9,9 @@ use crate::types::mana::ManaColor; use crate::types::player::PlayerId; use crate::types::zones::Zone; -/// CR 702.174: Deliver a gift to the opponent of the ability's controller. +/// CR 702.174: Deliver a gift to the opponent chosen when the gift cost was paid. /// Gift delivery is a no-op when the gift wasn't promised (`additional_cost_paid == false`). -/// When promised, the opponent receives the gift before the spell's other effects resolve. +/// When promised, the chosen opponent receives the gift before the spell's other effects resolve. pub fn resolve( state: &mut GameState, ability: &ResolvedAbility, @@ -33,8 +33,14 @@ pub fn resolve( return Ok(()); } - // In 2-player, the opponent is the next player after the controller. - let opponent = players::next_player(state, ability.controller); + // CR 702.174a/e: Deliver to the opponent chosen when the gift cost was paid. + // Prefer the cast-time SpellContext latch, then the finalize stamp on the + // source object. Never fall back to turn-order `next_player`. + let Some(opponent) = resolve_gift_recipient(state, ability) else { + // CR 800.4b / CR 609.3: Latched recipient left the game, or the cast + // path failed to stamp a recipient — do as much as possible (nothing). + return Ok(()); + }; // CR 702.174b: On a permanent, the gift ability triggers when the permanent enters. // CR 702.174j: For instants/sorceries, the gift effect always happens first. @@ -90,6 +96,18 @@ pub fn resolve( Ok(()) } +/// CR 702.174a: Resolve the latched gift recipient. +fn resolve_gift_recipient(state: &GameState, ability: &ResolvedAbility) -> Option { + let candidate = ability.context.gift_recipient.or_else(|| { + state + .objects + .get(&ability.source_id) + .and_then(|obj| obj.gift_recipient) + })?; + // CR 800.4: Only deliver if the chosen player is still in the game. + players::is_alive(state, candidate).then_some(candidate) +} + /// Deliver "gift a card" — opponent draws one card. /// Routes through the single-authority `start_draw_sequence` path so /// draw-replacement effects apply and CR 121.1's `allowed_draw_count` gate @@ -177,6 +195,10 @@ mod tests { PlayerId(0), ); ability.context.additional_cost_paid = promised; + if promised { + // CR 702.174a: Latched recipient (2p sole opponent = P1). + ability.context.gift_recipient = Some(PlayerId(1)); + } ability } @@ -201,6 +223,67 @@ mod tests { )); } + #[test] + fn gift_card_uses_source_object_recipient_when_context_is_absent() { + let mut state = GameState::new_two_player(42); + let source_id = zones::create_object( + &mut state, + CardId(2), + PlayerId(0), + "Gift Source".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&source_id).unwrap().gift_recipient = Some(PlayerId(1)); + let card_id = zones::create_object( + &mut state, + CardId(1), + PlayerId(1), + "Opponent Card".to_string(), + Zone::Library, + ); + let mut events = Vec::new(); + + let mut ability = make_gift_ability(GiftKind::Card, true); + ability.source_id = source_id; + ability.context.gift_recipient = None; + resolve(&mut state, &ability, &mut events).unwrap(); + + assert!(state.players[1].hand.contains(&card_id)); + assert!(events.iter().any( + |event| matches!(event, GameEvent::CardDrawn { player_id, .. } if *player_id == PlayerId(1)) + )); + } + + #[test] + fn gift_card_noops_for_eliminated_recipient() { + let mut state = GameState::new_two_player(42); + let card_id = zones::create_object( + &mut state, + CardId(1), + PlayerId(1), + "Opponent Card".to_string(), + Zone::Library, + ); + state.players[1].is_eliminated = true; + let mut events = Vec::new(); + + let ability = make_gift_ability(GiftKind::Card, true); + assert_eq!(resolve_gift_recipient(&state, &ability), None); + resolve(&mut state, &ability, &mut events).unwrap(); + + assert!(state.players[1].library.contains(&card_id)); + assert!(!events + .iter() + .any(|event| matches!(event, GameEvent::CardDrawn { .. }))); + assert!(!events.iter().any(|event| matches!( + event, + GameEvent::EffectResolved { + kind: EffectKind::GiftDelivery, + .. + } + ))); + } + #[test] fn gift_card_noop_when_not_promised() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs b/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs index 0b7baed07d..761be2b674 100644 --- a/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs +++ b/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs @@ -73,7 +73,9 @@ pub fn resolve( #[cfg(test)] mod tests { use super::*; - use crate::types::ability::{AbilityKind, QuantityExpr, SpellContext, SubAbilityLink}; + use crate::types::ability::{ + AbilityKind, QuantityExpr, SiblingCondition, SpellContext, SubAbilityLink, + }; use crate::types::identifiers::ObjectId; use crate::types::player::PlayerId; @@ -91,6 +93,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, targets: vec![], kind: AbilityKind::Activated, sub_ability: None, @@ -128,6 +131,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: SubAbilityLink::ContinuationStep, + sibling_condition: SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 414d1df19a..2354f0b401 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -14,7 +14,8 @@ use crate::types::ability::{ EffectKind, EffectOutcomeSignal, EffectScope, FilterProp, OpponentMayScope, PlayerFilter, PlayerScope, PtValue, QuantityExpr, QuantityRef, RepeatContinuation, ResolvedAbility, RevealUntilDisposition, SacrificeCost, SacrificeRequirement, SharedQuality, - SharedQualityRelation, SubAbilityLink, TapStateChange, TargetFilter, TargetRef, ThisWayCause, + SharedQualityRelation, SiblingCondition, SubAbilityLink, TapStateChange, TargetChoiceTiming, + TargetFilter, TargetRef, ThisWayCause, }; #[cfg(test)] use crate::types::ability::{AttackScope, AttackSubject}; @@ -121,6 +122,7 @@ pub mod explore; pub mod extra_turn; pub mod fight; pub mod flip_coin; +pub mod flip_permanent; pub mod forage; pub mod force_attack; pub mod force_block; @@ -338,7 +340,14 @@ pub(crate) fn matches_player_scope( PlayerFilter::AllExcept { exclude } => { !matches_player_scope(state, p.id, exclude, controller, source_id) } - PlayerFilter::Opponent => p.id != controller, + // CR 102.2 / CR 102.3: "opponent" is a game-topology + // relation, not raw player-ID inequality. In team + // multiplayer a teammate is neither the controller nor an + // opponent, so effect-recipient enumeration must route + // through the canonical `players::is_opponent` authority. + PlayerFilter::Opponent => { + crate::game::players::is_opponent(state, controller, p.id) + } PlayerFilter::DefendingPlayer => { crate::game::targeting::resolve_event_context_target_for_event_or_state( state, @@ -351,10 +360,12 @@ pub(crate) fn matches_player_scope( ) } PlayerFilter::OpponentLostLife => { - p.id != controller && p.life_lost_this_turn > 0 + crate::game::players::is_opponent(state, controller, p.id) + && p.life_lost_this_turn > 0 } PlayerFilter::OpponentGainedLife => { - p.id != controller && p.life_gained_this_turn > 0 + crate::game::players::is_opponent(state, controller, p.id) + && p.life_gained_this_turn > 0 } // CR 104.5 / CR 800.4: Players who lost have left the game; // this filter is quantity-only and has no live effect recipient. @@ -382,7 +393,7 @@ pub(crate) fn matches_player_scope( ), // CR 508.6: opponent the subject attacked within scope. PlayerFilter::OpponentAttacked { subject, scope } => { - p.id != controller + crate::game::players::is_opponent(state, controller, p.id) && state .opponent_attacked(*subject, *scope, controller, source_id, p.id) } @@ -393,7 +404,7 @@ pub(crate) fn matches_player_scope( // never appear in `combat.attackers` for `DefendingPlayer`), // resolved via the shared event-context target resolver. PlayerFilter::OpponentAttackingEnchantedPlayer => { - p.id != controller + crate::game::players::is_opponent(state, controller, p.id) && enchanted_player_anchor(state, source_id).is_some_and(|enchanted| { state.player_attacked_player_this_combat(p.id, enchanted) }) @@ -1634,7 +1645,7 @@ pub(crate) fn resolve_effect_pay_cost_rider( return Ok(()); }; let mut rider = sub.as_ref().clone(); - if rider.targets.is_empty() && !ability.targets.is_empty() { + if should_propagate_parent_targets(ability, &rider) { rider.targets = ability.targets.clone(); } apply_parent_chain_context(&mut rider, ability, None, state); @@ -1660,7 +1671,7 @@ pub(crate) fn prepend_remaining_pay_cost_continuation( if let Some(sub) = ability.sub_ability.as_ref() { let mut sub_clone = sub.as_ref().clone(); - if sub_clone.targets.is_empty() && !ability.targets.is_empty() { + if should_propagate_parent_targets(ability, &sub_clone) { sub_clone.targets = ability.targets.clone(); } apply_parent_chain_context(&mut sub_clone, ability, None, state); @@ -1849,13 +1860,37 @@ fn moved_object_context_from_events(events: &[GameEvent]) -> Option Some(CostPaidObjectSnapshot { - object_id: *object_id, - lki: lki_snapshot_from_zone_change_record(record), - }), + } if is_public_zone(*to) + // CR 400.7j: the public-destination arm above — "If an effect causes + // an object to move to a public zone, other parts of that effect can + // find that object." Findability is public-only, so a HIDDEN + // destination needs its own rules basis, below. + // + // CR 608.2h: "... if it's no longer in that zone, or if the effect + // has moved it from a public zone to a hidden zone, the effect uses + // the object's last known information." A public -> Hand move is + // exactly that clause, so a later instruction may still refer to the + // moved object — Volcanic Vision ("Return target instant or sorcery + // card from your graveyard to your hand. ~ deals damage equal to + // THAT CARD'S MANA VALUE ..."). The reference reads the move-time + // LKI snapshot, so the hidden destination is immaterial. + // + // A hidden SOURCE (a draw, Library -> Hand) is not a public -> hidden + // move, establishes no last known information, and is excluded. + // Library destinations stay excluded entirely (a shuffle/reorder + // loses the object's identity). Note the dominant newly captured + // population is ordinary bounce (Battlefield -> Hand), not only + // graveyard/exile recursion. + || (*to == Zone::Hand && is_public_zone(*from_zone)) => + { + Some(CostPaidObjectSnapshot { + object_id: *object_id, + lki: lki_snapshot_from_zone_change_record(record), + }) + } _ => None, }); let first = moved.next()?; @@ -2384,6 +2419,22 @@ fn apply_parent_chain_context( } } +/// CR 608.2d: whether `ability`'s already-chosen targets should propagate into +/// an empty-targeted `sub`. Single authority for every `sub.targets = +/// ability.targets.clone()` propagation site in this file — a `Resolution`- +/// timed sub makes its OWN untargeted choice at its own resolution (see the +/// interactive `PutCounter` recipient prompt in `resolve_chain_body`) and must +/// NOT inherit an earlier instruction's already-chosen recipient, or every +/// replicated instruction in a chain collapses onto whichever single object +/// the first one picked (Kathril, Aspect Warper, issue #6321 / PR #6533). +/// Every other sub keeps today's behavior: parent targets propagate when the +/// sub declares none of its own. +fn should_propagate_parent_targets(ability: &ResolvedAbility, sub: &ResolvedAbility) -> bool { + sub.targets.is_empty() + && !ability.targets.is_empty() + && sub.target_choice_timing != TargetChoiceTiming::Resolution +} + fn waits_for_resolution_choice(waiting_for: &WaitingFor) -> bool { matches!( waiting_for, @@ -2584,8 +2635,7 @@ pub(super) fn resolve_optional_effect_decision( // carries its OWN target filter (e.g. a Chaos-Wand cleanup that // returns `ExiledBySource` cards) from being clobbered with the // parent's targets. - if resolved.targets.is_empty() - && !ability.targets.is_empty() + if should_propagate_parent_targets(&ability, &resolved) && effect_refs_parent_target(&resolved.effect) { resolved.targets = ability.targets.clone(); @@ -2652,9 +2702,66 @@ fn condition_depends_on_effect_performed(condition: &AbilityCondition) -> bool { /// alongside the `WhenYouDo` reflexive, then re-evaluated once the choice /// resolves and `last_zone_changed_ids` reflects the moved objects. Predicate /// helper, not rule-implementing code. +fn filter_contains_last_zone_changed(filter: &TargetFilter) -> bool { + crate::game::filter::filter_contains_last_zone_changed(filter) +} + +fn quantity_expr_depends_on_zone_change_this_way(expr: &QuantityExpr) -> bool { + match expr { + QuantityExpr::Ref { qty } => quantity_ref_depends_on_zone_change_this_way(qty), + QuantityExpr::DivideRounded { inner, .. } + | QuantityExpr::Multiply { inner, .. } + | QuantityExpr::ClampMin { inner, .. } + | QuantityExpr::Offset { inner, .. } => { + quantity_expr_depends_on_zone_change_this_way(inner) + } + QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => exprs + .iter() + .any(quantity_expr_depends_on_zone_change_this_way), + QuantityExpr::UpTo { max } => quantity_expr_depends_on_zone_change_this_way(max), + QuantityExpr::Power { exponent, .. } => { + quantity_expr_depends_on_zone_change_this_way(exponent) + } + QuantityExpr::Difference { left, right } => { + quantity_expr_depends_on_zone_change_this_way(left) + || quantity_expr_depends_on_zone_change_this_way(right) + } + QuantityExpr::Fixed { .. } => false, + } +} + +fn quantity_ref_depends_on_zone_change_this_way(qty: &QuantityRef) -> bool { + match qty { + QuantityRef::ObjectCount { filter } + | QuantityRef::ObjectCountDistinct { filter, .. } + | QuantityRef::ObjectCountBySharedQuality { filter, .. } + | QuantityRef::CountersOnObjects { filter, .. } + | QuantityRef::Aggregate { filter, .. } + | QuantityRef::ControlledByEachPlayer { filter, .. } + | QuantityRef::DistinctColorsAmongPermanents { filter } + | QuantityRef::DistinctCounterKindsAmong { filter } + | QuantityRef::EnteredThisTurn { filter } + | QuantityRef::BattlefieldEntriesThisTurn { filter, .. } => { + filter_contains_last_zone_changed(filter) + } + QuantityRef::DistinctCardTypes { + source: crate::types::ability::CardTypeSetSource::Objects { filter }, + } + | QuantityRef::DistinctSubtypes { + source: crate::types::ability::CardTypeSetSource::Objects { filter }, + .. + } => filter_contains_last_zone_changed(filter), + _ => false, + } +} + fn condition_depends_on_zone_change_this_way(condition: &AbilityCondition) -> bool { match condition { AbilityCondition::ZoneChangedThisWay { .. } => true, + AbilityCondition::QuantityCheck { lhs, rhs, .. } => { + quantity_expr_depends_on_zone_change_this_way(lhs) + || quantity_expr_depends_on_zone_change_this_way(rhs) + } AbilityCondition::Not { condition } => condition_depends_on_zone_change_this_way(condition), AbilityCondition::And { conditions } | AbilityCondition::Or { conditions } => conditions .iter() @@ -3313,6 +3420,34 @@ fn split_player_scope_chain( (scoped, tail) } +/// CR 608.2c: A multi-target player subject owns only its same-sentence +/// continuation chain. A `SequentialSibling` starts an independent instruction +/// that resolves once after every selected player has completed the subject's +/// local clauses. This is the target-subject counterpart to +/// `split_player_scope_chain` above. +fn split_multi_target_player_chain( + ability: &ResolvedAbility, +) -> (ResolvedAbility, Option>) { + let mut per_target = ability.clone(); + let tail = detach_after_multi_target_player_local_chain(&mut per_target); + (per_target, tail) +} + +/// CR 608.2c: Retain `ContinuationStep` clauses under the current "each target +/// player" subject, but detach the first independently sequenced instruction. +fn detach_after_multi_target_player_local_chain( + node: &mut ResolvedAbility, +) -> Option> { + let mut next = node.sub_ability.take()?; + if next.sub_link != SubAbilityLink::ContinuationStep { + return Some(next); + } + + let tail = detach_after_multi_target_player_local_chain(&mut next); + node.sub_ability = Some(next); + tail +} + /// CR 608.2e: Collect cross-player equalization quantity references from a /// `QuantityExpr`. These are the refs whose value would shift as an APNAP /// fan-out mutates the board — `ControlledByEachPlayer` (battlefield extremum) @@ -3552,6 +3687,23 @@ fn effect_target_filter(effect: &Effect) -> Option<&TargetFilter> { effect.target_filter() } +/// CR 601.2c: Oracle's `target opponents` lowering can use the dedicated +/// `TargetFilter::Opponent` player role or a type-less `TypedFilter` constrained +/// solely by `ControllerRef::Opponent`; `target players` uses +/// `TargetFilter::Player`. All three select players at announcement and need +/// the same per-target resolution fan-out. +fn is_multi_target_player_filter(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::Player | TargetFilter::Opponent => true, + TargetFilter::Typed(typed) => { + typed.type_filters.is_empty() + && typed.properties.is_empty() + && matches!(typed.controller, Some(ControllerRef::Opponent)) + } + _ => false, + } +} + // ── Batch resolution (Tier 3) ──────────────────────────────────────────── // // Driver-level collapse of N contiguous, identical, observer-free @@ -3853,6 +4005,20 @@ pub fn resolve_effect( copy_token_blocking::resolve(state, ability, events) } Effect::BecomeCopy { .. } => become_copy::resolve(state, ability, events), + // CR 614.12a + CR 707.2c (Metamorphic Alteration): `ChoosePermanent` is an + // as-enters replacement-effect choice, NOT a stack-resolution effect. It is + // raised by `apply_post_replacement_effect` and answered by + // `handle_copy_target_choice` (PersistChosenAttribute). It never enters the + // normal resolution chain, so reaching this dispatch arm is a bug. + Effect::ChoosePermanent { .. } => { + debug_assert!( + false, + "Effect::ChoosePermanent must be handled via the Aura-ETB \ + replacement path (apply_post_replacement_effect), never resolved \ + as a stack effect" + ); + Ok(()) + } Effect::GainActivatedAbilitiesOfTarget { .. } => { gain_activated_abilities::resolve(state, ability, events) } @@ -3872,6 +4038,9 @@ pub fn resolve_effect( Effect::Discard { .. } => discard::resolve(state, ability, events), Effect::Shuffle { .. } => shuffle::resolve(state, ability, events), Effect::Transform { .. } => transform_effect::resolve(state, ability, events), + // CR 710.4: Kamigawa flip cards — a separate resolver from Transform + // because CR 710.1c keeps color and mana cost unchanged. + Effect::FlipPermanent { .. } => flip_permanent::resolve(state, ability, events), Effect::SearchLibrary { .. } => search_library::resolve(state, ability, events), Effect::SearchOutsideGame { .. } => search_outside_game::resolve(state, ability, events), Effect::Seek { .. } => seek::resolve(state, ability, events), @@ -4742,8 +4911,17 @@ fn affected_objects_from_events( // CR 701.20b + CR 608.2c: Reveal instructions do not move cards, so they // emit `CardsRevealed` rather than `ZoneChanged`. Publish the revealed // card ids for downstream "from among the revealed cards" - // `ChooseFromZone` continuations (Atraxa, Grand Unifier class). - Effect::RevealTop { .. } | Effect::RevealHand { .. } | Effect::Clash => events + // `ChooseFromZone` / `ForEachCategory` continuations (Atraxa, Portent of + // Calamity). Reveal-only Digs with `keep_count: 0` take the same path — + // they never emit ZoneChanged either. + Effect::RevealTop { .. } + | Effect::RevealHand { .. } + | Effect::Clash + | Effect::Dig { + reveal: true, + keep_count: Some(0), + .. + } => events .iter() .filter_map(|event| match event { GameEvent::CardsRevealed { card_ids, .. } => Some(card_ids.as_slice()), @@ -5820,6 +5998,44 @@ fn filter_uses_relative_controller_scoped(filter: &TargetFilter) -> bool { } } +/// CR 109.4 + CR 115.10a (issue #6505): True when this ability's effect — or any +/// effect in its sub-ability / else-ability chain — carries a `ScopedPlayer`- +/// scoped move-object filter ("a creature they control" / "their graveyard"). +/// Used by `stack::resolve_top` to bind the resolution-time scoped player to the +/// ability's single player target, so a "target opponent exiles a creature they +/// control" chooser is the target opponent, not the caster. +pub(crate) fn ability_uses_relative_controller_scoped(ability: &ResolvedAbility) -> bool { + fn effect_moves_scoped_filter(effect: &Effect) -> bool { + if effect + .target_filter() + .is_some_and(filter_uses_relative_controller_scoped) + { + return true; + } + // `Effect::target_filter()` does not surface `ChangeZoneAll`'s mass + // filter, so check it explicitly (the "their graveyard" leg). + matches!( + effect, + Effect::ChangeZoneAll { target, .. } if filter_uses_relative_controller_scoped(target) + ) + } + let mut node = Some(ability); + while let Some(current) = node { + if effect_moves_scoped_filter(¤t.effect) { + return true; + } + if current + .else_ability + .as_deref() + .is_some_and(ability_uses_relative_controller_scoped) + { + return true; + } + node = current.sub_ability.as_deref(); + } + false +} + pub(crate) fn controller_for_relative_filter( ability: &ResolvedAbility, target_filter: &TargetFilter, @@ -6233,6 +6449,8 @@ fn extract_event_context_filter(effect: &Effect) -> Option<&TargetFilter> { | Effect::Attach { target, .. } | Effect::UnattachAll { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::CopySpell { target, .. } | Effect::CastCopyOfCard { target, .. } | Effect::CopyTokenOf { target, .. } @@ -7562,11 +7780,12 @@ fn resolve_chain_body( return Ok(()); } - // CR 603.3d + CR 601.2c: Multi-target-over-`Player` resolution fan-out. - // An "any number of target players each " trigger/spell announces a - // variable number of player targets when it goes on the stack (CR 601.2c, - // reached for triggers via CR 603.3d). The chosen player set lands in - // `ability.targets` as `TargetRef::Player` entries with `multi_target` set. + // CR 603.3d + CR 601.2c: Multi-target player-subject resolution fan-out. + // An "any number of target players/opponents each " trigger/spell + // announces a variable number of player targets when it goes on the stack + // (CR 601.2c, reached for triggers via CR 603.3d). The chosen player set + // lands in `ability.targets` as `TargetRef::Player` entries with + // `multi_target` set. // Single-player-recipient effect handlers (`Discard`, `Mill`, `LoseLife`) // resolve for only the FIRST `TargetRef::Player`, so without this branch a // selection of two players discards only one. This fan-out is the missing @@ -7580,7 +7799,7 @@ fn resolve_chain_body( // `multi_target` and changes the effect's target filter, which would // otherwise skip this branch and resolve only the first chosen player. if ability.multi_target.is_some() - && effect_target_filter(&ability.effect) == Some(&TargetFilter::Player) + && effect_target_filter(&ability.effect).is_some_and(is_multi_target_player_filter) { let chosen_players: Vec = ability .targets @@ -7591,6 +7810,11 @@ fn resolve_chain_body( }) .collect(); if chosen_players.len() != 1 { + // A sentence boundary begins an instruction that is not qualified + // by the preceding "each target player" subject. Keep that tail + // outside the per-target template so Wheel and Deal's final + // "Draw a card" is performed once by its controller. + let (per_target, after_fanout) = split_multi_target_player_chain(ability); // CR 601.2c: "any number of target players" permits zero targets. // CR 608.2c: An ability resolving with zero chosen player targets // does nothing — emit `EffectResolved` and stop BEFORE @@ -7605,6 +7829,9 @@ fn resolve_chain_body( source_id: ability.source_id, subject: None, }); + if let Some(after_fanout) = after_fanout { + resolve_ability_chain(state, &after_fanout, events, depth + 1)?; + } return Ok(()); } // CR 101.4 + CR 608.2c: Resolve the effect once per chosen player in @@ -7617,7 +7844,7 @@ fn resolve_chain_body( .collect(); let initial_waiting_for = state.waiting_for.clone(); for (i, pid) in fanout_players.iter().enumerate() { - let mut narrowed = ability.clone(); + let mut narrowed = per_target.clone(); narrowed.targets = vec![TargetRef::Player(*pid)]; narrowed.multi_target = None; resolve_ability_chain(state, &narrowed, events, depth + 1)?; @@ -7629,9 +7856,9 @@ fn resolve_chain_body( // `drain_pending_continuation` after the choice resolves. if state.waiting_for != initial_waiting_for { let remaining = &fanout_players[i + 1..]; - let mut tail: Option> = None; + let mut tail = after_fanout.clone(); for &remaining_pid in remaining.iter().rev() { - let mut remaining_narrowed = ability.clone(); + let mut remaining_narrowed = per_target.clone(); remaining_narrowed.targets = vec![TargetRef::Player(remaining_pid)]; remaining_narrowed.multi_target = None; if let Some(prev) = tail { @@ -7646,6 +7873,11 @@ fn resolve_chain_body( break; } } + if state.waiting_for == initial_waiting_for { + if let Some(after_fanout) = after_fanout { + resolve_ability_chain(state, &after_fanout, events, depth + 1)?; + } + } return Ok(()); } } @@ -8090,7 +8322,7 @@ fn resolve_chain_body( if !evaluate_condition(condition, state, ability) { if let Some(ref else_branch) = ability.else_ability { let mut else_resolved = else_branch.as_ref().clone(); - if else_resolved.targets.is_empty() && !ability.targets.is_empty() { + if should_propagate_parent_targets(ability, &else_resolved) { else_resolved.targets = ability.targets.clone(); } else_resolved.context = ability.context.clone(); @@ -8142,16 +8374,44 @@ fn resolve_chain_body( sub.condition.as_ref(), Some(AbilityCondition::PostReplacementDamageSourceMatchesFilter { .. }) ); + // CR 702.1c ("the same is true") + CR 608.2c (written order): A + // sub produced by per-item keyword-list replication + // (`SiblingCondition::ReplicatedOrBranch`) is an INDEPENDENT + // OR-branch gated on its OWN keyword — Mutable Pupa's "perpetually + // gains if that creature has " and Kathril's "put a + // counter if a creature card in your graveyard has ". + // Its gate references neither this node's effect nor this node's + // keyword, so it must be evaluated regardless of whether this + // node's own gate (K_j's keyword check) held. Without this, once + // any earlier sibling's gate is false the rest of the keyword list + // never resolves ("list collapse"). Same shape of independent + // per-branch gate as `PostReplacementDamageSourceMatchesFilter` + // above, keyed on the replication marker rather than the condition + // variant (the gate here is a plain `ZoneChangeObjectMatchesFilter` + // / `QuantityCheck` that would otherwise look dependent). + let sub_is_replicated_or_branch = sub.sibling_condition + == SiblingCondition::ReplicatedOrBranch + && sub.sub_link == SubAbilityLink::SequentialSibling; if sub .condition .as_ref() .is_some_and(condition_depends_on_effect_performed) || sub_has_independent_event_gate + || sub_is_replicated_or_branch || (sub.sub_link == SubAbilityLink::SequentialSibling && sub.condition.is_none()) { let mut sub_resolved = sub.as_ref().clone(); - if sub_resolved.targets.is_empty() && !ability.targets.is_empty() { + // CR 608.2d: a `Resolution`-timed sub makes its OWN + // untargeted choice at its own resolution (see the + // interactive `PutCounter` recipient prompt in this + // function) — inheriting the parent's already-chosen + // target here would force every replicated instruction + // onto whichever single recipient the parent picked, + // instead of letting each independently-gated sibling + // offer its own choice (Kathril, Aspect Warper, issue + // #6321 / PR #6533). + if should_propagate_parent_targets(ability, &sub_resolved) { sub_resolved.targets = ability.targets.clone(); } sub_resolved.context = ability.context.clone(); @@ -8524,6 +8784,84 @@ fn resolve_chain_body( } } + // CR 608.2d + CR 115.10a: An untargeted `PutCounter` recipient ("any + // creature you control", no literal "target") is chosen while APPLYING + // the effect — at THIS instruction's OWN resolution, independently of any + // sibling instruction's own choice — not once, when the whole ability + // went on the stack. `target_choice_timing_for_clause` (parser) marks + // such clauses `Resolution`; the `ReplicatedOrBranch` propagation-skip + // above (via `should_propagate_parent_targets`) ensures such a node + // reaches here with `ability.targets` still empty rather than inheriting + // a parent's already-made choice. Mirrors the existing + // `filter_chosen_player_index` 0/1/N pattern above (a single legal + // recipient auto-binds with no prompt; zero legal recipients is a silent + // no-op — CR 608.2d: "The player can't choose an option that's illegal or + // impossible") and reuses the SAME `ChooseFromZoneChoice` + + // parked-continuation machinery already proven for `PutCounter` + // continuations — `engine_resolution_choices.rs`'s `is_partition` gate on + // this exact drain path names `Effect::PutCounter` explicitly (the + // Bolster keyword action). Kathril, Aspect Warper — issue #6321 / PR + // #6533. + // + // Candidates are enumerated via a PLAIN filter scan (`matches_target_filter` + // over the battlefield), NOT the targeting-legality path + // (`find_legal_targets`/`can_target`) — per CR 115.10a this is a CHOICE, not + // a target, so hexproof/shroud/protection/"can't be the target of" must NOT + // restrict candidacy. Mirrors `MultiplyCounter`'s existing untargeted + // enumeration a few lines below and `bolster.rs`'s own doc comment + // ("Bolster is a keyword action that 'chooses' (not 'targets') — hexproof + // and shroud do not prevent bolster"). + // + // EXCLUDES `contains_source_attachment_host()` (Equipped/Enchanted) + // targets — those have no real CHOICE (the host is uniquely determined by + // the attachment relationship) and already resolve deterministically via + // `resolve_defined_or_targets`'s existing `resolved_object_ids_for_filter` + // fallback in `counters.rs`; routing them through an interactive prompt + // here would be wrong (and untested against that resolver's semantics). + if ability.target_choice_timing == TargetChoiceTiming::Resolution + && ability.targets.is_empty() + && ability.distribution.is_none() + { + if let Effect::PutCounter { target, .. } = &ability.effect { + if !target.contains_source_attachment_host() { + let effective_filter = resolved_object_filter(ability, target); + let filter_ctx = filter::FilterContext::from_ability(ability); + let legal: Vec = state + .battlefield_phased_in_ids() + .into_iter() + .filter(|id| { + filter::matches_target_filter(state, *id, &effective_filter, &filter_ctx) + }) + .collect(); + match legal.len() { + 0 => {} + 1 => { + let mut bound = ability.clone(); + bound.targets = vec![TargetRef::Object(legal[0])]; + return resolve_ability_chain(state, &bound, events, depth); + } + _ => { + let mut cont = ability.clone(); + cont.targets.clear(); + state.park_ability_continuation(PendingContinuation::new( + Box::new(cont), + state, + )); + state.waiting_for = WaitingFor::ChooseFromZoneChoice { + player: ability.controller, + cards: legal, + count: 1, + up_to: false, + constraint: None, + source_id: ability.source_id, + }; + return Ok(()); + } + } + } + } + } + // CR 603.7: Snapshot event count so we can detect objects moved by this effect. let events_before = events.len(); @@ -9167,7 +9505,7 @@ fn resolve_chain_body( ) { if let Some(ref base_chain) = sub.else_ability { let mut resolved = base_chain.as_ref().clone(); - if resolved.targets.is_empty() && !ability.targets.is_empty() { + if should_propagate_parent_targets(ability, &resolved) { resolved.targets = ability.targets.clone(); } apply_parent_chain_context( @@ -9232,7 +9570,7 @@ fn resolve_chain_body( resolved.targets.insert(0, TargetRef::Object(source)); } } - } else if resolved.targets.is_empty() && !ability.targets.is_empty() { + } else if should_propagate_parent_targets(ability, &resolved) { resolved.targets = ability.targets.clone(); } apply_parent_chain_context( @@ -9303,7 +9641,7 @@ fn resolve_chain_body( || condition_awaits_resolution_only_referent(condition, state, ability)) { let mut sub_clone = sub.as_ref().clone(); - if sub_clone.targets.is_empty() && !ability.targets.is_empty() { + if should_propagate_parent_targets(ability, &sub_clone) { sub_clone.targets = ability.targets.clone(); } apply_parent_chain_context( @@ -9382,7 +9720,7 @@ fn resolve_chain_body( .iter() .map(|&id| TargetRef::Object(id)) .collect(); - } else if else_resolved.targets.is_empty() && !ability.targets.is_empty() { + } else if should_propagate_parent_targets(ability, &else_resolved) { else_resolved.targets = ability.targets.clone(); } apply_parent_chain_context( @@ -9418,7 +9756,7 @@ fn resolve_chain_body( while let Some(ref sibling) = current { if sibling.sub_link == SubAbilityLink::SequentialSibling { let mut sibling_resolved = sibling.as_ref().clone(); - if sibling_resolved.targets.is_empty() && !ability.targets.is_empty() { + if should_propagate_parent_targets(ability, &sibling_resolved) { sibling_resolved.targets = ability.targets.clone(); } apply_parent_chain_context( @@ -9547,7 +9885,7 @@ fn resolve_chain_body( && waits_for_resolution_choice(&state.waiting_for) { let mut sub_clone = sub.as_ref().clone(); - if sub_clone.targets.is_empty() && !ability.targets.is_empty() { + if should_propagate_parent_targets(ability, &sub_clone) { sub_clone.targets = ability.targets.clone(); } apply_parent_chain_context( @@ -9580,7 +9918,7 @@ fn resolve_chain_body( // rather than immediately processing it (which would bypass the UI). if waits_for_resolution_choice(&state.waiting_for) { let mut sub_clone = sub.as_ref().clone(); - if sub_clone.targets.is_empty() && !ability.targets.is_empty() { + if should_propagate_parent_targets(ability, &sub_clone) { sub_clone.targets = ability.targets.clone(); } apply_parent_chain_context( @@ -9727,6 +10065,14 @@ fn resolve_chain_body( } } else if sub_with_context.targets.is_empty() && !effect_uses_implicit_tracked_set_targets(&sub.effect) + // CR 608.2d: a `Resolution`-timed sub makes its OWN untargeted + // choice at its own resolution — neither inheriting the + // parent's target NOR force-binding the just-moved + // forward_result object is correct for it; leave `targets` + // empty so the interactive `PutCounter` recipient prompt + // handles it when this sub's own turn to resolve comes + // (Kathril, Aspect Warper, issue #6321 / PR #6533). + && sub_with_context.target_choice_timing != TargetChoiceTiming::Resolution { // CR 608.2c: ParentTarget consumers in a forward_result sub-chain // need the moved object's id in `targets`, not just a rebound @@ -10780,9 +11126,9 @@ pub(crate) fn evaluate_condition( /// `quantity::resolve_player_count`, but applied to one already-bound iteration /// player rather than a fold over all players. Set-valued / event-context /// variants that have no single-player semantic outside an iteration loop fail -/// closed — `ScopedPlayerMatches` is only emitted by the parser for `All` / -/// `Opponent` / `Controller` today (the canonical decline-tail scopes), so the -/// fall-through is defensive rather than load-bearing. +/// closed. `ScopedPlayerMatches` is emitted for decline tails and for +/// relationship-qualified scoped phase-player conditions; both surfaces use +/// the same printed-controller-relative player relation semantics. fn scoped_player_matches_filter( state: &GameState, ability: &ResolvedAbility, @@ -10802,7 +11148,10 @@ fn scoped_player_matches_filter( let controller = ability.original_controller.unwrap_or(ability.controller); match filter { PlayerFilter::Controller => candidate == controller, - PlayerFilter::Opponent => candidate != controller, + // CR 102.2 / CR 102.3: "opponent" is a game-topology relation, not raw + // player-ID inequality. In team multiplayer, a teammate is neither the + // controller nor an opponent. + PlayerFilter::Opponent => crate::game::players::is_opponent(state, controller, candidate), PlayerFilter::All => true, // CR 608.2c + CR 109.4: candidate matches unless it matches the anchor. // Recursive against the same printed-controller-relative predicate; @@ -10811,7 +11160,7 @@ fn scoped_player_matches_filter( !scoped_player_matches_filter(state, ability, candidate, exclude) } PlayerFilter::OpponentLostLife => { - candidate != controller + crate::game::players::is_opponent(state, controller, candidate) && state .players .iter() @@ -10819,7 +11168,7 @@ fn scoped_player_matches_filter( .is_some_and(|p| p.life_lost_this_turn > 0) } PlayerFilter::OpponentGainedLife => { - candidate != controller + crate::game::players::is_opponent(state, controller, candidate) && state .players .iter() @@ -11245,6 +11594,54 @@ mod tests { use crate::types::triggers::TriggerMode; use crate::types::zones::Zone; + // CR 608.2h (#6486): Volcanic Vision — "Return target instant or sorcery card + // from your graveyard to your hand. ~ deals damage equal to that card's mana + // value to each creature your opponents control. Exile ~." The card is + // returned to HAND (a hidden zone); "that card's mana value" must still read + // the returned card's move-time mana value, so each opponent creature takes + // that much damage. Before the fix the returned card was not bound as the + // earlier-instruction referent (only PUBLIC-zone moves were), so the damage + // resolved to 0. + #[test] + fn volcanic_vision_deals_returned_cards_mana_value_after_return_to_hand() { + use crate::game::scenario::{GameScenario, P0, P1}; + + const ORACLE: &str = "Return target instant or sorcery card from your graveyard to your hand. \ + Volcanic Vision deals damage equal to that card's mana value to each creature your opponents control. \ + Exile Volcanic Vision."; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + (0..8) + .map(|_| ManaUnit::new(ManaType::Red, ObjectId(0), false, vec![])) + .collect(), + ); + let volcanic = scenario + .add_spell_to_hand_from_oracle(P0, "Volcanic Vision", false, ORACLE) + .id(); + // Return target: an instant in the caster's graveyard with mana value 3. + let bolt = scenario + .add_spell_to_graveyard(P0, "Fire Blast", true) + .with_mana_cost(ManaCost::generic(3)) + .id(); + let goblin = scenario.add_creature(P1, "Goblin", 2, 2).id(); + let ogre = scenario.add_creature(P1, "Ogre", 3, 3).id(); + + let mut runner = scenario.build(); + let outcome = runner.cast(volcanic).target_objects(&[bolt]).resolve(); + + outcome.assert_zone(&[bolt], Zone::Hand); + assert_eq!( + outcome.damage_marked(goblin), + 3, + "each opponent creature must take the returned card's mana value (3)" + ); + assert_eq!(outcome.damage_marked(ogre), 3); + outcome.assert_zone(&[volcanic], Zone::Exile); + } + #[test] fn search_filter_dynamic_property_axes_consume_the_tracked_set() { let tracked = || QuantityExpr::Ref { @@ -11431,6 +11828,71 @@ mod tests { ); } + /// Build a bare `ZoneChanged` event carrying `mana_value` on its record, so + /// a bound referent's LKI payload can be asserted (not merely its presence). + fn zone_change_event(object_id: ObjectId, from: Zone, to: Zone, mana_value: u32) -> GameEvent { + GameEvent::ZoneChanged { + object_id, + from: Some(from), + to, + record: Box::new(ZoneChangeRecord { + mana_value, + ..ZoneChangeRecord::test_minimal(object_id, Some(from), to) + }), + } + } + + /// CR 608.2h: a draw (Library -> Hand) moves an object from a HIDDEN zone, + /// not "from a public zone to a hidden zone", so it establishes no last + /// known information and must never bind an anaphoric referent. Without this + /// exclusion every draw in the game would bind one. + #[test] + fn library_to_hand_move_establishes_no_parent_referent() { + let state = GameState::new_two_player(42); + let events = [zone_change_event(ObjectId(1), Zone::Library, Zone::Hand, 3)]; + assert!( + parent_referent_context_from_events(&state, &events).is_none(), + "a draw has a hidden source zone and must not bind a referent" + ); + } + + /// CR 608.2h: moving an object "from a public zone to a hidden zone" leaves + /// last known information a later instruction can refer to — Volcanic + /// Vision's "that card's mana value" after a Graveyard -> Hand return. + #[test] + fn graveyard_to_hand_move_binds_referent_with_move_time_mana_value() { + let state = GameState::new_two_player(42); + let events = [zone_change_event( + ObjectId(1), + Zone::Graveyard, + Zone::Hand, + 3, + )]; + let referent = parent_referent_context_from_events(&state, &events) + .expect("a public -> hidden move leaves last known information"); + assert_eq!(referent.object_id, ObjectId(1)); + assert_eq!( + referent.lki.mana_value, 3, + "the referent must carry the move-time mana value" + ); + } + + /// CR 608.2k: the singular guard still applies across the widened predicate — + /// a public -> Hand return plus a second qualifying move in the same span has + /// no single resolvable "that card". + #[test] + fn multiple_qualifying_moves_bind_no_parent_referent() { + let state = GameState::new_two_player(42); + let events = [ + zone_change_event(ObjectId(1), Zone::Graveyard, Zone::Hand, 3), + zone_change_event(ObjectId(2), Zone::Battlefield, Zone::Graveyard, 5), + ]; + assert!( + parent_referent_context_from_events(&state, &events).is_none(), + "two qualifying moves have no singular anaphoric referent" + ); + } + /// CR 608.2c: duplicate events for the same tapped creature still describe /// one singular referent. #[test] @@ -23543,6 +24005,92 @@ mod tests { ); } + /// CR 601.2c + CR 608.2c: If a multi-target opponent instruction pauses, + /// every remaining opponent must resume before its detached sequential tail + /// runs once for the controller. + #[test] + fn multi_target_opponents_resume_before_controller_sequential_tail() { + let mut state = GameState::new(FormatConfig::commander(), 3, 42); + for card in 0..3 { + create_object( + &mut state, + CardId(1_000 + card), + PlayerId(0), + format!("Controller library {card}"), + Zone::Library, + ); + } + for player in 1..3u8 { + for card in 0..2 { + create_object( + &mut state, + CardId(u64::from(player) * 100 + card), + PlayerId(player), + format!("P{player} hand {card}"), + Zone::Hand, + ); + } + } + + let opponent_filter = TargetFilter::Typed(TypedFilter { + type_filters: vec![], + controller: Some(ControllerRef::Opponent), + properties: vec![], + }); + let mut ability = ResolvedAbility::new( + Effect::Discard { + count: QuantityExpr::Fixed { value: 1 }, + target: opponent_filter, + selection: crate::types::ability::CardSelectionMode::Chosen, + unless_filter: None, + filter: None, + }, + vec![ + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)), + ], + ObjectId(500), + PlayerId(0), + ); + ability.multi_target = Some(crate::types::ability::MultiTargetSpec::unlimited(0)); + let mut controller_tail = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + ObjectId(500), + PlayerId(0), + ); + controller_tail.sub_link = crate::types::ability::SubAbilityLink::SequentialSibling; + ability.sub_ability = Some(Box::new(controller_tail)); + + let controller_hand_before = state.players[0].hand.len(); + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); + + let mut prompted_players = Vec::new(); + while let WaitingFor::DiscardChoice { player, cards, .. } = &state.waiting_for { + let player = *player; + let pick = cards[0]; + crate::game::engine::apply_as_current( + &mut state, + GameAction::SelectCards { cards: vec![pick] }, + ) + .unwrap(); + prompted_players.push(player); + } + + assert_eq!(prompted_players, vec![PlayerId(1), PlayerId(2)]); + assert_eq!(state.players[1].graveyard.len(), 1); + assert_eq!(state.players[2].graveyard.len(), 1); + assert_eq!( + state.players[0].hand.len() - controller_hand_before, + 1, + "the detached controller draw runs exactly once after both discards" + ); + } + /// GH #582 — CR 104.2b + CR 107.3i + CR 608.2c + CR 700.5: Thassa's Oracle /// runtime discriminator. The synthesized AST gates `Effect::WinTheGame` /// with `AbilityCondition::QuantityCheck { lhs: Devotion{[Blue]}, comparator: GE, rhs: @@ -25700,6 +26248,70 @@ mod tests { } } + /// CR 102.2 / CR 102.3 + CR 109.5: the production + /// `ScopedPlayerMatches` evaluator uses canonical team topology and the + /// printed controller preserved in `original_controller`. This test covers + /// only the relation predicate; it does not claim full Fevered Visions + /// support under CR 805.4d's multi-trigger fanout. + #[test] + fn scoped_player_matches_filter_uses_team_opponents_and_original_controller() { + let mut state = GameState::new(FormatConfig::two_headed_giant(), 4, 42); + let definition = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + let mut ability = build_resolved_from_def(&definition, ObjectId(9000), PlayerId(0)); + + // Model the live player-scope iteration: the driver has rebound the + // acting controller to opponent P2, while the printed controller P0 is + // retained as the relationship authority. + ability.controller = PlayerId(2); + ability.original_controller = Some(PlayerId(0)); + + for (candidate, expected) in [ + (PlayerId(0), false), + (PlayerId(1), false), + (PlayerId(2), true), + (PlayerId(3), true), + ] { + assert_eq!( + scoped_player_matches_filter(&state, &ability, candidate, &PlayerFilter::Opponent), + expected, + "2HG opponent relation mismatch for {candidate:?}" + ); + } + + // Both the teammate and one opponent have matching history. The + // teammate must still fail the relation leg; the other opponent lacks + // history and must fail the history leg. + for player in state.players.iter_mut() { + if matches!(player.id, PlayerId(1) | PlayerId(2)) { + player.life_lost_this_turn = 1; + player.life_gained_this_turn = 1; + } + } + for filter in [ + PlayerFilter::OpponentLostLife, + PlayerFilter::OpponentGainedLife, + ] { + assert!( + !scoped_player_matches_filter(&state, &ability, PlayerId(1), &filter), + "teammate history must not satisfy an opponent-history filter" + ); + assert!( + scoped_player_matches_filter(&state, &ability, PlayerId(2), &filter), + "opponent with matching history must satisfy {filter:?}" + ); + assert!( + !scoped_player_matches_filter(&state, &ability, PlayerId(3), &filter), + "opponent without matching history must fail {filter:?}" + ); + } + } + /// CR 102.3 (issue #4361): in Two-Headed Giant, the caster's TEAMMATE is not /// an opponent, so `PlayerFilter::OpponentOfTriggeringPlayer` must exclude /// them. With a 2HG topology (seats 0,1 = team 0; seats 2,3 = team 1) and a diff --git a/crates/engine/src/game/effects/player_counter.rs b/crates/engine/src/game/effects/player_counter.rs index ad72642be6..bd7130cacc 100644 --- a/crates/engine/src/game/effects/player_counter.rs +++ b/crates/engine/src/game/effects/player_counter.rs @@ -336,6 +336,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, targets: vec![], kind: AbilityKind::Spell, sub_ability: None, @@ -373,6 +374,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, @@ -528,6 +530,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, targets: vec![], kind: AbilityKind::Spell, sub_ability: None, @@ -565,6 +568,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, diff --git a/crates/engine/src/game/effects/reverse_turn_order.rs b/crates/engine/src/game/effects/reverse_turn_order.rs index 9315c07194..0eea7a4426 100644 --- a/crates/engine/src/game/effects/reverse_turn_order.rs +++ b/crates/engine/src/game/effects/reverse_turn_order.rs @@ -48,6 +48,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, targets: vec![], kind: AbilityKind::Spell, sub_ability: None, @@ -85,6 +86,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, diff --git a/crates/engine/src/game/effects/skip_next_step.rs b/crates/engine/src/game/effects/skip_next_step.rs index 6f8e81459a..f7407178a5 100644 --- a/crates/engine/src/game/effects/skip_next_step.rs +++ b/crates/engine/src/game/effects/skip_next_step.rs @@ -111,6 +111,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, targets: vec![], kind: AbilityKind::Spell, sub_ability: None, @@ -148,6 +149,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, diff --git a/crates/engine/src/game/effects/skip_next_turn.rs b/crates/engine/src/game/effects/skip_next_turn.rs index 8f9378f2b2..83ef4a5629 100644 --- a/crates/engine/src/game/effects/skip_next_turn.rs +++ b/crates/engine/src/game/effects/skip_next_turn.rs @@ -89,6 +89,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, targets: vec![], kind: AbilityKind::Spell, sub_ability: None, @@ -126,6 +127,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, diff --git a/crates/engine/src/game/effects/turn_face_down.rs b/crates/engine/src/game/effects/turn_face_down.rs index ef7d737945..bcb62b2c47 100644 --- a/crates/engine/src/game/effects/turn_face_down.rs +++ b/crates/engine/src/game/effects/turn_face_down.rs @@ -52,7 +52,21 @@ pub fn resolve( // writes these values into both live and base fields, so the layer // system then reapplies all continuous effects from the correct printed // baseline — not from an already-inflated one. - let snapshot = crate::game::printed_cards::snapshot_object_base_face(obj); + // + // CR 710.4 + CR 710.2: a FLIPPED flip permanent (CR 712.16 does not + // cover flip cards, so Ixidron / Cyber Conversion may legally turn one + // face down) already owns this slot: `flip::flip_permanent` stashed the + // NORMAL half there, and that half is what must reappear when the + // permanent leaves the battlefield. Overwriting it with a base snapshot + // — which, for a flipped permanent, is the ALTERNATIVE half — would put + // a flipped Kenzo the Hardhearted in the graveyard instead of Bushi + // Tenderfoot. Keep the flip stash; `zones::apply_zone_exit_cleanup` + // runs the CR 708.9 face-down restore BEFORE the CR 710.4 flip revert + // precisely so this single slot serves both. + let snapshot = match &obj.back_face { + Some(flip_stash) if obj.flipped => flip_stash.clone(), + _ => crate::game::printed_cards::snapshot_object_base_face(obj), + }; // CR 708.2a + CR 205.1a: Apply the effect-specified (or default vanilla // 2/2) face-down body. crate::game::morph::apply_face_down_creature_characteristics(obj, &profile); diff --git a/crates/engine/src/game/effects/vote.rs b/crates/engine/src/game/effects/vote.rs index 4051653eed..86b87fd47d 100644 --- a/crates/engine/src/game/effects/vote.rs +++ b/crates/engine/src/game/effects/vote.rs @@ -355,6 +355,7 @@ pub fn resolve_tally( source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, controller, original_controller: None, scoped_player: None, @@ -398,6 +399,7 @@ pub fn resolve_tally( repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, @@ -420,6 +422,7 @@ pub fn resolve_tally( source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, controller, original_controller: None, scoped_player: None, @@ -463,6 +466,7 @@ pub fn resolve_tally( repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, @@ -659,6 +663,7 @@ fn resolved_from_def( source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, controller, original_controller: None, scoped_player: None, @@ -706,6 +711,8 @@ fn resolved_from_def( replacement_applied: Default::default(), // CR 608.2c: Carry the parent-link kind through to the resolved ability. sub_link: def.sub_link, + // CR 608.2c: Carry the replication marker through (Dependent for vote sub-effects). + sibling_condition: def.sibling_condition, // CR 700.2b + CR 603.3c: Carry the reflexive modal choice + per-mode // abilities through (None for vote sub-effects). modal: def.modal.clone(), @@ -918,6 +925,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, controller, original_controller: None, scoped_player: None, @@ -958,6 +966,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, @@ -1024,6 +1033,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, controller, original_controller: None, scoped_player: None, @@ -1064,6 +1074,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, @@ -1455,6 +1466,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, controller, original_controller: None, scoped_player: None, @@ -1495,6 +1507,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, @@ -1618,6 +1631,7 @@ mod tests { source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, controller, original_controller: None, scoped_player: None, @@ -1658,6 +1672,7 @@ mod tests { repeat_until: None, replacement_applied: Default::default(), sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, + sibling_condition: crate::types::ability::SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index cd6e10ddb8..54ba0fd4ef 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -4922,6 +4922,14 @@ fn apply_action( }, GameAction::CancelCast, ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ( + WaitingFor::ChooseGiftRecipient { + player, + pending_cast, + .. + }, + GameAction::CancelCast, + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), // CR 702.47a–e: Splice — caster reveals a card to splice onto the spell // (re-offering for the rest), or declines to finish and proceed to targets. ( @@ -6001,6 +6009,26 @@ fn apply_action( } casting_costs::begin_deferred_target_selection(state, caster, pending, &mut events)? } + // CR 702.174a: Caster chose which opponent receives the promised Gift. + ( + WaitingFor::ChooseGiftRecipient { + player, + candidates, + pending_cast, + .. + }, + GameAction::ChooseGiftRecipient { opponent }, + ) => { + let caster = *player; + casting_costs::handle_choose_gift_recipient( + state, + caster, + (**pending_cast).clone(), + opponent, + candidates, + &mut events, + )? + } // CR 702.132a: Assist — caster chooses another player to help pay generic, // or declines. `assist_state` was set to `Offered` when the offer was made, // so both branches simply (re)enter the payment step from where they resume. diff --git a/crates/engine/src/game/engine_combat.rs b/crates/engine/src/game/engine_combat.rs index 6473a13d63..e3ad6c20b4 100644 --- a/crates/engine/src/game/engine_combat.rs +++ b/crates/engine/src/game/engine_combat.rs @@ -1,5 +1,3 @@ -use std::collections::HashSet; - use crate::game::combat::{AttackTarget, DamageAssignment, DamageTarget, TrampleKind}; use crate::types::ability::{CostPaidObjectSnapshot, TargetRef}; use crate::types::events::GameEvent; @@ -389,7 +387,12 @@ pub(super) fn handle_declare_blockers( } } - // CR 509.1c + CR 509.1d: Enumerate UnlessPay block-tax static abilities before + // CR 509.1a-c: Validate the submitted whole declaration before quoting a + // cost. A tax prompt must never bless an illegal or incomplete proposal. + super::combat::validate_blockers_for_player(state, player, assignments) + .map_err(EngineError::InvalidAction)?; + + // CR 509.1c + CR 509.1d: Enumerate UnlessPay block-tax static abilities after // finalizing the blocker declaration. Defending player pays or declines the // locked-in total; on decline, taxed blockers are dropped from the assignment // list (CR 509.1c: "that player is not required to pay that cost"). @@ -400,7 +403,7 @@ pub(super) fn handle_declare_blockers( total_cost, per_creature, pending: CombatTaxPending::Block { - assignments: assignments.to_vec(), + assignments: super::combat::snapshot_block_declaration(state, assignments), }, }); } @@ -416,10 +419,9 @@ pub(super) fn handle_declare_blockers( /// - `accept = true`: deduct the locked-in total via the shared mana-payment pipeline, /// then run the pending declaration with every creature intact (CR 508.1i–k: /// mana-abilities chance → pay costs → become attacking). -/// - `accept = false`: drop the taxed creatures from the declaration and submit the -/// remaining untaxed subset. If no creatures remain on the attack side, the engine -/// ends combat via `handle_empty_attackers` (CR 508.8); on the block side, submit -/// the filtered assignments. +/// - `accept = false`: discard the proposal and rebuild the appropriate declaration +/// prompt. The player may make a different legal declaration with a newly computed +/// tax quote. pub(super) fn handle_pay_combat_tax( state: &mut GameState, waiting_for: WaitingFor, @@ -440,6 +442,23 @@ pub(super) fn handle_pay_combat_tax( }; if accept { + let accepted_block_assignments = match &pending { + CombatTaxPending::Block { assignments } => { + let restored = super::combat::block_declaration_from_snapshot(state, assignments); + if restored.len() != assignments.len() { + return Err(EngineError::InvalidAction( + "Block declaration changed while its tax payment was pending".to_string(), + )); + } + // CR 509.1d: the declaration and its cost were fixed before + // the mana-ability/payment window. Do not rescore, revalidate, + // or reprice this exact snapshot here; only reject a stale + // incarnation atomically before any payment is taken. + Some(restored) + } + CombatTaxPending::Attack { .. } => None, + }; + // CR 508.1i–j / CR 509.1e–f: pay the locked-in total through the shared // unless-cost mana path. Failures bubble up to the caller. super::casting::pay_unless_cost(state, player, &total_cost, events)?; @@ -464,7 +483,9 @@ pub(super) fn handle_pay_combat_tax( events, ); } - CombatTaxPending::Block { assignments } => { + CombatTaxPending::Block { assignments: _ } => { + let assignments = accepted_block_assignments + .expect("block tax snapshots are restored and validated before payment"); return resume_declare_blockers(state, player, &assignments, events); } } @@ -481,19 +502,20 @@ pub(super) fn handle_pay_combat_tax( let _ = context; Ok(super::combat::build_declare_attackers_waiting_for(state)) } - // Block path is unchanged: filter the taxed blockers and submit the rest. + // CR 509.1d: declining discards the whole block proposal. Rebuild the + // live prompt; do not commit a stale filtered subset or reuse its price. CombatTaxPending::Block { assignments } => { - let taxed: HashSet = per_creature.iter().map(|(id, _)| *id).collect(); - let filtered: Vec<(ObjectId, ObjectId)> = assignments - .into_iter() - .filter(|(blocker, _)| !taxed.contains(blocker)) - .collect(); events.push(GameEvent::CombatTaxDeclined { player, - dropped: taxed.iter().copied().collect(), + dropped: assignments + .iter() + .map(|(blocker, _)| blocker.object_id) + .collect(), }); - let _ = context; // suppresses unused in this branch; kept for symmetry - resume_declare_blockers(state, player, &filtered, events) + let _ = (context, per_creature); + Ok(super::combat::build_declare_blockers_waiting_for( + state, player, + )) } } } diff --git a/crates/engine/src/game/engine_phase_trigger_regression_tests.rs b/crates/engine/src/game/engine_phase_trigger_regression_tests.rs index b953bd9160..3a81004797 100644 --- a/crates/engine/src/game/engine_phase_trigger_regression_tests.rs +++ b/crates/engine/src/game/engine_phase_trigger_regression_tests.rs @@ -3448,6 +3448,7 @@ fn copy_target_choice_resolves_become_copy() { source_id: clone_id, valid_targets: vec![target_id], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; // Player chooses to copy Grizzly Bears @@ -3542,6 +3543,7 @@ fn copy_target_choice_applies_copied_enter_with_counters_replacement_before_sba( source_id: assassin, valid_targets: vec![ghave], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; apply_as_current( @@ -3615,6 +3617,7 @@ fn echoing_deeps_copying_sunken_citadel_prompts_for_the_copied_color_choice() { source_id: deeps, valid_targets: vec![citadel], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; let result = apply_as_current( @@ -3922,6 +3925,7 @@ fn copy_target_choice_fires_granted_etb_trigger_against_deferred_entry_event() { source_id: assassin, valid_targets: vec![bear], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; apply_as_current( @@ -4103,6 +4107,7 @@ fn copy_target_choice_surfaces_interactive_trigger_prompt_for_deferred_entry() { source_id: assassin, valid_targets: vec![bear], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; let _waiting = apply_as_current( @@ -4182,6 +4187,7 @@ fn copy_target_choice_rejects_invalid_target() { source_id: clone_id, valid_targets: vec![valid_id], // Bird is NOT in valid targets max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; // Try to choose invalid target @@ -4332,6 +4338,7 @@ fn superior_spider_man_full_copy_flow_copies_graveyard_card_and_exiles_it() { source_id: spidey, valid_targets: vec![elesh], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; let result = apply_as_current( @@ -4508,6 +4515,7 @@ fn reflexive_when_you_do_fires_after_become_copy_replacement() { source_id: cloner, valid_targets: vec![source_card], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; // Accumulate events across the full resolution so we can count diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 317f008749..534752c98f 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; use crate::ai_support::copy_target_mana_value_ceiling; use crate::types::ability::{ - AbilityDefinition, Effect, PostReplacementContinuation, ResolvedAbility, TargetFilter, - TargetRef, + AbilityDefinition, CopyTargetPurpose, Effect, PostReplacementContinuation, ResolvedAbility, + TargetFilter, TargetRef, }; #[cfg(test)] use crate::types::ability::{EffectScope, TapStateChange}; @@ -1326,6 +1326,202 @@ pub(super) fn handle_replacement_choice( } } +/// CR 707.2c + CR 614.12a + CR 613.1a: Answer path for Metamorphic Alteration's +/// "As this Aura enters, choose a creature." Latches the chosen creature's +/// copiable values (fixed here, per CR 707.2c, as the copy effect first starts +/// to apply — later changes to the chosen creature never propagate, CR 707.2b) +/// and installs them as a Layer-1 `CopyValues` transient continuous effect +/// SOURCED FROM the Aura and applied to the Aura's enchanted host. The Aura +/// itself is left untouched (it does not become a copy). The install reuses the +/// single copy-values authority (`become_copy::apply_precomputed_copy_values`), +/// which strips exceptions / flushes layers / emits `EffectResolved`. +fn handle_persist_chosen_attribute_choice( + state: &mut GameState, + source_id: ObjectId, + controller: PlayerId, + donor_id: ObjectId, + events: &mut Vec, +) -> Result { + // CR 707.2c: copiable values are determined at the moment the effect first + // starts to apply — snapshot the donor now. + let values = crate::game::layers::compute_current_copiable_values(state, donor_id).ok_or( + EngineError::InvalidAction("Chosen copy source no longer exists".to_string()), + )?; + // CR 111.1 + CR 707.2: art routing follows the copy (token vs printed + // source), captured alongside the values — NOT a copiable value itself. + let (display_source, printed_ref, token_image_ref) = state + .objects + .get(&donor_id) + .map(|o| { + ( + o.display_source, + o.printed_ref.clone(), + o.token_image_ref.clone(), + ) + }) + .unwrap_or_default(); + + // CR 400.7: persist the frozen snapshot on the Aura so it is cleared + // automatically when the Aura changes zones (exactly the copy's lifetime). + let snapshot = crate::types::ability::LatchedCopiableSnapshot { + values: values.clone(), + display_source, + printed_ref: printed_ref.clone(), + token_image_ref: token_image_ref.clone(), + }; + if let Some(aura) = state.objects.get_mut(&source_id) { + aura.chosen_attributes + .push(crate::types::ability::ChosenAttribute::CopiableSnapshot( + Box::new(snapshot), + )); + } + + // CR 608.3c + CR 614.12a: Spell-path mid-entry `CopyTargetChoice` is raised + // from the CallerEpilogue post-replacement drain AFTER CR 608.3c Aura + // attach — `attached_to` is already the cast host. The stash pushed there + // (`PendingSpellResolution`) still carries `spell_targets` so this answer + // path can apply cast-link stamps and prefer the CR 303.4a spell target as + // the copy recipient (re-attach via `apply_pending_spell_resolution` is + // idempotent). + // + // CR 303.4f: Non-spell Aura entry attaches during delivery (before this + // choice), so `attached_to` is already set and there is no spell-resolution + // frame — fall through to the attached host / enter-time attach consult. + let mut host_from_spell: Option = None; + let mut took_spell_resolution = false; + if state + .active_spell_resolution() + .is_some_and(|ctx| ctx.object_id == source_id) + { + let ctx = state + .take_active_spell_resolution() + .expect("active spell-resolution frame checked above"); + took_spell_resolution = true; + host_from_spell = ctx.spell_targets.first().and_then(|t| match t { + crate::types::ability::TargetRef::Object(id) => Some(*id), + _ => None, + }); + apply_pending_spell_resolution(state, &ctx, events); + } + + // CR 303.4f: Non-spell path may still be unattached if entry skipped the + // auto-attach consult (e.g. liminal copy → Aura). Resolve it now. + // Never use this Enchant-filter consult as a CR 608.3c spell-path fallback + // — a resolving Aura spell's host is the target chosen at cast (CR 303.4a), + // carried on `PendingSpellResolution.spell_targets`. + if !took_spell_resolution + && state + .objects + .get(&source_id) + .is_some_and(|aura| aura.attached_to.is_none()) + { + match crate::game::zone_pipeline::resolve_entering_aura_attachment(state, source_id) { + crate::game::zone_pipeline::EnteringAuraAttachment::NotApplicable + | crate::game::zone_pipeline::EnteringAuraAttachment::Resolved => {} + crate::game::zone_pipeline::EnteringAuraAttachment::NeedsChoice { .. } => { + return Err(EngineError::InvalidAction( + "PersistChosenAttribute requires a resolved Aura host before the copy installs" + .to_string(), + )); + } + } + } + + // CR 303.4 + CR 702.5: the Aura enchants a creature — its host is the + // recipient of the copy effect. Prefer the spell-target host established + // above (spell-cast path), then the live attachment (non-spell / after + // attach). Never silently no-op if the host is missing. + let host_id = host_from_spell + .or_else(|| { + state + .objects + .get(&source_id) + .and_then(|aura| aura.attached_to.as_ref()) + .and_then(|attach| attach.as_object()) + }) + .ok_or_else(|| { + EngineError::InvalidAction( + "Metamorphic Alteration copy choice answered without a resolved Aura host \ + (CR 608.3c spell target / CR 303.4f attach)" + .to_string(), + ) + })?; + + // CR 707.2c + CR 613.1a + CR 611.2a: install the copy as a transient + // continuous effect sourced from the Aura, applied to the host, ending + // when the Aura leaves the battlefield (`UntilHostLeavesPlay` prunes on + // `tce.source_id` leaving — the Aura). `duration_subject_id` tracks the + // host for any recipient-relative duration reads (harmless here). + let copy = super::effects::become_copy::PrecomputedCopyValues { + source_id, + controller, + duration_subject_id: host_id, + duration: crate::types::ability::Duration::UntilHostLeavesPlay, + values, + display_source, + printed_ref, + token_image_ref, + additional_modifications: Vec::new(), + effect_kind: crate::types::ability::EffectKind::ChoosePermanent, + }; + super::effects::become_copy::apply_precomputed_copy_values(state, host_id, copy, events) + .map_err(|e| EngineError::InvalidAction(format!("{e:?}")))?; + + // CR 707.4: installing the host copy must not disturb the Aura's attachment. + debug_assert!( + state + .objects + .get(&source_id) + .is_some_and(|aura| aura.attached_to.is_some()), + "the Aura must remain attached after installing the host copy" + ); + + // CR 615.5 + CR 614.12a: PersistChosenAttribute's ChoosePermanent work is + // complete once the host copy is installed. Retire the paused post- + // replacement drain BEFORE replaying deferred entry events — otherwise a + // nested OrderTriggers / trigger-target pause can return while the same + // ChoosePermanent drain is still resident, and a later post-action pass + // re-drains it into a second `CopyTargetChoice`. + // + // Clear the prompt we just answered first: `state.waiting_for` is still the + // inbound `CopyTargetChoice`, and the completion tail below used to echo it + // via `if !Priority { return waiting_for }` — the cast driver then re-answered + // the same prompt until its 64-iteration cap, leaving sequential Metamorphic + // casts blocked (two_auras). Nested prompts raised during copy install / + // entry replay still overwrite `waiting_for` and propagate below. + // + // Divergence from the BecomeCopy completion tail (`:1878+`): that sibling + // retires the drain *after* replay and brackets replay with + // `capture_paused_zone_change_delivery_for_member` + + // `drain_pending_batch_deliveries`. Those steps are for liminal / multi- + // member batch deliveries (meld, ninjutsu copy tokens) that can park further + // co-arrivers behind the mid-entry choice. An Aura *spell* entry is a + // single-object `CallerEpilogue` delivery with no active batch frame — there + // is nothing for delivery-capture or batch-drain to preserve — so this path + // intentionally omits them. Retiring before replay is required here because + // `Effect::ChoosePermanent` (unlike `BecomeCopy`) is itself the post- + // replacement continuation being answered; leaving it paused through replay + // lets a nested prompt return and then re-surface the same ChoosePermanent. + state.waiting_for = WaitingFor::Priority { + player: state.active_player, + }; + state.finish_active_paused_post_replacement_dispatch(); + + // CR 614.12a + CR 603.2: replay the Aura's deferred battlefield-entry event + // now that the host copy is realized, so ETB observers see the final state + // (mirrors the BecomeCopy completion tail, but with the drain already + // retired — see above). + if let Some(waiting_for) = replay_deferred_entry_events(state, source_id, events)? { + return Ok(waiting_for); + } + if !matches!(state.waiting_for, WaitingFor::Priority { .. }) { + return Ok(state.waiting_for.clone()); + } + Ok(WaitingFor::Priority { + player: state.active_player, + }) +} + pub(super) fn handle_copy_target_choice( state: &mut GameState, waiting_for: WaitingFor, @@ -1336,6 +1532,7 @@ pub(super) fn handle_copy_target_choice( player, source_id, valid_targets, + purpose, .. } = waiting_for else { @@ -1353,6 +1550,15 @@ pub(super) fn handle_copy_target_choice( } }; + // CR 707.2c + CR 614.12a: Metamorphic Alteration — the chosen creature's + // copiable values are latched onto the source Aura and installed as a + // Layer-1 copy effect on the Aura's enchanted host. The entering Aura is + // NEVER turned into a copy (never `BecomeCopy`), and this path never runs + // the enter-as-a-copy / copy-token completion tail below. + if matches!(purpose, CopyTargetPurpose::PersistChosenAttribute) { + return handle_persist_chosen_attribute_choice(state, source_id, player, target_id, events); + } + if state.liminal_entries.contains_key(&source_id) { let Some(resume) = state.pending_liminal_entry_resume.take() else { return Err(EngineError::InvalidAction( @@ -1938,10 +2144,33 @@ pub(super) fn apply_post_replacement_effect( source_id, valid_targets, max_mana_value, + purpose: CopyTargetPurpose::BecomeCopy, }); } } + // CR 614.12a + CR 707.2c: "As this Aura enters, choose a creature." The + // chosen permanent's copiable values are latched onto the source Aura and + // installed as a copy effect on the Aura's enchanted host at the answer + // (`PersistChosenAttribute`) — never onto the entering Aura itself. This + // reuses the same `CopyTargetChoice` machinery as `BecomeCopy` (it is a + // choice, not targeting — hexproof/shroud don't apply, CR 115.10a) but is + // discriminated by `purpose`. Empty legal-choice set → no prompt (CR 609.3: + // an effect does only as much as possible). + if let Effect::ChoosePermanent { ref filter } = *real_work.effect { + let valid_targets = find_copy_targets(state, filter, source_id, controller, None); + if valid_targets.is_empty() { + return None; + } + return Some(WaitingFor::CopyTargetChoice { + player: controller, + source_id, + valid_targets, + max_mana_value: None, + purpose: CopyTargetPurpose::PersistChosenAttribute, + }); + } + // CR 614.1c: The injected `Object(source)` target is the source-as-SelfRef // hook for replacement post-effects that consume their source (BecomeCopy, // PutCounter, Choose). For an interactive chooser-driven `Effect::Sacrifice` diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 8bdf2c66df..6dde5e8c74 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -4970,6 +4970,14 @@ pub(super) fn handle_resolution_choice( &library_position, events, ); + if let Some(next_owner) = + effects::change_zone::resume_next_mass_library_order_choice(state) + { + state.priority_player = next_owner; + return Ok(ResolutionChoiceOutcome::WaitingFor( + state.waiting_for.clone(), + )); + } } else { // The selected EffectZoneChoice is now consumed. Clear it // before the pipeline may park a CR 616.1 prompt; otherwise @@ -6824,6 +6832,10 @@ fn finish_effect_zone_put_at_library_position( &library_position, events, ); + if let Some(next_owner) = effects::change_zone::resume_next_mass_library_order_choice(state) { + state.priority_player = next_owner; + return; + } if state.active_ability_continuation().is_some() { let tracked = if matches!(library_position, LibraryPosition::Bottom) { state diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 53e75fbb2c..f641e8e33b 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -76,6 +76,7 @@ pub(crate) fn affected_filter_uses_object_population(filter: &TargetFilter) -> b | TargetFilter::AttachedTo | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } @@ -311,6 +312,7 @@ pub(crate) fn entered_object_perturbs_affected_filter( | TargetFilter::AttachedTo | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } @@ -961,6 +963,22 @@ pub(crate) fn controller_ref_player( ControllerRef::ActivePlayer => Some(state.active_player), } } +/// Whether `filter` references the resolution-local `last_zone_changed_ids` +/// ledger population (bare or nested inside compound filters). +pub(crate) fn filter_contains_last_zone_changed(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::LastZoneChanged => true, + TargetFilter::And { filters } | TargetFilter::Or { filters } => { + filters.iter().any(filter_contains_last_zone_changed) + } + TargetFilter::Not { filter } => filter_contains_last_zone_changed(filter), + TargetFilter::TrackedSetFiltered { filter, .. } => { + filter_contains_last_zone_changed(filter) + } + _ => false, + } +} + /// Check if an object matches a typed TargetFilter against the given context. /// /// This is the unified entry point for filter evaluation. Build a @@ -2150,6 +2168,7 @@ fn filter_inner_for_object( .is_some_and(|attached| attached == object_id), TargetFilter::LastCreated => state.last_created_token_ids.contains(&object_id), TargetFilter::LastRevealed => state.last_revealed_ids.contains(&object_id), + TargetFilter::LastZoneChanged => state.last_zone_changed_ids.contains(&object_id), // CR 608.2k: "the sacrificed/exiled/discarded " — the specific // untargeted object previously referred to by this ability. Resolve // through the documented `cost_paid_object → effect_context_object` @@ -2668,6 +2687,7 @@ fn zone_change_filter_inner( ), TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } @@ -2980,6 +3000,7 @@ pub fn spell_record_matches_filter( | TargetFilter::AttachedTo | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } @@ -3296,6 +3317,7 @@ fn spell_object_matches_filter_inner( | TargetFilter::AttachedTo | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } diff --git a/crates/engine/src/game/flip.rs b/crates/engine/src/game/flip.rs new file mode 100644 index 0000000000..d56c6ac11f --- /dev/null +++ b/crates/engine/src/game/flip.rs @@ -0,0 +1,883 @@ +//! CR 710: Flip cards (Kamigawa block). +//! +//! A flip card is a **single-faced** card with a two-part frame. Its top half +//! carries the normal characteristics (CR 710.1a); its bottom half carries an +//! alternative name, text box, type line, power, and toughness that apply only +//! while the permanent is on the battlefield **and** flipped (CR 710.1b). +//! +//! Flipping is therefore NOT transforming: CR 701.27a restricts transforming to +//! permanents represented by double-faced cards/tokens, and CR 710.1c fixes a +//! flip card's color and mana cost across the flip where transforming swaps +//! them. This module keeps its own applicator ([`apply_flipped_face_to_object`]) +//! for exactly that reason — reusing the double-faced applicator +//! (`printed_cards::apply_back_face_to_object`) would swap mana cost and color +//! and break CR 710.1c. +//! +//! Not modeled here, deliberately: +//! - CR 710.5 — a player choosing a flip card's *alternative* name for a +//! "choose a card name" effect. That is a name-choice-menu concern, not a +//! permanent-status concern, and no current name-choice path consults +//! alternative faces. +//! - CR 110.5b's "unless a spell or ability says otherwise" entry override, +//! i.e. Homura, Human Ascendant's "return it to the battlefield flipped". +//! That is an entry-time replacement rider (the flip-card analogue of +//! `enter_transformed`), not a flip instruction, and it is the only printed +//! card in the class. Every permanent therefore enters unflipped, which +//! [`revert_flip_on_zone_exit`] already guarantees on every zone exit. +//! - Turning a *face-down* flipped permanent back FACE UP (CR 708.8) restores +//! the NORMAL half, not the alternative one. `GameObject` has a single +//! `back_face` slot, and a flipped permanent that is turned face down +//! (Ixidron, Cyber Conversion) must keep the normal half there so the +//! CR 710.2 zone-exit result stays correct (see `effects::turn_face_down` +//! and the ordering note in `zones::apply_zone_exit_cleanup`). The +//! alternative half is therefore not recoverable on a later turn-up; the +//! permanent's `flipped` status is still retained (CR 710.4 — flipping is +//! one-way and is never cleared while the permanent stays on the +//! battlefield). Storing both halves at once needs a second stash slot, +//! which is a `GameObject`/serialization change beyond this module. +//! - CR 707.3's *second-order* flip copy case (CR 110.5c's Dimir Doppelganger +//! example): a permanent that is ALREADY flipped and then becomes a copy of +//! another flip card should show the copied card's ALTERNATIVE half. The +//! copy pipeline installs copiable values only (CR 707.2 — the normal half, +//! see [`flipped_normal_copiable_values`]) and never re-derives the copied +//! card's other half, which again needs a second stash slot. + +use std::sync::Arc; + +use crate::game::game_object::{BackFaceData, GameObject}; +use crate::types::ability::CopiableValues; +use crate::types::card::LayoutKind; +use crate::types::events::GameEvent; +use crate::types::game_state::GameState; +use crate::types::identifiers::ObjectId; +use crate::types::zones::Zone; + +use super::engine::EngineError; +use super::printed_cards::snapshot_object_base_face; + +/// CR 710.1: True when `obj` is represented by a CR 710 flip card — i.e. its +/// `back_face` slot holds the *other half* of a flip card rather than the back +/// face of a double-faced card. +/// +/// This is the single authority for "is this a flip card?" and the reason +/// [`stash_flip_face`] re-stamps `LayoutKind::Flip` on **both** halves: whether +/// the permanent is currently flipped or not, the half sitting in `back_face` +/// always carries the tag, so no double-faced path (CR 701.27a transform, +/// CR 712.16 turn-face-down, MDFC/Adventure face choice) can ever mistake a +/// flip card for a DFC — not even after a flip → leave-the-battlefield → +/// return round trip. +pub(crate) fn is_flip_permanent(obj: &GameObject) -> bool { + matches!( + obj.back_face.as_ref().and_then(|face| face.layout_kind), + Some(LayoutKind::Flip) + ) +} + +/// CR 710.1b + CR 710.1c: Snapshot the half of a flip card that is about to be +/// hidden, for storage in the shared `back_face` slot. +/// +/// Two deliberate differences from a plain `snapshot_object_face`: +/// - It reads the **printed/base** characteristics (CR 613.1): the live fields +/// may carry continuous-effect modifications (an anthem, a granted keyword — +/// Student of Elements' own trigger condition is "when this creature has +/// flying"). [`apply_flipped_face_to_object`] writes the stash into both the +/// live and the `base_*` fields, so stashing inflated values would bake a +/// temporary effect into the other half's printed baseline permanently. +/// - It re-stamps `layout_kind: Some(LayoutKind::Flip)` (both snapshot helpers +/// hard-code `None`). Without it, a permanent that flipped and then left the +/// battlefield would sit in its new zone with `flipped == false` AND an +/// untagged `back_face`, and every flip guard keyed on either signal would go +/// false — reopening the CR 710.1c hole where a "transform each ..." effect +/// runs the double-faced applicator on a flip card and swaps the mana cost +/// and color CR 710.1c holds fixed. +fn stash_flip_face(obj: &GameObject) -> BackFaceData { + let mut face = snapshot_object_base_face(obj); + face.layout_kind = Some(LayoutKind::Flip); + face +} + +/// CR 710.4: Flip `object_id` — a one-way status change (CR 110.5) after which +/// the permanent's alternative characteristics apply (CR 710.1b). +/// +/// Silent no-op (returns `Ok(())`) when the instruction cannot apply, mirroring +/// CR 701.27c's "nothing happens" for the analogous transform instruction: +/// - the object is not on the battlefield (CR 710.1b + CR 710.2: the +/// alternative characteristics exist only for a battlefield permanent), +/// - the permanent is already flipped (CR 710.4: flipping is one-way, so a +/// second instruction has nothing to do), +/// - the card carries no alternative face (not a flip card). +/// +/// The pre-flip (normal) characteristics are stashed in `back_face` so +/// `zones::apply_zone_exit_cleanup` can restore them when the permanent leaves +/// the battlefield (CR 710.4 + CR 110.5: a flipped permanent that leaves the +/// battlefield retains no memory of its status). +pub fn flip_permanent( + state: &mut GameState, + object_id: ObjectId, + events: &mut Vec, +) -> Result<(), EngineError> { + let obj = state + .objects + .get(&object_id) + .ok_or_else(|| EngineError::InvalidAction("Object not found".to_string()))?; + + // CR 710.1b + CR 710.2: the alternative characteristics are used only if the + // permanent is on the battlefield. In every other zone a flip card has only + // its normal characteristics, so there is nothing to flip. + if obj.zone != Zone::Battlefield { + return Ok(()); + } + + // CR 710.4: flipping a permanent is a one-way process — once flipped, it's + // impossible for it to become unflipped, and a further flip instruction has + // no effect. + if obj.flipped { + return Ok(()); + } + + // CR 710.1: only a flip card has alternative characteristics to flip to. + let Some(alternative_face) = obj.back_face.clone() else { + return Ok(()); + }; + + let obj = state.objects.get_mut(&object_id).unwrap(); + + // CR 710.4 + CR 110.5: stash the normal characteristics so the zone-exit + // cleanup can restore them — a flipped permanent that leaves the + // battlefield retains no memory of its flipped status. + // + // CR 613.7: the object deliberately keeps its EXISTING timestamp. CR 613.7 + // enumerates every event that grants a new one — 613.7d zone entry, 613.7e + // attachment, 613.7f turning face up or face down, 613.7g transforming or + // converting — and flipping is not among them. (CR 613.7a grants nothing on + // its own; it only says a static ability's continuous effect inherits the + // object's timestamp, so the alternative text box's statics simply take the + // timestamp the permanent already has.) + let normal_face = stash_flip_face(obj); + apply_flipped_face_to_object(obj, alternative_face); + obj.back_face = Some(normal_face); + obj.flipped = true; + + crate::game::layers::mark_layers_full(state); + + events.push(GameEvent::Flipped { object_id }); + + Ok(()) +} + +/// CR 710.4 + CR 110.5 + CR 710.2: Restore a flipped permanent's normal +/// characteristics as it leaves the battlefield. A flipped permanent that +/// leaves the battlefield retains no memory of its status (CR 710.4), and in +/// every zone other than the battlefield a flip card has only the normal +/// characteristics of the card (CR 710.2). +/// +/// Called from `zones::apply_zone_exit_cleanup`, which owns the zone-exit +/// status reset for every status category (CR 110.5). It runs this AFTER the +/// CR 708.9 face-down restore — see the ordering note there. +pub(crate) fn revert_flip_on_zone_exit(obj: &mut GameObject) { + if !obj.flipped { + return; + } + let Some(normal_face) = obj.back_face.clone() else { + // CR 708.9 + CR 710.4: reached when the permanent was flipped AND then + // turned face down. `effects::turn_face_down` left the flip stash (the + // normal half) in `back_face`, and the face-down restore that runs just + // before this already consumed it — the object is showing the normal + // half again, so only the status is left to clear (CR 110.5: the new + // object is unflipped). + obj.flipped = false; + return; + }; + let alternative_face = stash_flip_face(obj); + apply_flipped_face_to_object(obj, normal_face); + obj.back_face = Some(alternative_face); + obj.flipped = false; +} + +/// CR 707.2 + CR 707.3: the copiable values of a permanent that is currently +/// flipped are its **normal** (top-half) printed values, not the alternative +/// ones now showing. +/// +/// CR 707.2 lists the copiable values as "the values derived from the text +/// printed on the object" and states that status is NOT copied; flipped is a +/// status (CR 110.5). CR 707.3's worked example is literally a flip card +/// (Tomoya the Revealer copying Nezumi Shortfang gets Nezumi's values and its +/// own flipped status then selects Stabwhisker). So a Clone / Phyrexian +/// Metamorph / Kiki-Jiki / token copy of a flipped Kenzo the Hardhearted must +/// be an UNFLIPPED Bushi Tenderfoot 1/1 — reading the object's `base_*` fields +/// would instead yield an unflipped 3/4 legendary Kenzo, because +/// [`apply_flipped_face_to_object`] wrote the alternative half into `base_*`. +/// +/// Returns `None` for any object that is not a flipped flip permanent, so +/// `printed_cards::intrinsic_copiable_values` keeps its `base_*` fast path. +pub(crate) fn flipped_normal_copiable_values(obj: &GameObject) -> Option { + if !obj.flipped { + return None; + } + let normal_face = obj.back_face.as_ref()?; + Some(CopiableValues { + name: normal_face.name.clone(), + // CR 710.1c: cost and color never changed across the flip, so the + // stash and the live object agree here — taken from the stash anyway so + // every copiable value has one source. + mana_cost: normal_face.mana_cost.clone(), + color: normal_face.color.clone(), + card_types: normal_face.card_types.clone(), + power: normal_face.power, + toughness: normal_face.toughness, + loyalty: normal_face.loyalty, + keywords: normal_face.keywords.clone(), + abilities: Arc::new(normal_face.abilities.clone()), + trigger_definitions: Arc::new( + normal_face + .trigger_definitions + .iter_all() + .cloned() + .collect(), + ), + // CR 707.2 + CR 611.2b: runtime replacements durably parked in the + // definition set are not printed characteristics — same exclusion the + // unflipped path applies via `copiable_replacement_definitions`. + replacement_definitions: Arc::new( + normal_face + .replacement_definitions + .iter_all() + .filter(|def| !crate::game::printed_cards::is_runtime_non_copiable_replacement(def)) + .cloned() + .collect(), + ), + static_definitions: Arc::new(normal_face.static_definitions.iter_all().cloned().collect()), + }) +} + +/// CR 710.1c: Re-assert a flipped permanent's unchanged color and mana cost +/// from the normal half stashed in `back_face`. +/// +/// [`apply_flipped_face_to_object`] never touches those four fields, so this is +/// a no-op for a live flip. It exists for the ONE seam that can overwrite them: +/// `printed_cards::reapply_printed_faces_from_card_db` re-applies the printed +/// face named by `printed_ref` — which for a flipped permanent is the +/// alternative half, and an alternative half carries no printed mana cost. On +/// every state reload that would blank the cost CR 710.1c preserves. +pub(crate) fn restore_normal_cost_and_color_if_flipped(obj: &mut GameObject) { + if !obj.flipped { + return; + } + let Some(normal_face) = obj.back_face.as_ref() else { + return; + }; + let mana_cost = normal_face.mana_cost.clone(); + let color = normal_face.color.clone(); + obj.mana_cost = mana_cost.clone(); + obj.base_mana_cost = mana_cost; + obj.color = color.clone(); + obj.base_color = color; +} + +/// CR 710.1b + CR 710.1c: Apply a flip card's *alternative* characteristics to +/// a battlefield permanent. +/// +/// This deliberately does NOT delegate to +/// `printed_cards::apply_back_face_to_object` (the double-faced-card +/// applicator). That function swaps mana cost and color, which is correct for +/// CR 712 double-faced cards and **wrong** for CR 710 flip cards. +/// +/// Copied from the alternative face (CR 710.1b — "an alternative name, text +/// box, type line, power, and toughness"), each alongside its `base_*` twin so +/// the layer system (CR 613) recomputes from the new printed values: +/// - `name` / `base_name` +/// - `power` / `base_power`, `toughness` / `base_toughness` +/// - `card_types` / `base_card_types` (the alternative type line, which for +/// every printed flip card adds the Legendary supertype) +/// - `keywords` / `base_keywords` +/// - `abilities` / `base_abilities`, `trigger_definitions` (via +/// `install_trigger_base_definitions`), `replacement_definitions` / +/// `base_replacement_definitions`, `static_definitions` / +/// `base_static_definitions` — the alternative text box +/// - `printed_ref` / `base_printed_ref` — the display identity of the half now +/// showing +/// - `loyalty` / `base_loyalty` and `defense` / `base_defense`: not enumerated +/// in CR 710.1b (and no printed flip card has either), but they are +/// type-line-derived printed values (CR 306.5b / CR 310.4b), so they follow +/// the type line rather than leaving a stale top-half value behind. +/// +/// Deliberately NOT copied: +/// - `mana_cost` / `base_mana_cost` — CR 710.1c: a flip card's mana cost +/// doesn't change if the permanent is flipped. +/// - `color` / `base_color` — CR 710.1c: a flip card's color doesn't change if +/// the permanent is flipped. +/// - `modal`, `additional_cost`, `strive_cost`, `casting_restrictions`, +/// `casting_options` — CR 710.2: a flip card is cast using only its normal +/// characteristics (it has only those in every zone other than the +/// battlefield), so the alternative half carries no casting properties and +/// must not clobber the normal half's. +/// +/// External effects applied to the permanent are untouched (CR 710.1c: "any +/// changes to it by external effects will still apply") — this writes only +/// printed/base values plus their live mirrors, exactly as the layer system +/// expects; `mark_layers_full` then reapplies every continuous effect. +pub(crate) fn apply_flipped_face_to_object(obj: &mut GameObject, face: BackFaceData) { + // CR 710.1b: alternative name. + obj.name = face.name.clone(); + obj.base_name = face.name; + + // CR 710.1b: alternative power and toughness. + obj.power = face.power; + obj.base_power = face.power; + obj.toughness = face.toughness; + obj.base_toughness = face.toughness; + + // CR 306.5b + CR 310.4b: loyalty/defense track the alternative type line. + obj.loyalty = face.loyalty; + obj.base_loyalty = face.loyalty; + obj.defense = face.defense; + obj.base_defense = face.defense; + + // CR 710.1b: alternative type line. + obj.card_types = face.card_types.clone(); + obj.base_card_types = face.card_types; + + // CR 710.1b: alternative text box — keywords, abilities, triggers, + // replacements, and statics all come from the half now showing. + obj.keywords = face.keywords.clone(); + obj.base_keywords = face.keywords; + obj.abilities = Arc::new(face.abilities.clone()); + obj.base_abilities = Arc::new(face.abilities); + obj.replacement_definitions = face.replacement_definitions.clone(); + obj.base_replacement_definitions = + Arc::new(face.replacement_definitions.iter_all().cloned().collect()); + obj.static_definitions = face.static_definitions.clone(); + obj.base_static_definitions = Arc::new(face.static_definitions.iter_all().cloned().collect()); + obj.install_trigger_base_definitions(Arc::new( + face.trigger_definitions.iter_all().cloned().collect(), + )) + .expect("trigger base-set generation must not overflow"); + obj.base_characteristics_initialized = true; + + // CR 710.1b: the alternative half is what's now shown. Cloned before the + // move so both the display baseline and the live pointer are set. + obj.base_printed_ref = face.printed_ref.clone(); + obj.printed_ref = face.printed_ref; + + // CR 710.1c: a flip card's color and mana cost don't change when flipped — + // `mana_cost`, `base_mana_cost`, `color`, and `base_color` are deliberately + // left untouched (`face.mana_cost` / `face.color` go unread). This is the + // single behavioral difference from the double-faced applicator and the + // reason this function exists rather than delegating to it. + + // CR 710.2: the card is cast using only its normal characteristics, so the + // normal half's casting properties (`modal`, `additional_cost`, + // `strive_cost`, `casting_restrictions`, `casting_options`) are deliberately + // left untouched. +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::zones::create_object; + use crate::types::ability::{AbilityDefinition, AbilityKind, Effect}; + use crate::types::card_type::{CardType, CoreType, Supertype}; + use crate::types::identifiers::CardId; + use crate::types::keywords::Keyword; + use crate::types::mana::{ManaColor, ManaCost, ManaCostShard}; + use crate::types::player::PlayerId; + + /// `{W}` — Bushi Tenderfoot's printed mana cost (CR 202.1). + fn white_mana_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ManaCostShard::White], + generic: 0, + } + } + + /// Bushi Tenderfoot // Kenzo the Hardhearted, the canonical CR 710 flip + /// card: a {W} 1/1 Creature — Human Soldier whose alternative half is a 3/4 + /// Legendary Creature — Human Samurai with double strike. + fn setup_flip_card(state: &mut GameState) -> ObjectId { + let id = create_object( + state, + CardId(1), + PlayerId(0), + "Bushi Tenderfoot".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.power = Some(1); + obj.toughness = Some(1); + obj.base_power = Some(1); + obj.base_toughness = Some(1); + obj.card_types = CardType { + supertypes: vec![], + core_types: vec![CoreType::Creature], + subtypes: vec!["Human".to_string(), "Soldier".to_string()], + }; + obj.base_card_types = obj.card_types.clone(); + obj.keywords = vec![Keyword::Bushido(1)]; + obj.base_keywords = obj.keywords.clone(); + obj.abilities = Arc::new(vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::FlipPermanent { + target: crate::types::ability::TargetFilter::SelfRef, + }, + )]); + obj.base_abilities = Arc::clone(&obj.abilities); + obj.mana_cost = white_mana_cost(); + obj.base_mana_cost = obj.mana_cost.clone(); + obj.color = vec![ManaColor::White]; + obj.base_color = vec![ManaColor::White]; + + obj.back_face = Some(BackFaceData { + name: "Kenzo the Hardhearted".to_string(), + power: Some(3), + toughness: Some(4), + loyalty: None, + defense: None, + card_types: CardType { + supertypes: vec![Supertype::Legendary], + core_types: vec![CoreType::Creature], + subtypes: vec!["Human".to_string(), "Samurai".to_string()], + }, + // The alternative half of a flip card has no printed mana cost and + // no printed color indicator — proof that reusing the double-faced + // applicator would blank both (CR 710.1c). + mana_cost: ManaCost::default(), + keywords: vec![Keyword::DoubleStrike, Keyword::Bushido(2)], + abilities: Vec::new(), + trigger_definitions: Default::default(), + replacement_definitions: Default::default(), + static_definitions: Default::default(), + color: Vec::new(), + printed_ref: None, + modal: None, + additional_cost: None, + strive_cost: None, + casting_restrictions: vec![], + casting_options: vec![], + layout_kind: Some(crate::types::card::LayoutKind::Flip), + }); + + id + } + + /// CR 710.1b: the alternative name, type line, power, toughness, and text + /// box replace the normal ones once the permanent is flipped. + #[test] + fn flip_applies_the_alternative_face() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert!(obj.flipped); + assert_eq!(obj.name, "Kenzo the Hardhearted"); + assert_eq!(obj.base_name, "Kenzo the Hardhearted"); + assert_eq!(obj.power, Some(3)); + assert_eq!(obj.toughness, Some(4)); + assert!(obj.card_types.supertypes.contains(&Supertype::Legendary)); + assert!(obj.card_types.subtypes.iter().any(|s| s == "Samurai")); + assert!(crate::game::keywords::has_keyword( + obj, + &Keyword::DoubleStrike + )); + assert!(state.layers_dirty.is_dirty()); + assert_eq!(events, vec![GameEvent::Flipped { object_id: id }]); + } + + /// CR 710.1c: a flip card's color and mana cost don't change if the + /// permanent is flipped. Reverting to the double-faced applicator + /// (`apply_back_face_to_object`) blanks both and fails this test. + #[test] + fn flip_preserves_color_and_mana_cost() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + let cost_before = state.objects[&id].mana_cost.clone(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert_eq!( + obj.mana_cost, cost_before, + "CR 710.1c: mana cost must not change when the permanent flips" + ); + assert_eq!(obj.base_mana_cost, cost_before); + assert_eq!( + obj.color, + vec![ManaColor::White], + "CR 710.1c: color must not change when the permanent flips" + ); + assert_eq!(obj.base_color, vec![ManaColor::White]); + } + + /// CR 710.4: flipping is one-way — a second flip instruction is a no-op and + /// emits no event. + #[test] + fn flipping_an_already_flipped_permanent_is_a_no_op() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + let timestamp_after_first = state.objects[&id].timestamp; + events.clear(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert!(obj.flipped, "CR 710.4: the permanent stays flipped"); + assert_eq!(obj.name, "Kenzo the Hardhearted"); + assert_eq!(obj.power, Some(3)); + assert!(events.is_empty(), "no second Flipped event"); + assert_eq!( + obj.timestamp, timestamp_after_first, + "a no-op flip must not draw a timestamp" + ); + } + + /// CR 710.1b + CR 710.2: a flip card that is not on the battlefield has + /// only its normal characteristics — the instruction does nothing. + #[test] + fn off_battlefield_object_cannot_flip() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + state.objects.get_mut(&id).unwrap().zone = Zone::Graveyard; + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert!(!obj.flipped); + assert_eq!(obj.name, "Bushi Tenderfoot"); + assert!(events.is_empty()); + } + + /// CR 710.4 + CR 110.5: a flipped permanent that leaves the battlefield + /// retains no memory of its status — the graveyard card shows only the + /// normal characteristics (CR 710.2). + #[test] + fn zone_change_resets_flipped_status() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + assert!(state.objects[&id].flipped); + + crate::game::zones::move_to_zone(&mut state, id, Zone::Graveyard, &mut events); + + let obj = &state.objects[&id]; + assert!(!obj.flipped, "CR 110.5: the new object is unflipped"); + assert_eq!(obj.name, "Bushi Tenderfoot"); + assert_eq!(obj.power, Some(1)); + assert_eq!(obj.toughness, Some(1)); + assert!(!obj.card_types.supertypes.contains(&Supertype::Legendary)); + assert_eq!( + obj.mana_cost, + white_mana_cost(), + "CR 710.1c: the mana cost was never changed, so the revert restores it unchanged" + ); + } + + /// A permanent with no alternative face is not a flip card — CR 710.1's + /// alternative characteristics don't exist, so nothing happens. + #[test] + fn non_flip_card_cannot_flip() { + let mut state = GameState::new_two_player(42); + let id = create_object( + &mut state, + CardId(7), + PlayerId(0), + "Grizzly Bears".to_string(), + Zone::Battlefield, + ); + let timestamp_before = state.objects[&id].timestamp; + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + assert!(!state.objects[&id].flipped); + assert!(events.is_empty()); + assert_eq!(state.objects[&id].timestamp, timestamp_before); + } + + /// CR 701.27a + CR 701.27c: a flip card is not represented by a + /// double-faced card, so an instruction to TRANSFORM it does nothing — + /// in particular it must not run the double-faced applicator and blank the + /// mana cost and color that CR 710.1c preserves. + #[test] + fn transform_instruction_does_nothing_to_a_flip_card() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + crate::game::transform::transform_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert!(!obj.transformed); + assert!(!obj.flipped); + assert_eq!(obj.name, "Bushi Tenderfoot"); + assert_eq!(obj.mana_cost, white_mana_cost()); + assert_eq!(obj.color, vec![ManaColor::White]); + assert!(events.is_empty()); + } + + /// CR 613.7: flipping grants NO new timestamp. CR 613.7 enumerates every + /// timestamp-granting event — 613.7d zone entry, 613.7e attachment, 613.7f + /// turning face up/down, 613.7g transforming/converting — and flipping is + /// not among them (CR 613.7a only says a static ability's continuous effect + /// inherits its object's timestamp; it grants nothing on its own). + /// + /// Discriminating: re-adding a `state.next_timestamp()` bump to + /// `flip_permanent` makes `after` differ from `before` and fails here. + #[test] + fn flip_does_not_grant_a_new_timestamp() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let before = state.objects[&id].timestamp; + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + assert!(state.objects[&id].flipped, "reach guard: it really flipped"); + assert_eq!( + state.objects[&id].timestamp, before, + "CR 613.7: flipping is not a timestamp-granting event" + ); + } + + /// CR 710.1c + CR 701.27a: a flip card that flipped, LEFT the battlefield, + /// and came back is still a flip card — a later "transform each ..." effect + /// must not run the double-faced applicator on it. + /// + /// Discriminating: `stash_flip_face` re-stamps `LayoutKind::Flip` on the + /// half it parks in `back_face`. Drop that re-stamp (i.e. use a plain + /// `snapshot_object_*_face`, whose `layout_kind` is hard-coded `None`) and + /// the returned permanent has `flipped == false` AND an untagged + /// `back_face`, so both arms of the transform guard go false: the assertions + /// below on `transformed`, `name`, `mana_cost`, and `color` all fail + /// (`apply_back_face_to_object` swaps in Kenzo and blanks the {W} cost). + #[test] + fn a_flip_card_that_left_and_returned_still_cannot_transform() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + assert!(state.objects[&id].flipped, "reach guard: it really flipped"); + + // Die, then get reanimated onto the battlefield. + crate::game::zones::move_to_zone(&mut state, id, Zone::Graveyard, &mut events); + crate::game::zones::move_to_zone(&mut state, id, Zone::Battlefield, &mut events); + assert!( + !state.objects[&id].flipped, + "reach guard: CR 110.5b — the returning permanent is unflipped, so \ + the `flipped` arm of the transform guard cannot be what blocks it" + ); + assert_eq!( + state.objects[&id].zone, + Zone::Battlefield, + "reach guard: transform_permanent only acts on battlefield permanents" + ); + + events.clear(); + crate::game::transform::transform_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert!( + !obj.transformed, + "CR 701.27a: a flip card is single-faced and cannot transform" + ); + assert_eq!(obj.name, "Bushi Tenderfoot"); + assert_eq!( + obj.mana_cost, + white_mana_cost(), + "CR 710.1c: the double-faced applicator must never touch a flip card's mana cost" + ); + assert_eq!(obj.color, vec![ManaColor::White]); + assert!(events.is_empty()); + assert!( + is_flip_permanent(obj), + "the LayoutKind::Flip tag survives the flip → leave → return round trip" + ); + } + + /// CR 613.1 + CR 710.1b: the half parked in `back_face` must be the PRINTED + /// one, not the layer-modified live one. Student of Elements' own trigger + /// condition is "when this creature has flying", so a granted keyword and a + /// pumped P/T are exactly the state a flip happens in. + /// + /// Discriminating: revert `stash_flip_face` to `snapshot_object_face` (live + /// fields) and the graveyard card is a 2/2 Bushi Tenderfoot WITH flying — + /// the printed-P/T and no-flying assertions below both fail. + #[test] + fn flip_stashes_printed_characteristics_not_layer_modified_ones() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + { + // Stand in for an active continuous effect (an anthem plus a + // granted keyword): live fields inflated, `base_*` untouched. + let obj = state.objects.get_mut(&id).unwrap(); + obj.power = Some(2); + obj.toughness = Some(2); + obj.keywords.push(Keyword::Flying); + } + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + assert_eq!( + state.objects[&id].name, "Kenzo the Hardhearted", + "reach guard: it really flipped" + ); + + crate::game::zones::move_to_zone(&mut state, id, Zone::Graveyard, &mut events); + + let obj = &state.objects[&id]; + assert_eq!(obj.name, "Bushi Tenderfoot"); + assert_eq!( + (obj.power, obj.toughness), + (Some(1), Some(1)), + "CR 613.1: the stash must carry the PRINTED 1/1, not the anthem-inflated 2/2" + ); + assert_eq!((obj.base_power, obj.base_toughness), (Some(1), Some(1))); + assert!( + !crate::game::keywords::has_keyword(obj, &Keyword::Flying), + "CR 613.1: a granted keyword must not be baked into the normal half" + ); + assert!( + !obj.base_keywords.contains(&Keyword::Flying), + "CR 613.1: a granted keyword must not be baked into the printed baseline" + ); + assert!( + crate::game::keywords::has_keyword(obj, &Keyword::Bushido(1)), + "reach guard: the printed keyword set really was restored" + ); + } + + /// CR 710.2 + CR 708.9: a flipped permanent turned FACE DOWN (Ixidron, + /// Cyber Conversion — CR 712.16 does not cover flip cards) and then killed + /// lands in the graveyard as the NORMAL half, not as the nameless + /// face-down shell and not as the alternative half. + /// + /// Discriminating: let `turn_face_down` overwrite `back_face` with a fresh + /// base snapshot again and the graveyard card is "Kenzo the Hardhearted" + /// 3/4; run `revert_flip_on_zone_exit` BEFORE the CR 708.9 restore again and + /// the graveyard card is the nameless 2/2 shell. Both fail the assertions + /// below. + #[test] + fn a_flipped_permanent_turned_face_down_still_dies_as_the_normal_half() { + use crate::types::ability::{FaceDownProfile, ResolvedAbility, TargetFilter}; + + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + assert!(state.objects[&id].flipped, "reach guard: it really flipped"); + + let turn_down = ResolvedAbility::new( + Effect::TurnFaceDown { + target: TargetFilter::SpecificObject { id }, + profile: Some(FaceDownProfile::vanilla_2_2()), + }, + vec![], + ObjectId(999), + PlayerId(0), + ); + crate::game::effects::turn_face_down::resolve(&mut state, &turn_down, &mut events).unwrap(); + assert!( + state.objects[&id].face_down, + "reach guard: CR 712.16 must NOT block a flip card, so it really is face down" + ); + assert!( + state.objects[&id].flipped, + "CR 710.4: flipping is one-way — turning face down does not unflip it" + ); + + crate::game::zones::move_to_zone(&mut state, id, Zone::Graveyard, &mut events); + + let obj = &state.objects[&id]; + assert!( + !obj.face_down, + "CR 708.9: revealed on leaving the battlefield" + ); + assert!(!obj.flipped, "CR 110.5: the new object is unflipped"); + assert_eq!( + obj.name, "Bushi Tenderfoot", + "CR 710.2: off the battlefield a flip card has only its normal characteristics" + ); + assert_eq!((obj.power, obj.toughness), (Some(1), Some(1))); + assert!(!obj.card_types.supertypes.contains(&Supertype::Legendary)); + assert_eq!(obj.mana_cost, white_mana_cost()); + } + + /// CR 707.2 + CR 707.3: status is not copied, and CR 707.3's worked example + /// is a flip card. A Clone copying a flipped Kenzo the Hardhearted must + /// become an UNFLIPPED Bushi Tenderfoot 1/1. + /// + /// Discriminating: remove the flipped branch from + /// `printed_cards::intrinsic_copiable_values` and the copy reads the + /// object's `base_*` — which `apply_flipped_face_to_object` overwrote with + /// the alternative half — producing a legendary 3/4 "Kenzo the Hardhearted" + /// and failing every assertion below. + #[test] + fn a_copy_of_a_flipped_permanent_takes_the_unflipped_normal_half() { + use crate::types::ability::{Duration, ResolvedAbility, TargetFilter, TargetRef}; + + let mut state = GameState::new_two_player(42); + let flipped = setup_flip_card(&mut state); + let clone = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Clone".to_string(), + Zone::Battlefield, + ); + let mut events = Vec::new(); + + flip_permanent(&mut state, flipped, &mut events).unwrap(); + assert_eq!( + state.objects[&flipped].name, "Kenzo the Hardhearted", + "reach guard: the copy source really is flipped" + ); + + let become_copy = ResolvedAbility::new( + Effect::BecomeCopy { + target: TargetFilter::Any, + recipient: TargetFilter::SelfRef, + duration: Some(Duration::Permanent), + mana_value_limit: None, + additional_modifications: Vec::new(), + }, + vec![TargetRef::Object(flipped)], + clone, + PlayerId(0), + ); + crate::game::effects::become_copy::resolve(&mut state, &become_copy, &mut events).unwrap(); + crate::game::layers::flush_layers(&mut state); + + let copy = &state.objects[&clone]; + assert!( + !copy.flipped, + "CR 707.2: status is not copied — the copy enters unflipped" + ); + assert_eq!( + copy.name, "Bushi Tenderfoot", + "CR 707.2 + CR 707.3: the copiable values are the NORMAL half" + ); + assert_eq!((copy.power, copy.toughness), (Some(1), Some(1))); + assert!( + !copy.card_types.supertypes.contains(&Supertype::Legendary), + "CR 710.1b: the Legendary supertype lives only on the alternative half" + ); + assert!( + !crate::game::keywords::has_keyword(copy, &Keyword::DoubleStrike), + "CR 707.2: the alternative half's text box is not a copiable value" + ); + assert_eq!( + state.objects[&flipped].name, "Kenzo the Hardhearted", + "the copy source is untouched" + ); + } +} diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 010e9b33a4..78c6ca2190 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -340,6 +340,18 @@ pub struct EmblemSource { pub printed_ref: Option, } +/// CR 702.16p: Start-time attachment exemption captured for one continuous +/// protection modification (`static_definitions` index + `modifications` index +/// on the grant source) and host. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtectionStartSnapshot { + pub resolved_quality: crate::types::keywords::ProtectionTarget, + pub attachment_ids: Vec, +} + +/// `(static_definitions index, modifications index, host object id)`. +pub type ProtectionEffectHostKey = (usize, usize, ObjectId); + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GameObject { pub id: ObjectId, @@ -381,6 +393,12 @@ pub struct GameObject { /// `None` if unattached. See `AttachTarget` for variants. pub attached_to: Option, pub attachments: Vec, + /// CR 702.16p: Per [`StaticGateKey::def_index`] on this source and enchanted + /// host, the controlled attachments matching that effect's resolved protection + /// quality when it first started applying to that host. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub protection_start_exempt_attachments: + HashMap, /// CR 702.95b-d: Soulbond pair relationship. Pairing is symmetric: /// if `A.paired_with == Some(B)`, then `B.paired_with == Some(A)`. /// This is independent from attachments; paired creatures are not @@ -660,6 +678,13 @@ pub struct GameObject { /// spell has left the stack. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub kickers_paid: Vec, + /// CR 702.174a: Opponent chosen when this object's Gift cost was paid. + /// Mirrors `SpellContext.gift_recipient`; stamped at cast finalize + /// (kickers_paid pattern). Cleared by `reset_for_battlefield_exit` / + /// `reset_for_battlefield_entry` (CR 400.7); restored across Stack→Battlefield + /// only via `CastLinkSnapshot` (CR 400.7d). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gift_recipient: Option, /// CR 601.2b/f/h + CR 702.157a: Count of non-kicker repeatable /// additional costs paid while casting the spell that produced this /// permanent. Kept separate from `kickers_paid` so Squad does not inherit @@ -1185,6 +1210,7 @@ fn _gameobject_partition_is_total(o: &GameObject) { cost_x_paid: _, fused_split_spell: _, kickers_paid: _, + gift_recipient: _, additional_cost_payment_count: _, additional_cost_payments: _, convoked_creatures: _, @@ -1249,6 +1275,7 @@ fn _gameobject_partition_is_total(o: &GameObject) { phyrexian_life_paid: _, mana_spent_source_snapshots: _, phase_status: _, + protection_start_exempt_attachments: _, } = o; } @@ -1981,6 +2008,7 @@ impl GameObject { dealt_deathtouch_damage: false, attached_to: None, attachments: Vec::new(), + protection_start_exempt_attachments: HashMap::new(), paired_with: None, pair_controller: None, counters: HashMap::new(), @@ -2043,6 +2071,7 @@ impl GameObject { cost_x_paid: None, fused_split_spell: false, kickers_paid: Vec::new(), + gift_recipient: None, additional_cost_payment_count: 0, additional_cost_payments: Vec::new(), convoked_creatures: Vec::new(), @@ -2253,6 +2282,11 @@ impl GameObject { // event as `cast_from_zone`; clear it on zone change for the same reason. self.cast_spell_keywords.clear(); self.kickers_paid.clear(); + // CR 400.7 + CR 702.174a: Gift recipient is cast-link provenance (who was + // promised the gift when this permanent was cast). A re-entering object + // has no memory of a prior Gift promise — clear before CastLinkSnapshot + // restores it for Stack→Battlefield cast resolution only. + self.gift_recipient = None; self.additional_cost_payment_count = 0; self.additional_cost_payments.clear(); // CR 400.7 + CR 702.51c: convoked-creature history is tied to the @@ -2345,6 +2379,10 @@ impl GameObject { // runs (zones.rs: exit seam → snapshot → reset), so latched trigger // contexts keep the payment record of the departing incarnation. self.clear_cast_payment_stamps(); + // CR 400.7 + CR 702.174a: Gift recipient is the same cast-link class as + // kickers_paid / payment stamps — a blinked or reanimated permanent must + // not deliver (or condition on) a prior incarnation's Gift promise. + self.gift_recipient = None; // CR 611.2f: the cast-time keyword snapshot is bound to the same casting // event as `cast_from_zone`; clear it on the same zone-change boundary. self.cast_spell_keywords.clear(); @@ -2355,6 +2393,7 @@ impl GameObject { // CR 305.1 + CR 603.4: Land-play provenance is likewise battlefield- // entry scoped and must not survive a later zone change. self.played_from_zone = None; + self.protection_start_exempt_attachments.clear(); self.convoked_creatures.clear(); // CR 702.103f: `bestow_form` is intentionally NOT cleared here. // The zone-exit cleanup in `apply_zone_exit_cleanup` (zones.rs) reads @@ -2765,6 +2804,49 @@ mod tests { ); } + /// CR 400.7 + CR 702.174a: Gift recipient is cast-link provenance and must + /// clear on battlefield exit — a blinked/reanimated permanent cannot keep a + /// prior incarnation's Gift promise. + #[test] + fn reset_for_battlefield_exit_clears_gift_recipient() { + let mut obj = GameObject::new( + ObjectId(1), + CardId(1), + PlayerId(0), + "Scrapshooter".to_string(), + Zone::Battlefield, + ); + obj.gift_recipient = Some(PlayerId(1)); + + obj.reset_for_battlefield_exit(); + + assert!( + obj.gift_recipient.is_none(), + "gift_recipient must clear on battlefield exit (CR 400.7)" + ); + } + + /// CR 400.7d: Entry reset also clears Gift recipient so effect-driven puts + /// (Reanimate) cannot resurrect a stamp that somehow survived exile/GY. + #[test] + fn reset_for_battlefield_entry_clears_gift_recipient() { + let mut obj = GameObject::new( + ObjectId(1), + CardId(1), + PlayerId(0), + "Scrapshooter".to_string(), + Zone::Graveyard, + ); + obj.gift_recipient = Some(PlayerId(1)); + + obj.reset_for_battlefield_entry(1, 1); + + assert!( + obj.gift_recipient.is_none(), + "gift_recipient must clear on battlefield entry (CR 400.7d)" + ); + } + /// CR 400.7d + CR 603.4 (issue #5943 review round): the zone-change /// snapshot latches all four trigger-relevant mana-payment stamps — including the /// per-mana-unit source-snapshot vector — into the owned trigger source diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 6dbbfcd187..391fc635c7 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -293,6 +293,7 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H | WaitingFor::ClashChooseOpponent { .. } | WaitingFor::ChooseFromZoneOpponentChooser { .. } | WaitingFor::ChooseAnnouncingOpponent { .. } + | WaitingFor::ChooseGiftRecipient { .. } | WaitingFor::ClashCardPlacement { .. } | WaitingFor::VoteChoice { .. } | WaitingFor::SeparatePilesChooseOpponent { .. } @@ -520,6 +521,7 @@ fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { | WaitingFor::ClashChooseOpponent { .. } | WaitingFor::ChooseFromZoneOpponentChooser { .. } | WaitingFor::ChooseAnnouncingOpponent { .. } + | WaitingFor::ChooseGiftRecipient { .. } | WaitingFor::ClashCardPlacement { .. } | WaitingFor::VoteChoice { .. } | WaitingFor::SeparatePilesChooseOpponent { .. } @@ -823,6 +825,9 @@ pub(crate) fn rebind_interaction_slots_after_action( } pub(crate) fn debug_assert_interaction_consistency(state: &GameState) { + #[cfg(not(debug_assertions))] + let _ = state; + #[cfg(debug_assertions)] { if state @@ -3239,6 +3244,7 @@ fn selection_projection( | WaitingFor::ClashChooseOpponent { .. } | WaitingFor::ChooseFromZoneOpponentChooser { .. } | WaitingFor::ChooseAnnouncingOpponent { .. } + | WaitingFor::ChooseGiftRecipient { .. } | WaitingFor::ClashCardPlacement { .. } | WaitingFor::VoteChoice { .. } | WaitingFor::SeparatePilesChooseOpponent { .. } @@ -3859,7 +3865,8 @@ fn project_action_payload( GameAction::ChooseClashOpponent { opponent } | GameAction::ChooseZoneOpponentChooser { opponent } | GameAction::ChoosePileOpponent { opponent } - | GameAction::ChooseAnnouncingOpponent { opponent } => { + | GameAction::ChooseAnnouncingOpponent { opponent } + | GameAction::ChooseGiftRecipient { opponent } => { push_player_surface(surfaces, *opponent, InteractionRoleCode::Opponent) } GameAction::ChooseAssistPlayer { player } => { @@ -4542,6 +4549,7 @@ fn action_code(action: &GameAction) -> InteractionActionCode { GameAction::ChooseAnnouncingOpponent { .. } => { InteractionActionCode::ChooseAnnouncingOpponent } + GameAction::ChooseGiftRecipient { .. } => InteractionActionCode::ChooseGiftRecipient, GameAction::ChooseAssistPlayer { .. } => InteractionActionCode::ChooseAssistPlayer, GameAction::CommitAssistPayment { .. } => InteractionActionCode::CommitAssistPayment, GameAction::MulliganDecision { .. } => InteractionActionCode::MulliganDecision, diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index 8907147ced..2f48b7cfdd 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -12,7 +12,7 @@ use crate::types::events::GameEvent; use crate::types::game_state::{GameState, WaitingFor}; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::keywords::{ - EmbalmCost, EternalizeCost, FlashbackCost, Keyword, KeywordKind, ProtectionTarget, + EmbalmCost, EternalizeCost, FlashbackCost, GiftKind, Keyword, KeywordKind, ProtectionTarget, }; use crate::types::mana::ManaCost; use crate::types::phase::Phase; @@ -82,6 +82,15 @@ pub fn effective_flashback_cost(state: &GameState, object_id: ObjectId) -> Optio } } +/// CR 702.174a: Effective Gift kind for casting prompts (hand / stack included). +/// Routes through [`effective_keyword_for_object`] so off-zone grants are visible. +pub fn effective_gift_kind(state: &GameState, object_id: ObjectId) -> Option { + match effective_keyword_for_object(state, object_id, KeywordKind::Gift)? { + Keyword::Gift(kind) => Some(kind), + _ => None, + } +} + /// CR 702.146a: Effective Disturb alt-cost for an object in the graveyard. pub fn effective_disturb_cost(state: &GameState, object_id: ObjectId) -> Option { let keyword = diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 6e7f427dbe..201ae484e7 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -15,7 +15,7 @@ use crate::game::filter::{matches_target_filter, FilterContext}; use crate::game::game_object::DisplaySource; use crate::game::printed_cards::{ apply_copiable_values, ensure_keyword_triggers_for_copiable_values, intrinsic_copiable_values, - is_runtime_target_die_exile_replacement, + is_runtime_host_lifetime_replacement, is_runtime_target_die_exile_replacement, }; use crate::game::quantity::{ continuous_modification_dynamic_quantity, filter_uses_recipient, quantity_expr_uses_recipient, @@ -694,9 +694,13 @@ pub(crate) fn prune_lapsed_controller_controls_source(state: &mut GameState) { /// CR 611.2b + CR 400.7: the captured source leaving play, OR the host leaving /// and re-entering as a new object (same storage ObjectId), ends the can't-untap /// continuous effect permanently — drop the gated def from base+live so it -/// cannot revive on a same-ObjectId re-entry. Called from `zones.rs` on a -/// battlefield exit, OUTSIDE `evaluate_layers`, so it marks layers full on change -/// (mirroring `prune_host_left_effects`). +/// cannot revive on a same-ObjectId re-entry. The same host-left arm also drops +/// the turn-bound die-exile rider and the host-lifetime exile rider +/// (`UntilHostLeavesPlay`, CR 702.84a): a base-installed rider must NOT revive +/// when the object re-enters as a new CR 400.7 object reusing the same storage +/// key. Called from `zones.rs` (`:481`) on a battlefield exit, OUTSIDE +/// `evaluate_layers`, so it marks layers full on change (mirroring +/// `prune_host_left_effects`). /// /// The predicate is purely id-based (`departed_id`), so no `&state` gate read is /// needed — each object is mutated in a single pass: case (b) the captured @@ -720,15 +724,20 @@ pub(crate) fn prune_controller_controls_source_on_leave( if source == departed_id ) }; - // CR 400.7 + CR 611.2a: Only a lapsed `ControllerControlsSource` def or a - // turn-bound die-exile rider on the departing host is eligible to be dropped. - // The host-left arm must not wipe printed replacements or unrelated runtime riders. + // CR 400.7 + CR 611.2a: Only a lapsed `ControllerControlsSource` def, a + // turn-bound die-exile rider, or a host-lifetime exile rider + // (`UntilHostLeavesPlay`, CR 702.84a) on the departing host is eligible + // to be dropped. The host-left arm must not wipe printed replacements or + // unrelated runtime riders. Dropping the host-lifetime rider from + // base+live here is what prevents it reviving on a same-ObjectId + // re-entry (CR 400.7 — the returning object is new). let drop = |def: &crate::types::ability::ReplacementDefinition| { (matches!( def.condition, Some(ReplacementCondition::ControllerControlsSource { .. }) ) && is_lapsed(def)) || (host_left && is_runtime_target_die_exile_replacement(def)) + || (host_left && is_runtime_host_lifetime_replacement(def)) }; let before_live = obj.replacement_definitions.len(); obj.replacement_definitions.retain(|d| !drop(d)); @@ -2243,6 +2252,7 @@ pub fn evaluate_layers(state: &mut GameState) { // Step 5: Clear dirty flag. A full evaluation satisfies any pending request // (Clean / EnteredObjects / Full). + crate::game::effects::attach::refresh_protection_start_attachment_snapshots(state); state.layers_dirty = LayersDirty::Clean; } @@ -2982,6 +2992,7 @@ fn target_filter_reads_life_total(filter: &TargetFilter) -> bool { | TargetFilter::AttachedTo | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } @@ -4594,6 +4605,17 @@ fn expand_granted_triggered_abilities( /// consults it too, so a display projection can never claim an effect is live /// after the layer engine has stopped applying it. pub(crate) fn transient_effect_is_live(state: &GameState, tce: &TransientContinuousEffect) -> bool { + // CR 400.7: a recipient that has changed zones is a new object, so a + // continuous effect tied to its prior incarnation cannot keep applying. + if let Some(recipient) = tce.affected_recipient { + if !state + .objects + .get(&recipient.object_id) + .is_some_and(|object| ObjectIncarnationRef::from_object(object) == recipient) + { + return false; + } + } // UntilHostLeavesPlay: skip if source is no longer on the battlefield if tce.duration == Duration::UntilHostLeavesPlay && !state @@ -6095,6 +6117,13 @@ fn apply_continuous_effect_filtered( }; match &effect.modification { + // CR 707.2c + CR 613.1a: `CopyChosen` is a parse-time marker for + // Metamorphic Alteration's "enchanted creature is a copy of the + // chosen creature" static. The copy is materialized exactly once — + // as a latched `CopyValues` TCE installed at the + // `Effect::ChoosePermanent` answer (values fixed per CR 707.2c) — + // so applying anything here would double-install. Explicit no-op. + ContinuousModification::CopyChosen => {} ContinuousModification::CopyValues { values, display_source, @@ -6228,7 +6257,7 @@ fn apply_continuous_effect_filtered( // Ward/Annihilator each apply independently). `evaluate_layers` resets // `obj.keywords = obj.base_keywords.clone()` each pass, so this never // accumulates unbounded across re-evaluations. - if resolved_keyword.sums_across_instances() { + if resolved_keyword.instances_must_coexist() { obj.keywords.push(resolved_keyword.clone()); } else if resolved_keyword.overrides_same_kind_on_grant() { // CR 613.7: this grant is a single-authoritative-value keyword — the @@ -6325,7 +6354,7 @@ fn apply_continuous_effect_filtered( for (keyword_output_index, kw) in add_chosen_keywords.iter().enumerate() { // CR 702.164b: summing keywords (Toxic) accumulate rather // than dedup, mirroring the plain `AddKeyword` arm above. - if kw.sums_across_instances() || !obj.keywords.contains(kw) { + if kw.instances_must_coexist() || !obj.keywords.contains(kw) { obj.keywords.push(kw.clone()); } for (companion_index, trigger) in KeywordTriggerInstaller::triggers_for(kw) @@ -6650,6 +6679,24 @@ fn apply_continuous_effect_filtered( obj.static_definitions.push(*definition.clone()); } } + // CR 614.1a + CR 614.6 + CR 613.1f: Grant an object-hosted replacement + // to the recipient. Mirror of `GrantStaticAbility` — push the cloned + // `ReplacementDefinition` onto `obj.replacement_definitions` so the + // granted replacement fires as a genuine replacement effect (its + // `valid_card: SelfRef` binds to this recipient object). Re-derived + // each layer pass (`obj.replacement_definitions` was reset to base at + // the start of the pass); structural-equality dedup keeps repeated + // grants (multiple sources, or a single static parsed twice) + // idempotent, matching the GrantTrigger / GrantStaticAbility invariant. + ContinuousModification::GrantReplacement { replacement } => { + if !obj + .replacement_definitions + .iter_all() + .any(|rd| rd == replacement.as_ref()) + { + obj.replacement_definitions.push(*replacement.clone()); + } + } ContinuousModification::AddStaticMode { mode } => { // CR 509.1b + CR 105.4 + CR 609.6 (issue #327): When the // granted static mode carries an `IsChosenColor` filter prop, @@ -13767,6 +13814,7 @@ mod tests { condition: StaticCondition::SourceIsTapped, }, affected: TargetFilter::SelfRef, + affected_recipient: None, modifications: vec![ContinuousModification::AddKeyword { keyword: Keyword::Flying, }], @@ -13799,6 +13847,7 @@ mod tests { condition: StaticCondition::SourceIsTapped, }, affected: TargetFilter::SelfRef, + affected_recipient: None, modifications: vec![ContinuousModification::AddKeyword { keyword: Keyword::Flying, }], diff --git a/crates/engine/src/game/ledger.rs b/crates/engine/src/game/ledger.rs index a6a9198c9c..fb67a9ee25 100644 --- a/crates/engine/src/game/ledger.rs +++ b/crates/engine/src/game/ledger.rs @@ -2,12 +2,14 @@ use crate::types::ability::TriggerDefinitionRef; use crate::types::game_state::{GameState, SpellCastRecord}; -use crate::types::identifiers::ObjectId; +use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; use crate::types::player::PlayerId; use crate::types::resolved_commands::{ - ResolvedLedgerEdit, ResolvedLedgerEditCommand, ResolvedLedgerEditReplayInvariantError, - ResolvedOncePerTurnPermission, ResolvedTriggerLedgerEdit, + ledger_edit_is_invalid, ResolvedLedgerEdit, ResolvedLedgerEditCommand, + ResolvedLedgerEditReplayInvariantError, ResolvedOncePerTurnPermission, + ResolvedTriggerLedgerEdit, }; +use crate::types::zones::Zone; /// Constructs, applies, and journals one exact semantic ledger edit. /// @@ -114,6 +116,83 @@ pub fn consume_once_per_turn_permission( ) } +/// CR 121.1 + CR 121.2 + CR 121.4: Capture and install one post-replacement +/// draw's exact bookkeeping. The zone-change hub has already installed +/// `drawn_object` before this ledger command is recorded; replay therefore +/// never selects a library card or re-runs replacement effects. +/// +/// `drawn_object` is absent only when an attempted draw found an empty library. +/// The returned boolean tells the continuation-only miracle hook whether this +/// command established the player's first draw of the turn. +pub fn resolve_and_apply_cards_drawn( + state: &mut GameState, + player: PlayerId, + drawn_object: Option, + attempted_empty_library: bool, +) -> Result { + let player_state = state + .players + .iter() + .find(|candidate| candidate.id == player) + .ok_or(ResolvedLedgerEditReplayInvariantError::UnknownPlayer( + player, + ))?; + let expected_drawn_cards_len = history_len( + state + .cards_drawn_this_turn + .get(&player) + .map_or(0, |drawn_cards| drawn_cards.len()), + )?; + let settled_card = drawn_object.is_some(); + let expected_first_card_drawn_this_turn = + state.first_card_drawn_this_turn.get(&player).copied(); + let resulting_first_card_drawn_this_turn = + expected_first_card_drawn_this_turn.or_else(|| drawn_object.map(|object| object.object_id)); + let established_first_draw = settled_card && expected_first_card_drawn_this_turn.is_none(); + let resulting_drawn_cards_len = if settled_card { + expected_drawn_cards_len + .checked_add(1) + .ok_or(ResolvedLedgerEditReplayInvariantError::CounterOverflow)? + } else { + expected_drawn_cards_len + }; + + resolve_and_apply_ledger_edit( + state, + ResolvedLedgerEdit::CardsDrawn { + player, + drawn_object, + attempted_empty_library, + expected_has_drawn_this_turn: player_state.has_drawn_this_turn, + resulting_has_drawn_this_turn: if settled_card { + true + } else { + player_state.has_drawn_this_turn + }, + expected_cards_drawn_this_turn: player_state.cards_drawn_this_turn, + resulting_cards_drawn_this_turn: if settled_card { + player_state.cards_drawn_this_turn.saturating_add(1) + } else { + player_state.cards_drawn_this_turn + }, + expected_cards_drawn_this_step: player_state.cards_drawn_this_step, + resulting_cards_drawn_this_step: if settled_card { + player_state.cards_drawn_this_step.saturating_add(1) + } else { + player_state.cards_drawn_this_step + }, + expected_drew_from_empty_library: player_state.drew_from_empty_library, + resulting_drew_from_empty_library: player_state.drew_from_empty_library + || attempted_empty_library, + expected_drawn_cards_len, + resulting_drawn_cards_len, + expected_first_card_drawn_this_turn, + resulting_first_card_drawn_this_turn, + }, + )?; + Ok(established_first_draw) +} + /// Applies one exact ledger edit without an event dispatcher, replacement /// pipeline, allocator, or dynamic permission lookup. pub fn apply_resolved_ledger_edit( @@ -216,6 +295,95 @@ pub fn apply_resolved_ledger_edit( .activated_abilities_this_game .insert(key, next_game_count); } + ResolvedLedgerEdit::CardsDrawn { + player, + drawn_object, + expected_has_drawn_this_turn, + resulting_has_drawn_this_turn, + expected_cards_drawn_this_turn, + resulting_cards_drawn_this_turn, + expected_cards_drawn_this_step, + resulting_cards_drawn_this_step, + expected_drew_from_empty_library, + resulting_drew_from_empty_library, + expected_drawn_cards_len, + expected_first_card_drawn_this_turn, + resulting_first_card_drawn_this_turn, + .. + } => { + if ledger_edit_is_invalid(&command.edit) { + return Err(ResolvedLedgerEditReplayInvariantError::CardsDrawnPreconditionMismatch); + } + let Some(player_index) = state + .players + .iter() + .position(|candidate| candidate.id == *player) + else { + return Err(ResolvedLedgerEditReplayInvariantError::UnknownPlayer( + *player, + )); + }; + let current_drawn_cards_len = history_len( + state + .cards_drawn_this_turn + .get(player) + .map_or(0, |drawn_cards| drawn_cards.len()), + )?; + let current_first_card_drawn_this_turn = + state.first_card_drawn_this_turn.get(player).copied(); + let player_state = &state.players[player_index]; + if player_state.has_drawn_this_turn != *expected_has_drawn_this_turn + || player_state.cards_drawn_this_turn != *expected_cards_drawn_this_turn + || player_state.cards_drawn_this_step != *expected_cards_drawn_this_step + || player_state.drew_from_empty_library != *expected_drew_from_empty_library + || current_drawn_cards_len != *expected_drawn_cards_len + || current_first_card_drawn_this_turn != *expected_first_card_drawn_this_turn + { + return Err(ResolvedLedgerEditReplayInvariantError::CardsDrawnPreconditionMismatch); + } + if let Some(expected) = drawn_object { + let found = state + .objects + .get(&expected.object_id) + .map(ObjectIncarnationRef::from_object); + if found != Some(*expected) { + return Err( + ResolvedLedgerEditReplayInvariantError::DrawnObjectMismatch { + expected: *expected, + found, + }, + ); + } + if state.objects[&expected.object_id].zone == Zone::Library { + return Err( + ResolvedLedgerEditReplayInvariantError::DrawnObjectStillInLibrary( + *expected, + ), + ); + } + } + + let player_state = &mut state.players[player_index]; + player_state.has_drawn_this_turn = *resulting_has_drawn_this_turn; + player_state.cards_drawn_this_turn = *resulting_cards_drawn_this_turn; + player_state.cards_drawn_this_step = *resulting_cards_drawn_this_step; + player_state.drew_from_empty_library = *resulting_drew_from_empty_library; + if let Some(object) = drawn_object { + crate::game::effects::drawn_this_turn_choice::record_drawn_card( + state, + *player, + object.object_id, + ); + } + match resulting_first_card_drawn_this_turn { + Some(object) => { + state.first_card_drawn_this_turn.insert(*player, *object); + } + None => { + state.first_card_drawn_this_turn.remove(player); + } + } + } ResolvedLedgerEdit::TriggerFired { trigger, edit } => match edit { ResolvedTriggerLedgerEdit::OncePerTurn => { if !state.triggers_fired_this_turn.insert(trigger.clone()) { diff --git a/crates/engine/src/game/log.rs b/crates/engine/src/game/log.rs index 25a22ffeb2..e1b68c61a4 100644 --- a/crates/engine/src/game/log.rs +++ b/crates/engine/src/game/log.rs @@ -179,6 +179,9 @@ fn categorize(event: &GameEvent) -> LogCategory { | GameEvent::CounterRemoved { .. } | GameEvent::ControllerChanged { .. } | GameEvent::Transformed { .. } + // CR 710.4: flipping is an object-status change, grouped with transform + // and face up/down. + | GameEvent::Flipped { .. } | GameEvent::TurnedFaceUp { .. } | GameEvent::TurnedFaceDown { .. } | GameEvent::Regenerated { .. } @@ -753,6 +756,12 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec { vec![card_seg(state, *object_id), text(" transforms")] } + // CR 710.4: the log names the permanent by its (now alternative, + // CR 710.1b) characteristics, which `card_seg` reads live. + GameEvent::Flipped { object_id } => { + vec![card_seg(state, *object_id), text(" flips")] + } + GameEvent::Specialized { object_id, color } => { vec![ card_seg(state, *object_id), diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 886423458e..66b88c575b 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -1,8 +1,8 @@ use crate::game::functioning_abilities::static_kind_present; use crate::types::ability::{ - AbilityCondition, AbilityCost, AbilityDefinition, ChoiceValue, ChosenAttribute, - ContinuousModification, CostPaidObjectSnapshot, Effect, ManaProduction, QuantityExpr, - QuantityRef, ResolvedAbility, TargetFilter, REMOVE_COUNTER_COST_ALL, + AbilityCondition, AbilityCost, AbilityDefinition, CardSelectionMode, ChoiceValue, + ChosenAttribute, ContinuousModification, CostPaidObjectSnapshot, Effect, ManaProduction, + QuantityExpr, QuantityRef, ResolvedAbility, TargetFilter, REMOVE_COUNTER_COST_ALL, REMOVE_COUNTER_COST_ANY_NUMBER, }; use crate::types::counter::{CounterMatch, CounterType}; @@ -2491,20 +2491,28 @@ fn settle_mana_ability_cost_events( // The typed cursor has already collected the events emitted by this action. // Claim their exact occurrences through the normal post-action authority so // a Priority resume cannot scan the same events a second time. - state.consumed_before_priority_trigger_events.extend( - events - .iter() - .enumerate() - .skip(current_start) - .map( - |(index, event)| crate::game::triggers::ConsumedTriggerEventOccurrence { - event: event.clone(), - occurrence: events[..index] - .iter() - .filter(|prior| *prior == event) - .count(), - }, - ), + let occurrences = events + .iter() + .enumerate() + .skip(current_start) + .map( + |(index, event)| crate::game::triggers::ConsumedTriggerEventOccurrence { + event: event.clone(), + occurrence: events[..index] + .iter() + .filter(|prior| *prior == event) + .count(), + }, + ) + .collect(); + super::triggers::resolve_and_apply_trigger_collection( + state, + crate::types::resolved_commands::ResolvedTriggerCollection::ConsumeBeforePriority { + occurrences, + }, + ) + .expect( + "mana-ability cost-settlement consumed-before-priority trigger journal cause must be live", ); } @@ -3561,10 +3569,24 @@ fn discard_cost_choice( source_id: ObjectId, cost: &Option, ) -> Option<(usize, Vec)> { - let (count, filter) = find_non_self_discard_cost(cost.as_ref()?)?; - let resolved = super::quantity::resolve_quantity(state, count, player, source_id).max(0); - let cards = super::casting::find_eligible_discard_targets(state, player, source_id, filter); - Some((resolved as usize, cards)) + let cost = cost.as_ref()?; + // Mana-ability interactive discard applies only to a player-CHOSEN discard leg; a + // non-Chosen discard (e.g. random / top-of-hand) is not a mid-activation card selection, + // so this interactive surfacing does not handle it. (Pre-existing scope; keeps blast + // radius nil.) `find_non_self_discard` is the single detector shared with the + // casting/activation path; the `Chosen` gate below is the ONLY mana-specific divergence. + let (_count, _filter, selection) = super::casting::find_non_self_discard(cost)?; + if selection != CardSelectionMode::Chosen { + return None; + } + // Single authority for the zero-count auto-pay + payability rules (CR 601.2h + CR 701.9a): + // delegate to `resolve_non_self_discard_requirement` so the mana path stays aligned with the + // activation path in one place. `Ok(Some)` => interactive selection; `Ok(None)` => zero-card + // discard paid by doing nothing (skip the leg). `Err` (fewer eligible cards than the nonzero + // count) is unreachable here because `cost_payability` already gated activation on hand size, + // so `unwrap_or_default()`'s `None` fallback is the correct "no selection to surface" result. + super::casting::resolve_non_self_discard_requirement(state, player, source_id, cost) + .unwrap_or_default() } /// CR 117.1 + CR 118.3: Match non-self `AbilityCost::Exile` shapes. Returns @@ -3662,21 +3684,6 @@ fn sacrifice_cost_choice( Some((count as usize, permanents)) } -fn find_non_self_discard_cost( - cost: &AbilityCost, -) -> Option<(&crate::types::ability::QuantityExpr, Option<&TargetFilter>)> { - match cost { - AbilityCost::Discard { - count, - filter, - self_scope: crate::types::ability::DiscardSelfScope::FromHand, - selection: crate::types::ability::CardSelectionMode::Chosen, - } => Some((count, filter.as_ref())), - AbilityCost::Composite { costs } => costs.iter().find_map(find_non_self_discard_cost), - _ => None, - } -} - fn tap_selected_creature_for_mana_cost( state: &mut GameState, source_id: ObjectId, @@ -6401,6 +6408,206 @@ mod tests { assert_eq!(state.players[0].mana_pool.count_color(ManaType::Red), 3); } + /// CR 605.1a + CR 106.1: Build a Lion's-Eye-Diamond-shaped mana ability — + /// `Composite[Discard { HandSize }, Sacrifice(SelfRef)]` producing three mana + /// of one chosen color. Mirrors the real card's parsed cost + /// (`Discard { count: Ref HandSize(Controller), self_scope: FromHand }` + + /// `Sacrifice(SelfRef, 1)`), so a name change alone re-targets it to any card + /// in the same "discard your hand, sacrifice ~: add three mana" class. + fn discard_hand_sacrifice_three_mana_ability() -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::AnyOneColor { + count: QuantityExpr::Fixed { value: 3 }, + color_options: vec![ + ManaColor::White, + ManaColor::Blue, + ManaColor::Black, + ManaColor::Red, + ManaColor::Green, + ], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Discard { + count: QuantityExpr::Ref { + qty: crate::types::ability::QuantityRef::HandSize { + player: crate::types::ability::PlayerScope::Controller, + }, + }, + filter: None, + selection: crate::types::ability::CardSelectionMode::Chosen, + self_scope: crate::types::ability::DiscardSelfScope::FromHand, + }, + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ], + }) + } + + /// Issue #6494 (PRIMARY revert-guard, mana path): Lion's Eye Diamond with an + /// EMPTY hand must be activatable — its "Discard your hand" leg with an empty + /// hand is a zero-card discard, paid by doing nothing (CR 601.2h + CR 701.9a). + /// + /// Oracle (Scryfall-verified): "Discard your hand, Sacrifice this artifact: + /// Add three mana of any one color. Activate only as an instant." + /// + /// At base the activation surfaces `WaitingFor::PayCost { Discard, count: 0 }` + /// (a dead prompt that re-emits forever), so the `ChooseManaColor` assertion + /// fails with `left: PayCost right: ChooseManaColor` — revert-sensitive to the + /// zero-count auto-pay guard `discard_cost_choice` now inherits from the shared + /// `casting::resolve_non_self_discard_requirement` authority. The 3-mana + /// assertion is a positive reach-guard (proves production, not a vacuous halt); + /// the sacrifice assertion proves the guard consumes ONLY the discard leg and + /// the self-sacrifice still fires. + #[test] + fn lions_eye_diamond_activates_empty_handed_and_produces_chosen_color() { + let mut state = GameState::new_two_player(42); + let led = create_object( + &mut state, + CardId(30), + PlayerId(0), + "Lion's Eye Diamond".to_string(), + Zone::Battlefield, + ); + // No cards in hand — HandSize(Controller) resolves to 0. + assert!(state.players[0].hand.is_empty()); + + let ability = discard_hand_sacrifice_three_mana_ability(); + Arc::make_mut(&mut state.objects.get_mut(&led).unwrap().abilities).push(ability.clone()); + + let mut events = Vec::new(); + let waiting = activate_mana_ability( + &mut state, + led, + PlayerId(0), + 0, + &ability, + &mut events, + ManaAbilityResume::Priority, + None, + ) + .unwrap(); + + // CR 601.2h + CR 701.9a: no dead PayCost { Discard, count: 0 } — the + // activation advances straight to the color choice. + let pending = match waiting { + WaitingFor::ChooseManaColor { + player, + choice: ManaChoicePrompt::SingleColor { options }, + context, + } => { + assert_eq!(player, PlayerId(0)); + assert_eq!(options.len(), 5); + *expect_mana_ability_context(context) + } + other => panic!("expected ChooseManaColor, got {other:?}"), + }; + + // CR 701.9a: nothing was discarded — the hand stays empty. + assert!(state.players[0].hand.is_empty()); + // The self-sacrifice leg still fired — LED left the battlefield for the + // graveyard, and it is the ONLY card there (the zero-card discard added + // nothing), proving the guard consumed only the discard leg. + assert_eq!(state.players[0].graveyard.len(), 1); + assert!(state.players[0].graveyard.contains(&led)); + assert_ne!( + state.objects.get(&led).map(|obj| obj.zone), + Some(Zone::Battlefield) + ); + + handle_choose_mana_color( + &mut state, + &pending, + &ManaChoicePrompt::SingleColor { + options: vec![ + ManaType::White, + ManaType::Blue, + ManaType::Black, + ManaType::Red, + ManaType::Green, + ], + }, + ManaChoice::SingleColor(ManaType::Red), + &mut events, + ) + .unwrap(); + + // Positive reach-guard: production actually ran (not a vacuous halt). + assert_eq!(state.players[0].mana_pool.count_color(ManaType::Red), 3); + } + + /// Issue #6494 (mana path, class not card): Diamond Lion shares Lion's Eye + /// Diamond's "Discard your hand, Sacrifice ~: Add three mana of any one color" + /// shape, so the empty-hand fix is class-wide, not keyed to one card. Only the + /// object name differs from the LED case above. + #[test] + fn diamond_lion_activates_empty_handed_and_produces_chosen_color() { + let mut state = GameState::new_two_player(7); + let lion = create_object( + &mut state, + CardId(40), + PlayerId(0), + "Diamond Lion".to_string(), + Zone::Battlefield, + ); + assert!(state.players[0].hand.is_empty()); + + let ability = discard_hand_sacrifice_three_mana_ability(); + Arc::make_mut(&mut state.objects.get_mut(&lion).unwrap().abilities).push(ability.clone()); + + let mut events = Vec::new(); + let waiting = activate_mana_ability( + &mut state, + lion, + PlayerId(0), + 0, + &ability, + &mut events, + ManaAbilityResume::Priority, + None, + ) + .unwrap(); + + let pending = match waiting { + WaitingFor::ChooseManaColor { + choice: ManaChoicePrompt::SingleColor { .. }, + context, + .. + } => *expect_mana_ability_context(context), + other => panic!("expected ChooseManaColor, got {other:?}"), + }; + assert_ne!( + state.objects.get(&lion).map(|obj| obj.zone), + Some(Zone::Battlefield) + ); + + handle_choose_mana_color( + &mut state, + &pending, + &ManaChoicePrompt::SingleColor { + options: vec![ + ManaType::White, + ManaType::Blue, + ManaType::Black, + ManaType::Red, + ManaType::Green, + ], + }, + ManaChoice::SingleColor(ManaType::Green), + &mut events, + ) + .unwrap(); + assert_eq!(state.players[0].mana_pool.count_color(ManaType::Green), 3); + } + /// Helper: build a Pit-of-Offerings-style permanent with a `{T}: Add one mana /// of any of the exiled cards' colors` mana ability and exile a card linked /// to it via `state.exile_links` (the same relation populated by the @@ -10628,4 +10835,83 @@ mod tests { "Pentad Prism must not be activatable without charge counters" ); } + + /// Issue #6494 (shared seam): `casting::find_non_self_discard` is the SOLE + /// detector for FromHand discard cost legs and no longer filters by selection + /// mode — it returns the `CardSelectionMode` for BOTH `Chosen` and `Random` + /// legs (and recurses into `Composite`, e.g. Lion's Eye Diamond's shape). The + /// mana path's only divergence from the casting/activation path is the explicit + /// `Chosen` gate in `discard_cost_choice`, which routes accepted legs through + /// the shared `resolve_non_self_discard_requirement` authority so the zero-count + /// auto-pay + payability rules live in one place. + /// + /// Revert-sensitive: fails if `find_non_self_discard` re-adds a selection filter + /// (the `Random` assertions go `None`), or if `discard_cost_choice` drops its + /// `Chosen` gate (the `Random` leg would then surface an interactive selection). + #[test] + fn find_non_self_discard_is_sole_detector_mana_path_gates_on_chosen() { + use crate::types::ability::{CardSelectionMode, DiscardSelfScope}; + + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + // One card in hand so a `Chosen` discard resolves to an interactive selection. + let card = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Card".to_string(), + Zone::Hand, + ); + + let discard_leg = |selection| AbilityCost::Discard { + count: QuantityExpr::Fixed { value: 1 }, + filter: None, + selection, + self_scope: DiscardSelfScope::FromHand, + }; + let chosen_leg = discard_leg(CardSelectionMode::Chosen); + let random_leg = discard_leg(CardSelectionMode::Random); + // LED-shaped composite: the Chosen discard leg lives beside a self-sacrifice. + let chosen_in_composite = AbilityCost::Composite { + costs: vec![ + chosen_leg.clone(), + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ], + }; + + // Sole detector: reports the selection mode for BOTH variants (no selection + // filter), including inside a Composite. + let detect = crate::game::casting::find_non_self_discard; + assert!(matches!( + detect(&chosen_leg), + Some((_, _, CardSelectionMode::Chosen)) + )); + assert!(matches!( + detect(&chosen_in_composite), + Some((_, _, CardSelectionMode::Chosen)) + )); + assert!(matches!( + detect(&random_leg), + Some((_, _, CardSelectionMode::Random)) + )); + + // Mana selection gate: only a Chosen leg surfaces an interactive discard, + // and it does so through the shared resolver (Some((1, [card]))). + match discard_cost_choice(&state, PlayerId(0), source, &Some(chosen_in_composite)) { + Some((count, cards)) => { + assert_eq!(count, 1); + assert_eq!(cards, vec![card]); + } + None => panic!("Chosen FromHand discard with a card in hand must surface a selection"), + } + // A non-Chosen (Random) FromHand discard is not a mid-activation card + // selection: the gate returns None even though the sole detector matched it. + assert!(discard_cost_choice(&state, PlayerId(0), source, &Some(random_leg)).is_none()); + } } diff --git a/crates/engine/src/game/meld_tests.rs b/crates/engine/src/game/meld_tests.rs index 93883d1144..ce0517d866 100644 --- a/crates/engine/src/game/meld_tests.rs +++ b/crates/engine/src/game/meld_tests.rs @@ -475,6 +475,7 @@ fn meld_renamed_non_meld_partner_is_noop() { timestamp: ts, duration: Duration::Permanent, affected: TargetFilter::SelfRef, + affected_recipient: None, modifications: vec![ContinuousModification::SetName { name: "Bruna, the Fading Light".to_string(), }], diff --git a/crates/engine/src/game/mod.rs b/crates/engine/src/game/mod.rs index 21a3f2d4d0..688eee5d79 100644 --- a/crates/engine/src/game/mod.rs +++ b/crates/engine/src/game/mod.rs @@ -61,6 +61,8 @@ pub mod engine_resolve_batch; pub(crate) mod engine_stack; pub(crate) mod exile_links; pub mod filter; +// CR 710: Kamigawa flip cards (flipping, alternative-face application). +pub mod flip; pub mod functioning_abilities; pub mod game_object; pub mod gap_analysis; diff --git a/crates/engine/src/game/off_zone_characteristics.rs b/crates/engine/src/game/off_zone_characteristics.rs index d6edc74ae2..70419cfebc 100644 --- a/crates/engine/src/game/off_zone_characteristics.rs +++ b/crates/engine/src/game/off_zone_characteristics.rs @@ -374,7 +374,7 @@ fn upsert_keyword_contribution( // granted off-zone Toxic pushes rather than clobbering an unrelated printed // keyword that shares its (Unknown) kind. Non-summing keywords keep the // upsert-by-kind dedup below unchanged. - if !contribution.keyword.sums_across_instances() { + if !contribution.keyword.instances_must_coexist() { if let Some(existing) = keywords .iter_mut() .find(|existing| existing.keyword.kind() == contribution.keyword.kind()) diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index 8fe5d9d215..556782a6b3 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -456,6 +456,13 @@ pub fn self_etb_counter_replacements( } pub fn intrinsic_copiable_values(obj: &GameObject) -> CopiableValues { + // CR 707.2 + CR 710.2: a flipped flip permanent's `base_*` fields hold the + // ALTERNATIVE half (written there by `flip::apply_flipped_face_to_object`), + // but flipped is a status (CR 110.5) and status is not copied. The copiable + // values are the normal half, which `flip` keeps stashed in `back_face`. + if let Some(values) = crate::game::flip::flipped_normal_copiable_values(obj) { + return values; + } CopiableValues { name: obj.base_name.clone(), mana_cost: obj.base_mana_cost.clone(), @@ -537,8 +544,22 @@ pub(crate) fn is_runtime_target_die_exile_replacement(def: &ReplacementDefinitio }) } +/// CR 614.1a + CR 400.7 + CR 707.2: True for a runtime replacement bound to the +/// lifetime of the OBJECT hosting it — the "if it would leave the battlefield, +/// exile it instead" rider installed by Unearth (CR 702.84a) and the +/// parser-driven reanimation cards (Gruesome Encore, Whip of Erebos, …). It is +/// stamped `RestrictionExpiry::UntilHostLeavesPlay`. Like the die-exile rider it +/// is persisted in base only to survive CR 613.1 layer reseeds; it is NOT a +/// copiable value (a copy of the host must not inherit the exile redirect, +/// CR 707.2) and must lapse when the host leaves the battlefield (CR 400.7). +pub(crate) fn is_runtime_host_lifetime_replacement(def: &ReplacementDefinition) -> bool { + matches!(def.expiry, Some(RestrictionExpiry::UntilHostLeavesPlay)) +} + pub(crate) fn is_runtime_non_copiable_replacement(def: &ReplacementDefinition) -> bool { - is_runtime_control_gated_replacement(def) || is_runtime_target_die_exile_replacement(def) + is_runtime_control_gated_replacement(def) + || is_runtime_target_die_exile_replacement(def) + || is_runtime_host_lifetime_replacement(def) } /// CR 707.2 + CR 712.4b: Build the copiable values for a melded permanent @@ -841,6 +862,9 @@ fn walk_continuous_mod(modification: &ContinuousModification, out: &mut Vec walk_ability_def(definition, out), ContinuousModification::GrantTrigger { trigger } => walk_trigger(trigger, out), + ContinuousModification::GrantReplacement { replacement } => { + walk_replacement(replacement, out) + } ContinuousModification::GrantStaticAbility { definition } => walk_static(definition, out), ContinuousModification::CopyValues { values, .. } => walk_copiable_values(values, out), // Remaining modifications carry no nested ability/effect carriers. @@ -849,6 +873,9 @@ fn walk_continuous_mod(modification: &ContinuousModification, out: &mut Vec) { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): filter-only copy choice; no nested + // ability carrier to walk — a leaf for printed-card collection. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::PutCounter { .. } @@ -1202,6 +1232,8 @@ fn walk_effect(effect: &Effect, out: &mut Vec) { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: no nested ability carrier and no conjured card name. + | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } @@ -1422,6 +1454,13 @@ pub fn populate_back_face_if_dfc(obj: &mut GameObject, db: &CardDatabase, card_f CardLayout::Modal(_, back) => Some((LayoutKind::Modal, back)), CardLayout::Meld(_, back) => Some((LayoutKind::Meld, back)), CardLayout::Omen(_, back) => Some((LayoutKind::Omen, back)), + // CR 710.1b: a flip card's alternative name, text box, type line, + // power, and toughness live on its bottom half. Stored in the same + // `back_face` slot so `flip::flip_permanent` can apply it — the + // `LayoutKind::Flip` tag is what keeps it out of every double-faced + // path (`transform::is_double_faced_permanent`, + // `transform::transform_permanent`, MDFC/Adventure face choice). + CardLayout::Flip(_, back) => Some((LayoutKind::Flip, back)), // CR 722: Preparation cards expose prepare-spell characteristics. CardLayout::Prepare(_, back) => Some((LayoutKind::Prepare, back)), _ => None, @@ -1755,6 +1794,10 @@ fn reapply_printed_faces_from_card_db(state: &mut GameState, db: &CardDatabase) CardLayout::Modal(..) => Some(LayoutKind::Modal), CardLayout::Meld(..) => Some(LayoutKind::Meld), CardLayout::Omen(..) => Some(LayoutKind::Omen), + // CR 710.1b: restore the flip tag so a reloaded + // flip permanent's stashed alternative face stays + // excluded from the double-faced paths. + CardLayout::Flip(..) => Some(LayoutKind::Flip), // CR 702.xxx: Prepare (Strixhaven) — treat like Adventure for // back-face layout tracking. Assign when WotC publishes SOS CR update. CardLayout::Prepare(..) => Some(LayoutKind::Prepare), @@ -1770,6 +1813,15 @@ fn reapply_printed_faces_from_card_db(state: &mut GameState, db: &CardDatabase) } } + // CR 710.1c: a flip card's color and mana cost don't change if the + // permanent is flipped. A flipped permanent's `printed_ref` names + // the ALTERNATIVE half, which carries no printed mana cost, so the + // `apply_card_face_to_object` reapply above would blank it on every + // reload. Restore both from the (just-refreshed) normal half stashed + // in `back_face` — the same values `flip::flip_permanent` + // deliberately left untouched when it flipped the permanent. + crate::game::flip::restore_normal_cost_and_color_if_flipped(obj); + if is_face_down_battlefield { // CR 708.2a: This reload path only runs while `printed_ref` is // still set (see the `obj.printed_ref.clone()` guard above); @@ -1952,6 +2004,88 @@ mod tests { intrinsic_copiable_values(&source) } + /// A bare "Moved SelfRef -> Exile" redirect with NO expiry stamp — the + /// Personal Decoy printed-static shape (CMB1 playtest card). This is the + /// fixture that kills shape-widening: the runtime detectors must key on the + /// `UntilHostLeavesPlay` expiry stamp, NOT on the redirect shape, so a + /// printed-static exile redirect is never misclassified as a runtime rider. + fn bare_moved_selfref_exile_rider() -> ReplacementDefinition { + ReplacementDefinition::new(ReplacementEvent::Moved) + .valid_card(TargetFilter::SelfRef) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: Some(Zone::Battlefield), + destination: Zone::Exile, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + )) + } + + /// R10 (issue #5976): a redirect that lacks the `UntilHostLeavesPlay` stamp + /// must be classified as NEITHER a host-lifetime rider NOR non-copiable — the + /// detectors key on the expiry stamp, not the Moved->Exile shape, so a + /// printed-static exile redirect (Personal Decoy) is never widened into a + /// runtime rider. + #[test] + fn r10_bare_exile_redirect_without_expiry_is_not_runtime_rider() { + let def = bare_moved_selfref_exile_rider(); + assert!( + !is_runtime_host_lifetime_replacement(&def), + "a redirect with no UntilHostLeavesPlay stamp is NOT a host-lifetime rider" + ); + assert!( + !is_runtime_non_copiable_replacement(&def), + "a printed-static exile redirect must stay copiable (shape must not widen)" + ); + } + + /// R6 (issue #5976): the same redirect, now stamped `UntilHostLeavesPlay`, IS + /// a runtime rider (CR 702.84a) and must be excluded from the host's copiable + /// values so a copy does not inherit the exile redirect (CR 707.2). This is + /// the production seam the runtime token-copy path + /// (`compute_current_copiable_values` -> `copiable_replacement_definitions`) + /// consumes. + #[test] + fn host_lifetime_rider_is_non_copiable_and_excluded_from_copiable_values() { + let rider = bare_moved_selfref_exile_rider() + .expiry(crate::types::ability::RestrictionExpiry::UntilHostLeavesPlay); + assert!(is_runtime_host_lifetime_replacement(&rider)); + assert!(is_runtime_non_copiable_replacement(&rider)); + + let mut obj = GameObject::new( + ObjectId(7), + CardId(7), + PlayerId(0), + "Unearthed".to_string(), + Zone::Battlefield, + ); + obj.base_replacement_definitions = Arc::new(vec![rider]); + + let copiable = intrinsic_copiable_values(&obj); + assert!( + copiable + .replacement_definitions + .iter() + .all(|r| !is_runtime_host_lifetime_replacement(r)), + "CR 707.2: a copy must not inherit the host-lifetime exile rider" + ); + assert!( + copiable.replacement_definitions.is_empty(), + "the only rider was the non-copiable host-lifetime one, so copiable defs are empty" + ); + } + fn copy_recipient(id: u64) -> GameObject { GameObject::new( ObjectId(id), @@ -2141,6 +2275,105 @@ mod tests { } } + /// CR 710.1c: a flip card's color and mana cost don't change if the + /// permanent is flipped — including across a state reload. + /// + /// A flipped permanent's `printed_ref` names the ALTERNATIVE half, which (on + /// every real flip card) has no printed mana cost. Without the + /// `restore_normal_cost_and_color_if_flipped` call in + /// `reapply_printed_faces_from_card_db`, the reapply blanks the cost and the + /// permanent silently becomes a {0} object on load. Reverting that call + /// fails the mana-cost assertion below. + #[test] + fn rehydrate_keeps_a_flipped_permanents_mana_cost_and_color() { + let normal_cost = ManaCost::Cost { + shards: vec![ManaCostShard::White], + generic: 0, + }; + let mut normal_half = test_face( + "Rehydrate Flip Normal", + "rehydrate-flip-oracle-id", + vec![CoreType::Creature], + normal_cost.clone(), + ); + normal_half.color_override = Some(vec![ManaColor::White]); + // CR 710.1b: the alternative half has no printed mana cost and no + // printed color indicator — exactly as MTGJSON reports face b. + let mut alternative_half = test_face( + "Rehydrate Flip Alternative", + "rehydrate-flip-oracle-id", + vec![CoreType::Creature], + ManaCost::default(), + ); + alternative_half.color_override = Some(vec![]); + let db = db_from_faces(&[normal_half.clone(), alternative_half.clone()]); + + let mut state = GameState::new_two_player(42); + let id = create_object( + &mut state, + CardId(31), + PlayerId(0), + "Rehydrate Flip Alternative".to_string(), + Zone::Battlefield, + ); + let object = state.objects.get_mut(&id).unwrap(); + // Post-flip state, exactly as `flip::flip_permanent` leaves it: the + // alternative half is displayed, the normal half is stashed, and the + // mana cost / color are still the normal half's (CR 710.1c). + object.flipped = true; + object.printed_ref = printed_ref_from_face(&alternative_half); + object.base_printed_ref = object.printed_ref.clone(); + object.mana_cost = normal_cost.clone(); + object.base_mana_cost = normal_cost.clone(); + object.color = vec![ManaColor::White]; + object.base_color = vec![ManaColor::White]; + object.back_face = Some(BackFaceData { + name: normal_half.name.clone(), + power: None, + toughness: None, + loyalty: None, + defense: None, + card_types: normal_half.card_type.clone(), + mana_cost: normal_cost.clone(), + keywords: vec![], + abilities: vec![], + trigger_definitions: Default::default(), + replacement_definitions: Default::default(), + static_definitions: Default::default(), + color: vec![ManaColor::White], + printed_ref: printed_ref_from_face(&normal_half), + modal: None, + additional_cost: None, + strive_cost: None, + casting_restrictions: vec![], + casting_options: vec![], + layout_kind: None, + }); + + rehydrate_game_from_card_db(&mut state, &db); + + let object = &state.objects[&id]; + assert!( + object.flipped, + "reach guard: the permanent is still flipped" + ); + assert_eq!( + object.name, "Rehydrate Flip Alternative", + "reach guard: the reapply really did run over the alternative half" + ); + assert_eq!( + object.mana_cost, normal_cost, + "CR 710.1c: reloading must not blank a flipped permanent's mana cost" + ); + assert_eq!(object.base_mana_cost, normal_cost); + assert_eq!( + object.color, + vec![ManaColor::White], + "CR 710.1c: reloading must not blank a flipped permanent's color" + ); + assert_eq!(object.base_color, vec![ManaColor::White]); + } + /// CR 604.3: explicit all-zone color data is authoritative even when a face /// also has Devoid. Production devoid cards normally enter through this path /// with `color_override: Some([])`. diff --git a/crates/engine/src/game/public_state.rs b/crates/engine/src/game/public_state.rs index ff2a152bf0..1ced56b127 100644 --- a/crates/engine/src/game/public_state.rs +++ b/crates/engine/src/game/public_state.rs @@ -376,6 +376,10 @@ pub fn mark_public_state_from_events(state: &mut GameState, events: &[GameEvent] // Transform changes copiable values (Layer 1) and can flip statics // on/off; conservatively all-dirty. | GameEvent::Transformed { .. } + // CR 710.1b: flipping replaces the permanent's name, type line, + // power, toughness, and text box (Layer 1 copiable values) and can + // flip statics on/off; conservatively all-dirty like Transform. + | GameEvent::Flipped { .. } | GameEvent::Specialized { .. } | GameEvent::TurnedFaceUp { .. } // Turning a permanent face down resets its copiable values to a 2/2 diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 2daf8b6fd7..0fa622527c 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -734,6 +734,8 @@ pub(crate) fn continuous_modification_dynamic_quantity( // magnitude. Enumerated explicitly (no wildcard) so a future // QuantityExpr-carrying variant forces a decision here. ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): inert copy marker — no dynamic magnitude. + | ContinuousModification::CopyChosen | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } @@ -749,6 +751,9 @@ pub(crate) fn continuous_modification_dynamic_quantity( | ContinuousModification::GrantAllActivatedAbilitiesOf { .. } | ContinuousModification::GrantAllTriggeredAbilitiesOf { .. } | ContinuousModification::GrantTrigger { .. } + // A granted object-hosted replacement carries no `QuantityExpr` + // magnitude — its `execute` (ChangeZone→Exile) has no dynamic value. + | ContinuousModification::GrantReplacement { .. } | ContinuousModification::RemoveAllAbilities | ContinuousModification::AddType { .. } | ContinuousModification::RemoveType { .. } @@ -1891,21 +1896,66 @@ pub(crate) fn aggregate_property_over( } } -pub(crate) fn object_count_matching_ids( - state: &GameState, - filter: &TargetFilter, - filter_ctx: &FilterContext<'_>, - source_id: ObjectId, -) -> Vec { +fn object_count_zone_object_ids(state: &GameState, filter: &TargetFilter) -> Vec { let zones = filter.extract_zones(); let zones = if zones.is_empty() { vec![crate::types::zones::Zone::Battlefield] } else { zones }; - let mut ids: Vec = zones + zones .into_iter() .flat_map(|zone| crate::game::targeting::zone_object_ids(state, zone)) + .collect() +} + +/// Candidate universe for `object_count_matching_ids`: union ledger, explicit +/// zones, and recursive branch populations for every boolean node. +fn object_count_candidate_universe(state: &GameState, filter: &TargetFilter) -> Vec { + match filter { + TargetFilter::Or { filters } | TargetFilter::And { filters } => { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for branch in filters { + for id in object_count_candidate_universe(state, branch) { + if seen.insert(id) { + out.push(id); + } + } + } + out + } + TargetFilter::Not { filter: inner } => { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for id in object_count_zone_object_ids(state, filter) { + if seen.insert(id) { + out.push(id); + } + } + for id in object_count_candidate_universe(state, inner) { + if seen.insert(id) { + out.push(id); + } + } + out + } + TargetFilter::LastZoneChanged => state.last_zone_changed_ids.clone(), + TargetFilter::TrackedSetFiltered { filter, .. } => { + object_count_candidate_universe(state, filter) + } + _ => object_count_zone_object_ids(state, filter), + } +} + +pub(crate) fn object_count_matching_ids( + state: &GameState, + filter: &TargetFilter, + filter_ctx: &FilterContext<'_>, + source_id: ObjectId, +) -> Vec { + let mut ids: Vec = object_count_candidate_universe(state, filter) + .into_iter() .filter(|&id| matches_target_filter(state, id, filter, filter_ctx)) .collect(); // Drop the triggering object for an "other than" filter (Valakut's "five @@ -7655,6 +7705,360 @@ mod tests { ); } + #[test] + fn resolve_object_count_by_shared_quality_last_zone_changed_color_max() { + use crate::types::mana::ManaColor; + + let mut state = GameState::new_two_player(44); + let red_a = create_object( + &mut state, + CardId(401), + PlayerId(1), + "Red A".to_string(), + Zone::Graveyard, + ); + let red_b = create_object( + &mut state, + CardId(402), + PlayerId(1), + "Red B".to_string(), + Zone::Graveyard, + ); + for id in [red_a, red_b] { + let obj = state.objects.get_mut(&id).unwrap(); + obj.color = vec![ManaColor::Red]; + } + state.last_zone_changed_ids = vec![red_a, red_b]; + + let expr = QuantityExpr::Ref { + qty: QuantityRef::ObjectCountBySharedQuality { + filter: TargetFilter::LastZoneChanged, + quality: SharedQuality::Color, + aggregate: AggregateFunction::Max, + }, + }; + assert_eq!( + resolve_quantity(&state, &expr, PlayerId(0), ObjectId(0)), + 2, + "two red cards milled this way must share red (Max bucket size 2)" + ); + } + + #[test] + fn resolve_object_count_by_shared_quality_last_zone_changed_colorless_max_zero() { + let mut state = GameState::new_two_player(45); + let colorless_a = create_object( + &mut state, + CardId(403), + PlayerId(1), + "Colorless A".to_string(), + Zone::Graveyard, + ); + let colorless_b = create_object( + &mut state, + CardId(404), + PlayerId(1), + "Colorless B".to_string(), + Zone::Graveyard, + ); + state.last_zone_changed_ids = vec![colorless_a, colorless_b]; + + let expr = QuantityExpr::Ref { + qty: QuantityRef::ObjectCountBySharedQuality { + filter: TargetFilter::LastZoneChanged, + quality: SharedQuality::Color, + aggregate: AggregateFunction::Max, + }, + }; + assert_eq!( + resolve_quantity(&state, &expr, PlayerId(0), ObjectId(0)), + 0, + "colorless milled pairs produce no shared-color bucket (Max 0)" + ); + } + + #[test] + fn object_count_matching_ids_applies_compound_last_zone_changed_filter() { + let mut state = GameState::new_two_player(46); + let red_a = create_object( + &mut state, + CardId(405), + PlayerId(1), + "Red A".to_string(), + Zone::Graveyard, + ); + let red_b = create_object( + &mut state, + CardId(406), + PlayerId(1), + "Red B".to_string(), + Zone::Graveyard, + ); + let green = create_object( + &mut state, + CardId(407), + PlayerId(1), + "Green C".to_string(), + Zone::Graveyard, + ); + for (id, color) in [ + (red_a, ManaColor::Red), + (red_b, ManaColor::Red), + (green, ManaColor::Green), + ] { + state.objects.get_mut(&id).unwrap().color = vec![color]; + } + state.last_zone_changed_ids = vec![red_a, red_b, green]; + + let filter = TargetFilter::And { + filters: vec![ + TargetFilter::LastZoneChanged, + TargetFilter::Typed(TypedFilter::card().properties(vec![FilterProp::HasColor { + color: ManaColor::Red, + }])), + ], + }; + let ctx = FilterContext::from_source(&state, ObjectId(0)); + let ids = object_count_matching_ids(&state, &filter, &ctx, ObjectId(0)); + assert_eq!(ids, vec![red_a, red_b]); + } + + #[test] + fn object_count_matching_ids_or_last_zone_changed_includes_typed_outside_ledger() { + let mut state = GameState::new_two_player(46); + let red_a = create_object( + &mut state, + CardId(405), + PlayerId(1), + "Red A".to_string(), + Zone::Graveyard, + ); + let red_b = create_object( + &mut state, + CardId(406), + PlayerId(1), + "Red B".to_string(), + Zone::Graveyard, + ); + let green = create_object( + &mut state, + CardId(407), + PlayerId(1), + "Green C".to_string(), + Zone::Graveyard, + ); + let red_on_battlefield = create_object( + &mut state, + CardId(408), + PlayerId(0), + "Red D".to_string(), + Zone::Battlefield, + ); + for (id, color) in [ + (red_a, ManaColor::Red), + (red_b, ManaColor::Red), + (green, ManaColor::Green), + (red_on_battlefield, ManaColor::Red), + ] { + state.objects.get_mut(&id).unwrap().color = vec![color]; + } + state.last_zone_changed_ids = vec![red_a, red_b, green]; + + let filter = TargetFilter::Or { + filters: vec![ + TargetFilter::LastZoneChanged, + TargetFilter::Typed(TypedFilter::card().properties(vec![FilterProp::HasColor { + color: ManaColor::Red, + }])), + ], + }; + let ctx = FilterContext::from_source(&state, ObjectId(0)); + let ids = object_count_matching_ids(&state, &filter, &ctx, ObjectId(0)); + assert_eq!( + ids.len(), + 4, + "Or must union ledger objects with typed reds outside the ledger" + ); + assert!(ids.contains(&red_on_battlefield)); + } + + #[test] + fn object_count_matching_ids_not_last_zone_changed_excludes_ledger() { + let mut state = GameState::new_two_player(46); + let red_a = create_object( + &mut state, + CardId(405), + PlayerId(1), + "Red A".to_string(), + Zone::Graveyard, + ); + let red_b = create_object( + &mut state, + CardId(406), + PlayerId(1), + "Red B".to_string(), + Zone::Graveyard, + ); + let green = create_object( + &mut state, + CardId(407), + PlayerId(1), + "Green C".to_string(), + Zone::Graveyard, + ); + let red_on_battlefield = create_object( + &mut state, + CardId(408), + PlayerId(0), + "Red D".to_string(), + Zone::Battlefield, + ); + for (id, color) in [ + (red_a, ManaColor::Red), + (red_b, ManaColor::Red), + (green, ManaColor::Green), + (red_on_battlefield, ManaColor::Red), + ] { + state.objects.get_mut(&id).unwrap().color = vec![color]; + } + state.last_zone_changed_ids = vec![red_a, red_b, green]; + + let filter = TargetFilter::Not { + filter: Box::new(TargetFilter::LastZoneChanged), + }; + let ctx = FilterContext::from_source(&state, ObjectId(0)); + let ids = object_count_matching_ids(&state, &filter, &ctx, ObjectId(0)); + assert_eq!( + ids, + vec![red_on_battlefield], + "Not(LastZoneChanged) must count battlefield objects outside the ledger" + ); + } + + #[test] + fn object_count_matching_ids_not_and_last_zone_changed_includes_nonmatching_ledger() { + let mut state = GameState::new_two_player(46); + let red_a = create_object( + &mut state, + CardId(405), + PlayerId(1), + "Red A".to_string(), + Zone::Graveyard, + ); + let red_b = create_object( + &mut state, + CardId(406), + PlayerId(1), + "Red B".to_string(), + Zone::Graveyard, + ); + let green = create_object( + &mut state, + CardId(407), + PlayerId(1), + "Green C".to_string(), + Zone::Graveyard, + ); + let red_on_battlefield = create_object( + &mut state, + CardId(408), + PlayerId(0), + "Red D".to_string(), + Zone::Battlefield, + ); + for (id, color) in [ + (red_a, ManaColor::Red), + (red_b, ManaColor::Red), + (green, ManaColor::Green), + (red_on_battlefield, ManaColor::Red), + ] { + state.objects.get_mut(&id).unwrap().color = vec![color]; + } + state.last_zone_changed_ids = vec![red_a, red_b, green]; + + let filter = TargetFilter::Not { + filter: Box::new(TargetFilter::And { + filters: vec![ + TargetFilter::LastZoneChanged, + TargetFilter::Typed(TypedFilter::card().properties(vec![ + FilterProp::HasColor { + color: ManaColor::Red, + }, + ])), + ], + }), + }; + let ctx = FilterContext::from_source(&state, ObjectId(0)); + let ids = object_count_matching_ids(&state, &filter, &ctx, ObjectId(0)); + assert!(ids.contains(&green)); + assert!(ids.contains(&red_on_battlefield)); + assert!(!ids.contains(&red_a)); + assert!(!ids.contains(&red_b)); + } + + #[test] + fn object_count_matching_ids_and_or_last_zone_changed_includes_off_battlefield_ledger() { + let mut state = GameState::new_two_player(46); + let red_a = create_object( + &mut state, + CardId(405), + PlayerId(1), + "Red A".to_string(), + Zone::Graveyard, + ); + let red_b = create_object( + &mut state, + CardId(406), + PlayerId(1), + "Red B".to_string(), + Zone::Graveyard, + ); + let green = create_object( + &mut state, + CardId(407), + PlayerId(1), + "Green C".to_string(), + Zone::Graveyard, + ); + let red_on_battlefield = create_object( + &mut state, + CardId(408), + PlayerId(0), + "Red D".to_string(), + Zone::Battlefield, + ); + for (id, color) in [ + (red_a, ManaColor::Red), + (red_b, ManaColor::Red), + (green, ManaColor::Green), + (red_on_battlefield, ManaColor::Red), + ] { + state.objects.get_mut(&id).unwrap().color = vec![color]; + } + state.last_zone_changed_ids = vec![red_a, red_b, green]; + + let filter = TargetFilter::And { + filters: vec![ + TargetFilter::Or { + filters: vec![ + TargetFilter::LastZoneChanged, + TargetFilter::Typed(TypedFilter::card()), + ], + }, + TargetFilter::Typed(TypedFilter::card().properties(vec![FilterProp::HasColor { + color: ManaColor::Red, + }])), + ], + }; + let ctx = FilterContext::from_source(&state, ObjectId(0)); + let ids = object_count_matching_ids(&state, &filter, &ctx, ObjectId(0)); + assert_eq!( + ids, + vec![red_a, red_b, red_on_battlefield], + "nested And(Or(LC, Typed), Typed) must union ledger graveyard members with typed reds" + ); + } + #[test] fn distinct_card_types_among_other_nonland_permanents_counts_matching_objects() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 7f53b1e119..9d8d52ffac 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -69,6 +69,34 @@ const FINALITY_COUNTER_INDEX: usize = usize::MAX - 5; /// card-granted `ReplacementDefinition`, so it uses the existing virtual-ID /// protocol shared by intrinsic shield, finality, and compleated effects. const COMMANDER_HAND_OR_LIBRARY_RETURN_INDEX: usize = usize::MAX - 6; +/// CR 702.44a + CR 702.44d: Granted Sunburst — a virtual `Moved`→Battlefield +/// ETB-counter replacement keyed on the entering spell that was GRANTED +/// sunburst ("that spell gains sunburst": Solar Array / Lux Artillery). Printed +/// sunburst is baked as an object-carried `ReplacementDefinition` at synthesis +/// time (`synthesize_sunburst`); a runtime grant adds a keyword but no +/// replacement definition, so this reserved candidate surfaces one virtual ETB +/// replacement per GRANTED instance (base-subtracted, mirroring +/// `synthesize_granted_keyword_triggers`). CR 702.44d — printed + granted +/// instances each yield a distinct candidate and apply separately. Only the +/// entering object's own granted sunburst is at issue, so the reserved index is +/// keyed on the entering object. +/// +/// Sunburst and Bloodthirst share the SAME structural gap — a printed as-enters +/// keyword synthesized into object-carried replacements, versus a runtime grant +/// that adds only the keyword — so they share the count/apply core +/// (`granted_keyword_etb_instances`, `apply_granted_keyword_etb_replacement`); +/// only the reserved index and per-instance-definition builder differ. Any future +/// granted as-enters keyword adds one more reserved index feeding the same core. +const GRANTED_SUNBURST_INDEX: usize = usize::MAX - 7; +/// CR 702.54a + CR 702.54c: Granted Bloodthirst — the Bloodthirst analogue of +/// `GRANTED_SUNBURST_INDEX`. Bloodlord of Vaasgoth's "Whenever you cast a Vampire +/// creature spell, it gains bloodthirst 3" adds only the keyword to the cast +/// spell; printed Bloodthirst is synthesized into carried replacements by +/// `synthesize_bloodthirst`, so this reserved candidate surfaces one virtual ETB +/// replacement per GRANTED Bloodthirst instance. Unlike Sunburst, the fixed-N +/// form is CONDITIONAL (an opponent must have been dealt damage this turn), so the +/// shared applier honors each granted instance's carried `condition`. +const GRANTED_BLOODTHIRST_INDEX: usize = usize::MAX - 8; /// CR 109.4 + CR 108.4a: Cards outside the battlefield/stack have no /// controller; if an effect asks for a card's controller, use its owner @@ -222,6 +250,174 @@ fn object_has_finality_counter(state: &GameState, object_id: ObjectId) -> bool { .is_some_and(|count| *count > 0) } +/// The reserved virtual-candidate index for each granted as-enters keyword family +/// (Sunburst, Bloodthirst). One reserved id per keyword feeds the shared +/// count/apply core, so the applier recovers WHICH keyword's per-instance +/// definitions to place from `rid.index` alone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GrantedEtbKeyword { + Sunburst, + Bloodthirst, +} + +impl GrantedEtbKeyword { + fn from_index(index: usize) -> Option { + match index { + GRANTED_SUNBURST_INDEX => Some(Self::Sunburst), + GRANTED_BLOODTHIRST_INDEX => Some(Self::Bloodthirst), + _ => None, + } + } + + fn index(self) -> usize { + match self { + Self::Sunburst => GRANTED_SUNBURST_INDEX, + Self::Bloodthirst => GRANTED_BLOODTHIRST_INDEX, + } + } +} + +fn granted_etb_keyword_replacement_id(object_id: ObjectId, kw: GrantedEtbKeyword) -> ReplacementId { + ReplacementId { + source: object_id, + index: kw.index(), + } +} + +fn is_granted_etb_keyword_replacement(rid: ReplacementId) -> bool { + GrantedEtbKeyword::from_index(rid.index).is_some() +} + +/// CR 604.1 + CR 613.1f: The count of GRANTED instances of `keyword` on +/// `object_id` matching `predicate` — the object's EFFECTIVE matching-keyword +/// count minus its printed (base) matching count. +/// +/// Printed as-enters keywords (Sunburst, Bloodthirst) are realized through +/// object-carried `ReplacementDefinition`s at synthesis time; only the granted +/// instances need a virtual candidate, so subtract `base_keywords` (mirrors +/// `synthesize_granted_keyword_triggers`). A printed-only keyword returns 0 here — +/// its counters come from the carried definitions, never this path — which keeps +/// printed + granted double-applying (CR 702.44d / CR 702.54c). +/// +/// CRITICAL: the entering spell is still on the STACK when its entry replacement +/// pipeline runs, and a granted keyword exists only as a continuous effect at that +/// moment — `obj.keywords` is NOT yet materialized for stack objects. +/// `effective_off_zone_keywords` is the single authority that resolves the live +/// keyword list for any zone (materialized list for battlefield objects; base + +/// ordered continuous grants, including transient effects, for stack/off-zone +/// objects — CR 613.1f recursion-guarded). +/// +/// `predicate` keys the count to a specific keyword identity: `Sunburst` is +/// parameter-less (match the variant); `Bloodthirst(v)` must match a distinct +/// value so a granted `bloodthirst 3` on top of a printed `bloodthirst 1` counts +/// one granted 3 and one printed 1 separately (CR 702.54c). +fn granted_keyword_etb_instances( + state: &GameState, + object_id: ObjectId, + live_keywords: &[crate::types::keywords::Keyword], + predicate: impl Fn(&crate::types::keywords::Keyword) -> bool, +) -> usize { + let Some(obj) = state.objects.get(&object_id) else { + return 0; + }; + let live = live_keywords.iter().filter(|kw| predicate(kw)).count(); + let base = obj.base_keywords.iter().filter(|kw| predicate(kw)).count(); + live.saturating_sub(base) +} + +/// CR 702.44d: number of GRANTED sunburst instances (parameter-less). +fn granted_sunburst_instances( + state: &GameState, + object_id: ObjectId, + live_keywords: &[crate::types::keywords::Keyword], +) -> usize { + granted_keyword_etb_instances(state, object_id, live_keywords, |kw| { + matches!(kw, crate::types::keywords::Keyword::Sunburst) + }) +} + +/// CR 702.54c: The GRANTED Bloodthirst instances on `object_id`, one entry per +/// granted instance carrying its `BloodthirstValue`. Counted per DISTINCT value +/// (effective-minus-base per value) exactly as `synthesize_bloodthirst` emits one +/// printed replacement per value, so a granted `bloodthirst 3` on a printed +/// `bloodthirst 1` yields exactly one granted-3 entry here. +fn granted_bloodthirst_instances( + state: &GameState, + object_id: ObjectId, + live_keywords: &[crate::types::keywords::Keyword], +) -> Vec { + use crate::types::keywords::Keyword; + let Some(obj) = state.objects.get(&object_id) else { + return Vec::new(); + }; + let live = live_keywords; + let distinct_values: Vec<_> = live + .iter() + .filter_map(|kw| match kw { + Keyword::Bloodthirst(v) => Some(v.clone()), + _ => None, + }) + .fold(Vec::new(), |mut acc, v| { + if !acc.contains(&v) { + acc.push(v); + } + acc + }); + let mut granted = Vec::new(); + for value in distinct_values { + let live_n = live + .iter() + .filter(|kw| matches!(kw, Keyword::Bloodthirst(v) if *v == value)) + .count(); + let base_n = obj + .base_keywords + .iter() + .filter(|kw| matches!(kw, Keyword::Bloodthirst(v) if *v == value)) + .count(); + for _ in 0..live_n.saturating_sub(base_n) { + granted.push(value.clone()); + } + } + granted +} + +/// Whether the granted `kw` virtual candidate should surface for `object_id` at +/// `event` — i.e. there is at least one granted instance whose carried condition +/// (if any) holds. This mirrors the PRINTED replacement path, which evaluates a +/// definition's `condition` at candidate-registration time and does not surface a +/// candidate whose condition is unmet (so a condition-unmet granted Bloodthirst +/// raises no spurious CR 616.1 ordering prompt). Sunburst definitions carry no +/// condition, so this reduces to "has at least one granted instance." +/// +/// The applier (`apply_granted_keyword_etb_replacement`) re-derives and re-checks +/// the same per-instance definitions, so registration and application agree. +fn granted_etb_keyword_candidate_applies( + state: &GameState, + object_id: ObjectId, + kw: GrantedEtbKeyword, + event: &ProposedEvent, + live_keywords: &[crate::types::keywords::Keyword], +) -> bool { + let controller = state + .objects + .get(&object_id) + .map(replacement_source_player) + .unwrap_or(state.active_player); + granted_etb_replacement_definitions(state, object_id, kw, live_keywords) + .iter() + .any(|definition| match &definition.condition { + Some(cond) => evaluate_replacement_condition( + cond, + controller, + object_id, + state, + event.affected_object_id(), + event, + ), + None => true, + }) +} + fn compleated_life_paid(state: &GameState, object_id: ObjectId) -> Option { state.objects.get(&object_id).and_then(|obj| { (obj.phyrexian_life_paid > 0 @@ -329,6 +525,168 @@ fn apply_compleated_replacement( } } +/// Build the per-instance `ReplacementDefinition`s for each GRANTED as-enters +/// keyword instance on `object_id`, using the same shared authority the printed +/// synthesizer uses so a granted spell places exactly the counters a printed one +/// would (CR 702.44d / CR 702.54c: each instance works separately). +/// +/// - Sunburst: N identical copies of `sunburst_replacement_definition`, branching +/// the counter type on the entering object's PRINTED core types (CR 702.44a). +/// - Bloodthirst: one `bloodthirst_replacement_definition(value)` per granted +/// instance, each carrying its own `condition` (CR 702.54a fixed-N is gated on +/// an opponent having been dealt damage this turn). +/// +/// `live_keywords` is the already-resolved off-zone keyword list for `object_id` +/// (`effective_off_zone_keywords`). It is threaded in rather than re-derived per +/// keyword family because that resolution runs a whole-game continuous-effect +/// collect, and this function sits on the `find_applicable_replacements` hot path. +fn granted_etb_replacement_definitions( + state: &GameState, + object_id: ObjectId, + kw: GrantedEtbKeyword, + live_keywords: &[crate::types::keywords::Keyword], +) -> Vec { + match kw { + GrantedEtbKeyword::Sunburst => { + let instances = granted_sunburst_instances(state, object_id, live_keywords); + // CR 702.44a: sunburst branches on whether the object is entering as a + // creature "ignoring any type-changing effects that would affect it" — + // i.e. on its PRINTED (characteristic-defining) core types. `card_types` + // is the LIVE layer result and type-changing effects do reach stack + // objects (CR 613.1d, via `remote_type_layer_recipients`), so reading it + // here would honor exactly the effects the rule says to ignore. + // `base_card_types` is seeded from the same `card_face.card_type` the + // printed synthesizer branches on (`synthesize_sunburst`), keeping the + // granted and printed paths identical. + let counter_type = state + .objects + .get(&object_id) + .filter(|obj| obj.base_card_types.core_types.contains(&CoreType::Creature)) + .map(|_| CounterType::Plus1Plus1) + .unwrap_or_else(|| CounterType::Generic("charge".to_string())); + let definition = + crate::database::synthesis::sunburst_replacement_definition(&counter_type); + std::iter::repeat_n(definition, instances).collect() + } + GrantedEtbKeyword::Bloodthirst => { + granted_bloodthirst_instances(state, object_id, live_keywords) + .iter() + .map(crate::database::synthesis::bloodthirst_replacement_definition) + .collect() + } + } +} + +/// CR 702.44a + CR 702.44d + CR 702.54a + CR 702.54c + CR 614.1c: Apply a granted +/// as-enters-keyword virtual ETB replacement (Sunburst or Bloodthirst) — fold the +/// as-enters counters onto the entering spell's `ZoneChange`, one placement group +/// per GRANTED instance. +/// +/// Each per-instance `ReplacementDefinition` comes from the same shared authority +/// the printed synthesizer uses (`granted_etb_replacement_definitions`), so a +/// granted spell places exactly the counters a printed one would; the count is +/// resolved by `event_modifiers_for_ability` against the entering spell so a +/// self-scoped quantity ref (Sunburst's `ManaSpentToCast`, Bloodthirst X's damage +/// total) reads its own cast/damage context (CR 601.2h). +/// +/// CR 702.54a — Bloodthirst is CONDITIONAL: each granted instance whose carried +/// `condition` is unmet (no opponent dealt damage this turn) contributes ZERO +/// counters, routed through the SAME `evaluate_replacement_condition` seam the +/// printed Bloodthirst path uses. Sunburst definitions carry `condition: None` +/// and are always applied. +/// +/// One `enter_with_counters` group is pushed per granted instance (CR 702.44d / +/// CR 702.54c: each instance works separately), so a counter-doubling replacement +/// (Doubling Season) doubles each instance's placement independently, exactly as +/// it would for multiple printed instances. +fn apply_granted_keyword_etb_replacement( + state: &mut GameState, + mut event: ProposedEvent, + rid: ReplacementId, + events: &mut Vec, +) -> ProposedEvent { + let Some(kw) = GrantedEtbKeyword::from_index(rid.index) else { + return event; + }; + // The candidate is keyed on the entering object; bail unchanged if the ids + // diverged (defensive) or the event is not the entering spell's ZoneChange. + let ProposedEvent::ZoneChange { object_id, .. } = &event else { + return event; + }; + if *object_id != rid.source { + return event; + } + + let live_keywords = + crate::game::off_zone_characteristics::effective_off_zone_keywords(state, rid.source); + let definitions = granted_etb_replacement_definitions(state, rid.source, kw, &live_keywords); + if definitions.is_empty() { + return event; + } + + // CR 110.2a: a battlefield-entry replacement's condition is evaluated relative + // to the entering object's controller (its owner while still on the stack). + let controller = state + .objects + .get(&rid.source) + .map(replacement_source_player) + .unwrap_or(state.active_player); + + // Resolve each granted instance's counter group, honoring its carried + // condition (CR 702.54a Bloodthirst gate), then fold them onto the event. + let mut instance_counter_groups: Vec> = Vec::new(); + for definition in &definitions { + // CR 614.1d + CR 702.54a: skip a granted instance whose condition is unmet + // (Bloodthirst fixed-N: no opponent dealt damage this turn). Sunburst's + // definition has `condition: None`, so it is always applied. + if let Some(cond) = &definition.condition { + if !evaluate_replacement_condition( + cond, + controller, + rid.source, + state, + event.affected_object_id(), + &event, + ) { + continue; + } + } + let modifiers = + event_modifiers_for_ability(definition.execute.as_deref(), state, rid.source, &event); + if !modifiers.etb_counters.is_empty() { + instance_counter_groups.push(modifiers.etb_counters); + } + } + if instance_counter_groups.is_empty() { + // No instance placed counters (e.g. Bloodthirst condition unmet, or zero + // colors of mana spent): the event passes through unchanged. `rid` is + // already recorded in `applied` by the pipeline's `mark_applied`. + return event; + } + + // Gemini nit (#5802 review): mutate `enter_with_counters` in place on the + // event rather than reconstructing every `ZoneChange` field — this survives + // new field additions to the variant (no field is manually re-listed). + if let ProposedEvent::ZoneChange { + enter_with_counters, + .. + } = &mut event + { + // CR 702.44d / CR 702.54c: one placement group per granted instance. + for group in instance_counter_groups { + enter_with_counters.extend(group); + } + } + // The candidate id is already recorded in `applied` by the pipeline's + // `mark_applied(rid)` before this applier runs (`for_event`-keyed), so the + // `applied` set is threaded through unchanged — no manual re-insert. + events.push(GameEvent::ReplacementApplied { + source_id: rid.source, + event_type: ReplacementEvent::Moved.to_string(), + }); + event +} + /// CR 614.1: Replacement effects modify events as they would occur. #[derive(Debug, Clone, PartialEq)] pub enum ReplacementResult { @@ -542,123 +900,77 @@ pub fn replacement_choice_waiting_for(player: PlayerId, state: &GameState) -> Wa .iter() .all(|candidate| candidate.is_optional); let count = pending_replacement_option_count(state, p); - let cands: Vec = - if p.is_optional && !p.search_found_candidates.is_empty() { - let candidate = &p.search_found_candidates[0]; - vec![ - ReplacementCandidateSummary { - source_id: candidate.disposition.source.object_id, - source_name: candidate.source_name.clone(), - description: candidate.description.clone(), - }, - ReplacementCandidateSummary { - source_id: candidate.disposition.source.object_id, - source_name: candidate.source_name.clone(), - description: "Decline".to_string(), - }, - ] - } else if !p.search_found_candidates.is_empty() { - let mut candidates: Vec<_> = p - .search_found_candidates - .iter() - .map(|candidate| ReplacementCandidateSummary { - source_id: candidate.disposition.source.object_id, - source_name: candidate.source_name.clone(), - description: candidate.description.clone(), - }) - .collect(); - if all_search_found_candidates_optional { - candidates.push(ReplacementCandidateSummary { - source_id: ObjectId(0), - source_name: String::new(), - description: "Use the original found-card destination".to_string(), - }); - } - candidates - } else if p.is_optional { - // CR 616.1: replacement-effect choices belong to the affected - // object's controller/owner or the affected player. An optional - // "you may" is one source shown as two branches — both carry - // `candidates[0].source`. - let source_id = p.candidates.first().map(|rid| rid.source); - let (accept_desc, decline_desc) = p - .candidates - .first() - .and_then(|rid| replacement_definition_for_id(state, *rid)) - .map(|repl| match &repl.mode { - ReplacementMode::MayCost { cost, .. } => { - (replacement_cost_description(cost), "Decline".to_string()) - } - // CR 702.136a (Riot) / CR 702.98a (Unleash): label an - // Optional replacement's accept branch by the - // replacement's own `description` (which names its source - // keyword, e.g. "Riot — ..." / "Unleash — ..."), falling - // back to the `execute` effect text when there is none. - // The decline branch, when it is a distinct outcome - // (e.g. Riot's "It gains haste"), is labeled by that - // outcome rather than a bare "Decline" — the reported - // bug was that declining silently granted haste with no - // indication; a decline-less Optional (Unleash) keeps a - // plain "Decline". - ReplacementMode::Optional { decline } => { - let accept = if repl.event - == crate::types::replacements::ReplacementEvent::Draw - { - "Accept".to_string() - } else { - repl.description - .clone() - .or_else(|| { - repl.execute - .as_ref() - .and_then(|e| e.description.clone()) - }) - .unwrap_or_else(|| "Accept".to_string()) - }; - let decline_label = decline - .as_ref() - .and_then(|d| d.description.clone()) - .unwrap_or_else(|| "Decline".to_string()); - (accept, decline_label) - } - ReplacementMode::Mandatory => ( - repl.description - .clone() - .unwrap_or_else(|| "Accept".to_string()), - "Decline".to_string(), - ), - }) - .unwrap_or_else(|| ("Accept".to_string(), "Decline".to_string())); - let source_id = source_id.unwrap_or(ObjectId(0)); - let source_name = name_of(source_id); - vec![ - ReplacementCandidateSummary { - source_id, - source_name: source_name.clone(), - description: accept_desc, - }, - ReplacementCandidateSummary { - source_id, - source_name, - description: decline_desc, - }, - ] - } else { - // CR 616.1 / CR 614.1c / CR 614.1d: each candidate gets an - // outcome-descriptive label derived from its `execute` - // effect, or from its synthetic shield-counter kind. - // `map` (not `filter_map`) guarantees the vec is never - // shorter than `candidate_count`, so the frontend index - // lookup stays aligned. - p.candidates - .iter() - .map(|rid| ReplacementCandidateSummary { - source_id: rid.source, - source_name: name_of(rid.source), - description: replacement_choice_label_for_rid(state, *rid), - }) - .collect() - }; + let cands: Vec = if p.is_optional + && !p.search_found_candidates.is_empty() + { + let candidate = &p.search_found_candidates[0]; + vec![ + ReplacementCandidateSummary { + source_id: candidate.disposition.source.object_id, + source_name: candidate.source_name.clone(), + description: candidate.description.clone(), + }, + ReplacementCandidateSummary { + source_id: candidate.disposition.source.object_id, + source_name: candidate.source_name.clone(), + description: "Decline".to_string(), + }, + ] + } else if !p.search_found_candidates.is_empty() { + let mut candidates: Vec<_> = p + .search_found_candidates + .iter() + .map(|candidate| ReplacementCandidateSummary { + source_id: candidate.disposition.source.object_id, + source_name: candidate.source_name.clone(), + description: candidate.description.clone(), + }) + .collect(); + if all_search_found_candidates_optional { + candidates.push(ReplacementCandidateSummary { + source_id: ObjectId(0), + source_name: String::new(), + description: "Use the original found-card destination".to_string(), + }); + } + candidates + } else if p.is_optional { + // CR 616.1: replacement-effect choices belong to the affected + // object's controller/owner or the affected player. An optional + // "you may" is one source shown as two branches — both carry + // `candidates[0].source`. + let source_id = p.candidates.first().map(|rid| rid.source); + let (accept_desc, decline_desc) = optional_replacement_choice_labels(state, p); + let source_id = source_id.unwrap_or(ObjectId(0)); + let source_name = name_of(source_id); + vec![ + ReplacementCandidateSummary { + source_id, + source_name: source_name.clone(), + description: accept_desc, + }, + ReplacementCandidateSummary { + source_id, + source_name, + description: decline_desc, + }, + ] + } else { + // CR 616.1 / CR 614.1c / CR 614.1d: each candidate gets an + // outcome-descriptive label derived from its `execute` + // effect, or from its synthetic shield-counter kind. + // `map` (not `filter_map`) guarantees the vec is never + // shorter than `candidate_count`, so the frontend index + // lookup stays aligned. + p.candidates + .iter() + .map(|rid| ReplacementCandidateSummary { + source_id: rid.source, + source_name: name_of(rid.source), + description: replacement_choice_label_for_rid(state, *rid), + }) + .collect() + }; (count, cands) } }) @@ -700,14 +1012,101 @@ pub fn park_waiting_for(state: &mut GameState, player: PlayerId) { state.waiting_for = replacement_choice_waiting_for(player, state); } -/// CR 614.12a: Human-readable accept-label for a `MayCost` replacement prompt. +/// Labels the two outcomes of an optional replacement choice. +fn optional_replacement_choice_labels( + state: &GameState, + pending: &PendingReplacement, +) -> (String, String) { + let Some(replacement_id) = pending.candidates.first().copied() else { + return ("Accept".to_string(), "Decline".to_string()); + }; + + if is_commander_hand_or_library_return_replacement(replacement_id) { + // CR 903.9b: this rules-source replacement redirects the commander to + // the command zone instead of the proposed hand/library destination. + return match &pending.proposed { + ProposedEvent::ZoneChange { to: Zone::Hand, .. } => ( + "Move to command zone".to_string(), + "Put into hand".to_string(), + ), + ProposedEvent::ZoneChange { + to: Zone::Library, .. + } => ( + "Move to command zone".to_string(), + "Put into library".to_string(), + ), + _ => ("Accept".to_string(), "Decline".to_string()), + }; + } + + replacement_definition_for_id(state, replacement_id) + .map(|replacement| match &replacement.mode { + ReplacementMode::MayCost { cost, decline } => { + let decline = decline + .as_ref() + .and_then(|effect| effect.description.clone()) + .unwrap_or_else(|| "Decline".to_string()); + (replacement_cost_description(cost), decline) + } + // CR 702.136a (Riot) / CR 702.98a (Unleash): label an optional + // replacement's accept branch by its source description, falling + // back to its execute effect. A distinct decline outcome names that + // outcome rather than using a bare "Decline". + ReplacementMode::Optional { decline } => { + let accept = if replacement.event == ReplacementEvent::Draw { + "Accept".to_string() + } else { + replacement + .description + .clone() + .or_else(|| { + replacement + .execute + .as_ref() + .and_then(|effect| effect.description.clone()) + }) + .unwrap_or_else(|| "Accept".to_string()) + }; + let decline = decline + .as_ref() + .and_then(|effect| effect.description.clone()) + .unwrap_or_else(|| "Decline".to_string()); + (accept, decline) + } + ReplacementMode::Mandatory => ( + replacement + .description + .clone() + .unwrap_or_else(|| "Accept".to_string()), + "Decline".to_string(), + ), + }) + .unwrap_or_else(|| ("Accept".to_string(), "Decline".to_string())) +} + +/// Human-readable accept-label for a `MayCost` replacement prompt. /// Returns a complete imperative phrase (the caller no longer prepends "Pay ") /// so non-mana costs read naturally. Exhaustive — a new `AbilityCost` variant /// forces a deliberate label decision here. fn replacement_cost_description(cost: &AbilityCost) -> String { match cost { - AbilityCost::Mana { cost } => format!("Pay {cost:?}"), - AbilityCost::PayLife { amount } => format!("Pay {amount:?} life"), + AbilityCost::Mana { cost } => match cost { + crate::types::mana::ManaCost::NoCost => "Pay no mana".to_string(), + crate::types::mana::ManaCost::Cost { shards, generic } => { + let generic = (*generic > 0).then(|| format!("{{{generic}}}")); + let symbols = shards.iter().map(|shard| format!("{{{}}}", shard.symbol())); + format!("Pay {}", generic.into_iter().chain(symbols).collect::()) + } + crate::types::mana::ManaCost::SelfManaCost => "Pay its mana cost".to_string(), + crate::types::mana::ManaCost::SelfManaValue => "Pay its mana value".to_string(), + crate::types::mana::ManaCost::SelfManaCostReduced { reduction } => { + format!("Pay its mana cost reduced by {{{reduction}}}") + } + }, + AbilityCost::PayLife { + amount: QuantityExpr::Fixed { value }, + } => format!("Pay {value} life"), + AbilityCost::PayLife { .. } => "Pay life".to_string(), // CR 614.12a: Karoo self-ETB cost lands. AbilityCost::Sacrifice(cost) => match &cost.requirement { crate::types::ability::SacrificeRequirement::Count { count } => { @@ -850,6 +1249,16 @@ fn replacement_choice_label_for_rid(state: &GameState, rid: ReplacementId) -> St if is_compleated_replacement(rid) { return "Compleated: enter with fewer loyalty counters".to_string(); } + if let Some(kw) = GrantedEtbKeyword::from_index(rid.index) { + // CR 702.44a / CR 702.54a: mandatory ETB-counter replacement — only ever + // labeled in a CR 616.1 ordering prompt, never an accept/decline choice. + return match kw { + GrantedEtbKeyword::Sunburst => { + "Sunburst: enter with counters for colors of mana spent".to_string() + } + GrantedEtbKeyword::Bloodthirst => "Bloodthirst: enter with +1/+1 counters".to_string(), + }; + } if is_finality_counter_replacement(rid) { return "Exile it instead".to_string(); } @@ -6407,6 +6816,52 @@ pub fn find_applicable_replacements( } } + // CR 702.44a + CR 702.44d + CR 702.54a + CR 702.54c + CR 614.1c: Granted + // as-enters keywords (Sunburst, Bloodthirst) — a spell GRANTED such a keyword + // as it was cast ("that spell gains sunburst": Solar Array / Lux Artillery; + // "it gains bloodthirst 3": Bloodlord of Vaasgoth) carries the keyword in its + // live keyword set but no object-carried ETB replacement (only printed keywords + // are synthesized into `replacement_definitions`). Surface one virtual + // ETB-counter candidate PER KEYWORD FAMILY here when the granted spell enters + // the battlefield so its as-enters counters are placed. Gated to + // `ZoneChange`→Battlefield exactly as the printed definition's `Moved`/ + // destination gate. One reserved candidate per family covers all that family's + // granted instances — its applier emits one counter placement per granted + // instance (CR 702.44d / CR 702.54c), and printed instances still apply + // separately via their own carried definitions. Ordered against Doubling + // Season-class modifiers by the shared enter-with-counters pipeline, exactly + // like the printed keyword. + if let ProposedEvent::ZoneChange { + object_id, + to: Zone::Battlefield, + .. + } = event + { + // Hot path (`find_applicable_replacements` runs per proposed event, and + // AI search clones/replays states constantly): test the CHEAP term first. + // `already_applied` is a set lookup, whereas the granted-instance query + // resolves the object's live off-zone keyword list — a whole-game + // continuous-effect collect plus ordering and per-effect filter + // evaluation. That resolution is also hoisted out of the family loop and + // computed at most ONCE (lazily, so an all-applied event pays nothing), + // then shared by every family instead of being re-swept per family. + let mut live_keywords: Option> = None; + for kw in [GrantedEtbKeyword::Sunburst, GrantedEtbKeyword::Bloodthirst] { + let rid = granted_etb_keyword_replacement_id(*object_id, kw); + if event.already_applied(&rid) { + continue; + } + let live = live_keywords.get_or_insert_with(|| { + crate::game::off_zone_characteristics::effective_off_zone_keywords( + state, *object_id, + ) + }); + if granted_etb_keyword_candidate_applies(state, *object_id, kw, event, live) { + candidates.push(rid); + } + } + } + // CR 702.89a: Umbra armor — a destroy of a permanent enchanted by an Umbra is // a candidate for the virtual umbra-armor replacement. Offered independently of // the shield-counter match above so a permanent carrying both a shield counter @@ -7151,6 +7606,12 @@ fn apply_single_replacement( return Ok(apply_compleated_replacement(state, proposed, rid, events)); } + if is_granted_etb_keyword_replacement(rid) { + return Ok(apply_granted_keyword_etb_replacement( + state, proposed, rid, events, + )); + } + if let Some(kind) = shield_counter_replacement_kind(rid) { return apply_shield_counter_replacement(state, proposed, rid, kind, events); } @@ -7927,6 +8388,19 @@ fn candidate_materiality( return CandidateMateriality::Unconditional; } + // CR 616.1 + CR 614.1c: a granted as-enters keyword (Sunburst / Bloodthirst) + // APPENDS to the event's counter payload — an ADDITIVE Count write. Two + // appenders commute (append 2 + append 3 = 5 either way), but an appender does + // NOT commute with a counter doubler on the same event ((0+N)*2 vs 0*2+N), so + // classifying it `Disjoint` would silently suppress the CR 616.1e ordering + // choice against a Doubling Season-class Count writer (review on #5802). + if is_granted_etb_keyword_replacement(rid) { + return CandidateMateriality::Writes { + field: EventField::Count, + commute: CommuteClass::Additive, + }; + } + // CR 614.10: the turn-scoped combat skip fully prevents the BeginPhase event, // so it is unconditional like the umbra-armor / shield-counter destroy. if is_turn_scoped_combat_skip_replacement(rid) { @@ -8074,11 +8548,12 @@ fn candidate_materiality( } // ETB-counter replacements (`PutCounter`) only *append* to // `enter_with_counters`, so they never conflict. `Effect::Choose` - // (the as-enters color choice) runs after the ZoneChange and - // touches no shared event field. Both are explicitly recognized as - // order-independent so they do NOT fall through to the conservative - // material default below. - Effect::PutCounter { .. } | Effect::Choose { .. } => {} + // (the as-enters color choice) and `Effect::ChoosePermanent` (the + // as-enters object choice — Metamorphic Alteration) run after the + // ZoneChange and touch no shared event field. Both are explicitly + // recognized as order-independent so they do NOT fall through to + // the conservative material default below. + Effect::PutCounter { .. } | Effect::Choose { .. } | Effect::ChoosePermanent { .. } => {} // CR 614.1a + CR 111.1: Full token substitution on a CreateToken // event rewrites `CreateToken::spec` in the applier. Two different // substitutions on one event are last-applied-wins and stay @@ -9942,14 +10417,17 @@ mod tests { cost: AbilityCost::PayLife { amount: QuantityExpr::Fixed { value: amount }, }, - decline: Some(Box::new(AbilityDefinition::new( - AbilityKind::Spell, - Effect::SetTapState { - target: TargetFilter::SelfRef, - scope: EffectScope::Single, - state: TapStateChange::Tap, - }, - ))), + decline: Some(Box::new( + AbilityDefinition::new( + AbilityKind::Spell, + Effect::SetTapState { + target: TargetFilter::SelfRef, + scope: EffectScope::Single, + state: TapStateChange::Tap, + }, + ) + .description("It enters tapped".to_string()), + )), }) .valid_card(TargetFilter::SelfRef) } @@ -9990,6 +10468,19 @@ mod tests { result, ReplacementResult::NeedsChoice(PlayerId(0)) )); + let WaitingFor::ReplacementChoice { candidates, .. } = + replacement_choice_waiting_for(PlayerId(0), &state) + else { + panic!("expected replacement choice prompt"); + }; + assert_eq!( + candidates + .iter() + .map(|candidate| candidate.description.as_str()) + .collect::>(), + vec!["Pay 2 life", "It enters tapped"], + "the decline choice must describe its branch outcome" + ); let result = continue_replacement(&mut state, 1, &mut events); let ReplacementResult::Execute(ProposedEvent::ZoneChange { enter_tapped, .. }) = result @@ -11161,6 +11652,60 @@ mod tests { ); } + #[test] + fn commander_hand_or_library_replacement_labels_both_destinations() { + let commander = ObjectId(21); + for (destination, decline_label) in [ + (Zone::Hand, "Put into hand"), + (Zone::Library, "Put into library"), + ] { + let mut state = test_state_with_object(commander, Zone::Battlefield, vec![]); + state.pending_replacement = Some(PendingReplacement { + proposed: ProposedEvent::zone_change( + commander, + Zone::Battlefield, + destination, + None, + ), + sacrifice_provenance: None, + candidates: vec![commander_hand_or_library_return_replacement_id(commander)], + search_found_candidates: Vec::new(), + depth: 0, + is_optional: true, + library_placement: None, + excess_recipient: None, + lifelink_bonus: 0, + may_cost_paid: false, + may_cost_remaining: None, + }); + + let WaitingFor::ReplacementChoice { candidates, .. } = + replacement_choice_waiting_for(PlayerId(0), &state) + else { + panic!("expected commander replacement choice for {destination:?}"); + }; + + assert_eq!( + candidates + .iter() + .map(|candidate| candidate.description.as_str()) + .collect::>(), + vec!["Move to command zone", decline_label], + "CR 903.9b choices must name the resulting zone, not generic accept/decline" + ); + } + } + + #[test] + fn fixed_life_may_cost_uses_a_display_label() { + assert_eq!( + replacement_cost_description(&AbilityCost::PayLife { + amount: QuantityExpr::Fixed { value: 2 }, + }), + "Pay 2 life" + ); + } + /// CR 703.4q + CR 616.1: On the step-end empty-mana path each candidate's /// own `rid.source` is the `ObjectId(0)` sentinel — the real source object /// lives on the handler entry (`StepEndManaScanEntry.source`). The builder diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 0cd300b29a..06457e7611 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -15,7 +15,7 @@ use crate::game::game_object::GameObject; use crate::game::printed_cards::apply_card_face_to_object; use crate::game::zones::create_object; use crate::types::ability::{ - AbilityDefinition, AbilityKind, AdditionalCost, Effect, PtValue, QuantityExpr, + AbilityDefinition, AbilityKind, AdditionalCost, Effect, EffectKind, PtValue, QuantityExpr, ReplacementDefinition, ResolvedAbility, StaticDefinition, TargetFilter, TargetRef, TriggerDefinition, }; @@ -989,10 +989,18 @@ impl<'a> CardBuilder<'a> { pub fn as_enchantment(&mut self) -> &mut Self { let obj = self.obj(); - obj.card_types - .core_types - .retain(|t| *t != CoreType::Creature); - obj.card_types.core_types.push(CoreType::Enchantment); + // Permanent enchantment spells staged from `add_spell_to_hand` keep the + // Instant/Sorcery seed until stripped here — same shape as + // `as_creature` / `as_planeswalker_with_loyalty`. + obj.card_types.core_types.retain(|t| { + !matches!( + t, + CoreType::Creature | CoreType::Instant | CoreType::Sorcery + ) + }); + if !obj.card_types.core_types.contains(&CoreType::Enchantment) { + obj.card_types.core_types.push(CoreType::Enchantment); + } self.sync_base_card_types(); self } @@ -1497,6 +1505,36 @@ impl GameRunner { super::triggers::drain_order_triggers_with_identity(&mut self.state); continue; } + // CR 401.4: mass library-bottom placement parks `EffectZoneChoice` even + // when the stack is empty (Teferi's Puzzle Box draw-step trigger). Tests + // that drive phase advancement without an explicit `.effect_zone()` policy + // submit the engine-listed card order so resolution can finish. + if let WaitingFor::EffectZoneChoice { + cards, + count, + min_count, + up_to, + effect_kind, + .. + } = &self.state.waiting_for + { + if *effect_kind != EffectKind::PutAtLibraryPosition { + break; + } + if *up_to || cards.len() < *min_count { + break; + } + let chosen: Vec<_> = cards.iter().take(*count).copied().collect(); + if chosen.len() != *count { + break; + } + if apply_as_current(&mut self.state, GameAction::SelectCards { cards: chosen }) + .is_err() + { + break; + } + continue; + } if self.state.stack.is_empty() { break; } @@ -1642,6 +1680,7 @@ impl GameRunner { WaitingFor::ModeChoice { .. } => "ModeChoice", WaitingFor::DiscardToHandSize { .. } => "DiscardToHandSize", WaitingFor::OptionalCostChoice { .. } => "OptionalCostChoice", + WaitingFor::ChooseGiftRecipient { .. } => "ChooseGiftRecipient", WaitingFor::CostTypeChoice { .. } => "CostTypeChoice", WaitingFor::SpliceOffer { .. } => "SpliceOffer", WaitingFor::DefilerPayment { .. } => "DefilerPayment", @@ -2155,12 +2194,14 @@ impl<'a> SpellCast<'a> { &mut events, )?; - // Intent the driver matches as it walks slots: object targets are - // consumed one per slot (most slots are object slots), while player - // targets are reusable across slots (one player may be targeted by - // several modes — see `pick_slot_target`). + // Intent the driver matches as it walks slots. Object targets are + // always consumed one per slot. Player declarations are consumed only + // by a multi-target run so `.target_players(&[a, b])` can express two + // distinct targets while a single declaration remains reusable across + // independent modal slots. let mut remaining_objects: Vec = target_objects; let declared_players: Vec = target_players; + let mut remaining_multi_target_players = declared_players.clone(); let mut remaining_cost_objects: Vec = cost_objects; // CR 601.2a: the spell leaves hand only at stack commit. Captured when @@ -2278,6 +2319,18 @@ impl<'a> SpellCast<'a> { let pay = matches!(optional, OptionalPolicy::Accept); act_collect(runner, GameAction::DecideOptionalCost { pay }, &mut events)?; } + // CR 702.174a: after promising Gift with ≥2 opponents, pick a recipient. + // Sole-opponent games auto-latch and never raise this prompt. + WaitingFor::ChooseGiftRecipient { candidates, .. } => { + let opponent = candidates.first().copied().unwrap_or_else(|| { + panic!("ChooseGiftRecipient raised with empty candidates") + }); + act_collect( + runner, + GameAction::ChooseGiftRecipient { opponent }, + &mut events, + )?; + } // CR 601.2f / CR 118.3: additional non-mana costs that require // selecting objects, such as sacrificing a creature. WaitingFor::PayCost { @@ -2358,6 +2411,7 @@ impl<'a> SpellCast<'a> { } // CR 601.2c: declare one target per slot, in written order. WaitingFor::TargetSelection { + pending_cast, target_slots, selection, .. @@ -2366,6 +2420,11 @@ impl<'a> SpellCast<'a> { let choice = pick_slot_target( slot, &mut remaining_objects, + pending_cast + .ability + .multi_target + .as_ref() + .map(|_| &mut remaining_multi_target_players), &declared_players, selection.current_slot, ); @@ -2547,15 +2606,17 @@ impl<'a> CastCommit<'a> { /// matching CR 601.2c (targets declared one per slot, in written order). /// /// Object intent is *consumed* (each declared object satisfies at most one -/// slot, so distinct exile/destroy targets never alias). Player intent is -/// *reusable* — the same player is routinely targeted by several modes of one -/// modal spell (e.g. Kozilek's Command mode 1 scries *and* draws for the same -/// target player), so a declared player may satisfy multiple player slots. +/// slot, so distinct exile/destroy targets never alias). A multi-target run +/// consumes player declarations in order when available; otherwise, player +/// intent is reusable, letting the same declared player satisfy independent +/// modal slots (e.g. Kozilek's Command mode 1 scries *and* draws for the same +/// target player). /// Falls back to `None` for optional slots; panics for an unsatisfiable /// required slot. fn pick_slot_target( slot: &crate::types::game_state::TargetSelectionSlot, remaining_objects: &mut Vec, + remaining_multi_target_players: Option<&mut Vec>, declared_players: &[PlayerId], slot_index: usize, ) -> Option { @@ -2565,6 +2626,14 @@ fn pick_slot_target( { return Some(TargetRef::Object(remaining_objects.remove(pos))); } + if let Some(remaining_players) = remaining_multi_target_players { + if let Some(pos) = remaining_players + .iter() + .position(|&player| slot.legal_targets.contains(&TargetRef::Player(player))) + { + return Some(TargetRef::Player(remaining_players.remove(pos))); + } + } if let Some(&player) = declared_players .iter() .find(|&&p| slot.legal_targets.contains(&TargetRef::Player(p))) @@ -2601,6 +2670,7 @@ fn waiting_for_variant_name(waiting: &WaitingFor) -> &'static str { WaitingFor::ArrangePlanarDeckTopChoice { .. } => "ArrangePlanarDeckTopChoice", WaitingFor::SearchChoice { .. } => "SearchChoice", WaitingFor::OptionalCostChoice { .. } => "OptionalCostChoice", + WaitingFor::ChooseGiftRecipient { .. } => "ChooseGiftRecipient", WaitingFor::CastOffer { .. } => "CastOffer", WaitingFor::ModalFaceChoice { .. } => "ModalFaceChoice", WaitingFor::AlternativeCastChoice { .. } => "AlternativeCastChoice", @@ -2882,6 +2952,7 @@ impl<'a> AbilityActivation<'a> { let choice = pick_slot_target( slot, &mut remaining_objects, + None, &declared_players, selection.current_slot, ); @@ -3106,6 +3177,7 @@ fn drive_resolution( let choice = pick_slot_target( slot, &mut remaining_objects, + None, declared_players, selection.current_slot, ); @@ -3127,6 +3199,7 @@ fn drive_resolution( let choice = pick_slot_target( slot, &mut remaining_objects, + None, declared_players, selection.current_slot, ); @@ -3219,6 +3292,17 @@ fn drive_resolution( let pay = matches!(policy.optional, OptionalPolicy::Accept); act_collect(runner, GameAction::DecideOptionalCost { pay }, &mut events)?; } + WaitingFor::ChooseGiftRecipient { candidates, .. } => { + let opponent = candidates + .first() + .copied() + .unwrap_or_else(|| panic!("ChooseGiftRecipient raised with empty candidates")); + act_collect( + runner, + GameAction::ChooseGiftRecipient { opponent }, + &mut events, + )?; + } WaitingFor::ReplacementChoice { .. } => { let Some(index) = policy.replacement_choice else { break; @@ -3290,6 +3374,9 @@ fn drive_resolution( )?; } WaitingFor::CopyTargetChoice { valid_targets, .. } => { + // No pick declared → halt so the caller can assert the offered + // options and the prompt boundary via `final_waiting_for()` + // (mirrors SpellbookDraft / NamedChoice / ReplacementChoice). let Some(target) = policy.copy_target else { break; }; diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 2cd0131e57..05bfac769f 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -1,16 +1,16 @@ use crate::types::ability::{ AbilityKind, ContinuousModification, CopyCountStatus, Duration, Effect, EffectKind, FilterProp, KeywordAction, ObjectScope, PlayerFilter, QuantityExpr, QuantityRef, ResolvedAbility, - SpellContext, SubAbilityLink, TargetChoiceTiming, TargetFilter, TargetRef, TargetSelectionMode, - TriggerCondition, + SiblingCondition, SpellContext, SubAbilityLink, TargetChoiceTiming, TargetFilter, TargetRef, + TargetSelectionMode, TriggerCondition, }; use crate::types::card_type::CoreType; use crate::types::counter::CounterType; use crate::types::events::GameEvent; use crate::types::game_state::{ AutoMayChoice, CastOfferKind, CastingVariant, ExileLink, ExileLinkKind, GameState, - MayTriggerAutoChoiceKey, MayTriggerOrigin, PendingCounterPostAction, StackEntry, - StackEntryKind, StackPaidSnapshot, WaitingFor, + MayTriggerAutoChoiceKey, MayTriggerOrigin, PendingCounterPostAction, PendingSpellResolution, + StackEntry, StackEntryKind, StackPaidSnapshot, WaitingFor, }; use crate::types::identifiers::ObjectId; use crate::types::player::PlayerId; @@ -25,6 +25,10 @@ use super::zone_pipeline::{self, ZoneMoveRequest, ZoneMoveResult}; /// CR 405.1: Add an object to the stack. pub fn push_to_stack(state: &mut GameState, mut entry: StackEntry, events: &mut Vec) { + let source_ref = state + .objects + .get(&entry.source_id) + .map(crate::types::identifiers::ObjectIncarnationRef::from_object); // CR 701.27f: an activated or triggered ability of a permanent may // transform that permanent only if it has not transformed/converted since // the ability was put onto the stack. Spells and keyword actions do not @@ -52,6 +56,12 @@ pub fn push_to_stack(state: &mut GameState, mut entry: StackEntry, events: &mut } } } + // CR 400.7 + CR 509.1c: source-referential force-block instructions bind + // their exact source at the common stack boundary, covering activated and + // other nontriggered stack abilities as well as normal triggered paths. + if let Some(ability) = entry.ability_mut() { + ability.bind_force_block_source_recursive(source_ref); + } events.push(GameEvent::StackPushed { object_id: entry.id, }); @@ -239,6 +249,58 @@ fn move_prevented_permanent_spell_to_graveyard_if_still_on_stack( } } +/// CR 608.3 + CR 400.7d: Snapshot cast-link / target facts for a permanent spell +/// paused mid-resolution (delivery-tail `NeedsChoice`, replacement-choice +/// `NeedsChoice`, or CallerEpilogue `CopyTargetChoice`). Single authority so a +/// new cast-metadata field cannot be threaded into only two of three stash sites. +fn pending_spell_resolution_snapshot( + state: &GameState, + entry: &StackEntry, + ability: Option<&ResolvedAbility>, + casting_variant: CastingVariant, + actual_mana_spent: u32, + spell_targets: &[TargetRef], +) -> PendingSpellResolution { + let obj = state.objects.get(&entry.id); + let cast_from_zone = ability + .and_then(|a| a.context.cast_from_zone) + .or_else(|| obj.and_then(|o| o.cast_from_zone)); + let cast_timing_permission = + obj.and_then(|o| o.cast_timing_permission.map(|(permission, _)| permission)); + let kickers_paid = ability + .map(|a| a.context.kickers_paid.clone()) + .unwrap_or_else(|| obj.map(|o| o.kickers_paid.clone()).unwrap_or_default()); + let additional_cost_payment_count = ability + .map(|a| a.context.additional_cost_payment_count) + .unwrap_or_else(|| { + obj.map(|o| o.additional_cost_payment_count) + .unwrap_or_default() + }); + let additional_cost_payments = ability + .map(|a| a.context.additional_cost_payments.clone()) + .unwrap_or_else(|| { + obj.map(|o| o.additional_cost_payments.clone()) + .unwrap_or_default() + }); + let convoked_creatures = obj + .map(|o| o.convoked_creatures.clone()) + .unwrap_or_default(); + PendingSpellResolution { + object_id: entry.id, + controller: entry.controller, + casting_variant, + cast_from_zone, + cast_controller: Some(entry.controller), + cast_timing_permission, + spell_targets: spell_targets.to_vec(), + actual_mana_spent, + kickers_paid, + additional_cost_payment_count, + additional_cost_payments, + convoked_creatures, + } +} + /// CR 608.2: Resolve the top object on the stack. pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // CR 603.3c + CR 603.3d: The top of the stack may be a trigger entry that @@ -407,6 +469,31 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { } } + // CR 109.4 + CR 115.10a/b (issue #6505): "Target opponent exiles a creature + // they control and their graveyard" (Strategic Betrayal). The spell targets + // ONLY the opponent (CR 115.1a); that opponent then CHOOSES a creature they + // control and exiles their graveyard — so a `ScopedPlayer`-scoped move-object + // filter must resolve its acting/choosing player against the resolved single + // player target, not the caster. Sibling of the DamageDealt/AttackersDeclared + // scoped-player stamp above: bind `scoped_player` from the ability's lone + // `TargetRef::Player` before the change_zone choosers run at resolution. + if let Some(ability) = ability.as_mut() { + if ability.scoped_player.is_none() { + let single_player_target = ability + .targets + .iter() + .filter(|target| matches!(target, TargetRef::Player(_))) + .count() + == 1; + if single_player_target + && crate::game::effects::ability_uses_relative_controller_scoped(ability) + { + let actor = ability.target_player(); + ability.set_scoped_player_recursive(actor); + } + } + } + // CR 608.2c: Re-stamp ParentTarget anaphora from the stack entry's trigger // event at resolution time (Stationed/VehicleCrewed/Saddled/attack batches). // Push-time seeding in `push_pending_trigger_to_stack_with_event_batch` can @@ -440,10 +527,19 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { return; } - // Capture targets for Aura attachment after resolution + // Capture targets for Aura attachment after resolution. Prefer the full + // chain flatten so Enchant targets assigned onto an Aura placeholder are + // not missed when only a nested sink holds them. let spell_targets = ability .as_ref() - .map(|a| a.targets.clone()) + .map(|a| { + let flat = flatten_targets_in_chain(a); + if flat.is_empty() { + a.targets.clone() + } else { + flat + } + }) .unwrap_or_default(); // CR 702.103e: As a bestowed Aura spell begins resolving, if its target is @@ -970,11 +1066,6 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { } } - let convoked_creatures = state - .objects - .get(&entry.id) - .map(|obj| obj.convoked_creatures.clone()) - .unwrap_or_default(); // CR 702.33d + CR 400.7d + CR 603.4: Normalize the authoritative // cast-link provenance onto the stack object BEFORE `replace_event`, // so the pipeline's `CastLinkSnapshot` (captured inside @@ -988,6 +1079,7 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { if let Some(ability) = ability.as_ref() { if let Some(obj) = state.objects.get_mut(&entry.id) { obj.kickers_paid = ability.context.kickers_paid.clone(); + obj.gift_recipient = ability.context.gift_recipient; obj.additional_cost_payment_count = ability.context.additional_cost_payment_count; obj.additional_cost_payments = ability.context.additional_cost_payments.clone(); @@ -1005,11 +1097,6 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { ability.context.cast_controller.or(Some(entry.controller)); } } - let cast_timing_permission = state - .objects - .get(&entry.id) - .and_then(|obj| obj.cast_timing_permission.map(|(permission, _)| permission)); - match super::replacement::replace_event(state, proposed, events) { super::replacement::ReplacementResult::Execute(event) => { if let crate::types::proposed_event::ProposedEvent::ZoneChange { @@ -1066,12 +1153,26 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { events, ) { zone_pipeline::ZoneDeliveryResult::Done => {} - // CR 614.1c / CR 616.1: the delivery tail parked a - // counter-replacement pause and stashed the - // remaining tail; surface it without running the - // caller epilogue (the parked tail carries - // `CallerEpilogue` and the resume path owns it). + // CR 614.1c / CR 616.1 / CR 614.12a: the delivery + // tail parked a mid-entry choice (CopyTargetChoice, + // NamedChoice, counter branch, …) and stashed the + // remaining tail. Surface it without running the + // caller epilogue — including CR 608.3c Aura attach, + // which has not run yet. + // + // CR 608.3c + CR 400.7d: stash PendingSpellResolution + // so the choice-answer resume can complete Aura + // attachment / cast-link stamps — mirrors the + // ReplacementResult::NeedsChoice arm below. zone_pipeline::ZoneDeliveryResult::NeedsChoice(_) => { + state.push_spell_resolution(pending_spell_resolution_snapshot( + state, + &entry, + ability.as_ref(), + casting_variant, + actual_mana_spent, + &spell_targets, + )); events.push(GameEvent::StackResolved { object_id: entry.id, }); @@ -1162,22 +1263,9 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { events, ); } - // CR 614.12a: Drain mandatory replacement post-effects (e.g., the - // Siege protector / Tribute opponent-choice prompt that was stashed - // by `apply_single_replacement` while resolving this ZoneChange). - // Sets `state.waiting_for` to the resulting prompt, if any — the - // caller's post-stack resolution checks waiting_for before returning - // priority. Without this drain the choice would be silently dropped. - if state.has_post_replacement_drain() { - state.clear_post_replacement_source(); - let _ = super::engine_replacement::apply_pending_post_replacement_effect( - state, - Some(entry.id), - None, - Some(crate::types::replacements::ReplacementEvent::Moved), - events, - ); - } + // CR 614.12a post-replacement drain runs AFTER CR 608.3c Aura + // attach below — PersistChosenAttribute needs `attached_to` + // before the choice is answered (mirrors dig/CR 303.4f). } super::replacement::ReplacementResult::Prevented => { // CR 608.3e: Permanent spell's ETB was fully prevented — @@ -1217,60 +1305,20 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { super::replacement::ReplacementResult::NeedsChoice(player) => { // A replacement needs player choice (e.g., Clone "enter as a copy"). // Store context so handle_replacement_choice can complete post-resolution. - let cast_from_zone = ability - .as_ref() - .and_then(|a| a.context.cast_from_zone) - .or_else(|| state.objects.get(&entry.id).and_then(|o| o.cast_from_zone)); // CR 702.33d + CR 400.7d: Use the authoritative kicker payments // (resolving spell's `SpellContext` when present, else the stack // object's stamped value) so placeholder permanent spells with // `ability == None` are not silently de-kicked when a replacement // needs a player choice. `engine_replacement` restores this onto // the permanent unconditionally after the choice resolves. - let kickers_paid = ability - .as_ref() - .map(|a| a.context.kickers_paid.clone()) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.kickers_paid.clone()) - .unwrap_or_default() - }); - let additional_cost_payment_count = ability - .as_ref() - .map(|a| a.context.additional_cost_payment_count) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.additional_cost_payment_count) - .unwrap_or_default() - }); - let additional_cost_payments = ability - .as_ref() - .map(|a| a.context.additional_cost_payments.clone()) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.additional_cost_payments.clone()) - .unwrap_or_default() - }); - state.push_spell_resolution(crate::types::game_state::PendingSpellResolution { - object_id: entry.id, - controller: entry.controller, + state.push_spell_resolution(pending_spell_resolution_snapshot( + state, + &entry, + ability.as_ref(), casting_variant, - cast_from_zone, - cast_controller: Some(entry.controller), - cast_timing_permission, - spell_targets: spell_targets.clone(), actual_mana_spent, - kickers_paid, - additional_cost_payment_count, - additional_cost_payments, - convoked_creatures, - }); + &spell_targets, + )); state.waiting_for = super::replacement::replacement_choice_waiting_for(player, state); // Emit StackResolved now — the spell has left the stack even though @@ -1435,6 +1483,10 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // Dead) legally accepts a graveyard host. A now-illegal target // leaves the Aura unattached and SBA (CR 704.5m) cleans it up // at the next checkpoint. + // + // CR 608.3c / CR 303.4a: the host is the spell's chosen + // target — never re-consult the Enchant filter (CR 303.4f + // non-spell entry) when that target is missing. Some(crate::types::ability::TargetRef::Object(target_id)) if crate::game::sba::is_valid_attachment_target( state, entry.id, *target_id, @@ -1461,6 +1513,53 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { } } + // CR 614.12a: Drain mandatory replacement post-effects (Siege / + // Tribute opponent-choice, Metamorphic ChoosePermanent + // CopyTargetChoice, …) stashed while resolving this permanent's + // ZoneChange. `CallerEpilogue` skipped the DeliveryTail drain, so + // this site owns the prompt — AFTER CR 608.3c Aura attach above so + // the Aura is hosted before SBAs / the copy-choice answer, while + // `PendingSpellResolution.spell_targets` still carries the cast + // target for the PersistChosenAttribute resume (CR 608.3c / + // CR 303.4a). Do not push SpellResolution on top of an + // AbilityContinuation (Tribute/Siege resume is top-only). + if state.has_post_replacement_drain() { + state.clear_post_replacement_source(); + if let Some(wf) = super::engine_replacement::apply_pending_post_replacement_effect( + state, + Some(entry.id), + None, + Some(crate::types::replacements::ReplacementEvent::Moved), + events, + ) { + match wf { + // CR 608.3c + CR 614.12a: stash the Aura spell's chosen + // host for the copy-choice answer path, then surface the + // prompt. Continue the cast-variant epilogue (same as + // Tribute NamedChoice) so resolve_top settles normally; + // the answer path still prefers spell_targets. + WaitingFor::CopyTargetChoice { .. } => { + state.push_spell_resolution(pending_spell_resolution_snapshot( + state, + &entry, + ability.as_ref(), + casting_variant, + actual_mana_spent, + &spell_targets, + )); + state.waiting_for = wf; + } + WaitingFor::Priority { .. } => {} + other => { + // Tribute / Siege NamedChoice — surface the prompt + // and continue the caller epilogue. Do not push + // SpellResolution on top of an AbilityContinuation. + state.waiting_for = other; + } + } + } + } + // CR 702.185a: Warp — when a permanent cast via Warp resolves to the battlefield, // create a delayed trigger to exile it at end step with WarpExile permission. // Only triggers on the initial Warp cast (CastingVariant::Warp), NOT on re-casts @@ -2194,6 +2293,7 @@ fn self_counter_ability_is_batch_candidate(ability: &ResolvedAbility) -> bool { source_incarnation, trigger_source, trigger_definition_ref, + force_block_attacker: _, controller: _, original_controller, scoped_player, @@ -2234,6 +2334,7 @@ fn self_counter_ability_is_batch_candidate(ability: &ResolvedAbility) -> bool { repeat_until, replacement_applied: _, sub_link, + sibling_condition, modal, mode_abilities, parent_target_missing_reason, @@ -2299,6 +2400,11 @@ fn self_counter_ability_is_batch_candidate(ability: &ResolvedAbility) -> bool { && chosen_players.is_empty() && repeat_until.is_none() && *sub_link == SubAbilityLink::ContinuationStep + // CR 702.1c ("the same is true") + CR 608.2c (written order): a + // `ReplicatedOrBranch` per-item keyword-list sibling (Mutable Pupa, + // Kathril) is not the vanilla batchable shape this proof + // covers — its independent OR-branch gate must be evaluated per entry. + && *sibling_condition == SiblingCondition::Dependent && modal.is_none() && mode_abilities.is_empty() && parent_target_missing_reason.is_none() @@ -2389,6 +2495,7 @@ fn fixed_controller_gain_life_ability_is_batch_candidate(ability: &ResolvedAbili source_incarnation: _, trigger_source: _, trigger_definition_ref: _, + force_block_attacker: _, controller: _, original_controller: _, scoped_player, @@ -2429,6 +2536,7 @@ fn fixed_controller_gain_life_ability_is_batch_candidate(ability: &ResolvedAbili repeat_until, replacement_applied: _, sub_link, + sibling_condition, modal, mode_abilities, parent_target_missing_reason, @@ -2477,6 +2585,11 @@ fn fixed_controller_gain_life_ability_is_batch_candidate(ability: &ResolvedAbili && chosen_players.is_empty() && repeat_until.is_none() && *sub_link == SubAbilityLink::ContinuationStep + // CR 702.1c ("the same is true") + CR 608.2c (written order): a + // `ReplicatedOrBranch` per-item keyword-list sibling (Mutable Pupa, + // Kathril) is not the vanilla batchable shape this proof + // covers — its independent OR-branch gate must be evaluated per entry. + && *sibling_condition == SiblingCondition::Dependent && modal.is_none() && mode_abilities.is_empty() && parent_target_missing_reason.is_none() @@ -2569,6 +2682,7 @@ fn fixed_opponent_lose_life_ability_is_batch_candidate(ability: &ResolvedAbility source_incarnation: _, trigger_source: _, trigger_definition_ref: _, + force_block_attacker: _, controller: _, original_controller: _, scoped_player, @@ -2609,6 +2723,7 @@ fn fixed_opponent_lose_life_ability_is_batch_candidate(ability: &ResolvedAbility repeat_until, replacement_applied: _, sub_link, + sibling_condition, modal, mode_abilities, parent_target_missing_reason, @@ -2657,6 +2772,11 @@ fn fixed_opponent_lose_life_ability_is_batch_candidate(ability: &ResolvedAbility && chosen_players.is_empty() && repeat_until.is_none() && *sub_link == SubAbilityLink::ContinuationStep + // CR 702.1c ("the same is true") + CR 608.2c (written order): a + // `ReplicatedOrBranch` per-item keyword-list sibling (Mutable Pupa, + // Kathril) is not the vanilla batchable shape this proof + // covers — its independent OR-branch gate must be evaluated per entry. + && *sibling_condition == SiblingCondition::Dependent && modal.is_none() && mode_abilities.is_empty() && parent_target_missing_reason.is_none() @@ -3174,6 +3294,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( source_incarnation: _, trigger_source: _, trigger_definition_ref: _, + force_block_attacker: a_force_block_attacker, controller: a_controller, original_controller: _, scoped_player: a_scoped_player, @@ -3214,6 +3335,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( repeat_until: a_repeat_until, replacement_applied: a_replacement_applied, sub_link: a_sub_link, + sibling_condition: a_sibling_condition, modal: a_modal, mode_abilities: a_mode_abilities, parent_target_missing_reason: a_parent_target_missing_reason, @@ -3225,6 +3347,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( source_incarnation: _, trigger_source: _, trigger_definition_ref: _, + force_block_attacker: b_force_block_attacker, controller: b_controller, original_controller: _, scoped_player: b_scoped_player, @@ -3265,6 +3388,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( repeat_until: b_repeat_until, replacement_applied: b_replacement_applied, sub_link: b_sub_link, + sibling_condition: b_sibling_condition, modal: b_modal, mode_abilities: b_mode_abilities, parent_target_missing_reason: b_parent_target_missing_reason, @@ -3272,6 +3396,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( a_effect == b_effect && a_targets == b_targets + && a_force_block_attacker == b_force_block_attacker && a_controller == b_controller && a_scoped_player == b_scoped_player && a_kind == b_kind @@ -3320,6 +3445,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( && a_repeat_until == b_repeat_until && a_replacement_applied == b_replacement_applied && a_sub_link == b_sub_link + && a_sibling_condition == b_sibling_condition && a_modal == b_modal && a_mode_abilities == b_mode_abilities && a_parent_target_missing_reason == b_parent_target_missing_reason diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs index e5d5a92aaf..92f3c80cdd 100644 --- a/crates/engine/src/game/targeting.rs +++ b/crates/engine/src/game/targeting.rs @@ -599,6 +599,11 @@ pub fn resolve_event_context_target( .first() .copied() .map(TargetRef::Object), + TargetFilter::LastZoneChanged => state + .last_zone_changed_ids + .first() + .copied() + .map(TargetRef::Object), TargetFilter::DefendingPlayer | TargetFilter::AttachedTo | TargetFilter::PostReplacementSourceController @@ -647,6 +652,13 @@ pub fn resolve_event_context_targets( .map(|id| TargetRef::Object(*id)) .collect(); } + TargetFilter::LastZoneChanged => { + return state + .last_zone_changed_ids + .iter() + .map(|id| TargetRef::Object(*id)) + .collect(); + } _ => {} } @@ -773,6 +785,14 @@ pub fn resolved_targets( .map(TargetRef::Object) .collect(); } + if matches!(target_filter, TargetFilter::LastZoneChanged) { + return state + .last_zone_changed_ids + .iter() + .copied() + .map(TargetRef::Object) + .collect(); + } if matches!(target_filter, TargetFilter::ParentTarget) && ability.targets.is_empty() { if let Some(targets) = parent_target_refs_from_attack_trigger_context(state) { return targets; @@ -987,6 +1007,7 @@ pub(crate) fn resolved_object_ids_for_filter( .collect(), TargetFilter::LastCreated => state.last_created_token_ids.clone(), TargetFilter::LastRevealed => state.last_revealed_ids.clone(), + TargetFilter::LastZoneChanged => state.last_zone_changed_ids.clone(), TargetFilter::TriggeringSource | TargetFilter::EventTarget | TargetFilter::AttachedTo => { resolve_event_context_target(state, filter, ability.source_id) .and_then(|target| target_ref_object(&target)) @@ -1462,6 +1483,8 @@ pub(crate) fn extract_source_from_event( } => Some(*object_id), GameEvent::Discarded { object_id, .. } => Some(*object_id), GameEvent::Transformed { object_id } => Some(*object_id), + // CR 710.4: the flipped permanent is the event's subject. + GameEvent::Flipped { object_id } => Some(*object_id), GameEvent::TurnedFaceUp { object_id } => Some(*object_id), GameEvent::TurnedFaceDown { object_id } => Some(*object_id), GameEvent::Cycled { object_id, .. } => Some(*object_id), diff --git a/crates/engine/src/game/transform.rs b/crates/engine/src/game/transform.rs index e6a60e67ef..c6a8d11ec8 100644 --- a/crates/engine/src/game/transform.rs +++ b/crates/engine/src/game/transform.rs @@ -49,6 +49,22 @@ pub fn transform_permanent( return Ok(()); } + // CR 701.27a + CR 701.27c: only permanents represented by double-faced + // tokens and double-faced cards can transform; if a spell or ability + // instructs a player to transform anything else, nothing happens. A CR 710 + // flip card is a SINGLE-faced card whose alternative characteristics are + // reached by flipping (CR 710.4), never by transforming — and applying the + // double-faced applicator to one would swap the mana cost and color that + // CR 710.1c holds fixed. `flip::is_flip_permanent` is the single authority: + // `flip::stash_flip_face` re-stamps `LayoutKind::Flip` on WHICHEVER half is + // parked in `back_face`, so the tag survives a flip, a zone exit (which + // reverts the flip and re-stashes the alternative half), and a later return + // to the battlefield. The `flipped` status is kept as a redundant second + // arm so a stash that some other path zeroed still cannot be transformed. + if obj.flipped || crate::game::flip::is_flip_permanent(obj) { + return Ok(()); + } + let back_face = obj .back_face .clone() @@ -90,15 +106,19 @@ pub fn transform_permanent( /// CR 712.16 + CR 730.2j: True when `obj` is a double-faced permanent /// (transform/modal/meld DFC) or a melded permanent — none of which can be -/// turned face down. Used by `effects::turn_face_down` to enforce the no-op. +/// turned face down. Used by `effects::turn_face_down` to enforce the no-op, +/// and by presentation adapters that need the engine's authoritative +/// "is this permanent double-faced?" answer instead of re-deriving one. /// /// Keys on the typed layout/merge discriminants rather than `back_face.is_some()` /// so that single-faced layouts that may legally be turned face down — Adventure, /// Omen, Split, Flip — are NOT blocked (they carry no Transform/Modal/Meld -/// `layout_kind`). A DFC currently showing its back face is caught by the +/// `layout_kind`). CR 710 flip cards in particular put their alternative half in +/// the same `back_face` slot, so `back_face.is_some()` would report all 21 of +/// them as double-faced. A DFC currently showing its back face is caught by the /// `transformed` flag, because `snapshot_object_face` zeroes `layout_kind` when /// the front face is stashed in `back_face` during a transform. -pub(crate) fn is_double_faced_permanent(obj: &crate::game::game_object::GameObject) -> bool { +pub fn is_double_faced_permanent(obj: &crate::game::game_object::GameObject) -> bool { use crate::types::card::LayoutKind; // CR 730.2j: a face-up melded permanent contains a double-faced component. if obj.merge_kind == Some(crate::game::game_object::MergeKind::Meld) { diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index da611f19f8..7c612dc48f 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -611,6 +611,15 @@ pub(crate) fn keys_from_event(event: &GameEvent, state: &GameState) -> Keys { | GameEvent::TurnedFaceDown { .. } => { push(TriggerEventKey::FaceOrTransform); } + // CR 701.27b (by analogy): transforming and turning a permanent face + // up/down are distinct game actions that don't share triggers even + // though they use the same physical action; flipping is likewise its + // own game action. No printed flip card has a trigger that fires on + // flipping (a design fact about the card pool, not a CR statement). + // Deliberately dispatches NO trigger key — folding it into + // `FaceOrTransform` would consult transform/face-change triggers for an + // event none of them can match. + GameEvent::Flipped { .. } => {} GameEvent::DayNightChanged { .. } => push(TriggerEventKey::DayNightChanged), GameEvent::CardsRevealed { .. } => push(TriggerEventKey::Revealed), GameEvent::CrimeCommitted { .. } => push(TriggerEventKey::PlayerActionPerformed), @@ -805,6 +814,7 @@ fn keys_from_effect_kind(kind: EffectKind, push: &mut impl FnMut(TriggerEventKey | EffectKind::ExileTop | EffectKind::TargetOnly | EffectKind::Choose + | EffectKind::ChoosePermanent | EffectKind::OpponentGuess | EffectKind::ChooseDamageSource | EffectKind::Suspect @@ -931,6 +941,12 @@ fn keys_from_effect_kind(kind: EffectKind, push: &mut impl FnMut(TriggerEventKey // action; its own `EffectResolved` dispatches no trigger key. | EffectKind::BecomeSaddled | EffectKind::Transform + // No printed flip card has a trigger that fires on flipping (a design + // fact about the card pool, not a CR statement), so — mirroring + // `Transform` above — this effect's `EffectResolved` dispatches no key; + // `GameEvent::Flipped` is a log/display notification and dispatches no + // key either. + | EffectKind::FlipPermanent | EffectKind::TurnFaceUp // CR 701.27b: a turned-face-down permanent fires any face-down trigger // via the dedicated `GameEvent::TurnedFaceDown`, not via this effect's diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index 84044d229e..74f17c107d 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -835,6 +835,7 @@ pub(super) fn target_filter_matches_object( | TargetFilter::AttachedTo | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } @@ -993,6 +994,11 @@ fn count_matching_trigger_event_subjects( | GameEvent::Saddled { .. } | GameEvent::ReplacementApplied { .. } | GameEvent::Transformed { .. } + // No printed flip card has a trigger that fires on flipping (a design + // fact about the card pool, not a CR statement), so — like `Transformed` + // above — this event carries no per-object trigger subject in this + // generic helper. + | GameEvent::Flipped { .. } | GameEvent::Specialized { .. } | GameEvent::DayNightChanged { .. } | GameEvent::TurnedFaceUp { .. } diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index c5f13f0925..5d7c78beec 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -395,6 +395,45 @@ fn matching_batched_trigger_events( .collect() } +/// CR 508.1 + CR 603.2: Split an attack declaration into the singleton event +/// contexts required by an event-referential attacker demonstrative. A plural +/// declaration has no single object for "that Wolf" to bind. +fn singleton_attack_events( + defending_player: PlayerId, + attacks: Vec<(ObjectId, crate::game::combat::AttackTarget)>, +) -> Vec { + attacks + .into_iter() + .map(|(attacker, target)| GameEvent::AttackersDeclared { + attacker_ids: vec![attacker], + defending_player, + attacks: vec![(attacker, target)], + }) + .collect() +} + +fn split_attack_event_into_singletons(event: &GameEvent) -> Option> { + let GameEvent::AttackersDeclared { + defending_player, + attacker_ids, + attacks, + } = event + else { + return None; + }; + let matching_attacks = attacker_ids + .iter() + .map(|attacker| { + let target = attacks + .iter() + .find_map(|(attacker_id, target)| (*attacker_id == *attacker).then_some(*target)) + .unwrap_or(crate::game::combat::AttackTarget::Player(*defending_player)); + (*attacker, target) + }) + .collect(); + Some(singleton_attack_events(*defending_player, matching_attacks)) +} + fn contextual_batched_trigger_event( state: &GameState, event: &GameEvent, @@ -1440,6 +1479,24 @@ fn collect_matching_triggers_inner( .into_iter() .map(|trigger_event| vec![trigger_event]) .collect() + } else if matches!(trig_def.mode, TriggerMode::YouAttack) + && ability.has_event_source_force_block_recursive() + { + let matching = super::trigger_matchers::matching_you_attack_pairs( + event, + trig_def, + &source_context, + state, + ); + match event { + GameEvent::AttackersDeclared { + defending_player, .. + } => singleton_attack_events(*defending_player, matching), + _ => Vec::new(), + } + .into_iter() + .map(|trigger_event| vec![trigger_event]) + .collect() } else if matches!(trig_def.mode, TriggerMode::Blocks) { super::trigger_matchers::matching_block_events( event, @@ -1499,6 +1556,16 @@ fn collect_matching_triggers_inner( .into_iter() .map(|trigger_event| vec![trigger_event]) .collect() + } else if ability.has_event_source_force_block_recursive() { + // CR 603.2 + CR 608.2c: every event-source force-block branch + // owns one unambiguous attacker. The ordinary attack modes + // above narrow by their own matcher; this fallback preserves + // singleton identity for any additional attack trigger mode. + split_attack_event_into_singletons(event) + .unwrap_or_else(|| vec![event.clone()]) + .into_iter() + .map(|trigger_event| vec![trigger_event]) + .collect() } else { vec![vec![event.clone()]] }; @@ -5920,6 +5987,25 @@ pub(crate) fn take_pending_trigger_event_batch( } } +/// CR 603.3d + CR 509.1c: Extract the one unambiguous attacker named by a +/// singleton attack trigger. Trigger construction completes before priority, so +/// this reads the same live incarnation that the stack-push binding observed. +fn event_attacker_from_trigger_event( + state: &GameState, + trigger_event: Option<&GameEvent>, +) -> Option { + let GameEvent::AttackersDeclared { attacker_ids, .. } = trigger_event? else { + return None; + }; + let [attacker_id] = attacker_ids.as_slice() else { + return None; + }; + state + .objects + .get(attacker_id) + .map(ObjectIncarnationRef::from_object) +} + /// CR 603.3 + CR 603.3c + CR 603.3d: Push a pending trigger to the stack with /// its event batch keyed by entry id. Returns the new entry's `ObjectId` so /// callers can stash it in `state.pending_trigger_entry` when the entry is @@ -6026,6 +6112,11 @@ pub(crate) fn push_pending_trigger_to_stack_with_event_batch( if let Some(origin) = may_trigger_origin { ability.set_may_trigger_origin_recursive(origin); } + // CR 400.7 + CR 509.1c: Bind either grammatical named-attacker form at + // stack creation. In particular, "that Wolf" is the exact object from the + // narrowed attack-trigger event, not the triggered ability's source. + let event_attacker = event_attacker_from_trigger_event(state, trigger_event.as_ref()); + ability.bind_force_block_attacker_recursive(event_attacker); seed_batched_attack_parent_targets(&mut ability, trigger_event.as_ref()); seed_event_context_parent_targets(&mut ability, trigger_event.as_ref()); @@ -6193,6 +6284,25 @@ fn assign_pending_trigger_entry_ability( entry_id: ObjectId, source_ability: &ResolvedAbility, ) -> bool { + let trigger_event = state + .stack + .iter() + .rev() + .find(|entry| entry.id == entry_id) + .and_then(|entry| match &entry.kind { + StackEntryKind::TriggeredAbility { trigger_event, .. } => trigger_event.as_ref(), + _ => None, + }); + let mut assigned_ability = source_ability.clone(); + // CR 603.3d: Target/mode construction replaces the provisional stack + // ability before any player receives priority. Rebind event-referential + // force-blocks here so that replacement cannot discard the stack-time + // identity for "that Wolf". + assigned_ability.bind_force_block_attacker_recursive(event_attacker_from_trigger_event( + state, + trigger_event, + )); + let Some(entry) = state .stack .iter_mut() @@ -6204,7 +6314,7 @@ fn assign_pending_trigger_entry_ability( let ability = entry .ability_mut() .expect("pending_trigger_entry must reference a TriggeredAbility stack entry"); - *ability = source_ability.clone(); + *ability = assigned_ability; true } @@ -8107,6 +8217,16 @@ fn evaluate_trigger_condition_with_source( TriggerCondition::SourceEnteredThisTurn => source_context.is_some_and(|source| { source.source_read(state).entered_battlefield_turn() == Some(state.turn_number) }), + // CR 400.7 + CR 508.1 + CR 603.4: the condition is tied to the + // trigger source's observed incarnation, not its storage id. It is + // checked both when the trigger is created and when it resolves. + TriggerCondition::SourceAttackedThisCombat => source_context.is_some_and(|source| { + state.combat.as_ref().is_some_and(|combat| { + combat + .attacking_incarnations_this_combat + .contains(&source.identity.reference) + }) + }), TriggerCondition::EchoDue => { source_context.is_some_and(|source| source.source_read(state).echo_due()) } @@ -10049,6 +10169,11 @@ pub(super) fn build_triggered_ability_from_context( resolved.set_scoped_player_recursive(state.active_player); } resolved.set_trigger_source_recursive(source_context.clone()); + // CR 400.7 + CR 509.1c: Source-referential force-block instructions + // latch their source incarnation as soon as the triggered ability is + // instantiated. EventSource remains intentionally unbound until the + // individual attack event is attached below. + resolved.bind_force_block_attacker_recursive(None); if let Some(definition_ref) = definition_ref { resolved.set_trigger_definition_ref_recursive(definition_ref.clone()); } @@ -12083,6 +12208,170 @@ pub mod tests { ); } + /// CR 603.3 + CR 508.1 + CR 509.1c: Tolsimir's source-referential + /// intervening-if and its event-referential "that Wolf" are different + /// identities. The parsed trigger must bind the attacking Wolf at stack + /// creation and force the chosen opposing creature to block that Wolf, not + /// Tolsimir. If Tolsimir did not attack, no trigger is created. + #[test] + fn tolsimir_attack_trigger_runs_from_parser_through_stack_to_block_legality() { + use crate::game::combat::{validate_blockers, AttackTarget, AttackerInfo, CombatState}; + use crate::types::actions::GameAction; + + const ORACLE: &str = "Whenever a Wolf you control attacks, if Tolsimir, Midnight's Light attacked this combat, target creature an opponent controls blocks that Wolf this combat if able."; + + let parsed = + crate::parser::oracle_trigger::parse_trigger_line(ORACLE, "Tolsimir, Midnight's Light"); + + let mut no_tolsimir_attack = setup(); + let inactive_tolsimir = make_creature( + &mut no_tolsimir_attack, + PlayerId(0), + "Tolsimir, Midnight's Light", + 3, + 4, + ); + let inactive_wolf = make_creature(&mut no_tolsimir_attack, PlayerId(0), "Wolf", 2, 2); + { + let wolf = no_tolsimir_attack + .objects + .get_mut(&inactive_wolf) + .expect("wolf exists"); + wolf.card_types.subtypes.push("Wolf".to_string()); + wolf.base_card_types = wolf.card_types.clone(); + } + let inactive = no_tolsimir_attack + .objects + .get_mut(&inactive_tolsimir) + .expect("Tolsimir exists"); + inactive.trigger_definitions.push(parsed.clone()); + std::sync::Arc::make_mut(&mut inactive.base_trigger_definitions).push(parsed.clone()); + no_tolsimir_attack.layers_dirty.mark_full(); + no_tolsimir_attack.combat = Some(CombatState { + attackers: vec![AttackerInfo::new( + inactive_wolf, + AttackTarget::Player(PlayerId(1)), + PlayerId(1), + )], + ..CombatState::default() + }); + let inactive_event = GameEvent::AttackersDeclared { + attacker_ids: vec![inactive_wolf], + defending_player: PlayerId(1), + attacks: vec![(inactive_wolf, AttackTarget::Player(PlayerId(1)))], + }; + assert!( + collect_pending_triggers(&mut no_tolsimir_attack, &[inactive_event]).is_empty(), + "the intervening-if prevents the trigger when Tolsimir did not attack" + ); + + let mut state = setup(); + let tolsimir = make_creature(&mut state, PlayerId(0), "Tolsimir, Midnight's Light", 3, 4); + let wolf = make_creature(&mut state, PlayerId(0), "First Wolf", 2, 2); + let second_wolf = make_creature(&mut state, PlayerId(0), "Second Wolf", 2, 2); + let blocker = make_creature(&mut state, PlayerId(1), "Opponent Bear", 2, 2); + let second_blocker = make_creature(&mut state, PlayerId(1), "Opponent Elk", 2, 2); + for wolf_id in [wolf, second_wolf] { + let wolf = state.objects.get_mut(&wolf_id).expect("wolf exists"); + wolf.card_types.subtypes.push("Wolf".to_string()); + wolf.base_card_types = wolf.card_types.clone(); + } + let source = state.objects.get_mut(&tolsimir).expect("Tolsimir exists"); + source.trigger_definitions.push(parsed.clone()); + std::sync::Arc::make_mut(&mut source.base_trigger_definitions).push(parsed); + state.layers_dirty.mark_full(); + let attacking_incarnations_this_combat = [tolsimir, wolf, second_wolf] + .into_iter() + .map(|id| ObjectIncarnationRef::from_object(&state.objects[&id])) + .collect(); + state.combat = Some(CombatState { + attackers: vec![ + AttackerInfo::new(tolsimir, AttackTarget::Player(PlayerId(1)), PlayerId(1)), + AttackerInfo::new(wolf, AttackTarget::Player(PlayerId(1)), PlayerId(1)), + AttackerInfo::new(second_wolf, AttackTarget::Player(PlayerId(1)), PlayerId(1)), + ], + attacking_incarnations_this_combat, + ..CombatState::default() + }); + let attack_event = GameEvent::AttackersDeclared { + attacker_ids: vec![wolf, second_wolf], + defending_player: PlayerId(1), + attacks: vec![ + (wolf, AttackTarget::Player(PlayerId(1))), + (second_wolf, AttackTarget::Player(PlayerId(1))), + ], + }; + assert!( + check_trigger_condition( + &state, + &TriggerCondition::SourceAttackedThisCombat, + PlayerId(0), + Some(tolsimir), + Some(&attack_event), + ), + "Tolsimir's observed incarnation attacked during this combat" + ); + let pending = collect_pending_triggers(&mut state, std::slice::from_ref(&attack_event)); + assert_eq!( + pending.len(), + 2, + "each matching Wolf receives an individual event-bound trigger" + ); + for (attacker, target) in [(wolf, blocker), (second_wolf, second_blocker)] { + let singleton_event = GameEvent::AttackersDeclared { + attacker_ids: vec![attacker], + defending_player: PlayerId(1), + attacks: vec![(attacker, AttackTarget::Player(PlayerId(1)))], + }; + process_triggers(&mut state, &[singleton_event]); + let waiting = crate::game::engine::begin_pending_trigger_target_selection(&mut state) + .expect("target selection setup must succeed") + .expect("Tolsimir trigger must request its target"); + let WaitingFor::TriggerTargetSelection { target_slots, .. } = &waiting else { + panic!("expected trigger target selection, got {waiting:?}"); + }; + assert_eq!( + target_slots.len(), + 1, + "the parsed target survives to stack setup" + ); + assert!( + target_slots[0] + .legal_targets + .contains(&TargetRef::Object(target)), + "production target selection must expose the chosen opposing creature" + ); + state.waiting_for = waiting; + crate::game::engine::apply( + &mut state, + PlayerId(0), + GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }, + ) + .expect("production target choice must succeed"); + } + let mut resolution_events = Vec::new(); + crate::game::stack::resolve_top(&mut state, &mut resolution_events); + crate::game::stack::resolve_top(&mut state, &mut resolution_events); + // CR 613.1: ForceBlock installs a transient continuous effect; blocker + // validation reads its layer-applied static definition. + crate::game::layers::flush_layers(&mut state); + + assert!( + validate_blockers(&state, &[]).is_err(), + "the selected creature must block the event Wolf if able" + ); + assert!( + validate_blockers(&state, &[(blocker, wolf), (second_blocker, second_wolf)]).is_ok(), + "each selected creature must block its own event Wolf" + ); + assert!( + validate_blockers(&state, &[(blocker, second_wolf), (second_blocker, wolf)]).is_err(), + "the two event-attacker bindings cannot cross" + ); + } + /// Places a battlefield commander object with the given owner/controller. fn make_commander(state: &mut GameState, owner: PlayerId, controller: PlayerId) -> ObjectId { let id = make_creature(state, owner, "Test Commander", 3, 3); @@ -27592,6 +27881,7 @@ pub mod tests { timestamp: 1, duration: Duration::Permanent, affected: TargetFilter::SpecificObject { id: recipient }, + affected_recipient: None, modifications: vec![ContinuousModification::AddKeyword { keyword: Keyword::Suspend { count: 0, @@ -27687,6 +27977,7 @@ pub mod tests { affected: TargetFilter::SpecificObject { id: exile_recipient, }, + affected_recipient: None, modifications: vec![ContinuousModification::AddKeyword { keyword: Keyword::Suspend { count: 0, @@ -27827,6 +28118,7 @@ pub mod tests { timestamp: 1 + i as u64, duration: Duration::Permanent, affected: TargetFilter::SpecificObject { id: card }, + affected_recipient: None, modifications: vec![ContinuousModification::AddKeyword { keyword: Keyword::Suspend { count: 0, @@ -28658,7 +28950,7 @@ pub mod tests { | Keyword::LivingMetal | Keyword::Bloodthirst(_) | Keyword::Amplify(_) - | Keyword::Devour(_) + | Keyword::Devour { .. } | Keyword::Toxic(_) | Keyword::Saddle(_) | Keyword::Teamwork(_) diff --git a/crates/engine/src/game/triggers_devour_runtime_tests.rs b/crates/engine/src/game/triggers_devour_runtime_tests.rs index 50113f23a7..a213a48212 100644 --- a/crates/engine/src/game/triggers_devour_runtime_tests.rs +++ b/crates/engine/src/game/triggers_devour_runtime_tests.rs @@ -16,7 +16,7 @@ use crate::database::synthesis::synthesize_all; use crate::game::printed_cards::apply_card_face_to_object; use crate::game::zones::{create_object, move_to_zone}; -use crate::types::ability::{EffectKind, PtValue, TargetFilter}; +use crate::types::ability::{EffectKind, PtValue, TargetFilter, TypeFilter}; use crate::types::actions::GameAction; use crate::types::card::CardFace; use crate::types::card_type::CoreType; @@ -36,7 +36,25 @@ fn devour_face(name: &str, n: u32) -> CardFace { name: name.to_string(), power: Some(PtValue::Fixed(3)), toughness: Some(PtValue::Fixed(3)), - keywords: vec![Keyword::Devour(n)], + keywords: vec![Keyword::Devour { + n, + quality: TypeFilter::Creature, + }], + ..CardFace::default() + }; + face.card_type.core_types.push(CoreType::Creature); + synthesize_all(&mut face); + face +} + +/// Build a creature face carrying `Keyword::Devour { n, quality }` (CR 702.82c) +/// and run the full synthesis pipeline. +fn devour_face_q(name: &str, n: u32, quality: TypeFilter) -> CardFace { + let mut face = CardFace { + name: name.to_string(), + power: Some(PtValue::Fixed(3)), + toughness: Some(PtValue::Fixed(3)), + keywords: vec![Keyword::Devour { n, quality }], ..CardFace::default() }; face.card_type.core_types.push(CoreType::Creature); @@ -74,6 +92,117 @@ fn battlefield_creature(state: &mut GameState, controller: PlayerId, name: &str) id } +/// Place a basic Forest (a Land) on the battlefield under `controller`. +fn battlefield_land(state: &mut GameState, controller: PlayerId, name: &str) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + controller, + name.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + obj.card_types.subtypes.push("Forest".to_string()); + obj.base_card_types = obj.card_types.clone(); + id +} + +/// Place an artifact (optionally carrying `subtypes`, e.g. "Food") on the +/// battlefield under `controller`. +fn battlefield_artifact( + state: &mut GameState, + controller: PlayerId, + name: &str, + subtypes: &[&str], +) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + controller, + name.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + for s in subtypes { + obj.card_types.subtypes.push((*s).to_string()); + } + obj.base_card_types = obj.card_types.clone(); + id +} + +/// Drive a Devour creature's Hand→Battlefield ZoneChange through the replacement +/// pipeline after `setup` populates the battlefield, then drain the +/// post-replacement continuation. Mirrors `drive_devour_etb_to_sacrifice_choice` +/// but hands the caller full control of the battlefield (mixed land/creature +/// pools) so the quality axis (CR 702.82c) is observable. +fn drive_devour_etb_with_battlefield( + face: &CardFace, + controller: PlayerId, + setup: impl FnOnce(&mut GameState), +) -> (GameState, ObjectId) { + assert!( + face.replacements + .iter() + .any(|r| matches!(r.event, ReplacementEvent::Moved) + && matches!(r.valid_card, Some(TargetFilter::SelfRef))), + "test fixture must carry a synthesized Devour ETB replacement; got {:?}", + face.replacements + ); + + let mut state = setup_state_with_priority(controller); + setup(&mut state); + + let next_card = CardId(state.next_object_id); + let obj_id = create_object( + &mut state, + next_card, + controller, + face.name.clone(), + Zone::Hand, + ); + { + let obj = state.objects.get_mut(&obj_id).unwrap(); + apply_card_face_to_object(obj, face); + } + + let proposed = crate::types::proposed_event::ProposedEvent::zone_change( + obj_id, + Zone::Hand, + Zone::Battlefield, + None, + ); + let mut events = Vec::new(); + let result = crate::game::replacement::replace_event(&mut state, proposed, &mut events); + let crate::game::replacement::ReplacementResult::Execute(event) = result else { + panic!("Devour ETB pipeline must return Execute, got {result:?}"); + }; + let crate::types::proposed_event::ProposedEvent::ZoneChange { object_id, to, .. } = event + else { + panic!("pipeline must yield a ZoneChange execute event"); + }; + move_to_zone(&mut state, object_id, to, &mut events); + + assert!( + state.has_post_replacement_drain(), + "Devour's non-modifier execute (Effect::Sacrifice) must be stashed as a \ + post-replacement continuation" + ); + state.clear_post_replacement_source(); + let _ = crate::game::engine_replacement::apply_pending_post_replacement_effect( + &mut state, + Some(obj_id), + None, + Some(ReplacementEvent::Moved), + &mut events, + ); + + (state, obj_id) +} + fn p1p1(state: &GameState, id: ObjectId) -> u32 { state .objects @@ -296,3 +425,252 @@ fn devour_2_one_sacrifice_places_two_counters() { "Devour 2 + one creature sacrificed → 2 +1/+1 counters (N per sacrifice)" ); } + +/// P (PRIMARY, the reported bug — Famished Worldsire "Devour land 3", CR 702.82c): +/// the ETB sacrifice pool is the controller's LANDS; a co-present creature is +/// EXCLUDED. Sacrificing 2 lands to Devour 3 places 3×2 = 6 +1/+1 counters. +/// +/// Revert-sensitive: if the quality drops to the CR 702.82a creature default, the +/// pool would offer the creature (not the lands) and this test fails on the pool +/// membership assertions. +#[test] +fn devour_land_3_sacrifices_lands_not_creatures() { + let face = devour_face_q("Famished Worldsire", 3, TypeFilter::Land); + + let mut land_ids = Vec::new(); + let mut creature_id = ObjectId(0); + let (mut state, devour) = drive_devour_etb_with_battlefield(&face, PlayerId(0), |state| { + land_ids.push(battlefield_land(state, PlayerId(0), "Forest 1")); + land_ids.push(battlefield_land(state, PlayerId(0), "Forest 2")); + creature_id = battlefield_creature(state, PlayerId(0), "Bystander Bear"); + }); + + let WaitingFor::EffectZoneChoice { + cards, effect_kind, .. + } = &state.waiting_for + else { + panic!( + "expected the Devour land sacrifice choice, got {:?}", + state.waiting_for + ); + }; + assert_eq!(*effect_kind, EffectKind::Sacrifice); + for land in &land_ids { + assert!( + cards.contains(land), + "CR 702.82c: each controlled land must be an eligible Devour-land sacrifice; pool={cards:?}" + ); + } + assert!( + !cards.contains(&creature_id), + "CR 702.82c: a creature must NOT be offered to a Devour-land sacrifice; pool={cards:?}" + ); + + crate::game::engine::apply_as_current( + &mut state, + GameAction::SelectCards { + cards: land_ids.clone(), + }, + ) + .unwrap(); + + assert_eq!( + state.objects.get(&devour).unwrap().zone, + Zone::Battlefield, + "the Devour-land creature enters the battlefield" + ); + assert_eq!( + p1p1(&state, devour), + 6, + "Devour 3 + two lands sacrificed → 3×2 = 6 +1/+1 counters (CR 702.82c counter math)" + ); + assert_eq!( + state.objects.get(&creature_id).unwrap().zone, + Zone::Battlefield, + "the bystander creature was never eligible and survives" + ); +} + +/// C (CONTROL, CR 702.82a default preserved): a plain "Devour 2" creature offers +/// its CREATURES and excludes lands. One sacrifice → 2 counters. Proves the +/// creature default survives the parameterization. +#[test] +fn devour_creature_default_excludes_lands() { + let face = devour_face_q("Mycoloth", 2, TypeFilter::Creature); + + let mut creature_ids = Vec::new(); + let mut land_id = ObjectId(0); + let (mut state, devour) = drive_devour_etb_with_battlefield(&face, PlayerId(0), |state| { + creature_ids.push(battlefield_creature(state, PlayerId(0), "Fodder A")); + creature_ids.push(battlefield_creature(state, PlayerId(0), "Fodder B")); + land_id = battlefield_land(state, PlayerId(0), "Idle Forest"); + }); + + let WaitingFor::EffectZoneChoice { cards, .. } = &state.waiting_for else { + panic!( + "expected the Devour creature sacrifice choice, got {:?}", + state.waiting_for + ); + }; + for creature in &creature_ids { + assert!( + cards.contains(creature), + "creatures are eligible; pool={cards:?}" + ); + } + assert!( + !cards.contains(&land_id), + "CR 702.82a: a land must NOT be offered to a plain Devour sacrifice; pool={cards:?}" + ); + + let one = vec![creature_ids[0]]; + crate::game::engine::apply_as_current(&mut state, GameAction::SelectCards { cards: one }) + .unwrap(); + assert_eq!( + p1p1(&state, devour), + 2, + "Devour 2 + one creature → 2 counters (creature default intact)" + ); +} + +/// B (BOUNDARY, CR 702.82a "may sacrifice"): Devour land 3 with ZERO controlled +/// lands (only creatures present) → no eligible land, so the creature still +/// enters with 0 counters and no land is consumed. +#[test] +fn devour_land_3_with_no_lands_enters_with_zero_counters() { + let face = devour_face_q("Famished Worldsire", 3, TypeFilter::Land); + + let mut creature_id = ObjectId(0); + let (mut state, devour) = drive_devour_etb_with_battlefield(&face, PlayerId(0), |state| { + creature_id = battlefield_creature(state, PlayerId(0), "Non-Land Bear"); + }); + + // With an empty eligible land pool and min_count 0, a ranged sacrifice may + // either auto-resolve or surface an empty prompt; either way no creature is + // offered and the empty choice is declined. + if let WaitingFor::EffectZoneChoice { cards, .. } = &state.waiting_for { + assert!( + !cards.contains(&creature_id), + "CR 702.82c: a creature is never a legal Devour-land sacrifice; pool={cards:?}" + ); + crate::game::engine::apply_as_current( + &mut state, + GameAction::SelectCards { cards: vec![] }, + ) + .unwrap(); + } + + assert_eq!( + state.objects.get(&devour).unwrap().zone, + Zone::Battlefield, + "CR 702.82a: the creature still enters when no land can be sacrificed" + ); + assert_eq!( + p1p1(&state, devour), + 0, + "no land sacrificed → 0 +1/+1 counters" + ); + assert_eq!( + state.objects.get(&creature_id).unwrap().zone, + Zone::Battlefield, + "the bystander creature is untouched" + ); +} + +/// A (subtype class — Caprichrome "Devour artifact 1", CR 702.82c): the pool is +/// the controller's ARTIFACTS; a creature is excluded. One artifact sacrificed → +/// 1 counter. +#[test] +fn devour_artifact_1_sacrifices_artifacts_not_creatures() { + let face = devour_face_q("Caprichrome", 1, TypeFilter::Artifact); + + let mut artifact_id = ObjectId(0); + let mut creature_id = ObjectId(0); + let (mut state, devour) = drive_devour_etb_with_battlefield(&face, PlayerId(0), |state| { + artifact_id = battlefield_artifact(state, PlayerId(0), "Trinket", &[]); + creature_id = battlefield_creature(state, PlayerId(0), "Bystander Bear"); + }); + + let WaitingFor::EffectZoneChoice { cards, .. } = &state.waiting_for else { + panic!( + "expected the Devour artifact sacrifice choice, got {:?}", + state.waiting_for + ); + }; + assert!( + cards.contains(&artifact_id), + "artifacts are eligible; pool={cards:?}" + ); + assert!( + !cards.contains(&creature_id), + "CR 702.82c: a creature must NOT be offered to a Devour-artifact sacrifice; pool={cards:?}" + ); + + crate::game::engine::apply_as_current( + &mut state, + GameAction::SelectCards { + cards: vec![artifact_id], + }, + ) + .unwrap(); + assert_eq!( + p1p1(&state, devour), + 1, + "Devour artifact 1 + one artifact → 1 +1/+1 counter" + ); +} + +/// A (subtype class — Feasting Hobbit "Devour Food 3", CR 702.82c + CR 205.3g): +/// the `Subtype("Food")` quality narrows the pool to FOOD artifacts only — a +/// plain (non-Food) artifact AND a creature are both excluded. Proves the +/// runtime `subtypes.contains("Food")` path (filter.rs) matches the canonical +/// subtype the parser emits. One Food sacrificed → 3 counters. +#[test] +fn devour_food_3_sacrifices_only_food_subtype() { + let face = devour_face_q( + "Feasting Hobbit", + 3, + TypeFilter::Subtype("Food".to_string()), + ); + + let mut food_id = ObjectId(0); + let mut plain_artifact_id = ObjectId(0); + let mut creature_id = ObjectId(0); + let (mut state, devour) = drive_devour_etb_with_battlefield(&face, PlayerId(0), |state| { + food_id = battlefield_artifact(state, PlayerId(0), "Food Token", &["Food"]); + plain_artifact_id = battlefield_artifact(state, PlayerId(0), "Trinket", &[]); + creature_id = battlefield_creature(state, PlayerId(0), "Bystander Bear"); + }); + + let WaitingFor::EffectZoneChoice { cards, .. } = &state.waiting_for else { + panic!( + "expected the Devour Food sacrifice choice, got {:?}", + state.waiting_for + ); + }; + assert!( + cards.contains(&food_id), + "the Food token is eligible; pool={cards:?}" + ); + assert!( + !cards.contains(&plain_artifact_id), + "CR 205.3g: a non-Food artifact is NOT a Food; pool={cards:?}" + ); + assert!( + !cards.contains(&creature_id), + "a creature is NOT a Food; pool={cards:?}" + ); + + crate::game::engine::apply_as_current( + &mut state, + GameAction::SelectCards { + cards: vec![food_id], + }, + ) + .unwrap(); + assert_eq!( + p1p1(&state, devour), + 3, + "Devour Food 3 + one Food → 3 +1/+1 counters" + ); +} diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index d902fb6580..55858095c8 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -574,8 +574,9 @@ impl ApprovedZoneChange { /// was never destroyed (verified against `reset_for_battlefield_entry` and the /// field's writer set; the capture/restore was a defensive no-op since PR /// #1119 introduced it). The cast-link family that IS cleared on entry -/// (CR 400.7d: kicker / additional-cost / convoke / cast-timing memory) is -/// preserved structurally by the delivery itself — see [`CastLinkSnapshot`]. +/// (CR 400.7d: kicker / Gift recipient / additional-cost / convoke / cast-timing +/// memory) is preserved structurally by the delivery itself — see +/// [`CastLinkSnapshot`]. pub(crate) struct DeliveryCtx { pub source_id: Option, pub exile_links: ExileLinkSpec, @@ -604,6 +605,8 @@ struct CastLinkSnapshot { cast_controller: Option, cast_timing_permission: Option, kickers_paid: Vec, + /// CR 702.174a: Opponent promised the Gift when this spell was cast. + gift_recipient: Option, additional_cost_payment_count: u32, additional_cost_payments: Vec, convoked_creatures: Vec, @@ -2198,6 +2201,7 @@ pub(crate) fn deliver_replaced_zone_change( cast_controller: obj.cast_controller, cast_timing_permission: obj.cast_timing_permission.map(|(p, _)| p), kickers_paid: obj.kickers_paid.clone(), + gift_recipient: obj.gift_recipient, additional_cost_payment_count: obj.additional_cost_payment_count, additional_cost_payments: obj.additional_cost_payments.clone(), convoked_creatures: obj.convoked_creatures.clone(), @@ -2339,6 +2343,7 @@ pub(crate) fn deliver_replaced_zone_change( obj.cast_timing_permission = Some((permission, state.turn_number)); } obj.kickers_paid = link.kickers_paid; + obj.gift_recipient = link.gift_recipient; obj.additional_cost_payment_count = link.additional_cost_payment_count; obj.additional_cost_payments = link.additional_cost_payments; obj.convoked_creatures = link.convoked_creatures; diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index 41deec1a52..58ed2c76be 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -5,6 +5,9 @@ use crate::types::game_state::{ }; use crate::types::identifiers::{CardId, ObjectId, ObjectIncarnationRef}; use crate::types::player::PlayerId; +use crate::types::resolved_commands::{ + ResolvedZoneChangeCommand, ResolvedZoneChangeReplayInvariantError, +}; use crate::types::statics::StaticMode; use crate::types::zones::Zone; @@ -286,6 +289,22 @@ pub(crate) fn apply_zone_exit_cleanup( } } + // CR 710.4 + CR 110.5: A flipped permanent that leaves the battlefield + // retains no memory of its status, and in every zone other than the + // battlefield a flip card has only its normal characteristics + // (CR 710.2). Restore the normal half and clear the flipped status. + // + // Ordered AFTER the CR 708.9 face-down restore on purpose: a flipped + // permanent that was then turned face down (Ixidron, Cyber Conversion) + // shares this one `back_face` slot between both statuses. + // `effects::turn_face_down` keeps the flip stash (the normal half) in + // it, so the face-down restore above already puts the normal half back + // on the object; this call then only has to clear the flipped status + // (its `back_face == None` branch). Running it first would instead + // consume the flip stash and leave the face-down 2/2 shell to be + // restored into the graveyard. + crate::game::flip::revert_flip_on_zone_exit(obj_mut); + // CR 400.7 + CR 113.6e: Clear exile-based casting permissions when leaving exile // (prevents re-casting if the card returns to exile via a different effect). if from == Zone::Exile { @@ -660,6 +679,239 @@ pub(crate) fn record_resolution_source_relatch( } } +fn zone_container_len( + state: &GameState, + zone: Zone, + owner: PlayerId, + object_id: ObjectId, +) -> usize { + match zone { + Zone::Library => state + .players + .iter() + .find(|player| player.id == owner) + .expect("zone command owner exists") + .library + .len(), + Zone::Hand => state + .players + .iter() + .find(|player| player.id == owner) + .expect("zone command owner exists") + .hand + .len(), + Zone::Graveyard => state + .players + .iter() + .find(|player| player.id == owner) + .expect("zone command owner exists") + .graveyard + .len(), + Zone::Battlefield => state.battlefield.len(), + Zone::Stack => state.stack.len(), + Zone::Exile => state.exile.len(), + Zone::Command => { + let object = state + .objects + .get(&object_id) + .expect("zone command object exists"); + let player = state + .players + .iter() + .find(|player| player.id == owner) + .expect("zone command owner exists"); + if object.in_attraction_deck { + player.attraction_deck.len() + } else if object.in_contraption_deck { + player.contraption_deck.len() + } else { + state.command_zone.len() + } + } + } +} + +fn destination_position_after_removal( + state: &GameState, + object_id: ObjectId, + from: Zone, + to: Zone, + owner: PlayerId, +) -> usize { + let destination_len = zone_container_len(state, to, owner, object_id); + if from == to { + destination_len + .checked_sub(1) + .expect("moving object must occupy its source container") + } else { + destination_len + } +} + +/// Resolves, applies, and journals the transition core of one ordinary zone move. +/// +/// CR 400.7 + CR 613.7d: the ordinary path consumes its timestamp, captures the +/// resulting incarnation and destination position, then delegates the exact +/// installation to [`apply_resolved_zone_change`]. Replay never allocates a +/// timestamp or a new object identity. +pub fn resolve_and_apply_zone_change( + state: &mut GameState, + object_id: ObjectId, + from: Zone, + to: Zone, + owner: PlayerId, + mut zone_change_record: crate::types::game_state::ZoneChangeRecord, +) -> Result { + let object = state.objects.get(&object_id).ok_or( + ResolvedZoneChangeReplayInvariantError::UnknownObject(object_id), + )?; + let occurrence = ObjectIncarnationRef::from_object(object); + if object.zone != from { + return Err(ResolvedZoneChangeReplayInvariantError::SourceZoneMismatch { + expected: from, + found: object.zone, + }); + } + if object.owner != owner { + return Err(ResolvedZoneChangeReplayInvariantError::OwnerMismatch { + expected: owner, + found: object.owner, + }); + } + + let entry_timestamp = (to == Zone::Battlefield).then(|| state.next_timestamp()); + let resulting_incarnation = if to == Zone::Battlefield || from != to { + occurrence.incarnation + 1 + } else { + occurrence.incarnation + }; + let destination_position = + destination_position_after_removal(state, object_id, from, to, owner); + let turn_zone_change_index = state.zone_changes_this_turn.len(); + zone_change_record.entered_incarnation = + (to == Zone::Battlefield).then_some(resulting_incarnation); + zone_change_record.turn_zone_change_index = turn_zone_change_index; + + let command = ResolvedZoneChangeCommand { + object: occurrence, + resulting_incarnation, + from, + to, + destination_position, + owner, + entry_timestamp, + turn_zone_change_index, + zone_change_record, + cause: state.current_or_begin_rules_execution_node(), + }; + apply_resolved_zone_change(state, &command)?; + state + .resolved_rules_journal + .record_zone_change(command.clone()) + .expect("resolved zone change must have a live journal cause"); + Ok(command) +} + +/// Installs one recorded transition core without a replacement consult, query, +/// timestamp allocation, or incarnation allocation. +pub fn apply_resolved_zone_change( + state: &mut GameState, + command: &ResolvedZoneChangeCommand, +) -> Result<(), ResolvedZoneChangeReplayInvariantError> { + let turn_number = state.turn_number; + let object = state.objects.get(&command.object.object_id).ok_or( + ResolvedZoneChangeReplayInvariantError::UnknownObject(command.object.object_id), + )?; + let found = ObjectIncarnationRef::from_object(object); + if found != command.object { + return Err(ResolvedZoneChangeReplayInvariantError::OccurrenceMismatch { + expected: command.object, + found, + }); + } + if object.owner != command.owner { + return Err(ResolvedZoneChangeReplayInvariantError::OwnerMismatch { + expected: command.owner, + found: object.owner, + }); + } + if object.zone != command.from { + return Err(ResolvedZoneChangeReplayInvariantError::SourceZoneMismatch { + expected: command.from, + found: object.zone, + }); + } + if command.to == Zone::Battlefield && command.entry_timestamp.is_none() { + return Err(ResolvedZoneChangeReplayInvariantError::MissingBattlefieldEntryTimestamp); + } + if command.to != Zone::Battlefield && command.entry_timestamp.is_some() { + return Err(ResolvedZoneChangeReplayInvariantError::UnexpectedNonbattlefieldTimestamp); + } + if command.turn_zone_change_index != state.zone_changes_this_turn.len() { + return Err( + ResolvedZoneChangeReplayInvariantError::TurnRecordIndexMismatch { + expected: command.turn_zone_change_index, + found: state.zone_changes_this_turn.len(), + }, + ); + } + + let destination_position = destination_position_after_removal( + state, + command.object.object_id, + command.from, + command.to, + command.owner, + ); + if destination_position != command.destination_position { + return Err( + ResolvedZoneChangeReplayInvariantError::DestinationPositionMismatch { + expected: command.destination_position, + found: destination_position, + }, + ); + } + + remove_from_zone(state, command.object.object_id, command.from, command.owner); + add_to_zone(state, command.object.object_id, command.to, command.owner); + + let object = state + .objects + .get_mut(&command.object.object_id) + .expect("validated zone command object remains live"); + object.zone = command.to; + if command.to == Zone::Battlefield { + object.reset_for_battlefield_entry( + turn_number, + command + .entry_timestamp + .expect("validated battlefield command has a timestamp"), + ); + } else { + object.incarnation = command.resulting_incarnation; + } + if object.incarnation != command.resulting_incarnation { + return Err( + ResolvedZoneChangeReplayInvariantError::ResultingIncarnationMismatch { + expected: command.resulting_incarnation, + found: object.incarnation, + }, + ); + } + + let turn_zone_change_index = + super::restrictions::record_zone_change(state, command.zone_change_record.clone()); + if turn_zone_change_index != command.turn_zone_change_index { + return Err( + ResolvedZoneChangeReplayInvariantError::TurnRecordIndexMismatch { + expected: command.turn_zone_change_index, + found: turn_zone_change_index, + }, + ); + } + Ok(()) +} + /// CR 400.7: Move an object to a new zone. An object that moves to a new zone becomes a new object. pub fn move_to_zone( state: &mut GameState, @@ -855,17 +1107,60 @@ pub fn move_to_zone( zone_change_record.attachments.clone(), ); - remove_from_zone(state, object_id, from, owner); - if redirect_attraction_to_command { - // CR 717.6a: Cards redirected this way are kept in the command-zone - // junkyard pile, separate from the Attraction deck. - state - .objects - .get_mut(&object_id) - .expect("object exists") - .in_attraction_deck = false; - } - add_to_zone(state, object_id, to, owner); + // Command-zone routes select between the ordinary command container and + // the owner-specific Attraction/Contraption containers. Stack routes have + // their `StackEntry` insertion/removal owned by the casting and resolution + // paths, not by `add_to_zone`. Those special containers are outside this + // first cut, so preserve their existing raw transition rather than encoding + // a partial container identity in this generic command. + let (pre_bump_incarnation, new_incarnation, transition_recorded) = + if matches!(from, Zone::Command | Zone::Stack) || matches!(to, Zone::Command | Zone::Stack) + { + remove_from_zone(state, object_id, from, owner); + if redirect_attraction_to_command { + // CR 717.6a: Cards redirected this way are kept in the command-zone + // junkyard pile, separate from the Attraction deck. + state + .objects + .get_mut(&object_id) + .expect("object exists") + .in_attraction_deck = false; + } + add_to_zone(state, object_id, to, owner); + + // CR 613.7d: An object receives a timestamp when it enters a zone. + let entry_timestamp = (to == Zone::Battlefield).then(|| state.next_timestamp()); + let obj_mut = state.objects.get_mut(&object_id).expect("object exists"); + let pre_bump_incarnation = obj_mut.incarnation; + obj_mut.zone = to; + if to == Zone::Battlefield { + obj_mut.reset_for_battlefield_entry( + state.turn_number, + entry_timestamp.expect("battlefield entry draws a timestamp"), + ); + zone_change_record.entered_incarnation = Some(obj_mut.incarnation); + } else if from != to { + // CR 400.7: a move between zones creates a new object. + obj_mut.bump_incarnation(); + } + (pre_bump_incarnation, obj_mut.incarnation, false) + } else { + let resolved_zone_change = resolve_and_apply_zone_change( + state, + object_id, + from, + to, + owner, + zone_change_record, + ) + .expect("ordinary zone transition must install its resolved core"); + zone_change_record = resolved_zone_change.zone_change_record; + ( + resolved_zone_change.object.incarnation, + resolved_zone_change.resulting_incarnation, + true, + ) + }; // CR 603.6c: Drop the leaving permanent from the TriggerIndex. The // leaves-battlefield last-known-information scan in @@ -877,37 +1172,6 @@ pub fn move_to_zone( state.trigger_index.remove(object_id); } - // CR 613.7d: an object receives a timestamp when it enters a zone. Stage 2 - // stamps battlefield entries only, so only draw a timestamp on a battlefield - // entry — a graveyard/exile/hand/library move must not burn one. Computed - // before the `get_mut` borrow because `next_timestamp` takes `&mut self` over - // the whole GameState. - let entry_timestamp = (to == Zone::Battlefield).then(|| state.next_timestamp()); - - let obj_mut = state.objects.get_mut(&object_id).unwrap(); - // CR 400.7j: capture the pre-bump incarnation BEFORE any bump (the battlefield - // arm bumps inside `reset_for_battlefield_entry`, which has no state access) so - // the resolution re-latch can chain the source across a self-move. - let pre_bump_incarnation = obj_mut.incarnation; - obj_mut.zone = to; - - if to == Zone::Battlefield { - obj_mut.reset_for_battlefield_entry( - state.turn_number, - entry_timestamp.expect("battlefield entry draws a timestamp"), - ); - // CR 400.7: capture the entrant's incarnation AFTER the battlefield-entry - // bump so a later leave + re-entry (same ObjectId, higher incarnation) is - // distinguishable from the original entrant when an ETB intervening-if is - // rechecked at resolution (CR 603.4 + CR 608.2h). - zone_change_record.entered_incarnation = Some(obj_mut.incarnation); - } else if from != to { - // CR 400.7: a move FROM one zone TO another makes a new object. The - // `from != to` guard generalizes the BF→BF no-op guard (upstream) to every - // same-zone case (Exile→Exile, GY→GY) that can reach this else-arm. - obj_mut.bump_incarnation(); - } - let new_incarnation = obj_mut.incarnation; if new_incarnation != pre_bump_incarnation { record_resolution_source_relatch(state, object_id, pre_bump_incarnation, new_incarnation); } @@ -1002,9 +1266,11 @@ pub fn move_to_zone( super::trigger_index::reindex_object_triggers(state, object_id); } - let turn_zone_change_index = - super::restrictions::record_zone_change(state, zone_change_record.clone()); - zone_change_record.turn_zone_change_index = turn_zone_change_index; + if !transition_recorded { + let turn_zone_change_index = + super::restrictions::record_zone_change(state, zone_change_record.clone()); + zone_change_record.turn_zone_change_index = turn_zone_change_index; + } if let Some(old_target) = unattached_from { events.push(GameEvent::Unattached { diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 5dfc0cbb8f..7cac640b52 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -30,7 +30,7 @@ use crate::types::zones::Zone; use super::oracle_nom::bridge::{nom_on_lower, split_once_on_lower}; use super::oracle_nom::condition::parse_graveyard_keyword_grant_sentence; use super::oracle_nom::primitives::{ - parse_number as nom_parse_number, scan_contains, scan_preceded, + parse_number as nom_parse_number, scan_at_word_boundaries, scan_contains, scan_preceded, }; use super::oracle_attraction::parse_attraction_visit_triggers; @@ -1192,6 +1192,7 @@ fn detect_document_relations(items: &[OracleItemIr], types: &[String]) -> Vec, +) { + let chooser = items + .iter() + .find(|item| item_ability(item).is_some_and(ability_is_as_enters_choose_permanent_gap)); + let copy_static = items.iter().find(|item| { + item_static(item).is_some_and(|s| { + s.modifications + .contains(&ContinuousModification::CopyChosen) + }) + }); + if let (Some(chooser), Some(copy_static)) = (chooser, copy_static) { + if chooser.id != copy_static.id { + relations.push(DocumentRelationIr::LinkedChoice( + LinkedChoiceKind::CopyChosenHost { + chooser: chooser.id, + copy_static: copy_static.id, + }, + )); + } + } +} + +/// CR 607.2d + CR 707.2c + CR 614.12a: Replace the proven chooser gap ability +/// with a Moved `ChoosePermanent` replacement. Filter is re-derived from the +/// Unimplemented description so line-local parse never assigns copy-host +/// semantics without this relation. +fn apply_linked_choice_copy_chosen_host( + result: &mut ParsedAbilities, + relations: &[DocumentRelationIr], + ability_ids: &mut Vec, + replacement_ids: &mut Vec, +) { + for relation in relations { + let DocumentRelationIr::LinkedChoice(LinkedChoiceKind::CopyChosenHost { chooser, .. }) = + relation + else { + continue; + }; + let Some(ability_pos) = position_of(ability_ids, *chooser) else { + continue; + }; + let Some(description) = result.abilities[ability_pos] + .effect + .unimplemented_description() + .map(str::to_owned) + else { + continue; + }; + let Some(filter) = filter_from_as_enters_choose_permanent_text(&description) else { + continue; + }; + result.abilities.remove(ability_pos); + ability_ids.remove(ability_pos); + let execute = + AbilityDefinition::new(AbilityKind::Spell, Effect::ChoosePermanent { filter }); + result.replacements.push( + ReplacementDefinition::new(ReplacementEvent::Moved) + .execute(execute) + .valid_card(TargetFilter::SelfRef) + // CR 614.1c: battlefield-entry-scoped. + .destination_zone(Zone::Battlefield) + .description(description), + ); + replacement_ids.push(*chooser); + } +} + +/// An Unimplemented ability whose fragment is an as-enters permanent-object +/// choice ("As … enters, choose a creature/permanent…"). Framing + Typed filter +/// must both match — same grammar as `as_enters_choose_permanent_filter`. +fn ability_is_as_enters_choose_permanent_gap(def: &AbilityDefinition) -> bool { + let Some(description) = def.effect.unimplemented_description() else { + return false; + }; + filter_from_as_enters_choose_permanent_text(description).is_some() +} + +fn filter_from_as_enters_choose_permanent_text(description: &str) -> Option { + let lower = description.to_lowercase(); + let has_as = + scan_at_word_boundaries(&lower, |i| tag::<_, _, OracleError<'_>>("as ").parse(i)).is_some(); + let has_enters = + scan_at_word_boundaries(&lower, |i| tag::<_, _, OracleError<'_>>("enters").parse(i)) + .is_some(); + if !has_as || !has_enters { + return None; + } + let (_, _, choose_suffix) = + scan_preceded(&lower, |i| tag::<_, _, OracleError<'_>>("choose ").parse(i))?; + super::oracle_replacement::as_enters_choose_permanent_filter(choose_suffix) +} + /// Whether an ability's effect chain (recursing sub-abilities) makes a /// player/opponent choice. fn ability_chain_has_player_choice(def: &AbilityDefinition) -> bool { @@ -2848,6 +2951,12 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { ); reconcile_host_bound_phase_outs(&mut result); apply_linked_choice_persisted_player(&mut result, &ir.relations, &ability_ids, &trigger_ids); + apply_linked_choice_copy_chosen_host( + &mut result, + &ir.relations, + &mut ability_ids, + &mut replacement_ids, + ); // Architectural rule: the parser must never silently discard Oracle text. Run // the swallow audit against the parsed result so any unrepresented clause @@ -2863,11 +2972,12 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { // rather than the whole card's text. The draft-matters (CR 905) filter that used // to strip lines from the whole-card text moves inside as a per-item skip. // - // The tracks are sound to zip here: of the four relation passes above, three are - // length-preserving and `apply_linked_choice_etb_counter` removes from - // `result.replacements` and `replacement_ids` at the same index. This is also - // exactly why the audit stays HERE, post-relation: a pre-lowering audit is blind - // to relation-synthesized semantics (that pass *synthesizes a replacement*), so + // The tracks are sound to zip here: of the relation passes above, + // `apply_linked_choice_etb_counter` removes from `result.replacements` and + // `replacement_ids` at the same index, and `apply_linked_choice_copy_chosen_host` + // moves an ability id onto the replacement track. This is also exactly why + // the audit stays HERE, post-relation: a pre-lowering audit is blind to + // relation-synthesized semantics (that pass *synthesizes a replacement*), so // the false-positive wave U1 bounded to 31 faces would be caused, not avoided. // // Emitted into a local vec and appended, rather than passing `&mut diff --git a/crates/engine/src/parser/oracle_classifier.rs b/crates/engine/src/parser/oracle_classifier.rs index c568568cfc..a3bb4f2f7d 100644 --- a/crates/engine/src/parser/oracle_classifier.rs +++ b/crates/engine/src/parser/oracle_classifier.rs @@ -977,6 +977,13 @@ fn is_as_enters_choose_pattern(lower: &str) -> bool { tag::<_, _, OracleError<'_>>("enters").parse(i) }) .is_some(); + // Named-attribute choices only ("choose a creature type", "choose a color"). + // Object choices ("choose a creature" — Metamorphic Alteration, Dauntless + // Bodyguard, Scheming Fence) are NOT replacement-classified here: claiming + // them as Moved without a proven CopyChosen consumer changes unsupported + // card shape for the whole class. Metamorphic's ChoosePermanent is injected + // only by `LinkedChoiceKind::CopyChosenHost` after the companion static + // parses. let has_choose = nom_primitives::scan_at_word_boundaries(lower, |i| { verify(tag::<_, _, OracleError<'_>>("choose "), |_: &&str| { try_parse_named_choice(i).is_some() diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 904f2a96ea..2dd0b16eb7 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -9,11 +9,20 @@ //! The clause-lowering helpers this traversal calls still live in `lower.rs` //! (widened to `pub(super)` for this move); relocating them is a later increment. +use nom::branch::alt; +use nom::bytes::complete::tag; +use nom::character::complete::multispace0; +use nom::combinator::value; +use nom::sequence::preceded; +use nom::Parser; + use crate::parser::oracle_ir::ast::*; use crate::parser::oracle_ir::effect_chain::{ AbsorbKind, ClauseDisposition, ClauseId, EffectChainIr, OtherwiseKind, PlayerScopeRewrite, PriorModifier, ReplaceMeaningKind, ReplicateKind, }; +use crate::parser::oracle_nom::bridge::nom_on_lower; +use crate::parser::oracle_nom::error::OracleError; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, CastFromZoneDriver, CastingPermission, ControllerRef, Effect, PlayerFilter, QuantityExpr, StaticCondition, @@ -29,10 +38,11 @@ use super::lower::{ attach_cast_cost_raise_to_previous_play_from_exile, attach_graveyard_redirect_rider_to_prior_cast_from_zone, attach_land_enters_tapped_to_previous_play_from_exile, cast_cost_raise_rider, - consolidate_die_and_coin_defs, definition_targets_self_source, - effect_publishes_revealed_subject, extract_bounded_target_multi_target, - extract_exact_target_multi_target, extract_optional_target_multi_target, - extract_verb_up_to_multi_target, fold_copy_spell_gains_haste_and_quoted_grant, + clone_would_transplant_gated_referent, consolidate_die_and_coin_defs, + definition_targets_self_source, effect_publishes_revealed_subject, + extract_bounded_target_multi_target, extract_exact_target_multi_target, + extract_optional_target_multi_target, extract_verb_up_to_multi_target, + fold_copy_spell_gains_haste_and_quoted_grant, fold_deal_damage_then_prevent_into_computed_amount, fold_enters_this_way_counter_rider, fold_exile_resolving_rider, fold_search_choose_type_conditional_destination, fold_token_it_has_grants_into_token_statics, gate_other_revealed_card_on_multiplayer_reveal, @@ -44,32 +54,81 @@ use super::lower::{ parse_controlled_by_different_players_target_constraint, parse_same_zone_owner_target_constraint, parse_total_mana_value_target_constraint, patch_choose_from_zone_counter_continuation_target, patch_population_head_tap_anaphor, - patch_self_ref_head_tap_anaphor, resolve_populated_token_anaphors, - resolve_populated_unsuspect_anaphors, resolve_those_tokens_anaphors, - rewire_result_anchored_subchain, rewrite_counter_instead_target_from_antecedent, - rewrite_else_event_context_to_stable, rewrite_else_parent_target_to_self_ref, - rewrite_player_anaphor_targets_in_definition, rewrite_those_tokens_from_antecedent, - rewrite_two_target_counter_chain, target_choice_timing_for_clause, - thread_chosen_damage_source_into_oneshot_effects, + patch_self_ref_head_tap_anaphor, relink_gated_token_referent_consumers, + resolve_populated_token_anaphors, resolve_populated_unsuspect_anaphors, + resolve_those_tokens_anaphors, rewire_result_anchored_subchain, + rewrite_counter_instead_target_from_antecedent, rewrite_else_event_context_to_stable, + rewrite_else_parent_target_to_self_ref, rewrite_player_anaphor_targets_in_definition, + rewrite_those_tokens_from_antecedent, rewrite_two_target_counter_chain, + target_choice_timing_for_clause, thread_chosen_damage_source_into_oneshot_effects, }; use super::sequence::{apply_clause_continuation, def_bears_retargetable_copy}; use super::{ append_to_deepest_sub_ability, apply_player_scope_rewrites, attach_alt_cost_to_prior_cast_from_zone, attach_mana_retention_to_prior_mana, - attach_repeat_process_keywords, attach_same_is_true_keywords, + attach_perpetual_keyword_grants, attach_repeat_process_keywords, attach_same_is_true_keywords, bind_anaphoric_damage_subject_keep_recipient, collapse_ephemeral_color_choice_mana, contains_explicit_tracked_set_pronoun, contains_implicit_tracked_set_pronoun, def_is_damage_dealer, def_is_dig_look, def_is_dig_or_mill, def_is_generic_effect_head, - def_is_keyword_counter_placement, demote_unbindable_batch_aggregate, draw_object_count_filter, - fold_cast_copy_of_card_defs, has_explicit_player_target, inject_chosen_color_choice_grant, - mark_uses_tracked_set, parse_spell_graveyard_replacement_rider, - publishes_aggregate_set_from_resolution, publishes_tracked_set_from_resolution, - rebind_tracked_aggregate_to_chain_set, retarget_counter_additional_cost_to_target, - rewrite_grant_parent_to_filter, rewrite_parent_targets_to_tracked_set, rewrite_rounding_mode, - rewrite_that_type_mana_instead, stamp_delayed_returns, try_fold_token_repeat_into_count, - wire_optional_cast_decline_fallback, + def_is_keyword_counter_placement, def_is_perpetual_keyword_grant, + demote_unbindable_batch_aggregate, draw_object_count_filter, fold_cast_copy_of_card_defs, + has_explicit_player_target, inject_chosen_color_choice_grant, mark_uses_tracked_set, + parse_spell_graveyard_replacement_rider, publishes_aggregate_set_from_resolution, + publishes_tracked_set_from_resolution, rebind_tracked_aggregate_to_chain_set, + retarget_counter_additional_cost_to_target, rewrite_grant_parent_to_filter, + rewrite_parent_targets_to_tracked_set, rewrite_rounding_mode, rewrite_that_type_mana_instead, + stamp_delayed_returns, try_fold_token_repeat_into_count, wire_optional_cast_decline_fallback, }; +/// CR 601.2c: True when the assembled head chose one or more players at +/// announcement, including Oracle's type-less `target opponents` lowering. +fn is_multi_target_player_subject_definition(def: &AbilityDefinition) -> bool { + def.multi_target.is_some() + && def + .effect + .target_filter() + .is_some_and(|target| match target { + TargetFilter::Player | TargetFilter::Opponent => true, + TargetFilter::Typed(typed) => { + typed.type_filters.is_empty() + && typed.properties.is_empty() + && matches!(typed.controller, Some(ControllerRef::Opponent)) + } + _ => false, + }) +} + +/// CR 608.2c: A bare verb after a multi-target player subject continues that +/// subject. A printed player subject (especially `you`) starts an independent +/// actor-relative instruction instead. +fn has_implicit_player_subject_continuation(source: &str) -> bool { + let lower = source.to_ascii_lowercase(); + nom_on_lower(source, &lower, |input| { + value( + (), + preceded( + multispace0, + alt(( + tag::<_, _, OracleError<'_>>("you "), + tag("target "), + tag("each "), + tag("that "), + tag("those "), + tag("the "), + tag("a player "), + tag("an opponent "), + tag("players "), + tag("opponents "), + tag("it "), + tag("they "), + )), + ), + ) + .parse(input) + }) + .is_none() +} + // =========================================================================== // AssemblyEnv (Plan 01 §6, U6-B1) — emit-time provenance + role registries // =========================================================================== @@ -638,6 +697,15 @@ pub(super) enum AntecedentRole { /// the sibling template a "Repeat this process for " continuation /// clones (Kathril, Aspect Warper). KeywordCounterPlacement, + /// CR 702.1c + CR 608.2c: A perpetual keyword grant + /// (`ApplyPerpetual { GrantKeywords }`) — the sibling + /// template a "The same is true for " continuation clones when the + /// antecedent is a PERPETUAL grant rather than Odric's static `GenericEffect` + /// grant (Mutable Pupa). Membership is the EFFECT VARIANT ALONE, mirroring + /// `def_is_perpetual_keyword_grant`; the gating condition is the mutator's + /// business, not the role's filter. "Perpetually" is a digital-only extension + /// outside the Comprehensive Rules. + PerpetualKeywordGrantHead, /// A `DealDamage` — the antecedent an "excess damage" rider redirects from /// (CR 120.4a). The rider need not be adjacent to the damage clause, which is /// why this is a role and not `LastEmitted`. @@ -709,6 +777,11 @@ fn live_role_predicate(role: AntecedentRole) -> Option match role { AntecedentRole::GenericEffectHead => Some(def_is_generic_effect_head), AntecedentRole::KeywordCounterPlacement => Some(def_is_keyword_counter_placement), + // LIVE — mirrors `KeywordCounterPlacement`. The mutator + // (`attach_perpetual_keyword_grants`) appends siblings (length-changing), + // but staying live keeps it consistent with its sibling role and immune to + // any future in-place effect rewrite. + AntecedentRole::PerpetualKeywordGrantHead => Some(def_is_perpetual_keyword_grant), // LIVE, not cached. The scan this role replaces (`sequence.rs`, the // `DigFromAmong` fallthrough) re-derived its antecedent from `defs` on every // call, so it saw the CURRENT effect of every def. A cached registry is @@ -1051,7 +1124,8 @@ impl AssemblyEnv { | AntecedentRole::DigOrMill | AntecedentRole::DigLook | AntecedentRole::DamageDealer - | AntecedentRole::CopySpellBearer => Vec::new(), + | AntecedentRole::CopySpellBearer + | AntecedentRole::PerpetualKeywordGrantHead => Vec::new(), }, }; members @@ -1302,16 +1376,47 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { } } ReplicateKind::CounterPlacement => { + let bound = env + .resolve( + &defs, + AntecedentSelector::LastWithRole( + AntecedentRole::KeywordCounterPlacement, + ), + None, + OnMiss::Ignore, + ) + // CR 603.12: the clones are pushed at the TAIL of + // `defs` carrying the template's target VERBATIM, so + // a template that reads a gated publisher's + // `TargetFilter::LastCreated` would have that + // chain-context referent transplanted past whatever + // sits in between, where + // `state.last_created_token_ids`, a game-lifetime + // ledger, binds a token from an EARLIER resolution. + // Decline ONLY that shape: the predicate builds the + // clone and asks + // `lower::relink_gated_token_referent_consumers` + // itself whether it lands honestly. Every other + // binding is replicated as printed — CR 608.2c has + // the controller follow the instructions in the + // order WRITTEN, so silently dropping a printed + // replication is the worse error direction. + .filter(|i| !clone_would_transplant_gated_referent(&defs, *i)); + if let Some(bound_index) = bound { + attach_repeat_process_keywords(&mut defs, bound_index, keywords); + } + } + ReplicateKind::PerpetualKeywordGrant => { let bound = env.resolve( &defs, AntecedentSelector::LastWithRole( - AntecedentRole::KeywordCounterPlacement, + AntecedentRole::PerpetualKeywordGrantHead, ), None, OnMiss::Ignore, ); if let Some(bound_index) = bound { - attach_repeat_process_keywords(&mut defs, bound_index, keywords); + attach_perpetual_keyword_grants(&mut defs, bound_index, keywords); } } } @@ -1735,16 +1840,10 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { def.description = Some(clause_ir.source.fragment().unwrap_or_default().to_string()); } // CR 608.2c: This clause's link to its parent = the boundary that - // SEPARATED the previous clause from this one. A `Sentence` boundary - // marks a `SequentialSibling` (next printed instruction, resolves even - // when an optional parent is declined); `Comma`/`Then`/none marks a - // within-clause `ContinuationStep` (part of the parent's action). - def.sub_link = match prev_boundary { - Some(ClauseBoundary::Sentence) => SubAbilityLink::SequentialSibling, - Some(ClauseBoundary::Then) | Some(ClauseBoundary::Comma) | None => { - SubAbilityLink::ContinuationStep - } - }; + // SEPARATED the previous clause from this one, translated by the single + // boundary→link authority (`oracle_ir::ast::sub_link_after_boundary`), + // which the referent walk in `oracle_effect::mod` also consults. + def.sub_link = sub_link_after_boundary(prev_boundary); // CR 615.5: A "(When|Whenever|If) damage [from a source] is // prevented this way, …" rider is printed as its own sentence but is not // an independent instruction — its "this way" back-reference binds to the @@ -2015,6 +2114,32 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { def = def.multi_target(spec.clone()); } } + // CR 601.2c + CR 608.2c: A conjugated continuation after an "any + // number of target players/opponents each" head shares the targets + // selected for that head. At this assembly seam, the previous definition + // has its finalized `multi_target` shape and this clause still has its + // source text, so bind a bare draw ("then draw seven cards") to + // `ParentTarget`. An explicit imperative ("then you draw a card") + // remains controller-relative. + if def.sub_link == SubAbilityLink::ContinuationStep + && defs + .last() + .is_some_and(is_multi_target_player_subject_definition) + && has_implicit_player_subject_continuation( + clause_ir.source.fragment().unwrap_or_default(), + ) + { + if let Effect::Draw { target, .. } = def.effect.as_mut() { + if matches!( + target, + TargetFilter::Controller + | TargetFilter::Player + | TargetFilter::ParentTargetController + ) { + *target = TargetFilter::ParentTarget; + } + } + } if parse_controlled_by_different_players_target_constraint( clause_ir.source.fragment().unwrap_or_default(), ) { @@ -2302,6 +2427,11 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { // continuation patched it. An unpatched Dig { reveal: true, keep_count: None, filter: Any } // is a simple "reveal the top N" with no player selection — it must resolve synchronously // (via RevealTop) so that sub_ability chains like RevealedHasCardType evaluate inline. + // + // CR 107.3 + CR 701.20a: Dynamic counts (Portent of Calamity's "top X cards") cannot + // round-trip through `RevealTop { count: u32 }` without collapsing to 1. Keep those + // Digs as reveal-only peeks (`keep_count: 0`) so X resolves at runtime; a later + // ForEachCategory / LastRevealed rest-move consumes the revealed pool. for def in &mut defs { if let Effect::Dig { count, @@ -2317,14 +2447,27 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { if destination == &Some(Zone::Library) && rest_destination == &Some(Zone::Library) { continue; } - let count_val = match count { - QuantityExpr::Fixed { value } => *value as u32, - _ => 1, - }; - *def.effect = Effect::RevealTop { - player: player.clone(), - count: count_val, - }; + match count { + QuantityExpr::Fixed { value } => { + *def.effect = Effect::RevealTop { + player: player.clone(), + count: *value as u32, + }; + } + _ => { + if let Effect::Dig { + keep_count, + destination, + rest_destination, + .. + } = &mut *def.effect + { + *keep_count = Some(0); + *destination = None; + *rest_destination = None; + } + } + } } } @@ -2357,6 +2500,14 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { // creation for the Sacrifice case — CR 603.7c). resolve_populated_token_anaphors(&mut defs); + // CR 603.12 + CR 609.3: A clause whose subject is the token published by a + // clause under an affirmative reflexive gate ("When you do, create a token. + // Put a +1/+1 counter on that token.") is part of that gated instruction, + // not the next independent one — with no token created it can do nothing. + // Must run AFTER the anaphor rewrites above, which are what bind the + // referent it looks for. + relink_gated_token_referent_consumers(&mut defs); + // CR 707.12: "Copy [a card]. You may cast the copy ..." is not a stack // copy (CR 707.10). It creates a card copy in the source zone, then casts // that copy during resolution. Fold the two parsed imperative clauses into diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index 09f8b931b3..55cdb6db21 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -11,7 +11,8 @@ use nom::Parser; use super::super::oracle_nom::bridge::{nom_on_lower, nom_parse_lower}; use super::super::oracle_nom::condition::{ - inject_controller_you, parse_cast_using_teamwork_phrase, parse_spell_target_superlative_suffix, + inject_controller_you, parse_cast_using_teamwork_phrase, + parse_scoped_player_opponent_and_has_condition, parse_spell_target_superlative_suffix, parse_you_put_onto_battlefield_this_way_clause, parse_zone_changed_this_way_clause, }; use super::super::oracle_nom::primitives as nom_primitives; @@ -1535,6 +1536,46 @@ pub(super) fn strip_coin_flip_conditional(text: &str) -> (Option (Option, String) { + use crate::types::ability::AggregateFunction; + + let lower = text.to_lowercase(); + match nom_on_lower(text, &lower, |i| { + let (i, _) = tag::<_, _, OracleError<'_>>("if ").parse(i)?; + let (i, n) = alt(( + value(2i32, tag("two")), + map(nom_primitives::parse_number, |n| n as i32), + )) + .parse(i)?; + let (i, _) = tag(" cards that share a ").parse(i)?; + let (i, quality) = crate::parser::oracle_target::parse_shared_quality(i)?; + let (i, _) = tag(" were milled this way").parse(i)?; + let (i, _) = opt(alt((tag(","), tag(", ")))).parse(i)?; + Ok((i, (n, quality))) + }) { + Some(((n, quality), remainder)) => { + let condition = AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ObjectCountBySharedQuality { + filter: TargetFilter::LastZoneChanged, + quality, + aggregate: AggregateFunction::Max, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: n }, + }; + (Some(condition), remainder.trim_start().to_string()) + } + None => (None, text.to_string()), + } +} + pub(super) fn strip_card_type_conditional(text: &str) -> (Option, String) { if let Some((condition, remainder)) = parse_if_revealed_card_type_conditional(text) { return (Some(condition), remainder); @@ -3120,6 +3161,32 @@ pub(super) fn strip_suffix_conditional( if let Some(cond) = parse_source_pt_comparison_condition_text(condition_text) { return (Some(cond), text[..if_pos].trim().to_string()); } + // CR 608.2c: "that creature has " / "that permanent has " + // are in NON_REHOMEABLE_CONDITION_PREFIXES, so — like the "it has " colored- + // mana and source-P/T gates above — this TRAILING zone-change object gate must + // run BEFORE the rehomeable bail or it would never reach the condition parser. + // It binds the TRIGGER's event-bound entering object + // (`AbilityCondition::ZoneChangeObjectMatchesFilter`, evaluated against + // `state.current_trigger_event`), which is DISJOINT from the leading-only + // `strip_target_keyword_instead` path (`AbilityCondition::TargetHasKeywordInstead`, + // evaluated against `ability.targets` — a spell/ability TARGET): a genuinely + // different anaphor source (event object vs. chosen target), not a duplicate + // of the same concept. Mutable Pupa's "…if that creature has " riders. + // + // This is gated on trigger context, mirroring `strip_counter_conditional`'s + // identical demonstrative-subject handling ("that creature has … counter" is + // offered `if !in_trigger` there). `ZoneChangeObjectMatchesFilter` reads + // `state.current_trigger_event`, which is only meaningful inside a trigger's + // resolution; outside a trigger the demonstrative "that creature" is the + // spell's target, NOT an entering object, so this branch must decline and + // leave the non-trigger form to whatever else handles it (nothing currently + // emits `ZoneChangeObjectMatchesFilter` for a non-trigger keyword predicate) + // rather than misfire an event-bound gate against a spell target. + if ctx.in_trigger { + if let Some(cond) = parse_zone_change_object_has_keyword_condition(condition_text) { + return (Some(cond), text[..if_pos].trim().to_string()); + } + } if !condition_text_is_rehomeable(condition_text) { return (None, text.to_string()); } @@ -5793,6 +5860,34 @@ pub(super) fn try_nom_condition_as_ability_condition( return Some(maybe_negate(cond, negated)); } + // CR 102.2 / CR 102.3 + CR 603.2b + CR 608.2c: A phase trigger over + // "each player's" step binds `ScopedPlayer`; a later leading condition can + // constrain that SAME player by both relationship and scalar state: + // "the player is your opponent and has ". + // + // The grammar returns both independent legs. Compose them here from + // existing conditions: `ScopedPlayerMatches(Opponent)` plus the bridged + // `HandSize/LifeTotal { ScopedPlayer }` quantity check. Full consumption + // and the exact context equality are both required. Other relative-player + // contexts (triggering/target/defending/chosen/etc.) deliberately fall + // through unchanged rather than reinterpreting their referent as a + // per-player phase iteration. + if matches!( + ctx.relative_player_scope.as_ref(), + Some(ControllerRef::ScopedPlayer) + ) { + if let Ok((_, (filter, scalar))) = + all_consuming(parse_scoped_player_opponent_and_has_condition).parse(lower.as_str()) + { + return Some(AbilityCondition::And { + conditions: vec![ + AbilityCondition::ScopedPlayerMatches { filter }, + static_condition_to_ability_condition(&scalar, ctx)?, + ], + }); + } + } + let (rest, condition) = parse_inner_condition(&lower).ok()?; if !rest.trim().is_empty() { return None; @@ -6607,12 +6702,65 @@ fn parse_entered_or_cast_from_zone_ability_condition(lower: &str) -> Option "` prefix +/// in a zone-change object gate. Typed (not a bool) per the typed-enum mandate, +/// so the caller routes each form to its own `TargetFilter` construction. The +/// copula form (`is/isn't [a/an] `) carries a type phrase; the keyword +/// form (`has/doesn't have `) carries a keyword name. +enum ZoneChangeObjectPredicate<'a> { + /// Remaining text is a type phrase (parsed via `parse_type_phrase`). + Type(&'a str), + /// Remaining text is a keyword name (parsed via `Keyword::from_str`). + Keyword(&'a str), +} + +/// Build the `TargetFilter` for a parsed zone-change object predicate. Copula → +/// type filter (rejecting `Any`/leftover, as before); keyword → a single +/// `FilterProp::WithKeyword` typed filter, mirroring the "it has [keyword]" arm. +fn zone_change_object_predicate_filter( + predicate: ZoneChangeObjectPredicate<'_>, +) -> Option { + match predicate { + ZoneChangeObjectPredicate::Type(type_text) => { + let (filter, leftover) = parse_type_phrase(type_text); + if matches!(filter, TargetFilter::Any) || !leftover.trim().is_empty() { + return None; + } + Some(filter) + } + ZoneChangeObjectPredicate::Keyword(keyword_text) => { + let keyword: Keyword = keyword_text + .trim() + .parse() + .unwrap_or(Keyword::Unknown(String::new())); + if matches!(keyword, Keyword::Unknown(_)) { + return None; + } + Some(TargetFilter::Typed(TypedFilter { + properties: vec![FilterProp::WithKeyword { value: keyword }], + ..Default::default() + })) + } + } +} + fn parse_zone_change_object_matches_filter_condition(lower: &str) -> Option { - let (type_text, negated) = parse_zone_change_object_type_text(lower).ok()?.1; - let (filter, leftover) = parse_type_phrase(type_text); - if matches!(filter, TargetFilter::Any) || !leftover.trim().is_empty() { + let (predicate, negated) = parse_zone_change_object_type_text(lower).ok()?.1; + // Copula-form only. The keyword form (`has/doesn't have `) is + // reachable through this ungated leading-conditional route + // (`strip_leading_general_conditional` → `try_nom_condition_as_ability_condition`), + // which runs BEFORE the dedicated `strip_target_keyword_instead` stripper. + // Accepting `Keyword` here would hijack CR 608.2c "If that creature has + // , [effect] instead" cards (Porcelain Zealot, Cut Propulsion, + // Burn the Impure, Compleat Devotion, Hexgold Slash) into a + // `ZoneChangeObjectMatchesFilter` that only reads `current_trigger_event` + // (always `None` off-trigger), permanently killing their "instead" branch. + // The keyword form must flow ONLY through the `ctx.in_trigger`-gated + // `parse_zone_change_object_has_keyword_condition` (Mutable Pupa's rider). + if matches!(predicate, ZoneChangeObjectPredicate::Keyword(_)) { return None; } + let filter = zone_change_object_predicate_filter(predicate)?; Some(maybe_negate( AbilityCondition::ZoneChangeObjectMatchesFilter { @@ -6624,6 +6772,31 @@ fn parse_zone_change_object_matches_filter_condition(lower: &str) -> Option" / "that permanent has " (Mutable +/// Pupa's per-keyword riders). Split out from +/// `parse_zone_change_object_matches_filter_condition` so `strip_suffix_conditional` +/// can early-except ONLY this form (its `"that has "` prefixes live in +/// `NON_REHOMEABLE_CONDITION_PREFIXES`), while the copula form keeps flowing +/// through its existing downstream `try_nom_condition_as_ability_condition` route. +pub(super) fn parse_zone_change_object_has_keyword_condition( + lower: &str, +) -> Option { + let (predicate, negated) = parse_zone_change_object_type_text(lower).ok()?.1; + if !matches!(predicate, ZoneChangeObjectPredicate::Keyword(_)) { + return None; + } + let filter = zone_change_object_predicate_filter(predicate)?; + Some(maybe_negate( + AbilityCondition::ZoneChangeObjectMatchesFilter { + origin: None, + destination: Zone::Battlefield, + filter, + }, + negated, + )) +} + /// CR 608.2c + CR 400.7j: "[effect] if at least one was this /// way" — the trailing (suffix) form of the prior-effect outcome gate. Later /// text may reference an earlier instruction in the same effect, and objects @@ -6664,9 +6837,20 @@ fn parse_outcome_this_way_condition(lower: &str) -> Option { )) } +/// Predicate-head discriminant for `parse_zone_change_object_type_text`: whether +/// the matched head was the copula (type phrase follows) or the "has" form +/// (keyword follows), plus the negation flag. Local selector so the remainder +/// `&str` (only known after the `alt` matches) maps into the typed +/// `ZoneChangeObjectPredicate` payload. +#[derive(Clone, Copy)] +enum PredicateHead { + Type, + Keyword, +} + fn parse_zone_change_object_type_text( input: &str, -) -> nom::IResult<&str, (&str, bool), OracleError<'_>> { +) -> nom::IResult<&str, (ZoneChangeObjectPredicate<'_>, bool), OracleError<'_>> { let (input, _) = tag("that ").parse(input)?; let (input, _) = alt(( tag("permanent"), @@ -6680,9 +6864,13 @@ fn parse_zone_change_object_type_text( tag("card"), )) .parse(input)?; - let (input, negated) = alt(( + // Two predicate forms share the `"that "` prefix: the copula + // (`is/isn't [a/an] `) and the keyword form (`has / doesn't have + // `). Composed as one `alt()` over the predicate heads; each arm + // yields `(negated, head)` and the remainder becomes the head's payload. + let (input, (negated, head)) = alt(( value( - true, + (true, PredicateHead::Type), alt(( tag(" is not an "), tag(" is not a "), @@ -6692,10 +6880,22 @@ fn parse_zone_change_object_type_text( tag(" isn't "), )), ), - value(false, alt((tag(" is an "), tag(" is a "), tag(" is ")))), + value( + (false, PredicateHead::Type), + alt((tag(" is an "), tag(" is a "), tag(" is "))), + ), + value( + (true, PredicateHead::Keyword), + alt((tag(" doesn't have "), tag(" does not have "))), + ), + value((false, PredicateHead::Keyword), tag(" has ")), )) .parse(input)?; - Ok(("", (input, negated))) + let predicate = match head { + PredicateHead::Type => ZoneChangeObjectPredicate::Type(input), + PredicateHead::Keyword => ZoneChangeObjectPredicate::Keyword(input), + }; + Ok(("", (predicate, negated))) } fn parse_target_supertype_condition_text(lower: &str) -> Option { @@ -6947,8 +7147,37 @@ mod tests { use super::*; use crate::parser::oracle_nom::condition::parse_inner_condition; use crate::parser::parse_oracle_text; + use crate::types::ability::{AggregateFunction, PlayerFilter, SharedQuality}; use crate::types::counter::{CounterMatch, CounterType}; + #[test] + fn strip_milled_shared_quality_conditional_maps_grindstone_gate() { + let (cond, rest) = strip_milled_shared_quality_conditional( + "if two cards that share a color were milled this way, repeat this process", + ); + assert_eq!(rest, "repeat this process"); + assert!( + matches!( + cond, + Some(AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ObjectCountBySharedQuality { + filter: TargetFilter::LastZoneChanged, + quality: SharedQuality::Color, + aggregate: AggregateFunction::Max, + .. + }, + .. + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 2 }, + .. + }) + ), + "got {cond:?}" + ); + } + /// CR 400.7 + CR 608.2c: S07 Batch 1 — the leading-"if" active-voice /// this-way gates must resolve to their typed conditions (previously they /// reached only the "when"-guarded combinators and dropped to `None`). @@ -7108,6 +7337,83 @@ mod tests { ); } + /// CR 102.2 / CR 102.3 + CR 603.2b + CR 608.2c: a leading + /// relationship-qualified phase-player condition is preserved with its + /// body and lowers to both existing condition legs. This exercises the + /// production leading-condition splitter, not just the leaf bridge. + #[test] + fn scoped_phase_player_opponent_and_hand_gate_preserves_leading_body() { + let mut ctx = ParseContext { + relative_player_scope: Some(ControllerRef::ScopedPlayer), + ..Default::default() + }; + let (condition, body) = strip_leading_general_conditional( + "If the player is your opponent and has four or more cards in hand, this enchantment deals 2 damage to that player", + &mut ctx, + ); + + assert_eq!( + body, "this enchantment deals 2 damage to that player", + "the leading gate must be peeled without changing the body" + ); + assert_eq!( + condition, + Some(AbilityCondition::And { + conditions: vec![ + AbilityCondition::ScopedPlayerMatches { + filter: PlayerFilter::Opponent, + }, + AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::HandSize { + player: PlayerScope::ScopedPlayer, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + ], + }) + ); + } + + /// The relationship grammar itself succeeds for every row (the reach + /// guard), but the effect bridge must decline unless the surrounding + /// context is exactly `ScopedPlayer`. This prevents an early grammar + /// failure from making any negative assertion vacuous. + #[test] + fn scoped_phase_player_opponent_bridge_declines_other_relative_scopes() { + let input = "the player is your opponent and has four or more cards in hand"; + let assert_declines = |scope: Option| { + assert!( + all_consuming(parse_scoped_player_opponent_and_has_condition) + .parse(input) + .is_ok(), + "reach guard: the direct relationship grammar must succeed" + ); + let mut ctx = ParseContext { + relative_player_scope: scope.clone(), + ..Default::default() + }; + assert_eq!( + try_nom_condition_as_ability_condition(input, &mut ctx), + None, + "non-scoped relative player must be declined: {scope:?}" + ); + }; + + for scope in [ + Some(ControllerRef::TriggeringPlayer), + Some(ControllerRef::TargetPlayer), + Some(ControllerRef::ParentTargetController), + Some(ControllerRef::DefendingPlayer), + Some(ControllerRef::SourceChosenPlayer), + None, + ] { + assert_declines(scope); + } + } + #[test] fn strip_target_keyword_instead_parses_toxic_as_typed_keyword() { // "if that creature has toxic, ..." must lower to a real Toxic keyword, diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index ab8ea00b1b..e62685ae94 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -25,6 +25,7 @@ use crate::parser::oracle_ir::diagnostic::OracleDiagnostic; use crate::parser::oracle_nom::bridge::{nom_on_lower, nom_parse_lower, split_once_on_lower}; use crate::parser::oracle_nom::primitives as nom_primitives; use crate::parser::oracle_nom::quantity as nom_quantity; +use crate::parser::oracle_nom::target as nom_target; use crate::parser::oracle_static::parse_activated_ability_cost_head; use crate::parser::oracle_static::{ parse_continuous_modifications, parse_may_look_at_face_down_filter, @@ -34,11 +35,11 @@ use crate::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, BounceSelection, CardSelectionMode, CategoryChooserScope, ChoiceType, Chooser, ContinuousModification, ControlWindow, ControllerRef, CopyRetargetPermission, CounterAdjustment, DigSource, DoorLockOp, Duration, - Effect, EffectScope, FaceDownProfile, FilterProp, GrantedAbilityScope, LibraryPosition, - MultiTargetSpec, OutsideGameSourcePool, PlayerScope, PreventionAmount, PreventionScope, PtStat, - PtValue, QuantityExpr, QuantityRef, ReassembleControlMode, SearchSelectionConstraint, - StaticDefinition, StickerTicketCostPayment, TapStateChange, TargetFilter, TargetSelectionMode, - TypeFilter, TypedFilter, ZoneOwner, + Effect, EffectScope, FaceDownProfile, FilterProp, ForceBlockAttackerRef, GrantedAbilityScope, + LibraryPosition, MultiTargetSpec, OutsideGameSourcePool, PlayerScope, PreventionAmount, + PreventionScope, PtStat, PtValue, QuantityExpr, QuantityRef, ReassembleControlMode, + SearchSelectionConstraint, StaticDefinition, StickerTicketCostPayment, TapStateChange, + TargetFilter, TargetSelectionMode, TypeFilter, TypedFilter, ZoneOwner, }; use crate::types::card_type::CoreType; use crate::types::phase::Phase; @@ -2359,10 +2360,13 @@ pub(super) fn lower_targeted_action_ast(ast: TargetedImperativeAst) -> Effect { enter_tapped, enter_with_counters, } => { - let origin = if matches!(target, TargetFilter::ExiledBySource) { - Some(Zone::Exile) - } else { - origin + let origin = match &target { + TargetFilter::ExiledBySource => Some(Zone::Exile), + TargetFilter::TrackedSetFiltered { + caused_by: Some(crate::types::ability::ThisWayCause::Exiled), + .. + } => Some(Zone::Exile), + _ => origin, }; Effect::ChangeZoneAll { origin, @@ -5051,6 +5055,71 @@ pub(super) fn lower_choose_ast(ast: ChooseImperativeAst) -> Effect { } } +/// CR 710.4 + CR 608.2k: Which anaphor class names the permanent in a +/// "flip <x>" instruction. The two classes bind differently — a self-deictic +/// always names the object the ability is on, while a bare object pronoun +/// routes through `resolve_pronoun_target`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FlipSubjectAnaphor { + SelfDeictic, + ObjectPronoun, +} + +/// CR 710.4: `flip`/`flips` + the permanent being flipped, anchored at `eof`. +/// +/// This covers the ENTIRE printed flip-card corpus (21 cards), which uses +/// exactly three surface forms: +/// - "flip this creature" — Budoka Gardener, Bushi Tenderfoot, Initiate of +/// Blood, Jushi Apprentice, Nezumi Graverobber, Nezumi Shortfang, Orochi +/// Eggwatcher +/// - "flip it" — Akki Lavarunner, Kitsune Mystic, Student of Elements, and the +/// five ki-counter Ascendants (Budoka Pupil, Callow Jushi, Cunning Bandit, +/// Faithful Squire, Hired Muscle) +/// - "flip <name>" — Erayo, Kuon, Rune-Tail, Sasaya; `normalize_card_name_refs` +/// has already rewritten the legendary short name to `~` before this runs +/// +/// CR 710 defines no "flip target <permanent>" form — a flip instruction is +/// always self-referential — so this recognizer is deliberately closed over +/// self-references and the bare object pronoun rather than falling through to +/// `parse_target`. That closure is also what keeps CR 705.1 coin flips out: +/// "flip a coin" matches no arm here, and the coin recognizers in +/// `parse_imperative_family_ast` are tried first regardless. +fn parse_flip_permanent_subject(input: &str) -> OracleResult<'_, FlipSubjectAnaphor> { + let (input, _) = alt((tag("flip "), tag("flips "))).parse(input)?; + let (input, anaphor) = alt(( + // Longest-match first: with the `eof` anchor outside this `alt`, a bare + // "it" arm would consume the head of "itself" and then fail without + // backtracking. + value( + FlipSubjectAnaphor::ObjectPronoun, + alt((tag("itself"), tag("it"))), + ), + value(FlipSubjectAnaphor::SelfDeictic, tag("~")), + // CR 700.7: "this" / "this " self-deictics ("this + // [something]" refers to that particular object), nested on the shared + // "this" prefix. + value( + FlipSubjectAnaphor::SelfDeictic, + preceded( + tag("this"), + opt(preceded( + tag(" "), + alt(( + tag("creature"), + tag("permanent"), + tag("artifact"), + tag("enchantment"), + tag("land"), + )), + )), + ), + ), + )) + .parse(input)?; + let (input, _) = eof.parse(input)?; + Ok((input, anaphor)) +} + pub(super) fn parse_utility_imperative_ast( text: &str, lower: &str, @@ -5253,6 +5322,25 @@ pub(super) fn parse_utility_imperative_ast( return Some(UtilityImperativeAst::Transform { target }); } } + // CR 710.4: the Kamigawa flip-card instruction. See + // `parse_flip_permanent_subject` for the corpus and the coin-flip + // separation (CR 705.1). + if let Some(anaphor) = nom_parse_lower(lower, parse_flip_permanent_subject) { + return Some(UtilityImperativeAst::FlipPermanent { + target: match anaphor { + // CR 700.7 + CR 201.5: a self-deictic always names the object + // the ability is on — CR 700.7 governs the "this " form + // ("this [something]" refers to that particular object), CR + // 201.5 the "~" name form (text referring to the object by name). + FlipSubjectAnaphor::SelfDeictic => TargetFilter::SelfRef, + // CR 608.2k: the bare object pronoun binds through the same + // anaphor dispatch as "transform it" — a typed trigger subject + // resolves to the triggering source, self-ref/any/none stays on + // the source. + FlipSubjectAnaphor::ObjectPronoun => resolve_pronoun_target(ctx, "it"), + }, + }); + } // CR 613.4d: switch power and toughness — two surface forms (sibling branches): // - prepositional: "switch the power and toughness of " (Inversion // Behemoth class — supports the "(each of) any number of target X" @@ -5661,6 +5749,8 @@ pub(super) fn lower_utility_imperative_ast(ast: UtilityImperativeAst) -> Effect starting_loyalty_from_casualty_sacrifice: false, }, UtilityImperativeAst::Transform { target } => Effect::Transform { target }, + // CR 710.4: Kamigawa flip cards. + UtilityImperativeAst::FlipPermanent { target } => Effect::FlipPermanent { target }, UtilityImperativeAst::Attach { attachment, target, .. } => Effect::Attach { attachment, target }, @@ -6524,17 +6614,31 @@ pub(super) fn lower_put_ast(ast: PutImperativeAst) -> Effect { choice_count: _, enter_with_counters, } => { - // CR 610.3: Mass filters (ExiledBySource, TrackedSet) act on all matching - // objects without individual targeting — use ChangeZoneAll. + // CR 610.3: Mass filters (ExiledBySource, TrackedSet, + // TrackedSetFiltered) act on all matching objects without individual + // targeting — use ChangeZoneAll. Bounded "up to N" picks from the + // tracked set ("put up to one land discarded this way") remain + // `ChangeZone` so the player selects a subset at resolution. // ExiledBySource always originates from Exile regardless of inferred zone. // CR 122.1: ChangeZoneAll has no counter-stamping channel — those // patterns are single-target only in current Oracle text, so the // mass-filter branch deliberately drops `enter_with_counters`. if matches!( target, - TargetFilter::ExiledBySource | TargetFilter::TrackedSet { .. } + TargetFilter::ExiledBySource + | TargetFilter::TrackedSet { .. } + | TargetFilter::TrackedSetFiltered { .. } ) && enter_with_counters.is_empty() + && !up_to { + let origin = match target { + TargetFilter::TrackedSetFiltered { + caused_by: Some(crate::types::ability::ThisWayCause::Exiled), + .. + } => origin.or(Some(Zone::Exile)), + TargetFilter::TrackedSetFiltered { .. } => origin, + _ => origin.or(Some(Zone::Exile)), + }; Effect::ChangeZoneAll { // CR 608.2c + CR 400.7: A tracked-set / impulse mass move // defaults to scanning Exile (cascade, impulse-draw, and the @@ -6543,7 +6647,7 @@ pub(super) fn lower_put_ast(ast: PutImperativeAst) -> Effect { // (Breach the Multiverse's graveyard choose stamps // `origin: Some(Graveyard)` in `parse_put_ast`), honor it so // the chosen cards are read out of the right zone. - origin: origin.or(Some(Zone::Exile)), + origin, destination, target, // CR 110.2a: Preserve the parsed entering-controller override @@ -8274,7 +8378,12 @@ pub(super) fn parse_exile_ast( } else { parsed_target }; - let origin = super::infer_origin_zone(rest_lower); + // CR 400.7 (issue #6505): infer the origin zone excluding ONLY a trailing + // compound conjunct ("and their graveyard", Strategic Betrayal) so its + // sibling graveyard leg cannot leak Zone::Graveyard onto the battlefield + // creature leg — while a non-"and" qualifier ("instead of putting it into + // its owner's graveyard") still defines this leg's origin. + let origin = super::infer_origin_zone(&super::compound_exile_origin_scan(rest_text, rem)); Some(ZoneCounterImperativeAst::Exile { origin, target, @@ -10040,6 +10149,15 @@ pub(super) fn parse_imperative_family_ast( .parse(lower) .ok() .map(|(_, ast)| ast) + // CR 710.4 vs CR 705.1: every coin-flip form is tried first, so the + // Kamigawa flip-card instruction ("flip this creature" / "flip it" / + // "flip ") can only reach this fallback once no coin arm has + // matched. `parse_flip_permanent_subject` is itself closed over + // self-references, so the two mechanics cannot collide either way. + .or_else(|| { + parse_utility_imperative_ast(text, lower, ctx) + .map(|ast| ImperativeFamilyAst::Structured(ImperativeAst::Utility(ast))) + }) } // CR 701.52: "roll to visit your Attractions" (not a generic d20/d6 roll). "roll" | "rolls" => { @@ -10231,18 +10349,9 @@ pub(super) fn parse_imperative_family_ast( // ── Combat-related ── - // CR 509.1g: "block [object] this turn/combat if able" - // Handles: "block this turn if able", "blocks ~ this turn if able", - // "blocks it this combat if able", "blocks this creature this turn if able" - "block" | "blocks" => { - if nom_primitives::scan_contains(lower, "this turn if able") - || nom_primitives::scan_contains(lower, "this combat if able") - { - Some(ImperativeFamilyAst::ForceBlock) - } else { - None - } - } + // CR 509.1c: the named attacker is a semantic referent, not a source-id + // inference. "that Wolf" means the narrowed attacking event subject. + "block" | "blocks" => parse_force_block_ast(lower, ctx), // CR 509.1c: "must be blocked [this turn] [if able]" "must" => { if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("must be blocked").parse(lower) { @@ -11735,6 +11844,87 @@ pub(super) fn lower_imperative_family_ast(ast: ImperativeFamilyAst) -> ParsedEff } } +/// CR 509.1c: Parse the force-block grammar after preserving the named attacker +/// axis. This is intentionally a small nom production rather than a text +/// substring check: the referent and duration survive lowering independently. +fn parse_force_block_ast(input: &str, ctx: &ParseContext) -> Option { + let (rest, _) = alt((tag::<_, _, OracleError<'_>>("block "), tag("blocks "))) + .parse(input) + .ok()?; + let parse_duration = |tail| { + all_consuming(terminated( + alt(( + value( + Duration::UntilEndOfTurn, + tag::<_, _, OracleError<'_>>("this turn if able"), + ), + value(Duration::UntilEndOfCombat, tag("this combat if able")), + )), + opt(tag(".")), + )) + .parse(tail) + .ok() + .map(|(_, duration)| duration) + }; + + if let Some(duration) = parse_duration(rest) { + return Some(ImperativeFamilyAst::ForceBlock { + attacker: None, + duration, + }); + } + if let Ok((tail, _)) = + alt((tag::<_, _, OracleError<'_>>("~ "), tag("this creature "))).parse(rest) + { + if let Some(duration) = parse_duration(tail) { + return Some(ImperativeFamilyAst::ForceBlock { + attacker: Some(ForceBlockAttackerRef::Source), + duration, + }); + } + } + if let Ok((tail, _)) = tag::<_, _, OracleError<'_>>("it ").parse(rest) { + if ctx.in_trigger && matches!(ctx.subject.as_ref(), Some(TargetFilter::Typed(_))) { + if let Some(duration) = parse_duration(tail) { + return Some(ImperativeFamilyAst::ForceBlock { + attacker: Some(ForceBlockAttackerRef::EventSource), + duration, + }); + } + } + } + // CR 603.2 + CR 506.3: an event-referential demonstrative needs a typed + // attacking-creature antecedent from a trigger. The shared nom production + // rejects arbitrary text and non-attacker nouns before this effect is built. + if ctx.in_trigger && matches!(ctx.subject.as_ref(), Some(TargetFilter::Typed(_))) { + if let Ok((tail, _referent)) = nom_target::parse_demonstrative_attacker_referent(rest) { + if let Some(duration) = parse_duration(tail) { + return Some(ImperativeFamilyAst::ForceBlock { + attacker: Some(ForceBlockAttackerRef::EventSource), + duration, + }); + } + } + } + + // Preserve the existing generic grammar: modifiers between "block(s)" and + // the duration still produce an attacker-agnostic requirement. Exact + // source/event referents above take precedence when their typed forms match. + if nom_primitives::scan_contains(rest, "this turn if able") { + Some(ImperativeFamilyAst::ForceBlock { + attacker: None, + duration: Duration::UntilEndOfTurn, + }) + } else if nom_primitives::scan_contains(rest, "this combat if able") { + Some(ImperativeFamilyAst::ForceBlock { + attacker: None, + duration: Duration::UntilEndOfCombat, + }) + } else { + None + } +} + fn lower_imperative_family_effect(ast: ImperativeFamilyAst) -> Effect { match ast { ImperativeFamilyAst::Structured(ast) => lower_imperative_ast(ast), @@ -11745,8 +11935,10 @@ fn lower_imperative_family_effect(ast: ImperativeFamilyAst) -> Effect { target: TargetFilter::Any, count: QuantityExpr::Fixed { value: 1 }, }, - ImperativeFamilyAst::ForceBlock => Effect::ForceBlock { + ImperativeFamilyAst::ForceBlock { attacker, duration } => Effect::ForceBlock { target: TargetFilter::Any, + attacker, + duration, }, ImperativeFamilyAst::ForceAttack { duration, diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index b7e2581a49..513d7aa6a3 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -1611,7 +1611,10 @@ fn filter_mentions_exiled_by_source(filter: &TargetFilter) -> bool { /// CR 115.1: True when a `ChangeZone` clause selects from the battlefield /// (explicitly or by permanent-type default) rather than a private/off-BF zone. -fn change_zone_selects_battlefield_permanent(origin: Option, target: &TargetFilter) -> bool { +pub(super) fn change_zone_selects_battlefield_permanent( + origin: Option, + target: &TargetFilter, +) -> bool { if target.is_context_ref() { return false; } @@ -1635,9 +1638,22 @@ pub(super) fn target_choice_timing_for_clause(clause_ir: &ClauseIr) -> TargetCho .fragment() .unwrap_or_default() .to_ascii_lowercase(); - if !nom_primitives::scan_contains(&lower, "target ") - && target.contains_source_attachment_host() - { + // CR 115.10a: an object is a target only if the text uses the literal + // word "target"; CR 608.2d: an untargeted choice is made "while + // applying the effect" (at resolution), not at announcement. Was + // previously scoped to `contains_source_attachment_host()` alone + // (Equipped/Enchanted-host counters, e.g. "put a loyalty counter on + // the equipped creature" — deterministic, no player choice). Widened + // to every untargeted `PutCounter` recipient that isn't already a + // deterministic `is_context_ref()` shape (SelfRef/ParentTarget/None/…, + // which resolve automatically regardless of timing) — this is the + // same generalization `MultiplyCounter` below already applies. Covers + // "put a keyword counter on any creature you control" (Kathril, + // Aspect Warper, issue #6321/#6533): each independent instruction in a + // replicated keyword-counter chain must offer its own untargeted + // choice at ITS OWN resolution (CR 608.2d), not inherit one shared + // choice made once when the whole ability went on the stack. + if !nom_primitives::scan_contains(&lower, "target ") && !target.is_context_ref() { return TargetChoiceTiming::Resolution; } } @@ -2501,6 +2517,332 @@ pub(super) fn is_token_creating_effect(effect: &Effect) -> bool { ) } +/// CR 603.12 + CR 609.3: Re-link a clause that READS the just-created-token +/// referent published by a clause under an AFFIRMATIVE reflexive gate. +/// +/// A reflexive gate ("When you do", "If you do") means the antecedent may not +/// have happened, in which case its clause created no token. A following clause +/// whose only subject is that token ("Put a +1/+1 counter on that token") is +/// then not the next independent instruction (CR 608.2c) — it is an instruction +/// that can do nothing at all (CR 609.3: an effect does only as much as +/// possible). Tagging it `SequentialSibling` makes the resolver's +/// condition-false descent resolve it anyway, and `TargetFilter::LastCreated` +/// resolves against `state.last_created_token_ids`, a GAME-LIFETIME ledger that +/// is never cleared at a resolution boundary — so it would bind a token from an +/// EARLIER resolution. +/// +/// Re-linking to `ContinuationStep` makes the clause a resolution step of the +/// instruction it is already attached to — and `gated_instruction_reaches` +/// restricts the pass to the case where that instruction is the gated +/// publisher's own, which is what the printed text means: it resolves when the +/// gate is true and is skipped when it is false. Because the whole clause moves, +/// every referent-reading position inside it moves with it — there is no +/// per-effect read-position list to keep in sync. +/// +/// Deliberately narrow: only a clause that reads the referent the gated clause +/// PUBLISHES, and that is not separated from it by an independent instruction, +/// is re-linked. A genuinely independent tail after a reflexive gate still +/// resolves (Springheart Nantuko's "If you didn't create a token this way" +/// complement; Scion of the Ur-Dragon's "Then shuffle", CR 701.23 + CR 701.24; +/// Localized Destruction's "Destroy all creatures"). +/// +/// This pass and the referent walk that seeds the binding +/// (`parser::oracle_effect::chain_prior_referent_is_created_token`) are two +/// halves of one rule and must not diverge: the walk predicts THIS pass's +/// acceptance before assembly, from the same two authorities — +/// `oracle_ir::ast::sub_link_after_boundary` and +/// [`instruction_spine_is_continuation`] — so a seed cannot land on a shape this +/// pass declines for a reason either authority can see. Widening either half +/// without the other re-opens the stale-`LastCreated` bind. The prediction's +/// blind spot (assembly-time `SequentialSibling` minters) is enumerated on +/// [`instruction_spine_is_continuation`]. +pub(super) fn relink_gated_token_referent_consumers(defs: &mut [AbilityDefinition]) { + for i in 0..defs.len() { + let Some(publisher) = defs[..i] + .iter() + .rposition(|d| is_token_creating_effect(&d.effect)) + else { + continue; + }; + if !defs[publisher] + .condition + .as_ref() + .is_some_and(AbilityCondition::is_affirmative_reflexive_gate) + { + continue; + } + if !gated_instruction_reaches(&defs[publisher..i]) { + continue; + } + if defs[i].sub_link == SubAbilityLink::SequentialSibling + && ability_reads_last_created(&defs[i]) + { + defs[i].sub_link = SubAbilityLink::ContinuationStep; + } + } +} + +/// CR 608.2c: Is the clause following `slice` still inside the gated +/// publisher's own instruction? +/// +/// `slice[0]` is the gated publisher; the remaining entries are the clauses +/// between it and the candidate consumer. `sub_link` describes the link to the +/// IMMEDIATELY preceding node, not to the publisher, and the resolver's +/// condition-false descent (`game::effects::resolve_ability_chain`) walks +/// `sub_ability` from the gated node and resolves the FIRST node whose +/// `sub_link` is `SequentialSibling` — together with that node's entire +/// sub-chain. So re-tagging a consumer that sits behind an intervening +/// `SequentialSibling` would only make it a continuation step of THAT sibling: +/// the descent selects the sibling and resolves the consumer anyway, changing +/// the link for nothing. Requiring an unbroken continuation path is what makes +/// the re-tag mean what `SubAbilityLink::ContinuationStep` says it means. +/// +/// Each node's own within-clause spine is checked too, via +/// [`instruction_spine_is_continuation`], because the chain assembler appends the +/// next clause to the DEEPEST `sub_ability`, so an internal `SequentialSibling` +/// rider also sits on the descent path. +fn gated_instruction_reaches(slice: &[AbilityDefinition]) -> bool { + slice.iter().enumerate().all(|(idx, def)| { + (idx == 0 || def.sub_link == SubAbilityLink::ContinuationStep) + && instruction_spine_is_continuation(def) + }) +} + +/// CR 608.2c: Is every node of this definition's own within-clause spine a +/// `ContinuationStep`? +/// +/// Shared by the two passes that must agree on "an unbroken continuation path +/// runs from the gated publisher to the consumer": `gated_instruction_reaches` +/// (above, over assembled `AbilityDefinition`s) and the referent walk +/// `parser::oracle_effect::chain_prior_referent_is_created_token` (over +/// `ClauseIr::parsed.sub_ability`, which is the same `AbilityDefinition` spine +/// before assembly appends the following clause to its deepest node). +/// +/// Not vacuous even though no shipped card exercises it today: PARSE-TIME +/// builders mint an internal `SequentialSibling` rider directly and hand it back +/// inside a `ParsedEffectClause` — [`try_parse_bidirectional_prevent`] here and +/// `oracle_effect::mod::try_parse_exile_play_grant_with_play_prohibition` — so +/// such a spine can reach both callers. +/// +/// SCOPE, stated so the seeder's use of it is not read as a proof: this sees the +/// parse-time spine only. Three ASSEMBLY-time sites mint a `SequentialSibling` +/// that no `ClauseIr` carries and that the referent walk therefore cannot +/// predict. Each would make `gated_instruction_reaches` stricter than the walk +/// predicted, i.e. leave a `LastCreated` bind the re-link does not protect: +/// +/// * [`attach_graveyard_redirect_rider_to_prior_cast_from_zone`] and +/// `absorb_last_created_riders` — each needs an `Effect::CastFromZone` / +/// `Effect::FlipCoins` antecedent, and the second MOVES its rider inside the +/// coin effect, off the top level entirely. +/// * `oracle_effect::mod::attach_repeat_process_keywords`, which pushes cloned +/// TOP-LEVEL siblings rather than a within-clause rider, and clones the +/// template's target VERBATIM. Closed at its binding site: `assembly.rs` +/// declines the binding when [`clone_would_transplant_gated_referent`] holds, +/// and that predicate decides by running THIS pass over the def vector the +/// clone would land in. So every clone that exists is one this pass either +/// re-tagged onto the gated instruction's continuation path or found honest +/// on its own (self-gated, or reading no gated referent at all). +/// +/// The backstop for all three is the invariant "no `SequentialSibling` node +/// reads `TargetFilter::LastCreated`". Two tests carry it, and NEITHER is a +/// corpus sweep — read them for what they cover before relying on them: +/// `bbfu9_no_stale_last_created_bind` asserts it over a FROZEN list of the 20 +/// cards whose AST this change moved, embedded verbatim (it cannot see a card +/// that acquires the shape later), and +/// `repeat_process_directive_never_joins_a_continuation_path` asserts it over +/// the repeat-process grammar's own fixtures. +pub(super) fn instruction_spine_is_continuation(def: &AbilityDefinition) -> bool { + let mut cursor = def.sub_ability.as_deref(); + while let Some(node) = cursor { + if node.sub_link != SubAbilityLink::ContinuationStep { + return false; + } + cursor = node.sub_ability.as_deref(); + } + true +} + +/// CR 111.1: Does this ability (or anything nested inside it) read the +/// just-created-token referent `TargetFilter::LastCreated`? Walks the whole +/// definition — target filter (including composite wrappers), `GenericEffect` +/// grant recipients, a `CreateDelayedTrigger`'s inner definition, modal modes, +/// and the within-clause sub/else chain — so the answer does not depend on an +/// enumeration of which `Effect` variants can carry the referent. +fn ability_reads_last_created(def: &AbilityDefinition) -> bool { + fn filter_reads(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::LastCreated => true, + TargetFilter::And { filters } | TargetFilter::Or { filters } => { + filters.iter().any(filter_reads) + } + TargetFilter::Not { filter } | TargetFilter::TrackedSetFiltered { filter, .. } => { + filter_reads(filter) + } + TargetFilter::ChosenDamageSource { filter } => { + filter.as_deref().is_some_and(filter_reads) + } + TargetFilter::None + | TargetFilter::Any + | TargetFilter::Player + | TargetFilter::Controller + | TargetFilter::ControllerAndControlledPermanents { .. } + | TargetFilter::Opponent + | TargetFilter::SelfRef + | TargetFilter::GrantingObject + | TargetFilter::SourceOrPaired + | TargetFilter::Typed(..) + | TargetFilter::StackAbility { .. } + | TargetFilter::StackSpell + | TargetFilter::SpecificObject { .. } + | TargetFilter::SpecificPlayer { .. } + | TargetFilter::PlayerWhoChoseLabel { .. } + | TargetFilter::Neighbor { .. } + | TargetFilter::ScopedPlayer + | TargetFilter::AttachedTo + | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged + | TargetFilter::CostPaidObject + | TargetFilter::ChosenCard + | TargetFilter::TrackedSet { .. } + | TargetFilter::ExiledBySource + | TargetFilter::ExiledCardByIndex { .. } + | TargetFilter::TriggeringSpellController + | TargetFilter::TriggeringSpellOwner + | TargetFilter::TriggeringPlayer + | TargetFilter::TriggeringSource + | TargetFilter::EventTarget + | TargetFilter::TriggeringSourceController + | TargetFilter::ParentTarget + | TargetFilter::ParentTargetSlot { .. } + | TargetFilter::ParentTargetController + | TargetFilter::ParentTargetOwner + | TargetFilter::SourceChosenPlayer + | TargetFilter::OriginalController + | TargetFilter::OriginalSource + | TargetFilter::PostReplacementSourceController + | TargetFilter::PostReplacementDamageSource + | TargetFilter::PostReplacementDamageTarget + | TargetFilter::PostReplacementDamageTargetOwner + | TargetFilter::DefendingPlayer + | TargetFilter::HasChosenName + | TargetFilter::Named { .. } + | TargetFilter::Owner + | TargetFilter::AllPlayers => false, + } + } + if def.effect.target_filter().is_some_and(filter_reads) { + return true; + } + match &*def.effect { + Effect::CreateDelayedTrigger { effect, .. } if ability_reads_last_created(effect) => { + return true; + } + Effect::GenericEffect { + static_abilities, .. + } if static_abilities + .iter() + .any(|s| s.affected.as_ref().is_some_and(filter_reads)) => + { + return true; + } + _ => {} + } + def.sub_ability + .as_deref() + .is_some_and(ability_reads_last_created) + || def + .else_ability + .as_deref() + .is_some_and(ability_reads_last_created) + || def.mode_abilities.iter().any(ability_reads_last_created) +} + +/// CR 603.12: Would replicating `defs[template]` at the TAIL of `defs` +/// transplant a gated publisher's just-created-token referent to a slot the +/// resolver can reach without that token? +/// +/// `oracle_effect::mod::attach_repeat_process_keywords` ("Repeat this process +/// for …") clones its template VERBATIM — target included — and pushes the +/// clones at the end of `defs`. That is position-independent unless the template +/// reads `TargetFilter::LastCreated`, which is a CHAIN-CONTEXT referent: +/// [`relink_gated_token_referent_consumers`] keeps such a read honest only while +/// an unbroken continuation path runs from the gated clause that published it, +/// and a clone landing off that path keeps `SubAbilityLink::SequentialSibling`. +/// The resolver's condition-false descent then resolves the clone anyway, and +/// `state.last_created_token_ids` is a game-lifetime ledger — so on a false gate +/// it binds a token from an EARLIER resolution. +/// +/// Two early returns bound the question, and neither is a guess about position: +/// +/// * a template that reads no `LastCreated` carries no chain-context referent at +/// all — nothing to transplant, wherever the clone lands; +/// * an UNGATED nearest publisher creates its token unconditionally during this +/// resolution, so the read is live at any position — that is BASE behaviour +/// and not a hazard. +/// +/// The remaining question — "is the clone honest where it lands?" — is not +/// re-derived here. It is ASKED, by building the def +/// [`super::attach_repeat_process_keywords`] will push +/// ([`super::repeat_process_clone_shape`], the shared authority for that shape) +/// and running [`relink_gated_token_referent_consumers`] over the result. The +/// clone is honest if either answer comes back yes: +/// +/// * the re-link re-tags it `ContinuationStep`, so it is a resolution step of +/// the gated instruction and the condition-false descent never selects it; or +/// * it is SELF-GATED — [`AbilityDefinition::is_self_gated_reflexive`] — so the +/// descent's own false-condition skip drops it wherever it sits. +/// +/// Running the pass rather than predicting it is what makes the answer exact. +/// The prediction has to model the pass's ORDER (the template is re-tagged +/// before the clone is examined, so a `Sentence`-joined template that is still +/// `SequentialSibling` at this point must be treated as if it were not) and the +/// pass's choice of publisher for the clone (a LATER token creator becomes the +/// clone's own nearest publisher). Both were hand-modelled before and both are +/// now simply what the pass does. +/// +/// The probe is not byte-for-byte the finished chain, in two ways, and neither +/// changes the answer — measured on purpose-built fixtures, not argued: +/// +/// * the probe DEF: the clone the caller actually pushes differs only in its +/// `counter_type` and in keyword payloads rewritten inside `QuantityCheck` / +/// `TargetHasKeywordInstead` / `SourceLacksKeyword` +/// (`super::rewrite_ability_condition_keyword`) — no field either answer reads. +/// * the probe VECTOR: later passes APPEND defs after this binding runs, so the +/// vector here is a PREFIX of the finished chain (a fixture ending "… Repeat +/// this process for first strike. Then create a Soldier token." is examined at +/// 4 defs and finishes at 6), and the caller pushes one clone per listed +/// keyword where this pushes one. Both are benign: the re-link's verdict for a +/// node is a function of the defs BEFORE it, which appends leave index-stable, +/// and every clone is a copy of the same template landing consecutively on the +/// same path — two-keyword fixtures emit or decline both clones together, +/// never split. +pub(super) fn clone_would_transplant_gated_referent( + defs: &[AbilityDefinition], + template: usize, +) -> bool { + if !ability_reads_last_created(&defs[template]) { + return false; + } + let Some(publisher) = defs[..template] + .iter() + .rposition(|d| is_token_creating_effect(&d.effect)) + else { + return false; + }; + if !defs[publisher] + .condition + .as_ref() + .is_some_and(AbilityCondition::is_affirmative_reflexive_gate) + { + return false; + } + let mut probe = defs.to_vec(); + probe.push(super::repeat_process_clone_shape(&defs[template])); + relink_gated_token_referent_consumers(&mut probe); + let landed = probe.last().expect("pushed just above"); + landed.sub_link == SubAbilityLink::SequentialSibling && !landed.is_self_gated_reflexive() +} + /// CR 301.5 + CR 303.4: True when the nearest preceding token creator makes /// an Equipment or Aura token. Used to prefer the `attachment` slot for the /// post-token anaphor rewrite (`rewrite_parent_target_to_last_created`): in @@ -3588,15 +3930,39 @@ pub(crate) fn target_filter_is_single_object_target(filter: &TargetFilter) -> bo } } +/// #5994: whether the per-opponent fanout slot is optional (min 0) or +/// mandatory (min 1), for verbs that fall through to this detector because +/// they aren't in `MULTI_TARGET_VERBS` (e.g. "put", "gain control of") — a +/// `MULTI_TARGET_VERBS` verb like "exile" takes its min from +/// `stripped_multi_target` upstream and never reaches this function. Scans at +/// word boundaries for an "up to N target …" quantifier anywhere in the +/// clause, not just immediately after the verb, so one detector covers every +/// non-`MULTI_TARGET_VERBS` verb instead of each needing its own hardcoded +/// prefix (the prior version only recognized "gain control of "). This does +/// NOT recognize "any number of target …" — that arm lives in +/// `strip_leading_quantifier`, which this function doesn't call; no card in +/// the per-opponent-fanout class currently uses that form. Reusing +/// `strip_optional_target_prefix` (rather than the bare `strip_leading_quantifier` +/// used by `MULTI_TARGET_VERBS`) is the safety property this relies on: it only +/// accepts a quantifier immediately followed by "target "/"other target "/ +/// "another target ", so it can't misfire on a resource-count quantifier that +/// happens to precede the object noun (e.g. "put up to three +1/+1 counters on +/// target creature" — the quantity there modifies the counters, not the +/// target, and the "target " guard declines it). fn per_opponent_target_fanout_min(text: &str) -> usize { let lower = text.to_ascii_lowercase(); - let Some((_, rest)) = nom_on_lower(text, &lower, |input| { - value((), tag("gain control of ")).parse(input) - }) else { - return 1; - }; - let (_, spec) = strip_optional_target_prefix(rest); - if spec.is_some_and(|spec| spec.min_is_fixed_zero()) { + let found_optional_target_slot = + nom_primitives::scan_at_word_boundaries(lower.as_str(), |input| { + match strip_optional_target_prefix(input) { + (rest, Some(spec)) if spec.min_is_fixed_zero() => Ok((rest, ())), + _ => Err(nom::Err::Error(OracleError::new( + input, + nom::error::ErrorKind::Fail, + ))), + } + }) + .is_some(); + if found_optional_target_slot { 0 } else { 1 @@ -9025,6 +9391,8 @@ fn apply_where_x_continuous_modification( // Keep this wildcard-free so a future QuantityExpr-carrying variant // forces a deliberate where-X decision. ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): inert copy marker — no where-X carrier. + | ContinuousModification::CopyChosen | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } @@ -9055,6 +9423,8 @@ fn apply_where_x_continuous_modification( | ContinuousModification::AddColor { .. } | ContinuousModification::AddStaticMode { .. } | ContinuousModification::GrantStaticAbility { .. } + // Granted object-hosted replacement: no where-X / anaphoric magnitude. + | ContinuousModification::GrantReplacement { .. } | ContinuousModification::SwitchPowerToughness | ContinuousModification::AssignDamageFromToughness | ContinuousModification::AssignDamageAsThoughUnblocked @@ -9123,6 +9493,8 @@ fn rebind_target_anaphor_continuous_modification(modification: &mut ContinuousMo ContinuousModification::AddCounterOnEnter { .. } | ContinuousModification::SetStartingLoyalty { .. } => {} ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): inert copy marker — no where-X carrier. + | ContinuousModification::CopyChosen | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } @@ -9154,6 +9526,8 @@ fn rebind_target_anaphor_continuous_modification(modification: &mut ContinuousMo | ContinuousModification::AddColor { .. } | ContinuousModification::AddStaticMode { .. } | ContinuousModification::GrantStaticAbility { .. } + // Granted object-hosted replacement: no where-X / anaphoric magnitude. + | ContinuousModification::GrantReplacement { .. } | ContinuousModification::SwitchPowerToughness | ContinuousModification::AssignDamageFromToughness | ContinuousModification::AssignDamageAsThoughUnblocked @@ -9905,15 +10279,17 @@ mod tests { use super::{ match_create_of_those_tokens, nest_whenever_this_turn_token_cleanup_delayed_trigger, parse_where_x_quantity_expression, patch_choose_from_zone_counter_continuation_target, - strip_redundant_flip_win_quantifier, strip_return_destination_ext_with_remainder, - strip_temporal_prefix, strip_temporal_suffix, strip_trailing_duration, - strip_trailing_where_x, value_quantity_clause_owns_this_turn_suffix, + relink_gated_token_referent_consumers, strip_redundant_flip_win_quantifier, + strip_return_destination_ext_with_remainder, strip_temporal_prefix, strip_temporal_suffix, + strip_trailing_duration, strip_trailing_where_x, + value_quantity_clause_owns_this_turn_suffix, }; use crate::parser::oracle_util::TextPair; use crate::types::ability::{ - AbilityDefinition, AbilityKind, AggregateFunction, ContinuousModification, - DelayedTriggerCondition, Duration, Effect, ObjectProperty, ObjectScope, PtValue, - QuantityExpr, QuantityRef, TargetFilter, TriggerDefinition, + AbilityCondition, AbilityDefinition, AbilityKind, AggregateFunction, + ContinuousModification, DelayedTriggerCondition, Duration, Effect, ModalChoice, + ObjectProperty, ObjectScope, PtValue, QuantityExpr, QuantityRef, SubAbilityLink, + TargetFilter, TriggerDefinition, }; use crate::types::counter::CounterType; use crate::types::phase::Phase; @@ -9936,6 +10312,110 @@ mod tests { } } + fn gated_token_creator_for_relink() -> AbilityDefinition { + let mut creator = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Token { + name: "Soldier".to_string(), + power: PtValue::Fixed(1), + toughness: PtValue::Fixed(1), + types: vec!["Creature".to_string(), "Soldier".to_string()], + colors: vec![], + keywords: vec![], + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to: None, + enters_attacking: false, + supertypes: vec![], + static_abilities: vec![], + enter_with_counters: vec![], + }, + ); + creator.condition = Some(AbilityCondition::WhenYouDo); + creator + } + + fn last_created_consumer_for_relink(target: TargetFilter) -> AbilityDefinition { + let mut consumer = AbilityDefinition::new( + AbilityKind::Spell, + Effect::PutCounter { + counter_type: CounterType::Plus1Plus1, + count: QuantityExpr::Fixed { value: 1 }, + target, + }, + ); + consumer.sub_link = SubAbilityLink::SequentialSibling; + consumer + } + + /// CR 603.12 + CR 608.2c: a token referent hidden by any `TargetFilter` + /// wrapper still makes the following clause dependent on the gated token + /// creator, so the false branch cannot bind an earlier resolution's token. + #[test] + fn relink_follows_last_created_through_target_filter_wrappers() { + let wrapped_filters = [ + TargetFilter::Not { + filter: Box::new(TargetFilter::LastCreated), + }, + TargetFilter::TrackedSetFiltered { + id: crate::types::identifiers::TrackedSetId(0), + filter: Box::new(TargetFilter::LastCreated), + caused_by: None, + }, + TargetFilter::ChosenDamageSource { + filter: Some(Box::new(TargetFilter::LastCreated)), + }, + ]; + + for filter in wrapped_filters { + let mut defs = vec![ + gated_token_creator_for_relink(), + last_created_consumer_for_relink(filter), + ]; + relink_gated_token_referent_consumers(&mut defs); + assert_eq!( + defs[1].sub_link, + SubAbilityLink::ContinuationStep, + "a wrapped LastCreated reader must stay on the gated creator's continuation path" + ); + } + } + + /// CR 700.2 + CR 603.12: modal mode bodies are part of the containing + /// definition for the re-link decision. A `LastCreated` reader in a chosen + /// mode must not remain a standalone sibling of its gated token creator. + #[test] + fn relink_follows_last_created_through_modal_modes() { + let modal = ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 1, + ..Default::default() + }; + let consumer = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ) + .with_modal( + modal, + vec![last_created_consumer_for_relink(TargetFilter::LastCreated)], + ); + let mut defs = vec![gated_token_creator_for_relink(), consumer]; + defs[1].sub_link = SubAbilityLink::SequentialSibling; + + relink_gated_token_referent_consumers(&mut defs); + + assert_eq!( + defs[1].sub_link, + SubAbilityLink::ContinuationStep, + "a modal LastCreated reader must keep its wrapper on the gated continuation path" + ); + } + /// CR 608.2c: a `ChooseFromZone` head with a `RemoveCounter`/`PutCounter` /// `sub_ability` whose `target` is the `SelfRef` "it" anaphor (Amy Pond's /// "choose a suspended card you own and remove that many time counters from diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 7edf095edd..ae0b69c2a9 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -42,17 +42,17 @@ use lower::{ apply_where_x_quantity_expression, compute_sentence_where_x, consolidate_die_and_coin_defs, extract_deal_damage_multi_target, extract_double_counter_multi_target, extract_put_counter_multi_target, extract_remove_counter_multi_target, - extract_switch_pt_multi_target, is_token_creating_effect, parse_damage_player_scope, - parse_for_each_opponent_target_fanout_clause, rebind_clause_recipients_with, - rebind_decline_body_recipient, rebind_subject_only_body_recipient, - scan_until_next_same_source_exile_invalidation, split_difference_repeat_suffix, - strip_any_number_quantifier, strip_each_player_subject, strip_each_scope_who_cant_subject, - strip_each_scope_who_didnt_verb_filter_this_way_subject, strip_each_scope_who_does_subject, - strip_each_scope_who_doesnt_subject, strip_for_each_opponent_who_doesnt, strip_for_each_prefix, - strip_for_each_repeat_suffix, strip_leading_duration, strip_leading_quantifier, - strip_leading_return_destination_ext, strip_leading_sequence_connector, - strip_optional_effect_prefix, strip_player_scope_subject, strip_redundant_flip_win_quantifier, - strip_repeat_count_suffix, strip_return_destination_ext, + extract_switch_pt_multi_target, instruction_spine_is_continuation, is_token_creating_effect, + parse_damage_player_scope, parse_for_each_opponent_target_fanout_clause, + rebind_clause_recipients_with, rebind_decline_body_recipient, + rebind_subject_only_body_recipient, scan_until_next_same_source_exile_invalidation, + split_difference_repeat_suffix, strip_any_number_quantifier, strip_each_player_subject, + strip_each_scope_who_cant_subject, strip_each_scope_who_didnt_verb_filter_this_way_subject, + strip_each_scope_who_does_subject, strip_each_scope_who_doesnt_subject, + strip_for_each_opponent_who_doesnt, strip_for_each_prefix, strip_for_each_repeat_suffix, + strip_leading_duration, strip_leading_quantifier, strip_leading_return_destination_ext, + strip_leading_sequence_connector, strip_optional_effect_prefix, strip_player_scope_subject, + strip_redundant_flip_win_quantifier, strip_repeat_count_suffix, strip_return_destination_ext, strip_return_destination_ext_with_remainder, strip_temporal_prefix, strip_temporal_suffix, trim_dangling_target_word, try_parse_damage, try_parse_damage_with_remainder, try_parse_distribute_counters, try_parse_distribute_damage, @@ -103,11 +103,11 @@ use crate::types::ability::{ DelayedTriggerLifetime, DoubleTarget, Duration, Effect, EffectOutcomeSignal, EffectScope, FilterProp, GameRestriction, GuessSubject, IntensityScope, IterationKindBinding, KeeperConstraint, LibraryPosition, ManaProduction, ManaSpendPermission, MultiTargetSpec, - NumberDistinctness, ObjectProperty, ObjectScope, OriginConstraint, PlayPermissionInvalidation, - PlayerFilter, PlayerRelation, PlayerScope, PreventionAmount, PreventionScope, - ProhibitedActivity, PtValue, QuantityExpr, QuantityRef, ReplacementCondition, + NumberDistinctness, ObjectProperty, ObjectScope, OriginConstraint, PerpetualModification, + PlayPermissionInvalidation, PlayerFilter, PlayerRelation, PlayerScope, PreventionAmount, + PreventionScope, ProhibitedActivity, PtValue, QuantityExpr, QuantityRef, ReplacementCondition, ReplacementDefinition, RestrictionExpiry, RestrictionPlayerScope, RevealUntilDisposition, - RoundingMode, SharedQuality, SharedQualityRelation, SkipScope, + RoundingMode, SharedQuality, SharedQualityRelation, SiblingCondition, SkipScope, SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, StepSkipTarget, SubAbilityLink, TapStateChange, TargetFilter, TargetSelectionMode, ThisWayCause, TrackedAnaphorSource, TriggerCondition, TriggerDefinition, TypeFilter, TypedFilter, @@ -1318,11 +1318,21 @@ fn try_parse_when_next_event(tp: TextPair) -> Option { let effect_lower = after.lower; // Check for "that creature enters with an additional +1/+1 counter on it" pattern - let inner = if let Some(parsed) = try_parse_enters_with_additional_counters(effect_lower) { + let mut inner = if let Some(parsed) = try_parse_enters_with_additional_counters(effect_lower) { parsed } else { parse_effect_chain(effect_text, AbilityKind::Spell) }; + // CR 608.2k: In a "when you next cast a this turn" delayed trigger the + // "that " anaphor in the body names the newly-cast spell object (the + // trigger's event source), NOT a chosen target — a `WhenNextEvent` delayed + // trigger has no parent target to inherit. `parse_target` returns the + // subject-position anaphor as `ParentTarget` because trigger context is not + // threaded through the effect parser; lift it to `TriggeringSource` so the + // runtime binds the grant via `resolve_event_context_target` (effect.rs:259) + // instead of the empty chain-tracked set (effect.rs:474). Mirrors the + // `lift_parent_target_to_triggering_source` family in oracle_trigger.rs. + lift_generic_effect_parent_target_to_triggering_source_in_ability(&mut inner); if let Some((spell_filter, ability_filter)) = try_parse_when_next_spell_or_activate_disjunction(condition_fragment) @@ -1365,6 +1375,42 @@ fn try_parse_when_next_event(tp: TextPair) -> Option { }) } +/// CR 608.2k: Lift `StaticDefinition.affected: ParentTarget` → +/// `TriggeringSource` on every `Effect::GenericEffect` in a `WhenNextEvent` +/// delayed trigger's body, recursing through chained `sub_ability` links. +/// +/// A `WhenNextEvent` ("when you next cast a this turn, that spell +/// gains ") has no chosen/parent target — the just-cast spell object +/// IS the referent of "that spell". The effect parser lowers the subject- +/// position "that spell" anaphor to `ParentTarget` (oracle_target.rs) because +/// trigger context is not threaded through it. Left as `ParentTarget`, the +/// runtime registers the transient grant against `chain_tracked_set_id`, which +/// is empty for a delayed trigger, so the grant silently never lands (#5337, +/// Solar Array / Lux Artillery's when-next form). Rebinding to +/// `TriggeringSource` routes it through `resolve_event_context_target` +/// (effect.rs:259) — the same path Lux Artillery's non-delayed "it gains +/// sunburst" already uses. Only `GenericEffect`-borne grants are affected; +/// other effect variants (e.g. a delayed `ChangeZone` on the cast spell) are +/// out of scope for this class and left untouched. +fn lift_generic_effect_parent_target_to_triggering_source_in_ability( + ability: &mut AbilityDefinition, +) { + let mut node = Some(ability); + while let Some(link) = node { + if let Effect::GenericEffect { + static_abilities, .. + } = link.effect.as_mut() + { + for static_def in static_abilities.iter_mut() { + if matches!(static_def.affected, Some(TargetFilter::ParentTarget)) { + static_def.affected = Some(TargetFilter::TriggeringSource); + } + } + } + node = link.sub_ability.as_deref_mut(); + } +} + /// CR 603.7: Parse a generic non-cast "when you next this turn, /// " one-shot delayed trigger. Delegates condition recognition to the /// shared trigger-condition parser (`parse_trigger_condition`) so the whole @@ -1744,6 +1790,12 @@ fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> { value( (), alt(( + // CR 201.5 + CR 201.5b: `normalize_card_name_refs` (oracle_util.rs) + // rewrites every "this creature/permanent/land" self-reference to `~` + // card-wide before parsing, so a quoted rider reaching the parser reads + // "if ~ would leave the battlefield, ...". Match `~` first; it never + // prefix-collides with the "it"/"the …"/"that …"/"this …" arms below. + tag("~"), tag("it"), tag("the card"), tag("the creature"), @@ -1761,11 +1813,77 @@ fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> { .parse(input) } +/// CR 614.1a + CR 614.6: The object-hosted "exile this permanent instead of +/// letting it leave the battlefield" replacement. A `Moved` replacement whose +/// `valid_card: SelfRef` binds it to its own host object and whose `execute` +/// redirects any battlefield-exit to exile. Shared single authority for the +/// #6538 targeted-rider front door (`try_parse_leave_battlefield_exile_replacement`, +/// wrapping `target: Any`), unearth's synthesis (`database/unearth.rs`), and the +/// #6566 reanimation-rider grant path. Callers add their own +/// `AddTargetReplacement` / `GrantReplacement` wrapper. +/// +/// **The returned definition is deliberately UNSTAMPED (`expiry: None`); the +/// lifetime is the caller's decision.** The two *standalone* consumers (#6538's +/// front door and unearth) compose `.expiry(RestrictionExpiry::UntilHostLeavesPlay)` +/// themselves, because there the rider is a runtime effect bound to the object it +/// was installed on. The *granted* consumer (`classify_quoted_inner` → +/// `ContinuousModification::GrantReplacement`) must NOT: a granted replacement's +/// lifetime is governed by the granting continuous effect's duration and is +/// re-derived every layer pass (CR 613.1f), with the grant itself ending per +/// CR 611.2a. A host-lifetime stamp there would be read by #6538's machinery +/// (`is_runtime_host_lifetime_replacement` → base-install + non-copiable + +/// host-exit prune), so the granted rider would be base-installed and outlive the +/// continuous effect that granted it — Elemental Expressionist's until-end-of-turn +/// grant would never lapse. +/// +/// Production visibility is `pub(crate)`; the `test-support` feature widens it to +/// `pub` so integration tests (compiled with that feature) assemble the exact +/// production replacement shape instead of a hand-copied duplicate. Both arms +/// delegate to the single `_impl` body — mirrors the `game::zones` module +/// visibility gate in `game/mod.rs`. +#[cfg(any(test, feature = "test-support"))] +pub fn leave_battlefield_exile_replacement() -> ReplacementDefinition { + leave_battlefield_exile_replacement_impl() +} + +#[cfg(not(any(test, feature = "test-support")))] +pub(crate) fn leave_battlefield_exile_replacement() -> ReplacementDefinition { + leave_battlefield_exile_replacement_impl() +} + +fn leave_battlefield_exile_replacement_impl() -> ReplacementDefinition { + ReplacementDefinition::new(ReplacementEvent::Moved) + .valid_card(TargetFilter::SelfRef) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: Some(Zone::Battlefield), + destination: Zone::Exile, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + )) +} + /// CR 614.1a + CR 614.6: Detect "If it would leave the battlefield, exile it /// instead of putting it anywhere else." riders attached to a previously /// selected object. The carried `ReplacementDefinition` is installed on the /// parent target, where `valid_card: SelfRef` binds to that host object. -fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Option { +/// +/// This is the STANDALONE (printed-rider) front door, so it stamps the #6538 +/// host-lifetime expiry on the shared unstamped definition. Callers that grant +/// the rider as a continuous effect must use `leave_battlefield_exile_replacement` +/// directly and treat this function purely as a detector — see its doc. +pub(crate) fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Option { let (rest, _) = nom::combinator::opt(tag::<_, _, OracleError<'_>>("if ")) .parse(lower) .ok()?; @@ -1784,29 +1902,17 @@ fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Option .ok()?; parse_optional_period_and_end(rest)?; - let replacement = ReplacementDefinition::new(ReplacementEvent::Moved) - .valid_card(TargetFilter::SelfRef) - .execute(AbilityDefinition::new( - AbilityKind::Spell, - Effect::ChangeZone { - origin: Some(Zone::Battlefield), - destination: Zone::Exile, - target: TargetFilter::SelfRef, - owner_library: false, - enter_transformed: false, - enters_under: None, - enter_tapped: crate::types::zones::EtbTapState::Unspecified, - enters_attacking: false, - up_to: false, - enter_with_counters: vec![], - conditional_enter_with_counters: vec![], - face_down_profile: None, - enters_modified_if: None, - }, - )); - Some(Effect::AddTargetReplacement { - replacement: Box::new(replacement), + // CR 400.7: The standalone rider is bound to the lifetime of the object it + // is installed on; stamp the expiry here so the lifetime is self-contained + // (not dependent on the ability frame's duration threading) and so + // `expiry_from_duration`'s `is_none` guard never retags it — mirrors the + // die-exile stamp precedent at `try_parse_die_exile_rider` / + // `parse_token_creation_replacement_effect`. Base-installed to survive + // CR 613.1 layer reseeds, non-copiable (CR 707.2), pruned on host exit. + replacement: Box::new( + leave_battlefield_exile_replacement().expiry(RestrictionExpiry::UntilHostLeavesPlay), + ), target: TargetFilter::Any, }) } @@ -6450,6 +6556,13 @@ pub(crate) fn parse_effect_clause(text: &str, ctx: &mut ParseContext) -> ParsedE ); } let original_lower = text.to_lowercase(); + if let Some(mut clause) = try_parse_for_each_target_copy_token(text, &original_lower, ctx) { + peel_ctx.apply_optional(&mut clause.optional); + if clause.condition.is_none() { + clause.condition = peel_ctx.condition().cloned().or(unless_condition.clone()); + } + return attach_unless_slots(clause, None, unless_pay_deferred); + } if let Some(mut clause) = try_parse_for_each_copy_token_source(text, &original_lower, ctx) { peel_ctx.apply_optional(&mut clause.optional); if clause.condition.is_none() { @@ -6584,6 +6697,98 @@ fn try_parse_for_each_copy_token_source( })) } +/// CR 115.1d + CR 601.2c + CR 707.2: "For each of target , create +/// tokens that are copies of that " — Doppelgang. The "for each of" +/// distributor iterates an EXACT-count *targeted* set (`` target permanents); +/// the body creates `` copy-tokens of each chosen target. +/// +/// This is distinct from both sibling for-each copy paths: +/// * `try_parse_for_each_copy_token_source` handles a NON-targeted object filter +/// ("for each creature you control, …") — no target slot, no `MultiTargetSpec`. +/// * Twinflame's "for each of them, create a token …" relies on a prior "Choose +/// any number of target creatures" sentence to establish the target slot, then +/// its body only recurses onto `ParentTarget`. +/// +/// Here the target set is declared inline, so we lift the exact count onto the +/// clause's `multi_target` and bind the copy source directly to the targeted +/// `` filter. The `CopyTokenOf` resolver iterates the ability's chosen +/// targets and creates `count` copies of each (game/effects/token_copy.rs), so +/// `N` targets × `M` copies = `N·M` tokens. The body's anaphor ("that permanent" +/// lowers to `TriggeringSource`) has no trigger event on a spell, so it is +/// discarded and replaced by the concrete targeted filter. +fn try_parse_for_each_target_copy_token( + text: &str, + lower: &str, + ctx: &mut ParseContext, +) -> Option { + // "for each of " distributor prefix over an exact-count target set. + let (after_prefix, _) = tag::<_, _, OracleError<'_>>("for each of ") + .parse(lower) + .ok()?; + let (after_count, target_count) = parse_multi_target_count_expr(after_prefix).ok()?; + let (after_count, _) = space1::<&str, OracleError<'_>>(after_count).ok()?; + // The iterated set must be a printed target ("target permanents"); require + // the "target " keyword so a bare "for each of them, …" (Twinflame) or a + // non-targeted filter never reaches this arm. + peek(tag::<_, _, OracleError<'_>>("target ")) + .parse(after_count) + .ok()?; + // Let the target parser itself consume the filter and hand back the + // distributor comma + body — the parser IS the detector, so a compound + // filter with internal commas ("target artifact, creature, or land + // permanent") that a naive ", " split would truncate is handled correctly. + // Map the lowercase offset back onto the original-case `text` (ASCII Oracle + // text keeps byte offsets aligned). + let target_start = lower.len() - after_count.len(); + let mut target_ctx = ctx.clone(); + let (target, target_rem) = parse_target_with_ctx(&text[target_start..], &mut target_ctx); + if !matches!(target, TargetFilter::Typed(_)) { + return None; + } + // The distributor comma separates the target set from the body; consume it + // with a combinator (the target parser may or may not have already eaten + // it, so `opt`). `opt` never fails, so this only extracts the body slice. + let (body, _) = opt(tag::<_, _, OracleError<'_>>(",")) + .parse(target_rem.trim_start()) + .ok()?; + let body = body.trim(); + if body.is_empty() { + return None; + } + let body_lower = body.to_lowercase(); + + // The body must be a bare "create tokens that are copies of that " + // whose copy source is an anaphor (no independent target of its own). + let mut body_ctx = ctx.clone(); + let effect = token::try_parse_token(&body_lower, body, &mut body_ctx)?; + let Effect::CopyTokenOf { + target: TargetFilter::ParentTarget | TargetFilter::SelfRef | TargetFilter::TriggeringSource, + owner, + source_filter: None, + enters_attacking, + tapped, + count, + extra_keywords, + additional_modifications, + } = effect + else { + return None; + }; + *ctx = body_ctx; + let mut clause = parsed_clause(Effect::CopyTokenOf { + target, + owner, + source_filter: None, + enters_attacking, + tapped, + count, + extra_keywords, + additional_modifications, + }); + clause.multi_target = Some(MultiTargetSpec::exact(target_count)); + Some(clause) +} + /// Parse the residual of a "choose ..." head as a bare battlefield-object /// selection filter: a `Typed` filter with NO controller/zone constraint and no /// leading "target", consuming the entire input. Returns `None` otherwise — the @@ -7345,6 +7550,88 @@ fn rebind_owned_scope(filter: &mut TargetFilter, to: ControllerRef) { } } +/// Rewrite a moved-object filter's controller/owner scope `from` → `to`, recursing +/// through `And`/`Or`/`Not` composites. The general building block for controller- +/// scope rebinding; `rebind_owned_scope` above is the pre-existing +/// `ScopedPlayer → ` specialization kept as-is per the #6505 review. +/// +/// CR 109.4 + CR 115.10a (issue #6505): used to lift a battlefield resolution-pick +/// leg's default `You` scope (the anaphoric "they control" lowered without a +/// parse-time relative-scope pin) to `ScopedPlayer`, so the resolution-time +/// `scoped_player` stamp binds the chooser to the target player, not the caster. +fn rebind_controller_scope(filter: &mut TargetFilter, from: ControllerRef, to: ControllerRef) { + use crate::types::ability::FilterProp; + match filter { + TargetFilter::Typed(tf) => { + if tf.controller.as_ref() == Some(&from) { + tf.controller = Some(to.clone()); + } + for prop in tf.properties.iter_mut() { + if let FilterProp::Owned { controller } = prop { + if *controller == from { + *controller = to.clone(); + } + } + } + } + TargetFilter::And { filters } | TargetFilter::Or { filters } => { + for f in filters.iter_mut() { + rebind_controller_scope(f, from.clone(), to.clone()); + } + } + TargetFilter::Not { filter } => rebind_controller_scope(filter, from, to), + TargetFilter::None + | TargetFilter::Any + | TargetFilter::Player + | TargetFilter::Controller + | TargetFilter::ControllerAndControlledPermanents { .. } + | TargetFilter::Opponent + | TargetFilter::SelfRef + | TargetFilter::GrantingObject + | TargetFilter::SourceOrPaired + | TargetFilter::StackAbility { .. } + | TargetFilter::StackSpell + | TargetFilter::SpecificObject { .. } + | TargetFilter::SpecificPlayer { .. } + | TargetFilter::PlayerWhoChoseLabel { .. } + | TargetFilter::Neighbor { .. } + | TargetFilter::ScopedPlayer + | TargetFilter::AttachedTo + | TargetFilter::LastCreated + | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged + | TargetFilter::CostPaidObject + | TargetFilter::ChosenCard + | TargetFilter::TrackedSet { .. } + | TargetFilter::TrackedSetFiltered { .. } + | TargetFilter::ExiledBySource + | TargetFilter::ExiledCardByIndex { .. } + | TargetFilter::TriggeringSpellController + | TargetFilter::TriggeringSpellOwner + | TargetFilter::TriggeringPlayer + | TargetFilter::TriggeringSource + | TargetFilter::EventTarget + | TargetFilter::TriggeringSourceController + | TargetFilter::ParentTarget + | TargetFilter::ParentTargetSlot { .. } + | TargetFilter::ParentTargetController + | TargetFilter::ParentTargetOwner + | TargetFilter::SourceChosenPlayer + | TargetFilter::OriginalController + | TargetFilter::OriginalSource + | TargetFilter::PostReplacementSourceController + | TargetFilter::PostReplacementDamageSource + | TargetFilter::PostReplacementDamageTarget + | TargetFilter::PostReplacementDamageTargetOwner + | TargetFilter::DefendingPlayer + | TargetFilter::HasChosenName + | TargetFilter::ChosenDamageSource { .. } + | TargetFilter::Named { .. } + | TargetFilter::Owner + | TargetFilter::AllPlayers => {} + } +} + fn try_parse_player_draws_and_gains_control( text: &str, ctx: &mut ParseContext, @@ -7730,6 +8017,9 @@ fn parse_effect_clause_inner(text: &str, ctx: &mut ParseContext) -> ParsedEffect return parsed_clause(effect); } + if let Some(clause) = try_parse_for_each_target_copy_token(text, &lower, ctx) { + return clause; + } if let Some(clause) = try_parse_for_each_copy_token_source(text, &lower, ctx) { return clause; } @@ -8551,7 +8841,14 @@ fn parse_effect_clause_inner(text: &str, ctx: &mut ParseContext) -> ParsedEffect } // Digital-only Alchemy: "[~/that X] perpetually gains [keyword(s)]" — persistent - // keyword grant (Monoist Gravliner station trigger). + // keyword grant (Monoist Gravliner station trigger). Mutable Pupa's + // keyword-MIRROR antecedent ("… perpetually gains if that creature has + // ") also lands here: the trailing "if that creature has " gate is + // peeled UPSTREAM by `strip_suffix_conditional` (its trigger-gated + // `ZoneChangeObjectMatchesFilter` branch) in the effect-chain chunk loop — + // the ONLY production path that reaches this dispatch — so the text arriving + // here is already the short bare-keyword form and the peeled condition is + // reattached at the chunk level. if let Some(effect) = try_parse_perpetual_grant_keywords(tp) { return parsed_clause(effect); } @@ -14889,8 +15186,10 @@ fn try_parse_verb_and_target<'a>( )); } - // Exile: infer origin zone from the full post-verb text (NOT the remainder, - // since parse_zone_suffix inside parse_type_phrase consumes zone phrases). + // Exile: infer origin zone from the primary target clause the target parser + // consumed (see the `infer_origin_zone` call below) — NOT the bare remainder + // (parse_zone_suffix inside parse_type_phrase strips zone phrases off it) and + // NOT the full post-verb text (a trailing compound conjunct would leak). if let Some((_, rest)) = nom_on_lower(text, lower, |i| { value((), alt((tag("exile all "), tag("exile each ")))).parse(i) }) { @@ -14932,7 +15231,11 @@ fn try_parse_verb_and_target<'a>( } else { parsed_target }; - let origin = infer_origin_zone(rest_lower); + // CR 400.7 (issue #6505): infer the origin excluding ONLY a trailing + // compound conjunct ("and their graveyard") so its sibling zone cannot + // leak onto the primary battlefield leg — a non-"and" qualifier still + // scopes the primary leg's origin. + let origin = infer_origin_zone(&compound_exile_origin_scan(rest, rem)); return Some(( TargetedImperativeAst::ZoneCounterProxy(Box::new(ZoneCounterImperativeAst::Exile { origin, @@ -14954,7 +15257,12 @@ fn try_parse_verb_and_target<'a>( } else { parsed_target }; - let origin = infer_origin_zone(rest_lower); + // CR 400.7 (issue #6505): infer the origin excluding ONLY a trailing + // compound conjunct ("and their graveyard") so it cannot leak + // Zone::Graveyard onto the battlefield leg — a non-"and" qualifier + // ("instead of putting it into its owner's graveyard", No More Lies) + // still defines this leg's origin. + let origin = infer_origin_zone(&compound_exile_origin_scan(rest, rem)); return Some(( TargetedImperativeAst::ZoneCounterProxy(Box::new(ZoneCounterImperativeAst::Exile { origin, @@ -17661,6 +17969,8 @@ fn replace_target_with_parent(effect: &mut Effect) { | Effect::Pump { target, .. } | Effect::Counter { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::Connive { target, .. } | Effect::PhaseOut { target } // CR 702.26c: PhaseIn is the symmetric partner of PhaseOut; route its @@ -17668,7 +17978,7 @@ fn replace_target_with_parent(effect: &mut Effect) { // parent-bound PhaseIn ("untap target permanent, then phase it in") // is not silently skipped. | Effect::PhaseIn { target } - | Effect::ForceBlock { target } + | Effect::ForceBlock { target, .. } | Effect::ForceAttack { target, .. } if !matches!(target, TargetFilter::ParentTargetController) => { @@ -17788,6 +18098,11 @@ fn replace_target_with_self(effect: &mut Effect) { Effect::Transform { target, .. } => { *target = TargetFilter::SelfRef; } + // CR 710.4: every printed flip instruction is self-referential, so the + // same source-binding fixup applies. + Effect::FlipPermanent { target, .. } => { + *target = TargetFilter::SelfRef; + } Effect::GenericEffect { target, static_abilities, @@ -18504,18 +18819,69 @@ fn chain_prior_referent_is_chosen_target(clauses: &[ClauseIr]) -> bool { /// SAFE direction only over-binds toward `LastCreated`, which the typed-target /// re-anchor bail prevents; a chain with no token creator falls through to /// `false`, leaving non-token self-triggers at `SelfRef`. +/// +/// CR 603.12 carve-out to the conditional bail: an AFFIRMATIVE reflexive gate +/// ("when you do" / "if you do") does NOT bail — see the in-body comment for +/// why, and for the paired re-link that makes it safe. fn chain_prior_referent_is_created_token(clauses: &[ClauseIr]) -> bool { - for prev in clauses.iter().rev() { - if prev.condition.is_some() { + // CR 608.2c: does an unbroken continuation path still run from the clause + // under inspection forward to the consumer being parsed? This is the + // parse-time PREDICTION of `lower::gated_instruction_reaches`, built from the + // same two authorities that pass reads off the assembled chain: + // `sub_link_after_boundary` (boundary → link) and + // `instruction_spine_is_continuation` (the within-clause spine). Consulted + // ONLY by the gated-publisher carve-out below — an UNGATED publisher's + // referent always exists, so its walk is unchanged. + let mut gated_publisher_reaches = true; + for (idx, prev) in clauses.iter().enumerate().rev() { + // CR 603.12 + CR 608.2c: an AFFIRMATIVE reflexive gate is stamped by + // `strip_if_you_do_conditional` onto the clause that FOLLOWS the + // connector — which, for a reflexive body that creates its own token + // (Iroh, Tea Master; Summoner's Sending), is the token-creating clause + // itself. That gate publishes no referent (CR 608.2c: the clause's + // effect does), so the token is the chain's most-recent object referent + // for a later anaphor in the same body. Every OTHER condition keeps the + // bail: for those, `resolve_ability_chain`'s condition-false descent + // deliberately still resolves the next independent `SequentialSibling` + // instruction, and `state.last_created_token_ids` is a game-lifetime + // ledger, so a `LastCreated` seed there would bind a STALE token. + if prev + .condition + .as_ref() + .is_some_and(|c| !c.is_affirmative_reflexive_gate()) + { return false; } + let spine_reaches = prev + .parsed + .sub_ability + .as_deref() + .is_none_or(instruction_spine_is_continuation); if is_token_creating_effect(&prev.parsed.effect) { - return true; + // CR 603.12: a GATED publisher may create no token, so seeding + // `LastCreated` from it is safe only when + // `relink_gated_token_referent_consumers` (lower.rs) will move the + // consuming clause INTO the gated instruction — which it does only + // when the continuation path is unbroken. Predicting the same + // condition here is what keeps the two halves one rule: the seeder + // can never bind `LastCreated` on a shape the re-link declines, + // which is what would leave a STALE bind behind. Any condition still + // present at this point is an affirmative gate (the bail above took + // every other value), so `is_none` is exactly "ungated". + return prev.condition.is_none() || (gated_publisher_reaches && spine_reaches); } if has_typed_target_widened(&prev.parsed.effect) { // A later explicit typed target re-anchors "it" to itself. return false; } + // This clause is being skipped; record whether it breaks the path. Its + // own link comes from the boundary printed BEFORE it — i.e. the boundary + // trailing `clauses[idx - 1]`, which is exactly the `prev_boundary` the + // assembler stamps it from (every clause advances `prev_boundary`, + // including the special/absorbed ones that emit no definition). + gated_publisher_reaches &= spine_reaches + && sub_link_after_boundary(idx.checked_sub(1).and_then(|p| clauses[p].boundary)) + == SubAbilityLink::ContinuationStep; // Carrier / non-referent clause (e.g. "It gains haste") — keep scanning. } false @@ -18689,6 +19055,16 @@ fn lower_subject_predicate_ast( enters_under: None, }); } + // NOTE (issue #6505, review follow-up): the predicate is lowered with + // NO parse-time relative-scope pin. An earlier revision pinned + // `relative_player_scope = ScopedPlayer` around this whole call, which + // re-scoped EVERY "they control" predicate (Sacrifice / Bounce / + // PutCounter / UntapAll …) — including non-ChangeZone effects the + // runtime `scoped_player` stamp does not cover, leaving them latently + // broken. The `ScopedPlayer` rebind is now applied post-lowering to the + // moved-object ChangeZone/ChangeZoneAll leg ALONE (see the resolution- + // pick rewrite in the player-target wrapper below), so sibling + // predicates keep their original scope. let mut clause = lower_imperative_clause(&text, ctx); // CR 608.2c + CR 109.4 + CR 115.1: "target 's controller/owner // s it" (Arcum Dagsson, Mercy Killing). `parse_subject_application` @@ -18804,6 +19180,23 @@ fn lower_subject_predicate_ast( clause.effect, Effect::ChangeZone { .. } | Effect::ChangeZoneAll { .. } ) { + // CR 115.1a + CR 702.21a (issue #6505): distinguish a + // battlefield-wide, NON-targeted resolution pick ("target + // opponent exiles a creature they control", Strategic + // Betrayal) from an off-battlefield or explicitly-targeted + // moved-object leg. Mirror `target_choice_timing_for_clause` + // in lower.rs: a battlefield-selecting ChangeZone with no + // "target " token has its object CHOSEN at resolution, so it + // never becomes a target and Ward on the opponent's own + // creature never fires. + let creature_leg_is_resolution_pick = match &clause.effect { + Effect::ChangeZone { origin, target, .. } + | Effect::ChangeZoneAll { origin, target, .. } => { + lower::change_zone_selects_battlefield_permanent(*origin, target) + && !scan_contains_phrase(&pred_lower, "target ") + } + _ => false, + }; // CR 109.4 (issue #1077): "target player exiles a card // from their graveyard" (Relic of Progenitus, Scrabbling // Claws, Merrow Bonegnawer, Graveyard Shovel, Grave @@ -18811,18 +19204,50 @@ fn lower_subject_predicate_ast( // [zone]" (Memory's Journey, above). The moved-object // filter's possessive "their" parses as // `Owned { controller: ScopedPlayer }` because the - // zone-suffix parser is scope-agnostic — but this branch - // only runs for an explicit "target player" (not an - // anaphoric "that player"), so the filter must be rebound - // to the real declared target before it's cloned into the - // sub-ability, or it stays scoped to the acting player - // (the activator) instead of the player just targeted. - match &mut clause.effect { - Effect::ChangeZone { target, .. } - | Effect::ChangeZoneAll { target, .. } => { - rebind_owned_scope(target, ControllerRef::TargetPlayer); + // zone-suffix parser is scope-agnostic. An off-battlefield + // or targeted leg selects its object on the STACK — before + // the resolution-time `scoped_player` stamp lands — so the + // filter must be rebound to the real declared target now, or + // it stays scoped to the activator instead of the targeted + // player. A battlefield resolution pick (issue #6505) is + // instead bound to the scoped player at resolution, so its + // acting/choosing player is the target opponent, not the + // caster (CR 115.10a — being affected is not choosing). + // + // CR 109.4 + CR 115.10a (issue #6505, review follow-up): the + // battlefield resolution-pick leg's anaphoric "they control" + // lowered to the default `ControllerRef::You` + // (oracle_target::parse_controller_suffix, no scope pinned), + // so rewrite ONLY this moved-object leg's `You` scope to + // `ScopedPlayer` here — the narrow, targeted replacement for + // the removed whole-clause parse-time pin. The runtime + // `scoped_player` stamp (stack::resolve_top) then binds the + // chooser to the target player. Gated on the "they control" + // anaphor so a "you control" caster leg (also `You`) is never + // mis-rebound; the "their graveyard" leg is already + // `Owned{ScopedPlayer}` (scope-agnostic) and needs no rewrite. + let creature_leg_is_they_control_pick = creature_leg_is_resolution_pick + && scan_contains_phrase(&pred_lower, "they control"); + if creature_leg_is_they_control_pick { + match &mut clause.effect { + Effect::ChangeZone { target, .. } + | Effect::ChangeZoneAll { target, .. } => { + rebind_controller_scope( + target, + ControllerRef::You, + ControllerRef::ScopedPlayer, + ); + } + _ => {} + } + } else if !creature_leg_is_resolution_pick { + match &mut clause.effect { + Effect::ChangeZone { target, .. } + | Effect::ChangeZoneAll { target, .. } => { + rebind_owned_scope(target, ControllerRef::TargetPlayer); + } + _ => {} } - _ => {} } let mut sub_ability = AbilityDefinition::new(AbilityKind::Spell, clause.effect.clone()); @@ -18834,6 +19259,16 @@ fn lower_subject_predicate_ast( // wrapper. Carry it onto the sub-ability so the cast // surfaces N card slots. sub_ability.multi_target = clause.multi_target; + // CR 115.1a + CR 702.21a (issue #6505): a battlefield + // resolution pick is chosen at resolution and never becomes a + // target — stamp Resolution timing (the default is Stack) so + // no BecomesTarget event fires and Ward on the chosen creature + // stays silent. Off-battlefield/targeted legs keep the Stack + // default so their stack-time selection is unchanged. + if creature_leg_is_resolution_pick { + sub_ability.target_choice_timing = + crate::types::ability::TargetChoiceTiming::Resolution; + } return ParsedEffectClause { effect: Effect::TargetOnly { target: player_target.clone(), @@ -19640,6 +20075,7 @@ fn target_filter_can_target_player(filter: &TargetFilter) -> bool { match filter { TargetFilter::Player | TargetFilter::Controller + | TargetFilter::Opponent | TargetFilter::ScopedPlayer | TargetFilter::TriggeringSpellController | TargetFilter::TriggeringSpellOwner @@ -19838,7 +20274,7 @@ fn inject_subject_target(effect: &mut Effect, subject: &SubjectPhraseAst) { Effect::Connive { target, .. } | Effect::PhaseOut { target } | Effect::PhaseIn { target } - | Effect::ForceBlock { target } + | Effect::ForceBlock { target, .. } | Effect::ForceAttack { target, .. } | Effect::Suspect { target, .. } | Effect::Unsuspect { target, .. } @@ -19986,6 +20422,8 @@ fn inject_subject_target(effect: &mut Effect, subject: &SubjectPhraseAst) { | Effect::MoveCounters { target, .. } | Effect::Animate { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::RevealHand { target, .. } | Effect::TargetOnly { target, .. } | Effect::PreventDamage { target, .. } @@ -21973,6 +22411,19 @@ pub(super) fn def_is_dig_look(def: &AbilityDefinition) -> bool { /// U6-C5: the template is bound by `AntecedentRole::KeywordCounterPlacement` in /// `assembly.rs` and handed in as `template_index`; the replicated siblings are /// still pushed onto `defs`. +/// +/// CR 603.12: each clone carries the template's target VERBATIM and is pushed at +/// the TAIL of `defs`, so a template that reads a gated publisher's +/// `TargetFilter::LastCreated` would have that chain-context referent +/// transplanted past whatever sits in between, where +/// `lower::relink_gated_token_referent_consumers` can no longer reach it and +/// `state.last_created_token_ids` — a game-lifetime ledger — would bind a token +/// from an EARLIER resolution. The caller therefore declines the binding when +/// `lower::clone_would_transplant_gated_referent` holds — a predicate that +/// answers by building the clone ([`repeat_process_clone_shape`]) and asking +/// `lower::relink_gated_token_referent_consumers` whether it lands honestly. +/// Every other template is replicated as printed (CR 608.2c: the controller +/// follows the instructions in the order written). fn attach_repeat_process_keywords( defs: &mut Vec, template_index: usize, @@ -21980,20 +22431,42 @@ fn attach_repeat_process_keywords( ) { let template = defs[template_index].clone(); for keyword in keywords { - let mut new_def = template.clone(); + let mut new_def = repeat_process_clone_shape(&template); if let Effect::PutCounter { counter_type, .. } = &mut *new_def.effect { *counter_type = CounterType::Keyword(keyword.kind()); } if let Some(condition) = &mut new_def.condition { rewrite_ability_condition_keyword(condition, keyword); } - // Each replicated counter placement is its own sequential instruction. - new_def.sub_link = SubAbilityLink::SequentialSibling; - new_def.sub_ability = None; defs.push(new_def); } } +/// The chain shape every clone pushed by [`attach_repeat_process_keywords`] +/// carries: a verbatim copy of the template that is its own sequential +/// instruction with no sub-chain of its own. +/// +/// Single authority, because `lower::clone_would_transplant_gated_referent` +/// decides whether the binding is safe by building this exact def and asking +/// `lower::relink_gated_token_referent_consumers` what it would do with it at +/// the tail. If the two shapes diverged, the guard would answer a question about +/// a def the assembler never pushes. +/// +/// CR 608.2c: each per-keyword sibling is an INDEPENDENT OR-branch gated on its +/// own keyword, so it must resolve even when a preceding sibling's condition +/// (K0's graveyard-keyword gate) was false. Without +/// `SiblingCondition::ReplicatedOrBranch`, K1..Kn never place their counters +/// once K0's gate fails — the "list collapse" bug fixed for Mutable Pupa's +/// perpetual grants (#6533). It lives here rather than at the push site so the +/// guard's simulated clone carries it too. +pub(super) fn repeat_process_clone_shape(template: &AbilityDefinition) -> AbilityDefinition { + let mut new_def = template.clone(); + new_def.sub_link = SubAbilityLink::SequentialSibling; + new_def.sibling_condition = SiblingCondition::ReplicatedOrBranch; + new_def.sub_ability = None; + new_def +} + /// Membership mirror for `AntecedentRole::KeywordCounterPlacement` — the shape /// `attach_repeat_process_keywords` clones its sibling template from. pub(super) fn def_is_keyword_counter_placement(def: &AbilityDefinition) -> bool { @@ -22006,6 +22479,58 @@ pub(super) fn def_is_keyword_counter_placement(def: &AbilityDefinition) -> bool ) } +/// Membership mirror for `AntecedentRole::PerpetualKeywordGrantHead` — the shape +/// `attach_perpetual_keyword_grants` clones its sibling template from (Mutable +/// Pupa's "perpetually gains if that creature has " antecedent). +pub(super) fn def_is_perpetual_keyword_grant(def: &AbilityDefinition) -> bool { + matches!( + &*def.effect, + Effect::ApplyPerpetual { + modification: PerpetualModification::GrantKeywords { .. }, + .. + } + ) +} + +/// CR 702.1c + CR 608.2c: Apply a "The same is true for " continuation whose +/// antecedent is a PERPETUAL keyword grant (Mutable Pupa). The counters-class +/// `attach_repeat_process_keywords` analogue: walk `defs` back to the most +/// recent conditional perpetual keyword-grant (`ApplyPerpetual { GrantKeywords }` +/// gated by a zone-change keyword `condition`) and append one cloned sibling per +/// listed keyword — swapping both the granted keyword and the gating condition's +/// keyword. Each clone is an independent sequential sibling +/// (`SiblingCondition::ReplicatedOrBranch`) so the engine grants every keyword +/// the entering object actually has during the trigger's one resolution, rather +/// than collapsing to K0's gate. Digital-only Alchemy (no CR entry for +/// "perpetually"). +fn attach_perpetual_keyword_grants( + defs: &mut Vec, + template_index: usize, + keywords: &[Keyword], +) { + let template = defs[template_index].clone(); + for keyword in keywords { + let mut new_def = template.clone(); + if let Effect::ApplyPerpetual { + modification: PerpetualModification::GrantKeywords { keywords: kws }, + .. + } = &mut *new_def.effect + { + *kws = vec![keyword.clone()]; + } + if let Some(condition) = &mut new_def.condition { + rewrite_ability_condition_keyword(condition, keyword); + } + // Each replicated perpetual grant is its own sequential instruction. + new_def.sub_link = SubAbilityLink::SequentialSibling; + // CR 702.1c + CR 608.2c: independent OR-branch — resolves regardless of any other + // sibling's keyword gate (see `SiblingCondition::ReplicatedOrBranch`). + new_def.sibling_condition = SiblingCondition::ReplicatedOrBranch; + new_def.sub_ability = None; + defs.push(new_def); + } +} + /// Swap the gating keyword inside an `AbilityCondition` to `new_keyword`. Used /// by `attach_repeat_process_keywords` to rewrite each replicated counter /// clause's keyword gate. Covers every keyword-gate shape the antecedent @@ -22027,6 +22552,13 @@ fn rewrite_ability_condition_keyword(condition: &mut AbilityCondition, new_keywo | AbilityCondition::SourceLacksKeyword { keyword } => { *keyword = new_keyword.clone(); } + // CR 702.1c + CR 608.2c: Mutable Pupa's per-keyword gate — "if that creature has + // " — carries the keyword inside a `ZoneChangeObjectMatchesFilter` + // typed filter (`FilterProp::WithKeyword`), swapped via the shared + // `rewrite_filter_keyword` walker. + AbilityCondition::ZoneChangeObjectMatchesFilter { filter, .. } => { + rewrite_filter_keyword(filter, new_keyword); + } AbilityCondition::And { conditions } | AbilityCondition::Or { conditions } => { for inner in conditions { rewrite_ability_condition_keyword(inner, new_keyword); @@ -23978,12 +24510,14 @@ fn rewrite_parent_targets_to_tracked_set(effect: &mut Effect) { | Effect::Pump { target, .. } | Effect::Counter { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::Connive { target, .. } | Effect::PhaseOut { target } // CR 702.26c: PhaseIn mirrors PhaseOut; expose its target to tracked-set // rewrites for symmetry so a parent-bound PhaseIn is not skipped. | Effect::PhaseIn { target } - | Effect::ForceBlock { target } + | Effect::ForceBlock { target, .. } | Effect::ForceAttack { target, .. } | Effect::CastCopyOfCard { target, .. } | Effect::CopyTokenOf { target, .. } @@ -24237,13 +24771,15 @@ pub(crate) fn each_target_filter_mut(effect: &mut Effect, f: &mut impl FnMut(&mu | Effect::UnattachAll { target, .. } | Effect::Counter { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::Connive { target, .. } | Effect::PhaseOut { target } // CR 702.26c: PhaseIn is the symmetric partner of PhaseOut above; expose // its target filter too so generic anaphor/scope rewrites and the // self-disjunctive delayed-trigger rebind (CR 603.7c) reach it. | Effect::PhaseIn { target } - | Effect::ForceBlock { target } + | Effect::ForceBlock { target, .. } | Effect::ForceAttack { target, .. } | Effect::Draw { target, .. } | Effect::Discard { target, .. } @@ -25362,7 +25898,12 @@ fn try_parse_repeat_process_directive( if card_type.is_some() { (card_type, rest) } else { - strip_leading_general_conditional(text, ctx) + let (milled, rest) = strip_milled_shared_quality_conditional(text); + if milled.is_some() { + (milled, rest) + } else { + strip_leading_general_conditional(text, ctx) + } } } }; @@ -27260,15 +27801,40 @@ pub(crate) fn parse_effect_chain_ir( // keyword. Requires a prior clause to attach to. if !builder.is_empty() { if let Some(keywords) = try_parse_same_is_true_continuation(normalized_text) { + // CR 702.1c ("the same is true") + CR 608.2c (written order): + // select the replication template shape from the antecedent clause's + // parsed effect. A PERPETUAL keyword grant + // (Mutable Pupa, `ApplyPerpetual { GrantKeywords }`) replicates via + // `attach_perpetual_keyword_grants`; every other "same is true for" + // antecedent (Odric's `GenericEffect` static grant) keeps the + // default `StaticGrant`. Mirrors `def_is_perpetual_keyword_grant`, + // applied to the clause's `.effect` (both expose `Effect`). + let kind = if builder + .clauses() + .iter() + .rev() + .find(|clause| { + !matches!(clause.disposition, ClauseDisposition::Continue { .. }) + }) + .is_some_and(|clause| { + matches!( + &clause.parsed.effect, + Effect::ApplyPerpetual { + modification: PerpetualModification::GrantKeywords { .. }, + .. + } + ) + }) { + ReplicateKind::PerpetualKeywordGrant + } else { + ReplicateKind::StaticGrant + }; builder .clause( normalized_text, placeholder_parsed_clause("same_is_true_for_placeholder"), chunk.boundary_after, - ClauseDisposition::ReplicatePerKeyword { - keywords, - kind: ReplicateKind::StaticGrant, - }, + ClauseDisposition::ReplicatePerKeyword { keywords, kind }, ) .push(); continue; @@ -31460,6 +32026,29 @@ fn parse_put_rest_library_position(lower: &str) -> Option { } } +/// CR 400.7 (issue #6505): produce the lowercase text `infer_origin_zone` should +/// scan for a (possibly compound) exile. Exclude ONLY a trailing COMPOUND +/// conjunct (`" and "`) — e.g. "exile a creature they control AND +/// their graveyard", whose sibling graveyard leg must not leak `Zone::Graveyard` +/// onto the primary battlefield leg. A non-"and" remainder legitimately QUALIFIES +/// the primary leg ("exile it … instead of putting it into its owner's graveyard", +/// No More Lies) and stays in scope. `rem` is normally a suffix of `base` (a +/// `parse_target` remainder); `strip_suffix` fails CLOSED (scan the whole base) if +/// it is not — never a panicking mid-UTF-8 / out-of-bounds manual slice. Scan the +/// ORIGINAL-CASE base then lowercase. +pub(super) fn compound_exile_origin_scan(base: &str, rem: &str) -> String { + let rem_head = rem.trim_start(); + let trailing_and_conjunct = tag::<_, _, OracleError<'_>>("and ").parse(rem_head).is_ok(); + // Structural fail-closed suffix strip of an ALREADY-parsed `parse_target` + // remainder (`rem`) off `base` — not parsing dispatch; the nom dispatch (the + // trailing-"and " conjunct test) is the `tag(...)` above. + // allow-noncombinator: strip a known parse remainder, not parser dispatch + match base.strip_suffix(rem).filter(|_| trailing_and_conjunct) { + Some(primary) => primary.to_ascii_lowercase(), + None => base.to_ascii_lowercase(), + } +} + fn infer_origin_zone(lower: &str) -> Option { // CR 400.7: An object that moves from one zone to another becomes a new // object — the "from" prepositional phrase identifies that origin zone. diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 465c44a762..94651053c1 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -1456,6 +1456,23 @@ fn quote_closes_sentence_before_sequence(current: &str, remainder: &str) -> bool return true; } + // CR 611.2a + CR 111.3 (issue class: Circle of Power): a token/permanent + // granted-ability quote that ends a sentence can also be followed by a fresh + // sibling sentence whose subject is a TYPED GROUP rather than a bare + // imperative verb — "Wizards you control get +1/+0 and gain lifelink until + // end of turn.", "Creatures you control get +1/+1 …". This one-shot mass + // continuous effect (CR 611.2a) is a sibling of the token creation, not an + // anaphor on the created object, so it must split off; otherwise the whole + // remainder is swallowed into the token-creation clause and the mass + // pump/grant is dropped entirely (the created token then also mis-reads a + // trailing "for each …" as its own count). Unlike + // `starts_imperative_action_continuation` (a bare verb head), the subject + // here is a NOUN, recognized by its type/subtype head plus a following + // continuous predicate verb. + if starts_typed_group_continuous_continuation(trimmed_lower.as_str()) { + return true; + } + // CR 608.2c: read the whole text and apply the rules of English — a // granted-ability quote that ends a sentence can be followed by a fresh // causative "may have …" sentence directed at the affected object's @@ -1484,6 +1501,27 @@ fn starts_imperative_action_continuation(remainder_lower: &str) -> bool { !starts_anaphoric_subject(remainder_lower) && starts_clause_text_lower(remainder_lower) } +/// CR 611.2a + CR 111.3: true iff `remainder_lower` begins a fresh sibling +/// sentence whose subject is a TYPED GROUP applying a continuous predicate — +/// "creatures you control get +1/+1 …", "wizards you control gain lifelink until +/// end of turn". After a token/permanent granted-ability quote that ends a +/// sentence, such a one-shot mass continuous effect (CR 611.2a) is a sibling +/// instruction, never an anaphor on the created object (anaphors begin with a +/// pronoun/determiner and are handled by `starts_anaphoric_subject`). +/// +/// Distinguished from a bare-verb imperative (`starts_imperative_action_ +/// continuation`) by its NOUN head: recognized via the shared +/// `starts_with_type_word` combinator (the same type-word detector used by +/// `continues_mass_exile_union` in this module) plus a following continuous +/// predicate verb located by `find_predicate_start` — the exact pair +/// `try_parse_subject_continuous_clause` uses to split a subject from its +/// continuous predicate, so a `true` here means the continuation parses as a +/// group-continuous clause rather than being swallowed. +fn starts_typed_group_continuous_continuation(remainder_lower: &str) -> bool { + crate::parser::oracle_target::starts_with_type_word(remainder_lower) + && super::subject::find_predicate_start(remainder_lower).is_some() +} + /// True iff `remainder_lower` begins with a demonstrative/pronoun word that, after /// a token/permanent's granted-ability quote, refers back to the CREATED object — /// an anaphor ("It becomes …", "Its power …", "That creature gains …", "Those @@ -3975,6 +4013,42 @@ pub(super) fn apply_clause_continuation( destination, reorder_all, } => { + // CR 608.2c + CR 701.20b (Portent of Calamity): After a per-category + // exile from among revealed cards, "put the rest into " moves + // the revealed cards still in the library — `LastRevealed` ∩ origin + // Library — NOT Dig.rest_destination (a keep_count-0 reveal Dig + // returns before applying rest) and NOT the chain tracked set of + // cards just exiled (which would dump the player's picks into the + // graveyard). Prefer this over Dig patching when both antecedents + // exist in the clause list. The exiled-card tail ("put the rest of + // the exiled cards …") is a distinct remainder set and must stay on + // the imperative `ExiledBySource` path. + let for_each_bound = defs.iter().rposition(|def| { + matches!( + &*def.effect, + Effect::ForEachCategory { + action: ForEachCategoryAction::ExileFromPool { .. }, + .. + } + ) + }); + if for_each_bound.is_some() && destination != Zone::Hand { + defs.push(AbilityDefinition::new( + kind, + Effect::ChangeZoneAll { + origin: Some(Zone::Library), + destination, + target: TargetFilter::LastRevealed, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enter_with_counters: vec![], + face_down_profile: None, + library_position: None, + random_order: false, + }, + )); + return; + } // Absorbed into preceding Dig or RevealUntil — sets rest_destination // for unchosen/non-matching cards. CR 608.2c: When the preceding def is // a conditional "instead" alternative (new def with `else_ability = @@ -3995,10 +4069,37 @@ pub(super) fn apply_clause_continuation( None, super::assembly::OnMiss::Ignore, ); - let Some(bound_index) = bound else { - return; - }; - patch_rest_destination_recursively(&mut defs[bound_index], destination, reorder_all); + if let Some(bound_index) = bound { + // CR 701.20a + CR 608.2c: Dynamic-count reveal-only Digs + // (`keep_count: 0`) return before `Dig.rest_destination` is + // applied at runtime. Emit an explicit `LastRevealed` sibling + // for the revealed-library remainder instead of patching an + // unused field (Sunbird's Invocation / Enshrined Memories class). + if !reorder_all && dig_needs_last_revealed_rest_sibling(&defs[bound_index].effect) { + let library_position = + (destination == Zone::Library).then_some(LibraryPosition::Bottom); + defs.push(AbilityDefinition::new( + kind, + Effect::ChangeZoneAll { + origin: Some(Zone::Library), + destination, + target: TargetFilter::LastRevealed, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enter_with_counters: vec![], + face_down_profile: None, + library_position, + random_order: false, + }, + )); + return; + } + patch_rest_destination_recursively( + &mut defs[bound_index], + destination, + reorder_all, + ); + } } ContinuationAst::DigFromAmong { quantity, @@ -4925,6 +5026,38 @@ fn apply_search_destination_to_ability_chain( } } +/// CR 608.2c + CR 701.20b: True for "put the rest …" clauses that move the +/// revealed-library remainder after a per-category exile. False for the distinct +/// exiled-card tail ("put the rest of the exiled cards …"), which must bind to +/// `ExiledBySource` instead of `LastRevealed` / chain `TrackedSet`. +fn put_rest_targets_revealed_remainder(lower: &str) -> bool { + nom_primitives::scan_contains(lower, "put the rest") + && !nom_primitives::scan_contains(lower, "of the exiled cards") + && !nom_primitives::scan_contains(lower, "of those exiled cards") +} + +/// CR 701.20a + CR 608.2c: True when a trailing PutRest must become an explicit +/// `LastRevealed` sibling rather than patching `Dig.rest_destination`. Matches +/// reveal-only Digs already at `keep_count: 0` and dynamic-count reveal Digs +/// that assembly demotes to `keep_count: 0` after continuations are applied. +fn dig_needs_last_revealed_rest_sibling(effect: &Effect) -> bool { + match effect { + Effect::Dig { + keep_count: Some(0), + reveal: true, + .. + } => true, + Effect::Dig { + keep_count: None, + reveal: true, + filter: TargetFilter::Any, + count, + .. + } => !matches!(count, QuantityExpr::Fixed { .. }), + _ => false, + } +} + /// Recursively patch `rest_destination` on Dig/RevealUntil effects reachable from /// `def` via `else_ability`. CR 608.2c: When a preceding def is a conditional /// "instead" wrapper (new_def with `else_ability = base_def`), a trailing @@ -5954,6 +6087,9 @@ pub(super) fn clause_is_dig_lookback_transparent(effect: &Effect) -> bool { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): the as-enters copy choice is its + // own resolving effect, not a Dig-lookback-transparent clause. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::PutCounter { .. } @@ -5970,6 +6106,7 @@ pub(super) fn clause_is_dig_lookback_transparent(effect: &Effect) -> bool { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } @@ -6615,6 +6752,32 @@ pub(super) fn parse_followup_continuation_ast( reorder_all: false, }) } + // CR 608.2c + CR 701.20b (Portent of Calamity / Sanar class): "Put the + // rest into your graveyard" after a per-category exile from among the + // revealed cards. The rest are the revealed cards still in the library + // (not the cards just exiled into the chain tracked set). + Effect::ForEachCategory { + action: ForEachCategoryAction::ExileFromPool { .. }, + .. + } if put_rest_targets_revealed_remainder(&lower) => + { + let destination = if nom_primitives::scan_contains(&lower, "into your graveyard") + || nom_primitives::scan_contains(&lower, "into their graveyard") + { + Zone::Graveyard + } else if nom_primitives::scan_contains(&lower, "into your hand") + || nom_primitives::scan_contains(&lower, "into their hand") + { + Zone::Hand + } else { + // "on the bottom", "on top of", and other library rest piles. + Zone::Library + }; + Some(ContinuationAst::PutRest { + destination, + reorder_all: false, + }) + } // CR 701.20a + CR 608.2c: A reveal-until rest-pile clause may be // separated from the RevealUntil by a transparent intervening effect // ("~ deals damage equal to that card's mana value. Put that card into @@ -7809,6 +7972,88 @@ mod tests { } } + // CR 611.2a + CR 111.3: a typed-group continuous sibling (" you control + // get/gain …") following a token's granted-ability quote is a fresh clause, + // not an anaphor on the created object. The recognizer keys off a type/subtype + // NOUN head plus a continuous predicate verb, so it fires for the group-pump / + // group-grant class while leaving pronoun anaphors ("it …", "that creature …") + // and bare imperatives ("draw …") to the other continuation gates. + #[test] + fn typed_group_continuous_continuation_recognizes_mass_pump_grant() { + for remainder in [ + "creatures you control get +1/+1 and gain vigilance until end of turn.", + "wizards you control get +1/+0 and gain lifelink until end of turn.", + "zombies you control get +2/+1 until end of turn.", + "artifacts you control gain hexproof until end of turn.", + ] { + assert!( + starts_typed_group_continuous_continuation(remainder), + "should recognize typed-group continuous sibling: {remainder:?}" + ); + } + // Anaphors on the created object stay attached (pronoun/determiner head). + for remainder in [ + "it gains haste until end of turn.", + "that creature gets +1/+1 until end of turn.", + "those tokens gain flying.", + ] { + assert!( + !starts_typed_group_continuous_continuation(remainder), + "anaphoric continuation must NOT be treated as a typed-group sibling: {remainder:?}" + ); + } + // Non-typed / bare-imperative continuations are handled by the other gates. + assert!(!starts_typed_group_continuous_continuation("draw a card.")); + assert!(!starts_typed_group_continuous_continuation( + "you gain 2 life." + )); + } + + // CR 111.3 + CR 611.2a (Circle of Power class): a token created "with \"…\"" + // whose granted-ability quote ends a sentence must not swallow a following + // typed-group continuous sibling. The sentence-ending close quote splits the + // clause so the mass pump/grant reaches its own chunk instead of being folded + // into the token-creation clause (which would drop it silently). + #[test] + fn quoted_token_ability_does_not_swallow_typed_group_sibling() { + let text = "Create a 1/1 red Elf creature token with \"When this token dies, \ + draw a card.\" Creatures you control get +1/+1 and gain vigilance \ + until end of turn."; + let chunks = split_clause_sequence(text); + assert!( + chunks.len() >= 2, + "the quoted-ability token clause and the mass-pump sibling must be \ + separate chunks, got {chunks:?}" + ); + let sibling_split = chunks.iter().any(|c| { + // allow-noncombinator: test assertion on split_clause_sequence output, not parser dispatch + c.text.starts_with("Creatures you control get") + }); + assert!( + sibling_split, + "the mass-pump sibling must survive as its own chunk, got {chunks:?}" + ); + + // End-to-end: the pumped-group continuous effect must survive chain + // assembly rather than being dropped when it follows the quoted token. + let def = super::super::parse_effect_chain(text, AbilityKind::Spell); + let mut node = Some(&def); + let mut saw_group_continuous = false; + while let Some(cur) = node { + if matches!( + &*cur.effect, + Effect::GenericEffect { .. } | Effect::PumpAll { .. } + ) { + saw_group_continuous = true; + } + node = cur.sub_ability.as_deref(); + } + assert!( + saw_group_continuous, + "the mass pump/grant sibling must survive chain assembly, got {def:?}" + ); + } + // CR 508.6 + CR 102.2 + CR 608.2c: the player-scoped "does the same" // recognizer maps the Commander 2017 curse-cycle rider to its affected // `PlayerFilter`. The "attacking that player" combat qualifier NARROWS the diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index a74bf1f96d..07ca55d7ad 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -6803,7 +6803,12 @@ mod tests { "mass force-block must not request a target, got {:?}", def.target_prompt ); - let Effect::ForceBlock { target } = &*def.effect else { + let Effect::ForceBlock { + target, + attacker, + duration, + } = &*def.effect + else { panic!("expected ForceBlock, got {:?}", def.effect); }; let TargetFilter::Typed(filter) = target else { @@ -6811,6 +6816,8 @@ mod tests { }; assert_eq!(filter.controller, Some(ControllerRef::Opponent)); assert!(filter.type_filters.contains(&TypeFilter::Creature)); + assert_eq!(*attacker, None); + assert_eq!(*duration, Duration::UntilEndOfTurn); } /// CR 702.3b: the subjectless conjunct recognizer accepts every grammatical diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 581c9a6c63..4ba8e7ab2a 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -11643,6 +11643,72 @@ fn effect_chain_then_conjugated_draws() { ); } +/// CR 601.2c + CR 608.2c: a bare continuation after an "any number of target +/// opponents each" subject inherits the chosen-player target set. The following +/// sentence remains a controller-only instruction. +#[test] +fn wheel_and_deal_implicit_draw_inherits_each_targeted_opponent() { + let def = parse_effect_chain( + "Any number of target opponents each discard their hands, then draw seven cards.\nDraw a card.", + AbilityKind::Spell, + ); + assert!( + def.multi_target.is_some() + && def + .effect + .target_filter() + .is_some_and(target_filter_can_target_player), + "the first sentence must remain a multi-target player instruction: {def:?}" + ); + let per_opponent_draw = def + .sub_ability + .as_ref() + .expect("discard must continue to the seven-card draw"); + assert!( + matches!( + &*per_opponent_draw.effect, + Effect::Draw { + count: QuantityExpr::Fixed { value: 7 }, + target: TargetFilter::ParentTarget, + } + ), + "expected the seven-card draw to inherit ParentTarget, got {:?}; full definition: {def:?}", + per_opponent_draw.effect + ); + let controller_draw = per_opponent_draw + .sub_ability + .as_ref() + .expect("the second sentence must remain a trailing draw"); + assert!(matches!( + &*controller_draw.effect, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + } + )); +} + +/// CR 608.2c: the lexical conjugation is load-bearing. A fresh imperative +/// subject (`you`) after the same multi-target head must remain controller-only. +#[test] +fn multi_target_player_subject_does_not_capture_explicit_controller_draw() { + let def = parse_effect_chain( + "Any number of target opponents each discard their hands, then you draw a card.", + AbilityKind::Spell, + ); + let controller_draw = def + .sub_ability + .as_ref() + .expect("the explicit controller draw must remain a continuation"); + assert!(matches!( + &*controller_draw.effect, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + } + )); +} + #[test] fn windfall_draw_uses_previous_discard_max_for_each_player() { let def = parse_effect_chain( @@ -19824,7 +19890,8 @@ fn force_block_targeted() { matches!( e, Effect::ForceBlock { - target: TargetFilter::Typed(_) + target: TargetFilter::Typed(_), + .. } ), "Expected ForceBlock with typed target, got {:?}", @@ -19832,6 +19899,25 @@ fn force_block_targeted() { ); } +#[test] +fn force_block_with_named_target_preserves_generic_requirement() { + // Hunt Down — retain the pre-existing generic grammar when the target + // creature is named between the block verb and duration. + let e = parse_effect("Target creature blocks target creature this turn if able."); + assert!( + matches!( + e, + Effect::ForceBlock { + target: TargetFilter::Typed(_), + attacker: None, + duration: Duration::UntilEndOfTurn, + } + ), + "Expected generic ForceBlock with turn duration, got {:?}", + e + ); +} + // ----------------------------------------------------------------------- // Item 3b: MustBeBlocked imperative (CR 509.1c) // ----------------------------------------------------------------------- @@ -20789,12 +20875,14 @@ fn static_must_be_blocked_still_routes_to_static_parser() { #[test] fn force_block_with_self_ref() { // "Target creature blocks ~ this turn if able" (e.g., Auriok Siege Sled) - let e = parse_effect("Target creature blocks ~ this turn if able"); + let e = parse_effect("Target creature blocks ~ this turn if able."); assert!( matches!( e, Effect::ForceBlock { - target: TargetFilter::Typed(_) + target: TargetFilter::Typed(_), + attacker: Some(crate::types::ability::ForceBlockAttackerRef::Source), + duration: Duration::UntilEndOfTurn, } ), "Expected ForceBlock with typed target, got {:?}", @@ -20822,12 +20910,19 @@ fn force_attack_you_this_combat_targets_creature() { #[test] fn force_block_blocks_it_this_combat() { // "target creature blocks it this combat if able" (e.g., Avalanche Tusker) - let e = parse_effect("Target creature blocks it this combat if able"); + let mut ctx = ParseContext { + in_trigger: true, + subject: Some(TargetFilter::Typed(TypedFilter::creature())), + ..ParseContext::default() + }; + let e = parse_effect_clause("Target creature blocks it this combat if able.", &mut ctx).effect; assert!( matches!( e, Effect::ForceBlock { - target: TargetFilter::Typed(_) + target: TargetFilter::Typed(_), + attacker: Some(crate::types::ability::ForceBlockAttackerRef::EventSource), + duration: Duration::UntilEndOfCombat, } ), "Expected ForceBlock with typed target, got {:?}", @@ -20835,6 +20930,32 @@ fn force_block_blocks_it_this_combat() { ); } +#[test] +fn force_block_that_triggered_creature_this_combat_preserves_event_referent() { + let mut ctx = ParseContext { + in_trigger: true, + subject: Some(TargetFilter::Typed(TypedFilter::creature())), + ..ParseContext::default() + }; + let e = parse_effect_clause( + "Target creature an opponent controls blocks that Wolf this combat if able", + &mut ctx, + ) + .effect; + assert!( + matches!( + e, + Effect::ForceBlock { + target: TargetFilter::Typed(_), + attacker: Some(crate::types::ability::ForceBlockAttackerRef::EventSource), + duration: Duration::UntilEndOfCombat, + } + ), + "Expected an event-bound combat force block, got {:?}", + e + ); +} + #[test] fn mass_forced_block_target_creature() { // "All creatures able to block target creature this turn do so" (Alluring Scent) @@ -32074,6 +32195,62 @@ fn effect_for_each_opponent_gain_control_uses_per_opponent_target_fanout() { } } +/// #5994: Riptide Gearhulk's ETB — "for each opponent, put up to one target +/// nonland permanent that player controls into its owner's library third +/// from the top." The per-opponent fanout already binds the target's +/// controller to `TargetPlayer` correctly here (`Effect::PutAtLibraryPosition` +/// is wired into `Effect::target_filter()`, and the noun phrase is structurally +/// identical to the working `GainControl`/`ChangeZone` fanout precedents), so +/// the caster-vs-opponent aliasing half of the bug was already fixed upstream. +/// What survived was `MultiTargetSpec.min`: `per_opponent_target_fanout_min` +/// only recognized the min-0 ("up to") shape after a literal "gain control of " +/// prefix, so every other per-opponent-fanout verb ("put", "exile", …) fell +/// back to `min: 1` — forcing a target from every opponent's permanents even +/// though "up to one" should allow skipping — which is the "fizzles if +/// skipped" half of the report. +#[test] +fn effect_for_each_opponent_put_at_library_position_uses_optional_per_opponent_fanout() { + let def = parse_effect_chain( + "for each opponent, put up to one target nonland permanent that player controls into its owner's library third from the top.", + AbilityKind::Spell, + ); + + assert!(def.repeat_for.is_none()); + assert_eq!( + def.multi_target, + Some(MultiTargetSpec::bounded( + 0, + QuantityExpr::Ref { + qty: QuantityRef::PlayerCount { + filter: PlayerFilter::Opponent, + }, + }, + )), + "an \"up to one\" per-opponent slot must be optional (min 0), not mandatory" + ); + match &*def.effect { + Effect::PutAtLibraryPosition { + target: TargetFilter::Typed(tf), + position: LibraryPosition::NthFromTop { n }, + .. + } => { + assert_eq!( + tf.controller, + Some(ControllerRef::TargetPlayer), + "target must be scoped to the iterated opponent, not the caster" + ); + assert!(tf + .type_filters + .iter() + .any(|filter| matches!(filter, TypeFilter::Permanent))); + assert_eq!(*n, 3); + } + other => { + panic!("expected PutAtLibraryPosition TargetPlayer nonland permanent, got {other:?}") + } + } +} + #[test] fn choose_two_target_creatures_controlled_by_different_players_sets_target_constraints() { let def = parse_effect_chain( @@ -37272,6 +37449,48 @@ fn when_next_cast_spell_with_x_in_cost_parses() { assert!(matches!(&*effect.effect, Effect::Draw { .. })); } +/// #5337 gap 1 (CR 608.2k): in a "when you next cast this turn" delayed +/// grant, the subject-position "that spell" anaphor names the newly-cast spell +/// (the trigger's event source) — a `WhenNextEvent` delayed trigger has no +/// parent target, so `affected` must lower to `TriggeringSource`. Left as +/// `ParentTarget`, the runtime registers the grant against the (empty) +/// chain-tracked set and it silently never lands (Solar Array). +#[test] +fn when_next_cast_that_spell_grant_binds_triggering_source() { + use crate::types::ability::ContinuousModification; + use crate::types::keywords::Keyword; + let def = parse_effect_chain( + "When you next cast an artifact spell this turn, that spell gains sunburst.", + AbilityKind::Activated, + ); + let Effect::CreateDelayedTrigger { effect, .. } = &*def.effect else { + panic!("expected CreateDelayedTrigger, got {:?}", def.effect); + }; + let Effect::GenericEffect { + static_abilities, .. + } = &*effect.effect + else { + panic!("expected GenericEffect grant body, got {:?}", effect.effect); + }; + assert_eq!(static_abilities.len(), 1, "one grant static expected"); + let grant = &static_abilities[0]; + assert_eq!( + grant.affected, + Some(TargetFilter::TriggeringSource), + "the 'that spell' grant must bind the event source, not ParentTarget" + ); + assert!( + grant.modifications.iter().any(|m| matches!( + m, + ContinuousModification::AddKeyword { + keyword: Keyword::Sunburst + } + )), + "the grant must add Sunburst: {:?}", + grant.modifications + ); +} + /// CR 603.7 + CR 707.10: Magus Lucea Kane Psychic Stimulus delayed copy. #[test] fn magus_lucea_kane_psychic_stimulus_parses_delayed_copy() { @@ -39296,6 +39515,14 @@ fn leave_battlefield_exile_rider_adds_replacement() { assert_eq!(replacement.event, ReplacementEvent::Moved); assert_eq!(replacement.valid_card, Some(TargetFilter::SelfRef)); assert_eq!(replacement.destination_zone, None); + // CR 400.7 (issue #5976): the parsed rider is stamped with the + // host-lifetime expiry so it is base-installed, non-copiable, and + // pruned on host exit. + assert_eq!( + replacement.expiry, + Some(crate::types::ability::RestrictionExpiry::UntilHostLeavesPlay), + "the parsed leaves-battlefield rider must carry UntilHostLeavesPlay" + ); let execute = replacement .execute @@ -44613,6 +44840,89 @@ fn repeat_process_directive_you_may_stays_controller_choice() { )); } +/// CR 608.2c + CR 701.17a: Grindstone-class "if two cards that share a color +/// were milled this way, repeat this process" → unbounded `WhileCondition` over +/// `ObjectCountBySharedQuality { LastZoneChanged, Color, Max } >= 2`. +#[test] +fn repeat_process_directive_milled_shared_color_while_condition() { + use crate::types::ability::{AggregateFunction, RepeatContinuation, SharedQuality}; + + let mut ctx = ParseContext::default(); + let outcome = try_parse_repeat_process_directive( + "if two cards that share a color were milled this way, repeat this process", + &mut ctx, + ); + match outcome { + Some(RepeatProcessOutcome::Continuation(RepeatContinuation::WhileCondition { + condition, + max_iterations, + })) => { + assert_eq!(max_iterations, None); + assert!( + matches!( + *condition, + AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ObjectCountBySharedQuality { + filter: TargetFilter::LastZoneChanged, + quality: SharedQuality::Color, + aggregate: AggregateFunction::Max, + .. + }, + .. + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 2 }, + .. + } + ), + "expected LastZoneChanged shared-color quantity gate, got {condition:?}" + ); + } + other => panic!("expected unbounded WhileCondition, got {other:?}"), + } +} + +/// Grindstone — full-card parse drops zero `Unimplemented` nodes and the +/// activated root carries the unbounded shared-color `WhileCondition` repeat. +#[test] +fn grindstone_parses_repeat_while_milled_shared_color() { + use crate::types::ability::RepeatContinuation; + + let parsed = parse_oracle_text( + GRINDSTONE_ORACLE, + "Grindstone", + &[], + &["Artifact".to_string()], + &[], + ); + let json = serde_json::to_string(&parsed).unwrap(); + assert!( + // allow-noncombinator: test assertion scans serialized AST JSON, not parsing dispatch + !json.contains("\"Unimplemented\""), + "Grindstone must parse with zero Unimplemented nodes" + ); + let activated = parsed + .abilities + .iter() + .find(|ability| ability.kind == AbilityKind::Activated) + .expect("Grindstone must expose its activated ability"); + assert!( + matches!( + activated.repeat_until, + Some(RepeatContinuation::WhileCondition { + max_iterations: None, + .. + }) + ), + "Grindstone repeat is an unbounded WhileCondition, got {:?}", + activated.repeat_until + ); +} + +const GRINDSTONE_ORACLE: &str = "{3}, {T}: Target player mills two cards. If two cards that \ + share a color were milled this way, repeat this process."; + /// Sin, Spira's Punishment — full-card parse drops zero `Unimplemented` nodes /// and the trigger's root carries the unbounded `WhileCondition` repeat. #[test] @@ -48716,3 +49026,129 @@ fn unless_extraction_offsets_survive_unicode_case_fold() { the boundary, and the mask must not eat the quote)" ); } + +// =========================================================================== +// Issue #6566 — granted "If ~ would leave the battlefield, exile it instead" +// rider. `parse_leave_battlefield_rider_ref` must accept the `~` self-reference +// that `normalize_card_name_refs` produces, and the shared F4 constructor must +// reproduce the exact #6538 ReplacementDefinition shape (no regression). +// =========================================================================== + +/// #6566 (1): CR 201.5 + CR 614.1a — after `normalize_card_name_refs`, a +/// "this creature/permanent/land would leave the battlefield" rider reaches the +/// parser as `~`. Without the `~` arm added to `parse_leave_battlefield_rider_ref` +/// this returns `None` and the granted rider is silently dropped. Reverting the +/// `tag("~")` arm flips this assertion (RED: `expect` panics on `None`). +#[test] +fn leave_battlefield_rider_accepts_tilde_selfref_6566() { + let effect = try_parse_leave_battlefield_exile_replacement( + "if ~ would leave the battlefield, exile it instead of putting it anywhere else", + ) + .expect("~-normalized leave-battlefield rider must parse (6566)"); + let Effect::AddTargetReplacement { + replacement, + target, + } = effect + else { + panic!("expected AddTargetReplacement"); + }; + assert_eq!(target, TargetFilter::Any); + assert_eq!(replacement.event, ReplacementEvent::Moved); + assert_eq!(replacement.valid_card, Some(TargetFilter::SelfRef)); + let exec = replacement.execute.expect("execute present"); + let Effect::ChangeZone { + destination, + target, + .. + } = &*exec.effect + else { + panic!("expected ChangeZone execute"); + }; + assert_eq!(*destination, Zone::Exile); + assert_eq!(*target, TargetFilter::SelfRef); +} + +/// #6566 (2) + #6538 regression: the pre-existing "it" front door still parses. +#[test] +fn leave_battlefield_rider_still_accepts_it_6538() { + let effect = try_parse_leave_battlefield_exile_replacement( + "if it would leave the battlefield, exile it instead of putting it anywhere else", + ); + assert!(matches!(effect, Some(Effect::AddTargetReplacement { .. }))); +} + +/// #6566 (8): standalone #6538 riders that use "it" / "that creature" as their +/// self-reference (Whip of Erebos / Kheru Lich Lord "it"; From the Catacombs +/// "that creature") must parse identically to base — the added `~` arm never +/// shadows the existing arms. +#[test] +fn leave_battlefield_rider_standalone_refs_unchanged_6566() { + for r in ["it", "that creature", "the creature", "this permanent"] { + let text = format!( + "if {r} would leave the battlefield, exile it instead of putting it anywhere else" + ); + assert!( + matches!( + try_parse_leave_battlefield_exile_replacement(&text), + Some(Effect::AddTargetReplacement { .. }) + ), + "standalone self-ref {r:?} must still parse", + ); + } +} + +/// #6566 F4 + #6538 byte identity: the STANDALONE front door must emit exactly +/// the `ReplacementDefinition` merged main's #6538 builds — Moved / valid_card +/// SelfRef / `expiry: Some(UntilHostLeavesPlay)` (CR 400.7) / execute ChangeZone +/// Battlefield→Exile with all 13 `ChangeZone` fields at their #6538 defaults — +/// wrapped in `{target: Any}`. Byte-identical == no #6538 regression. +/// +/// The shared constructor itself is deliberately UNSTAMPED; the host-lifetime +/// expiry is composed per-consumer (this front door + `database/unearth.rs`, +/// which wraps the same constructor in `{target: SelfRef}` + description). The +/// granted path must not compose it — pinned by +/// `granted_replacement_is_not_host_lifetime_stamped` in `oracle_static::tests`. +#[test] +fn leave_battlefield_exile_replacement_matches_6538_shape() { + let base = ReplacementDefinition::new(ReplacementEvent::Moved) + .valid_card(TargetFilter::SelfRef) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: Some(Zone::Battlefield), + destination: Zone::Exile, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + )); + // The shared constructor is the unstamped base: lifetime is the caller's call. + assert_eq!(leave_battlefield_exile_replacement(), base); + assert_eq!( + leave_battlefield_exile_replacement().expiry, + None, + "the shared constructor must stay unstamped so the granted path can reuse it" + ); + + // CR 400.7: the standalone (#6538) front door composes the host-lifetime stamp. + let expected = base.expiry(crate::types::ability::RestrictionExpiry::UntilHostLeavesPlay); + let targeted = try_parse_leave_battlefield_exile_replacement( + "if it would leave the battlefield, exile it instead of putting it anywhere else", + ) + .expect("targeted front door"); + assert_eq!( + targeted, + Effect::AddTargetReplacement { + replacement: Box::new(expected), + target: TargetFilter::Any, + } + ); +} diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index 6c6c17a226..4a051add21 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -4,10 +4,11 @@ use crate::types::ability::MultiTargetSpec; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, ActivationRestriction, BounceSelection, CastingPermission, ControlWindow, ControllerRef, CopyRetargetPermission, CounterAdjustment, - CounterSourceRider, DoorLockOp, Duration, Effect, FaceDownProfile, LibraryPosition, - ManaProduction, ManaSpendRestriction, ModalSelectionConstraint, OutsideGameSourcePool, - PlayerFilter, PtStat, PtValue, QuantityExpr, SearchDestinationSplit, SearchSelectionConstraint, - SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, TargetFilter, + CounterSourceRider, DoorLockOp, Duration, Effect, FaceDownProfile, ForceBlockAttackerRef, + LibraryPosition, ManaProduction, ManaSpendRestriction, ModalSelectionConstraint, + OutsideGameSourcePool, PlayerFilter, PtStat, PtValue, QuantityExpr, SearchDestinationSplit, + SearchSelectionConstraint, SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, + SubAbilityLink, TargetFilter, }; use crate::types::card_type::Supertype; use crate::types::counter::CounterType; @@ -531,8 +532,11 @@ pub(crate) enum ImperativeFamilyAst { Explore, /// CR 702.162a: Connive. Connive, - /// CR 509.1g: Block this turn if able. - ForceBlock, + /// CR 509.1c: Block this turn/combat if able. + ForceBlock { + attacker: Option, + duration: Duration, + }, /// CR 508.1d: Attack a required player this turn/combat if able. The /// `required_player` filter selects whom the forced attacker must attack — /// `TargetFilter::Controller` for "attacks you", or @@ -1208,6 +1212,14 @@ pub(crate) enum UtilityImperativeAst { Transform { target: TargetFilter, }, + /// CR 710.4: the Kamigawa flip-card instruction ("flip this creature" / + /// "flip it" / "flip "). A sibling of [`UtilityImperativeAst::Transform`] + /// rather than a parameterization of it because CR 701.27a and CR 710 are + /// different game actions with different copiable-value semantics + /// (CR 710.1c holds color and mana cost fixed). + FlipPermanent { + target: TargetFilter, + }, Attach { attachment: TargetFilter, target: TargetFilter, @@ -1637,6 +1649,26 @@ pub(crate) enum ClauseBoundary { Comma, } +/// CR 608.2c: the SINGLE translation from the printed boundary that precedes a +/// clause to the link the clause carries to the one before it. A `Sentence` +/// boundary marks the next printed instruction (`SequentialSibling`); a +/// `Comma`/`Then`/absent boundary marks a within-clause `ContinuationStep`. +/// +/// Two passes need this mapping and MUST agree: `assemble_effect_chain` stamps +/// `AbilityDefinition::sub_link` with it, and the referent walk +/// (`parser::oracle_effect::chain_prior_referent_is_created_token`) predicts, +/// while still in `ClauseIr` space, whether the clauses it walks past will be +/// continuation steps. Keeping the match here is what makes those two the same +/// rule rather than two copies of it. +pub(crate) fn sub_link_after_boundary(boundary: Option) -> SubAbilityLink { + match boundary { + Some(ClauseBoundary::Sentence) => SubAbilityLink::SequentialSibling, + Some(ClauseBoundary::Then) | Some(ClauseBoundary::Comma) | None => { + SubAbilityLink::ContinuationStep + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) struct ClauseChunk { pub(crate) text: String, diff --git a/crates/engine/src/parser/oracle_ir/doc.rs b/crates/engine/src/parser/oracle_ir/doc.rs index e8cec36a5e..862272cb4b 100644 --- a/crates/engine/src/parser/oracle_ir/doc.rs +++ b/crates/engine/src/parser/oracle_ir/doc.rs @@ -1271,6 +1271,7 @@ fn stamp_effect_printed_slot(effect: &mut Effect, slot: usize, kind: PrintedItem Effect::Discard { .. } => {} Effect::Shuffle { .. } => {} Effect::Transform { .. } => {} + Effect::FlipPermanent { .. } => {} Effect::SearchLibrary { .. } => {} Effect::SearchOutsideGame { .. } => {} Effect::RevealHand { .. } => {} @@ -1414,6 +1415,9 @@ fn stamp_effect_printed_slot(effect: &mut Effect, slot: usize, kind: PrintedItem Effect::ApplyPerpetual { .. } => {} Effect::Intensify { .. } => {} Effect::DraftFromSpellbook { .. } => {} + // CR 707.2c (Metamorphic Alteration): no nested printed-slot carrier — + // the copy is materialized from the chosen donor at resolution. + Effect::ChoosePermanent { .. } => {} Effect::Unimplemented { .. } => {} } } diff --git a/crates/engine/src/parser/oracle_ir/effect_chain.rs b/crates/engine/src/parser/oracle_ir/effect_chain.rs index e12a6868db..c9d2017045 100644 --- a/crates/engine/src/parser/oracle_ir/effect_chain.rs +++ b/crates/engine/src/parser/oracle_ir/effect_chain.rs @@ -406,6 +406,13 @@ pub(crate) enum ReplicateKind { /// CR 608.2c: "Repeat this process for ." — replicate the antecedent /// conditional keyword-COUNTER clause per keyword (Kathril, Aspect Warper). CounterPlacement, + /// CR 702.1c + CR 608.2c: "The same is true for ." — replicate the antecedent + /// conditional PERPETUAL keyword-GRANT clause per keyword (Mutable Pupa). Each + /// replicated grant is gated on the entering object having THAT keyword, an + /// independent OR-branch (unlike `StaticGrant`, whose Odric antecedent carries + /// no per-keyword condition). Digital-only Alchemy (no CR entry for + /// "perpetually"). + PerpetualKeywordGrant, } /// Per-clause IR: captures everything about a single parsed chunk before chain assembly. diff --git a/crates/engine/src/parser/oracle_ir/relation.rs b/crates/engine/src/parser/oracle_ir/relation.rs index 3a3a24981f..f4c8211e69 100644 --- a/crates/engine/src/parser/oracle_ir/relation.rs +++ b/crates/engine/src/parser/oracle_ir/relation.rs @@ -130,4 +130,17 @@ pub(crate) enum LinkedChoiceKind { /// exists, so a resolution-scoped choice with no durable reader stays /// non-persisted. PersistedPlayer { choosers: Vec }, + /// CR 607.2d + CR 707.2c + CR 614.12a: An as-enters permanent-object choice + /// gap (`chooser` — an Unimplemented ability whose Oracle text is + /// "As … enters, choose ") linked to a + /// `ContinuousModification::CopyChosen` static (`copy_static`). Applying + /// removes the gap ability and injects `Effect::ChoosePermanent` — + /// Metamorphic Alteration's Aura-host copy. Without this consumer relation + /// the choose line stays an ordinary Unimplemented ability (no Moved claim), + /// so non-CopyChosen cards (Dauntless Bodyguard, Scheming Fence) keep their + /// pre-existing unsupported shape. + CopyChosenHost { + chooser: OracleItemId, + copy_static: OracleItemId, + }, } diff --git a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs index 7a78ce4c8c..29305bfe48 100644 --- a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs @@ -906,6 +906,18 @@ fn dark_confidant() { insta::assert_json_snapshot!("dark_confidant_lowered", &lowered); } +#[test] +fn fevered_visions() { + let (ir, lowered) = parse_two_layer( + "At the beginning of each player's end step, that player draws a card. If the player is your opponent and has four or more cards in hand, this enchantment deals 2 damage to that player.", + "Fevered Visions", + &["Enchantment"], + &[], + ); + insta::assert_json_snapshot!("fevered_visions_ir", &ir); + insta::assert_json_snapshot!("fevered_visions_lowered", &lowered); +} + #[test] fn eidolon_of_the_great_revel() { let (ir, lowered) = parse_two_layer( diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_ir.snap new file mode 100644 index 0000000000..c6b757313b --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_ir.snap @@ -0,0 +1,120 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 169, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "At the beginning of each player's end step, that player draws a card. If the player is your opponent and has four or more cards in hand, ~ deals 2 damage to that player." + }, + "node": { + "PreLoweredTrigger": { + "mode": "Phase", + "execute": { + "kind": "Spell", + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "ScopedPlayer" + } + }, + "cost": null, + "sub_ability": { + "kind": "Spell", + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "ScopedPlayer" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": { + "type": "And", + "conditions": [ + { + "type": "ScopedPlayerMatches", + "filter": { + "type": "Opponent" + } + }, + { + "type": "QuantityCheck", + "lhs": { + "type": "Ref", + "qty": { + "type": "HandSize", + "player": { + "type": "ScopedPlayer" + } + } + }, + "comparator": "GE", + "rhs": { + "type": "Fixed", + "value": 4 + } + } + ] + }, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "sub_link": "SequentialSibling" + }, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "End", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": "At the beginning of each player's end step, that player draws a card. If the player is your opponent and has four or more cards in hand, ~ deals 2 damage to that player.", + "constraint": null, + "condition": null, + "batched": false + } + } + } + ], + "source_text": "At the beginning of each player's end step, that player draws a card. If the player is your opponent and has four or more cards in hand, this enchantment deals 2 damage to that player.", + "card_name": "Fevered Visions" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_lowered.snap new file mode 100644 index 0000000000..524c403251 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_lowered.snap @@ -0,0 +1,102 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [], + "triggers": [ + { + "mode": "Phase", + "execute": { + "kind": "Spell", + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "ScopedPlayer" + } + }, + "cost": null, + "sub_ability": { + "kind": "Spell", + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "ScopedPlayer" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": { + "type": "And", + "conditions": [ + { + "type": "ScopedPlayerMatches", + "filter": { + "type": "Opponent" + } + }, + { + "type": "QuantityCheck", + "lhs": { + "type": "Ref", + "qty": { + "type": "HandSize", + "player": { + "type": "ScopedPlayer" + } + } + }, + "comparator": "GE", + "rhs": { + "type": "Fixed", + "value": 4 + } + } + ] + }, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "sub_link": "SequentialSibling" + }, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "End", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": "At the beginning of each player's end step, that player draws a card. If the player is your opponent and has four or more cards in hand, ~ deals 2 damage to that player.", + "constraint": null, + "condition": null, + "batched": false + } + ], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_ir.snap index 664acb2dcc..a10829e3c9 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_ir.snap @@ -34,7 +34,12 @@ expression: "&ir" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -48,7 +53,12 @@ expression: "&ir" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -62,7 +72,12 @@ expression: "&ir" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -76,7 +91,12 @@ expression: "&ir" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -90,7 +110,12 @@ expression: "&ir" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -104,7 +129,12 @@ expression: "&ir" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -118,7 +148,12 @@ expression: "&ir" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -132,7 +167,12 @@ expression: "&ir" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -146,7 +186,12 @@ expression: "&ir" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -160,7 +205,12 @@ expression: "&ir" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -174,7 +224,12 @@ expression: "&ir" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -240,8 +295,10 @@ expression: "&ir" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -279,8 +336,10 @@ expression: "&ir" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -318,8 +377,10 @@ expression: "&ir" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -357,8 +418,10 @@ expression: "&ir" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -396,8 +459,10 @@ expression: "&ir" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -435,8 +500,10 @@ expression: "&ir" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -474,8 +541,10 @@ expression: "&ir" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -513,8 +582,10 @@ expression: "&ir" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -552,8 +623,10 @@ expression: "&ir" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -591,8 +664,10 @@ expression: "&ir" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -630,6 +705,7 @@ expression: "&ir" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false }, "valid_card": { @@ -655,13 +731,5 @@ expression: "&ir" } ], "source_text": "When Kathril enters, put a flying counter on any creature you control if a creature card in your graveyard has flying. Repeat this process for first strike, double strike, deathtouch, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance. Then put a +1/+1 counter on Kathril for each counter put on a creature this way.", - "card_name": "Kathril, Aspect Warper", - "diagnostics": [ - { - "type": "TargetFallback", - "context": "parse_target could not classify", - "text": "any creature you control", - "line_index": 0 - } - ] + "card_name": "Kathril, Aspect Warper" } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_lowered.snap index 4adeb1ffca..c7ac016c31 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_lowered.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_lowered.snap @@ -17,7 +17,12 @@ expression: "&lowered" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -31,7 +36,12 @@ expression: "&lowered" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -45,7 +55,12 @@ expression: "&lowered" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -59,7 +74,12 @@ expression: "&lowered" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -73,7 +93,12 @@ expression: "&lowered" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -87,7 +112,12 @@ expression: "&lowered" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -101,7 +131,12 @@ expression: "&lowered" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -115,7 +150,12 @@ expression: "&lowered" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -129,7 +169,12 @@ expression: "&lowered" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -143,7 +188,12 @@ expression: "&lowered" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -157,7 +207,12 @@ expression: "&lowered" "value": 1 }, "target": { - "type": "Any" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } }, "cost": null, @@ -223,8 +278,10 @@ expression: "&lowered" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -262,8 +319,10 @@ expression: "&lowered" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -301,8 +360,10 @@ expression: "&lowered" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -340,8 +401,10 @@ expression: "&lowered" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -379,8 +442,10 @@ expression: "&lowered" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -418,8 +483,10 @@ expression: "&lowered" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -457,8 +524,10 @@ expression: "&lowered" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -496,8 +565,10 @@ expression: "&lowered" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -535,8 +606,10 @@ expression: "&lowered" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -574,8 +647,10 @@ expression: "&lowered" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false, - "sub_link": "SequentialSibling" + "sub_link": "SequentialSibling", + "sibling_condition": "ReplicatedOrBranch" }, "duration": null, "description": null, @@ -613,6 +688,7 @@ expression: "&lowered" }, "optional_targeting": false, "optional": false, + "target_choice_timing": "Resolution", "forward_result": false }, "valid_card": { @@ -637,13 +713,5 @@ expression: "&lowered" ], "statics": [], "replacements": [], - "extractedKeywords": [], - "parseWarnings": [ - { - "type": "TargetFallback", - "context": "parse_target could not classify", - "text": "any creature you control", - "line_index": 0 - } - ] + "extractedKeywords": [] } diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index 5d8fa4cc1c..e6bd07bf32 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -1286,6 +1286,35 @@ fn parse_disguise_keyword_line(text: &str) -> Option { /// Oracle text uses space-separated format: "protection from red", "ward {2}", /// "flashback {2}{U}". Converts to the colon format that `FromStr` expects, /// handling the "from" preposition used by protection keywords. +/// CR 702.82a / CR 702.82c: "Devour [quality] N". +/// +/// Plain "Devour N" (CR 702.82a) sacrifices creatures; the qualified form +/// "Devour [quality] N" (CR 702.82c) sacrifices [quality] permanents. The +/// optional type qualifier sits between the keyword name and the count, so the +/// grammar is `"devour " (type_filter " ")? number`. `parse_type_filter_word` +/// errors on a bare number, so `opt` yields `None` for the plain form and the +/// quality defaults to `TypeFilter::Creature` (CR 702.82a). The word emits a +/// canonical-case `Subtype` (e.g. Food), so the synthesized runtime filter's +/// subtype membership test matches the canonical "Food" name. Returns +/// `(keyword, unconsumed)`. +fn parse_devour_keyword_line(text: &str) -> Option<(Keyword, &str)> { + let (rest, _) = tag::<_, _, OracleError<'_>>("devour ").parse(text).ok()?; + let (rest, quality) = opt(( + crate::parser::oracle_nom::target::parse_type_filter_word, + tag::<_, _, OracleError<'_>>(" "), + )) + .parse(rest) + .ok()?; + let (unconsumed, n) = nom_primitives::parse_number.parse(rest).ok()?; + Some(( + Keyword::Devour { + n, + quality: quality.map_or(TypeFilter::Creature, |(q, _)| q), + }, + unconsumed, + )) +} + pub(crate) fn parse_keyword_line_core(text: &str) -> Option<(Keyword, &str)> { use crate::types::keywords::PartnerType; @@ -1336,6 +1365,13 @@ pub(crate) fn parse_keyword_line_core(text: &str) -> Option<(Keyword, &str)> { return Some((kw, "")); } + // CR 702.82a / CR 702.82c: "Devour [quality] N" — the optional type qualifier + // precedes the count, so this must run BEFORE the generic numeric-count path, + // which would capture only N and silently drop the quality (the reported bug). + if let Some(result) = parse_devour_keyword_line(text) { + return Some(result); + } + if let Some(kw) = parse_firebending_keyword_line(text) { return Some((kw, "")); } @@ -1826,28 +1862,16 @@ pub(crate) fn parse_keyword_line_core(text: &str) -> Option<(Keyword, &str)> { // tail is precisely what the strict router must see in order to reject // " N " (e.g. "crew 2 if it's an artifact"). let (param, unconsumed): (Cow<'_, str>, &str) = if is_numeric_count_keyword(name) { - // CR 702.82c: "Devour [quality] N" is a variant where the count follows a - // leading type qualifier — Famished Worldsire's "Devour land 3" enters - // with three +1/+1 counters per sacrificed LAND, so the numeric token - // sits after "land". Take the count at the head; if a non-numeric - // qualifier word precedes it, skip that one word and retry, so the count - // is captured rather than lost to the keyword's `FromStr` `unwrap_or(1)` - // fallback (which produced the reported Devour 1). - let count_src = if nom_primitives::parse_number.parse(rest).is_ok() { - rest - } else { - ( - take_until::<_, _, OracleError<'_>>(" "), - tag::<_, _, OracleError<'_>>(" "), - ) - .parse(rest) - .map_or(rest, |(after_qualifier, _)| after_qualifier) - }; - match nom_primitives::parse_number.parse(count_src) { + // Bare-integer count keywords (Vanishing/Fading/Renown/Frenzy/Bushido/…): + // take only the leading integer and surface the trailing clause as + // `unconsumed` so the strict router can reject " N ". + // (CR 702.82c "Devour [quality] N" is handled upstream by + // `parse_devour_keyword_line`, so no qualifier-skip is needed here.) + match nom_primitives::parse_number.parse(rest) { // `remainder` is the clause the permissive contract drops on the // floor. Surface it; the strict router turns it into a rejection. Ok((remainder, _)) => ( - Cow::Borrowed(&count_src[..count_src.len() - remainder.len()]), + Cow::Borrowed(&rest[..rest.len() - remainder.len()]), remainder, ), // No leading integer: the whole `rest` goes to `FromStr`, so whatever @@ -2413,7 +2437,7 @@ pub fn keyword_display_name(keyword: &Keyword) -> String { Keyword::Bloodthirst(_) => "bloodthirst".to_string(), Keyword::Amplify(_) => "amplify".to_string(), Keyword::Graft(_) => "graft".to_string(), - Keyword::Devour(_) => "devour".to_string(), + Keyword::Devour { .. } => "devour".to_string(), Keyword::Toxic(_) => "toxic".to_string(), Keyword::Saddle(_) => "saddle".to_string(), Keyword::Teamwork(_) => "teamwork".to_string(), @@ -2814,22 +2838,42 @@ mod tests { } /// CR 702.82c: "Devour [quality] N" (Famished Worldsire — "Devour land 3") - /// puts the count after the type qualifier. The numeric-count extractor must - /// skip the leading qualifier word so the count is 3, not the `FromStr` - /// `unwrap_or(1)` fallback (which produced the reported Devour 1). + /// puts the count after the type qualifier. The parser must capture BOTH the + /// count and the quality — the quality axis is the reported bug: dropping it + /// yields the CR 702.82a creature default for a land-quality card. #[test] fn parse_granted_keyword_fragment_devour_quality_qualifier_count() { + // CR 702.82c: land quality — the discriminating case. REVERTS to + // `quality: Creature` (i.e. this assertion FAILS) if the qualifier is + // dropped, which is exactly the reported bug. assert_eq!( parse_granted_keyword_fragment("devour land 3"), - Some(Keyword::Devour(3)) + Some(Keyword::Devour { + n: 3, + quality: TypeFilter::Land, + }) ); - // CR 702.82a: the plain "Devour N" form is unaffected. + // CR 702.82a: the plain "Devour N" form defaults to the creature quality. assert_eq!( parse_granted_keyword_fragment("devour 3"), - Some(Keyword::Devour(3)) + Some(Keyword::Devour { + n: 3, + quality: TypeFilter::Creature, + }) + ); + // CR 702.82c + CR 205.3g: an artifact SUBTYPE quality (Feasting Hobbit — + // "Devour Food 3") canonicalizes to `Subtype("Food")` so the runtime + // subtype membership test matches the canonical "Food" name. + assert_eq!( + parse_granted_keyword_fragment("devour food 3"), + Some(Keyword::Devour { + n: 3, + quality: TypeFilter::Subtype("Food".to_string()), + }) ); - // Regression: a sibling numeric-count keyword still extracts its leading - // count — the qualifier-skip only fires on the parse_number-fails branch. + // Regression (BLOCKING 2): a sibling numeric-count keyword still extracts + // its leading count — the devour-qualifier path did not cannibalize the + // generic numeric-count branch that Vanishing/Fading/Renown rely on. assert_eq!( parse_granted_keyword_fragment("vanishing 3"), Some(Keyword::Vanishing(3)) diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index 61da730105..6f578cf506 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -1234,6 +1234,7 @@ fn lower_as_enters_anchor_word_modal( source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; result.statics.push(placeholder); } diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index 0b961a5957..a736b93b94 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -304,6 +304,8 @@ fn parse_event_history_conditions(input: &str) -> OracleResult<'_, StaticConditi parse_entered_this_turn, // CR 102.2 + CR 608.2h: opponent-scoped entry tally (Zendikar trap cycle). parse_opponent_had_entered_this_turn, + // CR 102.2 + CR 603.4: opponent-scoped past-tense entry gate (Lictor). + parse_entered_this_turn_under_opponent_control, parse_opponent_cast_spell_this_turn, parse_youve_this_turn, parse_first_spell_this_game_condition, @@ -3600,6 +3602,58 @@ fn parse_life_predicate(rest: &str, player: PlayerScope) -> Option<(&str, Static None } +/// CR 102.2 / CR 102.3 + CR 608.2c: Parse a relationship-qualified predicate +/// on the current scoped player: +/// +/// `the player|that player` + `is your opponent` + `and has` + +/// a hand-size or life-total predicate. +/// +/// Three axes compose independently — subject, relation, and scalar predicate. +/// The relation is a real `alt()` axis (`parse_scoped_player_relation`) yielding +/// a `PlayerFilter`, and the scalar predicate reuses the existing +/// `PlayerScope::ScopedPlayer` hand/life productions. The effect-condition +/// bridge decides whether its parse context actually has a scoped player to +/// bind; this leaf deliberately does not infer one. +/// +/// The `" and has "` connector is deliberately fused into this production +/// rather than delegated to `parse_condition_connective` (the single authority +/// documented at the top of this module): that combinator is typed +/// `OracleResult<'_, StaticCondition>`, and `StaticCondition` has no +/// player-relationship variant — the relation exists only as +/// `AbilityCondition::ScopedPlayerMatches`. The generic connective therefore +/// structurally cannot join a relation leg to a scalar leg without a new +/// `StaticCondition` variant, which is an `add-engine-variant`-gated proposal. +pub(crate) fn parse_scoped_player_opponent_and_has_condition( + input: &str, +) -> OracleResult<'_, (PlayerFilter, StaticCondition)> { + let (rest, _) = alt(( + tag::<_, _, OracleError<'_>>("the player "), + tag("that player "), + )) + .parse(input)?; + let (rest, filter) = parse_scoped_player_relation(rest)?; + let (rest, _) = tag(" and has ").parse(rest)?; + let Some((rest, predicate)) = parse_hand_size_predicate(rest, PlayerScope::ScopedPlayer) + .or_else(|| parse_life_predicate(rest, PlayerScope::ScopedPlayer)) + else { + return Err(oracle_err(input)); + }; + Ok((rest, (filter, predicate))) +} + +/// CR 102.2 / CR 102.3: the relation axis of a scoped-player predicate — +/// which topology relation to the printed controller the scoped player must +/// hold. Mirrors the `parse_condition_connector` shape (`value()` arms inside +/// an `alt()`), so a further relation is a one-line arm rather than a +/// restructure. +fn parse_scoped_player_relation(input: &str) -> OracleResult<'_, PlayerFilter> { + alt((value( + PlayerFilter::Opponent, + tag::<_, _, OracleError<'_>>("is your opponent"), + ),)) + .parse(input) +} + /// Build a QuantityComparison: qty [comparator] n. fn make_quantity_comparison(qty: QuantityRef, comparator: Comparator, n: u32) -> StaticCondition { StaticCondition::QuantityComparison { @@ -7811,6 +7865,38 @@ fn parse_opponent_had_entered_this_turn(input: &str) -> OracleResult<'_, StaticC parse_entered_this_turn_subject(rest, suffix, 1, player) } +/// CR 102.2 + CR 102.3 + CR 603.4 + CR 608.2h + CR 608.2i: "[a | an | another | N +/// or more] entered the battlefield under an opponent's control this turn" +/// — the opponent-scoped, PAST-tense mirror of `parse_entered_this_turn`'s "under +/// your control" surface (Lictor's Pheromone Trail intervening-"if"). Distinct +/// from `parse_opponent_had_entered_this_turn`, which reads the "an opponent had … +/// enter … under their control" auxiliary/present-tense surface of the Zendikar +/// trap cycle; this is the bare subject-first past-tense form that leads with the +/// type rather than an "an opponent had" prefix. +/// +/// The "under an opponent's control" scope is carried by +/// `PlayerScope::Opponent { aggregate: Max }` — the existential "an opponent" +/// reading documented on `parse_opponent_had_entered_this_turn` — NOT a +/// `controller: Opponent` injected into the type filter: the runtime keys the +/// `BattlefieldEntriesThisTurn` tally on `record.controller` per opponent and +/// takes the largest, so in a multiplayer game the per-opponent count is compared +/// to the threshold rather than the cross-opponent sum (two different opponents +/// each having one creature enter must NOT satisfy "two or more … under an +/// opponent's control"). CR 608.2i keeps a permanent that has since left the +/// battlefield counted, because the snapshot survives departure. +fn parse_entered_this_turn_under_opponent_control( + input: &str, +) -> OracleResult<'_, StaticCondition> { + let suffix = "entered the battlefield under an opponent's control this turn"; + let player = PlayerScope::Opponent { + aggregate: AggregateFunction::Max, + }; + if let Ok(result) = parse_or_more_entered_count(input, suffix, player.clone()) { + return Ok(result); + } + parse_entered_this_turn_subject(input, suffix, 1, player) +} + /// Parse "there are [fewer than/more than] N [or more] [things] ..." conditions. /// /// Covers threshold ("seven or more cards"), delirium ("four or more card types"), @@ -9938,6 +10024,88 @@ mod tests { } } + /// CR 102.2 / CR 102.3 + CR 119 + CR 402.1 + CR 608.2c: the scoped-player + /// relationship grammar factors subject, opponent relation, connector, and + /// scalar tail. Both demonstrative subjects and every existing hand/life + /// comparator family produce an exact typed pair with no remainder. + #[test] + fn scoped_player_opponent_and_has_composes_subject_relation_and_scalar_axes() { + let hand = |comparator, value| StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::HandSize { + player: PlayerScope::ScopedPlayer, + }, + }, + comparator, + rhs: QuantityExpr::Fixed { value }, + }; + let life = |comparator, value| StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::LifeTotal { + player: PlayerScope::ScopedPlayer, + }, + }, + comparator, + rhs: QuantityExpr::Fixed { value }, + }; + + for (input, expected) in [ + ( + "the player is your opponent and has four or more cards in hand", + hand(Comparator::GE, 4), + ), + ( + "that player is your opponent and has one or fewer cards in hand", + hand(Comparator::LE, 1), + ), + ( + "the player is your opponent and has fewer than seven cards in hand", + hand(Comparator::LT, 7), + ), + ( + "that player is your opponent and has exactly three cards in hand", + hand(Comparator::EQ, 3), + ), + ( + "the player is your opponent and has 10 or less life", + life(Comparator::LE, 10), + ), + ( + "that player is your opponent and has 20 or more life", + life(Comparator::GE, 20), + ), + ] { + let (rest, parsed) = + parse_scoped_player_opponent_and_has_condition(input).expect("grammar must parse"); + assert_eq!(rest, "", "{input:?} left remainder {rest:?}"); + assert_eq!(parsed, (PlayerFilter::Opponent, expected), "{input:?}"); + } + } + + /// Adjacent phrases must not be accepted as the relationship-qualified + /// production. The all-consuming wrapper is load-bearing: a valid prefix + /// followed by semantic text is a rejection, not a partial success. + #[test] + fn scoped_player_opponent_and_has_rejects_hostile_adjacent_phrases() { + for input in [ + "a player is your opponent and has four or more cards in hand", + "the opponent is your opponent and has four or more cards in hand", + "the player isn't your opponent and has four or more cards in hand", + "the player is an opponent and has four or more cards in hand", + "the player is your opponent or has four or more cards in hand", + "the player is your opponent and had four or more cards in hand", + "the player is your opponent and has four or more cards in handbag", + "the player is your opponent and has four or more cards in hand and draws", + ] { + assert!( + nom::combinator::all_consuming(parse_scoped_player_opponent_and_has_condition) + .parse(input) + .is_err(), + "hostile adjacent phrase must fail closed: {input:?}" + ); + } + } + /// CR 402: "fewer than N cards in hand" — strict less-than hand-size gate. /// Used by Kozilek, the Great Distortion ("fewer than seven") and Iymrith, /// Desert Doom ("fewer than three") as the draw-difference condition. @@ -12519,6 +12687,72 @@ mod tests { } } + #[test] + fn test_entered_this_turn_under_opponent_control_singular() { + // Lictor's Pheromone Trail intervening-"if". The "under an opponent's + // control" scope must land on PlayerScope::Opponent (existential Max), + // NOT a controller injected into the type filter, and the filter must + // still carry the creature type restriction. + let (rest, c) = parse_inner_condition( + "a creature entered the battlefield under an opponent's control this turn", + ) + .unwrap(); + assert_eq!(rest, ""); + match c { + StaticCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::BattlefieldEntriesThisTurn { + player: + PlayerScope::Opponent { + aggregate: AggregateFunction::Max, + }, + filter: TargetFilter::Typed(filter), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + } => { + assert_eq!(filter.controller, None); + assert!(filter.type_filters.contains(&TypeFilter::Creature)); + } + other => { + panic!("expected opponent-scoped BattlefieldEntriesThisTurn GE 1, got {other:?}") + } + } + } + + #[test] + fn test_entered_this_turn_under_opponent_control_count() { + // The counted threshold surface routes through the same opponent scope. + let (rest, c) = parse_inner_condition( + "two or more creatures entered the battlefield under an opponent's control this turn", + ) + .unwrap(); + assert_eq!(rest, ""); + match c { + StaticCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::BattlefieldEntriesThisTurn { + player: + PlayerScope::Opponent { + aggregate: AggregateFunction::Max, + }, + .. + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 2 }, + } => {} + other => { + panic!("expected opponent-scoped BattlefieldEntriesThisTurn GE 2, got {other:?}") + } + } + } + #[test] fn test_you_had_another_enter_this_turn() { let (rest, c) = parse_inner_condition( diff --git a/crates/engine/src/parser/oracle_nom/target.rs b/crates/engine/src/parser/oracle_nom/target.rs index 623ff4b3d8..1fd322b0cf 100644 --- a/crates/engine/src/parser/oracle_nom/target.rs +++ b/crates/engine/src/parser/oracle_nom/target.rs @@ -87,6 +87,52 @@ pub fn parse_type_phrase(input: &str) -> OracleResult<'_, TargetFilter> { Ok((rest, filter)) } +/// Parse a demonstrative that names the attacking object of a trigger event. +/// +/// This intentionally accepts only creature-compatible type phrases: "that +/// creature" and "that Wolf" can refer to an attacker, while "that artifact" +/// cannot be silently treated as the event attacker. Callers still own the +/// surrounding trigger-context gate; this combinator owns the typed noun phrase +/// and its required separator before the following grammar production. +pub fn parse_demonstrative_attacker_referent(input: &str) -> OracleResult<'_, TargetFilter> { + let (rest, _) = tag("that ").parse(input)?; + let (rest, filter) = parse_type_phrase(rest)?; + if !target_filter_can_name_attacker(&filter) { + return Err(oracle_err(input)); + } + let (rest, _) = space1.parse(rest)?; + Ok((rest, filter)) +} + +/// CR 506.3: an attacking object must be a creature. A printed creature +/// subtype ("Wolf") is creature-compatible even when the noun phrase omits +/// the word "creature". +fn target_filter_can_name_attacker(filter: &TargetFilter) -> bool { + let TargetFilter::Typed(typed) = filter else { + return false; + }; + typed.type_filters.iter().any(type_filter_can_name_attacker) +} + +fn type_filter_can_name_attacker(filter: &TypeFilter) -> bool { + match filter { + TypeFilter::Creature | TypeFilter::Subtype(_) => true, + TypeFilter::AnyOf(filters) => filters.iter().any(type_filter_can_name_attacker), + TypeFilter::Land + | TypeFilter::Artifact + | TypeFilter::Enchantment + | TypeFilter::Instant + | TypeFilter::Sorcery + | TypeFilter::Planeswalker + | TypeFilter::Battle + | TypeFilter::Kindred + | TypeFilter::Permanent + | TypeFilter::Card + | TypeFilter::Any + | TypeFilter::Non(_) => false, + } +} + /// Parse a "non" prefix: "non" or "non-" followed by implicit word boundary. fn parse_non_prefix(input: &str) -> OracleResult<'_, &str> { alt((tag("non-"), tag("non"))).parse(input) @@ -1475,4 +1521,16 @@ mod tests { "both ability spellings must fold to exactly one StackAbility: {filters:?}" ); } + + #[test] + fn demonstrative_attacker_referent_is_typed_and_creature_compatible() { + let (rest, wolf) = parse_demonstrative_attacker_referent("that Wolf this combat if able") + .expect("creature subtype can name an attacker"); + assert_eq!(rest, "this combat if able"); + assert!(matches!(wolf, TargetFilter::Typed(_))); + assert!( + parse_demonstrative_attacker_referent("that artifact this combat if able").is_err(), + "a noncreature noun cannot become an event attacker" + ); + } } diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 34c152300e..206db3d9e7 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -12,7 +12,7 @@ use nom::Parser; use super::oracle_effect::become_copy_except::parse_except_clause; use super::oracle_effect::{ parse_effect_chain, parse_effect_chain_with_context, parse_effect_clause, - try_parse_named_choice, try_parse_named_choice_conjunction, + parse_named_choice_object, try_parse_named_choice, try_parse_named_choice_conjunction, }; use super::oracle_ir::context::ParseContext; use super::oracle_ir::replacement::ReplacementIr; @@ -26,7 +26,7 @@ use super::oracle_nom::primitives as nom_primitives; use super::oracle_nom::quantity as nom_quantity; use super::oracle_nom::target::parse_type_filter_word; use super::oracle_quantity::capitalize_first; -use super::oracle_target::parse_type_phrase; +use super::oracle_target::{parse_target, parse_type_phrase}; use super::oracle_util::{ normalize_card_name_refs, parse_count_expr, parse_number, parse_ordinal, strip_after, strip_reminder_text, TextPair, @@ -2120,11 +2120,22 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option>("choose ").parse(i) })?; - let choice_type = try_parse_named_choice(choose_text)?; + // Structural trailing-period cleanup before the named-choice object table + // (not parsing dispatch). `parse_named_choice_object` expects the phrase + // with "choose " already stripped. + let choose_object = choose_suffix.trim_end().trim_end_matches('.').trim(); + // Named-attribute choices only. Object choices ("choose a creature") are + // deliberately NOT claimed here — see `LinkedChoiceKind::CopyChosenHost`. + // Claiming them as Moved+unsupported would reshape every as-enters + // permanent-choose card (Dauntless Bodyguard, Scheming Fence, …) even when + // no CopyChosen consumer exists. + let choice_type = parse_named_choice_object(choose_object)?; let choose = AbilityDefinition::new( AbilityKind::Spell, @@ -2273,6 +2284,39 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option Option { + // Structural optional-prefix + trailing-period cleanup (not dispatch): + // callers may pass the match-at-start slice or the post-`choose ` suffix. + let object_phrase = choose_text.trim_start(); + let object_phrase = object_phrase + .strip_prefix("choose ") // allow-noncombinator: optional prefix tolerance + .unwrap_or(object_phrase); + let trimmed_end = object_phrase.trim_end(); + let object_phrase = trimmed_end + .strip_suffix('.') // allow-noncombinator: structural trailing-period cleanup + .unwrap_or(trimmed_end) + .trim(); + if object_phrase.is_empty() { + return None; + } + let (filter, remainder) = parse_target(object_phrase); + if !remainder.trim().is_empty() { + return None; + } + // CR 707.2c: the copy source is a permanent — only a concrete object filter + // (never a player/zone/`Any` descriptor) is a valid copy-source pool. + matches!(filter, TargetFilter::Typed(_)).then_some(filter) +} + /// Parse "As ~ becomes attached [to a creature/permanent], choose …" into an /// `Attached`-event replacement with a persisted `Choose` (Psychic Paper: "As /// this Equipment becomes attached to a creature, choose a creature card name @@ -14631,6 +14675,22 @@ mod tests { )); } + /// CR 614.12a + CR 707.2c: object as-enters choice alone is NOT a + /// replacement line — line-local parse must leave it alone so non-CopyChosen + /// cards keep their unsupported ability shape. `CopyChosenHost` injects + /// ChoosePermanent only when the companion static is present. + #[test] + fn as_enters_choose_a_creature_is_not_a_line_local_replacement() { + assert!( + parse_replacement_line( + "As this Aura enters, choose a creature.", + "Metamorphic Alteration", + ) + .is_none(), + "bare object as-enters choose must not claim Moved without CopyChosen" + ); + } + #[test] fn as_enters_choose_does_not_match_shock_land() { // Shock lands with "choose a basic land type" should be handled by parse_shock_land, diff --git a/crates/engine/src/parser/oracle_static/anthem.rs b/crates/engine/src/parser/oracle_static/anthem.rs index 7be6b661ad..59b4aca0cc 100644 --- a/crates/engine/src/parser/oracle_static/anthem.rs +++ b/crates/engine/src/parser/oracle_static/anthem.rs @@ -440,10 +440,13 @@ pub(crate) fn parse_subject_continuous_static(text: &str) -> Option OracleResult<'_, ()> { Err(oracle_err(input)) } +/// CR 707.2c + CR 613.1a + CR 303.4: "Enchanted is a copy of the +/// chosen ." — Metamorphic Alteration's companion static. Emits the +/// parse-time `ContinuousModification::CopyChosen` MARKER affecting the +/// enchanted host. The marker is a Layer-1 no-op at apply time: the real copy +/// is installed as a `CopyValues` transient continuous effect when the +/// `Effect::ChoosePermanent` as-enters choice is answered (per CR 707.2c the +/// copiable values are fixed only when the effect first starts to apply). +/// Emitting the static keeps the printed line "supported" and documents the +/// copy layer on the card. The donor type after "the chosen" is informational +/// (the concrete copy source is picked at entry), so it is required to parse +/// but is not otherwise threaded. +fn parse_enchanted_is_copy_of_chosen(tp: &TextPair, text: &str) -> Option { + let rest = nom_tag_tp(tp, "enchanted ")?; + // Subject type of the enchanted host (creature / permanent). Trim the + // trailing period first so the donor phrase parses to an empty remainder. + let subject_lower = rest.lower.trim_end_matches('.').trim(); + let (subject_filter, after_subject) = parse_type_phrase(subject_lower); + let TargetFilter::Typed(mut subject) = subject_filter else { + return None; + }; + let (donor_lower, _) = tag::<_, _, OracleError<'_>>(" is a copy of the chosen ") + .parse(after_subject) + .ok()?; + // The donor type ("creature" / "permanent") must parse and fully consume the + // remainder — a trailing rider (e.g. "except it has flying") is not modeled + // by the marker, so bail rather than silently drop it. + let (_donor_filter, donor_rest) = parse_type_phrase(donor_lower); + if !donor_rest.trim().is_empty() { + return None; + } + // CR 303.4 + CR 613.1: the static affects the enchanted host. + subject.properties.push(FilterProp::EnchantedBy); + Some( + StaticDefinition::continuous() + .affected(TargetFilter::Typed(subject)) + .modifications(vec![ContinuousModification::CopyChosen]) + .description(text.to_string()), + ) +} + /// CR 208.1 + CR 113.7: Parse the dynamic referent of a "{X} … less to activate, /// where X is [source]'s {power|toughness|mana value}" activated-ability cost /// reduction (Agatha of the Vile Cauldron — "where X is Agatha's power", which @@ -1232,6 +1272,14 @@ pub(crate) fn parse_static_line_inner( if let Some(def) = parse_becomes_equipment_with_ability(&tp, &text) { return Some(def); } + // CR 707.2c + CR 613.1a + CR 303.4: "Enchanted is a copy of the + // chosen " — Metamorphic Alteration's companion static. Must precede + // `parse_enchanted_is_type`, whose "is a " copula would otherwise try + // (and fail) to read "copy" as a card type. + if let Some(def) = parse_enchanted_is_copy_of_chosen(&tp, &text) { + return Some(def); + } + // CR 613.1d + CR 205.1a: "Enchanted [permanent-type] is a [type] [with base P/T N/N] // [in addition to its other types]" — type-changing aura effects. // Must come before the basic-land-type handler which is a subset of this pattern. diff --git a/crates/engine/src/parser/oracle_static/keyword_grant.rs b/crates/engine/src/parser/oracle_static/keyword_grant.rs index 62b68f4447..120cfbfe55 100644 --- a/crates/engine/src/parser/oracle_static/keyword_grant.rs +++ b/crates/engine/src/parser/oracle_static/keyword_grant.rs @@ -1670,9 +1670,11 @@ pub(crate) fn push_grant_clause_modifications( // BARE (unquoted) keyword token — granted activated/triggered abilities are // quoted and stripped to a separate path (strip_quoted_segments at :645 + // parse_quoted_ability_modifications at :798) before extract_keyword_clause - // runs, so any ". " here can only introduce a trailing inert prose sentence + // runs, so any ". " here can only introduce a trailing rider sentence // (e.g. Benevolent Blessing's SBA-exemption "This effect doesn't remove ..."). - // Drop it so the keyword sentence reaches map_keyword clean. + // Drop it so the keyword sentence reaches map_keyword clean; the rider itself + // is recovered onto the hosting `StaticDefinition` via + // `parse_protection_does_not_remove` (CR 702.16n/p). let part = match super::oracle_nom::bridge::split_once_on_lower(part, &part.to_lowercase(), ". ") { Some((first, _)) => first, @@ -1926,6 +1928,31 @@ pub(crate) fn classify_quoted_inner(ability_text: &str) -> Vec Vec> { // comma-continuation, and Oxford comma protection patterns. super::oracle_keyword::expand_protection_parts(&parts) } + +/// CR 702.16n / CR 702.16p: Parse the trailing "This effect doesn't remove …" +/// rider that accompanies a protection grant (Flickering Ward, Ward cycle, +/// Spectra Ward, Benevolent Blessing). Returns `None` when the rider is absent. +/// +/// Combinator-based word-boundary scan (parser-combinator gate): tries the +/// fixed rider prefix at each word start so the sentence may follow any +/// protection grant phrasing. +pub(crate) fn parse_protection_does_not_remove( + text: &str, +) -> Option { + use nom::branch::alt; + use nom::bytes::complete::tag; + use nom::combinator::value; + use nom::Parser; + + let lower = text.to_lowercase(); + let mut remaining = lower.as_str(); + while !remaining.is_empty() { + if let Ok((rest, ())) = value( + (), + alt(( + tag::<_, _, nom::error::Error<&str>>("this effect doesn't remove "), + tag("this effect does not remove "), + )), + ) + .parse(remaining) + { + let rest = rest.trim().trim_end_matches('.').trim(); + return parse_does_not_remove_object(rest); + } + remaining = remaining + .find(' ') + .map_or("", |i| remaining[i + 1..].trim_start()); + } + None +} + +/// CR 702.16n / CR 702.16p: Object phrase after "doesn't remove ". +fn parse_does_not_remove_object( + rest: &str, +) -> Option { + use crate::types::ability::ProtectionDoesNotRemove; + use nom::branch::alt; + use nom::bytes::complete::tag; + use nom::combinator::value; + use nom::Parser; + + // Longest matches first so Benevolent Blessing doesn't collapse to Auras. + // `~` is the post-normalization form of "this Aura" (SELF_REF_TYPE_PHRASES): + // `parse_oracle_text` rewrites self-refs before static dispatch, so Source + // must match both the raw Oracle phrase and the tilde form. + alt(( + value( + ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached, + alt(( + tag::<_, _, nom::error::Error<&str>>( + "auras and equipment you control that are already attached to it", + ), + tag("auras and equipment you control that are already attached to them"), + )), + ), + value( + ProtectionDoesNotRemove::Source, + alt((tag("this aura"), tag("~"))), + ), + value(ProtectionDoesNotRemove::Auras, tag("auras")), + )) + .parse(rest) + .ok() + .and_then(|(leftover, exemption)| leftover.trim().is_empty().then_some(exemption)) +} + +/// CR 702.16n / CR 702.16p: Stamp a parsed protection SBA-exemption rider onto +/// a continuous static when the Oracle text carries one. +pub(crate) fn with_protection_does_not_remove( + def: crate::types::ability::StaticDefinition, + text: &str, +) -> crate::types::ability::StaticDefinition { + match parse_protection_does_not_remove(text) { + Some(exemption) => def.protection_does_not_remove(exemption), + None => def, + } +} diff --git a/crates/engine/src/parser/oracle_static/mod.rs b/crates/engine/src/parser/oracle_static/mod.rs index 05f52138f8..736f91ecea 100644 --- a/crates/engine/src/parser/oracle_static/mod.rs +++ b/crates/engine/src/parser/oracle_static/mod.rs @@ -127,7 +127,7 @@ mod support { apply_spell_keyword_subject_constraints, fold_grant_cap_rider, parse_chosen_qualifier_subject, parse_continuous_modifications, parse_quoted_ability_modifications, push_grant_clause_modifications, split_keyword_list, - RuleStaticPredicate, + with_protection_does_not_remove, RuleStaticPredicate, }; pub(super) use super::restriction::{ parse_cant_be_activated_exemption_in_text, parse_cast_and_activate_only_during, diff --git a/crates/engine/src/parser/oracle_static/restriction.rs b/crates/engine/src/parser/oracle_static/restriction.rs index 3ceae79d65..170ebfacc4 100644 --- a/crates/engine/src/parser/oracle_static/restriction.rs +++ b/crates/engine/src/parser/oracle_static/restriction.rs @@ -2300,6 +2300,7 @@ fn usable_disjunctive_permission_filter(filter: &TargetFilter) -> bool { | TargetFilter::AttachedTo | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } diff --git a/crates/engine/src/parser/oracle_static/shared.rs b/crates/engine/src/parser/oracle_static/shared.rs index 3796bf5057..c1344175a0 100644 --- a/crates/engine/src/parser/oracle_static/shared.rs +++ b/crates/engine/src/parser/oracle_static/shared.rs @@ -883,6 +883,8 @@ fn continuous_modification_dynamic_quantity_mut( ContinuousModification::AddCounterOnEnter { .. } | ContinuousModification::SetStartingLoyalty { .. } | ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): inert copy marker — no dynamic quantity. + | ContinuousModification::CopyChosen | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } @@ -914,6 +916,8 @@ fn continuous_modification_dynamic_quantity_mut( | ContinuousModification::AddColor { .. } | ContinuousModification::AddStaticMode { .. } | ContinuousModification::GrantStaticAbility { .. } + // Granted object-hosted replacement: no dynamic magnitude. + | ContinuousModification::GrantReplacement { .. } | ContinuousModification::SwitchPowerToughness | ContinuousModification::AssignDamageFromToughness | ContinuousModification::AssignDamageAsThoughUnblocked diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 0cf7b069ce..3956836015 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -28669,25 +28669,30 @@ fn protection_chosen_color_drops_trailing_sba_exemption_benevolent_blessing() { /// yield `Protection(ChosenColor)`. (fail-if-reverted) #[test] fn protection_chosen_color_drops_trailing_this_aura_exemption() { + use crate::types::ability::ProtectionDoesNotRemove; use crate::types::keywords::{Keyword, ProtectionTarget}; - let mods = parse_continuous_modifications( - "Enchanted creature has protection from the chosen color. This effect doesn't remove this Aura.", - ); + let text = + "Enchanted creature has protection from the chosen color. This effect doesn't remove this Aura."; + let mods = parse_continuous_modifications(text); assert!( mods.contains(&ContinuousModification::AddKeyword { keyword: Keyword::Protection(ProtectionTarget::ChosenColor), }), "expected Protection(ChosenColor), got {mods:?}" ); - assert!( - !mods.iter().any(|m| matches!( - m, - ContinuousModification::AddKeyword { - keyword: Keyword::Protection(ProtectionTarget::CardType(_)), - } - )), - "trailing prose must not be swallowed into Protection(CardType(_)), got {mods:?}" + assert_eq!( + parse_protection_does_not_remove(text), + Some(ProtectionDoesNotRemove::Source), + "CR 702.16n rider must parse as Source exemption" + ); + // `parse_oracle_text` rewrites "this Aura" → `~` before static dispatch. + assert_eq!( + parse_protection_does_not_remove( + "Enchanted creature has protection from the chosen color. This effect doesn't remove ~." + ), + Some(ProtectionDoesNotRemove::Source), + "normalized self-ref `~` must still stamp Source" ); } @@ -32118,3 +32123,106 @@ fn parse_other_untapped_creatures_you_control_declines_new_fallback() { whichever OTHER dispatch handler already resolves it correctly" ); } + +// =========================================================================== +// Issue #6566 — classify_quoted_inner routes a `~`-normalized leave-battlefield +// exile rider to ContinuousModification::GrantReplacement (Layer 6). +// =========================================================================== + +/// #6566 (3): the reanimation rider — reaching `classify_quoted_inner` after +/// `normalize_card_name_refs` rewrote "this creature/permanent/land" to `~` — +/// must classify as a single `GrantReplacement` carrying the SelfRef Moved→Exile +/// replacement. Reverting either the `~` parser arm or the classify routing flips +/// this (RED: falls through to a `GrantAbility` unimplemented body instead). +#[test] +fn classify_quoted_inner_grants_leave_battlefield_exile_6566() { + let mods = classify_quoted_inner( + "If ~ would leave the battlefield, exile it instead of putting it anywhere else", + ); + assert_eq!(mods.len(), 1, "expected one GrantReplacement, got {mods:?}"); + let ContinuousModification::GrantReplacement { replacement } = &mods[0] else { + panic!("expected GrantReplacement, got {:?}", mods[0]); + }; + assert_eq!( + replacement.event, + crate::types::replacements::ReplacementEvent::Moved + ); + assert_eq!(replacement.valid_card, Some(TargetFilter::SelfRef)); + let exec = replacement.execute.as_ref().expect("execute present"); + let Effect::ChangeZone { destination, .. } = &*exec.effect else { + panic!("expected ChangeZone execute, got {:?}", exec.effect); + }; + assert_eq!(*destination, crate::types::zones::Zone::Exile); +} + +/// #6566 (3-neg): a non-rider quoted body must NOT yield `GrantReplacement`. The +/// positive reach-guard (it DOES parse to `AddKeyword`) proves the body cleared +/// `classify_quoted_inner`'s dispatch, so the negative is not vacuous. +#[test] +fn classify_quoted_inner_non_rider_is_not_grant_replacement_6566() { + let mods = classify_quoted_inner("flying"); + assert!( + mods.iter() + .any(|m| matches!(m, ContinuousModification::AddKeyword { .. })), + "reach guard: 'flying' must parse to AddKeyword: {mods:?}" + ); + assert!( + !mods + .iter() + .any(|m| matches!(m, ContinuousModification::GrantReplacement { .. })), + "non-rider body must not yield GrantReplacement: {mods:?}" + ); +} + +/// #6566 (4): CR 613.1f + CR 614.6 — a granted object-hosted replacement is an +/// ability-adding effect, so it must layer at `Layer::Ability` (Layer 6). +#[test] +fn grant_replacement_layers_at_ability_6566() { + let m = ContinuousModification::GrantReplacement { + replacement: Box::new(crate::parser::oracle_effect::leave_battlefield_exile_replacement()), + }; + assert_eq!(m.layer(), crate::types::layers::Layer::Ability); +} + +/// #6566 × #6538 reconciliation: CR 611.2a + CR 613.1f — the GRANTED replacement +/// must carry NO expiry. #6538 stamps `RestrictionExpiry::UntilHostLeavesPlay` on +/// the *standalone* rider, and `is_runtime_host_lifetime_replacement` keys +/// base-install + non-copiable + host-exit-prune off exactly that stamp. A +/// base-installed granted rider would survive the granting continuous effect +/// lapsing, so Elemental Expressionist's "until end of turn" grant would become +/// permanent. This pins the reconciliation: if `classify_quoted_inner` ever goes +/// back to reusing the stamped `AddTargetReplacement` payload from +/// `try_parse_leave_battlefield_exile_replacement`, this assertion flips RED. +#[test] +fn granted_replacement_is_not_host_lifetime_stamped() { + let mods = classify_quoted_inner( + "If ~ would leave the battlefield, exile it instead of putting it anywhere else", + ); + let ContinuousModification::GrantReplacement { replacement } = &mods[0] else { + panic!("reach guard: expected GrantReplacement, got {:?}", mods[0]); + }; + assert_eq!( + replacement.expiry, None, + "a granted replacement's lifetime is governed by the grant's duration \ + (CR 611.2a), not a host-lifetime stamp — a stamped def would be \ + base-installed by #6538's machinery and outlive the grant" + ); + + // Positive contrast: the STANDALONE front door on the same text DOES stamp + // (#6538), proving the two paths are genuinely reconciled rather than the + // stamp having been deleted wholesale. + let Some(Effect::AddTargetReplacement { + replacement: standalone, + .. + }) = crate::parser::oracle_effect::try_parse_leave_battlefield_exile_replacement( + "if ~ would leave the battlefield, exile it instead of putting it anywhere else", + ) + else { + panic!("reach guard: standalone front door must parse the same text"); + }; + assert_eq!( + standalone.expiry, + Some(crate::types::ability::RestrictionExpiry::UntilHostLeavesPlay), + "#6538's standalone host-lifetime stamp must be preserved (CR 400.7)" + ); +} diff --git a/crates/engine/src/parser/oracle_target.rs b/crates/engine/src/parser/oracle_target.rs index ae5af8ddcc..2d404c106b 100644 --- a/crates/engine/src/parser/oracle_target.rs +++ b/crates/engine/src/parser/oracle_target.rs @@ -13,7 +13,7 @@ use crate::types::ability::{ Comparator, ControllerRef, CountScope, DamageKindFilter, FilterProp, ObjectProperty, ObjectScope, ParitySource, PlayerFilter, PtStat, PtValueScope, QuantityExpr, QuantityRef, SeatDirection, SharedQuality, SharedQualityRelation, TargetFilter, TargetSelectionMode, - TypeFilter, TypedFilter, + ThisWayCause, TypeFilter, TypedFilter, }; use crate::types::card_type::Supertype; use crate::types::counter::{CounterMatch, CounterType}; @@ -1026,6 +1026,24 @@ pub fn parse_target_with_syntax<'a>( return (filter, rest, syntax); } + // CR 608.2c + CR 607.2a (Portent of Calamity): "the rest of the exiled cards" + // names the cards still linked to this resolution's exile step — not the bare + // "the rest" tracked-set anaphor, which can absorb unrelated chain members + // after an intervening revealed-library cleanup publishes to the chain set. + if let Ok((rest, _)) = + tag::<_, _, OracleError<'_>>("the rest of the exiled cards").parse(lower.as_str()) + { + return ( + TargetFilter::TrackedSetFiltered { + id: TrackedSetId(0), + filter: Box::new(TargetFilter::Any), + caused_by: Some(ThisWayCause::Exiled), + }, + &text[lower.len() - rest.len()..], + syntax, + ); + } + // CR 603.7: Anaphoric tracked-set pronouns static TRACKED_SET_PHRASES: &[&str] = &[ "the chosen cards", @@ -2057,21 +2075,47 @@ pub fn parse_type_phrase_with_ctx<'a>( let offset = lower.len() - lower_trimmed.len(); pos += offset; - // Strip leading article ("a "/"an ") when followed by a recognized type word - // or the "commander" class. Guard: "an opponent" → "opponent" fails type word - // check → no stripping. CR 903.3: "commander" is recognized by the commander - // atom below (it pushes `IsCommander`), not by `starts_with_type_phrase_lead`, - // so the article guard must also accept it — otherwise "a commander you own" - // (Hellkite Courser, #5256) keeps its article and never reaches the atom, - // collapsing to a match-anything filter. "commander you own" / "target - // commander" already work; this makes the indefinite article compose too. - if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("a ").parse(&lower[pos..]) { - if starts_with_type_phrase_lead(rest) || starts_with_commander_word(rest) { - pos += "a ".len(); - } - } else if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("an ").parse(&lower[pos..]) { - if starts_with_type_phrase_lead(rest) || starts_with_commander_word(rest) { - pos += "an ".len(); + // Strip a leading indefinite quantifier ("a "/"an "/"any ") when followed by + // a recognized type word or the "commander" class. Guard: "an opponent" → + // "opponent" fails the type-word check → no stripping. CR 903.3: "commander" + // is recognized by the commander atom below (it pushes `IsCommander`), not by + // `starts_with_type_phrase_lead`, so the guard must also accept it — + // otherwise "a commander you own" (Hellkite Courser, #5256) keeps its article + // and never reaches the atom, collapsing to a match-anything filter. + // "commander you own" / "target commander" already work; this makes the + // indefinite article/quantifier compose too. + // + // CR 115.10a (+ CR 115.1d for the triggered-ability case): an object/player + // is a target ONLY if the text uses the literal word "target" — "any + // creature you control" (no "target") is an untargeted controller choice, + // distinct from "any target" (a fixed keyword phrase matched earlier in + // `parse_target_with_syntax`, which requires "target" as the very next word + // and so never reaches here). "any " strips exactly like "a "/"an " above: a + // plain quantifier over the following type word, adding no extra + // `FilterProp` (unlike "other"/"another" below). Without this the type word + // is never reached and the phrase falls through every arm to the + // `TargetFilter::Any` fallback at the bottom of this function's caller + // (Kathril, Aspect Warper's "put a flying counter on any creature you + // control", issue #6321). + // + // Composed through "other"/"another" — mirroring the "all"/"each"/"every" + // block's own `after_other` composition just below — so "any other + // creature you control" (gain-control / sacrifice effects) also reaches + // the type word instead of leaking "other" into the subtype string. Only + // the quantifier is consumed here; the "other"/"another" handler below + // still runs on the remainder and adds `FilterProp::Another`. + if let Ok((rest, matched)) = + alt((tag::<_, _, OracleError<'_>>("a "), tag("an "), tag("any "))).parse(&lower[pos..]) + { + let after_other = alt((tag::<_, _, OracleError<'_>>("other "), tag("another "))) + .parse(rest) + .map(|(r, _)| r) + .ok(); + if starts_with_type_phrase_lead(rest) + || starts_with_commander_word(rest) + || after_other.is_some_and(starts_with_type_phrase_lead) + { + pos += matched.len(); } } @@ -2489,6 +2533,72 @@ pub fn parse_type_phrase_with_ctx<'a>( _ => unreachable!(), }; (Some(tf), Some(sub_name)) + } else if let TypeFilter::Subtype(second) = tf { + // CR 205.3b + CR 205.3m: on a PRINTED type line, subtypes + // of every card type except creature (and plane) are + // always single words — each dash-separated word is its + // own subtype. Creature subtypes are the one category the + // rules let run one OR two words (the sole two-word + // creature type is "Time Lord"; every other type in the + // 205.3m list — "Elder"/"Dragon"/"Elf"/"Warrior"/"Human"/ + // "Wizard" included — is one word). So when ORACLE TEXT + // names two consecutive creature-subtype words, that is + // ambiguous ONLY for creatures — the same word-boundary + // question ("one two-word type, or two one-word types + // stacked?") never arises for other categories, where + // CR 205.3b already guarantees each word is separate. + // This generic phrase-chaining rule exists to resolve + // exactly that creature-only ambiguity, so it is scoped + // to fire ONLY when NEITHER matched word is a registered + // NONCREATURE subtype (`fixed_noncreature_subtypes` — + // land/artifact/enchantment/spell/battle/planeswalker). + // "Urza's" (a real land type per CR 205.3i, LAND_SUBTYPES + // in card_type.rs) is noncreature — land subtypes CAN + // co-occur on one permanent (Urza's Mine genuinely has + // BOTH the "Urza's" and "Mine" land subtypes), but the + // dedicated Urza-lands condition parser already owns that + // Oracle-text pattern and deliberately extracts only the + // discriminating second word ("Mine"/"Power-Plant"/ + // "Tower" — "Urza's" is common to all three lands in the + // cycle, so checking for it adds no discriminating + // power). Chaining here instead fully consumed "an urza's + // mine" into one filter with an empty remainder, which + // changed which downstream condition-builder claimed the + // clause and regressed that specialized parser (issue + // #6321 / PR #6533 review — + // urzas_lands_share_delta_shape / + // legacy_misparses_are_now_honest_gaps). Staying out of + // every noncreature category's way, not just this one + // land cycle, is why the check is by vocabulary + // membership rather than an Urza's-specific special case. + let first_name = match &card_type { + Some(TypeFilter::Subtype(s)) => s.as_str(), + _ => unreachable!(), + }; + let is_noncreature_subtype = |name: &str| { + crate::types::card_type::fixed_noncreature_subtypes() + .any(|s| s.eq_ignore_ascii_case(name)) + }; + if is_noncreature_subtype(first_name) || is_noncreature_subtype(&second) { + // Decline — this generic creature-stack rule doesn't + // own noncreature subtype pairs. Whichever specialized + // handler owns this category still gets the untouched + // trailing text. + (card_type, subtype) + } else { + // Both words are creature-only: chain the second as + // an additional AND-combined type filter instead of + // silently dropping it. Reuses the existing `subtype` + // slot (already flows into `base_type_filters` + // below), so `card_type` keeps the first subtype and + // this fills the second (Fate Reforged chapter II — + // "a copy of any Elder Dragon…", issue #6321 / PR + // #6533: without this, "any " strips down to + // `Subtype("Elder")` alone, dropping "Dragon"). + let ct_len = rest_after.len() - ct_rest.len(); + pos += ws + ct_len; + (card_type, Some(second)) + } } else { (card_type, subtype) } @@ -15275,6 +15385,196 @@ mod tests { } } + /// CR 115.10a + CR 608.2d: "any other you control" — the indefinite + /// quantifier "any" must compose through "other"/"another" the same way + /// "all"/"each"/"every" already do above, or the type word is never + /// reached and the phrase collapses to the degenerate `TargetFilter::Any` + /// fallback (gain-control / sacrifice effects — "gain control of any + /// other creature", "sacrifice any other creature you control"). + #[test] + fn parse_type_phrase_any_other_creature_you_control() { + let (filter, rest) = parse_type_phrase("any other creature you control"); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Typed(tf) = &filter else { + panic!("Expected Typed filter, got {filter:?}"); + }; + assert!( + tf.type_filters.contains(&TypeFilter::Creature), + "expected Creature, got {:?}", + tf.type_filters + ); + assert!( + !tf.type_filters + .iter() + .any(|t| matches!(t, TypeFilter::Subtype(s) if s.contains(' '))), + "quantifier/other leaked into subtype: {:?}", + tf.type_filters + ); + // "other" excludes the source → Another IS present. + assert!( + tf.properties.contains(&FilterProp::Another), + "expected Another: {:?}", + tf.properties + ); + assert_eq!(tf.controller, Some(ControllerRef::You)); + } + + /// CR 205.3b + CR 205.3m: creature subtypes are the one category the + /// rules let run one OR two words on a type line (the sole two-word + /// creature type is "Time Lord"; every other listed creature type — + /// "Elder"/"Dragon"/"Elf"/"Warrior"/"Human"/"Wizard" included — is one + /// word), so two of them printed back to back are two SEPARATE stacked + /// subtypes, not a compound word (`oracle-subtypes.json` lists "Elder" + /// and "Dragon" as separate entries). Not a `[Subtype] [CoreType]` + /// promotion either (that existing arm only fires when the SECOND word is + /// a concrete core type like "creature"). Before this fix the second + /// subtype word was silently dropped (Fate Reforged chapter II — "a copy + /// of any Elder Dragon from the Legends expansion" — collapsed to bare + /// `Subtype("Elder")`, an over-broad filter matching any "Elder"-subtype + /// creature, not just Elder Dragons; issue #6321 / PR #6533 review). + #[test] + fn parse_type_phrase_two_word_subtype_chain() { + for (text, first, second) in [ + ("Elder Dragon", "Elder", "Dragon"), + ("Elf Warrior", "Elf", "Warrior"), + ("Human Wizard", "Human", "Wizard"), + ] { + let (filter, rest) = parse_type_phrase(text); + assert!(rest.trim().is_empty(), "remainder for '{text}': '{rest}'"); + let TargetFilter::Typed(tf) = &filter else { + panic!("Expected Typed filter for '{text}', got {filter:?}"); + }; + assert!( + tf.type_filters + .contains(&TypeFilter::Subtype(first.to_string())), + "expected Subtype(\"{first}\") for '{text}', got {:?}", + tf.type_filters + ); + assert!( + tf.type_filters + .contains(&TypeFilter::Subtype(second.to_string())), + "expected Subtype(\"{second}\") for '{text}' — the second subtype word must \ + not be silently dropped, got {:?}", + tf.type_filters + ); + } + } + + /// CR 205.3b + CR 205.3i: "Urza's" is a real land type (LAND_SUBTYPES, + /// `card_type.rs`), and land subtypes CAN co-occur on one permanent — + /// Urza's Mine genuinely has both the "Urza's" and "Mine" land subtypes. + /// But the two-consecutive-subtype-word chain above is scoped to resolve + /// a CREATURE-only word-boundary ambiguity (CR 205.3b/205.3m) and must + /// stay out of every noncreature category's way — including this one. + /// Chaining here would fully consume "urza's mine" into one + /// `Typed{Subtype("Urza's"), Subtype("Mine")}` filter with an empty + /// remainder, which changes which downstream condition-builder claims the + /// clause and regresses the dedicated Urza-lands + /// `ControllerControlsMatching` parser (`urzas_lands_share_delta_shape` / + /// `legacy_misparses_are_now_honest_gaps` in oracle_tests.rs / + /// oracle_condition.rs — issue #6321 / PR #6533 review), which + /// deliberately extracts only the discriminating second word ("Mine" — + /// "Urza's" is common to all three cycle members and adds no + /// discriminating power). "mine" must stay unconsumed in the remainder so + /// that specialized handler still sees it. + #[test] + fn parse_type_phrase_urzas_possessive_prefix_does_not_chain() { + let (filter, rest) = parse_type_phrase("urza's mine"); + assert_eq!( + rest.trim(), + "mine", + "\"mine\" must stay unconsumed, not chained into the type filter" + ); + let TargetFilter::Typed(tf) = &filter else { + panic!("Expected Typed filter, got {filter:?}"); + }; + assert_eq!( + tf.type_filters, + vec![TypeFilter::Subtype("Urza's".to_string())], + "only the possessive fragment may be consumed here, got {:?}", + tf.type_filters + ); + } + + /// CR 201.2 + CR 115.10a: Naming Screen — "Each creature you control that + /// doesn't share a name with any other creature you control gets +1/+1." + /// `parse_shared_quality_reference` (the reference-population parser for + /// "that doesn't share a name with X") explicitly REJECTS a `TargetFilter + /// ::Any` result from `parse_target` as a parse failure (it cannot build a + /// meaningful name comparison against "anything"). Before the "any"/ + /// "other" composition fix, "any other creature you control" collapsed to + /// `Any`, so this whole relative clause failed to parse and the static + /// ability fell through to an unstructured fallback — after the fix it + /// builds a real `Typed{Creature, Another, You}` reference and the clause + /// parses (issue #6321 / PR #6533 review). + #[test] + fn parse_shared_quality_clause_naming_screen_reference() { + let ctx = ParseContext::default(); + let (rest, prop) = + parse_shared_quality_clause("that doesn't share a name with any other creature you control", &ctx) + .expect("the reference population must parse now that \"any other ...\" is a real Typed filter, not Any"); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let FilterProp::SharesQuality { + quality, + reference, + relation, + } = prop + else { + panic!("expected SharesQuality, got {prop:?}"); + }; + assert_eq!(quality, SharedQuality::Name); + assert_eq!(relation, SharedQualityRelation::DoesNotShare); + let reference = reference.expect("reference population must be present"); + let TargetFilter::Typed(tf) = *reference else { + panic!("expected Typed reference filter, got {reference:?}"); + }; + assert!( + tf.type_filters.contains(&TypeFilter::Creature), + "expected Creature in the reference filter, got {:?}", + tf.type_filters + ); + assert!( + tf.properties.contains(&FilterProp::Another), + "\"other\" must exclude the compared creature itself, got {:?}", + tf.properties + ); + assert_eq!(tf.controller, Some(ControllerRef::You)); + } + + /// CR 707.2 + CR 115.10a: Duplication Device — "target creature becomes a + /// copy of any creature on the battlefield". "any creature on the + /// battlefield" carries no controller restriction (any player's + /// creatures) — before the "any" widening this collapsed to `Any` + /// (matching literally anything, including non-creatures/players); + /// afterward it correctly reaches the pre-existing, unmodified zone- + /// suffix machinery that already handles "creature on the battlefield" + /// for non-"any" phrasing (issue #6321 / PR #6533 review). + #[test] + fn parse_type_phrase_any_creature_on_the_battlefield() { + let (filter, rest) = parse_type_phrase("any creature on the battlefield"); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Typed(tf) = &filter else { + panic!("Expected Typed filter, got {filter:?}"); + }; + assert!( + tf.type_filters.contains(&TypeFilter::Creature), + "expected Creature, got {:?}", + tf.type_filters + ); + assert!( + tf.properties + .iter() + .any(|p| matches!(p, FilterProp::InZone { zone } if *zone == Zone::Battlefield)), + "expected an InZone(Battlefield) property, got {:?}", + tf.properties + ); + // "on the battlefield" (not "you control") — no controller restriction. + assert_eq!( + tf.controller, None, + "\"on the battlefield\" must not add a controller restriction" + ); + } + /// CR 700.9 + CR 109.4: "modified creatures you control other than ~" /// (Thundering Raiju). The "modified" adjective adds `FilterProp::Modified` /// and the trailing "other than ~" adds `FilterProp::Another` so the count diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index 3ac1931516..cdd9da7537 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -3687,7 +3687,14 @@ fn leadership_vacuum_returns_target_players_commanders_to_command_zone() { destination, target: TargetFilter::Typed(TypedFilter { - controller: Some(ControllerRef::You), + // CR 109.4 + CR 115.10a (issue #6505): "each commander THEY + // control" is the TARGET player's commanders, not the + // caster's. The anaphoric "they" now lowers to `ScopedPlayer` + // (bound to the declared target player by the resolution-time + // `scoped_player` stamp), fixing the same class as Strategic + // Betrayal. Previously it wrongly resolved to `You` (the + // caster), contradicting this test's own name. + controller: Some(ControllerRef::ScopedPlayer), properties, .. }), @@ -14730,6 +14737,57 @@ fn twinflame_full_parse() { } } +/// CR 115.1d + CR 601.2c + CR 707.2 (issue #6509): Doppelgang — "For each of X +/// target permanents, create X tokens that are copies of that permanent." The +/// single-sentence for-each-of distributor must (a) target EXACTLY X permanents +/// (`MultiTargetSpec::exact(X)`) and (b) create X copies of each chosen target +/// (`CopyTokenOf { target: Typed(permanent), count: X }`). Previously this +/// resolved without effect because the inline targeted set never lowered. +#[test] +fn doppelgang_full_parse() { + use crate::types::ability::{ + Effect, MultiTargetSpec, QuantityExpr, QuantityRef, TargetFilter, TypeFilter, + }; + + let r = parse( + "For each of X target permanents, create X tokens that are copies of that permanent.", + "Doppelgang", + &[], + &["Sorcery"], + &[], + ); + + assert_eq!(r.abilities.len(), 1, "expected single spell ability"); + let ab = &r.abilities[0]; + + let x = QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }; + + // Exactly X targets, each a permanent. + assert_eq!( + ab.multi_target, + Some(MultiTargetSpec::exact(x.clone())), + "Doppelgang must target exactly X permanents" + ); + + match &*ab.effect { + Effect::CopyTokenOf { target, count, .. } => { + let TargetFilter::Typed(tf) = target else { + panic!("copy source must be the chosen permanent targets, got {target:?}"); + }; + assert!( + tf.type_filters.contains(&TypeFilter::Permanent), + "copy source must filter permanents, not resolve an anaphor" + ); + assert_eq!(*count, x, "each target yields X copies"); + } + other => panic!("expected CopyTokenOf, got {other:?}"), + } +} + // ── Mana spend restriction extensions ───────────────────────────── #[test] diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 92ff02848a..1d50203730 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -5174,6 +5174,20 @@ fn extract_if_condition_with_card_name( ); } + // CR 400.7 + CR 508.1 + CR 603.4: source-combat history is a distinct + // grammar production, not a card-text table entry. The scanner applies the + // nom production at word boundaries so a leading intervening-if is retained + // while later sentence-local conditionals remain outside this function. + if let Some((prefix, _, rest)) = scan_preceded(&lower, |i| { + tag::<_, _, OracleError<'_>>("if ~ attacked this combat").parse(i) + }) { + let clause_len = lower.len() - prefix.len() - rest.len(); + return ( + strip_condition_clause(text, prefix.len(), clause_len), + Some(TriggerCondition::SourceAttackedThisCombat), + ); + } + // Simple pattern→condition extractions (no dynamic parsing or guards needed). if let Some(result) = try_extract_simple_condition( &tp, @@ -11566,6 +11580,38 @@ fn try_parse_source_deals_damage_trigger(lower: &str) -> Option<(TriggerMode, Tr /// * head noun — "source" | supported object type head nouns /// * controller — optional " you control" → `ControllerRef::You` fn parse_damage_source_subject(input: &str) -> OracleResult<'_, TargetFilter> { + // CR 303.4b + CR 301.5a: Aura/Equipment self-referential + // damage-source subjects are article-less determiners — "enchanted creature" + // (the creature an Aura is attached to, CR 303.4b) and "equipped creature" + // (the creature an Equipment is attached to, CR 301.5a). Both name THIS + // permanent's attached creature as the damage source, so they resolve to + // `TargetFilter::AttachedTo` — a precise reference to the attached creature, + // not `Typed(Equipped/EnchantedBy)` (which would match any equipped/enchanted + // creature on the battlefield). Recognized here — before the article-anchored + // parse below, which would reject them for lacking an article — so + // "enchanted/equipped creature deals damage to a player" is classified as a + // DamageDone trigger. Without this, the strict subject parse fails and the + // pattern falls through to `condition_introduces_target_player`, mis-binding a + // later "that player" anaphor to `TargetPlayer` (which surfaces a phantom + // companion Player target slot at runtime) instead of the damaged + // `TriggeringPlayer` (Sigil of Sleep, the Sword cycle, Hammer of Ruin, …). + // Each branch consumes the trailing space so the caller can chain into + // `tag("deals ")`, mirroring the article path's own trailing-space consume. + if let Ok((rest, filter)) = alt(( + value( + TargetFilter::AttachedTo, + tag::<_, _, OracleError<'_>>("enchanted creature "), + ), + value( + TargetFilter::AttachedTo, + tag::<_, _, OracleError<'_>>("equipped creature "), + ), + )) + .parse(input) + { + return Ok((rest, filter)); + } + // CR 109.4: printed damage-source phrases either use an article // ("a source") or the article-less determiner "another source" (Ghyrson). // Parse "another" on the same axis as the article so it can feed the @@ -16750,9 +16796,16 @@ fn is_land_play_filter(tf: &TypedFilter) -> bool { /// tail: "you play a card from exile" yields an exile-origin constraint, while /// bare "you play a card" yields the unrestricted (`Any`) origin. The subject /// must be followed only by end-of-input, the effect comma, or that zone tail. -/// Restricted to the exact second-person bare-card form. Qualified variants -/// such as "a player plays a card exiled with ~" need additional linked-card -/// filtering before they can safely share this parser arm. +/// +/// CR 601.1a + CR 701.18b: The explicitly-spelled-out "play a land or cast a +/// spell" is the same event pair as "play a card" — a player plays a card by +/// playing it as a land OR casting it as a spell — so it routes to the identical +/// `PlayCard` trigger with the shared origin tail gating both halves +/// (Shadow of the Goblin — "from anywhere other than your hand"; Flubs, the +/// Fool / Infernal Sovereign / The Endstone — no tail → `Any`). Restricted to +/// the exact second-person forms. Qualified variants such as "a player plays a +/// card exiled with ~" need additional linked-card filtering before they can +/// safely share this parser arm. fn parse_play_card_trigger_subject(lower: &str) -> Option { let (after_prefix, _) = alt(( tag::<_, _, OracleError<'_>>("whenever "), @@ -16766,9 +16819,12 @@ fn parse_play_card_trigger_subject(lower: &str) -> Option { ) .parse(after_prefix) .ok()?; - let (after_card, _) = tag::<_, _, OracleError<'_>>("a card") - .parse(after_verb) - .ok()?; + let (after_card, _) = alt(( + value((), tag::<_, _, OracleError<'_>>("a card")), + value((), tag("a land or cast a spell")), + )) + .parse(after_verb) + .ok()?; // CR 601.1a + CR 400.1: An optional "from " tail restricts the play // origin ("whenever you play a card from exile"). The shared cast-origin // combinator consumes the "from " prefix and the zone phrase; absent a diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index c67a8a5547..57cadbb3cb 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -12,8 +12,9 @@ use crate::types::ability::{ CountScope, DamageChannel, DamageModification, DamageSource, DelayedTriggerCondition, DiscardSelfScope, Duration, Effect, EffectScope, FilterProp, ManaContribution, ManaProduction, ManaSpendPermission, ObjectScope, PerpetualModification, PlayerFilter, PlayerScope, PtStat, - PtValue, PtValueScope, QuantityExpr, QuantityRef, SeatDirection, SharedQuality, TapStateChange, - TargetFilter, TriggerCondition, TypeFilter, TypedFilter, ZoneRef, + PtValue, PtValueScope, QuantityExpr, QuantityRef, SeatDirection, SharedQuality, + SiblingCondition, SubAbilityLink, TapStateChange, TargetFilter, TriggerCondition, TypeFilter, + TypedFilter, ZoneRef, }; use crate::types::card_type::Supertype; use crate::types::counter::{CounterMatch, CounterType}; @@ -547,6 +548,28 @@ fn intervening_if_source_attacked_this_turn_populates_condition() { assert!(taigam.execute.is_some()); } +#[test] +fn tolsimir_midnights_light_preserves_combat_source_and_event_attacker_axes() { + let trigger = parse_trigger_line( + "Whenever a Wolf you control attacks, if Tolsimir, Midnight's Light attacked this combat, \ + target creature an opponent controls blocks that Wolf this combat if able.", + "Tolsimir, Midnight's Light", + ); + assert_eq!( + trigger.condition, + Some(TriggerCondition::SourceAttackedThisCombat), + "the intervening-if is combat-scoped and source-incarnation-bound" + ); + assert!(matches!( + trigger.execute.as_deref().map(|ability| &*ability.effect), + Some(Effect::ForceBlock { + attacker: Some(crate::types::ability::ForceBlockAttackerRef::EventSource), + duration: Duration::UntilEndOfCombat, + .. + }) + )); +} + #[test] fn intervening_if_source_attacked_or_blocked_this_turn_populates_condition() { // CR 508.1 + CR 509.1 + CR 603.4: the "attacked or blocked" sibling of the @@ -8257,6 +8280,46 @@ fn trigger_you_plays_a_card_does_not_match_play_card() { assert_ne!(def.mode, TriggerMode::PlayCard); } +// CR 601.1a + CR 701.18b: "play a land or cast a spell" is the same event pair +// as "play a card" (a player plays a card by playing it as a land OR casting it +// as a spell), so the explicitly-spelled-out form routes to the unified +// `PlayCard` mode. Regression for The Endstone / Flubs / Infernal Sovereign. +#[test] +fn trigger_you_play_a_land_or_cast_a_spell_is_play_card() { + let def = parse_trigger_line( + "Whenever you play a land or cast a spell, draw a card.", + "The Endstone", + ); + assert_eq!(def.mode, TriggerMode::PlayCard); + assert_eq!(def.valid_target, Some(TargetFilter::Controller)); + // No origin restriction → fires on any land play or spell cast. + assert_eq!(def.spell_cast_origin, OriginConstraint::Any); + assert!(def.valid_card.is_none()); +} + +// CR 601.1a + CR 601.2a + #6387: Shadow of the Goblin — the "from anywhere +// other than your hand" origin clause modifies BOTH the land-play and +// spell-cast halves of "play a land or cast a spell". Before the fix the +// land-play arm shadowed this line and dropped both the cast half and the +// origin, so it dealt damage on every land played from hand. It must parse as +// `PlayCard` with `NotEquals(Hand)` so the shared origin gate excludes plays +// and casts from the hand. +#[test] +fn trigger_shadow_of_the_goblin_play_or_cast_from_non_hand() { + let def = parse_trigger_line( + "Whenever you play a land or cast a spell from anywhere other than your hand, this enchantment deals 1 damage to each opponent.", + "Shadow of the Goblin", + ); + assert_eq!(def.mode, TriggerMode::PlayCard); + assert_eq!(def.valid_target, Some(TargetFilter::Controller)); + assert_eq!( + def.spell_cast_origin, + OriginConstraint::NotEquals(Zone::Hand) + ); + // No card-type narrowing — any land/spell from a non-hand zone qualifies. + assert!(def.valid_card.is_none()); +} + #[test] fn trigger_you_cast_target_player_mill_instead_keeps_chosen_player() { let def = parse_trigger_line( @@ -14636,6 +14699,101 @@ fn phase_trigger_each_players_upkeep_no_constraint() { assert_eq!(def.constraint, None); } +/// CR 102.2 / CR 102.3 + CR 402.1 + CR 603.2b + CR 608.2c: Fevered +/// Visions keeps the phase trigger unconditional, draws for the scoped +/// phase-player first, then gates the damage instruction on BOTH that player's +/// opponent relation and post-draw hand size. +#[test] +fn fevered_visions_scoped_player_damage_gate_is_fully_typed() { + const ORACLE: &str = "At the beginning of each player's end step, that player draws a card. If the player is your opponent and has four or more cards in hand, this enchantment deals 2 damage to that player."; + + fn count_unimplemented(ability: &AbilityDefinition) -> usize { + usize::from(matches!( + ability.effect.as_ref(), + Effect::Unimplemented { .. } + )) + ability + .sub_ability + .as_deref() + .map(count_unimplemented) + .unwrap_or(0) + + ability + .else_ability + .as_deref() + .map(count_unimplemented) + .unwrap_or(0) + } + + let parsed = parse_oracle_text( + ORACLE, + "Fevered Visions", + &[], + &["Enchantment".to_string()], + &[], + ); + assert_eq!(parsed.triggers.len(), 1, "{:?}", parsed.triggers); + let trigger = &parsed.triggers[0]; + assert_eq!(trigger.mode, TriggerMode::Phase); + assert_eq!(trigger.phase, Some(Phase::End)); + assert_eq!( + trigger.condition, None, + "the damage rider is a resolution-time instruction condition, not an intervening-if" + ); + + let draw = trigger.execute.as_deref().expect("trigger execute"); + assert_eq!( + draw.effect.as_ref(), + &Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::ScopedPlayer, + } + ); + let damage = draw + .sub_ability + .as_deref() + .expect("conditional damage child"); + assert_eq!( + damage.effect.as_ref(), + &Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 2 }, + target: TargetFilter::ScopedPlayer, + damage_source: None, + excess: None, + } + ); + assert_eq!( + damage.condition, + Some(AbilityCondition::And { + conditions: vec![ + AbilityCondition::ScopedPlayerMatches { + filter: PlayerFilter::Opponent, + }, + AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::HandSize { + player: PlayerScope::ScopedPlayer, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + ], + }) + ); + assert_eq!( + count_unimplemented(draw), + 0, + "Fevered Visions must contain no Unimplemented effects: {draw:?}" + ); + assert!( + parsed.parse_warnings.iter().all(|warning| !matches!( + warning, + OracleDiagnostic::SwallowedClause { detector, .. } if detector == "Condition_If" + )), + "Fevered Visions must not emit Condition_If: {:?}", + parsed.parse_warnings + ); +} + /// CR 603.2b + CR 608.2c: Roiling Vortex — "At the beginning of each player's /// upkeep, this enchantment deals 1 damage to them." The bare player anaphor /// "them" is the player whose upkeep it is — the same referent "that player" @@ -24976,3 +25134,288 @@ fn ketramose_exile_trigger_gated_on_source_zones_and_own_turn() { trigger.constraint ); } + +/// CR 303.4b + CR 301.5a + CR 603.2 (issue: Sigil of Sleep freeze): +/// An article-less damage-source subject — the Aura/Equipment self-referential +/// determiners "enchanted creature" / "equipped creature" — must still be +/// recognized as a "deals damage to a player" trigger so a later "that player" +/// anaphor binds to the DAMAGED (triggering) player. Before the fix, +/// `parse_damage_source_subject` required a leading article, so these subjects +/// failed the strict `is_damage_done_trigger_pattern` check and fell through to +/// the `condition_introduces_target_player` branch, yielding the wrong +/// `TargetPlayer` scope (which surfaces a spurious companion Player target slot +/// at runtime). Building-block test: assert the single-authority scope resolver +/// itself, so every card in the class is covered — not just one. +#[test] +fn relative_player_scope_binds_article_less_damage_source_to_triggering_player() { + for cond in [ + "enchanted creature deals damage to a player", + "equipped creature deals combat damage to a player", + ] { + assert!( + is_damage_done_trigger_pattern(cond), + "article-less subject must be a DamageDone pattern: {cond:?}", + ); + assert_eq!( + relative_player_scope_for_condition(cond), + Some(ControllerRef::TriggeringPlayer), + "\"that player\" in {cond:?} must bind to the damaged (triggering) player", + ); + } +} + +/// CR 120.3 + CR 603.7c: Sigil of Sleep's bounce must target a creature the +/// DAMAGED player controls (`ControllerRef::TriggeringPlayer`), not a +/// separately-chosen `TargetPlayer`. The `TargetPlayer` mis-scoping made the +/// runtime surface a phantom Player target slot, freezing the game (the reported +/// bug). Uses the card's verbatim Oracle text. +#[test] +fn parse_sigil_of_sleep_bounce_targets_triggering_player_controlled_creature() { + let def = parse_trigger_line( + "Whenever enchanted creature deals damage to a player, return target creature that player controls to its owner's hand.", + "Sigil of Sleep", + ); + + let execute = def.execute.as_ref().expect("execute must be Some"); + match &*execute.effect { + Effect::Bounce { target, .. } => match target { + TargetFilter::Typed(tf) => { + assert_eq!( + tf.controller, + Some(ControllerRef::TriggeringPlayer), + "bounce target must be controlled by the damaged (triggering) player, got {:?}", + tf.controller, + ); + assert!( + tf.type_filters.contains(&TypeFilter::Creature), + "bounce target must be a creature, got {:?}", + tf.type_filters, + ); + } + other => panic!("bounce target must be a Typed creature filter, got {other:?}"), + }, + other => panic!("Sigil of Sleep effect must be Bounce, got {other:?}"), + } +} + +// ----------------------------------------------------------------------- +// Mutable Pupa — perpetual keyword-mirror ETB trigger (issue #6321). +// Digital-only Alchemy (no CR entry for "perpetually"); CR 702.1c + CR 608.2c govern the +// per-branch resolution order the `SiblingCondition::ReplicatedOrBranch` marker +// restores. Oracle text verified verbatim against data/card-data.json. +// ----------------------------------------------------------------------- + +// The antecedent on its own — the SAME production entry point the real pipeline +// uses (`parse_trigger_line`), but with a SINGLE-sentence body (no "The same is +// true for …" tail), so no keyword replication runs. This isolates the +// antecedent build: the trigger body reaches `parse_effect_chain_ir`'s chunk +// loop with `in_trigger == true`, where `strip_suffix_conditional`'s +// trigger-gated `ZoneChangeObjectMatchesFilter` branch peels the trailing "if +// that creature has flying" gate BEFORE `parse_effect_clause` sees the chunk; +// the short bare-keyword form then lands `try_parse_perpetual_grant_keywords` +// (`ApplyPerpetual { GrantKeywords[Flying] }`) and the peeled gate is reattached +// at the chunk level. The root node must carry BOTH — proving the antecedent +// builds correctly through the real suffix-strip path without depending on the +// replication machinery. +#[test] +fn mutable_pupa_antecedent_clause_grants_and_gates_the_same_keyword() { + let def = parse_trigger_line( + "Whenever another creature you control enters, this creature perpetually gains flying if that creature has flying.", + "Mutable Pupa", + ); + let root = def + .execute + .as_deref() + .expect("trigger has an ability chain"); + // Single-sentence antecedent: exactly one node, no replicated siblings. + assert!( + root.sub_ability.is_none(), + "single-sentence antecedent must not build a sibling chain", + ); + match &*root.effect { + Effect::ApplyPerpetual { + modification: PerpetualModification::GrantKeywords { keywords }, + .. + } => assert_eq!( + keywords, + &vec![Keyword::Flying], + "antecedent must grant exactly Flying" + ), + other => panic!("expected ApplyPerpetual GrantKeywords, got {other:?}"), + } + assert_eq!( + root.condition, + Some(AbilityCondition::ZoneChangeObjectMatchesFilter { + origin: None, + destination: crate::types::zones::Zone::Battlefield, + filter: TargetFilter::Typed(TypedFilter { + properties: vec![FilterProp::WithKeyword { + value: Keyword::Flying + }], + ..Default::default() + }), + }), + "antecedent must be gated on the entering object having Flying", + ); +} + +// The whole two-sentence trigger builds EXACTLY 12 independent keyword-mirror +// nodes. Each node grants ONLY its own keyword and is gated on THAT SAME keyword +// (the positional correspondence is the "list collapse" regression guard: a +// bug that reused keyword[0] for every gate would fail the per-node condition +// assertion). Nodes 1..11 are `SequentialSibling` + `ReplicatedOrBranch`; the +// root is the unmarked `ContinuationStep` antecedent. +#[test] +fn mutable_pupa_full_trigger_builds_twelve_independent_keyword_mirrors() { + let def = parse_trigger_line( + "Whenever another creature you control enters, this creature perpetually gains flying if that creature has flying. The same is true for first strike, double strike, deathtouch, haste, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance.", + "Mutable Pupa", + ); + let root = def + .execute + .as_deref() + .expect("trigger has an ability chain"); + let mut nodes: Vec<&AbilityDefinition> = Vec::new(); + let mut cur = Some(root); + while let Some(n) = cur { + nodes.push(n); + cur = n.sub_ability.as_deref(); + } + let expected = [ + Keyword::Flying, + Keyword::FirstStrike, + Keyword::DoubleStrike, + Keyword::Deathtouch, + Keyword::Haste, + Keyword::Hexproof, + Keyword::Indestructible, + Keyword::Lifelink, + Keyword::Menace, + Keyword::Reach, + Keyword::Trample, + Keyword::Vigilance, + ]; + assert_eq!( + nodes.len(), + expected.len(), + "expected exactly 12 keyword-mirror nodes, got {}", + nodes.len() + ); + for (i, (node, kw)) in nodes.iter().zip(expected.iter()).enumerate() { + match &*node.effect { + Effect::ApplyPerpetual { + modification: PerpetualModification::GrantKeywords { keywords }, + .. + } => assert_eq!( + keywords, + &vec![kw.clone()], + "node {i} must grant only {kw:?}" + ), + other => panic!("node {i}: expected ApplyPerpetual GrantKeywords, got {other:?}"), + } + assert_eq!( + node.condition, + Some(AbilityCondition::ZoneChangeObjectMatchesFilter { + origin: None, + destination: crate::types::zones::Zone::Battlefield, + filter: TargetFilter::Typed(TypedFilter { + properties: vec![FilterProp::WithKeyword { value: kw.clone() }], + ..Default::default() + }), + }), + "node {i} must be gated on {kw:?} (not keyword[0])", + ); + if i == 0 { + assert_eq!( + node.sub_link, + SubAbilityLink::ContinuationStep, + "root antecedent is a continuation step", + ); + assert_eq!( + node.sibling_condition, + SiblingCondition::Dependent, + "root antecedent keeps the default sibling condition", + ); + } else { + assert_eq!( + node.sub_link, + SubAbilityLink::SequentialSibling, + "node {i} must be a sequential sibling", + ); + assert_eq!( + node.sibling_condition, + SiblingCondition::ReplicatedOrBranch, + "node {i} must be an independent OR-branch", + ); + } + } +} + +// Non-regression: Odric's "the same is true for" antecedent is a static +// keyword grant (`GenericEffect`, replicated in-place into `static_abilities`), +// NOT a perpetual grant — the new shape-based `ReplicateKind` selection must +// keep routing it through `StaticGrant`, so no node becomes `ApplyPerpetual` and +// no `SequentialSibling` sibling chain is built. +#[test] +fn odric_same_is_true_stays_generic_effect_not_perpetual_chain() { + let def = parse_trigger_line( + "At the beginning of each combat, creatures you control gain first strike until end of turn if a creature you control has first strike. The same is true for flying, deathtouch, double strike, haste, hexproof, indestructible, lifelink, menace, reach, skulk, trample, and vigilance.", + "Odric, Lunarch Marshal", + ); + let root = def + .execute + .as_deref() + .expect("trigger has an ability chain"); + assert!( + matches!(&*root.effect, Effect::GenericEffect { .. }), + "Odric's antecedent must stay a GenericEffect keyword grant, got {:?}", + root.effect, + ); + let mut cur = Some(root); + while let Some(n) = cur { + assert!( + !matches!(&*n.effect, Effect::ApplyPerpetual { .. }), + "Odric must never route through the perpetual keyword-grant path", + ); + assert_eq!( + n.sibling_condition, + SiblingCondition::Dependent, + "Odric nodes must not be stamped ReplicatedOrBranch", + ); + cur = n.sub_ability.as_deref(); + } +} + +// Field-level non-regression: an ordinary DEPENDENT continuation (Thieving +// Skydiver's "If that artifact is an Equipment, attach it") must keep the +// default `SiblingCondition::Dependent` — the `ReplicatedOrBranch` marker is +// stamped ONLY by the two replication helpers, never by sentence-boundary +// sibling stamping. The `node_count >= 2` reach guard proves the multi-node +// continuation chain actually built (so the all-`Dependent` assertion is not +// vacuous on a single node). +#[test] +fn thieving_skydiver_dependent_continuation_is_never_replicated_or_branch() { + let def = parse_trigger_line( + "When this creature enters, if it was kicked, gain control of target artifact with mana value X or less. If that artifact is an Equipment, attach it to this creature.", + "Thieving Skydiver", + ); + let root = def + .execute + .as_deref() + .expect("trigger has an ability chain"); + let mut cur = Some(root); + let mut node_count = 0usize; + while let Some(n) = cur { + node_count += 1; + assert_eq!( + n.sibling_condition, + SiblingCondition::Dependent, + "no Thieving Skydiver node may be stamped ReplicatedOrBranch", + ); + cur = n.sub_ability.as_deref(); + } + assert!( + node_count >= 2, + "reach guard: Thieving Skydiver must build a multi-node chain (GainControl + Attach continuation), got {node_count}", + ); +} diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 64269f60d1..16264ddcef 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -1334,6 +1334,16 @@ pub enum ChosenAttribute { /// Replace-on-rechoose: the {Left,Right} hijack in `choose.rs` removes any /// prior `Direction` before pushing, so only the "last chosen" survives. Direction(SeatDirection), + /// CR 707.2c + CR 614.12a + CR 400.7: The copiable-values snapshot chosen as + /// an `Effect::ChoosePermanent` Aura entered (Metamorphic Alteration: + /// "choose a creature"). Like `Card` and `TributeOutcome`, this is + /// ENGINE-SET (written directly at the `CopyTargetChoice` answer, not + /// produced through `ChoiceType`/`from_choice`) — it records the frozen + /// snapshot the companion copy effect was installed from, and, being stored + /// in `chosen_attributes`, is cleared automatically when the Aura changes + /// zones (CR 400.7), which is exactly the copy's lifetime. Boxed to keep + /// the enum small (mirrors the copy-value box idiom). + CopiableSnapshot(Box), } impl ChosenAttribute { @@ -1396,6 +1406,13 @@ impl ChosenAttribute { Self::Direction(_) => ChoiceType::Labeled { options: vec!["Left".into(), "Right".into()], }, + // Engine-set, never player-prompted (written directly at the + // `CopyTargetChoice` answer, not via `from_choice`). Mirrors the + // `Card` placeholder: an empty `Labeled` template rather than + // inventing a spurious copy-snapshot choice category. + Self::CopiableSnapshot(_) => ChoiceType::Labeled { + options: Vec::new(), + }, } } @@ -2448,6 +2465,20 @@ pub enum Duration { Permanent, } +/// The attacker named by a force-block instruction. +/// +/// This intentionally has only exact single-object referents. A filter would +/// allow resolution to reselect a different attacker after the trigger event; +/// CR 400.7 requires the event object, not a later object sharing its id. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ForceBlockAttackerRef { + /// "blocks this creature if able" / Provoke's source-referential form. + Source, + /// "blocks that Wolf if able" from a narrowed attack trigger event. + EventSource, +} + // --------------------------------------------------------------------------- // Game restriction system — composable runtime restrictions // --------------------------------------------------------------------------- @@ -2602,6 +2633,19 @@ pub enum RestrictionExpiry { UntilEndOfNextTurnOf { player: PlayerId, }, + /// CR 614.1a + CR 400.7: a runtime-installed replacement bound to the + /// lifetime of the OBJECT hosting it (the "if it would leave the + /// battlefield, exile it instead" rider of Unearth / Gruesome Encore / + /// Whip of Erebos, CR 702.84a). It is ONLY meaningful on an object-hosted + /// `ReplacementDefinition::expiry`; it is meaningless for the state-hosted + /// `GameRestriction` / `pending_damage_replacements` that share this enum, + /// and it is NEVER pruned by turn machinery (no untap/cleanup step ends it). + /// It lapses solely via the battlefield-exit prune + /// (`zones.rs` -> `layers.rs::prune_controller_controls_source_on_leave`). + /// Three operational consequences follow: it is base-installed so it + /// survives CR 613.1 layer reseeds; it is non-copiable (CR 707.2); and its + /// base+live copies are pruned together when the host leaves play. + UntilHostLeavesPlay, } /// Limits the scope of a game restriction to specific sources or targets. @@ -4765,6 +4809,11 @@ pub enum TargetFilter { /// (Amareth, the Lustrous) and by `AbilityCondition::ObjectsShareQuality` /// subject slots. LastRevealed, + /// CR 608.2c: Resolves to the card(s) most recently moved to a public zone + /// by the resolving spell or ability (`state.last_zone_changed_ids`). Used + /// by anaphoric "milled this way" / "exiled this way" quantity gates + /// (Grindstone) and `ObjectCountBySharedQuality` population filters. + LastZoneChanged, /// CR 400.7j + CR 608.2k: Resolves to the object paid as a cost for the /// resolving spell or ability. Used by effects such as "the exiled card" /// after an exile-as-cost clause. @@ -8743,6 +8792,10 @@ pub enum AdditionalCostOrigin { /// any optional additional cost) and lets Teamwork compose with another /// object additional cost in the announcement queue. Teamwork, + /// CR 702.174a: Gift — optional “choose an opponent” additional cost. A + /// dedicated origin distinguishes a promised gift from an unrelated optional + /// additional cost and lets the UI present Gift-specific promise copy. + Gift, #[default] Other, } @@ -10847,6 +10900,22 @@ pub enum Effect { #[serde(default, skip_serializing_if = "Vec::is_empty")] additional_modifications: Vec, }, + /// CR 614.12a + CR 707.2c: "As this Aura enters, choose a creature." The + /// controller chooses a permanent matching `filter` as the Aura enters + /// (CR 614.12a: the choice is made before the permanent enters). Unlike + /// `BecomeCopy` — where the *entering* object becomes the copy — this effect + /// latches the chosen permanent's copiable values and applies them to a + /// *separate* recipient: the Aura's enchanted host (Metamorphic Alteration). + /// Runtime discrimination is `CopyTargetPurpose::PersistChosenAttribute`; + /// emission is gated on `LinkedChoiceKind::CopyChosenHost` (companion + /// `ContinuousModification::CopyChosen` is a parse-time marker only). The + /// copy is materialized once, at the choice answer, as a Layer-1 + /// `CopyValues` transient continuous effect whose values are fixed per + /// CR 707.2c (determined when the copy effect first starts to apply). + ChoosePermanent { + #[serde(default = "default_target_filter_any")] + filter: TargetFilter, + }, /// CR 113.1a + CR 113.10 + CR 611.2 + CR 611.2c + CR 613.1f: Grant the /// recipient(s) all activated abilities of a chosen target object, for a /// duration. Unlike the static-side `ContinuousModification::GrantAllActivatedAbilitiesOf` @@ -11165,6 +11234,27 @@ pub enum Effect { #[serde(default = "default_target_filter_self_ref")] target: TargetFilter, }, + /// CR 710.4: Flip a Kamigawa flip permanent — a one-way status change + /// (CR 110.5) after which the card's alternative name, text box, type line, + /// power, and toughness apply (CR 710.1b) while it remains on the + /// battlefield. + /// + /// Deliberately a sibling of [`Effect::Transform`] rather than a + /// parameterization of it: CR 701.27a restricts transforming to permanents + /// represented by double-faced cards, and CR 710.1c holds a flipped + /// permanent's color and mana cost FIXED where transforming swaps them. + /// The two live in different CR sections (701.27 vs 710) with different + /// copiable-value semantics, so the shared "turn the card over" surface is + /// not a single parameterized axis. + /// + /// Every printed flip instruction names the permanent itself ("flip this + /// creature" / "flip it" / "flip "), so `target` is `SelfRef` for the + /// whole corpus; it is a `TargetFilter` for uniformity with `Transform` and + /// so an anaphoric trigger subject can bind the flipping permanent. + FlipPermanent { + #[serde(default = "default_target_filter_self_ref")] + target: TargetFilter, + }, /// Search a player's library for card(s) matching a filter. /// The destination is handled by the sub_ability chain (ChangeZone + Shuffle). SearchLibrary { @@ -11430,10 +11520,18 @@ pub enum Effect { #[serde(default = "default_target_filter_any")] target: TargetFilter, }, - /// CR 509.1g: Target creature must block this turn if able. + /// CR 509.1c: Target creature must block this turn/combat if able. ForceBlock { #[serde(default = "default_target_filter_any")] target: TargetFilter, + /// The exact grammatical attacker referent, if the instruction names + /// one. `None` remains the generic "blocks this turn if able" form. + #[serde(default, skip_serializing_if = "Option::is_none")] + attacker: Option, + /// `this combat` expires at end of combat; legacy payloads without a + /// duration retain the established `this turn` lifetime. + #[serde(default = "default_duration_until_end_of_turn")] + duration: Duration, }, /// CR 508.1d: Target creature must attack the required player this turn/combat if able. ForceAttack { @@ -14090,6 +14188,9 @@ impl Effect { | Effect::Discard { target, .. } | Effect::Shuffle { target, .. } | Effect::Transform { target, .. } + // CR 710.4: the flipping permanent is named by the effect's target + // slot exactly like `Transform`'s. + | Effect::FlipPermanent { target, .. } | Effect::RevealHand { target, .. } | Effect::Reveal { target, .. } | Effect::TargetOnly { target, .. } @@ -14384,6 +14485,10 @@ impl Effect { // target) picked at resolution time via // `WaitingFor::ReturnAsAuraTarget`. No stack-push target slot. | Effect::ReturnAsAura { .. } + // CR 614.12a + CR 707.2c: Metamorphic Alteration's "As this Aura + // enters, choose a creature" is an as-enters replacement CHOICE picked + // via `WaitingFor::CopyTargetChoice`, never a stack-declared target. + | Effect::ChoosePermanent { .. } | Effect::ChooseFromZone { .. } | Effect::ForEachCategory { .. } | Effect::ChooseAndSacrificeRest { .. } @@ -14689,6 +14794,8 @@ impl Effect { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c: the copy-source choice carries no magnitude. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::MultiplyCounter { .. } @@ -14706,6 +14813,7 @@ impl Effect { | Effect::SetRoomDoorLock { .. } | Effect::ExtraTurn { .. } | Effect::Transform { .. } + | Effect::FlipPermanent { .. } | Effect::RevealTop { .. } | Effect::Reveal { .. } | Effect::TargetOnly { .. } @@ -14942,6 +15050,8 @@ impl Effect { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c: the copy-source choice carries no magnitude. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::MultiplyCounter { .. } @@ -14959,6 +15069,7 @@ impl Effect { | Effect::SetRoomDoorLock { .. } | Effect::ExtraTurn { .. } | Effect::Transform { .. } + | Effect::FlipPermanent { .. } | Effect::RevealTop { .. } | Effect::Reveal { .. } | Effect::TargetOnly { .. } @@ -15152,6 +15263,7 @@ pub fn effect_variant_name(effect: &Effect) -> &str { Effect::HideawayConceal { .. } => "HideawayConceal", Effect::CopyTokenBlockingAttacker { .. } => "CopyTokenBlockingAttacker", Effect::BecomeCopy { .. } => "BecomeCopy", + Effect::ChoosePermanent { .. } => "ChoosePermanent", Effect::GainActivatedAbilitiesOfTarget { .. } => "GainActivatedAbilitiesOfTarget", Effect::ChooseCard { .. } => "ChooseCard", Effect::PutCounter { .. } => "PutCounter", @@ -15171,6 +15283,7 @@ pub fn effect_variant_name(effect: &Effect) -> &str { Effect::Discard { .. } => "Discard", Effect::Shuffle { .. } => "Shuffle", Effect::Transform { .. } => "Transform", + Effect::FlipPermanent { .. } => "FlipPermanent", Effect::SearchLibrary { .. } => "SearchLibrary", Effect::SearchOutsideGame { .. } => "SearchOutsideGame", Effect::RevealHand { .. } => "RevealHand", @@ -15371,6 +15484,7 @@ pub enum EffectKind { Tribute, TimeTravel, BecomeMonarch, + ChoosePermanent, NoOp, Proliferate, ProliferateTarget, @@ -15552,6 +15666,8 @@ pub enum EffectKind { DraftFromSpellbook, ChooseOneOf, ChooseCounterAdjustment, + /// CR 710.4: a Kamigawa flip permanent was flipped to its alternative face. + FlipPermanent, Unimplemented, /// Engine-level equip action (not via an Effect handler). Equip, @@ -15653,6 +15769,7 @@ impl From<&Effect> for EffectKind { // is bookkeeping layered on top of the same token-copy creation. Effect::CopyTokenBlockingAttacker { .. } => EffectKind::CopyTokenOf, Effect::BecomeCopy { .. } => EffectKind::BecomeCopy, + Effect::ChoosePermanent { .. } => EffectKind::ChoosePermanent, Effect::GainActivatedAbilitiesOfTarget { .. } => { EffectKind::GainActivatedAbilitiesOfTarget } @@ -15672,6 +15789,7 @@ impl From<&Effect> for EffectKind { Effect::Discard { .. } => EffectKind::Discard, Effect::Shuffle { .. } => EffectKind::Shuffle, Effect::Transform { .. } => EffectKind::Transform, + Effect::FlipPermanent { .. } => EffectKind::FlipPermanent, Effect::SearchLibrary { .. } => EffectKind::SearchLibrary, Effect::SearchOutsideGame { .. } => EffectKind::SearchOutsideGame, Effect::RevealHand { .. } => EffectKind::Reveal, @@ -16368,6 +16486,12 @@ pub struct AbilityDefinition { /// counter type must be rewritten to the current iteration's counter kind /// before resolution. `None` (default) = branch is fixed (e.g. "+1/+1"). pub iteration_kind_binding: Option, + /// CR 702.1c ("the same is true") + CR 608.2c (written order): whether a + /// `SequentialSibling` continuation with its OWN gating condition must still be + /// checked when a PRECEDING sibling's condition was + /// false. See `SiblingCondition`. `Dependent` (default) preserves today's + /// behavior; `ReplicatedOrBranch` marks per-item keyword-list replication. + pub sibling_condition: SiblingCondition, } /// Private serialization mirror for `AbilityDefinition`. Holds a borrowed view @@ -16443,6 +16567,8 @@ struct AbilityDefinitionRepr<'a> { sub_link: SubAbilityLink, #[serde(skip_serializing_if = "Option::is_none")] iteration_kind_binding: &'a Option, + #[serde(skip_serializing_if = "SiblingCondition::is_default")] + sibling_condition: SiblingCondition, } impl Serialize for AbilityDefinition { @@ -16487,6 +16613,7 @@ impl Serialize for AbilityDefinition { repeat_until, sub_link, iteration_kind_binding, + sibling_condition, } = self; let repr = AbilityDefinitionRepr { kind, @@ -16526,6 +16653,7 @@ impl Serialize for AbilityDefinition { repeat_until, sub_link: *sub_link, iteration_kind_binding, + sibling_condition: *sibling_condition, }; /// Flatten wrapper: the mirror carries the real field set; /// `consumes_source` (#506) and `is_mana_ability` (CR 605.1a) are @@ -16634,6 +16762,8 @@ struct AbilityDefinitionDe { sub_link: SubAbilityLink, #[serde(default)] iteration_kind_binding: Option, + #[serde(default)] + sibling_condition: SiblingCondition, } impl<'de> Deserialize<'de> for AbilityDefinition { @@ -16683,6 +16813,7 @@ impl<'de> Deserialize<'de> for AbilityDefinition { repeat_until: de.repeat_until, sub_link: de.sub_link, iteration_kind_binding: de.iteration_kind_binding, + sibling_condition: de.sibling_condition, }) } } @@ -16690,22 +16821,32 @@ impl<'de> Deserialize<'de> for AbilityDefinition { /// CR 608.2c: How a `sub_ability` relates to its parent in the resolution chain. /// Determines whether the sub is part of the parent's action (skipped when an /// optional parent is declined) or an independent following instruction (always -/// resolves). Derived at parse time from the `ClauseBoundary` separating the -/// two clauses in the printed Oracle text. +/// resolves). Seeded at parse time from the `ClauseBoundary` separating the two +/// clauses in the printed Oracle text. +/// +/// **The value is semantic, not purely syntactic.** The sentence boundary is the +/// default reading, but a later parse pass may override it when the printed text +/// makes a separate sentence part of the previous instruction anyway — see +/// `parser::oracle_effect::lower::relink_gated_token_referent_consumers`, which +/// re-tags a separate-sentence clause whose only subject is a token created by a +/// gated clause before it ("…create a token. Put a +1/+1 counter on that +/// token."). Do not add a consumer that infers a *sentence boundary* from this +/// field; infer only the *dependency* the variant docs describe. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum SubAbilityLink { - /// Within-sentence continuation (comma / "then" joined). The sub is a - /// resolution step of the parent's instruction — Squadron Hawk + /// The sub is a resolution step of the parent's instruction — Squadron Hawk /// "...put them into your hand, then shuffle." Skipped when an optional - /// parent is declined. This is the default: an unmarked sub is a - /// continuation, preserving today's runtime behavior for every existing + /// parent is declined. Ordinarily a within-sentence continuation (comma / + /// "then" joined), but also a separate sentence that depends on the + /// parent's action for its subject. This is the default: an unmarked sub is + /// a continuation, preserving today's runtime behavior for every existing /// chain. #[default] ContinuationStep, - /// Separate-sentence sibling instruction (sentence boundary). The sub is - /// the NEXT printed instruction, independent of the parent — Ponder - /// "You may shuffle." "Draw a card." Always resolves, even when an + /// The sub is the NEXT printed instruction, independent of the parent — + /// Ponder "You may shuffle." "Draw a card." Always resolves, even when an /// optional parent is declined (CR 608.2c "in the order written"). + /// Ordinarily produced by a sentence boundary. SequentialSibling, } @@ -16716,6 +16857,36 @@ impl SubAbilityLink { } } +/// CR 702.1c ("the same is true") + CR 608.2c (written order): whether a +/// `SequentialSibling` continuation with its OWN gating condition must still be +/// checked when a PRECEDING sibling's condition was +/// false. `Dependent` (default) is today's behavior — the continuation's own +/// condition/effect may presuppose the preceding sibling's effect actually ran +/// (Thieving Skydiver's "If that artifact is an Equipment" presupposes +/// `GainControl` produced a target), so it is skipped alongside a failed +/// predecessor. `ReplicatedOrBranch` marks a sibling produced by per-item +/// keyword-list replication ("The same is true for…" is CR 702.1c; "Repeat +/// this process for…" follows CR 608.2c) — each item is an INDEPENDENT OR-branch checked on its own +/// keyword, so it must be evaluated regardless of any other branch's outcome. +/// Stamped ONLY by the `ReplicatePerKeyword` lowering helpers +/// (`attach_repeat_process_keywords`, `attach_perpetual_keyword_grants`) — +/// never by ordinary sentence-boundary `SequentialSibling` stamping — so it +/// cannot leak into a Thieving-Skydiver-shaped dependent continuation that +/// also happens to carry `SequentialSibling`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum SiblingCondition { + #[default] + Dependent, + ReplicatedOrBranch, +} + +impl SiblingCondition { + /// `skip_serializing_if` predicate — the default needs no JSON byte. + pub fn is_default(cond: &Self) -> bool { + matches!(cond, Self::Dependent) + } +} + /// CR 608.2c + CR 107.1c: how a "repeat this process" loop decides whether to /// run another iteration. The non-count companion to `AbilityDefinition`'s /// `repeat_for` (a fixed `QuantityExpr` count) — this predicate decides @@ -16837,6 +17008,7 @@ impl AbilityDefinition { repeat_until: None, sub_link: SubAbilityLink::ContinuationStep, iteration_kind_binding: None, + sibling_condition: SiblingCondition::Dependent, } } @@ -17004,6 +17176,64 @@ impl AbilityDefinition { self.mode_abilities = mode_abilities; self } + + /// CR 603.12: Does this clause carry its own copy of an affirmative + /// reflexive gate, in the narrow POSITION-INDEPENDENT form, so that the + /// resolver drops it on the gate-false path wherever it sits in the chain? + /// + /// Lives here rather than in the parser because + /// `parser::oracle_effect::lower::clone_would_transplant_gated_referent` + /// uses it to decide whether a "Repeat this process for …" clone is honest + /// at its landing slot, and the `no_sequential_sibling_reads_last_created` + /// regression test must exempt exactly the nodes that predicate accepts. + /// `pub` specifically because a test under `crates/engine/tests/` cannot + /// call a private `fn` in `lower`, so the alternative was a second copy of + /// the predicate inside the test. + /// + /// It is NOT the engine's single authority for this shape, and does not + /// claim to be. `game::effects::resolve_optional_effect_decision` carries a + /// structurally identical inlined copy on the RUNTIME twin type — the same + /// two fields, the same `AbilityCondition::is_optional_effect_performed`, + /// the same conjunction — and it cannot call this method: its receiver is + /// `ResolvedAbility`, not `AbilityDefinition`, with no `Deref`/`From` + /// bridge between them (measured, not assumed: wiring the call there fails + /// to compile with E0599, method not found for `&ResolvedAbility`). + /// Unifying the two would take a trait over both types — a resolver-side + /// abstraction with its own blast radius, which this change deliberately + /// does not introduce. + /// + /// `parser::oracle_effect::attach_repeat_process_keywords` clones + /// `condition` and `else_ability` verbatim, and the resolver's + /// condition-false path (`game::effects::resolve_ability_chain`) reads BOTH + /// — at two different places, which is why both are tested here: + /// + /// * a false condition never runs the clause's own effect; `else_ability` + /// decides what runs INSTEAD. With an else present the resolver resolves + /// the else branch and RETURNS, so the clause's `sub_ability` tail never + /// runs at all and the cloned else — a second copy of a printed + /// instruction — executes. "The descent just drops this clause" would then + /// be the wrong description of the outcome. + /// * with no else present the resolver walks `sub_ability` for the next + /// `SequentialSibling`, skipping one whose own condition is false and + /// which itself has no else. + /// + /// Only `EffectOutcome { OptionalEffectPerformed }` qualifies, NOT the whole + /// of [`AbilityCondition::is_affirmative_reflexive_gate`]. Its evaluator + /// reads `context.optional_effect_performed` and + /// `state.cost_payment_failed_flag`, which are chain-scoped and therefore + /// position-independent. `WhenYouDo`'s evaluator is + /// `!(matches!(ability.effect, PayCost | Discard | DiscardCard) && + /// cost_payment_failed_flag)` — it keys on the effect it RIDES, so on a + /// cloned `PutCounter` it is a constant `true` and gates nothing. Measured: + /// the same fixture with `If you do` leaves a stale pre-seeded token at `{}` + /// and with `When you do` puts a `Keyword(FirstStrike)` counter on it. + pub fn is_self_gated_reflexive(&self) -> bool { + self.else_ability.is_none() + && self + .condition + .as_ref() + .is_some_and(AbilityCondition::is_optional_effect_performed) + } } /// The result of an `Effect::OpponentGuess` round-trip. @@ -17510,6 +17740,99 @@ impl AbilityCondition { matches!(self, AbilityCondition::EffectOutcome { .. }) } + /// CR 603.12 + CR 608.2c: True for the AFFIRMATIVE reflexive-conditional + /// gates — exactly the image of + /// `parser::oracle_nom::condition::parse_affirmative_reflexive_connector` + /// ("when you do", "if you do", "if they do", "if that player does", + /// "if the player does", "if a player does"). + /// + /// Such a gate does not introduce an object referent (CR 608.2c: the + /// clause's *effect* publishes the referent, its gate does not), and its + /// true-branch is exactly "the antecedent action occurred" (CR 603.12). Two + /// parse-time sites consume this classification and MUST agree: the referent + /// walk (`parser::oracle_effect::chain_prior_referent_is_created_token`) and + /// the re-link pass + /// (`parser::oracle_effect::lower::relink_gated_token_referent_consumers`). + /// Widening one without the other re-opens the stale-`LastCreated` bind. + /// + /// Classified on the VALUE, not on the phrase that produced it: + /// `AbilityCondition::effect_performed()` is also constructed at + /// non-connector sites, and the classification is sound for all of them + /// because the value itself means "the antecedent optional effect was + /// performed" — a token created under it may not exist, whatever text minted + /// the gate. + /// + /// The NEGATED half (`if they don't` → `Not{OptionalEffectPerformed}`) is + /// deliberately excluded: its body runs precisely when the antecedent did + /// NOT happen, so folding the gate away would invert the guarantee. + /// + /// Exhaustive, no wildcard: a new `AbilityCondition` variant must be + /// classified here deliberately, not silently defaulted (mirrors + /// `game::effects::should_resolve_subability_on_optional_decline`). + pub fn is_affirmative_reflexive_gate(&self) -> bool { + match self { + AbilityCondition::WhenYouDo + | AbilityCondition::EffectOutcome { + signal: EffectOutcomeSignal::OptionalEffectPerformed, + } => true, + AbilityCondition::EffectOutcome { + signal: + EffectOutcomeSignal::CurrentScopeSucceeded | EffectOutcomeSignal::Guessed { .. }, + } => false, + AbilityCondition::AdditionalCostPaidInstead + | AbilityCondition::AlternativeManaCostPaid + | AbilityCondition::EventOutcomeWon + | AbilityCondition::SourceEnteredThisTurn + | AbilityCondition::HasMaxSpeed + | AbilityCondition::IsMonarch + | AbilityCondition::IsInitiative + | AbilityCondition::HasCityBlessing + | AbilityCondition::IsRingBearer + | AbilityCondition::HasObjectTarget + | AbilityCondition::IsYourTurn + | AbilityCondition::FirstCombatPhaseOfTurn + | AbilityCondition::FirstEndStepOfTurn + | AbilityCondition::SourceIsTapped + | AbilityCondition::SourceAttachedToCreature + | AbilityCondition::DayNightIsNeither + | AbilityCondition::AdditionalCostPaid { .. } + | AbilityCondition::CoinFlipOutcome { .. } + | AbilityCondition::WasCast { .. } + | AbilityCondition::CastDuringPhase { .. } + | AbilityCondition::CurrentPhaseIs { .. } + | AbilityCondition::CastTimingPermission { .. } + | AbilityCondition::ManaColorSpent { .. } + | AbilityCondition::RevealedHasCardType { .. } + | AbilityCondition::ObjectsShareQuality { .. } + | AbilityCondition::TargetSharesNameWithOtherExiledThisWay { .. } + | AbilityCondition::CastVariantPaid { .. } + | AbilityCondition::CastVariantPaidInstead { .. } + | AbilityCondition::QuantityCheck { .. } + | AbilityCondition::PreviousEffectAmount { .. } + | AbilityCondition::CompletedDungeon { .. } + | AbilityCondition::TargetHasKeywordInstead { .. } + | AbilityCondition::TargetMatchesFilter { .. } + | AbilityCondition::TriggeringSpellTargetsFilter { .. } + | AbilityCondition::SourceMatchesFilter { .. } + | AbilityCondition::PostReplacementDamageSourceMatchesFilter { .. } + | AbilityCondition::ZoneChangeObjectMatchesFilter { .. } + | AbilityCondition::ControllerControlsMatching { .. } + | AbilityCondition::ControllerControlledMatchingAsCast { .. } + | AbilityCondition::WasStartingPlayer { .. } + | AbilityCondition::SpellCastWithVariantThisTurn { .. } + | AbilityCondition::ZoneChangedThisWay { .. } + | AbilityCondition::CostPaidObjectMatchesFilter { .. } + | AbilityCondition::ConditionInstead { .. } + | AbilityCondition::And { .. } + | AbilityCondition::Or { .. } + | AbilityCondition::Not { .. } + | AbilityCondition::DayNightIs { .. } + | AbilityCondition::NthResolutionThisTurn { .. } + | AbilityCondition::SourceLacksKeyword { .. } + | AbilityCondition::ScopedPlayerMatches { .. } => false, + } + } + pub fn is_optional_effect_performed(&self) -> bool { matches!( self, @@ -17677,6 +18000,10 @@ pub struct SpellContext { /// `None` until that choice is made (and for the single-opponent default). #[serde(default, skip_serializing_if = "Option::is_none")] pub announcing_opponent: Option, + /// CR 702.174a: Opponent chosen when the Gift additional cost was paid. + /// Distinct from `announcing_opponent` (CR 115.1 target-chooser). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gift_recipient: Option, /// Whether the spell's optional additional cost was paid during casting. #[serde(default)] pub additional_cost_paid: bool, @@ -17934,6 +18261,9 @@ pub enum TriggerCondition { /// CR 400.7 + CR 603.4: True when the source permanent entered the /// battlefield this turn. SourceEnteredThisTurn, + /// CR 400.7 + CR 508.1 + CR 603.4: True only when this exact source + /// incarnation attacked during the current combat. + SourceAttackedThisCombat, /// CR 702.30a: Echo intervening-if for a permanent that has not yet had /// its next-controller-upkeep echo payment handled. EchoDue, @@ -19625,6 +19955,30 @@ pub struct StaticDefinition { /// serialized statics (all unrestricted) round-trip unchanged. #[serde(default, skip_serializing_if = "Option::is_none")] pub bypass_beneficiary: Option, + /// CR 702.16n / CR 702.16p: When this continuous static grants protection, + /// attachments matching this exemption are not put into their owners' + /// graveyards as a state-based action by *this* protection instance + /// (Flickering Ward / Pentarch Ward / Ward cycle / Benevolent Blessing). + /// Other protection instances from the same quality still apply normally. + /// `None` = no exemption (ordinary protection). Serde-defaulted so + /// pre-existing card-data round-trips unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protection_does_not_remove: Option, +} + +/// CR 702.16n / CR 702.16p: Which attachments a protection-granting continuous +/// effect does not remove via SBA (and, for the already-attached form, which +/// may remain attached when the effect starts). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProtectionDoesNotRemove { + /// CR 702.16n: "This effect doesn't remove this Aura." — the grant source. + Source, + /// CR 702.16n: "This effect doesn't remove Auras." (Spectra Ward). + Auras, + /// CR 702.16p: "doesn't remove Auras and Equipment you control that are + /// already attached to it" (Benevolent Blessing). New same-quality + /// attachments remain illegal; only already-attached controlled ones stay. + ControlledAttachmentsAlreadyAttached, } impl StaticDefinition { @@ -19644,6 +19998,7 @@ impl StaticDefinition { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, } } @@ -19661,6 +20016,12 @@ impl StaticDefinition { self } + /// CR 702.16n / CR 702.16p: Attach the protection SBA exemption rider. + pub fn protection_does_not_remove(mut self, exemption: ProtectionDoesNotRemove) -> Self { + self.protection_does_not_remove = Some(exemption); + self + } + pub fn condition(mut self, cond: StaticCondition) -> Self { self.condition = Some(cond); self @@ -20511,6 +20872,47 @@ pub struct CopiableValues { pub static_definitions: Arc>, } +/// CR 707.2b + CR 707.2c + CR 111.1: A copiable-values snapshot latched when an +/// `Effect::ChoosePermanent` choice is answered (`CopyTargetPurpose::PersistChosenAttribute`). +/// Per CR 707.2c the copiable values a static copy effect grants are determined +/// only when the effect first starts to apply, and per CR 707.2b later changes +/// to the chosen object never propagate — so the values are frozen here rather +/// than re-read live. The display trio (`display_source` / `printed_ref` / +/// `token_image_ref`) is art routing only (NOT a CR 707.2 copiable value), +/// carried so the enchanted host renders the chosen creature's art; it mirrors +/// the identical trio on `ContinuousModification::CopyValues`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LatchedCopiableSnapshot { + pub values: CopiableValues, + #[serde(default)] + pub display_source: DisplaySource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub printed_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_image_ref: Option, +} + +/// CR 707.2 + CR 707.2c: Why a `WaitingFor::CopyTargetChoice` was raised, which +/// determines how the answer is consumed in `handle_copy_target_choice`. +/// `BecomeCopy` (default for serde back-compat with pre-existing serialized +/// enter-as-a-copy waits) resolves the entering object into a copy of the +/// chosen permanent. `PersistChosenAttribute` (Metamorphic Alteration) latches +/// the chosen permanent's copiable values onto the source Aura and installs a +/// copy effect on the Aura's enchanted host instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(tag = "type")] +pub enum CopyTargetPurpose { + /// CR 707.2: the entering object (the clone) becomes a copy of the chosen + /// permanent, acquiring its copiable values. Every classic enter-as-a-copy + /// card (Clone, Phantasmal Image, Vesuva, meld/embalm copy tokens). + #[default] + BecomeCopy, + /// CR 707.2c + CR 614.12a: the chosen permanent's copiable values are + /// latched and applied to a *separate* recipient (the source Aura's host) + /// rather than to the entering object. Metamorphic Alteration. + PersistChosenAttribute, +} + /// CR 702.143d + CR 702 (alternative-cost cast-from-off-zone family): how a /// granted cost-bearing keyword's cost is DERIVED from each recipient. Extensible /// axis (build-for-the-class): today only the reduced-mana-cost form is printed; @@ -20569,6 +20971,18 @@ pub enum ContinuousModification { #[serde(default)] token_image_ref: Option, }, + /// CR 707.2c + CR 613.1a: Parse-time MARKER for the static ability + /// "enchanted creature is a copy of the chosen creature" (Metamorphic + /// Alteration). It exists so the card's static parse claims copy support at + /// Layer 1, but it carries NO layered behavior: the actual copy is + /// materialized once — at the `Effect::ChoosePermanent` answer — as a + /// `CopyValues` transient continuous effect whose values are latched per + /// CR 707.2c (fixed when the copy effect first starts to apply). Applying it + /// in the layer pipeline would double-install the copy, so its + /// `apply_continuous_effect` arm is an explicit no-op. Layered at + /// `Layer::Copy` purely for correct copy-layer ordering/dependency + /// bookkeeping alongside the real `CopyValues` TCE. + CopyChosen, /// CR 707.9 + CR 707.2: Override the copy's name after `CopyValues` applies. /// Used by "enter as a copy, except its name is X" (e.g., Superior Spider-Man's /// Mind Swap). Applied in Layer 1 so the override is part of the copy's @@ -20653,6 +21067,25 @@ pub enum ContinuousModification { GrantTrigger { trigger: Box, }, + /// CR 614.1a + CR 614.6 + CR 613.1f: Grant an object-hosted replacement + /// effect to the affected object (Layer 6, ability-adding). The layer-6 + /// mirror of `GrantTrigger`: where that variant pushes a `TriggerDefinition` + /// onto `obj.trigger_definitions`, this pushes a `ReplacementDefinition` onto + /// `obj.replacement_definitions` so the granted replacement's event/scope + /// metadata is preserved and it fires as a genuine replacement (CR 614.6). + /// + /// Models the "it gains 'If ~ would leave the battlefield, exile it instead + /// of putting it anywhere else'" reanimation rider (Geth, Thane of Contracts; + /// Llanowar Greenwidow; Realmbreaker, the Invasion Tree; Spirit-Sister's + /// Call; Elemental Expressionist's until-EOT grant). The rider's `~` + /// self-reference resolves to the object that gains the ability (CR 201.5b), + /// which is the reanimated permanent, not the granting source. Re-derived + /// each layer pass (`obj.replacement_definitions` is reset to base at the + /// start of the pass); structural-equality dedup keeps repeated grants + /// idempotent, exactly like `GrantTrigger` / `GrantStaticAbility`. + GrantReplacement { + replacement: Box, + }, RemoveAllAbilities, AddType { core_type: CoreType, @@ -21078,6 +21511,12 @@ pub struct ResolvedAbility { /// `ability_index` remains presentation/compatibility only. #[serde(default, skip_serializing_if = "Option::is_none")] pub trigger_definition_ref: Option, + /// CR 400.7 + CR 509.1c: Exact attacker selected by a source- or + /// event-source-referential force-block instruction. This is bound when the + /// triggered ability is put on the stack, before targets are chosen; it is + /// deliberately separate from the parsed grammatical selector on `Effect`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub force_block_attacker: Option, pub controller: PlayerId, /// CR 109.5: The controller of the spell or ability before any /// resolution-time player-scope iteration rebinds the acting player. @@ -21278,6 +21717,15 @@ pub struct ResolvedAbility { /// `SequentialSibling` subs resolve even when an optional parent is declined. #[serde(default, skip_serializing_if = "SubAbilityLink::is_continuation")] pub sub_link: SubAbilityLink, + /// CR 702.1c ("the same is true") + CR 608.2c (written order): Copied through + /// from the originating `AbilityDefinition`. When `ReplicatedOrBranch`, this + /// `SequentialSibling` is an INDEPENDENT + /// per-item OR-branch produced by keyword-list replication (Mutable Pupa, + /// Kathril) and must be evaluated by `resolve_chain_body` regardless of a + /// preceding sibling's failed gate. `Dependent` (default) preserves the + /// prior skip-with-failed-predecessor behavior. See [`SiblingCondition`]. + #[serde(default, skip_serializing_if = "SiblingCondition::is_default")] + pub sibling_condition: SiblingCondition, /// CR 700.2b + CR 603.3c: Modal choice for a reflexive modal trigger whose modes /// are gated behind an optional cost (Caesar). Carried from the def so /// try_begin_reflexive_target_selection can hand it to the PendingTrigger and @@ -21352,9 +21800,11 @@ impl ResolvedAbility { repeat_until: None, replacement_applied: HashSet::new(), sub_link: SubAbilityLink::ContinuationStep, + sibling_condition: SiblingCondition::Dependent, source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, modal: None, mode_abilities: Vec::new(), parent_target_missing_reason: None, @@ -21418,6 +21868,79 @@ impl ResolvedAbility { } } + /// Preserve the exact named attacker through target selection and every + /// continuation of a triggered force-block instruction. The parser carries + /// only its grammatical selector; this stores the one event-time object + /// identity that resolution is permitted to use. + pub fn bind_force_block_attacker_recursive( + &mut self, + event_attacker: Option, + ) { + if let Effect::ForceBlock { + attacker: Some(selector), + .. + } = &self.effect + { + self.force_block_attacker = match selector { + ForceBlockAttackerRef::Source => self + .trigger_source + .as_ref() + .map(|source| source.identity.reference), + ForceBlockAttackerRef::EventSource => event_attacker, + }; + } + if let Some(sub) = self.sub_ability.as_mut() { + sub.bind_force_block_attacker_recursive(event_attacker); + } + if let Some(else_branch) = self.else_ability.as_mut() { + else_branch.bind_force_block_attacker_recursive(event_attacker); + } + } + + /// Whether any resolution branch names the attacker from its triggering + /// attack event. Such an ability must receive one singleton attack event + /// per pending trigger; a plural event has no single "that Wolf" referent. + pub fn has_event_source_force_block_recursive(&self) -> bool { + matches!( + &self.effect, + Effect::ForceBlock { + attacker: Some(ForceBlockAttackerRef::EventSource), + .. + } + ) || self + .sub_ability + .as_ref() + .is_some_and(|sub| sub.has_event_source_force_block_recursive()) + || self + .else_ability + .as_ref() + .is_some_and(|else_branch| else_branch.has_event_source_force_block_recursive()) + } + + /// Bind only source-referential force-block effects at the shared stack + /// boundary. Event-source effects are intentionally left untouched because + /// their authority is the singleton triggering event, not the stack entry's + /// source object. + pub fn bind_force_block_source_recursive(&mut self, source: Option) { + if source.is_some() + && matches!( + &self.effect, + Effect::ForceBlock { + attacker: Some(ForceBlockAttackerRef::Source), + .. + } + ) + { + self.force_block_attacker = source; + } + if let Some(sub) = self.sub_ability.as_mut() { + sub.bind_force_block_source_recursive(source); + } + if let Some(else_branch) = self.else_ability.as_mut() { + else_branch.bind_force_block_source_recursive(source); + } + } + /// Clears provenance that distinguishes otherwise identical triggered /// abilities for structural comparison. This deliberately clears the /// complete owned authorities together; retaining either a source context @@ -21427,6 +21950,7 @@ impl ResolvedAbility { self.source_incarnation = None; self.trigger_source = None; self.trigger_definition_ref = None; + self.force_block_attacker = None; if let Some(sub) = self.sub_ability.as_mut() { sub.clear_trigger_identity_recursive(); } @@ -23185,6 +23709,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; let json = serde_json::to_string(&static_def).unwrap(); let deserialized: StaticDefinition = serde_json::from_str(&json).unwrap(); @@ -23375,6 +23900,30 @@ mod tests { assert_eq!(effect, deserialized); } + #[test] + fn force_block_serde_preserves_named_attacker_and_legacy_defaults() { + let named = Effect::ForceBlock { + target: TargetFilter::Typed(TypedFilter::creature()), + attacker: Some(ForceBlockAttackerRef::EventSource), + duration: Duration::UntilEndOfCombat, + }; + let json = serde_json::to_string(&named).expect("serialize named force block"); + assert_eq!( + serde_json::from_str::(&json).expect("deserialize named force block"), + named + ); + + let legacy = r#"{"type":"ForceBlock","target":{"type":"Any"}}"#; + assert_eq!( + serde_json::from_str::(legacy).expect("deserialize legacy force block"), + Effect::ForceBlock { + target: TargetFilter::Any, + attacker: None, + duration: Duration::UntilEndOfTurn, + } + ); + } + #[test] fn effect_cleanup_typed_fields_roundtrip() { let effect = Effect::Cleanup { @@ -23478,6 +24027,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], duration: Some(Duration::UntilEndOfTurn), target: None, diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index df56558558..a55abb12ad 100644 --- a/crates/engine/src/types/action_stable_order.rs +++ b/crates/engine/src/types/action_stable_order.rs @@ -189,6 +189,12 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering { }; cmp_val(a0, b0) } + GameAction::ChooseGiftRecipient { opponent: a0 } => { + let GameAction::ChooseGiftRecipient { opponent: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } GameAction::ChooseAssistPlayer { player: a0 } => { let GameAction::ChooseAssistPlayer { player: b0 } = b else { unreachable!("cmp_payload: same-variant invariant"); @@ -1656,6 +1662,14 @@ mod tests { opponent: PlayerId(1), }, ); + assert_distinct_order( + GameAction::ChooseGiftRecipient { + opponent: PlayerId(0), + }, + GameAction::ChooseGiftRecipient { + opponent: PlayerId(1), + }, + ); assert_distinct_order( GameAction::SetMayTriggerAutoChoice { op: MayTriggerAutoChoiceOp::Remove { diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index cac3709594..50876c0369 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -220,6 +220,12 @@ pub enum GameAction { ChooseAnnouncingOpponent { opponent: PlayerId, }, + /// CR 702.174a: The spell controller's answer to + /// `WaitingFor::ChooseGiftRecipient` — which opponent receives the promised + /// gift. `opponent` must be one of that prompt's `candidates`. + ChooseGiftRecipient { + opponent: PlayerId, + }, /// CR 702.132a: Assist — the caster's answer to `WaitingFor::AssistChoosePlayer`. /// `Some(p)` chooses player `p` (one of the prompt's `candidates`) to help pay /// the generic mana; `None` declines and proceeds to normal payment. @@ -1632,6 +1638,7 @@ impl GameAction { | GameAction::ChooseZoneOpponentChooser { .. } | GameAction::ChoosePileOpponent { .. } | GameAction::ChooseAnnouncingOpponent { .. } + | GameAction::ChooseGiftRecipient { .. } | GameAction::ChooseAssistPlayer { .. } | GameAction::CommitAssistPayment { .. } | GameAction::ChooseBattleProtector { .. } diff --git a/crates/engine/src/types/events.rs b/crates/engine/src/types/events.rs index 22bd13b117..8b3f0677e2 100644 --- a/crates/engine/src/types/events.rs +++ b/crates/engine/src/types/events.rs @@ -500,6 +500,7 @@ impl EventObjectSnapshot { | TargetFilter::SpecificObject { .. } | TargetFilter::LastCreated | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } @@ -1179,6 +1180,15 @@ pub enum GameEvent { Transformed { object_id: ObjectId, }, + /// CR 710.4: A Kamigawa flip permanent was flipped to its alternative face. + /// Distinct from `Transformed` — CR 701.27a restricts transforming to + /// double-faced permanents, and CR 710.1c keeps a flipped permanent's color + /// and mana cost unchanged where transforming swaps them. Drives the game + /// log and the public-state/frontend re-render. No printed card triggers on + /// a permanent flipping, so this event dispatches no trigger key. + Flipped { + object_id: ObjectId, + }, /// Digital-only Specialize: a permanent became a color-specific specialized face. Specialized { object_id: ObjectId, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index b0483203a3..c94d3a817a 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -10,9 +10,9 @@ use super::ability::{ AdditionalCostInstance, AdditionalCostInstancePayment, AttackSubject, BeholdCostAction, CastTimingPermission, CastVariantPaid, CategoryChooserScope, ChoiceType, ChoiceValue, ChooseFromZoneConstraint, ChosenAttribute, CoinFlipResult, Comparator, ContinuousModification, - ControlWindow, CopiableValues, CopyChooseScope, CopyScale, CostPaidObjectSnapshot, - CounterCostSelection, DelayedTriggerCondition, Duration, EffectKind, FaceDownProfile, - GameRestriction, KeywordAction, KickerVariant, LibraryPosition, ModalChoice, + ControlWindow, CopiableValues, CopyChooseScope, CopyScale, CopyTargetPurpose, + CostPaidObjectSnapshot, CounterCostSelection, DelayedTriggerCondition, Duration, EffectKind, + FaceDownProfile, GameRestriction, KeywordAction, KickerVariant, LibraryPosition, ModalChoice, PermanentEntryMode, PileSource, QuantityExpr, ResolvedAbility, SearchDestinationSplit, SearchSelectionConstraint, StaticCondition, TapCreaturesAggregate, TargetFilter, TargetRef, ThisWayCause, TriggerCondition, TriggerDefinition, TriggerDefinitionRef, TriggerEntry, @@ -3582,6 +3582,22 @@ pub struct PendingPerPlayerZoneChoice { pub accumulated: bool, } +/// CR 401.4 + CR 608.2c: Per-owner library-order prompts for one +/// `ChangeZoneAll` instruction that places multiple owners' cards at the same +/// library position with `random_order: false`. The first owner's batch is +/// surfaced immediately as `WaitingFor::EffectZoneChoice`; remaining owner +/// batches drain after each batch completes. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PendingMassLibraryOrderChoice { + pub source_id: ObjectId, + pub library_position: crate::types::ability::LibraryPosition, + pub track_exiled_by_source: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option, + /// Remaining (owner, cards) batches in APNAP order after the current prompt. + pub remaining_batches: Vec<(PlayerId, Vec)>, +} + /// CR 101.4: If players make choices for one instruction, they choose in /// APNAP order before the simultaneous action happens. /// CR 701.21a: To sacrifice a permanent, its controller moves it from the @@ -6247,8 +6263,11 @@ pub enum CombatTaxPending { /// band for blocking (CR 702.22h). bands: Vec>, }, + /// CR 400.7 + CR 509.1d: Both sides of each proposed block pair are + /// snapshotted. A blocker or attacker that leaves and re-enters during the + /// payment pause is a different object and cannot receive the locked quote. Block { - assignments: Vec<(ObjectId, ObjectId)>, + assignments: Vec<(ObjectIncarnationRef, ObjectIncarnationRef)>, }, } @@ -7536,6 +7555,13 @@ pub enum WaitingFor { valid_targets: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] max_mana_value: Option, + /// CR 707.2 + CR 707.2c: Why this choice was raised — whether the + /// entering object becomes the copy (`BecomeCopy`, default for + /// serde back-compat) or the chosen permanent's values are latched onto + /// a source Aura's host (`PersistChosenAttribute`, Metamorphic + /// Alteration). Read at the answer in `handle_copy_target_choice`. + #[serde(default)] + purpose: CopyTargetPurpose, }, /// CR 701.44d: Player chooses which of their remaining permanents explores next. ExploreChoice { @@ -8088,6 +8114,23 @@ pub enum WaitingFor { /// Zero for the first prompt and for non-kicker optional costs. #[serde(default)] times_kicked: u32, + /// CR 601.2b / CR 702.174a: Origin of the optional cost being offered so + /// the UI can present Gift-specific promise copy without sniffing card text. + #[serde(default)] + origin: crate::types::ability::AdditionalCostOrigin, + /// CR 702.174: When `origin` is Gift, the gift kind for UI labels. + #[serde(default, skip_serializing_if = "Option::is_none")] + gift_kind: Option, + pending_cast: Box, + }, + /// CR 702.174a: After promising a Gift with ≥2 opponents, choose which + /// opponent receives the gift. Distinct from `ChooseAnnouncingOpponent` + /// (CR 115.1 target-chooser). + ChooseGiftRecipient { + player: PlayerId, + candidates: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + gift_kind: Option, pending_cast: Box, }, /// CR 702.47a–e: As an Arcane (or other matching-subtype) spell is cast, its @@ -9476,6 +9519,7 @@ impl WaitingFor { WaitingFor::ModeChoice { .. } => "ModeChoice", WaitingFor::DiscardToHandSize { .. } => "DiscardToHandSize", WaitingFor::OptionalCostChoice { .. } => "OptionalCostChoice", + WaitingFor::ChooseGiftRecipient { .. } => "ChooseGiftRecipient", WaitingFor::SpliceOffer { .. } => "SpliceOffer", WaitingFor::DefilerPayment { .. } => "DefilerPayment", WaitingFor::CastOffer { .. } => "CastOffer", @@ -9622,6 +9666,7 @@ impl WaitingFor { | WaitingFor::ModeChoice { player, .. } | WaitingFor::DiscardToHandSize { player, .. } | WaitingFor::OptionalCostChoice { player, .. } + | WaitingFor::ChooseGiftRecipient { player, .. } | WaitingFor::SpliceOffer { player, .. } | WaitingFor::DefilerPayment { player, .. } | WaitingFor::AbilityModeChoice { player, .. } @@ -9752,6 +9797,7 @@ impl WaitingFor { | WaitingFor::TargetSelection { pending_cast, .. } | WaitingFor::ModeChoice { pending_cast, .. } | WaitingFor::OptionalCostChoice { pending_cast, .. } + | WaitingFor::ChooseGiftRecipient { pending_cast, .. } | WaitingFor::SpliceOffer { pending_cast, .. } | WaitingFor::DefilerPayment { pending_cast, .. } | WaitingFor::ActivationCostOneOfChoice { pending_cast, .. } @@ -9786,6 +9832,7 @@ impl WaitingFor { | WaitingFor::TargetSelection { pending_cast, .. } | WaitingFor::ModeChoice { pending_cast, .. } | WaitingFor::OptionalCostChoice { pending_cast, .. } + | WaitingFor::ChooseGiftRecipient { pending_cast, .. } | WaitingFor::SpliceOffer { pending_cast, .. } | WaitingFor::DefilerPayment { pending_cast, .. } | WaitingFor::ActivationCostOneOfChoice { pending_cast, .. } @@ -12242,6 +12289,10 @@ pub struct GameState { /// `EffectZoneChoice`. #[serde(default, skip_serializing_if = "Option::is_none")] pub pending_player_scope_sacrifice_choice: Option, + /// CR 401.4: Remaining per-owner library-order batches for a mass + /// `ChangeZoneAll` instruction paused on `EffectZoneChoice`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_mass_library_order_choice: Option, /// CR 101.4 + CR 701.23i: Pending private selections for a simultaneous /// scoped self-library search. Kept separate from the generic continuation /// so the action phase cannot begin before every player has chosen. @@ -12867,6 +12918,11 @@ pub struct TransientContinuousEffect { pub timestamp: u64, pub duration: Duration, pub affected: TargetFilter, + /// CR 400.7: A one-shot effect that resolved on a specific target must not + /// apply to a later incarnation that reuses the same storage id. `None` + /// preserves the dynamic affected-set semantics required by general TCEs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub affected_recipient: Option, pub modifications: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub condition: Option, @@ -16207,6 +16263,7 @@ impl GameState { merged_card_component_route: None, resolution_coin_flip: None, pending_player_scope_sacrifice_choice: None, + pending_mass_library_order_choice: None, pending_scoped_library_search: None, pending_library_search_delivery: None, pending_search_found_batch: None, @@ -16559,6 +16616,7 @@ impl GameState { timestamp, duration, affected, + affected_recipient: None, modifications, condition, duration_subject: None, @@ -16568,6 +16626,19 @@ impl GameState { id } + /// Bind a transient effect to the exact recipient resolved by a one-shot + /// instruction. Dynamic effects intentionally leave this unset. + pub fn set_transient_affected_recipient(&mut self, id: u64, recipient: ObjectIncarnationRef) { + if let Some(tce) = self + .transient_continuous_effects + .iter_mut() + .find(|tce| tce.id == id) + { + tce.affected_recipient = Some(recipient); + self.layers_dirty.mark_full(); + } + } + /// CR 611.2b + CR 110.5d: bind a target-relative `ForAsLongAs` duration to a /// concrete object resolved at effect-resolution time, on the TCE that /// [`Self::add_transient_continuous_effect`] just created (addressed by its @@ -17540,6 +17611,7 @@ fn _gamestate_partition_is_total(s: &GameState) { // Priority`, effects/mod.rs:759) or a constant direct-assigned count across a real // copy-token loop, so COMPARING never suppresses a legitimate loop's detection. pending_player_scope_sacrifice_choice: _, + pending_mass_library_order_choice: _, pending_scoped_library_search: _, pending_library_search_delivery: _, pending_search_found_batch: _, @@ -17739,6 +17811,8 @@ impl PartialEq for GameState { && self.resolution_coin_flip == other.resolution_coin_flip && self.pending_player_scope_sacrifice_choice == other.pending_player_scope_sacrifice_choice + && self.pending_mass_library_order_choice + == other.pending_mass_library_order_choice && self.pending_scoped_library_search == other.pending_scoped_library_search && self.pending_library_search_delivery == other.pending_library_search_delivery && self.pending_search_found_batch == other.pending_search_found_batch @@ -20843,6 +20917,14 @@ mod tests { repeatability: crate::types::ability::AdditionalCostRepeatability::Once, }, times_kicked: 0, + origin: crate::types::ability::AdditionalCostOrigin::Other, + gift_kind: None, + pending_cast: dummy_pending(), + })); + variants.push(Box::new(WaitingFor::ChooseGiftRecipient { + player: PlayerId(0), + candidates: vec![PlayerId(1)], + gift_kind: Some(crate::types::keywords::GiftKind::Card), pending_cast: dummy_pending(), })); variants.push(Box::new(WaitingFor::AbilityModeChoice { @@ -20984,7 +21066,7 @@ mod tests { mana_reduction: ManaCost::zero(), pending_cast: dummy_pending(), })); - assert_eq!(variants.len(), 35); + assert_eq!(variants.len(), 36); } #[test] diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index 7a49ad382a..9eefd9d047 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -301,6 +301,7 @@ pub enum InteractionActionCode { ChooseZoneOpponentChooser, ChoosePileOpponent, ChooseAnnouncingOpponent, + ChooseGiftRecipient, ChooseAssistPlayer, CommitAssistPayment, MulliganDecision, diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index 81e2cc9a3d..d792ff332c 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -902,10 +902,22 @@ pub enum Keyword { /// RUNTIME: synthesized as-enters replacement by /// `database/synthesis.rs::synthesize_devour` — a `ReplacementEvent::Moved` /// replacement on `SelfRef` whose execute chain is a ranged `Effect::Sacrifice` - /// over the controller's creatures + a per-sacrifice `PutCounter(+1/+1)` - /// sub-ability. CR 702.82a: Devour N — as it enters, you may sacrifice any - /// number of creatures; it enters with N +1/+1 counters per sacrifice. - Devour(u32), + /// over the controller's permanents matching `quality` + a per-sacrifice + /// `PutCounter(+1/+1)` sub-ability. + /// + /// CR 702.82a: "Devour N" — as it enters, you may sacrifice any number of + /// creatures; it enters with N +1/+1 counters per sacrifice. This is the + /// default and corresponds to `quality: TypeFilter::Creature`. + /// + /// CR 702.82c: "Devour [quality] N" — the quality-qualified variant sacrifices + /// [quality] permanents instead (e.g. Land for Famished Worldsire, Artifact for + /// Caprichrome, Subtype("Food") for Feasting Hobbit). Per CR 702.82c the counter + /// math (N +1/+1 counters per permanent sacrificed, CR 122.1a) is identical to + /// the plain form and independent of `quality` — only the sacrifice pool changes. + Devour { + n: u32, + quality: TypeFilter, + }, /// CR 702.164: Toxic N — when this creature deals combat damage to a player, /// that player gets N poison counters. @@ -1332,7 +1344,7 @@ impl Keyword { | Keyword::Demonstrate | Keyword::Bloodthirst(_) | Keyword::Amplify(_) - | Keyword::Devour(_) + | Keyword::Devour { .. } | Keyword::Teamwork(_) | Keyword::Backup(_) | Keyword::Squad(_) @@ -1504,7 +1516,7 @@ impl Keyword { Keyword::Craft { .. } => KeywordKind::Craft, Keyword::Harmonize(_) => KeywordKind::Harmonize, Keyword::Warp(_) => KeywordKind::Warp, - Keyword::Devour(_) => KeywordKind::Devour, + Keyword::Devour { .. } => KeywordKind::Devour, Keyword::Offspring(_) => KeywordKind::Offspring, Keyword::Splice { .. } => KeywordKind::Splice, Keyword::Bargain => KeywordKind::Bargain, @@ -1710,22 +1722,47 @@ impl Keyword { ) } - /// CR 702.164b: Keywords whose multiple instances SUM their parameter values - /// into a single aggregate (e.g. a creature's total toxic value), rather than - /// collapsing identical instances. When such a keyword is granted on top of an - /// identical printed instance, BOTH must remain on the keyword list so the - /// aggregate reader counts every copy. Distinct from `instances_function_separately` - /// (which gates per-instance trigger installation — a different semantic axis). - /// Conservative/CR-driven: only Toxic sums today (CR 702.164b). Protection - /// (CR 702.16g), Ward, Annihilator, Afflict, Frenzy do NOT sum — they keep - /// deduping identical instances. Add any future "sum of all N" keyword here. + /// CR 702.164b + CR 702.44d: Keywords whose multiple instances must COEXIST on + /// the keyword list rather than collapse to one when an identical instance is + /// granted on top of a printed one. When such a keyword is granted on top of an + /// identical printed instance, BOTH must remain on the keyword list so each + /// instance's effect is realized separately. Two disjoint reasons a keyword + /// belongs here, both served by the same "don't dedup identical instances" + /// mechanic in the keyword-grant paths: + /// + /// - **Parameter-value summation** (CR 702.164b): the aggregate reader sums + /// each instance's parameter (a creature's total toxic value). Only Toxic + /// sums today. Protection (CR 702.16g), Ward, Annihilator, Afflict, Frenzy do + /// NOT sum — they keep deduping identical instances. + /// - **Instance-count multiplicity** (CR 702.44d + CR 702.54c): an as-enters + /// static ability where "each instance works separately" — Sunburst places + /// its as-enters counters once per instance (CR 702.44a), and Bloodthirst + /// likewise places its counters once per instance (CR 702.54a). A GRANTED + /// Sunburst ("that spell gains sunburst": Solar Array / Lux Artillery) or + /// GRANTED Bloodthirst ("it gains bloodthirst 3": Bloodlord of Vaasgoth) on + /// top of an identical printed one must coexist so both the printed + /// object-carried replacement AND the granted virtual replacement (counting + /// the base-subtracted surplus) fire. Without coexistence the layer-6 grant + /// dedups against the identical printed instance and the granted instance is + /// silently lost. (Bloodthirst carries a `BloodthirstValue`, so the + /// granted-count is per DISTINCT value — a granted `bloodthirst 3` and a + /// printed `bloodthirst 1` never dedup regardless.) + /// + /// Distinct from `instances_function_separately` (which gates per-instance + /// TRIGGER installation, and deliberately excludes Sunburst because its + /// multiplicity is realized by synthesis / the granted-replacement path, not by + /// the trigger installer — a different semantic axis). Add any future keyword + /// whose identical instances must coexist here. /// /// Out of scope (intentionally not gated by this predicate): cast-time spell /// keyword merge (`casting.rs` `upsert_keyword_by_kind`/`merge_spell_keyword` — /// Toxic is inert at cast time) and the layers `AddDynamicKeyword` arm - /// (`DynamicKeywordKind` is only Annihilator/Modular, never Toxic). - pub fn sums_across_instances(&self) -> bool { - matches!(self, Keyword::Toxic(_)) + /// (`DynamicKeywordKind` is only Annihilator/Modular, never Toxic/Sunburst). + pub fn instances_must_coexist(&self) -> bool { + matches!( + self, + Keyword::Toxic(_) | Keyword::Sunburst | Keyword::Bloodthirst(_) + ) } /// CR 613.7: When multiple effects grant the same single-authoritative-value @@ -1737,7 +1774,7 @@ impl Keyword { /// (CR 702.122/702.171, vehicle/mount crew-power) and Enchant (CR 702.5a, /// an Aura's current legal-attachment filter, reachable via /// `AddKeyword{Enchant(_)}` from `install_aura_continuous_effect`) are the - /// currently known members. Contrast `sums_across_instances` (Toxic, which + /// currently known members. Contrast `instances_must_coexist` (Toxic, which /// accumulates) and the default (Protection/Ward/Annihilator, which coexist /// as separate instances per CR 702.16g). pub fn overrides_same_kind_on_grant(&self) -> bool { @@ -2444,7 +2481,16 @@ impl FromStr for Keyword { "bloodthirst" => return Ok(Keyword::Bloodthirst(parse_bloodthirst_value(p))), "amplify" => return Ok(Keyword::Amplify(p.parse().unwrap_or(1))), "graft" => return Ok(Keyword::Graft(p.parse().unwrap_or(1))), - "devour" => return Ok(Keyword::Devour(p.parse().unwrap_or(1))), + // CR 702.82a: the colon-form `FromStr` path carries only the count; + // the quality qualifier (CR 702.82c) is captured upstream by the + // dedicated `parse_devour_keyword_line` combinator, so this fallback + // builds the CR 702.82a creature default. + "devour" => { + return Ok(Keyword::Devour { + n: p.parse().unwrap_or(1), + quality: TypeFilter::Creature, + }) + } // CR 702.164 "toxic" => return Ok(Keyword::Toxic(p.parse().unwrap_or(1))), // CR 702.171a @@ -3377,7 +3423,28 @@ fn keyword_from_tagged(variant: &str, data: &serde_json::Value) -> Result Ok(Keyword::Bloodthirst(bloodthirst(data)?)), "Amplify" => Ok(Keyword::Amplify(uint(data))), "Graft" => Ok(Keyword::Graft(uint(data))), - "Devour" => Ok(Keyword::Devour(uint(data))), + "Devour" => { + // Struct variant: {"Devour": {"n": N, "quality": }}. + // A bare number {"Devour": N} is also accepted for back-compat with + // card-data serialized before the CR 702.82c quality axis existed; it + // deserializes to the CR 702.82a creature default. Mirrors the Crew arm. + if let Some(obj) = data.as_object() { + let n = obj.get("n").map(uint).unwrap_or(1); + let quality = obj + .get("quality") + .cloned() + .map(serde_json::from_value::) + .transpose() + .map_err(|e| e.to_string())? + .unwrap_or(TypeFilter::Creature); + Ok(Keyword::Devour { n, quality }) + } else { + Ok(Keyword::Devour { + n: uint(data), + quality: TypeFilter::Creature, + }) + } + } // CR 702.164 / CR 702.171a / CR 702.46 / CR 702.165 "Toxic" => Ok(Keyword::Toxic(uint(data))), "Saddle" => Ok(Keyword::Saddle(uint(data))), @@ -3541,6 +3608,46 @@ mod tests { ); } + /// Back-compat: card-data.json serialized before the CR 702.82c quality axis + /// encoded Devour as a bare number `{"Devour": 3}`. The custom Deserialize + /// bare-number fallback must still load such states, defaulting to the CR + /// 702.82a creature quality — otherwise every pre-existing saved game / cached + /// card-data blob would drop the keyword on reload. + #[test] + fn devour_old_form_bare_number_json_still_loads() { + let old: Keyword = serde_json::from_str(r#"{"Devour": 3}"#).unwrap(); + assert_eq!( + old, + Keyword::Devour { + n: 3, + quality: TypeFilter::Creature, + } + ); + } + + /// The new struct form round-trips through serde, and a non-creature quality + /// (CR 702.82c) survives serialize→deserialize. + #[test] + fn devour_struct_form_round_trips_quality() { + for quality in [ + TypeFilter::Creature, + TypeFilter::Land, + TypeFilter::Artifact, + TypeFilter::Subtype("Food".to_string()), + ] { + let kw = Keyword::Devour { + n: 3, + quality: quality.clone(), + }; + let json = serde_json::to_string(&kw).unwrap(); + let back: Keyword = serde_json::from_str(&json).unwrap(); + assert_eq!( + back, kw, + "Devour quality must round-trip: {quality:?} via {json}" + ); + } + } + #[test] fn parse_simple_keywords() { assert_eq!(Keyword::from_str("Flying").unwrap(), Keyword::Flying); diff --git a/crates/engine/src/types/layers.rs b/crates/engine/src/types/layers.rs index f63dd78875..cad002a5d7 100644 --- a/crates/engine/src/types/layers.rs +++ b/crates/engine/src/types/layers.rs @@ -81,6 +81,11 @@ impl ContinuousModification { pub fn layer(&self) -> Layer { match self { ContinuousModification::CopyValues { .. } => Layer::Copy, + // CR 707.2c + CR 613.1a: parse-time marker for Metamorphic + // Alteration's static copy. Layered at Copy purely for ordering; its + // `apply_continuous_effect` arm is an explicit no-op (the real copy + // is the latched `CopyValues` TCE installed at the choice answer). + ContinuousModification::CopyChosen => Layer::Copy, // CR 707.9b + CR 613.1a: Copy-effect name override applies in Layer 1 // after CopyValues, per timestamp order within the layer. ContinuousModification::SetName { .. } => Layer::Copy, @@ -111,6 +116,9 @@ impl ContinuousModification { | ContinuousModification::GrantAllActivatedAbilitiesOf { .. } | ContinuousModification::GrantAllTriggeredAbilitiesOf { .. } | ContinuousModification::GrantTrigger { .. } + // CR 613.1f + CR 614.6: granting an object-hosted replacement is an + // ability-adding effect (Layer 6), beside GrantTrigger. + | ContinuousModification::GrantReplacement { .. } | ContinuousModification::RemoveAllAbilities | ContinuousModification::AddStaticMode { .. } | ContinuousModification::GrantStaticAbility { .. } => Layer::Ability, diff --git a/crates/engine/src/types/resolved_commands.rs b/crates/engine/src/types/resolved_commands.rs index 829d744162..cf73da677f 100644 --- a/crates/engine/src/types/resolved_commands.rs +++ b/crates/engine/src/types/resolved_commands.rs @@ -12,11 +12,12 @@ use crate::game::triggers::{ConsumedTriggerEventOccurrence, PendingTriggerContex use super::ability::TriggerDefinitionRef; use super::card_type::CoreType; use super::counter::CounterType; -use super::game_state::SpellCastRecord; +use super::game_state::{SpellCastRecord, ZoneChangeRecord}; use super::identifiers::{ObjectId, ObjectIncarnationRef, LEGACY_INCARNATION}; use super::mana::{ManaPipId, ManaUnit}; use super::player::{PlayerCounterKind, PlayerId}; use super::resolution::{FrameKind, ResolutionFrame, ResolutionStackError}; +use super::zones::Zone; /// Globally ordered identity of a resolved command. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -242,6 +243,26 @@ pub enum ResolvedLedgerEdit { source: super::identifiers::ObjectId, permission: ResolvedOncePerTurnPermission, }, + /// CR 121.1 + CR 121.2 + CR 121.4: Install one settled draw's bookkeeping + /// after its zone transition has already been resolved. `drawn_object` is + /// `None` only for an attempted draw from an empty library. + CardsDrawn { + player: PlayerId, + drawn_object: Option, + attempted_empty_library: bool, + expected_has_drawn_this_turn: bool, + resulting_has_drawn_this_turn: bool, + expected_cards_drawn_this_turn: u32, + resulting_cards_drawn_this_turn: u32, + expected_cards_drawn_this_step: u32, + resulting_cards_drawn_this_step: u32, + expected_drew_from_empty_library: bool, + resulting_drew_from_empty_library: bool, + expected_drawn_cards_len: u32, + resulting_drawn_cards_len: u32, + expected_first_card_drawn_this_turn: Option, + resulting_first_card_drawn_this_turn: Option, + }, } /// One exact per-event ledger mutation with its causal node. @@ -266,6 +287,53 @@ pub struct ResolvedLibraryShuffleCommand { pub cause: RulesExecutionNodeRef, } +/// One exact transition of an object occurrence between zone containers. +/// +/// CR 400.7: the command binds the source occurrence and its resulting +/// incarnation, so replay neither selects a new object nor creates a new +/// incarnation. CR 613.7d: battlefield-entry timestamps are captured by the +/// ordinary path and installed exactly on replay. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedZoneChangeCommand { + pub object: ObjectIncarnationRef, + pub resulting_incarnation: u64, + pub from: Zone, + pub to: Zone, + /// Zero-based position after the source occurrence has been removed. + pub destination_position: usize, + pub owner: PlayerId, + pub entry_timestamp: Option, + pub turn_zone_change_index: usize, + pub zone_change_record: ZoneChangeRecord, + pub cause: RulesExecutionNodeRef, +} + +/// Typed failure while applying one already-resolved zone transition. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ResolvedZoneChangeReplayInvariantError { + #[error("zone-change command references an unknown object {0:?}")] + UnknownObject(ObjectId), + #[error("zone-change occurrence mismatch: expected {expected:?}, found {found:?}")] + OccurrenceMismatch { + expected: ObjectIncarnationRef, + found: ObjectIncarnationRef, + }, + #[error("zone-change owner mismatch: expected {expected:?}, found {found:?}")] + OwnerMismatch { expected: PlayerId, found: PlayerId }, + #[error("zone-change source-zone mismatch: expected {expected:?}, found {found:?}")] + SourceZoneMismatch { expected: Zone, found: Zone }, + #[error("zone-change destination position mismatch: expected {expected}, found {found}")] + DestinationPositionMismatch { expected: usize, found: usize }, + #[error("zone-change turn-record index mismatch: expected {expected}, found {found}")] + TurnRecordIndexMismatch { expected: usize, found: usize }, + #[error("zone-change battlefield entry is missing its timestamp")] + MissingBattlefieldEntryTimestamp, + #[error("zone-change nonbattlefield entry unexpectedly has a timestamp")] + UnexpectedNonbattlefieldTimestamp, + #[error("zone-change installed incarnation mismatch: expected {expected}, found {found}")] + ResultingIncarnationMismatch { expected: u64, found: u64 }, +} + /// One bounded structural transition of the resolution-frame stack. /// /// This carries only the primitive operation and its native operand. It never @@ -323,6 +391,7 @@ pub enum ResolvedRulesCommand { Information(ResolvedInformationCommand), LedgerEdit(ResolvedLedgerEditCommand), LibraryShuffle(ResolvedLibraryShuffleCommand), + ZoneChange(Box), FrameTransition(Box), TriggerCollection(ResolvedTriggerCollectionCommand), } @@ -679,8 +748,17 @@ pub enum ResolvedLedgerEditReplayInvariantError { UnknownPlayer(PlayerId), SpellCastPreconditionMismatch, AbilityActivationPreconditionMismatch, + CardsDrawnPreconditionMismatch, + DrawnObjectMismatch { + expected: ObjectIncarnationRef, + found: Option, + }, + DrawnObjectStillInLibrary(ObjectIncarnationRef), TriggerAlreadyRecorded, - TriggerCountPreconditionMismatch { expected: u32, found: u32 }, + TriggerCountPreconditionMismatch { + expected: u32, + found: u32, + }, PermissionAlreadyConsumed(ResolvedOncePerTurnPermission), CounterOverflow, } @@ -699,6 +777,18 @@ impl std::fmt::Display for ResolvedLedgerEditReplayInvariantError { f, "resolved activated-ability command does not match its ledger prefix" ), + Self::CardsDrawnPreconditionMismatch => write!( + f, + "resolved draw-bookkeeping command does not match its ledger prefix" + ), + Self::DrawnObjectMismatch { expected, found } => write!( + f, + "resolved drawn-object occurrence mismatch: expected {expected:?}, found {found:?}" + ), + Self::DrawnObjectStillInLibrary(object) => write!( + f, + "resolved drawn-object occurrence remained in its library: {object:?}" + ), Self::TriggerAlreadyRecorded => { write!( f, @@ -1178,6 +1268,17 @@ impl ResolvedRulesJournal { self.append_command(command.cause, ResolvedRulesCommand::LibraryShuffle(command)) } + /// Records one exact zone-container transition under its causal node. + pub fn record_zone_change( + &mut self, + command: ResolvedZoneChangeCommand, + ) -> Result { + self.append_command( + command.cause, + ResolvedRulesCommand::ZoneChange(Box::new(command)), + ) + } + /// Records one exact bounded resolution-frame transition under its causal node. pub fn record_frame_transition( &mut self, @@ -1401,6 +1502,7 @@ impl ResolvedRulesJournal { | ResolvedRulesCommand::Information(_) | ResolvedRulesCommand::LedgerEdit(_) | ResolvedRulesCommand::LibraryShuffle(_) + | ResolvedRulesCommand::ZoneChange(_) | ResolvedRulesCommand::FrameTransition(_) | ResolvedRulesCommand::TriggerCollection(_) => {} } @@ -1634,6 +1736,14 @@ impl ResolvedRulesJournal { )); } } + ResolvedRulesCommand::ZoneChange(command) => { + if entry.node != command.cause || zone_change_command_is_invalid(command) { + return Err(ResolvedRulesJournalError::InvalidSerializedAuthority( + "zone-change command has an invalid occurrence, receipt, or unrelated cause" + .to_string(), + )); + } + } ResolvedRulesCommand::FrameTransition(command) => { if entry.node != command.cause { return Err(ResolvedRulesJournalError::InvalidSerializedAuthority( @@ -1719,7 +1829,24 @@ fn information_command_is_invalid(command: &ResolvedInformationCommand) -> bool }) } -fn ledger_edit_is_invalid(edit: &ResolvedLedgerEdit) -> bool { +fn zone_change_command_is_invalid(command: &ResolvedZoneChangeCommand) -> bool { + let record = &command.zone_change_record; + let changes_incarnation = command.from != command.to; + command.object.incarnation == LEGACY_INCARNATION + || command.owner != record.owner + || record.object_id != command.object.object_id + || record.from_zone != Some(command.from) + || record.to_zone != command.to + || record.turn_zone_change_index != command.turn_zone_change_index + || (changes_incarnation && command.resulting_incarnation <= command.object.incarnation) + || (!changes_incarnation && command.resulting_incarnation != command.object.incarnation) + || (command.to == Zone::Battlefield) != command.entry_timestamp.is_some() + || (command.to == Zone::Battlefield + && record.entered_incarnation != Some(command.resulting_incarnation)) + || (command.to != Zone::Battlefield && record.entered_incarnation.is_some()) +} + +pub(crate) fn ledger_edit_is_invalid(edit: &ResolvedLedgerEdit) -> bool { match edit { ResolvedLedgerEdit::SpellCast { expected_game_count, @@ -1740,6 +1867,59 @@ fn ledger_edit_is_invalid(edit: &ResolvedLedgerEdit) -> bool { expected_game_count, .. } => *expected_turn_count == u32::MAX || *expected_game_count == u32::MAX, + ResolvedLedgerEdit::CardsDrawn { + drawn_object, + attempted_empty_library, + expected_has_drawn_this_turn, + resulting_has_drawn_this_turn, + expected_cards_drawn_this_turn, + resulting_cards_drawn_this_turn, + expected_cards_drawn_this_step, + resulting_cards_drawn_this_step, + expected_drew_from_empty_library, + resulting_drew_from_empty_library, + expected_drawn_cards_len, + resulting_drawn_cards_len, + expected_first_card_drawn_this_turn, + resulting_first_card_drawn_this_turn, + .. + } => { + let settled_card = drawn_object.is_some(); + let expected_first = if let Some(object) = drawn_object { + expected_first_card_drawn_this_turn.or(Some(object.object_id)) + } else { + *expected_first_card_drawn_this_turn + }; + (!settled_card && !attempted_empty_library) + || *resulting_has_drawn_this_turn + != if settled_card { + true + } else { + *expected_has_drawn_this_turn + } + || *resulting_cards_drawn_this_turn + != if settled_card { + expected_cards_drawn_this_turn.saturating_add(1) + } else { + *expected_cards_drawn_this_turn + } + || *resulting_cards_drawn_this_step + != if settled_card { + expected_cards_drawn_this_step.saturating_add(1) + } else { + *expected_cards_drawn_this_step + } + || *resulting_drew_from_empty_library + != (*expected_drew_from_empty_library || *attempted_empty_library) + || *expected_drawn_cards_len == u32::MAX + || *resulting_drawn_cards_len + != if settled_card { + expected_drawn_cards_len + 1 + } else { + *expected_drawn_cards_len + } + || *resulting_first_card_drawn_this_turn != expected_first + } ResolvedLedgerEdit::TriggerFired { edit: ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old }, .. @@ -1754,6 +1934,12 @@ fn ledger_edit_has_legacy_object_identity(edit: &ResolvedLedgerEdit) -> bool { edit, ResolvedLedgerEdit::TriggerFired { trigger, .. } if trigger.source.incarnation == LEGACY_INCARNATION + ) || matches!( + edit, + ResolvedLedgerEdit::CardsDrawn { + drawn_object: Some(object), + .. + } if object.incarnation == LEGACY_INCARNATION ) } diff --git a/crates/engine/src/types/statics.rs b/crates/engine/src/types/statics.rs index b272858180..e2f8f748ec 100644 --- a/crates/engine/src/types/statics.rs +++ b/crates/engine/src/types/statics.rs @@ -10,7 +10,7 @@ use super::ability::{ QuantityRef, TargetFilter, }; use super::events::ActivatedAbilityKind; -use super::identifiers::ObjectId; +use super::identifiers::ObjectIncarnationRef; use super::keywords::{Keyword, KeywordKind}; use super::mana::{ManaColor, ManaCost, SpecialAction, StepEndManaAction}; use super::phase::Phase; @@ -1158,9 +1158,10 @@ pub enum StaticMode { /// of the attacker that must be blocked. Data-carrying variant — not /// registry-registered (see `coverage::is_data_carrying_static`); enforced by /// direct pattern-match in `combat.rs` declare-blockers validation. The - /// `ObjectId` is stable for the end-of-turn lifetime of the granting effect. + /// incarnation reference prevents an attacker that left and re-entered from + /// inheriting the old combat requirement (CR 400.7). MustBlockAttacker { - attacker: ObjectId, + attacker: ObjectIncarnationRef, }, CantDraw { who: ProhibitionScope, diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index 6ca1045bfb..fa28dbd56a 100644 Binary files a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz and b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz differ diff --git a/crates/engine/tests/integration/cr733_resolved_commands_p2.rs b/crates/engine/tests/integration/cr733_resolved_commands_p2.rs index 5ed9716a71..3fc2d5ec2b 100644 --- a/crates/engine/tests/integration/cr733_resolved_commands_p2.rs +++ b/crates/engine/tests/integration/cr733_resolved_commands_p2.rs @@ -83,6 +83,9 @@ fn apply_semantic_command(state: &mut GameState, command: &ResolvedRulesCommand) engine::game::library::apply_resolved_library_shuffle(state, command, &mut Vec::new()) .unwrap(); } + ResolvedRulesCommand::ZoneChange(command) => { + engine::game::zones::apply_resolved_zone_change(state, command).unwrap(); + } ResolvedRulesCommand::Information(command) => { state.apply_resolved_information(command).unwrap(); } @@ -181,6 +184,7 @@ fn exact_mana_spend_rejects_a_second_removal() { | ResolvedRulesCommand::ObjectCounter(_) | ResolvedRulesCommand::LedgerEdit(_) | ResolvedRulesCommand::LibraryShuffle(_) + | ResolvedRulesCommand::ZoneChange(_) | ResolvedRulesCommand::Information(_) | ResolvedRulesCommand::FrameTransition(_) | ResolvedRulesCommand::TriggerCollection(_) => { diff --git a/crates/engine/tests/integration/cr733_resolved_draw.rs b/crates/engine/tests/integration/cr733_resolved_draw.rs new file mode 100644 index 0000000000..bd91c8049d --- /dev/null +++ b/crates/engine/tests/integration/cr733_resolved_draw.rs @@ -0,0 +1,186 @@ +//! CR733 P2 coverage for replay-exact draw bookkeeping composed with zone changes. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::game_state::GameState; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::resolved_commands::{ + ResolvedLedgerEdit, ResolvedRulesCommand, ResolvedRulesJournal, +}; +use engine::types::zones::Zone; + +const DIVINATION_ORACLE: &str = "Draw two cards."; + +fn semantic_commands_after( + state: &GameState, + first_resolution_entry: usize, +) -> Vec { + state + .resolved_rules_journal + .entries() + .iter() + .skip(first_resolution_entry) + .filter_map(|entry| entry.command.clone()) + .collect() +} + +fn apply_semantic_command(state: &mut GameState, command: &ResolvedRulesCommand) { + match command { + ResolvedRulesCommand::ManaInsert(command) => { + state.apply_resolved_mana_insert(command).unwrap(); + } + ResolvedRulesCommand::ManaSpend(command) => { + state.apply_resolved_mana_spend(command).unwrap(); + } + ResolvedRulesCommand::PlayerEdit(command) => { + state.apply_resolved_player_edit(command).unwrap(); + } + ResolvedRulesCommand::ObjectStatus(command) => { + engine::game::object_state::apply_resolved_object_edit(state, command).unwrap(); + } + ResolvedRulesCommand::ObjectCounter(command) => { + engine::game::effects::counters::apply_resolved_counter_edit(state, command).unwrap(); + } + ResolvedRulesCommand::LedgerEdit(command) => { + engine::game::ledger::apply_resolved_ledger_edit(state, command).unwrap(); + } + ResolvedRulesCommand::LibraryShuffle(command) => { + engine::game::library::apply_resolved_library_shuffle(state, command, &mut Vec::new()) + .unwrap(); + } + ResolvedRulesCommand::ZoneChange(command) => { + engine::game::zones::apply_resolved_zone_change(state, command).unwrap(); + } + ResolvedRulesCommand::Information(command) => { + state.apply_resolved_information(command).unwrap(); + } + ResolvedRulesCommand::FrameTransition(command) => { + state + .apply_resolved_frame_transition(command.as_ref()) + .unwrap(); + } + ResolvedRulesCommand::TriggerCollection(command) => { + engine::game::triggers::apply_resolved_trigger_collection(state, command).unwrap(); + } + } +} + +/// CR 121.1 + CR 121.2: A real Divination-class draw spell records every +/// Library → Hand move through the zone-change hub and each settled draw's +/// bookkeeping through the ledger. Replaying those recorded commands from the +/// pre-resolution stack state must not inspect the library or select cards. +#[test] +fn divination_draw_replays_zone_changes_and_ledger_bookkeeping_exactly() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Divination", false, DIVINATION_ORACLE) + .with_mana_cost(ManaCost::zero()) + .id(); + scenario.with_library_top(P0, &["First", "Second", "Third"]); + let mut runner = scenario.build(); + + let committed = runner.cast(spell).commit(); + let pre_resolution_state = committed.state().clone(); + let first_resolution_entry = pre_resolution_state.resolved_rules_journal.entries().len(); + let outcome = committed.resolve(); + outcome.assert_hand_drawn(P0, 2); + let ordinary_state = runner.state().clone(); + let commands = semantic_commands_after(&ordinary_state, first_resolution_entry); + + let draw_zone_changes: Vec<_> = commands + .iter() + .filter_map(|command| match command { + ResolvedRulesCommand::ZoneChange(command) + if command.from == Zone::Library && command.to == Zone::Hand => + { + Some((command.object.object_id, command.resulting_incarnation)) + } + _ => None, + }) + .collect(); + let drawn_edits: Vec<_> = commands + .iter() + .filter_map(|command| match command { + ResolvedRulesCommand::LedgerEdit(command) => match &command.edit { + ResolvedLedgerEdit::CardsDrawn { + drawn_object: Some(object), + .. + } => Some((object.object_id, object.incarnation)), + _ => None, + }, + _ => None, + }) + .collect(); + assert_eq!( + draw_zone_changes.len(), + 2, + "CR 121.2: the two-card instruction settles one Library → Hand command per card" + ); + assert_eq!( + drawn_edits.len(), + 2, + "each settled draw must record one CardsDrawn ledger edit after its zone command" + ); + assert_eq!( + drawn_edits, draw_zone_changes, + "the ledger records the exact post-zone-change object occurrence, not a replay-time selection" + ); + + let journal_wire = serde_json::to_value(&ordinary_state.resolved_rules_journal) + .expect("draw journal serializes"); + assert_eq!( + serde_json::from_value::(journal_wire) + .expect("draw journal validates and deserializes"), + ordinary_state.resolved_rules_journal + ); + + let mut replay = pre_resolution_state; + replay.resolved_rules_journal = ordinary_state.resolved_rules_journal.clone(); + for command in &commands { + apply_semantic_command(&mut replay, command); + } + + let replay_player = replay + .players + .iter() + .find(|player| player.id == P0) + .expect("replay keeps the drawing player"); + let ordinary_player = ordinary_state + .players + .iter() + .find(|player| player.id == P0) + .expect("ordinary resolution keeps the drawing player"); + assert_eq!( + replay_player.cards_drawn_this_turn, ordinary_player.cards_drawn_this_turn, + "replay installs the player turn counter" + ); + assert_eq!( + replay_player.cards_drawn_this_step, ordinary_player.cards_drawn_this_step, + "replay installs the player step counter" + ); + assert_eq!( + replay_player.has_drawn_this_turn, ordinary_player.has_drawn_this_turn, + "replay installs the player draw flag" + ); + assert_eq!( + replay_player.drew_from_empty_library, ordinary_player.drew_from_empty_library, + "replay installs the player empty-library fact" + ); + assert_eq!( + replay.cards_drawn_this_turn, ordinary_state.cards_drawn_this_turn, + "replay appends the GameState drawn-card ledger in order" + ); + assert_eq!( + replay.first_card_drawn_this_turn, ordinary_state.first_card_drawn_this_turn, + "replay installs the first-draw ledger fact" + ); + assert_eq!( + replay_player.hand, ordinary_player.hand, + "the zone-change commands install the exact hand contents" + ); + assert_eq!( + replay_player.library, ordinary_player.library, + "the zone-change commands preserve the exact remaining library order" + ); +} diff --git a/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs b/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs index 7c7e5bedaa..9f0804c67b 100644 --- a/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs +++ b/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs @@ -6,17 +6,26 @@ use engine::game::triggers::{ ConsumedTriggerEventOccurrence, PendingTrigger, PendingTriggerContext, PendingTriggerDispatchOrigin, }; -use engine::types::ability::{Effect, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef}; +use engine::types::ability::{ + AbilityCost, AbilityDefinition, AbilityKind, Effect, ManaContribution, ManaProduction, + QuantityExpr, ReplacementDefinition, ResolvedAbility, SacrificeCost, TargetFilter, TargetRef, + TriggerDefinition, TypeFilter, TypedFilter, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; use engine::types::events::{GameEvent, PlayerActionKind}; -use engine::types::game_state::GameState; +use engine::types::game_state::{GameState, StackEntryKind, WaitingFor}; use engine::types::identifiers::ObjectId; +use engine::types::mana::ManaColor; use engine::types::phase::Phase; use engine::types::player::PlayerId; +use engine::types::replacements::ReplacementEvent; use engine::types::resolved_commands::{ ResolvedCommandOrdinal, ResolvedRulesCommand, ResolvedTriggerCollection, ResolvedTriggerCollectionCommand, RulesExecutionNodeRef, }; -use engine::types::zones::Zone; +use engine::types::triggers::TriggerMode; +use engine::types::zones::{EtbTapState, Zone}; const DIES_OBSERVER: &str = "Whenever another creature dies, you gain 1 life."; @@ -81,6 +90,101 @@ fn recorded_commands(state: &GameState) -> Vec { .collect() } +fn current_action_occurrences(events: &[GameEvent]) -> Vec { + events + .iter() + .enumerate() + .map(|(index, event)| ConsumedTriggerEventOccurrence { + event: event.clone(), + occurrence: events[..index] + .iter() + .filter(|prior| *prior == event) + .count(), + }) + .collect() +} + +fn redirect_self_moved_to(destination: Zone, redirected_to: Zone) -> ReplacementDefinition { + ReplacementDefinition::new(ReplacementEvent::Moved) + .destination_zone(destination) + .valid_card(TargetFilter::SelfRef) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + destination: redirected_to, + origin: None, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: Vec::new(), + conditional_enter_with_counters: Vec::new(), + face_down_profile: None, + enters_modified_if: None, + }, + )) +} + +fn sacrifice_observer() -> TriggerDefinition { + TriggerDefinition::new(TriggerMode::Sacrificed) + .valid_card(TargetFilter::Any) + .trigger_zones(vec![Zone::Battlefield]) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + )) +} + +fn artifact_sacrifice_observer() -> TriggerDefinition { + TriggerDefinition::new(TriggerMode::Sacrificed) + .valid_card(TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact))) + .trigger_zones(vec![Zone::Battlefield]) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + )) +} + +fn run_trigger_collection_fixture(fixture: impl FnOnce() + Send + 'static) { + std::thread::Builder::new() + // Journaled zone-change LKI exceeds the default 2 MiB test-thread stack. + .stack_size(4 * 1024 * 1024) + .spawn(fixture) + .expect("trigger collection fixture thread starts") + .join() + .expect("trigger collection fixture completes") +} + +fn assert_current_action_consumed_occurrences_were_journaled( + state: &GameState, + events: &[GameEvent], +) { + let expected = current_action_occurrences(events); + assert!( + state + .resolved_rules_journal + .entries() + .iter() + .any(|entry| matches!( + entry.command.as_ref(), + Some(ResolvedRulesCommand::TriggerCollection(ResolvedTriggerCollectionCommand { + collection: ResolvedTriggerCollection::ConsumeBeforePriority { occurrences }, + .. + })) if occurrences == &expected + )), + "the production cost settlement must journal its exact current-action consumed occurrences" + ); +} + #[test] fn trigger_collection_command_variants_round_trip_inside_the_journal_envelope() { for command in [ @@ -226,3 +330,181 @@ fn real_change_zone_resolver_journals_its_collected_trigger_occurrences() { "logical-zone segment collection must journal the observer context" ); } + +#[test] +fn paused_mana_cost_settlement_journals_consumed_occurrences_without_recollecting() { + run_trigger_collection_fixture( + paused_mana_cost_settlement_journals_consumed_occurrences_without_recollecting_impl, + ); +} + +fn paused_mana_cost_settlement_journals_consumed_occurrences_without_recollecting_impl() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let observer = scenario + .add_creature(P0, "Mana cost sacrifice observer", 1, 1) + .with_trigger_definition(sacrifice_observer()) + .id(); + let source = scenario + .add_creature(P0, "Self-sacrifice mana replacement witness", 1, 1) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![ManaColor::Green], + contribution: ManaContribution::Base, + }, + restrictions: Vec::new(), + grants: Vec::new(), + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ], + }), + ) + .with_replacement_definition(redirect_self_moved_to(Zone::Graveyard, Zone::Exile)) + .with_replacement_definition(redirect_self_moved_to(Zone::Graveyard, Zone::Hand)) + .id(); + let mut runner = scenario.build(); + + let paused = runner + .act(GameAction::ActivateAbility { + source_id: source, + ability_index: 0, + }) + .expect("the mana cost reaches its real replacement choice after its tap prefix"); + assert!(matches!( + paused.waiting_for, + WaitingFor::ReplacementChoice { .. } + )); + + let resumed = runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the replacement choice resumes the mana cost settlement"); + assert_eq!(runner.state().objects[&source].zone, Zone::Exile); + assert!(resumed.events.iter().any(|event| matches!( + event, + GameEvent::ZoneChanged { object_id, .. } if *object_id == source + ))); + assert_current_action_consumed_occurrences_were_journaled(runner.state(), &resumed.events); + assert_eq!( + runner + .state() + .stack + .iter() + .filter(|entry| matches!( + entry.kind, + StackEntryKind::TriggeredAbility { source_id, .. } if source_id == observer + )) + .count(), + 1, + "the settled mana-cost zone change must not be collected again at priority" + ); +} + +#[test] +fn paused_sacrifice_cost_settlement_journals_consumed_occurrences_without_recollecting() { + run_trigger_collection_fixture( + paused_sacrifice_cost_settlement_journals_consumed_occurrences_without_recollecting_impl, + ); +} + +fn paused_sacrifice_cost_settlement_journals_consumed_occurrences_without_recollecting_impl() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let observer = scenario + .add_creature(P0, "Sacrifice cost artifact observer", 1, 1) + .with_trigger_definition(artifact_sacrifice_observer()) + .id(); + let source = scenario + .add_creature(P0, "Count-two sacrifice activation witness", 1, 1) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + ) + .cost(AbilityCost::Sacrifice(SacrificeCost::count( + TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)), + 2, + ))), + ) + .id(); + let first = scenario + .add_creature(P0, "First sacrifice-cost witness", 1, 1) + .id(); + let second = scenario + .add_creature(P0, "Second sacrifice-cost replacement witness", 1, 1) + .with_replacement_definition(redirect_self_moved_to(Zone::Graveyard, Zone::Exile)) + .with_replacement_definition(redirect_self_moved_to(Zone::Graveyard, Zone::Hand)) + .id(); + let mut runner = scenario.build(); + let second_object = runner + .state_mut() + .objects + .get_mut(&second) + .expect("the second sacrifice witness exists"); + second_object.card_types.core_types.push(CoreType::Artifact); + second_object.base_card_types = second_object.card_types.clone(); + + runner + .act(GameAction::ActivateAbility { + source_id: source, + ability_index: 0, + }) + .expect("the sacrifice-cost activation begins"); + let paused = runner + .act(GameAction::SelectCards { + cards: vec![first, second], + }) + .expect("the second sacrifice reaches its real replacement choice"); + assert!(matches!( + paused.waiting_for, + WaitingFor::ReplacementChoice { .. } + )); + + let resumed = runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the replacement choice resumes the sacrifice cost settlement"); + assert_eq!(runner.state().objects[&second].zone, Zone::Exile); + assert!(resumed.events.iter().any(|event| matches!( + event, + GameEvent::ZoneChanged { object_id, .. } if *object_id == second + ))); + let current_end = resumed + .events + .iter() + .position(|event| { + matches!( + event, + GameEvent::PermanentSacrificed { object_id, .. } if *object_id == second + ) + }) + .expect("the settled second sacrifice emits its terminal sacrifice event") + + 1; + assert_current_action_consumed_occurrences_were_journaled( + runner.state(), + &resumed.events[..current_end], + ); + assert_eq!( + runner + .state() + .stack + .iter() + .filter(|entry| matches!( + entry.kind, + StackEntryKind::TriggeredAbility { source_id, .. } if source_id == observer + )) + .count(), + 1, + "the second settled sacrifice must not be collected again at priority" + ); +} diff --git a/crates/engine/tests/integration/cr733_resolved_zone_change.rs b/crates/engine/tests/integration/cr733_resolved_zone_change.rs new file mode 100644 index 0000000000..27b08a702b --- /dev/null +++ b/crates/engine/tests/integration/cr733_resolved_zone_change.rs @@ -0,0 +1,145 @@ +//! CR733 P2 coverage for exact single-object zone-transition commands. + +use engine::game::scenario::{GameScenario, P0}; +use engine::game::zones::{apply_resolved_zone_change, move_to_zone}; +use engine::types::game_state::GameState; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::resolved_commands::{ + ResolvedRulesCommand, ResolvedRulesJournal, ResolvedZoneChangeReplayInvariantError, +}; +use engine::types::zones::Zone; + +fn recorded_zone_change( + state: &GameState, + from: Zone, + to: Zone, +) -> engine::types::resolved_commands::ResolvedZoneChangeCommand { + state + .resolved_rules_journal + .entries() + .iter() + .filter_map(|entry| entry.command.as_ref()) + .find_map(|command| match command { + ResolvedRulesCommand::ZoneChange(command) + if command.from == from && command.to == to => + { + Some(command.as_ref().clone()) + } + _ => None, + }) + .expect("ordinary move must journal the expected zone-change command") +} + +fn battlefield_reentry_states() -> (GameState, GameState) { + let mut scenario = GameScenario::new_n_player(2, 0x733); + let creature = scenario + .add_creature_from_oracle(P0, "Journal Entrant", 2, 2, "") + .id(); + let runner = scenario.build(); + let mut state = runner.state().clone(); + let mut events = Vec::new(); + move_to_zone(&mut state, creature, Zone::Graveyard, &mut events); + let pre_state = state.clone(); + move_to_zone(&mut state, creature, Zone::Battlefield, &mut events); + (pre_state, state) +} + +#[test] +fn zone_change_command_round_trips_and_replays_the_exact_transition_core() { + let (pre_state, ordinary_state) = battlefield_reentry_states(); + let command = recorded_zone_change(&ordinary_state, Zone::Graveyard, Zone::Battlefield); + + let wire = serde_json::to_value(ResolvedRulesCommand::ZoneChange(Box::new(command.clone()))) + .expect("zone-change command serializes"); + assert_eq!( + serde_json::from_value::(wire) + .expect("zone-change command deserializes"), + ResolvedRulesCommand::ZoneChange(Box::new(command.clone())) + ); + + let journal_wire = serde_json::to_value(&ordinary_state.resolved_rules_journal) + .expect("journal serializes with a zone-change command"); + assert_eq!( + serde_json::from_value::(journal_wire) + .expect("journal validates and deserializes"), + ordinary_state.resolved_rules_journal + ); + + let mut replay = pre_state; + apply_resolved_zone_change(&mut replay, &command).expect("exact transition replays"); + + let object = &replay.objects[&command.object.object_id]; + assert_eq!(object.zone, command.to); + assert_eq!(object.incarnation, command.resulting_incarnation); + assert_eq!(object.timestamp, command.entry_timestamp.unwrap()); + assert_eq!( + replay.battlefield[command.destination_position], command.object.object_id, + "replay installs the recorded destination position" + ); + assert_eq!( + replay.zone_changes_this_turn[command.turn_zone_change_index], command.zone_change_record, + "replay appends the exact recorded per-turn zone-change entry" + ); +} + +#[test] +fn zone_change_rejects_occurrence_and_destination_position_mismatches() { + let (pre_state, ordinary_state) = battlefield_reentry_states(); + let command = recorded_zone_change(&ordinary_state, Zone::Graveyard, Zone::Battlefield); + + let mut replay = pre_state.clone(); + apply_resolved_zone_change(&mut replay, &command).expect("first exact application succeeds"); + assert!(matches!( + apply_resolved_zone_change(&mut replay, &command), + Err(ResolvedZoneChangeReplayInvariantError::OccurrenceMismatch { .. }) + )); + + let mut wrong_position = command.clone(); + wrong_position.destination_position += 1; + assert!(matches!( + apply_resolved_zone_change(&mut pre_state.clone(), &wrong_position), + Err(ResolvedZoneChangeReplayInvariantError::DestinationPositionMismatch { .. }) + )); +} + +#[test] +fn zone_change_journal_rejects_an_unrelated_cause() { + let (_, ordinary_state) = battlefield_reentry_states(); + let mut wire = serde_json::to_value(&ordinary_state.resolved_rules_journal) + .expect("journal serializes before malformed-cause test"); + let entries = wire["entries"] + .as_array_mut() + .expect("journal stores command entries"); + let command = entries + .iter_mut() + .find_map(|entry| entry["command"]["ZoneChange"].as_object_mut()) + .expect("journal contains a zone-change command"); + command.insert("cause".to_string(), serde_json::json!({ "Proposal": 99 })); + + assert!(serde_json::from_value::(wire).is_err()); +} + +#[test] +fn production_change_zone_pipeline_records_the_transition_command() { + let mut scenario = GameScenario::new_n_player(2, 0x733); + scenario.at_phase(Phase::PreCombatMain); + let target = scenario + .add_creature_from_oracle(P0, "Target Creature", 2, 2, "") + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Exile Test", true, "Exile target creature.") + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + runner.cast(spell).target_object(target).resolve(); + + let command = recorded_zone_change(runner.state(), Zone::Battlefield, Zone::Exile); + assert_eq!(command.object.object_id, target); + assert_eq!( + command.resulting_incarnation, + command.object.incarnation + 1 + ); + assert_eq!(runner.state().objects[&target].zone, Zone::Exile); +} diff --git a/crates/engine/tests/integration/fevered_visions.rs b/crates/engine/tests/integration/fevered_visions.rs new file mode 100644 index 0000000000..e95db6c97d --- /dev/null +++ b/crates/engine/tests/integration/fevered_visions.rs @@ -0,0 +1,122 @@ +//! Fevered Visions resolves its printed end-step instructions in order: +//! the active player draws, then the post-draw opponent/hand-size gate decides +//! whether that player takes 2 damage. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; + +const FEVERED_VISIONS_ORACLE: &str = "At the beginning of each player's end step, that player draws a card. If the player is your opponent and has four or more cards in hand, this enchantment deals 2 damage to that player."; + +fn hand_count(runner: &engine::game::scenario::GameRunner, player: PlayerId) -> usize { + runner + .state() + .players + .iter() + .find(|candidate| candidate.id == player) + .expect("scenario player") + .hand + .len() +} + +/// Drive the production turn transition (postcombat main -> end step), trigger +/// placement, priority passes, and stack resolution. Returns `(draw_delta, +/// life_delta)` for the active player. +fn resolve_fevered_end_step(active_player: PlayerId, pre_draw_hand: usize) -> (isize, i32) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PostCombatMain); + for index in 0..pre_draw_hand { + scenario.add_card_to_hand(active_player, &format!("Hand Card {index}")); + } + scenario.with_library_top(active_player, &["Fevered Draw"]); + scenario + .add_creature(P0, "Fevered Visions", 0, 0) + .as_enchantment() + .from_oracle_text(FEVERED_VISIONS_ORACLE); + + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = active_player; + state.priority_player = active_player; + state.waiting_for = WaitingFor::Priority { + player: active_player, + }; + } + let hand_before = hand_count(&runner, active_player); + let life_before = runner.life(active_player); + + // CR 513.1 + CR 603.2b: pass real priority from postcombat main into the + // end step, where the beginning-of-step trigger is created. + for _ in 0..8 { + if runner.state().phase == Phase::End { + break; + } + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "unexpected prompt before end step: {:?}", + runner.state().waiting_for + ); + runner + .act(GameAction::PassPriority) + .expect("priority pass toward end step"); + } + assert_eq!( + runner.state().phase, + Phase::End, + "real turn machinery must enter the end step" + ); + assert!( + !runner.state().stack.is_empty() + || matches!(runner.state().waiting_for, WaitingFor::OrderTriggers { .. }), + "Fevered Visions trigger must be pending at the end-step boundary" + ); + + // CR 608.2c: priority passes resolve the real triggered-ability stack entry, + // executing Draw before its conditional DealDamage child. + runner.advance_until_stack_empty(); + assert!( + runner.state().stack.is_empty(), + "Fevered Visions trigger must fully resolve" + ); + + ( + hand_count(&runner, active_player) as isize - hand_before as isize, + runner.life(active_player) - life_before, + ) +} + +#[test] +fn controller_draws_to_four_without_taking_damage() { + let (draw_delta, life_delta) = resolve_fevered_end_step(P0, 3); + assert_eq!(draw_delta, 1, "the controller must draw before the gate"); + assert_eq!( + life_delta, 0, + "the controller is not their own opponent and must take no damage" + ); +} + +#[test] +fn opponent_draws_to_four_then_takes_two_damage() { + let (draw_delta, life_delta) = resolve_fevered_end_step(P1, 3); + assert_eq!(draw_delta, 1, "the opponent must draw before the gate"); + assert_eq!( + life_delta, -2, + "the post-draw hand of four must satisfy the damage gate" + ); +} + +#[test] +fn opponent_draws_to_three_without_taking_damage() { + let (draw_delta, life_delta) = resolve_fevered_end_step(P1, 2); + assert_eq!( + draw_delta, 1, + "the opponent must still draw below threshold" + ); + assert_eq!( + life_delta, 0, + "the post-draw hand of three must fail the damage gate" + ); +} diff --git a/crates/engine/tests/integration/gift_delivery_draw_sequence_migration.rs b/crates/engine/tests/integration/gift_delivery_draw_sequence_migration.rs index 20454d337a..b47bf01a5f 100644 --- a/crates/engine/tests/integration/gift_delivery_draw_sequence_migration.rs +++ b/crates/engine/tests/integration/gift_delivery_draw_sequence_migration.rs @@ -56,6 +56,12 @@ fn cast_promised_coiling( .act(GameAction::DecideOptionalCost { pay: true }) .expect("promise Gift a card"); } + WaitingFor::ChooseGiftRecipient { candidates, .. } => { + let opponent = candidates[0]; + runner + .act(GameAction::ChooseGiftRecipient { opponent }) + .expect("choose gift recipient"); + } WaitingFor::TargetSelection { .. } => { runner .act(GameAction::ChooseTarget { diff --git a/crates/engine/tests/integration/granted_bloodthirst_5802.rs b/crates/engine/tests/integration/granted_bloodthirst_5802.rs new file mode 100644 index 0000000000..39a8f4d8bc --- /dev/null +++ b/crates/engine/tests/integration/granted_bloodthirst_5802.rs @@ -0,0 +1,241 @@ +//! #5802 review (matthewevans): the granted-as-enters-keyword virtual candidate +//! must generalize beyond Sunburst to Bloodthirst. +//! +//! Bloodlord of Vaasgoth's "Whenever you cast a Vampire creature spell, it gains +//! bloodthirst 3" grants Bloodthirst to a spell still ON THE STACK. Printed +//! Bloodthirst is synthesized into an object-carried ETB `ReplacementDefinition` +//! (`synthesize_bloodthirst`); a runtime grant adds only the keyword, so — exactly +//! like granted Sunburst (#5337) — the runtime must surface a VIRTUAL as-enters +//! counter replacement for the granted instance. +//! +//! Unlike Sunburst, fixed-N Bloodthirst is CONDITIONAL (CR 702.54a): the counters +//! are placed only if an opponent was dealt damage this turn. The virtual applier +//! honors that carried condition — the negative test below (no opponent damage → +//! ZERO counters) is the revert-canary for the condition handling. +//! +//! Oracle text is verbatim from Scryfall: +//! - Bloodlord of Vaasgoth: "Bloodthirst 3 (If an opponent was dealt damage this +//! turn, this creature enters with three +1/+1 counters on it.)\nFlying\n +//! Whenever you cast a Vampire creature spell, it gains bloodthirst 3." +//! +//! CR references (verified against docs/MagicCompRules.txt): +//! - CR 702.54a: Bloodthirst N — if an opponent was dealt damage this turn, the +//! creature enters with N +1/+1 counters. +//! - CR 702.54c: multiple instances of Bloodthirst each work separately. +//! - CR 613.1 + CR 400.7a: a keyword granted to a spell on the stack applies to +//! the permanent that spell becomes. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::TargetRef; +use engine::types::card_type::CoreType; +use engine::types::counter::CounterType; +use engine::types::game_state::DamageRecord; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::{BloodthirstValue, Keyword}; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const BLOODLORD_ORACLE: &str = "Bloodthirst 3 (If an opponent was dealt damage this turn, this creature enters with three +1/+1 counters on it.)\nFlying\nWhenever you cast a Vampire creature spell, it gains bloodthirst 3."; + +/// Float `count` units of `ty` into P0's mana pool so the cast is funded. +fn add_mana(runner: &mut GameRunner, ty: ManaType, count: usize) { + for _ in 0..count { + let unit = ManaUnit::new(ty, ObjectId(0), false, vec![]); + runner.state_mut().players[0].mana_pool.add(unit); + } +} + +fn counters_of(runner: &GameRunner, id: ObjectId, ct: &CounterType) -> u32 { + runner + .state() + .objects + .get(&id) + .and_then(|o| o.counters.get(ct)) + .copied() + .unwrap_or(0) +} + +/// Record noncombat damage to P0's opponent (P1) earlier this turn so the +/// Bloodthirst condition (CR 702.54a: "an opponent was dealt damage this turn") +/// is TRUE. CR 702.54a doesn't care about the source, so any source id works. +fn deal_damage_to_opponent(runner: &mut GameRunner) { + runner + .state_mut() + .damage_dealt_this_turn + .push_back(DamageRecord { + source_id: ObjectId(999), + source_controller: P0, + target: TargetRef::Player(P1), + target_controller: P1, + amount: 1, + is_combat: false, + ..Default::default() + }); +} + +/// Grant `bloodthirst 3` to `spell` the way Bloodlord's trigger would — a +/// transient continuous `AddKeyword` on the stack object, then rebuild the runner +/// so the layer pass materializes the grant onto the object's live keyword set. +/// This mirrors the `stack_object_keyword_grants` idiom (a `TriggeringSource` +/// keyword grant to a spell on the stack) and exercises the SAME virtual-candidate +/// surfacing the full Bloodlord trigger would reach at entry. +fn grant_bloodthirst_via_transient(runner: &mut GameRunner, spell: ObjectId, value: u32) { + use engine::types::ability::{ContinuousModification, Duration, TargetFilter}; + + runner.state_mut().add_transient_continuous_effect( + spell, + P0, + Duration::UntilEndOfTurn, + TargetFilter::SpecificObject { id: spell }, + vec![ContinuousModification::AddKeyword { + keyword: Keyword::Bloodthirst(BloodthirstValue::Fixed(value)), + }], + None, + ); +} + +/// Set up a Bloodlord on the battlefield (its trigger will grant Bloodthirst to a +/// cast Vampire creature) and a Vampire creature spell in hand (funded but not yet +/// cast). Returns `(runner, bloodlord, spell)`. +fn bloodlord_board() -> (GameRunner, ObjectId, ObjectId) { + board_with(true) +} + +/// Set up a Vampire creature spell in hand WITHOUT any Bloodlord on the +/// battlefield, so ONLY an explicit transient grant places counters (no +/// interfering real trigger). Returns `(runner, spell)`. +fn plain_vampire_board() -> (GameRunner, ObjectId) { + let (runner, _sink, spell) = board_with(false); + (runner, spell) +} + +fn board_with(include_bloodlord: bool) -> (GameRunner, ObjectId, ObjectId) { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + // A vanilla permanent keeps `sink` populated when Bloodlord is absent so the + // return shape is uniform; its plain-vanilla text grants nothing. + let sink = if include_bloodlord { + scenario + .add_creature_from_oracle(P0, "Bloodlord of Vaasgoth", 4, 4, BLOODLORD_ORACLE) + .id() + } else { + scenario.add_creature(P0, "Vanilla Bear", 2, 2).id() + }; + + let spell = scenario + .add_creature_to_hand_from_oracle(P0, "Test Vampire", 2, 2, "") + .with_subtypes(vec!["Vampire"]) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + // The cast spell must be a Vampire CREATURE for Bloodlord's `valid_card`. + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Creature]; + if !obj.card_types.subtypes.iter().any(|s| s == "Vampire") { + obj.card_types.subtypes.push("Vampire".to_string()); + } + obj.base_card_types = obj.card_types.clone(); + } + (runner, sink, spell) +} + +/// PRIMARY revert-canary for the Bloodthirst generalization: a spell GRANTED +/// bloodthirst 3 (transient, no interfering trigger), cast with an opponent +/// already dealt damage this turn, must enter with 3 +1/+1 counters via the +/// virtual granted-Bloodthirst candidate. +#[test] +fn granted_bloodthirst_with_opponent_damage_enters_with_three_p1p1() { + let (mut runner, spell) = plain_vampire_board(); + + // CR 702.54a: an opponent WAS dealt damage this turn — condition holds. + deal_damage_to_opponent(&mut runner); + grant_bloodthirst_via_transient(&mut runner, spell, 3); + add_mana(&mut runner, ManaType::Black, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + // Reach-guard: the granted spell actually resolved onto the battlefield, so + // the entry replacement pipeline WAS consulted for its ZoneChange. + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the granted-bloodthirst spell must have resolved onto the battlefield" + ); + // PRIMARY revert-failing assertion: reverting the granted-Bloodthirst virtual + // candidate (or the shared applier) makes this 0. + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 3, + "granted bloodthirst 3 with an opponent damaged this turn must place 3 +1/+1 counters" + ); +} + +/// CONDITIONAL negative — the revert-canary for the applier's condition handling +/// (CR 702.54a). Same grant, but NO opponent was dealt damage this turn, so the +/// carried `condition` is unmet and ZERO counters are placed. +#[test] +fn granted_bloodthirst_without_opponent_damage_places_no_counters() { + let (mut runner, spell) = plain_vampire_board(); + + // CR 702.54a: NO opponent damage recorded — the condition is FALSE. + assert!( + runner.state().damage_dealt_this_turn.is_empty(), + "precondition: no damage this turn" + ); + grant_bloodthirst_via_transient(&mut runner, spell, 3); + add_mana(&mut runner, ManaType::Black, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + // Reach-guard: the spell resolved (so the granted-Bloodthirst candidate WAS + // consulted for its battlefield entry) — the zero below is the condition + // gating, not a short-circuit before the candidate ran. + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the spell must resolve so the granted-bloodthirst candidate is consulted" + ); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 0, + "granted bloodthirst 3 with NO opponent damaged this turn must place ZERO counters (CR 702.54a)" + ); +} + +/// End-to-end through the REAL Bloodlord trigger: cast a Vampire creature spell +/// while Bloodlord is on the battlefield. Its "Whenever you cast a Vampire +/// creature spell, it gains bloodthirst 3" trigger grants Bloodthirst to the spell +/// on the stack; with an opponent damaged this turn it enters with 3 counters. +#[test] +fn bloodlord_trigger_grants_bloodthirst_end_to_end() { + let (mut runner, bloodlord, spell) = bloodlord_board(); + let _ = bloodlord; + + deal_damage_to_opponent(&mut runner); + add_mana(&mut runner, ManaType::Black, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the cast Vampire creature must resolve onto the battlefield" + ); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 3, + "Bloodlord's granted bloodthirst 3 (opponent damaged) must place 3 +1/+1 counters end-to-end" + ); + let _ = PlayerId(0); +} diff --git a/crates/engine/tests/integration/granted_sunburst_5337.rs b/crates/engine/tests/integration/granted_sunburst_5337.rs new file mode 100644 index 0000000000..6161208069 --- /dev/null +++ b/crates/engine/tests/integration/granted_sunburst_5337.rs @@ -0,0 +1,703 @@ +//! Issue #5337 — GRANTED sunburst must place as-enters counters. +//! +//! Two coordinated gaps are exercised end-to-end through the real cast / +//! activation / trigger / replacement pipeline (no shape assertions): +//! +//! Gap 1 (parser): Solar Array's "When you next cast an artifact spell this +//! turn, THAT SPELL gains sunburst" is a `WhenNextEvent` delayed grant. The +//! subject-position "that spell" anaphor must bind `TriggeringSource` (the newly +//! cast spell / event source) rather than `ParentTarget` — a delayed trigger has +//! no parent target, so a `ParentTarget` grant registers against the empty +//! chain-tracked set and silently never lands (CR 608.2k). +//! +//! Gap 2 (runtime): a spell GRANTED sunburst carries the keyword but no +//! object-carried ETB replacement (only PRINTED sunburst is synthesized into +//! `replacement_definitions`). The runtime must surface a virtual as-enters +//! counter replacement for the granted instance so the permanent enters with a +//! counter per color of mana spent (CR 702.44a/b/d). +//! +//! Oracle texts are verbatim from Scryfall: +//! - Solar Array: "{T}: Add one mana of any color. When you next cast an +//! artifact spell this turn, that spell gains sunburst. (...)" +//! - Lux Artillery: "Whenever you cast an artifact creature spell, it gains +//! sunburst. (...)\n..." +//! +//! CR references (verified against docs/MagicCompRules.txt): +//! - CR 702.44a: sunburst — enters with a +1/+1 (creature) or charge (otherwise) +//! counter for each color of mana spent to cast it. +//! - CR 702.44b: counts colors of mana spent, from the stack as a resolving spell. +//! - CR 702.44d: multiple instances of sunburst each work separately. +//! - CR 608.2k: a "that spell" anaphor in a delayed trigger names the event source. +//! - CR 616.1: replacement-effect ordering (Doubling Season interplay). + +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::types::card_type::CoreType; +use engine::types::counter::CounterType; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const SOLAR_ARRAY_ORACLE: &str = "{T}: Add one mana of any color. When you next cast an artifact spell this turn, that spell gains sunburst. (If it's a creature, it enters with a +1/+1 counter on it for each color of mana spent to cast it. Otherwise, it enters with that many charge counters on it.)"; + +const LUX_ARTILLERY_ORACLE: &str = "Whenever you cast an artifact creature spell, it gains sunburst. (It enters with a +1/+1 counter on it for each color of mana spent to cast it.)"; + +/// Turn a scenario "creature" permanent into a pure noncreature artifact and +/// clear its P/T so the 0/0 stub isn't destroyed as an SBA before use. +fn make_artifact(runner: &mut GameRunner, id: ObjectId) { + let obj = runner.state_mut().objects.get_mut(&id).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.base_card_types = obj.card_types.clone(); + obj.power = None; + obj.toughness = None; + obj.base_power = None; + obj.base_toughness = None; +} + +/// Float `count` units of `ty` into P0's mana pool. +fn add_mana(runner: &mut GameRunner, ty: ManaType, count: usize) { + for _ in 0..count { + let unit = ManaUnit::new(ty, ObjectId(0), false, vec![]); + runner.state_mut().players[0].mana_pool.add(unit); + } +} + +fn counters_of(runner: &GameRunner, id: ObjectId, ct: &CounterType) -> u32 { + runner + .state() + .objects + .get(&id) + .and_then(|o| o.counters.get(ct)) + .copied() + .unwrap_or(0) +} + +fn charge() -> CounterType { + CounterType::Generic("charge".to_string()) +} + +/// Index of Solar Array's `{T}` mana ability. +fn mana_ability_index(runner: &GameRunner, id: ObjectId) -> usize { + runner + .state() + .objects + .get(&id) + .unwrap() + .abilities + .iter() + .position(engine::game::mana_abilities::is_mana_ability) + .expect("Solar Array has a mana ability") +} + +/// Activate Solar Array's mana ability so the `WhenNextEvent` delayed grant is +/// created. Drives the "{T}: Add one mana of any color" color prompt manually +/// (`WaitingFor::ChooseManaColor` → `GameAction::ChooseManaColor`, the +/// brigid_mana_ability idiom), then CLEARS the pool so the produced unit cannot +/// leak into the cast — each test funds the cast with an explicit floated pool +/// so the colors-of-mana-spent mix stays test-controlled (CR 702.44b). +fn arm_solar_array(runner: &mut GameRunner, solar: ObjectId) { + use engine::types::actions::GameAction; + use engine::types::game_state::{ManaChoice, WaitingFor}; + + let idx = mana_ability_index(runner, solar); + runner + .act(GameAction::ActivateAbility { + source_id: solar, + ability_index: idx, + }) + .expect("activating Solar Array's mana ability must succeed"); + if matches!( + runner.state().waiting_for, + WaitingFor::ChooseManaColor { .. } + ) { + runner + .act(GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaType::White), + count: 1, + }) + .expect("submitting the any-color choice must succeed"); + } + runner.state_mut().players[0].mana_pool.clear(); +} + +/// PRIMARY end-to-end revert-canary for BOTH gaps: Solar Array grants sunburst +/// to a cast artifact CREATURE spell; paying three distinct colors, it must +/// enter with three +1/+1 counters. +#[test] +fn solar_array_grants_sunburst_creature_three_colors_enters_with_three_p1p1() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + + let spell = scenario + .add_creature_to_hand_from_oracle(P0, "Test Golem", 0, 0, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ + ManaCostShard::White, + ManaCostShard::Blue, + ManaCostShard::Black, + ], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + // The cast spell must be an artifact creature. + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact, CoreType::Creature]; + obj.base_card_types = obj.card_types.clone(); + } + + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::White, 1); + add_mana(&mut runner, ManaType::Blue, 1); + add_mana(&mut runner, ManaType::Black, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + // PRIMARY revert-failing assertion: reverting EITHER gap makes this 0. + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 3, + "granted-sunburst artifact creature cast for 3 colors must enter with 3 +1/+1 counters" + ); + // Reach-guard: the spell actually resolved onto the battlefield. + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the granted spell must have resolved onto the battlefield" + ); +} + +/// Solar Array grants sunburst to a NONCREATURE artifact → charge counters. +#[test] +fn solar_array_grants_sunburst_noncreature_two_colors_enters_with_two_charge() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Test Relic", false, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::White, ManaCostShard::Green], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + // The cast spell is a noncreature artifact. + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.base_card_types = obj.card_types.clone(); + } + + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::White, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!( + counters_of(&runner_after, spell, &charge()), + 2, + "granted-sunburst noncreature artifact cast for 2 colors must enter with 2 charge counters" + ); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 0, + "a noncreature granted-sunburst permanent must not place +1/+1 counters (CR 702.44a)" + ); + assert_eq!(outcome.zone_of(spell), Zone::Battlefield); +} + +/// CR 702.44a revert-canary for the PRINTED-vs-LIVE core-type branch. +/// +/// Sunburst reads "if this object is entering as a creature, IGNORING ANY +/// TYPE-CHANGING EFFECTS that would affect it". This spell is a PRINTED +/// noncreature artifact whose LIVE card types include Creature while it is on the +/// stack — exactly the state a type-changing effect leaves behind (Layer-6 type +/// effects do reach off-battlefield objects via `remote_type_layer_recipients`, +/// and the layer pass re-seeds live characteristics only for battlefield objects, +/// so the divergence survives to the entry-replacement pipeline). +/// +/// Branching on the LIVE types yields +1/+1 counters; the rule mandates the +/// PRINTED types, so charge counters are correct. Every other fixture in this +/// file sets `base_card_types == card_types`, so this is the only test that +/// exercises the divergent arm — without it the branch is unverified. +#[test] +fn granted_sunburst_ignores_type_changing_effect_and_branches_on_printed_types() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Test Relic", false, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::White, ManaCostShard::Green], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + // PRINTED (characteristic-defining): a noncreature artifact. + obj.base_card_types.core_types = vec![CoreType::Artifact]; + // LIVE: a type-changing effect has made it an artifact creature. CR 702.44a + // orders sunburst to ignore precisely this. + obj.card_types.core_types = vec![CoreType::Artifact, CoreType::Creature]; + } + + // Non-vacuity guard: the printed/live divergence this test turns on is really + // present at cast time. If a future change re-seeds stack objects from their + // printed types, this fires instead of the test silently going green. + { + let obj = runner.state().objects.get(&spell).unwrap(); + assert!( + obj.card_types.core_types.contains(&CoreType::Creature), + "fixture precondition: the LIVE types must include Creature" + ); + assert!( + !obj.base_card_types.core_types.contains(&CoreType::Creature), + "fixture precondition: the PRINTED types must NOT include Creature" + ); + } + + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::White, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + // Reach-guard: the spell actually entered, so the counter assertions below + // cannot pass vacuously on a spell that never resolved. + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the granted spell must have resolved onto the battlefield" + ); + // REVERT-FAILING: branching on the live `card_types` makes this 0 (and the + // +1/+1 assertion below 2). + assert_eq!( + counters_of(&runner_after, spell, &charge()), + 2, + "CR 702.44a: sunburst ignores type-changing effects, so a PRINTED noncreature \ + artifact must enter with charge counters even while a type-changing effect \ + makes it a creature" + ); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 0, + "CR 702.44a: the LIVE creature type must not redirect sunburst to +1/+1 counters" + ); +} + +/// Lux Artillery grants sunburst via a NON-delayed trigger ("it gains +/// sunburst"). Revert-canary for gap 2 alone (its trigger already lowers to +/// `TriggeringSource`, so gap 1's parser lift is not exercised). +#[test] +fn lux_artillery_grants_sunburst_two_colors_enters_with_two_p1p1() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let lux = scenario + .add_creature_from_oracle(P0, "Lux Artillery", 0, 0, LUX_ARTILLERY_ORACLE) + .id(); + + let spell = scenario + .add_creature_to_hand_from_oracle(P0, "Test Automaton", 0, 0, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Red, ManaCostShard::Green], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, lux); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact, CoreType::Creature]; + obj.base_card_types = obj.card_types.clone(); + } + + add_mana(&mut runner, ManaType::Red, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 2, + "Lux Artillery's granted sunburst on a 2-color cast must place 2 +1/+1 counters" + ); + assert_eq!(outcome.zone_of(spell), Zone::Battlefield); +} + +/// Negative (CR 702.44b): a granted-sunburst artifact cast paying ZERO colored +/// mana (all generic/colorless) must enter with NO counters. +#[test] +fn solar_array_granted_sunburst_zero_colors_places_no_counters() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Test Colorless Relic", false, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![], + generic: 2, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.base_card_types = obj.card_types.clone(); + } + + arm_solar_array(&mut runner, solar); + // Two colorless units fund the {2} generic cost — no colored mana spent. + add_mana(&mut runner, ManaType::Colorless, 2); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + // Reach-guard: the spell resolved (so the replacement pipeline WAS consulted + // for its battlefield entry), yet zero colors were spent. + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the colorless-cast granted-sunburst permanent must have resolved" + ); + assert_eq!( + counters_of(&runner_after, spell, &charge()), + 0, + "zero colors of mana spent means zero charge counters (CR 702.44b)" + ); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 0, + "zero colors of mana spent means zero +1/+1 counters" + ); + let _ = ManaColor::White; // keep the ManaColor import honest across cfgs +} + +/// Printed-sunburst control (CR 702.44a/b): the pre-synthesized object-carried +/// replacement path is untouched by the granted-instance virtual candidate — +/// a PRINTED-sunburst noncreature artifact cast for three colors still enters +/// with three charge counters. +#[test] +fn printed_sunburst_control_three_colors_enters_with_three_charge() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + // Explicit keyword hint: the bare "Sunburst (reminder)" line needs the + // MTGJSON-style keyword name for the scenario's keyword-line detection, + // which feeds `synthesize_all` (the printed ETB-replacement synthesis). + let spell = { + let mut b = scenario.add_spell_to_hand(P0, "Printed Relic", false); + b.from_oracle_text_with_keywords( + &["Sunburst"], + "Sunburst (This enters with a charge counter on it for each color of mana spent to cast it.)", + ); + b.with_mana_cost(ManaCost::Cost { + shards: vec![ + ManaCostShard::White, + ManaCostShard::Blue, + ManaCostShard::Black, + ], + generic: 0, + }) + .id() + }; + + let mut runner = scenario.build(); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.base_card_types = obj.card_types.clone(); + } + + add_mana(&mut runner, ManaType::White, 1); + add_mana(&mut runner, ManaType::Blue, 1); + add_mana(&mut runner, ManaType::Black, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!(outcome.zone_of(spell), Zone::Battlefield); + assert_eq!( + counters_of(&runner_after, spell, &charge()), + 3, + "printed sunburst cast for 3 colors must enter with 3 charge counters (control)" + ); +} + +/// CR 702.44d: "If an object has multiple instances of sunburst, each one works +/// separately." A PRINTED-sunburst artifact that is ALSO granted sunburst by +/// Solar Array must place its as-enters counters TWICE — once via the +/// object-carried printed replacement, once via the granted-instance virtual +/// candidate (which counts only the granted surplus, so nothing is lost or +/// double-counted on either side). +#[test] +fn printed_plus_granted_sunburst_each_apply_separately() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + // Explicit keyword hint — see the printed-control test above. + let spell = { + let mut b = scenario.add_spell_to_hand(P0, "Printed Relic", false); + b.from_oracle_text_with_keywords( + &["Sunburst"], + "Sunburst (This enters with a charge counter on it for each color of mana spent to cast it.)", + ); + b.with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::White, ManaCostShard::Green], + generic: 0, + }) + .id() + }; + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.base_card_types = obj.card_types.clone(); + } + + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::White, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!(outcome.zone_of(spell), Zone::Battlefield); + assert_eq!( + counters_of(&runner_after, spell, &charge()), + 4, + "printed + granted sunburst on a 2-color cast must each place 2 charge counters (CR 702.44d: 2+2=4)" + ); +} + +/// CR 616.1: the granted-instance virtual candidate participates in the normal +/// replacement-ordering pipeline — a Doubling Season-class AddCounter doubler +/// doubles the granted sunburst's placement (2 colors → 2 counters → 4). +#[test] +fn granted_sunburst_participates_in_counter_doubling() { + use engine::types::ability::QuantityModification; + use engine::types::replacements::ReplacementEvent; + use engine::types::ReplacementDefinition; + + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + let doubler = scenario.add_creature(P0, "Doubling Season", 0, 3).id(); + let spell = scenario + .add_creature_to_hand_from_oracle(P0, "Test Golem", 0, 0, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Red, ManaCostShard::Green], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact, CoreType::Creature]; + obj.base_card_types = obj.card_types.clone(); + } + // CR 614.1a: a Doubling Season-class counter-doubling replacement. + { + let repl = ReplacementDefinition::new(ReplacementEvent::AddCounter) + .quantity_modification(QuantityModification::DOUBLE); + runner + .state_mut() + .objects + .get_mut(&doubler) + .unwrap() + .replacement_definitions + .push(repl); + } + + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::Red, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!(outcome.zone_of(spell), Zone::Battlefield); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 4, + "granted sunburst (2 colors) under a counter doubler must enter with 4 +1/+1 counters (CR 616.1)" + ); +} + +/// #5802 review (CR 616.1e): the granted-sunburst virtual candidate is a +/// counter-payload WRITE (`Writes { Count, Additive }`), not `Disjoint` — when a +/// same-event Count writer co-fires on the entering spell's ZoneChange, the +/// affected controller's ordering choice MUST surface. Reverting the +/// classification to `Disjoint` suppresses the prompt and this test fails. +/// +/// Both legal orderings are driven end-to-end. NOTE on outcomes: in the current +/// engine the two orders converge (2 counters each) because a bare +/// `quantity_modification` on a `Moved`-keyed definition has no ZoneChange +/// counter-payload applier yet — the functioning Doubling Season path scales the +/// downstream AddCounter placement instead (covered by +/// `granted_sunburst_participates_in_counter_doubling`, which asserts 4). The +/// assertions below pin (a) the prompt surfacing with both candidates, (b) both +/// orders being drivable to a clean entry, and (c) the granted payload surviving +/// either order — so if a ZoneChange payload applier lands later, only the +/// counter totals need updating (4 for sunburst-first, 2 for writer-first), not +/// the ordering machinery. +fn drive_sunburst_vs_count_writer_ordering(pick_sunburst_first: bool) -> u32 { + use engine::types::ability::QuantityModification; + use engine::types::actions::GameAction; + use engine::types::game_state::WaitingFor; + use engine::types::replacements::ReplacementEvent; + use engine::types::ReplacementDefinition; + + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + let writer = scenario.add_creature(P0, "Entry Count Writer", 0, 3).id(); + let spell = scenario + .add_creature_to_hand_from_oracle(P0, "Test Golem", 0, 0, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Red, ManaCostShard::Green], + generic: 0, + }) + .id(); + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact, CoreType::Creature]; + obj.base_card_types = obj.card_types.clone(); + } + // A same-event Count writer on the entering spell's ZoneChange (the + // Moved-keyed quantity-modification shape from the reclassified pair). + { + let repl = ReplacementDefinition::new(ReplacementEvent::Moved) + .quantity_modification(QuantityModification::DOUBLE); + runner + .state_mut() + .objects + .get_mut(&writer) + .unwrap() + .replacement_definitions + .push(repl); + } + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::Red, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let commit = runner.cast(spell).commit(); + let mut r2 = GameRunner::from_state(commit.state().clone()); + let mut saw_ordering_prompt = false; + for _ in 0..30 { + match r2.state().waiting_for.clone() { + WaitingFor::Priority { .. } => { + if r2.act(GameAction::PassPriority).is_err() { + break; + } + } + WaitingFor::ReplacementChoice { candidates, .. } => { + // CR 616.1e revert-canary: BOTH candidates must be offered + // together — a `Disjoint` classification auto-applies the + // sunburst appender and never surfaces this prompt. + if candidates.len() == 2 { + saw_ordering_prompt = true; + let sunburst_idx = candidates + .iter() + .position(|c| c.description.contains("Sunburst")) + .expect("sunburst candidate must be listed"); + let writer_idx = 1 - sunburst_idx; + let idx = if pick_sunburst_first { + sunburst_idx + } else { + writer_idx + }; + r2.act(GameAction::ChooseReplacement { index: idx }) + .expect("ordering choice must be accepted"); + } else { + r2.act(GameAction::ChooseReplacement { index: 0 }) + .expect("remaining replacement choice must be accepted"); + } + } + other => panic!("unexpected waiting state during entry: {other:?}"), + } + let done = { + let st = r2.state(); + st.objects + .get(&spell) + .is_some_and(|o| o.zone == Zone::Battlefield) + }; + if done { + break; + } + } + assert!( + saw_ordering_prompt, + "the CR 616.1e ordering prompt must surface for the co-firing counter-payload writers" + ); + let o = r2.state().objects.get(&spell).unwrap(); + assert_eq!(o.zone, Zone::Battlefield, "the spell must finish entering"); + o.counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0) +} + +#[test] +fn granted_sunburst_ordering_choice_sunburst_first() { + let counters = drive_sunburst_vs_count_writer_ordering(true); + assert_eq!( + counters, 2, + "sunburst-first: the granted payload (2 colors) must survive the ordering pass" + ); +} + +#[test] +fn granted_sunburst_ordering_choice_count_writer_first() { + let counters = drive_sunburst_vs_count_writer_ordering(false); + assert_eq!( + counters, 2, + "writer-first: the granted payload must still be appended after the writer applies" + ); +} diff --git a/crates/engine/tests/integration/issue_4240_damaged_player_anaphor_runtime.rs b/crates/engine/tests/integration/issue_4240_damaged_player_anaphor_runtime.rs new file mode 100644 index 0000000000..a2c8f18e71 --- /dev/null +++ b/crates/engine/tests/integration/issue_4240_damaged_player_anaphor_runtime.rs @@ -0,0 +1,291 @@ +//! Runtime coverage for the PR #6540 parse-diff set (issue #4240). +//! +//! The parser fix that taught `parse_damage_source_subject` the article-less +//! Aura/Equipment determiners ("enchanted creature" / "equipped creature") +//! changed six cards' parse output. Two moved in the TARGET position +//! (Sigil of Sleep, Hammer of Ruin) and four in the QUANTITY position +//! (Quietus Spike, Scytheclaw, Sword of War and Peace, Sword of Fire and Ice +//! and War and Peace). Every one of them resolves the same anaphor: the +//! `"that player"` / `"their"` in the effect body is the player the equipped +//! or enchanted creature just damaged. +//! +//! CR 120.3a + CR 301.5a + CR 603.2: damage dealt to a player by the equipped +//! creature (CR 301.5a defines "equipped creature") identifies that player as +//! the triggering event's player (CR 603.2), so `"that player"` / `"their"` +//! binds to the DAMAGED player — never to the Equipment's controller. +//! +//! Every test is deliberately DISCRIMINATING: the two players are given +//! different life totals / hand sizes, so binding the anaphor to the +//! controller instead of the damaged player would produce a different, +//! asserted-against number. Sigil of Sleep's own regression lives in +//! `issue_4240_sigil_of_sleep_target_scope`. +//! +//! # The two classes of parse-diff change, and what these tests prove +//! +//! Running this module against the parser fix REVERTED separates them: +//! +//! * **Behavioral (regression tests — they FAIL when the fix is reverted).** +//! `hammer_of_ruin_*` fails with `target_slots.len() == 2`: the pre-fix +//! `ControllerRef::TargetPlayer` binding surfaces a phantom companion player +//! slot, which is the softlock reported in issue #4240. +//! +//! * **Representation-only (invariance tests — they PASS either way).** +//! `quietus_spike_*`, `scytheclaw_*` and `sword_of_war_and_peace_*` pass +//! identically with and without the fix. During trigger resolution +//! `QuantityContext::scoped_player` is already bound to the damaged player, +//! so the pre-fix `PlayerScope::ScopedPlayer` and the post-fix +//! `PlayerScope::Target` resolve to the SAME player. Their entries in the +//! PR's `coverage-parse-diff` are therefore a change of representation with +//! no change in behavior. These tests are kept precisely to pin that +//! invariance: they are the evidence that the shared-grammar edit did not +//! alter what these cards do. + +use engine::game::effects::attach::attach_to; +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +use super::rules::run_combat; + +const HAMMER_OF_RUIN_ORACLE: &str = "Whenever equipped creature deals combat damage to a player, you may destroy target Equipment that player controls."; + +/// Quietus Spike and Scytheclaw print the identical triggered ability. +const HALF_LIFE_ORACLE: &str = "Whenever equipped creature deals combat damage to a player, that player loses half their life, rounded up."; + +const SWORD_OF_WAR_AND_PEACE_ORACLE: &str = "Whenever equipped creature deals combat damage to a player, this Equipment deals damage to that player equal to the number of cards in their hand and you gain 1 life for each card in your hand."; + +/// Drive the pending combat-damage trigger to full resolution. +/// +/// Handles the two interactive windows a `"you may … target …"` trigger can +/// raise: CR 603.3d target declaration (skipped by the engine when exactly one +/// legal target exists) and the CR 603.3c optional decision, which is accepted +/// so the destroy actually happens. Returns the legal targets observed at the +/// target-selection window, if one was presented. +fn resolve_trigger(runner: &mut GameRunner) -> Option> { + let mut observed: Option> = None; + for _ in 0..30 { + match &runner.state().waiting_for { + WaitingFor::TriggerTargetSelection { target_slots, .. } => { + let slots = target_slots.clone(); + assert_eq!( + slots.len(), + 1, + "the trigger must create exactly one target slot, not a phantom player slot" + ); + let chosen = slots[0] + .legal_targets + .first() + .cloned() + .expect("the trigger must have at least one legal target"); + observed = Some(slots[0].legal_targets.clone()); + runner + .act(GameAction::SelectTargets { + targets: vec![chosen], + }) + .expect("selecting the only legal target must succeed"); + } + WaitingFor::OptionalEffectChoice { .. } => { + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("accepting the optional trigger must let it resolve"); + } + WaitingFor::Priority { .. } => { + if runner.state().stack.is_empty() { + return observed; + } + runner + .act(GameAction::PassPriority) + .expect("passing priority must advance the combat-damage trigger"); + } + other => panic!("unexpected state while resolving the trigger: {other:?}"), + } + } + panic!("trigger never finished resolving"); +} + +/// CR 301.5a + CR 603.2: "target Equipment **that player** controls" must offer +/// only Equipment controlled by the DAMAGED player. Before the fix the anaphor +/// bound to `ControllerRef::TargetPlayer`, which surfaces a phantom companion +/// player slot and admits the wrong controller's permanents. +#[test] +fn hammer_of_ruin_targets_only_the_damaged_players_equipment() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let equipped_creature = scenario.add_creature(P0, "Hammer Bearer", 2, 2).id(); + let hammer = scenario + .add_creature(P0, "Hammer of Ruin", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .from_oracle_text(HAMMER_OF_RUIN_ORACLE) + .id(); + let damaged_players_equipment = scenario + .add_creature(P1, "Damaged Player's Equipment", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .id(); + let controllers_equipment = scenario + .add_creature(P0, "Controller's Equipment", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), hammer, equipped_creature); + evaluate_layers(runner.state_mut()); + + run_combat(&mut runner, vec![equipped_creature], vec![]); + let observed_targets = resolve_trigger(&mut runner); + + // When a target-selection window is presented, the damaged player's + // Equipment must be the ONLY legal choice. (With a single legal target the + // engine may auto-select and skip the window entirely, so this is asserted + // conditionally; the destroyed-zone assertions below are unconditional.) + if let Some(legal) = observed_targets { + assert_eq!( + legal, + vec![TargetRef::Object(damaged_players_equipment)], + "only the damaged player's Equipment may be targeted" + ); + assert!( + !legal.contains(&TargetRef::Object(controllers_equipment)), + "the controller's own Equipment must not be legal for 'that player controls'" + ); + } + + // Behavioral discriminator: a controller-bound anaphor would have destroyed + // the Equipment controller's own Equipment (or the Hammer) instead. + assert_eq!( + runner.state().objects[&damaged_players_equipment].zone, + Zone::Graveyard, + "Hammer of Ruin must destroy the DAMAGED player's Equipment" + ); + assert_eq!( + runner.state().objects[&controllers_equipment].zone, + Zone::Battlefield, + "the Equipment controller's own Equipment must survive" + ); + assert_eq!( + runner.state().objects[&hammer].zone, + Zone::Battlefield, + "Hammer of Ruin must not destroy itself" + ); +} + +/// Shared driver for the Quietus Spike / Scytheclaw printed ability. +/// +/// CR 119.3 + CR 603.2: "that player loses half **their** life" halves the +/// DAMAGED player's life. Controller life is set far apart from the damaged +/// player's so a controller-bound anaphor yields a different total. +/// +/// INVARIANCE test (see module docs): this passes both with and without the +/// parser fix, because `scoped_player` is already the damaged player during +/// trigger resolution. It pins that the PR's parse-diff entry for these cards +/// is representation-only and changes no behavior. +fn assert_half_life_uses_damaged_player(card_name: &str) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // Deliberately divergent: half of the controller's 40 is 20, half of the + // damaged player's post-combat 28 is 14 — the two readings cannot collide. + scenario.with_life(P0, 40); + scenario.with_life(P1, 30); + + let equipped_creature = scenario.add_creature(P0, "Spike Bearer", 2, 2).id(); + let equipment = scenario + .add_creature(P0, card_name, 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .from_oracle_text(HALF_LIFE_ORACLE) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), equipment, equipped_creature); + evaluate_layers(runner.state_mut()); + + run_combat(&mut runner, vec![equipped_creature], vec![]); + runner.advance_until_stack_empty(); + + // 30 life − 2 combat damage = 28; half of 28 rounded up = 14 lost → 14 left. + // A controller-bound reading would halve 40 → lose 20 → 8 left. + let damaged_life = runner.state().players[P1.0 as usize].life; + assert_eq!( + damaged_life, 14, + "{card_name} must halve the DAMAGED player's life (28 → lose 14 → 14); \ + got {damaged_life} (8 would mean it halved the Equipment controller's 40)" + ); + assert_eq!( + runner.state().players[P0.0 as usize].life, + 40, + "{card_name} must not touch the Equipment controller's life" + ); +} + +#[test] +fn quietus_spike_halves_the_damaged_players_life() { + assert_half_life_uses_damaged_player("Quietus Spike"); +} + +#[test] +fn scytheclaw_halves_the_damaged_players_life() { + assert_half_life_uses_damaged_player("Scytheclaw"); +} + +/// CR 120.3a + CR 603.2: Sword of War and Peace deals damage equal to the +/// number of cards in **their** hand (the damaged player's), while the life +/// gain counts **your** hand (the controller's). Asserting both halves in one +/// test proves the damaged-player and controller-side references stay +/// correctly distinct. +/// +/// INVARIANCE test (see module docs): passes with and without the parser fix. +/// It pins that the PR's `DealDamage.amount` parse-diff entry for this card +/// (and for Sword of Fire and Ice and War and Peace, which prints the same +/// "cards in their hand" clause) is representation-only. +#[test] +fn sword_of_war_and_peace_damage_uses_damaged_players_hand_and_life_gain_uses_yours() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_life(P0, 20); + scenario.with_life(P1, 20); + + // Divergent hand sizes: damaged player 5, controller 2. + for i in 0..5 { + scenario.add_card_to_hand(P1, &format!("Damaged Player Card {i}")); + } + for i in 0..2 { + scenario.add_card_to_hand(P0, &format!("Controller Card {i}")); + } + + let equipped_creature = scenario.add_creature(P0, "Sword Bearer", 1, 1).id(); + let sword = scenario + .add_creature(P0, "Sword of War and Peace", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .from_oracle_text(SWORD_OF_WAR_AND_PEACE_ORACLE) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), sword, equipped_creature); + evaluate_layers(runner.state_mut()); + + run_combat(&mut runner, vec![equipped_creature], vec![]); + runner.advance_until_stack_empty(); + + // 20 − 1 combat − 5 (damaged player's hand) = 14. + // A controller-bound reading would deal 2 instead of 5 → 17. + let damaged_life = runner.state().players[P1.0 as usize].life; + assert_eq!( + damaged_life, 14, + "damage must equal the DAMAGED player's hand size (5), leaving 14; \ + got {damaged_life} (17 would mean it counted the controller's 2-card hand)" + ); + // Life gain still counts the CONTROLLER's hand (2) — unchanged by the fix. + assert_eq!( + runner.state().players[P0.0 as usize].life, + 22, + "life gain must count the controller's own hand (2)" + ); +} diff --git a/crates/engine/tests/integration/issue_4240_sigil_of_sleep_target_scope.rs b/crates/engine/tests/integration/issue_4240_sigil_of_sleep_target_scope.rs new file mode 100644 index 0000000000..e181b31411 --- /dev/null +++ b/crates/engine/tests/integration/issue_4240_sigil_of_sleep_target_scope.rs @@ -0,0 +1,99 @@ +//! Regression for issue #4240: Sigil of Sleep must create only its printed +//! creature target when the enchanted creature damages a player. +//! +//! CR 303.4b + CR 603.2 + CR 115.1d: an Aura's enchanted creature deals the +//! triggering damage; the triggered ability then receives its single printed +//! target as it is put on the stack. + +use engine::game::effects::attach::attach_to; +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +use super::rules::run_combat; + +const SIGIL_OF_SLEEP_ORACLE: &str = "Whenever enchanted creature deals damage to a player, return target creature that player controls to its owner's hand."; + +#[test] +fn sigil_of_sleep_uses_damaged_player_for_its_single_creature_target() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let enchanted_creature = scenario.add_creature(P0, "Sigil Bearer", 2, 2).id(); + let sigil = scenario + .add_creature(P0, "Sigil of Sleep", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(SIGIL_OF_SLEEP_ORACLE) + .id(); + let damaged_players_creature = scenario + .add_creature(P1, "Damaged Player's Creature", 2, 2) + .id(); + let second_damaged_creature = scenario + .add_creature(P1, "Damaged Player's Second Creature", 3, 3) + .id(); + let controller_creature = scenario + .add_creature(P0, "Controller's Creature", 2, 2) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), sigil, enchanted_creature); + evaluate_layers(runner.state_mut()); + + run_combat(&mut runner, vec![enchanted_creature], vec![]); + + for _ in 0..30 { + match runner.state().waiting_for { + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("passing priority must advance Sigil's trigger"); + } + WaitingFor::TriggerTargetSelection { .. } => break, + ref other => { + panic!("Sigil's combat-damage trigger must reach target selection, got {other:?}") + } + } + } + + let target_slots = match &runner.state().waiting_for { + WaitingFor::TriggerTargetSelection { target_slots, .. } => target_slots, + other => panic!("Sigil's combat-damage trigger must reach target selection, got {other:?}"), + }; + assert_eq!( + target_slots.len(), + 1, + "Sigil must create one creature target slot, not a phantom player slot" + ); + assert_eq!( + target_slots[0].legal_targets, + vec![ + TargetRef::Object(damaged_players_creature), + TargetRef::Object(second_damaged_creature), + ], + "every legal target must be a creature controlled by the damaged player" + ); + assert!( + !target_slots[0] + .legal_targets + .contains(&TargetRef::Object(controller_creature)), + "the Aura controller's creature must not be legal for 'that player controls'" + ); + + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(damaged_players_creature)], + }) + .expect("choosing Sigil's only legal target must succeed"); + runner.advance_until_stack_empty(); + + assert_eq!( + runner.state().objects[&damaged_players_creature].zone, + Zone::Hand, + "Sigil must return the damaged player's chosen creature to its owner's hand" + ); +} diff --git a/crates/engine/tests/integration/issue_5976_unearth_leave_redirect.rs b/crates/engine/tests/integration/issue_5976_unearth_leave_redirect.rs new file mode 100644 index 0000000000..c937b77ca9 --- /dev/null +++ b/crates/engine/tests/integration/issue_5976_unearth_leave_redirect.rs @@ -0,0 +1,584 @@ +//! GitHub issue #5976 — an unearthed creature's "if it would leave the +//! battlefield, exile it instead of putting it anywhere else" replacement +//! (CR 702.84a) is lost after a layer reseed, so sacrificing / destroying / +//! bouncing the creature sends it to the wrong zone instead of exile. +//! +//! CR 702.84a installs a `Moved` replacement on the returned permanent. It is +//! pushed onto the object's LIVE `replacement_definitions`, but every layer +//! pass reseeds live defs from `base_replacement_definitions` +//! (`seed_live_characteristics_from_base` in layers.rs — CR 613.1). Because the +//! rider is not written to base, the first full layer evaluation after the +//! return silently drops it, and the redirect (looked up via the host object's +//! live `replacement_definitions` in replacement.rs `moved_applier`) never +//! fires. The fix stamps the rider with +//! `RestrictionExpiry::UntilHostLeavesPlay` and installs it to base (surviving +//! the reseed, CR 613.1), non-copiable (CR 707.2), and pruned on host exit +//! (CR 400.7). +//! +//! Every row drives the REAL pipeline: activate Unearth, resolve, run a genuine +//! layer pass (`evaluate_layers`) between install and the leave event — the +//! exact reseed that used to wipe the rider — then trigger the leave through a +//! real sacrifice / destroy / bounce effect and assert the object lands in +//! exile. Reverting the base-install (and expiry stamp) flips R1/R3/R4/R8/R9 +//! back to graveyard/hand. + +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::zones::move_to_zone; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::events::GameEvent; +use engine::types::game_state::{PayCostKind, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +// Verbatim keyword line (reminder text is stripped by MTGJSON before the parser +// sees it, exactly as the runtime card presents). Rotting Rats is a 2/2. +const ROTTING_RATS: &str = + "When this creature enters, each player discards a card.\nUnearth {1}{B}"; + +// Verbatim Oracle text of the real cards used as leave-event outlets. +const SACRIFICE: &str = "As an additional cost to cast this spell, sacrifice a creature.\n\ + Add an amount of {B} equal to the sacrificed creature's mana value."; +const GRUESOME_ENCORE: &str = "Put target creature card from an opponent's graveyard onto the \ + battlefield under your control. It gains haste. Exile it at the beginning of the next end step. \ + If that creature would leave the battlefield, exile it instead of putting it anywhere else."; +const DESTROY_TARGET: &str = "Destroy target creature."; +const BOUNCE_TARGET: &str = "Return target creature to its owner's hand."; +// Real card Goblin Bombardment — a proven activated sacrifice-cost outlet +// (see ajani_nacatl_pariah_sacrifice_outlet_6018). Its "any target" damage +// gives a clean reach guard that the ability actually resolved. +const GOBLIN_BOMBARDMENT: &str = + "Sacrifice a creature: This enchantment deals 1 damage to any target."; + +// --------------------------------------------------------------------------- +// Shared harness helpers +// --------------------------------------------------------------------------- + +/// Stage a Rotting-Rats-shape unearth creature in P0's graveyard and fund the +/// {1}{B} activation. Both hands stay empty so the verbatim ETB "each player +/// discards a card" resolves as a harmless no-op at return time (nothing to +/// discard); the leave-event outlet is introduced only AFTER the return. +fn stage_rats(scenario: &mut GameScenario) -> ObjectId { + scenario.at_phase(Phase::PreCombatMain); + let rats = scenario + .add_creature_to_graveyard(P0, "Rotting Rats", 2, 2) + .from_oracle_text(ROTTING_RATS) + .id(); + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Colorless, rats, false, vec![]), + ManaUnit::new(ManaType::Black, rats, false, vec![]), + ], + ); + rats +} + +/// Activate the synthesized Unearth ability (the object's only activated +/// ability, index 0), resolve it, and then run a full layer pass — the CR 613.1 +/// reseed that used to wipe the live-only rider. Returns with the rider on the +/// battlefield, ready for a leave event. +fn unearth(runner: &mut GameRunner, rats: ObjectId) { + let outcome = runner.activate(rats, 0).resolve(); + assert_eq!( + outcome.zone_of(rats), + Zone::Battlefield, + "precondition: Unearth returns Rotting Rats to the battlefield (same ObjectId)" + ); + // The genuine layer pass between install and leave. Before the fix this + // reseed drops the live-only rider (CR 613.1); after the fix the base copy + // repopulates live so the redirect survives. + evaluate_layers(runner.state_mut()); +} + +/// Fund `player`'s mana pool post-build (the unearth activation drained the +/// staged pool). Provenance ObjectId is cosmetic for pool accounting. +fn fund(runner: &mut GameRunner, player: PlayerId, colors: &[ManaType], provenance: ObjectId) { + for &c in colors { + let _ = runner + .state_mut() + .add_mana_to_pool(player, ManaUnit::new(c, provenance, false, vec![])); + } +} + +/// Move a spell staged in the library into `player`'s hand after the return, so +/// the ETB discard never sees it. +fn to_hand(runner: &mut GameRunner, spell: ObjectId) { + move_to_zone(runner.state_mut(), spell, Zone::Hand, &mut Vec::new()); +} + +fn sacrificed(events: &[GameEvent], id: ObjectId) -> bool { + events + .iter() + .any(|e| matches!(e, GameEvent::PermanentSacrificed { object_id, .. } if *object_id == id)) +} + +// --------------------------------------------------------------------------- +// R1 — the reported bug: sacrifice as an additional cost of the real +// "Sacrifice" card. rats must land in EXILE, not the graveyard. +// --------------------------------------------------------------------------- + +#[test] +fn r1_sacrifice_additional_cost_redirects_unearthed_rats_to_exile() { + let mut scenario = GameScenario::new(); + let rats = stage_rats(&mut scenario); + // Staged in the library, moved to hand after the return (keeps the ETB + // discard a no-op). The real "Sacrifice" card carries the + // additional-cost-sacrifice clause verbatim. + let sac = scenario + .add_spell_to_library_top(P0, "Sacrifice", true) + .from_oracle_text(SACRIFICE) + .id(); + let mut runner = scenario.build(); + + unearth(&mut runner, rats); + to_hand(&mut runner, sac); + fund(&mut runner, P0, &[ManaType::Black], rats); + + let outcome = runner.cast(sac).sacrifice_with(&[rats]).resolve(); + + // Reach guard: the sacrifice actually happened (not short-circuited on a + // cost failure) — CR 701.21a. + assert!( + sacrificed(outcome.events(), rats), + "reach guard: Rotting Rats must be sacrificed as the additional cost \ + (CR 701.21a); events: {:?}", + outcome.events() + ); + // The load-bearing assertion (flips to Graveyard when the base-install is + // reverted): CR 702.84a redirect to exile. + assert_eq!( + outcome.zone_of(rats), + Zone::Exile, + "issue #5976: an unearthed creature sacrificed to a cost must be EXILED \ + (CR 702.84a), not put into the graveyard" + ); +} + +// --------------------------------------------------------------------------- +// R2 — ability-cost sacrifice outlet ("Sacrifice a creature: You gain 1 life."). +// --------------------------------------------------------------------------- + +#[test] +fn r2_ability_cost_sacrifice_outlet_redirects_to_exile() { + let mut scenario = GameScenario::new(); + let rats = stage_rats(&mut scenario); + // A durable (2-toughness) outlet enchantment already on the battlefield. + let outlet = scenario + .add_creature(P0, "Goblin Bombardment", 0, 0) + .as_enchantment() + .from_oracle_text(GOBLIN_BOMBARDMENT) + .id(); + let mut runner = scenario.build(); + + unearth(&mut runner, rats); + let p1_life_before = runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .unwrap() + .life; + + // Activate the outlet; drive the sacrifice cost and the damage target by + // hand so we observe the PayCost::Sacrifice window directly (CR 602.1b). + runner + .act(GameAction::ActivateAbility { + source_id: outlet, + ability_index: 0, + }) + .expect("activating Goblin Bombardment must succeed"); + + let mut sacrificed_rats = false; + let mut targeted = false; + for _ in 0..40 { + match runner.state().waiting_for.clone() { + WaitingFor::PayCost { + kind: PayCostKind::Sacrifice, + choices, + .. + } => { + assert!( + choices.contains(&rats), + "the unearthed rats must be a legal sacrifice, choices: {choices:?}" + ); + runner + .act(GameAction::SelectCards { cards: vec![rats] }) + .expect("sacrificing rats to the ability cost must succeed"); + sacrificed_rats = true; + } + WaitingFor::TargetSelection { .. } => { + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Player(P1)], + }) + .expect("targeting the opponent must succeed"); + targeted = true; + } + _ => { + runner.advance_until_stack_empty(); + if matches!(runner.state().waiting_for, WaitingFor::Priority { .. }) + && runner.state().stack.is_empty() + { + break; + } + } + } + } + + // Reach guards: the sacrifice cost was actually paid with rats, and the + // ability resolved (1 damage to P1) — CR 601.2b / CR 602.1b. + assert!(sacrificed_rats, "the sacrifice cost must be paid with rats"); + assert!(targeted, "the outlet's damage must select a target"); + let p1_life_after = runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .unwrap() + .life; + assert_eq!( + p1_life_after, + p1_life_before - 1, + "reach guard: the outlet must resolve and deal 1 damage to P1" + ); + + assert_eq!( + runner.state().objects[&rats].zone, + Zone::Exile, + "issue #5976: rats sacrificed to an ability cost must be EXILED (CR 702.84a)" + ); +} + +// --------------------------------------------------------------------------- +// R3 — destruction (a different leave event, CR 702.84a "ANY" exit). +// --------------------------------------------------------------------------- + +#[test] +fn r3_destroy_redirects_unearthed_rats_to_exile() { + let mut scenario = GameScenario::new(); + let rats = stage_rats(&mut scenario); + let destroy = scenario + .add_spell_to_library_top(P0, "Doom Blade", false) + .from_oracle_text(DESTROY_TARGET) + .id(); + let mut runner = scenario.build(); + + unearth(&mut runner, rats); + to_hand(&mut runner, destroy); + fund(&mut runner, P0, &[ManaType::Black], rats); + + let outcome = runner.cast(destroy).target_objects(&[rats]).resolve(); + + assert_eq!( + outcome.zone_of(rats), + Zone::Exile, + "issue #5976: a destroyed unearthed creature must be EXILED (CR 702.84a), \ + not put into the graveyard" + ); +} + +// --------------------------------------------------------------------------- +// R4 — bounce ("Return to its owner's hand" is a battlefield exit too; +// CR 702.84a redirects ANY leave to exile). Hand must stay unchanged. +// --------------------------------------------------------------------------- + +#[test] +fn r4_bounce_redirects_unearthed_rats_to_exile_hand_unchanged() { + let mut scenario = GameScenario::new(); + let rats = stage_rats(&mut scenario); + let bounce = scenario + .add_spell_to_library_top(P0, "Unsummon", true) + .from_oracle_text(BOUNCE_TARGET) + .id(); + let mut runner = scenario.build(); + + unearth(&mut runner, rats); + to_hand(&mut runner, bounce); + fund(&mut runner, P0, &[ManaType::Blue], rats); + + let outcome = runner.cast(bounce).target_objects(&[rats]).resolve(); + + // Reach guard: the bounce spell resolved cleanly (CR 608.2m) — the pipeline + // is back at a Priority window, not stuck on a target/cost prompt. + assert!( + matches!( + outcome.final_waiting_for(), + engine::types::game_state::WaitingFor::Priority { .. } + ), + "reach guard: the bounce must resolve cleanly, halted at {:?}", + outcome.final_waiting_for() + ); + assert_eq!( + outcome.zone_of(rats), + Zone::Exile, + "issue #5976: an unearthed creature returned to hand must be EXILED instead \ + (CR 702.84a — ANY leave event)" + ); + // CR 702.84a: it is exiled INSTEAD of going to hand — the owner's hand must + // not gain the card. + let owner_hand = &outcome + .state() + .players + .iter() + .find(|p| p.id == P0) + .expect("P0 exists") + .hand; + assert!( + !owner_hand.contains(&rats), + "the card must not reach its owner's hand — it is exiled instead" + ); +} + +// --------------------------------------------------------------------------- +// R5 — sibling: a Rotting Rats that was NEVER unearthed carries no rider, so a +// sacrifice sends it to the GRAVEYARD. Proves the rider is installed by the +// Unearth ability resolving, not intrinsic to the card shape. +// --------------------------------------------------------------------------- + +#[test] +fn r5_non_unearthed_rats_sacrifice_goes_to_graveyard() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // Same-name, same Oracle text, but placed directly on the battlefield (no + // Unearth activation), so no leaves-battlefield rider is installed. + let rats = scenario + .add_creature_from_oracle(P0, "Rotting Rats", 2, 2, ROTTING_RATS) + .id(); + let sac = scenario + .add_spell_to_hand_from_oracle(P0, "Sacrifice", true, SACRIFICE) + .id(); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new(ManaType::Black, rats, false, vec![])], + ); + let mut runner = scenario.build(); + + let outcome = runner.cast(sac).sacrifice_with(&[rats]).resolve(); + + assert!( + sacrificed(outcome.events(), rats), + "reach guard: the sibling rats was sacrificed; events: {:?}", + outcome.events() + ); + assert_eq!( + outcome.zone_of(rats), + Zone::Graveyard, + "a non-unearthed Rotting Rats has no exile rider — sacrifice goes to the graveyard" + ); +} + +// --------------------------------------------------------------------------- +// R7 — host-exit prune + no revival on same-ObjectId re-entry (CR 400.7). +// A first destroy exiles rats via the rider; the prune then removes the rider +// from base AND live; after a harness re-entry (same ObjectId), a second +// destroy must go to the GRAVEYARD (no revived rider). +// --------------------------------------------------------------------------- + +#[test] +fn r7_host_exit_prunes_rider_no_revival_on_reentry() { + let mut scenario = GameScenario::new(); + let rats = stage_rats(&mut scenario); + let destroy1 = scenario + .add_spell_to_library_top(P0, "Doom Blade", false) + .from_oracle_text(DESTROY_TARGET) + .id(); + let destroy2 = scenario + .add_spell_to_library_top(P0, "Terror", false) + .from_oracle_text(DESTROY_TARGET) + .id(); + let mut runner = scenario.build(); + + unearth(&mut runner, rats); + to_hand(&mut runner, destroy1); + fund(&mut runner, P0, &[ManaType::Black], rats); + + // First leave event: redirected to exile by the rider. + let outcome = runner.cast(destroy1).target_objects(&[rats]).resolve(); + assert_eq!( + outcome.zone_of(rats), + Zone::Exile, + "first destroy is redirected to exile by the rider" + ); + + // Prune reach-guard (CR 400.7): the rider must be gone from BOTH base and + // live after the host left the battlefield — otherwise it could revive. + { + let obj = &runner.state().objects[&rats]; + assert!( + obj.base_replacement_definitions.is_empty(), + "CR 400.7: the host-lifetime rider must be pruned from base on exit, got {:?}", + obj.base_replacement_definitions + ); + assert_eq!( + obj.replacement_definitions.len(), + 0, + "CR 400.7: the rider must be pruned from live defs on exit" + ); + } + + // Re-enter the SAME ObjectId via a legitimate harness zone move (the hazard + // under test is ObjectId persistence across a CR 400.7 boundary, not the + // re-entry route). A fresh object must NOT inherit the pruned rider. + move_to_zone(runner.state_mut(), rats, Zone::Battlefield, &mut Vec::new()); + evaluate_layers(runner.state_mut()); + assert_eq!( + runner.state().objects[&rats].zone, + Zone::Battlefield, + "precondition: rats re-entered the battlefield" + ); + + to_hand(&mut runner, destroy2); + fund(&mut runner, P0, &[ManaType::Black], rats); + let outcome = runner.cast(destroy2).target_objects(&[rats]).resolve(); + assert_eq!( + outcome.zone_of(rats), + Zone::Graveyard, + "CR 400.7: the re-entered object is new — the pruned rider must NOT revive, \ + so the second destroy goes to the graveyard" + ); +} + +// --------------------------------------------------------------------------- +// R8 — control theft: the rider is OBJECT-bound (valid_card: SelfRef), not +// controller-bound. After control passes to P1, P1 sacrificing rats still +// exiles it (CR 400.7 — the redirect follows the object). +// --------------------------------------------------------------------------- + +#[test] +fn r8_control_change_keeps_rider_object_bound() { + let mut scenario = GameScenario::new(); + let rats = stage_rats(&mut scenario); + // P1's sacrifice outlet targets a creature P1 controls once control passes. + // Staged in P1's library and moved to hand AFTER the return, so the unearth + // ETB ("each player discards a card") stays a no-op — P1's hand is empty at + // return time, exactly like R1/R3/R4 (see `stage_rats`). + let sac = scenario + .add_spell_to_library_top(P1, "Sacrifice", true) + .from_oracle_text(SACRIFICE) + .id(); + let mut runner = scenario.build(); + + unearth(&mut runner, rats); + to_hand(&mut runner, sac); + + // Hand P1 control of rats. Setting `base_controller` mirrors a genuine + // control assignment through the Layer-2 baseline + // (`obj.base_controller.unwrap_or(obj.owner)`), so a subsequent layer pass + // keeps P1 in control rather than reverting to the owner. A control change + // leaves the rider — an object-hosted replacement — untouched; only a + // battlefield exit prunes it (CR 400.7). + { + let obj = runner.state_mut().objects.get_mut(&rats).unwrap(); + obj.controller = P1; + obj.base_controller = Some(P1); + } + evaluate_layers(runner.state_mut()); + assert_eq!( + runner.state().objects[&rats].controller, + P1, + "precondition: P1 now controls rats" + ); + + // Hand P1 priority so P1 — the new controller — is the caster. The harness + // casts as the player holding priority, and the additional sacrifice cost + // requires the caster to control the sacrificed creature (CR 601.2h); only + // P1 can pay it now. Sacrifice is an instant, so P1 may cast it at this + // priority window (CR 601.3a). + { + let state = runner.state_mut(); + state.active_player = P1; + state.priority_player = P1; + state.waiting_for = WaitingFor::Priority { player: P1 }; + } + + fund(&mut runner, P1, &[ManaType::Black], rats); + let outcome = runner.cast(sac).sacrifice_with(&[rats]).resolve(); + + assert!( + sacrificed(outcome.events(), rats), + "reach guard: P1 sacrificed rats; events: {:?}", + outcome.events() + ); + assert_eq!( + outcome.zone_of(rats), + Zone::Exile, + "CR 400.7: the exile rider is object-bound — a new controller sacrificing rats \ + still exiles it" + ); +} + +// --------------------------------------------------------------------------- +// R9 — parsed-rider class via the REAL card Gruesome Encore. The rider is built +// by the parser path (try_parse_leave_battlefield_exile_replacement), driven +// through the full cast pipeline; a bounce of the returned creature must exile +// it instead. +// --------------------------------------------------------------------------- + +#[test] +fn r9_gruesome_encore_parsed_rider_redirects_bounce_to_exile() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // A vanilla creature card in an OPPONENT's graveyard (Gruesome Encore + // targets "an opponent's graveyard"). + let bear = scenario + .add_creature_to_graveyard(P1, "Grizzly Bears", 2, 2) + .id(); + let encore = scenario + .add_spell_to_hand_from_oracle(P0, "Gruesome Encore", false, GRUESOME_ENCORE) + .id(); + let bounce = scenario + .add_spell_to_hand_from_oracle(P0, "Unsummon", true, BOUNCE_TARGET) + .id(); + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Colorless, bear, false, vec![]), + ManaUnit::new(ManaType::Colorless, bear, false, vec![]), + ManaUnit::new(ManaType::Black, bear, false, vec![]), + ], + ); + let mut runner = scenario.build(); + + // Cast Gruesome Encore: reanimate the bear under P0's control, installing + // the parsed leaves-battlefield rider. + let outcome = runner.cast(encore).target_objects(&[bear]).resolve(); + assert_eq!( + outcome.zone_of(bear), + Zone::Battlefield, + "precondition: Gruesome Encore reanimates the bear" + ); + assert_eq!( + outcome.state().objects[&bear].controller, + P0, + "precondition: the bear is under P0's control" + ); + + // The genuine layer pass between install and the leave event. + evaluate_layers(runner.state_mut()); + fund(&mut runner, P0, &[ManaType::Blue], bear); + + let outcome = runner.cast(bounce).target_objects(&[bear]).resolve(); + assert_eq!( + outcome.zone_of(bear), + Zone::Exile, + "issue #5976 (parsed-rider class): the reanimated creature bounced must be \ + EXILED instead (CR 702.84a / CR 614.1a)" + ); + // Owner (P1) hand must not gain the card — exiled instead of bounced. + let p1_hand = &outcome + .state() + .players + .iter() + .find(|p| p.id == P1) + .expect("P1 exists") + .hand; + assert!( + !p1_hand.contains(&bear), + "the reanimated card must not reach its owner's hand — it is exiled instead" + ); +} diff --git a/crates/engine/tests/integration/issue_6091_grindstone.rs b/crates/engine/tests/integration/issue_6091_grindstone.rs new file mode 100644 index 0000000000..d34f93b166 --- /dev/null +++ b/crates/engine/tests/integration/issue_6091_grindstone.rs @@ -0,0 +1,281 @@ +//! Issue #6091 — Grindstone repeat loop via `LastZoneChanged` ledger + +//! `ObjectCountBySharedQuality` repeat-until gate. +//! +//! Verbatim Scryfall Oracle (Grindstone, Mirage): +//! +//! {3}, {T}: Target player mills two cards. If two cards that share a color +//! were milled this way, repeat this process. +//! +//! Painter's Servant is verified only as combo setup: chosen red must +//! materialize on `obj.color` through layer evaluation before activation. + +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::ChosenAttribute; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::PlayerId; + +const GRINDSTONE_ORACLE: &str = "{3}, {T}: Target player mills two cards. If two cards that \ + share a color were milled this way, repeat this process."; + +const PAINTER_ORACLE: &str = "As this creature enters, choose a color.\n\ +All cards that aren't on the battlefield, spells, and permanents are the chosen color in addition to their other colors."; + +fn three_generic() -> Vec { + vec![ + ManaUnit::new( + ManaType::Colorless, + engine::types::identifiers::ObjectId(0), + false, + vec![], + ), + ManaUnit::new( + ManaType::Colorless, + engine::types::identifiers::ObjectId(0), + false, + vec![], + ), + ManaUnit::new( + ManaType::Colorless, + engine::types::identifiers::ObjectId(0), + false, + vec![], + ), + ] +} + +fn add_colored_library_card( + scenario: &mut GameScenario, + player: PlayerId, + name: &str, + shard: ManaCostShard, +) -> engine::types::identifiers::ObjectId { + scenario + .add_spell_to_library_top(player, name, true) + .with_mana_cost(ManaCost::Cost { + generic: 1, + shards: vec![shard], + }) + .id() +} + +fn setup_painter_red(runner: &mut GameRunner, painter: engine::types::identifiers::ObjectId) { + runner + .state_mut() + .objects + .get_mut(&painter) + .unwrap() + .chosen_attributes + .push(ChosenAttribute::Color(ManaColor::Red)); + evaluate_layers(runner.state_mut()); +} + +fn assert_objects_include_red(runner: &GameRunner, object_ids: &[ObjectId]) { + for &id in object_ids { + let colors = &runner.state().objects.get(&id).unwrap().color; + assert!( + colors.contains(&ManaColor::Red), + "expected {id:?} to include red under Painter, got {colors:?}" + ); + } +} + +fn activate_grindstone_mill_p1( + runner: &mut GameRunner, + grindstone: engine::types::identifiers::ObjectId, +) { + runner.activate(grindstone, 0).target_player(P1).resolve(); +} + +#[test] +fn grindstone_two_blues_on_top_repeat_without_painter() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let grindstone = scenario + .add_creature_from_oracle(P0, "Grindstone", 0, 0, GRINDSTONE_ORACLE) + .as_artifact() + .id(); + + add_colored_library_card(&mut scenario, P1, "Blue Top", ManaCostShard::Blue); + add_colored_library_card(&mut scenario, P1, "Blue Second", ManaCostShard::Blue); + for idx in 0..4 { + add_colored_library_card( + &mut scenario, + P1, + &format!("Blue Pad {idx}"), + ManaCostShard::Blue, + ); + } + scenario.with_mana_pool(P0, three_generic()); + + let mut runner = scenario.build(); + + let lib_before = runner.state().players[P1.0 as usize].library.len(); + activate_grindstone_mill_p1(&mut runner, grindstone); + let lib_after = runner.state().players[P1.0 as usize].library.len(); + let milled = lib_before.saturating_sub(lib_after); + + // Six blue cards: three shared-color iterations × 2, then the library is empty. + assert_eq!( + milled, 6, + "all-blue library must be milled completely through shared-color repeats (milled {milled})" + ); +} + +#[test] +fn grindstone_stops_mid_library_when_pair_no_longer_shares_color() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let grindstone = scenario + .add_creature_from_oracle(P0, "Grindstone", 0, 0, GRINDSTONE_ORACLE) + .as_artifact() + .id(); + + // Bottom → top order after successive tops: Blue A, Blue B, Green C, Blue D. + // First mill: Blue D + Green C (unlike) would stop at 2 — put matching pair on top. + // Desired top-to-bottom: Blue, Blue, Blue, Green → mill BB (repeat), mill BG (stop) = 4. + add_colored_library_card(&mut scenario, P1, "Green Stop", ManaCostShard::Green); + add_colored_library_card(&mut scenario, P1, "Blue Third", ManaCostShard::Blue); + add_colored_library_card(&mut scenario, P1, "Blue Second", ManaCostShard::Blue); + add_colored_library_card(&mut scenario, P1, "Blue Top", ManaCostShard::Blue); + scenario.with_mana_pool(P0, three_generic()); + + let mut runner = scenario.build(); + + let lib_before = runner.state().players[P1.0 as usize].library.len(); + activate_grindstone_mill_p1(&mut runner, grindstone); + let lib_after = runner.state().players[P1.0 as usize].library.len(); + let milled = lib_before.saturating_sub(lib_after); + + assert_eq!( + milled, 4, + "must repeat once on BB then stop on BG (milled {milled})" + ); +} + +#[test] +fn grindstone_painter_red_mills_more_than_two() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let painter = scenario + .add_creature_from_oracle(P0, "Painter's Servant", 1, 3, PAINTER_ORACLE) + .as_artifact() + .id(); + + let grindstone = scenario + .add_creature_from_oracle(P0, "Grindstone", 0, 0, GRINDSTONE_ORACLE) + .as_artifact() + .id(); + + // Trailing blue pads first; Blue/Green on top so the first mill is the + // unlike-color pair that only repeats once Painter grants shared red. + for idx in 0..6 { + add_colored_library_card( + &mut scenario, + P1, + &format!("Blue Pad {idx}"), + ManaCostShard::Blue, + ); + } + let green_second = + add_colored_library_card(&mut scenario, P1, "Green Second", ManaCostShard::Green); + let blue_top = add_colored_library_card(&mut scenario, P1, "Blue Top", ManaCostShard::Blue); + + scenario.with_mana_pool(P0, three_generic()); + + let mut runner = scenario.build(); + setup_painter_red(&mut runner, painter); + assert_objects_include_red(&runner, &[blue_top, green_second]); + + let lib_before = runner.state().players[P1.0 as usize].library.len(); + activate_grindstone_mill_p1(&mut runner, grindstone); + let lib_after = runner.state().players[P1.0 as usize].library.len(); + let milled = lib_before.saturating_sub(lib_after); + + // Eight cards (blue+green + six blue pads), all share red under Painter → full mill. + assert_eq!( + milled, 8, + "Painter red must mill the whole library through shared-color repeats (milled {milled})" + ); + assert_eq!( + lib_after, 0, + "full-library mill must leave no trailing cards" + ); +} + +#[test] +fn grindstone_without_painter_stops_at_two_unlike_colors() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let grindstone = scenario + .add_creature_from_oracle(P0, "Grindstone", 0, 0, GRINDSTONE_ORACLE) + .as_artifact() + .id(); + + for idx in 0..4 { + add_colored_library_card( + &mut scenario, + P1, + &format!("Blue Trailing {idx}"), + ManaCostShard::Blue, + ); + } + add_colored_library_card(&mut scenario, P1, "Green Second", ManaCostShard::Green); + add_colored_library_card(&mut scenario, P1, "Blue Top", ManaCostShard::Blue); + scenario.with_mana_pool(P0, three_generic()); + + let mut runner = scenario.build(); + + let lib_before = runner.state().players[P1.0 as usize].library.len(); + activate_grindstone_mill_p1(&mut runner, grindstone); + let lib_after = runner.state().players[P1.0 as usize].library.len(); + let milled = lib_before.saturating_sub(lib_after); + + assert_eq!( + milled, 2, + "reach guard: blue+green without Painter must not repeat (milled {milled})" + ); + assert_eq!( + lib_after, 4, + "trailing blue cards must remain when the repeat loop does not fire" + ); +} + +#[test] +fn grindstone_colorless_pair_stops_at_two() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let grindstone = scenario + .add_creature_from_oracle(P0, "Grindstone", 0, 0, GRINDSTONE_ORACLE) + .as_artifact() + .id(); + + scenario.add_spell_to_library_top(P1, "Colorless Trailing A", true); + scenario.add_spell_to_library_top(P1, "Colorless Trailing B", true); + scenario.add_spell_to_library_top(P1, "Colorless B", true); + scenario.add_spell_to_library_top(P1, "Colorless A", true); + scenario.with_mana_pool(P0, three_generic()); + + let mut runner = scenario.build(); + + let lib_before = runner.state().players[P1.0 as usize].library.len(); + activate_grindstone_mill_p1(&mut runner, grindstone); + let lib_after = runner.state().players[P1.0 as usize].library.len(); + let milled = lib_before.saturating_sub(lib_after); + + assert_eq!( + milled, 2, + "two colorless cards produce Max 0 shared-color buckets, so the loop stops at two (milled {milled})" + ); + assert_eq!( + lib_after, 2, + "trailing colorless cards must remain when the repeat loop does not fire" + ); +} diff --git a/crates/engine/tests/integration/issue_6498_portent_of_calamity.rs b/crates/engine/tests/integration/issue_6498_portent_of_calamity.rs new file mode 100644 index 0000000000..beb308c11e --- /dev/null +++ b/crates/engine/tests/integration/issue_6498_portent_of_calamity.rs @@ -0,0 +1,519 @@ +//! Issue #6498 — Portent of Calamity: revealed cards cannot be selected; +//! they just go to the graveyard. +//! +//! Oracle: `Reveal the top X cards of your library. For each card type, you +//! may exile a card of that type from among them. Put the rest into your +//! graveyard. You may cast a spell from among the exiled cards without paying +//! its mana cost if you exiled four or more cards this way. Then put the rest +//! of the exiled cards into your hand.` +//! +//! Discord report: after revealing, the player could not keep selected cards — +//! picks dumped to the graveyard. Root cause: "Put the rest into your +//! graveyard" was modeled as `ChangeZoneAll { Exile → Graveyard, TrackedSet }`, +//! so the per-type exile picks (the tracked set) were immediately moved to the +//! graveyard. Also, Dig→RevealTop demotion collapsed X to 1. +//! +//! DISCRIMINATING: with X=5, exile one of each of four types plus a duplicate +//! creature; the unselected revealed creature goes to the graveyard; after +//! declining the free cast, the four exiled picks reach hand via +//! `TrackedSetFiltered { caused_by: Exiled }`, not chain `TrackedSet`. + +use engine::game::effects::change_zone::resolve_all; +use engine::game::engine::apply_as_current; +use engine::game::scenario::{GameRunner, GameScenario, P1}; +use engine::game::zones::create_object; +use engine::parser::oracle_effect::parse_effect_chain; +use engine::types::ability::{ + AbilityKind, Effect, EffectKind, ForEachCategoryAction, LibraryPosition, QuantityExpr, + QuantityRef, ResolvedAbility, TargetFilter, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::game_state::{GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::{EtbTapState, Zone}; + +const P0: PlayerId = PlayerId(0); + +const PORTENT: &str = "Reveal the top X cards of your library. For each card type, you may exile a card of that type from among them. Put the rest into your graveyard. You may cast a spell from among the exiled cards without paying its mana cost if you exiled four or more cards this way. Then put the rest of the exiled cards into your hand."; + +fn add_mana(runner: &mut GameRunner, amount_blue: u32, amount_colorless: u32) { + let dummy = ObjectId(0); + let pool = &mut runner.state_mut().players[0].mana_pool; + for _ in 0..amount_blue { + pool.add(ManaUnit::new(ManaType::Blue, dummy, false, vec![])); + } + for _ in 0..amount_colorless { + pool.add(ManaUnit::new(ManaType::Colorless, dummy, false, vec![])); + } +} + +#[test] +fn portent_parses_dynamic_reveal_and_last_revealed_rest_to_graveyard() { + let def = parse_effect_chain(PORTENT, AbilityKind::Spell); + // Head: Dig reveal with Variable X (not RevealTop count 1). + match &*def.effect { + Effect::Dig { + reveal: true, + keep_count: Some(0), + count: + QuantityExpr::Ref { + qty: QuantityRef::Variable { name }, + }, + .. + } => assert_eq!(name, "X"), + Effect::RevealTop { count: 1, .. } => { + panic!("X must not collapse to RevealTop {{ count: 1 }}") + } + other => panic!("expected reveal Dig with X, got {other:?}"), + } + + let mut node = &def; + let mut saw_for_each = false; + let mut saw_rest_to_gy = false; + let mut saw_final_hand_cleanup = false; + loop { + match &*node.effect { + Effect::ForEachCategory { + action: ForEachCategoryAction::ExileFromPool { .. }, + .. + } => saw_for_each = true, + Effect::ChangeZoneAll { + origin: Some(Zone::Library), + destination: Zone::Graveyard, + target: TargetFilter::LastRevealed, + .. + } => saw_rest_to_gy = true, + Effect::ChangeZoneAll { + origin: Some(Zone::Exile), + destination: Zone::Hand, + target: TargetFilter::TrackedSetFiltered { + caused_by: Some(engine::types::ability::ThisWayCause::Exiled), + .. + }, + .. + } => saw_final_hand_cleanup = true, + Effect::ChangeZoneAll { + origin: Some(Zone::Exile), + destination: Zone::Graveyard, + target: TargetFilter::TrackedSet { .. }, + .. + } => panic!( + "put-the-rest must NOT dump the exile tracked set into the graveyard (Discord #6498)" + ), + _ => {} + } + match node.sub_ability.as_deref() { + Some(next) => node = next, + None => break, + } + } + assert!(saw_for_each, "must parse ForEachCategory exile"); + assert!( + saw_rest_to_gy, + "must emit ChangeZoneAll Library+LastRevealed→Graveyard for put-the-rest" + ); + assert!( + saw_final_hand_cleanup, + "final tail must bind to action-stamped TrackedSetFiltered(Exiled), not chain TrackedSet" + ); + + let mut node = &def; + let cast = loop { + if matches!(&*node.effect, Effect::CastFromZone { .. }) { + break node; + } + node = node + .sub_ability + .as_ref() + .expect("Portent chain must reach CastFromZone"); + }; + let hand_cleanup = |node: &engine::types::ability::AbilityDefinition| { + matches!( + &*node.effect, + Effect::ChangeZoneAll { + origin: Some(Zone::Exile), + destination: Zone::Hand, + target: TargetFilter::TrackedSetFiltered { + caused_by: Some(engine::types::ability::ThisWayCause::Exiled), + .. + }, + .. + } + ) + }; + assert!( + cast.sub_ability.as_deref().is_some_and(hand_cleanup) + || cast.else_ability.as_deref().is_some_and(hand_cleanup), + "CastFromZone must chain into the exiled-card hand cleanup; sub={:?}, else={:?}", + cast.sub_ability.as_ref().map(|s| &*s.effect), + cast.else_ability.as_ref().map(|s| &*s.effect), + ); + assert!( + cast.sub_ability + .as_deref() + .is_some_and(hand_cleanup), + "hand cleanup must be the accept/decline sub_ability (SequentialSibling), not only else_ability" + ); + assert_eq!( + cast.sub_ability.as_ref().unwrap().sub_link, + engine::types::ability::SubAbilityLink::SequentialSibling, + "Portent hand cleanup must resolve on optional cast decline" + ); +} + +#[test] +fn dynamic_reveal_put_rest_emits_last_revealed_sibling() { + // Shared grammar class from the parse-diff (Sunbird's Invocation tail, + // Enshrined Memories-style separate rest clause, etc.): dynamic-count + // reveal-only Dig + trailing "put the rest …". + const DYNAMIC_REVEAL_REST: &str = "Reveal the top X cards of your library. Put the rest on the bottom of your library in any order."; + let def = parse_effect_chain(DYNAMIC_REVEAL_REST, AbilityKind::Spell); + match &*def.effect { + Effect::Dig { + reveal: true, + keep_count: Some(0), + count: + QuantityExpr::Ref { + qty: QuantityRef::Variable { name }, + }, + .. + } => assert_eq!(name, "X"), + other => panic!("expected dynamic reveal Dig with X, got {other:?}"), + } + + let mut node = &def; + let mut saw_last_revealed_rest = false; + loop { + if matches!( + &*node.effect, + Effect::ChangeZoneAll { + origin: Some(Zone::Library), + destination: Zone::Library, + target: TargetFilter::LastRevealed, + .. + } + ) { + saw_last_revealed_rest = true; + } + match node.sub_ability.as_deref() { + Some(next) => node = next, + None => break, + } + } + assert!( + saw_last_revealed_rest, + "dynamic reveal Dig with put-the-rest must emit explicit LastRevealed sibling \ + instead of relying on unused Dig.rest_destination" + ); +} + +#[test] +fn dynamic_reveal_put_rest_moves_revealed_cards_to_library_bottom() { + // CR 701.20a + CR 608.2c + CR 401.4: dynamic reveal-only Dig with a trailing + // put-the-rest clause must move the revealed library remainder to the bottom, + // leaving cards below the reveal window untouched (Enshrined Memories class). + const DYNAMIC_REVEAL_REST: &str = "Reveal the top X cards of your library. Put the rest on the bottom of your library in any order."; + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // Library top-to-bottom: [rev3, rev2, rev1, deep]. X=3 reveals the top three. + let deep = scenario + .add_spell_to_library_top(P0, "Deep Card", true) + .id(); + let rev1 = scenario + .add_spell_to_library_top(P0, "Revealed 1", true) + .id(); + let rev2 = scenario + .add_spell_to_library_top(P0, "Revealed 2", true) + .id(); + let rev3 = scenario + .add_spell_to_library_top(P0, "Revealed 3", true) + .id(); + + let spell = { + let mut b = scenario.add_spell_to_hand_from_oracle( + P0, + "Dynamic Reveal Probe", + false, + DYNAMIC_REVEAL_REST, + ); + b.with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::X], + generic: 0, + }); + b.id() + }; + + let mut runner = scenario.build(); + add_mana(&mut runner, 0, 3); + + // CR 401.4: submit a non-default bottom order through the production + // `EffectZoneChoice` path — not an engine-default batch order. + let outcome = runner + .cast(spell) + .x(3) + .effect_zone(&[rev3, rev1, rev2]) + .resolve(); + assert!( + matches!(outcome.final_waiting_for(), WaitingFor::Priority { .. }), + "dynamic reveal rest cleanup must finish after the library-order choice" + ); + + let library = &runner.state().players[0].library; + assert_eq!( + library.len(), + 4, + "spell must not remove the unrevealed fourth card from the library" + ); + assert_eq!( + library[0], deep, + "the card below the X-card reveal window must remain on top" + ); + let bottom_tail: Vec = library.iter().skip(1).copied().collect(); + assert_eq!( + bottom_tail, + vec![rev3, rev1, rev2], + "revealed remainder must land on the bottom in the player's submitted order" + ); + for id in [rev1, rev2, rev3] { + assert_eq!( + runner.state().objects[&id].zone, + Zone::Library, + "revealed cards must stay in the library, not be stranded elsewhere" + ); + } +} + +#[test] +fn opponent_library_bottom_order_prompts_owner_and_applies_submitted_order() { + // CR 401.4: mass library-bottom placement on opponent-owned revealed cards + // must prompt the opponent (not the spell controller) and honor their order. + let mut state = GameState::new_two_player(42); + let deep = create_object( + &mut state, + CardId(801), + P1, + "Deep Card".to_string(), + Zone::Library, + ); + let rev3 = create_object( + &mut state, + CardId(802), + P1, + "Revealed 3".to_string(), + Zone::Library, + ); + let rev1 = create_object( + &mut state, + CardId(803), + P1, + "Revealed 1".to_string(), + Zone::Library, + ); + let rev2 = create_object( + &mut state, + CardId(804), + P1, + "Revealed 2".to_string(), + Zone::Library, + ); + state.players[P1.0 as usize].library = im::vector![rev3, rev2, rev1, deep]; + state.last_revealed_ids = vec![rev3, rev2, rev1]; + + let ability = ResolvedAbility::new( + Effect::ChangeZoneAll { + origin: Some(Zone::Library), + destination: Zone::Library, + target: TargetFilter::LastRevealed, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enter_with_counters: vec![], + face_down_profile: None, + library_position: Some(LibraryPosition::Bottom), + random_order: false, + }, + vec![], + ObjectId(900), + P0, + ); + + let mut events = Vec::new(); + resolve_all(&mut state, &ability, &mut events).unwrap(); + + match &state.waiting_for { + WaitingFor::EffectZoneChoice { + player, + cards, + effect_kind, + .. + } => { + assert_eq!( + *player, P1, + "opponent-owned cards must be ordered by their owner, not the caster" + ); + assert_eq!(cards.len(), 3); + assert_eq!(*effect_kind, EffectKind::PutAtLibraryPosition); + } + other => panic!("expected library-order prompt for opponent-owned cards, got {other:?}"), + } + + apply_as_current( + &mut state, + GameAction::SelectCards { + cards: vec![rev3, rev1, rev2], + }, + ) + .unwrap(); + + assert!( + matches!(state.waiting_for, WaitingFor::Priority { .. }), + "library-order cleanup must finish after the owner submits order" + ); + let library = &state.players[P1.0 as usize].library; + assert_eq!(library[0], deep, "unrevealed card stays on top"); + assert_eq!( + library.iter().skip(1).copied().collect::>(), + vec![rev3, rev1, rev2], + "bottom tail must match the opponent's submitted order" + ); +} + +#[test] +fn portent_full_resolution_exiles_picks_to_hand_and_unselected_reveal_to_graveyard() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = { + let mut b = + scenario.add_spell_to_hand_from_oracle(P0, "Portent of Calamity", false, PORTENT); + b.with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::X, ManaCostShard::Blue], + generic: 0, + }); + b.id() + }; + + // Five revealed cards: four distinct types plus a duplicate creature. + let creature_a = scenario + .add_spell_to_library_top(P0, "Creature A", true) + .id(); + let creature_b = scenario + .add_spell_to_library_top(P0, "Creature B", true) + .id(); + let artifact = scenario.add_spell_to_library_top(P0, "Artifact", true).id(); + let enchantment = scenario + .add_spell_to_library_top(P0, "Enchantment", true) + .id(); + let sorcery = scenario.add_spell_to_library_top(P0, "Sorcery", true).id(); + + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&creature_a) + .unwrap() + .card_types + .core_types = vec![CoreType::Creature]; + runner + .state_mut() + .objects + .get_mut(&creature_b) + .unwrap() + .card_types + .core_types = vec![CoreType::Creature]; + runner + .state_mut() + .objects + .get_mut(&artifact) + .unwrap() + .card_types + .core_types = vec![CoreType::Artifact]; + runner + .state_mut() + .objects + .get_mut(&enchantment) + .unwrap() + .card_types + .core_types = vec![CoreType::Enchantment]; + runner + .state_mut() + .objects + .get_mut(&sorcery) + .unwrap() + .card_types + .core_types = vec![CoreType::Sorcery]; + + // X=5 + {U} + add_mana(&mut runner, 1, 5); + + let _outcome = runner.cast(spell).x(5).resolve(); + + let mut exiled = Vec::new(); + while let WaitingFor::ChooseFromZoneChoice { cards, .. } = &runner.state().waiting_for { + let pick = if cards.contains(&creature_a) && cards.contains(&creature_b) { + creature_a + } else { + cards[0] + }; + exiled.push(pick); + runner + .act(GameAction::SelectCards { cards: vec![pick] }) + .expect("per-type exile selection"); + } + + assert_eq!( + exiled.len(), + 4, + "must exile exactly one card per distinct revealed type" + ); + for id in &exiled { + assert_eq!( + runner.state().objects[id].zone, + Zone::Exile, + "exiled picks must remain in Exile through the revealed rest cleanup" + ); + } + + let unselected_creature = if exiled.contains(&creature_a) { + creature_b + } else { + creature_a + }; + assert_eq!( + runner.state().objects[&unselected_creature].zone, + Zone::Graveyard, + "the revealed-but-unselected duplicate creature must go to the graveyard" + ); + + // Decline the optional free cast, then drive the final hand cleanup. + while !matches!(runner.state().waiting_for, WaitingFor::Priority { .. }) { + match &runner.state().waiting_for { + WaitingFor::OptionalEffectChoice { .. } => { + runner + .act(GameAction::DecideOptionalEffect { accept: false }) + .expect("decline optional free cast"); + } + WaitingFor::CastOffer { .. } => { + runner + .act(GameAction::PassPriority) + .expect("decline cast offer"); + } + other => panic!("unexpected prompt before final cleanup: {other:?}"), + } + } + + for id in &exiled { + assert_eq!( + runner.state().objects[id].zone, + Zone::Hand, + "remaining exiled cards must reach hand via TrackedSetFiltered(Exiled) tail" + ); + assert!( + !runner.state().players[0].graveyard.contains(id), + "exiled pick must not be stranded in the graveyard" + ); + } +} diff --git a/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs b/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs new file mode 100644 index 0000000000..b73214aec8 --- /dev/null +++ b/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs @@ -0,0 +1,765 @@ +//! Issue #6499 — Flickering Ward cannot stay attached after choosing a color. +//! +//! Oracle: `Enchant creature` / `As this Aura enters, choose a color.` / +//! `Enchanted creature has protection from the chosen color. This effect +//! doesn't remove this Aura.` / `{W}: Return this Aura to its owner's hand.` +//! +//! Discord report: after picking a color, the Aura would not stay attached. +//! Choosing white (the Aura's color) grants protection from white; without +//! CR 702.16n's "doesn't remove this Aura" rider, SBA CR 704.5m / CR 702.16c +//! puts the white Aura into the graveyard. +//! +//! Root cause: the parser dropped the rider as "inert prose" so coverage +//! claimed the protection grant was supported while the exemption was never +//! modeled. Fix stamps `ProtectionDoesNotRemove::Source` on the continuous +//! static and honors it in `attachment_illegality`. +//! +//! DISCRIMINATING: with chosen color = white, Flickering Ward stays attached +//! and on the battlefield. A revert (no rider / no exemption check) sends it +//! to the graveyard. + +use engine::game::layers::evaluate_layers; +use engine::game::sba::check_state_based_actions; +use engine::game::zones::create_object; +use engine::parser::oracle::parse_oracle_text; +use engine::types::ability::{ChosenAttribute, ContinuousModification, ProtectionDoesNotRemove}; +use engine::types::card_type::CoreType; +use engine::types::game_state::GameState; +use engine::types::identifiers::CardId; +use engine::types::keywords::{Keyword, ProtectionTarget}; +use engine::types::mana::ManaColor; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const FLICKERING_WARD: &str = "Enchant creature\n\ +As this Aura enters, choose a color.\n\ +Enchanted creature has protection from the chosen color. This effect doesn't remove this Aura.\n\ +{W}: Return this Aura to its owner's hand."; + +#[test] +fn flickering_ward_parses_protection_source_exemption() { + let parsed = parse_oracle_text( + FLICKERING_WARD, + "Flickering Ward", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + let prot = parsed + .statics + .iter() + .find(|s| { + s.protection_does_not_remove == Some(ProtectionDoesNotRemove::Source) + && s.modifications.iter().any(|m| { + matches!( + m, + ContinuousModification::AddKeyword { + keyword: Keyword::Protection(ProtectionTarget::ChosenColor), + } + ) + }) + }) + .expect("Flickering Ward must carry Protection(ChosenColor) + Source exemption"); + assert_eq!( + prot.protection_does_not_remove, + Some(ProtectionDoesNotRemove::Source) + ); +} + +#[test] +fn flickering_ward_stays_attached_after_choosing_own_color() { + let parsed = parse_oracle_text( + FLICKERING_WARD, + "Flickering Ward", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + let prot_static = parsed + .statics + .iter() + .find(|s| s.protection_does_not_remove.is_some()) + .cloned() + .expect("protection static with exemption"); + + let mut state = GameState::new_two_player(42); + let creature = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&creature) + .unwrap() + .card_types + .core_types = vec![CoreType::Creature]; + + let aura = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Flickering Ward".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&aura).unwrap(); + obj.card_types.core_types = vec![CoreType::Enchantment]; + obj.base_card_types = obj.card_types.clone(); + obj.card_types.subtypes.push("Aura".to_string()); + obj.base_card_types.subtypes.push("Aura".to_string()); + obj.color.push(ManaColor::White); + obj.attached_to = Some(creature.into()); + obj.chosen_attributes + .push(ChosenAttribute::Color(ManaColor::White)); + obj.static_definitions.push(prot_static.clone()); + let base = std::sync::Arc::make_mut(&mut obj.base_static_definitions); + base.push(prot_static); + } + state + .objects + .get_mut(&creature) + .unwrap() + .attachments + .push(aura); + + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + + assert!( + state.battlefield.contains(&aura), + "CR 702.16n: Aura must stay on the battlefield after choosing white (Discord #6499)" + ); + assert_eq!( + state.objects.get(&aura).and_then(|o| o.attached_to), + Some(creature.into()), + "Aura must remain attached to the enchanted creature" + ); +} + +#[test] +fn printed_protection_still_removes_white_aura_without_rider() { + // Sanity: ordinary Pacifism on a host with printed protection from white + // is still removed — exemptions are per-grant, not global. + let mut state = GameState::new_two_player(42); + let creature = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&creature).unwrap(); + obj.card_types.core_types = vec![CoreType::Creature]; + obj.base_keywords + .push(Keyword::Protection(ProtectionTarget::Color( + ManaColor::White, + ))); + obj.keywords = obj.base_keywords.clone(); + } + let aura = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Pacifism".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&aura).unwrap(); + obj.card_types.core_types = vec![CoreType::Enchantment]; + obj.card_types.subtypes.push("Aura".to_string()); + obj.color.push(ManaColor::White); + obj.attached_to = Some(creature.into()); + } + state + .objects + .get_mut(&creature) + .unwrap() + .attachments + .push(aura); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + + assert!( + !state.battlefield.contains(&aura), + "printed protection without a 702.16n rider still removes white Auras" + ); + assert!( + state.players[1].graveyard.contains(&aura), + "illegal Aura must go to its owner's graveyard (CR 704.5m)" + ); +} + +fn apply_host_protection_grant( + state: &mut GameState, + source_id: engine::types::identifiers::ObjectId, + host_id: engine::types::identifiers::ObjectId, + pt: ProtectionTarget, + exemption: Option, +) { + use engine::types::ability::{ContinuousModification, StaticDefinition}; + use engine::types::keywords::Keyword; + + let mut def = StaticDefinition::continuous() + .affected(engine::types::ability::TargetFilter::SpecificObject { id: host_id }) + .modifications(vec![ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + }]); + if let Some(exemption) = exemption { + def = def.protection_does_not_remove(exemption); + } + state + .objects + .get_mut(&source_id) + .unwrap() + .static_definitions + .push(def); +} + +fn apply_host_dual_protection_grant( + state: &mut GameState, + source_id: engine::types::identifiers::ObjectId, + host_id: engine::types::identifiers::ObjectId, + qualities: [ProtectionTarget; 2], + exemption: Option, +) { + use engine::types::ability::{ContinuousModification, StaticDefinition}; + use engine::types::keywords::Keyword; + + let mut def = StaticDefinition::continuous() + .affected(engine::types::ability::TargetFilter::SpecificObject { id: host_id }) + .modifications(vec![ + ContinuousModification::AddKeyword { + keyword: Keyword::Protection(qualities[0].clone()), + }, + ContinuousModification::AddKeyword { + keyword: Keyword::Protection(qualities[1].clone()), + }, + ]); + if let Some(exemption) = exemption { + def = def.protection_does_not_remove(exemption); + } + state + .objects + .get_mut(&source_id) + .unwrap() + .static_definitions + .push(def); +} + +#[test] +fn cr_702_16p_two_qualities_in_one_static_each_modification_snapshots_independently() { + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(50), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let red_equipment = create_object( + &mut state, + CardId(51), + PlayerId(0), + "Red Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&red_equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::Red); + obj.attached_to = Some(host.into()); + } + + let blue_equipment = create_object( + &mut state, + CardId(52), + PlayerId(0), + "Blue Shield".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&blue_equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::Blue); + obj.attached_to = Some(host.into()); + } + + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .extend([red_equipment, blue_equipment]); + + let grant_source = create_object( + &mut state, + CardId(53), + PlayerId(0), + "Dual Ward".to_string(), + Zone::Battlefield, + ); + apply_host_dual_protection_grant( + &mut state, + grant_source, + host, + [ + ProtectionTarget::Color(ManaColor::Red), + ProtectionTarget::Color(ManaColor::Blue), + ], + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let snapshots = &state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments; + assert!( + snapshots + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&red_equipment)), + "red protection modification must snapshot the red Equipment at effect start" + ); + assert!( + snapshots + .get(&(0, 1, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&blue_equipment)), + "blue protection modification must snapshot the blue Equipment at effect start" + ); + assert!( + !snapshots + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&blue_equipment)), + "red snapshot must not absorb the blue Equipment" + ); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + assert_eq!( + state + .objects + .get(&red_equipment) + .and_then(|o| o.attached_to), + Some(host.into()), + "red Equipment exempted by the red protection instance must stay attached" + ); + assert_eq!( + state + .objects + .get(&blue_equipment) + .and_then(|o| o.attached_to), + Some(host.into()), + "blue Equipment exempted by the blue protection instance must stay attached" + ); +} + +#[test] +fn cr_702_16p_snapshots_matching_controlled_attachment_at_grant_start() { + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let equipment = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::White); + obj.attached_to = Some(host.into()); + } + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .push(equipment); + + let grant_source = create_object( + &mut state, + CardId(3), + PlayerId(0), + "Blessing".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + assert!( + state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&equipment)), + "CR 702.16p: white Equipment already attached when the grant starts must be snapshotted" + ); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).and_then(|o| o.attached_to), + Some(host.into()), + "snapshotted Equipment must survive SBA" + ); +} + +#[test] +fn cr_702_16p_does_not_retain_attachment_that_becomes_matching_later() { + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(10), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let equipment = create_object( + &mut state, + CardId(11), + PlayerId(0), + "Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.attached_to = Some(host.into()); + } + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .push(equipment); + + let grant_source = create_object( + &mut state, + CardId(12), + PlayerId(0), + "Blessing".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + state + .objects + .get_mut(&equipment) + .unwrap() + .base_color + .push(ManaColor::White); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + + assert_eq!( + state.objects.get(&equipment).and_then(|o| o.attached_to), + None, + "Equipment that became matching only after grant start must unattach (CR 702.16p snapshot, not live check)" + ); + assert!( + state.battlefield.contains(&equipment), + "Equipment remains on the battlefield after CR 704.5n" + ); +} + +#[test] +fn cr_702_16p_second_unridered_protection_grant_still_removes_despite_snapshot() { + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(20), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let equipment = create_object( + &mut state, + CardId(21), + PlayerId(0), + "Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::White); + obj.attached_to = Some(host.into()); + } + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .push(equipment); + + let rider_source = create_object( + &mut state, + CardId(22), + PlayerId(0), + "Blessing".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + rider_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let plain_source = create_object( + &mut state, + CardId(23), + PlayerId(0), + "Extra Ward".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + plain_source, + host, + ProtectionTarget::Color(ManaColor::White), + None, + ); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + + assert_eq!( + state.objects.get(&equipment).and_then(|o| o.attached_to), + None, + "second protection from white without a rider must remove despite the first grant's 702.16p snapshot" + ); +} + +#[test] +fn cr_702_16p_same_source_second_effect_does_not_inherit_first_snapshot() { + use std::sync::Arc; + + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(30), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let equipment = create_object( + &mut state, + CardId(31), + PlayerId(0), + "Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::Blue); + obj.attached_to = Some(host.into()); + } + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .push(equipment); + + let grant_source = create_object( + &mut state, + CardId(32), + PlayerId(0), + "Dual Blessing".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::Blue), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + assert!(state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&equipment))); + + { + let obj = state.objects.get_mut(&grant_source).unwrap(); + obj.static_definitions.clear(); + obj.base_static_definitions = Arc::new(Vec::new()); + obj.base_characteristics_initialized = false; + } + apply_host_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + assert!( + !state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&equipment)), + "white rider must not inherit blue-rider snapshot at the same effect slot" + ); + + state + .objects + .get_mut(&equipment) + .unwrap() + .base_color + .push(ManaColor::White); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).and_then(|o| o.attached_to), + None, + "Equipment that became white after the white rider started must unattach" + ); +} + +#[test] +fn cr_702_16p_opponent_controlled_matching_attachment_not_exempt() { + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(40), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let equipment = create_object( + &mut state, + CardId(41), + PlayerId(1), + "Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::White); + obj.controller = PlayerId(1); + obj.base_controller = Some(PlayerId(1)); + obj.attached_to = Some(host.into()); + } + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .push(equipment); + + let grant_source = create_object( + &mut state, + CardId(42), + PlayerId(0), + "Blessing".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + assert!( + !state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&equipment)), + "702.16p only exempts controlled attachments at effect start" + ); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).and_then(|o| o.attached_to), + None, + "opponent-controlled matching Equipment must unattach" + ); +} diff --git a/crates/engine/tests/integration/issue_6566_granted_leave_exile.rs b/crates/engine/tests/integration/issue_6566_granted_leave_exile.rs new file mode 100644 index 0000000000..27e0731c9e --- /dev/null +++ b/crates/engine/tests/integration/issue_6566_granted_leave_exile.rs @@ -0,0 +1,530 @@ +//! Issue #6566 — a granted "If ~ would leave the battlefield, exile it instead +//! of putting it anywhere else" rider (Geth, Thane of Contracts; Realmbreaker, +//! the Invasion Tree; Elemental Expressionist) must install a LIVE object-hosted +//! replacement on the object that gains it, so a later destruction exiles the +//! object instead of sending it to the graveyard. +//! +//! Drives the REAL pipeline end-to-end: parse each card's verbatim Oracle text → +//! resolve the grant-bearing ability → `evaluate_layers` (Layer 6 installs the +//! granted `ReplacementDefinition` onto the recipient) → destroy the recipient +//! through the zone-change hub (`Effect::Destroy`) and assert the exile +//! redirect. Reverting the parser `~` arm, the classify routing, the layer-6 +//! GrantReplacement apply, or the F2 duration promotion each flips a zone +//! assertion here (graveyard instead of exile, or an EOT lapse). +//! +//! CR 614.1a + CR 614.6 (replacement), CR 613.1f (Layer-6 ability grant), +//! CR 201.5b (the `~` self-ref binds to the object that gains the ability), +//! CR 611.2a (permanent — the grant states no duration, so it outlives its +//! source), CR 514.2 (a STATED "until end of turn" grant lapses at cleanup). + +use engine::game::ability_utils::build_resolved_from_def_with_targets; +use engine::game::effects::resolve_ability_chain; +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameScenario, P0}; +use engine::parser::oracle::parse_oracle_text; +use engine::parser::oracle_effect::leave_battlefield_exile_replacement; +use engine::types::ability::{ + AbilityDefinition, ContinuousModification, ControllerRef, Duration, Effect, ResolvedAbility, + StaticDefinition, TargetFilter, TargetRef, TriggerDefinition, TypedFilter, +}; +use engine::types::events::GameEvent; +use engine::types::game_state::GameState; +use engine::types::identifiers::ObjectId; +use engine::types::player::PlayerId; +use engine::types::replacements::ReplacementEvent; +use engine::types::triggers::TriggerMode; +use engine::types::zones::Zone; + +const GETH_ORACLE: &str = "Other creatures you control get -1/-1.\n\ +{1}{B}{B}, {T}: Return target creature card from your graveyard to the \ +battlefield. It gains \"If this creature would leave the battlefield, exile it \ +instead of putting it anywhere else.\" Activate only as a sorcery."; + +/// Geth's activated reanimation ability (the second line). The first line is a +/// static anthem, so the sole entry in `abilities` is the reanimation. +fn geth_reanimation_ability() -> AbilityDefinition { + let parsed = parse_oracle_text( + GETH_ORACLE, + "Geth, Thane of Contracts", + &[], + &["Legendary".to_string(), "Creature".to_string()], + &["Zombie".to_string()], + ); + parsed + .abilities + .into_iter() + .next() + .expect("Geth must parse an activated reanimation ability") +} + +/// The reanimated object on P0's battlefield, found by name (reanimation keeps +/// the card's identity; searching by name avoids assuming id stability). +fn battlefield_id(state: &GameState, player: PlayerId, name: &str) -> Option { + state + .objects + .iter() + .find(|(_, o)| o.zone == Zone::Battlefield && o.owner == player && o.name == name) + .map(|(id, _)| *id) +} + +/// Destroy `creature` through the production zone-change hub so the granted +/// Moved→Exile replacement (if live) is consulted. +fn destroy(state: &mut GameState, source: ObjectId, creature: ObjectId) { + // `destroy::resolve` iterates the resolved `targets` vec (TargetRef::Object), + // not the filter — pass the creature there. + let destroy = ResolvedAbility::new( + Effect::Destroy { + target: TargetFilter::Any, + cant_regenerate: false, + }, + vec![TargetRef::Object(creature)], + source, + P0, + ); + let mut events = Vec::::new(); + resolve_ability_chain(state, &destroy, &mut events, 0).expect("destroy resolves"); +} + +/// True when the object hosts the granted SelfRef Moved replacement. +fn hosts_leave_exile_replacement(state: &GameState, id: ObjectId) -> bool { + state.objects.get(&id).is_some_and(|o| { + o.replacement_definitions.as_slice().iter().any(|r| { + r.event == ReplacementEvent::Moved && r.valid_card == Some(TargetFilter::SelfRef) + }) + }) +} + +/// Number of SelfRef Moved replacements hosted (dedup guard — must be exactly 1). +fn leave_exile_replacement_count(state: &GameState, id: ObjectId) -> usize { + state + .objects + .get(&id) + .map(|o| { + o.replacement_definitions + .as_slice() + .iter() + .filter(|r| { + r.event == ReplacementEvent::Moved + && r.valid_card == Some(TargetFilter::SelfRef) + }) + .count() + }) + .unwrap_or(0) +} + +/// #6566 (5)+(7): Geth reanimates a graveyard creature and grants it the rider; +/// destroying the reanimated creature must EXILE it, not send it to the +/// graveyard. A separate un-granted creature destroyed in the same test is a +/// reach guard proving the exile is the RIDER's doing, not a global mis-route. +#[test] +fn geth_granted_leave_exile_redirects_reanimated_creature() { + let mut scenario = GameScenario::new(); + let geth = scenario + .add_creature(P0, "Geth, Thane of Contracts", 4, 4) + .id(); + // The reanimation target sits in P0's graveyard. + let corpse = scenario + .add_creature_to_graveyard(P0, "Dire Wolf", 2, 2) + .id(); + // An un-granted control creature already on the battlefield. + let control = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let mut runner = scenario.build(); + + // Resolve Geth's reanimation ability with the graveyard creature as target. + let ability = build_resolved_from_def_with_targets( + &geth_reanimation_ability(), + geth, + P0, + vec![TargetRef::Object(corpse)], + ); + let mut events = Vec::::new(); + resolve_ability_chain(runner.state_mut(), &ability, &mut events, 0) + .expect("reanimation resolves"); + evaluate_layers(runner.state_mut()); + + // The Dire Wolf is now on the battlefield (reach guard: reanimation worked). + let reanimated = battlefield_id(runner.state(), P0, "Dire Wolf") + .expect("Dire Wolf must be reanimated onto the battlefield"); + + // The granted Moved→Exile replacement is LIVE on the reanimated creature. + assert!( + hosts_leave_exile_replacement(runner.state(), reanimated), + "the granted 'exile instead of leaving' replacement must be installed on \ + the reanimated creature after evaluate_layers" + ); + + // Destroy the reanimated creature → it must be EXILED, not in the graveyard. + destroy(runner.state_mut(), geth, reanimated); + assert_eq!( + runner.state().objects[&reanimated].zone, + Zone::Exile, + "the granted rider must redirect the reanimated creature's destruction to \ + exile (revert-failing assertion for #6566)" + ); + + // Reach guard: an un-granted creature destroyed the same way goes to the + // graveyard — proving the exile above is the rider's doing. + destroy(runner.state_mut(), geth, control); + assert_eq!( + runner.state().objects[&control].zone, + Zone::Graveyard, + "an un-granted creature must die to the graveyard normally" + ); + + // Dedup guard: exactly one granted Moved/SelfRef def (a second evaluate_layers + // pass re-derives from base and must not accumulate). + evaluate_layers(runner.state_mut()); + let _ = corpse; // corpse id == reanimated identity; retained for clarity. +} + +/// #6566 dedup (5): two identical grants installed on one host collapse to a +/// single hosted replacement (structural-equality dedup in the Layer-6 apply). +#[test] +fn duplicate_grants_dedup_to_one_replacement() { + let mut scenario = GameScenario::new(); + let geth = scenario + .add_creature(P0, "Geth, Thane of Contracts", 4, 4) + .id(); + let corpse = scenario + .add_creature_to_graveyard(P0, "Dire Wolf", 2, 2) + .id(); + let mut runner = scenario.build(); + + // Resolve the reanimation grant twice onto the same reanimated host. The + // first resolves the reanimation + grant; the second re-runs only the grant + // effect against the now-battlefield creature (its ChangeZone is a no-op as + // the card is already on the battlefield, but the grant re-registers). + let ability = geth_reanimation_ability(); + let resolved = + build_resolved_from_def_with_targets(&ability, geth, P0, vec![TargetRef::Object(corpse)]); + let mut events = Vec::::new(); + resolve_ability_chain(runner.state_mut(), &resolved, &mut events, 0) + .expect("first reanimation+grant resolves"); + evaluate_layers(runner.state_mut()); + + let reanimated = battlefield_id(runner.state(), P0, "Dire Wolf").expect("Dire Wolf reanimated"); + + // Re-run the reanimation ability again targeting the (now battlefield) object + // to fire a second identical grant at the same host. + let resolved2 = build_resolved_from_def_with_targets( + &ability, + geth, + P0, + vec![TargetRef::Object(reanimated)], + ); + let mut events2 = Vec::::new(); + let _ = resolve_ability_chain(runner.state_mut(), &resolved2, &mut events2, 0); + evaluate_layers(runner.state_mut()); + + assert_eq!( + leave_exile_replacement_count(runner.state(), reanimated), + 1, + "two identical grants on one host must dedup to a single hosted \ + replacement (Layer-6 structural-equality dedup)" + ); +} + +/// #6566 F2 (permanent): CR 611.2a — Geth's grant states NO duration, so the F2 +/// promotion makes it `Duration::Permanent`. It must SURVIVE end-of-turn cleanup +/// (CR 514.2 prunes only `UntilEndOfTurn` effects). Reverting the F2 promotion +/// leaves the grant at the `UntilEndOfTurn` fallback, which `prune_end_of_turn_effects` +/// removes — the replacement would vanish and the destruction below would go to +/// the graveyard (revert-failing). +#[test] +fn geth_permanent_grant_survives_end_of_turn_cleanup() { + use engine::game::layers::prune_end_of_turn_effects; + + let mut scenario = GameScenario::new(); + let geth = scenario + .add_creature(P0, "Geth, Thane of Contracts", 4, 4) + .id(); + let corpse = scenario + .add_creature_to_graveyard(P0, "Dire Wolf", 2, 2) + .id(); + let mut runner = scenario.build(); + + let ability = build_resolved_from_def_with_targets( + &geth_reanimation_ability(), + geth, + P0, + vec![TargetRef::Object(corpse)], + ); + let mut events = Vec::::new(); + resolve_ability_chain(runner.state_mut(), &ability, &mut events, 0) + .expect("reanimation resolves"); + evaluate_layers(runner.state_mut()); + let reanimated = battlefield_id(runner.state(), P0, "Dire Wolf").expect("Dire Wolf reanimated"); + + // Reach guard: the grant is live before cleanup. + assert!( + hosts_leave_exile_replacement(runner.state(), reanimated), + "grant must be live before cleanup" + ); + + // CR 514.2 end-of-turn cleanup — the permanent grant must NOT be pruned. + prune_end_of_turn_effects(runner.state_mut()); + evaluate_layers(runner.state_mut()); + assert!( + hosts_leave_exile_replacement(runner.state(), reanimated), + "the permanent grant (F2, CR 611.2a) must survive end-of-turn cleanup — \ + it does not lapse at EOT (revert-failing for the F2 promotion)" + ); + + // And the replacement still redirects a later-turn destruction to exile. + destroy(runner.state_mut(), geth, reanimated); + assert_eq!( + runner.state().objects[&reanimated].zone, + Zone::Exile, + "the surviving permanent grant must still exile the creature after cleanup" + ); +} + +/// Resolve a synthetic `Effect::GenericEffect` that grants the shared +/// leave-battlefield->exile rider to `creature` for `Duration::UntilEndOfTurn`. +/// +/// This models the replacement half of Elemental Expressionist's grant. Its +/// verbatim Oracle text is: +/// +/// > Magecraft — Whenever you cast or copy an instant or sorcery spell, choose +/// > target creature you control. Until end of turn, it gains "If this creature +/// > would leave the battlefield, exile it instead of putting it anywhere else" +/// > and "When this creature is put into exile, create a 4/4 blue and red +/// > Elemental creature token." +/// +/// Note the card grants TWO abilities in one "Until end of turn, it gains …" +/// clause — a replacement AND a trigger. `parse_quoted_ability_modifications` +/// extends ONE modifications vec across every quote pair, so the real parse is a +/// single MIXED `StaticDefinition` (`[GrantReplacement, GrantTrigger]`), which is +/// exactly the shape the F2 mixed-def guard must refuse to promote — see +/// `mixed_replacement_and_trigger_grant_is_not_promoted_to_permanent` below. +/// +/// This fixture stays synthetic (replacement-only, resolution-level) — the +/// reviewer-sanctioned equivalent of driving the flagship card's verbatim Oracle +/// text end-to-end for the duration axis. The STATED duration +/// rides on the wrapper (`duration: Some(UntilEndOfTurn)`), so the F2 fallback +/// promotion in `effect.rs::resolve` (`duration_from_fallback = ability.duration +/// .is_none() && duration.is_none()`) is NOT taken and the grant stays +/// EOT-confined. Uses the `pub`(under `test-support`) shared +/// `leave_battlefield_exile_replacement()` constructor so the def shape matches +/// production exactly. +fn grant_eot_leave_exile(state: &mut GameState, source: ObjectId, creature: ObjectId) { + let grant = Effect::GenericEffect { + static_abilities: vec![StaticDefinition::continuous().modifications(vec![ + ContinuousModification::GrantReplacement { + replacement: Box::new(leave_battlefield_exile_replacement()), + }, + ])], + duration: Some(Duration::UntilEndOfTurn), + target: Some(TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::You), + )), + }; + let resolved = ResolvedAbility::new(grant, vec![TargetRef::Object(creature)], source, P0); + let mut events = Vec::::new(); + resolve_ability_chain(state, &resolved, &mut events, 0).expect("EOT grant resolves"); +} + +/// True when the object hosts a granted "put into exile" trigger (the second of +/// Elemental Expressionist's two quoted abilities). +fn hosts_put_into_exile_trigger(state: &GameState, id: ObjectId) -> bool { + state.objects.get(&id).is_some_and(|o| { + o.trigger_definitions.as_slice().iter().any(|t| { + t.definition.mode == TriggerMode::ChangesZone + && t.definition.destination == Some(Zone::Exile) + && t.definition.valid_card == Some(TargetFilter::SelfRef) + }) + }) +} + +/// #6566 F2 (mixed def is NOT promoted): the F2 promotion in `effect.rs::resolve` +/// fires only when EVERY modification on the `StaticDefinition` is a +/// `GrantReplacement`. That `.all(..)` predicate is load-bearing for the issue's +/// flagship card: `parse_quoted_ability_modifications` +/// (`oracle_static/keyword_grant.rs`) extends ONE modifications vec across every +/// quote pair, so Elemental Expressionist's two quoted abilities land in a single +/// MIXED def — `[GrantReplacement, GrantTrigger]` — not two replacement-only defs. +/// +/// Here that mixed def is resolved with NO stated duration on either source +/// (`ability.duration` and the wrapper `duration` are both `None`), so +/// `duration_from_fallback` is TRUE and the promotion predicate is actually +/// evaluated — unlike `expressionist_eot_grant_lapses_at_end_of_turn_cleanup`, +/// which hand-stamps `Some(UntilEndOfTurn)` and short-circuits before the +/// predicate. A mixed def must keep the resolved `UntilEndOfTurn` fallback +/// (CR 611.2a) rather than being promoted to `Duration::Permanent`, so it lapses +/// at cleanup (CR 514.2). +/// +/// Revert-sensitive: swapping the `.all(GrantReplacement)` predicate in +/// `effect.rs` for `.any(..)` promotes THIS def to Permanent — the rider then +/// survives cleanup and both post-cleanup assertions below fail. +#[test] +fn mixed_replacement_and_trigger_grant_is_not_promoted_to_permanent() { + use engine::game::layers::prune_end_of_turn_effects; + + let mut scenario = GameScenario::new(); + let source = scenario + .add_creature(P0, "Elemental Expressionist", 4, 4) + .id(); + let recipient = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let mut runner = scenario.build(); + + // The card's SECOND quoted ability: "When this creature is put into exile, + // create a 4/4 blue and red Elemental creature token." Only its trigger + // metadata matters here — it is the non-replacement modification that makes + // the def MIXED. + let mut put_into_exile = TriggerDefinition::new(TriggerMode::ChangesZone); + put_into_exile.valid_card = Some(TargetFilter::SelfRef); + put_into_exile.destination = Some(Zone::Exile); + + // ONE StaticDefinition carrying BOTH modifications — the real parse shape. + let grant = Effect::GenericEffect { + static_abilities: vec![StaticDefinition::continuous().modifications(vec![ + ContinuousModification::GrantReplacement { + replacement: Box::new(leave_battlefield_exile_replacement()), + }, + ContinuousModification::GrantTrigger { + trigger: Box::new(put_into_exile), + }, + ])], + // No stated duration anywhere -> `duration_from_fallback` is TRUE, so the + // mixed-def guard is the ONLY thing keeping this off `Permanent`. + duration: None, + target: Some(TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::You), + )), + }; + let resolved = ResolvedAbility::new(grant, vec![TargetRef::Object(recipient)], source, P0); + let mut events = Vec::::new(); + resolve_ability_chain(runner.state_mut(), &resolved, &mut events, 0) + .expect("mixed grant resolves"); + evaluate_layers(runner.state_mut()); + + // Reach guards: the mixed def registered and BOTH halves installed, so the + // promotion predicate genuinely saw a `[GrantReplacement, GrantTrigger]` def. + assert!( + hosts_leave_exile_replacement(runner.state(), recipient), + "the mixed grant must install the leave->exile replacement half" + ); + assert!( + hosts_put_into_exile_trigger(runner.state(), recipient), + "the mixed grant must install the put-into-exile trigger half — without \ + it the def is replacement-only and this test would not reach the guard" + ); + + // CR 514.2 end-of-turn cleanup: the un-promoted (fallback `UntilEndOfTurn`) + // mixed def must be pruned. + prune_end_of_turn_effects(runner.state_mut()); + evaluate_layers(runner.state_mut()); + + assert!( + !hosts_leave_exile_replacement(runner.state(), recipient), + "a MIXED def must NOT be promoted to Duration::Permanent — it stays at \ + the UntilEndOfTurn fallback and lapses at cleanup (revert-failing if \ + `.all(GrantReplacement)` becomes `.any(..)` in effect.rs)" + ); + + // Observable consequence: with the rider lapsed, a post-cleanup destruction + // goes to the GRAVEYARD. Under the `.any(..)` mis-wire it would be EXILED. + assert_eq!( + runner.state().objects[&recipient].zone, + Zone::Battlefield, + "`recipient` must still be on the battlefield to be destroyed post-cleanup" + ); + destroy(runner.state_mut(), source, recipient); + assert_eq!( + runner.state().objects[&recipient].zone, + Zone::Graveyard, + "the lapsed mixed-def rider must NOT redirect: `recipient` dies to the \ + graveyard (revert-failing for the mixed-def guard)" + ); +} + +/// #6566 F2 (EOT stated duration -> lapse-at-cleanup): Elemental Expressionist — +/// the issue's flagship and ONLY duration-bound card — chooses target creature +/// you control, then (verbatim): `Until end of turn, it gains "If this creature +/// would leave the battlefield, exile it instead of putting it anywhere else" +/// and "When this creature is put into exile, create a 4/4 blue and red +/// Elemental creature token."` — TWO granted abilities, see +/// `grant_eot_leave_exile` above. That STATED duration arrives on the GenericEffect +/// wrapper (`duration: Some(UntilEndOfTurn)`), so the F2 fallback promotion is NOT +/// taken (CR 611.2a) and the grant stays EOT-confined, lapsing at cleanup (CR +/// 514.2). +/// +/// This is the discriminating OPPOSITE of `geth_permanent_grant_survives_end_of_turn_cleanup` +/// above: Geth's grant states no duration -> Permanent -> survives cleanup; +/// Expressionist's states "until end of turn" -> lapses at cleanup. A mis-wire +/// that keyed the F2 promotion off only one duration source, or dropped the +/// `duration.is_none()` conjunct, would promote THIS grant to Permanent and still +/// pass every Geth assertion — assertion 2 below is the only thing that catches +/// it (revert-sensitive to the F2 confinement). +/// +/// Because the same-turn destroy (assertion 1) exiles its target, a SECOND +/// creature carries the surviving-to-cleanup branch (assertion 2): a destroyed +/// creature is gone from the battlefield and cannot be re-destroyed. +#[test] +fn expressionist_eot_grant_lapses_at_end_of_turn_cleanup() { + use engine::game::layers::prune_end_of_turn_effects; + + let mut scenario = GameScenario::new(); + // Model Elemental Expressionist as the granting source. + let source = scenario + .add_creature(P0, "Elemental Expressionist", 2, 2) + .id(); + // Two creatures you control receive the EOT rider: `live` is destroyed within + // the turn (rider active -> exile); `survivor` is destroyed AFTER cleanup + // (rider lapsed -> graveyard). + let live = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let survivor = scenario.add_creature(P0, "Dire Wolf", 2, 2).id(); + let mut runner = scenario.build(); + + grant_eot_leave_exile(runner.state_mut(), source, live); + grant_eot_leave_exile(runner.state_mut(), source, survivor); + evaluate_layers(runner.state_mut()); + + // Reach guards: both creatures on the battlefield, both hosting the rider. + assert_eq!(runner.state().objects[&live].zone, Zone::Battlefield); + assert_eq!(runner.state().objects[&survivor].zone, Zone::Battlefield); + assert!( + hosts_leave_exile_replacement(runner.state(), live), + "the EOT grant must install the rider on `live` after evaluate_layers" + ); + assert!( + hosts_leave_exile_replacement(runner.state(), survivor), + "the EOT grant must install the rider on `survivor` before cleanup" + ); + + // Assertion 1 — rider LIVE within the turn: a same-turn destroy exiles `live`. + destroy(runner.state_mut(), source, live); + assert_eq!( + runner.state().objects[&live].zone, + Zone::Exile, + "within the turn the EOT rider is live and must redirect the destruction \ + to exile" + ); + + // CR 514.2 end-of-turn cleanup: the STATED-duration grant must be pruned. + prune_end_of_turn_effects(runner.state_mut()); + evaluate_layers(runner.state_mut()); + + // Assertion 2 — rider LAPSED after cleanup: the rider is gone from `survivor` + // and a fresh destroy sends it to the GRAVEYARD, not exile. Revert-sensitive: + // if the EOT grant were wrongly promoted to Permanent (the F2 mis-wire), the + // rider would survive cleanup and `survivor` would EXILE instead — failing + // here while every Geth assertion still passes. + assert!( + !hosts_leave_exile_replacement(runner.state(), survivor), + "the EOT grant (CR 514.2) must lapse at cleanup — the rider must be gone \ + from `survivor` after prune + evaluate_layers" + ); + assert_eq!( + runner.state().objects[&survivor].zone, + Zone::Battlefield, + "`survivor` must still be on the battlefield to be destroyed post-cleanup" + ); + destroy(runner.state_mut(), source, survivor); + assert_eq!( + runner.state().objects[&survivor].zone, + Zone::Graveyard, + "after cleanup the lapsed rider must NOT redirect: `survivor` dies to the \ + graveyard (revert-failing for the F2 EOT-confinement)" + ); +} diff --git a/crates/engine/tests/integration/kaito_integration.rs b/crates/engine/tests/integration/kaito_integration.rs index 9da46cca80..4a37fa84e6 100644 --- a/crates/engine/tests/integration/kaito_integration.rs +++ b/crates/engine/tests/integration/kaito_integration.rs @@ -67,6 +67,7 @@ fn ninja_pump_static() -> StaticDefinition { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, } } diff --git a/crates/engine/tests/integration/kamigawa_flip_cards.rs b/crates/engine/tests/integration/kamigawa_flip_cards.rs new file mode 100644 index 0000000000..58f1b14c44 --- /dev/null +++ b/crates/engine/tests/integration/kamigawa_flip_cards.rs @@ -0,0 +1,605 @@ +//! CR 710: Kamigawa flip cards, end to end. +//! +//! Every test here builds its permanent from the card's VERBATIM Oracle text +//! and resolves the flip instruction through the real stack (`push_to_stack` +//! plus `GameAction::PassPriority` submitted through `apply()`), so the parser +//! dispatch, the `Effect` lowering, the effects dispatcher, and +//! `flip::flip_permanent` all run in production order. +//! +//! The rules-bearing assertion is CR 710.1c (`flip_keeps_color_and_mana_cost`): +//! a flip card's color and mana cost don't change when the permanent flips. +//! Reverting `flip::apply_flipped_face_to_object` to the double-faced +//! applicator (`printed_cards::apply_back_face_to_object`) blanks both and +//! fails exactly that test while every other assertion here still passes. + +use engine::game::ability_utils::build_resolved_from_def; +use engine::game::game_object::BackFaceData; +use engine::game::layers::flush_layers; +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::game::stack; +use engine::types::ability::{AbilityDefinition, AbilityKind, Effect, EffectKind}; +use engine::types::actions::GameAction; +use engine::types::card::LayoutKind; +use engine::types::card_type::{CardType, CoreType, Supertype}; +use engine::types::events::GameEvent; +use engine::types::game_state::{StackEntry, StackEntryKind}; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard}; +use engine::types::zones::Zone; + +/// Bushi Tenderfoot's verbatim Oracle text (MTGJSON `text`, face a). +const BUSHI_TENDERFOOT_ORACLE: &str = + "When a creature dealt damage by this creature this turn dies, flip this creature."; + +/// `{W}` — Bushi Tenderfoot's printed mana cost (CR 202.1). +fn white_mana_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ManaCostShard::White], + generic: 0, + } +} + +/// Kenzo the Hardhearted — Bushi Tenderfoot's alternative half (CR 710.1b). +/// A real flip card's bottom half has NO printed mana cost, which is why +/// reusing the double-faced applicator would violate CR 710.1c. +fn kenzo_alternative_face() -> BackFaceData { + BackFaceData { + name: "Kenzo the Hardhearted".to_string(), + power: Some(3), + toughness: Some(4), + loyalty: None, + defense: None, + card_types: CardType { + supertypes: vec![Supertype::Legendary], + core_types: vec![CoreType::Creature], + subtypes: vec!["Human".to_string(), "Samurai".to_string()], + }, + mana_cost: ManaCost::default(), + keywords: vec![Keyword::DoubleStrike, Keyword::Bushido(2)], + abilities: Vec::new(), + trigger_definitions: Default::default(), + replacement_definitions: Default::default(), + static_definitions: Default::default(), + color: Vec::new(), + printed_ref: None, + modal: None, + additional_cost: None, + strive_cost: None, + casting_restrictions: vec![], + casting_options: vec![], + layout_kind: Some(LayoutKind::Flip), + } +} + +/// Build a battlefield Bushi Tenderfoot from its verbatim Oracle text, with the +/// Kenzo half stashed exactly as `printed_cards::populate_back_face_if_dfc` +/// stores it for a `CardLayout::Flip` card. +fn bushi_tenderfoot_on_battlefield() -> (GameRunner, ObjectId) { + let mut scenario = GameScenario::new(); + let id = scenario + .add_creature_from_oracle(P0, "Bushi Tenderfoot", 1, 1, BUSHI_TENDERFOOT_ORACLE) + .id(); + let mut runner = scenario.build(); + { + let object = runner.state_mut().objects.get_mut(&id).unwrap(); + object.mana_cost = white_mana_cost(); + object.base_mana_cost = object.mana_cost.clone(); + object.color = vec![ManaColor::White]; + object.base_color = object.color.clone(); + object.back_face = Some(kenzo_alternative_face()); + } + (runner, id) +} + +/// Akki Lavarunner's verbatim Oracle text (MTGJSON `text`, face a). Its flip +/// instruction uses the BARE OBJECT PRONOUN form ("flip it"), which 8 of the 19 +/// corpus cards carrying a flip instruction use — the single most common +/// surface form, and the one that lowers to `TargetFilter::ParentTarget`. +const AKKI_LAVARUNNER_ORACLE: &str = "Haste +Whenever this creature deals damage to an opponent, flip it."; + +/// Tok-Tok, Volcano Born — Akki Lavarunner's alternative half (CR 710.1b). +/// A 2/2 Legendary Creature — Goblin Shaman with a damage-prevention static. +fn tok_tok_alternative_face() -> BackFaceData { + BackFaceData { + name: "Tok-Tok, Volcano Born".to_string(), + power: Some(2), + toughness: Some(2), + loyalty: None, + defense: None, + card_types: CardType { + supertypes: vec![Supertype::Legendary], + core_types: vec![CoreType::Creature], + subtypes: vec!["Goblin".to_string(), "Shaman".to_string()], + }, + mana_cost: ManaCost::default(), + keywords: Vec::new(), + abilities: Vec::new(), + trigger_definitions: Default::default(), + replacement_definitions: Default::default(), + static_definitions: Default::default(), + color: Vec::new(), + printed_ref: None, + modal: None, + additional_cost: None, + strive_cost: None, + casting_restrictions: vec![], + casting_options: vec![], + layout_kind: Some(LayoutKind::Flip), + } +} + +/// `{1}{R}` — Akki Lavarunner's printed mana cost (CR 202.1). +fn one_red_mana_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ManaCostShard::Red], + generic: 1, + } +} + +/// Build a battlefield Akki Lavarunner from its verbatim Oracle text. +fn akki_lavarunner_on_battlefield() -> (GameRunner, ObjectId) { + let mut scenario = GameScenario::new(); + let id = scenario + .add_creature_from_oracle(P0, "Akki Lavarunner", 1, 1, AKKI_LAVARUNNER_ORACLE) + .id(); + let mut runner = scenario.build(); + { + let object = runner.state_mut().objects.get_mut(&id).unwrap(); + object.mana_cost = one_red_mana_cost(); + object.base_mana_cost = object.mana_cost.clone(); + object.color = vec![ManaColor::Red]; + object.base_color = object.color.clone(); + object.back_face = Some(tok_tok_alternative_face()); + } + (runner, id) +} + +/// The parsed body of `source`'s first trigger — for Bushi Tenderfoot, the +/// "flip this creature" instruction. +/// +/// Captured separately from resolution because flipping REPLACES the +/// permanent's text box (CR 710.1b): after the flip, Kenzo the Hardhearted has +/// no triggered ability at all, so the repeat-flip test must re-push the body +/// it captured before the flip. +fn captured_trigger_body( + runner: &GameRunner, + source: ObjectId, + source_name: &str, +) -> AbilityDefinition { + runner.state().objects[&source] + .trigger_definitions + .as_slice() + .first() + .and_then(|entry| entry.definition.execute.as_deref().cloned()) + .unwrap_or_else(|| panic!("{source_name} must parse a trigger with an execute body")) +} + +/// Push `execute` onto the stack as a triggered ability of `source` and resolve +/// it by submitting real `GameAction::PassPriority` actions through `apply()`, +/// returning every `GameEvent` the engine emitted along the way. +fn resolve_trigger_body( + runner: &mut GameRunner, + source: ObjectId, + source_name: &str, + execute: &AbilityDefinition, +) -> Vec { + let ability = build_resolved_from_def(execute, source, P0); + let entry_id = ObjectId(runner.state().next_object_id); + runner.state_mut().next_object_id += 1; + stack::push_to_stack( + runner.state_mut(), + StackEntry { + id: entry_id, + source_id: source, + controller: P0, + kind: StackEntryKind::TriggeredAbility { + source_id: source, + ability: Box::new(ability), + condition: None, + trigger_event: None, + description: None, + source_name: source_name.to_string(), + subject_match_count: None, + die_result: None, + }, + }, + &mut vec![], + ); + + let mut emitted = Vec::new(); + let initial_stack_len = runner.state().stack.len(); + for _ in 0..10 { + if runner.state().stack.len() < initial_stack_len { + break; + } + match runner.act(GameAction::PassPriority) { + Ok(result) => emitted.extend(result.events), + Err(_) => break, + } + } + flush_layers(runner.state_mut()); + emitted +} + +/// CR 710.1b: once the permanent is flipped, the alternative name, type line, +/// power, toughness, and text box apply instead of the normal ones. +#[test] +fn flip_applies_the_alternative_name_type_line_pt_and_text_box() { + let (mut runner, bushi) = bushi_tenderfoot_on_battlefield(); + assert_eq!(runner.state().objects[&bushi].name, "Bushi Tenderfoot"); + assert!(!runner.state().objects[&bushi].flipped); + + let execute = captured_trigger_body(&runner, bushi, "Bushi Tenderfoot"); + let _ = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + + let object = &runner.state().objects[&bushi]; + assert!(object.flipped, "CR 710.4: the permanent is now flipped"); + assert_eq!(object.name, "Kenzo the Hardhearted"); + assert_eq!( + (object.power, object.toughness), + (Some(3), Some(4)), + "CR 710.1b: the alternative power and toughness apply" + ); + assert!( + object.card_types.supertypes.contains(&Supertype::Legendary), + "CR 710.1b: the alternative type line applies" + ); + assert!( + object + .card_types + .subtypes + .iter() + .any(|subtype| subtype == "Samurai"), + "CR 710.1b: the alternative type line applies" + ); + assert!( + object.keywords.contains(&Keyword::DoubleStrike), + "CR 710.1b: the alternative text box applies" + ); + assert!( + object.trigger_definitions.as_slice().is_empty(), + "CR 710.1b + CR 710.2: the normal half's triggered ability no longer applies once the permanent is flipped" + ); +} + +/// CR 710.1c: a flip card's color and mana cost DON'T change if the permanent +/// is flipped. +/// +/// This is the assertion that fails if `flip::apply_flipped_face_to_object` is +/// replaced by the double-faced applicator: Kenzo's half has no printed mana +/// cost and no color, so the permanent would become a colorless {0} object. +#[test] +fn flip_keeps_color_and_mana_cost() { + let (mut runner, bushi) = bushi_tenderfoot_on_battlefield(); + + let execute = captured_trigger_body(&runner, bushi, "Bushi Tenderfoot"); + let _ = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + + let object = &runner.state().objects[&bushi]; + assert!( + object.flipped, + "reach guard: the permanent actually flipped" + ); + assert_eq!(object.name, "Kenzo the Hardhearted", "reach guard"); + assert_eq!( + object.mana_cost, + white_mana_cost(), + "CR 710.1c: a flip card's mana cost doesn't change when it flips" + ); + assert_eq!( + object.base_mana_cost, + white_mana_cost(), + "CR 710.1c: the printed baseline keeps the normal half's mana cost" + ); + assert_eq!( + object.color, + vec![ManaColor::White], + "CR 710.1c: a flip card's color doesn't change when it flips" + ); + assert_eq!(object.base_color, vec![ManaColor::White]); +} + +/// CR 710.4: flipping a permanent is a one-way process. A second flip +/// instruction does nothing and emits no second `Flipped` event. +#[test] +fn flipping_an_already_flipped_permanent_is_a_no_op() { + let (mut runner, bushi) = bushi_tenderfoot_on_battlefield(); + + let execute = captured_trigger_body(&runner, bushi, "Bushi Tenderfoot"); + let first = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + assert!(runner.state().objects[&bushi].flipped, "reach guard"); + assert_eq!( + first + .iter() + .filter( + |event| matches!(event, GameEvent::Flipped { object_id } if *object_id == bushi) + ) + .count(), + 1, + "reach guard: the first instruction really did flip the permanent" + ); + let name_after_first = runner.state().objects[&bushi].name.clone(); + + let second = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + + // Reach guard: the second flip effect actually resolved (got past dispatch + // into `flip_permanent::resolve`), so the absence of a second `Flipped` + // event below is the CR 710.4 one-way no-op — not an upstream short-circuit + // that skipped the effect entirely. + assert!( + second.iter().any(|event| matches!( + event, + GameEvent::EffectResolved { + kind: EffectKind::FlipPermanent, + .. + } + )), + "the repeat FlipPermanent effect must reach the resolver even when it no-ops" + ); + + let state = runner.state(); + assert!(state.objects[&bushi].flipped); + assert_eq!( + state.objects[&bushi].name, name_after_first, + "CR 710.4: the permanent cannot flip back or flip again" + ); + assert_eq!( + (state.objects[&bushi].power, state.objects[&bushi].toughness), + (Some(3), Some(4)) + ); + assert_eq!( + second + .iter() + .filter( + |event| matches!(event, GameEvent::Flipped { object_id } if *object_id == bushi) + ) + .count(), + 0, + "CR 710.4: the repeat instruction emits no second Flipped event" + ); +} + +/// CR 710.2 + CR 710.4 + CR 110.5: in every zone other than the battlefield a +/// flip card has only the normal characteristics of the card, and a flipped +/// permanent that leaves the battlefield retains no memory of its status. +#[test] +fn a_flipped_permanent_leaving_the_battlefield_shows_only_normal_characteristics() { + let (mut runner, bushi) = bushi_tenderfoot_on_battlefield(); + + let execute = captured_trigger_body(&runner, bushi, "Bushi Tenderfoot"); + let _ = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + assert!(runner.state().objects[&bushi].flipped, "reach guard"); + assert_eq!(runner.state().objects[&bushi].name, "Kenzo the Hardhearted"); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), bushi, Zone::Graveyard, &mut events); + + let object = &runner.state().objects[&bushi]; + assert_eq!(object.zone, Zone::Graveyard); + assert!( + !object.flipped, + "CR 110.5b + CR 710.4: the card in the graveyard is not flipped" + ); + assert_eq!( + object.name, "Bushi Tenderfoot", + "CR 710.2: only the normal characteristics apply off the battlefield" + ); + assert_eq!((object.power, object.toughness), (Some(1), Some(1))); + assert!(!object.card_types.supertypes.contains(&Supertype::Legendary)); + assert_eq!( + object.mana_cost, + white_mana_cost(), + "CR 710.1c: the mana cost was never changed, so the revert restores it unchanged" + ); +} + +/// CR 710.1b + CR 710.2: a flip card sitting in a non-battlefield zone cannot +/// be flipped at all — the alternative characteristics exist only for a +/// battlefield permanent. +#[test] +fn a_flip_card_off_the_battlefield_cannot_flip() { + let (mut runner, bushi) = bushi_tenderfoot_on_battlefield(); + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), bushi, Zone::Graveyard, &mut events); + + let execute = captured_trigger_body(&runner, bushi, "Bushi Tenderfoot"); + let events = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + + // Reach guard: the flip effect actually resolved (got past dispatch into + // `flip_permanent::resolve`), so the unchanged object below reflects the CR + // 710.2 off-battlefield no-op, not an upstream short-circuit that never ran. + assert!( + events.iter().any(|event| matches!( + event, + GameEvent::EffectResolved { + kind: EffectKind::FlipPermanent, + .. + } + )), + "the FlipPermanent effect must reach the resolver even when it no-ops" + ); + + let object = &runner.state().objects[&bushi]; + assert!(!object.flipped); + assert_eq!(object.name, "Bushi Tenderfoot"); + assert_eq!((object.power, object.toughness), (Some(1), Some(1))); +} + +/// The verbatim Oracle text (MTGJSON `text`, face a) of every printed flip card +/// that carries a flip INSTRUCTION — 19 of the 21 CR 710 flip cards. +/// +/// The two excluded cards carry no flip instruction and are documented +/// out-of-scope in `game::flip`'s module docs: +/// - Homura, Human Ascendant — "return it to the battlefield flipped" is an +/// entry-time rider (CR 110.5b), not a flip instruction. +/// - Curse of the Fire Penguin — an Un-set Aura with no flip verb at all. +const FLIP_INSTRUCTION_CORPUS: &[(&str, &str)] = &[ + ("Akki Lavarunner", "Haste +Whenever this creature deals damage to an opponent, flip it."), + ("Budoka Gardener", "{T}: You may put a land card from your hand onto the battlefield. If you control ten or more lands, flip this creature."), + ("Budoka Pupil", "Whenever you cast a Spirit or Arcane spell, you may put a ki counter on this creature. +At the beginning of the end step, if there are two or more ki counters on this creature, you may flip it."), + ("Bushi Tenderfoot", "When a creature dealt damage by this creature this turn dies, flip this creature."), + ("Callow Jushi", "Whenever you cast a Spirit or Arcane spell, you may put a ki counter on this creature. +At the beginning of the end step, if there are two or more ki counters on this creature, you may flip it."), + ("Cunning Bandit", "Whenever you cast a Spirit or Arcane spell, you may put a ki counter on this creature. +At the beginning of the end step, if there are two or more ki counters on this creature, you may flip it."), + ("Erayo, Soratami Ascendant", "Flying +Whenever the fourth spell of a turn is cast, flip Erayo."), + ("Faithful Squire", "Whenever you cast a Spirit or Arcane spell, you may put a ki counter on this creature. +At the beginning of the end step, if there are two or more ki counters on this creature, you may flip it."), + ("Hired Muscle", "Whenever you cast a Spirit or Arcane spell, you may put a ki counter on this creature. +At the beginning of the end step, if there are two or more ki counters on this creature, you may flip it."), + ("Initiate of Blood", "{T}: This creature deals 1 damage to target creature that was dealt damage this turn. When that creature dies this turn, flip this creature."), + ("Jushi Apprentice", "{2}{U}, {T}: Draw a card. If you have nine or more cards in hand, flip this creature."), + ("Kitsune Mystic", "At the beginning of the end step, if this creature is enchanted by two or more Auras, flip it."), + ("Kuon, Ogre Ascendant", "At the beginning of the end step, if three or more creatures died this turn, flip Kuon."), + ("Nezumi Graverobber", "{1}{B}: Exile target card from an opponent's graveyard. If no cards are in that graveyard, flip this creature."), + ("Nezumi Shortfang", "{1}{B}, {T}: Target opponent discards a card. Then if that player has no cards in hand, flip this creature."), + ("Orochi Eggwatcher", "{2}{G}, {T}: Create a 1/1 green Snake creature token. If you control ten or more creatures, flip this creature."), + ("Rune-Tail, Kitsune Ascendant", "When you have 30 or more life, flip Rune-Tail."), + ("Sasaya, Orochi Ascendant", "Reveal your hand: If you have seven or more land cards in your hand, flip Sasaya."), + ("Student of Elements", "When this creature has flying, flip it."), +]; + +/// True when any effect anywhere in `definition`'s chain is `FlipPermanent`, +/// including inside a delayed-trigger body (Initiate of Blood's "When that +/// creature dies this turn, flip this creature" rider, CR 603.7a). +fn chain_contains_flip_permanent(definition: &AbilityDefinition) -> bool { + if matches!(*definition.effect, Effect::FlipPermanent { .. }) { + return true; + } + if let Effect::CreateDelayedTrigger { effect, .. } = definition.effect.as_ref() { + if chain_contains_flip_permanent(effect) { + return true; + } + } + definition + .sub_ability + .iter() + .chain(definition.else_ability.iter()) + .map(|boxed| boxed.as_ref()) + .chain(definition.mode_abilities.iter()) + .any(chain_contains_flip_permanent) +} + +/// CR 710.4: EVERY printed flip card's flip instruction lowers to +/// `Effect::FlipPermanent`, across all three surface forms — "flip this +/// creature" (7 cards), "flip it" (8 cards), and the by-name "flip <name>" +/// (4 cards, normalized to `~` upstream) — and across both carriers (activated +/// abilities and triggered abilities). This is the build-for-the-class proof: +/// the recognizer is not tuned to one card's phrasing. +#[test] +fn every_printed_flip_card_lowers_its_instruction_to_flip_permanent() { + let mut missing = Vec::new(); + for (card_name, oracle) in FLIP_INSTRUCTION_CORPUS { + let parsed = engine::parser::oracle::parse_oracle_text(oracle, card_name, &[], &[], &[]); + let found = parsed.abilities.iter().any(chain_contains_flip_permanent) + || parsed.triggers.iter().any(|trigger| { + trigger + .execute + .as_deref() + .is_some_and(chain_contains_flip_permanent) + }); + if !found { + missing.push(*card_name); + } + } + assert!( + missing.is_empty(), + "these flip cards did not lower their flip instruction to Effect::FlipPermanent: {missing:?}" + ); +} + +/// CR 710.4 + CR 608.2k: the BARE OBJECT PRONOUN form ("flip it") — an +/// untargeted back-reference to the object named by the trigger condition — +/// resolves and flips the ability's own source, end to end. +/// +/// This is the surface form 8 of the 19 corpus cards use (Akki Lavarunner, the +/// five ki-counter Ascendants, Kitsune Mystic, Student of Elements), and it +/// lowers to a different `TargetFilter` than the self-deictic "flip this +/// creature" form: `resolve_pronoun_target` yields `TargetFilter::ParentTarget` +/// whenever the parse context has no non-self trigger subject, which is true for +/// all eight. It reaches `flip_permanent` only through +/// `effects::flip_permanent`'s empty-`targets` arm (`[] => ability.source_id`). +/// +/// Discriminating: break that arm (return an error, or resolve `ParentTarget` +/// to anything other than the source) and the permanent never flips — the +/// `flipped` / name / power assertions below all fail. The parse assertion +/// alone would not catch it; this drives the real stack via +/// `GameAction::PassPriority` through `apply()`. +#[test] +fn the_flip_it_pronoun_form_flips_its_own_source_end_to_end() { + let (mut runner, akki) = akki_lavarunner_on_battlefield(); + assert_eq!(runner.state().objects[&akki].name, "Akki Lavarunner"); + assert!(!runner.state().objects[&akki].flipped); + + let execute = captured_trigger_body(&runner, akki, "Akki Lavarunner"); + // Reach guard: the "flip it" form really does take the ParentTarget branch + // (not the SelfRef branch the "flip this creature" cards take), so this test + // exercises the arm the majority of the corpus depends on. + assert!( + matches!( + *execute.effect, + Effect::FlipPermanent { + target: engine::types::ability::TargetFilter::ParentTarget + } + ), + "reach guard: 'flip it' must lower to ParentTarget, got {:?}", + execute.effect + ); + + let emitted = resolve_trigger_body(&mut runner, akki, "Akki Lavarunner", &execute); + + let object = &runner.state().objects[&akki]; + assert!(object.flipped, "CR 710.4: the permanent is now flipped"); + assert_eq!(object.name, "Tok-Tok, Volcano Born"); + assert_eq!( + (object.power, object.toughness), + (Some(2), Some(2)), + "CR 710.1b: the alternative power and toughness apply" + ); + assert!( + object.card_types.supertypes.contains(&Supertype::Legendary), + "CR 710.1b: the alternative type line applies" + ); + assert_eq!( + object.mana_cost, + one_red_mana_cost(), + "CR 710.1c: a flip card's mana cost doesn't change when it flips" + ); + assert_eq!(object.color, vec![ManaColor::Red]); + assert_eq!( + emitted + .iter() + .filter(|event| matches!(event, GameEvent::Flipped { object_id } if *object_id == akki)) + .count(), + 1, + "exactly one Flipped event for the source" + ); +} + +/// CR 705.1 vs CR 710.4: the coin-flip mechanic is untouched — "flip a coin" +/// still lowers to `Effect::FlipCoin`, not to the new flip-permanent effect. +#[test] +fn flip_a_coin_still_parses_to_flip_coin() { + let definition = + engine::parser::oracle_effect::parse_effect_chain("Flip a coin.", AbilityKind::Spell); + assert!( + matches!(*definition.effect, Effect::FlipCoin { .. }), + "CR 705.1: 'flip a coin' must stay a coin flip, got {:?}", + definition.effect + ); + + let until_lose = engine::parser::oracle_effect::parse_effect_chain( + "Flip a coin until you lose a flip.", + AbilityKind::Spell, + ); + assert!( + matches!(*until_lose.effect, Effect::FlipCoinUntilLose { .. }), + "CR 705.1: the Krark-style repeat coin flip is unaffected, got {:?}", + until_lose.effect + ); +} diff --git a/crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs b/crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs new file mode 100644 index 0000000000..d38ec0cf43 --- /dev/null +++ b/crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs @@ -0,0 +1,159 @@ +//! Lictor (Warhammer 40,000 Commander) — Pheromone Trail: +//! "When this creature enters, if a creature entered the battlefield under an +//! opponent's control this turn, create a 3/3 green Tyranid Warrior creature +//! token with trample." +//! +//! Regression for the dropped opponent-scoped "entered … under an opponent's +//! control this turn" intervening-"if" (CR 603.4). Before the fix the condition +//! parsed to `None`, so the ETB trigger fired UNCONDITIONALLY and Lictor made a +//! token every time it entered — even with no opponent entry that turn. +//! +//! The "under your control" surface of this class was already supported; this +//! adds the opponent-scoped past-tense mirror, carried by +//! `PlayerScope::Opponent { Max }` (the existential "an opponent" reading, per +//! `parse_opponent_had_entered_this_turn`) over the CR 608.2i +//! `BattlefieldEntriesThisTurn` snapshot. + +use engine::game::restrictions::record_battlefield_entry; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::parser::parse_oracle_text; +use engine::types::ability::{ + AggregateFunction, Comparator, PlayerScope, QuantityExpr, QuantityRef, TargetFilter, + TriggerCondition, TypeFilter, +}; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; + +const LICTOR: &str = + "Flash\nPheromone Trail — When this creature enters, if a creature entered the \ +battlefield under an opponent's control this turn, create a 3/3 green Tyranid Warrior creature \ +token with trample."; + +/// Stamp `id` into the production battlefield-entry ledger for the current turn, +/// exactly as `record_zone_change` does in a real game. +fn record_entry_now(runner: &mut GameRunner, id: ObjectId) { + let turn = runner.state().turn_number; + record_battlefield_entry(runner.state_mut(), id); + runner + .state_mut() + .objects + .get_mut(&id) + .unwrap() + .entered_battlefield_turn = Some(turn); +} + +/// Count battlefield Tyranid Warrior tokens (Lictor's Pheromone Trail output). +fn tyranid_warrior_count(runner: &GameRunner) -> usize { + runner + .state() + .battlefield + .iter() + .filter(|id| { + runner + .state() + .objects + .get(id) + .is_some_and(|o| o.is_token && o.name == "Tyranid Warrior") + }) + .count() +} + +/// Parse-level shape lock: the intervening-"if" must lower to the opponent-scoped +/// `BattlefieldEntriesThisTurn` comparison, NOT a dropped `None`. +/// +/// REVERT-PROBE: remove `parse_entered_this_turn_under_opponent_control` and the +/// condition returns to `None`, panicking here. +#[test] +fn lictor_condition_is_opponent_scoped_entry_tally() { + let parsed = parse_oracle_text( + LICTOR, + "Lictor", + &[], + &["Creature".to_string()], + &["Tyranid".to_string()], + ); + let trigger = parsed + .triggers + .iter() + .find(|t| t.condition.is_some()) + .expect("Lictor's ETB must carry an intervening-if condition, not a dropped None"); + match trigger.condition.as_ref().unwrap() { + TriggerCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::BattlefieldEntriesThisTurn { + player: + PlayerScope::Opponent { + aggregate: AggregateFunction::Max, + }, + filter: TargetFilter::Typed(f), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + } => { + assert_eq!(f.controller, None, "controller lives on the PlayerScope"); + assert!( + f.type_filters.contains(&TypeFilter::Creature), + "the creature restriction must survive, got {:?}", + f.type_filters + ); + } + other => panic!("expected opponent-scoped BattlefieldEntriesThisTurn GE 1, got {other:?}"), + } +} + +/// Positive: an opponent's creature entered this turn ⇒ Pheromone Trail fires. +#[test] +fn lictor_makes_token_when_opponent_creature_entered() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let lictor = scenario + .add_creature_to_hand_from_oracle(P0, "Lictor", 2, 3, LICTOR) + .id(); + // An opponent (P1) creature that entered the battlefield this turn. + let opp_creature = scenario.add_creature(P1, "Opponent Entrant", 2, 2).id(); + let mut runner = scenario.build(); + record_entry_now(&mut runner, opp_creature); + + runner.cast(lictor).resolve(); + runner.advance_until_stack_empty(); + + assert_eq!( + tyranid_warrior_count(&runner), + 1, + "CR 603.4: the intervening-if is TRUE (an opponent's creature entered this \ + turn), so Pheromone Trail creates a Tyranid Warrior" + ); +} + +/// Negative discriminator: only Lictor itself entered (under P0's control), so no +/// opponent entry exists ⇒ Pheromone Trail must NOT fire. +/// +/// REVERT-PROBE: with the condition dropped to `None` the trigger fires +/// unconditionally and this reads 1 token, FAIL. This is the load-bearing +/// assertion — it fails on the unfixed engine. +#[test] +fn lictor_makes_no_token_without_opponent_entry() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let lictor = scenario + .add_creature_to_hand_from_oracle(P0, "Lictor", 2, 3, LICTOR) + .id(); + // A P0 creature that entered this turn — under YOUR control, not an + // opponent's — plus Lictor's own entry. Neither satisfies the opponent scope. + let own_creature = scenario.add_creature(P0, "Own Entrant", 2, 2).id(); + let mut runner = scenario.build(); + record_entry_now(&mut runner, own_creature); + + runner.cast(lictor).resolve(); + runner.advance_until_stack_empty(); + + assert_eq!( + tyranid_warrior_count(&runner), + 0, + "CR 603.4: no creature entered under an OPPONENT's control this turn, so \ + the intervening-if is FALSE and no token is created" + ); +} diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index d5b5909ef5..8d43143b31 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -19,7 +19,7 @@ use engine::analysis::decision_template::{ TargetPin, TargetSchedule, }; use engine::analysis::loop_check::{LoopCertificate, ShortcutProposal, ShortcutResponse, WinKind}; -use engine::analysis::resource::{BoardDelta, ResourceAxis}; +use engine::analysis::resource::{loop_states_equal_modulo_resources, BoardDelta, ResourceAxis}; use engine::game::engine::{apply, EngineError}; use engine::game::scenario::{GameRunner, GameScenario}; use engine::types::ability::{Effect, TargetRef}; @@ -194,6 +194,9 @@ fn setup_3p_draw(mode: LoopDetectionMode) -> (GameRunner, ObjectId) { /// partition each cycle: fallers = {P1}, non-fallers = {P0, P2} — so `live_mandatory_loop_winner` /// refuses to name a winner (CR 104.2a). P1 starts very high so it never dies inside the drive /// window: the test asserts the mid-loop grind (no crown), not a natural CR 704.5a death. +/// The headroom is deliberate and far exceeds `PRIMED_LOOP_BEATS` — P1 ends the capped drive +/// ~14 points below its 1000 start, hundreds of points from lethal. Do NOT trim it to match +/// the cap; the slack is what keeps the no-death premise robust to engine drift. fn setup_3p_subset_lethal(mode: LoopDetectionMode) -> (GameRunner, ObjectId) { let mut scenario = GameScenario::new_n_player(3, 7); scenario.at_phase(Phase::PreCombatMain); @@ -274,6 +277,55 @@ fn drive_collect(runner: &mut GameRunner, cap: usize) -> (Vec, Waitin (all, runner.state().waiting_for.clone()) } +/// [`drive_collect`] plus a measured answer to "did the drive actually reach the board-recurrent +/// regime inside `cap`?" — the third element is `true` once some prior in +/// `GameState::loop_detect_ring` has compared equal to the live state modulo resources +/// ([`loop_states_equal_modulo_resources`], the engine's own public predicate; the test does not +/// reimplement recurrence). +/// +/// Why that witnesses the classification: the sampler only pushes a prior while the stack is +/// non-empty at a `Priority` beat, and `GameState`'s `PartialEq` compares both, so a ring hit +/// forces the §3 bridge conjuncts (engine.rs) to have held at that state — i.e. +/// `find_live_loop_winner` → `live_mandatory_loop_winner` ran on it. It is deliberately STRONGER +/// than "the classifier ran": the classifier is called on every sampled beat, so a `false` here +/// does not mean it never ran — it means the loop never recurred, which is the regime every +/// no-crown assertion below assumes. Note the engine's own faller partition short-circuits +/// (`nonfallers.len() != 1`, loop_check.rs) BEFORE its recurrence gate for these subset-lethal +/// fixtures; the recurrence witnessed here is the state property, not that specific branch. +/// +/// Only the equality disjunct is checked: the engine's gate is +/// `loop_states_equal_modulo_resources || loop_states_cover_modulo_growth`, and the latter is +/// `pub(crate)` (resource.rs), so an integration test cannot call it. Measured, both fixtures +/// still hit the equality disjunct; a fixture that drifted entirely into the coverability regime +/// would fail this guard and need it widened, not the cap raised. +/// +/// Checked per beat, because recurrence is PHASE-dependent, not monotone: measured over caps +/// {1, 2, 3, 4, 8, 24} it holds at 2 and 8 and not at 1/3/4/24 (period 6), so inspecting only +/// the terminal state would report `false` on a perfectly primed loop. The scan short-circuits +/// at the first hit — measured 5.6–17.5 ms per drive, priming at beat 2. +fn drive_collect_primed(runner: &mut GameRunner, cap: usize) -> (Vec, WaitingFor, bool) { + let mut all: Vec = Vec::new(); + let mut primed = false; + for _ in 0..cap { + if !matches!( + runner.state().waiting_for, + WaitingFor::Priority { .. } | WaitingFor::OrderTriggers { .. } + ) { + break; + } + let (events, _) = drive_collect(runner, 1); + all.extend(events); + if !primed { + let state = runner.state(); + primed = state + .loop_detect_ring + .iter() + .any(|prior| loop_states_equal_modulo_resources(prior, state)); + } + } + (all, runner.state().waiting_for.clone(), primed) +} + // ────────────────────────────── T-OFF ────────────────────────────── /// T-OFF: the real winning drain under `Off` reaches the natural CR 704.5a SBA death — no @@ -1099,6 +1151,48 @@ fn interactive_queued_opponent_concede_no_deadlock() { // ───────────── T-subset-lethal (D2 — nonfallers.len()==1 guard) ───────────── +/// Drive cap for the three tests below. Measured beats actually consumed at this cap, per +/// drive: `setup_3p_subset_lethal` **24/24** (both tests), `setup_3p_both_fall(1000, 1050)` +/// **24/24**, `setup_3p_both_fall(1000, 1000)` **0/24**. The first three genuinely pay every +/// beat — that bridge deliberately falls through to the pre-feature grind, so their drives +/// never reach `drive_collect`'s terminal-state exit. The equal-life half is the exception and +/// costs nothing at ANY cap: its loop is mandatory with a single non-faller, so the natural +/// bridge already crowned `GameOver{Some(P0)}` while the kick-off resolved, and the drive +/// returns on beat 0. That is pre-existing and cap-independent — it is why shrinking this +/// constant speeds up three drives, not four. +/// +/// Measured per-beat cost, `setup_3p_subset_lethal`: ~40 ms at invariant state (stack 1, +/// 4 objects). `setup_3p_both_fall` is NOT flat — its stack grows 13 → 45 and its per-beat +/// cost 78 → 152 ms over 200 beats — so its justification rests on verdict invariance, not on +/// periodicity. +/// +/// MEASURED — DO NOT CHANGE to a value outside the swept set {4, 8, 12, 16, 24, 32, 48, 64, +/// 100} without re-running the cap sweep (an unswept *lowering* is as uncovered as a raise). +/// Across that whole swept range the PASS/FAIL verdict and every reach-guard of the three +/// tests below are invariant, and each test's revert-probe still flips it to FAIL at every +/// cap ≥ 4 (weakened `nonfallers.len() != 1`; bypassed E1-measure `live_mandatory_loop_winner` +/// gate; removed F2 `fallers_lives_pairwise_equal` re-check). Board recurrence — the regime +/// every assertion below assumes — is first observed at **beat 2** (measured over caps +/// {1, 2, 3, 4, 8, 24}), so more beats buy zero discrimination and only burn wall clock; 24 is +/// itself a swept value, 12× that priming point and 6× the smallest swept cap, not an +/// interpolation. +/// +/// The cap does NOT stand on the sweep alone: each test below carries an explicit +/// [`drive_collect_primed`] guard that FAILS if the loop has not reached board recurrence inside +/// the cap — proven discriminating by forcing this constant to 1, which flips all three tests to +/// FAIL on that guard. That closes the cap-adequacy question, and only that. +/// +/// It does NOT close a separate, pre-existing blind spot, and no cap does either. Measured: +/// suppressing the live-detect bridge outright (make its `!loop_detect_ring.is_empty()` conjunct +/// unreachable in engine.rs) leaves all three tests below GREEN at this cap AND at cap 500, +/// while the offer-dependent `vito_2p_optional_offer_declare_crowns` and +/// `interactive_queued_opponent_concede_no_deadlock` FAIL. The guard does not catch that either: +/// the ring SAMPLER is a separate gate (engine.rs, `resolved_this_beat && …`) that the +/// suppression does not touch, so the ring still fills and this guard still reports primed. +/// A negative "did not crown" test cannot distinguish a refused classification from a disabled +/// one; the positive-side tests named above are what cover it. +const PRIMED_LOOP_BEATS: usize = 24; + /// D2: a 3p loop that drains ONLY P1 (P2 a bystander, life delta 0) must NOT crown. /// `live_mandatory_loop_winner` (loop_check.rs) partitions living into fallers/non-fallers and /// requires `nonfallers.len() == 1` (CR 104.2a — determinate only when EVERY other living @@ -1108,13 +1202,15 @@ fn interactive_queued_opponent_concede_no_deadlock() { /// pre-feature grind. /// /// REVERT-FAIL: weaken the `nonfallers.len() != 1` gate to an "any-faller wins" rewrite and -/// this MANDATORY loop is wrongly crowned `GameOver{winner: Some(P0)}` — flipping the two -/// no-crown assertions below. (Passes today, proving the gate holds.) +/// this MANDATORY loop is wrongly crowned `GameOver{winner: Some(P0)}` — flipping the `wf` +/// no-crown assertion below, which is the sole discriminator here: under that mutation the +/// event-scan assertion measurably stays TRUE (no `GameOver{Some}` lands in the collected +/// events) at every cap from 4 to 100. (Passes today, proving the gate holds.) #[test] fn interactive_3p_subset_lethal_does_not_crown() { let (mut runner, kickoff) = setup_3p_subset_lethal(LoopDetectionMode::Interactive); let _ = runner.cast(kickoff).resolve(); - let (events, wf) = drive_collect(&mut runner, 500); + let (events, wf, primed) = drive_collect_primed(&mut runner, PRIMED_LOOP_BEATS); // Positive reach-guard: the drain loop genuinely ran on P1 while P2 stayed untouched — we // are in the subset-lethal regime the gate must refuse, not an unrelated upstream no-op. @@ -1145,6 +1241,19 @@ fn interactive_3p_subset_lethal_does_not_crown() { !matches!(wf, WaitingFor::LoopShortcut { .. }), "subset-lethal loop must NOT raise a LoopShortcut offer, got {wf:?}" ); + + // Reach-guard on the regime itself (see `drive_collect_primed`): the drive really did reach + // a board-recurrent state, so `live_mandatory_loop_winner` ran on the subset-lethal loop the + // assertions above are about — they refused a real classification rather than never posing + // one. Deliberately LAST here: this test's classification and its wrongful-crown failure + // mode live in the same drive, so under the weakened-gate defect the crown must report as a + // crown (the `wf` assertion above) — measured, a guard placed first steals that panic (M1 + // crowns at beat ~1, before recurrence) and reports the wrong cause. + assert!( + primed, + "the loop never reached a board-recurrent state within {PRIMED_LOOP_BEATS} beats — this \ + is not the primed-loop regime the assertions above assume, so they passed vacuously" + ); } // ───────────── PR-7 Combo-UI Stage 2 — E1 drive-and-measure crown ────────────── @@ -1277,7 +1386,17 @@ fn vito_2p_optional_offer_declare_crowns() { fn injected_3p_one_faller_no_crown() { let (mut runner, kickoff) = setup_3p_subset_lethal(LoopDetectionMode::Interactive); let _ = runner.cast(kickoff).resolve(); - let (_events, _wf) = drive_collect(&mut runner, 500); + let (_events, _wf, primed) = drive_collect_primed(&mut runner, PRIMED_LOOP_BEATS); + + // Reach-guard on the regime (see `drive_collect_primed`): the board reached a genuine + // recurrence, so the E1 clone-drive below has a real cycle to measure rather than an + // un-primed board. (It witnesses the live bridge's frame pair, not the E1 measure's own + // boundary/work pair — those are different frames.) + assert!( + primed, + "the loop never reached a board-recurrent state within {PRIMED_LOOP_BEATS} beats — the \ + E1 measure below would have no primed cycle, so its no-crown assertion is vacuous" + ); // Reach-guard: the drain loop genuinely ran (P1 bled, alive) and P2 is untouched — this // is the subset-lethal regime the E1 measure must refuse. @@ -1475,11 +1594,11 @@ fn declare_illegal_pin_falls_back_legal_ingests() { fn injected_3p_unequal_life_pin_all_no_crown() { // Drive one primed cycle of a confirmed 3p both-fall drain and report the terminal // waiting_for. - fn drive_confirmed(p1_life: i32, p2_life: i32) -> WaitingFor { + fn drive_confirmed(p1_life: i32, p2_life: i32) -> (WaitingFor, bool) { let (mut runner, kickoff) = setup_3p_both_fall(LoopDetectionMode::Interactive, p1_life, p2_life); let _ = runner.cast(kickoff).resolve(); - let (_events, _wf) = drive_collect(&mut runner, 200); + let (_events, _wf, primed) = drive_collect_primed(&mut runner, PRIMED_LOOP_BEATS); // Reach-guard: both opponents bled equally (loop primed, both are fallers) and stay // pairwise-offset by the initial gap (equal deltas preserve the difference). assert!( @@ -1504,11 +1623,20 @@ fn injected_3p_unequal_life_pin_all_no_crown() { }) .expect("P0 declares UntilLethal"); accept_all_opponents(&mut runner); - runner.state().waiting_for.clone() + (runner.state().waiting_for.clone(), primed) } // UNEQUAL absolute life (gap 50) ⇒ NO crown (F2 staggered-death veto). - let unequal = drive_confirmed(1000, 1050); + let (unequal, unequal_primed) = drive_confirmed(1000, 1050); + // Reach-guard on the regime (see `drive_collect_primed`) — asserted on THIS half only: the + // equal-life half below is measured to consume 0 beats (already crowned as the kick-off + // resolved), so it has no drive in which to recur. This is the half the F2 revert-probe + // flips, so the whole discriminator lives on the guarded side. + assert!( + unequal_primed, + "the loop never reached a board-recurrent state within {PRIMED_LOOP_BEATS} beats — this \ + is not the ≥2-faller primed regime the assertions below assume" + ); assert!( !matches!(unequal, WaitingFor::GameOver { winner: Some(_) }), "unequal-life ≥2-faller drain must NOT crown (CR 704.3 simultaneity), got {unequal:?}" @@ -1519,7 +1647,7 @@ fn injected_3p_unequal_life_pin_all_no_crown() { ); // EQUAL absolute life ⇒ CROWN (reach-guard: the F2 check is not always-reject). - let equal = drive_confirmed(1000, 1000); + let (equal, _) = drive_confirmed(1000, 1000); assert_eq!( equal, WaitingFor::GameOver { winner: Some(P0) }, diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 81a3ae2ee2..6bb421bec8 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -93,8 +93,10 @@ mod court_of_cunning_multi_target_mill; mod cr733_resolved_commands_p0; mod cr733_resolved_commands_p1; mod cr733_resolved_commands_p2; +mod cr733_resolved_draw; mod cr733_resolved_frame_transition; mod cr733_resolved_trigger_collection; +mod cr733_resolved_zone_change; mod cr_annotations; mod craft_tithing_blade_transform; mod cross_line_instead_override_branch; @@ -162,6 +164,7 @@ mod eyetwitch_learn_decline_lesson; mod fact_or_fiction_pile_separation; mod fateful_handoff_target_mana_value_draw; mod festival_of_embers_graveyard_additional_cost; +mod fevered_visions; mod fewer_than_existential_threshold; mod field_marshal_soldier_anthem_first_strike; mod field_of_ruin_search; @@ -196,6 +199,8 @@ mod gollum_scheming_guide_card_predicate_guess; mod good_king_mog_xii_chapter_iv_588; mod gran_gran_integration; mod granted_alt_cost_hand_keyword; +mod granted_bloodthirst_5802; +mod granted_sunburst_5337; mod greater_good_activation; mod green_suns_zenith_regression; mod griffin_rider_conditional_self_buff; @@ -465,6 +470,8 @@ mod issue_4124_second_little_pig; mod issue_4220_agatha_soul_cauldron; mod issue_4226_elenda_azor_attack_pay_x; mod issue_4235_cloak_and_dagger_entwined; +mod issue_4240_damaged_player_anaphor_runtime; +mod issue_4240_sigil_of_sleep_target_scope; mod issue_4242_lagrella_crash; mod issue_4243_from_the_rubble; mod issue_4244_temple_altisaur; @@ -561,6 +568,7 @@ mod issue_5945_kellan_the_kid; mod issue_5946_pest_infestation_bogwater_softlock; mod issue_5963_scavengers_talent_food_sacrifice; mod issue_5972_tracked_set_token_cleanup; +mod issue_5976_unearth_leave_redirect; mod issue_5977_hunger_tide_chapter_iv; mod issue_5978_roar_chapter_ii_creature_mana; mod issue_5983_sothera_dies_edict; @@ -573,12 +581,16 @@ mod issue_5997_malcolm_treasure_trigger; mod issue_5999_aura_exile_host; mod issue_6002_neera_wild_mage_bottom_of_library; mod issue_6006_aminatou_veil_piercer_own_turn_miracle; +mod issue_6091_grindstone; mod issue_6092_ability_block_reason; mod issue_6102_ragavan_exile_cast; mod issue_6157_gold_token_auto_mana_payment; mod issue_629_fractured_sanity_cycling; +mod issue_6498_portent_of_calamity; +mod issue_6499_flickering_ward_protection_exemption; mod issue_6500_loreseekers_stone_hand_cost; mod issue_654_stridehangar_automaton; +mod issue_6566_granted_leave_exile; mod issue_680_shalai_and_hallar_forgotten_ancient; mod issue_680_shalai_upkeep_move; mod issue_688_mind_into_matter; @@ -618,6 +630,7 @@ mod jaws_of_defeat; mod json_smoke_test; mod judgment_bolt_where_x_damage_runtime; mod kaito_integration; +mod kamigawa_flip_cards; mod kaya_geist_hunter; mod kaya_spirits_justice_per_opponent_exile; mod kaysa_green_anthem; @@ -645,6 +658,7 @@ mod landing_zone_this_way_quantity; mod lathiel_end_step_counters_repro; mod leeching_sliver; mod leyline_taps_for_mana_repro; +mod lictor_opponent_entered_this_turn; mod lightning_dart_disjunctive_color_instead; mod liliana_dreadhorde_multi_dies; mod liliana_waker_cross_scope_decline; @@ -676,6 +690,7 @@ mod mechtitan_core_return_exiled; mod memory_plunder_free_cast_2884; mod mercenaries_any_player_activate_prevention_scope; mod merieke_ri_berit_cant_regenerate; +mod metamorphic_alteration; mod militant_angel_attacked_opponents; mod mill_double_redirect_choice_continuation; mod mill_rest_in_peace_redirect; @@ -695,6 +710,7 @@ mod msh_wave5a_group2_conditions; mod multi_layer_continuous_effect; mod multi_upkeep_triggers_suspend; mod must_attack_player_attribution; +mod mutable_pupa_perpetual_keyword_mirror; mod mycoloth_upkeep_trigger; mod myrkul_crew_phase1_incarnation; mod mystic_forge_regression; @@ -726,6 +742,7 @@ mod painters_servant_multi_zone_additive_color; mod palisade_giant_redirect; mod panther_habit_equipped_prevention_scope; mod peer_into_the_abyss; +mod peerless_recycling_gift_recipient; mod pendrell_flux_unless_pay_own_cost; mod perrie_the_pulverizer_attack_trigger_6219; mod peter_parker_modal_back_face_cast; @@ -753,6 +770,7 @@ mod repro_pilot_crew; mod rhys_evermore_remove_counters; mod riot_control_regression; mod ripples_of_undeath_regression; +mod riptide_gearhulk_5994; mod rite_of_consumption_damage; mod roots_of_wisdom_if_you_cant_draw; mod roughshod_mentor_green_trample_grant; @@ -800,6 +818,7 @@ mod std_s07_damage_doublers; mod steadfast_armasaur_lki_toughness; mod steelform_sliver_toughness_anthem; mod stensian_sanguinist_prepare; +mod strategic_betrayal_6505; mod summer_bloom_5979; mod sun_droplet_remove_counter_infeasible_4776; mod support; @@ -1040,6 +1059,7 @@ mod priest_of_the_crossing_died_under_control; mod quick_draw_target_opponent; mod rankle_and_torbran; mod razorkin_needlehead_opponent_draw_damage_2869; +mod reflexive_body_token_referent; mod reflexive_discard_this_way; mod reflexive_if_rider; mod reflexive_this_way_delayed_s25; @@ -1100,6 +1120,7 @@ mod taii_wakeen; mod tamiyo_inquisitive_student_flip; mod teamwork_keyword; mod teferis_puzzle_box_4241; +mod temporary_cant_be_blocked_view; mod tergrid_mirrormade_copy_on_effect_entry; mod the_mind_stone_harness_infinity; mod there_are_no_permanents_state_trigger; @@ -1124,6 +1145,7 @@ mod vannifar_cloak_from_hand; mod veteran_bodyguard_tap_redirect; mod vohar_discard_drain; mod weeping_angel_combat_prevention; +mod wheel_and_deal; mod where_x_coverage_runtime; mod where_x_quantity_channel_binds; mod where_x_totality_guard; diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs new file mode 100644 index 0000000000..67ac53a4d2 --- /dev/null +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -0,0 +1,795 @@ +//! GitHub #6013 — Metamorphic Alteration. +//! +//! ```text +//! Enchant creature +//! As this Aura enters, choose a creature. +//! Enchanted creature is a copy of the chosen creature. +//! ``` +//! +//! The Aura latches the CHOSEN creature's copiable values (fixed as the copy +//! effect first starts to apply, CR 707.2c; unaffected by later changes to the +//! chosen object, CR 707.2b) and installs them as a Layer-1 copy on its +//! ENCHANTED HOST — the entering Aura itself never becomes a copy. The install +//! reuses the single copy-values authority (`apply_precomputed_copy_values`), +//! and ends when the Aura leaves the battlefield (CR 400.7 / CR 611.2a). + +use engine::game::game_object::{AttachTarget, DisplaySource}; +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::parser::oracle::parse_oracle_text; +use engine::types::ability::{ + ChosenAttribute, ContinuousModification, Effect, FilterProp, TargetFilter, TypedFilter, +}; +use engine::types::card::TokenImageRef; +use engine::types::card_type::CoreType; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const METAMORPHIC_ALTERATION: &str = "Enchant creature\nAs this Aura enters, choose a creature.\nEnchanted creature is a copy of the chosen creature."; + +/// Stage Metamorphic Alteration in P0's hand as a {2}{U} Aura carrying its +/// parsed as-enters choice + copy static. Identity (Enchantment / Aura / +/// mana cost) is set BEFORE `from_oracle_text`, which preserves identity fields +/// while installing the parsed abilities/keywords/replacements/statics. +/// +/// Pass the MTGJSON-style `"enchant"` keyword hint so `"Enchant creature"` is +/// extracted as `Keyword::Enchant` (scenario bare-FromStr inference cannot +/// parse the space-form line into `Enchant:creature`). +fn stage_metamorphic(scenario: &mut GameScenario) -> ObjectId { + // CR 205.3h: Aura is an enchantment subtype. `as_enchantment` strips the + // Instant/Sorcery seed from `add_spell_to_hand` so this is a clean + // Enchantment — Aura spell (not a Sorcery+Enchantment hybrid). + scenario + .add_spell_to_hand(P0, "Metamorphic Alteration", false) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .with_mana_cost(ManaCost::Cost { + generic: 2, + shards: vec![ManaCostShard::Blue], + }) + .from_oracle_text_with_keywords(&["enchant"], METAMORPHIC_ALTERATION) + .id() +} + +fn blue_pool(scenario: &mut GameScenario) { + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Blue, ObjectId(9_990), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_991), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_992), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_993), false, vec![]), + ], + ); +} + +/// CR 707.2c + CR 614.12a + CR 613.1a: casting the Aura on a host, then choosing +/// a donor, turns the ENCHANTED HOST into a copy of the donor — while the Aura +/// itself stays a Metamorphic Alteration Aura attached to that host (it never +/// `BecomeCopy`s). Reverting the install leaves the host as Grizzly Bears 2/2, +/// so every copied-value assertion below flips. +#[test] +fn enchanted_creature_becomes_copy_of_chosen_and_aura_is_unchanged() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor = scenario + .add_creature_from_oracle(P0, "Serra Angel", 4, 4, "Flying") + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Serra Angel", + "CR 707.2c: enchanted host takes on the chosen creature's copiable name" + ); + assert_eq!(host_obj.power, Some(4), "host copies the donor's power"); + assert_eq!( + host_obj.toughness, + Some(4), + "host copies the donor's toughness" + ); + assert!( + host_obj.keywords.contains(&Keyword::Flying), + "host copies the donor's copiable keywords (CR 707.2)" + ); + + let aura_obj = &runner.state().objects[&aura]; + assert_eq!( + aura_obj.name, "Metamorphic Alteration", + "CR 707.4: the Aura's own identity must be unchanged — it never becomes a copy" + ); + assert!( + aura_obj + .card_types + .core_types + .contains(&CoreType::Enchantment) + && !aura_obj.card_types.core_types.contains(&CoreType::Creature), + "the Aura stays an Enchantment and is never turned into a creature copy" + ); + assert_eq!( + aura_obj.attached_to, + Some(AttachTarget::Object(host)), + "CR 608.3c: a spell-cast Aura is put onto the battlefield attached to its \ + enchant target, and the copy choice never disturbs that attachment" + ); +} + +/// CR 608.3c / CR 303.4a: with the normal `"Enchant creature"` filter and at +/// least two legal hosts, a resolving Aura spell must attach to the creature +/// it targeted at cast — never re-consult the Enchant filter (CR 303.4f) for an +/// ambiguous host. The copy donor is a third creature so attach-host ≠ +/// copy-source cannot be confused. +#[test] +fn spell_path_retains_chosen_enchant_target_among_multiple_legal_hosts() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor = scenario + .add_creature_from_oracle(P0, "Serra Angel", 4, 4, "Flying") + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let other_legal = scenario.add_creature(P0, "Runeclaw Bear", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + // Sanity: fixture must carry the real Enchant creature filter (not a + // SpecificObject pin that would collapse the multi-host consult). + assert!( + runner.state().objects[&aura].keywords.iter().any(|k| { + matches!( + k, + Keyword::Enchant(TargetFilter::Typed(tf)) + if tf.type_filters.contains(&engine::types::ability::TypeFilter::Creature) + ) + }), + "fixture must use normal Enchant creature, not a pinned SpecificObject host" + ); + + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + + let aura_obj = &runner.state().objects[&aura]; + assert_eq!( + aura_obj.attached_to, + Some(AttachTarget::Object(host)), + "CR 608.3c: Aura must remain attached to the cast target, not the other \ + legal Enchant creature ({other_legal:?})" + ); + assert_ne!( + aura_obj.attached_to, + Some(AttachTarget::Object(other_legal)), + "must not attach to the non-targeted legal creature via Enchant-filter consult" + ); + assert_ne!( + aura_obj.attached_to, + Some(AttachTarget::Object(donor)), + "must not attach to the copy donor" + ); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Serra Angel", + "targeted host still receives the chosen creature's copy" + ); + assert_eq!( + runner.state().objects[&other_legal].name, + "Runeclaw Bear", + "non-targeted creature must not receive the host copy" + ); +} + +/// CR 707.2b: the copy is a FROZEN snapshot taken when the effect first started +/// to apply — later changes to the chosen creature never propagate to the host. +/// Mutating the donor's base P/T and re-running the layer engine must leave the +/// host at the originally-copied 4/4. A live re-read of the donor would flip the +/// host to 9/9. +#[test] +fn copied_values_are_a_frozen_snapshot_of_the_chosen_creature() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor = scenario + .add_creature_from_oracle(P0, "Serra Angel", 4, 4, "Flying") + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + assert_eq!(runner.state().objects[&host].power, Some(4)); + + { + let donor_obj = runner.state_mut().objects.get_mut(&donor).unwrap(); + donor_obj.base_power = Some(9); + donor_obj.base_toughness = Some(9); + } + engine::game::layers::evaluate_layers(runner.state_mut()); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.power, + Some(4), + "CR 707.2b: the snapshot is frozen — the host keeps the donor's power at choice time" + ); + assert_eq!( + host_obj.toughness, + Some(4), + "CR 707.2b: later changes to the chosen creature must not propagate to the host" + ); +} + +/// CR 400.7 + CR 611.2a: the copy ends when the Aura leaves the battlefield +/// (`Duration::UntilHostLeavesPlay`, pruned on the Aura as the effect's source). +/// After the Aura is gone the host reverts to its own printed identity. +#[test] +fn host_reverts_when_the_aura_leaves_play() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor = scenario + .add_creature_from_oracle(P0, "Serra Angel", 4, 4, "Flying") + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + assert_eq!(runner.state().objects[&host].name, "Serra Angel"); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), aura, Zone::Graveyard, &mut events); + engine::game::layers::prune_host_left_effects(runner.state_mut(), aura); + engine::game::layers::evaluate_layers(runner.state_mut()); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Grizzly Bears", + "CR 400.7: the copy ends when the source Aura leaves — the host reverts to its own name" + ); + assert_eq!(host_obj.power, Some(2), "host reverts to its printed power"); + assert_eq!( + host_obj.toughness, + Some(2), + "host reverts to its printed toughness" + ); + assert!( + !host_obj.keywords.contains(&Keyword::Flying), + "the copied Flying keyword ends with the Aura" + ); +} + +/// CR 115.10a: "choose a creature" is a CHOICE, not targeting — hexproof (which +/// only protects against opponents' *targeted* spells and abilities) never +/// removes a creature from the copy-choice pool. An opponent's hexproof creature +/// must be a legal donor, and the host becomes a copy of it. Reverting the copy +/// install leaves the host as Grizzly Bears 2/2, so every assertion below flips. +#[test] +fn hexproof_opponent_creature_is_a_legal_copy_donor() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // Opponent-controlled donor with Hexproof: a *targeted* effect could never + // pick it, but this choice can (CR 115.10a). + let donor = scenario + .add_creature_from_oracle(P1, "Serra Angel", 4, 4, "Flying") + .hexproof() + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Serra Angel", + "CR 115.10a: an opponent's hexproof creature is still a legal copy CHOICE — the host copies it" + ); + assert_eq!( + host_obj.power, + Some(4), + "host copies the hexproof donor's power" + ); + assert!( + host_obj.keywords.contains(&Keyword::Flying), + "host copies the donor's copiable keywords across the choice" + ); + assert!( + host_obj.keywords.contains(&Keyword::Hexproof), + "the copied identity includes the donor's Hexproof (a copiable keyword)" + ); +} + +/// CR 609.3 + CR 303.4: the copy-choice pool is every creature matching "a +/// creature", and an Enchant creature Aura guarantees a creature host — so the +/// host itself is always in the pool. Even when the host is the ONLY creature on +/// the battlefield the pool is non-empty and the choice is raised (choosing the +/// host copies its own values — a legal, if inert, pick). +/// +/// This is why the empty-pool early-skip in +/// `apply_pending_post_replacement_effect` (the `Effect::ChoosePermanent` arm's +/// `valid_targets.is_empty()` guard, CR 609.3 "do only as much as possible") is +/// UNREACHABLE on the spell-cast Enchant-creature path: `find_copy_targets` over +/// "a creature" always contains the host. A genuinely empty pool (zero creatures +/// anywhere) would also leave the Aura spell with no legal enchant target, so +/// CR 608.2b counters it and it never enters — the "as this Aura enters" choose +/// replacement never fires with an empty pool. We therefore prove pool +/// composition here rather than fabricating an unreachable empty-pool fixture. +#[test] +fn host_is_the_only_creature_and_remains_a_legal_copy_choice() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + runner + .cast(aura) + .target_object(host) + .copy_target(host) + .resolve(); + + // Reach-guard: inert self-copy leaves printed P/T unchanged, so the + // host-stays-Grizzly assertions below would also pass if CopyTargetChoice + // were skipped entirely. The snapshot + Layer-1 CopyValues TCE are written + // only on the answer path — either missing flips if that path is bypassed. + assert!( + runner.state().objects[&aura] + .chosen_attributes + .iter() + .any(|a| matches!(a, ChosenAttribute::CopiableSnapshot(_))), + "CopyTargetChoice must latch ChosenAttribute::CopiableSnapshot on the Aura \ + (absent if the answer path was skipped)" + ); + assert!( + runner + .state() + .transient_continuous_effects + .iter() + .any(|tce| { + tce.source_id == aura + && tce + .modifications + .iter() + .any(|m| matches!(m, ContinuousModification::CopyValues { .. })) + && matches!( + &tce.affected, + TargetFilter::SpecificObject { id } if *id == host + ) + }), + "CopyTargetChoice must install a Layer-1 CopyValues TCE sourced from the Aura \ + on the enchanted host (absent if the answer path was skipped)" + ); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Grizzly Bears", + "choosing the host itself copies its own values — the host stays Grizzly Bears" + ); + assert_eq!( + host_obj.power, + Some(2), + "self-copy keeps the host's printed power" + ); + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(host)), + "CR 608.3c: the Aura remains attached to the host it enchanted" + ); +} + +/// Two Metamorphic Alterations on two different hosts, each choosing a DIFFERENT +/// donor, resolve independently: each host copies its own Aura's chosen donor. +/// The frozen snapshot is latched per-Aura (CR 707.2c) and installed on that +/// Aura's own enchanted host (CR 303.4 + CR 613.1a), so the two copy effects +/// never bleed. If host B picked up host A's donor's Flying, the independence +/// assertion below flips. +#[test] +fn two_auras_install_independent_copies_on_their_own_hosts() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor_a = scenario + .add_creature_from_oracle(P0, "Serra Angel", 4, 4, "Flying") + .id(); + let donor_b = scenario.add_creature(P0, "Hill Giant", 3, 3).id(); + let host_a = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let host_b = scenario.add_creature(P0, "Runeclaw Bear", 2, 2).id(); + let aura_a = stage_metamorphic(&mut scenario); + let aura_b = stage_metamorphic(&mut scenario); + + // Two casts of {2}{U}: 2 blue + 4 colorless. + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Blue, ObjectId(9_990), false, vec![]), + ManaUnit::new(ManaType::Blue, ObjectId(9_991), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_992), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_993), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_994), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_995), false, vec![]), + ], + ); + + let mut runner = scenario.build(); + runner + .cast(aura_a) + .target_object(host_a) + .copy_target(donor_a) + .resolve(); + assert!( + matches!( + runner.state().waiting_for, + engine::types::game_state::WaitingFor::Priority { .. } + ), + "first Metamorphic must fully clear CopyTargetChoice before the second cast, got {:?}", + runner.state().waiting_for + ); + runner + .cast(aura_b) + .target_object(host_b) + .copy_target(donor_b) + .resolve(); + + let ha = &runner.state().objects[&host_a]; + assert_eq!( + ha.name, "Serra Angel", + "host A copies Aura A's donor (Serra Angel)" + ); + assert_eq!(ha.power, Some(4)); + assert!(ha.keywords.contains(&Keyword::Flying)); + + let hb = &runner.state().objects[&host_b]; + assert_eq!( + hb.name, "Hill Giant", + "host B copies Aura B's donor (Hill Giant)" + ); + assert_eq!(hb.power, Some(3)); + assert!( + !hb.keywords.contains(&Keyword::Flying), + "host B must NOT pick up host A's donor's Flying — the two copies are independent" + ); +} + +/// CR 111.1 + CR 707.2: art routing follows the copy. When the chosen donor is a +/// true token, the host's display routing switches to the token art database +/// (`DisplaySource::Token` + the source token's `token_image_ref`) — carried +/// alongside the copiable values, NOT as a copiable value itself. The host is a +/// nontoken; reverting the copy install would reset its display routing to +/// `Card`/`None`, flipping both display assertions. +#[test] +fn host_copying_a_token_donor_routes_token_display() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor = scenario.add_creature(P0, "Angel", 4, 4).id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + // Make the donor a TRUE token so its display routes to the token art db + // (CR 111.1): `is_token` with no `base_printed_ref` derives + // `DisplaySource::Token` in the layer engine; its `token_image_ref` is its + // own art pointer. Mutate via `GameRunner::state_mut` (GameScenario has no + // mutable state escape hatch). + { + let d = runner.state_mut().objects.get_mut(&donor).unwrap(); + d.is_token = true; + d.base_printed_ref = None; + d.token_image_ref = Some(TokenImageRef { + scryfall_id: "tok-angel-1".to_string(), + scryfall_oracle_id: None, + face_name: None, + preset_id: "angel_4_4".to_string(), + }); + } + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + + let host_obj = &runner.state().objects[&host]; + assert_eq!(host_obj.name, "Angel", "host copies the token donor's name"); + assert_eq!( + host_obj.power, + Some(4), + "host copies the token donor's power" + ); + assert_eq!( + host_obj.display_source, + DisplaySource::Token, + "CR 111.1 + CR 707.2: copying a true token routes the host's art to the token db" + ); + assert_eq!( + host_obj + .token_image_ref + .as_ref() + .map(|r| r.preset_id.as_str()), + Some("angel_4_4"), + "the source token's token_image_ref rides along with the copy" + ); +} + +/// CR 303.4f + CR 614.12a + CR 707.2c: Non-spell Aura entry (dig → battlefield) +/// attaches via the enter-time host consult, then raises the as-enters copy +/// choice. Discriminates from the spell-cast path (CR 608.3c / +/// `PendingSpellResolution`): no spell-resolution frame is involved. +/// +/// Enchant is narrowed to "creature you control" so the opponent's donor is NOT +/// a legal attach host (single legal host → auto-attach, CR 303.4f) while still +/// remaining a legal copy CHOICE ("choose a creature", CR 115.10a). +#[test] +fn non_spell_aura_entry_copies_chosen_creature() { + use engine::types::ability::{ControllerRef, CopyTargetPurpose, TargetRef}; + use engine::types::actions::GameAction; + use engine::types::game_state::WaitingFor; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // Opponent-controlled donor: legal copy choice, illegal attach host under + // the narrowed Enchant(you-control) keyword installed below. + let donor = scenario + .add_creature_from_oracle(P1, "Serra Angel", 4, 4, "Flying") + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + // Library dig → battlefield is a non-spell Aura entry (CR 303.4f). Stage + // through the same oracle+enchant-hint path as the spell fixture so live + + // base replacement/static definitions are both populated. + let aura = scenario + .add_spell_to_library_top(P0, "Metamorphic Alteration", false) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .with_mana_cost(ManaCost::Cost { + generic: 2, + shards: vec![ManaCostShard::Blue], + }) + .from_oracle_text_with_keywords(&["enchant"], METAMORPHIC_ALTERATION) + .id(); + + let mut runner = scenario.build(); + + // Narrow Enchant so only `host` is a legal attach target (auto-attach). + { + let obj = runner.state_mut().objects.get_mut(&aura).unwrap(); + obj.keywords.retain(|k| !matches!(k, Keyword::Enchant(_))); + obj.base_keywords + .retain(|k| !matches!(k, Keyword::Enchant(_))); + let mut you_control_creature = TypedFilter::creature(); + you_control_creature.controller = Some(ControllerRef::You); + let narrowed = Keyword::Enchant(TargetFilter::Typed(you_control_creature)); + obj.keywords.push(narrowed.clone()); + obj.base_keywords.push(narrowed); + } + assert!( + runner.state().objects[&aura] + .replacement_definitions + .iter_unchecked() + .any(|r| matches!( + r.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::ChoosePermanent { .. }) + )), + "non-spell fixture must carry the ChoosePermanent as-enters replacement" + ); + + runner.state_mut().waiting_for = WaitingFor::DigChoice { + player: P0, + library_owner: P0, + cards: vec![aura], + keep_count: 1, + up_to: false, + selectable_cards: vec![aura], + kept_destination: Some(Zone::Battlefield), + rest_destination: Some(Zone::Graveyard), + source_id: None, + enter_tapped: false, + }; + + runner + .act(GameAction::SelectCards { cards: vec![aura] }) + .expect("dig keep must put the Aura onto the battlefield"); + + // Single legal host → auto-attach (no ReturnAsAuraTarget); copy choice next. + match &runner.state().waiting_for { + WaitingFor::CopyTargetChoice { purpose, .. } => { + assert!( + matches!(purpose, CopyTargetPurpose::PersistChosenAttribute), + "non-spell path must raise PersistChosenAttribute, got {purpose:?}" + ); + } + other => panic!("expected CopyTargetChoice after non-spell Aura entry, got {other:?}"), + } + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(host)), + "CR 303.4f: non-spell Aura auto-attaches to the sole legal host before the copy choice" + ); + + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(donor)), + }) + .expect("answer the copy-source choice"); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Serra Angel", + "CR 707.2c: non-spell Aura entry still installs the host copy" + ); + assert_eq!(host_obj.power, Some(4)); + assert!(host_obj.keywords.contains(&Keyword::Flying)); + + let aura_obj = &runner.state().objects[&aura]; + assert_eq!(aura_obj.name, "Metamorphic Alteration"); + assert_eq!( + aura_obj.attached_to, + Some(AttachTarget::Object(host)), + "CR 303.4f: Aura remains attached to the chosen host" + ); +} + +/// SHAPE: the full Metamorphic Oracle (choose line + CopyChosen static) lowers +/// the choose gap via `CopyChosenHost` to an as-enters +/// `Effect::ChoosePermanent` over a creature +/// copy-source pool — never a `BecomeCopy` on the entering Aura. A bare choose +/// line without CopyChosen stays an Unimplemented ability (see honesty test). +#[test] +fn as_enters_choose_a_creature_parses_to_choose_permanent() { + let parsed = parse_oracle_text( + METAMORPHIC_ALTERATION, + "Metamorphic Alteration", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + + let replacement = parsed + .replacements + .iter() + .find(|r| { + matches!( + r.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::ChoosePermanent { .. }) + ) + }) + .expect("as-enters choose-a-creature must lower to an Effect::ChoosePermanent replacement"); + + match replacement.execute.as_ref().unwrap().effect.as_ref() { + Effect::ChoosePermanent { filter } => { + assert_eq!( + filter, + &TargetFilter::Typed(TypedFilter::creature()), + "the copy-source pool is 'a creature'" + ); + } + other => panic!("expected Effect::ChoosePermanent, got {other:?}"), + } +} + +/// SHAPE: the "Enchanted creature is a copy of the chosen creature." line parses +/// to the `ContinuousModification::CopyChosen` marker affecting the enchanted +/// host (an `EnchantedBy` creature filter). The marker is a Layer-1 no-op; the +/// copy is materialized by the `ChoosePermanent` answer, not this static. +#[test] +fn enchanted_is_a_copy_of_chosen_parses_to_copy_chosen_static() { + let parsed = parse_oracle_text( + METAMORPHIC_ALTERATION, + "Metamorphic Alteration", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + + let static_def = parsed + .statics + .iter() + .find(|s| { + s.modifications + .contains(&ContinuousModification::CopyChosen) + }) + .expect("the copy static must lower to ContinuousModification::CopyChosen"); + + match static_def + .affected + .as_ref() + .expect("static must be scoped to a host") + { + TargetFilter::Typed(tf) => assert!( + tf.properties.contains(&FilterProp::EnchantedBy), + "the copy static must affect the enchanted host (CR 303.4 + CR 613.1a)" + ), + other => panic!("expected a Typed enchanted-host filter, got {other:?}"), + } +} + +/// COVERAGE HONESTY: as-enters permanent-object choices WITHOUT a CopyChosen +/// companion must not gain a Moved / ChoosePermanent shape. Dauntless Bodyguard +/// and Scheming Fence are the parse-diff blast-radius representatives — their +/// choose lines stay explicit Unimplemented abilities. +#[test] +fn non_copy_chosen_as_enters_permanent_choose_stays_unsupported() { + const CASES: &[(&str, &str, &[&str], &[&str])] = &[ + ( + "Dauntless Bodyguard", + "As this creature enters, choose another creature you control.\n\ + Sacrifice this creature: The chosen creature gains indestructible until end of turn.", + &["Creature"], + &["Human", "Knight"], + ), + ( + "Scheming Fence", + "As this creature enters, you may choose a nonland permanent.\n\ + Activated abilities of the chosen permanent can't be activated.\n\ + This creature has all activated abilities of the chosen permanent except for loyalty abilities. \ + You may spend mana as though it were mana of any color to activate those abilities.", + &["Creature"], + &["Human", "Citizen"], + ), + ]; + + for &(name, oracle, types, subtypes) in CASES { + let type_strings: Vec = types.iter().map(|s| (*s).to_string()).collect(); + let subtype_strings: Vec = subtypes.iter().map(|s| (*s).to_string()).collect(); + let parsed = parse_oracle_text(oracle, name, &[], &type_strings, &subtype_strings); + + assert!( + parsed.replacements.iter().all(|r| { + !matches!( + r.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::ChoosePermanent { .. }) + ) + }), + "{name}: must not emit ChoosePermanent without CopyChosen" + ); + assert!( + !parsed.statics.iter().any(|s| { + s.modifications + .contains(&ContinuousModification::CopyChosen) + }), + "{name}: sanity — no CopyChosen static on this class" + ); + assert!( + parsed.abilities.iter().any(|a| { + a.effect + .unimplemented_description() + .is_some_and(|d| d.to_lowercase().contains("choose")) + }), + "{name}: as-enters permanent choose must remain an explicit Unimplemented ability gap" + ); + } +} diff --git a/crates/engine/tests/integration/mutable_pupa_perpetual_keyword_mirror.rs b/crates/engine/tests/integration/mutable_pupa_perpetual_keyword_mirror.rs new file mode 100644 index 0000000000..f69bde6775 --- /dev/null +++ b/crates/engine/tests/integration/mutable_pupa_perpetual_keyword_mirror.rs @@ -0,0 +1,360 @@ +//! Issue #6321 — Mutable Pupa's perpetual keyword-mirror ETB trigger. +//! +//! "Whenever another creature you control enters, this creature perpetually +//! gains flying if that creature has flying. The same is true for first strike, +//! double strike, deathtouch, haste, hexproof, indestructible, lifelink, menace, +//! reach, trample, and vigilance." +//! +//! Digital-only Alchemy (no CR entry for "perpetually"); CR 702.1c + CR 608.2c govern the +//! per-branch resolution order that the `SiblingCondition::ReplicatedOrBranch` +//! marker restores. Each of the 12 keyword nodes is an INDEPENDENT OR-branch +//! gated on the entering object having THAT keyword — so the grant list must not +//! collapse to keyword[0]'s gate, and a keyword deep in the list (vigilance, +//! node 11) must still resolve after the earlier gates (flying, etc.) are false. +//! +//! These drive the REAL cast pipeline (`GameRunner::cast(..).resolve()`); the +//! Mutable Pupa trigger grants to its SOURCE (no target selection), so the +//! entering creature is cast and the trigger auto-resolves. Oracle text is +//! verbatim from data/card-data.json. + +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameScenario, P0}; +use engine::types::actions::GameAction; +use engine::types::counter::CounterType; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const MUTABLE_PUPA: &str = "Whenever another creature you control enters, this creature perpetually gains flying if that creature has flying. The same is true for first strike, double strike, deathtouch, haste, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance."; + +/// Every keyword the mirror can grant EXCEPT the ones a given entering creature +/// carries — used for the "no collapsed/leaked grant" negative sweep. +const ALL_MIRROR_KEYWORDS: &[Keyword] = &[ + Keyword::Flying, + Keyword::FirstStrike, + Keyword::DoubleStrike, + Keyword::Deathtouch, + Keyword::Haste, + Keyword::Hexproof, + Keyword::Indestructible, + Keyword::Lifelink, + Keyword::Menace, + Keyword::Reach, + Keyword::Trample, + Keyword::Vigilance, +]; + +/// Fund a pool with `n` white mana (white pays generic too), so a small creature +/// cast auto-pays without surfacing a mana window. The exact cost is not the +/// subject under test. +fn white_pool(n: usize) -> Vec { + vec![ManaUnit::new(ManaType::White, ObjectId(9_999), false, vec![]); n] +} + +// Affa Protector ({2}{W}, Human Soldier Ally, 1/4) has exactly one of the listed +// keywords — Vigilance. It enters under Mutable Pupa's controller: the mirror +// must grant Vigilance (node 11, reached ONLY because the resolve_chain_body +// ReplicatedOrBranch disjunct carries the chain past the false flying/first +// strike/... gates) and grant NOTHING else. +#[test] +fn mutable_pupa_gains_only_the_entering_creatures_vigilance() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let pupa = scenario + .add_creature_from_oracle(P0, "Mutable Pupa", 1, 1, MUTABLE_PUPA) + .id(); + // Affa Protector — keyword built via the builder (not inline reminder text), + // per the card-test foot-gun on inline keyword lines. + let affa = scenario + .add_creature_to_hand(P0, "Affa Protector", 1, 4) + .vigilance() + .id(); + scenario.with_mana_pool(P0, white_pool(3)); + let mut runner = scenario.build(); + + // Baseline reach-guard (positive, not vacuous): Mutable Pupa starts with + // neither Vigilance nor Flying. + assert!( + !runner.state().objects[&pupa].has_keyword(&Keyword::Vigilance), + "baseline: Mutable Pupa has no vigilance", + ); + assert!( + !runner.state().objects[&pupa].has_keyword(&Keyword::Flying), + "baseline: Mutable Pupa has no flying", + ); + + let outcome = runner.cast(affa).resolve(); + // Reach-guard: Affa Protector actually entered (so the trigger really fired). + outcome.assert_zone(&[affa], Zone::Battlefield); + + // Re-run layers so the perpetual base_keywords grant is reflected live. + let mut state = outcome.state().clone(); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + let pupa_obj = &state.objects[&pupa]; + + // Vigilance IS granted — FALSE without the resolve_chain_body fix (the chain + // would collapse at flying's false gate and never reach node 11). + assert!( + pupa_obj.has_keyword(&Keyword::Vigilance), + "Affa Protector has vigilance ⇒ Mutable Pupa perpetually gains vigilance", + ); + // Nothing else the entering creature lacks is granted (no collapse/leak). + for kw in ALL_MIRROR_KEYWORDS { + if *kw == Keyword::Vigilance { + continue; + } + assert!( + !pupa_obj.has_keyword(kw), + "Mutable Pupa must not gain {kw:?} (Affa Protector lacks it)", + ); + } +} + +// Accumulation: an entering creature carrying TWO listed keywords (vigilance AND +// trample) makes the mirror grant BOTH — independent `ApplyPerpetual` +// resolutions accumulate (GrantKeywords pushes to base_keywords, never +// overwrites), and neither is at keyword[0]'s position. +#[test] +fn mutable_pupa_accumulates_every_matching_keyword() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let pupa = scenario + .add_creature_from_oracle(P0, "Mutable Pupa", 1, 1, MUTABLE_PUPA) + .id(); + let twin = scenario + .add_creature_to_hand(P0, "Twin Keyworder", 3, 3) + .vigilance() + .trample() + .id(); + scenario.with_mana_pool(P0, white_pool(4)); + let mut runner = scenario.build(); + + assert!( + !runner.state().objects[&pupa].has_keyword(&Keyword::Vigilance) + && !runner.state().objects[&pupa].has_keyword(&Keyword::Trample), + "baseline: Mutable Pupa has neither vigilance nor trample", + ); + + let outcome = runner.cast(twin).resolve(); + outcome.assert_zone(&[twin], Zone::Battlefield); + + let mut state = outcome.state().clone(); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + let pupa_obj = &state.objects[&pupa]; + + // BOTH matching keywords accumulate (trample is node 10, vigilance node 11 — + // both past the earlier false gates, and both independently granted). + assert!( + pupa_obj.has_keyword(&Keyword::Vigilance), + "Mutable Pupa gains vigilance", + ); + assert!( + pupa_obj.has_keyword(&Keyword::Trample), + "Mutable Pupa gains trample (accumulates alongside vigilance, not overwritten)", + ); + for kw in ALL_MIRROR_KEYWORDS { + if matches!(kw, Keyword::Vigilance | Keyword::Trample) { + continue; + } + assert!( + !pupa_obj.has_keyword(kw), + "Mutable Pupa must not gain {kw:?} (Twin Keyworder lacks it)", + ); + } +} + +// ----------------------------------------------------------------------- +// Kathril, Aspect Warper — the SAME list-collapse bug in the counters class +// (`ReplicateKind::CounterPlacement` via `attach_repeat_process_keywords`), +// fixed by the same `SiblingCondition::ReplicatedOrBranch` marker + the shared +// `resolve_chain_body` disjunct. CR 608.2c. Oracle text verbatim from +// data/card-data.json. The counter recipient, "any creature you control", now +// parses to a real `TargetFilter::Typed{Creature, controller: You}` (see the +// `parse_type_phrase_with_ctx` "any " quantifier fix) instead of falling back +// to the degenerate `TargetFilter::Any`. +// +// CR 608.2d: an untargeted "any creature you control" choice (no literal +// "target") is made independently at EACH instruction's own resolution, not +// once when the whole ability goes on the stack. `target_choice_timing_for_ +// clause` (`oracle_effect/lower.rs`) marks every untargeted, non-context-ref +// `PutCounter` recipient `Resolution`-timed (widened from the narrower +// Equipped/Enchanted-only case, matching `MultiplyCounter`'s existing +// pattern); `resolve_chain_body` skips copying a parent's already-chosen +// target into a `Resolution`-timed sub; and each such instruction that +// reaches resolution with an empty, multi-candidate recipient opens an +// interactive `WaitingFor::ChooseFromZoneChoice` prompt (a single legal +// candidate auto-binds with no prompt; zero is a silent no-op — CR 608.2d: +// "The player can't choose an option that's illegal or impossible") — +// reusing the SAME parked-continuation machinery already proven +// for `PutCounter` (the Bolster keyword action). The two tests below cover +// both the single-creature case (no observable choice, matches the original +// #6321 regression exactly) and the multi-creature case (proves the choice +// is genuinely independent per instruction, not one shared pick). +// ----------------------------------------------------------------------- + +const KATHRIL: &str = "When Kathril enters, put a flying counter on any creature you control if a creature card in your graveyard has flying. Repeat this process for first strike, double strike, deathtouch, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance. Then put a +1/+1 counter on Kathril for each counter put on a creature this way."; + +// Only trample is in the graveyard — flying (K0) and every gate before trample +// are FALSE. The trample counter must still be placed (the chain reaches node 9 +// past the false earlier gates), the flying counter must NOT, and the +// unconditional +1/+1 tail must land (the chain reaches the tail past the last +// false gate). Reverting the `attach_repeat_process_keywords` marker OR the +// `resolve_chain_body` disjunct collapses the chain at flying's false gate, and +// both the trample and the +1/+1 assertions flip. +#[test] +fn kathril_reaches_matching_counter_and_tail_past_false_earlier_gates() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // A creature card with ONLY trample (explicitly NOT flying) in P0's graveyard. + scenario + .add_creature_to_graveyard(P0, "Trampling Remains", 2, 2) + .trample(); + let kathril = scenario + .add_creature_to_hand_from_oracle(P0, "Kathril, Aspect Warper", 3, 3, KATHRIL) + .id(); + // Kathril costs {2}{W}{B}{G}; use exact colored and generic mana so the + // cast reaches the ETB trigger under test. + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::White, ObjectId(9_996), false, vec![]), + ManaUnit::new(ManaType::Black, ObjectId(9_997), false, vec![]), + ManaUnit::new(ManaType::Green, ObjectId(9_998), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_999), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(10_000), false, vec![]), + ], + ); + let mut runner = scenario.build(); + + let outcome = runner.cast(kathril).target_object(kathril).resolve(); + outcome.assert_zone(&[kathril], Zone::Battlefield); + + let kathril_obj = &outcome.state().objects[&kathril]; + let count = |ct: CounterType| kathril_obj.counters.get(&ct).copied().unwrap_or(0); + + // The trample gate is true → a trample counter is placed (chain reached it). + assert_eq!( + count(CounterType::Keyword(Keyword::Trample.kind())), + 1, + "trample is in the graveyard ⇒ exactly one trample counter is placed", + ); + // The flying gate is false → NO flying counter (per-item independence, not a + // shared/collapsed gate). + assert_eq!( + count(CounterType::Keyword(Keyword::Flying.kind())), + 0, + "no flying card in the graveyard ⇒ no flying counter", + ); + // The unconditional tail fires: a +1/+1 counter on Kathril (proves the chain + // reached the end past vigilance's false gate). + assert_eq!( + count(CounterType::Plus1Plus1), + 1, + "exactly one +1/+1 counter is placed for the one counter put on a creature this way", + ); +} + +// Discriminating case for CR 608.2d: the graveyard has BOTH flying and +// trample, so TWO independent PutCounter instructions fire (flying is node 0, +// the head; trample is node 9, reached only via the SAME per-item independent +// gate the test above proves). P0 controls TWO creatures — Kathril plus +// "Second Recipient", already on the battlefield — so each instruction's "any +// creature you control" choice has a genuine, non-trivial answer. Declaring +// [second_recipient, kathril] in that order pins the flying prompt (which +// fires first, since flying resolves before trample in Oracle-text order) to +// second_recipient and leaves kathril for the trample prompt. If the two +// instructions wrongly shared one upfront choice (the bug this test guards +// against), both counters would land on whichever object was bound first and +// second_recipient would have NEITHER counter. +#[test] +fn kathril_offers_each_matching_counter_its_own_independent_recipient() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // A creature card with BOTH flying and trample in P0's graveyard, so both + // the head (flying) and the trample sibling fire their own instruction. + scenario + .add_creature_to_graveyard(P0, "Skybound Charger", 3, 3) + .flying() + .trample(); + // Already on the battlefield when Kathril enters — the second legal + // recipient for each "any creature you control" choice. + let second_recipient = scenario.add_creature(P0, "Second Recipient", 1, 1).id(); + let kathril = scenario + .add_creature_to_hand_from_oracle(P0, "Kathril, Aspect Warper", 3, 3, KATHRIL) + .id(); + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::White, ObjectId(9_996), false, vec![]), + ManaUnit::new(ManaType::Black, ObjectId(9_997), false, vec![]), + ManaUnit::new(ManaType::Green, ObjectId(9_998), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_999), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(10_000), false, vec![]), + ], + ); + let mut runner = scenario.build(); + + // `.resolve()` stops at the first `WaitingFor::ChooseFromZoneChoice` and + // hands control back here — the shared test driver deliberately does NOT + // auto-drive this variant (it also carries the pre-existing CR 608.2d + // tracked-set choice, e.g. Portent of Calamity's per-type exile picks, + // whose own tests rely on manually driving one prompt at a time). Declare + // the two independent recipients explicitly, in the order the two + // instructions actually resolve: flying (the head) first, trample + // (reached past the intervening false gates) second. + let _outcome = runner.cast(kathril).resolve(); + let declared_recipients = [second_recipient, kathril]; + let mut next_recipient = declared_recipients.iter(); + while let WaitingFor::ChooseFromZoneChoice { .. } = &runner.state().waiting_for { + let pick = *next_recipient + .next() + .expect("exactly two independent recipient prompts (flying, trample)"); + runner + .act(GameAction::SelectCards { cards: vec![pick] }) + .expect("per-instruction recipient choice"); + } + + assert_eq!( + runner.state().objects[&kathril].zone, + Zone::Battlefield, + "Kathril must resolve onto the battlefield" + ); + + let state = runner.state(); + let count_on = + |id: ObjectId, ct: CounterType| state.objects[&id].counters.get(&ct).copied().unwrap_or(0); + let flying_ct = CounterType::Keyword(Keyword::Flying.kind()); + let trample_ct = CounterType::Keyword(Keyword::Trample.kind()); + + // The flying instruction (resolved first) independently chose + // second_recipient — the first declared object still legal at that point. + assert_eq!( + count_on(second_recipient, flying_ct.clone()), + 1, + "second_recipient receives the flying counter (first instruction's own choice)", + ); + assert_eq!( + count_on(second_recipient, trample_ct.clone()), + 0, + "second_recipient must not also receive the trample counter", + ); + // The trample instruction (resolved later, past flying/first_strike/…'s + // now-satisfied-then-irrelevant gates — trample's OWN gate is what matters + // here) independently chose kathril — the only object left declared. + assert_eq!( + count_on(kathril, trample_ct), + 1, + "kathril receives the trample counter (second instruction's OWN, independent choice)", + ); + assert_eq!( + count_on(kathril, flying_ct), + 0, + "kathril must not also receive the flying counter — the two choices are independent, \ + not one shared pick forced onto a single recipient", + ); +} diff --git a/crates/engine/tests/integration/najeela_extra_combat_grant_2898.rs b/crates/engine/tests/integration/najeela_extra_combat_grant_2898.rs index c6d970d9ea..5d5efb0d59 100644 --- a/crates/engine/tests/integration/najeela_extra_combat_grant_2898.rs +++ b/crates/engine/tests/integration/najeela_extra_combat_grant_2898.rs @@ -83,6 +83,7 @@ fn najeela_chain(source: ObjectId, controller: PlayerId) -> ResolvedAbility { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], duration: Some(Duration::UntilEndOfTurn), target: None, diff --git a/crates/engine/tests/integration/peerless_recycling_gift_recipient.rs b/crates/engine/tests/integration/peerless_recycling_gift_recipient.rs new file mode 100644 index 0000000000..5df669b2ec --- /dev/null +++ b/crates/engine/tests/integration/peerless_recycling_gift_recipient.rs @@ -0,0 +1,394 @@ +//! Issue #5981: Gift recipient selection + Peerless Recycling gift-gated targets. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::ability::{AdditionalCostOrigin, TargetRef}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; +use engine::types::ObjectId; + +const PEERLESS_RECYCLING: &str = + "Gift a card (You may promise an opponent a gift as you cast this spell. \ +If you do, they draw a card before its other effects.)\n\ +Return target permanent card from your graveyard to your hand. If the gift was promised, \ +instead return two target permanent cards from your graveyard to your hand."; + +fn with_green_mana(scenario: &mut GameScenario, player: PlayerId, n: usize) { + scenario.with_mana_pool( + player, + (0..n) + .map(|_| ManaUnit::new(ManaType::Green, ObjectId(0), false, vec![])) + .collect(), + ); +} + +fn with_mana( + scenario: &mut GameScenario, + player: PlayerId, + units: impl IntoIterator, +) { + scenario.with_mana_pool( + player, + units + .into_iter() + .map(|mana_type| ManaUnit::new(mana_type, ObjectId(0), false, vec![])) + .collect(), + ); +} + +fn add_gift_spell_from_oracle( + scenario: &mut GameScenario, + player: PlayerId, + name: &str, + oracle_text: &str, +) -> engine::types::ObjectId { + // MTGJSON supplies the bare "Gift" keyword hint; scenario inference cannot + // FromStr a "Gift a card (reminder…)" line, so pass the production hint. + scenario + .add_spell_to_hand(player, name, true) + .from_oracle_text_with_keywords(&["Gift"], oracle_text) + .id() +} + +#[test] +fn peerless_recycling_decline_returns_one_and_no_gift_draw() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + with_green_mana(&mut scenario, P0, 2); + scenario.add_card_to_library_top(P1, "Gift Card"); + let gy_a = scenario + .add_creature_to_graveyard(P0, "Bear Cub", 2, 2) + .id(); + let gy_b = scenario + .add_creature_to_graveyard(P0, "Cougar Cub", 2, 2) + .id(); + let spell = + add_gift_spell_from_oracle(&mut scenario, P0, "Peerless Recycling", PEERLESS_RECYCLING); + + let mut runner = scenario.build(); + let p1_hand_before = runner.state().players[1].hand.len(); + runner + .cast(spell) + .decline_optional() + .target_objects(&[gy_a]) + .resolve(); + + assert_eq!(runner.state().objects[&gy_a].zone, Zone::Hand); + assert_eq!( + runner.state().objects[&gy_b].zone, + Zone::Graveyard, + "unpromised Peerless returns exactly one permanent card" + ); + assert_eq!( + runner.state().players[1].hand.len(), + p1_hand_before, + "declining Gift must not draw for the opponent" + ); +} + +#[test] +fn peerless_recycling_promise_returns_two_and_opponent_draws() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + with_green_mana(&mut scenario, P0, 2); + let draw_card = scenario.add_card_to_library_top(P1, "Gift Card"); + let gy_a = scenario + .add_creature_to_graveyard(P0, "Bear Cub", 2, 2) + .id(); + let gy_b = scenario + .add_creature_to_graveyard(P0, "Cougar Cub", 2, 2) + .id(); + let spell = + add_gift_spell_from_oracle(&mut scenario, P0, "Peerless Recycling", PEERLESS_RECYCLING); + + let mut runner = scenario.build(); + runner + .cast(spell) + .accept_optional() + .target_objects(&[gy_a, gy_b]) + .resolve(); + + assert_eq!(runner.state().objects[&gy_a].zone, Zone::Hand); + assert_eq!(runner.state().objects[&gy_b].zone, Zone::Hand); + assert!( + runner.state().players[1].hand.contains(&draw_card), + "promised Gift a card must draw for the sole opponent" + ); +} + +#[test] +fn peerless_recycling_three_player_chooses_gift_recipient_not_next_player() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + with_green_mana(&mut scenario, P0, 2); + let p1_draw = scenario.add_card_to_library_top(P1, "P1 Draw"); + let p2_draw = scenario.add_card_to_library_top(PlayerId(2), "P2 Draw"); + let gy_a = scenario + .add_creature_to_graveyard(P0, "Bear Cub", 2, 2) + .id(); + let gy_b = scenario + .add_creature_to_graveyard(P0, "Cougar Cub", 2, 2) + .id(); + let spell = + add_gift_spell_from_oracle(&mut scenario, P0, "Peerless Recycling", PEERLESS_RECYCLING); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("cast Peerless Recycling"); + + let mut chose_recipient = false; + let mut pending_targets = vec![gy_a, gy_b]; + for _ in 0..200 { + match &runner.state().waiting_for { + WaitingFor::ManaPayment { .. } => { + runner + .act(GameAction::PassPriority) + .expect("complete mana payment"); + } + WaitingFor::OptionalCostChoice { origin, .. } => { + assert_eq!( + *origin, + AdditionalCostOrigin::Gift, + "Gift optional cost must carry Gift origin" + ); + runner + .act(GameAction::DecideOptionalCost { pay: true }) + .expect("promise Gift"); + } + WaitingFor::ChooseGiftRecipient { candidates, .. } => { + assert!( + candidates.contains(&PlayerId(2)), + "P2 must be a legal Gift recipient" + ); + // Seat-order next player after P0 is P1; deliberately choose P2. + runner + .act(GameAction::ChooseGiftRecipient { + opponent: PlayerId(2), + }) + .expect("choose Gift recipient P2"); + chose_recipient = true; + } + WaitingFor::TargetSelection { .. } => { + let target = pending_targets.remove(0); + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }) + .expect("choose GY target"); + } + WaitingFor::OrderTriggers { .. } | WaitingFor::Priority { .. } + if !runner.state().stack.is_empty() => + { + runner + .act(GameAction::PassPriority) + .expect("advance Peerless"); + } + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => break, + other => panic!("unexpected prompt while casting Peerless: {other:?}"), + } + } + + assert!( + chose_recipient, + "3p Gift promise must raise ChooseGiftRecipient" + ); + assert_eq!(runner.state().objects[&gy_a].zone, Zone::Hand); + assert_eq!(runner.state().objects[&gy_b].zone, Zone::Hand); + assert!( + runner.state().players[2].hand.contains(&p2_draw), + "chosen Gift recipient P2 must draw" + ); + assert!( + runner.state().players[1].library.contains(&p1_draw), + "seat-next P1 must NOT receive the gift when P2 was chosen" + ); + let _ = runner; +} + +const GIFT_DRAW_SPELL: &str = + "Gift a card (You may promise an opponent a gift as you cast this spell. \ +If you do, they draw a card before its other effects.)\n\ +Draw a card."; + +/// Non-Peerless Gift delivery: 3p cast that chooses the non–seat-next opponent. +/// Reverting `gift_delivery` to `next_player` fails this even if Peerless +/// targeting still works. +#[test] +fn gift_card_three_player_delivery_uses_chosen_recipient_not_next_player() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + with_green_mana(&mut scenario, P0, 2); + let p1_draw = scenario.add_card_to_library_top(P1, "P1 Draw"); + let p2_draw = scenario.add_card_to_library_top(PlayerId(2), "P2 Draw"); + let spell = add_gift_spell_from_oracle(&mut scenario, P0, "Gift Draw Test", GIFT_DRAW_SPELL); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("cast Gift Draw Test"); + + let mut chose_non_first = false; + for _ in 0..200 { + match &runner.state().waiting_for { + WaitingFor::ManaPayment { .. } => { + runner + .act(GameAction::PassPriority) + .expect("complete mana payment"); + } + WaitingFor::OptionalCostChoice { origin, .. } => { + assert_eq!(*origin, AdditionalCostOrigin::Gift); + runner + .act(GameAction::DecideOptionalCost { pay: true }) + .expect("promise Gift"); + } + WaitingFor::ChooseGiftRecipient { candidates, .. } => { + assert!( + candidates.contains(&PlayerId(2)), + "P2 must be a legal Gift recipient" + ); + // Deliberately not seat-order next (P1) — revert of next_player fails here. + runner + .act(GameAction::ChooseGiftRecipient { + opponent: PlayerId(2), + }) + .expect("choose Gift recipient P2"); + chose_non_first = true; + } + WaitingFor::OrderTriggers { .. } | WaitingFor::Priority { .. } + if !runner.state().stack.is_empty() => + { + runner.act(GameAction::PassPriority).expect("advance spell"); + } + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => break, + other => panic!("unexpected prompt while casting Gift Draw Test: {other:?}"), + } + } + + assert!( + chose_non_first, + "3p Gift promise must raise ChooseGiftRecipient" + ); + assert!( + runner.state().players[2].hand.contains(&p2_draw), + "chosen Gift recipient must draw" + ); + assert!( + runner.state().players[1].library.contains(&p1_draw), + "seat-next opponent must not receive the gift" + ); +} + +/// CR 400.7 + CR 702.174a: Gift recipient stamped on a permanent at cast must +/// not survive blink / reanimation — the re-entering object is a new object +/// with no memory of the prior Gift promise. +#[test] +fn gift_recipient_clears_across_blink_and_reanimate() { + const GIFT_BEAR: &str = + "Gift a card (You may promise an opponent a gift as you cast this spell. \ +If you do, they draw a card before its other effects.)"; + const CLOUDSHIFT: &str = + "Exile target creature you control, then return that card to the battlefield under your control."; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // Gift bears are free/generic in the fixture; Cloudshift needs {W}; Murder / + // Zombify are free oracle stubs. Pad with colorless for any residual costs. + with_mana( + &mut scenario, + P0, + [ + ManaType::White, + ManaType::White, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ], + ); + scenario.add_card_to_library_top(P1, "Gift Card A"); + scenario.add_card_to_library_top(P1, "Gift Card B"); + let gift_bear = scenario + .add_creature_to_hand(P0, "Gift Bear", 2, 2) + .from_oracle_text_with_keywords(&["Gift"], GIFT_BEAR) + .id(); + let gift_bear_b = scenario + .add_creature_to_hand(P0, "Gift Bear B", 2, 2) + .from_oracle_text_with_keywords(&["Gift"], GIFT_BEAR) + .id(); + let cloudshift = scenario + .add_spell_to_hand_from_oracle(P0, "Cloudshift", true, CLOUDSHIFT) + .id(); + let murder = scenario + .add_spell_to_hand_from_oracle(P0, "Plain Murder", true, "Destroy target creature.") + .id(); + let zombify = scenario + .add_spell_to_hand_from_oracle( + P0, + "Plain Zombify", + false, + "Return target creature card from your graveyard to the battlefield.", + ) + .id(); + + let mut runner = scenario.build(); + + // --- Blink half --- + runner.cast(gift_bear).accept_optional().resolve(); + assert_eq!(runner.state().objects[&gift_bear].zone, Zone::Battlefield); + assert_eq!( + runner.state().objects[&gift_bear].gift_recipient, + Some(P1), + "cast-time Gift promise must stamp gift_recipient via CastLinkSnapshot" + ); + + runner.cast(cloudshift).target_object(gift_bear).resolve(); + assert_eq!(runner.state().objects[&gift_bear].zone, Zone::Battlefield); + assert!( + runner.state().objects[&gift_bear].gift_recipient.is_none(), + "blinked permanent must not keep prior Gift recipient (CR 400.7)" + ); + + // --- Destroy + reanimate half --- + runner.cast(gift_bear_b).accept_optional().resolve(); + assert_eq!( + runner.state().objects[&gift_bear_b].gift_recipient, + Some(P1), + "second Gift cast must stamp recipient" + ); + + runner.cast(murder).target_object(gift_bear_b).resolve(); + assert_eq!(runner.state().objects[&gift_bear_b].zone, Zone::Graveyard); + assert!( + runner.state().objects[&gift_bear_b] + .gift_recipient + .is_none(), + "battlefield exit must clear gift_recipient before GY residency" + ); + + runner.cast(zombify).target_object(gift_bear_b).resolve(); + assert_eq!(runner.state().objects[&gift_bear_b].zone, Zone::Battlefield); + assert!( + runner.state().objects[&gift_bear_b] + .gift_recipient + .is_none(), + "reanimated permanent must not restore Gift recipient (CR 400.7)" + ); +} diff --git a/crates/engine/tests/integration/reflexive_body_token_referent.rs b/crates/engine/tests/integration/reflexive_body_token_referent.rs new file mode 100644 index 0000000000..099d3216c9 --- /dev/null +++ b/crates/engine/tests/integration/reflexive_body_token_referent.rs @@ -0,0 +1,1826 @@ +//! BB-FU9 — a clause whose subject is the token published by a clause under an +//! AFFIRMATIVE reflexive gate ("When you do, create a token. Put a +1/+1 counter +//! on that token.") binds that token, and is re-linked onto the gated +//! instruction's continuation path so the resolver's condition-false descent +//! cannot resolve it against a STALE entry of `state.last_created_token_ids` — +//! a game-lifetime ledger that is never cleared at a resolution boundary +//! (CR 603.12 + CR 608.2c + CR 609.3). +//! +//! Every row drives the REAL pipeline — `parse_oracle_text` / +//! `parse_effect_chain`, then `build_resolved_from_def_with_targets` + +//! `resolve_ability_chain` + `runner.act(DecideOptionalEffect)` — and asserts +//! RESOLVED board state. Four rows (T14, T15, T18, T19) are explicitly +//! AST-SHAPE rows: what they pin is that a `sub_link` does not LIE about which +//! instruction a clause belongs to, which is a parse-time property with no +//! behavioural twin (the condition-false descent resolves the clause either +//! way, from a different parent). +//! +//! **`engine::ai_support::legal_actions()` is never called here.** It simulates +//! every candidate action on a CLONE and silently swallows the mutation being +//! measured; this lane read a false 0 that way before. +//! +//! Synthetic Oracle text (T8, T12–T15, T17, T19, T20) is disclosed as such and +//! is templated verbatim from shipped patterns: "you may pay {N}. If you do, +//! create …" (Akoum Stonewaker, Krenko, Cadric) and "If you didn't create a +//! token this way, create …" (Springheart Nantuko). The two-publisher and +//! intervening-instruction shapes have no shipped witness, which is why they +//! are synthetic. Every shipped-card claim is asserted on the shipped card +//! (T1, T2, T3, T5, T6, T7, T9, T10, T11, T16, T18, T21), with Oracle text +//! byte-verbatim from MTGJSON `AtomicCards.json`. + +use engine::game::ability_utils::build_resolved_from_def_with_targets; +use engine::game::effects::resolve_ability_chain; +use engine::game::game_object::AttachTarget; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::zones::create_object; +use engine::parser::oracle::parse_oracle_text; +use engine::parser::oracle_effect::parse_effect_chain; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, Effect, SubAbilityLink, TargetFilter, TargetRef, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::counter::CounterType; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::keywords::KeywordKind; +use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +// ───────────────────────────────────────────────────────────────────────────── +// Shipped-card Oracle text — byte-verbatim from `data/mtgjson/AtomicCards.json`. +// A paraphrase can take a different parser branch, so a paraphrased fixture can +// go green while the real card stays broken. +// ───────────────────────────────────────────────────────────────────────────── + +const IROH: &str = "When Iroh enters, create a Food token.\nAt the beginning of combat on your turn, you may have target opponent gain control of target permanent you control. When you do, create a 1/1 white Ally creature token. Put a +1/+1 counter on that token for each permanent you own that your opponents control."; +/// The Iroh reflexive BODY with the gate stripped — the ungated positive +/// control for T4. +const IROH_BODY: &str = "Create a 1/1 white Ally creature token. Put a +1/+1 counter on that token for each permanent you own that your opponents control."; +const SUMMONERS_SENDING: &str = "At the beginning of your end step, you may exile target creature card from a graveyard. If you do, create a 1/1 white Spirit creature token with flying. Put a +1/+1 counter on it if the exiled card's mana value is 4 or greater."; +const NORTH_POLE: &str = "At the beginning of your upkeep, target opponent draws a card and creates a Treasure token.\nWhenever chaos ensues, create a 2/2 white Alien creature token. When you do, tap target nontoken creature an opponent controls. Put a stun counter on it. (If a permanent with a stun counter would become untapped, remove one from it instead.)"; +const RATONHNHAKETON: &str = "As long as Ratonhnhak\u{e9}\u{a789}ton hasn't dealt damage yet, it has hexproof and can't be blocked.\nWhenever Ratonhnhak\u{e9}\u{a789}ton deals combat damage to a player, create a 1/1 black Assassin creature token with menace. When you do, return target Equipment card from your graveyard to the battlefield, then attach it to that token."; +const AKOUM: &str = "Landfall \u{2014} Whenever a land you control enters, you may pay {2}{R}. If you do, create a 3/1 red Elemental creature token with trample and haste. Exile that token at the beginning of the next end step."; +const SAHEELI: &str = "Whenever you cast an Artificer or artifact spell, you get {E} (an energy counter).\nAt the beginning of combat on your turn, you may pay {E}{E}{E}. When you do, create a token that's a copy of target permanent you control, except it's a 5/5 artifact creature in addition to its other types and has haste. Sacrifice it at the beginning of the next end step."; +const CADRIC: &str = "The \"legend rule\" doesn't apply to tokens you control.\nWhenever another nontoken legendary permanent you control enters, you may pay {1}. If you do, create a token that's a copy of it. That token gains haste. Sacrifice it at the beginning of the next end step."; +const REBELLION: &str = "Whenever you clash, you may pay {1}. If you do, create a 3/1 red Elemental Shaman creature token. If you won, that token gains haste until end of turn. (This ability triggers after the clash ends.)"; + +// ───────────────────────────────────────────────────────────────────────────── +// Synthetic building-block fixtures (disclosed above). +// ───────────────────────────────────────────────────────────────────────────── + +const BASE_IFYOUDO: &str = "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a +1/+1 counter on that token."; +const BASE_WHENYOUDO: &str = "When this creature enters, you may pay {2}. When you do, create a 1/1 white Ally creature token. Put a +1/+1 counter on that token."; +const COMPLEMENT: &str = "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. If you didn't create a token this way, create a 1/1 green Insect creature token."; +const INTERVENING_UNGATED: &str = "When this creature enters, create a 1/1 white Ally creature token. You may pay {2}. If you do, draw a card. Put a +1/+1 counter on that token."; +const TWOPUB_GATED_LAST: &str = "When this creature enters, create a 1/1 white Ally creature token. You may pay {2}. If you do, create a 1/1 green Insect creature token. Put a +1/+1 counter on that token."; +const TWOPUB_UNGATED_LAST: &str = "When this creature enters, you may pay {2}. If you do, create a 1/1 green Insect creature token. Create a 1/1 white Ally creature token. Put a +1/+1 counter on that token."; +const GAP1_IFYOUDO: &str = "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. You gain 1 life. Put a +1/+1 counter on that token."; +const GAP1_WHENYOUDO: &str = "When this creature enters, you may pay {2}. When you do, create a 1/1 white Ally creature token. You gain 1 life. Put a +1/+1 counter on that token."; +const GAP1_DELAYED: &str = "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. You gain 1 life. Exile that token at the beginning of the next end step."; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared helpers — each used by more than one test. +// ───────────────────────────────────────────────────────────────────────────── + +fn add_object(runner: &mut GameRunner, player: PlayerId, name: &str, zone: Zone) -> ObjectId { + let state = runner.state_mut(); + let card_id = CardId(state.next_object_id); + create_object(state, card_id, player, name.to_string(), zone) +} + +fn add_creature(runner: &mut GameRunner, player: PlayerId, name: &str) -> ObjectId { + let id = add_object(runner, player, name, Zone::Battlefield); + let obj = runner.state_mut().objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.power = Some(2); + obj.toughness = Some(2); + id +} + +fn counter(runner: &GameRunner, id: ObjectId, kind: &CounterType) -> u32 { + runner.state().objects[&id] + .counters + .get(kind) + .copied() + .unwrap_or(0) +} + +fn p1p1(runner: &GameRunner, id: ObjectId) -> u32 { + counter(runner, id, &CounterType::Plus1Plus1) +} + +fn kw_counter(runner: &GameRunner, id: ObjectId, k: KeywordKind) -> u32 { + counter(runner, id, &CounterType::Keyword(k)) +} + +/// The multi-authority second `LastCreated` candidate: a token from an EARLIER +/// resolution, sitting in the game-lifetime `last_created_token_ids` ledger. A +/// stale bind lands HERE, so every negative assertion names this object rather +/// than saying "not the source". +fn pre_seed_decoy(runner: &mut GameRunner) -> ObjectId { + let decoy = add_creature(runner, P0, "STALE DECOY TOKEN"); + runner.state_mut().objects.get_mut(&decoy).unwrap().is_token = true; + runner.state_mut().last_created_token_ids = vec![decoy]; + decoy +} + +/// In-test revert-probe for the §7.3 re-link pass: restore the BASE +/// `SequentialSibling` link on every node the pass re-tagged. +fn unrelink(def: &mut AbilityDefinition) { + if let Some(sub) = def.sub_ability.as_deref_mut() { + if reads_last_created(sub) && sub.sub_link == SubAbilityLink::ContinuationStep { + sub.sub_link = SubAbilityLink::SequentialSibling; + } + unrelink(sub); + } + if let Some(e) = def.else_ability.as_deref_mut() { + unrelink(e); + } +} + +/// Grant mana only when `funded`. `drive_ship` seeds energy separately, so a +/// false fixture can still drive Saheeli's `{E}{E}{E}` gate false in T16. +fn fund(scenario: &mut GameScenario, funded: bool) { + if funded { + scenario.with_mana_pool( + P0, + vec![ManaUnit::new(ManaType::Red, ObjectId(9_999), false, vec![]); 6], + ); + } +} + +/// Descend a definition's spine by a path of `s` (sub_ability) / `e` +/// (else_ability) steps: `""` is the definition itself, `"ss"` its +/// grandchild. +fn node_at<'a>(def: &'a AbilityDefinition, path: &str) -> &'a AbilityDefinition { + let mut cursor = def; + for (i, step) in path.chars().enumerate() { + cursor = match step { + 's' => cursor.sub_ability.as_deref(), + 'e' => cursor.else_ability.as_deref(), + other => panic!("bad path step {other:?}"), + } + .unwrap_or_else(|| panic!("path {path:?} has no node at step {i}")); + } + cursor +} + +fn sub_link_at(def: &AbilityDefinition, path: &str) -> SubAbilityLink { + node_at(def, path).sub_link +} + +/// The `TargetFilter` a named node's effect carries — the seeder-side property +/// (`PutCounter.target`), which `sub_link` alone cannot see. +fn target_at(def: &AbilityDefinition, path: &str) -> TargetFilter { + node_at(def, path) + .effect + .target_filter() + .cloned() + .unwrap_or_else(|| panic!("node at {path:?} carries no target filter")) +} + +/// Does any position inside this ONE node's effect read `LastCreated`? Mirrors +/// `lower::ability_reads_last_created`'s coverage (`GenericEffect` grant +/// recipients and a `CreateDelayedTrigger`'s inner definition included) by +/// asking the serialized effect, so the answer does not depend on an +/// enumeration of which `Effect` variants can carry the referent. +fn effect_reads_last_created(def: &AbilityDefinition) -> bool { + serde_json::to_string(&*def.effect) + .expect("effect serializes") + .contains("\"LastCreated\"") +} + +/// Same question over the whole within-clause chain (node + sub + else). +fn reads_last_created(def: &AbilityDefinition) -> bool { + effect_reads_last_created(def) + || def.sub_ability.as_deref().is_some_and(reads_last_created) + || def.else_ability.as_deref().is_some_and(reads_last_created) +} + +/// Visit this node and every node reachable through `sub_ability` / +/// `else_ability`. +fn walk(def: &AbilityDefinition, visit: &mut impl FnMut(&AbilityDefinition)) { + visit(def); + if let Some(s) = def.sub_ability.as_deref() { + walk(s, visit); + } + if let Some(e) = def.else_ability.as_deref() { + walk(e, visit); + } +} + +/// Every `AbilityDefinition` root a parsed card carries. +fn card_definitions(name: &str, text: &str, types: &[&str]) -> Vec { + let types: Vec = types.iter().map(|s| (*s).to_string()).collect(); + let parsed = parse_oracle_text(text, name, &[], &types, &[]); + parsed + .abilities + .into_iter() + .chain( + parsed + .triggers + .into_iter() + .filter_map(|t| t.execute) + .map(|b| *b), + ) + .chain( + parsed + .replacements + .into_iter() + .filter_map(|r| r.execute) + .map(|b| *b), + ) + .collect() +} + +fn parse_trigger(text: &str, name: &str, types: &[&str], index: usize) -> AbilityDefinition { + let types: Vec = types.iter().map(|s| (*s).to_string()).collect(); + let parsed = parse_oracle_text(text, name, &[], &types, &[]); + *parsed + .triggers + .get(index) + .unwrap_or_else(|| panic!("{name} has a trigger[{index}]")) + .execute + .clone() + .unwrap_or_else(|| panic!("{name} trigger[{index}] has an execute")) +} + +fn resolve_def( + runner: &mut GameRunner, + def: &AbilityDefinition, + source: ObjectId, + targets: Vec, +) { + let resolved = build_resolved_from_def_with_targets(def, source, P0, targets); + let mut events = Vec::new(); + let _ = resolve_ability_chain(runner.state_mut(), &resolved, &mut events, 0); +} + +/// Answer the optional-payment prompt through the real action pipeline. +fn decide(runner: &mut GameRunner, accept: bool) -> bool { + runner + .act(GameAction::DecideOptionalEffect { accept }) + .is_ok() +} + +/// Answer a sub-clause's `TriggerTargetSelection` prompt and let the resulting +/// stack object resolve. Used where the targeted clause is NOT the chain's head +/// (North Pole's `SetTapState`, Ratonhnhaké꞉ton's `ChangeZone`), so its target +/// cannot be pre-wired into `build_resolved_from_def_with_targets`'s slot list +/// and must go through the real `apply()` action pipeline. +fn select_targets_and_resolve(runner: &mut GameRunner, targets: Vec) { + runner + .act(GameAction::SelectTargets { targets }) + .expect("the reflexive body's target slot accepts the declared target"); + runner.advance_until_stack_empty(); +} + +/// Tokens created during THIS resolution (the decoy is excluded by id). +fn live_tokens(runner: &GameRunner, decoy: ObjectId) -> Vec { + let mut v: Vec = runner + .state() + .battlefield + .iter() + .copied() + .filter(|id| runner.state().objects[id].is_token && *id != decoy) + .collect(); + v.sort(); + v +} + +fn token_named(runner: &GameRunner, decoy: ObjectId, name: &str) -> ObjectId { + live_tokens(runner, decoy) + .into_iter() + .find(|id| runner.state().objects[id].name == name) + .unwrap_or_else(|| panic!("a token named {name:?} was created")) +} + +/// Does any pending delayed trigger target `id`? +fn delayed_targets(runner: &GameRunner, id: ObjectId) -> bool { + runner + .state() + .delayed_triggers + .iter() + .any(|d| d.ability.targets.contains(&TargetRef::Object(id))) +} + +// ═════════════════════════════════════════════════════════════════════════════ +// T1–T7 — shipped cards, gate-TRUE and the declines. +// ═════════════════════════════════════════════════════════════════════════════ + +/// T1 — Iroh's reflexive body ("When you do, create a 1/1 white Ally creature +/// token. Put a +1/+1 counter on that token for each permanent you own that +/// your opponents control.") binds the ALLY TOKEN, not the permanent given away +/// by the antecedent. +/// +/// FLIPS: reverting the §7.2 gated-publisher carve-out re-binds the counter to +/// the given-away Bear — `bear +1/+1 == 0` reds (it becomes 1) and +/// `ally +1/+1 == 1` reds (it becomes 0). +#[test] +fn iroh_reflexive_body_counter_binds_created_token() { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "Iroh, Tea Master"); + let bear = add_creature(&mut runner, P0, "Bear"); + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_trigger(IROH, "Iroh, Tea Master", &["Creature"], 1); + resolve_def( + &mut runner, + &def, + source, + vec![TargetRef::Object(bear), TargetRef::Player(P1)], + ); + assert!(decide(&mut runner, true), "the reflexive gate was accepted"); + + let ally = token_named(&runner, decoy, "Ally"); + assert_eq!( + p1p1(&runner, ally), + 1, + "'that token' binds the Ally created by the reflexive body" + ); + assert_eq!( + p1p1(&runner, bear), + 0, + "the given-away permanent (the antecedent's target) receives NO counter" + ); + assert_eq!( + p1p1(&runner, decoy), + 0, + "the stale decoy receives no counter" + ); +} + +/// T2 — Summoner's Sending: the counter lands on the SPIRIT token, not on the +/// creature card the antecedent exiled. +/// +/// FLIPS: reverting the §7.2 carve-out moves the counter from the token +/// (1 → 0) onto the exiled card (0 → 1). +#[test] +fn summoners_sending_counter_binds_created_token() { + let (runner, decoy, card) = drive_sending(4); + let spirit = token_named(&runner, decoy, "Spirit"); + + assert_eq!( + p1p1(&runner, spirit), + 1, + "'it' binds the created Spirit token (exiled card MV 4 satisfies the gate)" + ); + assert_eq!( + p1p1(&runner, card), + 0, + "the exiled creature card receives NO counter" + ); +} + +/// T3 — adjacent-value hostile: the same card with an MV-1 exiled card places +/// NO counter, and the negative is reach-guarded (the Spirit token really was +/// created and the card really is in exile, so the assertion cannot be +/// satisfied by an upstream short-circuit). +#[test] +fn summoners_sending_low_mv_places_no_counter() { + let (runner, decoy, card) = drive_sending(1); + let spirit = token_named(&runner, decoy, "Spirit"); + + // Reach-guards. + assert!( + runner.state().objects[&spirit].is_token, + "reach-guard: the Spirit token WAS created, so the consumer had a live referent" + ); + assert_eq!( + runner.state().objects[&card].zone, + Zone::Exile, + "reach-guard: the antecedent exile really happened (the mana-value input exists)" + ); + + assert_eq!( + p1p1(&runner, spirit), + 0, + "mana value 1 fails the 'is 4 or greater' condition — no counter" + ); + assert_eq!(p1p1(&runner, card), 0, "and none on the exiled card either"); +} + +/// Shared Summoner's Sending drive: a creature card of mana value `mv` in a +/// graveyard, a pre-seeded decoy, the reflexive exile ACCEPTED. +fn drive_sending(mv: u32) -> (GameRunner, ObjectId, ObjectId) { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "Summoner's Sending"); + let card = add_object(&mut runner, P0, "Fat Creature", Zone::Graveyard); + { + let obj = runner.state_mut().objects.get_mut(&card).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.mana_cost = ManaCost::Cost { + generic: mv, + shards: vec![], + }; + } + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_trigger(SUMMONERS_SENDING, "Summoner's Sending", &["Enchantment"], 0); + resolve_def(&mut runner, &def, source, vec![TargetRef::Object(card)]); + assert!( + decide(&mut runner, true), + "the reflexive exile was accepted" + ); + (runner, decoy, card) +} + +/// T4 — positive control. The Iroh BODY with no reflexive connector at all +/// still binds the created token, proving the harness, the per-permanent count +/// math and `LastCreated` resolution work independently of the gate. +#[test] +fn ungated_chain_still_binds_created_token() { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "Ungated Body"); + // One permanent P0 OWNS that an opponent CONTROLS — the "for each" count's + // only member, so the expected value is 1 rather than a vacuous 0. + // `base_controller` is load-bearing: the layer pass rewrites + // `obj.controller` from `base_controller.unwrap_or(owner)` on every + // recompute, so setting `controller` alone is silently undone. + let loaned = add_creature(&mut runner, P0, "Loaned Bear"); + { + let obj = runner.state_mut().objects.get_mut(&loaned).unwrap(); + obj.controller = P1; + obj.base_controller = Some(P1); + } + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_effect_chain(IROH_BODY, AbilityKind::Spell); + resolve_def(&mut runner, &def, source, vec![]); + + let ally = token_named(&runner, decoy, "Ally"); + // Reach-guard: the dynamic count really has a member, so `== 1` below is a + // measured count rather than an accidental zero. + assert_eq!( + runner.state().objects[&loaned].controller, + P1, + "reach-guard: the counted permanent really is owned by P0 and controlled by P1" + ); + assert_eq!( + p1p1(&runner, ally), + 1, + "ungated 'that token' binds the created Ally (one permanent owned by P0 under P1's control)" + ); + assert_eq!( + p1p1(&runner, decoy), + 0, + "the stale decoy receives no counter" + ); +} + +/// T5 — negative control. North Pole Research Base's reflexive body is +/// "When you do, tap target nontoken creature an opponent controls. Put a stun +/// counter on it." — its gated clause is a `SetTapState`, NOT a token +/// publisher, so "it" must keep binding the TAPPED CREATURE even though an +/// Alien token was created earlier in the same chain. +#[test] +fn north_pole_stun_counter_stays_on_tapped_creature() { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "North Pole Research Base"); + let victim = add_creature(&mut runner, P1, "Opponent Bear"); + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_trigger(NORTH_POLE, "North Pole Research Base", &["Land"], 1); + resolve_def(&mut runner, &def, source, vec![]); + select_targets_and_resolve(&mut runner, vec![TargetRef::Object(victim)]); + + let alien = token_named(&runner, decoy, "Alien"); + // Reach-guards: the token publisher DID run and the reflexive body DID tap. + assert!( + runner.state().objects[&alien].is_token, + "reach-guard: the Alien token exists, so a LastCreated mis-bind had a live target" + ); + assert!( + runner.state().objects[&victim].tapped, + "reach-guard: the reflexive body really resolved (the target is tapped)" + ); + + let stun = CounterType::Stun; + assert_eq!( + counter(&runner, victim, &stun), + 1, + "'it' after a SetTapState binds the tapped creature (CR 608.2c)" + ); + assert_eq!( + counter(&runner, alien, &stun), + 0, + "the Alien token created earlier in the chain receives NO stun counter" + ); +} + +/// T6 — no-regression control. Ratonhnhaké꞉ton's reflexive body returns an +/// Equipment card and attaches it to "that token": the NON-counter consumer of +/// a gated publisher's referent must still bind the new Assassin token. +#[test] +fn ratonhnhaketon_attach_still_binds_created_token() { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "Ratonhnhak\u{e9}\u{a789}ton"); + let equipment = add_object(&mut runner, P0, "Bone Saw", Zone::Graveyard); + { + let obj = runner.state_mut().objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + obj.card_types.subtypes.push("Equipment".to_string()); + obj.base_card_types = obj.card_types.clone(); + } + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_trigger( + RATONHNHAKETON, + "Ratonhnhak\u{e9}\u{a789}ton", + &["Creature"], + 0, + ); + resolve_def(&mut runner, &def, source, vec![]); + select_targets_and_resolve(&mut runner, vec![TargetRef::Object(equipment)]); + + let assassin = token_named(&runner, decoy, "Assassin"); + // Reach-guard: the antecedent return really happened, so the attach clause + // had something to attach. + assert_eq!( + runner.state().objects[&equipment].zone, + Zone::Battlefield, + "reach-guard: the Equipment really returned from the graveyard" + ); + assert_eq!( + runner.state().objects[&equipment].attached_to, + Some(AttachTarget::Object(assassin)), + "the returned Equipment attaches to the NEWLY created Assassin token" + ); + assert_ne!( + runner.state().objects[&equipment].attached_to, + Some(AttachTarget::Object(decoy)), + "and never to the stale decoy" + ); +} + +/// T7 — hostile: DECLINE. With the reflexive gate declined no token is created, +/// so the consumer must place nothing at all — in particular not on the decoy, +/// which is still the only entry in `last_created_token_ids`. +#[test] +fn declined_reflexive_body_places_no_counter_on_stale_token() { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "Iroh, Tea Master"); + let bear = add_creature(&mut runner, P0, "Bear"); + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_trigger(IROH, "Iroh, Tea Master", &["Creature"], 1); + resolve_def( + &mut runner, + &def, + source, + vec![TargetRef::Object(bear), TargetRef::Player(P1)], + ); + assert!( + decide(&mut runner, false), + "the reflexive gate was DECLINED" + ); + + assert_eq!( + live_tokens(&runner, decoy), + Vec::::new(), + "reach-guard: declining really created no token" + ); + assert_eq!( + runner.state().last_created_token_ids, + vec![decoy], + "reach-guard: the decoy is still the only LastCreated candidate" + ); + assert_eq!( + p1p1(&runner, decoy), + 0, + "the stale decoy receives NO counter on the declined path" + ); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// T8–T17 — the gate-FALSE hazard, the shipped consumers, and the anti-over-skip +// controls. +// ═════════════════════════════════════════════════════════════════════════════ + +/// Drive one synthetic trigger fixture with a pre-seeded decoy, optionally +/// funded, optionally with the re-link reverted in-test. +struct Driven { + runner: GameRunner, + decoy: ObjectId, + dlife: i32, +} + +fn drive_synth(text: &str, funded: bool, revert: bool) -> Driven { + let mut scenario = GameScenario::new(); + fund(&mut scenario, funded); + let mut runner = scenario.build(); + let source = add_creature(&mut runner, P0, "Synth"); + let decoy = pre_seed_decoy(&mut runner); + // Draw fuel, so a "Draw a card" reach-guard is measurable. + add_object(&mut runner, P0, "Library Filler", Zone::Library); + let life0 = runner.state().players[0].life; + + let mut def = parse_trigger(text, "Synth", &["Creature"], 0); + if revert { + unrelink(&mut def); + } + resolve_def(&mut runner, &def, source, vec![]); + decide(&mut runner, true); + + let dlife = runner.state().players[0].life - life0; + Driven { + runner, + decoy, + dlife, + } +} + +/// T8 — THE HAZARD. Accept "you may pay {2}" with zero mana: `pay.rs` sets +/// `cost_payment_failed_flag`, the reflexive gate evaluates FALSE, and the +/// resolver's condition-false descent would resolve the counter clause with no +/// token created. Both connectors are measured — `If you do` and `When you do` +/// are NOT interchangeable at the resolver. +/// +/// FLIPS: dropping the whole §7.3 re-link pass (or the in-test `unrelink`) +/// makes the decoy's `+1/+1` go 0 → 1 on BOTH gates. +#[test] +fn reflexive_gate_false_on_accept_places_no_counter_on_stale_token() { + for (label, text) in [("if you do", BASE_IFYOUDO), ("when you do", BASE_WHENYOUDO)] { + let d = drive_synth(text, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "[{label}] reach-guard: the payment really failed, so the gate really went FALSE" + ); + assert_eq!( + live_tokens(&d.runner, d.decoy), + Vec::::new(), + "[{label}] reach-guard: no token was created on the false gate" + ); + assert_eq!( + p1p1(&d.runner, d.decoy), + 0, + "[{label}] the stale decoy receives NO counter" + ); + } +} + +/// T9 — shipped `IfYouDo` consumer: Akoum Stonewaker's "Exile that token at the +/// beginning of the next end step." On a failed payment no delayed trigger may +/// be created at all, and none may name the decoy. +/// +/// FLIPS: the in-test `unrelink` revert-probe makes a delayed `ChangeZone` +/// appear TARGETING THE DECOY. +#[test] +fn akoum_stonewaker_gate_false_creates_no_delayed_trigger_on_stale_token() { + let d = drive_ship(AKOUM, 0, false, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: {{2}}{{R}} could not be paid, so the IfYouDo gate went FALSE" + ); + assert!( + !delayed_targets(&d.runner, d.decoy), + "no delayed trigger targets the stale decoy" + ); + assert!( + d.runner.state().delayed_triggers.is_empty(), + "and no delayed trigger was created at all" + ); +} + +/// T10 — shipped cascade consumer: Cadric, Soul Kindler's "That token gains +/// haste. Sacrifice it at the beginning of the next end step." Both legs of the +/// cascade must be skipped on a false gate. +/// +/// FLIPS: the in-test `unrelink` makes a delayed `Sacrifice` target the decoy. +#[test] +fn cadric_gate_false_skips_the_whole_cascade() { + let d = drive_ship(CADRIC, 0, false, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: {{1}} could not be paid, so the IfYouDo gate went FALSE" + ); + assert_eq!( + live_tokens(&d.runner, d.decoy), + Vec::::new(), + "reach-guard: no copy token was created" + ); + assert_eq!( + d.runner.state().objects[&d.decoy].keywords.len(), + 0, + "leg 1: the decoy gained no haste" + ); + assert!( + !delayed_targets(&d.runner, d.decoy), + "leg 2: no delayed Sacrifice targets the decoy" + ); +} + +/// T11 — true-path parity. With the payment funded, all three shipped +/// consumers behave IDENTICALLY with and without the re-link: the token is +/// created and every consumer binds the NEW token. +#[test] +fn akoum_and_cadric_and_rebellion_gate_true_are_unchanged() { + for revert in [false, true] { + // Akoum: the delayed ChangeZone names the new Elemental. + let d = drive_ship(AKOUM, 0, true, revert, false); + let elemental = token_named(&d.runner, d.decoy, "Elemental"); + assert!( + delayed_targets(&d.runner, elemental), + "[revert={revert}] Akoum's delayed exile names the NEW token" + ); + assert!( + !delayed_targets(&d.runner, d.decoy), + "[revert={revert}] and never the decoy" + ); + + // Cadric: the copy token exists WITH haste, and the delayed Sacrifice + // names it. + let d = drive_ship(CADRIC, 0, true, revert, false); + let copy = *live_tokens(&d.runner, d.decoy) + .first() + .expect("Cadric's copy token exists"); + assert_eq!( + d.runner.state().objects[©].keywords.len(), + 1, + "[revert={revert}] Cadric's copy token gained haste" + ); + assert!( + delayed_targets(&d.runner, copy), + "[revert={revert}] Cadric's delayed Sacrifice names the NEW token" + ); + + // Rebellion: the Elemental Shaman is created (its own EventOutcomeWon + // tail is still evaluated). + let d = drive_ship(REBELLION, 0, true, revert, false); + token_named(&d.runner, d.decoy, "Elemental Shaman"); + } +} + +/// T12 — anti-over-skip #1. Springheart Nantuko's templating: the tail carries +/// its OWN `Not{OptionalEffectPerformed}` complement condition, so an accept +/// with zero mana must STILL create the Insect. A blanket "skip the whole +/// reflexive descent" remedy fails here — this is the test that would have +/// caught the rejected round-2 design. +#[test] +fn reflexive_complement_branch_still_resolves_on_failed_payment() { + let d = drive_synth(COMPLEMENT, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: the payment failed, so the complement branch is the live one" + ); + let insect = token_named(&d.runner, d.decoy, "Insect"); + assert!( + d.runner.state().objects[&insect].is_token, + "the 'If you didn't create a token this way' branch still resolves" + ); +} + +/// T13 — anti-over-skip #2. The token is created UNGATED and the false gate +/// sits on a NON-publisher clause ("If you do, draw a card"), so the live Ally +/// must still get its counter. +/// +/// FLIPS: dropping the §7.3 publisher lookup (re-linking after ANY prior gated +/// clause) re-tags the tail `SequentialSibling → ContinuationStep` and the +/// live Ally's `+1/+1` goes 1 → 0. +#[test] +fn ungated_token_keeps_live_referent_across_a_false_gate() { + let d = drive_synth(INTERVENING_UNGATED, false, false); + let ally = token_named(&d.runner, d.decoy, "Ally"); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: the intervening {{2}} really failed, so a false gate is on the path" + ); + assert_eq!( + p1p1(&d.runner, ally), + 1, + "the UNGATED publisher's token still receives its counter" + ); + assert_eq!( + p1p1(&d.runner, d.decoy), + 0, + "and the stale decoy receives none" + ); +} + +/// T14 — two-publisher hostile pair. The NEAREST preceding publisher decides, +/// not "any gated publisher anywhere in the chain". +/// +/// Behavioural: gated-last places nothing (the Insect was never created); +/// ungated-last still places on the live Ally. +/// +/// AST-SHAPE (load-bearing): on ungated-last the consumer's `sub_link` STAYS +/// `SequentialSibling`. FLIPS: dropping the publisher-gate conjunct re-tags it +/// `ContinuationStep`. The drive rows are unchanged by that drop — the descent +/// resolves the ungated publisher's own chain either way — so the AST row is +/// what discriminates the conjunct, and is labelled as such. +#[test] +fn nearest_publisher_decides_the_relink() { + let gated = drive_synth(TWOPUB_GATED_LAST, false, false); + let ally = token_named(&gated.runner, gated.decoy, "Ally"); + assert!( + gated.runner.state().cost_payment_failed_flag, + "reach-guard: the {{2}} failed, so the nearest (Insect) publisher is gate-FALSE" + ); + assert_eq!( + p1p1(&gated.runner, ally), + 0, + "gated-last: the consumer belongs to the ungated Ally's SUCCESSOR gate and places nothing" + ); + assert_eq!( + p1p1(&gated.runner, gated.decoy), + 0, + "gated-last: and nothing on the stale decoy" + ); + + let ungated = drive_synth(TWOPUB_UNGATED_LAST, false, false); + let ally = token_named(&ungated.runner, ungated.decoy, "Ally"); + assert_eq!( + p1p1(&ungated.runner, ally), + 1, + "ungated-last: the nearest publisher is UNGATED, so the live Ally gets its counter" + ); + assert_eq!( + p1p1(&ungated.runner, ungated.decoy), + 0, + "ungated-last: and nothing on the stale decoy" + ); + + // AST-SHAPE row. + let def = parse_trigger(TWOPUB_UNGATED_LAST, "Synth", &["Creature"], 0); + let consumer = "sss"; + assert!( + matches!(target_at(&def, consumer), TargetFilter::LastCreated), + "reach-guard: the consumer really is the LastCreated reader" + ); + assert_eq!( + sub_link_at(&def, consumer), + SubAbilityLink::SequentialSibling, + "an UNGATED nearest publisher must NOT be re-linked (CR 608.2c: still the next \ + independent instruction)" + ); +} + +/// T15 — an independent instruction between the gated publisher and a +/// DELAYED-TRIGGER consumer blocks the re-link. AST-SHAPE row: the consumer's +/// `sub_link` stays `SequentialSibling`, because re-tagging it would make it a +/// continuation step of the intervening sibling — the descent selects that +/// sibling and resolves the consumer anyway, so the conjunct's job is to stop +/// `sub_link` from LYING, not to change behaviour. +/// +/// FLIPS: dropping `gated_instruction_reaches` re-tags the +/// `CreateDelayedTrigger` consumer to `ContinuationStep`. +#[test] +fn intervening_instruction_blocks_the_relink() { + let d = drive_synth(GAP1_DELAYED, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: the payment failed, so the gate went FALSE" + ); + assert_eq!( + d.dlife, 1, + "reach-guard: the intervening 'You gain 1 life.' really resolved" + ); + + let def = parse_trigger(GAP1_DELAYED, "Synth", &["Creature"], 0); + let mut delayed_links = Vec::new(); + walk(&def, &mut |node| { + if matches!(&*node.effect, Effect::CreateDelayedTrigger { .. }) { + delayed_links.push((node.sub_link, effect_reads_last_created(node))); + } + }); + assert_eq!( + delayed_links.len(), + 1, + "reach-guard: exactly one CreateDelayedTrigger consumer was parsed" + ); + assert!( + delayed_links[0].1, + "reach-guard: that consumer really reads LastCreated" + ); + assert_eq!( + delayed_links[0].0, + SubAbilityLink::SequentialSibling, + "an intervening independent instruction blocks the re-link (CR 608.2c)" + ); +} + +/// T16 — shipped `WhenYouDo` consumer on the FALSE path: Saheeli, Radiant +/// Creator's "Sacrifice it at the beginning of the next end step." with zero +/// energy. `fund` grants energy only when funded, which is what lets this gate +/// go false at all. +/// +/// FLIPS: the in-test `unrelink` makes a delayed `Sacrifice` target the decoy. +#[test] +fn saheeli_when_you_do_gate_false_creates_no_delayed_trigger_on_stale_token() { + let d = drive_ship(SAHEELI, 1, false, false, true); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: {{E}}{{E}}{{E}} could not be paid with 0 energy, so WhenYouDo went FALSE" + ); + assert!( + !delayed_targets(&d.runner, d.decoy), + "no delayed Sacrifice targets the stale decoy" + ); + assert!( + d.runner.state().delayed_triggers.is_empty(), + "and no delayed trigger was created at all" + ); +} + +/// T17 — round-5's seeder-side conjunct: an independent instruction between the +/// gated publisher and a COUNTER consumer blocks the SEED, so the consumer never +/// acquires a `LastCreated` target in the first place. +/// +/// FLIPS: dropping the seeder's reach conjunct puts the decoy's `+1/+1` at +/// 0 → 1 on BOTH connectors. +#[test] +fn intervening_instruction_blocks_the_seed() { + for (label, text) in [("if you do", GAP1_IFYOUDO), ("when you do", GAP1_WHENYOUDO)] { + let d = drive_synth(text, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "[{label}] reach-guard: the payment failed, so the gate went FALSE" + ); + assert_eq!( + d.dlife, 1, + "[{label}] reach-guard: the intervening 'You gain 1 life.' really resolved" + ); + assert_eq!( + p1p1(&d.runner, d.decoy), + 0, + "[{label}] the stale decoy receives NO counter" + ); + + // The seeder-side property `sub_link` alone cannot see: the PutCounter + // never became a `LastCreated` reader. + let def = parse_trigger(text, "Synth", &["Creature"], 0); + let mut put_targets = Vec::new(); + walk(&def, &mut |node| { + if matches!(&*node.effect, Effect::PutCounter { .. }) { + put_targets.push(node.effect.target_filter().cloned()); + } + }); + assert_eq!( + put_targets.len(), + 1, + "[{label}] reach-guard: exactly one PutCounter consumer was parsed" + ); + assert!( + !matches!(put_targets[0], Some(TargetFilter::LastCreated)), + "[{label}] the blocked seed leaves the PutCounter off LastCreated (got {:?})", + put_targets[0] + ); + } +} + +/// Shared shipped-card drive. `trig` selects the trigger, `targeted` supplies a +/// real object target (Saheeli's `CopyTokenOf`), `revert` applies the in-test +/// `unrelink` revert-probe. +fn drive_ship(text: &str, trig: usize, funded: bool, revert: bool, targeted: bool) -> Driven { + let mut scenario = GameScenario::new(); + fund(&mut scenario, funded); + let mut runner = scenario.build(); + let source = add_creature(&mut runner, P0, "Src"); + let decoy = pre_seed_decoy(&mut runner); + let victim = add_creature(&mut runner, P0, "Victim"); + if funded { + runner.state_mut().players[0].energy = 3; + } + // Give `CopyTokenOf { TriggeringSource }` a real antecedent object. + runner.state_mut().current_trigger_event = + Some(engine::types::events::GameEvent::PermanentUntapped { object_id: victim }); + let life0 = runner.state().players[0].life; + + let mut def = parse_trigger(text, "Src", &["Creature"], trig); + if revert { + unrelink(&mut def); + } + let targets = if targeted { + vec![TargetRef::Object(victim)] + } else { + vec![] + }; + resolve_def(&mut runner, &def, source, targets); + decide(&mut runner, true); + + let dlife = runner.state().players[0].life - life0; + Driven { + runner, + decoy, + dlife, + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// T18 — the corpus-complete stale-bind invariant over the 20 cards whose AST +// this change moved. +// ═════════════════════════════════════════════════════════════════════════════ + +/// The 20 cards of the BASE↔POST AST diff over all 34348 corpus cards, embedded +/// as inline consts — name, Oracle text and `types` byte-verbatim from +/// `AtomicCards.json`. Hermetic on purpose: `data/mtgjson/AtomicCards.json` and +/// `client/public/card-data.json` are both gitignored and CI's `rust-test` job +/// is a bare checkout + `cargo nextest run`, so a corpus-file form of this test +/// could only be permanently red or permanently skipped. +const CHANGED: &[(&str, &str, &[&str])] = &[ + ( + "Akoum Stonewaker", + "Landfall — Whenever a land you control enters, you may pay {2}{R}. If you do, create a 3/1 red Elemental creature token with trample and haste. Exile that token at the beginning of the next end step.", + &["Creature"], + ), + ( + "Blight Herder", + "When you cast this spell, you may put two cards your opponents own from exile into their owners' graveyards. If you do, create three 1/1 colorless Eldrazi Scion creature tokens. They have \"Sacrifice this token: Add {C}.\"", + &["Creature"], + ), + ( + "Boulder Jockey", + "({D} is a land drop. You may give up one potential land drop this turn to pay for {D}.)\nWhenever Boulder Jockey attacks, you may pay {D}. If you do, create a 3/3 colorless Construct artifact creature token named Boulder that's tapped and attacking. Sacrifice that token at the beginning of the next end step.", + &["Creature"], + ), + ( + "Cadric, Soul Kindler", + "The \"legend rule\" doesn't apply to tokens you control.\nWhenever another nontoken legendary permanent you control enters, you may pay {1}. If you do, create a token that's a copy of it. That token gains haste. Sacrifice it at the beginning of the next end step.", + &["Creature"], + ), + ( + "Dalek Intensive Care", + "When you planeswalk to Dalek Intensive Care and at the beginning of your upkeep, exile a non-Dalek creature you control. If you do, create a 3/3 black Dalek artifact creature token with menace. It gains haste until end of turn.\nWhenever chaos ensues, target Dalek you control deals damage equal to its power to target creature you don't control.", + &["Plane"], + ), + ( + "Felhide Spiritbinder", + "Inspired — Whenever this creature becomes untapped, you may pay {1}{R}. If you do, create a token that's a copy of another target creature, except it's an enchantment in addition to its other types. It gains haste. Exile it at the beginning of the next end step.", + &["Creature"], + ), + ( + "Flameshadow Conjuring", + "Whenever a nontoken creature you control enters, you may pay {R}. If you do, create a token that's a copy of that creature. That token gains haste. Exile it at the beginning of the next end step.", + &["Enchantment"], + ), + ( + "God-Pharaoh's Gift", + "At the beginning of combat on your turn, you may exile a creature card from your graveyard. If you do, create a token that's a copy of that card, except it's a 4/4 black Zombie. It gains haste until end of turn.", + &["Artifact"], + ), + ( + "Gyrus, Waker of Corpses", + "Gyrus enters with a number of +1/+1 counters on it equal to the amount of mana spent to cast it.\nWhenever Gyrus attacks, you may exile target creature card with lesser power from your graveyard. If you do, create a token that's a copy of that card and that's tapped and attacking. Exile the token at end of combat.", + &["Creature"], + ), + ( + "Inalla, Archmage Ritualist", + "Eminence — Whenever another nontoken Wizard you control enters, if Inalla is in the command zone or on the battlefield, you may pay {1}. If you do, create a token that's a copy of that Wizard. The token gains haste. Exile it at the beginning of the next end step.\nTap five untapped Wizards you control: Target player loses 7 life.", + &["Creature"], + ), + ( + "Iroh, Tea Master", + "When Iroh enters, create a Food token.\nAt the beginning of combat on your turn, you may have target opponent gain control of target permanent you control. When you do, create a 1/1 white Ally creature token. Put a +1/+1 counter on that token for each permanent you own that your opponents control.", + &["Creature"], + ), + ( + "Kavaron Harrier", + "Whenever this creature attacks, you may pay {2}. If you do, create a 2/2 colorless Robot artifact creature token that's tapped and attacking. Sacrifice that token at end of combat.", + &["Artifact", "Creature"], + ), + ( + "Krenko, Baron of Tin Street", + "Haste\n{T}, Sacrifice an artifact: Put a +1/+1 counter on each Goblin you control.\nWhenever an artifact is put into a graveyard from the battlefield, you may pay {R}. If you do, create a 1/1 red Goblin creature token. It gains haste until end of turn.", + &["Creature"], + ), + ( + "Rebellion of the Flamekin", + "Whenever you clash, you may pay {1}. If you do, create a 3/1 red Elemental Shaman creature token. If you won, that token gains haste until end of turn. (This ability triggers after the clash ends.)", + &["Kindred", "Enchantment"], + ), + ( + "Saheeli, Radiant Creator", + "Whenever you cast an Artificer or artifact spell, you get {E} (an energy counter).\nAt the beginning of combat on your turn, you may pay {E}{E}{E}. When you do, create a token that's a copy of target permanent you control, except it's a 5/5 artifact creature in addition to its other types and has haste. Sacrifice it at the beginning of the next end step.", + &["Creature"], + ), + ( + "Séance", + "At the beginning of each upkeep, you may exile target creature card from your graveyard. If you do, create a token that's a copy of that card, except it's a Spirit in addition to its other types. Exile it at the beginning of the next end step.", + &["Enchantment"], + ), + ( + "Summoner's Sending", + "At the beginning of your end step, you may exile target creature card from a graveyard. If you do, create a 1/1 white Spirit creature token with flying. Put a +1/+1 counter on it if the exiled card's mana value is 4 or greater.", + &["Enchantment"], + ), + ( + "Timothar, Baron of Bats", + "Ward—Discard a card.\nWhenever another nontoken Vampire you control dies, you may pay {1} and exile it. If you do, create a 1/1 black Bat creature token with flying. It gains \"When this token deals combat damage to a player, sacrifice it and return the exiled card to the battlefield tapped.\"", + &["Creature"], + ), + ( + "Ultron, Artificial Malevolence", + "Whenever another nontoken artifact you control enters, you may pay {2}. If you do, create a token that's a copy of it. If the token isn't a creature, it becomes a 2/2 Robot Villain creature in addition to its other types.", + &["Artifact", "Creature"], + ), + ( + "Vile Redeemer", + "Devoid (This card has no color.)\nFlash\nWhen you cast this spell, you may pay {C}. If you do, create a 1/1 colorless Eldrazi Scion creature token for each nontoken creature that died under your control this turn. Those tokens have \"Sacrifice this token: Add {C}.\"", + &["Creature"], + ), +]; + +/// T18 — no node tagged `SequentialSibling` reads `TargetFilter::LastCreated`, +/// over the 20 cards whose AST this change moved. Such a node is resolved by the +/// condition-false descent against `state.last_created_token_ids`, a +/// game-lifetime ledger, so it would bind a token from an EARLIER resolution. +/// +/// SCOPE: a FROZEN list. Because a card acquires a new `LastCreated` bind from +/// this change only if its parsed AST changed, and the BASE↔POST diff over all +/// 34348 cards is exactly these 20, checking the invariant here checks it over +/// the corpus AS OF THIS CHANGE — it cannot see a card that acquires the shape +/// later. +/// +/// ONE exemption, and it is the same predicate the shipping guard uses: +/// `AbilityDefinition::is_self_gated_reflexive` (`else_ability.is_none()` and +/// condition `EffectOutcome{OptionalEffectPerformed}`). A self-gated node is +/// dropped by the descent's own false-condition skip wherever it sits, so it +/// cannot stale-bind. The test CALLS that method rather than re-deriving it, so +/// the guard and the invariant cannot drift. It is deliberately NOT widened to +/// every `is_affirmative_reflexive_gate` condition: a `WhenYouDo` node reading +/// `LastCreated` DOES stale-bind (its evaluator keys on the effect it rides, so +/// on a `PutCounter` it is a constant true and gates nothing), and T20's +/// `COMMA_GAP_WHENYOUDO` row carries that measurement. +/// +/// NON-VACUITY (asserted in the same test): the number of `ContinuationStep` +/// nodes that DO read `LastCreated` is > 0. On the shipped tree it is 25, and +/// `SequentialSibling` readers are 0; with the re-link reverted the two counts +/// swap sides. +#[test] +fn no_sequential_sibling_reads_last_created() { + let mut stale: Vec = Vec::new(); + let mut exempt = 0usize; + let mut continuation_readers = 0usize; + + for (name, text, types) in CHANGED { + for def in card_definitions(name, text, types) { + walk(&def, &mut |node| { + if !effect_reads_last_created(node) { + return; + } + if node.sub_link != SubAbilityLink::SequentialSibling { + continuation_readers += 1; + } else if node.is_self_gated_reflexive() { + // Benign: the descent's false-condition skip drops it. + exempt += 1; + } else { + stale.push(format!("{name}: {:?}", node.effect)); + } + }); + } + } + + assert_eq!( + CHANGED.len(), + 20, + "the frozen list is the 20-card BASE↔POST AST diff" + ); + // The invariant is asserted BEFORE the non-vacuity control on purpose: + // reverting the re-link moves every reader back to `SequentialSibling`, so + // both assertions would fire and the failure must name the stale nodes + // rather than report an empty continuation count. + assert!( + stale.is_empty(), + "stale LastCreated bind(s) on {} SequentialSibling node(s): {stale:#?}", + stale.len() + ); + assert!( + continuation_readers > 0, + "non-vacuity: no LastCreated reader was found at all, so the invariant checks nothing \ + (exempt={exempt})" + ); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// T19–T21 — the "Repeat this process for …" replication grammar. +// ═════════════════════════════════════════════════════════════════════════════ + +const REPEAT_HEAD: &str = + "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token"; + +/// The complete reachable join space of `try_parse_repeat_process_for_keywords` +/// (anchored on exactly two tags: "repeat this process for " / "do the same +/// for "), crossed with both directive spellings and with the antecedent placed +/// before and after the gated publisher. +const REPEAT_TAILS: &[(&str, &str)] = &[ + ("repeat_sentence", ". Put a flying counter on this creature. Repeat this process for first strike. Put a +1/+1 counter on that token."), + ("repeat_comma", ", put a flying counter on this creature, repeat this process for first strike. Put a +1/+1 counter on that token."), + ("all_comma", ", put a flying counter on this creature, repeat this process for first strike, put a +1/+1 counter on that token."), + ("repeat_then", ", put a flying counter on this creature, then repeat this process for first strike. Put a +1/+1 counter on that token."), + ("repeat_and", ", put a flying counter on this creature and repeat this process for first strike. Put a +1/+1 counter on that token."), + ("dosame_sentence", ". Put a flying counter on this creature. Do the same for first strike. Put a +1/+1 counter on that token."), + ("dosame_comma", ", put a flying counter on this creature, do the same for first strike. Put a +1/+1 counter on that token."), + ("dosame_then", ", put a flying counter on this creature, then do the same for first strike. Put a +1/+1 counter on that token."), + ("dosame_and", ", put a flying counter on this creature and do the same for first strike. Put a +1/+1 counter on that token."), +]; + +/// The DIVERGENCE fixtures: the keyword-counter antecedent sits BEFORE the +/// gated publisher, so the directive clause lands directly after the publisher +/// joined by a comma / "then" / "and" / sentence — the shapes where a clone +/// could land between a gated publisher and a `LastCreated` consumer. +const REPEAT_HOSTILE: &[(&str, &str)] = &[ + ("hostile_comma", "When this creature enters, put a flying counter on this creature. You may pay {2}. If you do, create a 1/1 white Ally creature token, repeat this process for first strike. Put a +1/+1 counter on that token."), + ("hostile_then", "When this creature enters, put a flying counter on this creature. You may pay {2}. If you do, create a 1/1 white Ally creature token, then repeat this process for first strike. Put a +1/+1 counter on that token."), + ("hostile_and", "When this creature enters, put a flying counter on this creature. You may pay {2}. If you do, create a 1/1 white Ally creature token and repeat this process for first strike. Put a +1/+1 counter on that token."), + ("hostile_dosame_comma", "When this creature enters, put a flying counter on this creature. You may pay {2}. If you do, create a 1/1 white Ally creature token, do the same for first strike. Put a +1/+1 counter on that token."), + ("hostile_sentence", "When this creature enters, put a flying counter on this creature. You may pay {2}. If you do, create a 1/1 white Ally creature token. Repeat this process for first strike. Put a +1/+1 counter on that token."), + ("and_min", "When this creature enters, put a flying counter on this creature and repeat this process for first strike."), + ("comma_min", "When this creature enters, put a flying counter on this creature, repeat this process for first strike."), + ("sentence_min", "When this creature enters, put a flying counter on this creature. Repeat this process for first strike."), + ("and_gated", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token and put a flying counter on this creature and repeat this process for first strike. Put a +1/+1 counter on that token."), +]; + +/// T20's fixture set, reused by T19 (its templates DO read `LastCreated`, which +/// the `this creature` fixtures above structurally cannot). +/// +/// (a) GATED-HAZARD + controls, (b) round-8 NO-HAZARD, (c) round-11 acceptance +/// and its paired negative. +const T20_CASES: &[(&str, &str, &[&str])] = &[ + // ── (a) the transplant hazard: template is its OWN sentence, so the clone's + // landing slot is off the gated instruction's continuation path. + ("hazard_seq", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a flying counter on that token. You gain 1 life. Repeat this process for first strike.", &["Creature"]), + ("hazard_seq_whenyoudo", "When this creature enters, you may pay {2}. When you do, create a 1/1 white Ally creature token. Put a flying counter on that token. You gain 1 life. Repeat this process for first strike.", &["Creature"]), + // Hazard via a LATER token creator: the clone's own nearest publisher is the + // Soldier, so the flying template's referent would be transplanted. + ("hazard_2ndpub", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a flying counter on that token, then create a 1/1 white Soldier creature token. Repeat this process for first strike.", &["Creature"]), + // (a) controls. + ("adj", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a flying counter on that token. Repeat this process for first strike.", &["Creature"]), + ("comma_selfref", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token, put a flying counter on this creature. Repeat this process for first strike.", &["Creature"]), + ("ungated_gap", "When this creature enters, create a 1/1 white Ally creature token. Put a flying counter on that token. You gain 1 life. Repeat this process for first strike.", &["Creature"]), + // ── (b) NO-HAZARD: no token, no LastCreated, no gated publisher anywhere. + ("r8_gap_selfref", "When this creature enters, put a flying counter on this creature. You gain 1 life. Repeat this process for first strike.", &["Creature"]), + ("r8_gap2_selfref", "When this creature enters, put a flying counter on this creature. You gain 1 life. Draw a card. Repeat this process for first strike.", &["Creature"]), + ("r8_gap_selfref_dosame", "When this creature enters, put a flying counter on this creature. You gain 1 life. Do the same for first strike.", &["Creature"]), + ("r8_kathril_gap", "When Kathril enters, put a flying counter on any creature you control if a creature card in your graveyard has flying. You gain 1 life. Repeat this process for first strike, deathtouch.", &["Creature"]), + ("r8_adj_selfref", "When this creature enters, put a flying counter on this creature. Repeat this process for first strike.", &["Creature"]), + // ── (c) round-11 acceptance: the landing slot IS reached. + ("cont_gap", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a flying counter on that token, then you gain 1 life. Repeat this process for first strike.", &["Creature"]), + ("cont_gap_2kw", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a flying counter on that token, then you gain 1 life. Repeat this process for first strike and deathtouch.", &["Creature"]), + // ── (c) self-gating leg, and its PAIRED NEGATIVE one connector word apart. + ("comma_gap_ifyoudo", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token, put a flying counter on that token. You gain 1 life. Repeat this process for first strike.", &["Creature"]), + ("comma_gap_dosame", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token, put a flying counter on that token. You gain 1 life. Do the same for first strike.", &["Creature"]), + ("comma_gap_whenyoudo", "When this creature enters, you may pay {2}. When you do, create a 1/1 white Ally creature token, put a flying counter on that token. You gain 1 life. Repeat this process for first strike.", &["Creature"]), +]; + +/// A clone node: a `PutCounter` placing one of the REPLICATED keyword counters +/// (the templates in these fixtures all place `flying`). Typed, never a +/// `blob.contains("first strike")` substring flag — that flag false-positives +/// on the bare-`and` rows, where the phrase survives inside an `Unimplemented` +/// description. +fn is_clone_node(def: &AbilityDefinition) -> bool { + matches!( + &*def.effect, + Effect::PutCounter { + counter_type: CounterType::Keyword(KeywordKind::FirstStrike | KeywordKind::Deathtouch), + .. + } + ) +} + +fn counter_types_of(def: &AbilityDefinition) -> Vec { + let mut out = Vec::new(); + walk(def, &mut |node| { + if let Effect::PutCounter { counter_type, .. } = &*node.effect { + out.push(counter_type.clone()); + } + }); + out +} + +/// T19 — L1 tripwire over the replication grammar's OWN complete join space. +/// +/// The invariant: no replicated clone node reads `LastCreated` from a landing +/// slot the resolver can reach without that token — i.e. no clone is both a +/// `SequentialSibling` `LastCreated` reader and not self-gated. Measured 0 on +/// the shipped tree. +/// +/// Round 6's original XOR form (`clone_present XOR consumer_binds_LastCreated`) +/// was VACUOUS with respect to its own purpose: all 18 `this creature` fixtures +/// are structurally incapable of carrying a `LastCreated` template, so both +/// conjuncts were true on the counterexample. T20's fixture set — whose +/// templates DO read `LastCreated` — is therefore carried here too, and the XOR +/// is replaced by the direct invariant. +/// +/// SCOPE: the invariant is conditioned on "its nearest GATED publisher is +/// unreachable", so a fixture that carries no affirmative reflexive gate at all +/// (`ungated_gap`) is excluded — its publisher creates its token +/// unconditionally, so a `SequentialSibling` `LastCreated` clone there is BASE +/// behaviour and cannot stale-bind. The exclusion is per FIXTURE, not per +/// publisher, which is the conservative direction: a fixture with a gate +/// somewhere but an ungated nearest publisher is still checked, so this can +/// over-report but never under-report. +/// +/// NON-VACUITY (asserted): at least one fixture materialises a clone at all, +/// and at least one clone node reads `LastCreated`, so both arms are witnessed. +#[test] +fn repeat_process_directive_never_joins_a_continuation_path() { + let joined: Vec<(String, String, Vec<&str>)> = REPEAT_TAILS + .iter() + .map(|(l, t)| { + ( + (*l).to_string(), + format!("{REPEAT_HEAD}{t}"), + vec!["Creature"], + ) + }) + .chain( + REPEAT_HOSTILE + .iter() + .map(|(l, t)| ((*l).to_string(), (*t).to_string(), vec!["Creature"])), + ) + .chain( + T20_CASES + .iter() + .map(|(l, t, ty)| ((*l).to_string(), (*t).to_string(), ty.to_vec())), + ) + .collect(); + + let mut clones_seen = 0usize; + let mut clone_last_created_readers = 0usize; + let mut ungated_fixtures = 0usize; + let mut violations: Vec = Vec::new(); + + for (label, text, types) in &joined { + let defs = card_definitions("Repeat Probe", text, types); + let mut has_gate = false; + for def in &defs { + walk(def, &mut |node| { + has_gate |= node + .condition + .as_ref() + .is_some_and(|c| c.is_affirmative_reflexive_gate()); + }); + } + if !has_gate { + ungated_fixtures += 1; + continue; + } + for def in defs { + walk(&def, &mut |node| { + if !is_clone_node(node) { + return; + } + clones_seen += 1; + if !effect_reads_last_created(node) { + return; + } + clone_last_created_readers += 1; + if node.sub_link == SubAbilityLink::SequentialSibling + && !node.is_self_gated_reflexive() + { + violations.push(format!("{label}: {:?}", node.effect)); + } + }); + } + } + + assert!( + clones_seen > 0, + "non-vacuity: no replicated keyword counter materialised in ANY fixture" + ); + assert!( + clone_last_created_readers > 0, + "non-vacuity: no clone read LastCreated, so the invariant checks nothing" + ); + assert!( + ungated_fixtures > 0 && ungated_fixtures < joined.len(), + "the gate-presence filter is neither empty nor total (excluded {ungated_fixtures} of {})", + joined.len() + ); + assert!( + violations.is_empty(), + "a replicated clone landed off the gated instruction's continuation path while reading \ + LastCreated: {violations:#?}" + ); +} + +/// One driven row of the T20 matrix. +struct CloneRow { + runner: GameRunner, + decoy: ObjectId, + dlife: i32, + dhand: i64, + live: Vec, +} + +fn drive_clone(text: &str, types: &[&str], funded: bool) -> CloneRow { + let mut scenario = GameScenario::new(); + fund(&mut scenario, funded); + let mut runner = scenario.build(); + let source = add_creature(&mut runner, P0, "Synth"); + let decoy = pre_seed_decoy(&mut runner); + add_object(&mut runner, P0, "Library Filler", Zone::Library); + let life0 = runner.state().players[0].life; + let hand0 = runner.state().players[0].hand.len(); + + let def = parse_trigger(text, "Synth", types, 0); + resolve_def(&mut runner, &def, source, vec![]); + decide(&mut runner, true); + + let live = live_tokens(&runner, decoy); + CloneRow { + dlife: runner.state().players[0].life - life0, + dhand: runner.state().players[0].hand.len() as i64 - hand0 as i64, + runner, + decoy, + live, + } +} + +fn case_text(label: &str) -> (&'static str, &'static [&'static str]) { + T20_CASES + .iter() + .find(|(l, _, _)| *l == label) + .map(|(_, t, ty)| (*t, *ty)) + .unwrap_or_else(|| panic!("no T20 fixture named {label:?}")) +} + +/// T20 — a "Repeat this process for …" clone must never transplant a gated +/// publisher's just-created-token referent to a slot the resolver reaches +/// without that token, and must never silently DROP a printed replication that +/// is honest where it lands. +/// +/// (a) GATED-HAZARD, gate FALSE: the decoy gets no `Keyword(FirstStrike)` +/// counter, reach-guarded by `cost_payment_failed_flag` and "no live token +/// was created". +/// (b) NO-HAZARD (no token, no `LastCreated`, no gated publisher): every printed +/// replication is still emitted. +/// (c) LANDING-SLOT REACHED / SELF-GATED, gate TRUE: the live token gets BOTH +/// counters — paired with the negative `comma_gap_whenyoudo`, which gets only +/// `flying`, one connector word apart. +#[test] +fn repeat_process_clone_does_not_transplant_a_gated_referent() { + // ── (a) the hazard rows, gate FALSE. + for label in [ + "hazard_seq", + "hazard_seq_whenyoudo", + "hazard_2ndpub", + "comma_gap_dosame", + "adj", + "comma_selfref", + ] { + let (text, types) = case_text(label); + let r = drive_clone(text, types, false); + assert!( + r.runner.state().cost_payment_failed_flag, + "[{label}] reach-guard: the payment failed, so the gate really went FALSE" + ); + assert_eq!( + r.live, + Vec::::new(), + "[{label}] reach-guard: no token was created on the false gate" + ); + assert_eq!( + kw_counter(&r.runner, r.decoy, KeywordKind::FirstStrike), + 0, + "[{label}] the stale decoy receives NO replicated first-strike counter" + ); + assert_eq!( + kw_counter(&r.runner, r.decoy, KeywordKind::Flying), + 0, + "[{label}] nor the template's own flying counter" + ); + } + // The `*_gap` hazard rows carry an independent intervening instruction, so + // its resolution is an extra reach-guard. + for label in ["hazard_seq", "hazard_seq_whenyoudo"] { + let (text, types) = case_text(label); + assert_eq!( + drive_clone(text, types, false).dlife, + 1, + "[{label}] reach-guard: the intervening 'You gain 1 life.' really resolved" + ); + } + + // ── (a) ungated control: BASE behaviour, benign — the live token keeps BOTH + // printed counters because its publisher is unconditional. + { + let (text, types) = case_text("ungated_gap"); + let r = drive_clone(text, types, false); + let ally = token_named(&r.runner, r.decoy, "Ally"); + assert_eq!( + ( + kw_counter(&r.runner, ally, KeywordKind::Flying), + kw_counter(&r.runner, ally, KeywordKind::FirstStrike), + ), + (1, 1), + "ungated_gap: an UNGATED publisher's replication is never dropped" + ); + assert_eq!( + kw_counter(&r.runner, r.decoy, KeywordKind::FirstStrike), + 0, + "ungated_gap: and nothing lands on the stale decoy" + ); + } + + // ── (b) NO-HAZARD acceptance: every printed replication is emitted. + for label in [ + "r8_gap_selfref", + "r8_gap2_selfref", + "r8_gap_selfref_dosame", + "r8_adj_selfref", + ] { + let (text, types) = case_text(label); + let r = drive_clone(text, types, false); + let source = *r + .runner + .state() + .battlefield + .iter() + .find(|id| r.runner.state().objects[id].name == "Synth") + .expect("the source is on the battlefield"); + assert_eq!( + ( + kw_counter(&r.runner, source, KeywordKind::Flying), + kw_counter(&r.runner, source, KeywordKind::FirstStrike), + ), + (1, 1), + "[{label}] both the template's and the replicated counter are placed" + ); + if label != "r8_adj_selfref" { + assert_eq!( + r.dlife, 1, + "[{label}] reach-guard: the intervening 'You gain 1 life.' really resolved" + ); + } + if label == "r8_gap2_selfref" { + assert_eq!( + r.dhand, 1, + "[{label}] reach-guard: the second intervening 'Draw a card.' really resolved" + ); + } + } + // Kathril's own templating: its gate needs a graveyard, so this row is + // asserted on the parsed replication, with the intervening clause's + // resolution as the reach-guard. + { + let (text, types) = case_text("r8_kathril_gap"); + let r = drive_clone(text, types, false); + assert_eq!( + r.dlife, 1, + "r8_kathril_gap: reach-guard: the intervening clause really resolved" + ); + let kinds: Vec = card_definitions("Kathril Gap", text, types) + .iter() + .flat_map(counter_types_of) + .collect(); + for k in [ + KeywordKind::Flying, + KeywordKind::FirstStrike, + KeywordKind::Deathtouch, + ] { + assert!( + kinds.contains(&CounterType::Keyword(k)), + "r8_kathril_gap: the printed {k:?} replication is not dropped (got {kinds:?})" + ); + } + } + + // ── (c) landing slot reached / self-gated, gate TRUE. + for (label, extra) in [ + ("cont_gap", None), + ("cont_gap_2kw", Some(KeywordKind::Deathtouch)), + ("comma_gap_ifyoudo", None), + ("comma_gap_dosame", None), + ] { + let (text, types) = case_text(label); + let r = drive_clone(text, types, true); + let ally = token_named(&r.runner, r.decoy, "Ally"); + assert_eq!( + ( + kw_counter(&r.runner, ally, KeywordKind::Flying), + kw_counter(&r.runner, ally, KeywordKind::FirstStrike), + ), + (1, 1), + "[{label}] the live token receives the template AND the replicated counter" + ); + if let Some(k) = extra { + assert_eq!( + kw_counter(&r.runner, ally, k), + 1, + "[{label}] and the second listed keyword" + ); + } + } + + // ── (c) THE PAIRED NEGATIVE, one connector word apart: `When you do` is not + // position-independent (its evaluator keys on the effect it rides), so the + // clone must NOT be emitted — and on the false path the decoy stays clean. + { + let (text, types) = case_text("comma_gap_whenyoudo"); + let r = drive_clone(text, types, true); + let ally = token_named(&r.runner, r.decoy, "Ally"); + assert_eq!( + kw_counter(&r.runner, ally, KeywordKind::Flying), + 1, + "comma_gap_whenyoudo: reach-guard: the gate went TRUE and the template resolved" + ); + assert_eq!( + kw_counter(&r.runner, ally, KeywordKind::FirstStrike), + 0, + "comma_gap_whenyoudo: a WhenYouDo-gated clone is NOT position-independent, so the \ + replication is declined rather than transplanted" + ); + let f = drive_clone(text, types, false); + assert!( + f.runner.state().cost_payment_failed_flag, + "comma_gap_whenyoudo: reach-guard: the false arm's payment really failed" + ); + assert_eq!( + kw_counter(&f.runner, f.decoy, KeywordKind::FirstStrike), + 0, + "comma_gap_whenyoudo: and the stale decoy never receives the replicated counter" + ); + } + + // ── AST-shape: the only `SequentialSibling` `LastCreated` readers in the + // whole fixture set are `ungated_gap`'s (BASE behaviour, benign — its + // publisher is unconditional) and the SELF-GATED clones. + let mut ungated_seq = 0usize; + let mut selfgated_seq = 0usize; + for (label, text, types) in T20_CASES { + for def in card_definitions("T20", text, types) { + walk(&def, &mut |node| { + if node.sub_link != SubAbilityLink::SequentialSibling + || !effect_reads_last_created(node) + { + return; + } + if *label == "ungated_gap" { + ungated_seq += 1; + } else if node.is_self_gated_reflexive() { + selfgated_seq += 1; + } else { + panic!( + "[{label}] stale SequentialSibling LastCreated reader: {:?}", + node.effect + ); + } + }); + } + } + assert_eq!( + ungated_seq, 2, + "ungated_gap keeps BOTH its template and its clone as SequentialSibling LastCreated \ + readers (benign: the publisher is unconditional)" + ); + assert_eq!( + selfgated_seq, 2, + "exactly the two self-gated clones (comma_gap_ifyoudo, comma_gap_dosame) are exempt" + ); +} + +/// The complete set of cards whose Oracle text contains "repeat this process +/// for " or "do the same for " — corpus-complete by construction, since +/// `try_parse_repeat_process_for_keywords` is anchored on exactly those two +/// tags and is the sole producer of `ReplicateKind::CounterPlacement`. +/// Embedded verbatim from `AtomicCards.json`, like T18's fixtures. +const REPEAT_PROCESS_CORPUS: &[(&str, &str, &[&str])] = &[ + ( + "Equipoise", + "At the beginning of your upkeep, for each land target player controls in excess of the number you control, choose a land that player controls, then the chosen permanents phase out. Repeat this process for artifacts and creatures. (While they're phased out, they're treated as though they don't exist. They phase in before that player untaps during their next untap step.)", + &["Enchantment"], + ), + ( + "Estrid, the Masked", + "[+2]: Untap each enchanted permanent you control.\n[\u{2212}1]: Create a white Aura enchantment token named Mask attached to another target permanent. The token has enchant permanent and umbra armor.\n[\u{2212}7]: Mill seven cards. Return all non-Aura enchantment cards from your graveyard to the battlefield, then do the same for Aura cards.\nEstrid, the Masked can be your commander.", + &["Planeswalker"], + ), + ( + "Estrid, the Masked // Estrid, the Masked", + "[+2]: Untap each enchanted permanent you control.\n[\u{2212}1]: Create a white Aura enchantment token named Mask attached to another target permanent. The token has enchant permanent and umbra armor.\n[\u{2212}7]: Mill seven cards. Return all non-Aura enchantment cards from your graveyard to the battlefield, then do the same for Aura cards.\nEstrid, the Masked can be your commander.", + &["Planeswalker"], + ), + ( + "Firemind's Foresight", + "Search your library for an instant card with mana value 3, reveal it, and put it into your hand. Then repeat this process for instant cards with mana values 2 and 1. Then shuffle.", + &["Instant"], + ), + ( + "Glimpse of Tomorrow", + "Suspend 3\u{2014}{R}{R}\nShuffle all permanents you own into your library, then reveal that many cards from the top of your library. Put all non-Aura permanent cards revealed this way onto the battlefield, then do the same for Aura cards, then put the rest on the bottom of your library in a random order.", + &["Sorcery"], + ), + ( + "Grim Captain's Call", + "Return a Pirate card from your graveyard to your hand, then do the same for Vampire, Dinosaur, and Merfolk.", + &["Sorcery"], + ), + ( + "Gruesome Menagerie", + "Choose a creature card with mana value 1 in your graveyard, then do the same for creature cards with mana value 2 and 3. Return those cards to the battlefield.", + &["Sorcery"], + ), + ( + "Invoke Despair", + "Target opponent sacrifices a creature of their choice. If they can't, they lose 2 life and you draw a card. Then repeat this process for an enchantment and a planeswalker.", + &["Sorcery"], + ), + ( + "Kathril, Aspect Warper", + "When Kathril enters, put a flying counter on any creature you control if a creature card in your graveyard has flying. Repeat this process for first strike, double strike, deathtouch, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance. Then put a +1/+1 counter on Kathril for each counter put on a creature this way.", + &["Creature"], + ), + ( + "Queen Kayla bin-Kroog", + "{4}, {T}: Discard all the cards in your hand, then draw that many cards. You may choose an artifact or creature card with mana value 1 you discarded this way, then do the same for artifact or creature cards with mana values 2 and 3. Return those cards to the battlefield. Activate only as a sorcery.", + &["Creature"], + ), + ( + "Runed Terror", + "Instead of taking turns as normal, players take their phases sequentially. (For example, you take your beginning phase as the active player, then the next player in turn order becomes the active player and takes their beginning phase. After each player had a beginning phase, do the same for first main, combat phase, second main, ending phase, and then beginning phase again. If this creature leaves the battlefield, the active player continues their turn as normal.)", + &["Artifact", "Creature"], + ), + ( + "Super-Adaptoid", + "Super-Adaptoid's power is equal to the number of legendary creatures you control.\nWhenever Super-Adaptoid enters or attacks, choose another target creature. If that creature has haste and Super-Adaptoid doesn't, put a haste counter on Super-Adaptoid. Do the same for flying, first strike, double strike, deathtouch, indestructible, lifelink, menace, reach, trample, and vigilance.", + &["Artifact", "Creature"], + ), +]; + +/// T21 — the shipped-card replication pin (round-8's restatement of the +/// round-7 adjacency backstop, which asserted two locals inside +/// `assemble_effect_chain` and was therefore not implementable from +/// `crates/engine/tests/`). +/// +/// The observable proxy is the multiset of `PutCounter.counter_type` values +/// reachable from each card's parsed definition. It FAILS if a parser change +/// makes either shipped card bind differently or drop a listed keyword. It +/// deliberately does NOT claim to fire for a NEW card with a non-adjacent +/// directive: such a card emits no clones and no frozen-list test notices. The +/// §7.6 guard is what removes the need for that tripwire — a non-adjacent +/// directive is no longer dropped unless it would transplant a gated referent. +#[test] +fn repeat_process_replicates_every_listed_keyword() { + fn kws(list: &[KeywordKind]) -> Vec { + list.iter().copied().map(CounterType::Keyword).collect() + } + + // Kathril: the flying template + its ten printed clones + the + // "Then put a +1/+1 counter on Kathril" tail. + let kathril = { + let mut v = kws(&[ + KeywordKind::Flying, + KeywordKind::FirstStrike, + KeywordKind::DoubleStrike, + KeywordKind::Deathtouch, + KeywordKind::Hexproof, + KeywordKind::Indestructible, + KeywordKind::Lifelink, + KeywordKind::Menace, + KeywordKind::Reach, + KeywordKind::Trample, + KeywordKind::Vigilance, + ]); + v.push(CounterType::Plus1Plus1); + v + }; + // Super-Adaptoid: the haste template + its ten printed clones. + let super_adaptoid = kws(&[ + KeywordKind::Haste, + KeywordKind::Flying, + KeywordKind::FirstStrike, + KeywordKind::DoubleStrike, + KeywordKind::Deathtouch, + KeywordKind::Indestructible, + KeywordKind::Lifelink, + KeywordKind::Menace, + KeywordKind::Reach, + KeywordKind::Trample, + KeywordKind::Vigilance, + ]); + + let mut reached = 0usize; + for (name, text, types) in REPEAT_PROCESS_CORPUS { + let kinds: Vec = card_definitions(name, text, types) + .iter() + .flat_map(counter_types_of) + .collect(); + let expected: &[CounterType] = match *name { + "Kathril, Aspect Warper" => &kathril, + "Super-Adaptoid" => &super_adaptoid, + _ => &[], + }; + if !expected.is_empty() { + reached += 1; + } + assert_eq!( + kinds, expected, + "{name}: the parsed counter-placement multiset changed" + ); + } + + assert_eq!( + REPEAT_PROCESS_CORPUS.len(), + 12, + "corpus-complete by construction: exactly the cards whose Oracle text carries one of the \ + two anchored directive tags" + ); + assert_eq!( + reached, 2, + "non-vacuity: exactly two of the twelve reach the counter-placement binding" + ); +} diff --git a/crates/engine/tests/integration/riptide_gearhulk_5994.rs b/crates/engine/tests/integration/riptide_gearhulk_5994.rs new file mode 100644 index 0000000000..6c77fa06a0 --- /dev/null +++ b/crates/engine/tests/integration/riptide_gearhulk_5994.rs @@ -0,0 +1,116 @@ +//! Runtime regression for issue #5994: Riptide Gearhulk's per-opponent ETB target +//! fanout must be OPTIONAL ("up to one target"), so declining a target for an +//! opponent does not fizzle the ability. +//! +//! Riptide's ETB — "When this creature enters, for each opponent, put up to one +//! target nonland permanent that player controls into its owner's library third +//! from the top." — lowers to a per-opponent fanout with +//! `MultiTargetSpec { min: 0, max: PlayerCount { Opponent } }`. Before the parser +//! fix, the "put ..." verb fell through `per_opponent_target_fanout_min`'s +//! "gain control of"-only anchor and each per-opponent target lowered as MANDATORY +//! (`min: 1`); declining one then made the ability fizzle (and the harness's +//! required-slot check panics), which is what these tests would catch on a revert. +//! +//! https://github.com/phase-rs/phase/issues/5994 + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; +use engine::types::PlayerId; + +/// Riptide Gearhulk's verbatim Oracle text (keywords carried separately). +const RIPTIDE_ORACLE: &str = "Double strike\nProwess\nWhen this creature enters, for each opponent, put up to one target nonland permanent that player controls into its owner's library third from the top."; + +const P2: PlayerId = PlayerId(2); + +/// {1}{W}{W}{U}{U} funded into the pool so the cast auto-pays and never surfaces +/// a `ManaPayment` window. +fn riptide_mana() -> Vec { + vec![ + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::White, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::White, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Blue, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Blue, ObjectId(0), false, vec![]), + ] +} + +/// CR 601.2c (issue #5994): declining EVERY per-opponent target must resolve the +/// ability, not fizzle. In a three-player game (two opponents) with a nonland +/// permanent under each opponent, casting Riptide and declining both optional +/// per-opponent targets leaves both permanents on the battlefield and resolves +/// cleanly. If the `min` lowering reverted to 1, the per-opponent object slot +/// would be REQUIRED and the harness could not decline it (a required-slot panic). +#[test] +fn riptide_declining_all_per_opponent_targets_does_not_fizzle() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, riptide_mana()); + + let p1_perm = scenario.add_creature(P1, "Opp1 Bear", 2, 2).id(); + let p2_perm = scenario.add_creature(P2, "Opp2 Bear", 2, 2).id(); + let riptide = scenario + .add_creature_to_hand_from_oracle(P0, "Riptide Gearhulk", 4, 4, RIPTIDE_ORACLE) + .id(); + + let mut runner = scenario.build(); + + // Declare no object targets: every optional per-opponent object slot is + // declined. (Player intent is offered so player slots, if any, are satisfied.) + let outcome = runner.cast(riptide).target_players(&[P1, P2]).resolve(); + + // CR 601.2c: the ability must actually RESOLVE (return priority), not stall + // mid-resolution. This is the discriminating check: a mandatory (`min: 1`) + // lowering does not produce declinable per-opponent target slots — it halts at + // a non-targeted `EffectZoneChoice` and never reaches this Priority window, so + // the assertion fails on a revert. + assert!( + matches!(outcome.final_waiting_for(), WaitingFor::Priority { .. }), + "declining every per-opponent target must resolve the ETB back to priority, \ + got {:?}", + outcome.final_waiting_for() + ); + outcome.assert_zone(&[riptide], Zone::Battlefield); + outcome.assert_zone(&[p1_perm], Zone::Battlefield); + outcome.assert_zone(&[p2_perm], Zone::Battlefield); +} + +/// CR 601.2c (issue #5994): with two opponents, the controller may choose a target +/// for ONE opponent while DECLINING the (optional) per-opponent target for the +/// other, and the ability still resolves — moving only the chosen permanent. This +/// is the discriminating "one selected, one declined" scenario: a mandatory +/// (`min: 1`) lowering would force a target for the second opponent too. +#[test] +fn riptide_selects_one_opponent_target_and_declines_the_other() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, riptide_mana()); + // Give P1 a library card so the moved permanent's "third from the top" + // placement has somewhere to sit; P2's permanent is untouched anyway. + scenario.add_card_to_library_top(P1, "P1 Library Card"); + + let p1_perm = scenario.add_creature(P1, "Opp1 Bear", 2, 2).id(); + let p2_perm = scenario.add_creature(P2, "Opp2 Bear", 2, 2).id(); + let riptide = scenario + .add_creature_to_hand_from_oracle(P0, "Riptide Gearhulk", 4, 4, RIPTIDE_ORACLE) + .id(); + + let mut runner = scenario.build(); + + // Choose P1's permanent for P1's slot; declare no target for P2 → P2's + // optional per-opponent slot is declined. + let outcome = runner + .cast(riptide) + .target_players(&[P1, P2]) + .target_objects(&[p1_perm]) + .resolve(); + + outcome.assert_zone(&[riptide], Zone::Battlefield); + // The chosen permanent left the battlefield for its owner's library. + outcome.assert_zone(&[p1_perm], Zone::Library); + // The declined opponent's permanent is untouched. + outcome.assert_zone(&[p2_perm], Zone::Battlefield); +} diff --git a/crates/engine/tests/integration/s07_coiling_rebirth_full_cast.rs b/crates/engine/tests/integration/s07_coiling_rebirth_full_cast.rs index f56c335faa..12fa5c21ac 100644 --- a/crates/engine/tests/integration/s07_coiling_rebirth_full_cast.rs +++ b/crates/engine/tests/integration/s07_coiling_rebirth_full_cast.rs @@ -73,6 +73,12 @@ fn run_coiling(promise_gift: bool, legendary: bool) -> usize { .act(GameAction::DecideOptionalCost { pay: promise_gift }) .expect("decide gift"); } + WaitingFor::ChooseGiftRecipient { candidates, .. } => { + let opponent = candidates[0]; + runner + .act(GameAction::ChooseGiftRecipient { opponent }) + .expect("choose gift recipient"); + } WaitingFor::TargetSelection { .. } => { runner .act(GameAction::ChooseTarget { diff --git a/crates/engine/tests/integration/s07_malamet_castpath.rs b/crates/engine/tests/integration/s07_malamet_castpath.rs index 8f467c1936..1471acac6d 100644 --- a/crates/engine/tests/integration/s07_malamet_castpath.rs +++ b/crates/engine/tests/integration/s07_malamet_castpath.rs @@ -57,6 +57,12 @@ fn drive_cast(runner: &mut GameRunner, spell: ObjectId, targets: &[ObjectId], pr .act(GameAction::DecideOptionalCost { pay: promise_gift }) .expect("decide gift"); } + WaitingFor::ChooseGiftRecipient { candidates, .. } => { + let opponent = candidates[0]; + runner + .act(GameAction::ChooseGiftRecipient { opponent }) + .expect("choose gift recipient"); + } WaitingFor::TargetSelection { .. } => { let t = targets[next_target]; next_target += 1; diff --git a/crates/engine/tests/integration/strategic_betrayal_6505.rs b/crates/engine/tests/integration/strategic_betrayal_6505.rs new file mode 100644 index 0000000000..501abc7016 --- /dev/null +++ b/crates/engine/tests/integration/strategic_betrayal_6505.rs @@ -0,0 +1,816 @@ +//! Issue #6505: Strategic Betrayal ({1}{B} Sorcery) — verbatim Oracle text: +//! "Target opponent exiles a creature they control and their graveyard." +//! +//! Rules-correct behavior (CR 115.1a + CR 115.10a/b + CR 702.21a + CR 109.4): +//! the spell targets ONLY the opponent (CR 115.1a). That opponent then CHOOSES +//! — does not TARGET — a creature THEY control to exile, and exiles THEIR +//! graveyard. Because the creature is chosen at resolution rather than targeted, +//! no `BecomesTarget` event fires, so Ward on the opponent's OWN creature never +//! triggers (CR 115.10a: being affected by a spell is not being targeted). +//! +//! The bug had two halves: +//! (A) origin-inference leaked `Zone::Graveyard` onto the battlefield-creature +//! leg (a trailing "and their graveyard" conjunct polluted the scan), and +//! (B) the creature leg was emitted as a `Stack` target (Ward) chosen by the +//! wrong actor (the caster instead of the target opponent). +//! +//! These tests are per-mechanism revert-sensitive; each notes which revert flips +//! it (see the file header in the PR for the RED→GREEN matrix). + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::parser::oracle::parse_oracle_text; +use engine::types::ability::{ + AbilityDefinition, ControllerRef, Effect, TargetChoiceTiming, TargetFilter, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::{Keyword, WardCost}; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +/// Strategic Betrayal — verbatim Oracle text (used to build every test card). +const SB_ORACLE: &str = "Target opponent exiles a creature they control and their graveyard."; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Walk an ability chain (effect + sub_ability + else_ability) for any +/// `Effect::Unimplemented` — the coverage-honesty reach-guard shared by every +/// positive test so a vacuous pass (parse failed to a strict `Unimplemented`) +/// is impossible. +fn chain_has_unimplemented(def: &AbilityDefinition) -> bool { + fn effect_unimplemented(effect: &Effect) -> bool { + matches!(effect, Effect::Unimplemented { .. }) + } + effect_unimplemented(&def.effect) + || def + .sub_ability + .as_deref() + .is_some_and(chain_has_unimplemented) + || def + .else_ability + .as_deref() + .is_some_and(chain_has_unimplemented) +} + +/// The single parsed spell ability for a verbatim sorcery body. +fn parse_sb() -> AbilityDefinition { + let parsed = parse_oracle_text(SB_ORACLE, "Strategic Betrayal", &[], &[], &[]); + assert_eq!( + parsed.abilities.len(), + 1, + "SB parses to exactly one spell ability; got {:#?}", + parsed.abilities + ); + parsed.abilities.into_iter().next().unwrap() +} + +/// Find the first `ChangeZone` (single, the creature leg) in a chain. +fn find_change_zone(def: &AbilityDefinition) -> Option<&AbilityDefinition> { + let mut cursor = Some(def); + while let Some(node) = cursor { + if matches!(*node.effect, Effect::ChangeZone { .. }) { + return Some(node); + } + cursor = node.sub_ability.as_deref(); + } + None +} + +/// Find the first `ChangeZoneAll` (mass, the graveyard leg) in a chain. +fn find_change_zone_all(def: &AbilityDefinition) -> Option<&AbilityDefinition> { + let mut cursor = Some(def); + while let Some(node) = cursor { + if matches!(*node.effect, Effect::ChangeZoneAll { .. }) { + return Some(node); + } + cursor = node.sub_ability.as_deref(); + } + None +} + +fn ward2_creature( + scenario: &mut GameScenario, + controller: engine::types::player::PlayerId, + p: i32, + t: i32, + name: &str, +) -> ObjectId { + scenario + .add_creature(controller, name, p, t) + .with_keyword(Keyword::Ward(WardCost::Mana(ManaCost::generic(2)))) + .id() +} + +fn graveyard_ids(runner: &GameRunner, player: engine::types::player::PlayerId) -> Vec { + runner.state().players[player.0 as usize] + .graveyard + .iter() + .copied() + .collect() +} + +// --------------------------------------------------------------------------- +// 1. END-TO-END — the headline behavior. +// --------------------------------------------------------------------------- + +/// Opponent controls TWO creatures (one warded) plus graveyard cards. Casting +/// SB on the opponent must: (a) raise NO Ward payment/choice; (b) prompt the +/// OPPONENT with an `EffectZoneChoice` offering its two battlefield creatures; +/// (c) exile the chosen creature; (d) exile the opponent's whole graveyard. +/// +/// Revert sensitivity: reverting the timing stamp flips (a) to a Ward +/// `UnlessPayment`; reverting the `scoped_player` stamp flips (b)'s player to +/// the caster; reverting the origin slice flips (b)'s offered set to graveyard. +#[test] +fn end_to_end_opponent_chooses_creature_and_loses_graveyard_no_ward() { + // Reach-guard: the spell parses with zero Unimplemented before we cast it. + let sb = parse_sb(); + assert!( + !chain_has_unimplemented(&sb), + "SB must parse with no strict-failure Unimplemented; got {sb:#?}" + ); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let plain = scenario.add_creature(P1, "Opp Plain Beast", 2, 2).id(); + let warded = ward2_creature(&mut scenario, P1, 3, 3, "Opp Warded Beast"); + scenario.with_graveyard(P1, &["Grave Relic A", "Grave Relic B"]); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Strategic Betrayal", false, SB_ORACLE) + .id(); + + let mut runner = scenario.build(); + let gy = graveyard_ids(&runner, P1); + assert_eq!(gy.len(), 2, "opponent graveyard seeded with two cards"); + + let outcome = runner.cast(spell).target_player(P1).resolve(); + + // (a) + (b): no Ward, opponent is the chooser, both battlefield creatures + // offered — and NOT the graveyard cards. + match outcome.final_waiting_for() { + WaitingFor::EffectZoneChoice { player, cards, .. } => { + assert_eq!(*player, P1, "the OPPONENT chooses the exiled creature"); + assert!( + cards.contains(&plain) && cards.contains(&warded), + "both of the opponent's battlefield creatures must be offered; got {cards:?}" + ); + for g in &gy { + assert!( + !cards.contains(g), + "graveyard cards must not be offered to the creature leg; got {cards:?}" + ); + } + } + WaitingFor::UnlessPayment { .. } + | WaitingFor::WardSacrificeChoice { .. } + | WaitingFor::WardDiscardChoice { .. } => { + panic!( + "Ward must NOT fire — the creature is chosen at resolution, not targeted; got {:?}", + outcome.final_waiting_for() + ); + } + other => panic!("expected the opponent's creature EffectZoneChoice; got {other:?}"), + } + + // (c): choose the plain creature. + runner + .act(GameAction::SelectCards { cards: vec![plain] }) + .expect("opponent selects a creature to exile"); + + assert_eq!( + runner.state().objects[&plain].zone, + Zone::Exile, + "the chosen creature is exiled" + ); + assert_eq!( + runner.state().objects[&warded].zone, + Zone::Battlefield, + "the unchosen creature stays on the battlefield" + ); + // (d): the opponent's graveyard is exiled wholesale. + for g in &gy { + assert_eq!( + runner.state().objects[g].zone, + Zone::Exile, + "every card in the opponent's graveyard is exiled" + ); + } + assert!( + graveyard_ids(&runner, P1).is_empty(), + "the opponent's graveyard is now empty" + ); +} + +// --------------------------------------------------------------------------- +// 2. B(1)(c) ALONE — Ward isolation via the Resolution timing stamp. +// --------------------------------------------------------------------------- + +/// The creature leg parses as a Resolution-timed, non-targeted pick (SHAPE), and +/// at runtime a lone warded creature raises NO Ward. Reverting ONLY the timing +/// stamp restores `TargetChoiceTiming::Stack`, which re-introduces the +/// `BecomesTarget` → Ward `UnlessPayment` path. +#[test] +fn creature_leg_is_resolution_chosen_so_ward_never_fires() { + // SHAPE: creature leg carries Resolution timing (default is Stack). + let sb = parse_sb(); + let creature = find_change_zone(&sb).expect("SB has a single-creature ChangeZone leg"); + assert_eq!( + creature.target_choice_timing, + TargetChoiceTiming::Resolution, + "the creature leg must be resolution-chosen (non-targeted), not a Stack target" + ); + + // Runtime: a single warded creature (auto-selected once chosen at + // resolution) must not produce a Ward payment window. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let warded = ward2_creature(&mut scenario, P1, 3, 3, "Lone Warded Beast"); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Strategic Betrayal", false, SB_ORACLE) + .id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(spell).target_player(P1).resolve(); + assert!( + !matches!( + outcome.final_waiting_for(), + WaitingFor::UnlessPayment { .. } + | WaitingFor::WardSacrificeChoice { .. } + | WaitingFor::WardDiscardChoice { .. } + ), + "Ward must stay silent on a resolution-chosen creature; got {:?}", + outcome.final_waiting_for() + ); + // Reach-guard: the single warded creature was exiled (we reached resolution, + // proving the no-Ward assertion is not vacuous). + assert_eq!( + runner.state().objects[&warded].zone, + Zone::Exile, + "the lone warded creature is exiled at resolution" + ); +} + +// --------------------------------------------------------------------------- +// 3. B(2) ALONE — chooser isolation via the scoped_player stamp. +// --------------------------------------------------------------------------- + +/// The OPPONENT (not the caster) is the chooser of the exiled creature. +/// Reverting ONLY the `scoped_player` stamp in `stack::resolve_top` flips the +/// `EffectZoneChoice.player` back to the caster. +#[test] +fn opponent_not_caster_is_the_chooser() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // Two creatures so the choice is a real prompt (not an auto-pick). + scenario.add_creature(P1, "Opp A", 2, 2); + scenario.add_creature(P1, "Opp B", 2, 2); + // The caster also controls creatures — if the chooser wrongly resolved to + // the caster, its own creatures (not the opponent's) would be offered. + let caster_creature = scenario.add_creature(P0, "Caster Own", 4, 4).id(); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Strategic Betrayal", false, SB_ORACLE) + .id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(spell).target_player(P1).resolve(); + match outcome.final_waiting_for() { + WaitingFor::EffectZoneChoice { player, cards, .. } => { + assert_eq!( + *player, P1, + "the target OPPONENT is the chooser, not the caster" + ); + assert!( + !cards.contains(&caster_creature), + "the caster's own creature must never be an eligible pick; got {cards:?}" + ); + } + other => panic!("expected the opponent's EffectZoneChoice; got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// 4. PART A ALONE — the eligible set is the BATTLEFIELD, not the graveyard. +// --------------------------------------------------------------------------- + +/// With graveyard cards present, the creature leg still offers BATTLEFIELD +/// creatures. Reverting the origin slice (letting the trailing "and their +/// graveyard" leak `Zone::Graveyard` onto the creature leg) would make the +/// eligible set the graveyard cards instead. Bites imperative.rs:8380. +#[test] +fn creature_leg_selects_from_battlefield_not_graveyard() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let bf_a = scenario.add_creature(P1, "BF Creature A", 2, 2).id(); + let bf_b = scenario.add_creature(P1, "BF Creature B", 2, 2).id(); + scenario.with_graveyard(P1, &["GY Card X", "GY Card Y"]); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Strategic Betrayal", false, SB_ORACLE) + .id(); + let mut runner = scenario.build(); + let gy = graveyard_ids(&runner, P1); + + let outcome = runner.cast(spell).target_player(P1).resolve(); + match outcome.final_waiting_for() { + WaitingFor::EffectZoneChoice { + player, + cards, + zone, + .. + } => { + assert_eq!(*player, P1); + assert_eq!( + *zone, + Zone::Battlefield, + "the creature leg must select from the battlefield" + ); + assert!( + cards.contains(&bf_a) && cards.contains(&bf_b), + "battlefield creatures are the eligible set; got {cards:?}" + ); + for g in &gy { + assert!( + !cards.contains(g), + "graveyard cards must not be eligible for the creature leg; got {cards:?}" + ); + } + } + other => panic!("expected a battlefield EffectZoneChoice; got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// 5. CASTER-CHOOSER control (the BLOCKING-2 negative). +// --------------------------------------------------------------------------- + +/// A caster-subject "you control" exile keeps the CASTER as chooser — the +/// scope-set and scoped_player stamp key on a single Player TARGET plus a +/// ScopedPlayer filter, neither of which a "you control" imperative produces. +#[test] +fn caster_subject_exile_keeps_caster_as_chooser() { + const CASTER_EXILE: &str = "Exile a creature you control and their graveyard."; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // Two caster creatures so the caster's pick is a real prompt. + let own_a = scenario.add_creature(P0, "Own A", 2, 2).id(); + let own_b = scenario.add_creature(P0, "Own B", 2, 2).id(); + // An opponent creature which must NEVER be offered to the caster. + let opp = scenario.add_creature(P1, "Opp Creature", 5, 5).id(); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Betrayal Control", false, CASTER_EXILE) + .id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(spell).resolve(); + match outcome.final_waiting_for() { + WaitingFor::EffectZoneChoice { player, cards, .. } => { + assert_eq!(*player, P0, "the CASTER chooses their own creature"); + assert!( + cards.contains(&own_a) && cards.contains(&own_b), + "the caster's creatures are offered; got {cards:?}" + ); + assert!( + !cards.contains(&opp), + "the opponent's creature must not be offered to a 'you control' exile; got {cards:?}" + ); + } + other => panic!("expected the caster's own EffectZoneChoice; got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// 6. NEGATIVE — Ward is NOT over-suppressed. +// --------------------------------------------------------------------------- + +/// A plain "Exile target creature." still targets the warded creature, so Ward +/// STILL fires (the timing fix is scoped to the resolution-picked SB shape, not +/// to all exiles). Reach-guard: declining the Ward payment counters the spell, +/// so the creature is NOT exiled. +#[test] +fn plain_targeted_exile_still_triggers_ward() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let warded = ward2_creature(&mut scenario, P1, 3, 3, "Warded Target"); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Murder-Exile", true, "Exile target creature.") + .id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(spell).target_object(warded).resolve(); + assert!( + matches!( + outcome.final_waiting_for(), + WaitingFor::UnlessPayment { .. } + ), + "Ward must fire on a genuinely targeted exile; got {:?}", + outcome.final_waiting_for() + ); + + // Reach-guard: decline the Ward cost — the exile spell is countered and the + // creature stays on the battlefield. + runner + .act(GameAction::PayUnlessCost { pay: false }) + .expect("declining Ward payment is legal"); + runner.advance_until_stack_empty(); + assert_eq!( + runner.state().objects[&warded].zone, + Zone::Battlefield, + "unpaid Ward counters the exile; the creature survives" + ); +} + +// --------------------------------------------------------------------------- +// 7. SIBLING regression — Diabolic Edict (sacrifice) is unchanged. +// --------------------------------------------------------------------------- + +/// "Target player sacrifices a creature." routes through the sacrifice path +/// (untouched by this fix). The target player is still the chooser and no Ward +/// fires (sacrifice is not targeting the creature). +#[test] +fn target_player_sacrifice_still_binds_chooser_no_ward() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_creature(P1, "Sac A", 2, 2); + let warded = ward2_creature(&mut scenario, P1, 3, 3, "Warded Sac Target"); + + let spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Diabolic Edict", + true, + "Target player sacrifices a creature.", + ) + .id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(spell).target_player(P1).resolve(); + match outcome.final_waiting_for() { + WaitingFor::EffectZoneChoice { player, .. } => { + assert_eq!(*player, P1, "the target player chooses the sacrifice"); + } + WaitingFor::UnlessPayment { .. } + | WaitingFor::WardSacrificeChoice { .. } + | WaitingFor::WardDiscardChoice { .. } => panic!( + "sacrifice must not target the warded creature; got {:?}", + outcome.final_waiting_for() + ), + other => panic!("expected the target player's sacrifice choice; got {other:?}"), + } + // Reach-guard: the warded creature is still eligible to be sacrificed (the + // sacrifice path is not target-gated by Ward). + let WaitingFor::EffectZoneChoice { cards, .. } = outcome.final_waiting_for().clone() else { + unreachable!("asserted above"); + }; + assert!( + cards.contains(&warded), + "the warded creature is a legal sacrifice; got {cards:?}" + ); +} + +// --------------------------------------------------------------------------- +// 8. PARSER SHAPE (labeled SHAPE, semantic accessors). +// --------------------------------------------------------------------------- + +/// The full parsed shape: creature leg is a battlefield-origin, ScopedPlayer- +/// scoped, Resolution-timed ChangeZone; graveyard leg is a ChangeZoneAll whose +/// filter carries `Owned{ScopedPlayer}`; zero Unimplemented anywhere. +#[test] +fn shape_creature_scopedplayer_resolution_graveyard_owned_scopedplayer() { + let sb = parse_sb(); + assert!( + !chain_has_unimplemented(&sb), + "zero Unimplemented; got {sb:#?}" + ); + + // Root is the player TargetOnly wrapper. + assert!( + matches!(&*sb.effect, Effect::TargetOnly { target } if target_is_opponent_like(target)), + "root effect targets ONLY the opponent; got {:?}", + sb.effect + ); + + let creature = find_change_zone(&sb).expect("creature ChangeZone leg"); + // Battlefield origin (Part A): origin inference must NOT leak Graveyard. + if let Effect::ChangeZone { + origin, + target, + destination, + .. + } = &*creature.effect + { + assert!( + origin.is_none() || *origin == Some(Zone::Battlefield), + "creature leg origin is the battlefield (not Graveyard); got {origin:?}" + ); + assert_eq!(*destination, Zone::Exile, "creature leg exiles"); + assert!( + filter_controller_is_scoped(target), + "creature leg controller is ScopedPlayer; got {target:?}" + ); + } else { + panic!( + "expected ChangeZone creature leg; got {:?}", + creature.effect + ); + } + assert_eq!( + creature.target_choice_timing, + TargetChoiceTiming::Resolution, + "creature leg is resolution-chosen" + ); + + let graveyard = find_change_zone_all(&sb).expect("graveyard ChangeZoneAll leg"); + if let Effect::ChangeZoneAll { + target, + destination, + .. + } = &*graveyard.effect + { + assert_eq!(*destination, Zone::Exile, "graveyard leg exiles"); + assert!( + filter_owned_scoped(target), + "graveyard leg carries Owned{{ScopedPlayer}}; got {target:?}" + ); + } else { + panic!( + "expected ChangeZoneAll graveyard leg; got {:?}", + graveyard.effect + ); + } +} + +/// Hostile fixture: an explicit "target" on the creature keeps it a stack +/// target (no scope-set, no Resolution stamp) — and still parses cleanly. +#[test] +fn shape_explicit_target_creature_stays_stack_target() { + const EXPLICIT: &str = "Exile target creature and their graveyard."; + let parsed = parse_oracle_text(EXPLICIT, "Explicit Betrayal", &[], &[], &[]); + assert_eq!(parsed.abilities.len(), 1); + let root = &parsed.abilities[0]; + assert!( + !chain_has_unimplemented(root), + "explicit-target compound exile parses cleanly; got {root:#?}" + ); + // REQUIRED (not `if let`): the creature leg MUST exist, or the assertion + // below would pass vacuously when no ChangeZone is found. + let creature = find_change_zone(root) + .expect("explicit-target compound exile has a creature ChangeZone leg"); + assert_eq!( + creature.target_choice_timing, + TargetChoiceTiming::Stack, + "an explicit 'target creature' stays a Stack target" + ); +} + +/// Hostile fixture for Part A site 3 (mod.rs:15143): "Exile all creatures they +/// control and their graveyard." The mass-creature + whole-graveyard COMPOUND is +/// not modelled by the parser (a pre-existing gap unaffected by this fix — the +/// bare imperative never enters `lower_subject_predicate_ast`, and the site-3 +/// origin slice only changes the inferred zone, never whether the arm parses). +/// Coverage honesty (per the plan's fail-closed rule): the WHOLE clause must +/// stay an explicit strict failure carrying the full text — NOT a silently +/// narrowed partial that dropped the "and their graveyard" leg (a swallow bug). +#[test] +fn shape_exile_all_creatures_and_graveyard_stays_fail_closed() { + const MASS: &str = "Exile all creatures they control and their graveyard."; + let parsed = parse_oracle_text(MASS, "Mass Betrayal", &[], &[], &[]); + assert_eq!(parsed.abilities.len(), 1, "got {:#?}", parsed.abilities); + let root = &parsed.abilities[0]; + // The unmodelled compound stays fail-closed: an Unimplemented carrying the + // full sentence, not a partial that silently kept only one leg. + assert!( + matches!(&*root.effect, Effect::Unimplemented { description, .. } + if description.as_deref().is_some_and(|d| d.contains("their graveyard"))), + "the unmodelled mass-creature + graveyard compound must stay a whole-clause \ + strict failure (fail-closed, no silent orphan); got {root:#?}" + ); +} + +// --------------------------------------------------------------------------- +// 11. SIBLING (review follow-up) — the narrowed rewrite generalizes to a bare +// "target player exiles a creature they control" (no graveyard leg). +// --------------------------------------------------------------------------- + +/// Doomfall / Debt to the Kami class: a bare "Target opponent exiles a creature +/// they control." (the SB creature leg WITHOUT the graveyard conjunct). The +/// narrowed post-lowering `You → ScopedPlayer` rewrite must still fire, so the +/// target OPPONENT (not the caster) chooses from THEIR OWN creatures at +/// resolution, no `BecomesTarget` fires, and Ward stays silent. Reverting the +/// narrowing (dropping the ChangeZone-leg rewrite) flips the chooser/eligible set +/// back to the caster. +#[test] +fn sibling_bare_target_player_exile_they_control_binds_chooser_no_ward() { + const SIBLING: &str = "Target opponent exiles a creature they control."; + + // SHAPE: single ChangeZone leg is ScopedPlayer-scoped + Resolution-timed. + let parsed = parse_oracle_text(SIBLING, "Doomfall Mode", &[], &[], &[]); + assert_eq!(parsed.abilities.len(), 1); + let root = &parsed.abilities[0]; + assert!( + !chain_has_unimplemented(root), + "the bare sibling parses cleanly; got {root:#?}" + ); + let creature = find_change_zone(root).expect("sibling has a creature ChangeZone leg"); + assert!( + filter_controller_is_scoped(match &*creature.effect { + Effect::ChangeZone { target, .. } => target, + other => panic!("expected ChangeZone; got {other:?}"), + }), + "the sibling creature leg is ScopedPlayer-scoped; got {:?}", + creature.effect + ); + assert_eq!( + creature.target_choice_timing, + TargetChoiceTiming::Resolution, + "the sibling creature leg is resolution-chosen" + ); + + // Runtime: opponent controls a warded + a plain creature; the caster also + // controls a creature that must never be offered. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let plain = scenario.add_creature(P1, "Opp Plain", 2, 2).id(); + let warded = ward2_creature(&mut scenario, P1, 3, 3, "Opp Warded"); + let caster_own = scenario.add_creature(P0, "Caster Own", 4, 4).id(); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Doomfall Mode", false, SIBLING) + .id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(spell).target_player(P1).resolve(); + match outcome.final_waiting_for() { + WaitingFor::EffectZoneChoice { player, cards, .. } => { + assert_eq!(*player, P1, "the target OPPONENT chooses, not the caster"); + assert!( + cards.contains(&plain) && cards.contains(&warded), + "both of the opponent's creatures are offered; got {cards:?}" + ); + assert!( + !cards.contains(&caster_own), + "the caster's own creature must never be offered; got {cards:?}" + ); + } + WaitingFor::UnlessPayment { .. } + | WaitingFor::WardSacrificeChoice { .. } + | WaitingFor::WardDiscardChoice { .. } => panic!( + "Ward must NOT fire — the creature is chosen at resolution, not targeted; got {:?}", + outcome.final_waiting_for() + ), + other => panic!("expected the opponent's EffectZoneChoice; got {other:?}"), + } + + // Reach-guard: the opponent exiles a chosen creature. + runner + .act(GameAction::SelectCards { + cards: vec![warded], + }) + .expect("opponent selects a creature to exile"); + assert_eq!( + runner.state().objects[&warded].zone, + Zone::Exile, + "the opponent's chosen (warded) creature is exiled without paying Ward" + ); +} + +// --------------------------------------------------------------------------- +// 12. LEADERSHIP VACUUM (review follow-up) — the mass ChangeZoneAll sibling still +// binds ScopedPlayer under the narrowed rewrite (no whole-clause pin). +// --------------------------------------------------------------------------- + +/// "Target player returns each commander they control from the battlefield to the +/// command zone." The mass `ChangeZoneAll` moved-object leg must carry a +/// `ScopedPlayer` controller (bound to the target player at resolution), proving +/// the narrowed rewrite covers the mass leg exactly as the whole-clause pin did. +#[test] +fn leadership_vacuum_mass_leg_is_scopedplayer() { + const LV: &str = + "Target player returns each commander they control from the battlefield to the command zone."; + let parsed = parse_oracle_text(LV, "Leadership Vacuum", &[], &[], &[]); + assert_eq!(parsed.abilities.len(), 1); + let root = &parsed.abilities[0]; + assert!( + !chain_has_unimplemented(root), + "Leadership Vacuum's mass return parses cleanly; got {root:#?}" + ); + // Root wraps the lone player target; the mass leg is the sub-ability. + assert!( + matches!(&*root.effect, Effect::TargetOnly { target } if target_is_opponent_like(target) + || matches!(target, TargetFilter::Player)), + "root targets only the player; got {:?}", + root.effect + ); + let mass = find_change_zone_all(root).expect("Leadership Vacuum has a mass ChangeZoneAll leg"); + let Effect::ChangeZoneAll { target, .. } = &*mass.effect else { + unreachable!("find_change_zone_all guarantees the variant"); + }; + assert!( + filter_controller_is_scoped(target), + "the mass 'each commander they control' leg is ScopedPlayer-scoped; got {target:?}" + ); + assert_eq!( + mass.target_choice_timing, + TargetChoiceTiming::Resolution, + "the mass leg is resolution-bound (scoped_player stamp lands there)" + ); +} + +// --------------------------------------------------------------------------- +// 13. ORIGIN-LEAK CLASS (review follow-up) — a compound "exile X and " whose primary leg is a BATTLEFIELD/self object (Silent +// Gravestone) must not inherit the trailing conjunct's Graveyard origin. +// --------------------------------------------------------------------------- + +/// Silent Gravestone's activated body: "Exile this artifact and all cards from all +/// graveyards." The primary self/battlefield leg must NOT leak `Zone::Graveyard` +/// from the trailing "and all cards from all graveyards" conjunct — its origin +/// stays battlefield/default — while the trailing ChangeZoneAll leg keeps its own +/// `Zone::Graveyard` origin. Reverting `compound_exile_origin_scan` leaks Graveyard +/// onto the primary leg (the Part A defect), a different affected card than SB's +/// "and their graveyard". +#[test] +fn origin_leak_compound_exile_primary_leg_stays_battlefield() { + const CLAUSE: &str = "Exile this artifact and all cards from all graveyards."; + let parsed = parse_oracle_text( + CLAUSE, + "Silent Gravestone", + &["Artifact".to_string()], + &[], + &[], + ); + assert_eq!(parsed.abilities.len(), 1); + let root = &parsed.abilities[0]; + assert!( + !chain_has_unimplemented(root), + "the compound self-exile parses cleanly; got {root:#?}" + ); + + let primary = find_change_zone(root).expect("primary self/battlefield exile leg"); + let Effect::ChangeZone { origin, .. } = &*primary.effect else { + unreachable!("find_change_zone guarantees the variant"); + }; + assert!( + origin.is_none() || *origin == Some(Zone::Battlefield), + "the primary leg's origin must NOT leak Graveyard from the trailing conjunct; got {origin:?}" + ); + + let graveyard = + find_change_zone_all(root).expect("trailing 'all cards from all graveyards' leg"); + let Effect::ChangeZoneAll { origin, .. } = &*graveyard.effect else { + unreachable!("find_change_zone_all guarantees the variant"); + }; + assert_eq!( + *origin, + Some(Zone::Graveyard), + "the trailing conjunct keeps its own Graveyard origin; got {origin:?}" + ); +} + +// --------------------------------------------------------------------------- +// SHAPE predicates +// --------------------------------------------------------------------------- + +fn target_is_opponent_like(target: &TargetFilter) -> bool { + matches!(target, TargetFilter::Opponent | TargetFilter::Player) + || matches!(target, TargetFilter::Typed(tf) if tf.controller == Some(ControllerRef::Opponent)) +} + +fn filter_controller_is_scoped(target: &TargetFilter) -> bool { + match target { + TargetFilter::Typed(tf) => tf.controller == Some(ControllerRef::ScopedPlayer), + TargetFilter::And { filters } | TargetFilter::Or { filters } => { + filters.iter().any(filter_controller_is_scoped) + } + TargetFilter::Not { filter } => filter_controller_is_scoped(filter), + _ => false, + } +} + +fn filter_owned_scoped(target: &TargetFilter) -> bool { + use engine::types::ability::FilterProp; + match target { + TargetFilter::Typed(tf) => tf.properties.iter().any(|p| { + matches!( + p, + FilterProp::Owned { + controller: ControllerRef::ScopedPlayer + } + ) + }), + TargetFilter::And { filters } | TargetFilter::Or { filters } => { + filters.iter().any(filter_owned_scoped) + } + TargetFilter::Not { filter } => filter_owned_scoped(filter), + _ => false, + } +} diff --git a/crates/engine/tests/integration/temporary_cant_be_blocked_view.rs b/crates/engine/tests/integration/temporary_cant_be_blocked_view.rs new file mode 100644 index 0000000000..301925d3fe --- /dev/null +++ b/crates/engine/tests/integration/temporary_cant_be_blocked_view.rs @@ -0,0 +1,344 @@ +//! CR 509.1b + CR 611.2c + CR 613.1f: client projection for temporary +//! `CantBeBlocked` grants, sourced only from current Layer 6 attribution. + +use engine::game::derived_views::{ + derive_filtered_views, derive_views, ClientGameState, ClientGameStateRef, +}; +use engine::game::filter_state_for_viewer; +use engine::game::functioning_abilities::active_static_definitions; +use engine::game::game_object::PhaseOutCause; +use engine::game::layers::evaluate_layers; +use engine::game::phasing::phase_out_object; +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::game::zones::move_to_zone; +use engine::types::ability::{ContinuousModification, Duration, TargetFilter}; +use engine::types::actions::GameAction; +use engine::types::attribution::EffectRef; +use engine::types::game_state::GameState; +use engine::types::identifiers::ObjectId; +use engine::types::layers::Layer; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::statics::StaticMode; +use engine::types::zones::Zone; + +fn grant_cant_be_blocked_until_end_of_turn( + state: &mut GameState, + source: engine::types::identifiers::ObjectId, + recipient: engine::types::identifiers::ObjectId, +) { + state.add_transient_continuous_effect( + source, + P0, + Duration::UntilEndOfTurn, + TargetFilter::SpecificObject { id: recipient }, + vec![ + ContinuousModification::AddPower { value: 1 }, + ContinuousModification::AddStaticMode { + mode: StaticMode::CantBeBlocked, + }, + ], + None, + ); +} + +#[test] +fn derives_only_the_live_until_end_of_turn_cant_be_blocked_attribution() { + let mut scenario = GameScenario::new(); + let source = scenario.add_creature(P0, "Grant Source", 0, 1).id(); + let recipient = scenario.add_creature(P0, "Grant Recipient", 2, 2).id(); + let permanent_source = scenario.add_creature(P0, "Permanent Grant", 0, 1).id(); + let permanent_recipient = scenario.add_creature(P0, "Permanent Recipient", 2, 2).id(); + let mut runner = scenario.build(); + + // The leading P/T modification proves the projection follows the exact + // recorded `mod_index`, not merely the owning transient effect. + grant_cant_be_blocked_until_end_of_turn(runner.state_mut(), source, recipient); + runner.state_mut().add_transient_continuous_effect( + permanent_source, + P0, + Duration::Permanent, + TargetFilter::SpecificObject { + id: permanent_recipient, + }, + vec![ContinuousModification::AddStaticMode { + mode: StaticMode::CantBeBlocked, + }], + None, + ); + evaluate_layers(runner.state_mut()); + + let state = runner.state(); + assert!( + active_static_definitions(state, &state.objects[&recipient]) + .any(|definition| definition.mode == StaticMode::CantBeBlocked), + "reach guard: Layer 6 applied the temporary CantBeBlocked modification" + ); + assert!( + active_static_definitions(state, &state.objects[&permanent_recipient]) + .any(|definition| definition.mode == StaticMode::CantBeBlocked), + "reach guard: Layer 6 applied the permanent CantBeBlocked modification" + ); + + let views = derive_views(state, None); + assert_eq!( + views.temporary_cant_be_blocked.get(&recipient), + Some(&Some(source)), + "the first matching live UET AddStaticMode attribution carries its battlefield source" + ); + assert!( + !views + .temporary_cant_be_blocked + .contains_key(&permanent_recipient), + "a permanent CantBeBlocked grant must not be presented as temporary" + ); +} + +#[test] +fn wrapper_round_trip_keeps_the_badge_but_hides_a_departed_source() { + let mut scenario = GameScenario::new(); + let source = scenario.add_creature(P0, "Grant Source", 0, 1).id(); + let recipient = scenario.add_creature(P0, "Grant Recipient", 2, 2).id(); + let mut runner = scenario.build(); + grant_cant_be_blocked_until_end_of_turn(runner.state_mut(), source, recipient); + evaluate_layers(runner.state_mut()); + + let source_owner = runner.state().objects[&source].owner; + let mut events = Vec::new(); + move_to_zone(runner.state_mut(), source, Zone::Graveyard, &mut events); + assert_eq!( + runner.state().objects[&source].owner, + source_owner, + "reach guard: moving the source preserves the tracked source object" + ); + evaluate_layers(runner.state_mut()); + + let direct = derive_views(runner.state(), Some(P1)); + assert_eq!( + direct.temporary_cant_be_blocked.get(&recipient), + Some(&None), + "the live grant keeps its recipient badge but withholds a non-battlefield source" + ); + + let filtered = filter_state_for_viewer(runner.state(), P1); + let filtered_views = derive_filtered_views(runner.state(), &filtered, Some(P1)); + assert_eq!( + filtered_views.temporary_cant_be_blocked.get(&recipient), + Some(&None), + "filtered derivation uses the public filtered state and preserves the source-less badge" + ); + + let json = serde_json::to_string(&ClientGameStateRef::wrap_filtered( + runner.state(), + &filtered, + Some(P1), + )) + .expect("serialize filtered client state"); + let wire: serde_json::Value = serde_json::from_str(&json).expect("inspect client wire shape"); + assert!( + wire["derived"]["temporary_cant_be_blocked"] + .get(recipient.0.to_string()) + .is_some_and(serde_json::Value::is_null), + "the wire map represents a departed source as null rather than leaking its id" + ); + let round: ClientGameState = serde_json::from_str(&json).expect("deserialize client state"); + assert_eq!( + round.derived.temporary_cant_be_blocked.get(&recipient), + Some(&None), + "the owned client wrapper round-trips the source-less badge" + ); + + let empty_json = serde_json::to_string(&ClientGameStateRef::wrap( + &GameState::new_two_player(42), + None, + )) + .expect("serialize empty client state"); + assert!( + !empty_json.contains("temporary_cant_be_blocked"), + "the empty temporary map is omitted from the wire payload" + ); +} + +#[test] +fn phased_out_source_keeps_the_badge_but_hides_its_attribution() { + let mut scenario = GameScenario::new(); + let source = scenario.add_creature(P0, "Grant Source", 0, 1).id(); + let recipient = scenario.add_creature(P0, "Grant Recipient", 2, 2).id(); + let mut runner = scenario.build(); + grant_cant_be_blocked_until_end_of_turn(runner.state_mut(), source, recipient); + evaluate_layers(runner.state_mut()); + + let mut events = Vec::new(); + phase_out_object( + runner.state_mut(), + source, + PhaseOutCause::Directly, + &mut events, + ); + evaluate_layers(runner.state_mut()); + + assert!( + runner.state().objects[&source].is_phased_out(), + "reach guard: the source remains in the battlefield zone while phased out" + ); + assert_eq!( + derive_views(runner.state(), None) + .temporary_cant_be_blocked + .get(&recipient), + Some(&None), + "a phased-out source is not public attribution for a still-live temporary grant" + ); +} + +#[test] +fn first_departed_grant_withholds_later_public_source_attribution() { + let mut scenario = GameScenario::new(); + let first_source = scenario.add_creature(P0, "First Grant Source", 0, 1).id(); + let later_source = scenario.add_creature(P0, "Later Grant Source", 0, 1).id(); + let recipient = scenario.add_creature(P0, "Grant Recipient", 2, 2).id(); + let mut runner = scenario.build(); + grant_cant_be_blocked_until_end_of_turn(runner.state_mut(), first_source, recipient); + grant_cant_be_blocked_until_end_of_turn(runner.state_mut(), later_source, recipient); + evaluate_layers(runner.state_mut()); + + let mut events = Vec::new(); + move_to_zone( + runner.state_mut(), + first_source, + Zone::Graveyard, + &mut events, + ); + evaluate_layers(runner.state_mut()); + + assert!( + runner.state().objects[&later_source].is_phased_in(), + "reach guard: the later matching source remains public" + ); + assert_eq!( + derive_views(runner.state(), None) + .temporary_cant_be_blocked + .get(&recipient), + Some(&None), + "the first matching grant remains authoritative, so its departed source withholds attribution instead of falling through to a later source" + ); +} + +const ROGUES_PASSAGE_ORACLE: &str = + "{T}: Add {C}.\n{4}, {T}: Target creature can't be blocked this turn."; + +/// CR 509.1b + CR 611.2c + CR 613.1f + CR 514.2: a resolved Rogues Passage +/// activation registers a target-bound, Layer 6 `CantBeBlocked` grant, which +/// remains client-visible only through this turn's cleanup. +#[test] +fn rogues_passage_activation_projects_only_its_target_until_cleanup() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let passage = scenario + .add_land_from_oracle(P0, "Rogues Passage", ROGUES_PASSAGE_ORACLE) + .id(); + let target = scenario.add_creature(P0, "Target Creature", 2, 2).id(); + let non_target = scenario.add_creature(P0, "Non-target Creature", 2, 2).id(); + scenario.with_mana_pool( + P0, + (0..4) + .map(|_| ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![])) + .collect(), + ); + let mut runner = scenario.build(); + + // The verbatim Oracle text supplies the mana ability first and the target + // grant second; activate the latter through the production target-selection + // and resolution pipeline rather than installing a synthetic effect. + runner.activate(passage, 1).target_object(target).resolve(); + evaluate_layers(runner.state_mut()); + + let ability_attribution = runner.state().attribution[&target] + .by_layer + .get(&Layer::Ability) + .expect("the target must have a live Layer 6 attribution"); + assert!( + ability_attribution.iter().any(|effect_ref| { + let EffectRef::Transient { id, mod_index } = effect_ref else { + return false; + }; + runner + .state() + .transient_continuous_effects + .iter() + .find(|effect| effect.id == *id) + .is_some_and(|effect| { + effect.source_id == passage + && effect.duration == Duration::UntilEndOfTurn + && matches!( + effect.modifications.get(*mod_index), + Some(ContinuousModification::AddStaticMode { + mode: StaticMode::CantBeBlocked + }) + ) + }) + }), + "the real Rogues Passage grant must be attributed as its live UET Layer 6 CantBeBlocked modification" + ); + + let views = derive_views(runner.state(), None); + assert_eq!( + views.temporary_cant_be_blocked.get(&target), + Some(&Some(passage)), + "the targeted creature carries a temporary CantBeBlocked badge attributed to Rogues Passage" + ); + assert!( + !views.temporary_cant_be_blocked.contains_key(&non_target), + "a creature not selected during activation must not receive the badge" + ); + + // Cross CR 514.2 through the production action pipeline. The phase helpers + // may stop at an intermediate priority window; legal_actions also advances + // through the empty combat declarations, and the turn counter proves cleanup + // has actually completed. + let start_turn = runner.state().turn_number; + let mut crossed_turn = false; + for _ in 0..400 { + if runner.state().turn_number > start_turn { + crossed_turn = true; + break; + } + let actions = engine::ai_support::legal_actions(runner.state()); + let progress = actions + .iter() + .find(|action| matches!(action, GameAction::PassPriority)) + .or_else(|| { + actions.iter().find(|action| { + matches!( + action, + GameAction::DeclareAttackers { .. } + | GameAction::DeclareBlockers { .. } + | GameAction::SelectCards { .. } + | GameAction::ChooseTarget { .. } + ) + }) + }) + .cloned(); + match progress { + Some(action) => { + if runner.act(action).is_err() { + break; + } + } + None => break, + } + } + assert!( + crossed_turn, + "the game must advance past the turn containing the Rogues Passage activation; parked at turn {} phase {:?} waiting {:?}", + runner.state().turn_number, + runner.state().phase, + runner.state().waiting_for, + ); + evaluate_layers(runner.state_mut()); + assert!( + !derive_views(runner.state(), None) + .temporary_cant_be_blocked + .contains_key(&target), + "CR 514.2: the this-turn CantBeBlocked grant must disappear after cleanup" + ); +} diff --git a/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs b/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs index 4eb75157be..c3cb0345ab 100644 --- a/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs +++ b/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs @@ -27,8 +27,8 @@ use engine::game::planeswalker; use engine::game::zones::create_object; use engine::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, CopyCountStatus, Effect, QuantityExpr, - QuantityModification, ReplacementDefinition, ResolvedAbility, SubAbilityLink, TargetFilter, - TargetRef, TargetSelectionMode, + QuantityModification, ReplacementDefinition, ResolvedAbility, SiblingCondition, SubAbilityLink, + TargetFilter, TargetRef, TargetSelectionMode, }; use engine::types::actions::GameAction; use engine::types::card_type::CoreType; @@ -155,6 +155,7 @@ fn make_grant_ability(controller: PlayerId, source: ObjectId) -> ResolvedAbility source_incarnation: None, trigger_source: None, trigger_definition_ref: None, + force_block_attacker: None, targets: vec![], kind: AbilityKind::Activated, sub_ability: None, @@ -192,6 +193,7 @@ fn make_grant_ability(controller: PlayerId, source: ObjectId) -> ResolvedAbility chosen_players: Vec::new(), repeat_until: None, sub_link: SubAbilityLink::ContinuationStep, + sibling_condition: SiblingCondition::Dependent, modal: None, mode_abilities: vec![], parent_target_missing_reason: None, diff --git a/crates/engine/tests/integration/wheel_and_deal.rs b/crates/engine/tests/integration/wheel_and_deal.rs new file mode 100644 index 0000000000..d32596dddd --- /dev/null +++ b/crates/engine/tests/integration/wheel_and_deal.rs @@ -0,0 +1,130 @@ +//! Wheel and Deal's targeted-opponent instruction is distinct from its final +//! controller-only draw. The multi-target fan-out must repeat only the former. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::phase::Phase; +use engine::types::PlayerId; + +// Verified against Scryfall's Oracle text. +const WHEEL_AND_DEAL: &str = + "Any number of target opponents each discard their hands, then draw seven cards.\nDraw a card."; + +/// CR 601.2c + CR 608.2c: each selected opponent performs the first +/// instruction, then the spell's controller performs its final draw exactly +/// once after all selected opponents are finished. +#[test] +fn wheel_and_deal_fans_out_opponents_without_repeating_controller_draw() { + let p2 = PlayerId(2); + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + + let wheel_and_deal = scenario + .add_spell_to_hand_from_oracle(P0, "Wheel and Deal", true, WHEEL_AND_DEAL) + .id(); + scenario.with_cards_in_hand(P1, &["P1 discard A", "P1 discard B"]); + scenario.with_cards_in_hand(p2, &["P2 discard A", "P2 discard B"]); + scenario.with_library_top( + P0, + &[ + "Controller draw 1", + "Controller draw 2", + "Controller draw 3", + "Controller draw 4", + "Controller draw 5", + "Controller draw 6", + "Controller draw 7", + "Controller draw 8", + ], + ); + scenario.with_library_top( + P1, + &[ + "P1 draw 1", + "P1 draw 2", + "P1 draw 3", + "P1 draw 4", + "P1 draw 5", + "P1 draw 6", + "P1 draw 7", + ], + ); + scenario.with_library_top( + p2, + &[ + "P2 draw 1", + "P2 draw 2", + "P2 draw 3", + "P2 draw 4", + "P2 draw 5", + "P2 draw 6", + "P2 draw 7", + ], + ); + + let mut runner = scenario.build(); + let commit = runner + .cast(wheel_and_deal) + .target_players(&[P1, p2]) + .commit(); + let ability = commit + .state() + .stack + .last() + .and_then(|item| item.ability()) + .expect("Wheel and Deal must be on the stack"); + assert_eq!( + ability.targets, + vec![ + engine::types::ability::TargetRef::Player(P1), + engine::types::ability::TargetRef::Player(p2), + ], + "the cast pipeline must preserve both chosen opponents" + ); + let outcome = commit.resolve(); + + outcome.assert_hand_drawn(P0, 1); + outcome.assert_hand_drawn(P1, 5); + outcome.assert_hand_drawn(p2, 5); + assert_eq!( + outcome + .state() + .players + .iter() + .find(|player| player.id == P1) + .expect("P1 exists") + .graveyard + .len(), + 2, + "P1's whole hand must be discarded before drawing seven" + ); + assert_eq!( + outcome + .state() + .players + .iter() + .find(|player| player.id == p2) + .expect("P2 exists") + .graveyard + .len(), + 2, + "P2's whole hand must be discarded before drawing seven" + ); +} + +/// CR 601.2c + CR 608.2c: "Any number" permits no targets. That skips the +/// opponent-qualified instruction while preserving the independent final draw. +#[test] +fn wheel_and_deal_with_no_targets_draws_only_for_its_controller() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let wheel_and_deal = scenario + .add_spell_to_hand_from_oracle(P0, "Wheel and Deal", true, WHEEL_AND_DEAL) + .id(); + scenario.with_library_top(P0, &["Controller draw"]); + + let mut runner = scenario.build(); + let outcome = runner.cast(wheel_and_deal).target_players(&[]).resolve(); + + outcome.assert_hand_drawn(P0, 1); + outcome.assert_hand_drawn(P1, 0); +} diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index efa4b03f7d..e2001ecf23 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -1723,6 +1723,9 @@ pub fn convert_available_action(action: &GameAction, id: String) -> AvailableAct GameAction::ChooseAnnouncingOpponent { .. } => { AvailableActionConversion::Unsupported("local.announcing-opponent-unsupported") } + GameAction::ChooseGiftRecipient { .. } => { + AvailableActionConversion::Unsupported("local.gift-recipient-unsupported") + } GameAction::ChoosePileOpponent { .. } => { AvailableActionConversion::Unsupported("local.pile-opponent-unsupported") } @@ -2156,7 +2159,11 @@ fn build_card_dto( }, summoning_sick: !redacted && object.has_summoning_sickness, is_copy: false, - is_double_faced: !redacted && object.back_face.is_some(), + // CR 712.16 + CR 710.1b: the engine owns "is this permanent + // double-faced?". `back_face.is_some()` is not that predicate — a CR 710 + // flip card parks its alternative half in the same slot (as do Adventure + // and Omen cards), so the raw check reports every flip card as a DFC. + is_double_faced: !redacted && engine::game::transform::is_double_faced_permanent(object), is_transformed: !redacted && object.transformed, is_face_down: object.face_down, is_bestowed: !redacted && object.bestow_form.is_some(), diff --git a/crates/mtgish-import/src/convert/condition.rs b/crates/mtgish-import/src/convert/condition.rs index bc4df28065..b20bcbdc90 100644 --- a/crates/mtgish-import/src/convert/condition.rs +++ b/crates/mtgish-import/src/convert/condition.rs @@ -1081,6 +1081,7 @@ fn target_filter_variant_name(f: &TargetFilter) -> &'static str { TargetFilter::ExiledCardByIndex { .. } => "ExiledCardByIndex", TargetFilter::LastCreated => "LastCreated", TargetFilter::LastRevealed => "LastRevealed", + TargetFilter::LastZoneChanged => "LastZoneChanged", TargetFilter::CostPaidObject => "CostPaidObject", TargetFilter::ChosenCard => "ChosenCard", TargetFilter::TrackedSet { .. } => "TrackedSet", diff --git a/crates/mtgish-import/src/convert/keyword.rs b/crates/mtgish-import/src/convert/keyword.rs index 08fa11c2ca..64028bea65 100644 --- a/crates/mtgish-import/src/convert/keyword.rs +++ b/crates/mtgish-import/src/convert/keyword.rs @@ -10,7 +10,9 @@ //! type-system lookup; there is no semantic translation. Cost/filter //! keywords need real conversion logic, so they land with their phase. -use engine::types::ability::{AbilityCost, CostObjectCount, QuantityExpr}; +use engine::types::ability::{ + AbilityCost, CostObjectCount, QuantityExpr, TargetFilter, TypeFilter, +}; use engine::types::keywords::{ BestowCost, BloodthirstValue, BuybackCost, CyclingCost, EscapeCost, FlashbackCost, HexproofFilter, ProtectionTarget, WardCost, @@ -19,9 +21,10 @@ use engine::types::mana::{ManaColor, ManaCost}; use engine::types::Keyword; use crate::convert::result::{ConvResult, ConversionGap}; -use crate::convert::{cost as cost_conv, quantity}; +use crate::convert::{cost as cost_conv, filter, quantity}; use crate::schema::types::{ - CardType, Cards, Color, Cost, CreatureType, GameNumber, Protectable, ProtectableColor, Rule, + CardType, Cards, Color, Cost, CreatureType, GameNumber, Permanents, Protectable, + ProtectableColor, Rule, }; /// If `rule` is a keyword Rule we can translate today, return the engine @@ -327,9 +330,17 @@ pub fn try_convert(rule: &Rule, path: &str) -> ConvResult> { Rule::Firebending(g) => Keyword::Firebending(QuantityExpr::Fixed { value: int_or_gap(g, "Rule::Firebending", path)? as i32, }), - // CR 702.81: Devour N — engine encodes only N (the "creatures you - // sacrifice" filter is implicit). - Rule::Devour(_perm, g) => Keyword::Devour(int_or_gap(g, "Rule::Devour", path)?), + // CR 702.82a / CR 702.82c: Devour [quality] N — the engine now carries the + // sacrifice-pool quality (plain devour = creatures, CR 702.82a). The mtgish + // AST holds the "[quality] permanents" qualifier as `Permanents`; map it to + // the engine `TypeFilter` via the shared filter converter. Strict-fails + // rather than silently defaulting to Creature (a forbidden semantic drop — + // mtgish-import CLAUDE.md §1/§3) when the qualifier is not a single-type + // quality. + Rule::Devour(perm, g) => Keyword::Devour { + n: int_or_gap(g, "Rule::Devour", path)?, + quality: devour_quality(perm, path)?, + }, // CR 702.160a: Prototype — alt-cost cast that uses the secondary // power/toughness and mana cost characteristics. // CR 702.176a: Impending N—{cost} — alternative cost. "You may @@ -891,6 +902,46 @@ fn int_or_gap(g: &GameNumber, idiom: &'static str, path: &str) -> ConvResult ConvResult { + let TargetFilter::Typed(tf) = filter::convert(perm)? else { + return Err(ConversionGap::MalformedIdiom { + idiom: "Rule::Devour", + path: path.to_string(), + detail: format!("quality qualifier is not a typed filter: {perm:?}"), + }); + }; + if let Some(subtype) = tf.get_subtype() { + return Ok(TypeFilter::Subtype(subtype.to_string())); + } + match tf.get_primary_type() { + // A non-specific catch-all (`permanent`/`card`/`any`) or no type at all is + // not a CR 702.82c quality — fail loud rather than widen the sac pool. + Some(TypeFilter::Permanent | TypeFilter::Card | TypeFilter::Any) | None => { + Err(ConversionGap::MalformedIdiom { + idiom: "Rule::Devour", + path: path.to_string(), + detail: format!("quality qualifier has no single-type reduction: {perm:?}"), + }) + } + Some(ty) => Ok(ty.clone()), + } +} + #[cfg(test)] mod tests { use engine::types::ability::{CountScope, QuantityExpr, QuantityRef, TypeFilter, ZoneRef}; @@ -898,6 +949,44 @@ mod tests { use super::*; + /// CR 702.82a / CR 702.82c: `devour_quality` reduces a mtgish `Permanents` + /// qualifier to a single specific engine `TypeFilter`, and STRICT-FAILS (never + /// silently widens) on a non-specific catch-all. Exercised against the real + /// `filter::convert` output, using the exact `Permanents` shapes the Devour + /// rules encode (`IsCardtype(Creature)` for plain devour, `IsArtifactType(Food)` + /// for Feasting Hobbit, `AnyPermanent` for the catch-all defence case). + #[test] + fn devour_quality_reduces_specific_types_and_strict_fails_catch_all() { + use crate::schema::types::ArtifactType; + + // CR 702.82a: plain devour = `IsCardtype(Creature)` → Creature. + // (`ConversionGap` has no `PartialEq`, so unwrap the Ok and compare the + // `TypeFilter` rather than the whole `Result`.) + assert_eq!( + devour_quality(&Permanents::IsCardtype(CardType::Creature), "test").unwrap(), + TypeFilter::Creature + ); + + // CR 702.82c + CR 205.3g: Feasting Hobbit's `IsArtifactType(Food)` reduces + // to the SUBTYPE (Food ⊂ Artifact), matching the native parser. + assert_eq!( + devour_quality(&Permanents::IsArtifactType(ArtifactType::Food), "test").unwrap(), + TypeFilter::Subtype("Food".to_string()) + ); + + // Strict-failure defence: `AnyPermanent` reduces to `TypedFilter:: + // permanent()` (primary type `Permanent`), a non-specific catch-all that is + // NOT a valid CR 702.82c quality. It must fail loud, not silently widen the + // sacrifice pool to every permanent. + assert!(matches!( + devour_quality(&Permanents::AnyPermanent, "test"), + Err(ConversionGap::MalformedIdiom { + idiom: "Rule::Devour", + .. + }) + )); + } + #[test] fn aftermath_lowers_to_keyword() { assert_eq!( diff --git a/crates/phase-ai/src/ability_chain.rs b/crates/phase-ai/src/ability_chain.rs index eaf1e2a77d..2ecbe0e84e 100644 --- a/crates/phase-ai/src/ability_chain.rs +++ b/crates/phase-ai/src/ability_chain.rs @@ -1,4 +1,4 @@ -//! Shared helpers for walking an `AbilityDefinition`'s effect chain. +//! Shared helpers for walking an `AbilityDefinition`'s effect tree. //! //! `AbilityDefinition` composes a primary `effect` with an optional //! `sub_ability` that itself has an `effect` plus another optional @@ -8,19 +8,84 @@ //! land onto the battlefield?"), so they collect the chain into a flat //! slice and iterate with `matches!`. //! +//! Two branches of that definition are *conditional* rather than part of the +//! unconditional chain: `else_ability` (the CR 608.2c "Otherwise, ..." leg) and +//! `mode_abilities` (the CR 700.2 modal alternatives). Whether they belong in +//! the walk depends on the question being asked, which is what [`AbilityScope`] +//! names — see its docs. [`collect_scoped_effects`] is the single authority for +//! both walks; `collect_chain_effects` is the unconditional shorthand. +//! //! Keep this module small — it is a single building block shared across //! `features/*` and `policies/*`. use engine::types::ability::{AbilityDefinition, Effect}; +/// Which part of an ability tree a classification question is asking about. +/// +/// The distinction is a decision-boundary one, not a convenience one: +/// +/// * [`AbilityScope::Unconditional`] answers *"does resolving this ability as +/// already announced produce effect X?"* — the walk a LIVE per-action policy +/// needs. CR 601.2b makes mode selection a distinct step of announcing a +/// spell, so at `CastSpell` time no mode has been chosen yet and a modal +/// branch must not be credited to the cast. +/// * [`AbilityScope::Potential`] answers *"can this card ever produce effect +/// X?"* — the walk DECK-TIME detection needs, where every branch the card +/// could take is in scope. +/// +/// A typed scope rather than a `bool` so each call site states which question +/// it is asking. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AbilityScope { + /// `effect` plus the `sub_ability` chain — everything that happens no + /// matter which branch or mode is taken. + Unconditional, + /// The full tree: `Unconditional` plus the `else_ability` branch (CR + /// 608.2c) and every entry of `mode_abilities` (CR 700.2), recursively. + Potential, +} + +/// Walk an ability tree at `scope`, returning borrowed effect references in +/// traversal order. +/// +/// This is the mode-aware authority both deck-time detection and live policy +/// classification share; they differ only in the `scope` they pass, so the two +/// can never drift apart on which branches exist. +pub(crate) fn collect_scoped_effects( + ability: &AbilityDefinition, + scope: AbilityScope, +) -> Vec<&Effect> { + let mut effects: Vec<&Effect> = Vec::new(); + push_scoped_effects(ability, scope, &mut effects); + effects +} + +fn push_scoped_effects<'a>( + ability: &'a AbilityDefinition, + scope: AbilityScope, + out: &mut Vec<&'a Effect>, +) { + out.push(&ability.effect); + if let Some(sub) = &ability.sub_ability { + push_scoped_effects(sub, scope, out); + } + if scope == AbilityScope::Unconditional { + return; + } + // CR 608.2c: the "Otherwise, ..." leg is one of two mutually exclusive + // outcomes, so it is potential rather than unconditional. + if let Some(other) = &ability.else_ability { + push_scoped_effects(other, scope, out); + } + // CR 700.2: each mode is an alternative the controller may choose. + for mode in &ability.mode_abilities { + push_scoped_effects(mode, scope, out); + } +} + /// Walk `ability.effect` plus each `sub_ability.effect` in turn, returning -/// borrowed references in chain order. +/// borrowed references in chain order. Shorthand for +/// [`collect_scoped_effects`] at [`AbilityScope::Unconditional`]. pub(crate) fn collect_chain_effects(ability: &AbilityDefinition) -> Vec<&Effect> { - let mut effects: Vec<&Effect> = vec![&ability.effect]; - let mut current = &ability.sub_ability; - while let Some(sub) = current { - effects.push(&sub.effect); - current = &sub.sub_ability; - } - effects + collect_scoped_effects(ability, AbilityScope::Unconditional) } diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index 049149279c..69504451e0 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -463,6 +463,16 @@ pub struct PolicyPenalties { /// scalar.) #[serde(default = "default_loop_shortcut_winning_declare_bonus")] pub loop_shortcut_winning_declare_bonus: f64, + /// CR 104.3d: card-equivalent weight for advancing the poison clock. + /// Critical band when the action reaches ten poison (a win), scaled by + /// clock progress below that. + #[serde(default = "default_poison_clock_pressure")] + pub poison_clock_pressure: f64, + /// CR 205.2a: card-equivalent weight for advancing graveyard card-type + /// diversity toward a delirium/descend threshold. Strong band when the + /// action supplies the last missing type. + #[serde(default = "default_graveyard_types_progress")] + pub graveyard_types_progress: f64, } impl Default for PolicyPenalties { @@ -527,13 +537,28 @@ impl Default for PolicyPenalties { cycling_patience_penalty: default_cycling_patience_penalty(), cycling_needed_land_penalty: default_cycling_needed_land_penalty(), loop_shortcut_winning_declare_bonus: default_loop_shortcut_winning_declare_bonus(), + poison_clock_pressure: default_poison_clock_pressure(), + graveyard_types_progress: default_graveyard_types_progress(), } } } +/// CR 205.2a. Shared by `Default` and `#[serde(default)]` so a tuning artifact +/// written before this field existed still deserializes (`ai_tune` reads the +/// `policy_penalties` section directly into this struct). +fn default_graveyard_types_progress() -> f64 { + 2.5 +} + fn default_wasted_cast_penalty() -> f64 { -8.0 } +/// CR 104.3d. Shared by `Default` and `#[serde(default)]` so a tuning artifact +/// written before this field existed still deserializes (`ai_tune` reads the +/// `policy_penalties` section directly into this struct). +fn default_poison_clock_pressure() -> f64 { + 6.0 +} fn default_untap_own_tapped_bonus() -> f64 { 8.0 } @@ -724,6 +749,17 @@ pub const ACTIVE_POLICY_PENALTY_FIELDS: &[&str] = &[ /// Policy penalties intentionally not present in an active CMA-ES parameter /// vector yet. pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[ + ( + "poison_clock_pressure", + "CR 104.3d win-detector weight — a critical-band term whose magnitude is \ + load-bearing for correctness, not taste. Promote to ACTIVE only with a \ + paired-seed ai-gate calibration.", + ), + ( + "graveyard_types_progress", + "CR 205.2a delirium-threshold progress weight — awaiting a paired-seed \ + ai-gate calibration before promotion to ACTIVE.", + ), ( "artifact_cost_payoff_bonus", "new ArtifactSynergyPolicy knob; awaiting a paired-seed ai-gate calibration before joining the CMA-ES vector", @@ -1494,6 +1530,68 @@ mod tests { assert_eq!(p.cycling_needed_land_penalty, -2.0); } + /// Artifact compatibility: `ai_tune` deserializes a persisted + /// `policy_penalties` section straight into `PolicyPenalties` + /// (`bin/ai_tune.rs`, `TuneGroup::Penalties`), so an artifact written + /// before `poison_clock_pressure` existed must still load — with its own + /// tuned values intact and the new field filled from the shared default. + #[test] + fn policy_penalties_load_pre_poison_clock_artifact() { + let mut artifact = serde_json::to_value(PolicyPenalties::default()).unwrap(); + let object = artifact.as_object_mut().expect("serializes as object"); + object + .remove("poison_clock_pressure") + .expect("field must be present before removal"); + // A value CMA-ES could plausibly have tuned, to prove the round-trip + // reads the artifact rather than silently falling back to Default. + object.insert("wasted_cast_penalty".into(), serde_json::json!(-3.5)); + + let loaded: PolicyPenalties = serde_json::from_value(artifact) + .expect("a pre-poison_clock_pressure artifact must still deserialize"); + assert_eq!(loaded.wasted_cast_penalty, -3.5, "tuned value preserved"); + assert_eq!( + loaded.poison_clock_pressure, + default_poison_clock_pressure(), + "absent field must fall back to the shared default" + ); + assert_eq!( + PolicyPenalties::default().poison_clock_pressure, + default_poison_clock_pressure(), + "Default and serde must share one source of truth" + ); + } + + /// Artifact compatibility: `ai_tune` deserializes a persisted + /// `policy_penalties` section straight into `PolicyPenalties` + /// (`bin/ai_tune.rs`, `TuneGroup::Penalties`), so an artifact written + /// before `graveyard_types_progress` existed must still load — with its own + /// tuned values intact and the new field filled from the shared default. + #[test] + fn policy_penalties_load_pre_graveyard_types_artifact() { + let mut artifact = serde_json::to_value(PolicyPenalties::default()).unwrap(); + let object = artifact.as_object_mut().expect("serializes as object"); + object + .remove("graveyard_types_progress") + .expect("field must be present before removal"); + // A value CMA-ES could plausibly have tuned, to prove the round-trip + // reads the artifact rather than silently falling back to Default. + object.insert("wasted_cast_penalty".into(), serde_json::json!(-3.5)); + + let loaded: PolicyPenalties = serde_json::from_value(artifact) + .expect("a pre-graveyard_types_progress artifact must still deserialize"); + assert_eq!(loaded.wasted_cast_penalty, -3.5, "tuned value preserved"); + assert_eq!( + loaded.graveyard_types_progress, + default_graveyard_types_progress(), + "absent field must fall back to the shared default" + ); + assert_eq!( + PolicyPenalties::default().graveyard_types_progress, + default_graveyard_types_progress(), + "Default and serde must share one source of truth" + ); + } + #[test] fn every_policy_penalty_is_tuning_registered_or_explicitly_untuned() { let value = serde_json::to_value(PolicyPenalties::default()).unwrap(); diff --git a/crates/phase-ai/src/decision_kind.rs b/crates/phase-ai/src/decision_kind.rs index cb05977d10..7b9d1b7e5d 100644 --- a/crates/phase-ai/src/decision_kind.rs +++ b/crates/phase-ai/src/decision_kind.rs @@ -151,6 +151,7 @@ pub fn classify(waiting_for: &WaitingFor, action: &GameAction) -> DecisionKind { | WaitingFor::ClashChooseOpponent { .. } | WaitingFor::ChooseFromZoneOpponentChooser { .. } | WaitingFor::ChooseAnnouncingOpponent { .. } + | WaitingFor::ChooseGiftRecipient { .. } | WaitingFor::ClashCardPlacement { .. } | WaitingFor::VoteChoice { .. } | WaitingFor::SeparatePilesChooseOpponent { .. } diff --git a/crates/phase-ai/src/features/aggro_pressure.rs b/crates/phase-ai/src/features/aggro_pressure.rs index 1351480d2f..680ff16e80 100644 --- a/crates/phase-ai/src/features/aggro_pressure.rs +++ b/crates/phase-ai/src/features/aggro_pressure.rs @@ -505,6 +505,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; let mut face = creature_face(2); face.static_abilities.push(static_haste); @@ -598,6 +599,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; let face = CardFace { mana_cost: ManaCost::generic(3), diff --git a/crates/phase-ai/src/features/graveyard_types.rs b/crates/phase-ai/src/features/graveyard_types.rs new file mode 100644 index 0000000000..e6a6c5a3bf --- /dev/null +++ b/crates/phase-ai/src/features/graveyard_types.rs @@ -0,0 +1,489 @@ +//! Graveyard card-type-diversity feature — the delirium / descend / Goyf axis. +//! +//! Parser AST verification — VERIFIED against engine source: +//! - `QuantityRef::DistinctCardTypes { source: CardTypeSetSource }` at +//! `crates/engine/src/types/ability.rs:5565` (CR 205.2a card types). +//! - `CardTypeSetSource::Zone { zone: ZoneRef, scope: CountScope }` at +//! `crates/engine/src/types/ability.rs:5272` (CR 109.2a + CR 400.1) — the +//! graveyard scoping used by every card in this class. +//! - Threshold payoffs carry `StaticCondition::QuantityComparison { lhs, comparator, +//! rhs }` whose `lhs` is that quantity (Backwoods Survivalists, Autumnal Gloom). +//! - Scaling payoffs read the same quantity as a dynamic magnitude with no +//! threshold at all (Consuming Blob's `SetDynamicPower`). +//! - Enablers: `Effect::Mill`, `Effect::Discard`, `Effect::DiscardCard`, +//! `Effect::Surveil`, and `Effect::Dig { rest_destination: Graveyard }` — +//! classified by the shared `reanimator::effect_fills_own_graveyard` +//! authority, and read from BOTH `abilities` and `triggers[*].execute`. +//! +//! No parser remediation required. +//! +//! ## Why this axis exists +//! +//! CR 207.2c lists delirium, descend, threshold and undergrowth as **ability +//! words** — they have no rules meaning, so the mechanical content is entirely +//! the underlying "N or more card types among cards in your graveyard" +//! condition. 95 cards in the corpus read that quantity, and nothing in the AI +//! modelled graveyard type-diversity as a resource: a self-mill that turns a +//! delirium payoff on scored exactly the same as one that did not. +//! +//! ## Boundary with `reanimator` +//! +//! `reanimator` detects graveyard *recursion targets* — what is worth bringing +//! back. This axis measures the *type spread* of the graveyard, which is a +//! different resource: a graveyard of four creatures is excellent for +//! reanimator and useless for delirium. The axes are independent by design. + +use engine::game::DeckEntry; +use engine::types::ability::{ + AbilityCondition, AbilityDefinition, CardTypeSetSource, Comparator, CountScope, QuantityExpr, + QuantityRef, StaticCondition, TriggerCondition, TriggerDefinition, ZoneRef, +}; +use engine::types::card_type::CoreType; + +use crate::ability_chain::collect_chain_effects; +use crate::features::commitment; + +/// Commitment at or above which graveyard type-diversity is a real plan rather +/// than an incidental Goyf. Gates `GraveyardTypesPolicy::activation`. +pub const GRAVEYARD_TYPES_FLOOR: f32 = 0.35; + +/// CR 207.2c + CR 205.2a: per-deck graveyard type-diversity classification. +/// +/// Detection is structural over `CardFace.static_abilities`, `.triggers` and +/// `.abilities` — never by card name. +#[derive(Debug, Clone, Default)] +pub struct GraveyardTypesFeature { + /// Payoffs gated on a threshold ("four or more card types among cards in + /// your graveyard") — delirium, descend N, threshold. + pub threshold_payoff_count: u32, + /// Payoffs that scale continuously with the count and have no threshold + /// (Consuming Blob-likes). + pub scaling_payoff_count: u32, + /// Cards that put cards into the controller's own graveyard — self-mill, + /// self-discard, surveil, or a rest-to-graveyard dig (CR 701.17 / CR 701.25 + /// / CR 701.20e). Counted from ability chains AND trigger bodies. + pub enabler_count: u32, + /// The highest threshold any *threshold* payoff in the deck asks for, or + /// `None` when the deck has no threshold payoff at all. A descend 8 deck + /// must not think it is finished at four card types; a scaling-only deck + /// (Consuming Blob) has no threshold to "finish" and must keep + /// being rewarded for diversity — so absence is modelled distinctly from a + /// concrete four, never invented. + pub highest_threshold: Option, + /// `0.0..=1.0` — how central the axis is. Consumed by + /// `GraveyardTypesPolicy::activation` as the single scaling knob. + pub commitment: f32, + /// Names of detected payoffs. NOT used for classification — that already + /// happened against the AST. Identity lookup only. + pub payoff_names: Vec, +} + +/// Structural detection over each `DeckEntry`'s `CardFace` AST. +pub fn detect(deck: &[DeckEntry]) -> GraveyardTypesFeature { + if deck.is_empty() { + return GraveyardTypesFeature::default(); + } + + let mut threshold_payoff_count = 0u32; + let mut scaling_payoff_count = 0u32; + let mut enabler_count = 0u32; + let mut highest_threshold: Option = None; + let mut total_nonland = 0u32; + let mut payoff_names: Vec = Vec::new(); + + for entry in deck { + let face = &entry.card; + if !face.card_type.core_types.contains(&CoreType::Land) { + total_nonland = total_nonland.saturating_add(entry.count); + } + + // A graveyard-type gate can ride any of THREE carriers, and they are + // DISTINCT enums that merely share the same comparison shape, so each + // gets its own extractor rather than a forced conversion: + // * `StaticDefinition.condition` (Backwoods Survivalists) + // * `TriggerDefinition.condition` (Autumnal Gloom) + // * `AbilityDefinition.condition` (Traverse the Ulvenwald) — walked + // down the sub_ability/else_ability chain, where the gate usually + // sits rather than at the ability root. + let threshold = face + .static_abilities + .iter() + .filter_map(|def| def.condition.as_ref()) + .filter_map(static_graveyard_type_threshold) + .chain( + face.triggers + .iter() + .filter_map(|t| t.condition.as_ref()) + .filter_map(trigger_graveyard_type_threshold), + ) + .chain( + face.abilities + .iter() + .filter_map(ability_chain_graveyard_type_threshold), + ) + .max(); + + let scales = face_reads_graveyard_types(face); + + if let Some(threshold) = threshold { + threshold_payoff_count = threshold_payoff_count.saturating_add(entry.count); + // `Option` orders `None < Some(_)`, so `max` keeps the highest + // real threshold and never regresses to a fabricated default. + highest_threshold = highest_threshold.max(Some(threshold)); + } else if scales { + // Only a payoff with NO threshold is a scaling payoff — otherwise a + // delirium card would be counted on both axes. + scaling_payoff_count = scaling_payoff_count.saturating_add(entry.count); + } + // One push per UNIQUE face, and once even when both axes could fire. + if threshold.is_some() || scales { + payoff_names.push(face.name.clone()); + } + + if fills_own_graveyard_parts(&face.abilities, &face.triggers) { + enabler_count = enabler_count.saturating_add(entry.count); + } + } + + let commitment = compute_commitment( + threshold_payoff_count, + scaling_payoff_count, + enabler_count, + total_nonland, + ); + + GraveyardTypesFeature { + threshold_payoff_count, + scaling_payoff_count, + enabler_count, + // No fabricated fallback: `None` when the deck has no threshold payoff. + highest_threshold, + commitment, + payoff_names, + } +} + +/// Calibration: a Modern delirium shell (8 threshold payoffs + 2 scaling +/// payoffs + 8 enablers over 37 nonland) → commitment ≈ 0.90. +/// Anti-calibration: a deck running one incidental scaling body and no +/// enablers → well below `GRAVEYARD_TYPES_FLOOR`; UW control → 0.0. +/// +/// Geometric mean over (payoff, enabler): unlike poison, BOTH pillars are +/// mandatory here. Payoffs with no enablers never turn on reliably, and +/// enablers with no payoff are just self-mill — neither is this archetype. +fn compute_commitment( + threshold_payoff_count: u32, + scaling_payoff_count: u32, + enabler_count: u32, + total_nonland: u32, +) -> f32 { + let payoff_density = commitment::weighted_sum(&[ + ( + 1.0 / 8.0, + commitment::density_per_60(threshold_payoff_count, total_nonland), + ), + // A scaling payoff wants a big graveyard but never strands, so it is a + // weaker signal of intent than a threshold payoff. + ( + 0.5 / 8.0, + commitment::density_per_60(scaling_payoff_count, total_nonland), + ), + ]); + let enabler_density = + (commitment::density_per_60(enabler_count, total_nonland) / 10.0).min(1.0); + + commitment::geometric_mean(&[payoff_density, enabler_density]) +} + +/// CR 205.2a: read the threshold N out of a `StaticCondition` whose comparison +/// counts distinct card types in the controller's graveyard — but only when the +/// comparison is a genuine positive "N or more types" gate. +/// +/// Returns `None` for any other condition shape, for an opponent-scoped count (a +/// card that punishes an OPPONENT's diverse graveyard is not this deck's plan), +/// and for a comparison whose truth semantics reward FEWER or an EXACT number of +/// types (see [`positive_graveyard_threshold`]). +fn static_graveyard_type_threshold(condition: &StaticCondition) -> Option { + match condition { + StaticCondition::QuantityComparison { + lhs, + comparator, + rhs, + } => positive_graveyard_threshold(lhs, *comparator, rhs), + // CR 109.3: an `And` gates on EVERY constraint, so a delirium conjunct + // is mandatory — take the highest graveyard threshold present. + StaticCondition::And { conditions } => conditions + .iter() + .filter_map(static_graveyard_type_threshold) + .max(), + // An `Or` is satisfied by ANY branch, so a graveyard threshold is a + // mandatory gate only when EVERY branch is one — then the easiest + // (minimum) is what self-mill must reach. A single non-graveyard branch + // means the payoff can fire without delirium, so it is not our plan. + StaticCondition::Or { conditions } => { + all_graveyard_thresholds_min(conditions.iter().map(static_graveyard_type_threshold)) + } + // CR 205.2a: negating "N or more types" is a "fewer than N" condition — + // it rewards a SMALLER graveyard, the opposite of a delirium payoff. + StaticCondition::Not { .. } => None, + _ => None, + } +} + +/// CR 205.2a: the `TriggerCondition` twin of [`static_graveyard_type_threshold`] +/// — Autumnal Gloom carries its delirium clause on the trigger, not the static. +fn trigger_graveyard_type_threshold(condition: &TriggerCondition) -> Option { + match condition { + TriggerCondition::QuantityComparison { + lhs, + comparator, + rhs, + } => positive_graveyard_threshold(lhs, *comparator, rhs), + TriggerCondition::And { conditions } => conditions + .iter() + .filter_map(trigger_graveyard_type_threshold) + .max(), + TriggerCondition::Or { conditions } => { + all_graveyard_thresholds_min(conditions.iter().map(trigger_graveyard_type_threshold)) + } + TriggerCondition::Not { .. } => None, + _ => None, + } +} + +/// CR 205.2a: the `AbilityCondition` twin of [`static_graveyard_type_threshold`] +/// — the THIRD condition carrier, `AbilityDefinition.condition`. +/// +/// Traverse the Ulvenwald is the shape that forces this: its delirium gate is +/// not on `abilities[0].condition` but on `abilities[0].sub_ability.condition`, +/// because *"Delirium — If there are four or more card types among cards in your +/// graveyard, **instead** search…"* replaces the second clause and so lowers +/// onto the sub-ability, wrapped in `ConditionInstead`. Walking only the top +/// level would still miss it — see [`ability_chain_graveyard_type_threshold`]. +fn ability_graveyard_type_threshold(condition: &AbilityCondition) -> Option { + match condition { + AbilityCondition::QuantityCheck { + lhs, + comparator, + rhs, + } => positive_graveyard_threshold(lhs, *comparator, rhs), + // CR 608.2c: "instead" wraps the gate without changing its polarity. + AbilityCondition::ConditionInstead { inner } => ability_graveyard_type_threshold(inner), + AbilityCondition::And { conditions } => conditions + .iter() + .filter_map(ability_graveyard_type_threshold) + .max(), + AbilityCondition::Or { conditions } => { + all_graveyard_thresholds_min(conditions.iter().map(ability_graveyard_type_threshold)) + } + AbilityCondition::Not { .. } => None, + _ => None, + } +} + +/// Walk an ability and its `sub_ability` / `else_ability` chain, returning the +/// highest graveyard-type threshold gating any link. +/// +/// The chain walk is the load-bearing part: the gate frequently sits on a +/// sub-ability rather than the ability root (Traverse the Ulvenwald), so a +/// top-level-only read returns `None` for the very cards this axis exists for. +fn ability_chain_graveyard_type_threshold(ability: &AbilityDefinition) -> Option { + let here = ability + .condition + .as_ref() + .and_then(ability_graveyard_type_threshold); + let sub = ability + .sub_ability + .as_deref() + .and_then(ability_chain_graveyard_type_threshold); + let alt = ability + .else_ability + .as_deref() + .and_then(ability_chain_graveyard_type_threshold); + here.into_iter().chain(sub).chain(alt).max() +} + +/// The `Or`-combinator rule shared by both extractors: yield a threshold only +/// when EVERY disjunct is itself a graveyard threshold, and then the minimum — +/// the easiest branch self-mill can satisfy. Any `None` child (a branch that +/// enables the payoff without delirium) collapses the whole `Or` to `None`. +fn all_graveyard_thresholds_min(children: impl Iterator>) -> Option { + children + .collect::>>() + .and_then(|thresholds| thresholds.into_iter().min()) +} + +/// CR 205.2a: normalize a single `count CMP N` comparison into the delirium +/// threshold it mandates — the least graveyard-type count that satisfies it — or +/// `None` when the comparison is not a positive "N or more types" gate. +/// +/// Handles both orientations (`types >= N`, `N <= types`) and the strict-bound +/// off-by-one (`types > N` ⟺ `types >= N+1`). Rejects `<`, `<=`, `=`, `!=` +/// against the graveyard count: those reward FEWER or an EXACT number of types, +/// so self-milling toward N is not what they want. +fn positive_graveyard_threshold( + lhs: &QuantityExpr, + comparator: Comparator, + rhs: &QuantityExpr, +) -> Option { + // Orient so the graveyard-type count is the subject and the constant is the + // bound; flip the comparator when the count sits on the right. + let (bound, oriented) = if quantity_reads_own_graveyard_types(lhs) { + (fixed_quantity_value(rhs)?, comparator) + } else if quantity_reads_own_graveyard_types(rhs) { + (fixed_quantity_value(lhs)?, flip_comparator(comparator)) + } else { + return None; + }; + // `oriented` now reads `types CMP bound`; a delirium gate is a lower bound. + match oriented { + // types >= bound → needs `bound` types (a zero bound gates nothing). + Comparator::GE if bound > 0 => Some(bound as u32), + // types > bound → needs `bound + 1` types. + Comparator::GT if bound >= 0 => Some((bound + 1) as u32), + Comparator::GT + | Comparator::GE + | Comparator::LT + | Comparator::LE + | Comparator::EQ + | Comparator::NE => None, + } +} + +/// The `i32` behind a `QuantityExpr::Fixed`, or `None` for any dynamic value. +fn fixed_quantity_value(expr: &QuantityExpr) -> Option { + match expr { + QuantityExpr::Fixed { value } => Some(*value), + _ => None, + } +} + +/// Reflect a comparator across its operands (`a CMP b` ⟺ `b flip(CMP) a`), so a +/// `constant CMP count` comparison can be re-read as `count CMP constant`. +fn flip_comparator(comparator: Comparator) -> Comparator { + match comparator { + Comparator::GT => Comparator::LT, + Comparator::LT => Comparator::GT, + Comparator::GE => Comparator::LE, + Comparator::LE => Comparator::GE, + Comparator::EQ => Comparator::EQ, + Comparator::NE => Comparator::NE, + } +} + +/// True when a `QuantityExpr` reads distinct card types in the controller's +/// OWN graveyard, at any nesting depth (Consuming Blob wraps it in `Offset`). +/// +/// Only own-graveyard scopes qualify. `CountScope::All` is deliberately +/// excluded: the policy's `distinct_graveyard_types` counts only the AI's owned +/// objects, so classifying an all-graveyards payoff (Tarmogoyf-class) as an +/// own-graveyard plan would let an opponent satisfy it while the policy keeps +/// rewarding self-mill against a different quantity. Opponent- and +/// iterated-player scopes are likewise not this deck's own plan. +fn quantity_reads_own_graveyard_types(expr: &QuantityExpr) -> bool { + match expr { + QuantityExpr::Ref { qty } => matches!( + qty, + QuantityRef::DistinctCardTypes { + source: CardTypeSetSource::Zone { + zone: ZoneRef::Graveyard, + // CR 108.3 + CR 109.5: "your graveyard" — the controller as + // owner over a non-battlefield zone. + scope: CountScope::Controller | CountScope::Owner, + }, + } + ), + QuantityExpr::Fixed { .. } => false, + QuantityExpr::DivideRounded { inner, .. } + | QuantityExpr::Offset { inner, .. } + | QuantityExpr::ClampMin { inner, .. } + | QuantityExpr::Multiply { inner, .. } => quantity_reads_own_graveyard_types(inner), + QuantityExpr::UpTo { max } => quantity_reads_own_graveyard_types(max), + QuantityExpr::Power { exponent, .. } => quantity_reads_own_graveyard_types(exponent), + QuantityExpr::Difference { left, right } => { + quantity_reads_own_graveyard_types(left) || quantity_reads_own_graveyard_types(right) + } + QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => { + exprs.iter().any(quantity_reads_own_graveyard_types) + } + } +} + +/// True when any continuous modification or effect on the face scales off the +/// graveyard type count (Consuming Blob's `SetDynamicPower`). +fn face_reads_graveyard_types(face: &engine::types::card::CardFace) -> bool { + let in_statics = face.static_abilities.iter().any(|def| { + def.modifications + .iter() + .filter_map(crate::features::graveyard_types::modification_quantity) + .any(quantity_reads_own_graveyard_types) + }); + in_statics +} + +/// The dynamic magnitude carried by a continuous modification, if any. Mirrors +/// `game::quantity::continuous_modification_dynamic_quantity`. +pub(crate) fn modification_quantity( + m: &engine::types::ability::ContinuousModification, +) -> Option<&QuantityExpr> { + use engine::types::ability::ContinuousModification as CM; + match m { + CM::SetDynamicPower { value } + | CM::SetDynamicToughness { value } + | CM::SetPowerDynamic { value } + | CM::SetToughnessDynamic { value } + | CM::AddDynamicPower { value } + | CM::AddDynamicToughness { value } + | CM::AddDynamicKeyword { value, .. } => Some(value), + _ => None, + } +} + +/// CR 701.17 + CR 701.25 + CR 701.20e + CR 404.1: true when any ability chain +/// OR trigger body puts cards into the CONTROLLER's own graveyard — self-mill, +/// self-discard, surveil, or a rest-to-graveyard dig. +/// +/// Both carriers matter and missing either zeroes the axis: the archetypal +/// enabler (Stitcher's Supplier) is `abilities: []` with mill *triggers*, and +/// `compute_commitment`'s geometric mean collapses to `0.0` when +/// `enabler_count == 0`, which drops the deck below `GRAVEYARD_TYPES_FLOOR` and +/// switches the policy off entirely. Mirrors `tokens_wide::is_token_generator_parts`. +/// +/// The per-effect question delegates to +/// [`crate::features::reanimator::effect_fills_own_graveyard`] — the single +/// authority both graveyard axes share, so they cannot drift on which effects +/// and scopes count. An opponent-scoped mill is excluded there: filling an +/// opponent's graveyard does nothing for this deck's threshold. +pub(crate) fn fills_own_graveyard_parts( + abilities: &[AbilityDefinition], + triggers: &[TriggerDefinition], +) -> bool { + let chain_fills = |ability: &AbilityDefinition| { + collect_chain_effects(ability) + .iter() + .copied() + .any(crate::features::reanimator::effect_fills_own_graveyard) + }; + if abilities.iter().any(&chain_fills) { + return true; + } + // CR 603.6a: "when this enters, mill three" lives on the trigger body. + triggers + .iter() + .any(|trigger| trigger.execute.as_deref().is_some_and(&chain_fills)) +} + +/// The live-policy companion to [`fills_own_graveyard_parts`]: the same +/// single-authority question asked over an already-resolved effect chain (a +/// cast's spell abilities plus its immediate ETB triggers, or one activated +/// ability), where the carrier split has already been made by the caller. +pub(crate) fn abilities_fill_own_graveyard<'a>( + abilities: impl IntoIterator, +) -> bool { + abilities.into_iter().any(|ability| { + collect_chain_effects(ability) + .iter() + .copied() + .any(crate::features::reanimator::effect_fills_own_graveyard) + }) +} diff --git a/crates/phase-ai/src/features/mod.rs b/crates/phase-ai/src/features/mod.rs index a0430f971a..521b512fd5 100644 --- a/crates/phase-ai/src/features/mod.rs +++ b/crates/phase-ai/src/features/mod.rs @@ -15,11 +15,13 @@ pub mod control; pub mod enchantments; pub mod energy; pub mod equipment; +pub mod graveyard_types; pub mod landfall; pub mod lifegain; pub mod mana_ramp; pub mod mill; pub mod plus_one_counters; +pub mod poison; pub mod reanimator; pub mod spellslinger_prowess; pub mod tokens_wide; @@ -36,11 +38,13 @@ pub use control::ControlFeature; pub use enchantments::EnchantmentsFeature; pub use energy::EnergyFeature; pub use equipment::EquipmentFeature; +pub use graveyard_types::GraveyardTypesFeature; pub use landfall::LandfallFeature; pub use lifegain::LifegainFeature; pub use mana_ramp::ManaRampFeature; pub use mill::MillFeature; pub use plus_one_counters::PlusOneCountersFeature; +pub use poison::PoisonFeature; pub use reanimator::ReanimatorFeature; pub use spellslinger_prowess::SpellslingerProwessFeature; pub use tokens_wide::TokensWideFeature; @@ -78,6 +82,10 @@ pub struct DeckFeatures { pub reanimator: ReanimatorFeature, pub mill: MillFeature, pub energy: EnergyFeature, + /// CR 104.3d: the alternate poison win clock (toxic / infect / proliferate). + pub poison: PoisonFeature, + /// CR 207.2c + CR 205.2a: delirium / descend graveyard type-diversity. + pub graveyard_types: GraveyardTypesFeature, /// Declaration-derived: the deck's declared bracket tier. Unlike the /// other fields here, this is not structurally detected from card text — /// it is a per-deck declaration set at deck-analysis time from deck @@ -122,6 +130,8 @@ impl DeckFeatures { reanimator: reanimator::detect(deck), mill: mill::detect(deck), energy: energy::detect(deck), + poison: poison::detect(deck), + graveyard_types: graveyard_types::detect(deck), bracket_tier: tier, } } diff --git a/crates/phase-ai/src/features/poison.rs b/crates/phase-ai/src/features/poison.rs new file mode 100644 index 0000000000..767468eb1c --- /dev/null +++ b/crates/phase-ai/src/features/poison.rs @@ -0,0 +1,282 @@ +//! Poison feature — structural detection of the alternate-win poison clock. +//! +//! Parser AST verification — VERIFIED against engine source: +//! - `Keyword::Toxic(u32)` at `crates/engine/src/types/keywords.rs:912` +//! (CR 702.164: "Toxic N — when this creature deals combat damage to a +//! player, that player gets N poison counters"). +//! - `Keyword::Infect` at `crates/engine/src/types/keywords.rs:612` +//! (CR 702.90: damage to players is dealt as poison counters). +//! - `Keyword::Poisonous(u32)` at `crates/engine/src/types/keywords.rs:898` +//! (CR 702.70). +//! - `Effect::GivePlayerCounter { counter_kind, count, target }` at +//! `crates/engine/src/types/ability.rs:12230`; `PlayerCounterKind::Poison` at +//! `crates/engine/src/types/player.rs:56` (CR 122.1f). Note this effect scopes +//! by `TargetFilter`, NOT by `CountScope` — the sibling `QuantityRef` arm at +//! `ability.rs:5443` is a counter *reader*, not a granter. +//! - `Effect::Proliferate` / `Effect::ProliferateTarget { target }` at +//! `crates/engine/src/types/ability.rs:10450` and `:10457` (CR 701.34a). +//! +//! No parser remediation required — every axis is expressible over existing +//! typed AST. +//! +//! ## Why this axis exists +//! +//! CR 104.3d makes ten poison counters a loss condition entirely independent of +//! life total, and `Player.poison_counters` is a dedicated engine field +//! (`crates/engine/src/types/player.rs:117`). The AI's evaluation is +//! life-total-centric, so a deck whose whole clock is poison reads as doing +//! nothing. This axis lets a policy see the second clock. +//! +//! ## Boundary with `plus_one_counters` +//! +//! `plus_one_counters` already counts `Effect::Proliferate` as a +1/+1 counter +//! enabler. That overlap is intentional and the axes stay independent: one +//! proliferate card genuinely serves both decks, and a Hardened Scales deck +//! scores high there while scoring ~0 here (no poison sources). + +use engine::game::DeckEntry; +use engine::types::ability::{AbilityDefinition, ControllerRef, Effect, TargetFilter}; +use engine::types::card_type::CoreType; +use engine::types::keywords::Keyword; +use engine::types::player::PlayerCounterKind; + +use crate::ability_chain::{collect_scoped_effects, AbilityScope}; +use crate::features::commitment; + +/// Commitment at or above which the poison clock is a real plan for this deck +/// rather than an incidental splash. Gates `PoisonClockPolicy::activation`. +pub const POISON_CLOCK_FLOOR: f32 = 0.35; + +/// CR 104.3d: a player with ten or more poison counters loses the game. +pub const LETHAL_POISON: u32 = 10; + +/// CR 104.3d + CR 122.1f: per-deck poison-clock classification. +/// +/// Populated once per game from `DeckEntry` data. Detection is structural over +/// `CardFace.keywords` and `CardFace.abilities` — never by card name. +#[derive(Debug, Clone, Default)] +pub struct PoisonFeature { + /// Creatures that convert combat damage into poison counters — CR 702.164 + /// Toxic, CR 702.90 Infect, CR 702.70 Poisonous. + pub source_count: u32, + /// Cards whose effect chain gives poison counters to opponents outright, + /// without needing combat damage (CR 122.1f). + pub direct_count: u32, + /// Cards that proliferate (CR 701.34a) — the accelerant on an established + /// poison clock. + pub proliferate_count: u32, + /// `0.0..=1.0` — how central the poison clock is to this deck. Consumed by + /// `PoisonClockPolicy::activation` as the single scaling knob. + pub commitment: f32, +} + +/// Structural detection over each `DeckEntry`'s `CardFace` AST. +pub fn detect(deck: &[DeckEntry]) -> PoisonFeature { + if deck.is_empty() { + return PoisonFeature::default(); + } + + let mut source_count = 0u32; + let mut direct_count = 0u32; + let mut proliferate_count = 0u32; + let mut total_nonland = 0u32; + + for entry in deck { + let face = &entry.card; + if !face.card_type.core_types.contains(&CoreType::Land) { + total_nonland = total_nonland.saturating_add(entry.count); + } + + // Deck time asks "can this card ever advance the clock", so a poison + // mode of a modal card counts here even though the mode has not been + // chosen. The live policy asks the narrower question at the seam where + // the mode IS chosen — see `AbilityScope`. + if is_poison_source_parts(&face.card_type.core_types, &face.keywords) { + source_count = source_count.saturating_add(entry.count); + } + if gives_opponents_poison_parts(&face.abilities, AbilityScope::Potential) { + direct_count = direct_count.saturating_add(entry.count); + } + if proliferates_parts(&face.abilities, AbilityScope::Potential) { + proliferate_count = proliferate_count.saturating_add(entry.count); + } + } + + let commitment = + compute_commitment(source_count, direct_count, proliferate_count, total_nonland); + + PoisonFeature { + source_count, + direct_count, + proliferate_count, + commitment, + } +} + +/// Calibration: Modern Infect (12 infect creatures + 2 proliferate over 37 +/// nonland) → commitment ≈ 0.90. Anti-calibration: a superfriends deck running +/// 2 proliferate cards and no poison source → ≈ 0.06, far below +/// `POISON_CLOCK_FLOOR`; UW control → 0.0. +/// +/// Weighted sum rather than geometric mean: missing pillars are tolerable here. +/// A dedicated Infect deck runs zero direct-poison spells and often zero +/// proliferate, and must still read as fully committed. +fn compute_commitment( + source_count: u32, + direct_count: u32, + proliferate_count: u32, + total_nonland: u32, +) -> f32 { + commitment::weighted_sum(&[ + // Full pillar at ~15 sources per 60 nonland — a dedicated poison deck. + ( + 0.65 / 15.0, + commitment::density_per_60(source_count, total_nonland), + ), + // Direct poison is rarer per deck, so it saturates sooner. + ( + 0.20 / 6.0, + commitment::density_per_60(direct_count, total_nonland), + ), + // Proliferate alone is never a poison plan — it only accelerates one. + ( + 0.15 / 8.0, + commitment::density_per_60(proliferate_count, total_nonland), + ), + ]) +} + +/// CR 702.164 / CR 702.90 / CR 702.70: a creature whose combat damage becomes +/// poison counters. Non-creature faces never qualify — all three keywords are +/// combat-damage abilities. +pub(crate) fn is_poison_source_parts(core_types: &[CoreType], keywords: &[Keyword]) -> bool { + if !core_types.contains(&CoreType::Creature) { + return false; + } + keywords.iter().any(|k| { + matches!( + k, + Keyword::Toxic(_) | Keyword::Infect | Keyword::Poisonous(_) + ) + }) +} + +/// CR 702.90b / CR 702.164c / CR 702.70a: how many poison counters this +/// creature's combat damage to a PLAYER would produce. +/// +/// * Infect (CR 702.90b) converts the damage itself, so it yields `power`; +/// CR 702.90f makes multiple instances redundant. +/// * Toxic (CR 702.164c) adds the creature's total toxic value, which +/// CR 702.164b defines as the SUM of every toxic ability's N. +/// * Poisonous N (CR 702.70a) is a separate triggered ability per instance. +/// +/// Parts-based so the arithmetic is testable without a `GameObject`; the live +/// caller passes `obj.card_types.core_types`, `obj.keywords`, and `obj.power`. +pub(crate) fn poison_yield_parts(core_types: &[CoreType], keywords: &[Keyword], power: i32) -> u32 { + if !is_poison_source_parts(core_types, keywords) { + return 0; + } + let mut has_infect = false; + let mut flat = 0u32; + for keyword in keywords { + match keyword { + Keyword::Infect => has_infect = true, + Keyword::Toxic(n) | Keyword::Poisonous(n) => flat = flat.saturating_add(*n), + _ => {} + } + } + // CR 702.90b: damage dealt to a player by an infect source becomes that + // many poison counters. Negative or absent power deals no damage. + let from_infect = if has_infect { + u32::try_from(power).unwrap_or(0) + } else { + 0 + }; + from_infect.saturating_add(flat) +} + +/// CR 122.1f: an ability tree that gives poison counters to a player who can +/// be an opponent, walked at `scope`. +/// +/// A `TargetFilter::Controller` / `SelfRef` scope is rejected — a card that +/// poisons ITS OWN controller (a drawback clause, e.g. Phyrexian Vatmother) is +/// not a poison payoff. +pub(crate) fn gives_opponents_poison_parts<'a>( + abilities: impl IntoIterator, + scope: AbilityScope, +) -> bool { + abilities.into_iter().any(|ability| { + collect_scoped_effects(ability, scope).iter().any(|effect| { + matches!( + effect, + Effect::GivePlayerCounter { + counter_kind: PlayerCounterKind::Poison, + target, + .. + } if filter_can_hit_opponent(target) + ) + }) + }) +} + +/// True when a `TargetFilter` can resolve to a player other than the ability's +/// controller. Mirrors `landfall::filter_matches_land_you_control`'s recursion, +/// including the CR 109.3 conjunction rule for `And`. +/// +/// Player-scope arms follow `game::filter::player_matches_target_filter_with`, +/// the engine's authority for matching a player against a `TargetFilter`. +fn filter_can_hit_opponent(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::Opponent | TargetFilter::Player | TargetFilter::Any => true, + TargetFilter::Typed(typed) => !matches!(typed.controller, Some(ControllerRef::You)), + TargetFilter::Or { filters } => filters.iter().any(filter_can_hit_opponent), + // CR 109.3: every constraint of an `And` must hold for the match. + TargetFilter::And { filters } => filters.iter().all(filter_can_hit_opponent), + // A negated scope hits an opponent exactly when some opponent is NOT + // matched by the inner filter — so `Not { SelfRef }` and + // `Not { Typed(controller: You) }` ("each other player") are poison + // payoffs, while `Not { Opponent }` resolves to you alone and is not. + TargetFilter::Not { filter } => !filter_matches_every_opponent(filter), + _ => false, + } +} + +/// True when EVERY opponent matches `filter`, the complement +/// `filter_can_hit_opponent` needs to decide a negated scope. Anything this +/// function cannot prove universal answers `false`, which makes the negation +/// fail open toward "can hit an opponent" only for scopes that genuinely leave +/// an opponent unmatched. +fn filter_matches_every_opponent(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::Opponent | TargetFilter::Player | TargetFilter::Any => true, + TargetFilter::Typed(typed) => { + typed.type_filters.is_empty() + && matches!(typed.controller, Some(ControllerRef::Opponent)) + } + // Universality distributes the opposite way from `filter_can_hit_opponent`: + // an `Or` covers every opponent as soon as one branch does, an `And` + // only when all of them do (CR 109.3). + TargetFilter::Or { filters } => filters.iter().any(filter_matches_every_opponent), + TargetFilter::And { filters } => filters.iter().all(filter_matches_every_opponent), + // Double negation: `Not { X }` covers every opponent exactly when `X` + // covers none. + TargetFilter::Not { filter } => !filter_can_hit_opponent(filter), + _ => false, + } +} + +/// CR 701.34a: an ability tree containing either proliferate form, walked at +/// `scope`. +pub(crate) fn proliferates_parts<'a>( + abilities: impl IntoIterator, + scope: AbilityScope, +) -> bool { + abilities.into_iter().any(|ability| { + collect_scoped_effects(ability, scope).iter().any(|effect| { + matches!( + effect, + Effect::Proliferate | Effect::ProliferateTarget { .. } + ) + }) + }) +} diff --git a/crates/phase-ai/src/features/reanimator.rs b/crates/phase-ai/src/features/reanimator.rs index 25419019c7..41ae1fe9bd 100644 --- a/crates/phase-ai/src/features/reanimator.rs +++ b/crates/phase-ai/src/features/reanimator.rs @@ -235,28 +235,58 @@ fn effect_is_reanimation(effect: &Effect) -> bool { ) } -/// CR 701.17a / CR 701.9a: an effect that fills the controller's own graveyard. +/// CR 701.17a / CR 701.9a / CR 701.25a / CR 701.20e: an effect that fills the +/// controller's own graveyard. +/// +/// **Single authority for "does this effect load my own graveyard".** Shared by +/// the reanimator axis (what is there to bring back) and the graveyard +/// type-diversity axis (how wide the graveyard's type spread is). Those axes +/// measure different resources but ask this one question identically, so they +/// must not answer it from two divergent local copies. +/// /// A self-mill must deposit into the graveyard (not exile); a discard always -/// goes to the graveyard. Both must be controller-scoped — a `Player`-targeted -/// mill/discard is opponent disruption (Mind Rot, Glimpse the Unthinkable), not -/// a self-enabler. -fn effect_fills_own_graveyard(effect: &Effect) -> bool { +/// goes there. `Surveil` (CR 701.25a) and a `Dig` whose *rest* goes to the +/// graveyard (CR 701.20e — "look at N, put one in hand, the rest into your +/// graveyard") both deposit as well, and the latter is the densest type-spread +/// filler on the board. Every form must be controller-scoped — a +/// `Player`-targeted mill/discard is opponent disruption (Mind Rot, Glimpse the +/// Unthinkable), not a self-enabler. +pub(crate) fn effect_fills_own_graveyard(effect: &Effect) -> bool { match effect { Effect::Mill { target, destination, .. } => *destination == Zone::Graveyard && target_fills_own_graveyard(target), - Effect::Discard { target, .. } => target_fills_own_graveyard(target), + Effect::Discard { target, .. } | Effect::DiscardCard { target, .. } => { + target_fills_own_graveyard(target) + } + Effect::Surveil { target, .. } => target_fills_own_graveyard(target), + // CR 701.20e: only the rest-to-graveyard form deposits; a Dig whose + // remainder goes to the bottom of the library fills nothing. + Effect::Dig { + player, + rest_destination, + .. + } => *rest_destination == Some(Zone::Graveyard) && target_fills_own_graveyard(player), _ => false, } } -/// CR 608.2c: "you mill/discard" (`Controller`) and the unspecified default -/// (`Any`, e.g. "discard two cards" / "mill three cards") load the resolving -/// player's own graveyard. An explicitly targeted `Player` is opponent-facing. +/// CR 608.2c: "you mill/discard" (`Controller`), the source itself (`SelfRef`), +/// an explicitly you-controlled scope (`Typed { controller: You }`), and the +/// unspecified default (`Any`, e.g. "discard two cards" / "mill three cards") +/// all load the resolving player's own graveyard. An explicitly targeted +/// `Player` is opponent-facing. fn target_fills_own_graveyard(filter: &TargetFilter) -> bool { - matches!(filter, TargetFilter::Controller | TargetFilter::Any) + match filter { + TargetFilter::Controller | TargetFilter::Any | TargetFilter::SelfRef => true, + TargetFilter::Typed(typed) => matches!(typed.controller, Some(ControllerRef::You)), + TargetFilter::Or { filters } => filters.iter().any(target_fills_own_graveyard), + // Every constraint of a conjunction must hold for the match. + TargetFilter::And { filters } => filters.iter().all(target_fills_own_graveyard), + _ => false, + } } /// CR 608.2b: unwrap a reanimation target filter. Accepts a filter that diff --git a/crates/phase-ai/src/features/tests/graveyard_types.rs b/crates/phase-ai/src/features/tests/graveyard_types.rs new file mode 100644 index 0000000000..ffea6ef714 --- /dev/null +++ b/crates/phase-ai/src/features/tests/graveyard_types.rs @@ -0,0 +1,785 @@ +//! Unit tests for `features::graveyard_types` — structural detection + +//! calibration anchors for the delirium / descend / Goyf axis. No +//! `#[cfg(test)]` in SOURCE files; tests live here. + +use engine::game::DeckEntry; +use engine::types::ability::{ + AbilityCondition, AbilityDefinition, AbilityKind, CardTypeSetSource, Comparator, + ContinuousModification, ControllerRef, CountScope, DigSource, Effect, QuantityExpr, + QuantityRef, StaticCondition, StaticDefinition, TargetFilter, TriggerCondition, + TriggerDefinition, TypedFilter, ZoneRef, +}; +use engine::types::card::CardFace; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::triggers::TriggerMode; +use engine::types::zones::Zone; + +use crate::features::graveyard_types::*; + +fn creature(name: &str) -> CardFace { + CardFace { + name: name.to_string(), + card_type: CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Creature], + subtypes: Vec::new(), + }, + ..Default::default() + } +} + +fn entry(card: CardFace, count: u32) -> DeckEntry { + DeckEntry { card, count } +} + +/// CR 205.2a: distinct card types among cards in the controller's graveyard. +fn own_graveyard_types() -> QuantityExpr { + QuantityExpr::Ref { + qty: QuantityRef::DistinctCardTypes { + source: CardTypeSetSource::Zone { + zone: ZoneRef::Graveyard, + scope: CountScope::Controller, + }, + }, + } +} + +fn opponent_graveyard_types() -> QuantityExpr { + QuantityExpr::Ref { + qty: QuantityRef::DistinctCardTypes { + source: CardTypeSetSource::Zone { + zone: ZoneRef::Graveyard, + scope: CountScope::Opponents, + }, + }, + } +} + +/// Backwoods Survivalists shape: a static gated on "four or more card types". +fn threshold_payoff(name: &str, threshold: i32, lhs: QuantityExpr) -> CardFace { + let mut face = creature(name); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::QuantityComparison { + lhs, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: threshold }, + })]; + face +} + +/// Autumnal Gloom shape: the delirium clause rides the TRIGGER, not the static. +fn trigger_threshold_payoff(name: &str, threshold: i32) -> CardFace { + let mut face = creature(name); + face.triggers = vec![TriggerDefinition::new(TriggerMode::Phase).condition( + TriggerCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: threshold }, + }, + )]; + face +} + +/// Consuming Blob / Tarmogoyf shape: scales continuously, no threshold. +fn scaling_payoff(name: &str) -> CardFace { + let mut face = creature(name); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::SetDynamicPower { + value: own_graveyard_types(), + }])]; + face +} + +fn self_mill_enabler(name: &str) -> CardFace { + let mut face = creature(name); + face.abilities = vec![AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mill { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + destination: Zone::Graveyard, + }, + )]; + face +} + +/// Stitcher's Supplier shape: `abilities: []`, the mill rides a TRIGGER body. +/// The archetypal delirium enabler — and the shape that made the whole axis +/// inert when only `abilities` was scanned. +fn trigger_mill_enabler(name: &str) -> CardFace { + let mut face = creature(name); + face.triggers = + vec![ + TriggerDefinition::new(TriggerMode::ChangesZone).execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::Mill { + count: QuantityExpr::Fixed { value: 3 }, + target: TargetFilter::Controller, + destination: Zone::Graveyard, + }, + )), + ]; + face +} + +/// CR 701.20e: "look at N, keep one, rest into your graveyard" — the densest +/// type-spread filler there is. `in_trigger` picks the Satyr Wayfinder shape +/// (trigger-borne) vs the Grisly Salvage shape (ability-borne). +fn dig_to_graveyard_enabler(name: &str, in_trigger: bool) -> CardFace { + let dig = Effect::Dig { + player: TargetFilter::Controller, + count: QuantityExpr::Fixed { value: 4 }, + destination: None, + keep_count: Some(1), + keep_count_expr: None, + up_to: false, + filter: TargetFilter::Any, + rest_destination: Some(Zone::Graveyard), + reveal: true, + enter_tapped: false, + source: DigSource::Library, + }; + let mut face = creature(name); + if in_trigger { + face.triggers = vec![TriggerDefinition::new(TriggerMode::ChangesZone) + .execute(AbilityDefinition::new(AbilityKind::Spell, dig))]; + } else { + face.abilities = vec![AbilityDefinition::new(AbilityKind::Spell, dig)]; + } + face +} + +/// Traverse the Ulvenwald shape: the delirium gate is NOT at +/// `abilities[0].condition` but at `abilities[0].sub_ability.condition`, wrapped +/// in `ConditionInstead` — "Delirium — ... instead search ...". The third +/// condition carrier, and only reachable by walking the sub_ability chain. +fn ability_chain_threshold_payoff(name: &str, threshold: i32) -> CardFace { + let mut sub = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + sub.condition = Some(AbilityCondition::ConditionInstead { + inner: Box::new(AbilityCondition::QuantityCheck { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: threshold }, + }), + }); + let mut root = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + root.sub_ability = Some(Box::new(sub)); + + let mut face = creature(name); + face.abilities = vec![root]; + face +} + +#[test] +fn empty_deck_produces_defaults() { + let feature = detect(&[]); + assert_eq!(feature.threshold_payoff_count, 0); + assert_eq!(feature.commitment, 0.0); + assert!(feature.payoff_names.is_empty()); +} + +#[test] +fn vanilla_creature_not_registered() { + let feature = detect(&[entry(creature("Grizzly Bears"), 4)]); + assert_eq!(feature.threshold_payoff_count, 0); + assert_eq!(feature.scaling_payoff_count, 0); + assert_eq!(feature.commitment, 0.0); +} + +#[test] +fn detects_static_threshold_payoff() { + let feature = detect(&[entry( + threshold_payoff("Backwoods Survivalists", 4, own_graveyard_types()), + 4, + )]); + assert_eq!(feature.threshold_payoff_count, 4); + assert_eq!(feature.highest_threshold, Some(4)); +} + +#[test] +fn detects_trigger_threshold_payoff() { + let feature = detect(&[entry(trigger_threshold_payoff("Autumnal Gloom", 4), 4)]); + assert_eq!(feature.threshold_payoff_count, 4); +} + +#[test] +fn detects_scaling_payoff() { + let feature = detect(&[entry(scaling_payoff("Consuming Blob"), 2)]); + assert_eq!(feature.scaling_payoff_count, 2); + assert_eq!(feature.threshold_payoff_count, 0); +} + +/// A delirium card must land on the threshold axis only — counting it as a +/// scaling payoff too would double-weight it in the commitment formula. +#[test] +fn threshold_payoff_not_double_counted_as_scaling() { + let mut face = threshold_payoff("Hybrid", 4, own_graveyard_types()); + face.static_abilities[0].modifications = vec![ContinuousModification::SetDynamicPower { + value: own_graveyard_types(), + }]; + let feature = detect(&[entry(face, 4)]); + assert_eq!(feature.threshold_payoff_count, 4); + assert_eq!(feature.scaling_payoff_count, 0); +} + +/// A card punishing an OPPONENT's diverse graveyard is not a payoff for this +/// deck's own plan. +#[test] +fn opponent_scoped_graveyard_count_ignored() { + let feature = detect(&[entry( + threshold_payoff("Punisher", 4, opponent_graveyard_types()), + 4, + )]); + assert_eq!(feature.threshold_payoff_count, 0); +} + +#[test] +fn descend_eight_tracks_highest_threshold() { + let deck = vec![ + entry( + threshold_payoff("Delirium Four", 4, own_graveyard_types()), + 2, + ), + entry( + threshold_payoff("Descend Eight", 8, own_graveyard_types()), + 2, + ), + ]; + assert_eq!(detect(&deck).highest_threshold, Some(8)); +} + +/// A scaling-only deck has NO threshold — `highest_threshold` must stay `None` +/// rather than inventing a four-type ceiling that would make the policy stop +/// rewarding a payoff that keeps scaling. +#[test] +fn scaling_only_deck_has_no_threshold() { + let feature = detect(&[ + entry(scaling_payoff("Consuming Blob"), 4), + entry(self_mill_enabler("Stitcher's Supplier"), 4), + ]); + assert_eq!(feature.scaling_payoff_count, 4); + assert_eq!(feature.threshold_payoff_count, 0); + assert_eq!(feature.highest_threshold, None); +} + +// ─── comparator / negation / compound threshold semantics (CR 205.2a) ──────── + +fn static_threshold(comparator: Comparator, lhs: QuantityExpr, rhs: QuantityExpr) -> CardFace { + let mut face = creature("Cmp"); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::QuantityComparison { + lhs, + comparator, + rhs, + })]; + face +} + +/// `types >= N` and `types > N` are both positive gates; `>` normalizes to the +/// strict boundary (`> 3` needs four types, same as `>= 4`). +#[test] +fn positive_comparators_yield_thresholds() { + let ge = detect(&[entry( + static_threshold( + Comparator::GE, + own_graveyard_types(), + QuantityExpr::Fixed { value: 4 }, + ), + 1, + )]); + assert_eq!(ge.highest_threshold, Some(4), "types >= 4"); + + let gt = detect(&[entry( + static_threshold( + Comparator::GT, + own_graveyard_types(), + QuantityExpr::Fixed { value: 3 }, + ), + 1, + )]); + assert_eq!(gt.highest_threshold, Some(4), "types > 3 ⟺ types >= 4"); +} + +/// `<`, `<=`, `=`, `!=` reward FEWER or an EXACT number of types — self-mill +/// toward N is not what they want, so they are not threshold payoffs. +#[test] +fn non_positive_comparators_are_not_thresholds() { + for comparator in [ + Comparator::LT, + Comparator::LE, + Comparator::EQ, + Comparator::NE, + ] { + let feature = detect(&[entry( + static_threshold( + comparator, + own_graveyard_types(), + QuantityExpr::Fixed { value: 4 }, + ), + 1, + )]); + assert_eq!( + feature.threshold_payoff_count, 0, + "{comparator:?} against the graveyard count is not a delirium gate" + ); + assert_eq!(feature.highest_threshold, None); + } +} + +/// The mirror orientation `N <= types` / `N < types` reads as the same lower +/// bound once the comparator is flipped across its operands. +#[test] +fn mirrored_orientation_normalizes_to_lower_bound() { + let le = detect(&[entry( + static_threshold( + Comparator::LE, + QuantityExpr::Fixed { value: 4 }, + own_graveyard_types(), + ), + 1, + )]); + assert_eq!(le.highest_threshold, Some(4), "4 <= types ⟺ types >= 4"); + + let lt = detect(&[entry( + static_threshold( + Comparator::LT, + QuantityExpr::Fixed { value: 3 }, + own_graveyard_types(), + ), + 1, + )]); + assert_eq!(lt.highest_threshold, Some(4), "3 < types ⟺ types >= 4"); +} + +/// CR 205.2a: negating "N or more types" is a "fewer than N" condition — the +/// opposite of a delirium payoff, so it is not counted. +#[test] +fn negated_threshold_is_not_a_payoff() { + let mut face = creature("Anti-Delirium"); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::Not { + condition: Box::new(StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }), + })]; + let feature = detect(&[entry(face, 1)]); + assert_eq!(feature.threshold_payoff_count, 0); + assert_eq!(feature.highest_threshold, None); +} + +/// CR 109.3: an `And` gates on every constraint, so a delirium conjunct is +/// mandatory and the highest graveyard threshold present is taken. +#[test] +fn and_takes_the_highest_mandatory_threshold() { + let mut face = creature("Conjunctive"); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::And { + conditions: vec![ + StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 6 }, + }, + ], + })]; + assert_eq!(detect(&[entry(face, 1)]).highest_threshold, Some(6)); +} + +/// An `Or` makes a graveyard threshold a mandatory gate ONLY when every branch +/// is a graveyard threshold (then the easiest, minimum branch). A single +/// non-graveyard branch means the payoff can fire without delirium. +#[test] +fn or_is_a_gate_only_when_every_branch_is_graveyard() { + // Every branch is a graveyard threshold → the minimum is the effective gate. + let mut all_gy = creature("All Graveyard Or"); + all_gy.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::Or { + conditions: vec![ + StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 6 }, + }, + ], + })]; + assert_eq!(detect(&[entry(all_gy, 1)]).highest_threshold, Some(4)); + + // One non-graveyard branch → not a mandatory delirium gate. + let mut mixed = creature("Mixed Or"); + mixed.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::Or { + conditions: vec![ + StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + StaticCondition::QuantityComparison { + lhs: opponent_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + ], + })]; + assert_eq!(detect(&[entry(mixed, 1)]).threshold_payoff_count, 0); +} + +/// CR 108.3: an all-graveyards payoff must NOT be read as an own-graveyard plan +/// — the policy would count only the AI's own graveyard, a different quantity. +#[test] +fn all_graveyards_scope_is_not_own_graveyard() { + let all_scope = QuantityExpr::Ref { + qty: QuantityRef::DistinctCardTypes { + source: CardTypeSetSource::Zone { + zone: ZoneRef::Graveyard, + scope: CountScope::All, + }, + }, + }; + let feature = detect(&[entry(threshold_payoff("All Graveyards", 4, all_scope), 4)]); + assert_eq!(feature.threshold_payoff_count, 0); +} + +#[test] +fn detects_self_mill_enabler() { + let feature = detect(&[entry(self_mill_enabler("Stitcher's Supplier"), 4)]); + assert_eq!(feature.enabler_count, 4); +} + +/// Filling an OPPONENT's graveyard does nothing for this deck's threshold. +#[test] +fn opponent_mill_not_an_enabler() { + let mut face = creature("Opponent Mill"); + face.abilities = vec![AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mill { + count: QuantityExpr::Fixed { value: 3 }, + target: TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::Opponent), + ), + destination: Zone::Graveyard, + }, + )]; + let feature = detect(&[entry(face, 4)]); + assert_eq!(feature.enabler_count, 0); +} + +#[test] +fn payoff_names_dedup_per_face() { + let feature = detect(&[entry( + threshold_payoff("Backwoods Survivalists", 4, own_graveyard_types()), + 4, + )]); + assert_eq!( + feature.payoff_names, + vec!["Backwoods Survivalists".to_string()] + ); +} + +/// Calibration anchor: a Modern delirium shell — 8 threshold payoffs + +/// 2 scaling payoffs + 8 enablers over 37 nonland. +#[test] +fn delirium_shell_hits_calibration_floor() { + let deck = vec![ + entry( + threshold_payoff("Backwoods Survivalists", 4, own_graveyard_types()), + 4, + ), + entry(threshold_payoff("Grim Flayer", 4, own_graveyard_types()), 4), + entry(scaling_payoff("Tarmogoyf"), 2), + entry(self_mill_enabler("Stitcher's Supplier"), 4), + entry(self_mill_enabler("Thought Scour"), 4), + entry(creature("Filler"), 19), + ]; + let feature = detect(&deck); + assert_eq!(feature.threshold_payoff_count, 8); + assert_eq!(feature.enabler_count, 8); + assert!( + feature.commitment > 0.85, + "delirium shell must clear 0.85, got {}", + feature.commitment + ); +} + +/// Anti-calibration: an incidental Goyf with no enablers is not this archetype. +#[test] +fn lone_goyf_without_enablers_below_floor() { + let deck = vec![ + entry(scaling_payoff("Tarmogoyf"), 4), + entry(creature("Filler"), 33), + ]; + let feature = detect(&deck); + assert!( + feature.commitment < GRAVEYARD_TYPES_FLOOR, + "a lone Goyf is not a delirium deck, got {}", + feature.commitment + ); +} + +/// Geometric mean: payoffs with zero enablers collapse to 0.0 — the payoff +/// never turns on reliably, so the axis is not this deck's plan. +#[test] +fn payoffs_without_enablers_collapse() { + let deck = vec![ + entry( + threshold_payoff("Backwoods Survivalists", 4, own_graveyard_types()), + 8, + ), + entry(creature("Filler"), 29), + ]; + assert_eq!(detect(&deck).commitment, 0.0); +} + +/// And the mirror: enablers with no payoff are just self-mill. +#[test] +fn enablers_without_payoffs_collapse() { + let deck = vec![ + entry(self_mill_enabler("Thought Scour"), 8), + entry(creature("Filler"), 29), + ]; + assert_eq!(detect(&deck).commitment, 0.0); +} + +#[test] +fn control_deck_below_floor() { + assert_eq!( + detect(&[entry(creature("Counterspell"), 37)]).commitment, + 0.0 + ); +} + +// ─── the three condition/effect carriers (regression guards) ──────────────── + +/// Blocker regression: Stitcher's Supplier is `abilities: []` with mill +/// TRIGGERS. Reading only `abilities` left `enabler_count == 0`, which drives +/// `compute_commitment`'s geometric mean to 0.0 and switches the whole axis off. +#[test] +fn trigger_borne_mill_is_an_enabler() { + let feature = detect(&[entry(trigger_mill_enabler("Stitcher's Supplier"), 4)]); + assert_eq!(feature.enabler_count, 4); +} + +/// The mirror of the above through the real commitment path: a delirium shell +/// whose enablers are ALL trigger-borne must still clear the policy floor. +#[test] +fn trigger_only_enablers_still_clear_the_floor() { + let deck = vec![ + entry( + threshold_payoff("Backwoods Survivalists", 4, own_graveyard_types()), + 8, + ), + entry(trigger_mill_enabler("Stitcher's Supplier"), 8), + entry(creature("Filler"), 21), + ]; + let feature = detect(&deck); + assert_eq!(feature.enabler_count, 8); + assert!( + feature.commitment >= GRAVEYARD_TYPES_FLOOR, + "trigger-borne enablers must not zero the geometric mean, got {}", + feature.commitment + ); +} + +/// CR 701.20e: a rest-to-graveyard dig is an enabler from either carrier. +#[test] +fn dig_to_graveyard_is_an_enabler_from_either_carrier() { + for in_trigger in [true, false] { + let feature = detect(&[entry( + dig_to_graveyard_enabler("Satyr Wayfinder", in_trigger), + 4, + )]); + assert_eq!( + feature.enabler_count, 4, + "dig-to-graveyard must count (in_trigger={in_trigger})" + ); + } +} + +/// A dig whose remainder goes to the bottom of the library deposits nothing. +#[test] +fn dig_without_graveyard_rest_is_not_an_enabler() { + let mut face = dig_to_graveyard_enabler("Impulse", false); + face.abilities = vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::Dig { + player: TargetFilter::Controller, + count: QuantityExpr::Fixed { value: 4 }, + destination: None, + keep_count: Some(1), + keep_count_expr: None, + up_to: false, + filter: TargetFilter::Any, + rest_destination: None, + reveal: false, + enter_tapped: false, + source: DigSource::Library, + }, + )]; + assert_eq!(detect(&[entry(face, 4)]).enabler_count, 0); +} + +/// Blocker regression: the third condition carrier. Traverse the Ulvenwald's +/// gate sits on `abilities[0].sub_ability.condition`, so a top-level-only read +/// (or one that skipped `abilities` entirely) returned `None`. +#[test] +fn threshold_in_the_ability_chain_is_detected() { + let feature = detect(&[entry( + ability_chain_threshold_payoff("Traverse the Ulvenwald", 4), + 4, + )]); + assert_eq!(feature.threshold_payoff_count, 4); + assert_eq!(feature.highest_threshold, Some(4)); +} + +/// The ability-chain carrier feeds the same `.max()` as the other two, so a +/// descend-8 gate there still raises the deck's highest threshold. +#[test] +fn ability_chain_threshold_participates_in_highest_threshold() { + let deck = vec![ + entry( + threshold_payoff("Delirium Four", 4, own_graveyard_types()), + 2, + ), + entry(ability_chain_threshold_payoff("Descend Eight", 8), 2), + ]; + assert_eq!(detect(&deck).highest_threshold, Some(8)); +} + +/// Build an ability-chain payoff whose gate is an arbitrary `AbilityCondition` +/// on the sub-ability — the Traverse carrier, parameterized for the negation and +/// compound cases below. +fn ability_chain_gated_by(name: &str, condition: AbilityCondition) -> CardFace { + let mut sub = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + sub.condition = Some(condition); + let mut root = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + root.sub_ability = Some(Box::new(sub)); + let mut face = creature(name); + face.abilities = vec![root]; + face +} + +fn ability_quantity_check(comparator: Comparator, threshold: i32) -> AbilityCondition { + AbilityCondition::QuantityCheck { + lhs: own_graveyard_types(), + comparator, + rhs: QuantityExpr::Fixed { value: threshold }, + } +} + +/// CR 205.2a: negating "N or more types" is a "fewer than N" gate — the third +/// carrier rejects it exactly like the static and trigger carriers do. +#[test] +fn negated_ability_chain_threshold_is_not_a_payoff() { + let face = ability_chain_gated_by( + "Anti-Delirium Chain", + AbilityCondition::Not { + condition: Box::new(ability_quantity_check(Comparator::GE, 4)), + }, + ); + let feature = detect(&[entry(face, 4)]); + assert_eq!(feature.threshold_payoff_count, 0); + assert_eq!(feature.highest_threshold, None); +} + +/// A non-positive comparator on the ability carrier is rejected too. +#[test] +fn non_positive_comparator_in_the_ability_chain_is_not_a_payoff() { + let face = ability_chain_gated_by( + "Fewer Types Chain", + ability_quantity_check(Comparator::LT, 4), + ); + assert_eq!(detect(&[entry(face, 4)]).threshold_payoff_count, 0); +} + +/// The ability carrier follows the same combinator rules as the other two: +/// `And` is a mandatory gate (take the max), `Or` only when every branch is a +/// graveyard threshold. +#[test] +fn ability_chain_and_or_follow_the_shared_combinator_rules() { + let and_face = ability_chain_gated_by( + "Conjunctive Chain", + AbilityCondition::And { + conditions: vec![ + ability_quantity_check(Comparator::GE, 4), + ability_quantity_check(Comparator::GE, 6), + ], + }, + ); + assert_eq!(detect(&[entry(and_face, 1)]).highest_threshold, Some(6)); + + let all_gy_or = ability_chain_gated_by( + "All-Graveyard Or Chain", + AbilityCondition::Or { + conditions: vec![ + ability_quantity_check(Comparator::GE, 4), + ability_quantity_check(Comparator::GE, 6), + ], + }, + ); + assert_eq!(detect(&[entry(all_gy_or, 1)]).highest_threshold, Some(4)); + + // One branch that delirium does not gate → the payoff can fire without it. + let mixed_or = ability_chain_gated_by( + "Mixed Or Chain", + AbilityCondition::Or { + conditions: vec![ + ability_quantity_check(Comparator::GE, 4), + AbilityCondition::QuantityCheck { + lhs: opponent_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + ], + }, + ); + assert_eq!(detect(&[entry(mixed_or, 1)]).threshold_payoff_count, 0); +} diff --git a/crates/phase-ai/src/features/tests/mod.rs b/crates/phase-ai/src/features/tests/mod.rs index 5e2c7e1e87..c22837884e 100644 --- a/crates/phase-ai/src/features/tests/mod.rs +++ b/crates/phase-ai/src/features/tests/mod.rs @@ -6,7 +6,9 @@ pub mod blink; pub mod enchantments; pub mod energy; pub mod equipment; +pub mod graveyard_types; pub mod lifegain; pub mod mill; pub mod no_name_matching; +pub mod poison; pub mod reanimator; diff --git a/crates/phase-ai/src/features/tests/poison.rs b/crates/phase-ai/src/features/tests/poison.rs new file mode 100644 index 0000000000..b6cc4b242c --- /dev/null +++ b/crates/phase-ai/src/features/tests/poison.rs @@ -0,0 +1,593 @@ +//! Unit tests for `features::poison` — structural detection + calibration +//! anchors for the CR 104.3d poison clock. No `#[cfg(test)]` in SOURCE +//! files; tests live here. + +use engine::game::DeckEntry; +use engine::types::ability::{AbilityDefinition, Effect, TargetFilter}; +use engine::types::card_type::CoreType; +use engine::types::keywords::Keyword; +use engine::types::player::PlayerCounterKind; + +use crate::ability_chain::AbilityScope; +use crate::features::poison::*; +use engine::types::ability::{AbilityKind, ControllerRef, ModalChoice, QuantityExpr, TypedFilter}; +use engine::types::card::CardFace; +use engine::types::card_type::CardType; + +fn creature(name: &str) -> CardFace { + CardFace { + name: name.to_string(), + card_type: CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Creature], + subtypes: Vec::new(), + }, + ..Default::default() + } +} + +fn spell(name: &str) -> CardFace { + CardFace { + name: name.to_string(), + card_type: CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Instant], + subtypes: Vec::new(), + }, + ..Default::default() + } +} + +fn land(name: &str) -> CardFace { + CardFace { + name: name.to_string(), + card_type: CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Land], + subtypes: Vec::new(), + }, + ..Default::default() + } +} + +fn entry(card: CardFace, count: u32) -> DeckEntry { + DeckEntry { card, count } +} + +fn infect_creature(name: &str) -> CardFace { + let mut face = creature(name); + face.keywords = vec![Keyword::Infect]; + face +} + +/// CR 122.1f: gives poison to a player who can be an opponent. +fn poison_spell(name: &str, target: TargetFilter) -> CardFace { + let mut face = spell(name); + face.abilities = vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::GivePlayerCounter { + counter_kind: PlayerCounterKind::Poison, + count: QuantityExpr::Fixed { value: 1 }, + target, + }, + )]; + face +} + +fn proliferate_spell(name: &str) -> CardFace { + let mut face = spell(name); + face.abilities = vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::Proliferate, + )]; + face +} + +#[test] +fn empty_deck_produces_defaults() { + let feature = detect(&[]); + assert_eq!(feature.source_count, 0); + assert_eq!(feature.direct_count, 0); + assert_eq!(feature.proliferate_count, 0); + assert_eq!(feature.commitment, 0.0); +} + +#[test] +fn vanilla_creature_not_registered() { + let feature = detect(&[entry(creature("Grizzly Bears"), 4)]); + assert_eq!(feature.source_count, 0); + assert_eq!(feature.commitment, 0.0); +} + +#[test] +fn detects_infect_toxic_and_poisonous_sources() { + for keyword in [Keyword::Infect, Keyword::Toxic(1), Keyword::Poisonous(2)] { + let mut face = creature("Source"); + face.keywords = vec![keyword.clone()]; + let feature = detect(&[entry(face, 4)]); + assert_eq!( + feature.source_count, 4, + "keyword {keyword:?} must register as a poison source" + ); + } +} + +#[test] +fn detects_direct_poison_effect() { + let feature = detect(&[entry( + poison_spell("Virulent Wound", TargetFilter::Opponent), + 4, + )]); + assert_eq!(feature.direct_count, 4); +} + +#[test] +fn detects_proliferate() { + let feature = detect(&[entry(proliferate_spell("Contagion Clasp"), 2)]); + assert_eq!(feature.proliferate_count, 2); +} + +/// CR 702.164 / 702.90 / 702.70 are combat-damage abilities — a noncreature +/// face carrying the keyword is not a poison clock. +#[test] +fn noncreature_with_infect_does_not_count() { + let mut face = spell("Not A Creature"); + face.keywords = vec![Keyword::Infect]; + let feature = detect(&[entry(face, 4)]); + assert_eq!(feature.source_count, 0); +} + +/// A drawback clause that poisons its OWN controller (Phyrexian Vatmother +/// shape) is not a payoff. +#[test] +fn self_poison_drawback_not_counted() { + let feature = detect(&[entry( + poison_spell("Self Poisoner", TargetFilter::Controller), + 4, + )]); + assert_eq!(feature.direct_count, 0); +} + +/// Counts scale per playset copy, not per unique face. +#[test] +fn counts_scale_by_copy_count() { + let feature = detect(&[entry(infect_creature("Glistener Elf"), 4)]); + assert_eq!(feature.source_count, 4); +} + +/// Calibration anchor: Modern Infect — 12 infect creatures + 2 proliferate over +/// 37 nonland cards → commitment ≈ 0.904. Two-sided: the lower bound is the +/// policy calibration; the upper bound pins the source weight, since +/// `weighted_sum` clamps at 1.0 and a one-sided `> 0.85` would still pass if the +/// source contribution were inflated toward saturation. +#[test] +fn modern_infect_calibration_is_pinned() { + let deck = vec![ + entry(infect_creature("Glistener Elf"), 4), + entry(infect_creature("Blighted Agent"), 4), + entry(infect_creature("Phyrexian Crusader"), 4), + entry(proliferate_spell("Contagion Clasp"), 2), + entry(spell("Pump Spell"), 23), + ]; + let feature = detect(&deck); + assert_eq!(feature.source_count, 12); + assert!( + (0.894..=0.914).contains(&feature.commitment), + "Modern Infect must land at 0.904 ± 0.01, got {}", + feature.commitment + ); +} + +/// Anti-calibration: a superfriends deck running proliferate but no poison +/// source stays at ≈ 0.061 — far below the floor. Two-sided so the assertion +/// pins the proliferate weight rather than leaving 5.7× of headroom to the +/// floor. +#[test] +fn superfriends_proliferate_is_pinned_below_floor() { + let deck = vec![ + entry(proliferate_spell("Contagion Clasp"), 2), + entry(spell("Planeswalker Filler"), 35), + ]; + let feature = detect(&deck); + assert!( + (0.056..=0.066).contains(&feature.commitment), + "proliferate-only must land at 0.061 ± 0.005, got {}", + feature.commitment + ); + assert!(feature.commitment < POISON_CLOCK_FLOOR); +} + +/// Lands are excluded from the commitment denominator — they are not part of +/// the poison plan's nonland payload. This is a deck-modelling choice, not a +/// rules requirement, so it carries no CR citation. Two decks with identical +/// nonland content score identically no matter how many lands pad the manabase, +/// pinning the `CoreType::Land` guard in `detect`, whose removal would inflate +/// the denominator and silently lower every commitment. +#[test] +fn lands_are_excluded_from_commitment_denominator() { + let core = |extra: Vec| { + let mut deck = vec![ + entry(infect_creature("Glistener Elf"), 4), + entry(infect_creature("Blighted Agent"), 4), + entry(infect_creature("Phyrexian Crusader"), 4), + entry(spell("Pump Spell"), 25), + ]; + deck.extend(extra); + detect(&deck).commitment + }; + let landless = core(Vec::new()); + let with_lands = core(vec![entry(land("Inkmoth Nexus"), 20)]); + assert_eq!( + landless, with_lands, + "20 lands must not change commitment; the nonland payload is identical" + ); + assert!(landless > 0.0); +} + +#[test] +fn control_deck_below_floor() { + let deck = vec![entry(spell("Counterspell"), 37)]; + assert_eq!(detect(&deck).commitment, 0.0); +} + +#[test] +fn commitment_clamps_to_one() { + let deck = vec![entry(infect_creature("All Infect"), 37)]; + assert_eq!(detect(&deck).commitment, 1.0); +} + +// ─── negated player scopes (CR 122.1f) ────────────────────────────────────── + +fn poison_effect(target: TargetFilter) -> Effect { + Effect::GivePlayerCounter { + counter_kind: PlayerCounterKind::Poison, + count: QuantityExpr::Fixed { value: 1 }, + target, + } +} + +fn not_filter(inner: TargetFilter) -> TargetFilter { + TargetFilter::Not { + filter: Box::new(inner), + } +} + +fn controlled_by(controller: ControllerRef) -> TargetFilter { + TargetFilter::Typed(TypedFilter { + controller: Some(controller), + ..TypedFilter::default() + }) +} + +/// A negated self-scope ("each player other than you") leaves every opponent +/// unmatched by the inner filter, so it IS opponent poison. +#[test] +fn negated_self_scope_is_opponent_poison() { + for inner in [ + TargetFilter::SelfRef, + TargetFilter::Controller, + controlled_by(ControllerRef::You), + ] { + let feature = detect(&[entry( + poison_spell("Negated Self", not_filter(inner.clone())), + 1, + )]); + assert_eq!( + feature.direct_count, 1, + "Not {{ {inner:?} }} must read as opponent poison" + ); + } +} + +/// The mirror image: negating a scope that already covers every opponent +/// resolves to the controller alone, which is a drawback, not a payoff. +#[test] +fn negated_opponent_scope_is_not_opponent_poison() { + for inner in [ + TargetFilter::Opponent, + TargetFilter::Player, + TargetFilter::Any, + controlled_by(ControllerRef::Opponent), + ] { + let feature = detect(&[entry( + poison_spell("Negated Opponent", not_filter(inner.clone())), + 1, + )]); + assert_eq!( + feature.direct_count, 0, + "Not {{ {inner:?} }} resolves to the controller and must not count" + ); + } +} + +/// Double negation collapses back to the inner scope. +#[test] +fn double_negation_round_trips() { + let poisons_opponents = detect(&[entry( + poison_spell( + "Twice Negated", + not_filter(not_filter(TargetFilter::Opponent)), + ), + 1, + )]); + assert_eq!(poisons_opponents.direct_count, 1); + + let poisons_self = detect(&[entry( + poison_spell( + "Twice Negated Self", + not_filter(not_filter(TargetFilter::Controller)), + ), + 1, + )]); + assert_eq!(poisons_self.direct_count, 0); +} + +// ─── Or / And player scopes ───────────────────────────────────────────────── +// +// `Or`/`And` distribute their quantifier the OPPOSITE way between the two +// predicates: `filter_can_hit_opponent` is existential over `Or` / universal +// over `And` (some/every constraint must admit an opponent), while +// `filter_matches_every_opponent` — reached only through a negated scope — is +// universal over `Or` / existential over `And`. Each fixture below flips +// exactly one branch's outcome if its `.any`/`.all` is swapped, so a mistaken +// inversion in either predicate fails a test rather than passing silently. + +fn or_filter(filters: Vec) -> TargetFilter { + TargetFilter::Or { filters } +} + +fn and_filter(filters: Vec) -> TargetFilter { + TargetFilter::And { filters } +} + +/// `filter_can_hit_opponent` over `Or` is existential: one opponent-admitting +/// branch is enough, and no branch admitting one means it cannot hit. +#[test] +fn or_scope_hits_opponent_iff_some_branch_does() { + // Opponent branch admits an opponent → hits (swapping `.any`→`.all` here + // would drop this, since the Controller branch does not). + let hits = detect(&[entry( + poison_spell( + "Or Hits", + or_filter(vec![TargetFilter::Controller, TargetFilter::Opponent]), + ), + 1, + )]); + assert_eq!(hits.direct_count, 1); + + // No branch admits an opponent → cannot hit. + let misses = detect(&[entry( + poison_spell( + "Or Misses", + or_filter(vec![TargetFilter::Controller, TargetFilter::SelfRef]), + ), + 1, + )]); + assert_eq!(misses.direct_count, 0); +} + +/// `filter_can_hit_opponent` over `And` is universal (CR 109.3): every +/// constraint must admit an opponent, so one self-only constraint blocks it. +#[test] +fn and_scope_hits_opponent_iff_every_branch_does() { + // Both constraints admit an opponent → hits. + let hits = detect(&[entry( + poison_spell( + "And Hits", + and_filter(vec![TargetFilter::Player, TargetFilter::Opponent]), + ), + 1, + )]); + assert_eq!(hits.direct_count, 1); + + // Controller constraint cannot be an opponent → the conjunction cannot + // (swapping `.all`→`.any` here would wrongly count it). + let misses = detect(&[entry( + poison_spell( + "And Misses", + and_filter(vec![TargetFilter::Opponent, TargetFilter::Controller]), + ), + 1, + )]); + assert_eq!(misses.direct_count, 0); +} + +/// `filter_matches_every_opponent` (reached via `Not`) over `Or` is universal: +/// `Not { Or }` hits an opponent only if some `Or` branch leaves one unmatched. +#[test] +fn negated_or_scope_pins_every_opponent_quantifier() { + // `Or { Controller, Opponent }` covers every opponent (the Opponent branch + // does), so its negation resolves to you alone → does not count. Swapping + // the `Or` arm of `filter_matches_every_opponent` to `.all` would make the + // Controller branch drag universality to false → wrongly count. + let feature = detect(&[entry( + poison_spell( + "Not Or", + not_filter(or_filter(vec![ + TargetFilter::Controller, + TargetFilter::Opponent, + ])), + ), + 1, + )]); + assert_eq!(feature.direct_count, 0); +} + +/// `filter_matches_every_opponent` over `And` is existential: `Not { And }` +/// hits an opponent unless every conjunct covers all opponents. +#[test] +fn negated_and_scope_pins_every_opponent_quantifier() { + // `And { Opponent, Controller }` does NOT cover every opponent (the + // Controller conjunct is not universal), so its negation leaves an opponent + // matched → counts. Swapping the `And` arm to `.any` would make the + // Opponent conjunct alone assert universality → wrongly drop it. + let feature = detect(&[entry( + poison_spell( + "Not And", + not_filter(and_filter(vec![ + TargetFilter::Opponent, + TargetFilter::Controller, + ])), + ), + 1, + )]); + assert_eq!(feature.direct_count, 1); +} + +// ─── modal / conditional branches (CR 700.2, CR 608.2c) ───────────────────── + +/// A modal ACTIVATED ability keeps its modes in `mode_abilities`, which the +/// unconditional chain walk does not visit. +fn modal_poison_ability() -> AbilityDefinition { + let mut ability = AbilityDefinition::new( + AbilityKind::Activated, + Effect::GenericEffect { + static_abilities: Vec::new(), + duration: None, + target: None, + }, + ); + ability.modal = Some(ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 2, + ..ModalChoice::default() + }); + ability.mode_abilities = vec![ + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ), + AbilityDefinition::new( + AbilityKind::Activated, + poison_effect(TargetFilter::Opponent), + ), + ]; + ability +} + +/// CR 700.2: deck time asks "can this card ever poison", so a poison-only mode +/// must register — the gap that `AbilityScope::Potential` closes. +#[test] +fn detects_poison_inside_a_modal_ability_mode() { + let mut face = creature("Modal Poisoner"); + face.abilities = vec![modal_poison_ability()]; + assert_eq!(detect(&[entry(face, 3)]).direct_count, 3); +} + +/// CR 601.2b: the same card is NOT unconditional poison — nothing is decided +/// until a mode is chosen, which is the scope the live policy classifies at. +#[test] +fn modal_poison_mode_is_not_unconditional() { + let abilities = vec![modal_poison_ability()]; + assert!(gives_opponents_poison_parts( + &abilities, + AbilityScope::Potential + )); + assert!(!gives_opponents_poison_parts( + &abilities, + AbilityScope::Unconditional + )); +} + +/// CR 608.2c: an "Otherwise, ..." branch is a reachable outcome, so deck-time +/// detection must see poison sitting in it. +#[test] +fn detects_poison_in_an_else_branch() { + let mut ability = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + ability.else_ability = Some(Box::new(AbilityDefinition::new( + AbilityKind::Spell, + poison_effect(TargetFilter::Opponent), + ))); + + let mut face = spell("Else Poisoner"); + face.abilities = vec![ability]; + assert_eq!(detect(&[entry(face, 2)]).direct_count, 2); +} + +/// The proliferate axis walks the same scopes as the direct-poison axis. +#[test] +fn detects_proliferate_inside_a_modal_ability_mode() { + let mut ability = AbilityDefinition::new( + AbilityKind::Activated, + Effect::GenericEffect { + static_abilities: Vec::new(), + duration: None, + target: None, + }, + ); + ability.modal = Some(ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 1, + ..ModalChoice::default() + }); + ability.mode_abilities = vec![AbilityDefinition::new( + AbilityKind::Activated, + Effect::Proliferate, + )]; + + let mut face = creature("Modal Proliferator"); + face.abilities = vec![ability]; + assert_eq!(detect(&[entry(face, 2)]).proliferate_count, 2); +} + +// ─── combat conversion (CR 702.90b / 702.164c / 702.70a) ──────────────────── + +const CREATURE: &[CoreType] = &[CoreType::Creature]; + +/// CR 702.90b: infect converts the damage itself, so the yield is power. +#[test] +fn infect_yields_its_power_in_poison() { + assert_eq!(poison_yield_parts(CREATURE, &[Keyword::Infect], 3), 3); + assert_eq!(poison_yield_parts(CREATURE, &[Keyword::Infect], 0), 0); + // A negative power deals no damage, so it converts nothing. + assert_eq!(poison_yield_parts(CREATURE, &[Keyword::Infect], -2), 0); +} + +/// CR 702.164b: total toxic value is the SUM of every toxic ability's N, and +/// CR 702.164c adds it on top of the damage's other results — so an ordinary +/// (non-infect) creature's power contributes nothing. +#[test] +fn toxic_sums_and_ignores_power() { + assert_eq!(poison_yield_parts(CREATURE, &[Keyword::Toxic(2)], 5), 2); + assert_eq!( + poison_yield_parts(CREATURE, &[Keyword::Toxic(2), Keyword::Toxic(1)], 5), + 3, + "CR 702.164b: total toxic value is the sum of all N" + ); +} + +/// CR 702.70a is a separate triggered ability, and CR 702.164c is "in addition +/// to the damage's other results" — an infect creature with toxic gets both. +#[test] +fn infect_and_toxic_stack() { + assert_eq!( + poison_yield_parts(CREATURE, &[Keyword::Infect, Keyword::Toxic(2)], 3), + 5 + ); + assert_eq!( + poison_yield_parts(CREATURE, &[Keyword::Poisonous(1), Keyword::Infect], 2), + 3 + ); +} + +#[test] +fn non_source_yields_no_poison() { + assert_eq!(poison_yield_parts(CREATURE, &[Keyword::Flying], 7), 0); + // CR 702.90/702.164/702.70 are all combat-damage abilities — a noncreature + // face carrying one converts nothing. + assert_eq!( + poison_yield_parts(&[CoreType::Artifact], &[Keyword::Infect], 7), + 0 + ); +} diff --git a/crates/phase-ai/src/policies/copy_value.rs b/crates/phase-ai/src/policies/copy_value.rs index c99678b1de..9bec353139 100644 --- a/crates/phase-ai/src/policies/copy_value.rs +++ b/crates/phase-ai/src/policies/copy_value.rs @@ -644,6 +644,7 @@ mod tests { source_id: mockingbird_id, valid_targets: vec![small, large], max_mana_value: Some(4), + purpose: engine::types::ability::CopyTargetPurpose::BecomeCopy, }, candidates: Vec::new(), }; diff --git a/crates/phase-ai/src/policies/effect_classify.rs b/crates/phase-ai/src/policies/effect_classify.rs index a2801396b3..c60225f5f3 100644 --- a/crates/phase-ai/src/policies/effect_classify.rs +++ b/crates/phase-ai/src/policies/effect_classify.rs @@ -244,6 +244,7 @@ pub(crate) fn effect_polarity(effect: &Effect) -> EffectPolarity { | Effect::ChooseFromZone { .. } | Effect::ChooseObjectsIntoTrackedSet { .. } | Effect::ChooseOneOf { .. } + | Effect::ChoosePermanent { .. } | Effect::Clash | Effect::Cleanup { .. } | Effect::Cloak { .. } @@ -368,6 +369,9 @@ pub(crate) fn effect_polarity(effect: &Effect) -> EffectPolarity { | Effect::TargetOnly { .. } | Effect::TimeTravel | Effect::Transform { .. } + // CR 710.4: like Transform, flipping swaps a permanent's characteristics + // wholesale — whether the alternative half is better is card-specific. + | Effect::FlipPermanent { .. } | Effect::Tribute { .. } | Effect::TurnFaceDown { .. } | Effect::TurnFaceUp { .. } diff --git a/crates/phase-ai/src/policies/graveyard_types.rs b/crates/phase-ai/src/policies/graveyard_types.rs new file mode 100644 index 0000000000..25742de5c2 --- /dev/null +++ b/crates/phase-ai/src/policies/graveyard_types.rs @@ -0,0 +1,177 @@ +//! `GraveyardTypesPolicy` — makes graveyard card-type diversity a resource the +//! AI can see (delirium / descend / threshold). +//! +//! ## The defect this closes +//! +//! CR 207.2c lists delirium, descend and threshold as **ability words** with no +//! rules meaning — the mechanical content is entirely the underlying "N or more +//! card types among cards in your graveyard" condition (CR 205.2a). 95 cards in +//! the corpus read that quantity, and nothing in the AI modelled the graveyard's +//! type spread. A self-mill that put the fourth distinct card type into the +//! graveyard — switching every delirium payoff on — scored exactly the same as +//! one that put in a redundant fifth creature. +//! +//! ## When the axis stops rewarding +//! +//! A *threshold* payoff (delirium, descend N) goes live at its threshold; once +//! the graveyard reaches it, more diversity buys nothing and the branch scores +//! zero — otherwise the AI would durdle with self-mill after delirium is on. A +//! *scaling* payoff (Consuming Blob) has no threshold and keeps +//! wanting a bigger, more diverse graveyard, so it is rewarded continuously +//! with a diminishing signal. A deck's threshold is modelled as `Option` +//! precisely so a scaling-only deck is never handed a fabricated four-type +//! ceiling. Same no-progress-no-score backbone as `PoisonClockPolicy`. +//! +//! ## Performance +//! +//! `verdict()` runs per candidate per search node, so predicate order matters. +//! The card-local AST check (`fills_own_graveyard_parts`, over the action's +//! authoritative effect chain) runs FIRST and rejects the overwhelming majority +//! of candidates; only a confirmed graveyard-filler pays for the graveyard +//! scan. `GameState` carries no zone index, so that scan filters +//! `state.objects` (house practice) — but it never touches mana affordability +//! or `find_legal_targets`. + +use std::collections::HashSet; + +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::game_state::GameState; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +use crate::features::graveyard_types::{abilities_fill_own_graveyard, GRAVEYARD_TYPES_FLOOR}; +use crate::features::DeckFeatures; + +use super::context::PolicyContext; +use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy}; + +pub struct GraveyardTypesPolicy; + +/// CR 404.1 + CR 205.2a: how many distinct card types sit in this player's +/// graveyard. Uses `owner`, not `controller` — control is a battlefield notion +/// and a card in a graveyard belongs to its owner. +pub(crate) fn distinct_graveyard_types(state: &GameState, player: PlayerId) -> u32 { + let mut seen: HashSet = HashSet::new(); + for object in state.objects.values() { + if object.zone != Zone::Graveyard || object.owner != player { + continue; + } + for core_type in &object.card_types.core_types { + seen.insert(*core_type); + } + } + seen.len() as u32 +} + +impl TacticalPolicy for GraveyardTypesPolicy { + fn id(&self) -> PolicyId { + PolicyId::GraveyardTypes + } + + fn decision_kinds(&self) -> &'static [DecisionKind] { + &[DecisionKind::CastSpell, DecisionKind::ActivateAbility] + } + + fn activation( + &self, + features: &DeckFeatures, + _state: &GameState, + _player: PlayerId, + ) -> Option { + if features.graveyard_types.commitment < GRAVEYARD_TYPES_FLOOR { + None + } else { + Some(features.graveyard_types.commitment) + } + } + + fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { + // Cheapest discriminator first, and it must inspect exactly the effect + // the action performs — via the engine's authoritative ability + // enumeration, not every ability sitting on the object. + if !candidate_fills_own_graveyard(ctx) { + return PolicyVerdict::neutral(PolicyReason::new("graveyard_types_na")); + } + + let feature = ctx + .context + .session + .features + .get(&ctx.ai_player) + .map(|f| &f.graveyard_types); + let threshold = feature.and_then(|f| f.highest_threshold); + let has_scaling = feature.is_some_and(|f| f.scaling_payoff_count > 0); + let current = distinct_graveyard_types(ctx.state, ctx.ai_player); + let scalar = ctx.config.policy_penalties.graveyard_types_progress; + + // Below an unmet threshold: race to switch every delirium payoff on. + // The last missing type is worth far more than the first — it is what + // actually turns the payoffs on. Routes through `PolicyVerdict::score` + // so a tuned-up `scalar` auto-bands instead of tripping a band assert. + if let Some(threshold) = threshold { + if current < threshold { + // `scalar / deficit` already peaks at `scalar` when the + // deficit is 1 — the last missing type is what turns the + // payoffs on, and no separate branch is needed to say so. + let deficit = threshold - current; + let delta = scalar / f64::from(deficit); + return PolicyVerdict::score( + delta, + PolicyReason::new("graveyard_types_progress") + .with_fact("graveyard_types", current as i64) + .with_fact("deficit", deficit as i64), + ); + } + } + + // At/over the threshold, or no threshold at all: only a SCALING payoff + // still wants a bigger, more diverse graveyard. Diminishing (the nth + // type matters less than the first) but never zero, so a Consuming + // Blob deck keeps being rewarded past four types. + if has_scaling { + return PolicyVerdict::score( + scalar / f64::from(current + 1), + PolicyReason::new("graveyard_types_scaling") + .with_fact("graveyard_types", current as i64), + ); + } + + // A threshold-only deck already at its threshold: delirium is on and + // nothing scales, so more diversity buys nothing on this axis. + PolicyVerdict::neutral( + PolicyReason::new("graveyard_types_threshold_met") + .with_fact("graveyard_types", current as i64), + ) + } +} + +/// True when the candidate action ACTUALLY fills the AI's own graveyard. +/// +/// The lookup respects the action's authoritative ability semantics: +/// * `CastSpell` → the spell's own resolution chain (`CastFacts::primary_effects`, +/// the `AbilityKind::Spell` abilities) **plus its immediate ETB triggers** +/// (`CastFacts::immediate_etb_triggers`). CR 601.2 excludes *activated* +/// abilities — casting a permanent that merely has an activated self-mill does +/// not qualify — but an ETB trigger fires as a consequence of the cast, which +/// is exactly why `CastFacts` carries it as its own field. Excluding it made +/// the archetypal delirium play (casting Stitcher's Supplier or Satyr +/// Wayfinder) score `graveyard_types_na`. +/// * `ActivateAbility` → the ability at the engine's runtime-enumerated index +/// (`effective_activated_ability`), which is the correct index space for +/// runtime-granted abilities where `GameObject::abilities` is not (CR 602.2). +fn candidate_fills_own_graveyard(ctx: &PolicyContext<'_>) -> bool { + match &ctx.candidate.action { + GameAction::CastSpell { .. } => ctx.cast_facts().is_some_and(|facts| { + let etb_bodies = facts + .immediate_etb_triggers + .iter() + .filter_map(|trigger| trigger.execute.as_deref()); + abilities_fill_own_graveyard(facts.primary_effects.iter().copied().chain(etb_bodies)) + }), + GameAction::ActivateAbility { .. } => ctx + .effective_activated_ability() + .is_some_and(|ability| abilities_fill_own_graveyard(std::iter::once(&ability))), + _ => false, + } +} diff --git a/crates/phase-ai/src/policies/mod.rs b/crates/phase-ai/src/policies/mod.rs index 237ab920e2..e645221242 100644 --- a/crates/phase-ai/src/policies/mod.rs +++ b/crates/phase-ai/src/policies/mod.rs @@ -22,6 +22,7 @@ mod etb_value; mod evasion_removal_priority; mod fetch_land_patience; mod free_outlet_activation; +mod graveyard_types; pub(crate) mod hand_disruption; mod hold_mana_up; mod interaction_reservation; @@ -38,6 +39,7 @@ mod payment_selection; mod payoff; mod planeswalker_loyalty; mod plus_one_counters; +mod poison; mod ramp_timing; mod reactive_self_protection; mod recursion_awareness; diff --git a/crates/phase-ai/src/policies/planeswalker_loyalty.rs b/crates/phase-ai/src/policies/planeswalker_loyalty.rs index 3cb03c1775..4feafbab8a 100644 --- a/crates/phase-ai/src/policies/planeswalker_loyalty.rs +++ b/crates/phase-ai/src/policies/planeswalker_loyalty.rs @@ -206,6 +206,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], target: None, duration: None, diff --git a/crates/phase-ai/src/policies/poison.rs b/crates/phase-ai/src/policies/poison.rs new file mode 100644 index 0000000000..16ef3a1a51 --- /dev/null +++ b/crates/phase-ai/src/policies/poison.rs @@ -0,0 +1,352 @@ +//! `PoisonClockPolicy` — makes the CR 104.3d poison clock visible to an AI +//! whose evaluation is otherwise life-total-centric. +//! +//! ## The defect this closes +//! +//! CR 104.3d: "If a player has ten or more poison counters, that player loses +//! the game the next time a player would receive priority." That is a win +//! condition entirely independent of life total, tracked in a dedicated engine +//! field (`Player.poison_counters`). Nothing in the AI scored progress along +//! it, so an infect/toxic deck's whole plan registered as doing nothing: a +//! proliferate that takes an opponent from 9 to 10 poison scored the same as +//! one that took them from 0 to 1. +//! +//! ## Rules-correctness note that drives the branch structure +//! +//! CR 701.34a defines proliferate over "permanents and/or players that **have +//! a counter**". Proliferating when no opponent is poisoned adds nothing on +//! this axis — so that branch scores zero rather than a nudge. Getting this +//! backwards would push the AI to durdle with proliferate before the clock has +//! started. +//! +//! ## Where each branch is scored +//! +//! The clock advances through three distinct decisions, and each is scored at +//! the seam where it is actually decided: +//! +//! | Decision | Seam | Rule | +//! |---|---|---| +//! | direct poison / proliferate | `CastSpell` · `ActivateAbility` | CR 122.1f · CR 701.34a | +//! | a modal card's poison mode | `SelectModes` | CR 601.2b | +//! | attacking with a poison source | `DeclareAttackers` | CR 702.90b · 702.164c · 702.70a | +//! +//! CR 601.2b makes mode selection a step of *announcing* a spell, so a +//! `CastSpell` candidate has no chosen mode yet and must not be credited with +//! a mode's poison. The unconditional-vs-modal split is expressed once, in +//! [`crate::ability_chain::AbilityScope`], and shared with deck-time detection. +//! +//! ## Performance +//! +//! `verdict()` runs per candidate per search node. Every predicate here is +//! card-local (`obj.abilities` / `obj.keywords`) or a plain `u32` field read on +//! `Player.poison_counters`; the combat branch is linear in the candidate's own +//! attack list. No board-wide sweep, no affordability call, no +//! `find_legal_targets` — nothing this policy touches is on the documented +//! inner-loop landmine list. + +use engine::game::ability_utils::modal_spell_mode_ability_refs; +use engine::game::combat::AttackTarget; +use engine::types::ability::AbilityDefinition; +use engine::types::actions::GameAction; +use engine::types::game_state::{GameState, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::player::PlayerId; + +use crate::ability_chain::AbilityScope; +use crate::features::poison::{ + gives_opponents_poison_parts, poison_yield_parts, proliferates_parts, LETHAL_POISON, + POISON_CLOCK_FLOOR, +}; +use crate::features::DeckFeatures; + +use super::context::PolicyContext; +use super::registry::{ + DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy, CRITICAL_MAX, STRONG_MAX, +}; + +pub struct PoisonClockPolicy; + +/// What the candidate action contributes to the poison clock. Typed rather +/// than a pair of bools so the branch set stays exhaustive. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PoisonContribution { + /// Adds poison counters to a player who can be an opponent (CR 122.1f). + DirectPoison, + /// Proliferates (CR 701.34a) — only advances the clock on an already + /// poisoned opponent. + Proliferate, + /// CR 508.1: an attack declaration whose poison sources are pointed at a + /// live opponent. `current` is that defender's poison total now, `added` + /// what the declared attackers convert if the damage connects. + CombatDamage { current: u32, added: u32 }, + /// Nothing to do with the poison clock. + None, +} + +impl PoisonClockPolicy { + /// Re-classify the LIVE candidate structurally. Deck-time classification is + /// deliberately not trusted here — the object on the battlefield may have + /// been modified since the deck was analyzed. + fn contribution(&self, ctx: &PolicyContext<'_>) -> PoisonContribution { + match &ctx.candidate.action { + GameAction::CastSpell { object_id, .. } => match ctx.state.objects.get(object_id) { + // CR 601.2b + CR 700.2: a modal spell's poison lives in one of + // its printed modes, and mode selection is a later step of + // announcing the spell. Crediting the cast would score a branch + // the AI may never take — `SelectModes` is where it is decided. + Some(obj) if obj.modal.is_some() => PoisonContribution::None, + Some(obj) => classify_abilities(obj.abilities.iter()), + None => PoisonContribution::None, + }, + GameAction::ActivateAbility { + source_id, + ability_index, + } => ctx + .state + .objects + .get(source_id) + .and_then(|obj| obj.abilities.get(*ability_index)) + .map_or(PoisonContribution::None, |ability| { + classify_abilities(std::slice::from_ref(ability).iter()) + }), + // CR 601.2b: the mode IS chosen here — classify exactly the branch + // the candidate selects, so a poison mode and a non-poison mode of + // the same card score differently. + GameAction::SelectModes { indices } => { + let modes = pending_mode_abilities(ctx.state, &ctx.decision.waiting_for); + let selected: Vec<&AbilityDefinition> = indices + .iter() + .filter_map(|index| modes.get(*index).copied()) + .collect(); + classify_abilities(selected.iter().copied()) + } + GameAction::DeclareAttackers { attacks, .. } => combat_contribution(ctx, attacks), + _ => PoisonContribution::None, + } + } +} + +/// Classify an already-scoped ability set. Direct poison outranks proliferate +/// because it advances the clock without needing an existing counter. +/// +/// The walk is always [`AbilityScope::Unconditional`]: every caller has already +/// resolved which branch this candidate commits to, so a still-unchosen mode +/// must not leak in. +fn classify_abilities<'a, I>(abilities: I) -> PoisonContribution +where + I: IntoIterator + Clone, +{ + if gives_opponents_poison_parts(abilities.clone(), AbilityScope::Unconditional) { + PoisonContribution::DirectPoison + } else if proliferates_parts(abilities, AbilityScope::Unconditional) { + PoisonContribution::Proliferate + } else { + PoisonContribution::None + } +} + +/// CR 700.2: the modes a pending `SelectModes` decision is choosing among. +/// A modal SPELL carries them as the spell-kind abilities of the object being +/// cast (`modal_spell_mode_ability_refs`, the engine's authority, which +/// `handle_select_modes` indexes with the same `indices`); a modal activated or +/// triggered ABILITY carries them on the waiting payload. +fn pending_mode_abilities<'a>( + state: &'a GameState, + waiting_for: &'a WaitingFor, +) -> Vec<&'a AbilityDefinition> { + match waiting_for { + WaitingFor::ModeChoice { pending_cast, .. } => state + .objects + .get(&pending_cast.object_id) + .map(|obj| modal_spell_mode_ability_refs(obj).collect()) + .unwrap_or_default(), + WaitingFor::AbilityModeChoice { mode_abilities, .. } => mode_abilities.iter().collect(), + _ => Vec::new(), + } +} + +/// CR 508.1: score an attack declaration by the poison it converts. +/// +/// CR 702.90b / CR 702.164c / CR 702.70a all key on combat damage dealt **to a +/// player**, so an attack aimed at a planeswalker or a battle adds nothing on +/// this axis. Poison is summed per defending player — several +/// infect creatures attacking the same seat share one clock — and the seat +/// closest to CR 104.3d's ten is the one scored. +fn combat_contribution( + ctx: &PolicyContext<'_>, + attacks: &[(ObjectId, AttackTarget)], +) -> PoisonContribution { + // (defending seat, its poison now, poison this declaration would add). + let mut per_defender: Vec<(PlayerId, u32, u32)> = Vec::new(); + for (attacker_id, target) in attacks { + let AttackTarget::Player(defender) = target else { + continue; + }; + let Some(current) = live_opponent_poison(ctx.state, ctx.ai_player, *defender) else { + continue; + }; + let Some(attacker) = ctx.state.objects.get(attacker_id) else { + continue; + }; + let yielded = poison_yield_parts( + &attacker.card_types.core_types, + &attacker.keywords, + attacker.power.unwrap_or(0), + ); + if yielded == 0 { + continue; + } + match per_defender.iter_mut().find(|(seat, ..)| seat == defender) { + Some((_, _, added)) => *added = added.saturating_add(yielded), + None => per_defender.push((*defender, current, yielded)), + } + } + + per_defender + .into_iter() + .max_by_key(|(_, current, added)| current.saturating_add(*added)) + .map_or(PoisonContribution::None, |(_, current, added)| { + PoisonContribution::CombatDamage { current, added } + }) +} + +/// CR 104.3d: the highest poison total among the AI's LIVE opponents. +/// +/// CR 800.4: a multiplayer game continues after a player leaves, and the +/// eliminated seat stays in `GameState.players` — so a dead player's counters +/// must not be read as pressure the AI is still applying. +pub(crate) fn most_poisoned_opponent(state: &GameState, ai_player: PlayerId) -> u32 { + state + .players + .iter() + .filter(|player| player.id != ai_player && !player.is_eliminated) + .map(|player| player.poison_counters) + .max() + .unwrap_or(0) +} + +/// CR 104.3d + CR 800.4: this seat's poison total, or `None` when it is not a +/// live opponent of `ai_player`. +pub(crate) fn live_opponent_poison( + state: &GameState, + ai_player: PlayerId, + seat: PlayerId, +) -> Option { + state + .players + .iter() + .find(|player| player.id == seat && player.id != ai_player && !player.is_eliminated) + .map(|player| player.poison_counters) +} + +/// CR 104.3d: would `added` more poison counters put this player at ten or +/// more, losing them the game the next time a player would receive priority? +pub(crate) fn reaches_lethal(current_poison: u32, added: u32) -> bool { + current_poison.saturating_add(added) >= LETHAL_POISON +} + +impl TacticalPolicy for PoisonClockPolicy { + fn id(&self) -> PolicyId { + PolicyId::PoisonClock + } + + fn decision_kinds(&self) -> &'static [DecisionKind] { + // `SelectModes` routes through `ActivateAbility` (decision_kind.rs maps + // both `ModeChoice` and `AbilityModeChoice` to that bucket). + &[ + DecisionKind::CastSpell, + DecisionKind::ActivateAbility, + DecisionKind::DeclareAttackers, + ] + } + + fn activation( + &self, + features: &DeckFeatures, + _state: &GameState, + _player: PlayerId, + ) -> Option { + if features.poison.commitment < POISON_CLOCK_FLOOR { + None + } else { + Some(features.poison.commitment) + } + } + + fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { + let scalar = ctx.config.policy_penalties.poison_clock_pressure; + match self.contribution(ctx) { + PoisonContribution::None => { + PolicyVerdict::neutral(PolicyReason::new("poison_clock_na")) + } + // A single counter is the conservative floor: `GivePlayerCounter.count` + // is a `QuantityExpr` that need not be statically known. A resolving + // spell's counter is guaranteed, so its lethal case may reach the + // critical band (`CRITICAL_MAX` ceiling). + PoisonContribution::DirectPoison => score_clock( + scalar, + most_poisoned_opponent(ctx.state, ctx.ai_player), + 1, + CRITICAL_MAX, + ), + PoisonContribution::Proliferate => { + let highest = most_poisoned_opponent(ctx.state, ctx.ai_player); + // CR 701.34a: proliferate chooses among permanents and players + // that ALREADY have a counter. With no poisoned opponent it + // advances nothing, so it earns nothing on this axis. + if highest == 0 { + return PolicyVerdict::neutral(PolicyReason::new( + "poison_clock_no_counters_to_proliferate", + )); + } + score_clock(scalar, highest, 1, CRITICAL_MAX) + } + // CR 509.1a: declared combat damage is not guaranteed — the attack + // can be blocked or prevented — so even a would-be-lethal poison + // swing is held below the critical band (`STRONG_MAX` ceiling) that + // a guaranteed direct poison earns. A committed attacker is still a + // strong play, just not a booked win. + PoisonContribution::CombatDamage { current, added } => { + score_clock(scalar, current, added, STRONG_MAX) + } + } + } +} + +/// Floor on the progress multiplier so the first counters still read as a real +/// play rather than a rounding error — a clock at 1/10 is worth more than +/// one tenth of a clock at 10/10, because it is the only way to reach ten. +const MIN_CLOCK_PROGRESS: f64 = 0.25; + +/// Shared scoring for every branch: reaching CR 104.3d's ten is the top of the +/// scale, otherwise scaled by how far the clock has run — the last counters are +/// worth more than the first. +/// +/// `ceiling` is the highest band this branch may reach: `CRITICAL_MAX` for a +/// guaranteed counter (a resolving direct-poison spell), `STRONG_MAX` for a +/// merely-declared one (a combat swing that can still be blocked). Both +/// magnitudes are state- and config-dependent, so they route through +/// `PolicyVerdict::score`, which selects the band from the clamped value. The +/// sub-lethal case is always held under `STRONG_MAX`, so an advancing-but-not- +/// lethal clock never outranks a booked win. +fn score_clock(scalar: f64, current: u32, added: u32, ceiling: f64) -> PolicyVerdict { + let facts = |reason: PolicyReason| { + reason + .with_fact("opponent_poison", i64::from(current)) + .with_fact("poison_added", i64::from(added)) + }; + + if reaches_lethal(current, added) { + return PolicyVerdict::score( + scalar.min(ceiling), + facts(PolicyReason::new("poison_clock_lethal")), + ); + } + + let projected = current.saturating_add(added); + let progress = f64::from(projected) / f64::from(LETHAL_POISON); + PolicyVerdict::score( + scalar.min(STRONG_MAX) * progress.max(MIN_CLOCK_PROGRESS), + facts(PolicyReason::new("poison_clock_pressure")), + ) +} diff --git a/crates/phase-ai/src/policies/reactive_self_protection.rs b/crates/phase-ai/src/policies/reactive_self_protection.rs index ab4b60de65..f4f0c69134 100644 --- a/crates/phase-ai/src/policies/reactive_self_protection.rs +++ b/crates/phase-ai/src/policies/reactive_self_protection.rs @@ -169,6 +169,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], target, duration: None, @@ -275,6 +276,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], target: None, duration: None, @@ -425,6 +427,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], target: None, duration: None, @@ -510,6 +513,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], target: None, duration: None, diff --git a/crates/phase-ai/src/policies/redundancy_avoidance.rs b/crates/phase-ai/src/policies/redundancy_avoidance.rs index b87847ef36..15595d5f7a 100644 --- a/crates/phase-ai/src/policies/redundancy_avoidance.rs +++ b/crates/phase-ai/src/policies/redundancy_avoidance.rs @@ -23,6 +23,8 @@ //! Shipped predicates (see `redundancy_delta` arms): //! - `Tap` — every candidate target is already tapped. //! - `Untap` — every candidate target is already untapped. +//! - `FlipPermanent` — every candidate target is already flipped (CR 710.4: +//! flipping is one-way, so re-flipping a flipped permanent is a no-op). //! - `Pump` — every candidate target already has an active //! `UntilEndOfTurn` pump from this same source with matching P/T. //! - `GainLife` — controller's life ≥ `LIFE_DIMINISHING_RETURNS`. @@ -147,6 +149,10 @@ const KIND_ADD_COUNTER_ZERO: i64 = 9; /// CR 601.3b: Activating a flash-cast permission (Alchemist's Refuge class) /// when no hand spell would gain instant-speed timing. const KIND_FLASH_CAST_PERMISSION: i64 = 10; +/// CR 710.4: Flipping is a one-way process — once a permanent is flipped it can +/// never become unflipped. Targeting an already-flipped permanent with a flip +/// instruction is therefore a deterministic no-op, unlike two-way `Transform`. +const KIND_FLIP_ALREADY_FLIPPED: i64 = 11; pub struct RedundancyAvoidancePolicy; @@ -358,6 +364,11 @@ fn redundancy_delta( } Some(_) => None, }, + // CR 710.4: flipping is one-way — an already-flipped permanent can never + // become unflipped, so a flip instruction on it is a deterministic no-op. + // Unlike two-way `Transform` (which stays in the no-op list below), this + // admits a target-aware redundancy signal when every candidate is flipped. + Effect::FlipPermanent { target } => flip_redundancy(state, source_id, target), // ----- Variants with no shipped redundancy check ----- // @@ -446,6 +457,9 @@ fn redundancy_delta( | Effect::ExileResolvingSpellInsteadOfGraveyard { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): as-enters copy choice carries no + // "redundant if already controlled" signal for this policy. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::PutCounterAll { .. } @@ -778,6 +792,32 @@ fn tap_redundancy( } } +/// Flip-on-flipped: every candidate match already has `obj.flipped == true`. +/// +/// CR 710.4: flipping is a one-way process — once a permanent is flipped it can +/// never become unflipped. A flip instruction whose entire candidate set is +/// already flipped therefore does nothing, exactly like tap-on-tapped. This is +/// the axis on which `FlipPermanent` diverges from the two-way `Transform`, +/// which stays in the no-op list because re-transforming flips the face back. +fn flip_redundancy( + state: &GameState, + source_id: ObjectId, + target: &TargetFilter, +) -> Option<(f64, i64, i64)> { + let candidates = resolved_candidate_targets(state, source_id, target); + if candidates.is_empty() { + return None; + } + let all_flipped = candidates + .iter() + .all(|id| state.objects.get(id).is_some_and(|o| o.flipped)); + if all_flipped { + Some((-3.0, KIND_FLIP_ALREADY_FLIPPED, candidates.len() as i64)) + } else { + None + } +} + /// Untap-on-untapped: symmetric to `tap_redundancy`. Every candidate match /// is already untapped, so the Untap effect is a no-op on its target set. fn untap_redundancy( @@ -1305,6 +1345,63 @@ mod tests { assert_eq!(delta, 0.0, "untap on tapped should not penalise"); } + #[test] + fn flip_on_flipped_source_penalized() { + // CR 710.4: flipping is one-way, so a flip instruction targeting an + // already-flipped permanent is a deterministic no-op — same -3.0 + // classification as tap-on-tapped. + let mut state = GameState::new_two_player(0); + let obj_id = make_creature_with_ability( + &mut state, + "Kenzo the Hardhearted", + Effect::FlipPermanent { + target: TargetFilter::SelfRef, + }, + ); + state.objects.get_mut(&obj_id).unwrap().flipped = true; + + let config = AiConfig::default(); + let ai_ctx = AiContext::empty(&config.weights); + let decision = priority_decision(); + let candidate = activate_candidate(obj_id); + let ctx = mk_ctx(&state, &decision, &candidate, &config, &ai_ctx); + + let PolicyVerdict::Score { delta, .. } = RedundancyAvoidancePolicy.verdict(&ctx) else { + panic!("expected Score verdict"); + }; + assert_eq!( + delta, -3.0, + "flip on already-flipped should emit -3.0 delta" + ); + } + + #[test] + fn flip_on_unflipped_source_not_penalized() { + // Reach guard: same effect, unflipped target — the flip is meaningful, + // so the redundancy arm must NOT fire (proves the -3.0 above is driven + // by the `flipped` state, not an upstream short-circuit). + let mut state = GameState::new_two_player(0); + let obj_id = make_creature_with_ability( + &mut state, + "Bushi Tenderfoot", + Effect::FlipPermanent { + target: TargetFilter::SelfRef, + }, + ); + // default flipped = false -- flipping is a real state change here + + let config = AiConfig::default(); + let ai_ctx = AiContext::empty(&config.weights); + let decision = priority_decision(); + let candidate = activate_candidate(obj_id); + let ctx = mk_ctx(&state, &decision, &candidate, &config, &ai_ctx); + + let PolicyVerdict::Score { delta, .. } = RedundancyAvoidancePolicy.verdict(&ctx) else { + panic!("expected Score verdict"); + }; + assert_eq!(delta, 0.0, "flip on unflipped must not penalise"); + } + #[test] fn walking_ballista_deal_damage_not_penalized() { // Walking Ballista's ability is "Remove +1/+1 counter → deal 1 damage". diff --git a/crates/phase-ai/src/policies/registry.rs b/crates/phase-ai/src/policies/registry.rs index ab6796c3eb..f36b43e734 100644 --- a/crates/phase-ai/src/policies/registry.rs +++ b/crates/phase-ai/src/policies/registry.rs @@ -16,6 +16,7 @@ use super::etb_value::EtbValuePolicy; use super::evasion_removal_priority::EvasionRemovalPriorityPolicy; use super::fetch_land_patience::FetchLandPatiencePolicy; use super::free_outlet_activation::FreeOutletActivationPolicy; +use super::graveyard_types::GraveyardTypesPolicy; use super::hand_disruption::HandDisruptionPolicy; use super::hold_mana_up::HoldManaUpForInteractionPolicy; use super::interaction_reservation::InteractionReservationPolicy; @@ -29,6 +30,7 @@ use super::payoff::{ EQUIPMENT_PAYOFF, LIFEGAIN_PAYOFF, MILL_PAYOFF, REANIMATOR_PAYOFF, }; use super::plus_one_counters::PlusOneCountersPolicy; +use super::poison::PoisonClockPolicy; use super::ramp_timing::RampTimingPolicy; use super::reactive_self_protection::ReactiveSelfProtectionPolicy; use super::recursion_awareness::RecursionAwarenessPolicy; @@ -129,6 +131,10 @@ pub enum PolicyId { SeparatePilesTiming, XCastGate, LoopShortcut, + /// CR 104.3d: the alternate poison win clock. + PoisonClock, + /// CR 207.2c + CR 205.2a: delirium / descend graveyard type-diversity. + GraveyardTypes, } /// Coarse routing kind for a candidate decision. Each policy declares which @@ -320,6 +326,8 @@ impl Default for PolicyRegistry { Box::new(PayoffPolicy::new(&ARTIFACT_SYNERGY)), Box::new(BoardDevelopmentPolicy), Box::new(EtbValuePolicy), + Box::new(PoisonClockPolicy), + Box::new(GraveyardTypesPolicy), Box::new(PayoffPolicy::new(&ENCHANTMENTS_PAYOFF)), Box::new(PayoffPolicy::new(&EQUIPMENT_PAYOFF)), Box::new(CopyValuePolicy), diff --git a/crates/phase-ai/src/policies/tests/graveyard_types.rs b/crates/phase-ai/src/policies/tests/graveyard_types.rs new file mode 100644 index 0000000000..1eae43e60d --- /dev/null +++ b/crates/phase-ai/src/policies/tests/graveyard_types.rs @@ -0,0 +1,594 @@ +//! Unit tests for `policies::graveyard_types` — the delirium/descend progress +//! policy. No `#[cfg(test)]` in SOURCE files; tests live here. +//! +//! The `verdict` path runs against a real `PolicyContext` built over a +//! two-player `GameState`, mirroring the `energy_payoff` policy-test shape, so +//! the authoritative cast/activated-ability lookup and the graveyard scan are +//! exercised end to end rather than in isolation. + +use std::sync::Arc; + +use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; +use engine::game::zones::create_object; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, Effect, QuantityExpr, TargetFilter, TriggerDefinition, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::game_state::{CastPaymentMode, GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::player::PlayerId; +use engine::types::triggers::TriggerMode; +use engine::types::zones::Zone; + +use crate::config::AiConfig; +use crate::context::AiContext; +use crate::features::graveyard_types::{GraveyardTypesFeature, GRAVEYARD_TYPES_FLOOR}; +use crate::features::DeckFeatures; +use crate::policies::context::{PolicyContext, SearchDepth}; +use crate::policies::graveyard_types::*; +use crate::policies::registry::{PolicyReason, PolicyVerdict, TacticalPolicy}; +use crate::session::AiSession; + +const AI: PlayerId = PlayerId(0); + +// ─── fixtures ─────────────────────────────────────────────────────────────── + +fn config() -> AiConfig { + AiConfig::default() +} + +/// An `AiContext` whose cached graveyard feature carries the given threshold and +/// scaling posture, so `verdict` reads them the way it would in a real game. +fn context_with( + config: &AiConfig, + highest_threshold: Option, + scaling_payoff_count: u32, +) -> AiContext { + let features = DeckFeatures { + graveyard_types: GraveyardTypesFeature { + threshold_payoff_count: highest_threshold.map_or(0, |_| 8), + scaling_payoff_count, + enabler_count: 8, + highest_threshold, + commitment: 0.9, + payoff_names: Vec::new(), + }, + ..Default::default() + }; + let mut session = AiSession::empty(); + session.features.insert(AI, features); + let mut context = AiContext::empty(&config.weights); + context.session = Arc::new(session); + context.player = AI; + context +} + +fn priority_decision() -> AiDecisionContext { + AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: Vec::new(), + } +} + +fn ctx<'a>( + state: &'a GameState, + candidate: &'a CandidateAction, + decision: &'a AiDecisionContext, + context: &'a AiContext, + config: &'a AiConfig, +) -> PolicyContext<'a> { + PolicyContext { + state, + decision, + candidate, + ai_player: AI, + config, + context, + cast_facts: None, + search_depth: SearchDepth::Root, + } +} + +fn self_mill_effect() -> Effect { + Effect::Mill { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + destination: Zone::Graveyard, + } +} + +fn draw_effect() -> Effect { + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + } +} + +/// Put `count` objects with distinct core types into the AI's graveyard so +/// `distinct_graveyard_types` reports exactly `count`. +fn seed_graveyard_types(state: &mut GameState, count: usize) { + const TYPES: [CoreType; 6] = [ + CoreType::Creature, + CoreType::Instant, + CoreType::Sorcery, + CoreType::Artifact, + CoreType::Enchantment, + CoreType::Land, + ]; + for (i, core) in TYPES.iter().take(count).enumerate() { + let oid = create_object( + state, + CardId(1000 + i as u64), + AI, + format!("GY {i}"), + Zone::Graveyard, + ); + state.objects.get_mut(&oid).unwrap().card_types = CardType { + supertypes: Vec::new(), + core_types: vec![*core], + subtypes: Vec::new(), + }; + } +} + +/// A hand object whose SPELL resolution mills the controller (Thought Scour +/// shape) — casting it fills the graveyard. `card_id` is aligned to the object +/// id so `cast_candidate` resolves it through `cast_facts`. +fn mill_spell(state: &mut GameState, idx: u64) -> ObjectId { + let oid = create_object( + state, + CardId(idx), + AI, + format!("Mill Spell {idx}"), + Zone::Hand, + ); + let object = state.objects.get_mut(&oid).unwrap(); + object.card_id = CardId(oid.0); + object.card_types = CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Instant], + subtypes: Vec::new(), + }; + *Arc::make_mut(&mut object.abilities) = vec![AbilityDefinition::new( + AbilityKind::Spell, + self_mill_effect(), + )]; + oid +} + +/// A hand object that is a CREATURE with an ACTIVATED self-mill ability and no +/// spell-resolution mill — casting it fills no graveyard; activating it does. +fn creature_with_activated_mill(state: &mut GameState, idx: u64) -> ObjectId { + let oid = create_object( + state, + CardId(idx), + AI, + format!("Filler Body {idx}"), + Zone::Hand, + ); + let object = state.objects.get_mut(&oid).unwrap(); + object.card_id = CardId(oid.0); + object.card_types = CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Creature], + subtypes: Vec::new(), + }; + *Arc::make_mut(&mut object.abilities) = vec![AbilityDefinition::new( + AbilityKind::Activated, + self_mill_effect(), + )]; + oid +} + +fn cast_candidate(object_id: ObjectId) -> CandidateAction { + CandidateAction { + action: GameAction::CastSpell { + object_id, + card_id: CardId(object_id.0), + targets: Vec::new(), + payment_mode: CastPaymentMode::default(), + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Spell), + } +} + +fn activate_candidate(source_id: ObjectId, ability_index: usize) -> CandidateAction { + CandidateAction { + action: GameAction::ActivateAbility { + source_id, + ability_index, + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Ability), + } +} + +fn score_of(verdict: PolicyVerdict) -> (f64, PolicyReason) { + match verdict { + PolicyVerdict::Score { delta, reason } => (delta, reason), + PolicyVerdict::Reject { reason } => panic!("unexpected Reject: {reason:?}"), + } +} + +// Object ids for hand fixtures must match `cast_candidate`'s `card_id == +// object_id.0`, so keep them clear of the 1000+ graveyard-seed ids. +fn state() -> GameState { + GameState::new_two_player(42) +} + +// ─── activation + helpers ─────────────────────────────────────────────────── + +#[test] +fn activation_opts_out_below_floor() { + let mut features = DeckFeatures::default(); + features.graveyard_types.commitment = GRAVEYARD_TYPES_FLOOR - 0.01; + assert!(GraveyardTypesPolicy + .activation(&features, &state(), AI) + .is_none()); +} + +#[test] +fn activation_opts_in_above_floor() { + let mut features = DeckFeatures::default(); + features.graveyard_types.commitment = 0.9; + assert_eq!( + GraveyardTypesPolicy.activation(&features, &state(), AI), + Some(0.9) + ); +} + +/// CR 404.1: an empty graveyard has zero distinct card types. +#[test] +fn empty_graveyard_counts_zero_types() { + assert_eq!(distinct_graveyard_types(&state(), AI), 0); +} + +#[test] +fn distinct_graveyard_types_counts_each_core_type_once() { + let mut state = state(); + seed_graveyard_types(&mut state, 3); + assert_eq!(distinct_graveyard_types(&state, AI), 3); +} + +// ─── verdict: threshold race (CR 205.2a) ──────────────────────────────────── + +/// One type short of the threshold, casting a self-mill is the strongest play — +/// it can switch every delirium payoff on. +#[test] +fn verdict_rewards_the_last_missing_type_strongly() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); // deficit == 1 + let spell = mill_spell(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_progress"); + assert!( + delta > crate::policies::registry::PREFERENCE_MAX, + "the last missing type is a strong play, got {delta}" + ); +} + +/// Farther from the threshold, the same self-mill is worth less per point. +#[test] +fn verdict_scales_progress_by_deficit() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let decision = priority_decision(); + + let score_at = |types: usize| { + let mut state = state(); + seed_graveyard_types(&mut state, types); + let spell = mill_spell(&mut state, 1); + score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))) + .0 + }; + + // deficit 3 (1 type) < deficit 1 (3 types): closer to the threshold scores + // higher. + assert!(score_at(3) > score_at(1)); + assert!(score_at(1) > 0.0); +} + +/// At or over the threshold with no scaling payoff, delirium is on and more +/// diversity buys nothing. +#[test] +fn verdict_is_neutral_once_threshold_met_without_scaling() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 4); + let spell = mill_spell(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_threshold_met"); + assert_eq!(delta, 0.0); +} + +// ─── verdict: scaling-only continuation (the item-2 fix) ──────────────────── + +/// A scaling-only deck (no threshold) must keep receiving a progress signal +/// ABOVE four types — the payoff continues to scale, so the old invented +/// four-type ceiling was wrong. +#[test] +fn verdict_still_rewards_scaling_only_deck_above_four_types() { + let config = config(); + let context = context_with(&config, None, 4); // no threshold, has scaling + let mut state = state(); + seed_graveyard_types(&mut state, 5); // above the old ceiling of 4 + let spell = mill_spell(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_scaling"); + assert!( + delta > 0.0, + "a scaling payoff still wants more types, got {delta}" + ); +} + +/// The scaling reward diminishes as the graveyard grows — the sixth type is +/// worth less than the second. +#[test] +fn verdict_scaling_reward_diminishes() { + let config = config(); + let context = context_with(&config, None, 4); + let decision = priority_decision(); + + let score_at = |types: usize| { + let mut state = state(); + seed_graveyard_types(&mut state, types); + let spell = mill_spell(&mut state, 1); + score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))) + .0 + }; + assert!(score_at(1) > score_at(5)); + assert!(score_at(5) > 0.0); +} + +/// A mixed deck past its threshold still rewards diversity for the scaling half. +#[test] +fn verdict_mixed_deck_keeps_scaling_past_threshold() { + let config = config(); + let context = context_with(&config, Some(4), 2); // both threshold and scaling + let mut state = state(); + seed_graveyard_types(&mut state, 5); // past the threshold + let spell = mill_spell(&mut state, 1); + let decision = priority_decision(); + + let (_, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_scaling"); +} + +// ─── verdict: authoritative cast/activate semantics (the item-5 fix) ───────── + +/// CR 601.2: casting a permanent that merely HAS an activated self-mill ability +/// fills no graveyard — the cast's own resolution does nothing here. +#[test] +fn verdict_ignores_cast_of_a_body_with_an_activated_mill() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let body = creature_with_activated_mill(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(body), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_na"); + assert_eq!(delta, 0.0); +} + +/// But ACTIVATING that same body's self-mill ability is credited, resolved +/// through the engine's enumerated ability index. +#[test] +fn verdict_credits_the_activated_mill_ability() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let body = creature_with_activated_mill(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &activate_candidate(body, 0), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_progress"); + assert!(delta > 0.0); +} + +/// A spell whose OWN resolution mills is credited on cast. +#[test] +fn verdict_credits_a_spell_that_mills_on_resolution() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let spell = mill_spell(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_progress"); + assert!(delta > 0.0); +} + +/// A spell that does not touch the graveyard is neutral. +#[test] +fn verdict_ignores_a_non_filling_spell() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let oid = create_object( + &mut state, + CardId(1), + AI, + "Draw Spell".to_string(), + Zone::Hand, + ); + { + let object = state.objects.get_mut(&oid).unwrap(); + object.card_id = CardId(oid.0); + object.card_types = CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Instant], + subtypes: Vec::new(), + }; + *Arc::make_mut(&mut object.abilities) = + vec![AbilityDefinition::new(AbilityKind::Spell, draw_effect())]; + } + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(oid), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_na"); + assert_eq!(delta, 0.0); +} + +// ─── ETB-trigger casts (the delirium play the gate used to drop) ───────────── + +/// Stitcher's Supplier shape: a creature whose self-mill rides an ETB TRIGGER, +/// not a spell ability. `CastFacts` carries it as `immediate_etb_triggers` +/// precisely because it fires as a consequence of the cast. +fn etb_mill_creature(state: &mut GameState, idx: u64) -> ObjectId { + let oid = create_object( + state, + CardId(idx), + AI, + format!("Stitcher {idx}"), + Zone::Hand, + ); + let object = state.objects.get_mut(&oid).unwrap(); + object.card_id = CardId(oid.0); + object.card_types = CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Creature], + subtypes: Vec::new(), + }; + *Arc::make_mut(&mut object.abilities) = Vec::new(); + object.trigger_definitions.push( + TriggerDefinition::new(TriggerMode::ChangesZone) + .valid_card(TargetFilter::SelfRef) + .destination(Zone::Battlefield) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + self_mill_effect(), + )), + ); + oid +} + +/// Casting the archetypal delirium enabler must score. Excluding ETB triggers +/// from the cast gate made this exact play return `graveyard_types_na`. +#[test] +fn verdict_credits_a_cast_whose_etb_trigger_mills() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let stitcher = etb_mill_creature(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(stitcher), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_progress"); + assert!(delta > 0.0); +} + +/// Control: an ETB trigger that does NOT fill the graveyard stays neutral, so +/// the branch discriminates on the trigger body rather than on "has any ETB". +#[test] +fn verdict_ignores_a_cast_whose_etb_trigger_does_not_fill() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let oid = etb_mill_creature(&mut state, 1); + state.objects.get_mut(&oid).unwrap().trigger_definitions = Default::default(); + state + .objects + .get_mut(&oid) + .unwrap() + .trigger_definitions + .push( + TriggerDefinition::new(TriggerMode::ChangesZone) + .valid_card(TargetFilter::SelfRef) + .destination(Zone::Battlefield) + .execute(AbilityDefinition::new(AbilityKind::Spell, draw_effect())), + ); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(oid), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_na"); + assert_eq!(delta, 0.0); +} diff --git a/crates/phase-ai/src/policies/tests/mod.rs b/crates/phase-ai/src/policies/tests/mod.rs index 46f9fc14d2..ecaaa98afb 100644 --- a/crates/phase-ai/src/policies/tests/mod.rs +++ b/crates/phase-ai/src/policies/tests/mod.rs @@ -7,8 +7,10 @@ pub mod effect_classify_snapshot; pub mod enchantments_payoff; pub mod energy_payoff; pub mod equipment_payoff; +pub mod graveyard_types; pub mod lifegain_payoff; pub mod mill_payoff; pub mod mulligan_input_lint; +pub mod poison; pub mod reanimator_payoff; pub mod score_contract_lint; diff --git a/crates/phase-ai/src/policies/tests/poison.rs b/crates/phase-ai/src/policies/tests/poison.rs new file mode 100644 index 0000000000..bed28ebccc --- /dev/null +++ b/crates/phase-ai/src/policies/tests/poison.rs @@ -0,0 +1,1070 @@ +//! Unit tests for `policies::poison` — the CR 104.3d poison-clock policy. +//! No `#[cfg(test)]` in SOURCE files; tests live here. +//! +//! The `verdict` path runs against a real `PolicyContext` built over a +//! multiplayer `GameState`, mirroring the `energy_payoff` policy-test shape. + +use std::sync::Arc; + +use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; +use engine::game::combat::AttackTarget; +use engine::game::zones::create_object; +use engine::types::ability::ResolvedAbility; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, Effect, ModalChoice, QuantityExpr, TargetFilter, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::format::FormatConfig; +use engine::types::game_state::{CastPaymentMode, GameState, PendingCast, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::keywords::Keyword; +use engine::types::mana::ManaCost; +use engine::types::player::{PlayerCounterKind, PlayerId}; +use engine::types::zones::Zone; + +use crate::config::AiConfig; +use crate::context::AiContext; +use crate::features::poison::{LETHAL_POISON, POISON_CLOCK_FLOOR}; +use crate::features::DeckFeatures; +use crate::policies::context::{PolicyContext, SearchDepth}; +use crate::policies::registry::{ + PolicyId, PolicyReason, PolicyRegistry, PolicyVerdict, TacticalPolicy, +}; +use crate::session::AiSession; + +use crate::policies::poison::*; + +const AI: PlayerId = PlayerId(0); +const OPPONENT: PlayerId = PlayerId(1); + +// ─── fixtures ─────────────────────────────────────────────────────────────── + +fn state_with_players(count: u8) -> GameState { + GameState::new(FormatConfig::standard(), count, 42) +} + +fn config() -> AiConfig { + AiConfig::default() +} + +fn ai_context(config: &AiConfig) -> AiContext { + let mut context = AiContext::empty(&config.weights); + context.player = AI; + context +} + +fn priority_decision() -> AiDecisionContext { + AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: Vec::new(), + } +} + +fn ctx<'a>( + state: &'a GameState, + candidate: &'a CandidateAction, + decision: &'a AiDecisionContext, + context: &'a AiContext, + config: &'a AiConfig, +) -> PolicyContext<'a> { + PolicyContext { + state, + decision, + candidate, + ai_player: AI, + config, + context, + cast_facts: None, + search_depth: SearchDepth::Root, + } +} + +fn poison_effect(target: TargetFilter) -> Effect { + Effect::GivePlayerCounter { + counter_kind: PlayerCounterKind::Poison, + count: QuantityExpr::Fixed { value: 1 }, + target, + } +} + +fn draw_effect() -> Effect { + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + } +} + +/// A spell object in hand carrying `abilities`. +fn spell_object(state: &mut GameState, idx: u64, abilities: Vec) -> ObjectId { + let oid = create_object(state, CardId(idx), AI, format!("Spell {idx}"), Zone::Hand); + let object = state.objects.get_mut(&oid).unwrap(); + object.card_types = CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Instant], + subtypes: Vec::new(), + }; + *Arc::make_mut(&mut object.abilities) = abilities; + oid +} + +/// A battlefield creature with `keywords` and `power`. +fn creature_object( + state: &mut GameState, + idx: u64, + keywords: Vec, + power: i32, +) -> ObjectId { + let oid = create_object( + state, + CardId(idx), + AI, + format!("Creature {idx}"), + Zone::Battlefield, + ); + let object = state.objects.get_mut(&oid).unwrap(); + object.card_types = CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Creature], + subtypes: Vec::new(), + }; + object.keywords = keywords; + object.power = Some(power); + object.toughness = Some(power.max(1)); + oid +} + +fn cast_candidate(object_id: ObjectId) -> CandidateAction { + CandidateAction { + action: GameAction::CastSpell { + object_id, + card_id: CardId(object_id.0), + targets: Vec::new(), + payment_mode: CastPaymentMode::default(), + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Spell), + } +} + +fn attack_candidate(attacks: Vec<(ObjectId, AttackTarget)>) -> CandidateAction { + CandidateAction { + action: GameAction::DeclareAttackers { + attacks, + bands: Vec::new(), + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Attack), + } +} + +fn select_modes_candidate(indices: Vec) -> CandidateAction { + CandidateAction { + action: GameAction::SelectModes { indices }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Selection), + } +} + +fn activate_ability_candidate(source_id: ObjectId, ability_index: usize) -> CandidateAction { + CandidateAction { + action: GameAction::ActivateAbility { + source_id, + ability_index, + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Ability), + } +} + +/// Unwrap a `Score` verdict into `(delta, reason)`; fail on `Reject`. +fn score_of(verdict: PolicyVerdict) -> (f64, PolicyReason) { + match verdict { + PolicyVerdict::Score { delta, reason } => (delta, reason), + PolicyVerdict::Reject { reason } => panic!("unexpected Reject: {reason:?}"), + } +} + +// ─── helper boundaries ────────────────────────────────────────────────────── + +/// CR 104.3d: ten or more poison counters loses the game, so the ninth +/// counter is the lethal setup and the eighth is not. +#[test] +fn reaches_lethal_matches_cr_104_3d_boundary() { + assert_eq!(LETHAL_POISON, 10); + assert!(reaches_lethal(9, 1), "9 + 1 == 10 is lethal"); + assert!(!reaches_lethal(8, 1), "8 + 1 == 9 is not yet lethal"); + assert!(reaches_lethal(4, 6), "a multi-counter swing can reach ten"); + assert!( + !reaches_lethal(9, 0), + "nine with nothing added is not lethal" + ); + assert!(reaches_lethal(u32::MAX, 1), "saturating add must not wrap"); +} + +#[test] +fn activation_opts_out_below_floor() { + let mut features = DeckFeatures::default(); + features.poison.commitment = POISON_CLOCK_FLOOR - 0.01; + let state = state_with_players(2); + assert!(PoisonClockPolicy + .activation(&features, &state, AI) + .is_none()); +} + +#[test] +fn activation_opts_in_above_floor() { + let mut features = DeckFeatures::default(); + features.poison.commitment = 0.9; + let state = state_with_players(2); + assert_eq!( + PoisonClockPolicy.activation(&features, &state, AI), + Some(0.9) + ); +} + +#[test] +fn most_poisoned_opponent_ignores_the_ai_itself() { + let mut state = state_with_players(2); + state.players[0].poison_counters = 7; + state.players[1].poison_counters = 3; + // The AI's own 7 poison must not be read as pressure it is applying. + assert_eq!(most_poisoned_opponent(&state, AI), 3); + assert_eq!(most_poisoned_opponent(&state, OPPONENT), 7); +} + +/// CR 800.4: a multiplayer game continues after a player leaves, and the +/// eliminated seat stays in +/// `GameState.players`. Their counters must not produce phantom pressure. +#[test] +fn most_poisoned_opponent_ignores_eliminated_seats() { + let mut state = state_with_players(4); + state.players[1].poison_counters = 9; + state.players[1].is_eliminated = true; + state.players[2].poison_counters = 2; + state.players[3].poison_counters = 0; + + assert_eq!( + most_poisoned_opponent(&state, AI), + 2, + "a dead seat at 9 poison must not read as one counter from lethal" + ); + assert_eq!(live_opponent_poison(&state, AI, PlayerId(1)), None); + assert_eq!(live_opponent_poison(&state, AI, PlayerId(2)), Some(2)); + assert_eq!( + live_opponent_poison(&state, AI, AI), + None, + "not an opponent" + ); +} + +// ─── verdict: direct poison and proliferate ───────────────────────────────── + +/// CR 104.3d: an opponent at nine is one counter from losing, so a direct +/// poison spell is a game-ending play, not a value play. +#[test] +fn verdict_scores_lethal_direct_poison_as_critical() { + let config = config(); + let context = ai_context(&config); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + let spell = spell_object( + &mut state, + 1, + vec![AbilityDefinition::new( + AbilityKind::Spell, + poison_effect(TargetFilter::Opponent), + )], + ); + + let candidate = cast_candidate(spell); + let decision = priority_decision(); + let (delta, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "poison_clock_lethal"); + assert_eq!(delta, config.policy_penalties.poison_clock_pressure); + assert!( + delta > crate::policies::registry::STRONG_MAX, + "a game-ending play must land in the critical band" + ); +} + +/// Below lethal the value scales with how far the clock has already run — the +/// last counters are worth more than the first. +/// +/// Swept across every reachable clock value rather than sampled, because the +/// delta is state-dependent: a magnitude that escapes its declared band trips +/// `score_in_band`'s debug assert and silently clamps in release, flattening +/// the very progress signal this policy exists to provide. The sub-lethal +/// ceiling must also stay under `STRONG_MAX`, so that only an action reaching +/// CR 104.3d's ten can outrank one that does. +#[test] +fn verdict_scales_direct_poison_by_clock_progress() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + + let score_at = |existing: u32| { + let mut state = state_with_players(2); + state.players[1].poison_counters = existing; + let spell = spell_object( + &mut state, + 1, + vec![AbilityDefinition::new( + AbilityKind::Spell, + poison_effect(TargetFilter::Opponent), + )], + ); + let candidate = cast_candidate(spell); + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))) + }; + + let mut previous = 0.0; + for existing in 0..LETHAL_POISON - 1 { + let (delta, reason) = score_at(existing); + assert_eq!(reason.kind, "poison_clock_pressure", "at {existing} poison"); + assert!( + delta > crate::policies::registry::NUDGE_MAX, + "advancing the clock is never a mere nudge (at {existing}): {delta}" + ); + assert!( + delta <= crate::policies::registry::STRONG_MAX, + "a non-lethal advance must stay out of the critical band (at {existing}): {delta}" + ); + assert!( + delta >= previous, + "score must not decrease as the clock advances (at {existing}): {delta} < {previous}" + ); + previous = delta; + } + + let (lethal_delta, lethal_reason) = score_at(LETHAL_POISON - 1); + assert_eq!(lethal_reason.kind, "poison_clock_lethal"); + assert!( + lethal_delta > previous, + "reaching ten must outrank every non-lethal advance: {lethal_delta} vs {previous}" + ); +} + +/// `MIN_CLOCK_PROGRESS` floors the earliest counters onto a flat plateau: at 0 +/// and 1 existing poison the projected progress (0.1, 0.2) is below the floor, +/// so both score the SAME delta. Deleting the floor would split them (0.50 vs +/// 1.00), so the equality — not just a magnitude — is what pins the constant. +#[test] +fn verdict_floors_early_clock_progress_onto_a_plateau() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + + let score_at = |existing: u32| { + let mut state = state_with_players(2); + state.players[1].poison_counters = existing; + let spell = spell_object( + &mut state, + 1, + vec![AbilityDefinition::new( + AbilityKind::Spell, + poison_effect(TargetFilter::Opponent), + )], + ); + let candidate = cast_candidate(spell); + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))) + .0 + }; + + let at_zero = score_at(0); + let at_one = score_at(1); + assert_eq!( + at_zero, at_one, + "0 and 1 existing poison both sit on the MIN_CLOCK_PROGRESS floor" + ); + // STRONG_MAX (the sub-lethal ceiling) × the 0.25 floor. + assert!( + (at_zero - crate::policies::registry::STRONG_MAX * 0.25).abs() < 1e-9, + "plateau delta must be STRONG_MAX × 0.25, got {at_zero}" + ); + // And the plateau ends: 2 existing poison (progress 0.3) clears the floor. + assert!( + score_at(2) > at_one, + "the third counter must clear the floor and score higher" + ); +} + +/// CR 701.34a: proliferate chooses among permanents and players that ALREADY +/// have a counter. With no poisoned opponent it advances nothing. +#[test] +fn verdict_declines_proliferate_with_no_poisoned_opponent() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(2); + let spell = spell_object( + &mut state, + 1, + vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::Proliferate, + )], + ); + + let candidate = cast_candidate(spell); + let (delta, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_no_counters_to_proliferate"); + assert_eq!(delta, 0.0); +} + +/// The same proliferate becomes real value once the clock has started. +#[test] +fn verdict_rewards_proliferate_on_a_poisoned_opponent() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(2); + state.players[1].poison_counters = 4; + let spell = spell_object( + &mut state, + 1, + vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::Proliferate, + )], + ); + + let candidate = cast_candidate(spell); + let (delta, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_pressure"); + assert!(delta > 0.0); +} + +#[test] +fn verdict_is_neutral_for_an_unrelated_spell() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + let spell = spell_object( + &mut state, + 1, + vec![AbilityDefinition::new(AbilityKind::Spell, draw_effect())], + ); + + let candidate = cast_candidate(spell); + let (delta, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_na"); + assert_eq!(delta, 0.0); +} + +// ─── verdict: the modal seam (CR 601.2b / CR 700.2) ───────────────────────── + +/// Two-mode activated ability: mode 0 draws, mode 1 poisons. +fn modal_ability() -> AbilityDefinition { + let mut ability = AbilityDefinition::new( + AbilityKind::Activated, + Effect::GenericEffect { + static_abilities: Vec::new(), + duration: None, + target: None, + }, + ); + ability.modal = Some(ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 2, + ..ModalChoice::default() + }); + ability.mode_abilities = vec![ + AbilityDefinition::new(AbilityKind::Activated, draw_effect()), + AbilityDefinition::new( + AbilityKind::Activated, + poison_effect(TargetFilter::Opponent), + ), + ]; + ability +} + +fn ability_mode_decision(source_id: ObjectId) -> AiDecisionContext { + let ability = modal_ability(); + AiDecisionContext { + waiting_for: WaitingFor::AbilityModeChoice { + player: AI, + modal: ability.modal.clone().unwrap(), + source_id, + mode_abilities: ability.mode_abilities.clone(), + is_activated: true, + ability_index: Some(0), + ability_cost: None, + unavailable_modes: Vec::new(), + }, + candidates: Vec::new(), + } +} + +/// CR 601.2b: the poison mode is scored at the seam where it is chosen. +#[test] +fn verdict_scores_the_selected_poison_mode() { + let config = config(); + let context = ai_context(&config); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + let source = creature_object(&mut state, 1, Vec::new(), 1); + let decision = ability_mode_decision(source); + + let candidate = select_modes_candidate(vec![1]); + let (_, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_lethal"); +} + +/// The non-poison mode of the SAME ability must score nothing — the modes are +/// discriminated, not the card. +#[test] +fn verdict_ignores_a_selected_non_poison_mode() { + let config = config(); + let context = ai_context(&config); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + let source = creature_object(&mut state, 1, Vec::new(), 1); + let decision = ability_mode_decision(source); + + let candidate = select_modes_candidate(vec![0]); + let (delta, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_na"); + assert_eq!(delta, 0.0); +} + +/// CR 601.2b: announcing a modal spell chooses no mode yet, so the cast must +/// not be credited with a mode's poison — that would score a branch the AI may +/// never take. +#[test] +fn verdict_does_not_credit_a_modal_cast_before_mode_selection() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + + // A modal spell's printed modes are separate spell-kind abilities on the + // object (engine `modal_spell_mode_abilities`), one of which poisons. + let spell = spell_object( + &mut state, + 1, + vec![ + AbilityDefinition::new(AbilityKind::Spell, draw_effect()), + AbilityDefinition::new(AbilityKind::Spell, poison_effect(TargetFilter::Opponent)), + ], + ); + state.objects.get_mut(&spell).unwrap().modal = Some(ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 2, + ..ModalChoice::default() + }); + + let candidate = cast_candidate(spell); + let (delta, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_na"); + assert_eq!(delta, 0.0); +} + +/// Control for the previous test: the identical ability list on a NON-modal +/// object is unconditional and does score. +#[test] +fn verdict_credits_a_non_modal_cast_with_the_same_effect() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + let spell = spell_object( + &mut state, + 1, + vec![ + AbilityDefinition::new(AbilityKind::Spell, draw_effect()), + AbilityDefinition::new(AbilityKind::Spell, poison_effect(TargetFilter::Opponent)), + ], + ); + + let candidate = cast_candidate(spell); + let (_, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_lethal"); +} + +// ─── verdict: combat (CR 702.90b / 702.164c / 702.70a) ────────────────────── + +/// An infect deck's primary progression action is attacking with its clock. +#[test] +fn verdict_scores_a_poison_source_attacking_a_player() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(2); + state.players[1].poison_counters = 3; + let attacker = creature_object(&mut state, 1, vec![Keyword::Infect], 2); + + let candidate = attack_candidate(vec![(attacker, AttackTarget::Player(OPPONENT))]); + let (delta, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_pressure"); + assert!(delta > 0.0); + assert_eq!( + reason + .facts + .iter() + .find(|(key, _)| *key == "poison_added") + .map(|(_, value)| *value), + Some(2), + "CR 702.90b: a 2-power infect attacker converts two counters" + ); +} + +/// CR 509.1a: an 8-poison opponent facing a 2-power infect attacker would be +/// dead if the damage connects — but a declared attack can still be blocked or +/// prevented, so the lethal combat swing is held at the `STRONG_MAX` ceiling, +/// strictly below the critical band a guaranteed direct poison reaches. +#[test] +fn verdict_caps_a_lethal_infect_attack_below_critical() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(2); + state.players[1].poison_counters = 8; + let attacker = creature_object(&mut state, 1, vec![Keyword::Infect], 2); + + let candidate = attack_candidate(vec![(attacker, AttackTarget::Player(OPPONENT))]); + let (delta, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_lethal"); + assert_eq!( + delta, + crate::policies::registry::STRONG_MAX, + "combat lethal is capped at STRONG_MAX (the swing can be blocked)" + ); + assert!( + delta < config.policy_penalties.poison_clock_pressure, + "and stays strictly below the guaranteed-direct-poison magnitude" + ); + + // Control: a resolving direct-poison spell that ALSO reaches ten (from 9, + // +1) is a guaranteed counter, so it may enter the critical band that the + // blockable combat swing cannot. + state.players[1].poison_counters = 9; + let spell = spell_object( + &mut state, + 9, + vec![AbilityDefinition::new( + AbilityKind::Spell, + poison_effect(TargetFilter::Opponent), + )], + ); + let cast = cast_candidate(spell); + let (cast_delta, cast_reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &cast, &decision, &context, &config))); + assert_eq!(cast_reason.kind, "poison_clock_lethal"); + assert!( + cast_delta > crate::policies::registry::STRONG_MAX, + "a guaranteed lethal counter reaches critical while a blockable combat swing does not" + ); +} + +/// Poison from several attackers pointed at one seat shares that seat's clock. +#[test] +fn verdict_sums_poison_per_defending_player() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(2); + state.players[1].poison_counters = 6; + let first = creature_object(&mut state, 1, vec![Keyword::Infect], 2); + let second = creature_object(&mut state, 2, vec![Keyword::Toxic(2)], 1); + + let candidate = attack_candidate(vec![ + (first, AttackTarget::Player(OPPONENT)), + (second, AttackTarget::Player(OPPONENT)), + ]); + let (_, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + // 6 + (2 infect + 2 toxic) == 10. + assert_eq!(reason.kind, "poison_clock_lethal"); +} + +/// CR 702.90b / 702.164c / 702.70a all key on combat damage dealt to a PLAYER, +/// so an attack aimed at a planeswalker advances nothing on this axis. +#[test] +fn verdict_ignores_a_poison_source_attacking_a_planeswalker() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + let attacker = creature_object(&mut state, 1, vec![Keyword::Infect], 2); + let walker = creature_object(&mut state, 2, Vec::new(), 0); + + let candidate = attack_candidate(vec![(attacker, AttackTarget::Planeswalker(walker))]); + let (delta, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_na"); + assert_eq!(delta, 0.0); +} + +/// Negative control: a vanilla beater is not a poison clock. +#[test] +fn verdict_ignores_an_attack_with_no_poison_source() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + let attacker = creature_object(&mut state, 1, vec![Keyword::Flying], 5); + + let candidate = attack_candidate(vec![(attacker, AttackTarget::Player(OPPONENT))]); + let (delta, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_na"); + assert_eq!(delta, 0.0); +} + +/// CR 800.4: attacking a seat that has already left the game advances no clock. +#[test] +fn verdict_ignores_an_attack_on_an_eliminated_seat() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(4); + state.players[1].poison_counters = 9; + state.players[1].is_eliminated = true; + let attacker = creature_object(&mut state, 1, vec![Keyword::Infect], 2); + + let candidate = attack_candidate(vec![(attacker, AttackTarget::Player(PlayerId(1)))]); + let (delta, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "poison_clock_na"); + assert_eq!(delta, 0.0); +} + +/// With two live opponents, the seat the declaration pushes closest to ten is +/// the one scored. +#[test] +fn verdict_picks_the_defender_closest_to_lethal() { + let config = config(); + let context = ai_context(&config); + let decision = priority_decision(); + let mut state = state_with_players(3); + state.players[1].poison_counters = 1; + state.players[2].poison_counters = 9; + let first = creature_object(&mut state, 1, vec![Keyword::Infect], 2); + let second = creature_object(&mut state, 2, vec![Keyword::Infect], 1); + + let candidate = attack_candidate(vec![ + (first, AttackTarget::Player(PlayerId(1))), + (second, AttackTarget::Player(PlayerId(2))), + ]); + let (_, reason) = + score_of(PoisonClockPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!( + reason.kind, "poison_clock_lethal", + "the 9-poison seat is one infect counter from losing" + ); +} + +// ─── registry routing (the real pipeline) ─────────────────────────────────── + +/// An `AiContext` whose cached deck features clear `POISON_CLOCK_FLOOR`, so the +/// registry's activation gate lets the policy run. +fn committed_context(config: &AiConfig) -> AiContext { + let mut features = DeckFeatures::default(); + features.poison.commitment = 0.9; + let mut session = AiSession::empty(); + session.features.insert(AI, features); + let mut context = AiContext::empty(&config.weights); + context.session = Arc::new(session); + context.player = AI; + context +} + +/// The poison-clock verdict as the registry produces it, or `None` when the +/// policy did not run at all (wrong `DecisionKind`, or activation opted out). +fn routed_verdict(ctx: &PolicyContext<'_>) -> Option<(f64, PolicyReason)> { + PolicyRegistry::default() + .verdicts(ctx) + .into_iter() + .find(|(id, _)| *id == PolicyId::PoisonClock) + .map(|(_, verdict)| score_of(verdict)) +} + +#[test] +fn registry_registers_the_policy() { + assert!(PolicyRegistry::default().has_policy(PolicyId::PoisonClock)); +} + +/// End-to-end routing for the modal seam: `WaitingFor::AbilityModeChoice` +/// classifies to `DecisionKind::ActivateAbility`, the policy declares that +/// kind, and the two mode alternatives of the SAME ability come out +/// discriminated. +#[test] +fn registry_routes_modal_alternatives_to_the_policy() { + let config = config(); + let context = committed_context(&config); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + let source = creature_object(&mut state, 1, Vec::new(), 1); + let decision = ability_mode_decision(source); + + let poison_mode = select_modes_candidate(vec![1]); + let (poison_delta, poison_reason) = + routed_verdict(&ctx(&state, &poison_mode, &decision, &context, &config)) + .expect("the poison mode must reach the policy through the registry"); + assert_eq!(poison_reason.kind, "poison_clock_lethal"); + assert!(poison_delta > 0.0); + + let draw_mode = select_modes_candidate(vec![0]); + let (draw_delta, draw_reason) = + routed_verdict(&ctx(&state, &draw_mode, &decision, &context, &config)) + .expect("the non-poison mode is still routed, it just scores nothing"); + assert_eq!(draw_reason.kind, "poison_clock_na"); + assert_eq!(draw_delta, 0.0); + assert!( + poison_delta > draw_delta, + "the poison mode must outrank its sibling: {poison_delta} vs {draw_delta}" + ); +} + +/// End-to-end routing for combat: `WaitingFor::DeclareAttackers` classifies to +/// `DecisionKind::DeclareAttackers`, which the policy now declares — an infect +/// deck's primary progression action reaches it. +#[test] +fn registry_routes_declare_attackers_to_the_policy() { + let config = config(); + let context = committed_context(&config); + let mut state = state_with_players(2); + state.players[1].poison_counters = 4; + let attacker = creature_object(&mut state, 1, vec![Keyword::Infect], 2); + let decision = AiDecisionContext { + waiting_for: WaitingFor::DeclareAttackers { + player: AI, + valid_attacker_ids: vec![attacker], + valid_attack_targets: vec![], + valid_attack_targets_by_attacker: None, + attacker_constraints: Default::default(), + }, + candidates: Vec::new(), + }; + + let attacking = attack_candidate(vec![(attacker, AttackTarget::Player(OPPONENT))]); + let (delta, reason) = routed_verdict(&ctx(&state, &attacking, &decision, &context, &config)) + .expect("a poison-source attack must reach the policy through the registry"); + assert_eq!(reason.kind, "poison_clock_pressure"); + assert!(delta > 0.0); + + let staying_home = attack_candidate(Vec::new()); + let (idle_delta, idle_reason) = + routed_verdict(&ctx(&state, &staying_home, &decision, &context, &config)) + .expect("declining to attack is still routed"); + assert_eq!(idle_reason.kind, "poison_clock_na"); + assert!( + delta > idle_delta, + "attacking with the clock must outrank holding it back" + ); +} + +/// The activation gate is what keeps this policy off every non-poison deck: +/// with commitment below the floor the registry never invokes it, even on the +/// exact board that would otherwise score critical. +#[test] +fn registry_skips_the_policy_for_an_uncommitted_deck() { + let config = config(); + let context = ai_context(&config); // no cached features → commitment 0.0 + let decision = priority_decision(); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + let spell = spell_object( + &mut state, + 1, + vec![AbilityDefinition::new( + AbilityKind::Spell, + poison_effect(TargetFilter::Opponent), + )], + ); + + let candidate = cast_candidate(spell); + assert!( + routed_verdict(&ctx(&state, &candidate, &decision, &context, &config)).is_none(), + "below POISON_CLOCK_FLOOR the policy must not run at all" + ); +} + +/// End-to-end routing for the policy's PRIMARY seam: a direct-poison +/// `CastSpell` under `WaitingFor::Priority` classifies to +/// `DecisionKind::CastSpell`, which the policy declares. Without this the +/// `CastSpell` entry in `decision_kinds()` could be deleted with the whole +/// suite still green — the seam would be dead in production while every +/// direct-poison test still passed by calling `verdict()` directly. +#[test] +fn registry_routes_cast_spell_to_the_policy() { + let config = config(); + let context = committed_context(&config); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + let spell = spell_object( + &mut state, + 1, + vec![AbilityDefinition::new( + AbilityKind::Spell, + poison_effect(TargetFilter::Opponent), + )], + ); + let decision = priority_decision(); + + let candidate = cast_candidate(spell); + let (delta, reason) = routed_verdict(&ctx(&state, &candidate, &decision, &context, &config)) + .expect("a direct-poison cast must reach the policy through the registry"); + assert_eq!(reason.kind, "poison_clock_lethal"); + assert!(delta > 0.0); + + // A non-poison cast is still routed under the same kind; it just scores nil. + let inert = spell_object( + &mut state, + 2, + vec![AbilityDefinition::new(AbilityKind::Spell, draw_effect())], + ); + let (inert_delta, inert_reason) = routed_verdict(&ctx( + &state, + &cast_candidate(inert), + &decision, + &context, + &config, + )) + .expect("a non-poison cast is still routed under DecisionKind::CastSpell"); + assert_eq!(inert_reason.kind, "poison_clock_na"); + assert!( + delta > inert_delta, + "the poison cast must outrank an inert one through the pipeline" + ); +} + +/// End-to-end routing for an activated poison ability: an +/// `ActivateAbility` action under `WaitingFor::Priority` classifies to +/// `DecisionKind::ActivateAbility`, exercising the `obj.abilities.get(index)` +/// lookup that `verdict()`-direct tests bypass. +#[test] +fn registry_routes_activate_ability_to_the_policy() { + let config = config(); + let context = committed_context(&config); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + // A permanent with two activated abilities: index 0 draws, index 1 poisons. + let source = creature_object(&mut state, 1, Vec::new(), 1); + *Arc::make_mut(&mut state.objects.get_mut(&source).unwrap().abilities) = vec![ + AbilityDefinition::new(AbilityKind::Activated, draw_effect()), + AbilityDefinition::new( + AbilityKind::Activated, + poison_effect(TargetFilter::Opponent), + ), + ]; + let decision = priority_decision(); + + let (poison_delta, poison_reason) = routed_verdict(&ctx( + &state, + &activate_ability_candidate(source, 1), + &decision, + &context, + &config, + )) + .expect("the poison ability must reach the policy through the registry"); + assert_eq!(poison_reason.kind, "poison_clock_lethal"); + assert!(poison_delta > 0.0); + + // The draw ability on the same object is routed but scores nothing — the + // per-ability `ability_index` lookup is discriminating, not the object. + let (draw_delta, draw_reason) = routed_verdict(&ctx( + &state, + &activate_ability_candidate(source, 0), + &decision, + &context, + &config, + )) + .expect("the non-poison ability is still routed"); + assert_eq!(draw_reason.kind, "poison_clock_na"); + assert_eq!(draw_delta, 0.0); + + // An out-of-range index degrades to neutral, never panics. + let (oob_delta, oob_reason) = routed_verdict(&ctx( + &state, + &activate_ability_candidate(source, 9), + &decision, + &context, + &config, + )) + .expect("an out-of-range ability index is still routed"); + assert_eq!(oob_reason.kind, "poison_clock_na"); + assert_eq!(oob_delta, 0.0); +} + +/// End-to-end routing for a modal SPELL (as opposed to the modal *ability* +/// covered above): `WaitingFor::ModeChoice` carries a `PendingCast`, and the +/// policy reads the chosen mode from the spell object's spell-kind abilities via +/// `modal_spell_mode_ability_refs` — the sole production consumer of that new +/// engine API. The poison and non-poison modes come out discriminated. +#[test] +fn registry_routes_modal_spell_mode_choice_to_the_policy() { + let config = config(); + let context = committed_context(&config); + let mut state = state_with_players(2); + state.players[1].poison_counters = 9; + + // A modal spell: its two printed modes are spell-kind abilities on the + // object (mode 0 draws, mode 1 poisons). + let spell = spell_object( + &mut state, + 1, + vec![ + AbilityDefinition::new(AbilityKind::Spell, draw_effect()), + AbilityDefinition::new(AbilityKind::Spell, poison_effect(TargetFilter::Opponent)), + ], + ); + let modal = ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 2, + ..ModalChoice::default() + }; + state.objects.get_mut(&spell).unwrap().modal = Some(modal.clone()); + + // A `WaitingFor::ModeChoice` whose PendingCast points at that spell object. + let resolved = ResolvedAbility::new(draw_effect(), Vec::new(), spell, AI); + let pending_cast = PendingCast::new(spell, CardId(spell.0), resolved, ManaCost::zero()); + let decision = AiDecisionContext { + waiting_for: WaitingFor::ModeChoice { + player: AI, + modal, + pending_cast: Box::new(pending_cast), + unavailable_modes: Vec::new(), + }, + candidates: Vec::new(), + }; + + let (poison_delta, poison_reason) = routed_verdict(&ctx( + &state, + &select_modes_candidate(vec![1]), + &decision, + &context, + &config, + )) + .expect("the poison spell-mode must reach the policy through the registry"); + assert_eq!(poison_reason.kind, "poison_clock_lethal"); + assert!(poison_delta > 0.0); + + let (draw_delta, draw_reason) = routed_verdict(&ctx( + &state, + &select_modes_candidate(vec![0]), + &decision, + &context, + &config, + )) + .expect("the non-poison spell-mode is still routed"); + assert_eq!(draw_reason.kind, "poison_clock_na"); + assert_eq!(draw_delta, 0.0); +} diff --git a/crates/phase-ai/src/policies/x_reference.rs b/crates/phase-ai/src/policies/x_reference.rs index 9c215857f7..f0326de2d9 100644 --- a/crates/phase-ai/src/policies/x_reference.rs +++ b/crates/phase-ai/src/policies/x_reference.rs @@ -167,6 +167,9 @@ fn continuous_modification_references_x(modification: &ContinuousModification) - ContinuousModification::GrantStaticAbility { definition } => { static_definition_references_x(definition) } + ContinuousModification::GrantReplacement { replacement } => { + replacement_definition_references_x(replacement) + } // RC1: dynamic P/T, keyword, and enter-counter magnitudes may reference // the chosen X either directly (`Variable "X"`) or via `CostXPaid` (the // announced X carried on the granting object — Mirror Entity's @@ -182,7 +185,9 @@ fn continuous_modification_references_x(modification: &ContinuousModification) - | ContinuousModification::AddCounterOnEnter { count: value, .. } => { expr_references_chosen_x(value) } - ContinuousModification::SetName { .. } + // CR 707.2c (Metamorphic Alteration): inert copy marker references no X. + ContinuousModification::CopyChosen + | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } | ContinuousModification::AddToughness { .. } diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 0c78ee45ed..523e20ef9c 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -1215,6 +1215,11 @@ fn fallback_action(state: &GameState) -> Option { .first() .map(|&opponent| GameAction::ChooseAnnouncingOpponent { opponent }), + // CR 702.174a: Gift recipient — fall back to the first candidate. + WaitingFor::ChooseGiftRecipient { candidates, .. } => candidates + .first() + .map(|&opponent| GameAction::ChooseGiftRecipient { opponent }), + // Adventure/MDFC/alt-cost choice: default to the "normal" face/cost. WaitingFor::CastOffer { kind: CastOfferKind::Adventure { .. }, @@ -2782,7 +2787,11 @@ pub(crate) fn deterministic_choice( &config.profile, Some(valid_block_targets), ); - return Some(GameAction::DeclareBlockers { assignments }); + return Some(engine::game::combat::complete_blocker_proposal( + state, + ai_player, + &assignments, + )); } return Some(GameAction::DeclareBlockers { assignments: Vec::new(), @@ -2839,7 +2848,11 @@ fn deterministic_combat_choice( profile, Some(valid_block_targets), ); - return Some(GameAction::DeclareBlockers { assignments }); + return Some(engine::game::combat::complete_blocker_proposal( + state, + ai_player, + &assignments, + )); } return Some(GameAction::DeclareBlockers { assignments: Vec::new(), @@ -5671,6 +5684,8 @@ mod tests { repeatability: engine::types::ability::AdditionalCostRepeatability::Repeatable, }, times_kicked: 0, + origin: engine::types::ability::AdditionalCostOrigin::Kicker, + gift_kind: None, pending_cast: Box::new(pending), }; state diff --git a/crates/phase-server/src/draft_pools.rs b/crates/phase-server/src/draft_pools.rs index de9fbc4d14..1c4d10fabf 100644 --- a/crates/phase-server/src/draft_pools.rs +++ b/crates/phase-server/src/draft_pools.rs @@ -11,8 +11,15 @@ pub struct DraftPools { impl DraftPools { pub fn from_path(path: &Path) -> Result> { - let file = std::fs::File::open(path)?; - let pools: BTreeMap = serde_json::from_reader(file)?; + // Read the whole file, then parse from the in-memory slice. serde_json's + // `from_reader` over an unbuffered `File` issues a read syscall per token + // and is pathologically slow on Windows, where per-syscall cost is high: + // parsing this multi-megabyte pool file that way stalled the native-engine + // server past the desktop shell's 20s health-check budget, so games fell + // back to the in-browser engine. `from_slice` parses contiguous memory with + // no per-read overhead (mirrors the `BufReader` already used in card_db.rs). + let bytes = std::fs::read(path)?; + let pools: BTreeMap = serde_json::from_slice(&bytes)?; let pools = pools .into_iter() .map(|(code, pool)| (code.to_lowercase(), pool)) diff --git a/crates/phase-server/src/main.rs b/crates/phase-server/src/main.rs index a220266cf6..67507b004d 100644 --- a/crates/phase-server/src/main.rs +++ b/crates/phase-server/src/main.rs @@ -91,6 +91,12 @@ type SharedPlayerCount = Arc; type SharedGameDb = Arc; type SharedDraftState = Arc>; const SPECTATOR_PLAYER_ID: PlayerId = PlayerId(u8::MAX); +#[cfg(windows)] +// Windows gives the process' primary thread a comparatively small stack. A +// persisted game is a deeply nested rules snapshot, so restore it on a +// purpose-sized thread rather than letting one saved session prevent the +// server from reaching its health endpoint on the next launch. +const PERSISTED_SESSION_RESTORE_STACK_BYTES: usize = 16 * 1024 * 1024; type SharedDraftPools = Arc; /// Spectator senders keyed by draft_code. Each spectator has a visibility + sender. type SharedDraftSpectators = Arc< @@ -107,6 +113,30 @@ type SharedDraftSpectators = Arc< /// Spectator senders keyed by game code (live games only). type SharedGameSpectators = Arc>>>>; +#[cfg(windows)] +fn restore_persisted_session(json: &str, db: SharedDb) -> Result { + let json = json.to_owned(); + let restore = std::thread::Builder::new() + .name("phase-session-restore".to_owned()) + .stack_size(PERSISTED_SESSION_RESTORE_STACK_BYTES) + .spawn(move || { + let persisted = serde_json::from_str::(&json) + .map_err(|error| error.to_string())?; + Ok(GameSession::from_persisted(persisted, db.as_ref())) + }) + .map_err(|error| format!("could not start restore thread: {error}"))?; + restore + .join() + .map_err(|_| "persisted session restore thread panicked".to_owned())? +} + +#[cfg(not(windows))] +fn restore_persisted_session(json: &str, db: SharedDb) -> Result { + let persisted = serde_json::from_str::(json) + .map_err(|error| error.to_string())?; + Ok(GameSession::from_persisted(persisted, db.as_ref())) +} + async fn reserve_lobby_subscriber_slot( lobby_subscribers: &SharedLobbySubscribers, tx: &mpsc::UnboundedSender, @@ -442,7 +472,10 @@ const MAX_GAMES: usize = 100; // `lobby_broker::broker` — the broker enforces it inside `handle`. const RATE_LIMIT_MESSAGES: u32 = 30; const RATE_LIMIT_WINDOW_SECS: u64 = 1; -const MAX_WS_MESSAGE_BYTES: usize = 8 * 1024; // 8 KB +// A native Play-vs-AI setup carries the host deck and every AI deck in one +// CreateGameWithSettings frame. Keep a bounded transport limit while allowing +// a full multiplayer table's ordinary deck lists to reach the input guards. +const MAX_WS_MESSAGE_BYTES: usize = 64 * 1024; // 64 KB /// Native [`BrokerEnv`] implementation: wall clock via `SystemTime`, tokens / /// codes via the `server_core` generators (which stay in `server-core` — they @@ -520,6 +553,22 @@ struct Cli { #[arg(short, long, default_value = "data", env = "PHASE_DATA_DIR")] data_dir: PathBuf, + /// Path to the SQLite game-persistence database. Defaults to + /// `/games.db`. The desktop shell points this at a + /// version-independent location so saved games survive native-engine + /// updates — the versioned `data_dir` is recreated per engine version, so a + /// games.db living inside it would be orphaned on every update. + #[arg(long, env = "PHASE_GAMES_DB")] + games_db: Option, + + /// Single-user local instance (the desktop shell). There is no seat + /// contention to reclaim here, so the two online-tuned session policies do + /// not apply: persisted sessions are never stale-purged, and reconnects + /// never expire. Together these let a suspended solo game stay resumable + /// until the player starts a new one. + #[arg(long, env = "PHASE_SINGLE_USER")] + single_user: bool, + /// Signed data-manifest URL for bootstrapping a missing PHASE_DATA_DIR. /// This overrides the manifest resolved from the binary's embedded channel. #[arg(long, env = "PHASE_DATA_MANIFEST_URL")] @@ -912,18 +961,44 @@ async fn main() { info!(cards = card_db.card_count(), "card database loaded"); let db: SharedDb = Arc::new(card_db); - // Initialize SQLite persistence - let game_db_path = data_path.join("games.db"); - let game_db: SharedGameDb = - Arc::new(persistence::GameDb::open(&game_db_path).expect("Failed to open game database")); - // Clean up stale sessions (>24 hours old) - if let Ok(deleted) = game_db.delete_stale(86400) { - if deleted > 0 { - info!(count = deleted, "cleaned up stale persisted sessions"); + // Initialize SQLite persistence. `games_db` overrides the in-data-dir + // default so the shell can keep saved games outside the per-version data + // dir (which is recreated on every native-engine update). `Connection::open` + // does not create parent dirs, so ensure the target's directory exists. + let game_db_path = cli + .games_db + .clone() + .unwrap_or_else(|| data_path.join("games.db")); + if let Some(parent) = game_db_path.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).expect("Failed to create game database directory"); + } + let retention = if cli.single_user { + persistence::SessionRetention::SingleUser + } else { + persistence::SessionRetention::Multiplayer + }; + let game_db: SharedGameDb = Arc::new( + persistence::GameDb::open(&game_db_path, retention).expect("Failed to open game database"), + ); + // Clean up stale sessions (>24 hours old). Skipped for a single-user local + // instance, where the one suspended solo game must survive until replaced. + if !cli.single_user { + if let Ok(deleted) = game_db.delete_stale(86400) { + if deleted > 0 { + info!(count = deleted, "cleaned up stale persisted sessions"); + } } } - let state: SharedState = Arc::new(Mutex::new(SessionManager::new())); + // A single-user instance has no other players whose seats a grace period + // would free, so reconnects never expire — a game suspended for any length + // of time stays resumable. `with_grace_period` sets the reconnect window; + // ten years is effectively unbounded without risking overflow in `now + grace`. + let state: SharedState = Arc::new(Mutex::new(if cli.single_user { + SessionManager::with_grace_period(Duration::from_secs(10 * 365 * 24 * 60 * 60)) + } else { + SessionManager::new() + })); let draft_sessions: SharedDraftState = Arc::new(Mutex::new(DraftSessionManager::new())); let draft_pools_path = data_path.join("draft-pools.json"); let draft_pools: SharedDraftPools = match draft_pools::DraftPools::from_path(&draft_pools_path) @@ -961,12 +1036,11 @@ async fn main() { let mut restored = 0u32; for (game_code, json) in &persisted_games { - match serde_json::from_str::(json) { - Ok(ps) => { - let lobby_meta = ps.lobby_meta.clone(); - let is_started = ps.game_started; - let session = - server_core::session::GameSession::from_persisted(ps, db.as_ref()); + info!(game = %game_code, bytes = json.len(), "restoring persisted session"); + match restore_persisted_session(json, db.clone()) { + Ok(session) => { + let lobby_meta = session.lobby_meta.clone(); + let is_started = session.game_started; // Register all non-AI human players as disconnected // to start the 120s grace period from now @@ -6401,7 +6475,10 @@ mod ranked_tests { fn test_db() -> SharedGameDb { let file = NamedTempFile::new().unwrap(); - Arc::new(persistence::GameDb::open(file.path()).unwrap()) + Arc::new( + persistence::GameDb::open(file.path(), persistence::SessionRetention::Multiplayer) + .unwrap(), + ) } #[test] @@ -6851,7 +6928,10 @@ mod full_create_guard_tests { mod issue_4548_full_create_tests { use super::*; use futures_util::{SinkExt, StreamExt}; - use server_core::protocol::{ClientMessage, DeckData, ServerErrorCode, ServerMessage}; + use phase_ai::config::AiDifficulty; + use server_core::protocol::{ + AiSeatRequest, ClientMessage, DeckChoice, DeckData, ServerErrorCode, ServerMessage, + }; use tokio::io::{AsyncRead, AsyncWrite}; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::WebSocketStream; @@ -6860,10 +6940,21 @@ mod issue_4548_full_create_tests { DeckData::default() } + fn deck_with_main_entries(entries: usize) -> DeckData { + DeckData { + main_deck: vec!["Forest".to_string(); entries], + ..Default::default() + } + } + async fn spawn_full_mode_server() -> (String, tokio::task::JoinHandle<()>, tempfile::TempDir) { let temp_dir = tempfile::tempdir().expect("temp dir"); let game_db = Arc::new( - persistence::GameDb::open(&temp_dir.path().join("games.db")).expect("game db"), + persistence::GameDb::open( + &temp_dir.path().join("games.db"), + persistence::SessionRetention::Multiplayer, + ) + .expect("game db"), ); let app = Router::new() .route("/ws", get(ws_handler)) @@ -7048,6 +7139,85 @@ mod issue_4548_full_create_tests { "full-mode create did not reject the invalid format deck" ); } + + #[tokio::test] + async fn full_mode_accepts_native_multi_ai_setup_larger_than_eight_kib() { + let (url, server, _temp_dir) = spawn_full_mode_server().await; + let result = tokio::time::timeout(Duration::from_secs(2), async { + let (mut socket, _) = tokio_tungstenite::connect_async(url) + .await + .expect("connect"); + + assert!(matches!( + recv_server_message(&mut socket).await, + ServerMessage::ServerHello { .. } + )); + + let hello = ClientMessage::ClientHello { + client_version: env!("CARGO_PKG_VERSION").to_string(), + build_commit: build_commit().to_string(), + protocol_version: PROTOCOL_VERSION, + }; + socket + .send(WsMessage::Text( + serde_json::to_string(&hello).expect("hello json").into(), + )) + .await + .expect("send hello"); + + let create = ClientMessage::CreateGameWithSettings { + deck: deck_with_main_entries(300), + display_name: "Alice".to_string(), + public: false, + password: None, + timer_seconds: None, + player_count: 3, + match_config: Default::default(), + ai_seats: vec![ + AiSeatRequest { + seat_index: 1, + difficulty: AiDifficulty::Medium, + deck_name: None, + deck: Some(DeckChoice::DeckList(Box::new(deck_with_main_entries(300)))), + }, + AiSeatRequest { + seat_index: 2, + difficulty: AiDifficulty::Medium, + deck_name: None, + deck: Some(DeckChoice::DeckList(Box::new(deck_with_main_entries(300)))), + }, + ], + format_config: None, + room_name: None, + host_peer_id: None, + draft_metadata: None, + start_when_full: true, + ranked: false, + }; + let create_json = serde_json::to_string(&create).expect("create json"); + assert!(create_json.len() > 8 * 1024); + assert!(create_json.len() <= MAX_WS_MESSAGE_BYTES); + socket + .send(WsMessage::Text(create_json.into())) + .await + .expect("send create"); + + // The empty test card database rejects the deck, which proves the + // complete multi-AI frame passed WebSocket framing and reached the + // normal create-game validation path. + assert!(matches!( + recv_server_message(&mut socket).await, + ServerMessage::Error { .. } + )); + }) + .await; + server.abort(); + + assert!( + result.is_ok(), + "native multi-AI setup frame did not reach server validation" + ); + } } #[cfg(test)] @@ -7669,7 +7839,10 @@ mod issue_4548_deadlock_tests { let connections: SharedConnections = Arc::new(Mutex::new(HashMap::new())); let game_db = { let file = NamedTempFile::new().unwrap(); - Arc::new(persistence::GameDb::open(file.path()).unwrap()) + Arc::new( + persistence::GameDb::open(file.path(), persistence::SessionRetention::Multiplayer) + .unwrap(), + ) }; let (tx, _rx) = mpsc::unbounded_channel::(); @@ -7732,7 +7905,10 @@ mod admin_auth_tests { fn test_app_state(temp_dir: &tempfile::TempDir) -> AppState { let game_db_path = temp_dir.path().join("games.db"); - let game_db = Arc::new(persistence::GameDb::open(&game_db_path).expect("game db")); + let game_db = Arc::new( + persistence::GameDb::open(&game_db_path, persistence::SessionRetention::Multiplayer) + .expect("game db"), + ); AppState { sessions: Arc::new(Mutex::new(SessionManager::new())), draft_sessions: Arc::new(Mutex::new(DraftSessionManager::new())), @@ -7898,7 +8074,10 @@ mod p2p_backup_delete_tests { fn test_app_state(temp_dir: &tempfile::TempDir) -> AppState { let game_db_path = temp_dir.path().join("games.db"); - let game_db = Arc::new(persistence::GameDb::open(&game_db_path).expect("game db")); + let game_db = Arc::new( + persistence::GameDb::open(&game_db_path, persistence::SessionRetention::Multiplayer) + .expect("game db"), + ); AppState { sessions: Arc::new(Mutex::new(SessionManager::new())), draft_sessions: Arc::new(Mutex::new(DraftSessionManager::new())), diff --git a/crates/phase-server/src/persistence.rs b/crates/phase-server/src/persistence.rs index ec683e7c66..d717c55c27 100644 --- a/crates/phase-server/src/persistence.rs +++ b/crates/phase-server/src/persistence.rs @@ -5,6 +5,24 @@ use std::time::{SystemTime, UNIX_EPOCH}; use rusqlite::{params, Connection}; use tracing::{error, info}; +/// How the game-session store bounds retention of `game_sessions` rows. +/// +/// This is a property of the deployment, not of any individual save call, so it +/// lives on the store and `save_session` is its single enforcement point. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SessionRetention { + /// Online server: every `game_code` persists independently. Many games run + /// concurrently across distinct seats, so a save only upserts its own row. + Multiplayer, + /// Single-user desktop instance: at most one solo game exists at a time. + /// The client tracks a single active-game pointer, so starting a new game + /// orphans the previous session server-side. Saving a new `game_code` + /// prunes every other game session, keeping the store bounded to one row + /// without relying on the stale-age purge (which single-user disables so a + /// suspended game never expires). + SingleUser, +} + /// SQLite-backed persistence for active game sessions. /// /// Uses `std::sync::Mutex` to make `Connection` `Send`, since @@ -12,6 +30,7 @@ use tracing::{error, info}; /// All operations acquire the lock briefly for a single SQL statement. pub struct GameDb { conn: Mutex, + retention: SessionRetention, } #[derive(Debug, Clone)] @@ -28,7 +47,7 @@ pub struct RatingDelta { impl GameDb { /// Open (or create) the game database at the given path. /// Enables WAL mode and creates the schema if needed. - pub fn open(path: &Path) -> rusqlite::Result { + pub fn open(path: &Path, retention: SessionRetention) -> rusqlite::Result { let conn = Connection::open(path)?; conn.execute_batch("PRAGMA journal_mode=WAL;")?; conn.execute_batch( @@ -70,19 +89,34 @@ impl GameDb { info!("Game database opened at {}", path.display()); Ok(Self { conn: Mutex::new(conn), + retention, }) } /// Persist a game session (upsert). + /// + /// Under [`SessionRetention::SingleUser`] this also prunes every other game + /// session in the same transaction, enforcing the "one active solo game" + /// invariant: starting a new game replaces the previous (now unreachable) + /// one instead of leaving it to accumulate, since single-user mode disables + /// the stale-age purge. pub fn save_session(&self, game_code: &str, json: &str) -> rusqlite::Result<()> { let now = now_epoch(); - let conn = self.conn.lock().unwrap(); - conn.execute( + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + if self.retention == SessionRetention::SingleUser { + tx.execute( + "DELETE FROM game_sessions WHERE game_code != ?1", + params![game_code], + )?; + } + tx.execute( "INSERT INTO game_sessions (game_code, session_json, updated_at) VALUES (?1, ?2, ?3) ON CONFLICT(game_code) DO UPDATE SET session_json = ?2, updated_at = ?3", params![game_code, json, now], )?; + tx.commit()?; Ok(()) } @@ -297,7 +331,7 @@ mod tests { fn test_db() -> GameDb { let file = NamedTempFile::new().unwrap(); - GameDb::open(file.path()).unwrap() + GameDb::open(file.path(), SessionRetention::Multiplayer).unwrap() } #[test] @@ -321,6 +355,37 @@ mod tests { assert_eq!(all[0].1, "v2"); } + #[test] + fn single_user_save_prunes_other_game_sessions() { + let file = NamedTempFile::new().unwrap(); + let db = GameDb::open(file.path(), SessionRetention::SingleUser).unwrap(); + + // First solo game. + db.save_session("GAME_A", "a").unwrap(); + assert_eq!(db.load_all().unwrap().len(), 1); + + // Re-saving the same game (autosave) keeps exactly one row. + db.save_session("GAME_A", "a2").unwrap(); + let all = db.load_all().unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].1, "a2"); + + // Starting a new game replaces the previous (now orphaned) session. + db.save_session("GAME_B", "b").unwrap(); + let all = db.load_all().unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].0, "GAME_B"); + } + + #[test] + fn multiplayer_save_retains_every_game_session() { + let db = test_db(); // SessionRetention::Multiplayer + db.save_session("GAME_A", "a").unwrap(); + db.save_session("GAME_B", "b").unwrap(); + // Online mode keeps every concurrent game independently. + assert_eq!(db.load_all().unwrap().len(), 2); + } + #[test] fn delete_session_removes_row() { let db = test_db(); diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index 52dd6b571f..4a15147a5f 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -604,6 +604,7 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { | GameAction::ChooseZoneOpponentChooser { .. } | GameAction::ChoosePileOpponent { .. } | GameAction::ChooseAnnouncingOpponent { .. } + | GameAction::ChooseGiftRecipient { .. } | GameAction::ChooseAssistPlayer { .. } | GameAction::CommitAssistPayment { .. } | GameAction::MulliganDecision { .. } diff --git a/docs/AI-CONTRIBUTOR.md b/docs/AI-CONTRIBUTOR.md index ae70cde3d8..fcf37435dc 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):** §0.1 assigns your capability tier; the §0.1.2 pre-PR gates apply to every tier, and the tier line sets PR processing priority. Report your model on a single canonical line in the PR body (e.g. `Model: claude-opus-4-7`, `Model: claude-sonnet-4-6`, `Model: codex-5.4`). Do not editorialize this line — `/pr-contribution-handler` parses it (and the matching `Tier:` line in §0.1.4) to prioritize PRs. Claiming Frontier when you are Standard wastes maintainer time on a PR that will fail the §0.1.2 gates anyway. +- **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. - **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/`. @@ -27,14 +27,21 @@ Skill references in this section use the `$skill` / `/skill` convention defined | Tier | Models | Procedure | |----------|--------|-----------| -| Frontier | `claude-opus-4-7`+, `gpt-5-5`+, `codex-5-5`+ | Full pipeline per §4 onward. | -| Standard | `claude-sonnet-4-6`, `claude-haiku-4-5`, `gpt-5-3` through `gpt-5-4`, `codex-5-3` through `codex-5-4` | Full pipeline. | +| Frontier | **Anthropic:** `claude-opus-4-8`+, `claude-sonnet-5`+ · **OpenAI:** `gpt-5-5`+ · **Cursor/Codex:** `codex-5-5`+ | Full pipeline per §4 onward. | -If you cannot determine your model, assume Standard. Below Standard (no tools, no autonomy), abort per §0. The tier affects PR processing priority (§0.1.4), not which gates apply. It cannot satisfy the artifact gate or authorize architecture scope. +**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. -### 0.1.2. Pre-PR gates (all tiers) +**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. -Both gates run on your diff before you push and open a PR, regardless of tier — review data shows combinator violations and unanchored patterns come from every model class. Failure on either → stop, do not open the PR, trigger §0.1.3 honesty clause. +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. + +**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. + +**Agentic harnesses (Cursor, and similar).** Using an agentic harness is allowed, but a `Co-authored-by: Cursor ` trailer (or equivalent) on your commits **raises the review bar rather than lowering it**, and the underlying model must still be Frontier tier. The pattern that earns an immediate close is pushing changes you have not verified and using repo CI and maintainer review as your correction loop — reverting a fix to push a diagnostic commit so CI prints a value for you, or deleting an assertion to turn a job green, are both treated as that pattern. Run the gates locally, understand the change, then push. + +### 0.1.2. Pre-PR gates + +Both gates run on your diff before you push and open a PR — review data shows combinator violations and unanchored patterns come from every model class, Frontier included. Failure on either → stop, do not open the PR, trigger §0.1.3 honesty clause. **Gate A — Combinator-purity script.** Run from the repo root after the final read-only review in §5: @@ -71,13 +78,7 @@ Every PR body must include a single canonical line on its own line: Tier: Frontier ``` -or - -``` -Tier: Standard -``` - -`/pr-contribution-handler` may use this to sort processing order. It is never evidence of quality or authority. Missing or malformed means Standard. Do not editorialize. +`Frontier` is the only accepted value — see §0.1.1. `/pr-contribution-handler` reads this line, and it is never evidence of quality or authority on its own. A missing, malformed, or non-`Frontier` line means the PR is closed as out-of-policy without an implementation review, subject to the 2026-07-24 cutoff in §0.1.1. Do not editorialize. --- @@ -329,6 +330,12 @@ gh pr create --title "" --body "<body>" # no --label arg; upstream auto **PR body template:** +Start with the repository's .github/PULL_REQUEST_TEMPLATE.md. The GitHub UI +prepopulates it; when using gh pr create --body, copy that file's completed +contents into the body. Fill every section rather than writing an ad-hoc body +or omitting a placeholder. The repository template is the source of truth for +fields measured by the review loop, artifact audit, and triage workflow. + ```markdown ## Summary Adds engine support for **<Card Name>**. @@ -348,7 +355,8 @@ Method: /engine-implementer <Developer | Non-developer> ## LLM -Model: <claude-opus-4-7 | claude-sonnet-4-6 | codex-5.4 | …> +Model: <claude-opus-4-8 | gpt-5-5 | codex-5-5 | …> # Frontier tier only — see §0.1.1 +Tier: Frontier Thinking: <high | max> ## Verification @@ -372,16 +380,20 @@ Gate A PASS head=<40-hex-sha> base=<40-hex-sha> Final review-impl PASS head=<40-hex-sha> ## Claimed parse impact -- <Exact Card Name> -<!-- Optional manual quality evidence; not an admission artifact. Write `None.` when parse-diff has no changed cards. --> +None. +<!-- List exact card names only when the parse-diff changes cards. --> + +## Scope Expansion +None. +<!-- Describe any change that crosses the issue/card's stated scope. --> ## Validation Failures None. -<!-- If Step 5 could not be made to pass after retries, replace `None.` above with the failure details. --> +<!-- Replace with the unresolved validation failure and its evidence. --> ## CI Failures None. -<!-- If Step 6 surfaced a failure the LLM could not resolve, replace `None.` above with the failure details. --> +<!-- Replace with the unresolved CI failure and its evidence. --> ``` **Labels classify behavior; they do not grant authority.** The upstream workflows and maintainer apply type labels (`bug`, `enhancement`, `feature`, `test`, or `refactor`), `needs-maintainer` for operational attention, and the existing `quality` label only after manual evidence review. Fork PRs must not pass `--label` to `gh pr create`. No label waives artifact or architecture scope gates. @@ -419,6 +431,8 @@ and follow the Developer track end-to-end to implement or fix the card ladder: misparse fix → open issue → coverage gap}. Use high thinking. Do not stop for my input. Apply the §0.1 tier routing — BOTH §0.1.2 gates must pass before opening the PR (all tiers). Open a PR when done. +Use the repository's .github/PULL_REQUEST_TEMPLATE.md for the PR body and fill +every section; do not write an ad-hoc body. If the work requires protected architecture scope without a prior maintainer appointment or a linked issue labeled `accepted`, stop instead of opening a PR. ``` @@ -433,6 +447,8 @@ ladder: misparse fix → open issue → coverage gap}. Skip local verification PR. Use high thinking. Do not stop for my input. Apply the §0.1 tier routing — BOTH §0.1.2 gates must pass before opening the PR (all tiers). Open a PR when done. +Use the repository's .github/PULL_REQUEST_TEMPLATE.md for the PR body and fill +every section; do not write an ad-hoc body. If the work requires protected architecture scope without a prior maintainer appointment or a linked issue labeled `accepted`, stop instead of opening a PR. ``` @@ -443,11 +459,14 @@ 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: Best results with Claude Opus 4.7+ or Codex 5.4+ at high+ -thinking, but proceed even if your runtime is below that — just report your -actual model on a single canonical "Model:" line in the PR body (e.g. -"Model: claude-sonnet-4-6"). Do NOT editorialize that line. Hard requirements: -you can invoke skills, run shell commands, and you will not pause for input. +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 +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 +invoke skills, run shell commands, and you will not pause for input. Steps: 1. gh repo fork phase-rs/phase --clone --remote && cd phase @@ -485,7 +504,8 @@ Steps: "Fix <Card Name>" for a misparse fix (§3.1) or an issue (§3.2), and add "Closes #<number>" to the body for an issue; "Partial: <Card Name>" only if validation or CI failures were unresolved). - Body must follow the template in docs/AI-CONTRIBUTOR.md. Do NOT pass + Body must use the repository's .github/PULL_REQUEST_TEMPLATE.md exactly; + fill every section rather than writing an ad-hoc body. Do NOT pass --label flags — the upstream auto-labeler may apply needs-maintainer automatically based on the branch name and body content. 8. Print the PR URL and exit. diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index 82b1887fcf..0b7274791a 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -3,8 +3,8 @@ Consolidated from 50 per-batch clustering passes over the whole card database. Synonymous per-batch clusters were merged into canonical root causes, their card lists unioned and deduped, and ranked by total card appearances (largest first). - **Canonical root causes:** 30 -- **Distinct cards implicated:** 4734 -- **Total card appearances across root causes:** 4768 (a card may appear under more than one root cause when it exhibits multiple distinct misparses) +- **Distinct cards implicated:** 4733 +- **Total card appearances across root causes:** 4767 (a card may appear under more than one root cause when it exhibits multiple distinct misparses) This is the prioritized "fix N root causes → unlock M cards" backlog: the top handful of root causes account for the majority of broken cards. @@ -13,7 +13,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top | # | Root cause | # cards | Fix hint (where it likely lives) | |---|------------|--------:|----------------------------------| | 1 | Relative-clause / filter restriction on target dropped | 746 | oracle_target.rs / game/filter.rs — extend TargetFilter property extraction for trailing relative clauses | -| 2 | Dropped intervening-if / gating condition (condition: null) | 591 | oracle_nom/condition.rs parse_inner_condition — trigger/static parsers must delegate condition extraction here | +| 2 | Dropped intervening-if / gating condition (condition: null) | 590 | oracle_nom/condition.rs parse_inner_condition — trigger/static parsers must delegate condition extraction here | | 3 | Anaphor bound to wrong referent | 404 | oracle_quantity.rs context-ref resolution + game/ability_utils.rs forward_result wiring | | 4 | Conjoined / chained second effect clause dropped | 387 | oracle.rs effect-chain composition — split on 'and'/'then'/sentence boundaries and build sub_ability chain | | 5 | Dropped 'for each' / dynamic count collapsed to Fixed | 330 | oracle_quantity.rs parse_for_each_clause / parse_quantity_ref — thread ForEach/ObjectCount into the effect count field | @@ -805,7 +805,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top </details> -### 2. Dropped intervening-if / gating condition (condition: null) (591 cards) +### 2. Dropped intervening-if / gating condition (condition: null) (590 cards) **Signature.** Trigger/static/replacement/spell condition left null though Oracle has an 'if/while/as long as/unless' game-state gate; the effect resolves unconditionally (CR 603.4 / 608.2c). @@ -985,7 +985,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Feed the Infection - Festival - Feudkiller's Verdict -- Fevered Visions - Fiery Encore - Fight for the Throne - Filigree Fracture @@ -1115,7 +1114,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Lethal Throwdown - Liberating Combustion - Liberator, Urza's Battlethopter -- Lictor - Lifecraft Awakening - Lighthouse Chronologist - Lightning Dart @@ -1817,7 +1815,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Wandering Wolf - Warren Pilferers - Wei Assassins -- Wheel and Deal - Wicked Slumber - Wildcall - Will of the Abzan diff --git a/lobby-worker/broker-wasm/Cargo.lock b/lobby-worker/broker-wasm/Cargo.lock index 658e1364e8..e2506fe87e 100644 --- a/lobby-worker/broker-wasm/Cargo.lock +++ b/lobby-worker/broker-wasm/Cargo.lock @@ -25,7 +25,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "engine" -version = "0.35.0" +version = "0.36.0" dependencies = [ "im", "indexmap", @@ -159,7 +159,7 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "lobby-broker" -version = "0.35.0" +version = "0.36.0" dependencies = [ "engine", "serde", diff --git a/scripts/changelog/state.json b/scripts/changelog/state.json index 313c1a79cc..e072bfd8ca 100644 --- a/scripts/changelog/state.json +++ b/scripts/changelog/state.json @@ -1,4 +1,4 @@ { - "lastTip": "7195a8e31e140baabadcc87a60366b7ccb2348a7", - "lastPostedId": 179 + "lastTip": "2c58e278037b9091bf99e4f225f15b873fa65464", + "lastPostedId": 180 }