Files
settled-reach/server/src/npc/generate.rs
T
jpmschweitzerandClaude Opus 4.6 78c43ab802 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>
2026-02-21 14:39:17 +01:00

889 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 — 03 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 — 23 traits, no contradictory pairs
//! 9. TellSystem — tells derived from personality + secret severity
//! 10. SkillSet — 24 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"
);
}
}