From efa2dc543f0d1f52e97dc90ab5243f115079c6bd Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 23 Feb 2026 20:01:39 +0100 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20sprint=2016=20dialogue=20se?= =?UTF-8?q?rver=20=E2=80=94=20response=20handler,=20trust=20gossip,=20vari?= =?UTF-8?q?ety=20tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move dialogue system registrations from BridgePlugin to NpcPlugin (#538): game logic that depends on NPC-layer resources now registers where it belongs. BridgePlugin retains only wire protocol concerns. Implement DialogueResponse verb handler (#539): new process_dialogue_response system runs the full D-028 four-layer pipeline to select follow-up lines when the player picks a dialogue option. Clears ActiveDialogue when no candidates remain. Fix latent schedule ambiguity — emit_observation_events now has explicit .before(advance_tick) constraint. Verify trust-gated gossip pipeline (#171): confirmed process_talk_interaction correctly passes KnowledgeConfidence through relationship_to_trust() per D-075. Added integration tests for Secret-tier access (Friendly+KnowsDetails) and Surface-only fallback (Friendly+Suspects). Wire DialogueCooldownTracker into selection (#338): added regression test confirming no line_id repeats within the 600-tick cooldown window across 10 consecutive Talk interactions. Closes #538, #539, #171, #338 Co-Authored-By: Claude Opus 4.6 --- server/Cargo.lock | 2 +- server/src/bridge/mod.rs | 11 +- server/src/bridge/types.rs | 7 + server/src/npc/mod.rs | 12 + server/src/simulation/dialogue.rs | 681 ++++++++++++++++++++++++++++++ server/src/simulation/input.rs | 53 ++- server/tests/serialization.rs | 4 + 7 files changed, 760 insertions(+), 10 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index b31b7ea23..6b618aa1d 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.14" +version = "0.1.15" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 83c872f20..76783ea3d 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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), ), diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 8fd47d332..8c2697358 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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 { diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 5e0188613..f049c45e8 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -27,6 +27,7 @@ impl Plugin for NpcPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -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), ), ); diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index 18e35edcd..a542af343 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -116,6 +116,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. @@ -767,6 +777,201 @@ pub fn process_confrontation_response( .remove::(); } +// --------------------------------------------------------------------------- +// 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, + line_pool: Option>, + registry: Res, + mut rng: ResMut, + mut trust_queue: ResMut, + mut player_query: Query< + ( + Entity, + &KnowledgeGraph, + &DialogueResponseRequest, + &mut DialogueResponseBuffer, + &mut DialogueCooldownTracker, + Option<&ActiveDialogue>, + ), + With, + >, + npc_query: Query<( + &DialogueProfile, + Option<&CurrentMood>, + 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::(); + + let Some(line_pool) = line_pool else { return }; + + // Look up NPC dialogue profile, mood, name, and color + let Ok((profile, mood_opt, npc_name_opt, color_idx_opt)) = npc_query.get(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); + + // Layer 1: Access tiers from relationship + let access_tiers = available_access_tiers(relationship); + + // Layer 2: Derive active situations from game state + let situations = derive_situations(time.day_phase(), relationship); + + // Layer 3: Trust tier from relationship + confidence (D-075) + 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 { + if seen_ids.insert(&line.id) { + candidates.push(line); + } + } + } + + tracing::debug!( + response_id = response_id.as_str(), + candidate_count = candidates.len(), + "DialogueResponse: running follow-up pipeline" + ); + + // Layer 4: Topic + mood weighted selection + let npc_mood = mood_opt.map(|m| m.0); + let active_topics: Vec = Vec::new(); // v0.1: no topic context + + // Prune old cooldown entries + cooldown.prune(time.tick); + + let selected = select_dialogue_line( + &candidates, + npc_mood, + &active_topics, + &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); + + // Trust progression: follow-up dialogue warms the NPC + trust_queue.push(TrustEvent::TalkCompleted { + npc: target, + player: player_entity, + }); + + 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::(); + } + } + +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1787,6 +1992,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::().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::().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::(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::(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::().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::().register(player); + + let mut saw_secret = false; + for seed in 0u64..50 { + world.get_mut::(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::(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 = (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::().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::().register(player); + + let mut seen_ids: Vec = Vec::new(); + + for seed in 0u64..10 { + world.get_mut::(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::(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 +2460,182 @@ mod tests { "Marker should be removed after processing" ); } + + // === DialogueResponse Tests (#539) === + + fn setup_dialogue_response_world() -> World { + let mut world = setup_dialogue_world(); + world + } + + #[test] + fn process_dialogue_response_selects_follow_up_line() { + let mut world = setup_dialogue_response_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::().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::().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::(player).is_none(), + "DialogueResponseRequest should be consumed" + ); + } + + #[test] + fn process_dialogue_response_clears_active_dialogue_when_no_lines() { + let mut world = setup_dialogue_response_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::().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::().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::(player).is_none(), + "ActiveDialogue should be cleared when no follow-up lines available" + ); + assert!( + world.get::(player).is_none(), + "DialogueResponseRequest should be consumed" + ); + // Buffer should remain empty + let buffer = world.get::(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_response_world(); + + // NPC without DialogueProfile + let npc = world.spawn((Npc, TilePosition::new(5, 5, 0))).id(); + world.resource_mut::().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::().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::(player).is_none(), + "DialogueResponseRequest consumed even with no profile" + ); + let buffer = world.get::(player).unwrap(); + assert!( + buffer.response.is_none(), + "No response for NPC without DialogueProfile" + ); + } } diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 953e4531e..b91a6a0be 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -280,6 +280,18 @@ 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, + target_entity_id, + response_id, + ); + } PlayerAction::UsePerceptionMode(ref mode) => { tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode); } @@ -458,6 +470,44 @@ 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). +#[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, + >, + target_entity_id: u64, + response_id: &str, +) { + let Ok((player_entity, _, _, _)) = 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; + }; + + 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 +791,8 @@ fn handle_teleport_to_hub( .remove::() .remove::() .remove::() - .remove::(); + .remove::() + .remove::(); tracing::info!( x = hub_spawn.x, diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index f1b508086..f93478dc5 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -107,6 +107,10 @@ fn all_player_action_variants_roundtrip() { PlayerAction::ToggleStanceUp, PlayerAction::ToggleStanceDown, PlayerAction::WalkAway, + PlayerAction::DialogueResponse { + target_entity_id: 42, + response_id: "kael-davan_d_001".to_string(), + }, ]; for action in actions {