From 019a7b222174f9fe73df05a639b815d79bbf419c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 18:52:40 +0100 Subject: [PATCH] test(simulation): additional conversation system QA coverage (Hoshe, S14) Additional integration tests for conversation cooldown application, distance termination with both NPCs receiving cooldown, and edge cases in the NPC-to-NPC conversation pipeline. Co-Authored-By: Claude Sonnet 4.6 --- server/src/simulation/conversation.rs | 266 ++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) diff --git a/server/src/simulation/conversation.rs b/server/src/simulation/conversation.rs index c2ebf2c46..7e688679a 100644 --- a/server/src/simulation/conversation.rs +++ b/server/src/simulation/conversation.rs @@ -878,4 +878,270 @@ mod tests { // ListeningFocus subtracts 0.2 (20pp) assert_eq!(compute_drop_probability(4, 0, true), 30); // 50 - 20 } + + // -- Additional QA coverage (Hoshe, Sprint 14) -------------------------- + + #[test] + fn cooldown_applied_to_both_npcs_after_distance_termination() { + // When a conversation terminates (NPCs drift apart), both NPCs must + // receive ConversationCooldown to prevent immediate re-pairing. + let mut world = setup_conversation_world(); + world.resource_mut::().tick = 100; + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcName("Alice".to_string()), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 50, + end_tick: 200, + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + // Partner far away — conversation should terminate this tick + let npc_b = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(20, 20, 0), + NpcName("Bob".to_string()), + )) + .id(); + world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + // Both NPCs must have ConversationCooldown applied + let cooldown_a = world.get::(npc_a); + assert!( + cooldown_a.is_some(), + "Speaker (npc_a) must get ConversationCooldown after termination" + ); + assert_eq!( + cooldown_a.unwrap().until_tick, + 100 + CONVERSATION_COOLDOWN_TICKS, + "Cooldown until_tick must be current_tick + CONVERSATION_COOLDOWN_TICKS" + ); + + let cooldown_b = world.get::(npc_b); + assert!( + cooldown_b.is_some(), + "Partner (npc_b) must get ConversationCooldown after termination" + ); + assert_eq!( + cooldown_b.unwrap().until_tick, + 100 + CONVERSATION_COOLDOWN_TICKS, + "Both NPCs receive the same cooldown duration" + ); + } + + #[test] + fn cooldown_applied_after_duration_expiry() { + // Termination by duration should also apply cooldowns. + let mut world = setup_conversation_world(); + world.resource_mut::().tick = 200; + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, // expired + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + let npc_b = world + .spawn((Npc, ActiveSim, TilePosition::new(5, 6, 0))) + .id(); + world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + assert!( + world.get::(npc_a).is_some(), + "Speaker must get cooldown after duration expiry" + ); + assert!( + world.get::(npc_b).is_some(), + "Partner must get cooldown after duration expiry" + ); + } + + #[test] + fn npc_on_active_cooldown_cannot_start_conversation() { + // An NPC with ConversationCooldown (until_tick > current_tick) must + // not be eligible for new conversation initiation. + let mut world = setup_conversation_world(); + world.resource_mut::().tick = 50; + + // NPC on cooldown (expires at tick 100, current is 50) + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + ConversationCooldown { until_tick: 100 }, + )); + + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 6, 0), + ConversationCooldown { until_tick: 100 }, + )); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )); + + // Run many ticks — no conversation should ever start because all NPCs are on cooldown + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + + for _ in 0..50 { + schedule.run(&mut world); + world.flush(); + } + + // Verify no NpcConversation was created + let mut conv_query = world.query::<&NpcConversation>(); + assert!( + conv_query.iter(&world).count() == 0, + "NPCs on cooldown must not enter conversations" + ); + } + + #[test] + fn expired_cooldown_allows_conversation_initiation() { + // A cooldown whose until_tick <= current_tick should not block the NPC. + let mut world = setup_conversation_world(); + // Set tick high enough that the cooldown has expired + world.resource_mut::().tick = 200; + + // Both NPCs have cooldowns that expired at tick 100 + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + ConversationCooldown { until_tick: 100 }, // expired at 200 + )); + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 6, 0), + ConversationCooldown { until_tick: 100 }, // expired at 200 + )); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )); + + // With 2% chance per tick, over 300 ticks a conversation is extremely likely. + // Use a fresh world per attempt but share the schedule. + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + + // Run until we see a conversation or hit max attempts + let mut found = false; + for _ in 0..300 { + schedule.run(&mut world); + world.flush(); + + let mut conv_query = world.query::<&NpcConversation>(); + if conv_query.iter(&world).count() > 0 { + found = true; + break; + } + } + + assert!( + found, + "Expired cooldown should allow conversation initiation (2% per tick, 300 attempts)" + ); + } + + #[test] + fn buffer_take_events_drains_and_returns_events() { + let mut buffer = ConversationEventBuffer::default(); + buffer.events.push(ConversationEvent { + occluded_line: "Hello".to_string(), + speaker_id: 1, + target_id: 2, + speaker_name: "Alice".to_string(), + target_name: "Bob".to_string(), + }); + buffer.events.push(ConversationEvent { + occluded_line: "World".to_string(), + speaker_id: 1, + target_id: 2, + speaker_name: "Alice".to_string(), + target_name: "Bob".to_string(), + }); + + let taken = buffer.take_events(); + assert_eq!(taken.len(), 2, "take_events should return all events"); + assert!(buffer.events.is_empty(), "Buffer should be empty after take_events"); + + // Second call returns empty + let taken2 = buffer.take_events(); + assert!(taken2.is_empty(), "Second take_events call should return empty vec"); + } + + #[test] + fn buffer_take_ended_drains_and_returns_end_events() { + let mut buffer = ConversationEventBuffer::default(); + buffer.ended.push(ConversationEndEvent { + speaker_id: 10, + target_id: 20, + }); + + let taken = buffer.take_ended(); + assert_eq!(taken.len(), 1, "take_ended should return all end events"); + assert!(buffer.ended.is_empty(), "ended buffer should be empty after take_ended"); + + // Second call returns empty + assert!(buffer.take_ended().is_empty()); + } }