Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
33 changes: 9 additions & 24 deletions crates/engine/src/game/game_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
use std::sync::Arc;

use serde::{Deserialize, Serialize};

Check warning on line 5 in crates/engine/src/game/game_object.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

Diff in /home/runner/work/phase/phase/crates/engine/src/game/game_object.rs
use crate::types::ability::{
additional_cost_instance_payment_count, additional_cost_instance_payment_count_for_ordinal,
AbilityBlockEntry, AbilityDefinition, AdditionalCost, AdditionalCostInstancePayment,
Expand All @@ -11,6 +11,7 @@
ExiledSpellRider, ModalChoice, ReplacementDefinition, SeatDirection, SolveCondition,
SpellCastingOption, StaticDefinition, TriggerBaseSetInstanceRef, TriggerDefinition,
TriggerDefinitionOccurrenceRef, TriggerEntry, TriggerOccurrenceState,
materialize_legacy_printed_trigger_entries,
};
use crate::types::card::{LayoutKind, PrintedCardRef, PrintedLoyalty, TokenImageRef};
use crate::types::card_type::{CardType, CoreType};
Expand Down Expand Up @@ -1631,32 +1632,16 @@
/// runtime payloads are rejected rather than guessed from equal definition
/// bytes.
pub fn migrate_legacy_trigger_definitions(&mut self) -> Result<(), &'static str> {
let has_legacy_entries = self.trigger_definitions.iter_all().any(|entry| {
matches!(
entry.occurrence,
TriggerDefinitionOccurrenceRef::Unmaterialized
)
});
if !has_legacy_entries {
let mut entries = self.trigger_definitions.iter_all().cloned().collect();
materialize_legacy_printed_trigger_entries(
&mut entries,
self.base_trigger_definitions.as_slice(),

Check warning on line 1638 in crates/engine/src/game/game_object.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

Diff in /home/runner/work/phase/phase/crates/engine/src/game/game_object.rs
self.trigger_base_set_instance,
)?;
if entries == self.trigger_definitions.iter_all().cloned().collect::<Vec<_>>() {
return self.validate_trigger_definitions();
}
if self.base_trigger_definitions.is_empty()
|| self.trigger_definitions.len() != self.base_trigger_definitions.len()
|| !self.trigger_definitions.iter_all().all(|entry| {
matches!(
entry.occurrence,
TriggerDefinitionOccurrenceRef::Unmaterialized
)
})
|| !self
.trigger_definitions
.iter_all()
.zip(self.base_trigger_definitions.iter())
.all(|(entry, base)| entry.definition == *base)
{
return Err("legacy runtime trigger payload has no provable producer or base slot");
}
self.materialize_base_trigger_definitions();
self.trigger_definitions = entries.into();
self.validate_trigger_definitions()
}

Expand Down
61 changes: 61 additions & 0 deletions crates/engine/src/types/ability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22152,6 +22152,67 @@ impl TriggerEntry {
}
}

/// Classifies a persisted trigger list without inferring runtime provenance.

Check warning on line 22155 in crates/engine/src/types/ability.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

Diff in /home/runner/work/phase/phase/crates/engine/src/types/ability.rs
/// A list is either wholly legacy payloads or wholly identity-bearing entries;
/// a mixture cannot establish an exact occurrence mapping.
pub(crate) fn legacy_trigger_entry_list(
entries: &[TriggerEntry],
) -> Result<bool, &'static str> {
let has_legacy = entries.iter().any(|entry| {
matches!(
entry.occurrence,
TriggerDefinitionOccurrenceRef::Unmaterialized
)
});
if has_legacy
&& !entries.iter().all(|entry| {
matches!(
entry.occurrence,
TriggerDefinitionOccurrenceRef::Unmaterialized
)
})
{
return Err("legacy trigger list mixes payload-only and identity-bearing entries");
}
Ok(has_legacy)
}

/// Materializes a payload-only list only when an ordered printed base set proves
/// every slot. Runtime copied and granted triggers have no equivalent proof.
pub(crate) fn materialize_legacy_printed_trigger_entries(
entries: &mut Vec<TriggerEntry>,
base_definitions: &[TriggerDefinition],
base_set: TriggerBaseSetInstanceRef,
) -> Result<(), &'static str> {
if !legacy_trigger_entry_list(entries)? {
return Ok(());
}
if base_definitions.is_empty()
|| entries.len() != base_definitions.len()
|| !entries
.iter()
.zip(base_definitions)
.all(|(entry, base)| entry.definition == *base)
{
return Err("legacy runtime trigger payload has no provable producer or base slot");
}
*entries = base_definitions
.iter()
.cloned()
.enumerate()
.map(|(printed_index, definition)| {
TriggerEntry::new(
TriggerDefinitionOccurrenceRef::Printed {
base_set,
printed_index,
},
definition,
)
})
.collect();
Ok(())
}

