diff --git a/client/src/game/controllers/__tests__/aiController.test.ts b/client/src/game/controllers/__tests__/aiController.test.ts index ba83e3174b..4fb314cecc 100644 --- a/client/src/game/controllers/__tests__/aiController.test.ts +++ b/client/src/game/controllers/__tests__/aiController.test.ts @@ -38,6 +38,7 @@ let storeState: { isResolvingAll: boolean; }; let storeSubscriber: (() => void) | null = null; +let randomSpy: ReturnType; vi.mock("../../../stores/gameStore", () => ({ useGameStore: { @@ -82,6 +83,10 @@ function deferred() { beforeEach(() => { vi.useFakeTimers(); + // Keep retry timing deterministic: `runOnce` advances a fixed 1s window, + // while production delay variance would otherwise allow a variable number + // of nested retries to fit inside that same window. + randomSpy = vi.spyOn(Math, "random").mockReturnValue(1); dispatchAiActionProposal.mockReset(); notifyEngineLost.mockReset(); attemptStateRehydrate.mockReset(); @@ -101,6 +106,7 @@ beforeEach(() => { afterEach(() => { storeSubscriber = null; + randomSpy.mockRestore(); vi.useRealTimers(); }); diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index c2c77d8aee..58edabd9c8 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -69,7 +69,8 @@ use super::oracle_ir::diagnostic::OracleDiagnostic; use super::oracle_ir::doc::{ stamp_printed_ability_slot, stamp_printed_trigger_slot, OracleDocBuilder, OracleDocIr, OracleItemId, OracleItemIr, OracleNodeIr, OracleSourceSpan, OracleUnitSource, - PrintedAbilityIndex, PrintedTriggerIndex, SpellPayloadIr, UnsupportedAbilityIr, + PrintedAbilityIndex, PrintedTriggerIndex, RelationSynthesisIr, SpellPayloadIr, + UnsupportedAbilityIr, }; use super::oracle_ir::effect_chain::{ AbilityIr, AbilityRootTransform, AbilityShellIr, EffectChainIr, ResidualConditionPolicy, @@ -1273,11 +1274,44 @@ fn item_static(item: &OracleItemIr) -> Option<&StaticDefinition> { /// list, pairing producer/consumer items by `OracleItemId`. Runs at parse time; /// both the main and Class document-construction paths converge here. fn finalize_document_relations(mut doc: OracleDocIr, types: &[String]) -> OracleDocIr { - doc.relations - .extend(detect_document_relations(&doc.items, types)); + let relations = detect_document_relations(&doc.items, types); + finalize_relation_syntheses(&mut doc, &relations); + doc.relations.extend(relations); doc } +/// Install relation-derived nodes onto their already-emitted source item. This +/// preserves identity, source provenance, source order, and the builder's +/// historical printed-slot accounting; the builder deliberately cannot emit a +/// relation synthesis as a fresh item. +fn finalize_relation_syntheses(doc: &mut OracleDocIr, relations: &[DocumentRelationIr]) { + for relation in relations { + let DocumentRelationIr::LinkedChoice(LinkedChoiceKind::CopyChosenHost { + chooser, + copy_static, + filter, + description, + }) = relation + else { + continue; + }; + let Some(item) = doc.items.iter_mut().find(|item| item.id == *chooser) else { + continue; + }; + // Fail closed if a relation producer no longer names the unsupported + // chooser form it proved during discovery. Never overwrite another IR + // kind just because its id happens to match. + if !matches!(&item.node, OracleNodeIr::Unsupported { .. }) { + continue; + } + item.node = OracleNodeIr::RelationSynthesis(RelationSynthesisIr { + filter: filter.clone(), + description: description.clone(), + copy_static: *copy_static, + }); + } +} + fn detect_document_relations(items: &[OracleItemIr], types: &[String]) -> Vec { let mut relations = Vec::new(); detect_linked_choice_etb_counter(items, &mut relations); @@ -1838,80 +1872,39 @@ fn detect_linked_choice_copy_chosen_host( items: &[OracleItemIr], relations: &mut Vec, ) { - let chooser = items.iter().find(|item| { - item_ability(item).is_some_and(|def| ability_is_as_enters_choose_permanent_gap(&def)) - }); + let chooser = items.iter().find_map(as_enters_choose_permanent_gap_item); let copy_static = items.iter().find(|item| { item_static(item).is_some_and(|s| { s.modifications .contains(&ContinuousModification::CopyChosen) }) }); - if let (Some(chooser), Some(copy_static)) = (chooser, copy_static) { - if chooser.id != copy_static.id { + if let (Some((chooser, filter, description)), Some(copy_static)) = (chooser, copy_static) { + if chooser != copy_static.id { relations.push(DocumentRelationIr::LinkedChoice( LinkedChoiceKind::CopyChosenHost { - chooser: chooser.id, + chooser, copy_static: copy_static.id, + filter, + description, }, )); } } } -/// CR 607.2d + CR 707.2c + CR 614.12a: Replace the proven chooser gap ability -/// with a Moved `ChoosePermanent` replacement. Filter is re-derived from the -/// Unimplemented description so line-local parse never assigns copy-host -/// semantics without this relation. -fn apply_linked_choice_copy_chosen_host( - result: &mut ParsedAbilities, - relations: &[DocumentRelationIr], - ability_ids: &mut Vec, - replacement_ids: &mut Vec, -) { - for relation in relations { - let DocumentRelationIr::LinkedChoice(LinkedChoiceKind::CopyChosenHost { chooser, .. }) = - relation - else { - continue; - }; - let Some(ability_pos) = position_of(ability_ids, *chooser) else { - continue; - }; - let Some(description) = result.abilities[ability_pos] - .effect - .unimplemented_description() - .map(str::to_owned) - else { - continue; - }; - let Some(filter) = filter_from_as_enters_choose_permanent_text(&description) else { - continue; - }; - result.abilities.remove(ability_pos); - ability_ids.remove(ability_pos); - let execute = - AbilityDefinition::new(AbilityKind::Spell, Effect::ChoosePermanent { filter }); - result.replacements.push( - ReplacementDefinition::new(ReplacementEvent::Moved) - .execute(execute) - .valid_card(TargetFilter::SelfRef) - // CR 614.1c: battlefield-entry-scoped. - .destination_zone(Zone::Battlefield) - .description(description), - ); - replacement_ids.push(*chooser); - } -} - -/// An Unimplemented ability whose fragment is an as-enters permanent-object -/// choice ("As … enters, choose a creature/permanent…"). Framing + Typed filter -/// must both match — same grammar as `as_enters_choose_permanent_filter`. -fn ability_is_as_enters_choose_permanent_gap(def: &AbilityDefinition) -> bool { - let Some(description) = def.effect.unimplemented_description() else { - return false; +/// Typed facts from a proven unsupported chooser source. The legacy post-fold +/// path read `Effect::Unimplemented`'s description, which +/// `lower_unsupported_node` derives from this residual's fragment (not its +/// display description), so relation synthesis preserves that exact contract. +fn as_enters_choose_permanent_gap_item( + item: &OracleItemIr, +) -> Option<(OracleItemId, TargetFilter, String)> { + let OracleNodeIr::Unsupported { unsupported, .. } = &item.node else { + return None; }; - filter_from_as_enters_choose_permanent_text(description).is_some() + let filter = filter_from_as_enters_choose_permanent_text(&unsupported.fragment)?; + Some((item.id, filter, unsupported.fragment.clone())) } fn filter_from_as_enters_choose_permanent_text(description: &str) -> Option { @@ -3043,6 +3036,11 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { let mut trigger_ids: Vec = Vec::new(); let mut static_ids: Vec = Vec::new(); let mut replacement_ids: Vec = Vec::new(); + // An already-emitted unsupported chooser can become a relation-synthesized + // replacement without entering `result.abilities`. + // Its historical printed slot still exists, so this source-order counter is + // deliberately independent of the published ability vector length. + let mut printed_ability_slot = 0usize; // CR 707.9a printed slots are resolved in this loop, not in // `OracleDocBuilder::finish` where they used to be. The stamp rewrites the // `placeholder()` (= 0) the dispatch loop baked into each @@ -3054,9 +3052,9 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { // source-ordered `BTreeMap` and count each category separately, so the k-th // spell item is at ability slot k either way. // - // The slot IS `result..len()` at the moment of the push, which is - // what makes this the correct seam rather than merely a possible one. Stamped - // BEFORE the relation passes below, matching the pre-relation state the + // The slot counter advances for every source spell item, including a + // `RelationSynthesis` that publishes only a replacement. Stamped BEFORE the + // relation passes below, matching the pre-relation state the // `finish()` walk saw — several of those passes insert into, remove from, and // move ids between the category tracks. // @@ -3067,9 +3065,10 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { match &item.node { OracleNodeIr::Spell(ability_ir) => { let mut def = lower_ability_ir(ability_ir); - stamp_printed_ability_slot(&mut def, result.abilities.len()); + stamp_printed_ability_slot(&mut def, printed_ability_slot); result.abilities.push(def); ability_ids.push(item.id); + printed_ability_slot += 1; } // Same three steps as the two arms around it: lower, stamp the // CR 707.9a printed ability slot, push. The residual is stamped like @@ -3080,9 +3079,28 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { min_x_value, } => { let mut def = lower_unsupported_node(unsupported, *min_x_value); - stamp_printed_ability_slot(&mut def, result.abilities.len()); + stamp_printed_ability_slot(&mut def, printed_ability_slot); result.abilities.push(def); ability_ids.push(item.id); + printed_ability_slot += 1; + } + OracleNodeIr::RelationSynthesis(synthesis) => { + let execute = AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChoosePermanent { + filter: synthesis.filter.clone(), + }, + ); + result.replacements.push( + ReplacementDefinition::new(ReplacementEvent::Moved) + .execute(execute) + .valid_card(TargetFilter::SelfRef) + // CR 614.1c: battlefield-entry-scoped. + .destination_zone(Zone::Battlefield) + .description(synthesis.description.clone()), + ); + replacement_ids.push(item.id); + printed_ability_slot += 1; } OracleNodeIr::Trigger(trigger_node) => { let mut def = lower_trigger_node_ir(trigger_node); @@ -3129,9 +3147,10 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { } OracleNodeIr::PreLoweredSpell(def) => { let mut def = def.clone(); - stamp_printed_ability_slot(&mut def, result.abilities.len()); + stamp_printed_ability_slot(&mut def, printed_ability_slot); result.abilities.push(def); ability_ids.push(item.id); + printed_ability_slot += 1; } } } @@ -3166,12 +3185,6 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { ); reconcile_host_bound_phase_outs(&mut result); apply_linked_choice_persisted_player(&mut result, &ir.relations, &ability_ids, &trigger_ids); - apply_linked_choice_copy_chosen_host( - &mut result, - &ir.relations, - &mut ability_ids, - &mut replacement_ids, - ); // Architectural rule: the parser must never silently discard Oracle text. Run // the swallow audit against the parsed result so any unrepresented clause @@ -3189,11 +3202,9 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { // // The tracks are sound to zip here: of the relation passes above, // `apply_linked_choice_etb_counter` removes from `result.replacements` and - // `replacement_ids` at the same index, and `apply_linked_choice_copy_chosen_host` - // moves an ability id onto the replacement track. This is also exactly why - // the audit stays HERE, post-relation: a pre-lowering audit is blind to - // relation-synthesized semantics (that pass *synthesizes a replacement*), so - // the false-positive wave U1 bounded to 31 faces would be caused, not avoided. + // `replacement_ids` at the same index. Relation synthesis already populated + // the replacement track during the source-order fold, which is why the audit + // stays HERE: a pre-lowering audit is blind to that semantic output. // // Emitted into a local vec and appended, rather than passing `&mut // ir.diagnostics` directly: the audit reads `ir.items` and writes the @@ -3897,6 +3908,11 @@ impl<'a> DocEmitter<'a> { match node { OracleNodeIr::Static(ir) => self.static_ir_at(item_line, ir), OracleNodeIr::Trigger(ir) => self.trigger_ir_at(item_line, ir), + OracleNodeIr::RelationSynthesis(_) => { + panic!( + "relation synthesis is finalization-only and cannot be forwarded by DocEmitter" + ); + } other => { self.emit_at(item_line, other); } diff --git a/crates/engine/src/parser/oracle_ir/doc.rs b/crates/engine/src/parser/oracle_ir/doc.rs index f1a9347c92..c921afeacb 100644 --- a/crates/engine/src/parser/oracle_ir/doc.rs +++ b/crates/engine/src/parser/oracle_ir/doc.rs @@ -32,7 +32,7 @@ use super::trigger::TriggerNodeIr; use crate::types::ability::{ AbilityDefinition, AdditionalCost, CastingPermission, CastingRestriction, ContinuousModification, Effect, ModalChoice, SolveCondition, SpellCastingOption, - StaticDefinition, TriggerDefinition, VoteSubject, + StaticDefinition, TargetFilter, TriggerDefinition, VoteSubject, }; use crate::types::keywords::Keyword; use crate::types::mana::ManaCost; @@ -355,6 +355,19 @@ pub(crate) struct OracleItemIr { pub(crate) node: OracleNodeIr, } +/// A relation-proven replacement synthesized onto the source item that raised +/// the linked-choice fact. The item retains its original id, source span, +/// fragment, and printed ability slot; only its parsed payload changes. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub(crate) struct RelationSynthesisIr { + pub(crate) filter: TargetFilter, + pub(crate) description: String, + /// The exact static consumer selected during document relation discovery. + /// Kept even though lowering needs only the chooser item, so provenance is + /// inspectable and a future consumer cannot rescan for a lookalike static. + pub(crate) copy_static: OracleItemId, +} + /// The typed payload of a document item. Identity and provenance live on /// `OracleItemIr`; this enum carries only the parsed category. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] @@ -435,6 +448,12 @@ pub(crate) enum OracleNodeIr { min_x_value: u32, }, + /// A closed relation-derived payload, installed in place of an already + /// emitted source item during document finalization. Fresh emission is + /// rejected by `OracleDocBuilder::emit` so it cannot mint a source-less + /// item or consume a new printed ability slot. + RelationSynthesis(RelationSynthesisIr), + // ----------------------------------------------------------------------- // PLAN-05 DEBT — pre-lowered escape hatches. // @@ -496,6 +515,7 @@ impl OracleNodeIr { | OracleNodeIr::CastingOption(_) | OracleNodeIr::SolveCondition(_) | OracleNodeIr::StriveCost(_) + | OracleNodeIr::RelationSynthesis(_) | OracleNodeIr::PreLoweredTrigger(_) => None, } } @@ -542,6 +562,7 @@ impl OracleNodeIr { | OracleNodeIr::CastingOption(_) | OracleNodeIr::SolveCondition(_) | OracleNodeIr::StriveCost(_) + | OracleNodeIr::RelationSynthesis(_) | OracleNodeIr::PreLoweredTrigger(_) => None, } } @@ -684,6 +705,9 @@ impl OracleDocIr { /// violations in the parser, not Oracle-text problems. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum DocBuilderError { + /// Relation synthesis is finalization-only: it replaces an existing, + /// already-accounted source item and must never be emitted fresh. + RelationSynthesisRequiresExistingItem, DuplicateItemPosition { span: OracleSourceSpan, }, @@ -976,6 +1000,9 @@ impl OracleDocBuilder { slot: ItemSlot, node: OracleNodeIr, ) -> Result { + if matches!(&node, OracleNodeIr::RelationSynthesis(_)) { + return Err(DocBuilderError::RelationSynthesisRequiresExistingItem); + } slot.source.check_fragment_precision()?; let span = slot.source.span.clone(); let key = (span.first_line, span.start_byte, span.ordinal_within_span); @@ -1021,6 +1048,10 @@ impl OracleDocBuilder { | OracleNodeIr::CastingOption(_) | OracleNodeIr::SolveCondition(_) | OracleNodeIr::StriveCost(_) => {} + // Rejected above before validation, insertion, or slot accounting. + OracleNodeIr::RelationSynthesis(_) => { + unreachable!("relation synthesis is installed only by document finalization") + } } let id = slot.id; self.items.insert( @@ -1670,6 +1701,34 @@ mod tests { ); } + #[test] + fn builder_rejects_fresh_relation_synthesis_without_slot_or_item_side_effects() { + let mut builder = OracleDocBuilder::new(); + let slot = builder.begin_item(span(0, 0, 6, 0), Some("choose")); + let error = builder.emit( + slot, + OracleNodeIr::RelationSynthesis(RelationSynthesisIr { + filter: TargetFilter::Any, + description: "As this enters, choose a permanent.".to_string(), + copy_static: OracleItemId(99), + }), + ); + + assert_eq!( + error, + Err(DocBuilderError::RelationSynthesisRequiresExistingItem) + ); + assert_eq!( + builder.ability_index().get(), + 0, + "a rejected relation synthesis must not reserve a printed ability slot" + ); + assert!( + builder.finish("choose", "Probe", vec![]).items.is_empty(), + "a rejected relation synthesis must not insert a source-less item" + ); + } + /// One physical line may yield two items (`Kicker {2}{G}` → `Keyword` + /// `AdditionalCost`). They share a byte span and are separated by /// `ordinal_within_span`, so the overlap rule must not reject them. diff --git a/crates/engine/src/parser/oracle_ir/feature.rs b/crates/engine/src/parser/oracle_ir/feature.rs index 876397c3e6..4edd371b75 100644 --- a/crates/engine/src/parser/oracle_ir/feature.rs +++ b/crates/engine/src/parser/oracle_ir/feature.rs @@ -465,11 +465,11 @@ pub(crate) fn scope_to_unit( } OracleNodeIr::CastingRestriction(r) => scoped.casting_restrictions.push(r.clone()), OracleNodeIr::CastingOption(o) => scoped.casting_options.push(o.clone()), - // The residual contributes through the ability id track above, like - // every other spell shape: it lowers into `result.abilities`, so the - // `pick` over `tracks.abilities` already attributes it to this unit. - // Adding it here as well would double-count it as unit evidence. + // The residual contributes through the ability id track, while a + // relation synthesis contributes solely through the replacement id + // track. Adding either here would double-count unit evidence. OracleNodeIr::Unsupported { .. } + | OracleNodeIr::RelationSynthesis(_) | OracleNodeIr::Spell(_) | OracleNodeIr::Trigger(_) | OracleNodeIr::Static(_) diff --git a/crates/engine/src/parser/oracle_ir/relation.rs b/crates/engine/src/parser/oracle_ir/relation.rs index 0357297d85..1314f6fa2b 100644 --- a/crates/engine/src/parser/oracle_ir/relation.rs +++ b/crates/engine/src/parser/oracle_ir/relation.rs @@ -22,7 +22,7 @@ //! whole choice axis is one parameterized variant). use super::doc::OracleItemId; -use crate::types::ability::ChosenSubtypeKind; +use crate::types::ability::{ChosenSubtypeKind, TargetFilter}; /// A cross-item relation between parsed document items, recovered at parse time /// and applied by id during lowering. Closed set. @@ -146,8 +146,10 @@ pub(crate) enum LinkedChoiceKind { /// CR 607.2d + CR 707.2c + CR 614.12a: An as-enters permanent-object choice /// gap (`chooser` — an Unimplemented ability whose Oracle text is /// "As … enters, choose ") linked to a - /// `ContinuousModification::CopyChosen` static (`copy_static`). Applying - /// removes the gap ability and injects `Effect::ChoosePermanent` — + /// `ContinuousModification::CopyChosen` static (`copy_static`). Discovery + /// captures the chooser's typed filter and original description before + /// lowering; finalization replaces that same source item in place with a + /// relation-synthesis payload that injects `Effect::ChoosePermanent` — /// Metamorphic Alteration's Aura-host copy. Without this consumer relation /// the choose line stays an ordinary Unimplemented ability (no Moved claim), /// so non-CopyChosen cards (Dauntless Bodyguard, Scheming Fence) keep their @@ -155,5 +157,7 @@ pub(crate) enum LinkedChoiceKind { CopyChosenHost { chooser: OracleItemId, copy_static: OracleItemId, + filter: TargetFilter, + description: String, }, } diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index 48f292c3c9..0001e16f9a 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -1,6 +1,10 @@ use super::*; use crate::parser::oracle_effect::parse_effect_chain; -use crate::parser::oracle_ir::doc::{UnsupportedAbilityCategory, UnsupportedAbilityIr}; +use crate::parser::oracle_ir::doc::{ + OracleDocBuilder, OracleNodeIr, OracleSourceSpan, RelationSynthesisIr, + UnsupportedAbilityCategory, UnsupportedAbilityIr, +}; +use crate::parser::oracle_ir::static_ir::StaticIr; use crate::types::ability::{ AdditionalCostOrigin, AdditionalCostPaymentSource, CountScope, CounterAdjustment, DoorLockOp, }; @@ -36,6 +40,239 @@ fn unsupported_ability_ir_lowering_preserves_generic_and_structural_payloads() { assert_eq!(structural.description.as_deref(), Some("unsupported line")); } +/// A forced diagonal for CopyChosenHost provenance. Two eligible chooser gaps +/// and two CopyChosen statics prove the document relation binds the first +/// source-order pair exactly once; the later copy ability proves the transformed +/// first chooser still consumes printed ability slot 0. +#[test] +fn copy_chosen_host_relation_synthesizes_only_the_selected_source_and_preserves_slots() { + const FIRST_CHOOSER: &str = "As this Aura enters, choose a creature."; + const FIRST_CHOOSER_DISPLAY: &str = "structured chooser display description"; + const SECOND_CHOOSER: &str = "As this Aura enters, choose a nonland permanent."; + const LATER_ABILITY: &str = + "{2}, {T}: This permanent becomes a copy of target creature, except it has this ability."; + let oracle = format!( + "{FIRST_CHOOSER}\n{SECOND_CHOOSER}\ncopy static one\ncopy static two\n{LATER_ABILITY}" + ); + assert_ne!( + FIRST_CHOOSER, FIRST_CHOOSER_DISPLAY, + "the fixture must distinguish legacy fragment from display description" + ); + let mut builder = OracleDocBuilder::new(); + let first = builder.begin_item( + OracleSourceSpan::exact(0, 0, 0, FIRST_CHOOSER.len(), 0), + Some(FIRST_CHOOSER), + ); + let first_id = first.id(); + builder + .emit( + first, + OracleNodeIr::Unsupported { + // Structured residuals intentionally keep a display description + // distinct from the legacy `Effect::Unimplemented` fragment. + // CopyChosenHost must retain the latter, matching the removed + // post-fold lowering path exactly. + unsupported: UnsupportedAbilityIr::new( + UnsupportedAbilityCategory::EffectStructure, + FIRST_CHOOSER, + FIRST_CHOOSER_DISPLAY, + ), + min_x_value: 0, + }, + ) + .unwrap(); + let second_start = FIRST_CHOOSER.len() + 1; + let second = builder.begin_item( + OracleSourceSpan::exact(1, 1, second_start, second_start + SECOND_CHOOSER.len(), 0), + Some(SECOND_CHOOSER), + ); + let second_id = second.id(); + builder + .emit( + second, + OracleNodeIr::Unsupported { + unsupported: UnsupportedAbilityIr::unknown(SECOND_CHOOSER), + min_x_value: 0, + }, + ) + .unwrap(); + let first_static_start = second_start + SECOND_CHOOSER.len() + 1; + let first_static = builder.begin_item( + OracleSourceSpan::exact(2, 2, first_static_start, first_static_start + 15, 0), + Some("copy static one"), + ); + let first_static_id = first_static.id(); + builder + .emit( + first_static, + OracleNodeIr::Static(StaticIr::from_definition( + "copy static one", + StaticDefinition::continuous() + .modifications(vec![ContinuousModification::CopyChosen]), + )), + ) + .unwrap(); + let second_static_start = first_static_start + 16; + let second_static = builder.begin_item( + OracleSourceSpan::exact(3, 3, second_static_start, second_static_start + 15, 0), + Some("copy static two"), + ); + let second_static_id = second_static.id(); + builder + .emit( + second_static, + OracleNodeIr::Static(StaticIr::from_definition( + "copy static two", + StaticDefinition::continuous() + .modifications(vec![ContinuousModification::CopyChosen]), + )), + ) + .unwrap(); + let later_start = second_static_start + 16; + let later = builder.begin_item( + OracleSourceSpan::exact(4, 4, later_start, later_start + LATER_ABILITY.len(), 0), + Some(LATER_ABILITY), + ); + builder + .emit( + later, + OracleNodeIr::PreLoweredSpell(AbilityDefinition::new( + AbilityKind::Spell, + Effect::BecomeCopy { + target: TargetFilter::Any, + recipient: TargetFilter::SelfRef, + duration: None, + mana_value_limit: None, + additional_modifications: vec![ + ContinuousModification::RetainPrintedAbilityFromSource { + source_ability_index: 0, + }, + ], + }, + )), + ) + .unwrap(); + + let document = builder.finish(&oracle, "Probe", vec![]); + let selected_source = document + .items + .iter() + .find(|item| item.id == first_id) + .expect("selected chooser source item before finalization") + .source + .clone(); + let mut document = finalize_document_relations(document, &[]); + assert_eq!( + document.relations.len(), + 1, + "only the first chooser/static pair binds" + ); + assert!(matches!( + document.relations.as_slice(), + [DocumentRelationIr::LinkedChoice(LinkedChoiceKind::CopyChosenHost { + chooser, + copy_static, + filter: TargetFilter::Typed(filter), + description, + })] if *chooser == first_id + && *copy_static == first_static_id + && filter == &TypedFilter::creature() + && description == FIRST_CHOOSER + )); + let selected = document + .items + .iter() + .find(|item| item.id == first_id) + .expect("selected chooser source item"); + assert!(matches!( + &selected.node, + OracleNodeIr::RelationSynthesis(RelationSynthesisIr { + filter: TargetFilter::Typed(filter), + description, + copy_static, + }) if filter == &TypedFilter::creature() + && description == FIRST_CHOOSER + && *copy_static == first_static_id + )); + assert_eq!( + selected.source, selected_source, + "finalization changes only the node; source identity, span, fragment, and order remain exact" + ); + let unselected = document + .items + .iter() + .find(|item| item.id == second_id) + .expect("unselected chooser source item"); + assert!(matches!(&unselected.node, OracleNodeIr::Unsupported { .. })); + let second_static = document + .items + .iter() + .find(|item| item.id == second_static_id) + .expect("second CopyChosen static source item"); + assert!(matches!(&second_static.node, OracleNodeIr::Static(_))); + + let parsed = lower_oracle_ir(&mut document); + assert!( + parsed.replacements.iter().any(|replacement| { + replacement.event == ReplacementEvent::Moved + && replacement.description.as_deref() == Some(FIRST_CHOOSER) + && matches!( + replacement.execute.as_ref().map(|execute| execute.effect.as_ref()), + Some(Effect::ChoosePermanent { + filter: TargetFilter::Typed(filter), + }) if filter == &TypedFilter::creature() + ) + }), + "selected source must lower from the legacy fragment to Moved/ChoosePermanent with that exact description" + ); + assert!( + parsed.abilities.iter().any(|ability| { + ability + .effect + .unimplemented_description() + .is_some_and(|description| description == SECOND_CHOOSER) + }), + "unselected chooser remains an explicit, auditable unsupported ability" + ); + assert!( + document.diagnostics.iter().all(|diagnostic| { + !matches!( + diagnostic, + OracleDiagnostic::SwallowedClause { detector, items, .. } + if detector == "Replacement" && items.contains(&first_id) + ) + }), + "the selected source's lowered replacement must satisfy the post-lowering audit" + ); + let retained_slots = parsed + .abilities + .iter() + .flat_map(|ability| match ability.effect.as_ref() { + Effect::BecomeCopy { + additional_modifications, + .. + } => additional_modifications + .iter() + .filter_map(|modification| { + let ContinuousModification::RetainPrintedAbilityFromSource { + source_ability_index, + } = modification + else { + return None; + }; + Some(*source_ability_index) + }) + .collect::>(), + _ => Vec::new(), + }) + .collect::>(); + assert_eq!( + retained_slots, + vec![2], + "the synthesized chooser is absent from result.abilities but still occupies printed slot 0" + ); +} + #[test] fn ozai_document_ir_lowers_keyword_transform_and_unspent_mana_gate() { const ORACLE: &str = "Trample, firebending 4, haste\nIf you would lose unspent mana, that mana becomes red instead.\nOzai has flying and indestructible as long as you have six or more unspent mana."; diff --git a/scripts/prelowered-ratchet.txt b/scripts/prelowered-ratchet.txt index 6f92f16d2c..3b24764e11 100644 --- a/scripts/prelowered-ratchet.txt +++ b/scripts/prelowered-ratchet.txt @@ -25,3 +25,7 @@ crates/engine/src/parser/oracle_ir/feature.rs 7 # Prose references in the T1 witness-corpus comment. crates/engine/src/parser/oracle_ir/snapshot_tests.rs 2 + +# Test-only fixture for relation synthesis: the later ability proves the +# source item's historical printed slot remains occupied after synthesis. +crates/engine/src/parser/oracle_tests.rs 1