diff --git a/server/src/npc/generate.rs b/server/src/npc/generate.rs new file mode 100644 index 000000000..0b2870470 --- /dev/null +++ b/server/src/npc/generate.rs @@ -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, + /// 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" + ); + } +}