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
+29 -9
View File
@@ -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) {