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:
2026-02-12 19:48:55 +01:00
co-authored by Claude Opus 4.6
parent 5afea99622
commit 2de413dd87
12 changed files with 438 additions and 29 deletions
+4 -2
View File
@@ -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 -2
View File
@@ -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
+1 -1
View File
@@ -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);
+7 -3
View File
@@ -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",
+12 -6
View File
@@ -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");