From 58b3d359a3c2f05dcdc3a221c25948da276db377 Mon Sep 17 00:00:00 2001 From: traemyn Date: Mon, 10 Aug 2026 15:56:38 -0500 Subject: [PATCH 01/10] Fix Nadu trigger cap across granted abilities --- Cargo.lock | 164 ------------------ crates/engine/src/analysis/resource.rs | 14 +- crates/engine/src/game/layers.rs | 4 +- crates/engine/src/game/ledger.rs | 14 +- crates/engine/src/game/triggers.rs | 25 ++- crates/engine/src/types/ability.rs | 87 +++++++--- crates/engine/src/types/game_state.rs | 48 ++++- crates/engine/src/types/resolved_commands.rs | 8 +- crates/engine/tests/integration/main.rs | 1 + .../nadu_lavaspur_boots_max_times.rs | 96 ++++++++++ crates/feed-scraper/Cargo.toml | 2 +- .../src/policies/tests/draw_payoff.rs | 8 +- 12 files changed, 261 insertions(+), 210 deletions(-) create mode 100644 crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs diff --git a/Cargo.lock b/Cargo.lock index 5ace0b9d44..6fb898ec19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,15 +801,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "engine-inventory-gen" version = "0.50.0" @@ -920,21 +911,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1139,25 +1115,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -1311,7 +1268,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -1360,22 +1316,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -1394,11 +1334,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1867,23 +1805,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.2.1", - "openssl-sys", - "schannel", - "security-framework 3.7.0", - "security-framework-sys", - "tempfile", -] - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -1975,31 +1896,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "openssl-probe" version = "0.1.6" @@ -2012,18 +1908,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.116" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -2589,22 +2473,17 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-channel", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -2615,7 +2494,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -3304,27 +3182,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -3491,16 +3348,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-retry" version = "0.3.2" @@ -4133,17 +3980,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 3197611125..4ef0c00b2d 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -5288,7 +5288,7 @@ fn ability_has_per_game_activation_gate(state: &GameState, key: &(ObjectId, usiz mod tests { use super::*; use crate::game::game_object::GameObject; - use crate::types::ability::TriggerDefinitionRef; + use crate::types::ability::{TriggerDefinitionRef, TriggerFireLedgerKey}; use crate::types::identifiers::{ CardId, DelayedTriggerInstanceId, DelayedTriggerOrigin, DelayedTriggerToken, }; @@ -6238,11 +6238,15 @@ mod tests { fn trigger_max_times_per_turn_gate_breaks_modulo_equality() { let mut a = GameState::new_two_player(7); let oid = battlefield_creature(&mut a, 730, 0); - a.trigger_fire_counts_this_turn - .insert(test_trigger_ref(&a, oid), 1); + a.trigger_fire_counts_this_turn.insert( + TriggerFireLedgerKey::Definition(test_trigger_ref(&a, oid)), + 1, + ); let mut b = a.clone(); - b.trigger_fire_counts_this_turn - .insert(test_trigger_ref(&b, oid), 2); // limit progressed + b.trigger_fire_counts_this_turn.insert( + TriggerFireLedgerKey::Definition(test_trigger_ref(&b, oid)), + 2, + ); // limit progressed b.players[1].life -= 1; assert!( !loop_states_equal_modulo_resources(&a, &b), diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 308c3b3647..f8a4ab58e0 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -7007,8 +7007,8 @@ fn install_trigger_candidate( return; } obj.trigger_definitions - .push(crate::types::ability::TriggerEntry::new( - occurrence, definition, + .push(crate::types::ability::TriggerEntry::with_grant_producer( + occurrence, definition, producer, )); } diff --git a/crates/engine/src/game/ledger.rs b/crates/engine/src/game/ledger.rs index 20887870bf..5b319adcc5 100644 --- a/crates/engine/src/game/ledger.rs +++ b/crates/engine/src/game/ledger.rs @@ -453,10 +453,16 @@ pub fn apply_resolved_ledger_edit( return Err(ResolvedLedgerEditReplayInvariantError::TriggerAlreadyRecorded); } } - ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old } => { + ResolvedTriggerLedgerEdit::MaxTimesPerTurn { + expected_old, + ledger_key, + } => { + let key = ledger_key.as_ref().cloned().unwrap_or_else(|| { + crate::types::ability::TriggerFireLedgerKey::Definition(trigger.clone()) + }); let found = state .trigger_fire_counts_this_turn - .get(trigger) + .get(&key) .copied() .unwrap_or(0); if found != *expected_old { @@ -470,9 +476,7 @@ pub fn apply_resolved_ledger_edit( let next = expected_old .checked_add(1) .ok_or(ResolvedLedgerEditReplayInvariantError::CounterOverflow)?; - state - .trigger_fire_counts_this_turn - .insert(trigger.clone(), next); + state.trigger_fire_counts_this_turn.insert(key, next); } }, ResolvedLedgerEdit::OncePerTurnPermission { source, permission } => { diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 9d4975afda..97712fd0c7 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -9588,9 +9588,10 @@ fn check_trigger_constraint_with_ref( .is_some_and(|current| current == *level), // CR 603.4: "This ability triggers only the first N times each turn." TriggerConstraint::MaxTimesPerTurn { max } => definition_ref.is_none_or(|key| { + let ledger_key = trigger_fire_ledger_key(key, source_context); state .trigger_fire_counts_this_turn - .get(key) + .get(&ledger_key) .copied() .unwrap_or(0) < *max @@ -11266,6 +11267,24 @@ fn player_field(state: &GameState, controller: PlayerId, f: impl Fn(&Player) -> .unwrap_or(false) } +fn trigger_fire_ledger_key( + definition_ref: &TriggerDefinitionRef, + source_context: Option<&TriggerSourceContext>, +) -> crate::types::ability::TriggerFireLedgerKey { + source_context + .and_then(|source| { + source + .trigger_entries + .iter() + .find(|entry| entry.occurrence == definition_ref.occurrence) + }) + .and_then(|entry| entry.grant_producer.clone()) + .map(crate::types::ability::TriggerFireLedgerKey::Grant) + .unwrap_or_else(|| { + crate::types::ability::TriggerFireLedgerKey::Definition(definition_ref.clone()) + }) +} + /// Record that a constrained trigger has fired. fn record_trigger_fired_with_ref( state: &mut GameState, @@ -11337,9 +11356,10 @@ fn record_trigger_fired_with_ref( } // Increment the captured fire count for MaxTimesPerTurn tracking. TriggerConstraint::MaxTimesPerTurn { .. } => { + let ledger_key = trigger_fire_ledger_key(key, source_context); let expected_old = state .trigger_fire_counts_this_turn - .get(key) + .get(&ledger_key) .copied() .unwrap_or(0); crate::game::ledger::record_trigger_fired( @@ -11347,6 +11367,7 @@ fn record_trigger_fired_with_ref( key.clone(), crate::types::resolved_commands::ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old, + ledger_key: Some(ledger_key), }, ) .expect("max-times trigger must have a valid ledger prefix"); diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 9257c2629f..c40bfd0738 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -92,29 +92,36 @@ mod trigger_occurrence_tests { fn identical_grants_from_distinct_producers_remain_distinct_entries() { let definition = TriggerDefinition::new(TriggerMode::Attacks); let mut state = TriggerOccurrenceState::default(); + let first_producer = TriggerGrantProducerKey::Granted { + origin: static_origin(), + output_index: 0, + }; + let second_producer = TriggerGrantProducerKey::Granted { + origin: TriggerProducerOrigin::Transient { + continuous_effect_id: 19, + modification_index: 0, + }, + output_index: 0, + }; let entries = state .reconcile_trigger_entries(vec![ - ( - TriggerGrantProducerKey::Granted { - origin: static_origin(), - output_index: 0, - }, - definition.clone(), - ), - ( - TriggerGrantProducerKey::Granted { - origin: TriggerProducerOrigin::Transient { - continuous_effect_id: 19, - modification_index: 0, - }, - output_index: 0, - }, - definition, - ), + (first_producer.clone(), definition.clone()), + (second_producer.clone(), definition), ]) .unwrap(); assert_eq!(entries.len(), 2); assert_ne!(entries[0].occurrence, entries[1].occurrence); + assert_ne!( + TriggerFireLedgerKey::Grant(first_producer), + TriggerFireLedgerKey::Grant(second_producer.clone()) + ); + assert_ne!( + TriggerFireLedgerKey::Grant(second_producer.clone()), + TriggerFireLedgerKey::Definition(TriggerDefinitionRef { + source: ObjectIncarnationRef::of(ObjectId(8), 0), + occurrence: entries[0].occurrence.clone(), + }) + ); } #[test] @@ -21817,7 +21824,7 @@ pub struct CopyEffectInstanceRef { /// Payload-free identity of the continuous-effect occurrence which produced a /// Layer-6 trigger candidate. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum TriggerProducerOrigin { Static { @@ -21836,7 +21843,7 @@ pub enum TriggerProducerOrigin { /// This is deliberately independent of `TriggerDefinition`: byte-identical /// grants from distinct producers remain independently functioning abilities /// (CR 113.2c). -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum TriggerGrantProducerKey { KeywordCompanion { @@ -21864,6 +21871,18 @@ pub enum TriggerGrantProducerKey { }, } +/// Identity used only by the MaxTimesPerTurn ledger. +/// +/// A granted trigger's occurrence includes a recipient-local generation, so it +/// cannot serve as a producer-wide cap key. Full TriggerDefinitionRef remains +/// the identity for event matching and look-back semantics. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum TriggerFireLedgerKey { + Definition(TriggerDefinitionRef), + Grant(TriggerGrantProducerKey), +} + /// The immutable occurrence component of a live trigger definition identity. /// /// It is intentionally a closed typed representation rather than a definition @@ -21922,6 +21941,8 @@ pub struct TriggerDefinitionRef { pub struct TriggerEntry { pub occurrence: TriggerDefinitionOccurrenceRef, pub definition: TriggerDefinition, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_producer: Option, } impl TriggerEntry { @@ -21929,6 +21950,19 @@ impl TriggerEntry { Self { occurrence, definition, + grant_producer: None, + } + } + + pub fn with_grant_producer( + occurrence: TriggerDefinitionOccurrenceRef, + definition: TriggerDefinition, + grant_producer: TriggerGrantProducerKey, + ) -> Self { + Self { + occurrence, + definition, + grant_producer: Some(grant_producer), } } @@ -21959,6 +21993,8 @@ enum TriggerEntryWire { IdentityBearing { occurrence: TriggerDefinitionOccurrenceRef, definition: TriggerDefinition, + #[serde(default)] + grant_producer: Option, }, LegacyPayload(TriggerDefinition), } @@ -21972,7 +22008,12 @@ impl<'de> Deserialize<'de> for TriggerEntry { TriggerEntryWire::IdentityBearing { occurrence, definition, - } => Ok(Self::new(occurrence, definition)), + grant_producer, + } => Ok(Self { + occurrence, + definition, + grant_producer, + }), // A later GameState normalization validates this only for a // provable printed/base slot. Keeping the marker here preserves the // distinction instead of guessing copied/granted provenance from @@ -22088,9 +22129,10 @@ impl TriggerOccurrenceState { .iter() .find(|active| active.instance == grant_instance) .expect("reconciled grant instance must remain active"); - TriggerEntry::new( + TriggerEntry::with_grant_producer( occurrence_for_grant(&producer.producer, grant_instance), definition, + producer.producer.clone(), ) }) .collect() @@ -22118,9 +22160,10 @@ impl TriggerOccurrenceState { .iter() .find(|active| active.instance == grant_instance) .expect("reconciled grant instance must remain active"); - TriggerEntry::new( + TriggerEntry::with_grant_producer( occurrence_for_grant(&producer.producer, grant_instance), definition, + producer.producer.clone(), ) }) .collect() diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 20b7696d34..4f71b37095 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -16,6 +16,7 @@ use super::ability::{ PermanentEntryMode, PileSource, QuantityExpr, ResolvedAbility, SearchDestinationSplit, SearchSelectionConstraint, StaticCondition, TapCreaturesAggregate, TargetFilter, TargetRef, ThisWayCause, TriggerCondition, TriggerDefinition, TriggerDefinitionRef, TriggerEntry, + TriggerFireLedgerKey, }; use super::attribution::ObjectAttribution; use super::card::{CardFace, PrintedCardRef, TokenImageRef}; @@ -227,7 +228,8 @@ mod tuple_key_map { /// Serde adapter for trigger occurrence ledgers. JSON object keys must be /// strings, while a `TriggerDefinitionRef` is structured identity; encode the /// map as an explicit entry list rather than flattening or guessing a key. -mod trigger_definition_ref_map { +#[cfg(test)] +mod legacy_trigger_definition_ref_map { use super::*; pub fn serialize( @@ -253,6 +255,44 @@ mod trigger_definition_ref_map { } } +mod trigger_definition_ref_map { + use super::*; + + pub fn serialize( + map: &HashMap, + serializer: S, + ) -> Result + where + S: serde::Serializer, + { + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_unstable_by_key(|(key, _)| *key); + entries.serialize(serializer) + } + + pub fn deserialize<'de, D>( + deserializer: D, + ) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Current(Vec<(TriggerFireLedgerKey, u32)>), + Legacy(Vec<(TriggerDefinitionRef, u32)>), + } + + Wire::deserialize(deserializer).map(|wire| match wire { + Wire::Current(entries) => entries.into_iter().collect(), + Wire::Legacy(entries) => entries + .into_iter() + .map(|(key, count)| (TriggerFireLedgerKey::Definition(key), count)) + .collect(), + }) + } +} + /// Deserializes the object store and validates the one legacy trigger shape /// that can be materialized without guessing: a complete ordered payload list /// proven by the persisted printed base slots. Runtime copied/granted payloads @@ -14749,7 +14789,7 @@ declare_game_state! { skip_serializing_if = "HashMap::is_empty", with = "trigger_definition_ref_map" )] - pub trigger_fire_counts_this_turn: HashMap, + pub trigger_fire_counts_this_turn: HashMap, /// CR 603.2: Tracks per-opponent-per-turn firing for /// OncePerOpponentPerTurn. Keyed by exact occurrence and opponent. #[serde(default)] @@ -22494,7 +22534,7 @@ mod tests { #[derive(Serialize)] struct TriggerRefFixture<'a> { - #[serde(serialize_with = "trigger_definition_ref_map::serialize")] + #[serde(serialize_with = "legacy_trigger_definition_ref_map::serialize")] values: &'a HashMap, } @@ -22604,7 +22644,7 @@ mod tests { .expect("trigger-ref fixture should serialize"), r#"{"values":[[{"source":{"object_id":7,"incarnation":3},"occurrence":{"type":"Printed","data":{"base_set":1,"printed_index":0}}},10],[{"source":{"object_id":7,"incarnation":3},"occurrence":{"type":"Printed","data":{"base_set":1,"printed_index":1}}},11],[{"source":{"object_id":7,"incarnation":3},"occurrence":{"type":"Printed","data":{"base_set":1,"printed_index":2}}},12]]}"# ); - let trigger_round_trip = trigger_definition_ref_map::deserialize( + let trigger_round_trip = legacy_trigger_definition_ref_map::deserialize( &mut serde_json::Deserializer::from_str( r#"[[{"source":{"object_id":7,"incarnation":3},"occurrence":{"type":"Printed","data":{"base_set":1,"printed_index":2}}},12],[{"source":{"object_id":7,"incarnation":3},"occurrence":{"type":"Printed","data":{"base_set":1,"printed_index":0}}},10]]"#, ), diff --git a/crates/engine/src/types/resolved_commands.rs b/crates/engine/src/types/resolved_commands.rs index ac4e65dcdc..f908030767 100644 --- a/crates/engine/src/types/resolved_commands.rs +++ b/crates/engine/src/types/resolved_commands.rs @@ -769,7 +769,11 @@ pub enum ResolvedTriggerLedgerEdit { /// CR 603.2c: This trigger occurrence has used this opponent's per-turn fact. OncePerOpponentPerTurn { opponent: PlayerId }, /// Increment from the captured prior count for MaxTimesPerTurn. - MaxTimesPerTurn { expected_old: u32 }, + MaxTimesPerTurn { + expected_old: u32, + #[serde(default)] + ledger_key: Option, + }, } /// A named once-per-turn permission slot consumed by a completed play or cast. @@ -3252,7 +3256,7 @@ pub(crate) fn ledger_edit_is_invalid(edit: &ResolvedLedgerEdit) -> bool { || *resulting_first_card_drawn_this_turn != expected_first } ResolvedLedgerEdit::TriggerFired { - edit: ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old }, + edit: ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old, .. }, .. } => *expected_old == u32::MAX, ResolvedLedgerEdit::TriggerFired { .. } diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 83160706a4..86fcdc1c4c 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1170,6 +1170,7 @@ mod momir_token_firebreathing_duration; mod moon_girl_second_draw_base_pt; mod mox_diamond_discard_cost_2853; mod multi_source_each_power_damage; +mod nadu_lavaspur_boots_max_times; mod najeela_extra_combat_grant_2898; mod no_top_level_test_binaries; mod oblivions_hunger_conditional_draw; diff --git a/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs b/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs new file mode 100644 index 0000000000..a7e05daff4 --- /dev/null +++ b/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs @@ -0,0 +1,96 @@ +//! Regression for Nadu's granted MaxTimesPerTurn trigger through Lavaspur Boots. +//! +//! Nadu grants the targeting trigger to each creature separately, but one grant +//! producer owns one shared "twice each turn" limit. Each Equip activation below +//! uses the production targeting and trigger-collection pipeline. + +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameScenario, P0}; +use engine::types::ability::{ContinuousModification, TriggerConstraint}; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use std::sync::Arc; + +const NADU_ORACLE: &str = "Flying\nCreatures you control have \"Whenever this creature becomes the target of a spell or ability, reveal the top card of your library. If it's a land card, put it onto the battlefield. Otherwise, put it into your hand. This ability triggers only twice each turn.\""; +const LAVASPUR_BOOTS_ORACLE: &str = + "Equipped creature gets +1/+0 and has haste and ward {1}.\nEquip {1}"; + +#[test] +fn nadu_granted_trigger_shares_max_times_cap_across_lavaspur_targets() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + (0..3) + .map(|_| { + ManaUnit::new( + ManaType::Colorless, + engine::types::identifiers::ObjectId(0), + false, + vec![], + ) + }) + .collect(), + ); + scenario.with_library_top(P0, &["Forest", "Island", "Mountain"]); + + let nadu = scenario + .add_creature_from_oracle(P0, "Nadu, Winged Wisdom", 3, 4, NADU_ORACLE) + .id(); + let first_target = scenario.add_vanilla(P0, 1, 1); + let second_target = scenario.add_vanilla(P0, 1, 1); + let third_target = scenario.add_vanilla(P0, 1, 1); + let boots = scenario + .add_creature(P0, "Lavaspur Boots", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .from_oracle_text(LAVASPUR_BOOTS_ORACLE) + .id(); + + let mut runner = scenario.build(); + { + let nadu_object = runner.state_mut().objects.get_mut(&nadu).unwrap(); + for static_definition in Arc::make_mut(&mut nadu_object.base_static_definitions) { + for modification in &mut static_definition.modifications { + if let ContinuousModification::GrantTrigger { trigger } = modification { + trigger.constraint = Some(TriggerConstraint::MaxTimesPerTurn { max: 2 }); + } + } + } + nadu_object.static_definitions = (*nadu_object.base_static_definitions).clone().into(); + } + evaluate_layers(runner.state_mut()); + assert_eq!( + runner.state().objects[&boots].abilities.len(), + 1, + "Lavaspur Boots must expose its Equip ability" + ); + for target in [first_target, second_target, third_target] { + assert!( + runner.state().objects[&target] + .trigger_definitions + .as_slice() + .iter() + .any(|entry| matches!( + entry.definition.constraint, + Some(TriggerConstraint::MaxTimesPerTurn { max: 2 }) + )), + "Nadu must grant its targeting trigger with MaxTimesPerTurn=2" + ); + } + for target in [first_target, second_target, third_target] { + runner.activate(boots, 0).target_object(target).resolve(); + } + + let counts = &runner.state().trigger_fire_counts_this_turn; + assert_eq!( + counts.values().sum::(), + 2, + "Nadu's granted trigger must fire only twice across all creatures targeted by Equip" + ); + assert_eq!( + counts.len(), + 1, + "one Layer-6 grant producer must own one MaxTimesPerTurn ledger entry" + ); +} diff --git a/crates/feed-scraper/Cargo.toml b/crates/feed-scraper/Cargo.toml index 5f4ecaf087..de80d2e7f9 100644 --- a/crates/feed-scraper/Cargo.toml +++ b/crates/feed-scraper/Cargo.toml @@ -10,7 +10,7 @@ path = "src/main.rs" [dependencies] clap = { version = "4", features = ["derive"] } -reqwest = { version = "0.12", features = ["blocking"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls-webpki-roots"] } scraper = "0.22" serde = { workspace = true } serde_json = "1" diff --git a/crates/phase-ai/src/policies/tests/draw_payoff.rs b/crates/phase-ai/src/policies/tests/draw_payoff.rs index a9a44f5afb..cc481f5eee 100644 --- a/crates/phase-ai/src/policies/tests/draw_payoff.rs +++ b/crates/phase-ai/src/policies/tests/draw_payoff.rs @@ -12,7 +12,7 @@ use engine::types::ability::{ AbilityDefinition, AbilityKind, CastVariantPaid, DrawReplacementScope, Effect, ModalChoice, QuantityExpr, QuantityModification, QuantityRef, ReplacementCondition, ReplacementDefinition, ReplacementMode, StaticDefinition, TargetFilter, TriggerCondition, TriggerConstraint, - TriggerDefinition, + TriggerDefinition, TriggerFireLedgerKey, }; use engine::types::actions::GameAction; use engine::types::card_type::CoreType; @@ -578,7 +578,8 @@ fn max_times_per_turn_below_cap_rewards() { let entry = obj.trigger_definitions.iter_unchecked().next().unwrap(); obj.trigger_definition_ref(entry) }; - st.trigger_fire_counts_this_turn.insert(key, 1); // 1 < 2 → can still fire + st.trigger_fire_counts_this_turn + .insert(TriggerFireLedgerKey::Definition(key), 1); // 1 < 2 → can still fire let (oid, cid) = draw_spell(&mut st); let context = context(&config, session(0.9)); @@ -602,7 +603,8 @@ fn max_times_per_turn_at_cap_is_neutral() { let entry = obj.trigger_definitions.iter_unchecked().next().unwrap(); obj.trigger_definition_ref(entry) }; - st.trigger_fire_counts_this_turn.insert(key, 2); // 2 == max → exhausted + st.trigger_fire_counts_this_turn + .insert(TriggerFireLedgerKey::Definition(key), 2); // 2 == max → exhausted let (oid, cid) = draw_spell(&mut st); let context = context(&config, session(0.9)); From 68e5adbc2f511f64de815baefe39438cf4dbb16c Mon Sep 17 00:00:00 2001 From: traemyn Date: Mon, 10 Aug 2026 16:29:12 -0500 Subject: [PATCH 02/10] Preserve trigger cap provenance across state restore --- crates/engine/src/game/game_object.rs | 107 +++++++++++++++++++++++++- crates/engine/src/types/game_state.rs | 100 ++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index d95807ec68..9a9b2aafe7 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -1625,7 +1625,8 @@ impl GameObject { ) }); if !has_legacy_entries { - return self.validate_trigger_definitions(); + self.validate_trigger_definitions()?; + return self.migrate_legacy_trigger_provenance(); } if self.base_trigger_definitions.is_empty() || self.trigger_definitions.len() != self.base_trigger_definitions.len() @@ -1644,7 +1645,63 @@ impl GameObject { return Err("legacy runtime trigger payload has no provable producer or base slot"); } self.materialize_base_trigger_definitions(); - self.validate_trigger_definitions() + self.validate_trigger_definitions()?; + self.migrate_legacy_trigger_provenance() + } + + /// Restores producer provenance for identity-bearing granted triggers from + /// the persisted recipient-local grant table. Older payloads may contain a + /// `Granted` occurrence without the producer field; guessing from the + /// trigger definition would fragment `MaxTimesPerTurn` accounting again. + pub fn migrate_legacy_trigger_provenance(&mut self) -> Result<(), &'static str> { + let active_grants: Vec<_> = self + .trigger_occurrence_state + .active_grants() + .map(|(producer, instance)| (instance, producer.clone())) + .collect(); + + for index in 0..self.trigger_definitions.len() { + let (grant_instance, existing_producer) = { + let entry = self + .trigger_definitions + .get(index) + .expect("trigger definition index must remain valid"); + let grant_instance = match &entry.occurrence { + TriggerDefinitionOccurrenceRef::KeywordCompanion { grant_instance, .. } + | TriggerDefinitionOccurrenceRef::CopyRetained { grant_instance, .. } + | TriggerDefinitionOccurrenceRef::Granted { grant_instance } + | TriggerDefinitionOccurrenceRef::ExpandedGrant { grant_instance, .. } => { + Some(*grant_instance) + } + TriggerDefinitionOccurrenceRef::Printed { .. } + | TriggerDefinitionOccurrenceRef::CopiedValue { .. } + | TriggerDefinitionOccurrenceRef::Unmaterialized => None, + }; + (grant_instance, entry.grant_producer.clone()) + }; + let Some(grant_instance) = grant_instance else { + continue; + }; + let Some((_, producer)) = active_grants + .iter() + .find(|(instance, _)| *instance == grant_instance) + else { + return Err("grant trigger has no persisted producer provenance"); + }; + match existing_producer { + Some(existing) if existing != *producer => { + return Err("grant trigger producer provenance does not match its instance"); + } + Some(_) => {} + None => { + self.trigger_definitions + .get_mut(index) + .expect("trigger definition index must remain valid") + .grant_producer = Some(producer.clone()); + } + } + } + Ok(()) } /// Apply an Alchemy "perpetually" modification to this card: record it on the @@ -3004,6 +3061,7 @@ mod tests { use super::*; use crate::types::ability::{ TriggerDefinition, TriggerDefinitionOccurrenceRef, TriggerEntry, TriggerGrantInstanceRef, + TriggerGrantProducerKey, TriggerProducerOrigin, }; use crate::types::counter::parse_counter_type; use crate::types::triggers::TriggerMode; @@ -3344,6 +3402,51 @@ mod tests { ); } + #[test] + fn legacy_grant_trigger_restores_producer_from_persisted_instance() { + let mut object = trigger_test_object(); + let producer = TriggerGrantProducerKey::Granted { + origin: TriggerProducerOrigin::Transient { + continuous_effect_id: 1, + modification_index: 0, + }, + output_index: 0, + }; + let grant_instance = object + .trigger_occurrence_state + .grant_instance_for(producer.clone()) + .unwrap(); + object.trigger_definitions = vec![TriggerEntry { + occurrence: TriggerDefinitionOccurrenceRef::Granted { grant_instance }, + definition: TriggerDefinition::new(TriggerMode::Phase), + grant_producer: None, + }] + .into(); + + object + .migrate_legacy_trigger_provenance() + .expect("persisted grant instance proves the producer identity"); + assert_eq!(object.trigger_definitions[0].grant_producer, Some(producer)); + } + + #[test] + fn grant_trigger_without_persisted_instance_is_rejected() { + let mut object = trigger_test_object(); + object.trigger_definitions = vec![TriggerEntry { + occurrence: TriggerDefinitionOccurrenceRef::Granted { + grant_instance: TriggerGrantInstanceRef(7), + }, + definition: TriggerDefinition::new(TriggerMode::Phase), + grant_producer: None, + }] + .into(); + + assert_eq!( + object.migrate_legacy_trigger_provenance(), + Err("grant trigger has no persisted producer provenance") + ); + } + fn trigger_test_object() -> GameObject { GameObject::new( ObjectId(1), diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 4f71b37095..f60b048e67 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -313,6 +313,54 @@ where Ok(objects) } +/// Migrates the legacy MaxTimes ledger representation after object trigger +/// provenance has been restored. A legacy definition key is safe to promote +/// only when its exact recipient-local grant instance identifies one persisted +/// producer; equal definition payloads are never used as a fallback. +fn migrate_legacy_trigger_fire_counts(state: &mut GameState) -> Result<(), String> { + let mut producers_by_definition = HashMap::new(); + for (_, object) in state.objects.iter() { + for entry in object.trigger_definitions.iter_all() { + let Some(producer) = entry.grant_producer.as_ref() else { + continue; + }; + let definition = object.trigger_definition_ref(entry); + if let Some(previous) = producers_by_definition.insert(definition, producer.clone()) { + if previous != *producer { + return Err( + "legacy trigger ledger definition maps to conflicting grant producers" + .to_string(), + ); + } + } + } + } + + let legacy_counts: Vec<_> = state + .trigger_fire_counts_this_turn + .iter() + .filter_map(|(key, count)| match key { + TriggerFireLedgerKey::Definition(definition) => producers_by_definition + .get(definition) + .cloned() + .map(|producer| (definition.clone(), producer, *count)), + TriggerFireLedgerKey::Grant(_) => None, + }) + .collect(); + + for (definition, producer, count) in legacy_counts { + let grant_key = TriggerFireLedgerKey::Grant(producer); + if state.trigger_fire_counts_this_turn.contains_key(&grant_key) { + return Err("legacy trigger ledger migration collides with a grant count".to_string()); + } + state + .trigger_fire_counts_this_turn + .remove(&TriggerFireLedgerKey::Definition(definition)); + state.trigger_fire_counts_this_turn.insert(grant_key, count); + } + Ok(()) +} + /// Tracks whether the game is in day or night state (CR 730). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DayNight { @@ -15985,6 +16033,7 @@ impl GameStateDecode { let mut state = serde_json::from_value::(value) .map(ResolutionStateWire::into_game_state) .map_err(|error| error.to_string())?; + migrate_legacy_trigger_fire_counts(&mut state)?; validate_restored_zone_change_replay_keys(&state)?; normalize_delayed_trigger_allocators(&mut state)?; validate_trigger_firing_coherence(&state)?; @@ -16010,6 +16059,7 @@ impl GameStateDecode { )?; } let mut state = Self::materialize_prepared(value)?; + migrate_legacy_trigger_fire_counts(&mut state)?; normalize_delayed_trigger_allocators(&mut state)?; validate_trigger_firing_coherence(&state)?; // Both decode entry points guard, because they are genuinely two ingresses: @@ -22519,6 +22569,7 @@ mod tests { use crate::types::ability::{ AbilityDefinition, AbilityKind, Effect, PostReplacementContinuation, QuantityExpr, ResolvedAbility, TargetFilter, TriggerBaseSetInstanceRef, TriggerDefinitionOccurrenceRef, + TriggerEntry, TriggerGrantProducerKey, TriggerProducerOrigin, }; use crate::types::deterministic_serde::test_support::ReverseBuildHasher; use crate::types::identifiers::{ @@ -28443,6 +28494,55 @@ mod tests { )); } + #[test] + fn game_state_deserialize_migrates_legacy_grant_fire_count_to_producer_key() { + let object_id = ObjectId(993); + let mut state = GameState::new_two_player(42); + let mut object = GameObject::new( + object_id, + CardId(993), + PlayerId(0), + "Legacy granted trigger".to_string(), + Zone::Battlefield, + ); + let producer = TriggerGrantProducerKey::Granted { + origin: TriggerProducerOrigin::Static { + source: ObjectIncarnationRef::from_object(&object), + definition_index: 0, + modification_index: 0, + }, + output_index: 0, + }; + let grant_instance = object + .trigger_occurrence_state + .grant_instance_for(producer.clone()) + .expect("grant instance allocates"); + let entry = TriggerEntry::with_grant_producer( + TriggerDefinitionOccurrenceRef::Granted { grant_instance }, + TriggerDefinition::new(crate::types::triggers::TriggerMode::Attacks), + producer.clone(), + ); + let definition = object.trigger_definition_ref(&entry); + object.trigger_definitions.push(entry); + state.objects.insert(object_id, object); + state + .trigger_fire_counts_this_turn + .insert(TriggerFireLedgerKey::Definition(definition), 2); + + let mut snapshot = serde_json::to_value(state).expect("serialize fixture state"); + snapshot["objects"][object_id.0.to_string()]["trigger_definitions"][0] + .as_object_mut() + .expect("identity-bearing trigger serializes as an object") + .remove("grant_producer"); + + let restored: GameState = serde_json::from_value(snapshot) + .expect("legacy grant provenance and ledger count restore"); + assert_eq!( + restored.trigger_fire_counts_this_turn, + HashMap::from([(TriggerFireLedgerKey::Grant(producer), 2)]) + ); + } + #[test] fn game_state_deserialize_rejects_unproven_legacy_trigger_payload() { let object_id = ObjectId(992); From 74446c5f319ab9e4047cb2dbf806957027ecfc19 Mon Sep 17 00:00:00 2001 From: traemyn Date: Mon, 10 Aug 2026 18:58:49 -0500 Subject: [PATCH 03/10] Retire trigger grants across zone changes --- crates/engine/src/game/game_object.rs | 1 + crates/engine/src/game/zones.rs | 2 ++ crates/engine/src/types/ability.rs | 18 ++++++++++++ .../nadu_lavaspur_boots_max_times.rs | 28 +++++++++++++++++++ 4 files changed, 49 insertions(+) diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 9a9b2aafe7..a6f91058cb 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -2448,6 +2448,7 @@ impl GameObject { // Bump the incarnation so self-references captured by abilities created // for the previous incarnation no longer match this permanent. self.bump_incarnation(); + self.trigger_occurrence_state.retire_all_grants(); // CR 613.7d: an object receives a timestamp when it enters a zone. Stage 2 // stamps battlefield entries only; all-zone entry stamping (graveyard/exile- // functioning statics) is a deferred hook (see scope boundary). diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index bf2112a9a5..434ca3c82d 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -922,6 +922,7 @@ pub fn apply_resolved_zone_change( .get_mut(&command.object.object_id) .expect("validated zone command object remains live"); object.zone = command.to; + object.trigger_occurrence_state.retire_all_grants(); if command.to == Zone::Battlefield { object.reset_for_battlefield_entry( turn_number, @@ -1193,6 +1194,7 @@ pub fn move_to_zone( zone_change_record.entered_incarnation = Some(obj_mut.incarnation); } else if from != to { // CR 400.7: a move between zones creates a new object. + obj_mut.trigger_occurrence_state.retire_all_grants(); obj_mut.bump_incarnation(); } (pre_bump_incarnation, obj_mut.incarnation, false) diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index c40bfd0738..25e5a3966f 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -88,6 +88,24 @@ mod trigger_occurrence_tests { assert!(third.0 > first.0, "serialized generations are monotonic"); } + #[test] + fn zone_change_retirement_forces_a_fresh_grant_generation() { + let producer = TriggerGrantProducerKey::Granted { + origin: static_origin(), + output_index: 0, + }; + let mut state = TriggerOccurrenceState::default(); + let first = state.grant_instance_for(producer.clone()).unwrap(); + state.retire_all_grants(); + let second = state.grant_instance_for(producer).unwrap(); + + assert_ne!( + first, second, + "a new object incarnation must not reuse a grant generation" + ); + assert!(second.0 > first.0, "grant generations remain monotonic"); + } + #[test] fn identical_grants_from_distinct_producers_remain_distinct_entries() { let definition = TriggerDefinition::new(TriggerMode::Attacks); diff --git a/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs b/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs index a7e05daff4..206c6894d9 100644 --- a/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs +++ b/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs @@ -9,6 +9,7 @@ use engine::game::scenario::{GameScenario, P0}; use engine::types::ability::{ContinuousModification, TriggerConstraint}; use engine::types::mana::{ManaType, ManaUnit}; use engine::types::phase::Phase; +use engine::types::zones::Zone; use std::sync::Arc; const NADU_ORACLE: &str = "Flying\nCreatures you control have \"Whenever this creature becomes the target of a spell or ability, reveal the top card of your library. If it's a land card, put it onto the battlefield. Otherwise, put it into your hand. This ability triggers only twice each turn.\""; @@ -78,6 +79,33 @@ fn nadu_granted_trigger_shares_max_times_cap_across_lavaspur_targets() { "Nadu must grant its targeting trigger with MaxTimesPerTurn=2" ); } + let first_generation = runner.state().objects[&third_target] + .trigger_occurrence_state + .active_grants() + .map(|(_, instance)| instance) + .collect::>(); + engine::game::zones::move_to_zone( + runner.state_mut(), + third_target, + Zone::Exile, + &mut Vec::new(), + ); + engine::game::zones::move_to_zone( + runner.state_mut(), + third_target, + Zone::Battlefield, + &mut Vec::new(), + ); + evaluate_layers(runner.state_mut()); + let second_generation = runner.state().objects[&third_target] + .trigger_occurrence_state + .active_grants() + .map(|(_, instance)| instance) + .collect::>(); + assert_ne!( + first_generation, second_generation, + "a blinked recipient must receive a fresh grant generation" + ); for target in [first_target, second_target, third_target] { runner.activate(boots, 0).target_object(target).resolve(); } From 3c7896247553314607c0ff2790cafa9794b315b9 Mon Sep 17 00:00:00 2001 From: traemyn Date: Mon, 10 Aug 2026 20:37:13 -0500 Subject: [PATCH 04/10] Fix grant lifecycle replay boundaries --- crates/engine/src/game/ledger.rs | 105 +++++++++++++++++++++++++++++-- crates/engine/src/game/zones.rs | 86 ++++++++++++++++++++++++- 2 files changed, 186 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/ledger.rs b/crates/engine/src/game/ledger.rs index 5b319adcc5..d24e9fe465 100644 --- a/crates/engine/src/game/ledger.rs +++ b/crates/engine/src/game/ledger.rs @@ -1,6 +1,6 @@ //! Final authority for composable per-event ledger facts. -use crate::types::ability::TriggerDefinitionRef; +use crate::types::ability::{TriggerDefinitionRef, TriggerFireLedgerKey}; use crate::types::game_state::{GameState, SpellCastRecord}; use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; use crate::types::player::PlayerId; @@ -457,9 +457,7 @@ pub fn apply_resolved_ledger_edit( expected_old, ledger_key, } => { - let key = ledger_key.as_ref().cloned().unwrap_or_else(|| { - crate::types::ability::TriggerFireLedgerKey::Definition(trigger.clone()) - }); + let key = trigger_fire_ledger_key_for_replay(state, trigger, ledger_key.as_ref()); let found = state .trigger_fire_counts_this_turn .get(&key) @@ -515,6 +513,105 @@ pub fn apply_resolved_ledger_edit( Ok(()) } +fn trigger_fire_ledger_key_for_replay( + state: &GameState, + trigger: &TriggerDefinitionRef, + ledger_key: Option<&TriggerFireLedgerKey>, +) -> TriggerFireLedgerKey { + if let Some(ledger_key) = ledger_key { + return ledger_key.clone(); + } + + let grant_producer = state + .objects + .get(&trigger.source.object_id) + .filter(|object| object.incarnation == trigger.source.incarnation) + .and_then(|object| { + object + .trigger_definitions + .iter_all() + .find(|entry| entry.occurrence == trigger.occurrence) + }) + .and_then(|entry| entry.grant_producer.clone()); + + grant_producer + .map(TriggerFireLedgerKey::Grant) + .unwrap_or_else(|| TriggerFireLedgerKey::Definition(trigger.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::game_object::GameObject; + use crate::types::ability::{ + TriggerDefinition, TriggerDefinitionOccurrenceRef, TriggerEntry, TriggerGrantProducerKey, + TriggerProducerOrigin, + }; + use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; + use crate::types::player::PlayerId; + use crate::types::resolved_commands::{ResolvedCommandOrdinal, RulesExecutionNodeRef}; + use crate::types::triggers::TriggerMode; + use crate::types::zones::Zone; + use crate::types::CardId; + + #[test] + fn legacy_max_times_replay_resolves_migrated_grant_key() { + let object_id = ObjectId(1); + let mut state = GameState::new_two_player(42); + let mut object = GameObject::new( + object_id, + CardId(1), + PlayerId(0), + "Granted trigger".to_string(), + Zone::Battlefield, + ); + let producer = TriggerGrantProducerKey::Granted { + origin: TriggerProducerOrigin::Static { + source: ObjectIncarnationRef::from_object(&object), + definition_index: 0, + modification_index: 0, + }, + output_index: 0, + }; + let grant_instance = object + .trigger_occurrence_state + .grant_instance_for(producer.clone()) + .expect("grant instance allocates"); + let entry = TriggerEntry::with_grant_producer( + TriggerDefinitionOccurrenceRef::Granted { grant_instance }, + TriggerDefinition::new(TriggerMode::Attacks), + producer.clone(), + ); + let trigger = object.trigger_definition_ref(&entry); + object.trigger_definitions.push(entry); + state.objects.insert(object_id, object); + state + .trigger_fire_counts_this_turn + .insert(TriggerFireLedgerKey::Grant(producer.clone()), 2); + + let command = ResolvedLedgerEditCommand { + edit: ResolvedLedgerEdit::TriggerFired { + trigger, + edit: ResolvedTriggerLedgerEdit::MaxTimesPerTurn { + expected_old: 2, + ledger_key: None, + }, + }, + cause: RulesExecutionNodeRef::Proposal(ResolvedCommandOrdinal(0)), + }; + + apply_resolved_ledger_edit(&mut state, &command).expect("legacy replay resolves grant key"); + assert_eq!( + state + .trigger_fire_counts_this_turn + .get(&TriggerFireLedgerKey::Grant(producer)) + .copied(), + Some(3) + ); + assert_eq!(state.trigger_fire_counts_this_turn.len(), 1); + } +} + fn history_len(len: usize) -> Result { u32::try_from(len).map_err(|_| ResolvedLedgerEditReplayInvariantError::CounterOverflow) } diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index 434ca3c82d..d807cbffa1 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -1829,6 +1829,7 @@ pub fn move_to_library_at_index( // A within-Library reposition (reveal / scry bottom placement / look-at-top-N, // CR 701.20b) is zero moves — `from == Library` here — and must NOT bump. if from != Zone::Library { + obj_mut.trigger_occurrence_state.retire_all_grants(); obj_mut.bump_incarnation(); bump = Some((pre_bump_incarnation, obj_mut.incarnation)); } @@ -2085,6 +2086,7 @@ pub(crate) fn route_component(state: &mut GameState, component_id: ObjectId, to: // CR 730.3 + CR 400.7: the component becomes a new object in its // owner's destination zone. Keep this beside the raw delivery so // `apply_zone_exit_cleanup` cannot double-bump normal moves. + component.trigger_occurrence_state.retire_all_grants(); component.bump_incarnation(); } // CR 700.11: a nontoken permanent card put into its owner's graveyard from @@ -2411,7 +2413,7 @@ mod tests { use super::*; use crate::types::ability::{ ContinuousModification, ControllerRef, FilterProp, StaticDefinition, TargetFilter, - TypeFilter, TypedFilter, + TriggerGrantProducerKey, TriggerProducerOrigin, TypeFilter, TypedFilter, }; use crate::types::game_state::GameState; use crate::types::keywords::Keyword; @@ -2421,6 +2423,25 @@ mod tests { GameState::new_two_player(42) } + fn install_test_grant(state: &mut GameState, object_id: ObjectId) { + let source = ObjectIncarnationRef::from_object(&state.objects[&object_id]); + let producer = TriggerGrantProducerKey::Granted { + origin: TriggerProducerOrigin::Static { + source, + definition_index: 0, + modification_index: 0, + }, + output_index: 0, + }; + state + .objects + .get_mut(&object_id) + .unwrap() + .trigger_occurrence_state + .reconcile_grant_instances(vec![(producer, ())]) + .expect("test grant producer is unique"); + } + #[test] fn create_object_assigns_id_and_inserts() { let mut state = setup(); @@ -3252,6 +3273,69 @@ mod tests { assert_eq!(state.players[0].library[1], id2); // goes to bottom } + #[test] + fn move_to_library_retires_active_grants_before_new_incarnation() { + let mut state = setup(); + let id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Granted permanent".to_string(), + Zone::Battlefield, + ); + install_test_grant(&mut state, id); + assert_eq!( + state.objects[&id] + .trigger_occurrence_state + .active_grants() + .count(), + 1 + ); + + let mut events = Vec::new(); + move_to_library_at_index(&mut state, id, Some(0), &mut events); + + assert_eq!( + state.objects[&id] + .trigger_occurrence_state + .active_grants() + .count(), + 0, + "a library move must retire grants before the object incarnation changes" + ); + } + + #[test] + fn route_component_retires_active_grants_before_new_incarnation() { + let mut state = setup(); + let id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Merged component".to_string(), + Zone::Battlefield, + ); + install_test_grant(&mut state, id); + assert_eq!( + state.objects[&id] + .trigger_occurrence_state + .active_grants() + .count(), + 1 + ); + + route_component(&mut state, id, Zone::Graveyard); + + assert_eq!( + state.objects[&id] + .trigger_occurrence_state + .active_grants() + .count(), + 0, + "routing an absorbed component must retire its old grants" + ); + } + #[test] fn within_library_reposition_does_not_create_a_zone_change() { let mut state = setup(); From 38a863fdcf6afe0426297f78dddb1c632d7842e9 Mon Sep 17 00:00:00 2001 From: traemyn Date: Mon, 10 Aug 2026 21:15:25 -0500 Subject: [PATCH 05/10] Keep ledger helper before tests --- crates/engine/src/game/ledger.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/ledger.rs b/crates/engine/src/game/ledger.rs index d24e9fe465..bb1a4e9a32 100644 --- a/crates/engine/src/game/ledger.rs +++ b/crates/engine/src/game/ledger.rs @@ -539,6 +539,10 @@ fn trigger_fire_ledger_key_for_replay( .unwrap_or_else(|| TriggerFireLedgerKey::Definition(trigger.clone())) } +fn history_len(len: usize) -> Result { + u32::try_from(len).map_err(|_| ResolvedLedgerEditReplayInvariantError::CounterOverflow) +} + #[cfg(test)] mod tests { use super::*; @@ -611,7 +615,3 @@ mod tests { assert_eq!(state.trigger_fire_counts_this_turn.len(), 1); } } - -fn history_len(len: usize) -> Result { - u32::try_from(len).map_err(|_| ResolvedLedgerEditReplayInvariantError::CounterOverflow) -} From b9de2a35c10ee13986a822646a6400ee46a3dcd6 Mon Sep 17 00:00:00 2001 From: traemyn Date: Tue, 11 Aug 2026 08:26:16 -0500 Subject: [PATCH 06/10] Aggregate legacy trigger counts by producer --- crates/engine/src/types/game_state.rs | 54 +++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index f60b048e67..c207fef9b0 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -348,15 +348,31 @@ fn migrate_legacy_trigger_fire_counts(state: &mut GameState) -> Result<(), Strin }) .collect(); + let mut migrated_counts: HashMap<_, u32> = HashMap::new(); + let mut legacy_definitions = Vec::with_capacity(legacy_counts.len()); for (definition, producer, count) in legacy_counts { - let grant_key = TriggerFireLedgerKey::Grant(producer); + let total = migrated_counts.entry(producer).or_insert(0); + *total = (*total) + .checked_add(count) + .ok_or_else(|| "legacy trigger ledger count overflow".to_string())?; + legacy_definitions.push(definition); + } + + for producer in migrated_counts.keys() { + let grant_key = TriggerFireLedgerKey::Grant(producer.clone()); if state.trigger_fire_counts_this_turn.contains_key(&grant_key) { return Err("legacy trigger ledger migration collides with a grant count".to_string()); } + } + for definition in legacy_definitions { state .trigger_fire_counts_this_turn .remove(&TriggerFireLedgerKey::Definition(definition)); - state.trigger_fire_counts_this_turn.insert(grant_key, count); + } + for (producer, count) in migrated_counts { + state + .trigger_fire_counts_this_turn + .insert(TriggerFireLedgerKey::Grant(producer), count); } Ok(()) } @@ -28497,6 +28513,7 @@ mod tests { #[test] fn game_state_deserialize_migrates_legacy_grant_fire_count_to_producer_key() { let object_id = ObjectId(993); + let second_object_id = ObjectId(994); let mut state = GameState::new_two_player(42); let mut object = GameObject::new( object_id, @@ -28529,17 +28546,46 @@ mod tests { .trigger_fire_counts_this_turn .insert(TriggerFireLedgerKey::Definition(definition), 2); + let mut second_object = GameObject::new( + second_object_id, + CardId(994), + PlayerId(0), + "Second legacy granted trigger".to_string(), + Zone::Battlefield, + ); + let second_grant_instance = second_object + .trigger_occurrence_state + .grant_instance_for(producer.clone()) + .expect("second grant instance allocates"); + let second_entry = TriggerEntry::with_grant_producer( + TriggerDefinitionOccurrenceRef::Granted { + grant_instance: second_grant_instance, + }, + TriggerDefinition::new(crate::types::triggers::TriggerMode::Attacks), + producer.clone(), + ); + let second_definition = second_object.trigger_definition_ref(&second_entry); + second_object.trigger_definitions.push(second_entry); + state.objects.insert(second_object_id, second_object); + state + .trigger_fire_counts_this_turn + .insert(TriggerFireLedgerKey::Definition(second_definition), 3); + let mut snapshot = serde_json::to_value(state).expect("serialize fixture state"); snapshot["objects"][object_id.0.to_string()]["trigger_definitions"][0] .as_object_mut() .expect("identity-bearing trigger serializes as an object") .remove("grant_producer"); + snapshot["objects"][second_object_id.0.to_string()]["trigger_definitions"][0] + .as_object_mut() + .expect("second identity-bearing trigger serializes as an object") + .remove("grant_producer"); let restored: GameState = serde_json::from_value(snapshot) - .expect("legacy grant provenance and ledger count restore"); + .expect("legacy grant provenance and combined ledger count restore"); assert_eq!( restored.trigger_fire_counts_this_turn, - HashMap::from([(TriggerFireLedgerKey::Grant(producer), 2)]) + HashMap::from([(TriggerFireLedgerKey::Grant(producer), 5)]) ); } From a686a87cbffe7e8f200b42face8ad47a739c96a4 Mon Sep 17 00:00:00 2001 From: traemyn Date: Tue, 11 Aug 2026 09:49:10 -0500 Subject: [PATCH 07/10] Fix Nadu trigger caps per recipient --- Cargo.lock | 164 ++++++++++++++++++ crates/engine/src/game/ledger.rs | 27 +-- crates/engine/src/game/triggers.rs | 15 +- crates/engine/src/types/ability.rs | 21 +-- crates/engine/src/types/game_state.rs | 89 ++-------- .../nadu_lavaspur_boots_max_times.rs | 36 ++-- crates/feed-scraper/Cargo.toml | 2 +- 7 files changed, 217 insertions(+), 137 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6fb898ec19..5ace0b9d44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,6 +801,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "engine-inventory-gen" version = "0.50.0" @@ -911,6 +920,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1115,6 +1139,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1268,6 +1311,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1316,6 +1360,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1334,9 +1394,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1805,6 +1867,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -1896,6 +1975,31 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.1.6" @@ -1908,6 +2012,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2473,17 +2589,22 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -2494,6 +2615,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -3182,6 +3304,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3348,6 +3491,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-retry" version = "0.3.2" @@ -3980,6 +4133,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/crates/engine/src/game/ledger.rs b/crates/engine/src/game/ledger.rs index bb1a4e9a32..d4cb679c4c 100644 --- a/crates/engine/src/game/ledger.rs +++ b/crates/engine/src/game/ledger.rs @@ -514,29 +514,14 @@ pub fn apply_resolved_ledger_edit( } fn trigger_fire_ledger_key_for_replay( - state: &GameState, + _state: &GameState, trigger: &TriggerDefinitionRef, ledger_key: Option<&TriggerFireLedgerKey>, ) -> TriggerFireLedgerKey { if let Some(ledger_key) = ledger_key { return ledger_key.clone(); } - - let grant_producer = state - .objects - .get(&trigger.source.object_id) - .filter(|object| object.incarnation == trigger.source.incarnation) - .and_then(|object| { - object - .trigger_definitions - .iter_all() - .find(|entry| entry.occurrence == trigger.occurrence) - }) - .and_then(|entry| entry.grant_producer.clone()); - - grant_producer - .map(TriggerFireLedgerKey::Grant) - .unwrap_or_else(|| TriggerFireLedgerKey::Definition(trigger.clone())) + TriggerFireLedgerKey::Definition(trigger.clone()) } fn history_len(len: usize) -> Result { @@ -559,7 +544,7 @@ mod tests { use crate::types::CardId; #[test] - fn legacy_max_times_replay_resolves_migrated_grant_key() { + fn max_times_replay_resolves_recipient_key() { let object_id = ObjectId(1); let mut state = GameState::new_two_player(42); let mut object = GameObject::new( @@ -591,11 +576,11 @@ mod tests { state.objects.insert(object_id, object); state .trigger_fire_counts_this_turn - .insert(TriggerFireLedgerKey::Grant(producer.clone()), 2); + .insert(TriggerFireLedgerKey::Definition(trigger.clone()), 2); let command = ResolvedLedgerEditCommand { edit: ResolvedLedgerEdit::TriggerFired { - trigger, + trigger: trigger.clone(), edit: ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old: 2, ledger_key: None, @@ -608,7 +593,7 @@ mod tests { assert_eq!( state .trigger_fire_counts_this_turn - .get(&TriggerFireLedgerKey::Grant(producer)) + .get(&TriggerFireLedgerKey::Definition(trigger)) .copied(), Some(3) ); diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 97712fd0c7..709b94a643 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -11269,20 +11269,9 @@ fn player_field(state: &GameState, controller: PlayerId, f: impl Fn(&Player) -> fn trigger_fire_ledger_key( definition_ref: &TriggerDefinitionRef, - source_context: Option<&TriggerSourceContext>, + _source_context: Option<&TriggerSourceContext>, ) -> crate::types::ability::TriggerFireLedgerKey { - source_context - .and_then(|source| { - source - .trigger_entries - .iter() - .find(|entry| entry.occurrence == definition_ref.occurrence) - }) - .and_then(|entry| entry.grant_producer.clone()) - .map(crate::types::ability::TriggerFireLedgerKey::Grant) - .unwrap_or_else(|| { - crate::types::ability::TriggerFireLedgerKey::Definition(definition_ref.clone()) - }) + crate::types::ability::TriggerFireLedgerKey::Definition(definition_ref.clone()) } /// Record that a constrained trigger has fired. diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 25e5a3966f..3817b86862 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -128,18 +128,9 @@ mod trigger_occurrence_tests { ]) .unwrap(); assert_eq!(entries.len(), 2); + assert_eq!(entries[0].grant_producer, Some(first_producer.clone())); + assert_eq!(entries[1].grant_producer, Some(second_producer.clone())); assert_ne!(entries[0].occurrence, entries[1].occurrence); - assert_ne!( - TriggerFireLedgerKey::Grant(first_producer), - TriggerFireLedgerKey::Grant(second_producer.clone()) - ); - assert_ne!( - TriggerFireLedgerKey::Grant(second_producer.clone()), - TriggerFireLedgerKey::Definition(TriggerDefinitionRef { - source: ObjectIncarnationRef::of(ObjectId(8), 0), - occurrence: entries[0].occurrence.clone(), - }) - ); } #[test] @@ -21889,16 +21880,14 @@ pub enum TriggerGrantProducerKey { }, } -/// Identity used only by the MaxTimesPerTurn ledger. +/// Identity used by the MaxTimesPerTurn ledger. /// -/// A granted trigger's occurrence includes a recipient-local generation, so it -/// cannot serve as a producer-wide cap key. Full TriggerDefinitionRef remains -/// the identity for event matching and look-back semantics. +/// The recipient-local occurrence is part of the identity because each granted +/// ability functions independently on its recipient (CR 113.2c). #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum TriggerFireLedgerKey { Definition(TriggerDefinitionRef), - Grant(TriggerGrantProducerKey), } /// The immutable occurrence component of a live trigger definition identity. diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index c207fef9b0..916a33627b 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -225,9 +225,6 @@ mod tuple_key_map { } } -/// Serde adapter for trigger occurrence ledgers. JSON object keys must be -/// strings, while a `TriggerDefinitionRef` is structured identity; encode the -/// map as an explicit entry list rather than flattening or guessing a key. #[cfg(test)] mod legacy_trigger_definition_ref_map { use super::*; @@ -255,6 +252,10 @@ mod legacy_trigger_definition_ref_map { } } +/// Serde adapter for trigger occurrence ledgers. JSON object keys must be +/// strings, while a `TriggerDefinitionRef` is structured identity; encode the +/// map as an explicit entry list. The untagged wire adapter accepts both the +/// current keyed representation and legacy definition-only entries. mod trigger_definition_ref_map { use super::*; @@ -313,70 +314,6 @@ where Ok(objects) } -/// Migrates the legacy MaxTimes ledger representation after object trigger -/// provenance has been restored. A legacy definition key is safe to promote -/// only when its exact recipient-local grant instance identifies one persisted -/// producer; equal definition payloads are never used as a fallback. -fn migrate_legacy_trigger_fire_counts(state: &mut GameState) -> Result<(), String> { - let mut producers_by_definition = HashMap::new(); - for (_, object) in state.objects.iter() { - for entry in object.trigger_definitions.iter_all() { - let Some(producer) = entry.grant_producer.as_ref() else { - continue; - }; - let definition = object.trigger_definition_ref(entry); - if let Some(previous) = producers_by_definition.insert(definition, producer.clone()) { - if previous != *producer { - return Err( - "legacy trigger ledger definition maps to conflicting grant producers" - .to_string(), - ); - } - } - } - } - - let legacy_counts: Vec<_> = state - .trigger_fire_counts_this_turn - .iter() - .filter_map(|(key, count)| match key { - TriggerFireLedgerKey::Definition(definition) => producers_by_definition - .get(definition) - .cloned() - .map(|producer| (definition.clone(), producer, *count)), - TriggerFireLedgerKey::Grant(_) => None, - }) - .collect(); - - let mut migrated_counts: HashMap<_, u32> = HashMap::new(); - let mut legacy_definitions = Vec::with_capacity(legacy_counts.len()); - for (definition, producer, count) in legacy_counts { - let total = migrated_counts.entry(producer).or_insert(0); - *total = (*total) - .checked_add(count) - .ok_or_else(|| "legacy trigger ledger count overflow".to_string())?; - legacy_definitions.push(definition); - } - - for producer in migrated_counts.keys() { - let grant_key = TriggerFireLedgerKey::Grant(producer.clone()); - if state.trigger_fire_counts_this_turn.contains_key(&grant_key) { - return Err("legacy trigger ledger migration collides with a grant count".to_string()); - } - } - for definition in legacy_definitions { - state - .trigger_fire_counts_this_turn - .remove(&TriggerFireLedgerKey::Definition(definition)); - } - for (producer, count) in migrated_counts { - state - .trigger_fire_counts_this_turn - .insert(TriggerFireLedgerKey::Grant(producer), count); - } - Ok(()) -} - /// Tracks whether the game is in day or night state (CR 730). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DayNight { @@ -16049,7 +15986,6 @@ impl GameStateDecode { let mut state = serde_json::from_value::(value) .map(ResolutionStateWire::into_game_state) .map_err(|error| error.to_string())?; - migrate_legacy_trigger_fire_counts(&mut state)?; validate_restored_zone_change_replay_keys(&state)?; normalize_delayed_trigger_allocators(&mut state)?; validate_trigger_firing_coherence(&state)?; @@ -16075,7 +16011,6 @@ impl GameStateDecode { )?; } let mut state = Self::materialize_prepared(value)?; - migrate_legacy_trigger_fire_counts(&mut state)?; normalize_delayed_trigger_allocators(&mut state)?; validate_trigger_firing_coherence(&state)?; // Both decode entry points guard, because they are genuinely two ingresses: @@ -28511,7 +28446,7 @@ mod tests { } #[test] - fn game_state_deserialize_migrates_legacy_grant_fire_count_to_producer_key() { + fn game_state_deserialize_preserves_legacy_grant_fire_counts_by_recipient() { let object_id = ObjectId(993); let second_object_id = ObjectId(994); let mut state = GameState::new_two_player(42); @@ -28544,7 +28479,7 @@ mod tests { state.objects.insert(object_id, object); state .trigger_fire_counts_this_turn - .insert(TriggerFireLedgerKey::Definition(definition), 2); + .insert(TriggerFireLedgerKey::Definition(definition.clone()), 2); let mut second_object = GameObject::new( second_object_id, @@ -28567,9 +28502,10 @@ mod tests { let second_definition = second_object.trigger_definition_ref(&second_entry); second_object.trigger_definitions.push(second_entry); state.objects.insert(second_object_id, second_object); - state - .trigger_fire_counts_this_turn - .insert(TriggerFireLedgerKey::Definition(second_definition), 3); + state.trigger_fire_counts_this_turn.insert( + TriggerFireLedgerKey::Definition(second_definition.clone()), + 3, + ); let mut snapshot = serde_json::to_value(state).expect("serialize fixture state"); snapshot["objects"][object_id.0.to_string()]["trigger_definitions"][0] @@ -28585,7 +28521,10 @@ mod tests { .expect("legacy grant provenance and combined ledger count restore"); assert_eq!( restored.trigger_fire_counts_this_turn, - HashMap::from([(TriggerFireLedgerKey::Grant(producer), 5)]) + HashMap::from([ + (TriggerFireLedgerKey::Definition(definition), 2), + (TriggerFireLedgerKey::Definition(second_definition), 3), + ]) ); } diff --git a/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs b/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs index 206c6894d9..b9e0debd67 100644 --- a/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs +++ b/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs @@ -1,8 +1,8 @@ //! Regression for Nadu's granted MaxTimesPerTurn trigger through Lavaspur Boots. //! -//! Nadu grants the targeting trigger to each creature separately, but one grant -//! producer owns one shared "twice each turn" limit. Each Equip activation below -//! uses the production targeting and trigger-collection pipeline. +//! Nadu grants the targeting trigger to each creature separately, and each +//! recipient's ability owns its own "twice each turn" limit. Each Equip +//! activation below uses the production targeting and trigger-collection pipeline. use engine::game::layers::evaluate_layers; use engine::game::scenario::{GameScenario, P0}; @@ -17,12 +17,12 @@ const LAVASPUR_BOOTS_ORACLE: &str = "Equipped creature gets +1/+0 and has haste and ward {1}.\nEquip {1}"; #[test] -fn nadu_granted_trigger_shares_max_times_cap_across_lavaspur_targets() { +fn nadu_granted_trigger_has_independent_max_times_caps_per_target() { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); scenario.with_mana_pool( P0, - (0..3) + (0..6) .map(|_| { ManaUnit::new( ManaType::Colorless, @@ -33,7 +33,10 @@ fn nadu_granted_trigger_shares_max_times_cap_across_lavaspur_targets() { }) .collect(), ); - scenario.with_library_top(P0, &["Forest", "Island", "Mountain"]); + scenario.with_library_top( + P0, + &["Forest", "Island", "Mountain", "Plains", "Swamp", "Forest"], + ); let nadu = scenario .add_creature_from_oracle(P0, "Nadu, Winged Wisdom", 3, 4, NADU_ORACLE) @@ -106,19 +109,30 @@ fn nadu_granted_trigger_shares_max_times_cap_across_lavaspur_targets() { first_generation, second_generation, "a blinked recipient must receive a fresh grant generation" ); - for target in [first_target, second_target, third_target] { + for target in [ + first_target, + second_target, + third_target, + first_target, + second_target, + third_target, + ] { runner.activate(boots, 0).target_object(target).resolve(); } let counts = &runner.state().trigger_fire_counts_this_turn; assert_eq!( counts.values().sum::(), - 2, - "Nadu's granted trigger must fire only twice across all creatures targeted by Equip" + 6, + "Nadu's granted trigger must fire twice for each creature targeted by Equip" ); assert_eq!( counts.len(), - 1, - "one Layer-6 grant producer must own one MaxTimesPerTurn ledger entry" + 3, + "each recipient must own an independent MaxTimesPerTurn ledger entry" + ); + assert!( + counts.values().all(|count| *count == 2), + "each recipient's granted trigger must retain two uses" ); } diff --git a/crates/feed-scraper/Cargo.toml b/crates/feed-scraper/Cargo.toml index de80d2e7f9..5f4ecaf087 100644 --- a/crates/feed-scraper/Cargo.toml +++ b/crates/feed-scraper/Cargo.toml @@ -10,7 +10,7 @@ path = "src/main.rs" [dependencies] clap = { version = "4", features = ["derive"] } -reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls-webpki-roots"] } +reqwest = { version = "0.12", features = ["blocking"] } scraper = "0.22" serde = { workspace = true } serde_json = "1" From 7abfabca75c8d72d7da1f25c4c158a01f11ce807 Mon Sep 17 00:00:00 2001 From: traemyn Date: Tue, 11 Aug 2026 12:40:28 -0500 Subject: [PATCH 08/10] Exercise rejected Nadu trigger attempts --- .../tests/integration/nadu_lavaspur_boots_max_times.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs b/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs index b9e0debd67..e4399edf4b 100644 --- a/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs +++ b/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs @@ -22,7 +22,7 @@ fn nadu_granted_trigger_has_independent_max_times_caps_per_target() { scenario.at_phase(Phase::PreCombatMain); scenario.with_mana_pool( P0, - (0..6) + (0..9) .map(|_| { ManaUnit::new( ManaType::Colorless, @@ -35,7 +35,10 @@ fn nadu_granted_trigger_has_independent_max_times_caps_per_target() { ); scenario.with_library_top( P0, - &["Forest", "Island", "Mountain", "Plains", "Swamp", "Forest"], + &[ + "Forest", "Island", "Mountain", "Plains", "Swamp", "Forest", "Island", "Mountain", + "Plains", + ], ); let nadu = scenario @@ -116,6 +119,9 @@ fn nadu_granted_trigger_has_independent_max_times_caps_per_target() { first_target, second_target, third_target, + first_target, + second_target, + third_target, ] { runner.activate(boots, 0).target_object(target).resolve(); } From 216aae8a67b6c64d51215d7dfb4c6fc7e9fbf4f7 Mon Sep 17 00:00:00 2001 From: traemyn Date: Tue, 11 Aug 2026 14:10:05 -0500 Subject: [PATCH 09/10] Simplify recipient-local trigger ledger accounting --- crates/engine/src/analysis/resource.rs | 14 +-- crates/engine/src/game/game_object.rs | 107 +------------------ crates/engine/src/game/layers.rs | 4 +- crates/engine/src/game/ledger.rs | 62 +++-------- crates/engine/src/game/triggers.rs | 14 +-- crates/engine/src/types/ability.rs | 37 +------ crates/engine/src/types/game_state.rs | 83 ++++---------- crates/engine/src/types/resolved_commands.rs | 6 +- 8 files changed, 49 insertions(+), 278 deletions(-) diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 4ef0c00b2d..3197611125 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -5288,7 +5288,7 @@ fn ability_has_per_game_activation_gate(state: &GameState, key: &(ObjectId, usiz mod tests { use super::*; use crate::game::game_object::GameObject; - use crate::types::ability::{TriggerDefinitionRef, TriggerFireLedgerKey}; + use crate::types::ability::TriggerDefinitionRef; use crate::types::identifiers::{ CardId, DelayedTriggerInstanceId, DelayedTriggerOrigin, DelayedTriggerToken, }; @@ -6238,15 +6238,11 @@ mod tests { fn trigger_max_times_per_turn_gate_breaks_modulo_equality() { let mut a = GameState::new_two_player(7); let oid = battlefield_creature(&mut a, 730, 0); - a.trigger_fire_counts_this_turn.insert( - TriggerFireLedgerKey::Definition(test_trigger_ref(&a, oid)), - 1, - ); + a.trigger_fire_counts_this_turn + .insert(test_trigger_ref(&a, oid), 1); let mut b = a.clone(); - b.trigger_fire_counts_this_turn.insert( - TriggerFireLedgerKey::Definition(test_trigger_ref(&b, oid)), - 2, - ); // limit progressed + b.trigger_fire_counts_this_turn + .insert(test_trigger_ref(&b, oid), 2); // limit progressed b.players[1].life -= 1; assert!( !loop_states_equal_modulo_resources(&a, &b), diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index a6f91058cb..e5dd2eff86 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -1625,8 +1625,7 @@ impl GameObject { ) }); if !has_legacy_entries { - self.validate_trigger_definitions()?; - return self.migrate_legacy_trigger_provenance(); + return self.validate_trigger_definitions(); } if self.base_trigger_definitions.is_empty() || self.trigger_definitions.len() != self.base_trigger_definitions.len() @@ -1645,63 +1644,7 @@ impl GameObject { return Err("legacy runtime trigger payload has no provable producer or base slot"); } self.materialize_base_trigger_definitions(); - self.validate_trigger_definitions()?; - self.migrate_legacy_trigger_provenance() - } - - /// Restores producer provenance for identity-bearing granted triggers from - /// the persisted recipient-local grant table. Older payloads may contain a - /// `Granted` occurrence without the producer field; guessing from the - /// trigger definition would fragment `MaxTimesPerTurn` accounting again. - pub fn migrate_legacy_trigger_provenance(&mut self) -> Result<(), &'static str> { - let active_grants: Vec<_> = self - .trigger_occurrence_state - .active_grants() - .map(|(producer, instance)| (instance, producer.clone())) - .collect(); - - for index in 0..self.trigger_definitions.len() { - let (grant_instance, existing_producer) = { - let entry = self - .trigger_definitions - .get(index) - .expect("trigger definition index must remain valid"); - let grant_instance = match &entry.occurrence { - TriggerDefinitionOccurrenceRef::KeywordCompanion { grant_instance, .. } - | TriggerDefinitionOccurrenceRef::CopyRetained { grant_instance, .. } - | TriggerDefinitionOccurrenceRef::Granted { grant_instance } - | TriggerDefinitionOccurrenceRef::ExpandedGrant { grant_instance, .. } => { - Some(*grant_instance) - } - TriggerDefinitionOccurrenceRef::Printed { .. } - | TriggerDefinitionOccurrenceRef::CopiedValue { .. } - | TriggerDefinitionOccurrenceRef::Unmaterialized => None, - }; - (grant_instance, entry.grant_producer.clone()) - }; - let Some(grant_instance) = grant_instance else { - continue; - }; - let Some((_, producer)) = active_grants - .iter() - .find(|(instance, _)| *instance == grant_instance) - else { - return Err("grant trigger has no persisted producer provenance"); - }; - match existing_producer { - Some(existing) if existing != *producer => { - return Err("grant trigger producer provenance does not match its instance"); - } - Some(_) => {} - None => { - self.trigger_definitions - .get_mut(index) - .expect("trigger definition index must remain valid") - .grant_producer = Some(producer.clone()); - } - } - } - Ok(()) + self.validate_trigger_definitions() } /// Apply an Alchemy "perpetually" modification to this card: record it on the @@ -3062,7 +3005,6 @@ mod tests { use super::*; use crate::types::ability::{ TriggerDefinition, TriggerDefinitionOccurrenceRef, TriggerEntry, TriggerGrantInstanceRef, - TriggerGrantProducerKey, TriggerProducerOrigin, }; use crate::types::counter::parse_counter_type; use crate::types::triggers::TriggerMode; @@ -3403,51 +3345,6 @@ mod tests { ); } - #[test] - fn legacy_grant_trigger_restores_producer_from_persisted_instance() { - let mut object = trigger_test_object(); - let producer = TriggerGrantProducerKey::Granted { - origin: TriggerProducerOrigin::Transient { - continuous_effect_id: 1, - modification_index: 0, - }, - output_index: 0, - }; - let grant_instance = object - .trigger_occurrence_state - .grant_instance_for(producer.clone()) - .unwrap(); - object.trigger_definitions = vec![TriggerEntry { - occurrence: TriggerDefinitionOccurrenceRef::Granted { grant_instance }, - definition: TriggerDefinition::new(TriggerMode::Phase), - grant_producer: None, - }] - .into(); - - object - .migrate_legacy_trigger_provenance() - .expect("persisted grant instance proves the producer identity"); - assert_eq!(object.trigger_definitions[0].grant_producer, Some(producer)); - } - - #[test] - fn grant_trigger_without_persisted_instance_is_rejected() { - let mut object = trigger_test_object(); - object.trigger_definitions = vec![TriggerEntry { - occurrence: TriggerDefinitionOccurrenceRef::Granted { - grant_instance: TriggerGrantInstanceRef(7), - }, - definition: TriggerDefinition::new(TriggerMode::Phase), - grant_producer: None, - }] - .into(); - - assert_eq!( - object.migrate_legacy_trigger_provenance(), - Err("grant trigger has no persisted producer provenance") - ); - } - fn trigger_test_object() -> GameObject { GameObject::new( ObjectId(1), diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index f8a4ab58e0..308c3b3647 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -7007,8 +7007,8 @@ fn install_trigger_candidate( return; } obj.trigger_definitions - .push(crate::types::ability::TriggerEntry::with_grant_producer( - occurrence, definition, producer, + .push(crate::types::ability::TriggerEntry::new( + occurrence, definition, )); } diff --git a/crates/engine/src/game/ledger.rs b/crates/engine/src/game/ledger.rs index d4cb679c4c..89bdebc2f0 100644 --- a/crates/engine/src/game/ledger.rs +++ b/crates/engine/src/game/ledger.rs @@ -1,6 +1,6 @@ //! Final authority for composable per-event ledger facts. -use crate::types::ability::{TriggerDefinitionRef, TriggerFireLedgerKey}; +use crate::types::ability::TriggerDefinitionRef; use crate::types::game_state::{GameState, SpellCastRecord}; use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; use crate::types::player::PlayerId; @@ -453,14 +453,10 @@ pub fn apply_resolved_ledger_edit( return Err(ResolvedLedgerEditReplayInvariantError::TriggerAlreadyRecorded); } } - ResolvedTriggerLedgerEdit::MaxTimesPerTurn { - expected_old, - ledger_key, - } => { - let key = trigger_fire_ledger_key_for_replay(state, trigger, ledger_key.as_ref()); + ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old } => { let found = state .trigger_fire_counts_this_turn - .get(&key) + .get(trigger) .copied() .unwrap_or(0); if found != *expected_old { @@ -474,7 +470,9 @@ pub fn apply_resolved_ledger_edit( let next = expected_old .checked_add(1) .ok_or(ResolvedLedgerEditReplayInvariantError::CounterOverflow)?; - state.trigger_fire_counts_this_turn.insert(key, next); + state + .trigger_fire_counts_this_turn + .insert(trigger.clone(), next); } }, ResolvedLedgerEdit::OncePerTurnPermission { source, permission } => { @@ -513,17 +511,6 @@ pub fn apply_resolved_ledger_edit( Ok(()) } -fn trigger_fire_ledger_key_for_replay( - _state: &GameState, - trigger: &TriggerDefinitionRef, - ledger_key: Option<&TriggerFireLedgerKey>, -) -> TriggerFireLedgerKey { - if let Some(ledger_key) = ledger_key { - return ledger_key.clone(); - } - TriggerFireLedgerKey::Definition(trigger.clone()) -} - fn history_len(len: usize) -> Result { u32::try_from(len).map_err(|_| ResolvedLedgerEditReplayInvariantError::CounterOverflow) } @@ -532,11 +519,8 @@ fn history_len(len: usize) -> Result definition_ref.is_none_or(|key| { - let ledger_key = trigger_fire_ledger_key(key, source_context); state .trigger_fire_counts_this_turn - .get(&ledger_key) + .get(key) .copied() .unwrap_or(0) < *max @@ -11267,13 +11266,6 @@ fn player_field(state: &GameState, controller: PlayerId, f: impl Fn(&Player) -> .unwrap_or(false) } -fn trigger_fire_ledger_key( - definition_ref: &TriggerDefinitionRef, - _source_context: Option<&TriggerSourceContext>, -) -> crate::types::ability::TriggerFireLedgerKey { - crate::types::ability::TriggerFireLedgerKey::Definition(definition_ref.clone()) -} - /// Record that a constrained trigger has fired. fn record_trigger_fired_with_ref( state: &mut GameState, @@ -11345,10 +11337,9 @@ fn record_trigger_fired_with_ref( } // Increment the captured fire count for MaxTimesPerTurn tracking. TriggerConstraint::MaxTimesPerTurn { .. } => { - let ledger_key = trigger_fire_ledger_key(key, source_context); let expected_old = state .trigger_fire_counts_this_turn - .get(&ledger_key) + .get(key) .copied() .unwrap_or(0); crate::game::ledger::record_trigger_fired( @@ -11356,7 +11347,6 @@ fn record_trigger_fired_with_ref( key.clone(), crate::types::resolved_commands::ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old, - ledger_key: Some(ledger_key), }, ) .expect("max-times trigger must have a valid ledger prefix"); diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 3817b86862..ccc3922fae 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -128,8 +128,6 @@ mod trigger_occurrence_tests { ]) .unwrap(); assert_eq!(entries.len(), 2); - assert_eq!(entries[0].grant_producer, Some(first_producer.clone())); - assert_eq!(entries[1].grant_producer, Some(second_producer.clone())); assert_ne!(entries[0].occurrence, entries[1].occurrence); } @@ -21880,16 +21878,6 @@ pub enum TriggerGrantProducerKey { }, } -/// Identity used by the MaxTimesPerTurn ledger. -/// -/// The recipient-local occurrence is part of the identity because each granted -/// ability functions independently on its recipient (CR 113.2c). -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(tag = "type", content = "data")] -pub enum TriggerFireLedgerKey { - Definition(TriggerDefinitionRef), -} - /// The immutable occurrence component of a live trigger definition identity. /// /// It is intentionally a closed typed representation rather than a definition @@ -21948,8 +21936,6 @@ pub struct TriggerDefinitionRef { pub struct TriggerEntry { pub occurrence: TriggerDefinitionOccurrenceRef, pub definition: TriggerDefinition, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub grant_producer: Option, } impl TriggerEntry { @@ -21957,19 +21943,6 @@ impl TriggerEntry { Self { occurrence, definition, - grant_producer: None, - } - } - - pub fn with_grant_producer( - occurrence: TriggerDefinitionOccurrenceRef, - definition: TriggerDefinition, - grant_producer: TriggerGrantProducerKey, - ) -> Self { - Self { - occurrence, - definition, - grant_producer: Some(grant_producer), } } @@ -22000,8 +21973,6 @@ enum TriggerEntryWire { IdentityBearing { occurrence: TriggerDefinitionOccurrenceRef, definition: TriggerDefinition, - #[serde(default)] - grant_producer: Option, }, LegacyPayload(TriggerDefinition), } @@ -22015,11 +21986,9 @@ impl<'de> Deserialize<'de> for TriggerEntry { TriggerEntryWire::IdentityBearing { occurrence, definition, - grant_producer, } => Ok(Self { occurrence, definition, - grant_producer, }), // A later GameState normalization validates this only for a // provable printed/base slot. Keeping the marker here preserves the @@ -22136,10 +22105,9 @@ impl TriggerOccurrenceState { .iter() .find(|active| active.instance == grant_instance) .expect("reconciled grant instance must remain active"); - TriggerEntry::with_grant_producer( + TriggerEntry::new( occurrence_for_grant(&producer.producer, grant_instance), definition, - producer.producer.clone(), ) }) .collect() @@ -22167,10 +22135,9 @@ impl TriggerOccurrenceState { .iter() .find(|active| active.instance == grant_instance) .expect("reconciled grant instance must remain active"); - TriggerEntry::with_grant_producer( + TriggerEntry::new( occurrence_for_grant(&producer.producer, grant_instance), definition, - producer.producer.clone(), ) }) .collect() diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 916a33627b..4d2805b759 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -16,7 +16,6 @@ use super::ability::{ PermanentEntryMode, PileSource, QuantityExpr, ResolvedAbility, SearchDestinationSplit, SearchSelectionConstraint, StaticCondition, TapCreaturesAggregate, TargetFilter, TargetRef, ThisWayCause, TriggerCondition, TriggerDefinition, TriggerDefinitionRef, TriggerEntry, - TriggerFireLedgerKey, }; use super::attribution::ObjectAttribution; use super::card::{CardFace, PrintedCardRef, TokenImageRef}; @@ -254,13 +253,12 @@ mod legacy_trigger_definition_ref_map { /// Serde adapter for trigger occurrence ledgers. JSON object keys must be /// strings, while a `TriggerDefinitionRef` is structured identity; encode the -/// map as an explicit entry list. The untagged wire adapter accepts both the -/// current keyed representation and legacy definition-only entries. +/// map as an explicit entry list rather than flattening or guessing a key. mod trigger_definition_ref_map { use super::*; pub fn serialize( - map: &HashMap, + map: &HashMap, serializer: S, ) -> Result where @@ -273,24 +271,12 @@ mod trigger_definition_ref_map { pub fn deserialize<'de, D>( deserializer: D, - ) -> Result, D::Error> + ) -> Result, D::Error> where D: serde::Deserializer<'de>, { - #[derive(Deserialize)] - #[serde(untagged)] - enum Wire { - Current(Vec<(TriggerFireLedgerKey, u32)>), - Legacy(Vec<(TriggerDefinitionRef, u32)>), - } - - Wire::deserialize(deserializer).map(|wire| match wire { - Wire::Current(entries) => entries.into_iter().collect(), - Wire::Legacy(entries) => entries - .into_iter() - .map(|(key, count)| (TriggerFireLedgerKey::Definition(key), count)) - .collect(), - }) + Vec::<(TriggerDefinitionRef, u32)>::deserialize(deserializer) + .map(|entries| entries.into_iter().collect()) } } @@ -14790,7 +14776,7 @@ declare_game_state! { skip_serializing_if = "HashMap::is_empty", with = "trigger_definition_ref_map" )] - pub trigger_fire_counts_this_turn: HashMap, + pub trigger_fire_counts_this_turn: HashMap, /// CR 603.2: Tracks per-opponent-per-turn firing for /// OncePerOpponentPerTurn. Keyed by exact occurrence and opponent. #[serde(default)] @@ -22520,7 +22506,7 @@ mod tests { use crate::types::ability::{ AbilityDefinition, AbilityKind, Effect, PostReplacementContinuation, QuantityExpr, ResolvedAbility, TargetFilter, TriggerBaseSetInstanceRef, TriggerDefinitionOccurrenceRef, - TriggerEntry, TriggerGrantProducerKey, TriggerProducerOrigin, + TriggerEntry, TriggerGrantInstanceRef, }; use crate::types::deterministic_serde::test_support::ReverseBuildHasher; use crate::types::identifiers::{ @@ -28457,29 +28443,18 @@ mod tests { "Legacy granted trigger".to_string(), Zone::Battlefield, ); - let producer = TriggerGrantProducerKey::Granted { - origin: TriggerProducerOrigin::Static { - source: ObjectIncarnationRef::from_object(&object), - definition_index: 0, - modification_index: 0, + let entry = TriggerEntry::new( + TriggerDefinitionOccurrenceRef::Granted { + grant_instance: TriggerGrantInstanceRef(1), }, - output_index: 0, - }; - let grant_instance = object - .trigger_occurrence_state - .grant_instance_for(producer.clone()) - .expect("grant instance allocates"); - let entry = TriggerEntry::with_grant_producer( - TriggerDefinitionOccurrenceRef::Granted { grant_instance }, TriggerDefinition::new(crate::types::triggers::TriggerMode::Attacks), - producer.clone(), ); let definition = object.trigger_definition_ref(&entry); object.trigger_definitions.push(entry); state.objects.insert(object_id, object); state .trigger_fire_counts_this_turn - .insert(TriggerFireLedgerKey::Definition(definition.clone()), 2); + .insert(definition.clone(), 2); let mut second_object = GameObject::new( second_object_id, @@ -28488,43 +28463,25 @@ mod tests { "Second legacy granted trigger".to_string(), Zone::Battlefield, ); - let second_grant_instance = second_object - .trigger_occurrence_state - .grant_instance_for(producer.clone()) - .expect("second grant instance allocates"); - let second_entry = TriggerEntry::with_grant_producer( + let second_entry = TriggerEntry::new( TriggerDefinitionOccurrenceRef::Granted { - grant_instance: second_grant_instance, + grant_instance: TriggerGrantInstanceRef(1), }, TriggerDefinition::new(crate::types::triggers::TriggerMode::Attacks), - producer.clone(), ); let second_definition = second_object.trigger_definition_ref(&second_entry); second_object.trigger_definitions.push(second_entry); state.objects.insert(second_object_id, second_object); - state.trigger_fire_counts_this_turn.insert( - TriggerFireLedgerKey::Definition(second_definition.clone()), - 3, - ); - - let mut snapshot = serde_json::to_value(state).expect("serialize fixture state"); - snapshot["objects"][object_id.0.to_string()]["trigger_definitions"][0] - .as_object_mut() - .expect("identity-bearing trigger serializes as an object") - .remove("grant_producer"); - snapshot["objects"][second_object_id.0.to_string()]["trigger_definitions"][0] - .as_object_mut() - .expect("second identity-bearing trigger serializes as an object") - .remove("grant_producer"); + state + .trigger_fire_counts_this_turn + .insert(second_definition.clone(), 3); - let restored: GameState = serde_json::from_value(snapshot) - .expect("legacy grant provenance and combined ledger count restore"); + let snapshot = serde_json::to_value(state).expect("serialize fixture state"); + let restored: GameState = + serde_json::from_value(snapshot).expect("recipient-specific ledger counts restore"); assert_eq!( restored.trigger_fire_counts_this_turn, - HashMap::from([ - (TriggerFireLedgerKey::Definition(definition), 2), - (TriggerFireLedgerKey::Definition(second_definition), 3), - ]) + HashMap::from([(definition, 2), (second_definition, 3),]) ); } diff --git a/crates/engine/src/types/resolved_commands.rs b/crates/engine/src/types/resolved_commands.rs index f908030767..20198c9397 100644 --- a/crates/engine/src/types/resolved_commands.rs +++ b/crates/engine/src/types/resolved_commands.rs @@ -769,11 +769,7 @@ pub enum ResolvedTriggerLedgerEdit { /// CR 603.2c: This trigger occurrence has used this opponent's per-turn fact. OncePerOpponentPerTurn { opponent: PlayerId }, /// Increment from the captured prior count for MaxTimesPerTurn. - MaxTimesPerTurn { - expected_old: u32, - #[serde(default)] - ledger_key: Option, - }, + MaxTimesPerTurn { expected_old: u32 }, } /// A named once-per-turn permission slot consumed by a completed play or cast. From b16d1d6cdb685b7ff3f361c5ad45f1be8d491060 Mon Sep 17 00:00:00 2001 From: traemyn Date: Tue, 11 Aug 2026 15:43:35 -0500 Subject: [PATCH 10/10] Remove redundant trigger grant lifecycle hooks --- crates/engine/src/game/elimination.rs | 13 --- crates/engine/src/game/game_object.rs | 1 - crates/engine/src/game/zones.rs | 88 +------------------ crates/engine/src/types/ability.rs | 50 ----------- .../nadu_lavaspur_boots_max_times.rs | 28 ------ .../src/policies/tests/draw_payoff.rs | 8 +- 6 files changed, 4 insertions(+), 184 deletions(-) diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 1fc3a7cfc7..45254f8ba8 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -920,7 +920,6 @@ fn do_eliminate( // player leaving the game, so the consult is skipped while the // unconditional primitive guards still run (PLAN §3). exile_owned_objects_on_player_left_game(state, player, events); - retire_trigger_grants_owned_by(state, player); crate::game::planechase::finish_player_left_game_handoff(state, planar_handoff, events); state.auto_pass.remove(&player); @@ -1176,18 +1175,6 @@ fn abandon_change_zone_family_for_controller(state: &mut GameState, player: Play }; } -/// CR 800.4a: The trigger-grant registry is object-local serialized state. -/// Once its owner has left, no active producer may survive to a later layer -/// reconciliation; preserve the monotonic allocator while retiring all active -/// instances so no occurrence is resurrected. -fn retire_trigger_grants_owned_by(state: &mut GameState, player: PlayerId) { - for (_, object) in state.objects.iter_mut() { - if object.owner == player { - object.trigger_occurrence_state.retire_all_grants(); - } - } -} - /// CR 104.2a: A player wins if all opponents have left. CR 104.3g: A team loses if all members have lost. /// /// Check if the game should end. Game ends when 1 or fewer living players/teams remain. diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index e5dd2eff86..d95807ec68 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -2391,7 +2391,6 @@ impl GameObject { // Bump the incarnation so self-references captured by abilities created // for the previous incarnation no longer match this permanent. self.bump_incarnation(); - self.trigger_occurrence_state.retire_all_grants(); // CR 613.7d: an object receives a timestamp when it enters a zone. Stage 2 // stamps battlefield entries only; all-zone entry stamping (graveyard/exile- // functioning statics) is a deferred hook (see scope boundary). diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index d807cbffa1..bf2112a9a5 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -922,7 +922,6 @@ pub fn apply_resolved_zone_change( .get_mut(&command.object.object_id) .expect("validated zone command object remains live"); object.zone = command.to; - object.trigger_occurrence_state.retire_all_grants(); if command.to == Zone::Battlefield { object.reset_for_battlefield_entry( turn_number, @@ -1194,7 +1193,6 @@ pub fn move_to_zone( zone_change_record.entered_incarnation = Some(obj_mut.incarnation); } else if from != to { // CR 400.7: a move between zones creates a new object. - obj_mut.trigger_occurrence_state.retire_all_grants(); obj_mut.bump_incarnation(); } (pre_bump_incarnation, obj_mut.incarnation, false) @@ -1829,7 +1827,6 @@ pub fn move_to_library_at_index( // A within-Library reposition (reveal / scry bottom placement / look-at-top-N, // CR 701.20b) is zero moves — `from == Library` here — and must NOT bump. if from != Zone::Library { - obj_mut.trigger_occurrence_state.retire_all_grants(); obj_mut.bump_incarnation(); bump = Some((pre_bump_incarnation, obj_mut.incarnation)); } @@ -2086,7 +2083,6 @@ pub(crate) fn route_component(state: &mut GameState, component_id: ObjectId, to: // CR 730.3 + CR 400.7: the component becomes a new object in its // owner's destination zone. Keep this beside the raw delivery so // `apply_zone_exit_cleanup` cannot double-bump normal moves. - component.trigger_occurrence_state.retire_all_grants(); component.bump_incarnation(); } // CR 700.11: a nontoken permanent card put into its owner's graveyard from @@ -2413,7 +2409,7 @@ mod tests { use super::*; use crate::types::ability::{ ContinuousModification, ControllerRef, FilterProp, StaticDefinition, TargetFilter, - TriggerGrantProducerKey, TriggerProducerOrigin, TypeFilter, TypedFilter, + TypeFilter, TypedFilter, }; use crate::types::game_state::GameState; use crate::types::keywords::Keyword; @@ -2423,25 +2419,6 @@ mod tests { GameState::new_two_player(42) } - fn install_test_grant(state: &mut GameState, object_id: ObjectId) { - let source = ObjectIncarnationRef::from_object(&state.objects[&object_id]); - let producer = TriggerGrantProducerKey::Granted { - origin: TriggerProducerOrigin::Static { - source, - definition_index: 0, - modification_index: 0, - }, - output_index: 0, - }; - state - .objects - .get_mut(&object_id) - .unwrap() - .trigger_occurrence_state - .reconcile_grant_instances(vec![(producer, ())]) - .expect("test grant producer is unique"); - } - #[test] fn create_object_assigns_id_and_inserts() { let mut state = setup(); @@ -3273,69 +3250,6 @@ mod tests { assert_eq!(state.players[0].library[1], id2); // goes to bottom } - #[test] - fn move_to_library_retires_active_grants_before_new_incarnation() { - let mut state = setup(); - let id = create_object( - &mut state, - CardId(1), - PlayerId(0), - "Granted permanent".to_string(), - Zone::Battlefield, - ); - install_test_grant(&mut state, id); - assert_eq!( - state.objects[&id] - .trigger_occurrence_state - .active_grants() - .count(), - 1 - ); - - let mut events = Vec::new(); - move_to_library_at_index(&mut state, id, Some(0), &mut events); - - assert_eq!( - state.objects[&id] - .trigger_occurrence_state - .active_grants() - .count(), - 0, - "a library move must retire grants before the object incarnation changes" - ); - } - - #[test] - fn route_component_retires_active_grants_before_new_incarnation() { - let mut state = setup(); - let id = create_object( - &mut state, - CardId(1), - PlayerId(0), - "Merged component".to_string(), - Zone::Battlefield, - ); - install_test_grant(&mut state, id); - assert_eq!( - state.objects[&id] - .trigger_occurrence_state - .active_grants() - .count(), - 1 - ); - - route_component(&mut state, id, Zone::Graveyard); - - assert_eq!( - state.objects[&id] - .trigger_occurrence_state - .active_grants() - .count(), - 0, - "routing an absorbed component must retire its old grants" - ); - } - #[test] fn within_library_reposition_does_not_create_a_zone_change() { let mut state = setup(); diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index ccc3922fae..e0aa1848b4 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -88,24 +88,6 @@ mod trigger_occurrence_tests { assert!(third.0 > first.0, "serialized generations are monotonic"); } - #[test] - fn zone_change_retirement_forces_a_fresh_grant_generation() { - let producer = TriggerGrantProducerKey::Granted { - origin: static_origin(), - output_index: 0, - }; - let mut state = TriggerOccurrenceState::default(); - let first = state.grant_instance_for(producer.clone()).unwrap(); - state.retire_all_grants(); - let second = state.grant_instance_for(producer).unwrap(); - - assert_ne!( - first, second, - "a new object incarnation must not reuse a grant generation" - ); - assert!(second.0 > first.0, "grant generations remain monotonic"); - } - #[test] fn identical_grants_from_distinct_producers_remain_distinct_entries() { let definition = TriggerDefinition::new(TriggerMode::Attacks); @@ -193,31 +175,6 @@ mod trigger_occurrence_tests { .0; assert_eq!(instance, TriggerGrantInstanceRef(1)); } - - #[test] - fn abandoning_a_recipient_retires_grants_without_rewinding_the_allocator() { - let producer = TriggerGrantProducerKey::Granted { - origin: static_origin(), - output_index: 0, - }; - let mut state = TriggerOccurrenceState::default(); - let first = state - .reconcile_grant_instances(vec![(producer.clone(), ())]) - .unwrap()[0] - .0; - - state.retire_all_grants(); - assert_eq!(state.active_grants().count(), 0); - - let replacement = state - .reconcile_grant_instances(vec![(producer, ())]) - .unwrap()[0] - .0; - assert!( - replacement.0 > first.0, - "an abandoned recipient must not resurrect a retired grant generation" - ); - } } /// CR 400.1 + CR 608.2c: Which player's zone supplies cards for a direct @@ -22173,13 +22130,6 @@ impl TriggerOccurrenceState { .retain(|active| live_instances.contains(&active.instance)); } - /// Retires every active producer while preserving the monotonic allocator. - /// A player-left-game transition abandons the recipient permanently; a - /// future allocation must never resurrect one of its former grants. - pub fn retire_all_grants(&mut self) { - self.active_grants.clear(); - } - pub fn active_grants( &self, ) -> impl Iterator { diff --git a/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs b/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs index e4399edf4b..c568ce3b7f 100644 --- a/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs +++ b/crates/engine/tests/integration/nadu_lavaspur_boots_max_times.rs @@ -9,7 +9,6 @@ use engine::game::scenario::{GameScenario, P0}; use engine::types::ability::{ContinuousModification, TriggerConstraint}; use engine::types::mana::{ManaType, ManaUnit}; use engine::types::phase::Phase; -use engine::types::zones::Zone; use std::sync::Arc; const NADU_ORACLE: &str = "Flying\nCreatures you control have \"Whenever this creature becomes the target of a spell or ability, reveal the top card of your library. If it's a land card, put it onto the battlefield. Otherwise, put it into your hand. This ability triggers only twice each turn.\""; @@ -85,33 +84,6 @@ fn nadu_granted_trigger_has_independent_max_times_caps_per_target() { "Nadu must grant its targeting trigger with MaxTimesPerTurn=2" ); } - let first_generation = runner.state().objects[&third_target] - .trigger_occurrence_state - .active_grants() - .map(|(_, instance)| instance) - .collect::>(); - engine::game::zones::move_to_zone( - runner.state_mut(), - third_target, - Zone::Exile, - &mut Vec::new(), - ); - engine::game::zones::move_to_zone( - runner.state_mut(), - third_target, - Zone::Battlefield, - &mut Vec::new(), - ); - evaluate_layers(runner.state_mut()); - let second_generation = runner.state().objects[&third_target] - .trigger_occurrence_state - .active_grants() - .map(|(_, instance)| instance) - .collect::>(); - assert_ne!( - first_generation, second_generation, - "a blinked recipient must receive a fresh grant generation" - ); for target in [ first_target, second_target, diff --git a/crates/phase-ai/src/policies/tests/draw_payoff.rs b/crates/phase-ai/src/policies/tests/draw_payoff.rs index cc481f5eee..a9a44f5afb 100644 --- a/crates/phase-ai/src/policies/tests/draw_payoff.rs +++ b/crates/phase-ai/src/policies/tests/draw_payoff.rs @@ -12,7 +12,7 @@ use engine::types::ability::{ AbilityDefinition, AbilityKind, CastVariantPaid, DrawReplacementScope, Effect, ModalChoice, QuantityExpr, QuantityModification, QuantityRef, ReplacementCondition, ReplacementDefinition, ReplacementMode, StaticDefinition, TargetFilter, TriggerCondition, TriggerConstraint, - TriggerDefinition, TriggerFireLedgerKey, + TriggerDefinition, }; use engine::types::actions::GameAction; use engine::types::card_type::CoreType; @@ -578,8 +578,7 @@ fn max_times_per_turn_below_cap_rewards() { let entry = obj.trigger_definitions.iter_unchecked().next().unwrap(); obj.trigger_definition_ref(entry) }; - st.trigger_fire_counts_this_turn - .insert(TriggerFireLedgerKey::Definition(key), 1); // 1 < 2 → can still fire + st.trigger_fire_counts_this_turn.insert(key, 1); // 1 < 2 → can still fire let (oid, cid) = draw_spell(&mut st); let context = context(&config, session(0.9)); @@ -603,8 +602,7 @@ fn max_times_per_turn_at_cap_is_neutral() { let entry = obj.trigger_definitions.iter_unchecked().next().unwrap(); obj.trigger_definition_ref(entry) }; - st.trigger_fire_counts_this_turn - .insert(TriggerFireLedgerKey::Definition(key), 2); // 2 == max → exhausted + st.trigger_fire_counts_this_turn.insert(key, 2); // 2 == max → exhausted let (oid, cid) = draw_spell(&mut st); let context = context(&config, session(0.9));