feat(simulation): complete interaction and monologue pipelines (#413, #414, #415)

Three fixes to make the gameplay loop functional end-to-end:

- Add Interactable component to NPC spawn so E-prompt detection works
- Build monologue trigger system (enter_location + time_idle) with
  MonologueBuffer/MonologueState components, wire through ObserverSnapshot
  as current_monologue field, decode on client and display via HUD
- Change PlayerAction::Interact from unit to struct variant carrying
  optional target_entity_id and verb fields

Bumps protocol version from 4 to 5. Regenerates MessagePack fixtures.
All 200 tests pass (170 unit + 30 integration).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 19:23:47 +01:00
co-authored by Claude Opus 4.6
parent 8228ef3c1c
commit 3c106aa2d0
27 changed files with 480 additions and 30 deletions
+10 -7
View File
@@ -19,6 +19,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
entities,
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
}
}
@@ -41,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, 4);
assert_eq!(decoded.version, PROTOCOL_VERSION);
assert_eq!(decoded.tick, 42);
assert_eq!(decoded.entities.len(), 1);
assert_eq!(decoded.entities[0].entity_id, 1);
@@ -83,7 +84,7 @@ fn all_player_action_variants_roundtrip() {
PlayerAction::MoveNorthwest,
PlayerAction::MoveSoutheast,
PlayerAction::MoveSouthwest,
PlayerAction::Interact,
PlayerAction::Interact { target_entity_id: None, verb: None },
PlayerAction::UsePerceptionMode("thermal".to_string()),
PlayerAction::Pause,
PlayerAction::Unpause,
@@ -128,7 +129,7 @@ fn all_fixtures_deserialize() {
if name.starts_with("snapshot") {
let snap = rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
assert_eq!(snap.version, 4, "fixture {} has wrong version", name);
assert_eq!(snap.version, PROTOCOL_VERSION, "fixture {} has wrong version", name);
} else if name.starts_with("input_batch") {
rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes)
.unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e));
@@ -180,7 +181,7 @@ fn all_entity_kind_variants_roundtrip() {
#[test]
fn snapshot_v2_fields_roundtrip() {
let snapshot = ObserverSnapshot {
version: 4,
version: PROTOCOL_VERSION,
tick: 100,
game_time: GameTime {
day: 3,
@@ -216,12 +217,13 @@ fn snapshot_v2_fields_roundtrip() {
},
],
nearby_interactions: vec![],
current_monologue: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.version, 4);
assert_eq!(decoded.version, PROTOCOL_VERSION);
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);
@@ -259,7 +261,7 @@ fn entity_to_bits_roundtrip() {
fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(PROTOCOL_VERSION, 4, "bump this assertion when protocol version changes");
assert_eq!(PROTOCOL_VERSION, 5, "bump this assertion when protocol version changes");
}
/// All FacingDirection variants round-trip
@@ -278,7 +280,7 @@ fn all_facing_direction_variants_roundtrip() {
for dir in directions {
let snapshot = ObserverSnapshot {
version: 4,
version: PROTOCOL_VERSION,
tick: 0,
game_time: GameTime {
day: 0,
@@ -290,6 +292,7 @@ fn all_facing_direction_variants_roundtrip() {
entities: vec![],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");