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
+9
View File
@@ -22,6 +22,9 @@ var player_entity_id: int = 1
# v4 fields (#404/#405)
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
# v5 fields (#414)
var current_monologue: Variant = null # {id, text, duration_seconds} or null
func apply_snapshot(snapshot: Dictionary) -> void:
current_snapshot = snapshot
@@ -73,6 +76,12 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
nearby_interactions = []
# v5: current_monologue (#414)
if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary:
current_monologue = snapshot.current_monologue
else:
current_monologue = null
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+10
View File
@@ -309,6 +309,15 @@ func _test_snapshot() -> Dictionary:
],
})
# v5: monologue on first tick (#414)
var monologue: Variant = null
if _test_tick == 1:
monologue = {
"id": "test_enter_001",
"text": "Sova Transit District. Population twelve thousand and change.",
"duration_seconds": 5.0,
}
return {
"tick": _test_tick,
"version": Protocol.PROTOCOL_VERSION,
@@ -324,6 +333,7 @@ func _test_snapshot() -> Dictionary:
"visible_tiles": _test_visible_tiles(),
"visible_positions": _test_visible_positions(),
"nearby_interactions": nearby,
"current_monologue": monologue,
}
# Generate a small test room: 8x6 room with walls, a door, and floor
+12
View File
@@ -22,6 +22,12 @@ func _process(_delta: float) -> void:
if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state()
# Show monologue if server sent one this tick (#414)
if GameState.current_monologue != null and monologue_display:
var mono: Dictionary = GameState.current_monologue
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
GameState.current_monologue = null # Consume — don't re-show next frame
# Track camera to player position every frame (D-015: locked, no panning)
# Camera2D smoothing handles interpolation — we just set the target
camera.global_position = GameState.player_position * Constants.TILE_SIZE
@@ -31,9 +37,15 @@ func _process(_delta: float) -> void:
for input in inputs:
if input.action == InputMapper.Action.INTERACT:
var target_id: int = interaction_prompt.get_interaction_target()
# Always send struct form for Interact (#415) — server expects named fields
if target_id >= 0:
input["action_data"] = {
"target_entity_id": target_id,
"verb": interaction_prompt.get_selected_verb(),
}
else:
input["action_data"] = {
"target_entity_id": null,
"verb": null,
}
SimBridge.send_input(input)
+12 -1
View File
@@ -11,7 +11,7 @@ class_name Protocol
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 4
const PROTOCOL_VERSION: int = 5
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -94,6 +94,16 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
if ni != null:
nearby_interactions.append(ni)
# v5: current_monologue (#414)
var current_monologue: Variant = null
var raw_monologue: Variant = raw.get("current_monologue")
if raw_monologue is Dictionary and raw_monologue.has("text"):
current_monologue = {
"id": str(raw_monologue.get("id", "")),
"text": str(raw_monologue["text"]),
"duration_seconds": float(raw_monologue.get("duration_seconds", 5.0)),
}
return {
"tick": tick,
"entities": entities,
@@ -103,6 +113,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"player_facing": player_facing,
"visible_tiles": visible_tiles,
"nearby_interactions": nearby_interactions,
"current_monologue": current_monologue,
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9 -1
View File
@@ -132,6 +132,14 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- **Raised by:** Tyre (architecture synthesis), Dudley (implementation analysis), Gestalt (mechanics validation), Paula (narrative requirements). Workshop participants: Tyre, Dudley, Gestalt, Paula, SI. Source: Knowledge Graph & Information Boundaries Workshop (Epic #351), 2026-02-11.
- **Dissent:** None. Gestalt initially proposed dual-axis model (confidence + understanding) but validated that single-axis confidence with future content-driven understanding progression is architecturally sufficient. Dudley proposed HashMap; synthesis chose BTreeMap per D-010 principle 4 with Dudley's explicit acknowledgment ("iteration order is a determinism time bomb").
### D-042: UI microcopy format — YAML via GDScript autoload
- **Date:** 2026-02-13
- **Decision:** UI strings (interaction prompt labels, knowledge panel labels, relationship state descriptors, HUD labels, tutorial text) are stored in YAML format at `client/data/ui-strings.yaml` and loaded via a dedicated GDScript autoload singleton (`UIStrings`). UI strings are NOT hardcoded as GDScript constants in `client/scripts/constants/ui_strings.gd`.
- **Rationale:** YAML format enables editing UI text without rebuilding the client and supports future localization infrastructure (all player-facing text in one format). UI microcopy is **client-side rendering data** per D-020 (Godot is the renderer) — distinct from server-side game content (dialogue/monologue lines). UI labels are presentation metadata that never cross the protocol boundary, so they live in the client repository and load via a client-side autoload rather than the content loader system. Hardcoded constants would require client recompilation for copy edits.
- **Related ticket:** #409 (UI microcopy)
- **Raised by:** Team decision in Sprint 5 planning
- **Dissent:** None
---
*9 decisions. Last updated: 2026-02-11*
*10 decisions. Last updated: 2026-02-13*
+3
View File
@@ -159,9 +159,12 @@ impl Plugin for BridgePlugin {
.after(crate::simulation::movement::validate_movement),
crate::simulation::interaction::compute_nearby_interactions
.after(crate::simulation::movement::validate_movement),
crate::simulation::monologue::trigger_monologue
.after(crate::simulation::movement::validate_movement),
crate::perception::observer::compute_observer_snapshot
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::interaction::compute_nearby_interactions)
.after(crate::simulation::monologue::trigger_monologue)
.before(crate::simulation::time::advance_tick),
crate::perception::observation::emit_observation_events
.after(crate::perception::observer::compute_observer_snapshot),
+25 -4
View File
@@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 4;
pub const PROTOCOL_VERSION: u8 = 5;
/// The ONLY data structure crossing the client-server boundary (D-020)
/// Contains all information visible to the observer at a given tick.
@@ -23,8 +23,8 @@ pub const PROTOCOL_VERSION: u8 = 4;
/// 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).
/// v5 adds: current_monologue (#414 internal monologue pipeline).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 4.
@@ -42,6 +42,10 @@ pub struct ObserverSnapshot {
/// 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.
pub nearby_interactions: Vec<NearbyInteraction>,
/// Internal monologue line to display this tick, if any (#414).
/// None when no monologue is triggered. Client shows text and auto-fades.
#[serde(default)]
pub current_monologue: Option<MonologueEvent>,
}
/// Game time data for client display (D-031)
@@ -159,7 +163,12 @@ pub enum PlayerAction {
MoveNorthwest,
MoveSoutheast,
MoveSouthwest,
Interact,
/// Player pressed E on a nearby entity. Carries target + verb from client.
/// v0.1: logged only — full dialogue dispatch is future scope (#415).
Interact {
target_entity_id: Option<u64>,
verb: Option<String>,
},
UsePerceptionMode(String),
Pause,
Unpause,
@@ -204,6 +213,18 @@ pub enum VerbKind {
Talk,
}
/// Internal monologue event sent to the client for display (#414).
/// Contains the text and display duration. Client auto-fades after duration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonologueEvent {
/// Monologue line ID (for dedup and cooldown tracking)
pub id: String,
/// The monologue text to display
pub text: String,
/// Display duration in seconds before fade
pub duration_seconds: f32,
}
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
#[derive(Resource, Debug, Default)]
pub struct SnapshotBuffer {
+4
View File
@@ -28,6 +28,7 @@ use crate::knowledge::types::{
FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState, StableId,
};
use crate::npc;
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::time::DayPhase;
@@ -89,6 +90,9 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
// Default position — will be overridden by routine system on first phase transition
entity_commands.insert(TilePosition::new(0, 0, 0));
// Mark NPC as interactable for proximity-based verb detection (#413)
entity_commands.insert(Interactable);
// Axis 1: Want
if let Some(want) = &profile.want {
if let Some(kind) = parse_want_kind(&want.primary) {
+8 -1
View File
@@ -14,7 +14,8 @@ use settled_reach_server::npc::{
Want, WantKind,
};
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::time::DayPhase;
@@ -50,6 +51,7 @@ fn main() {
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.add_plugins(NpcPlugin);
app.add_plugins(settled_reach_server::content::ContentPlugin);
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
@@ -70,6 +72,8 @@ fn main() {
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -79,6 +83,7 @@ fn main() {
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(16, 13, 0),
Want {
primary: WantKind::Wealth,
@@ -125,6 +130,7 @@ fn main() {
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(14, 18, 0),
Want {
primary: WantKind::Knowledge,
@@ -161,6 +167,7 @@ fn main() {
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(18, 14, 0),
Want {
primary: WantKind::Safety,
+5
View File
@@ -238,6 +238,7 @@ mod tests {
Facing(FacingDirection::North),
KnowledgeGraph::new(),
crate::simulation::interaction::NearbyInteractionBuffer::default(),
crate::simulation::monologue::MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -289,6 +290,7 @@ mod tests {
Facing(FacingDirection::North),
KnowledgeGraph::new(),
crate::simulation::interaction::NearbyInteractionBuffer::default(),
crate::simulation::monologue::MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -360,6 +362,7 @@ mod tests {
Facing(FacingDirection::North),
kg,
crate::simulation::interaction::NearbyInteractionBuffer::default(),
crate::simulation::monologue::MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -393,6 +396,7 @@ mod tests {
Facing(FacingDirection::North),
KnowledgeGraph::new(), // Empty — never seen anyone
crate::simulation::interaction::NearbyInteractionBuffer::default(),
crate::simulation::monologue::MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -440,6 +444,7 @@ mod tests {
Facing(FacingDirection::North),
kg,
crate::simulation::interaction::NearbyInteractionBuffer::default(),
crate::simulation::monologue::MonologueBuffer::default(),
))
.id();
registry.register(player);
+3
View File
@@ -119,6 +119,7 @@ mod tests {
Facing(FacingDirection::North),
KnowledgeGraph::new(),
crate::simulation::interaction::NearbyInteractionBuffer::default(),
crate::simulation::monologue::MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -167,6 +168,7 @@ mod tests {
Facing(FacingDirection::North),
kg,
crate::simulation::interaction::NearbyInteractionBuffer::default(),
crate::simulation::monologue::MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -208,6 +210,7 @@ mod tests {
Facing::default(),
KnowledgeGraph::new(),
crate::simulation::interaction::NearbyInteractionBuffer::default(),
crate::simulation::monologue::MonologueBuffer::default(),
))
.id();
registry.register(player);
+6 -2
View File
@@ -14,6 +14,7 @@ use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId};
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::monologue::MonologueBuffer;
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::time::SimulationTime;
@@ -49,7 +50,7 @@ pub fn compute_observer_snapshot(
geometry: Res<VisibilityGeometry>,
registry: Res<EntityRegistry>,
mut observer_query: Query<
(&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer),
(&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer),
With<PlayerCharacter>,
>,
all_entities: Query<(
@@ -60,7 +61,7 @@ pub fn compute_observer_snapshot(
)>,
mut buffer: ResMut<SnapshotBuffer>,
) {
let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer)) =
let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer)) =
observer_query.single_mut()
else {
return;
@@ -101,6 +102,8 @@ pub fn compute_observer_snapshot(
geometry.visible_tiles.len(),
);
let current_monologue = monologue_buffer.take();
buffer.snapshot = Some(ObserverSnapshot {
version: crate::bridge::types::PROTOCOL_VERSION,
tick: time.tick,
@@ -109,6 +112,7 @@ pub fn compute_observer_snapshot(
entities,
visible_tiles: geometry.visible_tiles.clone(),
nearby_interactions,
current_monologue,
});
}
+18 -1
View File
@@ -2,6 +2,7 @@ use super::*;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use crate::simulation::monologue::MonologueBuffer;
use bevy_ecs::world::World;
/// Helper: set up a test world with resources for the two-stage observer pipeline.
@@ -48,13 +49,14 @@ fn player_always_visible_in_snapshot() {
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
assert_eq!(snapshot.version, 4);
assert_eq!(snapshot.version, 5);
assert_eq!(snapshot.entities.len(), 1);
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible);
@@ -69,6 +71,7 @@ fn npc_in_los_visible() {
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC directly north of player (in forward cone)
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
@@ -96,6 +99,7 @@ fn npc_behind_wall_not_visible() {
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// Wall between player and NPC
let mut walkability = world.resource_mut::<WalkabilityMap>();
@@ -125,6 +129,7 @@ fn npc_behind_player_not_visible() {
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC far behind player (south, in blind spot)
world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)));
@@ -150,6 +155,7 @@ fn different_z_level_not_visible() {
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC on different z-level
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1)));
@@ -179,6 +185,7 @@ fn game_time_populated() {
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
@@ -202,6 +209,7 @@ fn visible_tiles_populated() {
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
@@ -241,6 +249,7 @@ fn visible_npc_has_relationship_from_knowledge() {
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
world.insert_resource(registry);
@@ -283,6 +292,7 @@ fn remembered_entity_appears_as_ghost() {
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -336,6 +346,7 @@ fn direct_confidence_not_shown_as_remembered() {
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -386,6 +397,7 @@ fn remembered_entity_on_visible_tile_not_shown() {
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -430,6 +442,7 @@ fn remembered_entity_different_z_not_shown() {
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -483,6 +496,7 @@ fn knowledge_without_position_not_shown() {
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
@@ -513,6 +527,7 @@ fn multiple_npcs_in_los_all_visible() {
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// Three NPCs in front of player, no walls
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
@@ -548,6 +563,7 @@ fn npc_behind_wall_excluded_from_multi_entity_snapshot() {
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC 1: behind wall (should be hidden)
world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0)));
@@ -596,6 +612,7 @@ fn poi_interaction_gets_observe_first_priority() {
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
+7 -3
View File
@@ -86,8 +86,12 @@ pub fn process_player_input(
time.tick_rate = rate;
tracing::debug!("Tick rate set to {:?} by player input", rate);
}
PlayerAction::Interact => {
tracing::trace!("Interact action — no-op for Sprint 1");
PlayerAction::Interact { target_entity_id, verb } => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
target_entity_id,
verb,
);
}
PlayerAction::UsePerceptionMode(ref mode) => {
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
@@ -131,7 +135,7 @@ mod tests {
});
queue.push(PlayerInput {
tick: 5,
action: PlayerAction::Interact,
action: PlayerAction::Interact { target_entity_id: None, verb: None },
});
let inputs = queue.drain_for_tick(3);
assert_eq!(inputs.len(), 2);
+1
View File
@@ -6,6 +6,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod input;
pub mod interaction;
pub mod monologue;
pub mod movement;
pub mod path_follow;
pub mod pathfinding;
+321
View File
@@ -0,0 +1,321 @@
// Internal monologue trigger system (#414)
//
// Selects monologue lines from loaded content pools based on trigger conditions.
// v0.1: enter_location (on first tick) + time_idle (periodic when player hasn't moved).
// Lines are written to MonologueBuffer for inclusion in ObserverSnapshot.
use bevy_ecs::prelude::*;
use rand::Rng;
use crate::bridge::types::MonologueEvent;
use crate::content::ContentStoreResource;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
/// Minimum ticks between monologue lines (prevents spam).
/// At 10 ticks/game-minute, 300 ticks = 30 game-minutes.
const COOLDOWN_TICKS: u64 = 300;
/// Ticks of idle (no movement) before a time_idle monologue fires.
/// 100 ticks = 10 game-minutes.
const IDLE_THRESHOLD_TICKS: u64 = 100;
/// Display duration for monologue text on client (seconds).
const DISPLAY_DURATION: f32 = 5.0;
/// Tracks monologue state for cooldown and trigger detection.
/// Attached to the PlayerCharacter entity.
#[derive(Component, Debug)]
pub struct MonologueState {
/// Tick when the last monologue was fired.
pub last_fired_tick: u64,
/// Player position on the previous tick (for movement detection).
pub last_position: Option<(i32, i32)>,
/// Ticks since the player last moved (for time_idle trigger).
pub idle_ticks: u64,
/// Whether the enter_location monologue has fired this session.
pub entered: bool,
/// IDs of lines already shown (dedup within session).
pub shown_ids: Vec<String>,
/// Character type for pool filtering. v0.1: always "detective".
pub character: String,
}
impl Default for MonologueState {
fn default() -> Self {
Self {
last_fired_tick: 0,
last_position: None,
idle_ticks: 0,
entered: false,
shown_ids: Vec::new(),
// v0.1: default to detective; character selection sets this
character: "detective".to_string(),
}
}
}
/// Buffer holding the monologue event to include in the next snapshot.
/// `take()` drains the buffer (consumed once per snapshot).
#[derive(Component, Debug, Default)]
pub struct MonologueBuffer {
event: Option<MonologueEvent>,
}
impl MonologueBuffer {
/// Drain and return the monologue event, leaving the buffer empty.
pub fn take(&mut self) -> Option<MonologueEvent> {
self.event.take()
}
}
/// Monologue trigger system.
///
/// Runs each tick. Checks trigger conditions against loaded content pools
/// and writes a MonologueEvent to MonologueBuffer when a line should fire.
///
/// v0.1 triggers:
/// - `enter_location`: fires once on first tick (session start)
/// - `time_idle`: fires after IDLE_THRESHOLD_TICKS of no player movement
pub fn trigger_monologue(
time: Res<SimulationTime>,
content: Option<Res<ContentStoreResource>>,
mut rng: ResMut<SimRng>,
mut query: Query<
(&TilePosition, &mut MonologueState, &mut MonologueBuffer),
With<PlayerCharacter>,
>,
) {
let Some(content) = content else { return };
let Ok((pos, mut state, mut buffer)) = query.single_mut() else {
return;
};
// Track idle time
let current_pos = (pos.x, pos.y);
if let Some(last) = state.last_position {
if last == current_pos {
state.idle_ticks += 1;
} else {
state.idle_ticks = 0;
}
}
state.last_position = Some(current_pos);
// Cooldown check
if time.tick > 0 && time.tick - state.last_fired_tick < COOLDOWN_TICKS {
return;
}
// Determine which trigger to attempt
let trigger = if !state.entered {
state.entered = true;
Some("enter_location")
} else if state.idle_ticks >= IDLE_THRESHOLD_TICKS {
Some("time_idle")
} else {
None
};
let Some(trigger) = trigger else { return };
// Collect candidate lines from all district monologue pools
let character = state.character.as_str();
let mut candidates: Vec<(&str, &str)> = Vec::new(); // (id, text)
for (_district_id, district) in &content.0.districts {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != trigger {
continue;
}
if state.shown_ids.contains(&line.id) {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
if candidates.is_empty() {
// All lines for this trigger have been shown; allow repeats
for (_district_id, district) in &content.0.districts {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != trigger {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
}
if candidates.is_empty() {
return;
}
// Select a random line
let index = rng.rng.random_range(0..candidates.len());
let (id, text) = candidates[index];
buffer.event = Some(MonologueEvent {
id: id.to_string(),
text: text.to_string(),
duration_seconds: DISPLAY_DURATION,
});
state.shown_ids.push(id.to_string());
state.last_fired_tick = time.tick;
// Reset idle counter so time_idle doesn't fire again immediately
state.idle_ticks = 0;
tracing::debug!(
"Monologue fired: trigger={}, id={}, tick={}",
trigger,
id,
time.tick
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content::loader::{ContentStore, DistrictContent};
use crate::content::types::{MonologueLine, MonologuePool};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use bevy_ecs::world::World;
fn setup_world_with_content() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
// Create test monologue content
let pool = MonologuePool {
character: "detective".to_string(),
location: "general".to_string(),
lines: vec![
MonologueLine {
id: "test_enter_001".to_string(),
text: "Sova Transit District. Let's narrow that down.".to_string(),
trigger: "enter_location".to_string(),
prerequisites: None,
priority: None,
cooldown: None,
tags: vec![],
},
MonologueLine {
id: "test_idle_001".to_string(),
text: "Everyone knows I'm Commission.".to_string(),
trigger: "time_idle".to_string(),
prerequisites: None,
priority: None,
cooldown: None,
tags: vec![],
},
],
};
let mut district = DistrictContent::default();
district.monologue_pools.push(pool);
let mut store = ContentStore::default();
store.districts.insert("test".to_string(), district);
world.insert_resource(ContentStoreResource(store));
world
}
#[test]
fn enter_location_fires_on_first_tick() {
let mut world = setup_world_with_content();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_some());
let event = buffer.event.as_ref().unwrap();
assert_eq!(event.id, "test_enter_001");
}
#[test]
fn cooldown_prevents_spam() {
let mut world = setup_world_with_content();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_monologue);
// First tick: should fire enter_location
schedule.run(&mut world);
// Consume the buffer
let mut query = world.query::<&mut MonologueBuffer>();
query.single_mut(&mut world).unwrap().take();
// Advance a few ticks (still in cooldown)
world.resource_mut::<SimulationTime>().tick = 10;
// Set idle ticks high to try to trigger time_idle
let mut state_query = world.query::<&mut MonologueState>();
state_query.single_mut(&mut world).unwrap().idle_ticks = IDLE_THRESHOLD_TICKS + 1;
schedule.run(&mut world);
// Should NOT fire — cooldown active
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_none());
}
#[test]
fn time_idle_fires_after_threshold() {
let mut world = setup_world_with_content();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState {
entered: true, // Skip enter_location
last_position: Some((5, 5)),
idle_ticks: IDLE_THRESHOLD_TICKS, // At threshold
..Default::default()
},
MonologueBuffer::default(),
));
// Advance past cooldown
world.resource_mut::<SimulationTime>().tick = COOLDOWN_TICKS + 1;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_some());
let event = buffer.event.as_ref().unwrap();
assert_eq!(event.id, "test_idle_001");
}
}
+4 -3
View File
@@ -33,7 +33,7 @@ fn snapshot_roundtrip_over_unix_socket() {
let bridge = LocalBridge::accept(&server_path).expect("failed to accept");
let snapshot = ObserverSnapshot {
version: 4,
version: PROTOCOL_VERSION,
tick: 42,
game_time: GameTime {
day: 0,
@@ -54,6 +54,7 @@ fn snapshot_roundtrip_over_unix_socket() {
}],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
};
bridge
@@ -116,7 +117,7 @@ fn input_roundtrip_over_unix_socket() {
},
PlayerInput {
tick: 11,
action: PlayerAction::Interact,
action: PlayerAction::Interact { target_entity_id: None, verb: None },
},
];
@@ -134,7 +135,7 @@ fn input_roundtrip_over_unix_socket() {
_ => panic!("expected MoveNorth action"),
}
match &received_inputs[1].action {
PlayerAction::Interact => {}
PlayerAction::Interact { .. } => {}
_ => panic!("expected Interact action"),
}
}
+4 -3
View File
@@ -19,7 +19,7 @@ fn snapshot_roundtrip_over_tcp() {
let bridge = TcpBridge::accept_on(listener).expect("failed to accept");
let snapshot = ObserverSnapshot {
version: 4,
version: PROTOCOL_VERSION,
tick: 42,
game_time: GameTime {
day: 0,
@@ -40,6 +40,7 @@ fn snapshot_roundtrip_over_tcp() {
}],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
};
bridge
@@ -95,7 +96,7 @@ fn input_roundtrip_over_tcp() {
},
PlayerInput {
tick: 11,
action: PlayerAction::Interact,
action: PlayerAction::Interact { target_entity_id: None, verb: None },
},
];
@@ -112,7 +113,7 @@ fn input_roundtrip_over_tcp() {
_ => panic!("expected MoveNorth action"),
}
match &received_inputs[1].action {
PlayerAction::Interact => {}
PlayerAction::Interact { .. } => {}
_ => panic!("expected Interact action"),
}
}
+4 -1
View File
@@ -8,6 +8,7 @@ use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::SimulationPlugin;
use std::io::{BufReader, BufWriter};
@@ -36,6 +37,8 @@ fn player_moves_north_through_full_pipeline() {
TilePosition::new(16, 16, 0),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
MonologueState::default(),
));
// Run one tick: receive input, process, validate movement, generate snapshot, send
@@ -63,7 +66,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, 4);
assert_eq!(snapshot.version, 5);
assert_eq!(snapshot.tick, 0);
assert_eq!(snapshot.entities.len(), 1);
+5 -3
View File
@@ -18,7 +18,7 @@ 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: 4,
version: PROTOCOL_VERSION,
tick,
game_time: GameTime {
day: 0,
@@ -30,6 +30,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
entities,
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
}
}
@@ -151,7 +152,7 @@ fn generate_msgpack_fixtures() {
// v2 snapshot with visible_tiles and game_time populated
let snapshot_v2_full = ObserverSnapshot {
version: 4,
version: PROTOCOL_VERSION,
tick: 500,
game_time: GameTime {
day: 1,
@@ -194,6 +195,7 @@ fn generate_msgpack_fixtures() {
},
],
nearby_interactions: vec![],
current_monologue: None,
};
write_fixture(
"snapshot_v2_full",
@@ -208,7 +210,7 @@ fn generate_msgpack_fixtures() {
},
PlayerInput {
tick: 0,
action: PlayerAction::Interact,
action: PlayerAction::Interact { target_entity_id: None, verb: None },
},
];
write_fixture(
+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");