fix(simulation): Clippy cleanup and CI enforcement (#635)

Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 10:33:15 +01:00
co-authored by Claude Opus 4.6
parent 7f7706442a
commit aa79dd97e7
84 changed files with 2409 additions and 1339 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ pub fn chunk_streaming(
};
// Cadence gate — only run every N ticks
if cadence.ticks > 0 && time.tick % cadence.ticks != 0 {
if cadence.ticks > 0 && !time.tick.is_multiple_of(cadence.ticks) {
return;
}
+42 -30
View File
@@ -352,7 +352,9 @@ pub fn run_npc_conversations(
}
// Start conversation
let duration = rng.rng.random_range(MIN_DURATION_TICKS..=MAX_DURATION_TICKS);
let duration = rng
.rng
.random_range(MIN_DURATION_TICKS..=MAX_DURATION_TICKS);
commands.entity(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: time.tick,
@@ -422,12 +424,15 @@ pub fn run_npc_conversations(
}
// Check termination: partner moved away or no longer ActiveSim
let partner_ok = npc_query.get(conv.partner).ok().map(|(_, pos, _, _, _, _, _, _)| {
speaker_pos
.manhattan_distance(pos)
.map(|d| d <= CONVERSATION_PROXIMITY)
.unwrap_or(false)
});
let partner_ok = npc_query
.get(conv.partner)
.ok()
.map(|(_, pos, _, _, _, _, _, _)| {
speaker_pos
.manhattan_distance(pos)
.map(|d| d <= CONVERSATION_PROXIMITY)
.unwrap_or(false)
});
if partner_ok != Some(true) {
terminate_conversation(
@@ -497,8 +502,7 @@ pub fn run_npc_conversations(
// (D-071) wires in. Function signature already accepts the value.
let ambient_noise_pct = 0u32;
let drop_pct =
compute_drop_probability(distance, ambient_noise_pct, listening);
let drop_pct = compute_drop_probability(distance, ambient_noise_pct, listening);
let occluded = occlude_line(line_text, drop_pct, &mut rng.rng);
if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) {
@@ -509,11 +513,13 @@ pub fn run_npc_conversations(
.map(|e| e.known_attributes.contains_key("name"))
.unwrap_or(false);
if known {
speaker_name.clone().unwrap_or_else(|| "Unknown".to_string())
speaker_name
.clone()
.unwrap_or_else(|| "Unknown".to_string())
} else {
speaker_role
.as_deref()
.map(|r| display_label_for_role(r))
.map(display_label_for_role)
.unwrap_or_else(|| "Bystander".to_string())
}
};
@@ -525,11 +531,13 @@ pub fn run_npc_conversations(
.map(|e| e.known_attributes.contains_key("name"))
.unwrap_or(false);
if known {
partner_real_name.clone().unwrap_or_else(|| "Unknown".to_string())
partner_real_name
.clone()
.unwrap_or_else(|| "Unknown".to_string())
} else {
partner_role
.as_deref()
.map(|r| display_label_for_role(r))
.map(display_label_for_role)
.unwrap_or_else(|| "Bystander".to_string())
}
};
@@ -607,11 +615,7 @@ fn terminate_conversation(
}
}
tracing::debug!(
"NPC conversation ended: {:?} ↔ {:?}",
speaker,
partner,
);
tracing::debug!("NPC conversation ended: {:?} ↔ {:?}", speaker, partner,);
}
// ---------------------------------------------------------------------------
@@ -654,7 +658,10 @@ mod tests {
let without_focus = compute_drop_probability(2, 0, false);
let with_focus = compute_drop_probability(2, 0, true);
assert!(with_focus < without_focus, "focus should reduce drop probability");
assert!(
with_focus < without_focus,
"focus should reduce drop probability"
);
assert_eq!(without_focus, 25); // 2 * 100 / 8 = 25
assert_eq!(with_focus, 5); // 25 - 20 = 5
}
@@ -1281,13 +1288,11 @@ mod tests {
assert_eq!(buffer.events.len(), 1);
// No "name" attribute → falls back to role label
assert_eq!(
buffer.events[0].speaker_name,
"Dock Worker",
buffer.events[0].speaker_name, "Dock Worker",
"speaker with no KG name attribute should show role label"
);
assert_eq!(
buffer.events[0].target_name,
"Courier",
buffer.events[0].target_name, "Courier",
"target with no KG name attribute should show role label"
);
}
@@ -1368,13 +1373,11 @@ mod tests {
assert_eq!(buffer.events.len(), 1);
// "name" attribute present → use NpcName.0
assert_eq!(
buffer.events[0].speaker_name,
"Alice",
buffer.events[0].speaker_name, "Alice",
"speaker with KG name attribute should show real name"
);
assert_eq!(
buffer.events[0].target_name,
"Bob",
buffer.events[0].target_name, "Bob",
"target with KG name attribute should show real name"
);
}
@@ -1403,11 +1406,17 @@ mod tests {
let taken = buffer.take_events();
assert_eq!(taken.len(), 2, "take_events should return all events");
assert!(buffer.events.is_empty(), "Buffer should be empty after take_events");
assert!(
buffer.events.is_empty(),
"Buffer should be empty after take_events"
);
// Second call returns empty
let taken2 = buffer.take_events();
assert!(taken2.is_empty(), "Second take_events call should return empty vec");
assert!(
taken2.is_empty(),
"Second take_events call should return empty vec"
);
}
#[test]
@@ -1420,7 +1429,10 @@ mod tests {
let taken = buffer.take_ended();
assert_eq!(taken.len(), 1, "take_ended should return all end events");
assert!(buffer.ended.is_empty(), "ended buffer should be empty after take_ended");
assert!(
buffer.ended.is_empty(),
"ended buffer should be empty after take_ended"
);
// Second call returns empty
assert!(buffer.take_ended().is_empty());
+38 -22
View File
@@ -9,7 +9,7 @@
//! is implemented here.
//!
//! Integration points:
//! - Reads LinePoolIndexResource (content/mod.rs)
//! - Reads LinePoolIndexResource (server/content/mod.rs)
//! - Reads KnowledgeGraph + EntityRegistry for access/trust derivation
//! - Reads DialogueProfile on NPCs for pool lookup coordinates
//! - Writes DialogueResponseBuffer for snapshot inclusion
@@ -21,23 +21,23 @@ use bevy_ecs::prelude::*;
use rand::Rng;
use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState};
use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName};
use crate::storyteller::EngagementRecord;
use crate::simulation::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
use crate::simulation::knowledge_grant::KnowledgeGrant;
use crate::simulation::line_pool::LinePoolIndexResource;
use crate::knowledge::content_registry::ContentEntityRegistry;
use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant};
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId};
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory};
use crate::npc::relationships::{TrustEvent, TrustEventQueue};
use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName};
use crate::simulation::knowledge_grant::KnowledgeGrant;
use crate::simulation::line_pool::LinePoolIndexResource;
use crate::simulation::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
use crate::simulation::monologue::{MonologueBuffer, MonologueState};
use crate::simulation::movement::PlayerCharacter;
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::storyteller::EngagementRecord;
/// Cooldown ticks before the same dialogue line can be selected again.
/// At 10 ticks/game-minute, 600 ticks = 1 game-hour.
@@ -532,7 +532,9 @@ pub fn process_talk_interaction(
.map(|e| e.known_attributes.contains_key("name"))
.unwrap_or(false);
if known {
npc_name_opt.map(|n| n.0.clone()).unwrap_or_else(|| "Unknown".to_string())
npc_name_opt
.map(|n| n.0.clone())
.unwrap_or_else(|| "Unknown".to_string())
} else {
display_label_for_role(&profile.role)
}
@@ -648,7 +650,10 @@ fn emit_knowledge_grant(
};
match grant {
KnowledgeGrant::Fact { fact_id, confidence } => {
KnowledgeGrant::Fact {
fact_id,
confidence,
} => {
let conf = match KnowledgeConfidence::try_from(confidence.as_str()) {
Ok(c) => c,
Err(e) => {
@@ -658,9 +663,7 @@ fn emit_knowledge_grant(
};
let fid = FactId(fact_id.clone());
// Guardrail: NPC must know this fact to grant it (D-079).
let npc_knows = npc_kg_opt
.map(|kg| kg.knows_fact(&fid))
.unwrap_or(false);
let npc_knows = npc_kg_opt.map(|kg| kg.knows_fact(&fid)).unwrap_or(false);
if !npc_knows {
tracing::warn!(
"KnowledgeGrant dropped: NPC {:?} does not know fact '{}' — grant guardrail",
@@ -1110,14 +1113,14 @@ pub fn process_dialogue_response(
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::line_pool::LinePoolIndexResource;
use crate::simulation::line_pool::{
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
Topic, TrustTier,
};
use crate::simulation::line_pool::LinePoolIndexResource;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::movement::TilePosition;
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
@@ -2216,10 +2219,15 @@ mod tests {
// Run with multiple seeds — Secret-tier line must appear at least once
let mut saw_secret_line = false;
for seed in 0u64..50 {
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
world
.get_mut::<DialogueResponseBuffer>(player)
.unwrap()
.response = None;
world.entity_mut(player).insert(TalkRequest { target: npc });
// Reset cooldown so the pool is not exhausted between iterations
world.entity_mut(player).insert(DialogueCooldownTracker::default());
world
.entity_mut(player)
.insert(DialogueCooldownTracker::default());
world.insert_resource(SimRng::new(seed));
let mut schedule = bevy_ecs::schedule::Schedule::default();
@@ -2293,9 +2301,14 @@ mod tests {
let mut saw_secret = false;
for seed in 0u64..50 {
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
world
.get_mut::<DialogueResponseBuffer>(player)
.unwrap()
.response = None;
world.entity_mut(player).insert(TalkRequest { target: npc });
world.entity_mut(player).insert(DialogueCooldownTracker::default());
world
.entity_mut(player)
.insert(DialogueCooldownTracker::default());
world.insert_resource(SimRng::new(seed));
let mut schedule = bevy_ecs::schedule::Schedule::default();
@@ -2388,7 +2401,10 @@ mod tests {
let mut seen_ids: Vec<String> = Vec::new();
for seed in 0u64..10 {
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
world
.get_mut::<DialogueResponseBuffer>(player)
.unwrap()
.response = None;
world.entity_mut(player).insert(TalkRequest { target: npc });
// NOTE: SimulationTime is NOT advanced — all 10 talks happen within tick 0
world.insert_resource(SimRng::new(seed));
+72 -49
View File
@@ -223,9 +223,15 @@ pub fn process_examine_interaction(
// Try NPC examine path first
if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) {
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
let distance = player_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
if distance > CLOSE_RANGE {
tracing::info!(distance, "Examine: NPC target out of range (max {})", CLOSE_RANGE);
tracing::info!(
distance,
"Examine: NPC target out of range (max {})",
CLOSE_RANGE
);
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
@@ -243,12 +249,18 @@ pub fn process_examine_interaction(
},
});
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
tracing::warn!(?target, "Examine: NPC not in EntityRegistry, using bits");
target.to_bits()
});
let target_entity_id = registry
.to_stable(target)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::warn!(?target, "Examine: NPC not in EntityRegistry, using bits");
target.to_bits()
});
result_buffer.result = Some(ExamineResultEvent { text, target_entity_id });
result_buffer.result = Some(ExamineResultEvent {
text,
target_entity_id,
});
tracing::debug!(target_entity_id, "Examine: NPC result written to buffer");
commands.entity(player_entity).remove::<ExamineRequest>();
return;
@@ -256,9 +268,15 @@ pub fn process_examine_interaction(
// Object examine path: entity has a TilePosition but no NPC mood components.
if let Ok((target_pos, examine_text_opt)) = examine_text_query.get(target) {
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
let distance = player_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
if distance > CLOSE_RANGE {
tracing::info!(distance, "Examine: object target out of range (max {})", CLOSE_RANGE);
tracing::info!(
distance,
"Examine: object target out of range (max {})",
CLOSE_RANGE
);
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
@@ -267,18 +285,27 @@ pub fn process_examine_interaction(
.map(|et| et.0.clone())
.unwrap_or_else(|| "No further details are apparent.".to_string());
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
tracing::warn!(?target, "Examine: object not in EntityRegistry, using bits");
target.to_bits()
});
let target_entity_id = registry
.to_stable(target)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::warn!(?target, "Examine: object not in EntityRegistry, using bits");
target.to_bits()
});
result_buffer.result = Some(ExamineResultEvent { text, target_entity_id });
result_buffer.result = Some(ExamineResultEvent {
text,
target_entity_id,
});
tracing::debug!(target_entity_id, "Examine: object result written to buffer");
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
tracing::warn!(?target, "process_examine_interaction: target has no position component");
tracing::warn!(
?target,
"process_examine_interaction: target has no position component"
);
commands.entity(player_entity).remove::<ExamineRequest>();
}
@@ -297,35 +324,29 @@ mod tests {
#[test]
fn smuggler_hostile_npc_gives_threat_read() {
let text = generate_examine_text(
NpcMood::Hostile,
20,
CharacterArchetype::Smuggler,
None,
let text = generate_examine_text(NpcMood::Hostile, 20, CharacterArchetype::Smuggler, None);
assert!(
text.contains("Threat posture"),
"expected threat read, got: {text}"
);
assert!(text.contains("Threat posture"), "expected threat read, got: {text}");
}
#[test]
fn smuggler_focused_npc_notes_attention() {
let text = generate_examine_text(
NpcMood::Focused,
30,
CharacterArchetype::Smuggler,
None,
let text = generate_examine_text(NpcMood::Focused, 30, CharacterArchetype::Smuggler, None);
assert!(
text.contains("close attention"),
"expected attention note, got: {text}"
);
assert!(text.contains("close attention"), "expected attention note, got: {text}");
}
#[test]
fn smuggler_high_stress_identifies_distraction() {
let text = generate_examine_text(
NpcMood::Anxious,
80,
CharacterArchetype::Smuggler,
None,
let text = generate_examine_text(NpcMood::Anxious, 80, CharacterArchetype::Smuggler, None);
assert!(
text.contains("Too distracted"),
"expected distraction read, got: {text}"
);
assert!(text.contains("Too distracted"), "expected distraction read, got: {text}");
}
#[test]
@@ -337,17 +358,15 @@ mod tests {
CharacterArchetype::Detective,
Some(&t),
);
assert!(text.contains("Controlled affect"), "expected concealment note, got: {text}");
assert!(
text.contains("Controlled affect"),
"expected concealment note, got: {text}"
);
}
#[test]
fn detective_anxious_npc_notes_stress_markers() {
let text = generate_examine_text(
NpcMood::Anxious,
50,
CharacterArchetype::Detective,
None,
);
let text = generate_examine_text(NpcMood::Anxious, 50, CharacterArchetype::Detective, None);
assert!(
text.contains("stress markers"),
"expected stress markers, got: {text}"
@@ -356,12 +375,7 @@ mod tests {
#[test]
fn detective_content_npc_notes_low_guard() {
let text = generate_examine_text(
NpcMood::Content,
10,
CharacterArchetype::Detective,
None,
);
let text = generate_examine_text(NpcMood::Content, 10, CharacterArchetype::Detective, None);
assert!(
text.contains("Less guarded"),
"expected low guard note, got: {text}"
@@ -370,19 +384,28 @@ mod tests {
#[test]
fn stress_ratio_zero_when_threshold_zero() {
let t = ToleranceThreshold { current_stress: 50, threshold: 0 };
let t = ToleranceThreshold {
current_stress: 50,
threshold: 0,
};
assert_eq!(stress_ratio(&t), 0);
}
#[test]
fn stress_ratio_clamped_at_100() {
let t = ToleranceThreshold { current_stress: 200, threshold: 100 };
let t = ToleranceThreshold {
current_stress: 200,
threshold: 100,
};
assert_eq!(stress_ratio(&t), 100);
}
#[test]
fn stress_ratio_negative_stress_is_zero() {
let t = ToleranceThreshold { current_stress: -10, threshold: 70 };
let t = ToleranceThreshold {
current_stress: -10,
threshold: 70,
};
assert_eq!(stress_ratio(&t), 0);
}
+5 -2
View File
@@ -13,11 +13,11 @@
use bevy_ecs::prelude::*;
use crate::npc::tolerance::ToleranceBreached;
use crate::npc::{Npc, ToleranceThreshold};
use crate::perception::query::VisibilityGeometry;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::time::SimulationTime;
use crate::npc::tolerance::ToleranceBreached;
// ---------------------------------------------------------------------------
// Constants
@@ -783,7 +783,10 @@ mod tests {
#[test]
fn follow_constants_have_expected_values() {
// Spec-defined in #241 — changes here break the design contract
assert_eq!(FOLLOW_PROXIMITY_RANGE, 2, "D-241: 'too close' range is 2 Manhattan tiles");
assert_eq!(
FOLLOW_PROXIMITY_RANGE, 2,
"D-241: 'too close' range is 2 Manhattan tiles"
);
assert_eq!(
FOLLOW_SUSPICION_TICKS, 60,
"D-241: suspicion starts after 60 sustained proximity ticks"
+5 -2
View File
@@ -80,7 +80,7 @@ pub type Season = String;
pub type RoleSlot = String;
/// Day phase (morning, afternoon, evening, night, late-night). Stub.
pub type DayPhase = String;
/// Triangle template reference (links to content/templates/). Stub.
/// Triangle template reference (links to server/content/templates/). Stub.
pub type TriangleTemplate = String;
/// Raw chunk tile data for generator use (64×64 bool grid, true = walkable). Stub.
pub type GeneratorChunkData = Vec<bool>;
@@ -559,7 +559,10 @@ mod tests {
vertical_corridors: vec![],
hosted_sites: vec![],
};
assert_eq!(r.base_z, -30, "deep mine base_z must be representable as i8");
assert_eq!(
r.base_z, -30,
"deep mine base_z must be representable as i8"
);
assert_eq!(r.z_levels, 30u8, "z_levels count must remain u8");
}
+127 -99
View File
@@ -4,9 +4,9 @@
use crate::bridge::debug::DebugCommandBuffer;
use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput};
use crate::settings::{SettingsCommand, SettingsCommandBuffer};
use crate::knowledge::{EntityRegistry, StableId};
use crate::perception::vision_cone::{facing_from_delta, Facing};
use crate::settings::{SettingsCommand, SettingsCommandBuffer};
use crate::simulation::interaction::{DoorInteractRequest, DoorState, TerminalInteractRequest};
use crate::simulation::inventory::{
find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS,
@@ -207,93 +207,97 @@ pub fn process_player_input(
}
}
match verb.as_deref() {
Some("Take") => {
handle_take(
&mut commands,
&registry,
&player_query,
&inventory_items,
target_entity_id,
);
Some("Take") => {
handle_take(
&mut commands,
&registry,
&player_query,
&inventory_items,
target_entity_id,
);
}
Some("Place") => {
handle_place(&mut commands, &registry, &player_query, target_entity_id);
}
Some("Talk") => {
handle_talk(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
);
}
Some("Follow") => {
handle_follow(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
current_tick,
);
}
Some("Examine NPC")
| Some("ExamineNpc")
| Some("Examine Object")
| Some("ExamineObject")
| Some("Observe") => {
handle_examine(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
);
}
Some("Confront") => {
handle_confront(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
);
}
Some("Reset") => {
handle_reset(
&mut commands,
&registry,
&reset_triggers,
&mut room_snapshots,
target_entity_id,
current_tick,
);
}
// #246: Door and Terminal behavior
Some("Open") | Some("Close") => {
handle_door_interact(
&mut commands,
&registry,
&player_query,
&door_states,
target_entity_id,
);
}
Some("Use") => {
handle_terminal_interact(
&mut commands,
&registry,
&player_query,
&object_types,
target_entity_id,
);
}
_ => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only",
target_entity_id,
verb,
);
}
}
Some("Place") => {
handle_place(&mut commands, &registry, &player_query, target_entity_id);
}
Some("Talk") => {
handle_talk(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
);
}
Some("Follow") => {
handle_follow(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
current_tick,
);
}
Some("Examine NPC") | Some("ExamineNpc") | Some("Examine Object")
| Some("ExamineObject") | Some("Observe") => {
handle_examine(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
);
}
Some("Confront") => {
handle_confront(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
);
}
Some("Reset") => {
handle_reset(
&mut commands,
&registry,
&reset_triggers,
&mut room_snapshots,
target_entity_id,
current_tick,
);
}
// #246: Door and Terminal behavior
Some("Open") | Some("Close") => {
handle_door_interact(
&mut commands,
&registry,
&player_query,
&door_states,
target_entity_id,
);
}
Some("Use") => {
handle_terminal_interact(
&mut commands,
&registry,
&player_query,
&object_types,
target_entity_id,
);
}
_ => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only",
target_entity_id,
verb,
);
}
}}
}
PlayerAction::WalkAway => {
if let Ok((player_entity, _, _, _)) = player_query.single() {
commands
@@ -383,21 +387,27 @@ pub fn process_player_input(
if let Some(ref mut buf) = settings_cmd_buffer {
buf.push(SettingsCommand::Change { key, value });
} else {
tracing::warn!("ChangeSetting received but SettingsCommandBuffer not registered");
tracing::warn!(
"ChangeSetting received but SettingsCommandBuffer not registered"
);
}
}
PlayerAction::RequestAllSettings => {
if let Some(ref mut buf) = settings_cmd_buffer {
buf.push(SettingsCommand::RequestAll);
} else {
tracing::warn!("RequestAllSettings received but SettingsCommandBuffer not registered");
tracing::warn!(
"RequestAllSettings received but SettingsCommandBuffer not registered"
);
}
}
PlayerAction::DeleteSetting { key } => {
if let Some(ref mut buf) = settings_cmd_buffer {
buf.push(SettingsCommand::Delete { key });
} else {
tracing::warn!("DeleteSetting received but SettingsCommandBuffer not registered");
tracing::warn!(
"DeleteSetting received but SettingsCommandBuffer not registered"
);
}
}
}
@@ -663,7 +673,10 @@ fn handle_dialogue_response(
let target_stable = StableId(target_entity_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_entity_id, "DialogueResponse: target entity not in registry");
tracing::warn!(
target_entity_id,
"DialogueResponse: target entity not in registry"
);
return;
};
@@ -690,7 +703,11 @@ fn handle_dialogue_response(
response_id: response_id.to_string(),
});
tracing::debug!(target_entity_id, response_id, "DialogueResponse: marker set on player");
tracing::debug!(
target_entity_id,
response_id,
"DialogueResponse: marker set on player"
);
}
/// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063).
@@ -971,11 +988,14 @@ fn handle_door_interact(
return;
};
commands
.entity(player_entity)
.insert(DoorInteractRequest { door_entity: target_entity });
commands.entity(player_entity).insert(DoorInteractRequest {
door_entity: target_entity,
});
tracing::debug!(target_id, "Door interact: DoorInteractRequest inserted on player");
tracing::debug!(
target_id,
"Door interact: DoorInteractRequest inserted on player"
);
}
/// Handle Terminal Use: insert `TerminalInteractRequest` on the player entity (#246).
@@ -1001,7 +1021,10 @@ fn handle_terminal_interact(
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Terminal interact: target entity not in registry");
tracing::warn!(
target_id,
"Terminal interact: target entity not in registry"
);
return;
};
@@ -1020,9 +1043,14 @@ fn handle_terminal_interact(
commands
.entity(player_entity)
.insert(TerminalInteractRequest { terminal_entity: target_entity });
.insert(TerminalInteractRequest {
terminal_entity: target_entity,
});
tracing::debug!(target_id, "Terminal interact: TerminalInteractRequest inserted on player");
tracing::debug!(
target_id,
"Terminal interact: TerminalInteractRequest inserted on player"
);
}
/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491).
+11 -4
View File
@@ -16,8 +16,8 @@ use serde::{Deserialize, Serialize};
// Re-export ObjectType for backward compatibility — definition moved to bridge::types (#422).
pub use crate::bridge::types::ObjectType;
use crate::bridge::types::{EntityKind, MovementStance, NearbyInteraction, VerbKind, VerbOption};
use crate::knowledge::EntityRegistry;
use crate::knowledge::types::StableId;
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::stance::Stance;
@@ -416,10 +416,15 @@ pub fn process_door_interaction(
};
let door_entity = req.door_entity;
commands.entity(player_entity).remove::<DoorInteractRequest>();
commands
.entity(player_entity)
.remove::<DoorInteractRequest>();
let Ok(mut door) = door_query.get_mut(door_entity) else {
tracing::warn!(?door_entity, "process_door_interaction: no DoorState on target");
tracing::warn!(
?door_entity,
"process_door_interaction: no DoorState on target"
);
return;
};
@@ -455,7 +460,9 @@ pub fn process_terminal_interaction(
};
let terminal_entity = req.terminal_entity;
commands.entity(player_entity).remove::<TerminalInteractRequest>();
commands
.entity(player_entity)
.remove::<TerminalInteractRequest>();
let terminal_id = registry
.to_stable(terminal_entity)
+1 -4
View File
@@ -20,10 +20,7 @@ use std::collections::BTreeMap;
pub enum KnowledgeGrant {
/// Grant knowledge of a non-entity fact.
/// Format: fact_id "category.topic", confidence string.
Fact {
fact_id: String,
confidence: String,
},
Fact { fact_id: String, confidence: String },
/// Grant knowledge of an entity (creates EntityKnowledge entry in observer's KG).
/// Required for contradiction detection: testimony must create ToldBy EntityKnowledge
/// so a subsequent DirectObservation can detect a discrepancy (D-079, D-083).
+72 -17
View File
@@ -467,10 +467,19 @@ mod tests {
#[test]
fn access_tier_parse_all_values() {
assert_eq!("public".parse::<AccessTier>().unwrap(), AccessTier::Public);
assert_eq!("insider".parse::<AccessTier>().unwrap(), AccessTier::Insider);
assert_eq!("authority".parse::<AccessTier>().unwrap(), AccessTier::Authority);
assert_eq!(
"insider".parse::<AccessTier>().unwrap(),
AccessTier::Insider
);
assert_eq!(
"authority".parse::<AccessTier>().unwrap(),
AccessTier::Authority
);
assert_eq!("peer".parse::<AccessTier>().unwrap(), AccessTier::Peer);
assert_eq!("hostile".parse::<AccessTier>().unwrap(), AccessTier::Hostile);
assert_eq!(
"hostile".parse::<AccessTier>().unwrap(),
AccessTier::Hostile
);
assert!("invalid".parse::<AccessTier>().is_err());
}
@@ -486,12 +495,29 @@ mod tests {
#[test]
fn situation_parse_all_values() {
let values = [
"arrival", "shift_start", "shift_end", "shift_transition", "bar_evening",
"night_shift", "investigation", "confrontation", "social", "alone",
"emergency", "routine", "observation", "greeting", "first_meeting", "repeated_visit",
"arrival",
"shift_start",
"shift_end",
"shift_transition",
"bar_evening",
"night_shift",
"investigation",
"confrontation",
"social",
"alone",
"emergency",
"routine",
"observation",
"greeting",
"first_meeting",
"repeated_visit",
];
for v in values {
assert!(v.parse::<Situation>().is_ok(), "Failed to parse situation: {}", v);
assert!(
v.parse::<Situation>().is_ok(),
"Failed to parse situation: {}",
v
);
}
assert!("invalid".parse::<Situation>().is_err());
}
@@ -499,8 +525,15 @@ mod tests {
#[test]
fn topic_parse_all_values() {
let values = [
"colleague", "routine", "cargo", "money", "trust", "danger",
"institution", "personal", "investigation",
"colleague",
"routine",
"cargo",
"money",
"trust",
"danger",
"institution",
"personal",
"investigation",
];
for v in values {
assert!(v.parse::<Topic>().is_ok(), "Failed to parse topic: {}", v);
@@ -510,8 +543,14 @@ mod tests {
#[test]
fn mood_parse_all_values() {
let values = [
"anxious", "frustrated", "content", "suspicious", "warm",
"hostile", "relieved", "focused",
"anxious",
"frustrated",
"content",
"suspicious",
"warm",
"hostile",
"relieved",
"focused",
];
for v in values {
assert!(v.parse::<Mood>().is_ok(), "Failed to parse mood: {}", v);
@@ -521,19 +560,35 @@ mod tests {
#[test]
fn trigger_parse_all_values() {
let values = [
"enter_location", "observe_npc", "hear_sound", "observe_anomaly",
"post_conversation", "discover_evidence", "witness_interaction",
"time_idle", "return_visit",
"enter_location",
"observe_npc",
"hear_sound",
"observe_anomaly",
"post_conversation",
"discover_evidence",
"witness_interaction",
"time_idle",
"return_visit",
];
for v in values {
assert!(v.parse::<Trigger>().is_ok(), "Failed to parse trigger: {}", v);
assert!(
v.parse::<Trigger>().is_ok(),
"Failed to parse trigger: {}",
v
);
}
}
#[test]
fn character_parse() {
assert_eq!("smuggler".parse::<Character>().unwrap(), Character::Smuggler);
assert_eq!("detective".parse::<Character>().unwrap(), Character::Detective);
assert_eq!(
"smuggler".parse::<Character>().unwrap(),
Character::Smuggler
);
assert_eq!(
"detective".parse::<Character>().unwrap(),
Character::Detective
);
assert!("other".parse::<Character>().is_err());
}
}
+1 -1
View File
@@ -32,9 +32,9 @@ pub mod save_state;
pub mod sound;
pub mod spatial;
pub mod stance;
pub mod ticker;
pub mod tier;
pub mod time;
pub mod ticker;
pub mod triangle;
pub mod zone;
+109 -88
View File
@@ -22,7 +22,6 @@ use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::storyteller::EngagementRecord;
/// Display duration for monologue text on client (seconds).
const DISPLAY_DURATION: f32 = 5.0;
@@ -89,10 +88,7 @@ const HEAR_SOUND_LINES: &[(&str, &str)] = &[
/// Fire when the player overhears an NPC-to-NPC conversation (D-078).
/// Future: move to content pools with trigger="witness_interaction".
const WITNESS_INTERACTION_LINES: &[(&str, &str)] = &[
(
"witness_01",
"Interesting. Wonder what that was about.",
),
("witness_01", "Interesting. Wonder what that was about."),
("witness_02", "I should remember what they just said."),
("witness_03", "They didn't know I was listening."),
];
@@ -102,10 +98,7 @@ const WITNESS_INTERACTION_LINES: &[(&str, &str)] = &[
/// Future: move to content pools with trigger="post_conversation".
const POST_CONVERSATION_LINES: &[(&str, &str)] = &[
("post_conv_01", "More questions than answers."),
(
"post_conv_02",
"I'll have to think about what they said.",
),
("post_conv_02", "I'll have to think about what they said."),
(
"post_conv_03",
"Something about that exchange didn't sit right.",
@@ -384,7 +377,10 @@ fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, Stri
"witness_interaction" => WITNESS_INTERACTION_LINES,
"post_conversation" => POST_CONVERSATION_LINES,
unknown => {
tracing::warn!("select_hardcoded_fallback: unrecognized trigger '{}', using observe_npc", unknown);
tracing::warn!(
"select_hardcoded_fallback: unrecognized trigger '{}', using observe_npc",
unknown
);
OBSERVE_NPC_LINES
}
};
@@ -469,7 +465,10 @@ pub fn trigger_event_monologue(
.unwrap_or(false)
{
Some("hear_sound")
} else if conv_buffer_opt.map(|b| !b.events.is_empty()).unwrap_or(false) {
} else if conv_buffer_opt
.map(|b| !b.events.is_empty())
.unwrap_or(false)
{
Some("witness_interaction")
} else if !post_conv_npcs.is_empty() {
Some("post_conversation")
@@ -1425,15 +1424,16 @@ mod tests {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
world.resource_mut::<SoundEventQueue>().events.push(
SoundEvent::at(
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(12, 10, 0), // distance 2 from player at (10,10)
SoundEventKind::Machinery,
0.8,
crate::knowledge::types::SoundRange::Medium,
None,
),
);
));
run_event_system(&mut world);
@@ -1450,15 +1450,16 @@ mod tests {
let mut world = setup_event_world();
let _player = spawn_event_player(&mut world);
world.resource_mut::<SoundEventQueue>().events.push(
SoundEvent::at(
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(11, 10, 0),
SoundEventKind::Footstep,
0.5,
crate::knowledge::types::SoundRange::Close,
None,
),
);
));
run_event_system(&mut world);
@@ -1476,15 +1477,16 @@ mod tests {
let _player = spawn_event_player(&mut world);
// Machinery sound at distance 20 with Close range (3 tiles)
world.resource_mut::<SoundEventQueue>().events.push(
SoundEvent::at(
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(30, 10, 0), // distance 20 from (10,10)
SoundEventKind::Machinery,
0.8,
crate::knowledge::types::SoundRange::Close,
None,
),
);
));
run_event_system(&mut world);
@@ -1551,12 +1553,11 @@ mod tests {
let npc = world.spawn_empty().id();
// Pre-fill buffer (another system wrote first)
world.get_mut::<MonologueBuffer>(player).unwrap().event =
Some(MonologueEvent {
id: "existing".to_string(),
text: "Already have something.".to_string(),
duration_seconds: 5.0,
});
world.get_mut::<MonologueBuffer>(player).unwrap().event = Some(MonologueEvent {
id: "existing".to_string(),
text: "Already have something.".to_string(),
duration_seconds: 5.0,
});
world.resource_mut::<PostConversationQueue>().push(npc);
@@ -1579,12 +1580,11 @@ mod tests {
let player = spawn_event_player(&mut world);
// Pre-fill buffer
world.get_mut::<MonologueBuffer>(player).unwrap().event =
Some(MonologueEvent {
id: "prior_line".to_string(),
text: "I was already thinking.".to_string(),
duration_seconds: 5.0,
});
world.get_mut::<MonologueBuffer>(player).unwrap().event = Some(MonologueEvent {
id: "prior_line".to_string(),
text: "I was already thinking.".to_string(),
duration_seconds: 5.0,
});
// Push observation event that would normally fire
world
@@ -1625,15 +1625,16 @@ mod tests {
observer: player,
});
world.resource_mut::<SoundEventQueue>().events.push(
SoundEvent::at(
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(11, 10, 0),
SoundEventKind::Machinery,
0.8,
crate::knowledge::types::SoundRange::Medium,
None,
),
);
));
run_event_system(&mut world);
@@ -1650,15 +1651,16 @@ mod tests {
let player = spawn_event_player(&mut world);
// Sound event
world.resource_mut::<SoundEventQueue>().events.push(
SoundEvent::at(
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(11, 10, 0),
SoundEventKind::Alert,
1.0,
crate::knowledge::types::SoundRange::Medium,
None,
),
);
));
// Conversation event
world
@@ -1739,15 +1741,16 @@ mod tests {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
world.resource_mut::<SoundEventQueue>().events.push(
SoundEvent::at(
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(10, 11, 0), // distance 1
SoundEventKind::Alert,
1.0,
crate::knowledge::types::SoundRange::Long,
None,
),
);
));
run_event_system(&mut world);
@@ -1764,15 +1767,16 @@ mod tests {
let _player = spawn_event_player(&mut world);
// Sound on z=1, player on z=0
world.resource_mut::<SoundEventQueue>().events.push(
SoundEvent::at(
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(10, 11, 1), // same xy but different z
SoundEventKind::Machinery,
0.8,
crate::knowledge::types::SoundRange::Medium,
None,
),
);
));
run_event_system(&mut world);
@@ -1814,15 +1818,16 @@ mod tests {
let _player = spawn_event_player(&mut world);
// Voice sound in range — should NOT trigger (routine background noise)
world.resource_mut::<SoundEventQueue>().events.push(
SoundEvent::at(
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(11, 10, 0), // distance 1
SoundEventKind::Voice,
0.7,
crate::knowledge::types::SoundRange::Medium,
None,
),
);
));
run_event_system(&mut world);
@@ -1840,15 +1845,16 @@ mod tests {
let _player = spawn_event_player(&mut world);
// Ambient sound in range — should NOT trigger (background atmosphere)
world.resource_mut::<SoundEventQueue>().events.push(
SoundEvent::at(
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(10, 12, 0), // distance 2
SoundEventKind::Ambient,
0.9,
crate::knowledge::types::SoundRange::Long,
None,
),
);
));
run_event_system(&mut world);
@@ -1899,12 +1905,14 @@ mod tests {
}
fn spawn_contradiction_player(world: &mut bevy_ecs::world::World) -> bevy_ecs::entity::Entity {
world.spawn((
PlayerCharacter,
TilePosition::new(0, 0, 0),
MonologueState::default(),
MonologueBuffer::default(),
)).id()
world
.spawn((
PlayerCharacter,
TilePosition::new(0, 0, 0),
MonologueState::default(),
MonologueBuffer::default(),
))
.id()
}
#[test]
@@ -1966,11 +1974,14 @@ mod tests {
let player = spawn_contradiction_player(&mut world);
// Pre-fill buffer with a higher-priority monologue
world.get_mut::<MonologueBuffer>(player).unwrap().set(MonologueEvent {
id: "prior_event".to_string(),
text: "Something already fired.".to_string(),
duration_seconds: 5.0,
});
world
.get_mut::<MonologueBuffer>(player)
.unwrap()
.set(MonologueEvent {
id: "prior_event".to_string(),
text: "Something already fired.".to_string(),
duration_seconds: 5.0,
});
world.resource_mut::<ContradictionDetectedQueue>().push(
crate::knowledge::ContradictionDetectedEvent {
@@ -1995,7 +2006,10 @@ mod tests {
// Buffer should still have the prior event
let buf = world.get::<MonologueBuffer>(player).unwrap();
let event = buf.event.as_ref().unwrap();
assert_eq!(event.id, "prior_event", "prior monologue should not be overridden");
assert_eq!(
event.id, "prior_event",
"prior monologue should not be overridden"
);
// Queue should have been drained regardless
assert!(
@@ -2049,15 +2063,15 @@ mod tests {
// Tick T3: process_contradiction_monologue fires monologue with Sera/Kael names
//
// Tests the full D-083 event chain end-to-end.
use crate::knowledge::{
EntityRegistry, KnowledgeGraph, KnowledgeEventQueue, KnowledgeEventType,
};
use crate::knowledge::events::{process_knowledge_events, KnowledgeEvent};
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::{
EntityKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState,
RelationshipState, StableId,
};
use crate::knowledge::{
EntityRegistry, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
};
use std::collections::BTreeMap;
let mut world = bevy_ecs::world::World::new();
@@ -2070,10 +2084,9 @@ mod tests {
// Spawn NPCs with NpcName components
let sera_entity = world.spawn(NpcName("Sera".to_string())).id();
let kael_entity = world.spawn((
NpcName("Kael".to_string()),
TilePosition::new(10, 10, 0),
)).id();
let kael_entity = world
.spawn((NpcName("Kael".to_string()), TilePosition::new(10, 10, 0)))
.id();
let sera_sid = registry.register(sera_entity);
let kael_sid = registry.register(kael_entity);
@@ -2100,14 +2113,16 @@ mod tests {
},
);
let player = world.spawn((
PlayerCharacter,
TilePosition::new(0, 0, 0),
MonologueState::default(),
MonologueBuffer::default(),
player_kg,
StableEntityId(StableId(999)),
)).id();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(0, 0, 0),
MonologueState::default(),
MonologueBuffer::default(),
player_kg,
StableEntityId(StableId(999)),
))
.id();
registry.register(player);
world.insert_resource(registry);
@@ -2156,13 +2171,19 @@ mod tests {
{
// Drain to inspect event contents, then re-push for the monologue consumer.
let mut events = world.resource_mut::<ContradictionDetectedQueue>().drain();
assert_eq!(events.len(), 1, "should have exactly one contradiction event");
assert_eq!(
events.len(),
1,
"should have exactly one contradiction event"
);
let event = &events[0];
assert_eq!(event.source_display_name, "Sera");
assert_eq!(event.subject_display_name, "Kael");
// Re-push so process_contradiction_monologue can consume it on T3.
let event = events.remove(0);
world.resource_mut::<ContradictionDetectedQueue>().push(event);
world
.resource_mut::<ContradictionDetectedQueue>()
.push(event);
}
// Tick T3: Run process_contradiction_monologue
+4 -4
View File
@@ -434,15 +434,15 @@ pub fn validate_movement(
Some(MovementStance::Careful) => 0.3,
Some(MovementStance::Crouch) => 0.15,
};
commands.entity(entity).insert(SoundEventEmitter::new(
SoundEvent::at(
commands
.entity(entity)
.insert(SoundEventEmitter::new(SoundEvent::at(
&target,
SoundEventKind::Footstep,
intensity,
SoundRange::Close,
None,
),
));
)));
}
commands.entity(entity).remove::<MoveIntent>();
}
+62 -47
View File
@@ -14,6 +14,7 @@
use bevy_ecs::prelude::*;
use rand::Rng;
use std::cmp::Reverse;
use std::collections::BTreeMap;
use crate::knowledge::registry::StableEntityId;
@@ -30,8 +31,8 @@ use crate::npc::Npc;
use crate::simulation::conversation::NpcConversation;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
// ---------------------------------------------------------------------------
// Constants
@@ -123,7 +124,12 @@ pub fn transfer_npc_knowledge(
mut event_queue: ResMut<KnowledgeEventQueue>,
// NPCs that just started a conversation — Added fires once per conversation.
new_conv_query: Query<
(Entity, &NpcConversation, &TilePosition, Option<&StableEntityId>),
(
Entity,
&NpcConversation,
&TilePosition,
Option<&StableEntityId>,
),
(With<Npc>, With<ActiveSim>, Added<NpcConversation>),
>,
// Read-only StableEntityId on NPC partner (distinct query, no KG conflict).
@@ -267,7 +273,7 @@ pub fn transfer_npc_knowledge(
}
// Sort by most recently updated (deterministic: descending tick, stable by BTreeMap key order)
candidates.sort_by(|a, b| b.sort_key().cmp(&a.sort_key()));
candidates.sort_by_key(|c| Reverse(c.sort_key()));
// Take top 13 entries by recency (random count, deterministic selection).
// The random element is HOW MANY facts transfer, not WHICH ones.
@@ -327,13 +333,10 @@ pub fn transfer_npc_knowledge(
let capped = ek.confidence.min(KnowledgeConfidence::KnowsOf);
// Preserve existing relationship state if the partner already knows this entity.
let (should_write, existing_relationship) =
match partner_kg.entities.get(&id) {
None => (true, RelationshipState::Unknown),
Some(existing) => {
(existing.confidence < capped, existing.relationship)
}
};
let (should_write, existing_relationship) = match partner_kg.entities.get(&id) {
None => (true, RelationshipState::Unknown),
Some(existing) => (existing.confidence < capped, existing.relationship),
};
if should_write {
partner_kg.entities.insert(
@@ -438,8 +441,8 @@ mod tests {
use crate::simulation::conversation::NpcConversation;
use crate::simulation::movement::TilePosition;
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
fn build_test_world() -> App {
let mut app = App::new();
@@ -551,12 +554,14 @@ mod tests {
}
// Start conversation — tick 0, so started_tick == 0
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.world_mut()
.entity_mut(entity_a)
.insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
@@ -599,12 +604,14 @@ mod tests {
);
}
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.world_mut()
.entity_mut(entity_a)
.insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
@@ -645,12 +652,14 @@ mod tests {
);
}
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.world_mut()
.entity_mut(entity_a)
.insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
@@ -703,12 +712,14 @@ mod tests {
);
}
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.world_mut()
.entity_mut(entity_a)
.insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
@@ -750,12 +761,14 @@ mod tests {
);
}
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.world_mut()
.entity_mut(entity_a)
.insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
@@ -815,12 +828,14 @@ mod tests {
);
}
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.world_mut()
.entity_mut(entity_a)
.insert(NpcConversation {
partner: entity_b,
started_tick: 0,
end_tick: 100,
ticks_since_last_line: 0,
});
app.update();
+4 -1
View File
@@ -56,7 +56,10 @@ impl MovementSpeed {
#[tracing::instrument(level = "debug", skip_all)]
pub fn follow_paths(
mut commands: Commands,
mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), (With<Npc>, With<ActiveSim>)>,
mut query: Query<
(Entity, &mut ComputedPath, Option<&mut MovementSpeed>),
(With<Npc>, With<ActiveSim>),
>,
) {
for (entity, mut path, speed_opt) in query.iter_mut() {
if let Some(mut speed) = speed_opt {
+30 -8
View File
@@ -115,21 +115,41 @@ mod tests {
#[test]
fn fact_id_uses_poi_namespace() {
let poi = make_poi("docking_bay_7", PoiCategory::Location, PoiVisibility::LineOfSight);
let poi = make_poi(
"docking_bay_7",
PoiCategory::Location,
PoiVisibility::LineOfSight,
);
assert_eq!(poi.fact_id(), FactId("poi.docking_bay_7".to_string()));
}
#[test]
fn fact_id_format_is_deterministic() {
let poi1 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly);
let poi2 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly);
let poi1 = make_poi(
"cargo_hold",
PoiCategory::Hidden,
PoiVisibility::KnowledgeOnly,
);
let poi2 = make_poi(
"cargo_hold",
PoiCategory::Hidden,
PoiVisibility::KnowledgeOnly,
);
assert_eq!(poi1.fact_id(), poi2.fact_id());
}
#[test]
fn different_poi_ids_produce_different_fact_ids() {
let poi1 = make_poi("bay_alpha", PoiCategory::Location, PoiVisibility::LineOfSight);
let poi2 = make_poi("bay_beta", PoiCategory::Location, PoiVisibility::LineOfSight);
let poi1 = make_poi(
"bay_alpha",
PoiCategory::Location,
PoiVisibility::LineOfSight,
);
let poi2 = make_poi(
"bay_beta",
PoiCategory::Location,
PoiVisibility::LineOfSight,
);
assert_ne!(poi1.fact_id(), poi2.fact_id());
}
@@ -172,7 +192,10 @@ mod tests {
#[test]
fn poi_discovery_sources_are_distinct() {
assert_ne!(PoiDiscoverySource::MapTemplate, PoiDiscoverySource::Procedural);
assert_ne!(
PoiDiscoverySource::MapTemplate,
PoiDiscoverySource::Procedural
);
assert_ne!(
PoiDiscoverySource::QuestGenerated,
PoiDiscoverySource::NpcRevealed
@@ -183,8 +206,7 @@ mod tests {
fn poi_serialization_roundtrip() {
let poi = make_poi("med_bay", PoiCategory::Service, PoiVisibility::LineOfSight);
let serialized = serde_yaml::to_string(&poi).expect("serialize");
let deserialized: PointOfInterest =
serde_yaml::from_str(&serialized).expect("deserialize");
let deserialized: PointOfInterest = serde_yaml::from_str(&serialized).expect("deserialize");
assert_eq!(deserialized.poi_id, "med_bay");
assert_eq!(deserialized.category, PoiCategory::Service);
}
+20 -9
View File
@@ -152,11 +152,7 @@ mod tests {
use crate::simulation::poi::{PoiCategory, PoiDiscoverySource};
use std::collections::BTreeSet;
fn make_poi(
id: &str,
position: TilePosition,
visibility: PoiVisibility,
) -> PointOfInterest {
fn make_poi(id: &str, position: TilePosition, visibility: PoiVisibility) -> PointOfInterest {
PointOfInterest {
poi_id: id.to_string(),
name: format!("Test {}", id),
@@ -181,7 +177,11 @@ mod tests {
#[test]
fn los_poi_discovered_when_in_visible_positions() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight);
let poi = make_poi(
"bay",
TilePosition::new(10, 5, 0),
PoiVisibility::LineOfSight,
);
let geometry = make_geometry(&[(10, 5)], 0);
let kg = KnowledgeGraph::new();
@@ -191,7 +191,11 @@ mod tests {
#[test]
fn los_poi_not_discovered_when_not_visible() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight);
let poi = make_poi(
"bay",
TilePosition::new(10, 5, 0),
PoiVisibility::LineOfSight,
);
let geometry = make_geometry(&[(8, 5)], 0); // (10,5) not in visible set
let kg = KnowledgeGraph::new();
@@ -201,7 +205,11 @@ mod tests {
#[test]
fn los_poi_not_discovered_on_different_z() {
let observer_pos = TilePosition::new(5, 5, 0);
let poi = make_poi("bay", TilePosition::new(10, 5, 1), PoiVisibility::LineOfSight);
let poi = make_poi(
"bay",
TilePosition::new(10, 5, 1),
PoiVisibility::LineOfSight,
);
let geometry = make_geometry(&[(10, 5)], 0); // observer on z=0, poi on z=1
let kg = KnowledgeGraph::new();
@@ -371,7 +379,10 @@ mod tests {
let mut query = world.query_filtered::<&KnowledgeGraph, With<PlayerCharacter>>();
let kg = query.single(&world).expect("player should exist");
let fact_id = FactId("poi.docking_bay".to_string());
assert!(kg.knows_fact(&fact_id), "Player should know poi.docking_bay");
assert!(
kg.knows_fact(&fact_id),
"Player should know poi.docking_bay"
);
assert_eq!(
kg.facts.get(&fact_id).unwrap().confidence,
KnowledgeConfidence::KnowsOf
+7 -8
View File
@@ -163,7 +163,7 @@ pub fn update_character_pressure(
mut player_query: Query<(Entity, &mut CharacterPressure), With<PlayerCharacter>>,
) {
// Only run on interval ticks
if time.tick % PRESSURE_UPDATE_INTERVAL != 0 {
if !time.tick.is_multiple_of(PRESSURE_UPDATE_INTERVAL) {
return;
}
@@ -210,8 +210,8 @@ mod tests {
use crate::npc::relationships::{RelationshipEdge, RelationshipGraph};
use crate::npc::{Npc, RelationshipKind};
use crate::simulation::movement::PlayerCharacter;
use crate::simulation::time::SimulationTime;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
use bevy_ecs::world::World;
fn setup_world() -> World {
@@ -251,7 +251,10 @@ mod tests {
let mut q = world.query::<&CharacterPressure>();
let pressure = q.single(&world).unwrap();
assert_eq!(pressure.exposure, 0, "should not update on non-interval tick");
assert_eq!(
pressure.exposure, 0,
"should not update on non-interval tick"
);
}
#[test]
@@ -301,11 +304,7 @@ mod tests {
}
// 2 non-suspicious NPCs
for _ in 0..2 {
world.spawn((
Npc,
ActiveSim,
PlayerAwareness::default(),
));
world.spawn((Npc, ActiveSim, PlayerAwareness::default()));
}
world.spawn((PlayerCharacter, CharacterPressure::default()));
+27 -15
View File
@@ -16,21 +16,21 @@ use thiserror::Error;
use crate::bridge::types::SaveLoadResultWire;
use crate::bridge::types::SnapshotBuffer;
use crate::simulation::triangle::{TemplateReferenceMap, TriangleState};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::Npc;
use crate::knowledge::types::StableId;
use crate::npc::relationships::RelationshipGraph;
use crate::npc::Npc;
use crate::simulation::interaction::DoorState;
use crate::simulation::movement::PlayerCharacter;
use crate::simulation::rng::SimRng;
use crate::simulation::save_state::{
deserialize_npc_from_frozen, serialize_npc_to_frozen, SaveStateV1, SAVE_FORMAT_VERSION,
};
use crate::knowledge::types::StableId;
use crate::simulation::interaction::DoorState;
use crate::simulation::tier::{ActiveSim, BackgroundSim};
use crate::simulation::time::SimulationTime;
use crate::simulation::triangle::TriangleCrisisEventQueue;
use crate::simulation::triangle::{TemplateReferenceMap, TriangleState};
use crate::storyteller::{
ActivationState, ContaminationActive, ContaminationEventQueue, MovementHistoryBuffer,
TriangleActivatedQueue,
@@ -93,7 +93,9 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
let player_knowledge = {
let mut q = world.query_filtered::<&KnowledgeGraph, With<PlayerCharacter>>();
q.single(world).cloned().unwrap_or_else(|_| {
tracing::warn!("save_to_file: no PlayerCharacter with KnowledgeGraph found — saving empty graph");
tracing::warn!(
"save_to_file: no PlayerCharacter with KnowledgeGraph found — saving empty graph"
);
KnowledgeGraph::new()
})
};
@@ -151,7 +153,7 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
modifications: vec![], // TODO: persist when modification system is implemented
contamination_active: world
.get_resource::<ContaminationActive>()
.map_or(false, |c| c.0),
.is_some_and(|c| c.0),
activated_count: world
.get_resource::<ActivationState>()
.map_or(0, |a| a.activated_count),
@@ -294,7 +296,11 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro
if !state.open_doors.is_empty() {
let open_set: std::collections::BTreeSet<_> = state.open_doors.iter().copied().collect();
let door_entities: Vec<(Entity, StableId)> = {
let mut q = world.query::<(Entity, &crate::knowledge::registry::StableEntityId, &DoorState)>();
let mut q = world.query::<(
Entity,
&crate::knowledge::registry::StableEntityId,
&DoorState,
)>();
q.iter(world)
.filter(|(_, sid, _)| open_set.contains(&sid.0))
.map(|(e, sid, _)| (e, sid.0))
@@ -304,7 +310,9 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro
if let Some(mut door) = world.get_mut::<DoorState>(entity) {
door.is_open = true;
let tile = door.blocking_tile;
if let Some(mut wmap) = world.get_resource_mut::<crate::simulation::movement::WalkabilityMap>() {
if let Some(mut wmap) =
world.get_resource_mut::<crate::simulation::movement::WalkabilityMap>()
{
wmap.set_walkable(&tile, true);
}
tracing::debug!(stable_id = sid.0, "load: restored open door state");
@@ -395,8 +403,8 @@ mod tests {
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::knowledge::types::StableId;
use crate::npc::Npc;
use crate::npc::relationships::RelationshipGraph;
use crate::npc::Npc;
use crate::simulation::movement::TilePosition;
use crate::simulation::rng::SimRng;
use crate::simulation::save_state::{SaveStateV1, SAVE_FORMAT_VERSION};
@@ -467,7 +475,11 @@ mod tests {
let bytes = std::fs::read(&path).expect("read saved file");
let state = SaveStateV1::from_bytes(&bytes).unwrap();
let ids: Vec<u64> = state.npc_states.iter().map(|n| n.stable_id.0).collect();
assert_eq!(ids, vec![10, 30, 50], "npc_states must be sorted by stable_id");
assert_eq!(
ids,
vec![10, 30, 50],
"npc_states must be sorted by stable_id"
);
let _ = std::fs::remove_file(&path);
}
@@ -651,10 +663,7 @@ mod tests {
let mut q = world.query_filtered::<Entity, (With<Npc>, With<BackgroundSim>)>();
q.iter(&world).count() > 0
};
assert!(
has_background,
"loaded NPC should be in BackgroundSim tier"
);
assert!(has_background, "loaded NPC should be in BackgroundSim tier");
let _ = std::fs::remove_file(&path);
}
@@ -903,7 +912,10 @@ mod tests {
// 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());
assert!(world
.resource::<ActivationState>()
.last_activation_tick
.is_none());
load_from_file(&path, &mut world).expect("load");
+53 -30
View File
@@ -39,22 +39,22 @@ use bevy_ecs::entity::Entity;
use bevy_ecs::world::World;
use serde::{Deserialize, Serialize};
use crate::simulation::triangle::{TemplateOwnership, TemplateReferenceMap, TriangleState};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::StableId;
use crate::simulation::modification::Modification;
use crate::npc::awareness::PlayerAwareness;
use crate::npc::mood::MoodState;
use crate::npc::relationships::RelationshipGraph;
use crate::npc::vision::{NpcMemory, NpcVisionState};
use crate::npc::{
CombatCapability, Contentment, DailyRoutine, InformationInventory, JobPerformance, Npc,
PersonalityTraits, Relationships, Secret, SecretSeverity, SkillSet, TellSystem,
ToleranceThreshold, Want, WantKind,
};
use crate::npc::awareness::PlayerAwareness;
use crate::npc::mood::MoodState;
use crate::npc::relationships::RelationshipGraph;
use crate::npc::vision::{NpcMemory, NpcVisionState};
use crate::simulation::modification::Modification;
use crate::simulation::movement::TilePosition;
use crate::simulation::time::TickRate;
use crate::simulation::triangle::{TemplateOwnership, TemplateReferenceMap, TriangleState};
use crate::storyteller::EngagementRecord;
/// Current format version. Bump on any breaking schema change.
@@ -181,7 +181,6 @@ pub struct NpcSaveState {
// --- Full reconstruction fields (added #96, for tier eviction freeze) ---
// All fields below use serde(default) for backward compatibility with saves
// created before #96 shipped.
/// Axis 1: Want (primary drive, intensity, and description).
#[serde(default)]
pub want: Option<Want>,
@@ -350,10 +349,7 @@ pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> E
level: state.contentment,
};
let kg = state
.knowledge_graph
.clone()
.unwrap_or_else(KnowledgeGraph::new);
let kg = state.knowledge_graph.clone().unwrap_or_default();
// Spawn the entity with all required components. Tier marker (ActiveSim /
// BackgroundSim) is NOT added here — the caller assigns it after registration.
@@ -579,17 +575,14 @@ mod tests {
// Roundtrip the recovered state again — bytes must still match
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(
bytes, bytes2,
"KnowledgeGraph roundtrip must be idempotent"
);
assert_eq!(bytes, bytes2, "KnowledgeGraph roundtrip must be idempotent");
}
#[test]
fn relationship_graph_roundtrips() {
use crate::knowledge::types::StableId;
use crate::npc::relationships::RelationshipEdge;
use crate::npc::RelationshipKind;
use crate::knowledge::types::StableId;
let mut state = minimal_save_state();
let mut rg = RelationshipGraph::new();
@@ -608,7 +601,10 @@ mod tests {
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(bytes, bytes2, "RelationshipGraph roundtrip must be lossless");
assert_eq!(
bytes, bytes2,
"RelationshipGraph roundtrip must be lossless"
);
}
#[test]
@@ -660,12 +656,12 @@ mod tests {
// -----------------------------------------------------------------------
fn spawn_minimal_npc(world: &mut World, stable_id: StableId) -> Entity {
use crate::npc::awareness::PlayerAwareness;
use crate::npc::mood::MoodState;
use crate::npc::vision::{NpcMemory, NpcVisionState};
use crate::npc::{
Contentment, Relationships, Secret, SecretSeverity, ToleranceThreshold, Want, WantKind,
};
use crate::npc::mood::MoodState;
use crate::npc::vision::{NpcMemory, NpcVisionState};
use crate::npc::awareness::PlayerAwareness;
world
.spawn((
@@ -763,13 +759,22 @@ mod tests {
// Want
let orig_want = world.get::<Want>(original).cloned().unwrap();
let rest_want = world.get::<Want>(restored).cloned().unwrap();
assert_eq!(orig_want.primary, rest_want.primary, "want.primary must match");
assert_eq!(orig_want.intensity, rest_want.intensity, "want.intensity must match");
assert_eq!(
orig_want.primary, rest_want.primary,
"want.primary must match"
);
assert_eq!(
orig_want.intensity, rest_want.intensity,
"want.intensity must match"
);
// Secret severity
let orig_secret = world.get::<Secret>(original).cloned().unwrap();
let rest_secret = world.get::<Secret>(restored).cloned().unwrap();
assert_eq!(orig_secret.severity, rest_secret.severity, "secret severity must match");
assert_eq!(
orig_secret.severity, rest_secret.severity,
"secret severity must match"
);
}
#[test]
@@ -806,15 +811,24 @@ mod tests {
assert!(world.get::<StableEntityId>(entity).is_some());
assert!(world.get::<ToleranceThreshold>(entity).is_some());
assert!(world.get::<Contentment>(entity).is_some());
assert!(world.get::<Want>(entity).is_some(), "Want defaults to Safety");
assert!(world.get::<Secret>(entity).is_some(), "Secret built from secret_severity");
assert!(
world.get::<Want>(entity).is_some(),
"Want defaults to Safety"
);
assert!(
world.get::<Secret>(entity).is_some(),
"Secret built from secret_severity"
);
// Secret severity must be preserved from the legacy field
let secret = world.get::<Secret>(entity).unwrap();
assert_eq!(secret.severity, SecretSeverity::Moderate);
// Optional axes absent in frozen state → not inserted or use defaults
assert!(world.get::<DailyRoutine>(entity).is_none(), "routine absent when not frozen");
assert!(
world.get::<DailyRoutine>(entity).is_none(),
"routine absent when not frozen"
);
}
#[test]
@@ -845,7 +859,10 @@ mod tests {
let bytes = save.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(bytes, bytes2, "frozen NPC state must roundtrip via MessagePack");
assert_eq!(
bytes, bytes2,
"frozen NPC state must roundtrip via MessagePack"
);
}
#[test]
@@ -885,7 +902,7 @@ mod tests {
#[test]
fn populated_modifications_roundtrips_in_save_state() {
// Acceptance (#567): non-empty modifications field survives save/load.
use crate::simulation::modification::{ModificationType, Modification};
use crate::simulation::modification::{Modification, ModificationType};
let mut state = minimal_save_state();
state.modifications = vec![
@@ -908,9 +925,15 @@ mod tests {
2,
"two modifications must survive roundtrip"
);
assert_eq!(recovered.modifications[0].position, TilePosition::new(10, 20, 0));
assert_eq!(
recovered.modifications[0].position,
TilePosition::new(10, 20, 0)
);
assert_eq!(recovered.modifications[0].placed_at_tick, 500);
assert_eq!(recovered.modifications[1].position, TilePosition::new(3, 7, -1));
assert_eq!(
recovered.modifications[1].position,
TilePosition::new(3, 7, -1)
);
assert_eq!(recovered.modifications[1].placed_at_tick, 1200);
let bytes2 = recovered.to_bytes().expect("re-serialize");
+11 -10
View File
@@ -169,7 +169,7 @@ pub fn collect_sound_events(
) {
queue.events.clear();
for (entity, mut emitter) in emitters.iter_mut() {
queue.events.extend(emitter.pending.drain(..));
queue.events.append(&mut emitter.pending);
commands.entity(entity).remove::<SoundEventEmitter>();
}
}
@@ -221,9 +221,7 @@ mod tests {
world.insert_resource(SoundEventQueue::default());
let pos = tile(5, 5);
let _entity = world
.spawn(SoundEventEmitter::new(close_event(&pos)))
.id();
let _entity = world.spawn(SoundEventEmitter::new(close_event(&pos))).id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(collect_sound_events);
@@ -337,8 +335,7 @@ mod tests {
#[test]
fn long_range_sound_audible_within_20_tiles() {
let source = tile(0, 0);
let event =
SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None);
let event = SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None);
// Manhattan distance 20 — exactly at boundary
let listener = tile(10, 10);
@@ -351,8 +348,7 @@ mod tests {
#[test]
fn long_range_sound_not_audible_beyond_20_tiles() {
let source = tile(0, 0);
let event =
SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None);
let event = SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None);
let listener = tile(11, 10); // manhattan 21
assert!(
@@ -364,8 +360,13 @@ mod tests {
#[test]
fn long_range_audible_at_origin() {
let source = tile(5, 5);
let event =
SoundEvent::at(&source, SoundEventKind::Machinery, 0.5, SoundRange::Long, None);
let event = SoundEvent::at(
&source,
SoundEventKind::Machinery,
0.5,
SoundRange::Long,
None,
);
assert!(event.audible_at(&source), "audible at source position");
}
+30 -9
View File
@@ -173,7 +173,10 @@ mod tests {
index.update(e_near, TilePosition::new(5, 6, 0));
let in_range = index.entities_in_range(&center, 2);
assert!(!in_range.contains(&e_at), "entity at center should be excluded");
assert!(
!in_range.contains(&e_at),
"entity at center should be excluded"
);
assert!(in_range.contains(&e_near));
}
@@ -331,7 +334,10 @@ mod tests {
let in_range = index.entities_in_range(&center, 1);
assert!(in_range.contains(&e_dist1_x));
assert!(in_range.contains(&e_dist1_y));
assert!(!in_range.contains(&e_dist2), "distance 2 must not appear in radius-1 result");
assert!(
!in_range.contains(&e_dist2),
"distance 2 must not appear in radius-1 result"
);
}
/// Diagonal: Manhattan distance covers all 4 orthogonal directions.
@@ -348,9 +354,9 @@ mod tests {
let center = TilePosition::new(10, 10, 0);
index.update(north, TilePosition::new(10, 11, 0)); // distance 1
index.update(south, TilePosition::new(10, 9, 0)); // distance 1
index.update(east, TilePosition::new(11, 10, 0)); // distance 1
index.update(west, TilePosition::new(9, 10, 0)); // distance 1
index.update(south, TilePosition::new(10, 9, 0)); // distance 1
index.update(east, TilePosition::new(11, 10, 0)); // distance 1
index.update(west, TilePosition::new(9, 10, 0)); // distance 1
index.update(corner, TilePosition::new(11, 11, 0)); // distance 2
let in_range = index.entities_in_range(&center, 2);
@@ -425,7 +431,11 @@ mod tests {
}
let in_range = index.entities_in_range(&center, 200);
assert_eq!(in_range.len(), 10, "radius 200 should include all 10 entities");
assert_eq!(
in_range.len(),
10,
"radius 200 should include all 10 entities"
);
}
/// Remove one entity from a multi-entity index, others remain.
@@ -500,7 +510,11 @@ mod tests {
{
let idx = world.resource::<NaiveSpatialIndex>();
assert_eq!(idx.entities_at(&start).len(), 1, "entity should be at start after first sync");
assert_eq!(
idx.entities_at(&start).len(),
1,
"entity should be at start after first sync"
);
}
// Update position
@@ -511,8 +525,15 @@ mod tests {
{
let idx = world.resource::<NaiveSpatialIndex>();
assert!(idx.entities_at(&start).is_empty(), "old position should be cleared");
assert_eq!(idx.entities_at(&dest).len(), 1, "entity should be at new position");
assert!(
idx.entities_at(&start).is_empty(),
"old position should be cleared"
);
assert_eq!(
idx.entities_at(&dest).len(),
1,
"entity should be at new position"
);
}
}
}
+150 -71
View File
@@ -4,8 +4,8 @@
// Scope tag system: NPCs with active scope tags stay pinned to ActiveSim (#98).
// Timestamp-based eviction: LRU eviction when ActiveSim exceeds capacity (#97).
use std::collections::{BTreeSet, BinaryHeap};
use std::cmp::Reverse;
use std::collections::{BTreeSet, BinaryHeap};
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
@@ -14,8 +14,8 @@ use serde::{Deserialize, Serialize};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::KnowledgeConfidence;
use crate::npc::{Npc, RelationshipKind};
use crate::npc::relationships::RelationshipGraph;
use crate::npc::{Npc, RelationshipKind};
use crate::simulation::movement::{PlayerCharacter, TilePosition};
// --- Tier radius constants (D-026) ---
@@ -238,7 +238,10 @@ pub fn assign_scope_tags(
.relationships_of(&player_id)
.into_iter()
.filter(|(_, edge)| {
matches!(edge.kind, RelationshipKind::Friend | RelationshipKind::Colleague)
matches!(
edge.kind,
RelationshipKind::Friend | RelationshipKind::Colleague
)
})
.map(|(target_id, _)| *target_id)
.collect();
@@ -324,19 +327,17 @@ pub fn update_last_interaction_tick(
// Update existing LastInteractionTick for visible NPCs.
for (pos, mut last_tick) in &mut npcs_with_tick {
if pos.z == vis_geo.observer_z
&& vis_geo.visible_positions.contains(&(pos.x, pos.y))
{
if pos.z == vis_geo.observer_z && vis_geo.visible_positions.contains(&(pos.x, pos.y)) {
last_tick.0 = current_tick;
}
}
// Insert LastInteractionTick for NPCs that don't have it yet but are visible.
for (entity, pos) in &npcs_without_tick {
if pos.z == vis_geo.observer_z
&& vis_geo.visible_positions.contains(&(pos.x, pos.y))
{
commands.entity(entity).insert(LastInteractionTick(current_tick));
if pos.z == vis_geo.observer_z && vis_geo.visible_positions.contains(&(pos.x, pos.y)) {
commands
.entity(entity)
.insert(LastInteractionTick(current_tick));
}
}
}
@@ -392,9 +393,15 @@ pub fn evict_excess_active(
let dist = tile_distance(player_pos, &pos);
if dist > BACKGROUND_RADIUS {
commands.entity(entity).remove::<ActiveSim>().insert(StateSaved);
commands
.entity(entity)
.remove::<ActiveSim>()
.insert(StateSaved);
} else {
commands.entity(entity).remove::<ActiveSim>().insert(BackgroundSim);
commands
.entity(entity)
.remove::<ActiveSim>()
.insert(BackgroundSim);
}
evicted += 1;
}
@@ -517,8 +524,7 @@ mod tests {
let background = world.spawn(BackgroundSim).id();
let _state_saved = world.spawn(StateSaved).id();
let mut query =
world.query_filtered::<bevy_ecs::entity::Entity, With<BackgroundSim>>();
let mut query = world.query_filtered::<bevy_ecs::entity::Entity, With<BackgroundSim>>();
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
assert_eq!(results.len(), 1, "only one BackgroundSim entity expected");
@@ -532,8 +538,7 @@ mod tests {
let _background = world.spawn(BackgroundSim).id();
let state_saved = world.spawn(StateSaved).id();
let mut query =
world.query_filtered::<bevy_ecs::entity::Entity, With<StateSaved>>();
let mut query = world.query_filtered::<bevy_ecs::entity::Entity, With<StateSaved>>();
let results: Vec<bevy_ecs::entity::Entity> = query.iter(&world).collect();
assert_eq!(results.len(), 1, "only one StateSaved entity expected");
@@ -610,11 +615,13 @@ mod tests {
world.get::<BackgroundSim>(entity).is_some(),
"BackgroundSim added"
);
assert!(world.get::<ActiveSim>(entity).is_none(), "ActiveSim removed");
assert!(
world.get::<ActiveSim>(entity).is_none(),
"ActiveSim removed"
);
// Must NOT appear in ActiveSim query after demotion
let mut active_query =
world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
let mut active_query = world.query_filtered::<bevy_ecs::entity::Entity, With<ActiveSim>>();
assert_eq!(
active_query.iter(&world).count(),
0,
@@ -686,7 +693,10 @@ mod tests {
let npc = world.spawn((ActiveSim, make_pos(60, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<ActiveSim>(npc).is_none(), "ActiveSim removed");
assert!(world.get::<BackgroundSim>(npc).is_some(), "BackgroundSim added");
assert!(
world.get::<BackgroundSim>(npc).is_some(),
"BackgroundSim added"
);
}
#[test]
@@ -707,7 +717,10 @@ mod tests {
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((BackgroundSim, make_pos(20, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<BackgroundSim>(npc).is_none(), "BackgroundSim removed");
assert!(
world.get::<BackgroundSim>(npc).is_none(),
"BackgroundSim removed"
);
assert!(world.get::<ActiveSim>(npc).is_some(), "ActiveSim added");
}
@@ -718,7 +731,10 @@ mod tests {
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((BackgroundSim, make_pos(200, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<BackgroundSim>(npc).is_none(), "BackgroundSim removed");
assert!(
world.get::<BackgroundSim>(npc).is_none(),
"BackgroundSim removed"
);
assert!(world.get::<StateSaved>(npc).is_some(), "StateSaved added");
}
@@ -741,7 +757,10 @@ mod tests {
let npc = world.spawn((StateSaved, make_pos(80, 0))).id();
run_tier_update(&mut world);
assert!(world.get::<StateSaved>(npc).is_none(), "StateSaved removed");
assert!(world.get::<BackgroundSim>(npc).is_some(), "BackgroundSim added");
assert!(
world.get::<BackgroundSim>(npc).is_some(),
"BackgroundSim added"
);
}
#[test]
@@ -761,13 +780,14 @@ mod tests {
let mut world = World::new();
world.spawn((PlayerCharacter, TilePosition::new(0, 0, 0)));
// Spawn as ActiveSim at same x/y but different floor
let npc = world
.spawn((ActiveSim, TilePosition::new(0, 0, 1)))
.id();
let npc = world.spawn((ActiveSim, TilePosition::new(0, 0, 1))).id();
run_tier_update(&mut world);
// Should demote: u32::MAX > BACKGROUND_RADIUS → StateSaved
assert!(world.get::<ActiveSim>(npc).is_none(), "ActiveSim removed");
assert!(world.get::<StateSaved>(npc).is_some(), "StateSaved due to z-distance");
assert!(
world.get::<StateSaved>(npc).is_some(),
"StateSaved due to z-distance"
);
}
#[test]
@@ -775,18 +795,28 @@ mod tests {
// Distance = ACTIVE_RADIUS exactly → should stay Active (threshold is >)
let mut world = World::new();
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((ActiveSim, make_pos(ACTIVE_RADIUS as i32, 0))).id();
let npc = world
.spawn((ActiveSim, make_pos(ACTIVE_RADIUS as i32, 0)))
.id();
run_tier_update(&mut world);
assert!(world.get::<ActiveSim>(npc).is_some(), "stays Active at exact boundary");
assert!(
world.get::<ActiveSim>(npc).is_some(),
"stays Active at exact boundary"
);
}
#[test]
fn npc_one_tile_beyond_active_radius_demotes() {
let mut world = World::new();
world.spawn((PlayerCharacter, make_pos(0, 0)));
let npc = world.spawn((ActiveSim, make_pos(ACTIVE_RADIUS as i32 + 1, 0))).id();
let npc = world
.spawn((ActiveSim, make_pos(ACTIVE_RADIUS as i32 + 1, 0)))
.id();
run_tier_update(&mut world);
assert!(world.get::<ActiveSim>(npc).is_none(), "demoted to Background");
assert!(
world.get::<ActiveSim>(npc).is_none(),
"demoted to Background"
);
assert!(world.get::<BackgroundSim>(npc).is_some());
}
@@ -934,7 +964,9 @@ mod tests {
world.init_resource::<RelationshipGraph>();
// NPC exists but no PlayerCharacter
let npc = world.spawn((Npc, StableEntityId(crate::knowledge::types::StableId(1)))).id();
let npc = world
.spawn((Npc, StableEntityId(crate::knowledge::types::StableId(1))))
.id();
run_assign_scope_tags(&mut world);
@@ -970,7 +1002,9 @@ mod tests {
run_assign_scope_tags(&mut world);
let scope_tag = world.get::<ScopeTag>(npc).expect("ScopeTag should be assigned");
let scope_tag = world
.get::<ScopeTag>(npc)
.expect("ScopeTag should be assigned");
assert!(
scope_tag.contains(ScopeTagKind::KnownContact),
"NPC known at KnowsOf level should get KnownContact tag"
@@ -1017,7 +1051,9 @@ mod tests {
run_assign_scope_tags(&mut world);
let scope_tag = world.get::<ScopeTag>(npc).expect("ScopeTag assigned for colleague");
let scope_tag = world
.get::<ScopeTag>(npc)
.expect("ScopeTag assigned for colleague");
assert!(
scope_tag.contains(ScopeTagKind::Colleague),
"Friend relationship should grant Colleague scope tag"
@@ -1067,7 +1103,10 @@ mod tests {
assert_eq!(unpinned_results.len(), 1, "only one unpinned NPC");
assert_eq!(unpinned_results[0], unpinned);
assert!(!unpinned_results.contains(&pinned), "pinned NPC excluded from eviction query");
assert!(
!unpinned_results.contains(&pinned),
"pinned NPC excluded from eviction query"
);
}
// -----------------------------------------------------------------------
@@ -1090,9 +1129,15 @@ mod tests {
world.spawn((PlayerCharacter, make_pos(0, 0)));
// Spawn 3 active NPCs (under cap of 5)
let npc1 = world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10))).id();
let npc2 = world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20))).id();
let npc3 = world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30))).id();
let npc1 = world
.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10)))
.id();
let npc2 = world
.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20)))
.id();
let npc3 = world
.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30)))
.id();
run_evict_excess_active(&mut world);
@@ -1112,16 +1157,28 @@ mod tests {
world.spawn((PlayerCharacter, make_pos(0, 0)));
// 3 NPCs, cap=2 → must evict 1 (the oldest: tick 10)
let oldest = world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10))).id();
let mid = world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20))).id();
let newest = world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30))).id();
let oldest = world
.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10)))
.id();
let mid = world
.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20)))
.id();
let newest = world
.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30)))
.id();
run_evict_excess_active(&mut world);
assert!(world.get::<ActiveSim>(oldest).is_none(), "oldest evicted");
assert!(world.get::<BackgroundSim>(oldest).is_some(), "oldest → Background");
assert!(
world.get::<BackgroundSim>(oldest).is_some(),
"oldest → Background"
);
assert!(world.get::<ActiveSim>(mid).is_some(), "mid stays Active");
assert!(world.get::<ActiveSim>(newest).is_some(), "newest stays Active");
assert!(
world.get::<ActiveSim>(newest).is_some(),
"newest stays Active"
);
}
#[test]
@@ -1134,20 +1191,30 @@ mod tests {
world.spawn((PlayerCharacter, make_pos(0, 0)));
// 2 NPCs, cap=1. The oldest is ScopePinned → skip it, evict the other.
let pinned = world.spawn((
Npc, ActiveSim, ScopePinned,
ScopeTag::with(ScopeTagKind::KnownContact),
make_pos(5, 0), LastInteractionTick(5),
)).id();
let unpinned = world.spawn((
Npc, ActiveSim,
make_pos(6, 0), LastInteractionTick(20),
)).id();
let pinned = world
.spawn((
Npc,
ActiveSim,
ScopePinned,
ScopeTag::with(ScopeTagKind::KnownContact),
make_pos(5, 0),
LastInteractionTick(5),
))
.id();
let unpinned = world
.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20)))
.id();
run_evict_excess_active(&mut world);
assert!(world.get::<ActiveSim>(pinned).is_some(), "pinned NPC stays Active");
assert!(world.get::<ActiveSim>(unpinned).is_none(), "unpinned NPC evicted");
assert!(
world.get::<ActiveSim>(pinned).is_some(),
"pinned NPC stays Active"
);
assert!(
world.get::<ActiveSim>(unpinned).is_none(),
"unpinned NPC evicted"
);
assert!(world.get::<BackgroundSim>(unpinned).is_some());
}
@@ -1161,21 +1228,25 @@ mod tests {
world.spawn((PlayerCharacter, make_pos(0, 0)));
// NPC at distance 200 (beyond BACKGROUND_RADIUS=120) → StateSaved
let far = world.spawn((
Npc, ActiveSim,
make_pos(200, 0), LastInteractionTick(5),
)).id();
let far = world
.spawn((Npc, ActiveSim, make_pos(200, 0), LastInteractionTick(5)))
.id();
// NPC at distance 5 (within ACTIVE_RADIUS) → stays
let near = world.spawn((
Npc, ActiveSim,
make_pos(5, 0), LastInteractionTick(50),
)).id();
let near = world
.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(50)))
.id();
run_evict_excess_active(&mut world);
assert!(world.get::<ActiveSim>(far).is_none(), "far NPC evicted");
assert!(world.get::<StateSaved>(far).is_some(), "far NPC → StateSaved");
assert!(world.get::<ActiveSim>(near).is_some(), "near NPC stays Active");
assert!(
world.get::<StateSaved>(far).is_some(),
"far NPC → StateSaved"
);
assert!(
world.get::<ActiveSim>(near).is_some(),
"near NPC stays Active"
);
}
#[test]
@@ -1189,16 +1260,21 @@ mod tests {
// NPC without LastInteractionTick defaults to tick 0 (most stale)
let no_tick = world.spawn((Npc, ActiveSim, make_pos(5, 0))).id();
let with_tick = world.spawn((
Npc, ActiveSim,
make_pos(6, 0), LastInteractionTick(100),
)).id();
let with_tick = world
.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(100)))
.id();
run_evict_excess_active(&mut world);
assert!(world.get::<ActiveSim>(no_tick).is_none(), "no-tick NPC evicted first");
assert!(
world.get::<ActiveSim>(no_tick).is_none(),
"no-tick NPC evicted first"
);
assert!(world.get::<BackgroundSim>(no_tick).is_some());
assert!(world.get::<ActiveSim>(with_tick).is_some(), "with-tick NPC stays");
assert!(
world.get::<ActiveSim>(with_tick).is_some(),
"with-tick NPC stays"
);
}
#[test]
@@ -1219,7 +1295,10 @@ mod tests {
let pressure = world.resource::<SimSpacePressure>();
// active_count is set BEFORE eviction runs (it reads the pre-eviction count).
// The actual count changes via deferred commands, which apply after the system.
assert_eq!(pressure.active_count, 3, "pressure tracks pre-eviction count");
assert_eq!(
pressure.active_count, 3,
"pressure tracks pre-eviction count"
);
}
#[test]
+33 -20
View File
@@ -562,7 +562,8 @@ impl FullTemplateDef {
// 2 — each role validates
for role in &self.roles {
role.validate().map_err(|e| format!("role '{}': {}", role.role_id.0, e))?;
role.validate()
.map_err(|e| format!("role '{}': {}", role.role_id.0, e))?;
}
// 3 — space spec
@@ -854,7 +855,7 @@ impl std::fmt::Display for ValidationError {
/// divergence).
pub fn validate_triangle_def(def: &TriangleDef) -> Result<(), ValidationError> {
// 1. Conflict viability: at least one Want axis
if !def.interest_axes.iter().any(|a| *a == NpcAxis::Want) {
if !def.interest_axes.contains(&NpcAxis::Want) {
return Err(ValidationError::ConflictViability {
triangle_id: def.triangle_id,
});
@@ -1104,7 +1105,7 @@ pub fn tick_triangle_escalation(
thresholds: Query<&ToleranceThreshold>,
) {
// Only process on game-minute boundaries (every 10 ticks, D-031)
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
if !time.tick.is_multiple_of(TICKS_PER_GAME_MINUTE) {
return;
}
@@ -1178,7 +1179,7 @@ pub fn apply_resolve_triangle(
// Avoids O(N*M) full scan when multiple resolves fire in one tick.
let id_to_entity: BTreeMap<TriangleId, Entity> = triangles
.iter()
.map(|(entity, state)| (state.triangle_id.clone(), entity))
.map(|(entity, state)| (state.triangle_id, entity))
.collect();
for cmd in commands {
@@ -1347,7 +1348,11 @@ mod tests {
RoleId::new("supervisor"),
],
conflict_type: ConflictType::LatentTension,
interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance],
interest_axes: [
NpcAxis::Contentment,
NpcAxis::Contentment,
NpcAxis::Tolerance,
],
relationship_constraints: vec![],
};
@@ -1438,7 +1443,11 @@ mod tests {
let result = generate_intra_template_triangles(&mut world, tid, &defs, &mut rng);
assert_eq!(result.triangles.len(), 2, "should generate 2 triangles");
assert!(result.warnings.is_empty(), "no warnings expected: {:?}", result.warnings);
assert!(
result.warnings.is_empty(),
"no warnings expected: {:?}",
result.warnings
);
// Verify role assignments
let t1 = &result.triangles[0];
@@ -1511,7 +1520,10 @@ mod tests {
// Actually with only 2 NPCs, both are already assigned before we need a 3rd.
// The triangle should be skipped with a warning.
assert!(!result.warnings.is_empty(), "should have warnings about missing roles");
assert!(
!result.warnings.is_empty(),
"should have warnings about missing roles"
);
}
#[test]
@@ -1553,11 +1565,7 @@ mod tests {
let defs = vec![
TriangleDef {
triangle_id: TriangleId(300),
roles: [
RoleId::new("a"),
RoleId::new("b"),
RoleId::new("c"),
],
roles: [RoleId::new("a"), RoleId::new("b"), RoleId::new("c")],
conflict_type: ConflictType::LoyaltyConflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![RelationshipConstraint {
@@ -1568,11 +1576,7 @@ mod tests {
},
TriangleDef {
triangle_id: TriangleId(301),
roles: [
RoleId::new("a"),
RoleId::new("c"),
RoleId::new("d"),
],
roles: [RoleId::new("a"), RoleId::new("c"), RoleId::new("d")],
conflict_type: ConflictType::LatentTension,
interest_axes: [NpcAxis::Want, NpcAxis::Contentment, NpcAxis::Tolerance],
relationship_constraints: vec![RelationshipConstraint {
@@ -1603,7 +1607,10 @@ mod tests {
assert_eq!(result1.triangles.len(), result2.triangles.len());
for (t1, t2) in result1.triangles.iter().zip(result2.triangles.iter()) {
assert_eq!(t1.tension, t2.tension, "tension must be deterministic");
assert_eq!(t1.tension_rate, t2.tension_rate, "tension_rate must be deterministic");
assert_eq!(
t1.tension_rate, t2.tension_rate,
"tension_rate must be deterministic"
);
assert_eq!(t1.role_assignments, t2.role_assignments);
}
}
@@ -1681,7 +1688,10 @@ mod tests {
schedule.run(&mut world);
let state = world.get::<TriangleState>(triangle).unwrap();
assert_eq!(state.tension, 60, "Active triangle should gain +5 per game-minute × 2");
assert_eq!(
state.tension, 60,
"Active triangle should gain +5 per game-minute × 2"
);
assert_eq!(
state.phase,
TrianglePhase::Active,
@@ -1721,7 +1731,10 @@ mod tests {
schedule.run(&mut world);
let state = world.get::<TriangleState>(triangle).unwrap();
assert_eq!(state.tension, 255, "tension should saturate at u8::MAX (255)");
assert_eq!(
state.tension, 255,
"tension should saturate at u8::MAX (255)"
);
}
#[test]