//! 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, /// 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, /// 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 = (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 = 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 = (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 = 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 = 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 = 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) { let mut skills: BTreeMap = 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 = 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::(entity).is_some(), "must have Npc marker"); assert!(world.get::(entity).is_some(), "must have Want"); assert!(world.get::(entity).is_some(), "must have Secret"); assert!( world.get::(entity).is_some(), "must have Relationships" ); assert!( world.get::(entity).is_some(), "must have ToleranceThreshold" ); assert!( world.get::(entity).is_some(), "must have DailyRoutine" ); assert!( world.get::(entity).is_some(), "must have InformationInventory" ); assert!( world.get::(entity).is_some(), "must have Contentment" ); assert!( world.get::(entity).is_some(), "must have PersonalityTraits" ); assert!( world.get::(entity).is_some(), "must have TellSystem" ); assert!(world.get::(entity).is_some(), "must have SkillSet"); assert!( world.get::(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::(ea).unwrap(); let want_b = world_b.get::(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::(ea).unwrap(); let tol_b = world_b.get::(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::(ea).unwrap(); let con_b = world_b.get::(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::(ea).unwrap(); let tol_b = world_b.get::(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::(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::(entity).unwrap(); let mut seen: Vec = 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::(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::(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::(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::(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::(entity).unwrap(); let mut phases: Vec = 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 = 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::(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::(entity).unwrap(); assert!( !skills.combat_trained, "seed {seed}: non-combat role should not be combat trained" ); assert!( world.get::(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::(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::(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::(entity).is_some(), "generated NPCs must spawn as ActiveSim" ); } }