diff --git a/crates/engine/src/bin/coverage_parse_diff.rs b/crates/engine/src/bin/coverage_parse_diff.rs index b7ffb32a00..63418e7751 100644 --- a/crates/engine/src/bin/coverage_parse_diff.rs +++ b/crates/engine/src/bin/coverage_parse_diff.rs @@ -329,18 +329,51 @@ fn load(path: &str) -> CoverageFile { } } -fn main() { - let mut args = std::env::args().skip(1); +/// Parsed CLI arguments. +#[derive(Debug)] +struct Args { + base_path: String, + head_path: String, + base_sha: String, + head_sha: String, + markdown_out: Option, + json_out: Option, + max_clusters: usize, +} + +/// Parse the CLI. `head_sha_default` is CI's `HEAD_SHA` env value, read by the caller so this stays +/// a pure function of its inputs. +/// +/// The two provenance flags REJECT a present-but-valueless form: falling back would silently +/// misattribute the whole report to another commit, and a confidently wrong SHA is worse than a +/// missing one. `--markdown` / `--json` / `--max-clusters` stay deliberately lenient β€” a missing +/// value there omits or degrades output the caller can see, so there is nothing to misattribute. +fn parse_args( + mut args: impl Iterator, + head_sha_default: String, +) -> Result { let mut positional: Vec = Vec::new(); let mut markdown_out: Option = None; let mut json_out: Option = None; let mut base_sha = String::from("unknown"); + let mut head_sha = head_sha_default; let mut max_clusters = 25usize; while let Some(a) = args.next() { match a.as_str() { "--markdown" => markdown_out = args.next(), "--json" => json_out = args.next(), - "--base-sha" => base_sha = args.next().unwrap_or(base_sha), + "--base-sha" => { + base_sha = args + .next() + .filter(|value| !value.is_empty() && !value.starts_with("--")) + .ok_or("--base-sha requires a value")? + } + "--head-sha" => { + head_sha = args + .next() + .filter(|value| !value.is_empty() && !value.starts_with("--")) + .ok_or("--head-sha requires a value")? + } "--max-clusters" => { max_clusters = args .next() @@ -350,12 +383,33 @@ fn main() { other => positional.push(other.to_string()), } } - if positional.len() != 2 { - eprintln!("usage: coverage-parse-diff [--base-sha SHA] [--markdown OUT] [--json OUT] [--max-clusters N]"); - process::exit(2); - } - let base = load(&positional[0]); - let head = load(&positional[1]); + let [base_path, head_path] = <[String; 2]>::try_from(positional) + .map_err(|_| "expected exactly two positional arguments")?; + Ok(Args { + base_path, + head_path, + base_sha, + head_sha, + markdown_out, + json_out, + max_clusters, + }) +} + +fn main() { + // CI exports HEAD_SHA on the `parsediff` step (`ci.yml`) as `pull_request.head.sha`. NOT derived + // from git: that job checks out the synthetic PR merge commit, so `HEAD` is not the PR head. + let head_sha_default = std::env::var("HEAD_SHA").unwrap_or_else(|_| String::from("unknown")); + let args = match parse_args(std::env::args().skip(1), head_sha_default) { + Ok(a) => a, + Err(msg) => { + eprintln!("coverage-parse-diff: {msg}"); + eprintln!("usage: coverage-parse-diff [--base-sha SHA] [--head-sha SHA] [--markdown OUT] [--json OUT] [--max-clusters N]"); + process::exit(2); + } + }; + let base = load(&args.base_path); + let head = load(&args.head_path); let bmap: BTreeMap = base .cards @@ -437,15 +491,16 @@ fn main() { }); let md = render_markdown( - &base_sha, + &args.base_sha, + &args.head_sha, &clusters, - max_clusters, + args.max_clusters, changed_card_set.len(), oracle_changed, &added_cards, &removed_cards, ); - match &markdown_out { + match &args.markdown_out { Some(p) => { if let Err(e) = fs::write(p, &md) { eprintln!("coverage-parse-diff: cannot write {p}: {e}"); @@ -455,8 +510,15 @@ fn main() { None => println!("{md}"), } - if let Some(p) = &json_out { - let json = render_json(&clusters, &added_cards, &removed_cards, oracle_changed); + if let Some(p) = &args.json_out { + let json = render_json( + &args.head_sha, + &args.base_sha, + &clusters, + &added_cards, + &removed_cards, + oracle_changed, + ); if let Err(e) = fs::write(p, json) { eprintln!("coverage-parse-diff: cannot write {p}: {e}"); process::exit(2); @@ -577,6 +639,7 @@ fn render_cluster_sections(s: &mut String, clusters: &[Cluster], show_cards: boo #[allow(clippy::too_many_arguments)] fn render_markdown( base_sha: &str, + head_sha: &str, clusters: &[Cluster], max_clusters: usize, changed_cards: usize, @@ -586,6 +649,12 @@ fn render_markdown( ) -> String { let mut s = String::new(); s.push_str("\n"); + // Provenance: bind this comment to the head it was generated from. The sticky is EDITED in + // place on every re-push (coverage-parse-diff-comment.yml), so without the head SHA a reader + // cannot tell a fresh "no changes" from a stale one. Emitted before the branch so the + // no-changes early return below carries it too, and above the fold so the 60k-char truncation + // in the comment workflow cannot drop it. + let _ = writeln!(s, "_Generated for head `{head_sha}`._\n"); if clusters.is_empty() && added.is_empty() && removed.is_empty() { s.push_str("### Parse changes introduced by this PR\n\n"); s.push_str("βœ“ No card-parse changes detected.\n"); @@ -637,6 +706,11 @@ fn render_markdown( /// hand-rolled escaping/joining. #[derive(Serialize)] struct DiffReport<'a> { + /// Same provenance pair the Markdown carries, in the order it presents them (head, then + /// baseline). The sticky comment sends a reader here when it truncates, so the artifact has to + /// identify its own commits rather than borrow the comment's. + head_sha: &'a str, + base_sha: &'a str, oracle_changed: usize, added_cards: &'a [String], removed_cards: &'a [String], @@ -656,12 +730,16 @@ struct ClusterJson<'a> { } fn render_json( + head_sha: &str, + base_sha: &str, clusters: &[Cluster], added: &[String], removed: &[String], oracle_changed: usize, ) -> String { let report = DiffReport { + head_sha, + base_sha, oracle_changed, added_cards: added, removed_cards: removed, @@ -686,6 +764,10 @@ fn render_json( mod tests { use super::*; + /// Stand-in for CI's `HEAD_SHA`; full 40 chars so the identity check the sticky supports is + /// exercised at its real width. + const HEAD_SHA_FIXTURE: &str = "bee984f809e084d2bd0c71c4bbbb3d67ac8d13b4"; + /// Build a childless ability item with the given label/details/support. fn item(label: &str, details: &[(&str, &str)], supported: bool) -> ParsedItem { ParsedItem { @@ -811,7 +893,16 @@ mod tests { ), ]; - let markdown = render_markdown("e085a8d5fa08", &clusters, 4, 5, 0, &[], &[]); + let markdown = render_markdown( + "e085a8d5fa08", + HEAD_SHA_FIXTURE, + &clusters, + 4, + 5, + 0, + &[], + &[], + ); for section in [ "#### 🟒 Added (1 signature)", @@ -877,7 +968,16 @@ mod tests { ), ]; - let markdown = render_markdown("e085a8d5fa08", &clusters, 1, 4, 0, &[], &[]); + let markdown = render_markdown( + "e085a8d5fa08", + HEAD_SHA_FIXTURE, + &clusters, + 1, + 4, + 0, + &[], + &[], + ); assert!(markdown.contains( "
… 3 more signature(s) (3 card-changes) β€” showing first 3;" @@ -888,6 +988,48 @@ mod tests { assert!(!markdown.contains("Affected (first 3): Added Card")); } + /// The sticky is edited in place on every re-push, so a body with no head SHA cannot be told + /// apart from a stale one. Both render branches must carry it β€” the no-changes early return is + /// the one the maintainer hit. + #[test] + fn markdown_identifies_the_head_sha_in_both_branches() { + const HEAD: &str = HEAD_SHA_FIXTURE; + + let empty = render_markdown("e085a8d5fa08", HEAD, &[], 4, 0, 0, &[], &[]); + assert!( + empty.contains(HEAD), + "the no-changes body must identify the head it was generated from: {empty}" + ); + assert!( + empty.starts_with(""), + "scripts/pr_review.py matches the sticky with startswith(MARKER); the marker must stay \ + the first line: {empty}" + ); + assert!( + !empty.contains("signature(s)"), + "scripts/pr_review.py classifies a body containing 'signature(s)' as real_changes; the \ + no-changes body must not: {empty}" + ); + + let clusters = vec![cluster( + ChangeKind::SupportFlip, + "Mill", + "", + "false", + "true", + &["Support Card"], + )]; + let changed = render_markdown("e085a8d5fa08", HEAD, &clusters, 4, 1, 0, &[], &[]); + assert!( + changed.contains(HEAD), + "the with-changes body must identify the head too: {changed}" + ); + assert!( + changed.contains("e085a8d5fa08"), + "the baseline SHA is still reported alongside the head" + ); + } + /// Regression guard for the sibling-collision case: two items share /// (category, label, source_text); the identical one must cancel as a /// multiset and the residual pair must reconcile to ONE field-change β€” @@ -927,4 +1069,112 @@ mod tests { assert_eq!(changes[0].kind, ChangeKind::SupportFlip); assert_eq!(changes[0].label, "Mill"); } + + /// The two required positionals plus whatever flags the case is exercising. + fn argv(flags: &[&str]) -> std::vec::IntoIter { + let mut v = vec!["base.json".to_string(), "head.json".to_string()]; + v.extend(flags.iter().map(|s| (*s).to_string())); + v.into_iter() + } + + /// A missing, empty, or option-token value after a provenance flag is a usage error, not a + /// silent fallback: the report would otherwise be stamped with a commit the caller never named. + /// Each arm asserts on its own flag name, so fixing only one of the provenance pair fails the + /// other. + #[test] + fn provenance_flags_reject_missing_empty_and_option_values() { + let base_err = parse_args(argv(&["--base-sha"]), "env-head".into()) + .expect_err("a valueless --base-sha must not fall back to `unknown`"); + assert!( + base_err.contains("--base-sha"), + "the error must name the offending flag: {base_err}" + ); + + let head_err = parse_args(argv(&["--head-sha"]), "env-head".into()) + .expect_err("a valueless --head-sha must not fall back to the env default"); + assert!( + head_err.contains("--head-sha"), + "the error must name the offending flag: {head_err}" + ); + + for (flag, invalid_value) in [ + ("--base-sha", ""), + ("--base-sha", "--markdown"), + ("--head-sha", ""), + ("--head-sha", "--markdown"), + ] { + let err = parse_args(argv(&[flag, invalid_value]), "env-head".into()) + .expect_err("empty and option-token provenance values must be rejected"); + assert!( + err.contains(flag), + "the error must name {flag} for {invalid_value:?}: {err}" + ); + } + + // Positive control: the same flags WITH values parse, and an explicit --head-sha overrides + // the env default rather than being ignored. + let ok = parse_args( + argv(&["--base-sha", "e085a8d5fa08", "--head-sha", HEAD_SHA_FIXTURE]), + "env-head".into(), + ) + .expect("both provenance flags with values must parse"); + assert_eq!(ok.base_sha, "e085a8d5fa08"); + assert_eq!(ok.head_sha, HEAD_SHA_FIXTURE); + + // Omitting them entirely is still legal β€” that is CI's shape for the head (env-supplied). + let defaulted = parse_args(argv(&[]), "env-head".into()).expect("positionals alone parse"); + assert_eq!(defaulted.head_sha, "env-head"); + assert_eq!(defaulted.base_sha, "unknown"); + + // The positional arity check survives the Vec β†’ [String; 2] rewrite. + assert!(parse_args(["only-one.json".to_string()].into_iter(), "env-head".into()).is_err()); + } + + /// The asymmetry with the provenance flags is deliberate. A missing `--markdown`/`--json`/ + /// `--max-clusters` value omits or degrades output the caller can see for themselves; there is + /// no commit to misattribute. Pinned so a later "make every flag strict" sweep is a decision. + #[test] + fn output_flags_stay_lenient_on_a_missing_value() { + let md = parse_args(argv(&["--markdown"]), "env-head".into()) + .expect("a valueless --markdown must not be a usage error"); + assert!(md.markdown_out.is_none(), "output falls back to stdout"); + + let js = parse_args(argv(&["--json"]), "env-head".into()) + .expect("a valueless --json must not be a usage error"); + assert!( + js.json_out.is_none(), + "the drill-down artifact is simply skipped" + ); + + let mc = parse_args(argv(&["--max-clusters"]), "env-head".into()) + .expect("a valueless --max-clusters must not be a usage error"); + assert_eq!(mc.max_clusters, 25, "the default cluster cap stands"); + } + + /// The sticky comment sends a reader to `parse-diff.json` when its body is truncated, so the + /// artifact must identify its own commits instead of borrowing the comment's. + #[test] + fn json_report_carries_both_shas() { + const BASE: &str = "e085a8d5fa0817e3a1f6e7c9d40b2a5c3e8f1d62"; + + let clusters = vec![cluster( + ChangeKind::SupportFlip, + "Mill", + "", + "false", + "true", + &["Support Card"], + )]; + let json = render_json(HEAD_SHA_FIXTURE, BASE, &clusters, &[], &[], 0); + let v: serde_json::Value = + serde_json::from_str(&json).expect("render_json must emit valid JSON"); + + // Distinct fixture values, so a head/base swap fails rather than passing symmetrically. + assert_eq!(v["head_sha"], HEAD_SHA_FIXTURE); + assert_eq!(v["base_sha"], BASE); + assert_eq!( + v["clusters"][0]["label"], "Mill", + "the drill-down is unchanged" + ); + } } diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index 057ff7b941..070262ea33 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -713,14 +713,25 @@ fn apply_pending_counter_post_action( remaining_count, events, ), - PendingCounterPostAction::EmitCommittedCopyTokenEntry { - object_id, - name, - source_id, - } => { - super::token::push_committed_token_entry_events( - state, object_id, name, source_id, events, - ); + PendingCounterPostAction::EmitCommittedCopyTokenEntry { object_id } => { + // CR 400.7 + CR 616.1: the ETB-counter ordering choice is answered and `BecomeCopy` + // has run (or, on the pre-`BecomeCopy` commit pause, the copy chain was abandoned and + // this is as realized as that route gets), so realize the entry inside the drain β€” + // before the rest of this action, whether or not that action settles. + // + // MEASURED redundancy, stated rather than implied: when the drain's action DOES settle + // to `Priority` (the Faithful Watchdog fixture in + // `tests/integration/token_zone_change_index.rs`, and every route the current card pool + // reaches), `token::realize_settled_token_battlefield_entry` realizes it anyway β€” from + // inside `apply_action` ahead of that action's CR 603.2 scan, and, for handlers that + // never reach that pipeline, from `apply_action_boundary_core`, which now runs + // `run_post_action_pipeline_from` over the slice it appended. Deleting this call AND the + // in-`apply_action` one flips no test. It is kept for a drain that does NOT settle in + // its own action, where this is the only in-action realization point, and because the + // in-`apply_action` call orders the CR 400.7 row ahead of that action's CR 704.3 SBA + // pass (CR 704.5f). `false` means an earlier convergence point already realized it + // (structurally idempotent, `Option::take_if`), which is not an error. + let _ = super::token::flush_pending_token_battlefield_entry(state, object_id, events); if !state.last_created_token_ids.contains(&object_id) { state.last_created_token_ids.push(object_id); } diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index c23de1d81e..c9b1fd45a1 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -20,7 +20,7 @@ use crate::types::events::GameEvent; use crate::types::game_state::{ DelayedTrigger, GameState, LiminalEntry, LiminalTokenAbilityInjection, PendingCopyTokenBatch, PendingCounterAddition, PendingCounterPostAction, PendingEffectResolutionEvent, - TokenEntryEventEmission, WaitingFor, + PendingTokenBattlefieldEntry, TokenEntryEventEmission, WaitingFor, }; use crate::types::identifiers::{CardId, ObjectId, ObjectIncarnationRef, TrackedSetId}; use crate::types::keywords::{Keyword, WardCost}; @@ -1699,8 +1699,9 @@ pub(crate) fn finalize_committed_liminal_token_entry_from_action( } } crate::game::layers::mark_layers_entered(state, object_id); - // CR 403.3 battlefield-entry bookkeeping is done by `record_zone_change` inside - // `push_committed_token_entry_events` below β€” recording it here too double-counts. + // CR 403.3 battlefield-entry bookkeeping is done by `record_zone_change`, reached from the + // `entry_events` match below (directly on the `Emit` route, via the parked entry's flush on + // the `Suppress` route) β€” recording it here too double-counts. crate::game::restrictions::record_token_created(state, object_id); if enters_attacking { @@ -1717,8 +1718,46 @@ pub(crate) fn finalize_committed_liminal_token_entry_from_action( }; } - if matches!(entry_events, TokenEntryEventEmission::Emit) { - push_committed_token_entry_events(state, object_id, name, source_id, events); + // CR 400.7 + CR 403.3 + CR 614.12a: the entry RECORD and the entry EVENTS are one indivisible + // operation over one snapshot, and both wait until the object IS the thing that entered. + // `Emit` means it already is (nothing is deferred on that route). `Suppress` means it is not + // yet β€” `BecomeCopy` has not run and any mandatory as-enters choice is unanswered β€” so the + // whole entry is PARKED on `GameState` and realized later by + // `flush_pending_token_battlefield_entry`. Recording here instead would write CR 400.7's "the + // state at the moment of the move" from a pre-copy 0/0 Shapeshifter. + match entry_events { + TokenEntryEventEmission::Emit => { + push_committed_token_entry_events(state, object_id, name, source_id, events); + } + TokenEntryEventEmission::Suppress => { + // Overwriting a live parked entry would silently lose its CR 400.7 row AND both of its + // entry events β€” the precise failure mode this lifecycle exists to remove. A + // `debug_assert!` alone does not remove it: it compiles out in release, unlike the + // `pending_liminal_entry_resume` precedent in `engine_replacement.rs`, which returns an + // `Err` in every profile. So realize the outgoing entry FIRST (data preserved in every + // profile), and keep the assert as the debug-profile tripwire, because an entry + // realized here is realized from a snapshot taken at a moment nobody designed for. + // Exactly one liminal copy entry can be in flight today: the multi-token continuation + // runs only after `finish_copy_target_choice_entry` returned `Ok(None)`, i.e. after the + // copy-completion convergence point already flushed. Measured: zero fires across the + // engine suite. + let stranded = state + .pending_token_battlefield_entry + .as_ref() + .map(|pending| pending.object_id); + if let Some(stranded_id) = stranded { + flush_pending_token_battlefield_entry(state, stranded_id, events); + } + debug_assert!( + stranded.is_none(), + "CR 400.7: parking a token battlefield entry over a live pending one: {stranded:?}" + ); + state.pending_token_battlefield_entry = Some(PendingTokenBattlefieldEntry { + object_id, + name, + source_id, + }); + } } if matches!(sacrifice_at, Some(Duration::UntilEndOfCombat)) { let sacrifice_token = DelayedTrigger { @@ -1761,6 +1800,11 @@ pub(crate) fn finalize_committed_liminal_token_entry_from_action( /// /// Callers must NOT also call `record_battlefield_entry` β€” `record_zone_change` does it, and a /// second call double-counts `battlefield_entries_this_turn`. +/// +/// This is the `TokenEntryEventEmission::Emit` half of the lifecycle: the object is already fully +/// realized when the finalize tail runs, so record and emit happen inline. The `Suppress` half +/// parks the entry and realizes it through [`flush_pending_token_battlefield_entry`], which pairs +/// the same two authorities in the same order. pub(crate) fn push_committed_token_entry_events( state: &mut GameState, object_id: ObjectId, @@ -1768,18 +1812,156 @@ pub(crate) fn push_committed_token_entry_events( source_id: ObjectId, events: &mut Vec, ) { - let entry = state + let record = record_committed_token_entry(state, object_id); + push_token_entry_events_for_record(record, object_id, name, source_id, events); +} + +/// CR 400.7 + CR 403.3: record a token's battlefield entry through +/// [`crate::game::restrictions::record_zone_change`] β€” the single authority that assigns this +/// turn's zone-change index and performs the CR 403.3 battlefield-entry bookkeeping β€” and emit +/// NOTHING. +/// +/// Split out of [`push_committed_token_entry_events`] because the record is *state*, not an +/// event; both of its callers ([`push_committed_token_entry_events`] and +/// [`flush_pending_token_battlefield_entry`]) pair it with the emit in the same breath. +/// +/// Returns the recorded zone change with its index assigned, so the caller emits the row it just +/// wrote instead of recording a second time (which would double-count +/// `battlefield_entries_this_turn`). `None` when the object is already gone. +pub(crate) fn record_committed_token_entry( + state: &mut GameState, + object_id: ObjectId, +) -> Option { + let mut zone_change_record = state .objects .get(&object_id) - .map(|token| token.snapshot_for_zone_change(object_id, None, Zone::Battlefield)); - if let Some(mut zone_change_record) = entry { - zone_change_record.turn_zone_change_index = - crate::game::restrictions::record_zone_change(state, zone_change_record.clone()); + .map(|token| token.snapshot_for_zone_change(object_id, None, Zone::Battlefield))?; + zone_change_record.turn_zone_change_index = + crate::game::restrictions::record_zone_change(state, zone_change_record.clone()); + Some(zone_change_record) +} + +/// CR 400.7 + CR 403.3 + CR 614.12a: realize a postponed token battlefield entry β€” record it +/// through `record_zone_change` and emit its entry pair β€” at the first instant the object IS the +/// thing that entered. Record and emit are ONE indivisible operation over ONE owned value, so no +/// route can perform half of it. Returns `false` when no entry is parked for `object_id`. +/// +/// Idempotence is structural: [`Option::take_if`] consumes the parked value, so a second call for +/// the same object is a no-op and the duplicate-row class is unrepresentable rather than guarded. +/// +/// LOOK-BACK WINDOW (owned, not hidden): between the commit and this flush the token is on the +/// battlefield with ZERO rows on either CR 400.7 / CR 403.3 ledger, and on a paused route that +/// window spans one or more client round-trips. `game/quantity.rs`'s zone-change scans and +/// `restrictions::battlefield_entry_matches_filter` therefore answer "0 entered this turn" for it +/// during the window. That is inherent to postponing, and it is the lesser error: recording early +/// answers "1" with the WRONG object (a 0/0 pre-copy Shapeshifter), which silently mis-answers +/// "each Zombie that entered this turn" rather than under-counting an entry that, per CR 614.12a, +/// has not finished happening. +/// +/// SBA SCOPE β€” what the rules do and do NOT guarantee about the window. CR 704.3 checks +/// state-based actions only when a player would get priority, and CR 704.4 says they pay no +/// attention to what happens during the resolution of a spell or ability, so nothing can remove the +/// token while the entry is PAUSED on a replacement/choice prompt. Neither rule covers the action +/// that finally settles: that action runs its own SBA pass inside `run_post_action_pipeline`, with +/// the entry still parked. That is exactly why [`realize_settled_token_battlefield_entry`] is +/// called from inside `apply_action` BEFORE that pipeline β€” a copy realized with toughness 0 gets +/// its CR 400.7 row written and its pair emitted before CR 704.5f can bury it. +/// [`record_committed_token_entry`]'s `None` arm remains the fail-safe for an object that is gone +/// by flush time. +pub(crate) fn flush_pending_token_battlefield_entry( + state: &mut GameState, + object_id: ObjectId, + events: &mut Vec, +) -> bool { + let Some(pending) = state + .pending_token_battlefield_entry + .take_if(|pending| pending.object_id == object_id) + else { + return false; + }; + let record = record_committed_token_entry(state, pending.object_id); + push_token_entry_events_for_record( + record, + pending.object_id, + pending.name, + pending.source_id, + events, + ); + true +} + +/// CR 400.7 + CR 603.6a: realize a parked token battlefield entry once the action carrying it has +/// SETTLED β€” `WaitingFor::Priority`, the complement of "any pause", so the gate is pause-shape +/// agnostic by construction instead of enumerating prompt variants. +/// +/// ONE gate, TWO call sites in `engine.rs`, both settled-action convergence points: +/// +/// * inside `apply_action`, immediately before `engine_priority::run_post_action_pipeline` β€” so the +/// entry pair is in the event set that action's CR 603.2 / CR 603.6a trigger scan reads. This is +/// what makes the copy token's ETB observers ("whenever another creature enters") fire, and it +/// also puts the CR 400.7 row on the ledger before that pipeline's SBA pass (CR 704.3) can bury a +/// 0-toughness copy under CR 704.5f. +/// * in `apply_action_boundary_core`, after `apply_action` returned β€” for the handlers that build +/// an `ActionResult` straight out of the reducer match and never reach that pipeline +/// (`handle_tribute_choice` is the reachable one). That call site converges them onto +/// `engine_priority::run_post_action_pipeline_from` over exactly the slice this realization +/// appended, so the CR 603.6a check runs for them too and their ETB observers fire. For the +/// REALIZED ENTRY the only remaining difference from the in-`apply_action` call is ordering +/// against that action's CR 704.3 SBA pass, which is why both call sites are kept; the handler's +/// OWN earlier events stay outside that scan window by design (`scan_from`). +/// +/// Order between the two is irrelevant: the flush's `Option::take_if` makes the second call β€” and +/// any call after the two in-resolution convergence points in `engine_replacement.rs` / +/// `counters.rs` β€” a no-op. +/// +/// CR 704.5f: when the token is no longer on the battlefield at the settling point, the parked +/// entry is DROPPED β€” no row, no pair β€” rather than emitting a battlefield-entry event for an +/// object that is not there, which would make ETB triggers fire for a permanent that has already +/// left. The cost is a lost CR 400.7 row for an entry that did happen. After the in-`apply_action` +/// call above, the only way to reach this branch is a settling action that never runs the pipeline +/// AND removes the token within itself; no production route is known to do both. +/// +/// Returns whether an entry pair was actually appended to `events` β€” `false` for an unsettled +/// action, for nothing parked, for an entry an earlier convergence point already consumed, and +/// for the CR 704.5f drop branch (which does consume the park but emits nothing). The boundary +/// call site gates its CR 603.6a trigger pass on exactly that. +pub(crate) fn realize_settled_token_battlefield_entry( + state: &mut GameState, + events: &mut Vec, +) -> bool { + if !matches!(state.waiting_for, WaitingFor::Priority { .. }) { + return false; + } + let Some(pending_id) = state + .pending_token_battlefield_entry + .as_ref() + .map(|pending| pending.object_id) + else { + return false; + }; + if state.battlefield.contains(&pending_id) { + flush_pending_token_battlefield_entry(state, pending_id, events) + } else { + state.pending_token_battlefield_entry = None; + false + } +} + +/// The event half of a token battlefield entry, shared by the immediate (`Emit`) and postponed +/// (`Suppress` + flush) routes so the emitted pair is defined exactly once. +fn push_token_entry_events_for_record( + record: Option, + object_id: ObjectId, + name: String, + source_id: ObjectId, + events: &mut Vec, +) { + if let Some(record) = record { events.push(GameEvent::ZoneChanged { object_id, from: None, to: Zone::Battlefield, - record: Box::new(zone_change_record), + record: Box::new(record), }); } events.push(GameEvent::TokenCreated { @@ -3724,6 +3906,333 @@ mod tests { (state, events) } + // ── CR 403.3: the entry RECORD is not gated on event emission ──────── + + /// CR 400.7 + CR 403.3 rows for `object_id`, as `(battlefield_entry_rows, zone_change_rows)`. + fn ledger_rows(state: &GameState, object_id: ObjectId) -> (usize, usize) { + ( + state + .battlefield_entries_this_turn + .iter() + .filter(|record| record.object_id == object_id) + .count(), + state + .zone_changes_this_turn + .iter() + .filter(|record| { + record.object_id == object_id && record.to_zone == Zone::Battlefield + }) + .count(), + ) + } + + /// Build a battlefield token and run the liminal finalize tail over it under `emission`, + /// returning the resulting `(state, token_id, emitted_events)` so callers can inspect the + /// ledgers, the parked entry, and any later flush. + fn finalize_liminal_entry_under( + emission: TokenEntryEventEmission, + ) -> (GameState, ObjectId, Vec) { + let mut state = GameState::new_two_player(42); + let controller = PlayerId(0); + let source_id = ObjectId(1); + let object_id = create_object( + &mut state, + CardId(0), + controller, + "Record Probe".to_string(), + Zone::Battlefield, + ); + let mut events = Vec::new(); + assert!(finalize_committed_liminal_token_entry_from_action( + &mut state, + PendingCounterPostAction::FinalizeCommittedLiminalTokenEntry { + object_id, + name: "Record Probe".to_string(), + source_id, + controller, + enters_attacking: false, + attach_to: None, + sacrifice_at: None, + created_ids: Vec::new(), + ability_injection: LiminalTokenAbilityInjection::ResolvedToken, + entry_events: emission, + }, + &mut events, + )); + (state, object_id, events) + } + + /// CR 400.7 + CR 614.12a: `Suppress` means the object is NOT yet the thing that entered β€” + /// `BecomeCopy` has not run and any mandatory as-enters choice is unanswered β€” so the record + /// and the events are parked TOGETHER and realized later, as one operation, from a snapshot + /// taken at flush. Recording here instead writes CR 400.7's "state at the moment of the move" + /// from a pre-copy 0/0 Shapeshifter, which is the defect this lifecycle replaces. + /// + /// REVERT-PROBE (discriminating, RUN): replace the `Suppress` park with + /// `record_committed_token_entry(state, object_id);` β‡’ the row counts here read `(1, 1)` and + /// the pending assertion fails, while `suppress_does_not_emit_the_entry_pair` below still + /// passes β€” isolating the flip to the record, not the events. + #[test] + fn suppressed_liminal_entry_parks_instead_of_recording() { + let (state, object_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + assert_eq!( + ledger_rows(&state, object_id), + (0, 0), + "CR 614.12a: a Suppress-route token writes NEITHER ledger until it is realized" + ); + assert_eq!( + state.pending_token_battlefield_entry, + Some(PendingTokenBattlefieldEntry { + object_id, + name: "Record Probe".to_string(), + source_id: ObjectId(1), + }), + "the whole entry is parked on GameState so it survives any number of round trips" + ); + } + + /// The other half of the pin: `Suppress` really does withhold the events, so the test above + /// is measuring a park with no emit rather than an emit that happened anyway. + #[test] + fn suppress_does_not_emit_the_entry_pair() { + let (_state, _object_id, events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + assert!( + !events.iter().any(|event| matches!( + event, + GameEvent::ZoneChanged { .. } | GameEvent::TokenCreated { .. } + )), + "Suppress withholds both entry events; got {events:?}" + ); + } + + /// CR 400.7 + CR 603.6a: the flush is the single realization authority β€” it records through + /// `record_zone_change` AND emits the pair, once. A second call is structurally a no-op + /// (`Option::take_if` consumed the parked value), which is what makes the duplicate-row class + /// unrepresentable rather than guarded. + /// + /// REVERT-PROBE (discriminating, RUN): swap `take_if` for a non-consuming + /// `as_ref().filter(..).cloned()` β‡’ the second flush returns `true`, appends a second row to + /// each ledger and a second event pair, failing the idempotence half while the first-flush + /// assertions stay green. + #[test] + fn flushing_a_parked_entry_records_and_emits_exactly_once() { + let (mut state, object_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + let mut events = Vec::new(); + assert!( + flush_pending_token_battlefield_entry(&mut state, object_id, &mut events), + "the parked entry is realized by its first flush" + ); + assert_eq!( + ledger_rows(&state, object_id), + (1, 1), + "realization writes exactly one row on each ledger" + ); + assert_eq!( + ( + events + .iter() + .filter(|event| matches!(event, GameEvent::ZoneChanged { .. })) + .count(), + events + .iter() + .filter(|event| matches!(event, GameEvent::TokenCreated { .. })) + .count(), + ), + (1, 1), + "realization emits the entry pair exactly once; got {events:?}" + ); + assert!(state.pending_token_battlefield_entry.is_none()); + + let mut second = Vec::new(); + assert!( + !flush_pending_token_battlefield_entry(&mut state, object_id, &mut second), + "a second flush finds nothing parked" + ); + assert_eq!( + ledger_rows(&state, object_id), + (1, 1), + "a second flush adds no row" + ); + assert!(second.is_empty(), "a second flush emits nothing"); + } + + /// The parked entry is bound to ONE object identity: a flush for a different object must not + /// consume it. Without this, an unrelated token's realization would emit this token's entry. + #[test] + fn flushing_a_foreign_object_id_is_a_no_op() { + let (mut state, object_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + let foreign = ObjectId(object_id.0 + 1_000); + let mut events = Vec::new(); + assert!(!flush_pending_token_battlefield_entry( + &mut state, + foreign, + &mut events + )); + assert_eq!(ledger_rows(&state, object_id), (0, 0)); + assert_eq!(ledger_rows(&state, foreign), (0, 0)); + assert!(events.is_empty()); + assert!( + state + .pending_token_battlefield_entry + .as_ref() + .is_some_and(|pending| pending.object_id == object_id), + "the binding survives a foreign flush untouched" + ); + } + + /// CR 704.5f fail-safe: if the object is gone when the flush runs, `record_committed_token_entry` + /// has nothing to snapshot, so no CR 400.7 row is written and no `ZoneChanged` is emitted. + /// (`TokenCreated` still reports the creation that did happen.) + #[test] + fn flushing_after_the_object_left_the_battlefield_records_nothing() { + let (mut state, object_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + state.objects.remove(&object_id); + state.battlefield.retain(|id| *id != object_id); + let mut events = Vec::new(); + assert!(flush_pending_token_battlefield_entry( + &mut state, + object_id, + &mut events + )); + assert_eq!( + ledger_rows(&state, object_id), + (0, 0), + "a vanished object gets no CR 400.7 row" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, GameEvent::ZoneChanged { .. })), + "no phantom entry event is emitted; got {events:?}" + ); + } + + /// The settled-action GATE that both `engine.rs` convergence points share + /// ([`realize_settled_token_battlefield_entry`]), exercised over its three arms β€” including the + /// CR 704.5f drop branch, which no production drive reaches (see that function's doc comment). + /// Helper-level by construction: the two production entry points are covered by the Painter / + /// Fanatic / Watchdog integration drives, which measure WHERE it is called from. + #[test] + fn the_settled_gate_realizes_only_a_settled_action_and_drops_a_departed_token() { + // (i) Mid-prompt: the action has not settled, so nothing is realized. + let (mut state, object_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + state.waiting_for = WaitingFor::MeldPairChoice { + player: PlayerId(0), + choices: Vec::new(), + }; + let mut events = Vec::new(); + assert!( + !realize_settled_token_battlefield_entry(&mut state, &mut events), + "an unsettled action realizes nothing, so the boundary convergence must not run a \ + trigger pass" + ); + assert_eq!(ledger_rows(&state, object_id), (0, 0)); + assert!(events.is_empty()); + assert!( + state.pending_token_battlefield_entry.is_some(), + "an unsettled action leaves the entry parked for a later round trip" + ); + + // (ii) Settled with the token still on the battlefield: realized, once. + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + assert!( + realize_settled_token_battlefield_entry(&mut state, &mut events), + "a settled action with the token still on the battlefield realizes the pair, which is \ + what gates the CR 603.6a pass at the action boundary" + ); + assert_eq!(ledger_rows(&state, object_id), (1, 1)); + assert!(state.pending_token_battlefield_entry.is_none()); + assert_eq!( + events + .iter() + .filter(|event| matches!(event, GameEvent::ZoneChanged { .. })) + .count(), + 1, + "the settled action carries the entry pair; got {events:?}" + ); + + // (iii) CR 704.5f: settled, but the token has left the battlefield β‡’ the parked entry is + // DROPPED β€” no row and, unlike a direct flush, no `TokenCreated` for an object that + // is not there. + let (mut departed, departed_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + departed.battlefield.retain(|id| *id != departed_id); + departed.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + let mut departed_events = Vec::new(); + assert!( + !realize_settled_token_battlefield_entry(&mut departed, &mut departed_events), + "the CR 704.5f drop branch consumes the park but emits nothing, so there is no slice \ + for the boundary convergence to scan" + ); + assert_eq!(ledger_rows(&departed, departed_id), (0, 0)); + assert!(departed_events.is_empty()); + assert!(departed.pending_token_battlefield_entry.is_none()); + } + + /// Serde: the parked entry round-trips, and a `GameState` JSON written before this field + /// existed still loads (the `#[serde(default)]` save-compat claim). + #[test] + fn pending_token_battlefield_entry_round_trips() { + let mut state = GameState::new_two_player(42); + state.pending_token_battlefield_entry = Some(PendingTokenBattlefieldEntry { + object_id: ObjectId(7), + name: "Record Probe".to_string(), + source_id: ObjectId(1), + }); + let encoded = serde_json::to_string(&state).expect("GameState serializes"); + let decoded: GameState = serde_json::from_str(&encoded).expect("GameState deserializes"); + assert_eq!( + decoded.pending_token_battlefield_entry, + state.pending_token_battlefield_entry + ); + + let mut without: serde_json::Value = + serde_json::from_str(&encoded).expect("the encoded state is JSON"); + assert!( + without + .as_object_mut() + .expect("GameState encodes as a JSON object") + .remove("pending_token_battlefield_entry") + .is_some(), + "the key must be present to begin with, or the removal below proves nothing" + ); + let legacy: GameState = + serde_json::from_value(without).expect("a save without the key still loads"); + assert!(legacy.pending_token_battlefield_entry.is_none()); + } + + /// The double-count guard for the `Emit` arm: recording in the finalize tail AND inside + /// `push_committed_token_entry_events` would put two rows on the ledger. Exactly one β€” and + /// nothing is parked, because that route's object is already fully realized. + #[test] + fn emitted_liminal_entry_records_exactly_one_row() { + let (state, object_id, events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Emit); + let (entries, zone_rows) = ledger_rows(&state, object_id); + assert_eq!(entries, 1, "Emit records battlefield entry exactly once"); + assert_eq!(zone_rows, 1, "Emit records the zone change exactly once"); + assert!( + state.pending_token_battlefield_entry.is_none(), + "the Emit route parks nothing" + ); + assert!( + events + .iter() + .any(|event| matches!(event, GameEvent::TokenCreated { .. })), + "Emit still emits the entry pair; got {events:?}" + ); + } + #[test] fn controller_owned_token_ignores_scoped_player() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 55c51e786a..be919ceeb1 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -1070,6 +1070,14 @@ fn abandon_source_bound_resolution_prompt(state: &mut GameState, player: PlayerI crate::game::stack::clear_resolving_stack_entry(state); state.resolution_source_relatch = None; state.deferred_entry_events.clear(); + // The prompt and its ability continuation are abandoned, so no realization point will ever be + // reached for a token battlefield entry parked by this resolution. Leaving the `Option` live + // would let a later token's park trip the fail-loud overwrite assert, and would let the + // action-boundary convergence write a CR 400.7 row and run a CR 603.6a trigger pass for a + // resolution that no longer exists. If the token itself survives the abandonment its entry row + // is lost β€” the same loss the `deferred_entry_events.clear()` above already accepts for that + // entry's trigger replay. + state.pending_token_battlefield_entry = None; state.waiting_for = WaitingFor::Priority { player: players::next_player(state, player), }; @@ -1100,6 +1108,9 @@ fn abandon_change_zone_family_for_controller(state: &mut GameState, player: Play crate::game::stack::clear_resolving_stack_entry(state); state.resolution_source_relatch = None; state.deferred_entry_events.clear(); + // Same reasoning as `abandon_source_bound_resolution_prompt`: the owning resolution is gone, + // so a parked token battlefield entry has no realization point left. + state.pending_token_battlefield_entry = None; state.waiting_for = WaitingFor::Priority { player: players::next_player(state, player), }; diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index e9edd68565..a753858b5a 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -366,13 +366,55 @@ fn apply_action_boundary_core( *state = boundary_snapshot; return Err(err); } - let result = match apply_action(state, semantic_owner, action, stack_resolution_limit) { + let mut result = match apply_action(state, semantic_owner, action, stack_resolution_limit) { Ok(result) => result, Err(err) => { *state = boundary_snapshot; return Err(err); } }; + // CR 400.7 + CR 403.3 + CR 614.12a: an as-enters choice (and any continuation it raises) can + // span an arbitrary number of client round-trips of ANY `WaitingFor` shape, so realization of a + // parked token battlefield entry is keyed on the action having SETTLED, not on prompt shape. + // `apply_action` realizes it itself on every route that reaches `run_post_action_pipeline`. + // + // CR 603.6a: the entry pair this realization emits IS the event that puts a permanent onto the + // battlefield, so every permanent must be checked for matching enters-the-battlefield triggers + // (CR 603.2 + CR 603.3b place them on the stack before the next player receives priority). + // Reaching here with something to realize means the action settled WITHOUT running + // `run_post_action_pipeline` β€” one of the reducer arms that builds an `ActionResult` straight + // out of the match (`handle_tribute_choice` is the reachable one). Converge those onto the same + // pipeline the rest of the reducer uses, scanning ONLY the slice this realization appended, so + // a handler that already settled its own events (`handle_opponent_may_choice`, which collects + // into `deferred_triggers` without recording them in `consumed_before_priority_trigger_events`) + // cannot have them collected a second time. Inert on every other route: the flush returns + // `false` when nothing was parked or an earlier convergence point already consumed it + // (`Option::take_if`). + let scan_from = result.events.len(); + if effects::token::realize_settled_token_battlefield_entry(state, &mut result.events) { + let wf = match engine_priority::run_post_action_pipeline_from( + state, + &mut result.events, + scan_from, + &result.waiting_for, + false, + false, + ) { + Ok(wf) => wf, + Err(err) => { + *state = boundary_snapshot; + return Err(err); + } + }; + // The pipeline's terminal return hands back `flush_pending_priority_intercepts(..)` WITHOUT + // writing `state.waiting_for`, and the drain can raise `OrderTriggers` (CR 603.3b; measured + // on the Fanatic route). BOTH writes are load-bearing: `finish_action_boundary` copies + // `result.waiting_for` INTO the state at `sync_waiting_for`, and + // `apply_interaction_pre_reconciliation_for_life_safety` returns `raw.result` without ever + // calling `finish_action_boundary`. + state.waiting_for = wf.clone(); + result.waiting_for = wf; + } Ok(RawActionApplication { result, journal_start, @@ -8938,6 +8980,15 @@ fn apply_action( // the action's result, not the pre-action state (fixes stale TargetSelection // after CancelCast). state.waiting_for = waiting_for.clone(); + // CR 704.3 + CR 704.5f: a token battlefield entry postponed by an as-enters choice is + // realized HERE, before the pipeline below, so the CR 400.7 row is written ahead of that + // pipeline's SBA pass and survives a copy that enters with 0 toughness. It also puts the + // entry pair into this action's `events` ahead of the CR 603.2 / CR 603.6a scan β€” no longer + // the ONLY way that check runs (the action-boundary convergence in + // `apply_action_boundary_core` runs the same pipeline for direct-return handlers), but + // still the only placement that beats the SBA pass. Same gate as that boundary call, one + // authority; keeping it here also avoids two full pipeline passes per settling action. + effects::token::realize_settled_token_battlefield_entry(state, &mut events); let wf = engine_priority::run_post_action_pipeline( state, &mut events, diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 0d1166724a..916653cb9d 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1719,10 +1719,11 @@ pub(super) fn handle_copy_target_choice( else { unreachable!("meld resume returned above") }; - let entry_events = state - .liminal_entries - .get(&source_id) - .map(|entry| (entry.name.clone(), entry.source_id)); + // The entry's `name` / `source_id` now ride the parked + // `GameState::pending_token_battlefield_entry` that the `Suppress` commit installs, so + // only the liminal entry's PRESENCE matters here: it is what says this commit will park + // an entry that later needs realizing. + let has_liminal_entry = state.liminal_entries.contains_key(&source_id); let copy_continuation = state.liminal_entries.get(&source_id).and_then(|entry| { entry.copy_resume.as_ref().and_then(|copy| { (entry.remaining_count > 0).then(|| { @@ -1777,24 +1778,25 @@ pub(super) fn handle_copy_target_choice( // CR 403.3 + CR 603.6a: the commit applies the token's enter-with-counters, which can // PAUSE on a CR 616.1 ordering choice between two AddCounter replacements. On that pause // the only stashed post-action is the entry finalization, and its `Suppress` emission mode - // means the finalize tail neither emits the entry events nor (since the entry record is now - // written by `record_zone_change` inside `push_committed_token_entry_events`) records the - // entry at all β€” the token would enter invisibly. Hand the emit down as a post-finalize - // action so the paused path still performs the entry EMIT the unpaused one performs below. - // Only the emit: on a pause this function returns at the `commit_liminal_token_entry_*` - // call below, so the unpaused tail's CR 614.12a `BecomeCopy` chain, - // `finish_copy_target_choice_entry`, and the copy continuation do not run on that route. - // That abandonment is pre-existing and is not what this hand-down addresses. Dropped - // unused when the commit does not pause. - let paused_entry_emit: Vec = entry_events - .clone() - .map( - |(name, event_source_id)| PendingCounterPostAction::EmitCommittedCopyTokenEntry { - object_id: source_id, - name, - source_id: event_source_id, - }, - ) + // PARKS the whole entry (record + events) on `GameState` instead of realizing it. Hand the + // realization down as a post-finalize action so the paused path performs the same CR 400.7 + // record and CR 603.6a emit the unpaused one performs below. + // + // Realizing it INSIDE the counter drain (rather than leaving it to the action-boundary + // convergence) keeps the emitted pair ahead of this action's `run_post_action_pipeline` + // trigger scan AND ahead of its CR 704.3 SBA pass. The boundary now converges the trigger + // half for handlers that never reach that pipeline, so this hand-down is retained for the + // SBA ordering and for a drain that does not settle in its own action. + // + // On a pause this function returns at the `commit_liminal_token_entry_*` call below, so + // the unpaused tail's CR 614.12a `BecomeCopy` chain, `finish_copy_target_choice_entry`, + // and the copy continuation do not run on that route. THAT abandonment β€” of the copy + // chain and continuation, not of the entry lifecycle β€” is pre-existing and is not what + // this hand-down addresses. Dropped unused when the commit does not pause. + let paused_entry_emit: Vec = has_liminal_entry + .then_some(PendingCounterPostAction::EmitCommittedCopyTokenEntry { + object_id: source_id, + }) .into_iter() .collect(); if !super::effects::token::commit_liminal_token_entry_with_post_actions( @@ -1812,12 +1814,10 @@ pub(super) fn handle_copy_target_choice( // exceptions (CR 707.9b). let _ = effects::resolve_ability_chain(state, &ability, events, 0); let mut counter_pause_post_actions = Vec::new(); - if let Some((name, event_source_id)) = entry_events.clone() { + if has_liminal_entry { counter_pause_post_actions.push( PendingCounterPostAction::EmitCommittedCopyTokenEntry { object_id: source_id, - name, - source_id: event_source_id, }, ); } @@ -1841,15 +1841,6 @@ pub(super) fn handle_copy_target_choice( )? { return Ok(waiting_for); } - if let Some((name, event_source_id)) = entry_events { - super::effects::token::push_committed_token_entry_events( - state, - source_id, - name, - event_source_id, - events, - ); - } if let Some((owner, copy, enter_tapped, enter_with_counters, remaining_count)) = copy_continuation { @@ -1966,6 +1957,12 @@ fn finish_copy_target_choice_entry( return Ok(Some(waiting_for)); } } + // CR 400.7 + CR 403.3 + CR 614.12a: the copy is realized and every mandatory as-enters + // choice is answered β€” the first instant the token IS the thing that entered. Placed before + // the replay/batch-drain/aura blocks so their pause returns cannot strand a parked entry. + // `false` here means an earlier convergence point already realized it (structurally + // idempotent, `Option::take_if`), which is not an error. + let _ = super::effects::token::flush_pending_token_battlefield_entry(state, source_id, events); crate::game::layers::mark_layers_full(state); // CR 614.12a + CR 707.9: The battlefield-entry `ZoneChanged` event was // captured into `state.deferred_entry_events` when `CopyTargetChoice` was diff --git a/crates/engine/src/game/scenario_db.rs b/crates/engine/src/game/scenario_db.rs index a81a9993d3..64b9d43092 100644 --- a/crates/engine/src/game/scenario_db.rs +++ b/crates/engine/src/game/scenario_db.rs @@ -38,6 +38,9 @@ fn abandon_as_enters_choice_for_scenario_setup( return false; } state.deferred_entry_events.clear(); + // The abandoned as-enters prompt owns any token battlefield entry parked by this setup, and + // nothing will reach a realization point for it once the prompt is dropped. + state.pending_token_battlefield_entry = None; state.waiting_for = WaitingFor::Priority { player: controller }; true } diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index 3bfa98b926..8956d835cd 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -1158,6 +1158,14 @@ pub fn start_next_turn(state: &mut GameState, events: &mut Vec) { state.zone_changes_this_turn.clear(); state.batched_zone_change_trigger_fired.clear(); state.battlefield_entries_this_turn.clear(); + // CR 514.2 + CR 400.7: the cleanup step is where "this turn" state ends, which is the authority + // for this reset; CR 400.7 names the two ledgers above that it defends. Defence in depth only β€” + // a parked token battlefield entry is realized within the action that settles, and every + // prompt-abandonment path clears it, so none should reach a turn boundary. One that did would + // write its row onto the NEXT turn's freshly cleared ledger β€” an "entered this turn" answer for + // an entry that happened last turn. Mirrors the `deferred_entry_events` clears in + // `elimination.rs` / `scenario_db.rs`. + state.pending_token_battlefield_entry = None; // CR 701.26 + CR 603.4: reset per-object tap counts so "first time it became // tapped this turn" intervening-ifs start fresh each turn. state.object_tap_count_this_turn.clear(); diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index c9b89bc4e0..4273bc2a39 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -5168,6 +5168,17 @@ pub enum TokenEntryEventEmission { Suppress, } +/// CR 400.7: the three values a postponed token battlefield entry needs at flush time. The +/// characteristics are NOT stored β€” they are re-snapshotted from the live object at flush, which +/// is the whole point of postponing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingTokenBattlefieldEntry { + pub object_id: ObjectId, + /// The `TokenCreated` display name (the token's OWN name, not the copied source's). + pub name: String, + pub source_id: ObjectId, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum PendingCounterPostAction { EmitEffectResolved { @@ -5251,10 +5262,12 @@ pub enum PendingCounterPostAction { enter_with_counters: Vec<(CounterType, u32)>, remaining_count: u32, }, + /// CR 400.7 + CR 616.1: realize the token battlefield entry parked in + /// `GameState::pending_token_battlefield_entry` once the ETB-counter ordering choice has + /// drained. It carries only the object identity β€” the entry's `name` / `source_id` live on the + /// parked record, and its characteristics are re-snapshotted from the live object at flush. EmitCommittedCopyTokenEntry { object_id: ObjectId, - name: String, - source_id: ObjectId, }, /// CR 701.42 + CR 707.9: finish a meld instruction after a copy-as-enters /// choice whose entry counters paused on their own replacement choice. @@ -12691,6 +12704,15 @@ pub struct GameState { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub deferred_entry_events: Vec, + /// CR 400.7 + CR 403.3 + CR 614.12a: a token that was committed to the battlefield with its + /// entry EVENTS suppressed and whose CR 400.7 record has NOT been written yet, because the + /// object is not yet the thing that entered β€” `BecomeCopy` has not run and/or a mandatory + /// as-enters choice (CR 614.12a) is unanswered. Parked here so it survives an arbitrary number + /// of client round-trips; realized by the single authority + /// `crate::game::effects::token::flush_pending_token_battlefield_entry`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_token_battlefield_entry: Option, + // Layer system // CONSERVATIVE: deserialized snapshots (e.g. the WASM-export repro) rebuild // fully on first flush. The previous `bool` field serialized as `true` @@ -17813,6 +17835,7 @@ impl GameState { post_replacement_token_choice_applied: None, post_replacement_token_substitution_count: None, deferred_entry_events: Vec::new(), + pending_token_battlefield_entry: None, layers_dirty: LayersDirty::full(), static_gate_truth: im::HashMap::new(), trigger_index: TriggerIndex::default(), @@ -19361,6 +19384,7 @@ fn _gamestate_partition_is_total(s: &GameState) { replacement_may_cost_paused: _, post_replacement_token_choice_applied: _, deferred_entry_events: _, + pending_token_battlefield_entry: _, layers_dirty: _, static_gate_truth: _, trigger_index: _, @@ -19673,6 +19697,7 @@ impl PartialEq for GameState { && self.priority_pass_count == other.priority_pass_count && self.pending_replacement == other.pending_replacement && self.deferred_entry_events == other.deferred_entry_events + && self.pending_token_battlefield_entry == other.pending_token_battlefield_entry && self.layers_dirty == other.layers_dirty // `static_gate_truth` is INTENTIONALLY excluded: unlike // `layers_dirty`/`public_state_dirty` (which encode pending work), diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index 1579d2a48b..e55178196e 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/token_zone_change_index.rs b/crates/engine/tests/integration/token_zone_change_index.rs index 267826c6d0..b5da8b4f74 100644 --- a/crates/engine/tests/integration/token_zone_change_index.rs +++ b/crates/engine/tests/integration/token_zone_change_index.rs @@ -12,15 +12,17 @@ //! `(def, 0)` and its fire was silently swallowed. use engine::game::effects::{incubate, token}; -use engine::game::scenario::{GameScenario, P0}; +use engine::game::scenario::{GameRunner, GameScenario, P0}; use engine::game::triggers::{drain_order_triggers_with_identity, process_triggers}; use engine::types::ability::{ AbilityDefinition, AbilityKind, Effect, PtValue, QuantityExpr, ResolvedAbility, TargetFilter, - TriggerDefinition, + TargetRef, TriggerDefinition, }; +use engine::types::actions::GameAction; use engine::types::events::GameEvent; -use engine::types::game_state::GameState; +use engine::types::game_state::{GameState, WaitingFor}; use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; use engine::types::phase::Phase; use engine::types::triggers::TriggerMode; use engine::types::zones::Zone; @@ -407,12 +409,13 @@ fn battlefield_entries_this_turn_counts_each_token_exactly_once() { // ───────── the SUPPRESS route (CR 403.3 + CR 603.6a) ───────── // -// `finalize_committed_liminal_token_entry_from_action` records the entry through -// `push_committed_token_entry_events`, which is gated on `TokenEntryEventEmission::Emit`. The one -// `Suppress` caller is the liminal branch of `engine_replacement.rs::handle_copy_target_choice`, -// which emits the entry itself after the commit returns. Deleting the unconditional -// `record_battlefield_entry` from the finalize tail therefore leaves this route's CR 403.3 record -// entirely to that caller β€” which is what this test pins. +// `finalize_committed_liminal_token_entry_from_action` records AND emits inline only on the +// `TokenEntryEventEmission::Emit` route. On `Suppress` β€” reached solely from the liminal branch of +// `engine_replacement.rs::handle_copy_target_choice` β€” the token is on the battlefield before it is +// the thing that entered (`BecomeCopy` has not run), so the whole entry, record and events +// together, is PARKED on `GameState::pending_token_battlefield_entry` and realized later by +// `token::flush_pending_token_battlefield_entry`. This test pins that the realization happens +// exactly once on both per-turn ledgers, describing the copy rather than the pre-copy Shapeshifter. // // HONEST SCOPE β€” the PAUSED sub-route (a liminal entry carrying counters, so the commit consults // `add_counter_with_replacement` and may suspend mid-loop) is NOT covered here, and is deliberately @@ -436,10 +439,12 @@ fn battlefield_entries_this_turn_counts_each_token_exactly_once() { // copy-target route the grant is not even offered, because the token has not yet chosen what to // copy when the replacement pass runs and both grants are subtype-scoped. // -// So: unreached by the current card pool, not impossible. The post-finalize emit handed to the -// commit for the paused case is kept for that reason β€” it keeps the record local to this route -// rather than resting on a `liminal_immediate β‡’ no counters` argument that spans two files and -// holds only as long as the card-pool measurement above does. +// So: unreached by the current card pool, not impossible. The post-finalize realization handed to +// the commit for the paused case (`PendingCounterPostAction::EmitCommittedCopyTokenEntry`, +// convergence point (b) below) is kept for that reason β€” it keeps the realization inside the action +// that answers the counter-ordering choice rather than resting on a `liminal_immediate β‡’ no +// counters` argument that spans two files and holds only as long as the card-pool measurement +// above does. /// Verbatim Oracle text (Amonkhet). The Embalm line is a keyword hint so the scenario's parse /// pipeline synthesizes the graveyard-activated token-copy ability, exactly as @@ -448,19 +453,21 @@ fn battlefield_entries_this_turn_counts_each_token_exactly_once() { /// which is the only production route to `TokenEntryEventEmission::Suppress`. const VIZIER_ORACLE: &str = "You may have this creature enter as a copy of any creature on the battlefield, except if this creature was embalmed, the token has no mana cost, it's white, and it's a Zombie in addition to its other types.\nEmbalm {3}{U}{U}"; -/// CR 403.3 + CR 603.6a: a liminal copy-token entry committed with entry-event emission -/// SUPPRESSED must still land on both per-turn ledgers exactly once, and must still emit the -/// battlefield-entry event its caller defers. +/// CR 403.3 + CR 603.6a: a liminal copy-token entry committed on the `Suppress` route must land on +/// both per-turn ledgers exactly once, describing the REALIZED copy, and must emit its entry pair +/// exactly once β€” all of it from the single realization the flush performs, never half of it. /// -/// This is the route the paired deletion had to compensate. The finalize tail no longer records -/// the entry itself (`record_zone_change`, inside `push_committed_token_entry_events`, does), and -/// on this route that call is made by `handle_copy_target_choice` rather than by the finalize β€” -/// so if the emit and the record were ever separated again, the copy token would enter invisibly. +/// Record and events are one owned value (`GameState::pending_token_battlefield_entry`) consumed by +/// one function (`token::flush_pending_token_battlefield_entry`), so "recorded but never emitted" +/// and "emitted but never recorded" are both unrepresentable rather than guarded. This test pins +/// that on the production Embalm/copy-target drive; the unpaused route realizes at convergence +/// point (a), inside `engine_replacement::finish_copy_target_choice_entry`. /// -/// REVERT-PROBE (discriminating, RUN): restore the direct `snapshot_for_zone_change` emit inside -/// `push_committed_token_entry_events` (the pre-change form that never reached the recorder) while -/// keeping the deleted `record_battlefield_entry` deleted β‡’ the Embalm copy token appears in -/// NEITHER ledger and both count assertions fail with 0. +/// REVERT-PROBE (discriminating, RUN): replace the `Suppress` park in +/// `token::finalize_committed_liminal_token_entry_from_action` with the pre-lifecycle +/// `record_committed_token_entry(state, object_id);` β‡’ the row is written from the pre-copy +/// Shapeshifter (assertion (2b) reads `name: "Vizier of Many Faces"`, `power: Some(0)`) and nothing +/// is ever parked for the flush to realize, so assertion (3)'s emit count is 0. #[test] fn suppressed_liminal_copy_token_entry_is_recorded_once() { let mut scenario = GameScenario::new(); @@ -592,6 +599,83 @@ fn suppressed_liminal_copy_token_entry_is_recorded_once() { 1, "the Suppress-route copy token reaches the zone-change ledger exactly once" ); + // (2b) CR 400.7: the row must describe the state at the moment of the move β€” the REALIZED + // copy. The `Suppress` commit does NOT record: it PARKS the entry on + // `GameState::pending_token_battlefield_entry`, and + // `token::flush_pending_token_battlefield_entry` writes the row ONCE, post-`BecomeCopy`, + // from a snapshot taken at flush. Recording at commit instead would describe a 0/0 + // Shapeshifter, and the look-back consumers that read this ledger directly + // (`game/quantity.rs` zone-change scans, the `SuppressTriggers` ETB filters in + // `game/triggers.rs`) would see the pre-copy object, so "each Bear that entered the + // battlefield this turn" would miss a token that is by then a Bear. + // + // REVERT-PROBE (discriminating, RUN): move the flush call in + // `engine_replacement.rs::finish_copy_target_choice_entry` to BEFORE the `BecomeCopy` + // chain resolves β‡’ `name` reads "Vizier of Many Faces" and `power` reads `Some(0)`, + // failing here, while the count assertions (1) and (2) above stay green β€” isolating the + // flip to row CONTENT, not row COUNT. + let entry_row = runner + .state() + .zone_changes_this_turn + .iter() + .find(|r| r.object_id == token && r.to_zone == Zone::Battlefield) + .expect("the Suppress-route copy token has a zone-change row") + .clone(); + assert_eq!( + entry_row.name, "Grizzly Bears", + "the recorded entry names the copied creature, not the pre-copy Shapeshifter" + ); + assert_eq!( + entry_row.power, + Some(3), + "the recorded entry carries the copied power, not the 0/0 the token had before \ + `BecomeCopy` resolved" + ); + // (2c) …and the TWO ledgers agree. `record_zone_change` writes the zone-change row and + // calls `record_battlefield_entry`, so both describe this one entry; they back + // different typed predicates (`ZoneChangeCountThisTurn` vs `BattlefieldEntriesThisTurn`, + // the latter via `battlefield_entry_matches_filter`, which reads these very fields). + // Refreshing one without the other makes "how many Bears entered this turn" answer 1 + // on one ledger and 0 on the other. + // + // Both rows come from the SINGLE `record_zone_change` call the flush makes, so they + // cannot disagree by construction β€” that structural agreement is what this pins. + // + // REVERT-PROBE (discriminating, RUN): delete the `record_zone_change` call inside + // `token::record_committed_token_entry` and push the row onto `zone_changes_this_turn` + // directly β‡’ `battlefield_entries_this_turn` never gets its row and the + // `.expect("...has a battlefield-entry row")` below panics, while (2b) above stays + // green β€” isolating the flip to the SECOND ledger. + let battlefield_row = runner + .state() + .battlefield_entries_this_turn + .iter() + .find(|r| r.object_id == token) + .expect("the Suppress-route copy token has a battlefield-entry row") + .clone(); + assert_eq!( + battlefield_row.name, entry_row.name, + "both CR 403.3 ledgers describe the same entry, so they must name the same creature" + ); + // The measured subtypes here are ["Zombie"], and that is the FIXTURE, not a copy rule: + // `GameScenario::add_creature` (game/scenario.rs:357) sets only `CoreType::Creature` and + // P/T, so this "Grizzly Bears" has no subtypes to copy and Zombie is all that remains. + // Embalm adds it β€” `VIZIER_ORACLE` above says "a Zombie IN ADDITION TO its other types" β€” + // so nothing here is evidence about whether copy exceptions replace subtypes. Do not read + // it as such. + // + // These two therefore assert ledger AGREEMENT rather than a concrete subtype; `name` above + // is what carries the discrimination, since (2b) already pins it to a concrete post-copy + // value. Both rows snapshot the same live object, so a typed query cannot get one answer + // from `battlefield_entry_matches_filter` and a different one from a zone-change scan. + assert_eq!( + battlefield_row.subtypes, entry_row.subtypes, + "both CR 403.3 ledgers snapshot the same object, so their subtypes agree" + ); + assert_eq!( + battlefield_row.core_types, entry_row.core_types, + "both CR 403.3 ledgers snapshot the same object, so their core types agree" + ); // (3) The deferred emit really happened, carrying the recorder-assigned index (CR 603.6a + // CR 400.7). Read off the `ActionResult` of the copy-target submission itself, which is // the action that runs the whole Suppress tail. @@ -620,3 +704,811 @@ fn suppressed_liminal_copy_token_entry_is_recorded_once() { // (`replay_deferred_entry_events` takes that vector EMPTY before this emit happens). Recorded // as a follow-up with the symptom only, not fixed here. } + +// ───────── the POSTPONED entry lifecycle (CR 400.7 + CR 403.3 + CR 614.12a) ───────── +// +// A `Suppress`-route token is committed to the battlefield BEFORE it is the thing that entered: +// `BecomeCopy` has not run, and the copied card's own mandatory as-enters choice (CR 614.12a) is +// unanswered. Its CR 400.7 record and its CR 603.6a entry events are therefore PARKED on +// `GameState::pending_token_battlefield_entry` and realized as one indivisible operation by +// `token::flush_pending_token_battlefield_entry` at the first instant the object IS that thing. +// +// Three convergence points call that one flush, and a fourth defensive call in the `Suppress` arm +// itself (`token.rs`) exists only so parking over a live entry cannot lose it. (a) is pinned +// INDEPENDENTLY β€” deleting it alone flips its test. (b) and the in-`apply_action` half of (c) are +// EXERCISED, not isolated: on every route the card pool reaches, the action-boundary half of (c) +// converges the same work, so deleting either one alone flips nothing (measured). They are kept for +// CR 704.3 ordering β€” the CR 400.7 row must be written before the settling action's SBA pass so +// CR 704.5f cannot bury a 0-toughness copy first β€” and, for (b), for a drain that does not settle +// in its own action. Their tests still discriminate the flush lifecycle as a whole (delete the park +// and every route test fails). +// (a) `engine_replacement::finish_copy_target_choice_entry` β€” the unpaused route +// (`suppressed_liminal_copy_token_entry_is_recorded_once`, above). +// (b) `PendingCounterPostAction::EmitCommittedCopyTokenEntry` β€” the CR 616.1 ETB-counter +// ordering pause (`..._realizes_through_an_etb_counter_ordering_pause`). +// (c) `token::realize_settled_token_battlefield_entry` β€” every other pause shape, however many +// round trips it takes (`..._through_a_mandatory_as_enters_choice`, +// `..._that_raises_a_second_pause`). One gate (settled `Priority` + token still on the +// battlefield) called from two places in `engine.rs`: inside `apply_action` before +// `run_post_action_pipeline`, and at the action boundary, where a realization now also runs +// `run_post_action_pipeline_from` over the slice it appended so the handlers that return an +// `ActionResult` straight out of the reducer match (`handle_tribute_choice`) get the same +// CR 603.6a check. Both tests measure +1 life; what distinguishes them is WHERE the pipeline +// runs, pinned by the presence of `OrderTriggers(2)` on the Fanatic route. + +/// Verbatim Oracle text from `data/card-data.json` (paraphrases can take a different parser +/// branch, so the fixtures below must use the real strings). +const PAINTERS_SERVANT_ORACLE: &str = "As this creature enters, choose a color.\nAll cards that aren't on the battlefield, spells, and permanents are the chosen color in addition to their other colors."; +const FANATIC_OF_XENAGOS_ORACLE: &str = "Trample\nTribute 1 (As this creature enters, an opponent of your choice may put a +1/+1 counter on it.)\nWhen this creature enters, if tribute wasn't paid, it gets +1/+1 and gains haste until end of turn."; +const FAITHFUL_WATCHDOG_ORACLE: &str = + "Vigilance\nThis creature enters with three +1/+1 counters on it."; +const HARDENED_SCALES_ORACLE: &str = "If one or more +1/+1 counters would be put on a creature you control, that many plus one +1/+1 counters are put on it instead."; +const BRANCHING_EVOLUTION_ORACLE: &str = "If one or more +1/+1 counters would be put on a creature you control, twice that many +1/+1 counters are put on that creature instead."; +const SOUL_WARDEN_ORACLE: &str = "Whenever another creature enters, you gain 1 life."; + +/// What one answered prompt did to the token's entry: the events its `ActionResult` carried and +/// both per-turn ledgers as of immediately after it returned. +#[derive(Debug, Clone)] +struct CopyEntryStep { + /// The prompt label this step answered (mirrors `CopyEntryDrive::prompts` positionally). + answered: String, + /// `turn_zone_change_index` of every battlefield `ZoneChanged` this action emitted FOR THE + /// TOKEN. + zone_changed_indices: Vec, + /// How many `TokenCreated` events this action emitted for the token. + tokens_created: usize, + /// CR 400.7 rows for the token on `zone_changes_this_turn` after this action. + zone_rows: usize, + /// CR 403.3 rows for the token on `battlefield_entries_this_turn` after this action. + entry_rows: usize, + /// Whether an entry is still parked awaiting realization after this action. + parked: bool, +} + +#[derive(Debug)] +struct CopyEntryDrive { + prompts: Vec, + steps: Vec, + token: Option, +} + +impl CopyEntryDrive { + fn token(&self) -> ObjectId { + self.token.unwrap_or_else(|| { + panic!( + "the Embalm token must reach its copy-target prompt; prompts seen = {:?}", + self.prompts + ) + }) + } +} + +/// Put a graveyard Vizier of Many Faces with its synthesized Embalm ability in play, and stage the +/// {3}{U}{U} it costs into P0's pool. +fn stage_embalm_vizier(scenario: &mut GameScenario) -> ObjectId { + let vizier = scenario + .add_creature_to_graveyard(P0, "Vizier of Many Faces", 0, 0) + .with_mana_cost(ManaCost::Cost { + generic: 3, + shards: vec![ManaCostShard::Blue], + }) + .from_oracle_text_with_keywords(&["Embalm"], VIZIER_ORACLE) + .id(); + scenario.with_mana_pool( + P0, + [ + ManaType::Blue, + ManaType::Blue, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ] + .into_iter() + .map(|m| ManaUnit::new(m, ObjectId(0), false, vec![])) + .collect(), + ); + vizier +} + +fn token_entry_step( + runner: &GameRunner, + token: Option, + answered: String, + events: &[GameEvent], +) -> CopyEntryStep { + let matches_token = |id: ObjectId| token == Some(id); + CopyEntryStep { + answered, + zone_changed_indices: events + .iter() + .filter_map(|event| match event { + GameEvent::ZoneChanged { record, to, .. } + if matches_token(record.object_id) && *to == Zone::Battlefield => + { + Some(record.turn_zone_change_index) + } + _ => None, + }) + .collect(), + tokens_created: events + .iter() + .filter(|event| { + matches!(event, GameEvent::TokenCreated { object_id, .. } if matches_token(*object_id)) + }) + .count(), + zone_rows: runner + .state() + .zone_changes_this_turn + .iter() + .filter(|record| matches_token(record.object_id) && record.to_zone == Zone::Battlefield) + .count(), + entry_rows: runner + .state() + .battlefield_entries_this_turn + .iter() + .filter(|record| matches_token(record.object_id)) + .count(), + parked: runner.state().pending_token_battlefield_entry.is_some(), + } +} + +/// Activate the graveyard Vizier's Embalm ability and answer every prompt the resulting token +/// entry raises, recording each answer's effect on the two CR 400.7 / CR 403.3 ledgers. +/// +/// `copy_target` names the battlefield creature the copy-target prompt must pick; `None` DECLINES +/// the "enter as a copy" replacement, which routes the entry through `TokenEntryEventEmission::Emit` +/// instead (the positive control). Later `ReplacementChoice` prompts are the CR 616.1 ETB-counter +/// ordering choice and always take the first ordering. +fn drive_embalm_copy( + runner: &mut GameRunner, + vizier: ObjectId, + copy_target: Option<&str>, +) -> CopyEntryDrive { + let embalm_index = runner.state().objects[&vizier] + .abilities + .iter() + .position(|ability| matches!(&*ability.effect, Effect::CopyTokenOf { .. })) + .expect("the synthesized Embalm ability is on the graveyard Vizier"); + runner + .act(GameAction::ActivateAbility { + source_id: vizier, + ability_index: embalm_index, + }) + .expect("activate Embalm"); + + let mut drive = CopyEntryDrive { + prompts: Vec::new(), + steps: Vec::new(), + token: None, + }; + let mut replacements_answered = 0_usize; + for _ in 0..64 { + let (label, action) = match runner.state().waiting_for.clone() { + WaitingFor::ManaPayment { .. } | WaitingFor::Priority { .. } => { + // Settled: the entry finished (the copy route knows its token id; the declined + // route never gets one) and nothing is left resolving. Anything further would be + // the turn advancing, which clears the per-turn ledgers under the assertions. + let entry_done = drive.token.is_some() || copy_target.is_none(); + if entry_done && runner.state().stack.is_empty() { + break; + } + runner.act(GameAction::PassPriority).expect("pass priority"); + continue; + } + WaitingFor::ReplacementChoice { candidates, .. } => { + // The FIRST replacement choice is Vizier's own optional "enter as a copy" + // (index 1 declines it); any later one is the CR 616.1 ordering between two + // ETB-counter replacements, where either ordering reaches this seam. + let index = usize::from(replacements_answered == 0 && copy_target.is_none()); + replacements_answered += 1; + ( + format!("ReplacementChoice({})", candidates.len()), + GameAction::ChooseReplacement { index }, + ) + } + WaitingFor::CopyTargetChoice { + source_id, + valid_targets, + .. + } => { + let wanted = copy_target.expect("declining must not raise a copy-target prompt"); + let target = *valid_targets + .iter() + .find(|id| { + runner + .state() + .objects + .get(id) + .is_some_and(|object| object.name == wanted) + }) + .unwrap_or_else(|| panic!("{wanted} must be a legal copy target")); + drive.token = Some(source_id); + ( + "CopyTargetChoice".to_string(), + GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }, + ) + } + WaitingFor::NamedChoice { options, .. } => ( + format!("NamedChoice({})", options.len()), + GameAction::ChooseOption { + choice: options + .first() + .expect("a mandatory named choice offers at least one option") + .clone(), + }, + ), + // CR 702.104a: decline the tribute so the companion "if tribute wasn't paid" trigger + // also runs β€” the longest continuation this class produces. + WaitingFor::TributeChoice { .. } => ( + "TributeChoice".to_string(), + GameAction::DecideOptionalEffect { accept: false }, + ), + // CR 603.3b: a realized entry can trigger two same-controller abilities at once (the + // copy's own ETB plus a battlefield observer), which surfaces an ordering prompt. + WaitingFor::OrderTriggers { triggers, .. } => ( + format!("OrderTriggers({})", triggers.len()), + GameAction::OrderTriggers { + order: (0..triggers.len()).collect(), + }, + ), + other => { + drive.prompts.push(format!("{other:?}")); + break; + } + }; + let result = runner + .act(action) + .unwrap_or_else(|err| panic!("answering {label} failed: {err:?}")); + drive.prompts.push(label.clone()); + let step = token_entry_step(runner, drive.token, label, &result.events); + drive.steps.push(step); + } + runner.advance_until_stack_empty(); + drive +} + +/// Both per-turn ledgers' single row for `token`, panicking (with the drive's prompt trace) when +/// either is missing. +fn entry_rows( + runner: &GameRunner, + token: ObjectId, + drive: &CopyEntryDrive, +) -> (String, Option, String) { + let zone_row = runner + .state() + .zone_changes_this_turn + .iter() + .find(|record| record.object_id == token && record.to_zone == Zone::Battlefield) + .unwrap_or_else(|| { + panic!( + "the realized copy token must have a CR 400.7 zone-change row; prompts = {:?}", + drive.prompts + ) + }); + let battlefield_row = runner + .state() + .battlefield_entries_this_turn + .iter() + .find(|record| record.object_id == token) + .unwrap_or_else(|| { + panic!( + "the realized copy token must have a CR 403.3 battlefield-entry row; prompts = {:?}", + drive.prompts + ) + }); + ( + zone_row.name.clone(), + zone_row.power, + battlefield_row.name.clone(), + ) +} + +fn ledger_index(runner: &GameRunner, token: ObjectId) -> usize { + runner + .state() + .zone_changes_this_turn + .iter() + .position(|record| record.object_id == token && record.to_zone == Zone::Battlefield) + .expect("the entry is on the CR 400.7 ledger") +} + +/// CR 400.7 + CR 403.3 + CR 614.12a β€” the maintainer's named failure path. Embalm Vizier of Many +/// Faces copying Painter's Servant: the copy carries Painter's MANDATORY "as this creature enters, +/// choose a color" replacement, so the entry pauses on a `NamedChoice` that spans a client round +/// trip. Both ledgers must describe the REALIZED copy exactly once, and the entry pair must be +/// emitted exactly once, on the action that finally settles. +/// +/// REVERT-PROBE (discriminating, RUN): delete the +/// `token::realize_settled_token_battlefield_entry` call in `engine::apply_action_boundary_core` +/// AND the one in `engine::apply_action` β‡’ the `ChooseOption` step carries no entry events and both +/// ledgers stay at 0 rows, failing the four post-flush assertions, while +/// `suppressed_liminal_copy_token_entry_is_recorded_once` (convergence point (a)) and +/// `..._realizes_through_an_etb_counter_ordering_pause` (convergence point (b)) stay green. +/// +/// SECOND REVERT-PROBE, isolating the CONVERGENCE as a whole (discriminating, RUN): deleting only +/// the `apply_action` call now flips NOTHING β€” the action-boundary call realizes the entry and runs +/// `run_post_action_pipeline_from` over the slice it appended, so the observer still fires. What +/// still flips this test's Soul Warden assertion 1 β†’ 0 is deleting the boundary block's pipeline +/// call as well; the two placements now differ only in ordering against this action's CR 704.3 SBA +/// pass, which no fixture on this route discriminates. +#[test] +fn suppressed_liminal_copy_token_entry_realizes_through_a_mandatory_as_enters_choice() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + scenario.add_creature_from_oracle(P0, "Painter's Servant", 1, 3, PAINTERS_SERVANT_ORACLE); + scenario.add_creature_from_oracle(P0, "Soul Warden", 1, 1, SOUL_WARDEN_ORACLE); + let mut runner = scenario.build(); + let life_start = life_of_p0(runner.state()); + + let drive = drive_embalm_copy(&mut runner, vizier, Some("Painter's Servant")); + // POSITIVE reach-guard: the mandatory as-enters pause was actually reached. Without it every + // assertion below could be about a route that never postponed anything. + assert_eq!( + drive.prompts, + vec![ + "ReplacementChoice(2)".to_string(), + "CopyTargetChoice".to_string(), + "NamedChoice(5)".to_string(), + ], + "the copy's own CR 614.12a colour choice must pause the entry" + ); + let token = drive.token(); + + // (1) PRE-FLUSH NEGATIVE, paired with the reach-guard above: at the `NamedChoice` pause the + // entry is postponed β€” no row on either ledger, no event emitted, and the entry is parked. + let copy_step = &drive.steps[1]; + assert_eq!( + (copy_step.zone_rows, copy_step.entry_rows), + (0, 0), + "the entry is postponed until the copy IS the thing that entered (CR 614.12a)" + ); + assert_eq!( + ( + copy_step.zone_changed_indices.len(), + copy_step.tokens_created + ), + (0, 0), + "nothing is emitted for the token while its as-enters choice is unanswered" + ); + assert!( + copy_step.parked, + "the postponed entry is parked on GameState so it survives the round trip" + ); + + // (2) DISCRIMINATOR: the realizing action writes ONE row on EACH ledger, describing the + // copied creature β€” not the 0/0 pre-copy Shapeshifter the head recorded here. + let settled = &drive.steps[2]; + assert_eq!( + (settled.zone_rows, settled.entry_rows), + (1, 1), + "the realized entry lands on both CR 400.7 / CR 403.3 ledgers exactly once" + ); + assert!( + !settled.parked, + "the parked entry is consumed by its realization" + ); + let (zone_name, zone_power, battlefield_name) = entry_rows(&runner, token, &drive); + assert_eq!( + zone_name, "Painter's Servant", + "the recorded entry names the copied creature, not the pre-copy Shapeshifter" + ); + assert_eq!( + zone_power, + Some(1), + "the recorded entry carries the copied power, not the 0/0 the token had before BecomeCopy" + ); + assert_eq!( + battlefield_name, zone_name, + "both CR 403.3 ledgers are written by the one record_zone_change call, so they agree" + ); + + // (3) The emit rides the SAME action that realized the entry, exactly once, carrying the + // recorder-assigned `turn_zone_change_index`. That index is the engine's own key β€” the CR + // does not name it β€” and the batched zone-change replay guard dedups on it to hold the + // CR 603.2c once-per-occurrence bound (same framing as this file's module header). + assert_eq!( + settled.tokens_created, 1, + "the entry pair is emitted exactly once, on the realizing action" + ); + assert_eq!( + settled.zone_changed_indices, + vec![ledger_index(&runner, token)], + "the emitted ZoneChanged carries the index the recorder assigned" + ); + + // (4) CR 603.2 + CR 603.6a: the pair is emitted from inside `apply_action`, ahead of + // `run_post_action_pipeline`, so this action's trigger scan sees the token enter and the + // board's ETB observers fire. The action-boundary convergence would also produce +1 here + // (it runs the same pipeline over the slice it appends), so this assertion pins THAT the + // observer fires, not WHERE the realization happened; the Fanatic test's `OrderTriggers(2)` + // is what pins the boundary route specifically. + assert_eq!( + life_of_p0(runner.state()) - life_start, + 1, + "Soul Warden observes the realized copy token entering; prompts = {:?}", + drive.prompts + ); +} + +/// CR 400.7 + CR 614.12a + CR 702.104a β€” the SECOND-PAUSE class. Fanatic of Xenagos's as-enters +/// `Choose(Opponent)` continuation raises a `TributeChoice`, so the entry spans TWO client round +/// trips of two different prompt shapes. This is the shape a fix hung off any single prompt +/// variant's resume arm cannot see. +/// +/// REVERT-PROBE (discriminating, RUN): delete the `run_post_action_pipeline_from` block in +/// `engine::apply_action_boundary_core` (leaving the bare realize call) β‡’ the reach-guard below +/// loses its `"OrderTriggers(2)"` element and fails first, and the Soul Warden assertion goes +/// 1 β†’ 0. No other test in this file moves. +/// +/// CR 603.6a (`docs/MagicCompRules.txt:2599`): this class settles through `handle_tribute_choice`, +/// which builds its `ActionResult` directly in the reducer match and never reaches +/// `run_post_action_pipeline`, so the action-boundary convergence is what runs the ETB check for +/// it. TWO abilities trigger β€” Soul Warden's observer and the copy's own CR 603.4 "if tribute +/// wasn't paid" ETB β€” same controller, so CR 603.3b makes their order the controller's choice and +/// the ordering prompt is REQUIRED here, not an artifact of the harness. +#[test] +fn suppressed_liminal_copy_token_entry_realizes_through_an_as_enters_choice_with_a_second_pause() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + scenario + .add_creature(P0, "Fanatic of Xenagos", 3, 3) + .from_oracle_text_with_keywords(&["Trample", "Tribute"], FANATIC_OF_XENAGOS_ORACLE); + scenario.add_creature_from_oracle(P0, "Soul Warden", 1, 1, SOUL_WARDEN_ORACLE); + let mut runner = scenario.build(); + let life_start = life_of_p0(runner.state()); + + let drive = drive_embalm_copy(&mut runner, vizier, Some("Fanatic of Xenagos")); + // POSITIVE reach-guard: the SECOND pause was reached. A fixture that stopped at the + // `NamedChoice` would exercise the same route as the Painter test. + assert_eq!( + drive.prompts, + vec![ + "ReplacementChoice(2)".to_string(), + "CopyTargetChoice".to_string(), + "NamedChoice(1)".to_string(), + "TributeChoice".to_string(), + "OrderTriggers(2)".to_string(), + ], + "the tribute continuation raises a SECOND pause, and the realized entry then raises the \ + CR 603.3b ordering prompt for its two ETB triggers" + ); + let token = drive.token(); + + // (1) PRE-FLUSH NEGATIVE at BOTH intermediate pauses, paired with the reach-guard above. + for step in &drive.steps[1..3] { + assert_eq!( + (step.zone_rows, step.entry_rows), + (0, 0), + "nothing is recorded at the {:?} pause", + step.answered + ); + assert_eq!( + (step.zone_changed_indices.len(), step.tokens_created), + (0, 0), + "nothing is emitted at the {:?} pause", + step.answered + ); + assert!( + step.parked, + "the entry stays parked across the {:?} pause", + step.answered + ); + } + + // (2) DISCRIMINATOR: post-copy identity survives TWO round trips, once per ledger. + let settled = &drive.steps[3]; + assert_eq!( + (settled.zone_rows, settled.entry_rows), + (1, 1), + "the realized entry lands on both ledgers exactly once after two pauses" + ); + assert!( + !settled.parked, + "the parked entry is consumed by its realization" + ); + let (zone_name, zone_power, battlefield_name) = entry_rows(&runner, token, &drive); + assert_eq!(zone_name, "Fanatic of Xenagos"); + assert_eq!(zone_power, Some(3)); + assert_eq!(battlefield_name, zone_name); + + // (3) The emit rides the action that finally settled. + assert_eq!( + settled.tokens_created, 1, + "the entry pair is emitted exactly once, on the action that settled" + ); + assert_eq!( + settled.zone_changed_indices, + vec![ledger_index(&runner, token)], + "the emitted ZoneChanged carries the index the recorder assigned" + ); + + // (4) CR 603.6a (`MagicCompRules.txt:2599`): the realized entry is the event that put a + // permanent onto the battlefield, so every permanent is checked for matching ETB triggers. + // `handle_tribute_choice` builds its `ActionResult` straight out of the reducer match, so + // the action-boundary convergence in `apply_action_boundary_core` is what runs that check + // for this class. TWO triggers fire (Soul Warden's observer and Fanatic's own CR 603.4 + // "if tribute wasn't paid" ETB) β€” the `OrderTriggers(2)` element of the reach-guard above + // pins that, and this assertion pins that the observer actually resolved. + assert_eq!( + life_of_p0(runner.state()) - life_start, + 1, + "Soul Warden observes the realized copy token entering through the direct-return handler; \ + prompts = {:?}", + drive.prompts + ); +} + +/// CR 400.7 + CR 616.1 β€” convergence point (b). Copying Faithful Watchdog ("enters with three +/// +1/+1 counters") while Hardened Scales and Branching Evolution both want to modify that counter +/// event forces the CR 616.1 ordering choice, which pauses the entry INSIDE the counter pipeline. +/// Realizing there puts the entry pair into `events` before this action's trigger scan AND before +/// its CR 704.3 SBA pass. The action-boundary convergence would also make the observers fire on +/// this fixture (it runs the same pipeline over the slice it appends); what (b) and the +/// in-`apply_action` call own, and the boundary does not, is that SBA ordering β€” (b) additionally +/// owns a drain that does NOT settle in its own action. +/// +/// REVERT-PROBE (discriminating, RUN): delete the park itself (`token.rs`'s `Suppress` arm stores +/// nothing) β‡’ every ledger, emit and observer assertion in this test fails. Deleting the two +/// IN-ACTION realization points β€” the flush call in +/// `counters::apply_pending_counter_post_action`'s `EmitCommittedCopyTokenEntry` arm AND +/// `token::realize_settled_token_battlefield_entry` inside `engine::apply_action` β€” no longer flips +/// anything here: this fixture's counter-order answer settles to `Priority`, so the action-boundary +/// convergence realizes the entry and runs `run_post_action_pipeline_from` over it in the same +/// action. What those two still own is CR 704.3 ordering (row before the SBA pass), which this +/// fixture does not discriminate. +#[test] +fn suppressed_liminal_copy_token_entry_realizes_through_an_etb_counter_ordering_pause() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + scenario + .add_creature(P0, "Faithful Watchdog", 0, 0) + .with_plus_counters(3) + .from_oracle_text_with_keywords(&["Vigilance"], FAITHFUL_WATCHDOG_ORACLE); + scenario.add_enchantment_from_oracle(P0, "Hardened Scales", HARDENED_SCALES_ORACLE); + scenario.add_enchantment_from_oracle(P0, "Branching Evolution", BRANCHING_EVOLUTION_ORACLE); + scenario.add_creature_from_oracle(P0, "Soul Warden", 1, 1, SOUL_WARDEN_ORACLE); + let mut runner = scenario.build(); + let life_start = life_of_p0(runner.state()); + + let drive = drive_embalm_copy(&mut runner, vizier, Some("Faithful Watchdog")); + // POSITIVE reach-guard: the SECOND `ReplacementChoice` is the CR 616.1 ordering pause. Without + // it this fixture would be the unpaused route the (a) test already covers. + assert_eq!( + drive.prompts, + vec![ + "ReplacementChoice(2)".to_string(), + "CopyTargetChoice".to_string(), + "ReplacementChoice(2)".to_string(), + ], + "two competing +1/+1 counter replacements must raise the CR 616.1 ordering choice" + ); + let token = drive.token(); + + // (1) The entry is postponed across the counter pause, exactly as across a named choice. + let copy_step = &drive.steps[1]; + assert_eq!( + (copy_step.zone_rows, copy_step.entry_rows), + (0, 0), + "nothing is recorded while the CR 616.1 ordering choice is open" + ); + assert!( + copy_step.parked, + "the entry is parked across the counter pause" + ); + + // (2) The counter-order answer realizes it, once per ledger, post-copy. + let settled = &drive.steps[2]; + assert_eq!( + (settled.zone_rows, settled.entry_rows), + (1, 1), + "the realized entry lands on both ledgers exactly once" + ); + assert_eq!( + settled.tokens_created, 1, + "the entry pair rides the counter-order answer" + ); + let (zone_name, _zone_power, battlefield_name) = entry_rows(&runner, token, &drive); + assert_eq!(zone_name, "Faithful Watchdog"); + assert_eq!(battlefield_name, zone_name); + + // (3) The pair is emitted BEFORE this action's trigger scan, so a board ETB observer sees the + // token enter (CR 603.2). + assert_eq!( + life_of_p0(runner.state()) - life_start, + 1, + "Soul Warden observes the copy token entering (CR 603.6a); deleting the park entirely is \ + what takes this to 0" + ); +} + +/// POSITIVE CONTROL (CR 603.6a): declining the "enter as a copy" replacement routes the same +/// fixture through `TokenEntryEventEmission::Emit`, which records and emits inline at the finalize +/// tail and never parks anything. Proves the instrument the tests above use is not blind β€” the +/// same drive, the same assertions, a different lifecycle half. +#[test] +fn declined_copy_replacement_records_the_token_entry_without_parking_it() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + scenario.add_creature_from_oracle(P0, "Painter's Servant", 1, 3, PAINTERS_SERVANT_ORACLE); + scenario.add_creature_from_oracle(P0, "Soul Warden", 1, 1, SOUL_WARDEN_ORACLE); + let mut runner = scenario.build(); + let life_start = life_of_p0(runner.state()); + + let drive = drive_embalm_copy(&mut runner, vizier, None); + // POSITIVE reach-guard: the enter-as-a-copy replacement really was offered and declined. + assert_eq!( + drive.prompts, + vec!["ReplacementChoice(2)".to_string()], + "declining the copy replacement raises no copy-target prompt" + ); + assert!( + drive.steps.iter().all(|step| !step.parked), + "the Emit route never parks an entry" + ); + assert!( + runner.state().pending_token_battlefield_entry.is_none(), + "no entry is left parked once the drive settles" + ); + + // The Embalm token entered under its OWN identity, once per ledger. It is a 0/0 Shapeshifter + // copy of Vizier with no copy target chosen, so CR 704.5f puts it into the graveyard right + // after β€” the ENTRY still happened and is still recorded, which is the point. + let entry = runner.state().battlefield_entries_this_turn.to_vec(); + assert_eq!( + entry.len(), + 1, + "the declined route records exactly one battlefield entry (the Embalm token's)" + ); + let token = entry[0].object_id; + assert_eq!( + entry[0].name, "Vizier of Many Faces", + "the Emit route records the token's OWN identity" + ); + assert_eq!( + runner + .state() + .zone_changes_this_turn + .iter() + .filter(|record| record.object_id == token && record.to_zone == Zone::Battlefield) + .count(), + 1, + "the Emit-route token is recorded on the CR 400.7 ledger exactly once" + ); + assert_eq!( + runner + .state() + .battlefield_entries_this_turn + .iter() + .filter(|record| record.object_id == token) + .count(), + 1, + "the Emit-route token is recorded on the CR 403.3 ledger exactly once" + ); + assert_eq!( + life_of_p0(runner.state()) - life_start, + 1, + "Soul Warden observes the plain Embalm token entering β€” the instrument is not blind" + ); +} + +/// CR 603.2c β€” a postponed entry must not collide with a normally-recorded one. The realized copy +/// token and a plain `Effect::Token` batch minted in the SAME turn (the `Emit` path, through +/// `push_committed_token_entry_events` β†’ `record_committed_token_entry` β†’ `record_zone_change`) +/// must occupy DISTINCT `turn_zone_change_index` values, because the batched zone-change replay +/// guard dedups on that index. +/// +/// The second producer is deliberately NOT `token_copy.rs`'s `record_battlefield_entry` sites: +/// those never reach `record_zone_change`, so they have no `zone_changes_this_turn` row to compare +/// against and the assertion would be vacuous. +/// +/// REVERT-PROBE (discriminating, RUN): delete the `record_zone_change` call inside +/// `token::record_committed_token_entry` (push onto `zone_changes_this_turn` directly, leaving the +/// snapshot's `0` placeholder) β‡’ the copy token and the minted tokens all report index `0` and the +/// distinctness assertion fails. +#[test] +fn a_realized_copy_token_entry_and_a_same_turn_token_batch_take_distinct_indices() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + let painter = scenario + .add_creature_from_oracle(P0, "Painter's Servant", 1, 3, PAINTERS_SERVANT_ORACLE) + .id(); + let mut runner = scenario.build(); + + let drive = drive_embalm_copy(&mut runner, vizier, Some("Painter's Servant")); + // POSITIVE reach-guard: the postponed route ran, so the index below is a REALIZED entry's. + assert_eq!( + drive.prompts, + vec![ + "ReplacementChoice(2)".to_string(), + "CopyTargetChoice".to_string(), + "NamedChoice(5)".to_string(), + ], + ); + let token = drive.token(); + let copy_index = ledger_index(&runner, token); + let turn_start = runner.state().turn_number; + + let minted = mint_token_batch(runner.state_mut(), painter, 2); + assert_eq!( + runner.state().turn_number, + turn_start, + "both producers are in the SAME turn (the dedup ledger is per-turn)" + ); + let minted_indices = zone_change_indices(&minted); + assert_eq!( + minted_indices.len(), + 2, + "the Emit-path batch emits one ZoneChanged per token" + ); + assert!( + minted_indices.iter().all(|index| *index != copy_index), + "the realized copy entry ({copy_index}) must not share an index with the same-turn \ + token batch ({minted_indices:?})" + ); +} + +/// CR 400.7 + CR 603.6a β€” convergence point (a). On the UNPAUSED copy route the entry is realized +/// inside `finish_copy_target_choice_entry`, i.e. during the action that answers the copy-target +/// prompt. The settled-`Priority` backstop cannot substitute for it: this action does not settle +/// (a stale second `CopyTargetChoice` is a known pre-existing defect on this route), so the +/// backstop would slip the row and the emit into a LATER action β€” one client round trip late, with +/// an empty CR 400.7 look-back in between. +/// +/// REVERT-PROBE (discriminating, RUN): delete the flush call in +/// `engine_replacement::finish_copy_target_choice_entry` β‡’ the FIRST copy-target answer emits +/// nothing and both ledgers are still empty after it, failing here, while +/// `..._through_a_mandatory_as_enters_choice`, `..._with_a_second_pause` and +/// `..._an_etb_counter_ordering_pause` stay green (they realize at (c) / (b)). +#[test] +fn unpaused_copy_token_entry_is_realized_by_the_copy_target_action_itself() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + scenario.add_creature(P0, "Grizzly Bears", 2, 2); + let mut runner = scenario.build(); + + let drive = drive_embalm_copy(&mut runner, vizier, Some("Grizzly Bears")); + // POSITIVE reach-guard: the copy-target prompt is the only production entrance to the + // postponed (`Suppress`) route, and this route raises no as-enters pause after it. + assert_eq!( + drive.prompts[..2], + [ + "ReplacementChoice(2)".to_string(), + "CopyTargetChoice".to_string() + ], + "the unpaused route reaches the copy-target prompt with no intervening pause" + ); + let token = drive.token(); + + let copy_step = &drive.steps[1]; + assert_eq!( + (copy_step.zone_rows, copy_step.entry_rows), + (1, 1), + "the FIRST copy-target answer realizes the entry on both ledgers, in its own action" + ); + assert_eq!( + copy_step.tokens_created, 1, + "the entry pair rides that same action's ActionResult, not a later one" + ); + assert_eq!( + copy_step.zone_changed_indices, + vec![ledger_index(&runner, token)], + "the emitted ZoneChanged carries the index the recorder assigned" + ); + assert!( + !copy_step.parked, + "nothing is left parked once the copy completes with no as-enters pause" + ); + // Post-copy identity, exactly once β€” the same pins the other three routes carry. + let (zone_name, zone_power, battlefield_name) = entry_rows(&runner, token, &drive); + assert_eq!(zone_name, "Grizzly Bears"); + assert_eq!(zone_power, Some(2)); + assert_eq!(battlefield_name, zone_name); +}