#[derive(Deserialize)]
#[serde(untagged)]
enum TriggerEntryWire {
Expand Down
189 changes: 188 additions & 1 deletion crates/engine/src/types/game_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use serde::{Deserialize, Serialize};

Check warning on line 6 in crates/engine/src/types/game_state.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

Diff in /home/runner/work/phase/phase/crates/engine/src/types/game_state.rs

use super::ability::{
default_target_filter_permanent, AbilityCost, AbilityDefinition, AdditionalCost,
Expand All @@ -12,11 +12,12 @@
ChooseFromZoneConstraint, ChosenAttribute, CoinFlipResult, Comparator, ContinuousModification,
ControlWindow, CopiableValues, CopyChooseScope, CopyScale, CopyTargetPurpose,
CostPaidObjectSnapshot, CounterCostSelection, DelayedTriggerCondition, DigRestOrder, Duration,
EffectKind, FaceDownProfile, GameRestriction, KeywordAction, KickerVariant, LibraryPosition,

Check warning on line 15 in crates/engine/src/types/game_state.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

Diff in /home/runner/work/phase/phase/crates/engine/src/types/game_state.rs
ModalChoice, PermanentEntryMode, PileSource, QuantityExpr, ResolvedAbility,
SearchDestinationSplit, SearchSelectionConstraint, StaticCondition, TapCreaturesAggregate,
TargetFilter, TargetRef, ThisWayCause, TriggerCondition, TriggerDefinition,
TriggerDefinitionRef, TriggerEntry,
TriggerBaseSetInstanceRef, TriggerDefinitionRef, TriggerEntry,
legacy_trigger_entry_list, materialize_legacy_printed_trigger_entries,
};
use super::attribution::ObjectAttribution;
use super::card::{CardFace, PrintedCardRef, TokenImageRef};
Expand Down Expand Up @@ -7968,6 +7969,192 @@
Ok(())
}

/// Promotes legacy payload-only trigger lists carried by persisted zone-change
/// snapshots before the typed state can expose them to a serializer. A record's
/// own source context is authoritative; an absent live-record context can use
/// only the exact initial printed base set of the persisted object with the
/// same map key and object id. Journal snapshots never receive that fallback.
pub(crate) fn migrate_legacy_zone_change_trigger_provenance(
value: &mut serde_json::Value,
additional_live_event_roots: &[&str],
) -> Result<(), String> {
let state = value
.as_object_mut()
.ok_or_else(|| "persisted game state must be a JSON object".to_string())?;
let objects = state
.get("objects")
.and_then(serde_json::Value::as_object)
.cloned()
.ok_or_else(|| "persisted game state objects must be an object".to_string())?;

for field in [
"created_tokens_this_turn",
"sacrificed_permanents_this_turn",
"zone_changes_this_turn",
] {
let Some(records) = state.get_mut(field) else {
continue;
};
let records = records
.as_array_mut()
.ok_or_else(|| format!("{field} must be an array"))?;
for record in records {
migrate_persisted_zone_change_trigger_record(record, &objects, true)?;
}
}

visit_persisted_live_zone_changed_records(state, additional_live_event_roots, &mut |record| {
migrate_persisted_zone_change_trigger_record(record, &objects, true)
})?;

if let Some(journal) = state.get_mut("resolved_rules_journal") {
visit_persisted_journal_zone_change_trigger_records(journal, &objects)?;
}
Ok(())
}

