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>
889 lines
32 KiB
Rust
889 lines
32 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|