diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 3b13bc803d..a139c764c1 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -82,6 +82,38 @@ pub struct BestowFormState; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct MutateFormState; +/// Serde adapter for presence-only unit markers stored in optional fields. +/// +/// The owning fields retain `#[serde(default)]` so an absent field restores as +/// `None`, while a present legacy `null` reaches this adapter and restores the +/// marker. Canonical serialization omits `None` through the fields' +/// `skip_serializing_if` attributes and represents `Some(_)` as `true`. +/// +/// This adapter must not be used for markers that carry payload. Adding payload +/// requires a deliberately versioned wire representation rather than silently +/// discarding it behind this presence bit. +mod unit_marker_option_serde { + use serde::{Deserialize, Deserializer, Serializer}; + + pub(super) fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_bool(value.is_some()) + } + + pub(super) fn deserialize<'de, T, D>(deserializer: D) -> Result, D::Error> + where + T: Default, + D: Deserializer<'de>, + { + match Option::::deserialize(deserializer)? { + Some(true) | None => Ok(Some(T::default())), + Some(false) => Ok(None), + } + } +} + /// CR 712.4c / CR 730.2: Which merge keyword built a merged permanent. /// Disambiguates Meld (cannot transform — CR 712.4c) from Mutate, which /// `merged_components.len()` alone cannot, since a two-creature mutate also @@ -726,7 +758,11 @@ pub struct GameObject { /// CR 702.103b + CR 702.103f: `Some(_)` while this object is in the /// "bestowed Aura" form. Set by `apply_bestow_aura_form`; cleared per /// CR 702.103e–g (illegal target, unattach, zone exit). - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "unit_marker_option_serde", + skip_serializing_if = "Option::is_none" + )] pub bestow_form: Option, /// CR 702.160a: `Some(_)` while this object was cast prototyped. The @@ -739,7 +775,11 @@ pub struct GameObject { /// the stack (cast for its mutate cost). Set by `apply_mutate_form`; cleared /// by `revert_mutate_form` when the target is illegal at resolution /// (CR 702.140b). Does not persist onto the merged permanent. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "unit_marker_option_serde", + skip_serializing_if = "Option::is_none" + )] pub mutate_form: Option, /// CR 730.2 + CR 702.140c: The ordered list of card/token `ObjectId`s that @@ -970,7 +1010,11 @@ pub struct GameObject { /// memory of its previous existence). `Option` over a bool /// per project idiom (no bool flags). Assign when WotC publishes SOS CR /// update. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + with = "unit_marker_option_serde", + skip_serializing_if = "Option::is_none" + )] pub prepared: Option, /// CR 702.171b: Saddled designation. A permanent stays saddled until the end diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 38a7059789..4bcf0f0456 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -824,6 +824,7 @@ mod power_leak_dynamic_prevention; mod power_up_keyword; mod pr7_trigger_ordering; mod precast_copy_shortcut; +mod prepared_state_serde; mod primo_unbounded_fractal_counters; mod printed_ability_order; mod proliferate_zero_counter; diff --git a/crates/engine/tests/integration/prepared_state_serde.rs b/crates/engine/tests/integration/prepared_state_serde.rs new file mode 100644 index 0000000000..e13febf570 --- /dev/null +++ b/crates/engine/tests/integration/prepared_state_serde.rs @@ -0,0 +1,241 @@ +use engine::game::game_object::{ + BestowFormState, GameObject, MutateFormState, PreparedState, SignatureSpellState, +}; +use engine::types::game_state::GameState; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; +use serde_json::{Map, Value}; + +#[derive(Clone, Copy)] +struct MarkerObjectIds { + prepared: ObjectId, + bestow: ObjectId, + mutate: ObjectId, + unmarked: ObjectId, + signature: ObjectId, +} + +fn marker_state_fixture() -> (GameState, MarkerObjectIds) { + let mut state = GameState::new_two_player(42); + let ids = MarkerObjectIds { + prepared: ObjectId(10_001), + bestow: ObjectId(10_002), + mutate: ObjectId(10_003), + unmarked: ObjectId(10_004), + signature: ObjectId(10_005), + }; + + let mut prepared = marker_object(ids.prepared, "Prepared Object"); + prepared.prepared = Some(PreparedState); + state.objects.insert(ids.prepared, prepared); + + let mut bestow = marker_object(ids.bestow, "Bestow Object"); + bestow.bestow_form = Some(BestowFormState); + state.objects.insert(ids.bestow, bestow); + + let mut mutate = marker_object(ids.mutate, "Mutate Object"); + mutate.mutate_form = Some(MutateFormState); + state.objects.insert(ids.mutate, mutate); + + state + .objects + .insert(ids.unmarked, marker_object(ids.unmarked, "Unmarked Object")); + + let mut signature = marker_object(ids.signature, "Signature Object"); + signature.signature_spell = Some(SignatureSpellState {}); + state.objects.insert(ids.signature, signature); + + (state, ids) +} + +fn marker_object(id: ObjectId, name: &str) -> GameObject { + GameObject::new( + id, + CardId(id.0), + PlayerId(0), + name.to_string(), + Zone::Battlefield, + ) +} + +fn object_json(value: &Value, id: ObjectId) -> &Map { + value + .get("objects") + .and_then(Value::as_object) + .and_then(|objects| objects.get(&id.0.to_string())) + .and_then(Value::as_object) + .expect("fixture object is present in serialized GameState") +} + +fn object_json_mut(value: &mut Value, id: ObjectId) -> &mut Map { + value + .get_mut("objects") + .and_then(Value::as_object_mut) + .and_then(|objects| objects.get_mut(&id.0.to_string())) + .and_then(Value::as_object_mut) + .expect("fixture object is present in serialized GameState") +} + +fn assert_fixture_reachable(state: &GameState, ids: MarkerObjectIds) { + for (id, expected_name) in [ + (ids.prepared, "Prepared Object"), + (ids.bestow, "Bestow Object"), + (ids.mutate, "Mutate Object"), + (ids.unmarked, "Unmarked Object"), + (ids.signature, "Signature Object"), + ] { + assert_eq!( + state.objects.get(&id).map(|object| object.name.as_str()), + Some(expected_name), + "fixture object {id:?} must be reachable with the expected identity" + ); + } +} + +fn assert_marker_isolation(state: &GameState, ids: MarkerObjectIds) { + let prepared = state + .objects + .get(&ids.prepared) + .expect("prepared object exists"); + assert!(prepared.prepared.is_some()); + assert!(prepared.bestow_form.is_none()); + assert!(prepared.mutate_form.is_none()); + + let bestow = state + .objects + .get(&ids.bestow) + .expect("bestow object exists"); + assert!(bestow.prepared.is_none()); + assert!(bestow.bestow_form.is_some()); + assert!(bestow.mutate_form.is_none()); + + let mutate = state + .objects + .get(&ids.mutate) + .expect("mutate object exists"); + assert!(mutate.prepared.is_none()); + assert!(mutate.bestow_form.is_none()); + assert!(mutate.mutate_form.is_some()); + + let unmarked = state + .objects + .get(&ids.unmarked) + .expect("unmarked object exists"); + assert!(unmarked.prepared.is_none()); + assert!(unmarked.bestow_form.is_none()); + assert!(unmarked.mutate_form.is_none()); +} + +#[test] +fn game_state_roundtrip_preserves_unit_marker_presence() { + let (state, ids) = marker_state_fixture(); + assert_fixture_reachable(&state, ids); + assert_marker_isolation(&state, ids); + assert!(state + .objects + .get(&ids.signature) + .expect("signature object exists") + .signature_spell + .is_some()); + + let serialized = serde_json::to_value(&state).expect("marker state serializes"); + assert_eq!( + object_json(&serialized, ids.prepared).get("prepared"), + Some(&Value::Bool(true)) + ); + assert_eq!( + object_json(&serialized, ids.bestow).get("bestow_form"), + Some(&Value::Bool(true)) + ); + assert_eq!( + object_json(&serialized, ids.mutate).get("mutate_form"), + Some(&Value::Bool(true)) + ); + + let unmarked_json = object_json(&serialized, ids.unmarked); + assert!(!unmarked_json.contains_key("prepared")); + assert!(!unmarked_json.contains_key("bestow_form")); + assert!(!unmarked_json.contains_key("mutate_form")); + assert_eq!( + object_json(&serialized, ids.signature).get("signature_spell"), + Some(&Value::Object(Map::new())), + "the adjacent braced-empty marker must retain its ordinary object wire shape" + ); + + let restored = + serde_json::from_value::(serialized).expect("marker state deserializes"); + assert_fixture_reachable(&restored, ids); + assert_marker_isolation(&restored, ids); + assert!(restored + .objects + .get(&ids.signature) + .expect("signature object exists after restore") + .signature_spell + .is_some()); +} + +#[test] +fn legacy_null_markers_and_absent_none_fields_deserialize_losslessly() { + let (state, ids) = marker_state_fixture(); + let mut legacy = serde_json::to_value(&state).expect("marker state serializes"); + + object_json_mut(&mut legacy, ids.prepared).insert("prepared".into(), Value::Null); + object_json_mut(&mut legacy, ids.bestow).insert("bestow_form".into(), Value::Null); + object_json_mut(&mut legacy, ids.mutate).insert("mutate_form".into(), Value::Null); + let unmarked = object_json_mut(&mut legacy, ids.unmarked); + unmarked.remove("prepared"); + unmarked.remove("bestow_form"); + unmarked.remove("mutate_form"); + + let restored = + serde_json::from_value::(legacy).expect("legacy marker state deserializes"); + assert_fixture_reachable(&restored, ids); + assert_marker_isolation(&restored, ids); +} + +#[test] +fn explicit_false_deserializes_as_absence_with_true_sibling() { + let (state, ids) = marker_state_fixture(); + let mut serialized = serde_json::to_value(&state).expect("marker state serializes"); + + object_json_mut(&mut serialized, ids.prepared).insert("prepared".into(), Value::Bool(false)); + object_json_mut(&mut serialized, ids.unmarked).insert("prepared".into(), Value::Bool(true)); + + let restored = + serde_json::from_value::(serialized).expect("boolean marker state deserializes"); + assert_fixture_reachable(&restored, ids); + assert!(restored + .objects + .get(&ids.prepared) + .expect("false marker object exists") + .prepared + .is_none()); + assert!(restored + .objects + .get(&ids.unmarked) + .expect("true sibling object exists") + .prepared + .is_some()); +} + +#[test] +fn invalid_unit_marker_wire_value_fails_closed() { + let (state, ids) = marker_state_fixture(); + let mut serialized = serde_json::to_value(&state).expect("marker state serializes"); + + let valid = serde_json::from_value::(serialized.clone()) + .expect("the unmodified marker state must reach and pass GameState deserialization"); + assert_fixture_reachable(&valid, ids); + assert_marker_isolation(&valid, ids); + + object_json_mut(&mut serialized, ids.prepared).insert( + "prepared".into(), + Value::String("not-a-presence-bit".into()), + ); + + assert!( + serde_json::from_value::(serialized).is_err(), + "a present non-boolean, non-null marker value must be rejected" + ); +}