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>
424 lines
14 KiB
Rust
424 lines
14 KiB
Rust
//! Examine interaction system (#242).
|
|
//!
|
|
//! Handles the Examine verb: player examines an NPC or object at close range,
|
|
//! generating character-filtered observation text and a DirectObservation
|
|
//! KnowledgeGraph entry.
|
|
//!
|
|
//! Pipeline:
|
|
//! Interact { verb: "Examine NPC" | "ExamineNpc" | "ExamineObject" }
|
|
//! → process_player_input inserts ExamineRequest on player
|
|
//! → process_examine_interaction reads request, generates text, pushes KG event
|
|
//! → ExamineResultBuffer consumed by compute_observer_snapshot
|
|
//! → ObserverSnapshot.examine_result delivered to client
|
|
|
|
use bevy_ecs::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::bridge::types::CharacterArchetype;
|
|
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
|
use crate::knowledge::EntityRegistry;
|
|
use crate::npc::mood::{MoodState, NpcMood};
|
|
use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold};
|
|
use crate::simulation::interaction::{ObjectType, CLOSE_RANGE};
|
|
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
|
use crate::simulation::time::SimulationTime;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Marker: player requested Examine interaction with a target entity this tick.
|
|
///
|
|
/// Inserted by process_player_input when verb == "Examine NPC", "ExamineNpc",
|
|
/// "Examine Object", or "ExamineObject". Consumed and removed by
|
|
/// process_examine_interaction each tick.
|
|
#[derive(Component, Debug)]
|
|
pub struct ExamineRequest {
|
|
pub target: Entity,
|
|
}
|
|
|
|
/// Character-filtered examination result for snapshot delivery.
|
|
///
|
|
/// Content differs per CharacterArchetype:
|
|
/// Smuggler — physical threat read, cargo-handling posture, opportunity windows.
|
|
/// Detective — procedural tells, behavioral inconsistencies, stress indicators.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ExamineResultEvent {
|
|
/// Character-filtered observation text for client display.
|
|
pub text: String,
|
|
/// Wire-format entity identifier of the examined entity.
|
|
pub target_entity_id: u64,
|
|
}
|
|
|
|
/// Authored examine text for a non-NPC entity (#246).
|
|
///
|
|
/// Attach to any examinable object (Readable, Terminal, etc.) to provide
|
|
/// a fixed description returned when the player examines it.
|
|
/// If absent, examining a non-NPC entity returns a generic fallback.
|
|
#[derive(Component, Debug, Clone)]
|
|
pub struct ExamineText(pub String);
|
|
|
|
/// Buffer holding the examine result for snapshot inclusion.
|
|
///
|
|
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
|
|
/// Attach to the player entity alongside other buffer components.
|
|
#[derive(Component, Debug, Default)]
|
|
pub struct ExamineResultBuffer {
|
|
pub(crate) result: Option<ExamineResultEvent>,
|
|
}
|
|
|
|
impl ExamineResultBuffer {
|
|
/// Drain and return the examine result, leaving the buffer empty.
|
|
pub fn take(&mut self) -> Option<ExamineResultEvent> {
|
|
self.result.take()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Text generation (deterministic, integer-only — D-010)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Stress ratio 0..=100 derived from ToleranceThreshold components.
|
|
/// Uses integer multiplication to avoid division by zero.
|
|
fn stress_ratio(threshold: &ToleranceThreshold) -> u8 {
|
|
if threshold.threshold <= 0 {
|
|
return 0;
|
|
}
|
|
((threshold.current_stress.max(0) as i32 * 100) / threshold.threshold as i32).clamp(0, 100)
|
|
as u8
|
|
}
|
|
|
|
/// Map NpcMood to a terse descriptor shared by both archetypes.
|
|
fn mood_word(mood: NpcMood) -> &'static str {
|
|
match mood {
|
|
NpcMood::Neutral => "neutral",
|
|
NpcMood::Anxious => "anxious",
|
|
NpcMood::Frustrated => "frustrated",
|
|
NpcMood::Content => "at ease",
|
|
NpcMood::Suspicious => "watchful",
|
|
NpcMood::Warm => "open",
|
|
NpcMood::Hostile => "hostile",
|
|
NpcMood::Focused => "focused",
|
|
}
|
|
}
|
|
|
|
fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> bool {
|
|
traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false)
|
|
}
|
|
|
|
/// Generate character-filtered examination text from NPC component state.
|
|
/// All logic is pure, deterministic, and integer-based (D-010).
|
|
pub fn generate_examine_text(
|
|
mood: NpcMood,
|
|
ratio: u8,
|
|
archetype: CharacterArchetype,
|
|
traits_opt: Option<&PersonalityTraits>,
|
|
) -> String {
|
|
let stress_label = match ratio {
|
|
0..=30 => "relaxed",
|
|
31..=60 => "tense",
|
|
61..=85 => "stressed",
|
|
_ => "near breaking point",
|
|
};
|
|
|
|
let mood_label = mood_word(mood);
|
|
|
|
match archetype {
|
|
CharacterArchetype::Smuggler => {
|
|
// Physical threat read + cargo opportunity window
|
|
let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) {
|
|
"Threat posture. Don't push it."
|
|
} else if has_trait(traits_opt, PersonalityTrait::Bold) {
|
|
"Confident bearing. Will push back if cornered."
|
|
} else if has_trait(traits_opt, PersonalityTrait::Cautious) {
|
|
"Nervous type. Predictable under pressure."
|
|
} else {
|
|
"No obvious threat read."
|
|
};
|
|
|
|
let window = if ratio > 60 {
|
|
"Too distracted to track cargo movement."
|
|
} else if matches!(mood, NpcMood::Focused) {
|
|
"Paying close attention to this section."
|
|
} else {
|
|
"Standard patrol pattern. Window is there."
|
|
};
|
|
|
|
format!("Appears {mood_label}, {stress_label}. {threat} {window}")
|
|
}
|
|
|
|
CharacterArchetype::Detective => {
|
|
// Procedural tells + behavioral read
|
|
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
|
|
"Controlled affect — practiced concealment."
|
|
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
|
|
"Involuntary stress markers present."
|
|
} else if matches!(mood, NpcMood::Suspicious) {
|
|
"Scanning. Aware of being observed."
|
|
} else {
|
|
"Baseline presentation."
|
|
};
|
|
|
|
let read = if ratio > 60 {
|
|
"Under pressure — potential liability or asset."
|
|
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
|
|
"Comfortable. Less guarded than usual."
|
|
} else {
|
|
"Routine behavior pattern."
|
|
};
|
|
|
|
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// System
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Process examine interaction: generate character-filtered observation text,
|
|
/// push DirectObservation to KnowledgeGraph, write result to ExamineResultBuffer.
|
|
///
|
|
/// Handles two target types:
|
|
/// - NPC entities: generate character-filtered text from NPC component state.
|
|
/// - Non-NPC entities with `ExamineText`: use the authored text directly.
|
|
/// - Non-NPC entities without `ExamineText`: generic fallback text.
|
|
///
|
|
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
|
#[allow(clippy::type_complexity)]
|
|
pub fn process_examine_interaction(
|
|
mut commands: Commands,
|
|
time: Res<SimulationTime>,
|
|
registry: Res<EntityRegistry>,
|
|
mut kg_events: ResMut<KnowledgeEventQueue>,
|
|
mut player_query: Query<
|
|
(
|
|
Entity,
|
|
&TilePosition,
|
|
&ExamineRequest,
|
|
Option<&CharacterArchetype>,
|
|
&mut ExamineResultBuffer,
|
|
),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
npc_query: Query<
|
|
(
|
|
&TilePosition,
|
|
Option<&MoodState>,
|
|
Option<&ToleranceThreshold>,
|
|
Option<&PersonalityTraits>,
|
|
),
|
|
Without<ObjectType>,
|
|
>,
|
|
examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>,
|
|
) {
|
|
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
|
|
player_query.single_mut()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let target = examine_req.target;
|
|
let archetype = archetype_opt.copied().unwrap_or_default();
|
|
|
|
// 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);
|
|
if distance > CLOSE_RANGE {
|
|
tracing::info!(
|
|
distance,
|
|
"Examine: NPC target out of range (max {})",
|
|
CLOSE_RANGE
|
|
);
|
|
commands.entity(player_entity).remove::<ExamineRequest>();
|
|
return;
|
|
}
|
|
|
|
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
|
|
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
|
|
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
|
|
|
|
kg_events.push(KnowledgeEvent {
|
|
observer: player_entity,
|
|
tick: time.tick,
|
|
event_type: KnowledgeEventType::DirectObservation {
|
|
target,
|
|
position: *target_pos,
|
|
},
|
|
});
|
|
|
|
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,
|
|
});
|
|
tracing::debug!(target_entity_id, "Examine: NPC result written to buffer");
|
|
commands.entity(player_entity).remove::<ExamineRequest>();
|
|
return;
|
|
}
|
|
|
|
// 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);
|
|
if distance > CLOSE_RANGE {
|
|
tracing::info!(
|
|
distance,
|
|
"Examine: object target out of range (max {})",
|
|
CLOSE_RANGE
|
|
);
|
|
commands.entity(player_entity).remove::<ExamineRequest>();
|
|
return;
|
|
}
|
|
|
|
let text = examine_text_opt
|
|
.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()
|
|
});
|
|
|
|
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"
|
|
);
|
|
commands.entity(player_entity).remove::<ExamineRequest>();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::npc::PersonalityTrait;
|
|
|
|
fn traits(t: &[PersonalityTrait]) -> PersonalityTraits {
|
|
PersonalityTraits { traits: t.to_vec() }
|
|
}
|
|
|
|
#[test]
|
|
fn smuggler_hostile_npc_gives_threat_read() {
|
|
let text = generate_examine_text(NpcMood::Hostile, 20, CharacterArchetype::Smuggler, None);
|
|
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);
|
|
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);
|
|
assert!(
|
|
text.contains("Too distracted"),
|
|
"expected distraction read, got: {text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detective_deceptive_npc_notes_concealment() {
|
|
let t = traits(&[PersonalityTrait::Deceptive]);
|
|
let text = generate_examine_text(
|
|
NpcMood::Neutral,
|
|
20,
|
|
CharacterArchetype::Detective,
|
|
Some(&t),
|
|
);
|
|
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);
|
|
assert!(
|
|
text.contains("stress markers"),
|
|
"expected stress markers, got: {text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detective_content_npc_notes_low_guard() {
|
|
let text = generate_examine_text(NpcMood::Content, 10, CharacterArchetype::Detective, None);
|
|
assert!(
|
|
text.contains("Less guarded"),
|
|
"expected low guard note, got: {text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stress_ratio_zero_when_threshold_zero() {
|
|
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,
|
|
};
|
|
assert_eq!(stress_ratio(&t), 100);
|
|
}
|
|
|
|
#[test]
|
|
fn stress_ratio_negative_stress_is_zero() {
|
|
let t = ToleranceThreshold {
|
|
current_stress: -10,
|
|
threshold: 70,
|
|
};
|
|
assert_eq!(stress_ratio(&t), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn examine_result_buffer_take_drains() {
|
|
let mut buf = ExamineResultBuffer::default();
|
|
assert!(buf.take().is_none());
|
|
buf.result = Some(ExamineResultEvent {
|
|
text: "Test".into(),
|
|
target_entity_id: 42,
|
|
});
|
|
assert!(buf.take().is_some());
|
|
assert!(buf.take().is_none()); // idempotent drain
|
|
}
|
|
}
|