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:
2026-02-21 13:42:59 +01:00
co-authored by Claude Opus 4.6
parent 82e58559ba
commit 28e7e6d9ad
12 changed files with 446 additions and 63 deletions
+2
View File
@@ -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.\""));
+9
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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]
+310 -40
View File
@@ -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();
+29 -3
View File
@@ -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);
+22 -1
View File
@@ -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);
}
+50
View File
@@ -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 };
+2
View File
@@ -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((
+3 -1
View File
@@ -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",
+1 -1
View File
@@ -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"
);
}