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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user