feat(simulation): Sprint 18 — 9 server systems

Background tier state machines (#95), NPC vision (#115), player-
awareness behavior (#244), skill system & combat flag (#91), social
propagation (#249), examine mechanic (#242), character pressure
framework (#248), save state data model (#256), tell state wiring
(#337). Protocol version bumped 13→14 for examine_result and
character_pressure snapshot fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 09:50:37 +01:00
co-authored by Claude Opus 4.6
parent 2eca8e961b
commit 276538cde1
16 changed files with 4450 additions and 5 deletions
+9
View File
@@ -19,6 +19,7 @@ use crate::perception::vision_cone::Facing;
use crate::simulation::contraband::ScanEventBuffer;
use crate::simulation::conversation::ConversationEventBuffer;
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::examine::ExamineResultBuffer;
use crate::simulation::follow::FollowTarget;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
@@ -82,6 +83,7 @@ pub fn compute_observer_snapshot(
Option<&mut ScanEventBuffer>,
Option<&mut ConversationEventBuffer>,
Option<&FollowTarget>,
Option<&mut ExamineResultBuffer>,
),
With<PlayerCharacter>,
>,
@@ -96,6 +98,7 @@ pub fn compute_observer_snapshot(
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
mut buffer: ResMut<SnapshotBuffer>,
sim_rng: Option<Res<SimRng>>,
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
) {
let Ok((
observer_entity,
@@ -112,6 +115,7 @@ pub fn compute_observer_snapshot(
mut scan_event_buffer_opt,
mut conversation_buffer_opt,
follow_target_opt,
mut examine_result_buffer_opt,
)) = observer_query.single_mut()
else {
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
@@ -193,6 +197,7 @@ pub fn compute_observer_snapshot(
let current_monologue = monologue_buffer.take();
let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take());
let examine_result = examine_result_buffer_opt.as_mut().and_then(|buf| buf.take());
let scan_events = scan_event_buffer_opt
.as_mut()
.map(|buf| buf.take())
@@ -290,6 +295,10 @@ pub fn compute_observer_snapshot(
conversation_events,
conversation_ended,
follow_state,
examine_result,
character_pressure: pressure_query.iter().next().map(|p| {
crate::simulation::pressure::CharacterPressureWire::from(p)
}),
sound_events,
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
});
+166
View File
@@ -2583,3 +2583,169 @@ fn no_zone_map_resource_tiles_have_no_zone_id() {
);
}
}
// ---------------------------------------------------------------------------
// #337 — Tell state → snapshot integration (D-024 tell system)
// ---------------------------------------------------------------------------
/// Helper: run derive_tell_state + two-stage observer pipeline together.
fn run_tell_plus_observer_pipeline(world: &mut World) {
use crate::npc::tell_state::derive_tell_state;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
derive_tell_state,
compute_visibility_geometry.after(derive_tell_state),
compute_observer_snapshot
.after(compute_visibility_geometry)
.after(derive_tell_state),
));
schedule.run(world);
}
#[test]
fn tell_state_nervous_appears_in_snapshot_for_major_secret_high_stress() {
// Spec (#337, D-024): NPC with Major secret + stress past midpoint shows
// TellCategory::Nervous in ObserverSnapshot.entities[].tell_state.
// End-to-end pipeline: axis values → derive_tell_state → DerivedTellState
// → compute_observer_snapshot → VisibleEntity.tell_state.
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::tell_state::{DerivedTellState, TellCategory};
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
use crate::simulation::tier::ActiveSim;
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC directly north — in forward LOS — with Major secret + stress > midpoint.
// stress=60, threshold=100 → stress*2=120 > 100 → Nervous (D-024 priority 2)
world.spawn((
Npc,
ActiveSim,
TilePosition::new(16, 14, 0),
Secret {
description: "criminal record".into(),
severity: SecretSeverity::Major,
known_by: vec![],
},
ToleranceThreshold { current_stress: 60, threshold: 100 },
Contentment { level: 0 },
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
DerivedTellState::default(),
));
run_tell_plus_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state,
Some(TellCategory::Nervous),
"NPC with Major secret + stress past midpoint should show Nervous tell in snapshot"
);
}
#[test]
fn tell_state_none_for_neutral_npc_in_snapshot() {
// Spec (#337, D-024): neutral NPC shows tell_state = None in snapshot.
// Verifies the pipeline correctly omits tell when no conditions are met.
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::tell_state::DerivedTellState;
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
use crate::simulation::tier::ActiveSim;
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC in LOS — neutral state (Minor secret, low stress, neutral mood, low contentment)
world.spawn((
Npc,
ActiveSim,
TilePosition::new(16, 14, 0),
Secret {
description: "minor embarrassment".into(),
severity: SecretSeverity::Minor,
known_by: vec![],
},
ToleranceThreshold { current_stress: 10, threshold: 100 },
Contentment { level: 0 },
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
DerivedTellState::default(),
));
run_tell_plus_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state,
None,
"neutral NPC should have no tell state in snapshot"
);
}
#[test]
fn tell_state_none_when_npc_has_no_derived_tell_component() {
// Spec (#337): NPC without DerivedTellState component has tell_state = None.
// Verifies Option<&DerivedTellState> query handles absent component gracefully.
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC with no DerivedTellState component at all
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state,
None,
"NPC without DerivedTellState component should have tell_state = None"
);
}