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