Merge remote-tracking branch 'origin/server'

# Conflicts:
#	CHANGELOG.md
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack
#	client/tests/fixtures/msgpack/snapshot_empty.msgpack
#	client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack
#	client/tests/fixtures/msgpack/snapshot_one_npc.msgpack
#	client/tests/fixtures/msgpack/snapshot_player.msgpack
#	client/tests/fixtures/msgpack/snapshot_v2_full.msgpack
#	server/src/bridge/types.rs
This commit is contained in:
2026-02-25 10:09:27 +01:00
34 changed files with 4546 additions and 8 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};
@@ -83,6 +84,7 @@ pub fn compute_observer_snapshot(
Option<&mut ScanEventBuffer>,
Option<&mut ConversationEventBuffer>,
Option<&FollowTarget>,
Option<&mut ExamineResultBuffer>,
),
With<PlayerCharacter>,
>,
@@ -98,6 +100,7 @@ pub fn compute_observer_snapshot(
poi_query: Query<&PointOfInterest>,
mut buffer: ResMut<SnapshotBuffer>,
sim_rng: Option<Res<SimRng>>,
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
) {
let Ok((
observer_entity,
@@ -114,6 +117,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");
@@ -199,6 +203,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())
@@ -391,6 +396,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()),
poi_list,
+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"
);
}