Merge remote-tracking branch 'origin/server'
This commit is contained in:
@@ -0,0 +1 @@
|
||||
うtickヘ,ヲaction�DialogueResponseげtarget_entity_id*ォresponse_idーkael-davan_d_001
|
||||
Generated
+1
-1
@@ -1092,7 +1092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
@@ -178,13 +178,6 @@ impl Plugin for BridgePlugin {
|
||||
.after(crate::simulation::sound::collect_sound_events)
|
||||
.after(crate::simulation::conversation::run_npc_conversations)
|
||||
.after(crate::simulation::dialogue::process_walk_away),
|
||||
crate::simulation::dialogue::process_talk_interaction
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::simulation::dialogue::process_walk_away
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.after(crate::simulation::dialogue::process_talk_interaction),
|
||||
crate::simulation::dialogue::process_confrontation_response
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::simulation::follow::update_follow_state
|
||||
.after(crate::perception::observer::compute_visibility_geometry)
|
||||
.after(crate::simulation::movement::validate_movement)
|
||||
@@ -195,9 +188,11 @@ impl Plugin for BridgePlugin {
|
||||
.after(crate::simulation::monologue::trigger_event_monologue)
|
||||
.after(crate::simulation::dialogue::process_talk_interaction)
|
||||
.after(crate::simulation::dialogue::process_confrontation_response)
|
||||
.after(crate::simulation::dialogue::process_dialogue_response)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
crate::perception::observation::emit_observation_events
|
||||
.after(crate::perception::observer::compute_observer_snapshot),
|
||||
.after(crate::perception::observer::compute_observer_snapshot)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
send_bridge_snapshot
|
||||
.after(crate::perception::observer::compute_observer_snapshot),
|
||||
),
|
||||
|
||||
@@ -377,6 +377,13 @@ pub enum PlayerAction {
|
||||
/// Clears dialogue, monologue, and interaction buffers.
|
||||
/// Rejected with a log warning on non-Gauntlet maps.
|
||||
TeleportToHub,
|
||||
/// Player chose a dialogue option (#539, D-028 follow-up).
|
||||
/// response_id is the line_id that was displayed; target_entity_id is the NPC's wire ID.
|
||||
/// Server runs the same 4-layer pipeline to select a follow-up line.
|
||||
DialogueResponse {
|
||||
target_entity_id: u64,
|
||||
response_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlayerAction {
|
||||
|
||||
@@ -27,6 +27,7 @@ impl Plugin for NpcPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<relationships::RelationshipGraph>()
|
||||
.init_resource::<relationships::TrustEventQueue>()
|
||||
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
|
||||
.init_resource::<routine::PreviousDayPhase>()
|
||||
.init_resource::<tolerance::ToleranceBreachEventQueue>()
|
||||
.init_resource::<routine::RoutineDeviationEventQueue>()
|
||||
@@ -42,6 +43,7 @@ impl Plugin for NpcPlugin {
|
||||
.after(crate::simulation::dialogue::process_talk_interaction)
|
||||
.after(crate::simulation::dialogue::process_walk_away)
|
||||
.after(crate::simulation::dialogue::process_confrontation_response)
|
||||
.after(crate::simulation::dialogue::process_dialogue_response)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
relationships::update_relationship_dynamics
|
||||
.after(relationships::update_trust)
|
||||
@@ -59,6 +61,16 @@ impl Plugin for NpcPlugin {
|
||||
.after(mood::update_mood)
|
||||
.after(routine::detect_routine_deviation)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
crate::simulation::dialogue::process_talk_interaction
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::simulation::dialogue::process_walk_away
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.after(crate::simulation::dialogue::process_talk_interaction),
|
||||
crate::simulation::dialogue::process_confrontation_response
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::simulation::dialogue::process_dialogue_response
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.after(crate::simulation::dialogue::process_talk_interaction),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -79,6 +79,11 @@ impl Default for CurrentMood {
|
||||
///
|
||||
/// Prevents the same line from being selected within LINE_COOLDOWN_TICKS.
|
||||
/// Entries older than the cooldown window are pruned each query.
|
||||
///
|
||||
/// Design note: this is player-global, not per-NPC. Line IDs are NPC-scoped
|
||||
/// per D-035 (`{template}_{d|m|e}_{###}`), so cross-NPC collisions don't occur
|
||||
/// in practice. If a future sprint introduces shared line IDs across roles,
|
||||
/// the key should become `(StableId, line_id)` instead.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct DialogueCooldownTracker {
|
||||
used: std::collections::BTreeMap<String, u64>, // line_id → tick_used (D-041)
|
||||
@@ -116,6 +121,16 @@ pub struct ActiveDialogue {
|
||||
pub started_tick: u64,
|
||||
}
|
||||
|
||||
/// Marker: player submitted a dialogue response this tick (#539).
|
||||
///
|
||||
/// Set by process_player_input when PlayerAction::DialogueResponse is received.
|
||||
/// Consumed and removed by process_dialogue_response each tick.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct DialogueResponseRequest {
|
||||
pub target: Entity,
|
||||
pub response_id: String,
|
||||
}
|
||||
|
||||
/// Marker: player walked away during active dialogue this tick (D-064).
|
||||
///
|
||||
/// Set by process_player_input when PlayerAction::WalkAway is received.
|
||||
@@ -311,11 +326,9 @@ pub fn select_dialogue_line<'a>(
|
||||
return None;
|
||||
}
|
||||
|
||||
// Weighted random selection
|
||||
// Weighted random selection — score_line always returns >= 1 (base score),
|
||||
// so total_weight > 0 is guaranteed when scored is non-empty.
|
||||
let total_weight: u32 = scored.iter().map(|(_, s)| s).sum();
|
||||
if total_weight == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut roll = rng.random_range(0..total_weight);
|
||||
for (line, weight) in &scored {
|
||||
@@ -325,8 +338,70 @@ pub fn select_dialogue_line<'a>(
|
||||
roll -= weight;
|
||||
}
|
||||
|
||||
// Fallback (shouldn't reach here with valid weights)
|
||||
Some(scored.last().unwrap().0)
|
||||
unreachable!("weighted selection with total_weight > 0 must select a line")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared pipeline: Layers 1-4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the full D-028 four-layer dialogue pipeline and return a selected line.
|
||||
///
|
||||
/// Shared by `process_talk_interaction` and `process_dialogue_response` to
|
||||
/// avoid duplicating the L1-L4 query + scoring logic. Callers handle the
|
||||
/// result differently (initial Talk sets ActiveDialogue; follow-up may clear it).
|
||||
fn run_dialogue_pipeline<'a>(
|
||||
line_pool: &'a crate::content::line_pool::LinePoolIndex,
|
||||
location: &str,
|
||||
role: &str,
|
||||
relationship: RelationshipState,
|
||||
confidence: crate::knowledge::types::KnowledgeConfidence,
|
||||
day_phase: crate::simulation::time::DayPhase,
|
||||
interaction_mem: Option<&InteractionMemory>,
|
||||
npc_mood: Option<Mood>,
|
||||
cooldown: &DialogueCooldownTracker,
|
||||
tick: u64,
|
||||
rng: &mut impl Rng,
|
||||
) -> Option<&'a IndexedDialogueLine> {
|
||||
// Layer 1: Access tiers from relationship
|
||||
let access_tiers = available_access_tiers(relationship);
|
||||
|
||||
// Layer 2: Derive active situations from game state
|
||||
let mut situations = derive_situations(day_phase, relationship);
|
||||
|
||||
// Layer 2 extension: first_meeting / repeated_visit from InteractionMemory (#325, D-028)
|
||||
if let Some(mem) = interaction_mem {
|
||||
if mem.is_first_meeting() {
|
||||
situations.push(Situation::FirstMeeting);
|
||||
} else if mem.is_repeated_visit() {
|
||||
situations.push(Situation::RepeatedVisit);
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: Trust tier from relationship + confidence (D-075)
|
||||
let trust = relationship_to_trust(relationship, confidence);
|
||||
|
||||
// Query Layers 1-3: collect candidates across all available access tiers
|
||||
let mut candidates: Vec<&IndexedDialogueLine> = Vec::new();
|
||||
let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
|
||||
|
||||
for access in &access_tiers {
|
||||
let results = line_pool.query_dialogue(location, role, *access, &situations, trust);
|
||||
for line in results {
|
||||
if seen_ids.insert(&line.id) {
|
||||
candidates.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Layer 4: Topic + mood weighted selection
|
||||
let active_topics: Vec<Topic> = Vec::new(); // v0.1: no topic context yet
|
||||
|
||||
select_dialogue_line(&candidates, npc_mood, &active_topics, cooldown, tick, rng)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -405,73 +480,26 @@ pub fn process_talk_interaction(
|
||||
.map(|sid| observer_kg.relationship_with(&sid))
|
||||
.unwrap_or(RelationshipState::Unknown);
|
||||
|
||||
// Layer 1: Access tiers from relationship
|
||||
let access_tiers = available_access_tiers(relationship);
|
||||
|
||||
// Layer 2: Derive active situations from game state
|
||||
let mut situations = derive_situations(time.day_phase(), relationship);
|
||||
|
||||
// Layer 2 extension: first_meeting / repeated_visit from InteractionMemory (#325, D-028)
|
||||
if let Some(ref mem) = interaction_mem_opt {
|
||||
if mem.is_first_meeting() {
|
||||
situations.push(Situation::FirstMeeting);
|
||||
} else if mem.is_repeated_visit() {
|
||||
situations.push(Situation::RepeatedVisit);
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: Trust tier from relationship + confidence (D-075)
|
||||
// Default to Suspects for unknown NPCs — no KG entry means no basis for
|
||||
// deeper dialogue, which correctly yields Surface trust tier.
|
||||
let confidence = target_stable
|
||||
.and_then(|sid| observer_kg.confidence_of(&sid))
|
||||
.unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects);
|
||||
let trust = relationship_to_trust(relationship, confidence);
|
||||
|
||||
// Query Layers 1-3: collect candidates across all available access tiers
|
||||
let mut candidates: Vec<&IndexedDialogueLine> = Vec::new();
|
||||
let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
|
||||
|
||||
for access in &access_tiers {
|
||||
let results = line_pool.0.query_dialogue(
|
||||
&profile.location,
|
||||
&profile.role,
|
||||
*access,
|
||||
&situations,
|
||||
trust,
|
||||
);
|
||||
for line in results {
|
||||
// Deduplicate across access tiers (BTreeSet for deterministic iteration)
|
||||
if seen_ids.insert(&line.id) {
|
||||
candidates.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
tracing::debug!(
|
||||
"No dialogue lines available for {}/{} (access={:?}, situations={:?}, trust={:?})",
|
||||
profile.location,
|
||||
profile.role,
|
||||
access_tiers,
|
||||
situations,
|
||||
trust,
|
||||
);
|
||||
commands.entity(player_entity).remove::<TalkRequest>();
|
||||
return;
|
||||
}
|
||||
|
||||
// Layer 4: Topic + mood weighted selection
|
||||
let npc_mood = mood_opt.map(|m| m.0);
|
||||
let active_topics: Vec<Topic> = Vec::new(); // v0.1: no topic context yet
|
||||
|
||||
// Prune old cooldown entries
|
||||
cooldown.prune(time.tick);
|
||||
|
||||
let selected = select_dialogue_line(
|
||||
&candidates,
|
||||
let selected = run_dialogue_pipeline(
|
||||
&line_pool.0,
|
||||
&profile.location,
|
||||
&profile.role,
|
||||
relationship,
|
||||
confidence,
|
||||
time.day_phase(),
|
||||
interaction_mem_opt.as_deref(),
|
||||
npc_mood,
|
||||
&active_topics,
|
||||
&cooldown,
|
||||
time.tick,
|
||||
&mut rng.rng,
|
||||
@@ -658,7 +686,7 @@ pub fn process_walk_away(
|
||||
|
||||
/// Hardcoded confrontation monologue lines (D-063).
|
||||
/// Fired as a monologue spike when the player delivers a confrontation.
|
||||
/// Future: move to content pools with trigger="confrontation_delivered".
|
||||
/// TODO: move to content pools with trigger="confrontation_delivered" (D-028/D-035).
|
||||
const CONFRONTATION_LINES: &[(&str, &str)] = &[
|
||||
(
|
||||
"confront_01",
|
||||
@@ -767,6 +795,185 @@ pub fn process_confrontation_response(
|
||||
.remove::<ConfrontationDelivered>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System: process_dialogue_response (#539)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process DialogueResponse actions through the full D-028 four-layer pipeline (#539).
|
||||
///
|
||||
/// Called when the player picks a dialogue option. Re-runs the same pipeline as
|
||||
/// process_talk_interaction to select a follow-up line. Clears ActiveDialogue if
|
||||
/// no candidates remain after cooldown filtering (conversation ends naturally).
|
||||
///
|
||||
/// The response_id is the line_id that was shown; it's already on cooldown from
|
||||
/// process_talk_interaction, ensuring the follow-up is a different line.
|
||||
///
|
||||
/// System ordering: after process_player_input, after process_talk_interaction,
|
||||
/// before compute_observer_snapshot.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub fn process_dialogue_response(
|
||||
mut commands: Commands,
|
||||
time: Res<SimulationTime>,
|
||||
line_pool: Option<Res<LinePoolIndexResource>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut trust_queue: ResMut<TrustEventQueue>,
|
||||
mut player_query: Query<
|
||||
(
|
||||
Entity,
|
||||
&KnowledgeGraph,
|
||||
&DialogueResponseRequest,
|
||||
&mut DialogueResponseBuffer,
|
||||
&mut DialogueCooldownTracker,
|
||||
Option<&ActiveDialogue>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
mut npc_query: Query<(
|
||||
&DialogueProfile,
|
||||
Option<&CurrentMood>,
|
||||
Option<&mut InteractionMemory>,
|
||||
Option<&NpcName>,
|
||||
Option<&NpcColorIndex>,
|
||||
)>,
|
||||
) {
|
||||
let Ok((
|
||||
player_entity,
|
||||
observer_kg,
|
||||
response_req,
|
||||
mut response_buffer,
|
||||
mut cooldown,
|
||||
active_dialogue_opt,
|
||||
)) = player_query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target = response_req.target;
|
||||
let response_id = response_req.response_id.clone();
|
||||
|
||||
// Always remove the marker regardless of outcome — request is consumed this tick.
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.remove::<DialogueResponseRequest>();
|
||||
|
||||
let Some(line_pool) = line_pool else { return };
|
||||
|
||||
// Look up NPC dialogue profile, mood, interaction history, name, and color
|
||||
let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) =
|
||||
npc_query.get_mut(target)
|
||||
else {
|
||||
tracing::debug!(
|
||||
"DialogueResponse target {:?} has no DialogueProfile — cannot select follow-up",
|
||||
target
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Resolve target's StableId for KG lookup
|
||||
let target_stable = registry.to_stable(target);
|
||||
let relationship = target_stable
|
||||
.map(|sid| observer_kg.relationship_with(&sid))
|
||||
.unwrap_or(RelationshipState::Unknown);
|
||||
|
||||
let confidence = target_stable
|
||||
.and_then(|sid| observer_kg.confidence_of(&sid))
|
||||
.unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects);
|
||||
|
||||
let npc_mood = mood_opt.map(|m| m.0);
|
||||
|
||||
// Prune old cooldown entries
|
||||
cooldown.prune(time.tick);
|
||||
|
||||
let selected = run_dialogue_pipeline(
|
||||
&line_pool.0,
|
||||
&profile.location,
|
||||
&profile.role,
|
||||
relationship,
|
||||
confidence,
|
||||
time.day_phase(),
|
||||
interaction_mem_opt.as_deref(),
|
||||
npc_mood,
|
||||
&cooldown,
|
||||
time.tick,
|
||||
&mut rng.rng,
|
||||
);
|
||||
|
||||
if let Some(line) = selected {
|
||||
let Some(speaker_stable) = registry.to_stable(target) else {
|
||||
tracing::warn!(
|
||||
"DialogueResponse target {:?} not in EntityRegistry — skipping follow-up",
|
||||
target
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Update ActiveDialogue with current tick — prevents stale started_tick
|
||||
commands.entity(player_entity).insert(ActiveDialogue {
|
||||
target,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: time.tick,
|
||||
});
|
||||
|
||||
// Trust progression: follow-up dialogue warms the NPC
|
||||
trust_queue.push(TrustEvent::TalkCompleted {
|
||||
npc: target,
|
||||
player: player_entity,
|
||||
});
|
||||
|
||||
// Interaction tracking: record follow-up as a talk event
|
||||
if let Some(ref mut mem) = interaction_mem_opt {
|
||||
mem.record_talk(time.tick);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Follow-up selected: id={}, response_id={}, location={}, role={}",
|
||||
line.id,
|
||||
response_id,
|
||||
profile.location,
|
||||
profile.role,
|
||||
);
|
||||
} else {
|
||||
// No follow-up lines — conversation ends naturally (D-062: invisible locks)
|
||||
tracing::debug!(
|
||||
"No follow-up lines for response_id={} at {}/{} — ending conversation",
|
||||
response_id,
|
||||
profile.location,
|
||||
profile.role,
|
||||
);
|
||||
// Clear active dialogue state
|
||||
if active_dialogue_opt.is_some() {
|
||||
commands.entity(player_entity).remove::<ActiveDialogue>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1787,6 +1994,304 @@ mod tests {
|
||||
|
||||
use rand::SeedableRng;
|
||||
|
||||
// -- Trust-gated gossip tests (#171, D-075) ----------------------------------
|
||||
|
||||
/// Build a pool with a Surface-tier Public line and a Secret-tier Insider line.
|
||||
/// Used to verify that KnowledgeConfidence gates Secret access correctly.
|
||||
fn build_trust_tier_pool() -> LinePoolIndex {
|
||||
let mut index = LinePoolIndex::default();
|
||||
let pool = IndexedDialoguePool {
|
||||
location: "the-terminal".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
lines: vec![
|
||||
IndexedDialogueLine {
|
||||
id: "trust_surface_001".to_string(),
|
||||
text: "Just another day at the terminal.".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
access: vec![AccessTier::Public],
|
||||
trust: TrustTier::Surface,
|
||||
situation: vec![Situation::Routine],
|
||||
topic: vec![],
|
||||
mood: vec![],
|
||||
tags: vec![],
|
||||
knowledge_grant: None,
|
||||
},
|
||||
IndexedDialogueLine {
|
||||
id: "trust_secret_001".to_string(),
|
||||
text: "The manifests don't match. You didn't hear that from me.".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
access: vec![AccessTier::Insider], // requires Friendly relationship
|
||||
trust: TrustTier::Secret, // requires Friendly + KnowsDetails+
|
||||
situation: vec![Situation::Routine],
|
||||
topic: vec![],
|
||||
mood: vec![],
|
||||
tags: vec![],
|
||||
knowledge_grant: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
index.dialogue.insert(
|
||||
("the-terminal".to_string(), "dock-worker".to_string()),
|
||||
pool,
|
||||
);
|
||||
index
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_gated_knows_details_can_get_secret_tier_line() {
|
||||
// D-075: Friendly + KnowsDetails → Secret trust tier → secret lines available.
|
||||
// Spec ref: #171, D-075 "Secret: Friendly + KnowsDetails+"
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.insert_resource(LinePoolIndexResource(build_trust_tier_pool()));
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 5, 0),
|
||||
DialogueProfile {
|
||||
location: "the-terminal".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// observe_entity → Direct; observe_entity_leaving_los → KnowsDetails
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0);
|
||||
kg.observe_entity_leaving_los(&npc_sid, 1);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Friendly);
|
||||
|
||||
assert_eq!(
|
||||
kg.confidence_of(&npc_sid),
|
||||
Some(KnowledgeConfidence::KnowsDetails),
|
||||
"precondition: KG must have KnowsDetails confidence"
|
||||
);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
kg,
|
||||
TalkRequest { target: npc },
|
||||
DialogueResponseBuffer::default(),
|
||||
DialogueCooldownTracker::default(),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Run with multiple seeds — Secret-tier line must appear at least once
|
||||
let mut saw_secret_line = false;
|
||||
for seed in 0u64..50 {
|
||||
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
|
||||
world.entity_mut(player).insert(TalkRequest { target: npc });
|
||||
// Reset cooldown so the pool is not exhausted between iterations
|
||||
world.entity_mut(player).insert(DialogueCooldownTracker::default());
|
||||
world.insert_resource(SimRng::new(seed));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_talk_interaction);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
if let Some(resp) = &world
|
||||
.get::<DialogueResponseBuffer>(player)
|
||||
.unwrap()
|
||||
.response
|
||||
{
|
||||
if resp.line_id == "trust_secret_001" {
|
||||
saw_secret_line = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
saw_secret_line,
|
||||
"Friendly + KnowsDetails player should be able to access Secret-tier lines (D-075 #171)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_gated_suspects_only_gets_surface_tier() {
|
||||
// D-075: Friendly + Suspects → Surface trust tier → Secret lines invisible.
|
||||
// Spec ref: #171, D-075 "Surface: any relationship + any confidence"
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.insert_resource(LinePoolIndexResource(build_trust_tier_pool()));
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 5, 0),
|
||||
DialogueProfile {
|
||||
location: "the-terminal".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Friendly relationship but Suspects confidence → Surface trust only
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Friendly);
|
||||
// Patch down to Suspects (observe_entity sets Direct — too high)
|
||||
kg.entities.get_mut(&npc_sid).unwrap().confidence = KnowledgeConfidence::Suspects;
|
||||
|
||||
assert_eq!(
|
||||
kg.confidence_of(&npc_sid),
|
||||
Some(KnowledgeConfidence::Suspects),
|
||||
"precondition: KG must have Suspects confidence"
|
||||
);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
kg,
|
||||
TalkRequest { target: npc },
|
||||
DialogueResponseBuffer::default(),
|
||||
DialogueCooldownTracker::default(),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut saw_secret = false;
|
||||
for seed in 0u64..50 {
|
||||
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
|
||||
world.entity_mut(player).insert(TalkRequest { target: npc });
|
||||
world.entity_mut(player).insert(DialogueCooldownTracker::default());
|
||||
world.insert_resource(SimRng::new(seed));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_talk_interaction);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
if let Some(resp) = &world
|
||||
.get::<DialogueResponseBuffer>(player)
|
||||
.unwrap()
|
||||
.response
|
||||
{
|
||||
if resp.line_id == "trust_secret_001" {
|
||||
saw_secret = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
!saw_secret,
|
||||
"Friendly + Suspects player must NOT access Secret-tier lines (D-075 #171)"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Line variety regression test (#338, D-028) ------------------------------
|
||||
|
||||
/// Pool with 12 distinct Public/Surface/Routine lines for variety testing.
|
||||
fn build_variety_pool() -> LinePoolIndex {
|
||||
let mut index = LinePoolIndex::default();
|
||||
let lines: Vec<IndexedDialogueLine> = (1u32..=12)
|
||||
.map(|n| IndexedDialogueLine {
|
||||
id: format!("variety_{:03}", n),
|
||||
text: format!("Line number {}.", n),
|
||||
role: "dock-worker".to_string(),
|
||||
access: vec![AccessTier::Public],
|
||||
trust: TrustTier::Surface,
|
||||
situation: vec![Situation::Routine],
|
||||
topic: vec![],
|
||||
mood: vec![],
|
||||
tags: vec![],
|
||||
knowledge_grant: None,
|
||||
})
|
||||
.collect();
|
||||
let pool = IndexedDialoguePool {
|
||||
location: "the-terminal".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
lines,
|
||||
};
|
||||
index.dialogue.insert(
|
||||
("the-terminal".to_string(), "dock-worker".to_string()),
|
||||
pool,
|
||||
);
|
||||
index
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_variety_no_repeats_within_cooldown_window() {
|
||||
// #338, D-028: No line_id should repeat within LINE_COOLDOWN_TICKS.
|
||||
// Regression: Talk 10 times at tick 0 (well within the 600-tick window).
|
||||
// Each selected line must be distinct — cooldown tracker enforces this.
|
||||
let mut world = setup_dialogue_world();
|
||||
world.insert_resource(LinePoolIndexResource(build_variety_pool()));
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 5, 0),
|
||||
DialogueProfile {
|
||||
location: "the-terminal".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Unknown player — Public access only; tick stays at 0 throughout
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
TalkRequest { target: npc },
|
||||
DialogueResponseBuffer::default(),
|
||||
DialogueCooldownTracker::default(),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut seen_ids: Vec<String> = Vec::new();
|
||||
|
||||
for seed in 0u64..10 {
|
||||
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
|
||||
world.entity_mut(player).insert(TalkRequest { target: npc });
|
||||
// NOTE: SimulationTime is NOT advanced — all 10 talks happen within tick 0
|
||||
world.insert_resource(SimRng::new(seed));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_talk_interaction);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
if let Some(resp) = &world
|
||||
.get::<DialogueResponseBuffer>(player)
|
||||
.unwrap()
|
||||
.response
|
||||
{
|
||||
let id = resp.line_id.clone();
|
||||
assert!(
|
||||
!seen_ids.contains(&id),
|
||||
"Line '{}' was repeated within the {}-tick cooldown window (iteration {}). \
|
||||
Cooldown tracker must prevent repeats. (#338)",
|
||||
id,
|
||||
LINE_COOLDOWN_TICKS,
|
||||
seed,
|
||||
);
|
||||
seen_ids.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
seen_ids.len(),
|
||||
10,
|
||||
"Should have selected 10 distinct lines across 10 consecutive Talks (#338)"
|
||||
);
|
||||
}
|
||||
|
||||
// === Confrontation Response Tests (#520, D-063) ===
|
||||
|
||||
#[test]
|
||||
@@ -1957,4 +2462,177 @@ mod tests {
|
||||
"Marker should be removed after processing"
|
||||
);
|
||||
}
|
||||
|
||||
// === DialogueResponse Tests (#539) ===
|
||||
|
||||
#[test]
|
||||
fn process_dialogue_response_selects_follow_up_line() {
|
||||
let mut world = setup_dialogue_world();
|
||||
let index = build_test_line_pool();
|
||||
world.insert_resource(LinePoolIndexResource(index));
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 5, 0),
|
||||
DialogueProfile {
|
||||
location: "the-terminal".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
},
|
||||
CurrentMood(Mood::Content),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
// Simulate that first line was already selected (on cooldown)
|
||||
DialogueResponseRequest {
|
||||
target: npc,
|
||||
response_id: "test_d_001".to_string(),
|
||||
},
|
||||
DialogueResponseBuffer::default(),
|
||||
DialogueCooldownTracker::default(),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_dialogue_response);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
// Should have consumed the marker
|
||||
assert!(
|
||||
world.get::<DialogueResponseRequest>(player).is_none(),
|
||||
"DialogueResponseRequest should be consumed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_dialogue_response_clears_active_dialogue_when_no_lines() {
|
||||
let mut world = setup_dialogue_world();
|
||||
|
||||
// Build pool with only one line — it will be on cooldown
|
||||
let mut index = LinePoolIndex::default();
|
||||
let pool = IndexedDialoguePool {
|
||||
location: "test".to_string(),
|
||||
role: "worker".to_string(),
|
||||
lines: vec![IndexedDialogueLine {
|
||||
id: "only_line".to_string(),
|
||||
text: "Only thing I can say.".to_string(),
|
||||
role: "worker".to_string(),
|
||||
access: vec![AccessTier::Public],
|
||||
trust: TrustTier::Surface,
|
||||
situation: vec![Situation::Routine],
|
||||
topic: vec![],
|
||||
mood: vec![],
|
||||
tags: vec![],
|
||||
knowledge_grant: None,
|
||||
}],
|
||||
};
|
||||
index
|
||||
.dialogue
|
||||
.insert(("test".to_string(), "worker".to_string()), pool);
|
||||
world.insert_resource(LinePoolIndexResource(index));
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 5, 0),
|
||||
DialogueProfile {
|
||||
location: "test".to_string(),
|
||||
role: "worker".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Set the only line on cooldown — so no follow-up can be selected
|
||||
let mut cooldown = DialogueCooldownTracker::default();
|
||||
cooldown.record("only_line", 0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
DialogueResponseRequest {
|
||||
target: npc,
|
||||
response_id: "only_line".to_string(),
|
||||
},
|
||||
DialogueResponseBuffer::default(),
|
||||
cooldown,
|
||||
ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_dialogue_response);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
// No follow-up lines — ActiveDialogue should be cleared
|
||||
assert!(
|
||||
world.get::<ActiveDialogue>(player).is_none(),
|
||||
"ActiveDialogue should be cleared when no follow-up lines available"
|
||||
);
|
||||
assert!(
|
||||
world.get::<DialogueResponseRequest>(player).is_none(),
|
||||
"DialogueResponseRequest should be consumed"
|
||||
);
|
||||
// Buffer should remain empty
|
||||
let buffer = world.get::<DialogueResponseBuffer>(player).unwrap();
|
||||
assert!(
|
||||
buffer.response.is_none(),
|
||||
"No response when all lines on cooldown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_dialogue_response_no_profile_is_noop() {
|
||||
let mut world = setup_dialogue_world();
|
||||
|
||||
// NPC without DialogueProfile
|
||||
let npc = world.spawn((Npc, TilePosition::new(5, 5, 0))).id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
DialogueResponseRequest {
|
||||
target: npc,
|
||||
response_id: "some_line".to_string(),
|
||||
},
|
||||
DialogueResponseBuffer::default(),
|
||||
DialogueCooldownTracker::default(),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_dialogue_response);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<DialogueResponseRequest>(player).is_none(),
|
||||
"DialogueResponseRequest consumed even with no profile"
|
||||
);
|
||||
let buffer = world.get::<DialogueResponseBuffer>(player).unwrap();
|
||||
assert!(
|
||||
buffer.response.is_none(),
|
||||
"No response for NPC without DialogueProfile"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,19 @@ pub fn process_player_input(
|
||||
PlayerAction::TeleportToHub => {
|
||||
handle_teleport_to_hub(&mut player_query, &mut commands);
|
||||
}
|
||||
PlayerAction::DialogueResponse {
|
||||
target_entity_id,
|
||||
ref response_id,
|
||||
} => {
|
||||
handle_dialogue_response(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
&all_positions,
|
||||
target_entity_id,
|
||||
response_id,
|
||||
);
|
||||
}
|
||||
PlayerAction::UsePerceptionMode(ref mode) => {
|
||||
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
||||
}
|
||||
@@ -458,6 +471,65 @@ fn handle_talk(
|
||||
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
|
||||
}
|
||||
|
||||
/// Handle DialogueResponse action: set DialogueResponseRequest marker (#539).
|
||||
/// The follow-up dialogue pipeline runs in process_dialogue_response (dialogue.rs).
|
||||
///
|
||||
/// Range check: same CLOSE_RANGE as Talk/Confront (D-010 info boundary). The player
|
||||
/// must still be near the NPC to continue a conversation — walking away mid-dialogue
|
||||
/// should not allow remote follow-ups.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn handle_dialogue_response(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&mut Stance>,
|
||||
Option<&mut PlayerMoveCooldown>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
all_positions: &Query<&TilePosition>,
|
||||
target_entity_id: u64,
|
||||
response_id: &str,
|
||||
) {
|
||||
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_entity_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_entity_id, "DialogueResponse: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Server-side range check: reject DialogueResponse if target moved out of range
|
||||
if let Ok(target_pos) = all_positions.get(target_entity) {
|
||||
let distance = player_pos
|
||||
.manhattan_distance(target_pos)
|
||||
.unwrap_or(u32::MAX);
|
||||
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
target_entity_id,
|
||||
distance,
|
||||
"DialogueResponse: target out of range (max {})",
|
||||
crate::simulation::interaction::CLOSE_RANGE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(crate::simulation::dialogue::DialogueResponseRequest {
|
||||
target: target_entity,
|
||||
response_id: response_id.to_string(),
|
||||
});
|
||||
|
||||
tracing::debug!(target_entity_id, response_id, "DialogueResponse: marker set on player");
|
||||
}
|
||||
|
||||
/// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063).
|
||||
/// The confrontation response system runs in process_confrontation_response (dialogue.rs).
|
||||
/// Server-side range check: Confront requires CLOSE_RANGE (same as Talk).
|
||||
@@ -741,7 +813,8 @@ fn handle_teleport_to_hub(
|
||||
.remove::<crate::simulation::dialogue::TalkRequest>()
|
||||
.remove::<crate::simulation::dialogue::ActiveDialogue>()
|
||||
.remove::<crate::simulation::dialogue::WalkAwayRequest>()
|
||||
.remove::<crate::simulation::dialogue::ConfrontationDelivered>();
|
||||
.remove::<crate::simulation::dialogue::ConfrontationDelivered>()
|
||||
.remove::<crate::simulation::dialogue::DialogueResponseRequest>();
|
||||
|
||||
tracing::info!(
|
||||
x = hub_spawn.x,
|
||||
|
||||
@@ -253,6 +253,19 @@ fn generate_msgpack_fixtures() {
|
||||
&rmp_serde::to_vec_named(&input_batch).unwrap(),
|
||||
);
|
||||
|
||||
// PlayerInput: DialogueResponse (#539)
|
||||
let input_dialogue_response = PlayerInput {
|
||||
tick: 300,
|
||||
action: PlayerAction::DialogueResponse {
|
||||
target_entity_id: 42,
|
||||
response_id: "kael-davan_d_001".to_string(),
|
||||
},
|
||||
};
|
||||
write_fixture(
|
||||
"input_dialogue_response",
|
||||
&rmp_serde::to_vec_named(&input_dialogue_response).unwrap(),
|
||||
);
|
||||
|
||||
// Diagonal movement fixtures (clockwise: NE, SE, SW, NW)
|
||||
for (name, action) in [
|
||||
("input_move_northeast", PlayerAction::MoveNortheast),
|
||||
|
||||
@@ -107,6 +107,14 @@ fn all_player_action_variants_roundtrip() {
|
||||
PlayerAction::ToggleStanceUp,
|
||||
PlayerAction::ToggleStanceDown,
|
||||
PlayerAction::WalkAway,
|
||||
PlayerAction::SetFacing {
|
||||
facing: "north".to_string(),
|
||||
},
|
||||
PlayerAction::TeleportToHub,
|
||||
PlayerAction::DialogueResponse {
|
||||
target_entity_id: 42,
|
||||
response_id: "kael-davan_d_001".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
for action in actions {
|
||||
@@ -169,8 +177,7 @@ fn all_fixtures_deserialize() {
|
||||
rmp_serde::from_slice::<u64>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize boundary raw fixture {}: {}", name, e));
|
||||
} else {
|
||||
assert!(
|
||||
false,
|
||||
panic!(
|
||||
"unknown fixture naming convention: {} — add a deserialization branch for this prefix",
|
||||
name
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user