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:
@@ -15,7 +15,7 @@ use crate::content::template::TriangleState;
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::conversation::NpcName;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::storyteller::{ContaminationActive, ContaminationEventQueue, CONTAMINATION_DELAY_TICKS};
|
||||
@@ -63,6 +63,7 @@ pub fn handle_debug_commands(
|
||||
mut time: ResMut<SimulationTime>,
|
||||
mut contamination: Option<ResMut<ContaminationActive>>,
|
||||
mut contamination_queue: Option<ResMut<ContaminationEventQueue>>,
|
||||
walkability: Option<Res<WalkabilityMap>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut player_query: Query<(Entity, &mut TilePosition), With<PlayerCharacter>>,
|
||||
triangles: Query<(Entity, &TriangleState), With<ActiveSim>>,
|
||||
@@ -113,13 +114,25 @@ pub fn handle_debug_commands(
|
||||
),
|
||||
success: true,
|
||||
},
|
||||
Some(false) if time.tick >= CONTAMINATION_DELAY_TICKS => {
|
||||
// Tick is already past the delay — advancing would be a no-op
|
||||
// or rewinding would break cooldowns. Report error instead.
|
||||
DebugResponsePayload {
|
||||
command: "SkipToContamination".to_string(),
|
||||
text: format!(
|
||||
"Current tick ({}) already past contamination delay ({}). Contamination should fire on next system run — use ForceContaminationActivate if it hasn't.",
|
||||
time.tick, CONTAMINATION_DELAY_TICKS
|
||||
),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
let old_tick = time.tick;
|
||||
time.tick = CONTAMINATION_DELAY_TICKS;
|
||||
DebugResponsePayload {
|
||||
command: "SkipToContamination".to_string(),
|
||||
text: format!(
|
||||
"Skipped to contamination delay: tick {} -> {}. Contamination will fire on next system run.",
|
||||
"Advanced tick to contamination threshold: {} -> {}. Contamination will fire on next system run.",
|
||||
old_tick, time.tick
|
||||
),
|
||||
success: true,
|
||||
@@ -128,7 +141,21 @@ pub fn handle_debug_commands(
|
||||
}
|
||||
}
|
||||
DebugCommandKind::TeleportToPosition { x, y, z } => {
|
||||
if let Ok((_, mut pos)) = player_query.single_mut() {
|
||||
let target = TilePosition::new(x, y, z);
|
||||
// Validate target is walkable (if WalkabilityMap available)
|
||||
let walkable = walkability
|
||||
.as_ref()
|
||||
.map_or(true, |wm| wm.can_move_to(&target));
|
||||
if !walkable {
|
||||
DebugResponsePayload {
|
||||
command: format!("TeleportToPosition({}, {}, {})", x, y, z),
|
||||
text: format!(
|
||||
"Target ({}, {}, {}) is not walkable. Player would be stuck in wall/void.",
|
||||
x, y, z
|
||||
),
|
||||
success: false,
|
||||
}
|
||||
} else if let Ok((_, mut pos)) = player_query.single_mut() {
|
||||
let old = (pos.x, pos.y, pos.z);
|
||||
pos.x = x;
|
||||
pos.y = y;
|
||||
@@ -297,6 +324,12 @@ pub fn handle_debug_commands(
|
||||
};
|
||||
|
||||
tracing::debug!(command = %response.command, success = response.success, "Debug command processed");
|
||||
if buffer.pending_debug_response.is_some() {
|
||||
tracing::trace!(
|
||||
"Debug response overwritten by '{}' — earlier response dropped (last-wins per tick)",
|
||||
response.command
|
||||
);
|
||||
}
|
||||
buffer.pending_debug_response = Some(response);
|
||||
}
|
||||
}
|
||||
@@ -420,4 +453,53 @@ mod tests {
|
||||
schedule.run(&mut world);
|
||||
assert!(world.resource::<SnapshotBuffer>().pending_debug_response.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleport_rejects_unwalkable_target() {
|
||||
let (mut world, mut schedule) = setup_debug_world();
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
map.set_walkable(&TilePosition::new(5, 5, 0), false);
|
||||
world.insert_resource(map);
|
||||
|
||||
world.resource_mut::<DebugCommandBuffer>().push(
|
||||
DebugCommandKind::TeleportToPosition { x: 5, y: 5, z: 0 },
|
||||
);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Player should NOT have moved
|
||||
let mut q = world.query_filtered::<&TilePosition, With<PlayerCharacter>>();
|
||||
let pos = q.single(&world).unwrap();
|
||||
assert_eq!((pos.x, pos.y, pos.z), (10, 20, 0), "player must not teleport to unwalkable tile");
|
||||
|
||||
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
|
||||
assert!(!resp.success);
|
||||
assert!(resp.text.contains("not walkable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_to_contamination_rejects_when_past_delay() {
|
||||
let (mut world, mut schedule) = setup_debug_world();
|
||||
// Set tick past the delay but contamination not yet active
|
||||
world.resource_mut::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS + 100;
|
||||
|
||||
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::SkipToContamination);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Tick should NOT have changed (no rewind)
|
||||
assert_eq!(
|
||||
world.resource::<SimulationTime>().tick,
|
||||
CONTAMINATION_DELAY_TICKS + 100,
|
||||
"tick must not rewind"
|
||||
);
|
||||
|
||||
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
|
||||
assert!(!resp.success);
|
||||
assert!(resp.text.contains("already past"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_enabled_defaults_to_debug_assertions() {
|
||||
let d = DebugEnabled::default();
|
||||
assert_eq!(d.0, cfg!(debug_assertions));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,10 +72,11 @@ pub struct StartupMessage {
|
||||
/// v16 adds: triangle_crisis_events (#250, D-087 triangle escalation for future client rendering).
|
||||
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
|
||||
/// sim_errors (#85, structured error reporting to client).
|
||||
/// v18 adds: debug_response (#580, debug console server — command/response wire).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 17.
|
||||
/// Protocol version for forward compatibility. Current: 18.
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
@@ -550,13 +551,13 @@ pub struct DebugResponsePayload {
|
||||
/// Whether the debug console is enabled (#580).
|
||||
///
|
||||
/// Set at server startup. Cannot be toggled mid-session via IPC.
|
||||
/// v0.1: defaults to true. Production builds will default to false.
|
||||
/// Defaults to true in debug builds, false in release builds.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct DebugEnabled(pub bool);
|
||||
|
||||
impl Default for DebugEnabled {
|
||||
fn default() -> Self {
|
||||
Self(true)
|
||||
Self(cfg!(debug_assertions))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -377,29 +377,43 @@ fn apply_location_tiles(location: &Location, walkability: &mut WalkabilityMap) -
|
||||
return false;
|
||||
};
|
||||
|
||||
// Guard: inverted bounds cause (y_max - y_min) to be negative, which wraps to
|
||||
// ~18 quintillion when cast to usize, silently writing tiles at garbage positions.
|
||||
if bounds.x_min > bounds.x_max || bounds.y_min > bounds.y_max {
|
||||
tracing::error!(
|
||||
"Location '{}': inverted tile_bounds (x: {}..={}, y: {}..={}), skipping",
|
||||
location.canonical_id,
|
||||
bounds.x_min, bounds.x_max,
|
||||
bounds.y_min, bounds.y_max,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let expected_height = (bounds.y_max - bounds.y_min + 1) as usize;
|
||||
let expected_width = (bounds.x_max - bounds.x_min + 1) as usize;
|
||||
|
||||
if tiles.len() != expected_height {
|
||||
tracing::warn!(
|
||||
"Location '{}': tile row count {} != expected height {} (from tile_bounds)",
|
||||
tracing::error!(
|
||||
"Location '{}': tile row count {} != expected height {} (from tile_bounds) — skipping to prevent walkability holes",
|
||||
location.canonical_id,
|
||||
tiles.len(),
|
||||
expected_height,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (row_idx, row) in tiles.iter().enumerate() {
|
||||
let y = bounds.y_min + row_idx as i32;
|
||||
|
||||
if row.len() != expected_width {
|
||||
tracing::warn!(
|
||||
"Location '{}' row {}: length {} != expected width {}",
|
||||
tracing::error!(
|
||||
"Location '{}' row {}: length {} != expected width {} — skipping row to prevent walkability holes",
|
||||
location.canonical_id,
|
||||
row_idx,
|
||||
row.len(),
|
||||
expected_width,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (col_idx, ch) in row.chars().enumerate() {
|
||||
|
||||
@@ -42,6 +42,7 @@ pub struct Discovery {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DistrictMeta {
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub locations: Vec<String>,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -382,7 +382,8 @@ impl Plugin for StorytellerPlugin {
|
||||
Update,
|
||||
activation_pass
|
||||
.after(append_player_history)
|
||||
.after(tick_contamination_activation),
|
||||
.after(tick_contamination_activation)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
);
|
||||
|
||||
tracing::debug!("StorytellerPlugin initialized");
|
||||
@@ -448,7 +449,9 @@ pub fn tick_contamination_activation(
|
||||
fn compute_engagement_score(record: &EngagementRecord) -> f32 {
|
||||
let conv_score = record.conversation_count.min(CONVERSATION_CAP) as f32
|
||||
* ENGAGEMENT_WEIGHT_CONVERSATION;
|
||||
let obs_score = record.observation_time_ticks as f32 * ENGAGEMENT_WEIGHT_OBSERVATION;
|
||||
// Use f64 intermediate to avoid precision loss beyond ~16.8M ticks (2^24),
|
||||
// where f32 can no longer distinguish adjacent integers.
|
||||
let obs_score = (record.observation_time_ticks as f64 * ENGAGEMENT_WEIGHT_OBSERVATION as f64) as f32;
|
||||
let mono_score = record.monologue_trigger_count as f32 * ENGAGEMENT_WEIGHT_MONOLOGUE;
|
||||
conv_score + obs_score + mono_score
|
||||
}
|
||||
@@ -462,7 +465,8 @@ fn compute_engagement_score(record: &EngagementRecord) -> f32 {
|
||||
/// 2. Co-presence query: find NPCs near any recorded player position (MovementHistoryBuffer).
|
||||
/// 3. Engagement scoring: score each co-present NPC via EngagementRecord.
|
||||
/// 4+5. Triangle routing: find Simmering triangle containing highest-scoring co-present NPC.
|
||||
/// Fallback (unentangled NPC, D-025): highest-tension Simmering triangle.
|
||||
/// v0.1: NPCs not assigned to any triangle are excluded (D-025 reference link routing deferred to v0.3+).
|
||||
/// Holds (no activation) if no triangle-assigned NPC is co-present.
|
||||
/// 6. Activation event: emit TriangleActivatedEvent, record in ActivationState.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn activation_pass(
|
||||
@@ -484,15 +488,22 @@ pub fn activation_pass(
|
||||
if !activation_state.can_activate(time.tick) {
|
||||
return;
|
||||
}
|
||||
// Gate 3: poll cadence — only run every ACTIVATION_CADENCE_TICKS
|
||||
if time.tick % ACTIVATION_CADENCE_TICKS != 0 {
|
||||
// Gate 3: poll cadence — only run every ACTIVATION_CADENCE_TICKS.
|
||||
// Guard: at tick 0 all engagement scores are 0.0 and selection is random
|
||||
// (e.g. contamination pre-set in a save file). Skip tick 0.
|
||||
if time.tick == 0 || time.tick % ACTIVATION_CADENCE_TICKS != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: co-presence query
|
||||
let npc_positions: Vec<(Entity, TilePosition)> =
|
||||
npcs.iter().map(|(e, pos, _)| (e, *pos)).collect();
|
||||
let copresent = history.npcs_copresent_in_window(npc_positions.into_iter(), COPRESENCE_THRESHOLD);
|
||||
let mut copresent = history.npcs_copresent_in_window(npc_positions.into_iter(), COPRESENCE_THRESHOLD);
|
||||
// Deduplicate: the co-presence query can return the same entity more than once
|
||||
// if multiple player positions fall near the same NPC. Without dedup, the NPC
|
||||
// gets added to candidates twice, doubling its RNG weight.
|
||||
copresent.sort_unstable();
|
||||
copresent.dedup();
|
||||
|
||||
// Snapshot simmering triangles for read (avoids double-borrow during activation write)
|
||||
let simmering: Vec<(Entity, TriangleState)> = triangles
|
||||
@@ -553,12 +564,21 @@ pub fn activation_pass(
|
||||
};
|
||||
let (anchor_entity, tri_entity, anchor_score) = candidates[selected_idx];
|
||||
|
||||
// Retrieve triangle_id for the event
|
||||
let triangle_id = simmering
|
||||
// Retrieve triangle_id for the event.
|
||||
// Guard: the entity was in the simmering snapshot we collected above, but in
|
||||
// theory it could have been despawned between snapshot and lookup (unlikely but
|
||||
// a panic in the gameplay loop is unacceptable).
|
||||
let Some(triangle_id) = simmering
|
||||
.iter()
|
||||
.find(|(e, _)| *e == tri_entity)
|
||||
.map(|(_, s)| s.triangle_id)
|
||||
.expect("target triangle must be in simmering snapshot");
|
||||
else {
|
||||
tracing::error!(
|
||||
"activation_pass: target triangle entity {:?} missing from simmering snapshot — skipping activation at tick {}",
|
||||
tri_entity, time.tick
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Step 6: set triangle phase to Active
|
||||
if let Ok((_, mut tri_state)) = triangles.get_mut(tri_entity) {
|
||||
|
||||
@@ -420,6 +420,8 @@ fn minimal_save() -> SaveStateV1 {
|
||||
open_doors: vec![],
|
||||
modifications: vec![],
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,8 @@ fn save_state_npc_kg_isolation() {
|
||||
open_doors: vec![],
|
||||
modifications: vec![],
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
};
|
||||
|
||||
// Roundtrip: serialize → deserialize.
|
||||
|
||||
Reference in New Issue
Block a user