feat(simulation): basic environmental interaction — doors, examine, terminals (#246)

- Add DoorState component tracking is_open and blocking_tile; add
  DoorInteractRequest per-player component consumed by new
  process_door_interaction system (toggles walkability each use)
- Add TerminalInteracted event, TerminalInteractedQueue resource,
  TerminalInteractRequest component, and process_terminal_interaction
  system (emits event on Use verb)
- Add ExamineText(String) component for authored object examine text;
  extend process_examine_interaction with object examine path:
  uses ExamineText if present, falls back to generic string if absent
- Fix: add Without<ObjectType> filter to npc_query in
  process_examine_interaction — previously any entity with TilePosition
  was mis-routed through the NPC text generator
- Add SaveStateV1.open_doors: Vec<StableId> with #[serde(default)]
  for backward-compatible serialization
- Add "Open"/"Close" → DoorInteractRequest and "Use" →
  TerminalInteractRequest dispatch in process_player_input
- 10 integration tests in tests/environmental_interaction.rs covering
  all acceptance criteria: door toggle (both directions), open-to-close,
  invalid target, readable examine (with/without ExamineText), out-of-range,
  terminal event emission, request cleanup, and save state round-trip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 11:11:45 +01:00
co-authored by Claude Sonnet 4.6
parent c4e09bff77
commit b6a9b78205
5 changed files with 803 additions and 49 deletions
+73 -47
View File
@@ -19,7 +19,7 @@ use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEve
use crate::knowledge::EntityRegistry;
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold};
use crate::simulation::interaction::CLOSE_RANGE;
use crate::simulation::interaction::{ObjectType, CLOSE_RANGE};
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::time::SimulationTime;
@@ -50,6 +50,14 @@ pub struct ExamineResultEvent {
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.
@@ -171,6 +179,11 @@ pub fn generate_examine_text(
/// 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(
@@ -188,12 +201,16 @@ pub fn process_examine_interaction(
),
With<PlayerCharacter>,
>,
npc_query: Query<(
&TilePosition,
Option<&MoodState>,
Option<&ToleranceThreshold>,
Option<&PersonalityTraits>,
)>,
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()
@@ -204,55 +221,64 @@ pub fn process_examine_interaction(
let target = examine_req.target;
let archetype = archetype_opt.copied().unwrap_or_default();
// Range check — examine requires close range (same as Talk/Confront)
let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) else {
tracing::warn!(?target, "process_examine_interaction: target not in query");
commands.entity(player_entity).remove::<ExamineRequest>();
return;
};
// 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 distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
if distance > CLOSE_RANGE {
tracing::info!(
distance,
"Examine: target out of range (max {})",
CLOSE_RANGE
);
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;
}
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
// 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 = generate_examine_text(mood, ratio, archetype, traits_opt);
let text = examine_text_opt
.map(|et| et.0.clone())
.unwrap_or_else(|| "No further details are apparent.".to_string());
// Push DirectObservation to KnowledgeEventQueue
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: object not in EntityRegistry, using bits");
target.to_bits()
});
// Resolve target wire ID for snapshot
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
tracing::warn!(?target, "Examine: target not in EntityRegistry, using bits");
target.to_bits()
});
result_buffer.result = Some(ExamineResultEvent {
text,
target_entity_id,
});
tracing::debug!(
target_entity_id,
"Examine: DirectObservation pushed, result written to buffer"
);
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>();
}