feat(simulation): persist template ownership and references in save state
Adds TemplateOwnership to NpcSaveState and TemplateReferenceMap to SaveStateV1 so cross-template links survive save/load and tier eviction (D-025, D-026). Both fields use serde(default) for backward compatibility with existing saves. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ use thiserror::Error;
|
|||||||
|
|
||||||
use crate::bridge::types::SaveLoadResultWire;
|
use crate::bridge::types::SaveLoadResultWire;
|
||||||
use crate::bridge::types::SnapshotBuffer;
|
use crate::bridge::types::SnapshotBuffer;
|
||||||
|
use crate::content::template::TemplateReferenceMap;
|
||||||
use crate::knowledge::graph::KnowledgeGraph;
|
use crate::knowledge::graph::KnowledgeGraph;
|
||||||
use crate::knowledge::registry::EntityRegistry;
|
use crate::knowledge::registry::EntityRegistry;
|
||||||
use crate::npc::Npc;
|
use crate::npc::Npc;
|
||||||
@@ -105,6 +106,13 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
|
|||||||
npc_states.sort_by_key(|s| s.stable_id.0);
|
npc_states.sort_by_key(|s| s.stable_id.0);
|
||||||
|
|
||||||
let npc_count = npc_states.len();
|
let npc_count = npc_states.len();
|
||||||
|
|
||||||
|
// Capture TemplateReferenceMap if present — default to empty if not yet initialised.
|
||||||
|
let template_references = world
|
||||||
|
.get_resource::<TemplateReferenceMap>()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let state = SaveStateV1 {
|
let state = SaveStateV1 {
|
||||||
format_version: SAVE_FORMAT_VERSION,
|
format_version: SAVE_FORMAT_VERSION,
|
||||||
tick,
|
tick,
|
||||||
@@ -113,6 +121,7 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
|
|||||||
player_knowledge,
|
player_knowledge,
|
||||||
relationship_graph,
|
relationship_graph,
|
||||||
npc_states,
|
npc_states,
|
||||||
|
template_references,
|
||||||
};
|
};
|
||||||
|
|
||||||
let bytes = state
|
let bytes = state
|
||||||
@@ -200,6 +209,7 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro
|
|||||||
|
|
||||||
// Restore simulation resources.
|
// Restore simulation resources.
|
||||||
world.insert_resource(state.relationship_graph);
|
world.insert_resource(state.relationship_graph);
|
||||||
|
world.insert_resource(state.template_references);
|
||||||
{
|
{
|
||||||
let mut t = world.resource_mut::<SimulationTime>();
|
let mut t = world.resource_mut::<SimulationTime>();
|
||||||
t.tick = state.tick;
|
t.tick = state.tick;
|
||||||
@@ -487,6 +497,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_from_file_rejects_wrong_format_version() {
|
fn load_from_file_rejects_wrong_format_version() {
|
||||||
|
use crate::content::template::TemplateReferenceMap;
|
||||||
// Craft a save with a wrong format_version
|
// Craft a save with a wrong format_version
|
||||||
let bad_state = SaveStateV1 {
|
let bad_state = SaveStateV1 {
|
||||||
format_version: 0xFF, // deliberately wrong
|
format_version: 0xFF, // deliberately wrong
|
||||||
@@ -496,6 +507,7 @@ mod tests {
|
|||||||
player_knowledge: KnowledgeGraph::new(),
|
player_knowledge: KnowledgeGraph::new(),
|
||||||
relationship_graph: RelationshipGraph::new(),
|
relationship_graph: RelationshipGraph::new(),
|
||||||
npc_states: vec![],
|
npc_states: vec![],
|
||||||
|
template_references: TemplateReferenceMap::default(),
|
||||||
};
|
};
|
||||||
let bytes = bad_state.to_bytes().expect("serialize");
|
let bytes = bad_state.to_bytes().expect("serialize");
|
||||||
let path = temp_path();
|
let path = temp_path();
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ use bevy_ecs::entity::Entity;
|
|||||||
use bevy_ecs::world::World;
|
use bevy_ecs::world::World;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::content::template::{TemplateOwnership, TemplateReferenceMap};
|
||||||
use crate::knowledge::graph::KnowledgeGraph;
|
use crate::knowledge::graph::KnowledgeGraph;
|
||||||
use crate::knowledge::registry::StableEntityId;
|
use crate::knowledge::registry::StableEntityId;
|
||||||
use crate::knowledge::types::StableId;
|
use crate::knowledge::types::StableId;
|
||||||
@@ -83,6 +84,11 @@ pub struct SaveStateV1 {
|
|||||||
/// Per-NPC summary state for each simulated NPC.
|
/// Per-NPC summary state for each simulated NPC.
|
||||||
/// Order is deterministic (sorted by stable_id in ascending order).
|
/// Order is deterministic (sorted by stable_id in ascending order).
|
||||||
pub npc_states: Vec<NpcSaveState>,
|
pub npc_states: Vec<NpcSaveState>,
|
||||||
|
/// Cross-template reference links (#165).
|
||||||
|
/// Preserved across save/load so that tier-evicted templates retain their
|
||||||
|
/// relationship metadata even when their NPCs are not in Active tier.
|
||||||
|
#[serde(default)]
|
||||||
|
pub template_references: TemplateReferenceMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-NPC state snapshot for `SaveStateV1`.
|
/// Per-NPC state snapshot for `SaveStateV1`.
|
||||||
@@ -184,6 +190,12 @@ pub struct NpcSaveState {
|
|||||||
/// Job performance score — drifts over time, persist across tier transitions.
|
/// Job performance score — drifts over time, persist across tier transitions.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub job_performance: Option<JobPerformance>,
|
pub job_performance: Option<JobPerformance>,
|
||||||
|
|
||||||
|
/// Template ownership (#165): which template owns this NPC and which role it fills.
|
||||||
|
/// `None` for NPCs that predate the template system or were hand-authored without
|
||||||
|
/// template assignment. Preserved across tier transitions (D-025 single-ownership).
|
||||||
|
#[serde(default)]
|
||||||
|
pub template_ownership: Option<TemplateOwnership>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SaveStateV1 {
|
impl SaveStateV1 {
|
||||||
@@ -258,6 +270,7 @@ pub fn serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState {
|
|||||||
combat_capability: world.get::<CombatCapability>(entity).cloned(),
|
combat_capability: world.get::<CombatCapability>(entity).cloned(),
|
||||||
mood_state: world.get::<MoodState>(entity).cloned(),
|
mood_state: world.get::<MoodState>(entity).cloned(),
|
||||||
job_performance: world.get::<JobPerformance>(entity).cloned(),
|
job_performance: world.get::<JobPerformance>(entity).cloned(),
|
||||||
|
template_ownership: world.get::<TemplateOwnership>(entity).cloned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +366,10 @@ pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> E
|
|||||||
if let Some(combat) = state.combat_capability.clone() {
|
if let Some(combat) = state.combat_capability.clone() {
|
||||||
em.insert(combat);
|
em.insert(combat);
|
||||||
}
|
}
|
||||||
|
// Restore template ownership if present — never reassigned after initial spawn (D-025).
|
||||||
|
if let Some(ownership) = state.template_ownership.clone() {
|
||||||
|
em.insert(ownership);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entity
|
entity
|
||||||
@@ -381,6 +398,7 @@ mod tests {
|
|||||||
player_knowledge: KnowledgeGraph::new(),
|
player_knowledge: KnowledgeGraph::new(),
|
||||||
relationship_graph: RelationshipGraph::new(),
|
relationship_graph: RelationshipGraph::new(),
|
||||||
npc_states: vec![],
|
npc_states: vec![],
|
||||||
|
template_references: TemplateReferenceMap::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +467,7 @@ mod tests {
|
|||||||
combat_capability: None,
|
combat_capability: None,
|
||||||
mood_state: None,
|
mood_state: None,
|
||||||
job_performance: None,
|
job_performance: None,
|
||||||
|
template_ownership: None,
|
||||||
},
|
},
|
||||||
NpcSaveState {
|
NpcSaveState {
|
||||||
stable_id: StableId(202),
|
stable_id: StableId(202),
|
||||||
@@ -469,6 +488,7 @@ mod tests {
|
|||||||
combat_capability: None,
|
combat_capability: None,
|
||||||
mood_state: None,
|
mood_state: None,
|
||||||
job_performance: None,
|
job_performance: None,
|
||||||
|
template_ownership: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -577,6 +597,7 @@ mod tests {
|
|||||||
combat_capability: None,
|
combat_capability: None,
|
||||||
mood_state: None,
|
mood_state: None,
|
||||||
job_performance: None,
|
job_performance: None,
|
||||||
|
template_ownership: None,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let bytes = state.to_bytes().expect("serialize");
|
let bytes = state.to_bytes().expect("serialize");
|
||||||
@@ -734,6 +755,7 @@ mod tests {
|
|||||||
combat_capability: None,
|
combat_capability: None,
|
||||||
mood_state: None,
|
mood_state: None,
|
||||||
job_performance: None,
|
job_performance: None,
|
||||||
|
template_ownership: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
@@ -771,6 +793,7 @@ mod tests {
|
|||||||
player_knowledge: KnowledgeGraph::new(),
|
player_knowledge: KnowledgeGraph::new(),
|
||||||
relationship_graph: RelationshipGraph::new(),
|
relationship_graph: RelationshipGraph::new(),
|
||||||
npc_states: vec![frozen],
|
npc_states: vec![frozen],
|
||||||
|
template_references: TemplateReferenceMap::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let bytes = save.to_bytes().expect("serialize");
|
let bytes = save.to_bytes().expect("serialize");
|
||||||
|
|||||||
Reference in New Issue
Block a user