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:
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user