Skip to content
13 changes: 0 additions & 13 deletions crates/engine/src/game/elimination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions crates/engine/src/game/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,3 +514,57 @@ pub fn apply_resolved_ledger_edit(
fn history_len(len: usize) -> Result<u32, ResolvedLedgerEditReplayInvariantError> {
u32::try_from(len).map_err(|_| ResolvedLedgerEditReplayInvariantError::CounterOverflow)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::game::game_object::GameObject;
use crate::types::ability::{TriggerDefinition, TriggerDefinitionOccurrenceRef, TriggerEntry};
use crate::types::identifiers::ObjectId;
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 max_times_replay_resolves_recipient_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 entry = TriggerEntry::new(
TriggerDefinitionOccurrenceRef::Printed {
base_set: object.trigger_base_set_instance,
printed_index: 0,
},
TriggerDefinition::new(TriggerMode::Attacks),
);
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(trigger.clone(), 2);

let command = ResolvedLedgerEditCommand {
edit: ResolvedLedgerEdit::TriggerFired {
trigger: trigger.clone(),
edit: ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old: 2 },
},
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(&trigger).copied(),
Some(3)
);
assert_eq!(state.trigger_fire_counts_this_turn.len(), 1);
}
}
71 changes: 19 additions & 52 deletions crates/engine/src/types/ability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,25 +92,21 @@ 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);
Expand Down Expand Up @@ -179,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
Expand Down Expand Up @@ -21817,7 +21788,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 {
Expand All @@ -21836,7 +21807,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 {
Expand Down Expand Up @@ -21972,7 +21943,10 @@ impl<'de> Deserialize<'de> for TriggerEntry {
TriggerEntryWire::IdentityBearing {
occurrence,
definition,
} => Ok(Self::new(occurrence, definition)),
} => Ok(Self {
occurrence,
definition,
}),
// 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
Expand Down Expand Up @@ -22156,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<Item = (&TriggerGrantProducerKey, TriggerGrantInstanceRef)> {
Expand Down
86 changes: 84 additions & 2 deletions crates/engine/src/types/game_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,33 @@ mod tuple_key_map {
}
}

#[cfg(test)]
mod legacy_trigger_definition_ref_map {
use super::*;

pub fn serialize<S, H>(
map: &HashMap<TriggerDefinitionRef, u32, H>,
serializer: S,
) -> Result<S::Ok, S::Error>
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<HashMap<TriggerDefinitionRef, u32>, D::Error>
where
D: serde::Deserializer<'de>,
{
Vec::<(TriggerDefinitionRef, u32)>::deserialize(deserializer)
.map(|entries| entries.into_iter().collect())
}
}

/// 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.
Expand Down Expand Up @@ -22479,6 +22506,7 @@ mod tests {
use crate::types::ability::{
AbilityDefinition, AbilityKind, Effect, PostReplacementContinuation, QuantityExpr,
ResolvedAbility, TargetFilter, TriggerBaseSetInstanceRef, TriggerDefinitionOccurrenceRef,
TriggerEntry, TriggerGrantInstanceRef,
};
use crate::types::deterministic_serde::test_support::ReverseBuildHasher;
use crate::types::identifiers::{
Expand All @@ -22494,7 +22522,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<TriggerDefinitionRef, u32, ReverseBuildHasher>,
}

Expand Down Expand Up @@ -22604,7 +22632,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]]"#,
),
Expand Down Expand Up @@ -28403,6 +28431,60 @@ mod tests {
));
}

#[test]
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);
let mut object = GameObject::new(
object_id,
CardId(993),
PlayerId(0),
"Legacy granted trigger".to_string(),
Zone::Battlefield,
);
let entry = TriggerEntry::new(
TriggerDefinitionOccurrenceRef::Granted {
grant_instance: TriggerGrantInstanceRef(1),
},
TriggerDefinition::new(crate::types::triggers::TriggerMode::Attacks),
);
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(definition.clone(), 2);

let mut second_object = GameObject::new(
second_object_id,
CardId(994),
PlayerId(0),
"Second legacy granted trigger".to_string(),
Zone::Battlefield,
);
let second_entry = TriggerEntry::new(
TriggerDefinitionOccurrenceRef::Granted {
grant_instance: TriggerGrantInstanceRef(1),
},
TriggerDefinition::new(crate::types::triggers::TriggerMode::Attacks),
);
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(second_definition.clone(), 3);

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([(definition, 2), (second_definition, 3),])
);
}

#[test]
fn game_state_deserialize_rejects_unproven_legacy_trigger_payload() {
let object_id = ObjectId(992);
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/types/resolved_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3252,7 +3252,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 { .. }
Expand Down
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading