feat(simulation): add NPC name masking with role labels and color index
Server-side infrastructure for information asymmetry in dialogue. NPCs display role labels (Worker, Supervisor, Patron) instead of real names until the player explicitly learns them via KG lookup. NpcColorIndex (stable_id % 8) persists across name reveal. ConversationEvent and DialogueResponseEvent carry display names + color indices on the wire with serde(default) for compat. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.\""));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
+13
-13
@@ -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::<CurrentMood>(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::<CurrentMood>(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]
|
||||
|
||||
@@ -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<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
@@ -266,6 +300,7 @@ pub fn run_npc_conversations(
|
||||
&TilePosition,
|
||||
Option<&ListeningFocus>,
|
||||
&mut ConversationEventBuffer,
|
||||
&KnowledgeGraph,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
@@ -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<String>)> =
|
||||
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<String>,
|
||||
Option<String>,
|
||||
Option<u8>,
|
||||
)> = 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<PlayerCharacter>,
|
||||
>,
|
||||
@@ -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::<EntityRegistry>().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::<EntityRegistry>().register(player);
|
||||
@@ -807,6 +901,7 @@ mod tests {
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
ConversationEventBuffer::default(),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
@@ -860,6 +955,7 @@ mod tests {
|
||||
PlayerCharacter,
|
||||
TilePosition::new(30, 30, 0),
|
||||
ConversationEventBuffer::default(),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
@@ -933,6 +1029,7 @@ mod tests {
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
ConversationEventBuffer::default(),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
@@ -999,6 +1096,7 @@ mod tests {
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
ConversationEventBuffer::default(),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().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::<EntityRegistry>().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::<EntityRegistry>().register(npc_b);
|
||||
|
||||
world.get_mut::<NpcConversation>(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::<EntityRegistry>().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::<ConversationEventBuffer>(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::<EntityRegistry>().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::<EntityRegistry>().register(npc_b);
|
||||
|
||||
world.get_mut::<NpcConversation>(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::<EntityRegistry>().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::<ConversationEventBuffer>(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();
|
||||
|
||||
@@ -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<PlayerCharacter>,
|
||||
>,
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<bevy_ecs::prelude::Entity> = {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<bevy_ecs::prelude::Entity, (
|
||||
bevy_ecs::prelude::With<Npc>,
|
||||
bevy_ecs::prelude::Without<DialogueProfile>,
|
||||
)>();
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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::<TrustEventQueue>();
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
app.world_mut().spawn((
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user