use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
use crate::{DefinitionId, Value};
pub const SAVE_FORMAT_VERSION: u16 = 1;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct SaveState {
pub version: u16,
pub globals: BTreeMap<String, Value>,
#[serde(default)]
pub global_ids: BTreeMap<String, DefinitionId>,
pub visits: Vec<VisitEntry>,
pub turns: Vec<VisitEntry>,
pub turn_index: u32,
pub rng_seed: i32,
pub previous_random: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suspended: Option<SuspendedFlow>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct VisitEntry {
pub id: DefinitionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub count: u32,
}
pub const SUSPENDED_FLOW_SECTION_VERSION: u16 = 2;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct SuspendedFlow {
pub version: u16,
pub current: DefinitionId,
pub return_stack: Vec<DefinitionId>,
pub frame: Value,
pub wake: WakePolicy,
#[serde(default)]
pub next_block_id: u64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub pending_element: BTreeMap<String, String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct WakePolicy {
pub site: DefinitionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub condition: Option<DefinitionId>,
pub source: WakeSource,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum WakeSource {
Condition,
Host,
}
#[derive(Default, Clone, Debug, PartialEq, Serialize)]
pub struct LoadReport {
pub unknown_globals: Vec<String>,
pub unresolved_renames: Vec<String>,
pub anonymous_states_dropped: u32,
}
impl LoadReport {
#[must_use]
pub fn is_clean(&self) -> bool {
self.unknown_globals.is_empty()
&& self.unresolved_renames.is_empty()
&& self.anonymous_states_dropped == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::id::DefinitionTag;
use crate::value::{MapKey, OrderedMap};
#[test]
fn save_state_round_trips_collection_globals() {
let inventory: OrderedMap = [
(
MapKey::from("weapons"),
Value::array(vec![Value::from("sword"), Value::from("bow")]),
),
(MapKey::from("gold"), Value::Int(42)),
]
.into_iter()
.collect();
let mut globals = BTreeMap::new();
globals.insert(String::from("inventory"), Value::map(inventory));
globals.insert(
String::from("scores"),
Value::array(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
);
let save = SaveState {
version: SAVE_FORMAT_VERSION,
globals,
global_ids: BTreeMap::new(),
visits: Vec::new(),
turns: Vec::new(),
turn_index: 0,
rng_seed: 7,
previous_random: 0,
suspended: None,
};
let json = serde_json::to_string(&save).expect("serialize save");
let back: SaveState = serde_json::from_str(&json).expect("deserialize save");
assert_eq!(back, save);
}
#[test]
fn suspended_absent_by_default_and_omitted_from_wire() {
let save = SaveState {
version: SAVE_FORMAT_VERSION,
globals: BTreeMap::new(),
global_ids: BTreeMap::new(),
visits: Vec::new(),
turns: Vec::new(),
turn_index: 0,
rng_seed: 0,
previous_random: 0,
suspended: None,
};
let json = serde_json::to_string(&save).expect("serialize save");
assert!(
!json.contains("suspended"),
"unsuspended save must omit the key entirely: {json}"
);
let old_json = r#"{"version":1,"globals":{},"global_ids":{},"visits":[],"turns":[],"turn_index":0,"rng_seed":0,"previous_random":0}"#;
let back: SaveState = serde_json::from_str(old_json).expect("deserialize old save");
assert_eq!(back, save);
}
#[test]
fn suspended_flow_round_trips() {
let mut frame = OrderedMap::new();
frame.insert(MapKey::from("hp"), Value::Int(7));
frame.insert(
MapKey::from("party"),
Value::array(vec![Value::from("hero"), Value::from("mage")]),
);
let mut pending_element = BTreeMap::new();
pending_element.insert("speaker".to_string(), "VENDOR".to_string());
let suspended_condition = SuspendedFlow {
version: SUSPENDED_FLOW_SECTION_VERSION,
current: DefinitionId::new(DefinitionTag::Address, 1),
return_stack: vec![
DefinitionId::new(DefinitionTag::Address, 2),
DefinitionId::new(DefinitionTag::Address, 3),
],
frame: Value::map(frame.clone()),
wake: WakePolicy {
site: DefinitionId::new(DefinitionTag::Address, 4),
condition: Some(DefinitionId::new(DefinitionTag::ExternalFn, 5)),
source: WakeSource::Condition,
},
next_block_id: 12,
pending_element: pending_element.clone(),
};
let save_condition = SaveState {
version: SAVE_FORMAT_VERSION,
globals: BTreeMap::new(),
global_ids: BTreeMap::new(),
visits: Vec::new(),
turns: Vec::new(),
turn_index: 3,
rng_seed: 1,
previous_random: 0,
suspended: Some(suspended_condition),
};
let json = serde_json::to_string(&save_condition).expect("serialize save");
assert!(
json.contains("next_block_id") && json.contains("pending_element"),
"a parked flow's block-run state must actually appear on the \
wire, not just round-trip through an in-memory equality check: \
{json}"
);
let back: SaveState = serde_json::from_str(&json).expect("deserialize save");
assert_eq!(back, save_condition);
let suspended_host = SuspendedFlow {
version: SUSPENDED_FLOW_SECTION_VERSION,
current: DefinitionId::new(DefinitionTag::Address, 1),
return_stack: Vec::new(),
frame: Value::map(frame),
wake: WakePolicy {
site: DefinitionId::new(DefinitionTag::Address, 4),
condition: None,
source: WakeSource::Host,
},
next_block_id: 0,
pending_element: BTreeMap::new(),
};
let save_host = SaveState {
suspended: Some(suspended_host),
..save_condition
};
let json = serde_json::to_string(&save_host).expect("serialize save");
let back: SaveState = serde_json::from_str(&json).expect("deserialize save");
assert_eq!(back, save_host);
assert!(
!json.contains("condition"),
"absent condition must be omitted, not null: {json}"
);
assert!(
!json.contains("pending_element"),
"no open attach run at park time must omit the key entirely, \
not serialize an empty map: {json}"
);
}
#[test]
fn suspended_flow_tolerates_a_pre_2108_save() {
let new_shape = SuspendedFlow {
version: 1,
current: DefinitionId::new(DefinitionTag::Address, 1),
return_stack: Vec::new(),
frame: Value::map(OrderedMap::new()),
wake: WakePolicy {
site: DefinitionId::new(DefinitionTag::Address, 2),
condition: None,
source: WakeSource::Host,
},
next_block_id: 99,
pending_element: {
let mut m = BTreeMap::new();
m.insert("speaker".to_string(), "SHOULD_NOT_SURVIVE".to_string());
m
},
};
let json = serde_json::to_string(&new_shape).expect("serialize");
let mut as_map: serde_json::Value = serde_json::from_str(&json).expect("parse to Value");
let obj = as_map
.as_object_mut()
.expect("SuspendedFlow serializes as an object");
obj.remove("next_block_id");
obj.remove("pending_element");
let old_json = serde_json::to_string(&as_map).expect("re-serialize stripped");
let back: SuspendedFlow =
serde_json::from_str(&old_json).expect("deserialize old-shape flow");
assert_eq!(back.next_block_id, 0, "{back:?}");
assert!(back.pending_element.is_empty(), "{back:?}");
}
#[test]
#[expect(clippy::panic, reason = "test assertion on a specific enum variant")]
fn suspended_flow_frame_drift_is_representable() {
let mut old_shape = OrderedMap::new();
old_shape.insert(MapKey::from("hp"), Value::Int(7));
old_shape.insert(MapKey::from("gold"), Value::Int(100));
let old = SuspendedFlow {
version: SUSPENDED_FLOW_SECTION_VERSION,
current: DefinitionId::new(DefinitionTag::Address, 1),
return_stack: Vec::new(),
frame: Value::map(old_shape),
wake: WakePolicy {
site: DefinitionId::new(DefinitionTag::Address, 2),
condition: Some(DefinitionId::new(DefinitionTag::ExternalFn, 3)),
source: WakeSource::Condition,
},
next_block_id: 4,
pending_element: BTreeMap::new(),
};
let json = serde_json::to_string(&old).expect("serialize old-shape frame");
let mut new_shape = OrderedMap::new();
new_shape.insert(MapKey::from("health"), Value::Int(0));
new_shape.insert(MapKey::from("mana"), Value::Int(50));
let new_shape_flow = SuspendedFlow {
frame: Value::map(new_shape),
..old.clone()
};
let new_shape_json =
serde_json::to_string(&new_shape_flow).expect("serialize new-shape frame");
let decoded_old: SuspendedFlow = serde_json::from_str(&json).expect("decode old shape");
let decoded_new: SuspendedFlow =
serde_json::from_str(&new_shape_json).expect("decode new shape");
assert_eq!(decoded_old, old);
assert_eq!(decoded_new, new_shape_flow);
let Value::Map(m) = &decoded_old.frame else {
panic!("expected a map frame");
};
assert_eq!(m.get(&MapKey::from("hp")), Some(&Value::Int(7)));
assert_eq!(m.get(&MapKey::from("gold")), Some(&Value::Int(100)));
}
}