fix(simulation): address PR #81 review — critical and high-priority issues
Critical fixes: - storyteller: replace .expect() with guard + log in activation_pass (Hoshe #1) - content/loader: validate inverted tile_bounds before iteration (Hoshe #C) - save_io: persist ActivationState on save/load (Tyre #7) High-priority fixes: - storyteller: f64 intermediate for observation_time_ticks scoring (Hoshe #A) - storyteller: deduplicate copresent entities before scoring (Hoshe #B) - storyteller: skip activation_pass at tick 0 (Hoshe #G) - storyteller: explicit .before(advance_tick) ordering (Tyre #9) - save_state: insert EngagementRecord on NPC deserialize (Tyre #10) - save_io: reset TriangleActivatedQueue + MovementHistoryBuffer on load (Tyre #8) - debug: validate teleport target walkability (Hoshe #E) - debug: reject SkipToContamination when tick past delay (Hoshe #F) - debug: DebugEnabled defaults to cfg!(debug_assertions) (Hoshe #3) - debug: log when response overwritten (Hoshe #2) - content/loader: error on tile dimension mismatch (Hoshe #5) - content/types: DistrictMeta.description optional (Hoshe #D) - types: version docs updated to v18 (Tyre #1, #2, #3) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,10 @@ use crate::simulation::interaction::DoorState;
|
||||
use crate::simulation::tier::{ActiveSim, BackgroundSim};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::content::template::TriangleCrisisEventQueue;
|
||||
use crate::storyteller::{ContaminationActive, ContaminationEventQueue};
|
||||
use crate::storyteller::{
|
||||
ActivationState, ContaminationActive, ContaminationEventQueue, MovementHistoryBuffer,
|
||||
TriangleActivatedQueue,
|
||||
};
|
||||
|
||||
/// Errors from save/load operations (#553).
|
||||
#[derive(Debug, Error)]
|
||||
@@ -149,6 +152,12 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
|
||||
contamination_active: world
|
||||
.get_resource::<ContaminationActive>()
|
||||
.map_or(false, |c| c.0),
|
||||
activated_count: world
|
||||
.get_resource::<ActivationState>()
|
||||
.map_or(0, |a| a.activated_count),
|
||||
last_activation_tick: world
|
||||
.get_resource::<ActivationState>()
|
||||
.and_then(|a| a.last_activation_tick),
|
||||
};
|
||||
|
||||
let bytes = state
|
||||
@@ -268,10 +277,18 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro
|
||||
// Restore contamination state (#254) — prevents double-firing on reload.
|
||||
world.insert_resource(ContaminationActive(state.contamination_active));
|
||||
|
||||
// Reset event queues — prevent stale events from the pre-load world
|
||||
// leaking into the post-load simulation.
|
||||
// Restore activation state (#572) — prevents double-activation on reload.
|
||||
world.insert_resource(ActivationState {
|
||||
activated_count: state.activated_count,
|
||||
last_activation_tick: state.last_activation_tick,
|
||||
});
|
||||
|
||||
// Reset event queues and transient buffers — prevent stale events/history
|
||||
// from the pre-load world leaking into the post-load simulation.
|
||||
world.insert_resource(ContaminationEventQueue::default());
|
||||
world.insert_resource(TriangleCrisisEventQueue::default());
|
||||
world.insert_resource(TriangleActivatedQueue::default());
|
||||
world.insert_resource(MovementHistoryBuffer::default());
|
||||
|
||||
// Restore door open states (#246) — find door entities by StableId and toggle.
|
||||
if !state.open_doors.is_empty() {
|
||||
@@ -401,6 +418,7 @@ mod tests {
|
||||
w.insert_resource(RelationshipGraph::new());
|
||||
w.init_resource::<EntityRegistry>();
|
||||
w.init_resource::<ContaminationActive>();
|
||||
w.init_resource::<ActivationState>();
|
||||
w
|
||||
}
|
||||
|
||||
@@ -591,6 +609,8 @@ mod tests {
|
||||
open_doors: vec![],
|
||||
modifications: vec![],
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
};
|
||||
let bytes = bad_state.to_bytes().expect("serialize");
|
||||
let path = temp_path();
|
||||
@@ -862,4 +882,65 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ActivationState roundtrip (#572, Task #12)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn load_from_file_restores_activation_state() {
|
||||
let mut world = minimal_world();
|
||||
|
||||
// Set activation state: 1 activation at tick 500
|
||||
world.insert_resource(ActivationState {
|
||||
activated_count: 1,
|
||||
last_activation_tick: Some(500),
|
||||
});
|
||||
|
||||
let path = temp_path();
|
||||
save_to_file(&path, &mut world).expect("save");
|
||||
|
||||
// Reset activation state to defaults before load
|
||||
world.insert_resource(ActivationState::default());
|
||||
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
|
||||
assert!(world.resource::<ActivationState>().last_activation_tick.is_none());
|
||||
|
||||
load_from_file(&path, &mut world).expect("load");
|
||||
|
||||
let state = world.resource::<ActivationState>();
|
||||
assert_eq!(
|
||||
state.activated_count, 1,
|
||||
"activated_count must survive save/load"
|
||||
);
|
||||
assert_eq!(
|
||||
state.last_activation_tick,
|
||||
Some(500),
|
||||
"last_activation_tick must survive save/load"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_file_restores_zero_activation_state() {
|
||||
// Verify that saves with no activations restore correctly (serde(default))
|
||||
let mut world = minimal_world();
|
||||
|
||||
let path = temp_path();
|
||||
save_to_file(&path, &mut world).expect("save");
|
||||
|
||||
// Pollute state before load
|
||||
world.insert_resource(ActivationState {
|
||||
activated_count: 5,
|
||||
last_activation_tick: Some(9999),
|
||||
});
|
||||
|
||||
load_from_file(&path, &mut world).expect("load");
|
||||
|
||||
let state = world.resource::<ActivationState>();
|
||||
assert_eq!(state.activated_count, 0);
|
||||
assert!(state.last_activation_tick.is_none());
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ use crate::npc::relationships::RelationshipGraph;
|
||||
use crate::npc::vision::{NpcMemory, NpcVisionState};
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::time::TickRate;
|
||||
use crate::storyteller::EngagementRecord;
|
||||
|
||||
/// Current format version. Bump on any breaking schema change.
|
||||
pub const SAVE_FORMAT_VERSION: u8 = 1;
|
||||
@@ -111,6 +112,16 @@ pub struct SaveStateV1 {
|
||||
/// and apply a duplicate tension delta to all ActiveFork triangles.
|
||||
#[serde(default)]
|
||||
pub contamination_active: bool,
|
||||
/// Number of triangles activated this session (#572).
|
||||
/// Persisted to prevent double-activation on save/load — without this,
|
||||
/// reloading a save after activation would reset the one-shot guard
|
||||
/// and allow a second triangle to be activated.
|
||||
#[serde(default)]
|
||||
pub activated_count: u32,
|
||||
/// Tick at which the most recent triangle activation occurred (#572).
|
||||
/// `None` if no activation yet. Persisted alongside `activated_count`.
|
||||
#[serde(default)]
|
||||
pub last_activation_tick: Option<u64>,
|
||||
}
|
||||
|
||||
/// Per-NPC state snapshot for `SaveStateV1`.
|
||||
@@ -363,6 +374,7 @@ pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> E
|
||||
NpcVisionState::default(),
|
||||
NpcMemory::default(),
|
||||
PlayerAwareness::default(),
|
||||
EngagementRecord::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
@@ -425,6 +437,8 @@ mod tests {
|
||||
open_doors: vec![],
|
||||
modifications: vec![],
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,6 +838,8 @@ mod tests {
|
||||
open_doors: vec![],
|
||||
modifications: vec![],
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
};
|
||||
|
||||
let bytes = save.to_bytes().expect("serialize");
|
||||
|
||||
Reference in New Issue
Block a user