Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4a86a64
fix(engine): qualify zone-change occurrences by turn
matthewevans Aug 7, 2026
1bcaf6a
test(engine): cover cross-turn zone occurrences
matthewevans Aug 7, 2026
df7be9a
fix(engine): migrate legacy batched trigger keys
matthewevans Aug 7, 2026
5018bae
test(engine): cover cross-turn batched replay keys
matthewevans Aug 7, 2026
eca6dc2
fix(tests): update batched trigger entry access
matthewevans Aug 7, 2026
00c2e26
fix(tests): refresh zone-change provenance fixtures
matthewevans Aug 7, 2026
a5053db
fix(engine): reconcile serialized zone change occurrences (#7077)
matthewevans Aug 7, 2026
a6490aa
fix(engine): scope persisted zone event reconciliation
matthewevans Aug 7, 2026
932fd59
fix(engine): reconcile paused sacrifice events
matthewevans Aug 7, 2026
8813047
fix(engine): respect resolution frame boundary
matthewevans Aug 7, 2026
7fd5e1f
fix(engine): reconcile canonical frame event carriers
matthewevans Aug 7, 2026
af8a9e6
fix(engine): prune stale zone-change ledger rows
matthewevans Aug 7, 2026
228cbd6
fix(engine): reindex batched zone-change replay keys
matthewevans Aug 7, 2026
33b1117
fix(engine): reject stale zone-change replay aliases
matthewevans Aug 7, 2026
bc67509
fix(engine): reconcile serialized resolution frame events
matthewevans Aug 7, 2026
8bd5db6
fix(engine): cover v1 resolution event carriers
matthewevans Aug 7, 2026
9be17e3
test(engine): pass resolution fixture as JSON value
matthewevans Aug 7, 2026
cc3472d
fix(engine): retain unkeyed legacy resolution events
matthewevans Aug 7, 2026
d8b7020
test(engine): assert persisted provenance canonicalization
matthewevans Aug 7, 2026
0693fe9
test(engine): initialize boxed resolution stacks
matthewevans Aug 7, 2026
860b58b
test(engine): box serialized frame fixture stack
matthewevans Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/engine/src/game/derived_views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3408,6 +3408,7 @@ mod tests {
attached_to: None,
entered_incarnation: None,
turn_zone_change_index: 0,
recorded_turn_number: 0,
is_suspected: false,
}),
};
Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/game/effects/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1162,12 +1162,12 @@ pub fn apply_resolved_token_creation(
// directly above, which runs before this snapshot exactly as the live paths
// do. Storing the live record on the command would not close (i) or (ii)
// either, for the same ordering reason, so it was not done.
let entry_record = state
let mut entry_record = state
.objects
.get(&object_id)
.expect("the token was materialized above")
.snapshot_for_zone_change(object_id, None, Zone::Battlefield);
crate::game::restrictions::record_zone_change(state, entry_record);
crate::game::restrictions::record_zone_change(state, &mut entry_record);
// CR 111.1: replay must not hand the same id out again to a later allocation.
state.next_object_id = state.next_object_id.max(command.resulting_next_object_id);
// CR 613.7d: the birth drew an entry timestamp alongside the object id, and
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2420,6 +2420,7 @@ pub fn matches_target_filter_on_lki_snapshot(
attached_to: None,
entered_incarnation: None,
turn_zone_change_index: 0,
recorded_turn_number: 0,
// CR 701.60b: Carry suspected status from the LKI snapshot so
// `FilterProp::Suspected` reads the cost-paid look-back value.
is_suspected: lki.is_suspected,
Expand Down Expand Up @@ -12824,6 +12825,7 @@ mod tests {
attached_to: None,
entered_incarnation: None,
turn_zone_change_index: 0,
recorded_turn_number: 0,
is_suspected: false,
};
let goblin_filter = make_subtype_filter("Goblin");
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/game_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1981,6 +1981,7 @@ impl GameObject {
// battlefield-entry incarnation bump; `None` here (pre-entry snapshot).
entered_incarnation: None,
turn_zone_change_index: 0,
recorded_turn_number: 0,
// CR 701.60b: Snapshot suspected status at the moment of the move,
// before `move_to_zone` resets the live flag — so an LTB / cost-paid
// look-back ("the sacrificed creature was suspected") reads it.
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/meld.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ fn refresh_meld_entry_records(
realized.entered_incarnation = old.entered_incarnation;
realized.attached_to = old.attached_to;
realized.turn_zone_change_index = old.turn_zone_change_index;
realized.recorded_turn_number = old.recorded_turn_number;
realized.combat_status = combat_status;
realized.sync_trigger_source_context();
*record = realized;
Expand Down
4 changes: 1 addition & 3 deletions crates/engine/src/game/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,9 +683,7 @@ pub(crate) fn put_component_into_zone(
}
}

let turn_zone_change_index =
crate::game::restrictions::record_zone_change(state, record.clone());
record.turn_zone_change_index = turn_zone_change_index;
crate::game::restrictions::record_zone_change(state, &mut record);
events.push(GameEvent::ZoneChanged {
object_id: component_id,
from: None,
Expand Down
5 changes: 3 additions & 2 deletions crates/engine/src/game/restrictions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -605,13 +605,14 @@ pub(crate) fn ledger_filter_is_evaluable(filter: &TargetFilter) -> bool {
/// Returns the per-turn zone-change index assigned to this record.
pub fn record_zone_change(
state: &mut crate::types::game_state::GameState,
mut record: crate::types::game_state::ZoneChangeRecord,
record: &mut crate::types::game_state::ZoneChangeRecord,
) -> usize {
let object_id = record.object_id;
let to_zone = record.to_zone;
let turn_zone_change_index = state.zone_changes_this_turn.len();
record.recorded_turn_number = state.turn_number;
record.turn_zone_change_index = turn_zone_change_index;
state.zone_changes_this_turn.push_back(record);
state.zone_changes_this_turn.push_back(record.clone());

if to_zone == Zone::Battlefield {
record_battlefield_entry(state, object_id);
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3919,6 +3919,7 @@ fn zone_change_record_from_spec(
attached_to: None,
entered_incarnation: None,
turn_zone_change_index: 0,
recorded_turn_number: 0,
// A freshly created token is never suspected (CR 701.60b).
is_suspected: false,
}
Expand Down
42 changes: 25 additions & 17 deletions crates/engine/src/game/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ enum TriggerCollectionOperation {
},
RecordBatchedZoneChanges {
definition_ref: TriggerDefinitionRef,
turn_zone_change_indices: Vec<usize>,
turn_zone_change_keys: Vec<(u32, usize)>,
},
AllocateTimestamp,
RecordCombatDamageCastingPermission {
Expand Down Expand Up @@ -562,19 +562,21 @@ impl TriggerCollectionSession {
let Some(definition_ref) = matched.definition_ref.clone() else {
return;
};
let turn_zone_change_indices = matched
let turn_zone_change_keys = matched
.trigger_events
.iter()
.filter_map(|event| match event {
GameEvent::ZoneChanged { record, .. } => Some(record.turn_zone_change_index),
GameEvent::ZoneChanged { record, .. } => {
Some((record.recorded_turn_number, record.turn_zone_change_index))
}
_ => None,
})
.collect();
let _ = self.apply(
state,
TriggerCollectionOperation::RecordBatchedZoneChanges {
definition_ref,
turn_zone_change_indices,
turn_zone_change_keys,
},
);
}
Expand Down Expand Up @@ -674,12 +676,14 @@ impl TriggerCollectionSession {
}
TriggerCollectionOperation::RecordBatchedZoneChanges {
definition_ref,
turn_zone_change_indices,
turn_zone_change_keys,
} => {
for turn_zone_change_index in turn_zone_change_indices {
state
.batched_zone_change_trigger_fired
.insert((definition_ref.clone(), turn_zone_change_index));
for (recorded_turn_number, turn_zone_change_index) in turn_zone_change_keys {
state.batched_zone_change_trigger_fired.insert((
definition_ref.clone(),
recorded_turn_number,
turn_zone_change_index,
));
}
None
}
Expand Down Expand Up @@ -1691,17 +1695,19 @@ fn batched_zone_change_already_collected(
.iter()
.filter_map(|event| {
if let GameEvent::ZoneChanged { record, .. } = event {
Some(record.turn_zone_change_index)
Some((record.recorded_turn_number, record.turn_zone_change_index))
} else {
None
}
})
.peekable();
zone_changes.peek().is_some()
&& zone_changes.all(|turn_zone_change_index| {
state
.batched_zone_change_trigger_fired
.contains(&(definition_ref.clone(), turn_zone_change_index))
&& zone_changes.all(|(recorded_turn_number, turn_zone_change_index)| {
state.batched_zone_change_trigger_fired.contains(&(
definition_ref.clone(),
recorded_turn_number,
turn_zone_change_index,
))
})
}

Expand All @@ -1715,9 +1721,11 @@ fn record_batched_zone_change_collected(
};
for event in trigger_events {
if let GameEvent::ZoneChanged { record, .. } = event {
state
.batched_zone_change_trigger_fired
.insert((definition_ref.clone(), record.turn_zone_change_index));
state.batched_zone_change_trigger_fired.insert((
definition_ref.clone(),
record.recorded_turn_number,
record.turn_zone_change_index,
));
}
}
}
Expand Down
112 changes: 112 additions & 0 deletions crates/engine/src/game/triggers_dedup_regression_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3713,6 +3713,118 @@ fn zone_change_count(events: &[GameEvent]) -> usize {
.count()
}

fn recorded_zone_change_event(state: &mut GameState, object_id: ObjectId) -> GameEvent {
let mut event = zone_change_event(object_id);
let GameEvent::ZoneChanged { record, .. } = &mut event else {
unreachable!("zone_change_event always returns ZoneChanged");
};
crate::game::restrictions::record_zone_change(state, record);
event
}

#[test]
fn deferred_zone_change_witness_does_not_alias_the_next_turns_ledger_index() {
let mut state = setup();
let old_turn = state.turn_number;
let old_event = recorded_zone_change_event(&mut state, ObjectId(7));
state
.deferred_triggers
.push(queued_context_for(old_event.clone()));

assert!(
filter_already_collected_trigger_events_from(
&state,
std::slice::from_ref(&old_event),
0,
&[]
)
.is_empty(),
"the same-turn queued witness must suppress its own occurrence"
);

crate::game::turns::start_next_turn(&mut state, &mut Vec::new());
let new_event = recorded_zone_change_event(&mut state, ObjectId(7));
let GameEvent::ZoneChanged { record, .. } = &new_event else {
unreachable!("recorded helper always returns ZoneChanged");
};
assert_eq!(record.turn_zone_change_index, 0);
assert_eq!(
filter_already_collected_trigger_events_from(
&state,
std::slice::from_ref(&new_event),
0,
&[],
),
vec![new_event],
"a deferred witness from turn {old_turn} must not consume index 0 from turn {}",
state.turn_number
);
}

#[test]
fn batched_zone_change_replay_guard_keeps_old_turn_markers_distinct_from_new_index_zero() {
let (mut state, observer) = setup_with_observer(TriggerMode::ChangesZone);
let (definition, definition_ref) = {
let object = state.objects.get_mut(&observer).unwrap();
object.trigger_definitions[0].definition.batched = true;
let definition = object.trigger_definitions[0].definition.clone();
let definition_ref = object.trigger_definition_ref(&object.trigger_definitions[0]);
(definition, definition_ref)
};
let old_event = recorded_zone_change_event(&mut state, ObjectId(7));

assert!(batched_zone_change_replay_guard_applies(
&definition,
std::slice::from_ref(&old_event)
));
record_batched_zone_change_collected(
&mut state,
Some(&definition_ref),
std::slice::from_ref(&old_event),
);
assert!(
batched_zone_change_already_collected(
&state,
Some(&definition_ref),
std::slice::from_ref(&old_event),
),
"the same-turn marker must suppress the event it recorded"
);

let GameEvent::ZoneChanged { record, .. } = &old_event else {
unreachable!("recorded helper always returns ZoneChanged");
};
let old_key = (
definition_ref.clone(),
record.recorded_turn_number,
record.turn_zone_change_index,
);
crate::game::turns::start_next_turn(&mut state, &mut Vec::new());
state.batched_zone_change_trigger_fired.insert(old_key);
assert!(
batched_zone_change_already_collected(
&state,
Some(&definition_ref),
std::slice::from_ref(&old_event),
),
"a retained marker must still suppress its old-turn event after the boundary"
);

let new_event = recorded_zone_change_event(&mut state, ObjectId(7));
let GameEvent::ZoneChanged { record, .. } = &new_event else {
unreachable!("recorded helper always returns ZoneChanged");
};
assert_eq!(record.turn_zone_change_index, 0);
assert!(
!batched_zone_change_already_collected(
&state,
Some(&definition_ref),
std::slice::from_ref(&new_event),
),
"a new-turn index-0 event must not alias the retained old-turn marker"
);
}

/// U1 — the queued witness is COUNT-LIMITED, not set membership.
///
/// Two byte-identical `ZoneChanged` in the slice against ONE queued context
Expand Down
22 changes: 14 additions & 8 deletions crates/engine/src/game/zones.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,7 @@ pub fn resolve_and_apply_zone_change(
zone_change_record.entered_incarnation =
(to == Zone::Battlefield).then_some(resulting_incarnation);
zone_change_record.turn_zone_change_index = turn_zone_change_index;
zone_change_record.recorded_turn_number = state.turn_number;

let command = ResolvedZoneChangeCommand {
object: occurrence,
Expand Down Expand Up @@ -888,6 +889,14 @@ pub fn apply_resolved_zone_change(
},
);
}
if command.zone_change_record.recorded_turn_number != state.turn_number {
return Err(
ResolvedZoneChangeReplayInvariantError::RecordedTurnMismatch {
expected: command.zone_change_record.recorded_turn_number,
found: state.turn_number,
},
);
}

let destination_position = destination_position_after_removal(
state,
Expand Down Expand Up @@ -941,8 +950,9 @@ pub fn apply_resolved_zone_change(
state.adopt_replayed_timestamp(entry_timestamp);
}

let mut zone_change_record = command.zone_change_record.clone();
let turn_zone_change_index =
super::restrictions::record_zone_change(state, command.zone_change_record.clone());
super::restrictions::record_zone_change(state, &mut zone_change_record);
if turn_zone_change_index != command.turn_zone_change_index {
return Err(
ResolvedZoneChangeReplayInvariantError::TurnRecordIndexMismatch {
Expand Down Expand Up @@ -1347,9 +1357,7 @@ pub fn move_to_zone(
}

if !transition_recorded {
let turn_zone_change_index =
super::restrictions::record_zone_change(state, zone_change_record.clone());
zone_change_record.turn_zone_change_index = turn_zone_change_index;
super::restrictions::record_zone_change(state, &mut zone_change_record);
}

if let Some(old_target) = unattached_from {
Expand Down Expand Up @@ -1426,7 +1434,7 @@ pub(crate) fn record_and_emit_entry_from_no_zone(
.objects
.get(&object_id)
.map(|obj| obj.snapshot_for_zone_change(object_id, None, Zone::Battlefield))?;
record.turn_zone_change_index = super::restrictions::record_zone_change(state, record.clone());
super::restrictions::record_zone_change(state, &mut record);
events.push(GameEvent::ZoneChanged {
object_id,
from: None,
Expand Down Expand Up @@ -1825,9 +1833,7 @@ pub fn move_to_library_at_index(
record_resolution_source_relatch(state, object_id, pre, new);
}

let turn_zone_change_index =
super::restrictions::record_zone_change(state, zone_change_record.clone());
zone_change_record.turn_zone_change_index = turn_zone_change_index;
super::restrictions::record_zone_change(state, &mut zone_change_record);

if let Some(old_target) = unattached_from {
events.push(GameEvent::Unattached {
Expand Down
Loading
Loading