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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user