diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index b170cdb17..65e5c2fea 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -363,6 +363,8 @@ mod tests { line_id: "line_test".into(), text: "Welcome to the docks.".into(), speaker_entity_id: 100, + speaker_color_index: 0, + speaker_name: "Dock Worker".into(), }); let text = format_snapshot_text(&snap); assert!(text.contains("Dialogue: [npc:100] \"Welcome to the docks.\"")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 5c784529e..56db7ca1a 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -358,6 +358,9 @@ pub enum PlayerAction { ToggleStanceUp, /// Move one step down the stance ladder (toward Crouch) per D-053 ToggleStanceDown, + /// Update player facing without movement (D-054). Client sends when + /// the player turns in place (e.g. mouse aim, turn keys). + SetFacing { facing: String }, /// Teleport player to the Gauntlet hub spawn point (#491). /// Clears dialogue, monologue, and interaction buffers. /// Rejected with a log warning on non-Gauntlet maps. @@ -500,6 +503,12 @@ pub struct DialogueResponseEvent { pub text: String, /// Wire-format entity identifier of the speaking NPC pub speaker_entity_id: u64, + /// Color index (0-7) for the speaker's dialogue box header. + #[serde(default)] + pub speaker_color_index: u8, + /// Display name of the speaker (real name if known to player, else role label). + #[serde(default)] + pub speaker_name: String, } /// Snapshot buffer resource for staging outgoing ObserverSnapshots diff --git a/server/src/main.rs b/server/src/main.rs index 89e409881..8913c7b54 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -131,6 +131,11 @@ fn main() { app.add_plugins(BridgePlugin); app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin); app.add_plugins(settled_reach_server::npc::NpcPlugin); + // Content root is at the repo root, one level up from server/ + app.insert_resource(settled_reach_server::content::ContentConfig { + content_root: std::path::PathBuf::from("../content"), + hot_reload: false, + }); app.add_plugins(settled_reach_server::content::ContentPlugin); app.insert_resource(BridgeResource::new(bridge)); diff --git a/server/src/npc/mood.rs b/server/src/npc/mood.rs index c2bd26a7a..a46d21dc5 100644 --- a/server/src/npc/mood.rs +++ b/server/src/npc/mood.rs @@ -86,13 +86,13 @@ pub struct MoodState { /// the distinction matters for behavior but not for line selection. pub fn mood_to_content_mood(mood: NpcMood) -> ContentMood { match mood { - NpcMood::Neutral => ContentMood::Comfortable, - NpcMood::Anxious => ContentMood::Worried, - NpcMood::Frustrated => ContentMood::Conflicted, + NpcMood::Neutral => ContentMood::Content, + NpcMood::Anxious => ContentMood::Anxious, + NpcMood::Frustrated => ContentMood::Frustrated, NpcMood::Content => ContentMood::Relieved, NpcMood::Suspicious => ContentMood::Suspicious, - NpcMood::Warm => ContentMood::Fond, - NpcMood::Hostile => ContentMood::Concerned, + NpcMood::Warm => ContentMood::Warm, + NpcMood::Hostile => ContentMood::Hostile, NpcMood::Focused => ContentMood::Focused, } } @@ -425,13 +425,13 @@ mod tests { } #[test] - fn mood_mapping_anxious_is_worried() { - assert_eq!(mood_to_content_mood(NpcMood::Anxious), ContentMood::Worried); + fn mood_mapping_anxious_is_anxious() { + assert_eq!(mood_to_content_mood(NpcMood::Anxious), ContentMood::Anxious); } #[test] - fn mood_mapping_warm_is_fond() { - assert_eq!(mood_to_content_mood(NpcMood::Warm), ContentMood::Fond); + fn mood_mapping_warm_is_warm() { + assert_eq!(mood_to_content_mood(NpcMood::Warm), ContentMood::Warm); } #[test] @@ -480,7 +480,7 @@ mod tests { assert_eq!(mood_state.mood, NpcMood::Hostile); let current_mood = world.get::(npc).unwrap(); - assert_eq!(current_mood.0, ContentMood::Concerned); + assert_eq!(current_mood.0, ContentMood::Hostile); } #[test] @@ -602,7 +602,7 @@ mod tests { Npc, ActiveSim, MoodState::default(), - CurrentMood::default(), // Starts at Comfortable + CurrentMood::default(), // Starts at Content ToleranceThreshold { current_stress: 70, threshold: 100, // → Anxious (70% of 100) @@ -615,8 +615,8 @@ mod tests { schedule.run(&mut world); let current_mood = world.get::(npc).unwrap(); - // Anxious maps to Worried - assert_eq!(current_mood.0, ContentMood::Worried); + // Anxious maps to Anxious + assert_eq!(current_mood.0, ContentMood::Anxious); } #[test] diff --git a/server/src/simulation/conversation.rs b/server/src/simulation/conversation.rs index 90d7d04d2..739292e88 100644 --- a/server/src/simulation/conversation.rs +++ b/server/src/simulation/conversation.rs @@ -18,7 +18,9 @@ use serde::{Deserialize, Serialize}; use crate::knowledge::registry::StableEntityId; use crate::knowledge::types::SoundRange; use crate::knowledge::EntityRegistry; +use crate::knowledge::KnowledgeGraph; use crate::npc::Npc; +use crate::simulation::dialogue::DialogueProfile; use crate::simulation::listening::ListeningFocus; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::rng::SimRng; @@ -62,6 +64,28 @@ const LINE_INTERVAL_TICKS: u64 = 20; #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct NpcName(pub String); +/// Map a dialogue role string to a display label for use when the player +/// does not yet know the NPC's real name. +pub fn display_label_for_role(role: &str) -> String { + match role { + "dock-worker" => "Dock Worker", + "courier" => "Courier", + "maintenance-tech" => "Technician", + "new-hire" | "day-worker" | "transit-worker" => "Worker", + "scheduler" => "Scheduler", + "shift-supervisor" => "Supervisor", + "bartender" => "Bartender", + "bar-regular" => "Patron", + _ => "Bystander", + } + .to_string() +} + +/// Color index (0-7) for rendering this NPC with a distinct color in the +/// conversation log. Assigned at spawn time as `(stable_id % 8)`. +#[derive(Component, Debug, Clone, Copy, Serialize, Deserialize)] +pub struct NpcColorIndex(pub u8); + /// Active NPC-to-NPC conversation session. /// Attached to the "speaker" NPC (the one who initiated). /// The "listener" is tracked by entity reference. @@ -100,10 +124,18 @@ pub struct ConversationEvent { pub speaker_id: u64, /// Wire-format entity ID of the NPC being spoken to. pub target_id: u64, - /// Display name of the speaker. + /// Display name of the speaker (real name if known to player, else role label). + #[serde(default)] pub speaker_name: String, - /// Display name of the target. + /// Display name of the target (real name if known to player, else role label). + #[serde(default)] pub target_name: String, + /// Color index (0-7) for the speaker's conversation log entry. + #[serde(default)] + pub speaker_color_index: u8, + /// Color index (0-7) for the target's conversation log entry. + #[serde(default)] + pub target_color_index: u8, } /// End-of-conversation event. Client dismisses the passive dialogue panel. @@ -257,6 +289,8 @@ pub fn run_npc_conversations( Option<&NpcConversation>, Option<&ConversationCooldown>, Option<&StableEntityId>, + Option<&NpcColorIndex>, + Option<&DialogueProfile>, ), (With, With), >, @@ -266,6 +300,7 @@ pub fn run_npc_conversations( &TilePosition, Option<&ListeningFocus>, &mut ConversationEventBuffer, + &KnowledgeGraph, ), With, >, @@ -276,13 +311,13 @@ pub fn run_npc_conversations( // Sorted by StableId for deterministic pairing order (D-010). let mut eligible: Vec<(Entity, TilePosition, u64)> = npc_query .iter() - .filter(|(_, _, _, conv, cooldown, _)| { + .filter(|(_, _, _, conv, cooldown, _, _, _)| { conv.is_none() && cooldown .map(|cd| time.tick >= cd.until_tick) .unwrap_or(true) }) - .map(|(entity, pos, _, _, _, sid)| { + .map(|(entity, pos, _, _, _, sid, _, _)| { (entity, *pos, sid.map(|s| s.0 .0).unwrap_or(u64::MAX)) }) .collect(); @@ -338,28 +373,39 @@ pub fn run_npc_conversations( // --- Phase 2: Tick active conversations --- - // Collect active conversations — need mutable access later, so collect first - let active_conversations: Vec<(Entity, NpcConversation, TilePosition, Option)> = - npc_query - .iter() - .filter_map(|(entity, pos, name, conv, _, _)| { - conv.map(|c| { - ( - entity, - NpcConversation { - partner: c.partner, - started_tick: c.started_tick, - end_tick: c.end_tick, - ticks_since_last_line: c.ticks_since_last_line, - }, - *pos, - name.map(|n| n.0.clone()), - ) - }) + // Collect active conversations — need mutable access later, so collect first. + // Tuple: (entity, conv, pos, npc_real_name, npc_role, npc_color_index) + let active_conversations: Vec<( + Entity, + NpcConversation, + TilePosition, + Option, + Option, + Option, + )> = npc_query + .iter() + .filter_map(|(entity, pos, name, conv, _, _, color_idx, profile)| { + conv.map(|c| { + ( + entity, + NpcConversation { + partner: c.partner, + started_tick: c.started_tick, + end_tick: c.end_tick, + ticks_since_last_line: c.ticks_since_last_line, + }, + *pos, + name.map(|n| n.0.clone()), + profile.map(|p| p.role.clone()), + color_idx.map(|ci| ci.0), + ) }) - .collect(); + }) + .collect(); - for (speaker_entity, conv, speaker_pos, speaker_name) in &active_conversations { + for (speaker_entity, conv, speaker_pos, speaker_name, speaker_role, speaker_color) in + &active_conversations + { let speaker_entity = *speaker_entity; // Check termination: duration expired @@ -376,7 +422,7 @@ pub fn run_npc_conversations( } // Check termination: partner moved away or no longer ActiveSim - let partner_ok = npc_query.get(conv.partner).ok().map(|(_, pos, _, _, _, _)| { + let partner_ok = npc_query.get(conv.partner).ok().map(|(_, pos, _, _, _, _, _, _)| { speaker_pos .manhattan_distance(pos) .map(|d| d <= CONVERSATION_PROXIMITY) @@ -415,16 +461,26 @@ pub fn run_npc_conversations( let line_idx = rng.rng.random_range(0..NPC_CONVERSATION_LINES.len()); let line_text = NPC_CONVERSATION_LINES[line_idx]; - // Get partner name - let partner_name = npc_query + // Collect partner display info (real name, role, color) once — used + // per-observer below to resolve display names against each observer's KG. + let (partner_real_name, partner_role, partner_color) = npc_query .get(conv.partner) .ok() - .and_then(|(_, _, name, _, _, _)| name.map(|n| n.0.clone())) - .unwrap_or_else(|| "Unknown".to_string()); + .map(|(_, _, pname, _, _, _, pcolor, pprofile)| { + ( + pname.map(|n| n.0.clone()), + pprofile.map(|p| p.role.clone()), + pcolor.map(|ci| ci.0).unwrap_or(0u8), + ) + }) + .unwrap_or((None, None, 0u8)); + + let speaker_sid = registry.to_stable(speaker_entity); + let target_sid = registry.to_stable(conv.partner); // Compute per-observer occlusion (D-078, D-010 principle 3). // Iterates all observers — supports future multi-observer scenarios (D-027). - for (player_pos, listening_focus_opt, mut conv_buffer) in + for (player_pos, listening_focus_opt, mut conv_buffer, player_kg) in player_query.iter_mut() { let distance = speaker_pos @@ -445,18 +501,47 @@ pub fn run_npc_conversations( compute_drop_probability(distance, ambient_noise_pct, listening); let occluded = occlude_line(line_text, drop_pct, &mut rng.rng); - let speaker_sid = registry.to_stable(speaker_entity); - let target_sid = registry.to_stable(conv.partner); - if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) { + // Resolve speaker display name per this observer's KG. + let speaker_display = { + let known = player_kg + .entity_knowledge(&s_sid) + .map(|e| e.known_attributes.contains_key("name")) + .unwrap_or(false); + if known { + speaker_name.clone().unwrap_or_else(|| "Unknown".to_string()) + } else { + speaker_role + .as_deref() + .map(|r| display_label_for_role(r)) + .unwrap_or_else(|| "Bystander".to_string()) + } + }; + + // Resolve target display name per this observer's KG. + let target_display = { + let known = player_kg + .entity_knowledge(&t_sid) + .map(|e| e.known_attributes.contains_key("name")) + .unwrap_or(false); + if known { + partner_real_name.clone().unwrap_or_else(|| "Unknown".to_string()) + } else { + partner_role + .as_deref() + .map(|r| display_label_for_role(r)) + .unwrap_or_else(|| "Bystander".to_string()) + } + }; + conv_buffer.events.push(ConversationEvent { occluded_line: occluded, speaker_id: s_sid.0, target_id: t_sid.0, - speaker_name: speaker_name - .clone() - .unwrap_or_else(|| "Unknown".to_string()), - target_name: partner_name.clone(), + speaker_name: speaker_display, + target_name: target_display, + speaker_color_index: speaker_color.unwrap_or(0), + target_color_index: partner_color, }); } } @@ -490,6 +575,7 @@ fn terminate_conversation( &TilePosition, Option<&ListeningFocus>, &mut ConversationEventBuffer, + &KnowledgeGraph, ), With, >, @@ -513,7 +599,7 @@ fn terminate_conversation( let target_sid = registry.to_stable(partner); if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) { - for (_, _, mut conv_buffer) in player_query.iter_mut() { + for (_, _, mut conv_buffer, _) in player_query.iter_mut() { conv_buffer.ended.push(ConversationEndEvent { speaker_id: s_sid.0, target_id: t_sid.0, @@ -535,6 +621,7 @@ fn terminate_conversation( #[cfg(test)] mod tests { use super::*; + use crate::knowledge::KnowledgeGraph; use rand::SeedableRng; use rand_chacha::ChaCha20Rng; @@ -685,6 +772,7 @@ mod tests { PlayerCharacter, TilePosition::new(5, 8, 0), // distance 3 from speaker ConversationEventBuffer::default(), + KnowledgeGraph::new(), )) .id(); world.resource_mut::().register(player); @@ -709,8 +797,13 @@ mod tests { 1, "Player in range should receive a conversation event" ); - assert_eq!(buffer.events[0].speaker_name, "Alice"); - assert_eq!(buffer.events[0].target_name, "Bob"); + // Player KG has no "name" attribute for either NPC, and NPCs have no + // DialogueProfile, so both should fall back to the Bystander label. + assert_eq!(buffer.events[0].speaker_name, "Bystander"); + assert_eq!(buffer.events[0].target_name, "Bystander"); + // Color index defaults to 0 when NpcColorIndex is not attached. + assert_eq!(buffer.events[0].speaker_color_index, 0); + assert_eq!(buffer.events[0].target_color_index, 0); } #[test] @@ -751,6 +844,7 @@ mod tests { PlayerCharacter, TilePosition::new(5, 5, 0), ConversationEventBuffer::default(), + KnowledgeGraph::new(), )) .id(); world.resource_mut::().register(player); @@ -807,6 +901,7 @@ mod tests { PlayerCharacter, TilePosition::new(5, 5, 0), ConversationEventBuffer::default(), + KnowledgeGraph::new(), )) .id(); world.resource_mut::().register(player); @@ -860,6 +955,7 @@ mod tests { PlayerCharacter, TilePosition::new(30, 30, 0), ConversationEventBuffer::default(), + KnowledgeGraph::new(), )) .id(); world.resource_mut::().register(player); @@ -933,6 +1029,7 @@ mod tests { PlayerCharacter, TilePosition::new(5, 5, 0), ConversationEventBuffer::default(), + KnowledgeGraph::new(), )) .id(); world.resource_mut::().register(player); @@ -999,6 +1096,7 @@ mod tests { PlayerCharacter, TilePosition::new(5, 5, 0), ConversationEventBuffer::default(), + KnowledgeGraph::new(), )) .id(); world.resource_mut::().register(player); @@ -1044,6 +1142,7 @@ mod tests { PlayerCharacter, TilePosition::new(5, 5, 0), ConversationEventBuffer::default(), + KnowledgeGraph::new(), )); // Run many ticks — no conversation should ever start because all NPCs are on cooldown @@ -1087,6 +1186,7 @@ mod tests { PlayerCharacter, TilePosition::new(5, 5, 0), ConversationEventBuffer::default(), + KnowledgeGraph::new(), )); // With 2% chance per tick, over 300 ticks a conversation is extremely likely. @@ -1113,6 +1213,172 @@ mod tests { ); } + // -- Name masking tests (Sprint 15) -------------------------------------- + + #[test] + fn conversation_uses_role_label_when_name_not_in_player_kg() { + // Player KG has an entry for the NPC but no "name" attribute. + // ConversationEvent.speaker_name should be the role label. + let mut world = setup_conversation_world(); + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcName("Alice".to_string()), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }, + DialogueProfile { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + }, + )) + .id(); + let npc_a_sid = world.resource_mut::().register(npc_a); + + let npc_b = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 6, 0), + NpcName("Bob".to_string()), + DialogueProfile { + location: "the-terminal".to_string(), + role: "courier".to_string(), + }, + )) + .id(); + let npc_b_sid = world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + // Player KG observes both NPCs but has NO "name" attribute for either. + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_a_sid, TilePosition::new(5, 5, 0), 0); + kg.observe_entity(npc_b_sid, TilePosition::new(5, 6, 0), 0); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + kg, + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + let buffer = world.get::(player).unwrap(); + assert_eq!(buffer.events.len(), 1); + // No "name" attribute → falls back to role label + assert_eq!( + buffer.events[0].speaker_name, + "Dock Worker", + "speaker with no KG name attribute should show role label" + ); + assert_eq!( + buffer.events[0].target_name, + "Courier", + "target with no KG name attribute should show role label" + ); + } + + #[test] + fn conversation_uses_real_name_when_name_in_player_kg() { + // Player KG has a "name" attribute for the speaker. + // ConversationEvent.speaker_name should use NpcName.0. + let mut world = setup_conversation_world(); + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcName("Alice".to_string()), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }, + DialogueProfile { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + }, + )) + .id(); + let npc_a_sid = world.resource_mut::().register(npc_a); + + let npc_b = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 6, 0), + NpcName("Bob".to_string()), + DialogueProfile { + location: "the-terminal".to_string(), + role: "courier".to_string(), + }, + )) + .id(); + let npc_b_sid = world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + // Player KG has "name" attribute for both NPCs (name has been revealed). + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_a_sid, TilePosition::new(5, 5, 0), 0); + kg.entities + .get_mut(&npc_a_sid) + .unwrap() + .known_attributes + .insert("name".to_string(), "Alice".to_string()); + kg.observe_entity(npc_b_sid, TilePosition::new(5, 6, 0), 0); + kg.entities + .get_mut(&npc_b_sid) + .unwrap() + .known_attributes + .insert("name".to_string(), "Bob".to_string()); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + kg, + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + let buffer = world.get::(player).unwrap(); + assert_eq!(buffer.events.len(), 1); + // "name" attribute present → use NpcName.0 + assert_eq!( + buffer.events[0].speaker_name, + "Alice", + "speaker with KG name attribute should show real name" + ); + assert_eq!( + buffer.events[0].target_name, + "Bob", + "target with KG name attribute should show real name" + ); + } + #[test] fn buffer_take_events_drains_and_returns_events() { let mut buffer = ConversationEventBuffer::default(); @@ -1122,6 +1388,8 @@ mod tests { target_id: 2, speaker_name: "Alice".to_string(), target_name: "Bob".to_string(), + speaker_color_index: 0, + target_color_index: 1, }); buffer.events.push(ConversationEvent { occluded_line: "World".to_string(), @@ -1129,6 +1397,8 @@ mod tests { target_id: 2, speaker_name: "Alice".to_string(), target_name: "Bob".to_string(), + speaker_color_index: 0, + target_color_index: 1, }); let taken = buffer.take_events(); diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index 81e961087..134eb47b1 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -21,6 +21,7 @@ use bevy_ecs::prelude::*; use rand::Rng; use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState}; +use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName}; use crate::content::line_pool::{ AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier, }; @@ -360,7 +361,13 @@ pub fn process_talk_interaction( ), With, >, - mut npc_query: Query<(&DialogueProfile, Option<&CurrentMood>, Option<&mut InteractionMemory>)>, + mut npc_query: Query<( + &DialogueProfile, + Option<&CurrentMood>, + Option<&mut InteractionMemory>, + Option<&NpcName>, + Option<&NpcColorIndex>, + )>, ) { let Some(line_pool) = line_pool else { return; @@ -380,8 +387,10 @@ pub fn process_talk_interaction( let target = talk_request.target; - // Look up NPC dialogue profile, mood, and interaction history (#325) - let Ok((profile, mood_opt, mut interaction_mem_opt)) = npc_query.get_mut(target) else { + // Look up NPC dialogue profile, mood, interaction history, name, and color (#325) + let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) = + npc_query.get_mut(target) + else { tracing::debug!( "Talk target {:?} has no DialogueProfile — cannot select dialogue", target @@ -479,10 +488,27 @@ pub fn process_talk_interaction( return; }; + // Resolve speaker display name: use real name if player KG has "name" + // attribute for the target, otherwise fall back to role label. + let speaker_display_name = { + let known = observer_kg + .entity_knowledge(&speaker_stable) + .map(|e| e.known_attributes.contains_key("name")) + .unwrap_or(false); + if known { + npc_name_opt.map(|n| n.0.clone()).unwrap_or_else(|| "Unknown".to_string()) + } else { + display_label_for_role(&profile.role) + } + }; + let speaker_color = color_idx_opt.map(|c| c.0).unwrap_or(0u8); + response_buffer.response = Some(DialogueResponseEvent { line_id: line.id.clone(), text: line.text.clone(), speaker_entity_id: speaker_stable.0, + speaker_color_index: speaker_color, + speaker_name: speaker_display_name, }); cooldown.record(&line.id, time.tick); diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 324afdc82..90564ec3f 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -2,7 +2,7 @@ // Timestamped player input events for deterministic simulation (D-010 principle 4) // PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance) -use crate::bridge::types::{PlayerAction, PlayerInput}; +use crate::bridge::types::{FacingDirection, PlayerAction, PlayerInput}; use crate::knowledge::{EntityRegistry, StableId}; use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::simulation::inventory::{ @@ -237,6 +237,27 @@ pub fn process_player_input( tracing::debug!("WalkAway: marker set on player"); } } + PlayerAction::SetFacing { ref facing } => { + let dir = match facing.as_str() { + "North" => Some(FacingDirection::North), + "Northeast" => Some(FacingDirection::Northeast), + "East" => Some(FacingDirection::East), + "Southeast" => Some(FacingDirection::Southeast), + "South" => Some(FacingDirection::South), + "Southwest" => Some(FacingDirection::Southwest), + "West" => Some(FacingDirection::West), + "Northwest" => Some(FacingDirection::Northwest), + _ => { + tracing::warn!("SetFacing: unknown direction {:?}", facing); + None + } + }; + if let Some(dir) = dir { + if let Ok((entity, _, _, _)) = player_query.single_mut() { + commands.entity(entity).insert(Facing(dir)); + } + } + } PlayerAction::TeleportToHub => { handle_teleport_to_hub(&mut player_query, &mut commands); } diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 5687fa50b..aad81a8c9 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -537,6 +537,56 @@ pub fn setup_gauntlet(app: &mut App) { } } + // --- Dialogue fixup: wire up DialogueProfile on all Npc entities missing one --- + // Gauntlet room builders (except dialogue_room) don't include DialogueProfile. + // Without it, the Talk verb silently no-ops. This fixup ensures every NPC + // can respond to Talk using content from the YAML dialogue pools. + { + use crate::npc::Npc; + use crate::simulation::conversation::NpcColorIndex; + use crate::simulation::dialogue::{CurrentMood, DialogueProfile}; + + // (location, role) pairs matching content/campaigns/.../dialogue/ YAML pools. + // Cycling through these gives NPC variety across rooms. + const DIALOGUE_ROLES: &[(&str, &str)] = &[ + ("the-terminal", "dock-worker"), + ("the-terminal", "courier"), + ("the-terminal", "maintenance-tech"), + ("the-terminal", "new-hire"), + ("the-terminal", "scheduler"), + ("the-terminal", "shift-supervisor"), + ("the-last-shift", "bartender"), + ("the-last-shift", "bar-regular"), + ("the-last-shift", "day-worker"), + ("maintenance-corridors", "transit-worker"), + ]; + + let missing: Vec = { + let mut q = app + .world_mut() + .query_filtered::, + bevy_ecs::prelude::Without, + )>(); + q.iter(app.world()).collect() + }; + for (i, entity) in missing.iter().enumerate() { + let (location, role) = DIALOGUE_ROLES[i % DIALOGUE_ROLES.len()]; + let color_index = registry + .to_stable(*entity) + .map(|sid| (sid.0 % 8) as u8) + .unwrap_or(0u8); + app.world_mut().entity_mut(*entity).insert(( + DialogueProfile { + location: location.to_string(), + role: role.to_string(), + }, + CurrentMood::default(), + NpcColorIndex(color_index), + )); + } + } + app.insert_resource(registry); } diff --git a/server/src/test_world/rooms/shift_change.rs b/server/src/test_world/rooms/shift_change.rs index 16e91a034..a71ca99a1 100644 --- a/server/src/test_world/rooms/shift_change.rs +++ b/server/src/test_world/rooms/shift_change.rs @@ -24,10 +24,6 @@ use crate::simulation::path_follow::MovementSpeed; use crate::simulation::tier::ActiveSim; use crate::simulation::time::DayPhase; -/// Room origin (top-left corner including walls). -const ORIGIN_X: i32 = 64; -const ORIGIN_Y: i32 = 78; - /// Observer position for Shift Change tests (absolute). pub const OBSERVER_POS: TilePosition = TilePosition { x: 72, y: 90, z: 0 }; diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 1959757b3..c2effff41 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -10,6 +10,7 @@ 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::npc::relationships::TrustEventQueue; use settled_reach_server::simulation::SimulationPlugin; use std::io::{BufReader, BufWriter}; use std::net::{TcpListener, TcpStream}; @@ -30,6 +31,7 @@ fn player_moves_north_through_full_pipeline() { app.add_plugins(SimulationPlugin); app.add_plugins(BridgePlugin); app.add_plugins(KnowledgePlugin); + app.init_resource::(); app.insert_resource(BridgeResource::new(bridge)); app.insert_resource(WalkabilityMap::new(32, 32, 1)); app.world_mut().spawn(( diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 63153764b..700eaff99 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -2,6 +2,8 @@ "blocked_entities": [ 2 ], + "conversation_ended": [], + "conversation_events": [], "current_monologue": null, "dialogue_response": null, "entities": [ @@ -68,7 +70,7 @@ "scan_events": [], "sound_events": [], "tick": 8, - "version": 10, + "version": 12, "visible_tiles": [ { "tile_kind": "Wall", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 7190ded26..804f9e42d 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -318,7 +318,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 11, + PROTOCOL_VERSION, 12, "bump this assertion when protocol version changes" ); }