fn migrate_persisted_zone_change_trigger_record(
record: &mut serde_json::Value,
objects: &serde_json::Map<String, serde_json::Value>,
allow_object_fallback: bool,
) -> Result<(), String> {
let record = record
.as_object_mut()
.ok_or_else(|| "persisted zone-change record must be an object".to_string())?;
let object_id: ObjectId = record
.get("object_id")
.cloned()
.ok_or_else(|| "persisted zone-change record has no object id".to_string())
.and_then(|value| serde_json::from_value(value).map_err(|error| error.to_string()))?;
let mut entries = record
.get("trigger_definitions")
.cloned()
.map(serde_json::from_value::<Vec<TriggerEntry>>)
.transpose()
.map_err(|error| error.to_string())?
.unwrap_or_default();
let has_legacy_entries = legacy_trigger_entry_list(&entries).map_err(str::to_string)?;

let migrated = match record.get("trigger_source_context") {
Some(serde_json::Value::Null) | None => {
if !has_legacy_entries {
return Ok(());
}
if !allow_object_fallback {
return Err(
"legacy journal zone-change record has no record-owned trigger source context"
.to_string(),
);
}
let object_key = object_id.0.to_string();
let object_value = objects.get(&object_key).ok_or_else(|| {
"legacy zone-change record has no same-id persisted object base set".to_string()
})?;
let object: GameObject =
serde_json::from_value(object_value.clone()).map_err(|error| error.to_string())?;
if object.id != object_id {
return Err(
"legacy zone-change record object map key does not match serialized object id"
.to_string(),
);
}
if object.trigger_base_set_instance != TriggerBaseSetInstanceRef::INITIAL {
return Err(
"legacy zone-change record requires the initial printed trigger base set"
.to_string(),
);
}
materialize_legacy_printed_trigger_entries(
&mut entries,
object.base_trigger_definitions.as_slice(),
TriggerBaseSetInstanceRef::INITIAL,
)
.map_err(str::to_string)?;
entries

Check warning on line 8073 in crates/engine/src/types/game_state.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

Diff in /home/runner/work/phase/phase/crates/engine/src/types/game_state.rs
}
Some(context_value) => {
let context: TriggerSourceContext = serde_json::from_value(context_value.clone())
.map_err(|error| error.to_string())?;
if context.identity.reference.object_id != object_id {
return Err(
"zone-change trigger source context object id does not match its record"
.to_string(),
);
}
if legacy_trigger_entry_list(&context.trigger_entries).map_err(str::to_string)? {
return Err(
"zone-change trigger source context has unmaterialized trigger entries"
.to_string(),
);
}

Check warning on line 8089 in crates/engine/src/types/game_state.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

Diff in /home/runner/work/phase/phase/crates/engine/src/types/game_state.rs
if entries.len() != context.trigger_entries.len() {
return Err(
"zone-change record trigger list disagrees with its source context"
.to_string(),
);
}
if has_legacy_entries {
if !entries

Check warning on line 8097 in crates/engine/src/types/game_state.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

Diff in /home/runner/work/phase/phase/crates/engine/src/types/game_state.rs
.iter()
.zip(&context.trigger_entries)
.all(|(record_entry, context_entry)| {
record_entry.definition == context_entry.definition
})
{
return Err(
"zone-change record trigger list disagrees with its source context"
.to_string(),
);
}
context.trigger_entries
} else {
if entries != context.trigger_entries {
return Err(
"zone-change record trigger occurrence does not match its source context"
.to_string(),
);
}
return Ok(());
}
}
};

record.insert(
"trigger_definitions".to_string(),
serde_json::to_value(migrated).map_err(|error| error.to_string())?,
);
Ok(())
}

/// Journal snapshots are immutable historical authority: they may use only an
/// exact record-owned context, never a current object's printed base set.
fn visit_persisted_journal_zone_change_trigger_records(
value: &mut serde_json::Value,
objects: &serde_json::Map<String, serde_json::Value>,
) -> Result<(), String> {
match value {
serde_json::Value::Array(values) => {
for value in values {
visit_persisted_journal_zone_change_trigger_records(value, objects)?;
}
}
serde_json::Value::Object(object) => {
if let Some(record) = serialized_zone_changed_record_mut(object) {
migrate_persisted_zone_change_trigger_record(record, objects, false)?;
return Ok(());
}
if let Some(record) = object.get_mut("zone_change_record") {
migrate_persisted_zone_change_trigger_record(record, objects, false)?;
}
for value in object.values_mut() {
visit_persisted_journal_zone_change_trigger_records(value, objects)?;
}
}
_ => {}
}
Ok(())
}

/// CR 400.7 + CR 603.2c: a persisted `ZoneChanged` must retain the identity of
/// the ledger occurrence that produced it. The live allocator writes the
/// current turn and the ledger position together, but historical payloads may
Expand Down
17 changes: 17 additions & 0 deletions crates/engine/src/types/resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2826,6 +2826,23 @@ impl ResolutionStateWire {
// both belong to `GameStateDecode`; no wire branch gets a private
// `GameState` serde shortcut.
GameStateDecode::prepare_resolution_wire(&mut value, decode_mode)?;
let additional_live_event_roots: &[&str] = match version {
LEGACY_RESOLUTION_STATE_WIRE_VERSION => &[
"pending_continuation",
"pending_choose_zone_trigger_context",
"pending_optional_trigger_event",
"pending_change_zone_iteration",
"pending_batch_deliveries",
"pending_mill_deliveries",
"pending_each_player_copy_chosen",
][..],
RESOLUTION_STATE_WIRE_VERSION => &[],
_ => unreachable!("version was validated before migration"),
};
crate::types::game_state::migrate_legacy_zone_change_trigger_provenance(
&mut value,
additional_live_event_roots,
)?;

match version {
// V1 reader compatibility path: historical keys are consumed here
Expand Down
Loading
Loading