feat(simulation): add proximity detection and interaction verbs
Implement compute_nearby_interactions system that detects entities within close (≤2) and mid (≤5) Manhattan distance, computes available verbs per D-060 spec. NPCs get Talk+Observe at close range, Observe-only at mid range; PersonOfInterest flips priority. Objects get Examine. Results populate nearby_interactions[] on ObserverSnapshot v4. Bump protocol version 3→4. Implements #404. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -102,7 +102,8 @@ pub fn generate_snapshot(
|
||||
day: time.day(),
|
||||
time_of_day: time.time_of_day_minutes(),
|
||||
day_phase: time.day_phase(),
|
||||
paused: time.paused,
|
||||
paused: time.paused(),
|
||||
tick_rate: time.tick_rate,
|
||||
};
|
||||
|
||||
tracing::trace!(
|
||||
@@ -111,12 +112,13 @@ pub fn generate_snapshot(
|
||||
visible.len()
|
||||
);
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: 3,
|
||||
version: 4,
|
||||
tick: time.tick,
|
||||
game_time,
|
||||
player_facing: FacingDirection::default(),
|
||||
entities: visible,
|
||||
visible_tiles: Vec::new(), // Empty until #112 adds LOS filtering
|
||||
nearby_interactions: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,18 +6,19 @@ use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::knowledge::types::{EntityVisibility, KnowledgeConfidence, RelationshipState};
|
||||
pub use crate::simulation::time::DayPhase;
|
||||
pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
/// Contains all information visible to the observer at a given tick.
|
||||
///
|
||||
/// v2 adds: game_time, player_facing, visible_tiles, visibility sectors.
|
||||
/// v3 adds: relationship (D-033 entity color), observation (Visible/Remembered).
|
||||
/// v4 adds: nearby_interactions (D-060, #404 proximity + verbs[]).
|
||||
/// Future fields: ambient sound events, internal monologue triggers,
|
||||
/// HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 3.
|
||||
/// Protocol version for forward compatibility. Current: 4.
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
@@ -29,6 +30,10 @@ pub struct ObserverSnapshot {
|
||||
pub entities: Vec<VisibleEntity>,
|
||||
/// Tiles visible to the observer for fog rendering
|
||||
pub visible_tiles: Vec<VisibleTile>,
|
||||
/// Entities within interaction range with available verbs (D-060, #404).
|
||||
/// Sorted by distance (nearest first). v0.1 client reads verbs[0] on the nearest entity.
|
||||
#[serde(default)]
|
||||
pub nearby_interactions: Vec<NearbyInteraction>,
|
||||
}
|
||||
|
||||
/// Game time data for client display (D-031)
|
||||
@@ -41,8 +46,11 @@ pub struct GameTime {
|
||||
pub time_of_day: u64,
|
||||
/// Current day phase (Morning/Afternoon/Evening/Night)
|
||||
pub day_phase: DayPhase,
|
||||
/// Whether simulation is paused
|
||||
/// Whether simulation is paused (tick_rate == Paused)
|
||||
pub paused: bool,
|
||||
/// Current tick rate state (D-052)
|
||||
#[serde(default)]
|
||||
pub tick_rate: TickRate,
|
||||
}
|
||||
|
||||
/// 8-directional facing direction, matching movement system.
|
||||
@@ -136,6 +144,45 @@ pub enum PlayerAction {
|
||||
UsePerceptionMode(String),
|
||||
Pause,
|
||||
Unpause,
|
||||
/// Set tick rate: Full (1.0), Half (0.5), or Paused (0.0) per D-052
|
||||
SetTickRate(TickRate),
|
||||
}
|
||||
|
||||
/// Available interaction verbs for a nearby entity (D-060, #404)
|
||||
/// Embedded in ObserverSnapshot.nearby_interactions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NearbyInteraction {
|
||||
/// Wire-format entity identifier
|
||||
pub entity_id: u64,
|
||||
/// Entity type for client-side verb display
|
||||
pub entity_type: EntityKind,
|
||||
/// Manhattan distance from player
|
||||
pub distance: f32,
|
||||
/// Available verbs sorted by priority (index 0 = highest priority)
|
||||
pub verbs: Vec<VerbOption>,
|
||||
}
|
||||
|
||||
/// A single available verb on a nearby entity
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerbOption {
|
||||
/// Verb type
|
||||
pub kind: VerbKind,
|
||||
/// Display label for the context prompt (e.g. "Talk", "Observe", "Examine")
|
||||
pub label: String,
|
||||
/// Priority rank (lower = higher priority). v0.1 client reads only priority 1.
|
||||
pub priority: u8,
|
||||
/// Whether this verb is currently available (false = greyed out in v0.2)
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
/// Verb types for the interaction system (D-060)
|
||||
/// Only active verbs appear in verbs[]. Passive (Look, Overhear) and
|
||||
/// reactive (Monologue) verbs fire independently.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum VerbKind {
|
||||
ExamineObject,
|
||||
ExamineNpc,
|
||||
Talk,
|
||||
}
|
||||
|
||||
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
|
||||
|
||||
@@ -199,6 +199,7 @@ mod tests {
|
||||
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<ObservationEventQueue>();
|
||||
world.init_resource::<crate::simulation::interaction::NearbyInteractionBuffer>();
|
||||
world
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ mod tests {
|
||||
world.init_resource::<SnapshotBuffer>();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<crate::simulation::interaction::NearbyInteractionBuffer>();
|
||||
world
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::bridge::types::*;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::perception::shadowcast::compute_fov;
|
||||
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
@@ -22,6 +23,7 @@ pub fn compute_observer_snapshot(
|
||||
time: Res<SimulationTime>,
|
||||
walkability: Res<WalkabilityMap>,
|
||||
registry: Res<EntityRegistry>,
|
||||
interaction_buffer: Res<NearbyInteractionBuffer>,
|
||||
observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With<PlayerCharacter>>,
|
||||
all_entities: Query<(
|
||||
Entity,
|
||||
@@ -185,7 +187,8 @@ pub fn compute_observer_snapshot(
|
||||
day: time.day(),
|
||||
time_of_day: time.time_of_day_minutes(),
|
||||
day_phase: time.day_phase(),
|
||||
paused: time.paused,
|
||||
paused: time.paused(),
|
||||
tick_rate: time.tick_rate,
|
||||
};
|
||||
|
||||
tracing::trace!(
|
||||
@@ -196,14 +199,15 @@ pub fn compute_observer_snapshot(
|
||||
visible_tiles.len(),
|
||||
);
|
||||
|
||||
// Step 8: Assemble snapshot (v3: added relationship + observation fields)
|
||||
// Step 8: Assemble snapshot (v4: added nearby_interactions)
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: 3,
|
||||
version: 4,
|
||||
tick: time.tick,
|
||||
game_time,
|
||||
player_facing: facing,
|
||||
entities,
|
||||
visible_tiles,
|
||||
nearby_interactions: interaction_buffer.interactions.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,6 +225,7 @@ mod tests {
|
||||
world.insert_resource(WalkabilityMap::new(width, height, 1));
|
||||
world.init_resource::<SnapshotBuffer>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<NearbyInteractionBuffer>();
|
||||
world
|
||||
}
|
||||
|
||||
@@ -240,7 +245,7 @@ mod tests {
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
assert_eq!(snapshot.version, 3);
|
||||
assert_eq!(snapshot.version, 4);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
|
||||
assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible);
|
||||
@@ -359,10 +364,10 @@ mod tests {
|
||||
#[test]
|
||||
fn game_time_populated() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.insert_resource(SimulationTime {
|
||||
tick: 7200, // 720 minutes = Evening
|
||||
paused: true,
|
||||
});
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 7200; // 720 minutes = Evening
|
||||
time.tick_rate = crate::simulation::time::TickRate::Paused;
|
||||
world.insert_resource(time);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
@@ -479,7 +484,7 @@ mod tests {
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(SimulationTime { tick: 100, paused: false });
|
||||
world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
// Interaction system — proximity detection + multi-verb InteractionOptions
|
||||
// Implements #404: server-side verb computation for context-sensitive [E] key
|
||||
// Spec: docs/design/interaction-verbs-v0.1.md
|
||||
// D-060: actions[] renamed to verbs[] across all surfaces
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption};
|
||||
use crate::knowledge::types::RelationshipState;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
|
||||
/// Interaction range thresholds (Manhattan distance, same z-level)
|
||||
pub const CLOSE_RANGE: u32 = 2;
|
||||
pub const MID_RANGE: u32 = 5;
|
||||
|
||||
/// Component marking an entity as having available interactions.
|
||||
/// Attached to NPCs and examinable objects by the world setup or content loader.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct Interactable;
|
||||
|
||||
/// Compute nearby interactions for the player character.
|
||||
/// For each visible entity in range, determines available verbs sorted by priority.
|
||||
/// Results are written to the NearbyInteractionBuffer for inclusion in ObserverSnapshot.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn compute_nearby_interactions(
|
||||
player_query: Query<(&TilePosition, &KnowledgeGraph), With<PlayerCharacter>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
interactables: Query<
|
||||
(Entity, &TilePosition, Option<&Npc>),
|
||||
(With<Interactable>, Without<PlayerCharacter>),
|
||||
>,
|
||||
mut buffer: ResMut<NearbyInteractionBuffer>,
|
||||
) {
|
||||
buffer.interactions.clear();
|
||||
|
||||
let Ok((player_pos, knowledge)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (entity, pos, is_npc) in interactables.iter() {
|
||||
let Some(distance) = player_pos.manhattan_distance(pos) else {
|
||||
continue; // Different z-level
|
||||
};
|
||||
|
||||
if distance > MID_RANGE {
|
||||
continue; // Out of interaction range
|
||||
}
|
||||
|
||||
let entity_type = if is_npc.is_some() {
|
||||
EntityKind::Npc
|
||||
} else {
|
||||
EntityKind::Object
|
||||
};
|
||||
|
||||
// Look up relationship state from knowledge graph
|
||||
let relationship = if let Some(stable_id) = registry.to_stable(entity) {
|
||||
knowledge.relationship_with(&stable_id)
|
||||
} else {
|
||||
RelationshipState::Unknown
|
||||
};
|
||||
|
||||
let is_poi = relationship == RelationshipState::PersonOfInterest;
|
||||
let is_close = distance <= CLOSE_RANGE;
|
||||
|
||||
let mut verbs = Vec::new();
|
||||
|
||||
match entity_type {
|
||||
EntityKind::Npc => {
|
||||
if is_close {
|
||||
if is_poi {
|
||||
// Post-contradiction: Observe takes priority over Talk
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "Observe".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::Talk,
|
||||
label: "Talk".into(),
|
||||
priority: 2,
|
||||
available: true,
|
||||
});
|
||||
} else {
|
||||
// Default: Talk takes priority
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::Talk,
|
||||
label: "Talk".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "Observe".into(),
|
||||
priority: 2,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Mid range: only Examine NPC (Talk requires close range)
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "Observe".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
EntityKind::Object | EntityKind::Terrain => {
|
||||
if is_close {
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineObject,
|
||||
label: "Examine".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
EntityKind::Player => {} // No self-interaction
|
||||
}
|
||||
|
||||
if verbs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort by priority (lower number = higher priority)
|
||||
verbs.sort_by_key(|v| v.priority);
|
||||
|
||||
buffer.interactions.push(NearbyInteraction {
|
||||
entity_id: entity.to_bits(),
|
||||
entity_type,
|
||||
distance: distance as f32,
|
||||
verbs,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort interactions by distance (nearest first)
|
||||
buffer
|
||||
.interactions
|
||||
.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap());
|
||||
}
|
||||
|
||||
/// Buffer for nearby interaction results, consumed by snapshot generation
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct NearbyInteractionBuffer {
|
||||
pub interactions: Vec<NearbyInteraction>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<NearbyInteractionBuffer>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_in_close_range_gets_talk_and_observe() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 2);
|
||||
// Talk should be priority 1 (default, not POI)
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_in_mid_range_gets_observe_only() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Distance 4 (mid range, beyond close)
|
||||
world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_out_of_range_no_interactions() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Distance 8 (beyond mid range)
|
||||
world.spawn((Npc, TilePosition::new(5, 13, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_npc_observe_takes_priority() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(5, 6, 0), Interactable))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg));
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
// Observe should be priority 1 for POI NPC
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Talk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_in_close_range_gets_examine() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Object (no Npc component) at close range
|
||||
world.spawn((TilePosition::new(5, 6, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineObject);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_z_level_no_interactions() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 1), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_entities_sorted_by_distance() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Farther NPC
|
||||
world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable));
|
||||
// Closer NPC
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
assert_eq!(buffer.interactions.len(), 2);
|
||||
assert!(buffer.interactions[0].distance < buffer.interactions[1].distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactable_entity_ignored() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// NPC without Interactable component
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod input;
|
||||
pub mod interaction;
|
||||
pub mod movement;
|
||||
pub mod path_follow;
|
||||
pub mod pathfinding;
|
||||
@@ -22,6 +23,8 @@ impl Plugin for SimulationPlugin {
|
||||
app.init_resource::<time::SimulationTime>()
|
||||
.insert_resource(rng::SimRng::new(0))
|
||||
.init_resource::<input::InputQueue>()
|
||||
.init_resource::<interaction::NearbyInteractionBuffer>()
|
||||
.init_resource::<crate::knowledge::EntityRegistry>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -29,8 +32,12 @@ impl Plugin for SimulationPlugin {
|
||||
pathfinding::compute_paths.after(input::process_player_input),
|
||||
path_follow::follow_paths.after(pathfinding::compute_paths),
|
||||
movement::validate_movement.after(path_follow::follow_paths),
|
||||
interaction::compute_nearby_interactions
|
||||
.after(movement::validate_movement),
|
||||
path_follow::cleanup_path_blocked.after(movement::validate_movement),
|
||||
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
||||
time::advance_tick
|
||||
.after(path_follow::cleanup_path_blocked)
|
||||
.after(interaction::compute_nearby_interactions),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::local::LocalBridge;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::SimBridge;
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use settled_reach_server::simulation::time::{DayPhase, TickRate};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
@@ -33,13 +33,14 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
let bridge = LocalBridge::accept(&server_path).expect("failed to accept");
|
||||
|
||||
let snapshot = ObserverSnapshot {
|
||||
version: 3,
|
||||
version: 4,
|
||||
tick: 42,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
paused: false,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
entities: vec![VisibleEntity {
|
||||
@@ -53,6 +54,7 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
observation: EntityVisibility::Visible,
|
||||
}],
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -4,7 +4,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::SimBridge;
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use settled_reach_server::simulation::time::{DayPhase, TickRate};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::thread;
|
||||
|
||||
@@ -19,13 +19,14 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("failed to accept");
|
||||
|
||||
let snapshot = ObserverSnapshot {
|
||||
version: 3,
|
||||
version: 4,
|
||||
tick: 42,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
paused: false,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
entities: vec![VisibleEntity {
|
||||
@@ -39,6 +40,7 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
observation: EntityVisibility::Visible,
|
||||
}],
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -61,7 +61,7 @@ fn player_moves_north_through_full_pipeline() {
|
||||
rmp_serde::from_slice(&response).expect("deserialize snapshot");
|
||||
|
||||
// Snapshot captures state at end of tick 0 (before advance_tick increments to 1)
|
||||
assert_eq!(snapshot.version, 3);
|
||||
assert_eq!(snapshot.version, 4);
|
||||
assert_eq!(snapshot.tick, 0);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Run with: cargo test --test gen_fixtures -- --ignored
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use settled_reach_server::simulation::time::{DayPhase, TickRate};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -18,17 +18,19 @@ fn write_fixture(name: &str, bytes: &[u8]) {
|
||||
/// Helper to create a minimal v2 snapshot for fixtures
|
||||
fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
ObserverSnapshot {
|
||||
version: 3,
|
||||
version: 4,
|
||||
tick,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
paused: false,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
entities,
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,13 +152,14 @@ fn generate_msgpack_fixtures() {
|
||||
|
||||
// v2 snapshot with visible_tiles and game_time populated
|
||||
let snapshot_v2_full = ObserverSnapshot {
|
||||
version: 3,
|
||||
version: 4,
|
||||
tick: 500,
|
||||
game_time: GameTime {
|
||||
day: 1,
|
||||
time_of_day: 720,
|
||||
day_phase: DayPhase::Evening,
|
||||
paused: false,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::Southeast,
|
||||
entities: vec![VisibleEntity {
|
||||
@@ -189,6 +192,7 @@ fn generate_msgpack_fixtures() {
|
||||
visibility: VisibilitySector::Forward,
|
||||
},
|
||||
],
|
||||
nearby_interactions: vec![],
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_v2_full",
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
//! IPC serialization round-trip tests (D-030 Layer 1: fixture-based).
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use settled_reach_server::simulation::time::{DayPhase, TickRate};
|
||||
use std::fs;
|
||||
|
||||
/// Helper to create a minimal v2 snapshot for tests
|
||||
fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
ObserverSnapshot {
|
||||
version: 3,
|
||||
version: 4,
|
||||
tick,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
paused: false,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
entities,
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +42,7 @@ fn observer_snapshot_roundtrip() {
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(decoded.version, 3);
|
||||
assert_eq!(decoded.version, 4);
|
||||
assert_eq!(decoded.tick, 42);
|
||||
assert_eq!(decoded.entities.len(), 1);
|
||||
assert_eq!(decoded.entities[0].entity_id, 1);
|
||||
@@ -178,13 +180,14 @@ fn all_entity_kind_variants_roundtrip() {
|
||||
#[test]
|
||||
fn snapshot_v2_fields_roundtrip() {
|
||||
let snapshot = ObserverSnapshot {
|
||||
version: 3,
|
||||
version: 4,
|
||||
tick: 100,
|
||||
game_time: GameTime {
|
||||
day: 3,
|
||||
time_of_day: 720,
|
||||
day_phase: DayPhase::Evening,
|
||||
paused: true,
|
||||
tick_rate: TickRate::Paused,
|
||||
},
|
||||
player_facing: FacingDirection::Southeast,
|
||||
entities: vec![VisibleEntity {
|
||||
@@ -211,12 +214,13 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
visibility: VisibilitySector::Peripheral,
|
||||
},
|
||||
],
|
||||
nearby_interactions: vec![],
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(decoded.version, 3);
|
||||
assert_eq!(decoded.version, 4);
|
||||
assert_eq!(decoded.game_time.day, 3);
|
||||
assert_eq!(decoded.game_time.time_of_day, 720);
|
||||
assert_eq!(decoded.game_time.day_phase, DayPhase::Evening);
|
||||
@@ -244,17 +248,19 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
|
||||
for dir in directions {
|
||||
let snapshot = ObserverSnapshot {
|
||||
version: 3,
|
||||
version: 4,
|
||||
tick: 0,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
paused: false,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: dir,
|
||||
entities: vec![],
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
Reference in New Issue
Block a user