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 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 18:52:40 +01:00
co-authored by Claude Sonnet 4.6
parent 8bd65a5662
commit 019a7b2221
+266
View File
@@ -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::<SimulationTime>().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::<EntityRegistry>().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::<EntityRegistry>().register(npc_b);
world.get_mut::<NpcConversation>(npc_a).unwrap().partner = npc_b;
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
ConversationEventBuffer::default(),
))
.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();
// Both NPCs must have ConversationCooldown applied
let cooldown_a = world.get::<ConversationCooldown>(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::<ConversationCooldown>(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::<SimulationTime>().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::<EntityRegistry>().register(npc_a);
let npc_b = world
.spawn((Npc, ActiveSim, TilePosition::new(5, 6, 0)))
.id();
world.resource_mut::<EntityRegistry>().register(npc_b);
world.get_mut::<NpcConversation>(npc_a).unwrap().partner = npc_b;
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
ConversationEventBuffer::default(),
))
.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();
assert!(
world.get::<ConversationCooldown>(npc_a).is_some(),
"Speaker must get cooldown after duration expiry"
);
assert!(
world.get::<ConversationCooldown>(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::<SimulationTime>().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::<SimulationTime>().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());
}
}