feat(simulation): add NPC generation pipeline (#92)

RoleDefinition struct + generate_npc() seeds all 10 D-024 axes via
SimRng for deterministic generation. Constraint validation: no duplicate
relationship targets, stress < threshold, one routine entry per phase,
no contradictory personality trait pairs. Spawns fully-populated NPC
entity with 2-3 personality traits for tell system (#90).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 14:39:17 +01:00
co-authored by Claude Opus 4.6
parent 9a20e4dea9
commit 78c43ab802
+888
View File
@@ -0,0 +1,888 @@
//! Procedural NPC generation pipeline (#92).
//!
//! Takes a `RoleDefinition`, seeds all 10 NPC axes via `SimRng` (D-010 principle 4),
//! applies constraint validation, and spawns a fully-populated NPC entity.
//!
//! ## Determinism guarantee
//! All randomness flows through `SimRng` (ChaCha20). Same seed → same NPC.
//! Integer arithmetic only — no floats, no HashMap, no OS entropy.
//!
//! ## 10 axes
//! 1. Want — primary drive and intensity
//! 2. Secret — vulnerability and severity
//! 3. Relationships — 0–3 key relationships drawn from a target pool
//! 4. Tolerance — threshold and initial stress (validated: stress < threshold)
//! 5. DailyRoutine — phase→location assignments from location pool
//! 6. InformationInventory — seeded facts from role definition
//! 7. Contentment — initial level in –30..+30
//! 8. PersonalityTraits — 2–3 traits, no contradictory pairs
//! 9. TellSystem — tells derived from personality + secret severity
//! 10. SkillSet — 2–4 skills from role focus + optional CombatCapability
//!
//! ## Constraint validation
//! - Relationships: no duplicate target StableIds
//! - Tolerance: initial stress strictly < threshold (never immediately triggers)
//! - Routine: at most one entry per DayPhase (deduplication by phase)
use rand::Rng;
use std::collections::BTreeMap;
use bevy_ecs::prelude::*;
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
use crate::npc::{
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, KnownFact,
Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind, Relationships,
RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem, TellTrigger,
ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
};
use crate::npc::mood::MoodState;
use crate::simulation::movement::TilePosition;
use crate::simulation::rng::SimRng;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::DayPhase;
// ---------------------------------------------------------------------------
// RoleDefinition
// ---------------------------------------------------------------------------
/// Template driving procedural NPC generation.
///
/// Describes the role constraints for all 10 generation axes. The generator
/// uses `SimRng` to make choices within these constraints deterministically.
#[derive(Debug, Clone)]
pub struct RoleDefinition {
/// Human-readable role label (e.g. "guard", "dockworker", "administrator").
pub name: String,
/// Available phase→location pairs for routine generation (axis 5).
/// The generator picks at most one entry per `DayPhase` from this pool.
pub location_pool: Vec<(DayPhase, TilePosition)>,
/// Candidate `StableId`s for relationship targets (axis 3).
/// Generator draws 0–`MAX_KEY_RELATIONSHIPS` unique targets from this pool.
pub relationship_targets: Vec<StableId>,
/// Seeded knowledge facts for this role (axis 6).
pub known_facts: Vec<(FactId, KnowledgeConfidence)>,
/// Skill types biased toward for this role (axis 10).
/// Generator always includes these, then adds random extras up to the cap.
pub skill_focus: Vec<Skill>,
/// Whether this role may receive a `CombatCapability` component.
pub combat_enabled: bool,
}
// ---------------------------------------------------------------------------
// Contradictory trait pairs
// ---------------------------------------------------------------------------
/// Returns `true` if the two traits are contradictory and cannot coexist.
fn traits_contradict(a: PersonalityTrait, b: PersonalityTrait) -> bool {
use PersonalityTrait::*;
matches!(
(a, b),
(Cautious, Bold)
| (Bold, Cautious)
| (Honest, Deceptive)
| (Deceptive, Honest)
| (Compassionate, Ruthless)
| (Ruthless, Compassionate)
| (Curious, Incurious)
| (Incurious, Curious)
| (Social, Reclusive)
| (Reclusive, Social)
)
}
// ---------------------------------------------------------------------------
// Enum pickers (deterministic index → variant)
// ---------------------------------------------------------------------------
fn pick_want_kind(idx: usize) -> WantKind {
use WantKind::*;
const VARIANTS: [WantKind; 9] = [
Wealth, Safety, Knowledge, Connection, Power, Freedom, Justice, Revenge, Happiness,
];
VARIANTS[idx % VARIANTS.len()]
}
fn pick_secret_severity(idx: usize) -> SecretSeverity {
match idx % 3 {
0 => SecretSeverity::Minor,
1 => SecretSeverity::Moderate,
_ => SecretSeverity::Major,
}
}
fn pick_relationship_kind(idx: usize) -> RelationshipKind {
use RelationshipKind::*;
const VARIANTS: [RelationshipKind; 7] =
[Colleague, Friend, Rival, Romantic, Family, Superior, Subordinate];
VARIANTS[idx % VARIANTS.len()]
}
fn pick_personality_trait(idx: usize) -> PersonalityTrait {
use PersonalityTrait::*;
const VARIANTS: [PersonalityTrait; 10] = [
Cautious, Bold, Honest, Deceptive, Compassionate, Ruthless, Curious, Incurious, Social,
Reclusive,
];
VARIANTS[idx % VARIANTS.len()]
}
fn pick_skill(idx: usize) -> Skill {
use Skill::*;
const VARIANTS: [Skill; 8] =
[Combat, Intimidation, Medical, Observation, Persuasion, Piloting, Stealth, Technical];
VARIANTS[idx % VARIANTS.len()]
}
fn pick_combat_style(idx: usize) -> CombatStyle {
match idx % 4 {
0 => CombatStyle::Ranged,
1 => CombatStyle::Melee,
2 => CombatStyle::Evasive,
_ => CombatStyle::Defensive,
}
}
// ---------------------------------------------------------------------------
// Axis generators
// ---------------------------------------------------------------------------
fn gen_want(rng: &mut SimRng, role: &RoleDefinition) -> Want {
let kind_idx = rng.rng.random_range(0..9_usize);
let intensity = rng.rng.random_range(3_u8..=9);
Want {
primary: pick_want_kind(kind_idx),
intensity,
description: format!("{} driven by {:?}", role.name, pick_want_kind(kind_idx)),
}
}
fn gen_secret(rng: &mut SimRng, role: &RoleDefinition) -> Secret {
let sev_idx = rng.rng.random_range(0..3_usize);
let severity = pick_secret_severity(sev_idx);
Secret {
description: format!("{} has a {:?} secret", role.name, severity),
severity,
known_by: vec![],
}
}
fn gen_relationships(rng: &mut SimRng, targets: &[StableId]) -> Relationships {
if targets.is_empty() {
return Relationships { entries: vec![] };
}
// Number of relationships: 0..=min(3, targets.len())
let max = MAX_KEY_RELATIONSHIPS.min(targets.len());
let count = rng.rng.random_range(0..=(max));
// Pick `count` unique targets — simple shuffle prefix via Fisher-Yates on indices.
let mut indices: Vec<usize> = (0..targets.len()).collect();
for i in 0..count {
let swap = rng.rng.random_range(i..targets.len());
indices.swap(i, swap);
}
let entries = indices[..count]
.iter()
.map(|&target_idx| {
let kind_idx = rng.rng.random_range(0..7_usize);
let trust: i8 = rng.rng.random_range(-4_i8..=4);
Relationship {
target_id: targets[target_idx],
kind: pick_relationship_kind(kind_idx),
trust_level: trust,
history: vec![],
}
})
.collect();
Relationships { entries }
}
fn gen_tolerance(rng: &mut SimRng) -> ToleranceThreshold {
// Threshold: 40..=80 — varies per NPC, not hardcoded.
let threshold: i16 = rng.rng.random_range(40_i16..=80);
// Constraint: initial stress strictly < threshold (never immediately triggers).
let stress_max = (threshold - 1).max(0);
let current_stress: i16 = if stress_max == 0 {
0
} else {
rng.rng.random_range(0_i16..stress_max)
};
ToleranceThreshold {
current_stress,
threshold,
}
}
fn gen_routine(rng: &mut SimRng, location_pool: &[(DayPhase, TilePosition)]) -> DailyRoutine {
if location_pool.is_empty() {
return DailyRoutine {
entries: vec![],
description: "No fixed schedule".into(),
};
}
// Deduplicate by phase: at most one entry per DayPhase.
// Build a BTreeMap from phase index → (phase, location) to guarantee uniqueness.
let mut phase_map: BTreeMap<u8, (DayPhase, TilePosition)> = BTreeMap::new();
for &(phase, loc) in location_pool {
let key = match phase {
DayPhase::Morning => 0,
DayPhase::Afternoon => 1,
DayPhase::Evening => 2,
DayPhase::Night => 3,
};
// If multiple entries for the same phase, pick deterministically.
// Use the last entry in the pool — caller controls priority by ordering.
phase_map.insert(key, (phase, loc));
}
// Select 1..=pool_size entries (biased toward having at least 2 phases).
let available: Vec<(DayPhase, TilePosition)> = phase_map.into_values().collect();
let count = rng.rng.random_range(1..=available.len());
// Pick `count` from the available phases (shuffle prefix).
let mut indices: Vec<usize> = (0..available.len()).collect();
for i in 0..count {
let swap = rng.rng.random_range(i..available.len());
indices.swap(i, swap);
}
let entries: Vec<RoutineEntry> = indices[..count]
.iter()
.map(|&i| {
let (phase, location) = available[i];
let activity_names = ["Work", "Patrol", "Rest", "Meeting", "Training", "Maintenance"];
let act_idx = rng.rng.random_range(0..activity_names.len());
RoutineEntry {
phase,
location,
activity: activity_names[act_idx].to_string(),
}
})
.collect();
DailyRoutine {
entries,
description: format!("Routine schedule"),
}
}
fn gen_information_inventory(facts: &[(FactId, KnowledgeConfidence)]) -> InformationInventory {
InformationInventory {
facts: facts
.iter()
.map(|(fact_id, confidence)| KnownFact {
fact_id: fact_id.clone(),
confidence: *confidence,
})
.collect(),
}
}
fn gen_contentment(rng: &mut SimRng) -> Contentment {
Contentment {
level: rng.rng.random_range(-20_i16..=20),
}
}
fn gen_personality_traits(rng: &mut SimRng) -> PersonalityTraits {
let count = rng.rng.random_range(2_usize..=3);
let mut chosen: Vec<PersonalityTrait> = Vec::with_capacity(count);
let mut attempts = 0_usize;
while chosen.len() < count && attempts < 50 {
attempts += 1;
let idx = rng.rng.random_range(0..10_usize);
let candidate = pick_personality_trait(idx);
// Reject if contradicts any already-chosen trait.
if chosen.iter().any(|&t| traits_contradict(t, candidate)) {
continue;
}
// Reject duplicates.
if chosen.contains(&candidate) {
continue;
}
chosen.push(candidate);
}
PersonalityTraits { traits: chosen }
}
fn gen_tells(traits: &PersonalityTraits, secret: &Secret) -> TellSystem {
let mut tells: Vec<Tell> = Vec::new();
// Guarded tell: Major secret → becomes evasive under stress.
if secret.severity == SecretSeverity::Major {
tells.push(Tell {
trigger: TellTrigger::StressAboveThreshold,
behavior: "becomes evasive and avoids eye contact".to_string(),
});
}
for &trait_ in &traits.traits {
match trait_ {
PersonalityTrait::Cautious => tells.push(Tell {
trigger: TellTrigger::StressAboveThreshold,
behavior: "checks surroundings repeatedly".to_string(),
}),
PersonalityTrait::Bold => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "maintains confident posture".to_string(),
}),
PersonalityTrait::Honest => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "makes direct eye contact".to_string(),
}),
PersonalityTrait::Deceptive => tells.push(Tell {
trigger: TellTrigger::StressAboveThreshold,
behavior: "affects exaggerated calm".to_string(),
}),
PersonalityTrait::Social => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "greets passersby unprompted".to_string(),
}),
PersonalityTrait::Reclusive => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "avoids eye contact and moves away from groups".to_string(),
}),
PersonalityTrait::Ruthless => tells.push(Tell {
trigger: TellTrigger::StressAboveThreshold,
behavior: "speaks curtly and dismisses others".to_string(),
}),
PersonalityTrait::Compassionate => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "pauses to check on distressed individuals".to_string(),
}),
PersonalityTrait::Curious => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "lingers near unusual activity".to_string(),
}),
PersonalityTrait::Incurious => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "moves through space without pausing".to_string(),
}),
}
}
// Cap at 3 tells — avoid overwhelming the observer snapshot.
tells.truncate(3);
TellSystem { tells }
}
fn gen_skills(rng: &mut SimRng, role: &RoleDefinition) -> (SkillSet, Option<CombatCapability>) {
let mut skills: BTreeMap<Skill, u8> = BTreeMap::new();
// Always include role skill focus with higher proficiency.
for &skill in &role.skill_focus {
let prof: u8 = rng.rng.random_range(5_u8..=9);
skills.insert(skill, prof);
}
// Add 1-2 random extra skills (not already present).
let extras = rng.rng.random_range(1_usize..=2);
let mut extra_attempts = 0_usize;
while extra_attempts < 20 && skills.len() < (role.skill_focus.len() + extras).min(6) {
extra_attempts += 1;
let idx = rng.rng.random_range(0..8_usize);
let skill = pick_skill(idx);
skills.entry(skill).or_insert_with(|| rng.rng.random_range(2_u8..=5));
}
let combat_trained = role.combat_enabled && rng.rng.random_range(0..3_u32) < 2;
let skill_set = SkillSet {
skills,
combat_trained,
};
let combat_cap = if combat_trained {
let style_idx = rng.rng.random_range(0..4_usize);
let prof: u8 = rng.rng.random_range(3_u8..=8);
Some(CombatCapability {
weapon_proficiency: prof,
combat_style: pick_combat_style(style_idx),
})
} else {
None
};
(skill_set, combat_cap)
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
/// Generate a fully-populated NPC entity from a role definition.
///
/// Spawns the entity into `world` with all 10 D-024 axis components.
/// All randomness flows through `rng` — deterministic for a fixed seed (D-010).
///
/// Returns the newly spawned `Entity` ID.
pub fn generate_npc(role: &RoleDefinition, world: &mut World, rng: &mut SimRng) -> Entity {
// Generate all axes before spawning to keep the borrow checker happy.
let want = gen_want(rng, role);
let secret = gen_secret(rng, role);
let relationships = gen_relationships(rng, &role.relationship_targets);
let tolerance = gen_tolerance(rng);
let routine = gen_routine(rng, &role.location_pool);
let inventory = gen_information_inventory(&role.known_facts);
let contentment = gen_contentment(rng);
let personality = gen_personality_traits(rng);
let tells = gen_tells(&personality, &secret);
let (skills, combat_opt) = gen_skills(rng, role);
// Determinism assertion: tolerance constraint must hold.
debug_assert!(
tolerance.current_stress < tolerance.threshold,
"NPC generation violated tolerance constraint: stress {} >= threshold {}",
tolerance.current_stress,
tolerance.threshold,
);
// Determinism assertion: no duplicate relationship targets.
debug_assert!(
{
let mut seen: Vec<StableId> = Vec::new();
let mut ok = true;
for rel in &relationships.entries {
if seen.contains(&rel.target_id) {
ok = false;
break;
}
seen.push(rel.target_id);
}
ok
},
"NPC generation produced duplicate relationship targets"
);
// Spawn entity with all components.
let mut entity_builder = world.spawn((
Npc,
ActiveSim,
want,
secret,
relationships,
tolerance,
routine,
inventory,
contentment,
personality,
tells,
skills,
MoodState::default(),
));
if let Some(cap) = combat_opt {
entity_builder.insert(cap);
}
entity_builder.id()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::types::FactId;
use bevy_ecs::world::World;
fn make_rng(seed: u64) -> SimRng {
SimRng::new(seed)
}
fn minimal_role() -> RoleDefinition {
RoleDefinition {
name: "guard".into(),
location_pool: vec![
(DayPhase::Morning, TilePosition::new(5, 5, 0)),
(DayPhase::Afternoon, TilePosition::new(10, 10, 0)),
(DayPhase::Evening, TilePosition::new(5, 5, 0)),
],
relationship_targets: vec![StableId(1), StableId(2), StableId(3)],
known_facts: vec![],
skill_focus: vec![Skill::Combat, Skill::Observation],
combat_enabled: true,
}
}
fn merchant_role() -> RoleDefinition {
RoleDefinition {
name: "merchant".into(),
location_pool: vec![
(DayPhase::Morning, TilePosition::new(20, 5, 0)),
(DayPhase::Afternoon, TilePosition::new(20, 5, 0)),
],
relationship_targets: vec![StableId(10), StableId(11)],
known_facts: vec![(
FactId("contraband.ring_exists".into()),
KnowledgeConfidence::KnowsOf,
)],
skill_focus: vec![Skill::Persuasion],
combat_enabled: false,
}
}
// -----------------------------------------------------------------------
// Basic spawning
// -----------------------------------------------------------------------
#[test]
fn generate_npc_spawns_entity() {
let mut world = World::new();
let mut rng = make_rng(42);
let role = minimal_role();
let entity = generate_npc(&role, &mut world, &mut rng);
assert!(world.get_entity(entity).is_ok(), "spawned entity must exist");
}
#[test]
fn generated_npc_has_required_components() {
let mut world = World::new();
let mut rng = make_rng(1);
let role = minimal_role();
let entity = generate_npc(&role, &mut world, &mut rng);
assert!(world.get::<Npc>(entity).is_some(), "must have Npc marker");
assert!(world.get::<Want>(entity).is_some(), "must have Want");
assert!(world.get::<Secret>(entity).is_some(), "must have Secret");
assert!(
world.get::<Relationships>(entity).is_some(),
"must have Relationships"
);
assert!(
world.get::<ToleranceThreshold>(entity).is_some(),
"must have ToleranceThreshold"
);
assert!(
world.get::<DailyRoutine>(entity).is_some(),
"must have DailyRoutine"
);
assert!(
world.get::<InformationInventory>(entity).is_some(),
"must have InformationInventory"
);
assert!(
world.get::<Contentment>(entity).is_some(),
"must have Contentment"
);
assert!(
world.get::<PersonalityTraits>(entity).is_some(),
"must have PersonalityTraits"
);
assert!(
world.get::<TellSystem>(entity).is_some(),
"must have TellSystem"
);
assert!(world.get::<SkillSet>(entity).is_some(), "must have SkillSet");
assert!(
world.get::<MoodState>(entity).is_some(),
"must have MoodState"
);
}
// -----------------------------------------------------------------------
// Determinism
// -----------------------------------------------------------------------
#[test]
fn same_seed_produces_identical_npc() {
let role = minimal_role();
let mut world_a = World::new();
let mut rng_a = make_rng(99);
let ea = generate_npc(&role, &mut world_a, &mut rng_a);
let mut world_b = World::new();
let mut rng_b = make_rng(99);
let eb = generate_npc(&role, &mut world_b, &mut rng_b);
// Compare all value-type components for equality.
let want_a = world_a.get::<Want>(ea).unwrap();
let want_b = world_b.get::<Want>(eb).unwrap();
assert_eq!(want_a.primary, want_b.primary, "Want.primary must match");
assert_eq!(want_a.intensity, want_b.intensity, "Want.intensity must match");
let tol_a = world_a.get::<ToleranceThreshold>(ea).unwrap();
let tol_b = world_b.get::<ToleranceThreshold>(eb).unwrap();
assert_eq!(tol_a.threshold, tol_b.threshold);
assert_eq!(tol_a.current_stress, tol_b.current_stress);
let con_a = world_a.get::<Contentment>(ea).unwrap();
let con_b = world_b.get::<Contentment>(eb).unwrap();
assert_eq!(con_a.level, con_b.level);
}
#[test]
fn different_seeds_may_produce_different_npcs() {
let role = minimal_role();
let mut world_a = World::new();
let mut rng_a = make_rng(1);
let ea = generate_npc(&role, &mut world_a, &mut rng_a);
let mut world_b = World::new();
let mut rng_b = make_rng(2);
let eb = generate_npc(&role, &mut world_b, &mut rng_b);
// It's statistically extremely unlikely both produce identical tolerance thresholds.
let tol_a = world_a.get::<ToleranceThreshold>(ea).unwrap();
let tol_b = world_b.get::<ToleranceThreshold>(eb).unwrap();
// We don't assert inequality (could theoretically be equal) but this
// documents that seeding drives variance.
let _ = (tol_a, tol_b);
}
// -----------------------------------------------------------------------
// Constraint: tolerance
// -----------------------------------------------------------------------
#[test]
fn tolerance_constraint_never_immediately_triggers() {
let role = minimal_role();
for seed in 0..100_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
let tol = world.get::<ToleranceThreshold>(entity).unwrap();
assert!(
tol.current_stress < tol.threshold,
"seed {seed}: stress={} must be < threshold={}",
tol.current_stress,
tol.threshold
);
}
}
// -----------------------------------------------------------------------
// Constraint: relationships (no duplicate targets)
// -----------------------------------------------------------------------
#[test]
fn relationships_have_no_duplicate_targets() {
let role = minimal_role();
for seed in 0..50_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
let rels = world.get::<Relationships>(entity).unwrap();
let mut seen: Vec<StableId> = Vec::new();
for rel in &rels.entries {
assert!(
!seen.contains(&rel.target_id),
"seed {seed}: duplicate relationship target {:?}",
rel.target_id
);
seen.push(rel.target_id);
}
}
}
#[test]
fn relationships_respect_max_key_relationships() {
let role = minimal_role();
for seed in 0..50_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
let rels = world.get::<Relationships>(entity).unwrap();
assert!(
rels.entries.len() <= MAX_KEY_RELATIONSHIPS,
"seed {seed}: {} relationships exceeds max {}",
rels.entries.len(),
MAX_KEY_RELATIONSHIPS
);
}
}
#[test]
fn empty_relationship_targets_produces_no_relationships() {
let mut role = minimal_role();
role.relationship_targets = vec![];
let mut world = World::new();
let mut rng = make_rng(7);
let entity = generate_npc(&role, &mut world, &mut rng);
let rels = world.get::<Relationships>(entity).unwrap();
assert!(rels.entries.is_empty());
}
// -----------------------------------------------------------------------
// Constraint: personality (no contradictory pairs)
// -----------------------------------------------------------------------
#[test]
fn personality_traits_contain_no_contradictory_pairs() {
let role = minimal_role();
for seed in 0..100_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
let traits = world.get::<PersonalityTraits>(entity).unwrap();
for i in 0..traits.traits.len() {
for j in (i + 1)..traits.traits.len() {
assert!(
!traits_contradict(traits.traits[i], traits.traits[j]),
"seed {seed}: contradictory trait pair {:?} and {:?}",
traits.traits[i],
traits.traits[j]
);
}
}
}
}
#[test]
fn personality_traits_count_is_2_or_3() {
let role = minimal_role();
for seed in 0..50_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
let traits = world.get::<PersonalityTraits>(entity).unwrap();
assert!(
traits.traits.len() >= 2 && traits.traits.len() <= 3,
"seed {seed}: got {} traits, expected 2 or 3",
traits.traits.len()
);
}
}
// -----------------------------------------------------------------------
// Constraint: routine (at most one entry per phase)
// -----------------------------------------------------------------------
#[test]
fn routine_has_at_most_one_entry_per_phase() {
let role = minimal_role();
for seed in 0..50_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
let routine = world.get::<DailyRoutine>(entity).unwrap();
let mut phases: Vec<DayPhase> = routine.entries.iter().map(|e| e.phase).collect();
let original_len = phases.len();
phases.dedup(); // only works if sorted — but we check by unique count instead
let unique: std::collections::BTreeSet<u8> = routine
.entries
.iter()
.map(|e| match e.phase {
DayPhase::Morning => 0,
DayPhase::Afternoon => 1,
DayPhase::Evening => 2,
DayPhase::Night => 3,
})
.collect();
assert_eq!(
unique.len(),
original_len,
"seed {seed}: duplicate phase in routine"
);
}
}
// -----------------------------------------------------------------------
// Information inventory from role
// -----------------------------------------------------------------------
#[test]
fn information_inventory_matches_role_known_facts() {
let role = merchant_role();
let mut world = World::new();
let mut rng = make_rng(5);
let entity = generate_npc(&role, &mut world, &mut rng);
let inv = world.get::<InformationInventory>(entity).unwrap();
assert_eq!(inv.facts.len(), 1);
assert_eq!(inv.facts[0].fact_id.0, "contraband.ring_exists");
assert_eq!(inv.facts[0].confidence, KnowledgeConfidence::KnowsOf);
}
// -----------------------------------------------------------------------
// Combat capability
// -----------------------------------------------------------------------
#[test]
fn non_combat_role_never_gets_combat_capability() {
let role = merchant_role(); // combat_enabled = false
for seed in 0..50_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
let skills = world.get::<SkillSet>(entity).unwrap();
assert!(
!skills.combat_trained,
"seed {seed}: non-combat role should not be combat trained"
);
assert!(
world.get::<CombatCapability>(entity).is_none(),
"seed {seed}: non-combat role must not have CombatCapability"
);
}
}
// -----------------------------------------------------------------------
// Skill focus present in SkillSet
// -----------------------------------------------------------------------
#[test]
fn skill_focus_always_present_in_skill_set() {
let role = minimal_role(); // skill_focus = [Combat, Observation]
for seed in 0..30_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
let skills = world.get::<SkillSet>(entity).unwrap();
for focused_skill in &role.skill_focus {
assert!(
skills.skills.contains_key(focused_skill),
"seed {seed}: skill {:?} from role focus must be in SkillSet",
focused_skill
);
}
}
}
// -----------------------------------------------------------------------
// Want intensity in 1-10 range
// -----------------------------------------------------------------------
#[test]
fn want_intensity_in_valid_range() {
let role = minimal_role();
for seed in 0..50_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
let want = world.get::<Want>(entity).unwrap();
assert!(
want.intensity >= 1 && want.intensity <= 10,
"seed {seed}: Want intensity {} out of range",
want.intensity
);
}
}
// -----------------------------------------------------------------------
// ActiveSim tier
// -----------------------------------------------------------------------
#[test]
fn generated_npc_spawns_in_active_tier() {
let mut world = World::new();
let mut rng = make_rng(0);
let entity = generate_npc(&minimal_role(), &mut world, &mut rng);
assert!(
world.get::<ActiveSim>(entity).is_some(),
"generated NPCs must spawn as ActiveSim"
);
}
}