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:
2026-03-03 23:40:59 +01:00
co-authored by Claude Opus 4.6
parent eb64f23b77
commit ac68ec6eef
9 changed files with 241 additions and 22 deletions
+84 -3
View File
@@ -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);
}
}
+16
View File
@@ -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");