diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 0d9667ae2..62333e36b 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -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: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 9d31763a3..2e6d885b5 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -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 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 4c579ee1d..3feca31a5 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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) diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index bb69783bc..dc2be42d4 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -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, } diff --git a/client/tests/fixtures/msgpack/input_batch_two.msgpack b/client/tests/fixtures/msgpack/input_batch_two.msgpack index 4bdca573b..895f9f080 100644 Binary files a/client/tests/fixtures/msgpack/input_batch_two.msgpack and b/client/tests/fixtures/msgpack/input_batch_two.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index adfee537e..3868af268 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 09c0b91be..51037146a 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 1a3d25c12..708014458 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 8c2e99937..e759cbf5f 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 7fe0e2d3d..cb6219705 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/decisions/architecture.md b/decisions/architecture.md index db82506c7..47850ea0c 100644 --- a/decisions/architecture.md +++ b/decisions/architecture.md @@ -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* diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 644fada87..5c3dd298e 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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), diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 056bc47f0..9fd31e2cf 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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, + /// 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, } /// 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, + verb: Option, + }, 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 { diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 2795887a3..9d65283f5 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -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) { diff --git a/server/src/main.rs b/server/src/main.rs index 96991b5e6..7eb3c6f1b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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, diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 338db3c9b..100be305b 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -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); diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index dff298646..35a480100 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -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); diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index ad5eb1716..6de16c552 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -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, registry: Res, mut observer_query: Query< - (&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer), + (&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer), With, >, all_entities: Query<( @@ -60,7 +61,7 @@ pub fn compute_observer_snapshot( )>, mut buffer: ResMut, ) { - 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, }); } diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index 78cb50bf0..dd70ac97e 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -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::(); 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::(); @@ -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); diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 25488cfba..be0670ac7 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -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); diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 492d2400a..c78854d78 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -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; diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs new file mode 100644 index 000000000..4e2421542 --- /dev/null +++ b/server/src/simulation/monologue.rs @@ -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, + /// 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, +} + +impl MonologueBuffer { + /// Drain and return the monologue event, leaving the buffer empty. + pub fn take(&mut self) -> Option { + 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, + content: Option>, + mut rng: ResMut, + mut query: Query< + (&TilePosition, &mut MonologueState, &mut MonologueBuffer), + With, + >, +) { + 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::(); + 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::().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::().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"); + } +} diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 43bc305cf..ab88147f8 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -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"), } } diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 1df011156..3373445b9 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -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"), } } diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 76e634e36..721aefc6a 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -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); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 9cf73a5c9..862e2a8b8 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -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) -> ObserverSnapshot { ObserverSnapshot { - version: 4, + version: PROTOCOL_VERSION, tick, game_time: GameTime { day: 0, @@ -30,6 +30,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> 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( diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index fc2dfeef2..06063c043 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -19,6 +19,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> 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::(&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::>(&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");