feat(simulation): wire Sprint 15 systems + protocol v13

Register all new systems in NpcPlugin and SimulationPlugin with correct
ordering constraints. Protocol bumped to v13: tell_state on VisibleEntity,
follow_state and PostConversationQueue on ObserverSnapshot, Follow verb
on VerbKind. Observer snapshot populates tell state from DerivedTellState
and follow state from FollowTarget. System ordering: tolerance after
mood, deviation after activity, tell after mood+deviation, follow after
visibility geometry, event monologue after conversations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 14:40:09 +01:00
co-authored by Claude Opus 4.6
parent 517318c8a7
commit 8538f916af
8 changed files with 98 additions and 11 deletions
+34 -1
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::follow::FollowTarget;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
@@ -80,6 +81,7 @@ pub fn compute_observer_snapshot(
Option<&mut DialogueResponseBuffer>,
Option<&mut ScanEventBuffer>,
Option<&mut ConversationEventBuffer>,
Option<&FollowTarget>,
),
With<PlayerCharacter>,
>,
@@ -89,6 +91,7 @@ pub fn compute_observer_snapshot(
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
Option<&AccessRule>,
Option<&crate::npc::tell_state::DerivedTellState>,
)>,
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
mut buffer: ResMut<SnapshotBuffer>,
@@ -108,6 +111,7 @@ pub fn compute_observer_snapshot(
mut dialogue_response_opt,
mut scan_event_buffer_opt,
mut conversation_buffer_opt,
follow_target_opt,
)) = observer_query.single_mut()
else {
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
@@ -245,6 +249,29 @@ pub fn compute_observer_snapshot(
None => geometry.visible_tiles.clone(),
};
// Build follow-mode state for client HUD (#241)
let follow_state = follow_target_opt.and_then(|ft| {
let target_wire_id = registry.to_stable(ft.target)?.0;
// Distance computed from current positions
let all_query_iter = all_entities.iter();
let target_pos = all_query_iter
.filter_map(|(e, pos, _, _, _, _)| (e == ft.target).then_some(pos))
.next()?;
let distance = observer_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
let has_los = target_pos.z == geometry.observer_z
&& geometry
.visible_positions
.contains(&(target_pos.x, target_pos.y));
Some(crate::simulation::follow::FollowStateWire {
target_entity_id: target_wire_id,
distance,
has_los,
proximity_ticks: ft.proximity_ticks,
})
});
buffer.snapshot = Some(ObserverSnapshot {
version: crate::bridge::types::PROTOCOL_VERSION,
tick: time.tick,
@@ -262,6 +289,7 @@ pub fn compute_observer_snapshot(
scan_events,
conversation_events,
conversation_ended,
follow_state,
sound_events,
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
});
@@ -282,13 +310,14 @@ fn filter_visible_entities(
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
Option<&AccessRule>,
Option<&crate::npc::tell_state::DerivedTellState>,
)>,
) -> (Vec<VisibleEntity>, BTreeSet<u64>, Vec<u64>) {
let mut entities = Vec::new();
let mut visible_ids: BTreeSet<u64> = BTreeSet::new();
let mut blocked_ids: BTreeSet<u64> = BTreeSet::new();
for (entity, pos, is_player, is_npc, access_rule) in all_entities.iter() {
for (entity, pos, is_player, is_npc, access_rule, tell_opt) in all_entities.iter() {
if pos.z != geometry.observer_z {
continue;
}
@@ -343,6 +372,8 @@ fn filter_visible_entities(
RelationshipState::Unknown
};
let tell_state = tell_opt.and_then(|t| t.category);
visible_ids.insert(wire_id);
entities.push(VisibleEntity {
entity_id: wire_id,
@@ -353,6 +384,7 @@ fn filter_visible_entities(
visibility: sector,
relationship,
observation: EntityVisibility::Visible,
tell_state,
});
}
@@ -413,6 +445,7 @@ fn collect_remembered_entities(
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: knowledge.relationship,
tell_state: None, // Remembered entities have no live tell state
observation: EntityVisibility::Remembered {
confidence: knowledge.confidence,
age_ticks,
+8 -7
View File
@@ -759,8 +759,8 @@ fn phase2_confront_injected_for_npc_with_knows_details() {
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let interaction = &snapshot.nearby_interactions[0];
// Should have Talk, ExamineNpc, AND Confront (Phase 2 injected)
assert_eq!(interaction.verbs.len(), 3);
// Should have Talk, ExamineNpc, Follow, AND Confront (Phase 2 injected)
assert_eq!(interaction.verbs.len(), 4);
let confront = interaction
.verbs
.iter()
@@ -1322,16 +1322,17 @@ fn phase2_poi_with_confront_verb_order() {
let verbs = &snapshot.nearby_interactions[0].verbs;
assert_eq!(
verbs.len(),
3,
"POI+KnowsDetails: ExamineNpc + Talk + Confront"
4,
"POI+KnowsDetails: ExamineNpc + Talk + Follow + Confront"
);
// POI flips ExamineNpc to priority 1, Talk to 2, Confront at 3
// POI flips ExamineNpc to priority 1, Talk to 2, Follow at 3, Confront at 3
assert_eq!(verbs[0].kind, VerbKind::ExamineNpc);
assert_eq!(verbs[0].priority, 1);
assert_eq!(verbs[1].kind, VerbKind::Talk);
assert_eq!(verbs[1].priority, 2);
assert_eq!(verbs[2].kind, VerbKind::Confront);
assert_eq!(verbs[2].priority, 3);
// Follow and Confront both at priority 3 — sorted by VerbKind discriminant
assert!(verbs.iter().any(|v| v.kind == VerbKind::Follow));
assert!(verbs.iter().any(|v| v.kind == VerbKind::Confront));
}
// -----------------------------------------------------------------------