- vision.rs: deduplicate own-tile entity iteration, add same-tile test - spawn.rs: add NpcVisionState, NpcMemory, PlayerAwareness to content- spawned NPCs (matching generate_npc) - pressure.rs: update who_knows_full_scan doc to reflect actual call frequency, document O(N) acceptability at call site - types.rs: fix stale protocol version comment (13→14) - generate.rs: format!() → .to_string() (clippy) - vision.rs: hardcoded 10 → TICKS_PER_GAME_MINUTE - relationships.rs: fix comment "15%" → "20%" to match code - pressure.rs: document total() floor-truncation - save_state.rs: document NpcMemory exclusion as intentional Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1184 lines
42 KiB
Rust
1184 lines
42 KiB
Rust
//! Content → ECS entity spawning (two-phase).
|
|
//!
|
|
//! Maps intermediate content types from the loader into bevy_ecs
|
|
//! Components and Resources. The separation ensures content schema
|
|
//! changes don't require ECS component changes (and vice versa).
|
|
//!
|
|
//! **Phase 1** (spawn_entities): Create NPC entities with per-entity components
|
|
//! (Want, Secret, Tolerance, Contentment, Personality, Tells, Skills). Builds
|
|
//! the canonical_id → StableId map needed for cross-reference resolution.
|
|
//!
|
|
//! **Phase 2** (resolve_cross_references): Wire up components that reference
|
|
//! other entities by canonical_id — Relationships (per-entity + global graph),
|
|
//! KnowledgeGraph (background facts), DailyRoutine (schedules.yaml join).
|
|
//! Requires the npc_ids map from Phase 1.
|
|
//!
|
|
//! NPC spawn follows the 10-axis model (D-024):
|
|
//! Want, Secret, Relationships, Tolerance, Routine, Information,
|
|
//! Contentment, Personality, Tells, Skills
|
|
|
|
use bevy_ecs::prelude::*;
|
|
use std::collections::BTreeMap;
|
|
|
|
use crate::content::loader::{ContentStore, DistrictContent};
|
|
use crate::content::types;
|
|
use crate::knowledge::content_registry::ContentEntityRegistry;
|
|
use crate::knowledge::graph::KnowledgeGraph;
|
|
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
|
use crate::knowledge::types::{
|
|
FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState, StableId,
|
|
};
|
|
use crate::npc;
|
|
use crate::simulation::interaction::Interactable;
|
|
use crate::simulation::movement::TilePosition;
|
|
use crate::simulation::tier::ActiveSim;
|
|
use crate::simulation::time::DayPhase;
|
|
|
|
/// Stable content identifier from YAML (e.g., "kael-davan", "sera-venn").
|
|
///
|
|
/// Bridges authoring identity to ECS entities. Independent of StableId —
|
|
/// StableId is runtime entity tracking (KG references), ContentSlug is
|
|
/// authoring/content identity (which authored NPC template). Not all entities
|
|
/// have ContentSlugs (e.g., procedurally spawned NPCs, furniture).
|
|
///
|
|
/// Used by #427 (walk-away KG recording) to record interaction memory
|
|
/// against a stable content identity rather than an Entity (which is
|
|
/// unstable across save/load).
|
|
#[derive(Component, Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct ContentSlug(pub String);
|
|
|
|
/// Result of spawning content into the ECS world.
|
|
#[derive(Debug, Default)]
|
|
pub struct SpawnResult {
|
|
/// Number of NPC entities spawned.
|
|
pub npcs_spawned: u32,
|
|
/// Mapping from NPC canonical_id to their StableId.
|
|
pub npc_ids: BTreeMap<String, StableId>,
|
|
}
|
|
|
|
/// Spawn all loaded content into the ECS world.
|
|
///
|
|
/// This is the main entry point for content → ECS conversion.
|
|
/// Runs Phase 1 (entity spawning) then Phase 2 (cross-reference resolution).
|
|
pub fn spawn_content(world: &mut World, store: &ContentStore) -> SpawnResult {
|
|
// Phase 1: spawn entities and build canonical_id → StableId map
|
|
let result = spawn_entities(world, store);
|
|
|
|
// Phase 2: resolve cross-references using the id map
|
|
resolve_cross_references(world, store, &result);
|
|
|
|
tracing::info!(
|
|
"Content spawn complete: {} NPCs (phase 1), cross-references resolved (phase 2)",
|
|
result.npcs_spawned
|
|
);
|
|
|
|
result
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Phase 1: Entity spawning
|
|
// ===========================================================================
|
|
|
|
/// Phase 1: Spawn all NPC entities with per-entity components.
|
|
/// Returns the canonical_id → StableId map for Phase 2.
|
|
fn spawn_entities(world: &mut World, store: &ContentStore) -> SpawnResult {
|
|
let mut result = SpawnResult::default();
|
|
|
|
for (district_id, content) in &store.districts {
|
|
tracing::info!("Phase 1 — spawning entities for district: {}", district_id);
|
|
for profile in &content.npc_profiles {
|
|
spawn_npc(world, profile, &mut result);
|
|
}
|
|
}
|
|
|
|
tracing::info!("Phase 1 complete: {} NPCs spawned", result.npcs_spawned);
|
|
result
|
|
}
|
|
|
|
/// Spawn a single NPC entity from a content profile.
|
|
///
|
|
/// Maps per-entity axes of the 10-axis NPC model (D-024) to ECS components.
|
|
/// Cross-entity axes (Relationships, Routine, Information) are handled in Phase 2.
|
|
fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnResult) {
|
|
let mut entity_commands = world.spawn(npc::Npc);
|
|
|
|
// Default position — will be overridden by routine system on first phase transition
|
|
entity_commands.insert(TilePosition::new(0, 0, 0));
|
|
|
|
// All spawned NPCs start in the Active tier (D-026, #94).
|
|
// The tier transition system (#99) will demote NPCs that are far from the player.
|
|
entity_commands.insert(ActiveSim);
|
|
|
|
// Mark NPC as interactable for proximity-based verb detection (#413)
|
|
entity_commands.insert(Interactable);
|
|
|
|
// Interaction history — drives Layer 2 situation activation (#325, D-028)
|
|
entity_commands.insert(npc::interaction::InteractionMemory::default());
|
|
|
|
// Mood state — drives Layer 4 dialogue selection and monologue tone (#323)
|
|
entity_commands.insert(npc::mood::MoodState::default());
|
|
|
|
// Axis 1: Want
|
|
if let Some(want) = &profile.want {
|
|
if let Some(kind) = parse_want_kind(&want.primary) {
|
|
entity_commands.insert(npc::Want {
|
|
primary: kind,
|
|
intensity: want.intensity.unwrap_or(5).clamp(1, 10) as u8,
|
|
description: want
|
|
.description
|
|
.clone()
|
|
.unwrap_or_else(|| want.primary.clone()),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Axis 2: Secret
|
|
// Content has `secret: Option<String>` — freeform narrative description.
|
|
// Severity defaults to Moderate (most secrets in v0.1 are career/life-threatening).
|
|
// known_by starts empty — runtime systems populate it when secrets are discovered.
|
|
if let Some(secret_text) = &profile.secret {
|
|
let trimmed = secret_text.trim();
|
|
if !trimmed.is_empty() {
|
|
entity_commands.insert(npc::Secret {
|
|
description: trimmed.to_string(),
|
|
severity: parse_secret_severity(trimmed),
|
|
known_by: Vec::new(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Axis 4: Tolerance threshold
|
|
if let Some(tolerance) = &profile.tolerance {
|
|
entity_commands.insert(npc::ToleranceThreshold {
|
|
current_stress: 0,
|
|
threshold: tolerance.threshold.unwrap_or(50).clamp(0, 100) as i16,
|
|
});
|
|
}
|
|
|
|
// Axis 7: Contentment
|
|
if let Some(contentment) = &profile.contentment {
|
|
entity_commands.insert(npc::Contentment {
|
|
level: contentment.level.unwrap_or(0).clamp(-100, 100) as i16,
|
|
});
|
|
}
|
|
|
|
// Supporting axis 1: Personality traits
|
|
if let Some(personality_map) = &profile.personality {
|
|
let traits: Vec<npc::PersonalityTrait> = personality_map
|
|
.keys()
|
|
.filter_map(|k| parse_personality_trait(k))
|
|
.collect();
|
|
if !traits.is_empty() {
|
|
entity_commands.insert(npc::PersonalityTraits { traits });
|
|
}
|
|
}
|
|
|
|
// Supporting axis 2: Tell system
|
|
if !profile.tells.is_empty() {
|
|
let tells: Vec<npc::Tell> = profile
|
|
.tells
|
|
.iter()
|
|
.map(|t| npc::Tell {
|
|
trigger: parse_tell_trigger(&t.trigger),
|
|
behavior: t.behavior.clone(),
|
|
})
|
|
.collect();
|
|
entity_commands.insert(npc::TellSystem { tells });
|
|
}
|
|
|
|
// Supporting axis 3: Skills
|
|
if let Some(skills) = &profile.skills {
|
|
let mut skill_map = BTreeMap::new();
|
|
if let Some(ref s) = skills.skills {
|
|
for (name, &level) in s {
|
|
if let Some(skill) = parse_skill(name) {
|
|
skill_map.insert(skill, level.clamp(1, 10) as u8);
|
|
}
|
|
}
|
|
}
|
|
entity_commands.insert(npc::SkillSet {
|
|
skills: skill_map,
|
|
combat_trained: skills.combat_trained.unwrap_or(false),
|
|
});
|
|
}
|
|
|
|
// TODO: CombatCapability — no content schema type exists yet. When combat content
|
|
// is authored, add weapon_proficiency + combat_style mapping here.
|
|
|
|
// Vision + awareness components (#115, #244) — must match generate_npc().
|
|
// Without these, vision/awareness systems silently skip content-spawned NPCs.
|
|
entity_commands.insert((
|
|
npc::vision::NpcVisionState::default(),
|
|
npc::vision::NpcMemory::default(),
|
|
npc::awareness::PlayerAwareness::default(),
|
|
));
|
|
|
|
let entity = entity_commands.id();
|
|
|
|
// Register in EntityRegistry for StableId mapping
|
|
let stable_id = world.resource_mut::<EntityRegistry>().register(entity);
|
|
world.entity_mut(entity).insert((
|
|
StableEntityId(stable_id),
|
|
ContentSlug(profile.canonical_id.clone()),
|
|
));
|
|
|
|
// Register in ContentEntityRegistry so KnowledgeGrant::Entity can resolve entity_ref strings
|
|
// (D-079: ContentEntityRegistry populated at NPC spawn time)
|
|
world
|
|
.resource_mut::<ContentEntityRegistry>()
|
|
.register(profile.canonical_id.clone(), stable_id);
|
|
|
|
result
|
|
.npc_ids
|
|
.insert(profile.canonical_id.clone(), stable_id);
|
|
result.npcs_spawned += 1;
|
|
|
|
tracing::debug!(
|
|
"Spawned NPC: {} (StableId: {:?}, tier: {})",
|
|
profile.canonical_id,
|
|
stable_id,
|
|
profile.tier
|
|
);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Phase 2: Cross-reference resolution
|
|
// ===========================================================================
|
|
|
|
/// Phase 2: Resolve cross-references that depend on the canonical_id → StableId map.
|
|
///
|
|
/// This runs after all entities are spawned so that canonical_id references
|
|
/// (e.g., relationship targets, routine NPC keys) can be resolved to StableIds.
|
|
fn resolve_cross_references(world: &mut World, store: &ContentStore, result: &SpawnResult) {
|
|
for (district_id, content) in &store.districts {
|
|
tracing::info!(
|
|
"Phase 2 — resolving cross-references for district: {}",
|
|
district_id
|
|
);
|
|
|
|
// Axis 6: Information → KnowledgeGraph via with_background()
|
|
resolve_information(world, content, &result.npc_ids);
|
|
|
|
// Axis 3: Relationships → per-entity component + global RelationshipGraph
|
|
resolve_relationships(world, content, &result.npc_ids);
|
|
|
|
// Axis 5: DailyRoutine from schedules.yaml
|
|
resolve_routines(world, content, &result.npc_ids);
|
|
}
|
|
|
|
tracing::info!("Phase 2 complete");
|
|
}
|
|
|
|
/// Axis 6: Populate KnowledgeGraph with background facts from NPC information.knows.
|
|
///
|
|
/// Per Tyre's recommendation: populate KnowledgeGraph directly via `with_background()`,
|
|
/// skip InformationInventory component — one source of truth.
|
|
/// Default confidence: KnowsOf for all background knowledge (D-041).
|
|
fn resolve_information(
|
|
world: &mut World,
|
|
content: &DistrictContent,
|
|
npc_ids: &BTreeMap<String, StableId>,
|
|
) {
|
|
for profile in &content.npc_profiles {
|
|
let Some(info) = &profile.information else {
|
|
continue;
|
|
};
|
|
if info.knows.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let Some(&stable_id) = npc_ids.get(&profile.canonical_id) else {
|
|
tracing::warn!(
|
|
"Cannot resolve information for {}: not in npc_ids map",
|
|
profile.canonical_id
|
|
);
|
|
continue;
|
|
};
|
|
|
|
let Some(entity) = world.resource::<EntityRegistry>().to_entity(&stable_id) else {
|
|
continue;
|
|
};
|
|
|
|
// Build background facts with KnowsOf confidence
|
|
let facts: Vec<(FactId, FactKnowledge)> = info
|
|
.knows
|
|
.iter()
|
|
.map(|fact_name| {
|
|
(
|
|
FactId(fact_name.clone()),
|
|
FactKnowledge {
|
|
confidence: KnowledgeConfidence::KnowsOf,
|
|
source: KnowledgeSource::Background,
|
|
state: KnowledgeState::Active,
|
|
acquired_tick: 0,
|
|
disclosure_blocked: false,
|
|
},
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let fact_count = facts.len();
|
|
let knowledge = KnowledgeGraph::with_background(facts);
|
|
world.entity_mut(entity).insert(knowledge);
|
|
|
|
tracing::debug!(
|
|
"Populated {} background facts for NPC: {}",
|
|
fact_count,
|
|
profile.canonical_id
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Axis 3: Populate per-entity Relationships components and the global RelationshipGraph.
|
|
///
|
|
/// Two-pass resolution: canonical_id targets are resolved to StableIds using
|
|
/// the npc_ids map from Phase 1. Unresolvable targets are logged and skipped.
|
|
fn resolve_relationships(
|
|
world: &mut World,
|
|
content: &DistrictContent,
|
|
npc_ids: &BTreeMap<String, StableId>,
|
|
) {
|
|
// Collect all relationship data first, then apply to world.
|
|
// (Avoids holding immutable + mutable borrows on world simultaneously.)
|
|
struct ResolvedRelationship {
|
|
subject_entity: Entity,
|
|
subject_stable_id: StableId,
|
|
entries: Vec<npc::Relationship>,
|
|
}
|
|
|
|
let registry = world.resource::<EntityRegistry>();
|
|
let mut resolved = Vec::new();
|
|
|
|
for profile in &content.npc_profiles {
|
|
if profile.relationships.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let Some(&subject_stable_id) = npc_ids.get(&profile.canonical_id) else {
|
|
continue;
|
|
};
|
|
let Some(subject_entity) = registry.to_entity(&subject_stable_id) else {
|
|
continue;
|
|
};
|
|
|
|
let mut entries = Vec::new();
|
|
for rel in &profile.relationships {
|
|
let Some(&target_stable_id) = npc_ids.get(&rel.target) else {
|
|
tracing::debug!(
|
|
"Skipping relationship {}->{}: target not in npc_ids map",
|
|
profile.canonical_id,
|
|
rel.target
|
|
);
|
|
continue;
|
|
};
|
|
|
|
let kind = parse_relationship_kind(&rel.kind);
|
|
let trust_level = rel.trust.unwrap_or(0).clamp(-10, 10) as i8;
|
|
|
|
entries.push(npc::Relationship {
|
|
target_id: target_stable_id,
|
|
kind,
|
|
trust_level,
|
|
history: Vec::new(),
|
|
});
|
|
}
|
|
|
|
if !entries.is_empty() {
|
|
resolved.push(ResolvedRelationship {
|
|
subject_entity,
|
|
subject_stable_id,
|
|
entries,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Apply: insert per-entity Relationships component + populate global RelationshipGraph
|
|
let rel_count: usize = resolved.iter().map(|r| r.entries.len()).sum();
|
|
|
|
for r in &resolved {
|
|
world
|
|
.entity_mut(r.subject_entity)
|
|
.insert(npc::Relationships {
|
|
entries: r.entries.clone(),
|
|
});
|
|
}
|
|
|
|
// Populate global RelationshipGraph resource
|
|
let mut graph = world
|
|
.get_resource::<npc::relationships::RelationshipGraph>()
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
for r in &resolved {
|
|
for entry in &r.entries {
|
|
graph.set_relationship(
|
|
r.subject_stable_id,
|
|
entry.target_id,
|
|
npc::relationships::RelationshipEdge {
|
|
kind: entry.kind,
|
|
trust: entry.trust_level,
|
|
history: Vec::new(),
|
|
last_interaction_tick: 0,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
world.insert_resource(graph);
|
|
|
|
tracing::debug!(
|
|
"Resolved {} relationship edges across {} NPCs",
|
|
rel_count,
|
|
resolved.len()
|
|
);
|
|
}
|
|
|
|
/// Axis 5: Populate DailyRoutine from routines/schedules.yaml.
|
|
///
|
|
/// Joins schedule entries (npc canonical_id → phase + location + tile) with
|
|
/// spawned NPC entities. Location strings are mapped to TilePosition via
|
|
/// tile coordinates when available; defaults to (0,0,0) otherwise.
|
|
/// Deviations are logged and skipped for v0.1 (v0.2 scope per Tyre).
|
|
fn resolve_routines(
|
|
world: &mut World,
|
|
content: &DistrictContent,
|
|
npc_ids: &BTreeMap<String, StableId>,
|
|
) {
|
|
let Some(routine_file) = &content.routines else {
|
|
tracing::debug!("No routine schedules loaded for district — skipping");
|
|
return;
|
|
};
|
|
|
|
// Collect all resolved routines first (avoids borrow conflict with world).
|
|
struct ResolvedRoutine {
|
|
entity: Entity,
|
|
routine: npc::DailyRoutine,
|
|
npc_id: String,
|
|
deviation_count: usize,
|
|
}
|
|
|
|
let mut resolved = Vec::new();
|
|
|
|
for schedule in &routine_file.schedules {
|
|
let Some(&stable_id) = npc_ids.get(&schedule.npc) else {
|
|
tracing::debug!("Skipping routine for {}: not in npc_ids map", schedule.npc);
|
|
continue;
|
|
};
|
|
let Some(entity) = world.resource::<EntityRegistry>().to_entity(&stable_id) else {
|
|
continue;
|
|
};
|
|
|
|
let mut entries = Vec::new();
|
|
for entry in &schedule.entries {
|
|
let Some(phase) = parse_day_phase(&entry.phase) else {
|
|
tracing::warn!(
|
|
"Unknown day phase '{}' in routine for {}",
|
|
entry.phase,
|
|
schedule.npc
|
|
);
|
|
continue;
|
|
};
|
|
|
|
// Use explicit tile coordinates if provided, otherwise default position
|
|
let location = entry
|
|
.tile
|
|
.as_ref()
|
|
.map(|t| TilePosition::new(t.x, t.y, 0))
|
|
.unwrap_or_else(|| TilePosition::new(0, 0, 0));
|
|
|
|
entries.push(npc::RoutineEntry {
|
|
phase,
|
|
location,
|
|
activity: entry
|
|
.activity
|
|
.clone()
|
|
.unwrap_or_else(|| entry.location.clone()),
|
|
});
|
|
}
|
|
|
|
if !entries.is_empty() {
|
|
resolved.push(ResolvedRoutine {
|
|
entity,
|
|
routine: npc::DailyRoutine {
|
|
entries,
|
|
description: String::new(),
|
|
},
|
|
npc_id: schedule.npc.clone(),
|
|
deviation_count: schedule.deviations.len(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Apply routines to world
|
|
let routines_resolved = resolved.len();
|
|
for r in resolved {
|
|
world.entity_mut(r.entity).insert(r.routine);
|
|
|
|
if r.deviation_count > 0 {
|
|
tracing::debug!(
|
|
"Skipping {} deviations for {} (v0.2 scope)",
|
|
r.deviation_count,
|
|
r.npc_id
|
|
);
|
|
}
|
|
}
|
|
|
|
tracing::debug!("Resolved routines for {} NPCs", routines_resolved);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Content value → ECS enum mapping functions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Parse a want.primary string into a WantKind enum value.
|
|
///
|
|
/// Content files must use exact WantKind enum names (case-insensitive):
|
|
/// Wealth, Safety, Knowledge, Connection, Power, Freedom, Justice, Revenge.
|
|
/// Narrative descriptions belong in want.description, not want.primary.
|
|
fn parse_want_kind(s: &str) -> Option<npc::WantKind> {
|
|
match s.to_lowercase().as_str() {
|
|
"wealth" => Some(npc::WantKind::Wealth),
|
|
"safety" => Some(npc::WantKind::Safety),
|
|
"knowledge" => Some(npc::WantKind::Knowledge),
|
|
"connection" => Some(npc::WantKind::Connection),
|
|
"power" => Some(npc::WantKind::Power),
|
|
"freedom" => Some(npc::WantKind::Freedom),
|
|
"justice" => Some(npc::WantKind::Justice),
|
|
"revenge" => Some(npc::WantKind::Revenge),
|
|
"happiness" => Some(npc::WantKind::Happiness),
|
|
other => {
|
|
tracing::warn!("Unknown WantKind '{}': must be one of Wealth, Safety, Knowledge, Connection, Power, Freedom, Justice, Revenge, Happiness", other);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_relationship_kind(s: &str) -> npc::RelationshipKind {
|
|
match s.to_lowercase().as_str() {
|
|
"colleague" => npc::RelationshipKind::Colleague,
|
|
"friend" => npc::RelationshipKind::Friend,
|
|
"rival" => npc::RelationshipKind::Rival,
|
|
"romantic" => npc::RelationshipKind::Romantic,
|
|
"family" => npc::RelationshipKind::Family,
|
|
"superior" => npc::RelationshipKind::Superior,
|
|
"subordinate" => npc::RelationshipKind::Subordinate,
|
|
other => {
|
|
tracing::warn!(
|
|
"Unknown relationship kind '{}', defaulting to Colleague",
|
|
other
|
|
);
|
|
npc::RelationshipKind::Colleague
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_personality_trait(s: &str) -> Option<npc::PersonalityTrait> {
|
|
match s.to_lowercase().as_str() {
|
|
"cautious" => Some(npc::PersonalityTrait::Cautious),
|
|
"bold" => Some(npc::PersonalityTrait::Bold),
|
|
"honest" => Some(npc::PersonalityTrait::Honest),
|
|
"deceptive" => Some(npc::PersonalityTrait::Deceptive),
|
|
"compassionate" => Some(npc::PersonalityTrait::Compassionate),
|
|
"ruthless" => Some(npc::PersonalityTrait::Ruthless),
|
|
"curious" => Some(npc::PersonalityTrait::Curious),
|
|
"incurious" => Some(npc::PersonalityTrait::Incurious),
|
|
"social" => Some(npc::PersonalityTrait::Social),
|
|
"reclusive" => Some(npc::PersonalityTrait::Reclusive),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn parse_tell_trigger(s: &str) -> npc::TellTrigger {
|
|
match s.to_lowercase().as_str() {
|
|
"stress_above_threshold" | "stressed" => npc::TellTrigger::StressAboveThreshold,
|
|
"always" => npc::TellTrigger::Always,
|
|
other => {
|
|
// Could be a phase-based or activity-based trigger
|
|
if let Some(phase) = parse_day_phase(other) {
|
|
npc::TellTrigger::TimeOfDay(phase)
|
|
} else {
|
|
npc::TellTrigger::DuringActivity(other.to_string())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_skill(s: &str) -> Option<npc::Skill> {
|
|
match s.to_lowercase().as_str() {
|
|
"combat" => Some(npc::Skill::Combat),
|
|
"intimidation" => Some(npc::Skill::Intimidation),
|
|
"medical" => Some(npc::Skill::Medical),
|
|
"observation" => Some(npc::Skill::Observation),
|
|
"persuasion" => Some(npc::Skill::Persuasion),
|
|
"piloting" => Some(npc::Skill::Piloting),
|
|
"stealth" => Some(npc::Skill::Stealth),
|
|
"technical" => Some(npc::Skill::Technical),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Infer secret severity from the narrative description.
|
|
///
|
|
/// Keyword-based heuristic: secrets mentioning criminal activity, violence,
|
|
/// or life-threatening stakes are Major; those mentioning career/exposure are
|
|
/// Moderate; mild social secrets are Minor. Default: Moderate.
|
|
fn parse_secret_severity(description: &str) -> npc::SecretSeverity {
|
|
let lower = description.to_lowercase();
|
|
|
|
// Major: criminal, life-threatening, ring coordination, smuggling operations
|
|
if lower.contains("ring coordinator")
|
|
|| lower.contains("smuggling operation")
|
|
|| lower.contains("violently")
|
|
|| lower.contains("killed")
|
|
|| lower.contains("criminal")
|
|
|| lower.contains("security breach")
|
|
{
|
|
return npc::SecretSeverity::Major;
|
|
}
|
|
|
|
// Minor: social embarrassment, mild secrets, ambiguous situations
|
|
if lower.contains("ambiguous") || lower.contains("embarrassment") || lower.contains("gossip") {
|
|
return npc::SecretSeverity::Minor;
|
|
}
|
|
|
|
// Default: Moderate (career-threatening, covers most v0.1 secrets)
|
|
npc::SecretSeverity::Moderate
|
|
}
|
|
|
|
/// Parse a day phase string into the DayPhase enum.
|
|
pub fn parse_day_phase(s: &str) -> Option<DayPhase> {
|
|
match s.to_lowercase().as_str() {
|
|
"morning" => Some(DayPhase::Morning),
|
|
"afternoon" => Some(DayPhase::Afternoon),
|
|
"evening" => Some(DayPhase::Evening),
|
|
"night" => Some(DayPhase::Night),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::content::types::*;
|
|
|
|
fn create_test_world() -> World {
|
|
let mut world = World::new();
|
|
world.init_resource::<EntityRegistry>();
|
|
world.init_resource::<ContentEntityRegistry>();
|
|
world
|
|
}
|
|
|
|
fn create_test_profile() -> NpcProfile {
|
|
NpcProfile {
|
|
canonical_id: "test-npc".to_string(),
|
|
display_name: "Test NPC".to_string(),
|
|
tier: 1,
|
|
pattern: Some("FRIEND".to_string()),
|
|
motivation: Some("HANDLER".to_string()),
|
|
description: Some("A test NPC".to_string()),
|
|
want: Some(NpcWant {
|
|
primary: "Wealth".to_string(),
|
|
intensity: Some(7),
|
|
description: Some("Wants money".to_string()),
|
|
}),
|
|
secret: None,
|
|
relationships: vec![],
|
|
tolerance: Some(NpcTolerance {
|
|
threshold: Some(60),
|
|
description: None,
|
|
}),
|
|
routine: None,
|
|
information: None,
|
|
contentment: Some(NpcContentment {
|
|
level: Some(25),
|
|
description: None,
|
|
}),
|
|
personality: Some({
|
|
let mut m = BTreeMap::new();
|
|
m.insert("cautious".to_string(), "Very careful".to_string());
|
|
m.insert("deceptive".to_string(), "Good liar".to_string());
|
|
m
|
|
}),
|
|
tells: vec![NpcTell {
|
|
trigger: "stressed".to_string(),
|
|
behavior: "Taps fingers on table".to_string(),
|
|
visible_to: None,
|
|
}],
|
|
skills: Some(NpcSkills {
|
|
combat_trained: Some(false),
|
|
skills: Some({
|
|
let mut m = BTreeMap::new();
|
|
m.insert("persuasion".to_string(), 6);
|
|
m.insert("stealth".to_string(), 4);
|
|
m
|
|
}),
|
|
}),
|
|
triangle_membership: vec!["hub-power".to_string()],
|
|
trust_levels: None,
|
|
friend_arc: None,
|
|
dual_lens: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_npc_creates_entity_with_components() {
|
|
let mut world = create_test_world();
|
|
let profile = create_test_profile();
|
|
let mut result = SpawnResult::default();
|
|
|
|
spawn_npc(&mut world, &profile, &mut result);
|
|
|
|
assert_eq!(result.npcs_spawned, 1);
|
|
assert!(result.npc_ids.contains_key("test-npc"));
|
|
|
|
let stable_id = result.npc_ids["test-npc"];
|
|
let entity = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&stable_id)
|
|
.unwrap();
|
|
|
|
// Verify components
|
|
assert!(world.get::<npc::Npc>(entity).is_some());
|
|
assert!(world.get::<TilePosition>(entity).is_some());
|
|
|
|
let want = world.get::<npc::Want>(entity).unwrap();
|
|
assert_eq!(want.primary, npc::WantKind::Wealth);
|
|
assert_eq!(want.intensity, 7);
|
|
|
|
let tolerance = world.get::<npc::ToleranceThreshold>(entity).unwrap();
|
|
assert_eq!(tolerance.threshold, 60);
|
|
|
|
let contentment = world.get::<npc::Contentment>(entity).unwrap();
|
|
assert_eq!(contentment.level, 25);
|
|
|
|
let personality = world.get::<npc::PersonalityTraits>(entity).unwrap();
|
|
assert_eq!(personality.traits.len(), 2);
|
|
|
|
let tells = world.get::<npc::TellSystem>(entity).unwrap();
|
|
assert_eq!(tells.tells.len(), 1);
|
|
assert_eq!(
|
|
tells.tells[0].trigger,
|
|
npc::TellTrigger::StressAboveThreshold
|
|
);
|
|
|
|
let skills = world.get::<npc::SkillSet>(entity).unwrap();
|
|
assert_eq!(skills.skills.len(), 2);
|
|
assert!(!skills.combat_trained);
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_npc_attaches_content_slug() {
|
|
let mut world = create_test_world();
|
|
let profile = create_test_profile();
|
|
let mut result = SpawnResult::default();
|
|
|
|
spawn_npc(&mut world, &profile, &mut result);
|
|
|
|
let stable_id = result.npc_ids["test-npc"];
|
|
let entity = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&stable_id)
|
|
.unwrap();
|
|
|
|
let slug = world
|
|
.get::<ContentSlug>(entity)
|
|
.expect("ContentSlug should be attached during spawn");
|
|
assert_eq!(slug.0, "test-npc");
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_npc_minimal_profile() {
|
|
let mut world = create_test_world();
|
|
let profile = NpcProfile {
|
|
canonical_id: "minimal".to_string(),
|
|
display_name: "Minimal NPC".to_string(),
|
|
tier: 3,
|
|
pattern: None,
|
|
motivation: None,
|
|
description: None,
|
|
want: None,
|
|
secret: None,
|
|
relationships: vec![],
|
|
tolerance: None,
|
|
routine: None,
|
|
information: None,
|
|
contentment: None,
|
|
personality: None,
|
|
tells: vec![],
|
|
skills: None,
|
|
triangle_membership: vec![],
|
|
trust_levels: None,
|
|
friend_arc: None,
|
|
dual_lens: None,
|
|
};
|
|
let mut result = SpawnResult::default();
|
|
|
|
spawn_npc(&mut world, &profile, &mut result);
|
|
|
|
assert_eq!(result.npcs_spawned, 1);
|
|
let stable_id = result.npc_ids["minimal"];
|
|
let entity = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&stable_id)
|
|
.unwrap();
|
|
|
|
// Only Npc marker and TilePosition should be present
|
|
assert!(world.get::<npc::Npc>(entity).is_some());
|
|
assert!(world.get::<TilePosition>(entity).is_some());
|
|
assert!(world.get::<npc::Want>(entity).is_none());
|
|
assert!(world.get::<npc::ToleranceThreshold>(entity).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_want_kinds() {
|
|
// Exact enum names (case-insensitive)
|
|
assert_eq!(parse_want_kind("Wealth"), Some(npc::WantKind::Wealth));
|
|
assert_eq!(parse_want_kind("safety"), Some(npc::WantKind::Safety));
|
|
assert_eq!(parse_want_kind("KNOWLEDGE"), Some(npc::WantKind::Knowledge));
|
|
assert_eq!(
|
|
parse_want_kind("Connection"),
|
|
Some(npc::WantKind::Connection)
|
|
);
|
|
assert_eq!(parse_want_kind("Power"), Some(npc::WantKind::Power));
|
|
assert_eq!(parse_want_kind("Freedom"), Some(npc::WantKind::Freedom));
|
|
assert_eq!(parse_want_kind("Justice"), Some(npc::WantKind::Justice));
|
|
assert_eq!(parse_want_kind("Revenge"), Some(npc::WantKind::Revenge));
|
|
assert_eq!(parse_want_kind("Happiness"), Some(npc::WantKind::Happiness));
|
|
|
|
// Narrative strings are NOT matched — content must use exact enum keywords
|
|
assert_eq!(parse_want_kind("operational stability"), None);
|
|
assert_eq!(parse_want_kind("control"), None);
|
|
assert_eq!(parse_want_kind("answers"), None);
|
|
|
|
// Unknown
|
|
assert_eq!(parse_want_kind("bogus"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_day_phases() {
|
|
assert_eq!(parse_day_phase("morning"), Some(DayPhase::Morning));
|
|
assert_eq!(parse_day_phase("Evening"), Some(DayPhase::Evening));
|
|
assert_eq!(parse_day_phase("unknown"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_want_kind_drops_component() {
|
|
let mut world = create_test_world();
|
|
let mut profile = create_test_profile();
|
|
profile.want = Some(NpcWant {
|
|
primary: "nonexistent_desire".to_string(),
|
|
intensity: Some(5),
|
|
description: None,
|
|
});
|
|
let mut result = SpawnResult::default();
|
|
|
|
spawn_npc(&mut world, &profile, &mut result);
|
|
|
|
// NPC should still spawn
|
|
assert_eq!(result.npcs_spawned, 1);
|
|
let stable_id = result.npc_ids["test-npc"];
|
|
let entity = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&stable_id)
|
|
.unwrap();
|
|
|
|
// But Want component should NOT be attached (invalid kind was dropped)
|
|
assert!(world.get::<npc::Npc>(entity).is_some());
|
|
assert!(world.get::<npc::Want>(entity).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_information_populates_knowledge_graph() {
|
|
let mut world = create_test_world();
|
|
|
|
// Spawn an NPC with information.knows
|
|
let mut profile = create_test_profile();
|
|
profile.information = Some(NpcInformation {
|
|
knows: vec![
|
|
"contraband.ring_exists".to_string(),
|
|
"relationship.kael_trust".to_string(),
|
|
],
|
|
access_tier: Some("insider".to_string()),
|
|
});
|
|
|
|
let mut result = SpawnResult::default();
|
|
spawn_npc(&mut world, &profile, &mut result);
|
|
|
|
// Run Phase 2 information resolution
|
|
let district = crate::content::loader::DistrictContent {
|
|
npc_profiles: vec![profile],
|
|
..Default::default()
|
|
};
|
|
resolve_information(&mut world, &district, &result.npc_ids);
|
|
|
|
// Verify KnowledgeGraph was attached with background facts
|
|
let stable_id = result.npc_ids["test-npc"];
|
|
let entity = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&stable_id)
|
|
.unwrap();
|
|
|
|
let kg = world
|
|
.get::<KnowledgeGraph>(entity)
|
|
.expect("KnowledgeGraph should be attached");
|
|
assert!(kg.knows_fact(&FactId("contraband.ring_exists".to_string())));
|
|
assert!(kg.knows_fact(&FactId("relationship.kael_trust".to_string())));
|
|
assert!(kg.fact_at_least(
|
|
&FactId("contraband.ring_exists".to_string()),
|
|
KnowledgeConfidence::KnowsOf
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn two_phase_spawn_via_spawn_content() {
|
|
let mut world = create_test_world();
|
|
|
|
let mut profile = create_test_profile();
|
|
profile.information = Some(NpcInformation {
|
|
knows: vec!["investigation.inspection_lapses".to_string()],
|
|
access_tier: None,
|
|
});
|
|
|
|
let mut store = ContentStore::default();
|
|
let mut district = crate::content::loader::DistrictContent::default();
|
|
district.npc_profiles.push(profile);
|
|
store.districts.insert("test".to_string(), district);
|
|
|
|
let result = spawn_content(&mut world, &store);
|
|
|
|
assert_eq!(result.npcs_spawned, 1);
|
|
|
|
// Verify Phase 2 ran: KnowledgeGraph should be present
|
|
let stable_id = result.npc_ids["test-npc"];
|
|
let entity = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&stable_id)
|
|
.unwrap();
|
|
|
|
let kg = world
|
|
.get::<KnowledgeGraph>(entity)
|
|
.expect("Phase 2 should attach KnowledgeGraph");
|
|
assert!(kg.knows_fact(&FactId("investigation.inspection_lapses".to_string())));
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_npc_with_secret() {
|
|
let mut world = create_test_world();
|
|
let mut profile = create_test_profile();
|
|
profile.secret = Some("Takes a cut from the smuggling operation".to_string());
|
|
|
|
let mut result = SpawnResult::default();
|
|
spawn_npc(&mut world, &profile, &mut result);
|
|
|
|
let stable_id = result.npc_ids["test-npc"];
|
|
let entity = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&stable_id)
|
|
.unwrap();
|
|
|
|
let secret = world
|
|
.get::<npc::Secret>(entity)
|
|
.expect("Secret should be attached");
|
|
assert!(secret.description.contains("smuggling operation"));
|
|
assert_eq!(secret.severity, npc::SecretSeverity::Major);
|
|
assert!(secret.known_by.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_secret_severity_keywords() {
|
|
assert_eq!(
|
|
parse_secret_severity("Ring coordinator who sets volume targets"),
|
|
npc::SecretSeverity::Major
|
|
);
|
|
assert_eq!(
|
|
parse_secret_severity("Handles cargo routing for the smuggling operation"),
|
|
npc::SecretSeverity::Major
|
|
);
|
|
assert_eq!(
|
|
parse_secret_severity("Security breach — unauthorized contact"),
|
|
npc::SecretSeverity::Major
|
|
);
|
|
assert_eq!(
|
|
parse_secret_severity("Spending is deliberately ambiguous"),
|
|
npc::SecretSeverity::Minor
|
|
);
|
|
assert_eq!(
|
|
parse_secret_severity("Gambling debt and compromised inspections"),
|
|
npc::SecretSeverity::Moderate
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_relationship_kinds() {
|
|
assert_eq!(
|
|
parse_relationship_kind("friend"),
|
|
npc::RelationshipKind::Friend
|
|
);
|
|
assert_eq!(
|
|
parse_relationship_kind("colleague"),
|
|
npc::RelationshipKind::Colleague
|
|
);
|
|
assert_eq!(
|
|
parse_relationship_kind("family"),
|
|
npc::RelationshipKind::Family
|
|
);
|
|
assert_eq!(
|
|
parse_relationship_kind("romantic"),
|
|
npc::RelationshipKind::Romantic
|
|
);
|
|
assert_eq!(
|
|
parse_relationship_kind("superior"),
|
|
npc::RelationshipKind::Superior
|
|
);
|
|
assert_eq!(
|
|
parse_relationship_kind("subordinate"),
|
|
npc::RelationshipKind::Subordinate
|
|
);
|
|
assert_eq!(
|
|
parse_relationship_kind("rival"),
|
|
npc::RelationshipKind::Rival
|
|
);
|
|
// Unknown defaults to Colleague
|
|
assert_eq!(
|
|
parse_relationship_kind("acquaintance"),
|
|
npc::RelationshipKind::Colleague
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_relationships_populates_components_and_graph() {
|
|
let mut world = create_test_world();
|
|
world.init_resource::<npc::relationships::RelationshipGraph>();
|
|
|
|
// Spawn two NPCs
|
|
let mut profile_a = create_test_profile();
|
|
profile_a.canonical_id = "npc-a".to_string();
|
|
profile_a.relationships = vec![NpcRelationship {
|
|
target: "npc-b".to_string(),
|
|
kind: "friend".to_string(),
|
|
trust: Some(7),
|
|
notes: None,
|
|
}];
|
|
|
|
let mut profile_b = create_test_profile();
|
|
profile_b.canonical_id = "npc-b".to_string();
|
|
profile_b.relationships = vec![NpcRelationship {
|
|
target: "npc-a".to_string(),
|
|
kind: "colleague".to_string(),
|
|
trust: Some(3),
|
|
notes: None,
|
|
}];
|
|
|
|
let mut result = SpawnResult::default();
|
|
spawn_npc(&mut world, &profile_a, &mut result);
|
|
spawn_npc(&mut world, &profile_b, &mut result);
|
|
|
|
// Run Phase 2 relationship resolution
|
|
let district = crate::content::loader::DistrictContent {
|
|
npc_profiles: vec![profile_a, profile_b],
|
|
..Default::default()
|
|
};
|
|
resolve_relationships(&mut world, &district, &result.npc_ids);
|
|
|
|
// Verify per-entity Relationships components
|
|
let entity_a = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&result.npc_ids["npc-a"])
|
|
.unwrap();
|
|
let rels_a = world.get::<npc::Relationships>(entity_a).unwrap();
|
|
assert_eq!(rels_a.entries.len(), 1);
|
|
assert_eq!(rels_a.entries[0].kind, npc::RelationshipKind::Friend);
|
|
assert_eq!(rels_a.entries[0].trust_level, 7);
|
|
|
|
let entity_b = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&result.npc_ids["npc-b"])
|
|
.unwrap();
|
|
let rels_b = world.get::<npc::Relationships>(entity_b).unwrap();
|
|
assert_eq!(rels_b.entries.len(), 1);
|
|
assert_eq!(rels_b.entries[0].kind, npc::RelationshipKind::Colleague);
|
|
|
|
// Verify global RelationshipGraph
|
|
let graph = world.resource::<npc::relationships::RelationshipGraph>();
|
|
assert_eq!(graph.edge_count(), 2);
|
|
let edge = graph
|
|
.get_relationship(&result.npc_ids["npc-a"], &result.npc_ids["npc-b"])
|
|
.unwrap();
|
|
assert_eq!(edge.kind, npc::RelationshipKind::Friend);
|
|
assert_eq!(edge.trust, 7);
|
|
}
|
|
|
|
#[test]
|
|
#[test]
|
|
fn spawn_npc_combat_trained_sets_skill_flag() {
|
|
// #91: combat_trained: true in YAML sets SkillSet.combat_trained = true.
|
|
// Known gap: CombatCapability is NOT yet attached for content-spawned NPCs
|
|
// (see TODO in spawn.rs near "Supporting axis 3: Skills"). The procedural
|
|
// path (generate.rs) correctly attaches CombatCapability. This test documents
|
|
// current behavior so the gap is visible in CI.
|
|
let mut world = create_test_world();
|
|
let mut profile = create_test_profile();
|
|
profile.skills = Some(NpcSkills {
|
|
combat_trained: Some(true),
|
|
skills: Some({
|
|
let mut m = std::collections::BTreeMap::new();
|
|
m.insert("combat".to_string(), 7);
|
|
m
|
|
}),
|
|
});
|
|
|
|
let mut result = SpawnResult::default();
|
|
spawn_npc(&mut world, &profile, &mut result);
|
|
|
|
let entity = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&result.npc_ids["test-npc"])
|
|
.unwrap();
|
|
|
|
// SkillSet.combat_trained is correctly set from YAML (#91 — done)
|
|
let skills = world.get::<npc::SkillSet>(entity).unwrap();
|
|
assert!(
|
|
skills.combat_trained,
|
|
"SkillSet.combat_trained should be true when YAML sets combat_trained: true"
|
|
);
|
|
|
|
// Known gap: CombatCapability not yet attached in content-spawn path.
|
|
// The procedural path (generate.rs) does attach it — content path has TODO.
|
|
// Update this assertion when the TODO is resolved.
|
|
assert!(
|
|
world.get::<npc::CombatCapability>(entity).is_none(),
|
|
"CombatCapability not yet attached in content-spawn path (known gap — see spawn.rs TODO)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_relationships_skips_unknown_targets() {
|
|
let mut world = create_test_world();
|
|
world.init_resource::<npc::relationships::RelationshipGraph>();
|
|
|
|
let mut profile = create_test_profile();
|
|
profile.relationships = vec![NpcRelationship {
|
|
target: "nonexistent-npc".to_string(),
|
|
kind: "friend".to_string(),
|
|
trust: Some(5),
|
|
notes: None,
|
|
}];
|
|
|
|
let mut result = SpawnResult::default();
|
|
spawn_npc(&mut world, &profile, &mut result);
|
|
|
|
let district = crate::content::loader::DistrictContent {
|
|
npc_profiles: vec![profile],
|
|
..Default::default()
|
|
};
|
|
resolve_relationships(&mut world, &district, &result.npc_ids);
|
|
|
|
// No Relationships component should be attached (all targets unresolvable)
|
|
let entity = world
|
|
.resource::<EntityRegistry>()
|
|
.to_entity(&result.npc_ids["test-npc"])
|
|
.unwrap();
|
|
assert!(world.get::<npc::Relationships>(entity).is_none());
|
|
}
|
|
}
|