refactor(simulation): remove v0.1 content loading system (#655)

Delete the hand-authored YAML content pipeline (server/src/content/) superseded
by the v0.2 generator-first approach (D-122, D-128). Runtime ECS types that were
co-located with content loading have been extracted to dedicated simulation modules:

- simulation/triangle.rs: TriangleState, TriangleCrisisEventQueue, tick/resolve systems
- simulation/line_pool.rs: LinePoolIndex, AccessTier, TrustTier, Mood, LinePoolIndexResource
- simulation/knowledge_grant.rs: KnowledgeGrant, Prerequisites

Monologue systems (trigger_monologue, trigger_recognition_monologue,
trigger_event_monologue) now use hardcoded fallback lines only; the
ContentStoreResource branch and select_pool_line function are removed.

Deleted: content/{loader,types,line_pool,hot_reload,spawn,instantiation,entanglement,mod}.rs
Deleted: tests/{content_loading,content_runtime,content_scaling,template_instantiation,template_schema}.rs
Deleted: bin/line_preview.rs (v0.1 tool)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 09:07:57 +01:00
co-authored by Claude Sonnet 4.6
parent b979cab41e
commit ce0df2f320
39 changed files with 721 additions and 8943 deletions
-645
View File
@@ -1,645 +0,0 @@
//! Line previewer CLI — content authoring tool (#193).
//!
//! Loads YAML content packs and previews dialogue/monologue lines with
//! simulated filter context. Designed for content authors to verify line
//! gating, prerequisite logic, and selection ordering before runtime.
//!
//! # Examples
//!
//! ```sh
//! # Monologue: show lines for smuggler character
//! cargo run --bin line_preview -- --character smuggler
//!
//! # Monologue with knowledge context and explain mode
//! cargo run --bin line_preview -- --character smuggler --knows smuggling_operation --explain
//!
//! # Dialogue: show lines for dock-worker at the-last-shift
//! cargo run --bin line_preview -- --role dock-worker --location the-last-shift \
//! --access insider --trust real --situation bar_evening
//!
//! # Monologue sequence (priority-ordered)
//! cargo run --bin line_preview -- --character smuggler --location the-terminal --sequence
//! ```
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::process;
use clap::Parser;
use settled_reach_server::content::line_pool::*;
use settled_reach_server::content::loader;
#[derive(Parser)]
#[command(
name = "line_preview",
about = "Preview dialogue and monologue lines from content packs"
)]
struct Args {
/// Content directory root (must contain content.yaml)
#[arg(long, default_value = "content")]
content_root: PathBuf,
// -- Mode detection --
/// Character for monologue mode (smuggler, detective)
#[arg(long)]
character: Option<String>,
/// NPC role for dialogue mode (e.g., dock-worker, bar-owner)
#[arg(long)]
role: Option<String>,
// -- Shared --
/// Location filter
#[arg(long)]
location: Option<String>,
// -- Monologue options --
/// Trigger filter for monologue (enter_location, observe_npc, etc.)
#[arg(long)]
trigger: Option<String>,
/// Known facts for prerequisite checking (repeatable: --knows fact_a --knows fact_b)
#[arg(long)]
knows: Vec<String>,
/// Show priority-ordered monologue sequence
#[arg(long)]
sequence: bool,
// -- Dialogue options --
/// Player access tier for dialogue (public, insider, authority, peer, hostile)
#[arg(long, default_value = "public")]
access: String,
/// Player trust tier for dialogue (surface, real, secret)
#[arg(long, default_value = "surface")]
trust: String,
/// Active situations for dialogue (comma-separated: --situation arrival,bar_evening)
#[arg(long, value_delimiter = ',')]
situation: Vec<String>,
// -- Output control --
/// Show filter reasoning for each line
#[arg(long)]
explain: bool,
}
fn main() {
let args = Args::parse();
// Load content
let store = match loader::load_content(&args.content_root) {
Ok(s) => s,
Err(e) => {
eprintln!(
"Error: failed to load content from {:?}: {}",
args.content_root, e
);
process::exit(1);
}
};
// Build line pool index
let index = LinePoolIndex::build(&store);
eprintln!(
"Loaded: {} dialogue lines, {} monologue lines",
index.dialogue_line_count(),
index.monologue_line_count()
);
// Route to mode based on flags
if args.character.is_some() {
run_monologue(&index, &args);
} else if args.role.is_some() {
run_dialogue(&index, &args);
} else {
print_summary(&index);
}
}
// ---------------------------------------------------------------------------
// Summary mode — no mode flags, show what's available
// ---------------------------------------------------------------------------
fn print_summary(index: &LinePoolIndex) {
println!("=== Content Summary ===\n");
if !index.dialogue.is_empty() {
println!("Dialogue pools:");
for ((loc, role), pool) in &index.dialogue {
println!(" {loc} / {role}: {} lines", pool.lines.len());
}
}
if !index.monologue.is_empty() {
println!("\nMonologue pools:");
for ((character, loc), pool) in &index.monologue {
let line_count: usize = pool.by_trigger.values().map(|v| v.len()).sum();
let triggers: Vec<&str> = pool.by_trigger.keys().map(trigger_str).collect();
println!(
" {} @ {loc}: {line_count} lines [{triggers}]",
character_str(character),
triggers = triggers.join(", ")
);
}
}
println!("\nUse --character <name> for monologue or --role <role> --location <loc> for dialogue.");
}
// ---------------------------------------------------------------------------
// Monologue mode
// ---------------------------------------------------------------------------
fn run_monologue(index: &LinePoolIndex, args: &Args) {
let char_str = args.character.as_deref().unwrap();
let character: Character = parse_or_exit(char_str, "character", "smuggler, detective");
let known_facts: BTreeSet<&str> = args.knows.iter().map(|s| s.as_str()).collect();
let trigger_filter: Option<Trigger> = args.trigger.as_deref().map(|t| {
parse_or_exit(
t,
"trigger",
"enter_location, observe_npc, hear_sound, observe_anomaly, \
post_conversation, discover_evidence, witness_interaction, time_idle, return_visit",
)
});
// Header
println!("Mode: monologue");
println!("Character: {}", character_str(&character));
if let Some(loc) = &args.location {
println!("Location: {}", loc);
}
if let Some(tf) = &trigger_filter {
println!("Trigger: {}", trigger_str(tf));
}
if !known_facts.is_empty() {
println!("Known facts: {}", args.knows.join(", "));
}
println!();
// Collect matching pools
let pools: Vec<_> = index
.monologue
.iter()
.filter(|((c, loc), _)| {
*c == character && args.location.as_ref().map_or(true, |l| loc == l)
})
.collect();
if pools.is_empty() {
println!("No monologue pools found for {}", character_str(&character));
if let Some(loc) = &args.location {
println!(" (location filter: {})", loc);
}
return;
}
if args.sequence {
run_monologue_sequence(&pools, &known_facts, trigger_filter.as_ref());
return;
}
let mut pass_count = 0u32;
let mut fail_count = 0u32;
for ((_, loc), pool) in &pools {
println!("--- {} ---", loc);
for (trigger, lines) in &pool.by_trigger {
let trigger_match = trigger_filter.as_ref().map_or(true, |tf| trigger == tf);
for line in lines {
let prereq_pass = check_prerequisites(line, &known_facts);
let overall = trigger_match && prereq_pass;
if args.explain {
let mark = if overall { "PASS" } else { "FAIL" };
println!(
"\n [{}] {} (pri:{} cd:{})",
mark, line.id, line.priority, line.cooldown
);
if trigger_filter.is_some() {
println!(
" trigger: {} {}",
trigger_str(trigger),
if trigger_match { "+" } else { "- (filtered)" }
);
} else {
println!(" trigger: {}", trigger_str(trigger));
}
print_prereq_detail(line, &known_facts);
if !line.tags.is_empty() {
println!(" tags: [{}]", line.tags.join(", "));
}
println!(" \"{}\"", line.text);
} else if overall {
println!(
" [{:>2}] [{}] {} \"{}\"",
line.priority,
trigger_str(trigger),
line.id,
line.text
);
}
if overall {
pass_count += 1;
} else {
fail_count += 1;
}
}
}
}
println!("\n{} matched, {} filtered", pass_count, fail_count);
}
// ---------------------------------------------------------------------------
// Monologue sequence mode — priority-ordered preview
// ---------------------------------------------------------------------------
fn run_monologue_sequence(
pools: &[(&(Character, String), &IndexedMonologuePool)],
known_facts: &BTreeSet<&str>,
trigger_filter: Option<&Trigger>,
) {
println!("=== Sequence Preview (priority order) ===\n");
// Collect all passing lines across pools and triggers
let mut all_lines: Vec<(&str, &Trigger, &IndexedMonologueLine)> = Vec::new();
for ((_, loc), pool) in pools {
for (trigger, lines) in &pool.by_trigger {
if let Some(tf) = trigger_filter {
if trigger != tf {
continue;
}
}
for line in lines {
if check_prerequisites(line, known_facts) {
all_lines.push((loc.as_str(), trigger, line));
}
}
}
}
// Sort by priority descending, then by id for determinism
all_lines.sort_by(|a, b| {
b.2.priority
.cmp(&a.2.priority)
.then_with(|| a.2.id.cmp(&b.2.id))
});
if all_lines.is_empty() {
println!(" (no matching lines)");
return;
}
for (i, (loc, trigger, line)) in all_lines.iter().enumerate() {
println!(
" {:>2}. [pri:{:>2}] [{}] [{}] {}",
i + 1,
line.priority,
trigger_str(trigger),
loc,
line.id,
);
println!(" \"{}\"", line.text);
}
println!("\n{} lines in sequence", all_lines.len());
}
// ---------------------------------------------------------------------------
// Dialogue mode
// ---------------------------------------------------------------------------
fn run_dialogue(index: &LinePoolIndex, args: &Args) {
let role = args.role.as_deref().unwrap();
let location = args.location.as_deref().unwrap_or_else(|| {
eprintln!("Error: --location is required for dialogue mode");
process::exit(1)
});
let access: AccessTier = parse_or_exit(
&args.access,
"access",
"public, insider, authority, peer, hostile",
);
let trust: TrustTier = parse_or_exit(&args.trust, "trust", "surface, real, secret");
let situations: Vec<Situation> = if args.situation.is_empty() {
vec![Situation::Arrival]
} else {
args.situation
.iter()
.map(|s| {
parse_or_exit(
s,
"situation",
"arrival, shift_start, shift_end, shift_transition, bar_evening, \
night_shift, investigation, confrontation, social, alone, \
emergency, routine, observation, greeting, first_meeting, \
repeated_visit",
)
})
.collect()
};
// Header
println!("Mode: dialogue");
println!("Location: {}, Role: {}", location, role);
println!(
"Access: {}, Trust: {}",
access_str(&access),
trust_str(&trust)
);
println!(
"Situations: [{}]",
situations
.iter()
.map(situation_str)
.collect::<Vec<_>>()
.join(", ")
);
println!();
let key = (location.to_string(), role.to_string());
let Some(pool) = index.dialogue.get(&key) else {
println!(
"No dialogue pool found for {} / {}",
location, role
);
return;
};
if args.explain {
run_dialogue_explain(pool, access, trust, &situations);
} else {
let results = index.query_dialogue(location, role, access, &situations, trust);
if results.is_empty() {
println!("No matching lines.");
return;
}
for line in &results {
println!(" {} \"{}\"", line.id, line.text);
if !line.topic.is_empty() || !line.mood.is_empty() {
println!(
" topic: [{}] mood: [{}]",
line.topic
.iter()
.map(topic_str)
.collect::<Vec<_>>()
.join(", "),
line.mood
.iter()
.map(mood_str)
.collect::<Vec<_>>()
.join(", ")
);
}
}
println!("\n{} lines matched", results.len());
}
}
fn run_dialogue_explain(
pool: &IndexedDialoguePool,
access: AccessTier,
trust: TrustTier,
situations: &[Situation],
) {
let mut pass_count = 0u32;
let mut fail_count = 0u32;
for line in &pool.lines {
let l1 = line.access.contains(&access);
let l2 = line.situation.iter().any(|s| situations.contains(s));
let l3 = trust.meets(line.trust);
let overall = l1 && l2 && l3;
let mark = if overall { "PASS" } else { "FAIL" };
println!("[{}] {}", mark, line.id);
println!(
" L1 access: requires [{}], player has {} {}",
line.access
.iter()
.map(access_str)
.collect::<Vec<_>>()
.join(", "),
access_str(&access),
if l1 { "+" } else { "-" }
);
println!(
" L2 situation: requires [{}], active [{}] {}",
line.situation
.iter()
.map(situation_str)
.collect::<Vec<_>>()
.join(", "),
situations
.iter()
.map(situation_str)
.collect::<Vec<_>>()
.join(", "),
if l2 { "+" } else { "-" }
);
println!(
" L3 trust: requires {}, player has {} {}",
trust_str(&line.trust),
trust_str(&trust),
if l3 { "+" } else { "-" }
);
if !line.topic.is_empty() || !line.mood.is_empty() {
println!(
" L4 topic: [{}], mood: [{}]",
line.topic
.iter()
.map(topic_str)
.collect::<Vec<_>>()
.join(", "),
line.mood
.iter()
.map(mood_str)
.collect::<Vec<_>>()
.join(", ")
);
}
println!(" \"{}\"", line.text);
println!();
if overall {
pass_count += 1;
} else {
fail_count += 1;
}
}
println!("{} passed, {} filtered", pass_count, fail_count);
}
// ---------------------------------------------------------------------------
// Prerequisite checking
// ---------------------------------------------------------------------------
/// Check monologue line prerequisites against known facts.
///
/// Fact prerequisites pass if the fact_id is in the known set.
/// Entity attributes and relationships require runtime state and are
/// treated as passing (shown as unchecked in explain mode).
fn check_prerequisites(line: &IndexedMonologueLine, known_facts: &BTreeSet<&str>) -> bool {
let Some(prereqs) = &line.prerequisites else {
return true;
};
prereqs
.facts
.iter()
.all(|f| known_facts.contains(f.fact_id.as_str()))
}
/// Print prerequisite detail for explain mode.
fn print_prereq_detail(line: &IndexedMonologueLine, known_facts: &BTreeSet<&str>) {
let Some(prereqs) = &line.prerequisites else {
println!(" prerequisites: none");
return;
};
println!(" prerequisites:");
for fact in &prereqs.facts {
let has_it = known_facts.contains(fact.fact_id.as_str());
println!(
" fact {} >= {} {}",
fact.fact_id,
fact.min_confidence,
if has_it { "+" } else { "- (not in --knows)" }
);
}
for attr in &prereqs.entity_attributes {
println!(
" entity_attr {}.{} == {} ? (unchecked — needs runtime)",
attr.entity, attr.key, attr.value
);
}
if let Some(rel) = &prereqs.relationship {
let target = rel.target.as_deref().unwrap_or("?");
let state = rel.state.as_deref().unwrap_or("?");
println!(
" relationship {} state={} ? (unchecked — needs runtime)",
target, state
);
}
}
// ---------------------------------------------------------------------------
// Enum → string helpers (mirrors FromStr in line_pool.rs)
// ---------------------------------------------------------------------------
fn parse_or_exit<T: std::str::FromStr>(s: &str, kind: &str, valid: &str) -> T {
s.parse().unwrap_or_else(|_| {
eprintln!("Error: invalid {} '{}'. Valid: {}", kind, s, valid);
process::exit(1)
})
}
fn character_str(c: &Character) -> &'static str {
match c {
Character::Smuggler => "smuggler",
Character::Detective => "detective",
}
}
fn access_str(t: &AccessTier) -> &'static str {
match t {
AccessTier::Public => "public",
AccessTier::Insider => "insider",
AccessTier::Authority => "authority",
AccessTier::Peer => "peer",
AccessTier::Hostile => "hostile",
}
}
fn trust_str(t: &TrustTier) -> &'static str {
match t {
TrustTier::Surface => "surface",
TrustTier::Real => "real",
TrustTier::Secret => "secret",
}
}
fn situation_str(s: &Situation) -> &'static str {
match s {
Situation::Arrival => "arrival",
Situation::ShiftStart => "shift_start",
Situation::ShiftEnd => "shift_end",
Situation::ShiftTransition => "shift_transition",
Situation::BarEvening => "bar_evening",
Situation::NightShift => "night_shift",
Situation::Investigation => "investigation",
Situation::Confrontation => "confrontation",
Situation::Social => "social",
Situation::Alone => "alone",
Situation::Emergency => "emergency",
Situation::Routine => "routine",
Situation::Observation => "observation",
Situation::Greeting => "greeting",
Situation::FirstMeeting => "first_meeting",
Situation::RepeatedVisit => "repeated_visit",
}
}
fn trigger_str(t: &Trigger) -> &'static str {
match t {
Trigger::EnterLocation => "enter_location",
Trigger::ObserveNpc => "observe_npc",
Trigger::HearSound => "hear_sound",
Trigger::ObserveAnomaly => "observe_anomaly",
Trigger::PostConversation => "post_conversation",
Trigger::DiscoverEvidence => "discover_evidence",
Trigger::WitnessInteraction => "witness_interaction",
Trigger::TimeIdle => "time_idle",
Trigger::ReturnVisit => "return_visit",
}
}
fn topic_str(t: &Topic) -> &'static str {
match t {
Topic::Colleague => "colleague",
Topic::Routine => "routine",
Topic::Cargo => "cargo",
Topic::Money => "money",
Topic::Trust => "trust",
Topic::Danger => "danger",
Topic::Institution => "institution",
Topic::Personal => "personal",
Topic::Investigation => "investigation",
}
}
fn mood_str(m: &Mood) -> &'static str {
match m {
Mood::Anxious => "anxious",
Mood::Frustrated => "frustrated",
Mood::Content => "content",
Mood::Suspicious => "suspicious",
Mood::Warm => "warm",
Mood::Hostile => "hostile",
Mood::Relieved => "relieved",
Mood::Focused => "focused",
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ use bevy_ecs::prelude::*;
use crate::bridge::types::{
DebugCommandKind, DebugEnabled, DebugResponsePayload, SnapshotBuffer,
};
use crate::content::template::TriangleState;
use crate::simulation::triangle::TriangleState;
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::conversation::NpcName;
+23 -3
View File
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 19;
pub const PROTOCOL_VERSION: u8 = 20;
/// Handshake message sent as the very first framed message after connection (#555).
/// Client reads this before entering the normal tick loop and validates
@@ -80,6 +80,7 @@ pub struct StartupMessage {
/// sim_errors (#85, structured error reporting to client).
/// v18 adds: debug_response (#580, debug console server — command/response wire).
/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591).
/// v20 adds: settings_response (#627, SQLite settings IPC).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
@@ -210,6 +211,11 @@ pub struct ObserverSnapshot {
/// None when player is outside the bar or no ticker content is loaded.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_ticker: Option<TickerLine>,
/// Settings response (#627, SQLite settings IPC).
/// Present for exactly one tick after a settings operation completes.
/// Client reads to confirm setting changes or to populate the settings UI.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings_response: Option<crate::settings::types::SettingsResponseWire>,
}
/// A single news ticker headline crossing the wire boundary (#591).
@@ -523,6 +529,18 @@ pub enum PlayerAction {
/// Debug console command (#580). Only processed when `DebugEnabled` is true.
/// Response delivered via `ObserverSnapshot.debug_response`.
DebugCommand(DebugCommandKind),
/// Change a single setting (#627). Server persists to SQLite and sends
/// a `SettingsResponseWire` confirmation in the next snapshot.
ChangeSetting {
key: String,
value: crate::settings::types::SettingValue,
},
/// Request a full settings dump (#627). Server responds with all current
/// settings in `ObserverSnapshot.settings_response`.
RequestAllSettings,
/// Delete a single setting (#627). Restores the key to its default
/// (absent from the database). Confirmation via `settings_response`.
DeleteSetting { key: String },
}
impl PlayerAction {
@@ -839,8 +857,8 @@ pub struct TriangleCrisisEventWire {
pub tick: u64,
}
impl From<crate::content::template::TriangleCrisisEvent> for TriangleCrisisEventWire {
fn from(e: crate::content::template::TriangleCrisisEvent) -> Self {
impl From<crate::simulation::triangle::TriangleCrisisEvent> for TriangleCrisisEventWire {
fn from(e: crate::simulation::triangle::TriangleCrisisEvent) -> Self {
Self {
triangle_id: e.triangle_id.into(),
role_assignments: e
@@ -912,6 +930,8 @@ pub struct SnapshotBuffer {
pub pending_save_result: Option<SaveLoadResultWire>,
/// Pending debug response, consumed once by `compute_observer_snapshot` (#580).
pub pending_debug_response: Option<DebugResponsePayload>,
/// Pending settings response, consumed once by `compute_observer_snapshot` (#627).
pub pending_settings_response: Option<crate::settings::types::SettingsResponseWire>,
}
#[cfg(test)]
-243
View File
@@ -1,243 +0,0 @@
//! EntanglementConfig — per-seed NPC population entanglement ratios (D-029, #175, #178).
//!
//! Per D-029: NPC population split is ~30% flat / ~50% mundane / ~20% intrigue.
//! The entanglement rate varies per world seed to prevent player metagaming calibration
//! across playthroughs. Two runs with the same seed must produce identical ratios;
//! two runs with different seeds must (in ≥90% of cases) produce different ratios.
//!
//! ## Acceptance criteria (#175 / #178)
//!
//! 1. `EntanglementConfig::from_seed(seed_a) == EntanglementConfig::from_seed(seed_a)` (deterministic)
//! 2. `EntanglementConfig::from_seed(seed_a) != EntanglementConfig::from_seed(seed_b)` for ≥90% of random pairs
//! 3. `flat_ratio + mundane_ratio + intrigue_ratio == 100`
//! 4. Ratios stay within bounds: flat ∈ [25,35], mundane ∈ [45,55], intrigue ∈ [15,25]
//!
//! ## Wire format (#175)
//!
//! The world seed flows: client new_game() → world_seed field in session startup IPC →
//! server reads seed → SimRng::from_seed(seed) → EntanglementConfig::from_rng(&mut rng).
//! This means two clients using the same seed produce identical NPC populations.
use crate::simulation::rng::SimRng;
use rand::Rng;
/// NPC population entanglement ratios for one world seed.
///
/// All ratios are percentages (integer, sum to 100).
/// Ranges per D-029: flat 25-35%, mundane 45-55%, intrigue 15-25%.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EntanglementConfig {
/// % of NPCs with purely flat routines — social wallpaper, no triangle involvement
pub flat_ratio: u8,
/// % of NPCs in mundane triangles — neighbor disputes, workplace rivalries, no conspiracy
pub mundane_ratio: u8,
/// % of NPCs entangled with intrigue content — connected to conspiracy modules
pub intrigue_ratio: u8,
}
impl EntanglementConfig {
/// Sample entanglement ratios from the given RNG.
///
/// Must be called exactly once at session start after `SimRng::new(world_seed)`.
/// Subsequent calls to the same seeded RNG will produce different values
/// (the RNG state advances), so `from_seed()` is the canonical API for tests.
pub fn from_rng(rng: &mut SimRng) -> Self {
// Sample flat_ratio ∈ [25, 35] — step of 1%
let flat: u8 = rng.rng.random_range(25u8..=35u8);
// Constrain intrigue range so mundane = 100 - flat - intrigue stays in [45, 55].
// mundane ≥ 45 → intrigue ≤ 55 - flat; mundane ≤ 55 → intrigue ≥ 45 - flat.
// Intersect with D-029 base range [15, 25].
let intrigue_min: u8 = (45u8.saturating_sub(flat)).max(15);
let intrigue_max: u8 = (55u8.saturating_sub(flat)).min(25);
let intrigue: u8 = rng.rng.random_range(intrigue_min..=intrigue_max);
// Mundane fills the remainder (ensures sum = 100, stays in [45, 55])
let mundane: u8 = 100 - flat - intrigue;
Self {
flat_ratio: flat,
mundane_ratio: mundane,
intrigue_ratio: intrigue,
}
}
/// Convenience: create EntanglementConfig from a raw seed value.
///
/// Equivalent to `EntanglementConfig::from_rng(&mut SimRng::new(seed))`.
/// Use in tests for determinism assertions.
pub fn from_seed(seed: u64) -> Self {
let mut rng = SimRng::new(seed);
Self::from_rng(&mut rng)
}
/// Verify internal consistency: ratios must sum to 100.
pub fn is_valid(&self) -> bool {
self.flat_ratio as u16 + self.mundane_ratio as u16 + self.intrigue_ratio as u16 == 100
}
}
#[cfg(test)]
mod tests {
use super::*;
// -------------------------------------------------------------------------
// Acceptance criterion 1: Determinism (#178)
// EntanglementConfig::from_seed(seed_A) == EntanglementConfig::from_seed(seed_A)
// -------------------------------------------------------------------------
#[test]
fn same_seed_produces_same_config() {
// D-010 / D-029: deterministic simulation must produce identical NPC populations
// for the same world seed across all playthroughs.
let config_a = EntanglementConfig::from_seed(42);
let config_b = EntanglementConfig::from_seed(42);
assert_eq!(
config_a, config_b,
"Same world seed must produce identical EntanglementConfig (D-010 determinism)"
);
}
#[test]
fn determinism_holds_for_multiple_seeds() {
// Spot-check several seeds to ensure the determinism invariant holds broadly.
for seed in [0u64, 1, 100, 9999, u64::MAX / 2, u64::MAX] {
let c1 = EntanglementConfig::from_seed(seed);
let c2 = EntanglementConfig::from_seed(seed);
assert_eq!(
c1, c2,
"Seed {seed}: EntanglementConfig must be deterministic"
);
}
}
// -------------------------------------------------------------------------
// Acceptance criterion 2: Variation (#178)
// from_seed(A) != from_seed(B) for ≥90% of random seed pairs
// -------------------------------------------------------------------------
#[test]
fn different_seeds_produce_different_configs_at_least_90_percent() {
// D-029: entanglement rate varies per seed to prevent metagaming calibration.
// ≥90% of random seed pairs must produce distinct EntanglementConfig values.
let test_seeds: Vec<u64> = (0u64..100).collect();
let configs: Vec<EntanglementConfig> =
test_seeds.iter().map(|&s| EntanglementConfig::from_seed(s)).collect();
let mut distinct_pairs: usize = 0;
let mut total_pairs: usize = 0;
for i in 0..configs.len() {
for j in (i + 1)..configs.len() {
total_pairs += 1;
if configs[i] != configs[j] {
distinct_pairs += 1;
}
}
}
let ratio = distinct_pairs as f64 / total_pairs as f64;
assert!(
ratio >= 0.90,
"Only {}/{} ({:.1}%) seed pairs produced distinct EntanglementConfig — need ≥90% (D-029)",
distinct_pairs,
total_pairs,
ratio * 100.0
);
}
// -------------------------------------------------------------------------
// Acceptance criterion 3: Ratios sum to 100
// -------------------------------------------------------------------------
#[test]
fn ratios_sum_to_100() {
// Invariant: flat + mundane + intrigue == 100 for any seed.
for seed in [0u64, 1, 42, 12345, u64::MAX] {
let c = EntanglementConfig::from_seed(seed);
assert!(
c.is_valid(),
"Seed {seed}: ratios must sum to 100, got {}+{}+{}={}",
c.flat_ratio,
c.mundane_ratio,
c.intrigue_ratio,
c.flat_ratio as u16 + c.mundane_ratio as u16 + c.intrigue_ratio as u16
);
}
}
// -------------------------------------------------------------------------
// Acceptance criterion 4: Ratios within D-029 bounds
// -------------------------------------------------------------------------
#[test]
fn flat_ratio_within_bounds() {
// D-029: flat ∈ [25, 35]%
for seed in 0u64..200 {
let c = EntanglementConfig::from_seed(seed);
assert!(
c.flat_ratio >= 25 && c.flat_ratio <= 35,
"Seed {seed}: flat_ratio {} out of [25, 35] bounds",
c.flat_ratio
);
}
}
#[test]
fn mundane_ratio_within_bounds() {
// D-029: mundane ∈ [45, 55]%
// Achieved by constraining intrigue range based on flat value so that
// mundane = 100 - flat - intrigue always stays within spec bounds.
for seed in 0u64..200 {
let c = EntanglementConfig::from_seed(seed);
assert!(
c.is_valid(),
"Seed {seed}: ratios must sum to 100"
);
assert!(
c.mundane_ratio >= 45 && c.mundane_ratio <= 55,
"Seed {seed}: mundane_ratio {} out of D-029 [45, 55] bounds",
c.mundane_ratio
);
}
}
#[test]
fn intrigue_ratio_within_bounds() {
// D-029: intrigue ∈ [15, 25]%
for seed in 0u64..200 {
let c = EntanglementConfig::from_seed(seed);
assert!(
c.intrigue_ratio >= 15 && c.intrigue_ratio <= 25,
"Seed {seed}: intrigue_ratio {} out of [15, 25] bounds",
c.intrigue_ratio
);
}
}
// -------------------------------------------------------------------------
// Edge cases
// -------------------------------------------------------------------------
#[test]
fn seed_zero_produces_valid_config() {
let c = EntanglementConfig::from_seed(0);
assert!(c.is_valid(), "Seed 0 must produce valid config");
}
#[test]
fn seed_max_produces_valid_config() {
let c = EntanglementConfig::from_seed(u64::MAX);
assert!(c.is_valid(), "Seed u64::MAX must produce valid config");
}
#[test]
fn from_rng_and_from_seed_are_consistent() {
// from_seed() is the canonical API; from_rng() is the runtime API.
// When given a freshly-seeded SimRng, from_rng() must match from_seed().
let seed = 999u64;
let via_seed = EntanglementConfig::from_seed(seed);
let mut rng = SimRng::new(seed);
let via_rng = EntanglementConfig::from_rng(&mut rng);
assert_eq!(
via_seed, via_rng,
"from_seed() and from_rng(SimRng::new(seed)) must produce identical results"
);
}
}
-264
View File
@@ -1,264 +0,0 @@
//! Content hot-reload via timestamp polling (dev-only).
//!
//! Periodically checks content YAML files for modifications and triggers
//! a full reload when changes are detected. Designed for the authoring
//! workflow — not enabled in production builds.
//!
//! Check interval: every 20 ticks (~2s at 10 tps per D-031).
//! Failures are non-critical: previous content is preserved on reload error.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use bevy_ecs::prelude::*;
use crate::content::line_pool::LinePoolIndex;
use crate::content::loader;
use crate::content::{ContentConfig, ContentStoreResource, LinePoolIndexResource};
/// How often to check for content changes (in system ticks).
/// At 10 tps (D-031), 20 ticks = 2 seconds.
const CHECK_INTERVAL_TICKS: u64 = 20;
/// Consecutive reload failures before escalating to a warning.
const FAILURE_WARN_THRESHOLD: u32 = 5;
/// Resource tracking content file timestamps for change detection.
#[derive(Resource, Debug)]
pub struct ContentWatcher {
file_timestamps: BTreeMap<PathBuf, SystemTime>,
ticks_since_check: u64,
/// Consecutive reload failures. Resets on success.
consecutive_failures: u32,
}
impl ContentWatcher {
/// Create a new watcher and perform initial timestamp scan.
/// Returns a watcher with no tracked files if content_root is invalid.
pub fn new(content_root: &Path) -> Self {
let mut watcher = Self {
file_timestamps: BTreeMap::new(),
ticks_since_check: 0,
consecutive_failures: 0,
};
if content_root.as_os_str().is_empty() || !content_root.is_dir() {
tracing::warn!(
"ContentWatcher: invalid content root {:?}, hot-reload disabled",
content_root,
);
return watcher;
}
watcher.scan(content_root);
watcher
}
/// Scan content directory tree and record all YAML file timestamps.
fn scan(&mut self, content_root: &Path) {
self.file_timestamps.clear();
walk_yaml(content_root, &mut self.file_timestamps, 0);
tracing::debug!(
"ContentWatcher: tracking {} content files",
self.file_timestamps.len()
);
}
/// Check for changes and rescan. Returns true if any files changed.
fn check_and_rescan(&mut self, content_root: &Path) -> bool {
let mut new_timestamps = BTreeMap::new();
walk_yaml(content_root, &mut new_timestamps, 0);
let changed = new_timestamps != self.file_timestamps;
if changed {
self.file_timestamps = new_timestamps;
}
changed
}
/// Number of tracked files (for diagnostics).
pub fn tracked_file_count(&self) -> usize {
self.file_timestamps.len()
}
}
/// Maximum recursion depth for directory walking (guards against symlink loops).
const MAX_WALK_DEPTH: usize = 100;
/// Recursively walk a directory, recording .yaml file modification timestamps.
/// Stops recursing at MAX_WALK_DEPTH to guard against symlink loops.
fn walk_yaml(dir: &Path, timestamps: &mut BTreeMap<PathBuf, SystemTime>, depth: usize) {
if depth >= MAX_WALK_DEPTH {
tracing::warn!(
"walk_yaml: max depth {} reached at {:?}, stopping",
MAX_WALK_DEPTH,
dir
);
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
walk_yaml(&path, timestamps, depth + 1);
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
if let Ok(meta) = std::fs::metadata(&path) {
if let Ok(modified) = meta.modified() {
timestamps.insert(path, modified);
}
}
}
}
}
/// System: periodically check for content file changes and reload.
///
/// Only runs when a ContentWatcher resource exists (hot-reload enabled).
/// Runs in PostUpdate to avoid interfering with the current tick.
pub fn hot_reload_content(
config: Res<ContentConfig>,
watcher: Option<ResMut<ContentWatcher>>,
store_res: Option<ResMut<ContentStoreResource>>,
index_res: Option<ResMut<LinePoolIndexResource>>,
) {
let Some(mut watcher) = watcher else {
return;
};
let Some(mut store_res) = store_res else {
return;
};
let Some(mut index_res) = index_res else {
return;
};
watcher.ticks_since_check += 1;
if watcher.ticks_since_check < CHECK_INTERVAL_TICKS {
return;
}
watcher.ticks_since_check = 0;
if !watcher.check_and_rescan(&config.content_root) {
return;
}
tracing::info!("Content files changed, reloading...");
match loader::load_content(&config.content_root) {
Ok(store) => {
let index = LinePoolIndex::build(&store);
let d_count = index.dialogue_line_count();
let m_count = index.monologue_line_count();
store_res.0 = store;
index_res.0 = index;
watcher.consecutive_failures = 0;
tracing::info!(
"Content hot-reloaded: {} dialogue lines, {} monologue lines",
d_count,
m_count
);
}
Err(e) => {
watcher.consecutive_failures += 1;
if watcher.consecutive_failures >= FAILURE_WARN_THRESHOLD {
tracing::warn!(
"Content hot-reload failed {} consecutive times (keeping previous): {}",
watcher.consecutive_failures,
e,
);
} else {
tracing::warn!("Content hot-reload failed (keeping previous): {}", e);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn watcher_tracks_yaml_files() {
let dir = std::env::temp_dir().join("sr_hotreload_test_track");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("test.yaml"), "key: value\n").unwrap();
fs::write(dir.join("other.txt"), "ignored\n").unwrap();
let watcher = ContentWatcher::new(&dir);
assert_eq!(watcher.tracked_file_count(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn watcher_detects_new_file() {
let dir = std::env::temp_dir().join("sr_hotreload_test_new");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.yaml"), "key: a\n").unwrap();
let mut watcher = ContentWatcher::new(&dir);
assert!(!watcher.check_and_rescan(&dir)); // no change yet
fs::write(dir.join("b.yaml"), "key: b\n").unwrap();
assert!(watcher.check_and_rescan(&dir)); // new file detected
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn watcher_detects_deleted_file() {
let dir = std::env::temp_dir().join("sr_hotreload_test_del");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.yaml"), "key: a\n").unwrap();
fs::write(dir.join("b.yaml"), "key: b\n").unwrap();
let mut watcher = ContentWatcher::new(&dir);
assert_eq!(watcher.tracked_file_count(), 2);
fs::remove_file(dir.join("b.yaml")).unwrap();
assert!(watcher.check_and_rescan(&dir));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn watcher_detects_modification() {
let dir = std::env::temp_dir().join("sr_hotreload_test_mod");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.yaml"), "key: a\n").unwrap();
let mut watcher = ContentWatcher::new(&dir);
// Sleep briefly to ensure modification time differs
std::thread::sleep(std::time::Duration::from_millis(50));
fs::write(dir.join("a.yaml"), "key: modified\n").unwrap();
assert!(watcher.check_and_rescan(&dir));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn watcher_recurses_subdirectories() {
let dir = std::env::temp_dir().join("sr_hotreload_test_recurse");
let _ = fs::remove_dir_all(&dir);
let sub = dir.join("sub/deep");
fs::create_dir_all(&sub).unwrap();
fs::write(dir.join("root.yaml"), "key: root\n").unwrap();
fs::write(sub.join("deep.yaml"), "key: deep\n").unwrap();
let watcher = ContentWatcher::new(&dir);
assert_eq!(watcher.tracked_file_count(), 2);
let _ = fs::remove_dir_all(&dir);
}
}
-214
View File
@@ -1,214 +0,0 @@
//! Template instantiation engine (#161).
//!
//! Wires the full pipeline: `FullTemplateDef` → NPC spawn (via spawn.rs) →
//! triangle generation (via template.rs) → instance tracking.
//!
//! **Pipeline:**
//! 1. Validate the `FullTemplateDef` (schema-level checks).
//! 2. Call `spawn_template_npcs` to create NPC entities and wire relationships.
//! 3. Call `generate_intra_template_triangles` to generate `TriangleState` values.
//! 4. Spawn each `TriangleState` as an ECS entity with the `ActiveSim` marker.
//! 5. Register the live instance in `ActiveTemplateInstances`.
//!
//! **Instance lifecycle:**
//! Instances are tracked by `TemplateId` in `ActiveTemplateInstances`.
//! `unload_template` despawns all NPC and triangle entities and removes the
//! entry from `ActiveTemplateInstances`.
//!
//! **Determinism (D-010):** given the same `FullTemplateDef`, `TemplateId`,
//! `world_seed`, and `SimRng` state, the spawned NPC and triangle layout is
//! identical.
use std::collections::BTreeMap;
use bevy_ecs::prelude::*;
use crate::content::spawn::spawn_template_npcs;
use crate::content::template::{
generate_intra_template_triangles, FullTemplateDef, TemplateId,
};
use crate::simulation::rng::SimRng;
use crate::simulation::tier::ActiveSim;
// ===========================================================================
// Public types
// ===========================================================================
/// A live template instance — the result of `instantiate_template`.
///
/// Holds entity handles for all NPCs and triangle entities spawned from a
/// single `FullTemplateDef`. Required by `unload_template` to despawn them.
#[derive(Debug, Clone)]
pub struct TemplateInstance {
/// Template this instance was created from.
pub template_id: TemplateId,
/// ECS entities for the NPC role slots (one per `RoleSchema`).
pub npc_entities: Vec<Entity>,
/// ECS entities for the generated `TriangleState` components.
pub triangle_entities: Vec<Entity>,
/// Non-fatal warnings from triangle generation (e.g., fallback assignments).
pub warnings: Vec<String>,
}
/// Resource tracking all currently active template instances.
///
/// Key = `TemplateId.0` (deterministic u64). Initialized on demand by
/// `instantiate_template`; may also be initialized explicitly with
/// `world.init_resource::<ActiveTemplateInstances>()`.
///
/// **Determinism (D-010):** `BTreeMap` for consistent iteration order.
#[derive(Resource, Default, Debug)]
pub struct ActiveTemplateInstances {
instances: BTreeMap<u64, TemplateInstance>,
}
impl ActiveTemplateInstances {
/// Register a new instance. Overwrites any existing entry for the same ID.
pub fn insert(&mut self, instance: TemplateInstance) {
self.instances.insert(instance.template_id.0, instance);
}
/// Look up a live instance by template ID.
pub fn get(&self, template_id: TemplateId) -> Option<&TemplateInstance> {
self.instances.get(&template_id.0)
}
/// Remove and return an instance (used by `unload_template`).
pub fn remove(&mut self, template_id: TemplateId) -> Option<TemplateInstance> {
self.instances.remove(&template_id.0)
}
/// Number of active instances.
pub fn len(&self) -> usize {
self.instances.len()
}
/// `true` if no instances are active.
pub fn is_empty(&self) -> bool {
self.instances.is_empty()
}
}
// ===========================================================================
// Instantiation
// ===========================================================================
/// Instantiate a template: validate, spawn NPCs, generate triangles, register.
///
/// **Preconditions:**
/// - `EntityRegistry` must be initialized as a world resource (done by
/// `SimulationPlugin` at startup).
/// - `ActiveTemplateInstances` is initialized on demand inside this function.
///
/// **Returns** the created `TemplateInstance` (also stored in
/// `ActiveTemplateInstances`).
///
/// **Errors:** returns `Err(String)` if `template_def.validate()` fails.
pub fn instantiate_template(
world: &mut World,
template_def: &FullTemplateDef,
template_id: TemplateId,
world_seed: u64,
rng: &mut SimRng,
) -> Result<TemplateInstance, String> {
// Schema validation before any ECS mutations.
template_def.validate()?;
// Phases 13: NPC spawn + relationship wiring + cross-template ref map.
let spawn_result = spawn_template_npcs(world, template_def, template_id, world_seed, rng);
// Phase 4: Generate intra-template triangle state values.
let tri_result =
generate_intra_template_triangles(world, template_id, &template_def.triangles, rng);
let warnings = tri_result.warnings;
// Spawn each TriangleState as a dedicated ECS entity with ActiveSim so
// the escalation system can pick it up (D-087).
let triangle_entities: Vec<Entity> = tri_result
.triangles
.into_iter()
.map(|state| world.spawn((ActiveSim, state)).id())
.collect();
let instance = TemplateInstance {
template_id,
npc_entities: spawn_result.entities,
triangle_entities,
warnings,
};
// Register in ActiveTemplateInstances (init if absent).
// If a previous instance with the same ID exists, unload it first to
// prevent orphaned ECS entities (Hoshe review #2).
world.init_resource::<ActiveTemplateInstances>();
let previous = world
.resource_mut::<ActiveTemplateInstances>()
.remove(template_id);
if let Some(prev) = previous {
tracing::warn!(
"instantiate_template: overwriting live TemplateId({}) — despawning {} entities",
template_id.0,
prev.npc_entities.len() + prev.triangle_entities.len(),
);
for entity in prev.npc_entities.iter().chain(prev.triangle_entities.iter()) {
if world.get_entity(*entity).is_ok() {
world.despawn(*entity);
}
}
}
world
.resource_mut::<ActiveTemplateInstances>()
.insert(instance.clone());
Ok(instance)
}
// ===========================================================================
// Lifecycle: unload
// ===========================================================================
/// Unload a template instance: despawn all entities and remove from tracking.
///
/// No-op (with a warning log) if the given `template_id` is not active.
pub fn unload_template(world: &mut World, template_id: TemplateId) {
let instance = world
.resource_mut::<ActiveTemplateInstances>()
.remove(template_id);
let Some(instance) = instance else {
tracing::warn!(
"unload_template: TemplateId({}) not active — no-op",
template_id.0
);
return;
};
let mut despawned = 0usize;
for entity in instance.npc_entities.iter().chain(instance.triangle_entities.iter()) {
if world.get_entity(*entity).is_ok() {
world.despawn(*entity);
despawned += 1;
}
}
tracing::info!(
"unload_template: TemplateId({}) unloaded — {} entities despawned",
template_id.0,
despawned,
);
}
// ===========================================================================
// YAML loader
// ===========================================================================
/// Load a `FullTemplateDef` from a YAML file on disk.
///
/// Returns `Err(String)` if the file cannot be read or fails YAML parsing.
pub fn load_template_from_file(path: &std::path::Path) -> Result<FullTemplateDef, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {:?}: {}", path, e))?;
serde_yaml::from_str::<FullTemplateDef>(&content)
.map_err(|e| format!("failed to parse {:?}: {}", path, e))
}
File diff suppressed because it is too large Load Diff
-952
View File
@@ -1,952 +0,0 @@
//! Content discovery and deserialization.
//!
//! Reads content.yaml, discovers campaigns and districts via directory
//! structure, deserializes YAML files into intermediate content types.
//! Comment-only YAML files (stubs) are skipped gracefully.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::content::types::*;
/// All content loaded from disk, organized by district.
/// Inserted as a bevy Resource after loading completes.
#[derive(Debug, Default)]
pub struct ContentStore {
pub manifest: Option<ContentManifest>,
pub districts: BTreeMap<String, DistrictContent>,
}
/// Content for a single district.
#[derive(Debug, Default)]
pub struct DistrictContent {
pub meta: Option<DistrictMeta>,
pub district_path: PathBuf,
pub pools: Vec<Pool>,
pub templates: Vec<Template>,
pub triangles: Vec<Triangle>,
pub npc_profiles: Vec<NpcProfile>,
pub locations: Vec<Location>,
pub routines: Option<RoutineFile>,
pub dialogue_pools: Vec<DialoguePool>,
pub monologue_pools: Vec<MonologuePool>,
/// Ticker headlines loaded from `ticker/*.yaml` files (#591).
pub ticker_headlines: Vec<crate::content::types::TickerHeadline>,
}
/// Errors that can occur during content loading.
#[derive(Debug, thiserror::Error)]
pub enum ContentError {
#[error("IO error: {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("YAML parse error: {path}: {source}")]
Yaml {
path: PathBuf,
source: serde_yaml::Error,
},
#[error("Content manifest not found at {0}")]
ManifestNotFound(PathBuf),
}
/// Load all content from the given root directory.
///
/// The root should contain `content.yaml` and the campaign directories.
/// Comment-only YAML stubs are skipped (logged at debug level).
pub fn load_content(content_root: &Path) -> Result<ContentStore, ContentError> {
let mut store = ContentStore::default();
// 1. Load content manifest
let manifest_path = content_root.join("content.yaml");
if !manifest_path.exists() {
return Err(ContentError::ManifestNotFound(manifest_path));
}
let manifest: ContentManifest = load_yaml(&manifest_path)?;
// 2. Discover districts for each enabled campaign
for campaign in &manifest.campaigns {
if !campaign.enabled {
tracing::debug!("Skipping disabled campaign: {}", campaign.id);
continue;
}
let campaign_path = content_root.join(&campaign.path);
let district_dirs = discover_districts(&campaign_path);
for district_dir in district_dirs {
let district_id = derive_district_id(content_root, &district_dir);
tracing::info!("Loading district: {} from {:?}", district_id, district_dir);
let content = load_district(&district_dir)?;
store.districts.insert(district_id, content);
}
}
store.manifest = Some(manifest);
Ok(store)
}
/// Discover district directories by recursively searching for district.yaml.
fn discover_districts(campaign_path: &Path) -> Vec<PathBuf> {
let mut districts = Vec::new();
let systems_path = campaign_path.join("systems");
if systems_path.is_dir() {
walk_for_districts(&systems_path, &mut districts);
}
// Discovery order from fs::read_dir is platform-dependent. Sort the Vec
// here so districts load in a deterministic order regardless of OS.
// ContentStore.districts uses BTreeMap for deterministic *iteration* later,
// but sorted discovery ensures deterministic *load* order (and thus
// deterministic ID derivation and log output).
districts.sort();
districts
}
/// Recursively walk directories looking for district.yaml files.
fn walk_for_districts(dir: &Path, results: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
// Collect and sort entries for deterministic traversal order
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
sorted_entries.sort_by_key(|e| e.file_name());
for entry in sorted_entries {
let path = entry.path();
if path.is_dir() {
let district_yaml = path.join("district.yaml");
if district_yaml.exists() {
results.push(path);
} else {
walk_for_districts(&path, results);
}
}
}
}
/// Derive a district ID from its filesystem path.
/// e.g. campaigns/main/systems/krenn/stations/sova/districts/transit → krenn.sova.transit
fn derive_district_id(content_root: &Path, district_dir: &Path) -> String {
let rel = district_dir
.strip_prefix(content_root)
.unwrap_or(district_dir);
let components: Vec<&str> = rel
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
// Extract meaningful path segments: system, station, district name
// Path pattern: campaigns/{id}/systems/{system}/stations/{station}/districts/{district}
let mut parts = Vec::new();
let mut iter = components.iter().peekable();
while let Some(&segment) = iter.next() {
match segment {
"systems" | "stations" | "districts" => {
if let Some(&&name) = iter.peek() {
parts.push(name.to_string());
iter.next();
}
}
_ => {}
}
}
if parts.is_empty() {
// Fallback: use the directory name
district_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
} else {
parts.join(".")
}
}
/// Load all content for a single district directory.
fn load_district(district_dir: &Path) -> Result<DistrictContent, ContentError> {
let mut content = DistrictContent {
district_path: district_dir.to_path_buf(),
..Default::default()
};
// District metadata
let meta_path = district_dir.join("district.yaml");
if meta_path.exists() {
match load_yaml::<DistrictMeta>(&meta_path) {
Ok(meta) => content.meta = Some(meta),
Err(e) => tracing::warn!("Failed to parse district metadata: {}", e),
}
}
// Pools
let pools_path = district_dir.join("pools.yaml");
if pools_path.exists() {
match load_yaml::<PoolFile>(&pools_path) {
Ok(pool_file) => content.pools = pool_file.pools,
Err(e) => tracing::warn!("Failed to parse pools: {}", e),
}
}
// Templates
let templates_dir = district_dir.join("templates");
if templates_dir.is_dir() {
content.templates = load_yaml_dir::<Template>(&templates_dir);
}
// Triangles
let triangles_dir = district_dir.join("triangles");
if triangles_dir.is_dir() {
content.triangles = load_yaml_dir::<Triangle>(&triangles_dir);
}
// NPC profiles
let npcs_dir = district_dir.join("npcs");
if npcs_dir.is_dir() {
content.npc_profiles = load_yaml_dir::<NpcProfile>(&npcs_dir);
}
// Locations
let locations_dir = district_dir.join("locations");
if locations_dir.is_dir() {
content.locations = load_yaml_dir::<Location>(&locations_dir);
}
// Routines
let routines_path = district_dir.join("routines").join("schedules.yaml");
if routines_path.exists() {
match load_yaml::<RoutineFile>(&routines_path) {
Ok(routines) => content.routines = Some(routines),
Err(e) => tracing::debug!("Skipping routines (stub or invalid): {}", e),
}
}
// Dialogue pools
let dialogue_dir = district_dir.join("dialogue");
if dialogue_dir.is_dir() {
content.dialogue_pools = load_yaml_recursive::<DialoguePool>(&dialogue_dir);
}
// Monologue pools
let monologue_dir = district_dir.join("monologue");
if monologue_dir.is_dir() {
content.monologue_pools = load_yaml_recursive::<MonologuePool>(&monologue_dir);
}
// Ticker headlines (#591): scan ticker/*.yaml in this district
let ticker_dir = district_dir.join("ticker");
if ticker_dir.is_dir() {
let ticker_files = load_yaml_dir::<crate::content::types::TickerFile>(&ticker_dir);
for tf in ticker_files {
tracing::info!(
"Ticker loaded: {} headlines from '{}' (feed: {})",
tf.headlines.len(),
tf.location,
tf.feed,
);
content.ticker_headlines.extend(tf.headlines);
}
}
let npc_count = content.npc_profiles.len();
let triangle_count = content.triangles.len();
let template_count = content.templates.len();
let pool_count = content.pools.len();
let dialogue_count = content.dialogue_pools.len();
let monologue_count = content.monologue_pools.len();
let ticker_count = content.ticker_headlines.len();
tracing::info!(
"District loaded: {} NPCs, {} triangles, {} templates, {} pools, {} dialogue pools, {} monologue pools, {} ticker headlines",
npc_count, triangle_count, template_count, pool_count, dialogue_count, monologue_count, ticker_count
);
Ok(content)
}
/// Load and parse a single YAML file.
fn load_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, ContentError> {
let text = std::fs::read_to_string(path).map_err(|e| ContentError::Io {
path: path.to_path_buf(),
source: e,
})?;
serde_yaml::from_str(&text).map_err(|e| ContentError::Yaml {
path: path.to_path_buf(),
source: e,
})
}
/// Load all YAML files in a directory (non-recursive), skipping stubs.
fn load_yaml_dir<T: serde::de::DeserializeOwned>(dir: &Path) -> Vec<T> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
sorted_entries.sort_by_key(|e| e.file_name());
let mut results = Vec::new();
for entry in sorted_entries {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
continue;
}
match load_yaml::<T>(&path) {
Ok(item) => results.push(item),
Err(e) => {
// Check if this is a comment-only stub
if is_comment_only_file(&path) {
tracing::debug!("Skipping stub file: {:?}", path);
} else {
tracing::warn!("Failed to parse {:?}: {}", path, e);
}
}
}
}
results
}
/// Load all YAML files recursively under a directory, skipping stubs.
fn load_yaml_recursive<T: serde::de::DeserializeOwned>(dir: &Path) -> Vec<T> {
let mut results = Vec::new();
walk_yaml_files(dir, &mut |path| match load_yaml::<T>(path) {
Ok(item) => results.push(item),
Err(e) => {
if is_comment_only_file(path) {
tracing::debug!("Skipping stub: {:?}", path);
} else {
tracing::warn!("Failed to parse {:?}: {}", path, e);
}
}
});
results
}
/// Walk a directory recursively, calling the callback for each .yaml file.
fn walk_yaml_files(dir: &Path, callback: &mut impl FnMut(&Path)) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
sorted_entries.sort_by_key(|e| e.file_name());
for entry in sorted_entries {
let path = entry.path();
if path.is_dir() {
walk_yaml_files(&path, callback);
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
callback(&path);
}
}
}
// ---------------------------------------------------------------------------
// Tile loading (#577)
// ---------------------------------------------------------------------------
use crate::simulation::movement::{TileKind, TilePosition, WalkabilityMap};
/// Parse a tile character into a TileKind.
/// Returns `None` for unrecognized characters.
fn parse_tile_char(ch: char) -> Option<TileKind> {
match ch {
'F' => Some(TileKind::Floor),
'W' => Some(TileKind::Wall),
'V' => Some(TileKind::Void),
'R' => Some(TileKind::Restricted),
_ => None,
}
}
/// Load tile data from all locations in a ContentStore into a WalkabilityMap.
///
/// For each location that has both `tile_bounds` and `tiles`, parses the tile
/// rows and calls `set_walkable` + `set_tile_kind` on the WalkabilityMap.
///
/// Logs warnings for:
/// - Row count mismatch vs tile_bounds height
/// - Column count mismatch vs tile_bounds width
/// - Unrecognized tile characters
///
/// Returns the number of locations that had tile data applied.
pub fn load_location_tiles(store: &ContentStore, walkability: &mut WalkabilityMap) -> u32 {
let mut locations_loaded = 0u32;
for (_district_id, district) in &store.districts {
for location in &district.locations {
if apply_location_tiles(location, walkability) {
locations_loaded += 1;
}
}
}
locations_loaded
}
/// Apply tile data from a single Location to the WalkabilityMap.
/// Returns true if tiles were applied, false if skipped.
fn apply_location_tiles(location: &Location, walkability: &mut WalkabilityMap) -> bool {
let (Some(bounds), Some(tiles)) = (&location.tile_bounds, &location.tiles) else {
return false;
};
// Guard: inverted bounds cause (y_max - y_min) to be negative, which wraps to
// ~18 quintillion when cast to usize, silently writing tiles at garbage positions.
if bounds.x_min > bounds.x_max || bounds.y_min > bounds.y_max {
tracing::error!(
"Location '{}': inverted tile_bounds (x: {}..={}, y: {}..={}), skipping",
location.canonical_id,
bounds.x_min, bounds.x_max,
bounds.y_min, bounds.y_max,
);
return false;
}
let expected_height = (bounds.y_max - bounds.y_min + 1) as usize;
let expected_width = (bounds.x_max - bounds.x_min + 1) as usize;
if tiles.len() != expected_height {
tracing::error!(
"Location '{}': tile row count {} != expected height {} (from tile_bounds) — skipping to prevent walkability holes",
location.canonical_id,
tiles.len(),
expected_height,
);
return false;
}
for (row_idx, row) in tiles.iter().enumerate() {
let y = bounds.y_min + row_idx as i32;
if row.len() != expected_width {
tracing::error!(
"Location '{}' row {}: length {} != expected width {} — skipping row to prevent walkability holes",
location.canonical_id,
row_idx,
row.len(),
expected_width,
);
continue;
}
for (col_idx, ch) in row.chars().enumerate() {
let x = bounds.x_min + col_idx as i32;
let pos = TilePosition::new(x, y, bounds.z);
match parse_tile_char(ch) {
Some(kind) => {
let walkable = matches!(kind, TileKind::Floor);
walkability.set_walkable(&pos, walkable);
walkability.set_tile_kind(&pos, kind);
}
None => {
tracing::warn!(
"Location '{}' row {} col {}: unrecognized tile char '{}'",
location.canonical_id,
row_idx,
col_idx,
ch,
);
}
}
}
}
tracing::info!(
"Loaded tiles for location '{}': {}x{} at ({},{}) z={}",
location.canonical_id,
expected_width,
expected_height,
bounds.x_min,
bounds.y_min,
bounds.z,
);
true
}
fn is_comment_only_file(path: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(path) else {
return false;
};
text.lines()
.all(|line| line.trim().is_empty() || line.trim().starts_with('#'))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn create_temp_content(dir: &Path) {
// Create content.yaml
fs::write(
dir.join("content.yaml"),
r#"version: "0.1.0"
campaigns:
- id: test
path: campaigns/test
enabled: true
discovery:
districts: "systems/**/districts/*/district.yaml"
"#,
)
.unwrap();
// Create district directory structure
let district_dir = dir.join("campaigns/test/systems/alpha/stations/beta/districts/gamma");
fs::create_dir_all(&district_dir).unwrap();
// district.yaml
fs::write(
district_dir.join("district.yaml"),
r#"display_name: "Test District"
description: "A test district"
locations: ["loc-a"]
npc_count: 2
"#,
)
.unwrap();
// pools.yaml
fs::write(
district_dir.join("pools.yaml"),
r#"pools:
- pool_id: "test:pool_a"
category: npc_role
candidates:
- npc_id: "npc:alice"
weight: 1
"#,
)
.unwrap();
// triangles/
let tri_dir = district_dir.join("triangles");
fs::create_dir_all(&tri_dir).unwrap();
fs::write(
tri_dir.join("test-triangle.yaml"),
r#"canonical_id: test-triangle
display_name: "Test Triangle"
members:
- npc: "npc:alice"
role: "role-a"
- npc: "npc:bob"
role: "role-b"
- npc: "npc:carol"
role: "role-c"
forks: []
resolution_states: []
"#,
)
.unwrap();
// templates/
let tpl_dir = district_dir.join("templates");
fs::create_dir_all(&tpl_dir).unwrap();
fs::write(
tpl_dir.join("test-site.yaml"),
r#"template_id: test-site
display_name: "Test Site"
location: loc-a
role_slots:
- role: worker
display_name: "Worker"
count: 1
required: true
"#,
)
.unwrap();
// npcs/ — one stub, one real
let npc_dir = district_dir.join("npcs");
fs::create_dir_all(&npc_dir).unwrap();
fs::write(
npc_dir.join("alice.yaml"),
r#"canonical_id: alice
display_name: "Alice"
tier: 1
pattern: "FRIEND"
motivation: "HANDLER"
"#,
)
.unwrap();
fs::write(
npc_dir.join("bob.yaml"),
"# NPC Profile: bob\n# canonical_id: test.bob\n",
)
.unwrap();
}
#[test]
fn load_content_discovers_district() {
let dir = std::env::temp_dir().join("sr_content_test_discover");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
create_temp_content(&dir);
let store = load_content(&dir).unwrap();
assert!(store.manifest.is_some());
assert_eq!(store.districts.len(), 1);
let (id, content) = store.districts.iter().next().unwrap();
assert_eq!(id, "alpha.beta.gamma");
assert!(content.meta.is_some());
assert_eq!(content.meta.as_ref().unwrap().display_name, "Test District");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_content_parses_pools() {
let dir = std::env::temp_dir().join("sr_content_test_pools");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
create_temp_content(&dir);
let store = load_content(&dir).unwrap();
let content = store.districts.values().next().unwrap();
assert_eq!(content.pools.len(), 1);
assert_eq!(content.pools[0].pool_id, "test:pool_a");
assert_eq!(content.pools[0].candidates.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_content_parses_triangles() {
let dir = std::env::temp_dir().join("sr_content_test_triangles");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
create_temp_content(&dir);
let store = load_content(&dir).unwrap();
let content = store.districts.values().next().unwrap();
assert_eq!(content.triangles.len(), 1);
assert_eq!(content.triangles[0].canonical_id, "test-triangle");
assert_eq!(content.triangles[0].members.len(), 3);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_content_skips_stub_npcs() {
let dir = std::env::temp_dir().join("sr_content_test_stubs");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
create_temp_content(&dir);
let store = load_content(&dir).unwrap();
let content = store.districts.values().next().unwrap();
// Only alice.yaml should parse; bob.yaml is a stub
assert_eq!(content.npc_profiles.len(), 1);
assert_eq!(content.npc_profiles[0].canonical_id, "alice");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_content_parses_templates() {
let dir = std::env::temp_dir().join("sr_content_test_templates");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
create_temp_content(&dir);
let store = load_content(&dir).unwrap();
let content = store.districts.values().next().unwrap();
assert_eq!(content.templates.len(), 1);
assert_eq!(content.templates[0].template_id, "test-site");
assert_eq!(content.templates[0].role_slots.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn derive_district_id_from_path() {
let root = Path::new("/content");
let district =
Path::new("/content/campaigns/main/systems/krenn/stations/sova/districts/transit");
let id = derive_district_id(root, district);
assert_eq!(id, "krenn.sova.transit");
}
#[test]
fn is_comment_only_detects_stubs() {
let dir = std::env::temp_dir().join("sr_content_test_comment");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let stub = dir.join("stub.yaml");
fs::write(&stub, "# comment\n# another\n").unwrap();
assert!(is_comment_only_file(&stub));
let real = dir.join("real.yaml");
fs::write(&real, "key: value\n").unwrap();
assert!(!is_comment_only_file(&real));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn role_count_range_deserialization() {
// RoleCount::Range uses untagged enum — verify {min, max} object parses
let yaml = r#"
template_id: test
display_name: "Test"
role_slots:
- role: worker
display_name: "Worker"
count:
min: 2
max: 4
required: true
- role: manager
display_name: "Manager"
count: 1
required: true
"#;
let template: crate::content::types::Template =
serde_yaml::from_str(yaml).expect("template with RoleCount::Range should parse");
assert_eq!(template.role_slots.len(), 2);
match &template.role_slots[0].count {
crate::content::types::RoleCount::Range { min, max } => {
assert_eq!(*min, 2);
assert_eq!(*max, 4);
}
other => panic!("Expected RoleCount::Range, got {:?}", other),
}
match &template.role_slots[1].count {
crate::content::types::RoleCount::Fixed(n) => assert_eq!(*n, 1),
other => panic!("Expected RoleCount::Fixed, got {:?}", other),
}
}
#[test]
fn pool_constraint_deserialization() {
// PoolConstraint uses untagged enum — verify both variants parse
let yaml = r#"
pools:
- pool_id: "test:pool"
category: npc_role
constraints:
- must_be_in_template: "logistics-hub"
- must_have_pattern: "FRIEND"
- bonded_character: "smuggler"
candidates:
- npc_id: "npc:alice"
weight: 1
"#;
let pool_file: crate::content::types::PoolFile =
serde_yaml::from_str(yaml).expect("pool with constraints should parse");
assert_eq!(pool_file.pools.len(), 1);
assert_eq!(pool_file.pools[0].constraints.len(), 3);
// All constraints in this format are key-value strings (plain scalars)
// which match PoolConstraint::KeyValue
for constraint in &pool_file.pools[0].constraints {
match constraint {
crate::content::types::PoolConstraint::KeyValue(s) => {
assert!(!s.is_empty());
}
crate::content::types::PoolConstraint::Structured(_) => {
// Structured constraints are also valid
}
}
}
}
// -----------------------------------------------------------------------
// Tile loading tests (#577)
// -----------------------------------------------------------------------
fn make_location_with_tiles(tiles: Vec<&str>) -> Location {
Location {
canonical_id: "test-loc".to_string(),
display_name: "Test Location".to_string(),
description: None,
tile_bounds: Some(TileBounds {
x_min: 0,
y_min: 0,
x_max: tiles.first().map_or(0, |r| r.len() as i32 - 1),
y_max: tiles.len() as i32 - 1,
z: 0,
}),
tiles: Some(tiles.iter().map(|s| s.to_string()).collect()),
sightlines: None,
ambient_sound: None,
social_site: None,
}
}
#[test]
fn parse_tile_char_all_kinds() {
assert_eq!(parse_tile_char('F'), Some(TileKind::Floor));
assert_eq!(parse_tile_char('W'), Some(TileKind::Wall));
assert_eq!(parse_tile_char('V'), Some(TileKind::Void));
assert_eq!(parse_tile_char('R'), Some(TileKind::Restricted));
assert_eq!(parse_tile_char('X'), None);
assert_eq!(parse_tile_char(' '), None);
}
#[test]
fn apply_location_tiles_stamps_walkability() {
let loc = make_location_with_tiles(vec![
"FWF",
"FFF",
"WFW",
]);
let mut map = WalkabilityMap::new(4, 4, 1);
let applied = apply_location_tiles(&loc, &mut map);
assert!(applied);
// Row 0: F W F
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
assert!(!map.can_move_to(&TilePosition::new(1, 0, 0)));
assert!(map.can_move_to(&TilePosition::new(2, 0, 0)));
// Row 1: F F F
assert!(map.can_move_to(&TilePosition::new(0, 1, 0)));
assert!(map.can_move_to(&TilePosition::new(1, 1, 0)));
assert!(map.can_move_to(&TilePosition::new(2, 1, 0)));
// Row 2: W F W
assert!(!map.can_move_to(&TilePosition::new(0, 2, 0)));
assert!(map.can_move_to(&TilePosition::new(1, 2, 0)));
assert!(!map.can_move_to(&TilePosition::new(2, 2, 0)));
}
#[test]
fn apply_location_tiles_stamps_tile_kind() {
let loc = make_location_with_tiles(vec![
"FWVR",
]);
let mut map = WalkabilityMap::new(4, 1, 1);
apply_location_tiles(&loc, &mut map);
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor);
assert_eq!(map.tile_kind(&TilePosition::new(1, 0, 0)), TileKind::Wall);
assert_eq!(map.tile_kind(&TilePosition::new(2, 0, 0)), TileKind::Void);
assert_eq!(map.tile_kind(&TilePosition::new(3, 0, 0)), TileKind::Restricted);
}
#[test]
fn apply_location_tiles_with_offset() {
let loc = Location {
canonical_id: "offset-loc".to_string(),
display_name: "Offset".to_string(),
description: None,
tile_bounds: Some(TileBounds {
x_min: 10,
y_min: 20,
x_max: 12,
y_max: 21,
z: 0,
}),
tiles: Some(vec!["FWF".to_string(), "WFW".to_string()]),
sightlines: None,
ambient_sound: None,
social_site: None,
};
let mut map = WalkabilityMap::new(32, 32, 1);
apply_location_tiles(&loc, &mut map);
// (10,20) = F, (11,20) = W, (12,20) = F
assert!(map.can_move_to(&TilePosition::new(10, 20, 0)));
assert!(!map.can_move_to(&TilePosition::new(11, 20, 0)));
assert!(map.can_move_to(&TilePosition::new(12, 20, 0)));
// (10,21) = W, (11,21) = F, (12,21) = W
assert!(!map.can_move_to(&TilePosition::new(10, 21, 0)));
assert!(map.can_move_to(&TilePosition::new(11, 21, 0)));
assert!(!map.can_move_to(&TilePosition::new(12, 21, 0)));
}
#[test]
fn apply_location_tiles_skips_without_tiles() {
let loc = Location {
canonical_id: "no-tiles".to_string(),
display_name: "No Tiles".to_string(),
description: None,
tile_bounds: Some(TileBounds {
x_min: 0, y_min: 0, x_max: 4, y_max: 4, z: 0,
}),
tiles: None,
sightlines: None,
ambient_sound: None,
social_site: None,
};
let mut map = WalkabilityMap::new(5, 5, 1);
assert!(!apply_location_tiles(&loc, &mut map));
}
#[test]
fn apply_location_tiles_skips_without_bounds() {
let loc = Location {
canonical_id: "no-bounds".to_string(),
display_name: "No Bounds".to_string(),
description: None,
tile_bounds: None,
tiles: Some(vec!["FFF".to_string()]),
sightlines: None,
ambient_sound: None,
social_site: None,
};
let mut map = WalkabilityMap::new(5, 5, 1);
assert!(!apply_location_tiles(&loc, &mut map));
}
#[test]
fn load_location_tiles_from_store() {
let mut store = ContentStore::default();
let mut district = DistrictContent::default();
district.locations.push(make_location_with_tiles(vec![
"FW",
"WF",
]));
store.districts.insert("test".to_string(), district);
let mut map = WalkabilityMap::new(4, 4, 1);
let count = load_location_tiles(&store, &mut map);
assert_eq!(count, 1);
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
assert!(!map.can_move_to(&TilePosition::new(1, 0, 0)));
assert!(!map.can_move_to(&TilePosition::new(0, 1, 0)));
assert!(map.can_move_to(&TilePosition::new(1, 1, 0)));
}
#[test]
fn location_yaml_with_tiles_deserializes() {
let yaml = r#"
canonical_id: test-room
display_name: "Test Room"
tile_bounds:
x_min: 5
y_min: 10
x_max: 9
y_max: 12
z: 0
tiles:
- "FFFFF"
- "FWWWF"
- "FFFFF"
"#;
let loc: Location = serde_yaml::from_str(yaml).expect("location with tiles should parse");
assert_eq!(loc.canonical_id, "test-room");
assert!(loc.tiles.is_some());
let tiles = loc.tiles.unwrap();
assert_eq!(tiles.len(), 3);
assert_eq!(tiles[0], "FFFFF");
assert_eq!(tiles[1], "FWWWF");
assert_eq!(tiles[2], "FFFFF");
}
}
-151
View File
@@ -1,151 +0,0 @@
//! Content loading, indexing, and entity spawning system.
//!
//! Architecture (per Tyre's D-020 guidance):
//! 1. Deserialize YAML -> intermediate content types (types.rs)
//! 2. Content discovery + loading (loader.rs) -> ContentStore resource
//! 3. ContentStore -> ECS entity spawning (spawn.rs)
//! 4. ContentStore -> indexed line pools (line_pool.rs) -> LinePoolIndex resource
//! 5. Optional hot-reload (hot_reload.rs) for dev/authoring workflow
//!
//! Content schema is decoupled from ECS components. The spawn module
//! handles the mapping between the two representations.
pub mod entanglement;
pub mod hot_reload;
pub mod instantiation;
pub mod line_pool;
pub mod loader;
pub mod spawn;
pub mod template;
pub mod types;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use std::path::PathBuf;
use crate::knowledge::ContentEntityRegistry;
/// Configuration for the content loader.
/// Set the content root path before adding ContentPlugin.
#[derive(Resource, Debug, Clone)]
pub struct ContentConfig {
/// Root directory containing content.yaml and campaign directories.
pub content_root: PathBuf,
/// Enable hot-reload (timestamp polling). Dev-only, not for production.
pub hot_reload: bool,
}
impl Default for ContentConfig {
fn default() -> Self {
Self {
content_root: PathBuf::from("content"),
hot_reload: false,
}
}
}
/// Content loading plugin.
///
/// Loads content from YAML files at startup, spawns ECS entities,
/// and builds the indexed line pools for dialogue/monologue queries.
/// Optionally enables hot-reload for the authoring workflow.
pub struct ContentPlugin;
impl Plugin for ContentPlugin {
fn build(&self, app: &mut App) {
if !app.world().contains_resource::<ContentConfig>() {
app.insert_resource(ContentConfig::default());
}
// ContentEntityRegistry is required by spawn_npc (D-079).
// Init here so ContentPlugin works standalone without KnowledgePlugin.
app.init_resource::<ContentEntityRegistry>();
app.add_systems(Startup, load_and_spawn_content);
app.add_systems(PostUpdate, hot_reload::hot_reload_content);
tracing::debug!("ContentPlugin initialized");
}
}
/// Startup system: load content from disk, spawn entities, and build line pool index.
fn load_and_spawn_content(world: &mut World) {
let config = world.resource::<ContentConfig>().clone();
tracing::info!("Loading content from: {:?}", config.content_root);
match loader::load_content(&config.content_root) {
Ok(store) => {
let result = spawn::spawn_content(world, &store);
tracing::info!("Content loaded and spawned: {} NPCs", result.npcs_spawned);
// Stamp location tile data onto WalkabilityMap (#577)
if world.contains_resource::<crate::simulation::movement::WalkabilityMap>() {
let mut walkability = world.resource_mut::<crate::simulation::movement::WalkabilityMap>();
let tiles_loaded = loader::load_location_tiles(&store, &mut walkability);
if tiles_loaded > 0 {
tracing::info!("Loaded tile data for {} locations", tiles_loaded);
}
}
// Build line pool index
let index = line_pool::LinePoolIndex::build(&store);
tracing::info!(
"Line pool index built: {} dialogue lines, {} monologue lines",
index.dialogue_line_count(),
index.monologue_line_count()
);
// Build TickerPool from loaded headlines (#591).
let ticker_lines: Vec<crate::bridge::types::TickerLine> = store
.districts
.values()
.flat_map(|d| &d.ticker_headlines)
.map(|h| crate::bridge::types::TickerLine {
id: h.id.clone(),
text: h.text.clone(),
category: h.category.clone(),
})
.collect();
let ticker_count = ticker_lines.len();
if ticker_count > 0 {
world.insert_resource(
crate::simulation::ticker::TickerPool::from_lines(ticker_lines),
);
tracing::info!("TickerPool built: {} headlines loaded", ticker_count);
} else {
tracing::warn!("TickerPool: no ticker headlines found — current_ticker will be None");
}
world.insert_resource(ContentStoreResource(store));
world.insert_resource(LinePoolIndexResource(index));
}
Err(e) => {
tracing::error!("Failed to load content: {}", e);
world.insert_resource(ContentStoreResource(loader::ContentStore::default()));
world.insert_resource(LinePoolIndexResource(line_pool::LinePoolIndex::default()));
}
}
// Set up hot-reload if enabled
if config.hot_reload {
let watcher = hot_reload::ContentWatcher::new(&config.content_root);
tracing::info!(
"Content hot-reload enabled — tracking {} files, polling every ~2s",
watcher.tracked_file_count()
);
world.insert_resource(watcher);
}
}
/// Wrapper resource holding the loaded content store.
/// Available for runtime systems that need to query content data
/// (e.g., dialogue selection, triangle fork evaluation).
#[derive(Resource, Debug)]
pub struct ContentStoreResource(pub loader::ContentStore);
/// Wrapper resource holding the indexed line pools.
/// Available for runtime systems that need to query dialogue/monologue lines
/// through the D-028 four-layer filtering pipeline.
#[derive(Resource, Debug)]
pub struct LinePoolIndexResource(pub line_pool::LinePoolIndex);
File diff suppressed because it is too large Load Diff
-620
View File
@@ -1,620 +0,0 @@
//! Intermediate content types for YAML deserialization.
//!
//! These types mirror the JSON Schema definitions in content/_schema/.
//! They are decoupled from ECS components — the spawn module handles
//! the mapping from content types to bevy_ecs Components/Resources.
//!
//! Load order: content files → seed config → entity instantiation.
//! Per Tyre's architecture guidance (D-020, #394).
use serde::Deserialize;
use std::collections::BTreeMap;
// ---------------------------------------------------------------------------
// Content manifest (content.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct ContentManifest {
pub version: String,
pub campaigns: Vec<CampaignRef>,
}
#[derive(Debug, Deserialize)]
pub struct CampaignRef {
pub id: String,
pub path: String,
pub enabled: bool,
#[serde(default)]
pub discovery: Option<Discovery>,
}
#[derive(Debug, Deserialize)]
pub struct Discovery {
#[serde(default)]
pub districts: Option<String>,
}
// ---------------------------------------------------------------------------
// District metadata (district.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct DistrictMeta {
pub display_name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub locations: Vec<String>,
#[serde(default)]
pub npc_count: u32,
#[serde(default)]
pub canonical_id: Option<String>,
}
// ---------------------------------------------------------------------------
// Pool configuration (pools.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct PoolFile {
pub pools: Vec<Pool>,
}
#[derive(Debug, Deserialize)]
pub struct Pool {
pub pool_id: String,
pub category: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub constraints: Vec<PoolConstraint>,
#[serde(default)]
pub candidates: Vec<PoolCandidate>,
}
/// Pool constraints are stored as key-value strings.
/// The seed system interprets them; the loader just preserves them.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum PoolConstraint {
KeyValue(String),
Structured(BTreeMap<String, String>),
}
#[derive(Debug, Deserialize)]
pub struct PoolCandidate {
/// NPC candidates use `npc_id`, contraband uses `id`.
#[serde(alias = "id")]
pub npc_id: Option<String>,
#[serde(default = "default_weight")]
pub weight: u32,
}
fn default_weight() -> u32 {
1
}
// ---------------------------------------------------------------------------
// Template configuration (templates/*.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct Template {
pub template_id: String,
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub capacity: Option<Capacity>,
#[serde(default)]
pub role_slots: Vec<RoleSlot>,
#[serde(default)]
pub v01_assignments: Option<BTreeMap<String, String>>,
#[serde(default)]
pub reference_links: Vec<ReferenceLink>,
#[serde(default)]
pub triangle_constraints: Vec<TriangleConstraint>,
#[serde(default)]
pub dialogue_pools: Vec<DialoguePoolRef>,
}
#[derive(Debug, Deserialize)]
pub struct Capacity {
pub min: u32,
pub max: u32,
}
#[derive(Debug, Deserialize)]
pub struct RoleSlot {
pub role: String,
pub display_name: String,
#[serde(default)]
pub count: RoleCount,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub pool_ref: Option<String>,
#[serde(default)]
pub flags: Vec<String>,
}
/// Role count can be a plain integer or a {min, max} object.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum RoleCount {
Fixed(u32),
Range { min: u32, max: u32 },
}
impl Default for RoleCount {
fn default() -> Self {
Self::Fixed(1)
}
}
#[derive(Debug, Deserialize)]
pub struct ReferenceLink {
pub npc: String,
#[serde(default)]
pub owning_template: Option<String>,
#[serde(default)]
pub relationship: Option<String>,
#[serde(default)]
pub presence_phases: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct TriangleConstraint {
pub triangle: String,
#[serde(default)]
pub required_roles: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct DialoguePoolRef {
pub location: String,
#[serde(default)]
pub roles: Vec<String>,
}
// ---------------------------------------------------------------------------
// Triangle (triangles/*.yaml) — mirrors triangle.schema.json
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct Triangle {
pub canonical_id: String,
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
pub members: Vec<TriangleMember>,
#[serde(default)]
pub forks: Vec<Fork>,
#[serde(default)]
pub resolution_states: Vec<Resolution>,
/// D-087 classification: "active_fork" (default) or "passive_tension".
#[serde(default)]
pub classification: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TriangleMember {
pub npc: String,
pub role: String,
}
#[derive(Debug, Deserialize)]
pub struct Fork {
pub id: String,
#[serde(default)]
pub condition: Option<String>,
#[serde(default)]
pub outcomes: Vec<ForkOutcome>,
}
#[derive(Debug, Deserialize)]
pub struct ForkOutcome {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub effects: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct Resolution {
pub id: String,
pub description: String,
}
// ---------------------------------------------------------------------------
// NPC Profile (npcs/*.yaml) — mirrors npc-profile.schema.json
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct NpcProfile {
pub canonical_id: String,
pub display_name: String,
#[serde(default = "default_tier")]
pub tier: u8,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default)]
pub motivation: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub want: Option<NpcWant>,
#[serde(default)]
pub secret: Option<String>,
#[serde(default)]
pub relationships: Vec<NpcRelationship>,
#[serde(default)]
pub tolerance: Option<NpcTolerance>,
#[serde(default)]
pub routine: Option<NpcRoutineSummary>,
#[serde(default)]
pub information: Option<NpcInformation>,
#[serde(default)]
pub contentment: Option<NpcContentment>,
#[serde(default)]
pub personality: Option<BTreeMap<String, String>>,
#[serde(default)]
pub tells: Vec<NpcTell>,
#[serde(default)]
pub skills: Option<NpcSkills>,
#[serde(default)]
pub triangle_membership: Vec<String>,
#[serde(default)]
pub trust_levels: Option<NpcTrustLevels>,
#[serde(default)]
pub friend_arc: Option<NpcFriendArc>,
#[serde(default)]
pub dual_lens: Option<NpcDualLens>,
}
fn default_tier() -> u8 {
3
}
#[derive(Debug, Deserialize)]
pub struct NpcWant {
pub primary: String,
#[serde(default)]
pub intensity: Option<i32>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcRelationship {
pub target: String,
pub kind: String,
#[serde(default)]
pub trust: Option<i32>,
#[serde(default)]
pub notes: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcTolerance {
#[serde(default)]
pub threshold: Option<i32>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcRoutineSummary {
#[serde(default)]
pub summary: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcInformation {
#[serde(default)]
pub knows: Vec<String>,
#[serde(default)]
pub access_tier: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcContentment {
#[serde(default)]
pub level: Option<i32>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcTell {
pub trigger: String,
pub behavior: String,
#[serde(default)]
pub visible_to: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcSkills {
#[serde(default)]
pub combat_trained: Option<bool>,
#[serde(default)]
pub skills: Option<BTreeMap<String, i32>>,
}
#[derive(Debug, Deserialize)]
pub struct NpcTrustLevels {
#[serde(default)]
pub surface: Option<String>,
#[serde(default)]
pub real: Option<String>,
#[serde(default)]
pub secret: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcFriendArc {
pub bonded_character: String,
#[serde(default)]
pub phases: Vec<NpcFriendPhase>,
}
#[derive(Debug, Deserialize)]
pub struct NpcFriendPhase {
pub phase: u8,
pub description: String,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub routine_deviation: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NpcDualLens {
#[serde(default)]
pub smuggler: Option<String>,
#[serde(default)]
pub detective: Option<String>,
}
// ---------------------------------------------------------------------------
// Location (locations/*.yaml) — mirrors location.schema.json
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct Location {
pub canonical_id: String,
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub tile_bounds: Option<TileBounds>,
/// Tile layout for this location (#577).
///
/// Array of strings, one row per string, left-to-right = +x, top-to-bottom = +y.
/// Each character maps to a server-side TileKind:
/// `F` = Floor (walkable, open space)
/// `W` = Wall (solid obstacle, blocks movement and LOS)
/// `V` = Void (out-of-bounds / unloaded)
/// `R` = Restricted (blocked but traversable by specific entities)
///
/// Row 0 is placed at `tile_bounds.y_min`, column 0 at `tile_bounds.x_min`.
/// Requires `tile_bounds` to be set. Row count must equal
/// `y_max - y_min + 1`, and each row length must equal `x_max - x_min + 1`.
#[serde(default)]
pub tiles: Option<Vec<String>>,
#[serde(default)]
pub sightlines: Option<Sightlines>,
#[serde(default)]
pub ambient_sound: Option<String>,
#[serde(default)]
pub social_site: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TileBounds {
pub x_min: i32,
pub y_min: i32,
pub x_max: i32,
pub y_max: i32,
pub z: i32,
}
#[derive(Debug, Deserialize)]
pub struct Sightlines {
#[serde(default)]
pub open: Option<bool>,
#[serde(default)]
pub notes: Option<String>,
}
// ---------------------------------------------------------------------------
// Routine schedules (routines/schedules.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct RoutineFile {
pub district: String,
pub schedules: Vec<NpcSchedule>,
}
#[derive(Debug, Deserialize)]
pub struct NpcSchedule {
pub npc: String,
pub entries: Vec<RoutineEntry>,
#[serde(default)]
pub deviations: Vec<Deviation>,
}
#[derive(Debug, Deserialize)]
pub struct RoutineEntry {
pub phase: String,
pub location: String,
#[serde(default)]
pub tile: Option<TileCoord>,
#[serde(default)]
pub activity: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Deviation {
pub trigger: String,
#[serde(default)]
pub phase: Option<String>,
pub location: String,
#[serde(default)]
pub tile: Option<TileCoord>,
#[serde(default)]
pub activity: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TileCoord {
pub x: i32,
pub y: i32,
}
// ---------------------------------------------------------------------------
// Dialogue pool (dialogue/**/*.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct DialoguePool {
pub location: String,
pub role: String,
pub lines: Vec<DialogueLine>,
}
#[derive(Debug, Deserialize)]
pub struct DialogueLine {
pub id: String,
pub text: String,
pub role: String,
pub access: Vec<String>,
pub trust: String,
pub situation: Vec<String>,
#[serde(default)]
pub topic: Vec<String>,
#[serde(default)]
pub mood: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub knowledge_grant: Option<KnowledgeGrant>,
}
/// Knowledge grant attached to a dialogue line (D-079).
///
/// Untagged enum — serde tries each variant in order:
/// `Fact` matches YAML with `fact_id` field.
/// `Entity` matches YAML with `entity_ref` field.
/// `Compound` variant deferred to Sprint 18.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum KnowledgeGrant {
/// Grant knowledge of a non-entity fact.
/// Format: fact_id "category.topic", confidence string.
Fact {
fact_id: String,
confidence: String,
},
/// Grant knowledge of an entity (creates EntityKnowledge entry in observer's KG).
/// Required for contradiction detection: testimony must create ToldBy EntityKnowledge
/// so a subsequent DirectObservation can detect a discrepancy (D-079, D-083).
Entity {
entity_ref: String,
#[serde(default)]
attributes: BTreeMap<String, String>,
confidence: String,
},
}
// ---------------------------------------------------------------------------
// Monologue pool (monologue/**/*.yaml)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct MonologuePool {
pub character: String,
pub location: String,
pub lines: Vec<MonologueLine>,
}
#[derive(Debug, Deserialize)]
pub struct MonologueLine {
pub id: String,
pub text: String,
pub trigger: String,
#[serde(default)]
pub prerequisites: Option<Prerequisites>,
#[serde(default)]
pub priority: Option<i32>,
/// Per-line cooldown in ticks. `None` (omitted in YAML) means no per-line
/// cooldown — fire-once lines rely on trigger semantics instead (e.g.,
/// `first_*` and `contradiction_detected` triggers fire once by design).
#[serde(default)]
pub cooldown: Option<i32>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Prerequisites {
#[serde(default)]
pub facts: Vec<FactPrerequisite>,
#[serde(default)]
pub entity_attributes: Vec<AttributePrerequisite>,
#[serde(default)]
pub relationship: Option<RelationshipPrerequisite>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FactPrerequisite {
pub fact_id: String,
pub min_confidence: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AttributePrerequisite {
pub entity: String,
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RelationshipPrerequisite {
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub state: Option<String>,
}
// ---------------------------------------------------------------------------
// Ticker content (#591)
// ---------------------------------------------------------------------------
/// Intermediate type for deserializing a news ticker YAML file.
/// The `dual_lens` field is authoring metadata — not deserialized or forwarded.
#[derive(Debug, Deserialize)]
pub struct TickerFile {
/// Location slug this ticker belongs to (e.g. "the-last-shift").
pub location: String,
/// Feed identifier (e.g. "meridian").
pub feed: String,
pub headlines: Vec<TickerHeadline>,
}
/// A single headline entry in a ticker YAML file.
#[derive(Debug, Deserialize)]
pub struct TickerHeadline {
pub id: String,
pub text: String,
pub category: String,
// dual_lens is intentionally omitted — authoring metadata only
}
+1 -1
View File
@@ -3,10 +3,10 @@
pub mod bridge;
pub mod cause_chain;
pub mod content;
pub mod knowledge;
pub mod npc;
pub mod perception;
pub mod settings;
pub mod simulation;
pub mod storyteller;
pub mod voice;
+25 -7
View File
@@ -150,19 +150,36 @@ fn main() {
app.add_plugins(BridgePlugin);
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
app.add_plugins(settled_reach_server::npc::NpcPlugin);
// Content root is at the repo root, one level up from server/
app.insert_resource(settled_reach_server::content::ContentConfig {
content_root: std::path::PathBuf::from("../content"),
hot_reload: false,
});
app.add_plugins(settled_reach_server::content::ContentPlugin);
app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin);
app.add_plugins(settled_reach_server::settings::SettingsPlugin);
// Initialize SQLite settings store (#627).
// Path: alongside save files in the server's working directory.
let settings_path = std::path::PathBuf::from("settings.db");
match settled_reach_server::settings::SettingsStore::open(&settings_path) {
Ok(store) => {
tracing::info!("Settings store opened: {:?}", settings_path);
app.insert_resource(settled_reach_server::settings::SettingsStoreResource::new(
store,
"default".to_string(),
));
}
Err(e) => {
tracing::error!("Failed to open settings store: {}. Settings will not persist.", e);
}
}
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(HandshakeState::Complete);
// Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0)
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
// Initialize empty line pool index (populated by generator pipeline in v0.2).
app.insert_resource(settled_reach_server::simulation::line_pool::LinePoolIndexResource(
settled_reach_server::simulation::line_pool::LinePoolIndex::default(),
));
// Character archetype from client's StartupMessage (#587).
let archetype = startup.character_archetype;
@@ -305,6 +322,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
state_hash: None,
debug_response: None,
current_ticker: None,
settings_response: None,
sim_errors: vec![SimError {
kind: SimErrorKind::Panic,
message: format!("Simulation panic: {}", panic_msg),
@@ -337,8 +355,8 @@ fn dump_schedule_graph() {
app.add_plugins(BridgePlugin);
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
app.add_plugins(settled_reach_server::npc::NpcPlugin);
app.add_plugins(settled_reach_server::content::ContentPlugin);
app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin);
app.add_plugins(settled_reach_server::settings::SettingsPlugin);
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(0));
// Access Schedules resource directly — schedules are populated by plugins
+1 -1
View File
@@ -18,7 +18,7 @@
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::content::line_pool::Mood as ContentMood;
use crate::simulation::line_pool::Mood as ContentMood;
use crate::npc::interaction::InteractionMemory;
use crate::npc::{Npc, ToleranceThreshold};
use crate::simulation::dialogue::CurrentMood;
+1 -1
View File
@@ -213,7 +213,7 @@ mod tests {
world.init_resource::<ObservationEventQueue>();
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
world.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>();
world
}
+1 -1
View File
@@ -175,7 +175,7 @@ mod tests {
world.init_resource::<EntityRegistry>();
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
world.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>();
world
}
+5 -1
View File
@@ -28,7 +28,7 @@ use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::poi::PointOfInterest;
use crate::simulation::rng::SimRng;
use crate::content::template::TriangleCrisisEventQueue;
use crate::simulation::triangle::TriangleCrisisEventQueue;
use crate::simulation::sound::SoundEventQueue;
use crate::simulation::stance::Stance;
use crate::simulation::ticker::{TickerPool, LAST_SHIFT_ZONE_ID};
@@ -394,6 +394,9 @@ pub fn compute_observer_snapshot(
// Consume pending save/load result for this tick (#553).
let save_result = buffer.pending_save_result.take();
// Consume pending settings response for this tick (#627).
let settings_response = buffer.pending_settings_response.take();
// Consume pending debug response for this tick (#580).
let debug_response = buffer.pending_debug_response.take();
@@ -483,6 +486,7 @@ pub fn compute_observer_snapshot(
sim_errors,
debug_response,
current_ticker,
settings_response,
});
}
+1 -1
View File
@@ -16,7 +16,7 @@ fn setup_world(width: i32, height: i32) -> World {
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world.init_resource::<crate::simulation::sound::SoundEventQueue>();
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
world.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>();
world
}
+6 -6
View File
@@ -23,11 +23,11 @@ use rand::Rng;
use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState};
use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName};
use crate::storyteller::EngagementRecord;
use crate::content::line_pool::{
use crate::simulation::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
use crate::content::types::KnowledgeGrant;
use crate::content::LinePoolIndexResource;
use crate::simulation::knowledge_grant::KnowledgeGrant;
use crate::simulation::line_pool::LinePoolIndexResource;
use crate::knowledge::content_registry::ContentEntityRegistry;
use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant};
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId};
@@ -356,7 +356,7 @@ pub fn select_dialogue_line<'a>(
/// avoid duplicating the L1-L4 query + scoring logic. Callers handle the
/// result differently (initial Talk sets ActiveDialogue; follow-up may clear it).
fn run_dialogue_pipeline<'a>(
line_pool: &'a crate::content::line_pool::LinePoolIndex,
line_pool: &'a crate::simulation::line_pool::LinePoolIndex,
location: &str,
role: &str,
relationship: RelationshipState,
@@ -1110,11 +1110,11 @@ pub fn process_dialogue_response(
#[cfg(test)]
mod tests {
use super::*;
use crate::content::line_pool::{
use crate::simulation::line_pool::{
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
Topic, TrustTier,
};
use crate::content::LinePoolIndexResource;
use crate::simulation::line_pool::LinePoolIndexResource;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::Npc;
+64
View File
@@ -0,0 +1,64 @@
//! Knowledge grant types for dialogue and monologue lines.
//!
//! `KnowledgeGrant` describes the knowledge a player gains from a dialogue line.
//! `Prerequisites` describes preconditions for a monologue line to fire.
use serde::Deserialize;
use std::collections::BTreeMap;
/// Knowledge grant attached to a dialogue line (D-079).
///
/// Untagged enum — serde tries each variant in order:
/// `Fact` matches YAML with `fact_id` field.
/// `Entity` matches YAML with `entity_ref` field.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum KnowledgeGrant {
/// Grant knowledge of a non-entity fact.
/// Format: fact_id "category.topic", confidence string.
Fact {
fact_id: String,
confidence: String,
},
/// Grant knowledge of an entity (creates EntityKnowledge entry in observer's KG).
/// Required for contradiction detection: testimony must create ToldBy EntityKnowledge
/// so a subsequent DirectObservation can detect a discrepancy (D-079, D-083).
Entity {
entity_ref: String,
#[serde(default)]
attributes: BTreeMap<String, String>,
confidence: String,
},
}
/// Prerequisite set for a monologue line.
#[derive(Debug, Clone, Deserialize)]
pub struct Prerequisites {
#[serde(default)]
pub facts: Vec<FactPrerequisite>,
#[serde(default)]
pub entity_attributes: Vec<AttributePrerequisite>,
#[serde(default)]
pub relationship: Option<RelationshipPrerequisite>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FactPrerequisite {
pub fact_id: String,
pub min_confidence: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AttributePrerequisite {
pub entity: String,
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RelationshipPrerequisite {
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub state: Option<String>,
}
+530
View File
@@ -0,0 +1,530 @@
//! Indexed line pool data structures and query API (D-028 four-layer filtering).
//!
//! Provides typed, pre-indexed runtime representations of dialogue and monologue
//! content pools. Populated by the v0.2 generator pipeline.
//!
//! Indexing strategy (D-041 determinism):
//! - All maps use BTreeMap for deterministic iteration order
//! - Dialogue: BTreeMap<(location, role), pool> with lines ready for filtering
//! - Monologue: BTreeMap<(character, location), pool> with trigger grouping
use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use bevy_ecs::prelude::*;
use crate::simulation::knowledge_grant::{KnowledgeGrant, Prerequisites};
// ---------------------------------------------------------------------------
// Tag enums (D-035 converged taxonomy)
// ---------------------------------------------------------------------------
/// D-028 Layer 1: Access tier — hard filter on who can hear this line.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AccessTier {
Public,
Insider,
Authority,
Peer,
Hostile,
}
impl FromStr for AccessTier {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"public" => Ok(Self::Public),
"insider" => Ok(Self::Insider),
"authority" => Ok(Self::Authority),
"peer" => Ok(Self::Peer),
"hostile" => Ok(Self::Hostile),
_ => Err(ParseEnumError {
kind: "AccessTier",
value: s.to_string(),
}),
}
}
}
/// D-028 Layer 3: Trust tier — hard filter on relationship depth.
///
/// Ordering: Surface < Real < Secret (derived from PartialOrd on discriminant).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TrustTier {
Surface,
Real,
Secret,
}
impl TrustTier {
/// Returns true if `self` meets or exceeds the `required` tier.
pub fn meets(self, required: TrustTier) -> bool {
self >= required
}
}
impl FromStr for TrustTier {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"surface" => Ok(Self::Surface),
"real" => Ok(Self::Real),
"secret" => Ok(Self::Secret),
_ => Err(ParseEnumError {
kind: "TrustTier",
value: s.to_string(),
}),
}
}
}
/// D-028 Layer 2: Situation context — when this line can fire.
///
/// 14 v0.1 values: 13 original + Greeting added Sprint 8 (D-035 amendment)
/// for PC dialogue pools initial contact lines.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Situation {
Arrival,
ShiftStart,
ShiftEnd,
ShiftTransition,
BarEvening,
NightShift,
Investigation,
Confrontation,
Social,
Alone,
Emergency,
Routine,
Observation,
/// Added Sprint 8 (D-035 amendment): PC dialogue initial contact lines.
Greeting,
/// First player-NPC interaction — interaction_count == 0 (#325, D-028 Layer 2).
FirstMeeting,
/// Player has talked to this NPC 3+ times — interaction_count >= 3 (#325, D-028 Layer 2).
RepeatedVisit,
}
impl FromStr for Situation {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"arrival" => Ok(Self::Arrival),
"shift_start" => Ok(Self::ShiftStart),
"shift_end" => Ok(Self::ShiftEnd),
"shift_transition" => Ok(Self::ShiftTransition),
"bar_evening" => Ok(Self::BarEvening),
"night_shift" => Ok(Self::NightShift),
"investigation" => Ok(Self::Investigation),
"confrontation" => Ok(Self::Confrontation),
"social" => Ok(Self::Social),
"alone" => Ok(Self::Alone),
"emergency" => Ok(Self::Emergency),
"routine" => Ok(Self::Routine),
"observation" => Ok(Self::Observation),
"greeting" => Ok(Self::Greeting),
"first_meeting" => Ok(Self::FirstMeeting),
"repeated_visit" => Ok(Self::RepeatedVisit),
_ => Err(ParseEnumError {
kind: "Situation",
value: s.to_string(),
}),
}
}
}
/// D-028 Layer 4: Topic tag — influences weighted selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Topic {
Colleague,
Routine,
Cargo,
Money,
Trust,
Danger,
Institution,
Personal,
Investigation,
}
impl FromStr for Topic {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"colleague" => Ok(Self::Colleague),
"routine" => Ok(Self::Routine),
"cargo" => Ok(Self::Cargo),
"money" => Ok(Self::Money),
"trust" => Ok(Self::Trust),
"danger" => Ok(Self::Danger),
"institution" => Ok(Self::Institution),
"personal" => Ok(Self::Personal),
"investigation" => Ok(Self::Investigation),
_ => Err(ParseEnumError {
kind: "Topic",
value: s.to_string(),
}),
}
}
}
/// D-028 Layer 4: Mood tag — influences weighted selection.
///
/// 8 v0.1 values aligned to voice guide vocabulary (Sprint 14 rename).
/// D-035 amendment (Sprint 8): `Focused` added as 9th variant.
/// Neutral mood is represented by omitting the mood tag (untagged = baseline).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Mood {
Anxious,
Frustrated,
Content,
Suspicious,
Warm,
Hostile,
Relieved,
/// D-035 amendment (Sprint 8): task-focused NPC mood — used at The Terminal
/// and maintenance corridors. Maps from NpcMood::Focused.
Focused,
}
impl FromStr for Mood {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"anxious" => Ok(Self::Anxious),
"frustrated" => Ok(Self::Frustrated),
"content" => Ok(Self::Content),
"suspicious" => Ok(Self::Suspicious),
"warm" => Ok(Self::Warm),
"hostile" => Ok(Self::Hostile),
"relieved" => Ok(Self::Relieved),
"focused" => Ok(Self::Focused),
_ => Err(ParseEnumError {
kind: "Mood",
value: s.to_string(),
}),
}
}
}
/// Monologue trigger type — what causes this line to fire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Trigger {
EnterLocation,
ObserveNpc,
HearSound,
ObserveAnomaly,
PostConversation,
DiscoverEvidence,
WitnessInteraction,
TimeIdle,
ReturnVisit,
}
impl FromStr for Trigger {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"enter_location" => Ok(Self::EnterLocation),
"observe_npc" => Ok(Self::ObserveNpc),
"hear_sound" => Ok(Self::HearSound),
"observe_anomaly" => Ok(Self::ObserveAnomaly),
"post_conversation" => Ok(Self::PostConversation),
"discover_evidence" => Ok(Self::DiscoverEvidence),
"witness_interaction" => Ok(Self::WitnessInteraction),
"time_idle" => Ok(Self::TimeIdle),
"return_visit" => Ok(Self::ReturnVisit),
_ => Err(ParseEnumError {
kind: "Trigger",
value: s.to_string(),
}),
}
}
}
/// Hard character partition for monologue pools (D-032).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Character {
Smuggler,
Detective,
}
impl FromStr for Character {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"smuggler" => Ok(Self::Smuggler),
"detective" => Ok(Self::Detective),
_ => Err(ParseEnumError {
kind: "Character",
value: s.to_string(),
}),
}
}
}
/// Error type for enum parsing failures.
#[derive(Debug)]
pub struct ParseEnumError {
pub kind: &'static str,
pub value: String,
}
impl fmt::Display for ParseEnumError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid {} value: {:?}", self.kind, self.value)
}
}
impl std::error::Error for ParseEnumError {}
// ---------------------------------------------------------------------------
// Indexed line types — typed runtime representations
// ---------------------------------------------------------------------------
/// A dialogue line with typed enum fields, ready for filtering.
#[derive(Debug, Clone)]
pub struct IndexedDialogueLine {
pub id: String,
pub text: String,
pub role: String,
pub access: Vec<AccessTier>,
pub trust: TrustTier,
pub situation: Vec<Situation>,
pub topic: Vec<Topic>,
pub mood: Vec<Mood>,
pub tags: Vec<String>,
pub knowledge_grant: Option<KnowledgeGrant>,
}
/// A monologue line with typed enum fields, ready for filtering.
#[derive(Debug, Clone)]
pub struct IndexedMonologueLine {
pub id: String,
pub text: String,
pub trigger: Trigger,
pub prerequisites: Option<Prerequisites>,
pub priority: u8,
pub cooldown: u32,
pub tags: Vec<String>,
}
// ---------------------------------------------------------------------------
// Pool index types
// ---------------------------------------------------------------------------
/// Dialogue pool indexed for querying.
#[derive(Debug)]
pub struct IndexedDialoguePool {
pub location: String,
pub role: String,
pub lines: Vec<IndexedDialogueLine>,
}
/// Monologue pool indexed by trigger for fast lookup.
#[derive(Debug)]
pub struct IndexedMonologuePool {
pub character: Character,
pub location: String,
/// Lines grouped by trigger type (BTreeMap for deterministic iteration).
pub by_trigger: BTreeMap<Trigger, Vec<IndexedMonologueLine>>,
}
// ---------------------------------------------------------------------------
// Top-level index
// ---------------------------------------------------------------------------
/// Top-level line pool index — the queryable runtime data structure.
///
/// All internal maps use BTreeMap per D-041 determinism requirement.
#[derive(Debug, Default)]
pub struct LinePoolIndex {
/// Dialogue pools indexed by (location, role).
pub dialogue: BTreeMap<(String, String), IndexedDialoguePool>,
/// Monologue pools indexed by (character, location).
pub monologue: BTreeMap<(Character, String), IndexedMonologuePool>,
}
impl LinePoolIndex {
/// Query dialogue lines through Layers 1-3 of the D-028 pipeline.
///
/// Returns lines that pass:
/// - Layer 1: player's access tier is in line.access
/// - Layer 2: any active situation is in line.situation
/// - Layer 3: player's trust >= line.trust
///
/// Layer 4 (topic+mood scoring) is handled by the selection pipeline (#305).
pub fn query_dialogue(
&self,
location: &str,
role: &str,
player_access: AccessTier,
active_situations: &[Situation],
player_trust: TrustTier,
) -> Vec<&IndexedDialogueLine> {
let key = (location.to_string(), role.to_string());
let Some(pool) = self.dialogue.get(&key) else {
return Vec::new();
};
pool.lines
.iter()
.filter(|line| {
// Layer 1: Access filter (hard)
line.access.contains(&player_access)
})
.filter(|line| {
// Layer 2: Situation filter (context)
line.situation.iter().any(|s| active_situations.contains(s))
})
.filter(|line| {
// Layer 3: Trust filter (hard)
player_trust.meets(line.trust)
})
.collect()
}
/// Query monologue lines for a trigger event.
///
/// Returns lines matching character + trigger from both:
/// - Location-specific pool (exact match)
/// - General pool (location = "general")
///
/// Prerequisite evaluation and cooldown checking are the caller's
/// responsibility (they require KG state and tick tracking).
pub fn query_monologue(
&self,
character: Character,
location: &str,
trigger: Trigger,
) -> Vec<&IndexedMonologueLine> {
let mut results = Vec::new();
// Location-specific pool
let key = (character, location.to_string());
if let Some(pool) = self.monologue.get(&key) {
if let Some(lines) = pool.by_trigger.get(&trigger) {
results.extend(lines.iter());
}
}
// General pool fallback
if location != "general" {
let general_key = (character, "general".to_string());
if let Some(pool) = self.monologue.get(&general_key) {
if let Some(lines) = pool.by_trigger.get(&trigger) {
results.extend(lines.iter());
}
}
}
results
}
/// Returns total number of indexed dialogue lines.
pub fn dialogue_line_count(&self) -> usize {
self.dialogue.values().map(|p| p.lines.len()).sum()
}
/// Returns total number of indexed monologue lines.
pub fn monologue_line_count(&self) -> usize {
self.monologue
.values()
.flat_map(|p| p.by_trigger.values())
.map(|lines| lines.len())
.sum()
}
}
// ---------------------------------------------------------------------------
// Wrapper resource
// ---------------------------------------------------------------------------
/// Wrapper resource holding the indexed line pools.
/// Available for runtime systems that need to query dialogue/monologue lines
/// through the D-028 four-layer filtering pipeline.
#[derive(Resource, Debug)]
pub struct LinePoolIndexResource(pub LinePoolIndex);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_tier_parse_all_values() {
assert_eq!("public".parse::<AccessTier>().unwrap(), AccessTier::Public);
assert_eq!("insider".parse::<AccessTier>().unwrap(), AccessTier::Insider);
assert_eq!("authority".parse::<AccessTier>().unwrap(), AccessTier::Authority);
assert_eq!("peer".parse::<AccessTier>().unwrap(), AccessTier::Peer);
assert_eq!("hostile".parse::<AccessTier>().unwrap(), AccessTier::Hostile);
assert!("invalid".parse::<AccessTier>().is_err());
}
#[test]
fn trust_tier_ordering() {
assert!(TrustTier::Surface < TrustTier::Real);
assert!(TrustTier::Real < TrustTier::Secret);
assert!(TrustTier::Secret.meets(TrustTier::Secret));
assert!(TrustTier::Secret.meets(TrustTier::Surface));
assert!(!TrustTier::Surface.meets(TrustTier::Real));
}
#[test]
fn situation_parse_all_values() {
let values = [
"arrival", "shift_start", "shift_end", "shift_transition", "bar_evening",
"night_shift", "investigation", "confrontation", "social", "alone",
"emergency", "routine", "observation", "greeting", "first_meeting", "repeated_visit",
];
for v in values {
assert!(v.parse::<Situation>().is_ok(), "Failed to parse situation: {}", v);
}
assert!("invalid".parse::<Situation>().is_err());
}
#[test]
fn topic_parse_all_values() {
let values = [
"colleague", "routine", "cargo", "money", "trust", "danger",
"institution", "personal", "investigation",
];
for v in values {
assert!(v.parse::<Topic>().is_ok(), "Failed to parse topic: {}", v);
}
}
#[test]
fn mood_parse_all_values() {
let values = [
"anxious", "frustrated", "content", "suspicious", "warm",
"hostile", "relieved", "focused",
];
for v in values {
assert!(v.parse::<Mood>().is_ok(), "Failed to parse mood: {}", v);
}
}
#[test]
fn trigger_parse_all_values() {
let values = [
"enter_location", "observe_npc", "hear_sound", "observe_anomaly",
"post_conversation", "discover_evidence", "witness_interaction",
"time_idle", "return_visit",
];
for v in values {
assert!(v.parse::<Trigger>().is_ok(), "Failed to parse trigger: {}", v);
}
}
#[test]
fn character_parse() {
assert_eq!("smuggler".parse::<Character>().unwrap(), Character::Smuggler);
assert_eq!("detective".parse::<Character>().unwrap(), Character::Detective);
assert!("other".parse::<Character>().is_err());
}
}
+22 -4
View File
@@ -14,6 +14,8 @@ pub mod generator;
pub mod input;
pub mod interaction;
pub mod inventory;
pub mod knowledge_grant;
pub mod line_pool;
pub mod listening;
pub mod modification;
pub mod monologue;
@@ -33,6 +35,7 @@ pub mod stance;
pub mod tier;
pub mod time;
pub mod ticker;
pub mod triangle;
pub mod zone;
/// Core simulation plugin
@@ -58,8 +61,8 @@ impl Plugin for SimulationPlugin {
.init_resource::<monologue::PostConversationQueue>()
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
// Triangle escalation resources (#250)
.init_resource::<crate::content::template::TriangleCrisisEventQueue>()
.init_resource::<crate::content::template::ResolveTriangleQueue>()
.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>()
.init_resource::<crate::simulation::triangle::ResolveTriangleQueue>()
// discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin).
// Init here so SimulationPlugin works standalone in tests without PerceptionPlugin.
.init_resource::<crate::perception::query::VisibilityGeometry>()
@@ -116,11 +119,11 @@ impl Plugin for SimulationPlugin {
.after(crate::npc::awareness::detect_player_awareness)
.before(crate::perception::observer::compute_observer_snapshot),
// Triangle escalation (#250) — runs on game-minute boundaries (every 10 ticks)
crate::content::template::tick_triangle_escalation
crate::simulation::triangle::tick_triangle_escalation
.after(crate::npc::tolerance::check_tolerance_threshold)
.before(crate::perception::observer::compute_observer_snapshot),
// Triangle resolution (#250, D-089) — apply player resolve commands
crate::content::template::apply_resolve_triangle
crate::simulation::triangle::apply_resolve_triangle
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
time::advance_tick.after(path_follow::cleanup_path_blocked),
@@ -145,6 +148,21 @@ impl Plugin for SimulationPlugin {
.before(crate::perception::observer::compute_observer_snapshot),
);
// Voice enrichment (D-138, Phase 3) — rewrite NPC text with voiced
// variants from cache before the observer snapshot is assembled.
// No-op when VoiceCacheResource is absent (voice pipeline disabled).
app.add_systems(
Update,
(
crate::voice::integration::voice_enrich_dialogue_response
.after(crate::simulation::dialogue::process_talk_interaction)
.before(crate::perception::observer::compute_observer_snapshot),
crate::voice::integration::voice_enrich_conversation_events
.after(conversation::run_npc_conversations)
.before(crate::perception::observer::compute_observer_snapshot),
),
);
// Initialize TickerPool with empty default; populated by ContentPlugin at Startup.
app.init_resource::<ticker::TickerPool>();
+12 -445
View File
@@ -14,7 +14,6 @@ use bevy_ecs::prelude::*;
use rand::Rng;
use crate::bridge::types::MonologueEvent;
use crate::content::ContentStoreResource;
use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
use crate::perception::interpretation::ObservationTrigger;
use crate::simulation::conversation::NpcName;
@@ -23,13 +22,6 @@ use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::storyteller::EngagementRecord;
/// Minimum ticks between monologue lines (prevents spam).
/// At 10 ticks/game-minute, 300 ticks = 30 game-minutes.
const COOLDOWN_TICKS: u64 = 300;
/// Ticks of idle (no movement) before a time_idle monologue fires.
/// 100 ticks = 10 game-minutes.
const IDLE_THRESHOLD_TICKS: u64 = 100;
/// Display duration for monologue text on client (seconds).
const DISPLAY_DURATION: f32 = 5.0;
@@ -310,7 +302,6 @@ pub fn process_sprint_anomaly_monologue(
/// System ordering: after trigger_monologue, before process_sprint_anomaly_monologue.
pub fn trigger_recognition_monologue(
time: Res<SimulationTime>,
content: Option<Res<ContentStoreResource>>,
mut rng: ResMut<SimRng>,
mut query: Query<
(
@@ -355,21 +346,11 @@ pub fn trigger_recognition_monologue(
return;
};
// Try content pools for observe_anomaly trigger lines
let line = content
.as_deref()
.and_then(|c| select_pool_line("observe_anomaly", &state, c, &mut rng.rng));
// Use content pool line or hardcoded fallback
let (id, text) = if let Some((id, text)) = line {
(id, text)
} else {
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
(
RECOGNITION_LINES[i].0.to_string(),
RECOGNITION_LINES[i].1.to_string(),
)
};
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
let (id, text) = (
RECOGNITION_LINES[i].0.to_string(),
RECOGNITION_LINES[i].1.to_string(),
);
buffer.event = Some(MonologueEvent {
id: id.clone(),
@@ -395,63 +376,6 @@ pub fn trigger_recognition_monologue(
// Shared content pool selection (#119)
// ---------------------------------------------------------------------------
/// Select a monologue line from content pools, matching trigger and character.
/// Returns (id, text) or None if no matching lines exist.
/// Prefers unseen lines; falls back to repeats if all have been shown.
fn select_pool_line(
trigger: &str,
state: &MonologueState,
content: &ContentStoreResource,
rng: &mut impl Rng,
) -> Option<(String, String)> {
let character = state.character.as_str();
let mut candidates: Vec<(&str, &str)> = Vec::new();
for district in content.0.districts.values() {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != trigger {
continue;
}
if state.shown_ids.contains(&line.id) {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
if candidates.is_empty() {
// Fallback: allow repeats
for district in content.0.districts.values() {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != trigger {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
}
if candidates.is_empty() {
return None;
}
let index = rng.random_range(0..candidates.len());
Some((
candidates[index].0.to_string(),
candidates[index].1.to_string(),
))
}
/// Select from hardcoded fallback lines for the given trigger type.
fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, String) {
let lines = match trigger {
@@ -486,7 +410,7 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
///
/// Checks observation events, sound events, overheard conversations, and
/// completed dialogues for monologue-worthy triggers. Fires at most one
/// monologue per tick. Bypasses normal COOLDOWN_TICKS (event-driven),
/// monologue per tick. Bypasses cooldown (event-driven),
/// but updates last_fired_tick for periodic trigger cooldown tracking.
///
/// Priority order (first match wins):
@@ -500,7 +424,6 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
#[allow(clippy::too_many_arguments)]
pub fn trigger_event_monologue(
time: Res<SimulationTime>,
content: Option<Res<ContentStoreResource>>,
mut rng: ResMut<SimRng>,
observation_queue: Option<Res<crate::perception::interpretation::ObservationEventQueue>>,
sound_queue: Option<Res<crate::simulation::sound::SoundEventQueue>>,
@@ -567,16 +490,7 @@ pub fn trigger_event_monologue(
let Some(trigger) = trigger else { return };
// Select line: content pool first, hardcoded fallback second
let (id, text) = if let Some(ref content) = content {
if let Some(line) = select_pool_line(trigger, &state, content, &mut rng.rng) {
line
} else {
select_hardcoded_fallback(trigger, &mut rng.rng)
}
} else {
select_hardcoded_fallback(trigger, &mut rng.rng)
};
let (id, text) = select_hardcoded_fallback(trigger, &mut rng.rng);
buffer.event = Some(MonologueEvent {
id: id.clone(),
@@ -670,112 +584,16 @@ fn has_hear_sound_event(
///
/// v0.1 triggers:
/// - `enter_location`: fires once on first tick (session start)
/// - `time_idle`: fires after IDLE_THRESHOLD_TICKS of no player movement
/// - `time_idle`: fires after idle threshold of no player movement
pub fn trigger_monologue(
time: Res<SimulationTime>,
content: Option<Res<ContentStoreResource>>,
mut rng: ResMut<SimRng>,
mut query: Query<
_time: Res<SimulationTime>,
_rng: ResMut<SimRng>,
_query: Query<
(&TilePosition, &mut MonologueState, &mut MonologueBuffer),
With<PlayerCharacter>,
>,
) {
let Some(content) = content else { return };
let Ok((pos, mut state, mut buffer)) = query.single_mut() else {
return;
};
// Track idle time
let current_pos = (pos.x, pos.y);
if let Some(last) = state.last_position {
if last == current_pos {
state.idle_ticks += 1;
} else {
state.idle_ticks = 0;
}
}
state.last_position = Some(current_pos);
// Cooldown check
if time.tick > 0 && time.tick - state.last_fired_tick < COOLDOWN_TICKS {
return;
}
// Determine which trigger to attempt
let trigger = if !state.entered {
state.entered = true;
Some("enter_location")
} else if state.idle_ticks >= IDLE_THRESHOLD_TICKS {
Some("time_idle")
} else {
None
};
let Some(trigger) = trigger else { return };
// Collect candidate lines from all district monologue pools
let character = state.character.as_str();
let mut candidates: Vec<(&str, &str)> = Vec::new(); // (id, text)
for district in content.0.districts.values() {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != trigger {
continue;
}
if state.shown_ids.contains(&line.id) {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
if candidates.is_empty() {
// All lines for this trigger have been shown; allow repeats
for district in content.0.districts.values() {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != trigger {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
}
if candidates.is_empty() {
return;
}
// Select a random line
let index = rng.rng.random_range(0..candidates.len());
let (id, text) = candidates[index];
buffer.event = Some(MonologueEvent {
id: id.to_string(),
text: text.to_string(),
duration_seconds: DISPLAY_DURATION,
});
state.shown_ids.insert(id.to_string());
state.last_fired_tick = time.tick;
// Reset idle counter so time_idle doesn't fire again immediately
state.idle_ticks = 0;
tracing::debug!(
"Monologue fired: trigger={}, id={}, tick={}",
trigger,
id,
time.tick
);
// v0.2: content pool removed; line selection deferred to generator pipeline
}
// ---------------------------------------------------------------------------
@@ -877,137 +695,10 @@ pub fn process_contradiction_monologue(
#[cfg(test)]
mod tests {
use super::*;
use crate::content::loader::{ContentStore, DistrictContent};
use crate::content::types::{MonologueLine, MonologuePool};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use bevy_ecs::world::World;
fn setup_world_with_content() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
// Create test monologue content
let pool = MonologuePool {
character: "detective".to_string(),
location: "general".to_string(),
lines: vec![
MonologueLine {
id: "test_enter_001".to_string(),
text: "Sova Transit District. Let's narrow that down.".to_string(),
trigger: "enter_location".to_string(),
prerequisites: None,
priority: None,
cooldown: None,
tags: vec![],
},
MonologueLine {
id: "test_idle_001".to_string(),
text: "Everyone knows I'm Commission.".to_string(),
trigger: "time_idle".to_string(),
prerequisites: None,
priority: None,
cooldown: None,
tags: vec![],
},
],
};
let mut district = DistrictContent::default();
district.monologue_pools.push(pool);
let mut store = ContentStore::default();
store.districts.insert("test".to_string(), district);
world.insert_resource(ContentStoreResource(store));
world
}
#[test]
fn enter_location_fires_on_first_tick() {
let mut world = setup_world_with_content();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_some());
let event = buffer.event.as_ref().unwrap();
assert_eq!(event.id, "test_enter_001");
}
#[test]
fn cooldown_prevents_spam() {
let mut world = setup_world_with_content();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_monologue);
// First tick: should fire enter_location
schedule.run(&mut world);
// Consume the buffer
let mut query = world.query::<&mut MonologueBuffer>();
query.single_mut(&mut world).unwrap().take();
// Advance a few ticks (still in cooldown)
world.resource_mut::<SimulationTime>().tick = 10;
// Set idle ticks high to try to trigger time_idle
let mut state_query = world.query::<&mut MonologueState>();
state_query.single_mut(&mut world).unwrap().idle_ticks = IDLE_THRESHOLD_TICKS + 1;
schedule.run(&mut world);
// Should NOT fire — cooldown active
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_none());
}
#[test]
fn time_idle_fires_after_threshold() {
let mut world = setup_world_with_content();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState {
entered: true, // Skip enter_location
last_position: Some((5, 5)),
idle_ticks: IDLE_THRESHOLD_TICKS, // At threshold
..Default::default()
},
MonologueBuffer::default(),
));
// Advance past cooldown
world.resource_mut::<SimulationTime>().tick = COOLDOWN_TICKS + 1;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_some());
let event = buffer.event.as_ref().unwrap();
assert_eq!(event.id, "test_idle_001");
}
// -----------------------------------------------------------------------
// SprintAnomalyQueue unit tests (#428, D-055)
// -----------------------------------------------------------------------
@@ -2095,58 +1786,6 @@ mod tests {
);
}
#[test]
fn event_uses_content_pool_when_available() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
// Set up content pool with a witness_interaction line
let pool = MonologuePool {
character: "detective".to_string(),
location: "general".to_string(),
lines: vec![MonologueLine {
id: "pool_witness_01".to_string(),
text: "She's lying to him.".to_string(),
trigger: "witness_interaction".to_string(),
prerequisites: None,
priority: None,
cooldown: None,
tags: vec![],
}],
};
let mut district = DistrictContent::default();
district.monologue_pools.push(pool);
let mut store = ContentStore::default();
store.districts.insert("test".to_string(), district);
world.insert_resource(ContentStoreResource(store));
// Push a conversation event
world
.get_mut::<ConversationEventBuffer>(player)
.unwrap()
.events
.push(crate::simulation::conversation::ConversationEvent {
occluded_line: "Test".to_string(),
speaker_id: 100,
target_id: 101,
speaker_name: "A".to_string(),
target_name: "B".to_string(),
speaker_color_index: 0,
target_color_index: 1,
});
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(buf.event.is_some());
assert_eq!(
buf.event.as_ref().unwrap().id,
"pool_witness_01",
"should use content pool line over hardcoded fallback"
);
}
#[test]
fn hardcoded_lines_all_valid() {
for lines in &[
@@ -2167,13 +1806,6 @@ mod tests {
// Constant assertions
// -----------------------------------------------------------------------
#[test]
fn cooldown_ticks_constant_is_300() {
// D-035: 300 ticks = 30 game-minutes at 10 ticks/game-minute (D-031).
// If this changes, players will see more/less monologue spam.
assert_eq!(COOLDOWN_TICKS, 300, "D-035: COOLDOWN_TICKS must be 300");
}
// -----------------------------------------------------------------------
// hear_sound: only Machinery and Alert trigger (not Voice/Ambient/Footstep)
// -----------------------------------------------------------------------
@@ -2230,71 +1862,6 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// observe_anomaly content pool integration via recognition monologue
// -----------------------------------------------------------------------
#[test]
fn recognition_monologue_uses_observe_anomaly_content_pool_key() {
// When a content pool has lines with trigger="observe_anomaly",
// trigger_recognition_monologue should select from that pool (not hardcoded fallback).
// This verifies the content key matches the implementation.
let mut world = setup_recognition_world();
let pool = MonologuePool {
character: "detective".to_string(),
location: "general".to_string(),
lines: vec![MonologueLine {
id: "observe_anomaly_pool_01".to_string(),
text: "That person shouldn't be here.".to_string(),
trigger: "observe_anomaly".to_string(),
prerequisites: None,
priority: None,
cooldown: None,
tags: vec![],
}],
};
let mut district = DistrictContent::default();
district.monologue_pools.push(pool);
let mut store = ContentStore::default();
store.districts.insert("test".to_string(), district);
world.insert_resource(ContentStoreResource(store));
let target = world.spawn_empty().id();
let mut cd = CognitiveDelay::default();
cd.push(PendingRecognition {
target,
stable_id: StableId(1),
position: TilePosition::new(5, 5, 0),
delay_until_tick: NORMAL_DELAY_TICKS,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
world.spawn((
PlayerCharacter,
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
cd,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_recognition_monologue);
schedule.run(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buffer = buf_query.single(&world).unwrap();
assert!(buffer.event.is_some(), "recognition monologue should fire");
assert_eq!(
buffer.event.as_ref().unwrap().id,
"observe_anomaly_pool_01",
"should use content pool line with trigger='observe_anomaly' key"
);
}
#[test]
fn observation_tick_tracking_updated() {
let mut world = setup_event_world();
+4 -4
View File
@@ -16,7 +16,7 @@ use thiserror::Error;
use crate::bridge::types::SaveLoadResultWire;
use crate::bridge::types::SnapshotBuffer;
use crate::content::template::{TemplateReferenceMap, TriangleState};
use crate::simulation::triangle::{TemplateReferenceMap, TriangleState};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::Npc;
@@ -30,7 +30,7 @@ use crate::knowledge::types::StableId;
use crate::simulation::interaction::DoorState;
use crate::simulation::tier::{ActiveSim, BackgroundSim};
use crate::simulation::time::SimulationTime;
use crate::content::template::TriangleCrisisEventQueue;
use crate::simulation::triangle::TriangleCrisisEventQueue;
use crate::storyteller::{
ActivationState, ContaminationActive, ContaminationEventQueue, MovementHistoryBuffer,
TriangleActivatedQueue,
@@ -594,7 +594,7 @@ mod tests {
#[test]
fn load_from_file_rejects_wrong_format_version() {
use crate::content::template::TemplateReferenceMap;
use crate::simulation::triangle::TemplateReferenceMap;
// Craft a save with a wrong format_version
let bad_state = SaveStateV1 {
format_version: 0xFF, // deliberately wrong
@@ -775,7 +775,7 @@ mod tests {
// -----------------------------------------------------------------------
fn make_test_triangle(slug: &str, tension: u8) -> TriangleState {
use crate::content::template::{
use crate::simulation::triangle::{
RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase,
};
let mut role_assignments = std::collections::BTreeMap::new();
+1 -1
View File
@@ -39,7 +39,7 @@ use bevy_ecs::entity::Entity;
use bevy_ecs::world::World;
use serde::{Deserialize, Serialize};
use crate::content::template::{TemplateOwnership, TemplateReferenceMap, TriangleState};
use crate::simulation::triangle::{TemplateOwnership, TemplateReferenceMap, TriangleState};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::StableId;
+2 -2
View File
@@ -55,7 +55,7 @@ use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use rand::Rng;
use crate::content::template::{TriangleClassification, TriangleId, TrianglePhase, TriangleState};
use crate::simulation::triangle::{TriangleClassification, TriangleId, TrianglePhase, TriangleState};
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
@@ -691,7 +691,7 @@ pub fn expire_routine_deviations(
#[cfg(test)]
mod tests {
use super::*;
use crate::content::template::{
use crate::simulation::triangle::{
RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState,
};
use crate::knowledge::types::StableId;
+1 -1
View File
@@ -7,7 +7,7 @@ use bevy_ecs::prelude::*;
use bevy_ecs::schedule::Schedule;
use std::collections::BTreeMap;
use settled_reach_server::content::template::{
use settled_reach_server::simulation::triangle::{
RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState,
};
use settled_reach_server::knowledge::types::StableId;
-617
View File
@@ -1,617 +0,0 @@
//! Integration test: content loading pipeline.
//!
//! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities.
//! Uses real content files from content/ directory for structural content,
//! and a test fixture for isolated NPC profile spawning.
//!
//! Runtime validation (TCP boot + tick) is in content_runtime.rs.
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use std::collections::BTreeMap;
use std::path::PathBuf;
use settled_reach_server::content::loader::{load_content, ContentStore};
use settled_reach_server::content::spawn::spawn_content;
use settled_reach_server::content::types::*;
use settled_reach_server::content::{ContentConfig, ContentPlugin, ContentStoreResource};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::npc;
use settled_reach_server::simulation::SimulationPlugin;
/// Find the content root relative to the test binary location.
fn content_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir).join("../content")
}
// -----------------------------------------------------------------------
// Test: real content discovery and structural loading
// -----------------------------------------------------------------------
#[test]
fn discover_real_content_structure() {
let root = content_root();
if !root.join("content.yaml").exists() {
// Skip if content directory is not present (e.g. CI without content)
eprintln!("Skipping: content directory not found at {:?}", root);
return;
}
let store = load_content(&root).expect("content loading should succeed");
// Manifest should be present
assert!(store.manifest.is_some());
let manifest = store.manifest.as_ref().unwrap();
assert_eq!(manifest.version, "0.1.0");
assert!(!manifest.campaigns.is_empty());
// At least one district should be discovered
assert!(
!store.districts.is_empty(),
"should discover at least one district"
);
}
#[test]
fn load_real_transit_district() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
// The transit district should be discovered
let transit = store
.districts
.get("krenn.sova.transit")
.expect("transit district should be discovered");
// District metadata
assert!(transit.meta.is_some());
let meta = transit.meta.as_ref().unwrap();
assert_eq!(meta.display_name, "Sova Transit District");
assert_eq!(meta.npc_count, 17);
// 5 triangles from ticket #391
assert_eq!(transit.triangles.len(), 5);
let triangle_ids: Vec<&str> = transit
.triangles
.iter()
.map(|t| t.canonical_id.as_str())
.collect();
assert!(triangle_ids.contains(&"hub-power"));
assert!(triangle_ids.contains(&"worried-knowledge"));
assert!(triangle_ids.contains(&"bar-tensions"));
assert!(triangle_ids.contains(&"worried-partner"));
assert!(triangle_ids.contains(&"informant-question"));
// Each triangle should have exactly 3 members
for triangle in &transit.triangles {
assert_eq!(
triangle.members.len(),
3,
"Triangle {} should have 3 members",
triangle.canonical_id
);
}
// 5 pools from ticket #389
assert_eq!(transit.pools.len(), 5);
let pool_ids: Vec<&str> = transit.pools.iter().map(|p| p.pool_id.as_str()).collect();
assert!(pool_ids.contains(&"transit:friend_smuggler"));
assert!(pool_ids.contains(&"transit:friend_detective"));
assert!(pool_ids.contains(&"transit:bar_regulars"));
assert!(pool_ids.contains(&"transit:compromised_inspector"));
assert!(pool_ids.contains(&"transit:primary_contraband"));
// 3 templates from ticket #390
assert_eq!(transit.templates.len(), 3);
let template_ids: Vec<&str> = transit
.templates
.iter()
.map(|t| t.template_id.as_str())
.collect();
assert!(template_ids.contains(&"logistics-hub"));
assert!(template_ids.contains(&"bar"));
assert!(template_ids.contains(&"smuggling-ring"));
// 23 NPC profiles: 17 NPCs + 2 PC-as-NPC + 1 extended NPC (nils-davan) + 3 Sprint 14 additions
// Populated by #398 (wiki→YAML NPC conversion) and Sprint 14 content expansion
assert_eq!(
transit.npc_profiles.len(),
23,
"Expected 23 parseable NPC profiles"
);
}
#[test]
fn verify_triangle_fork_structure() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
let transit = store.districts.get("krenn.sova.transit").unwrap();
// Hub power triangle: should have 1 fork with 3 outcomes
let hub_power = transit
.triangles
.iter()
.find(|t| t.canonical_id == "hub-power")
.expect("hub-power triangle should exist");
assert_eq!(hub_power.forks.len(), 1);
assert_eq!(hub_power.forks[0].id, "volume-escalation");
assert_eq!(hub_power.forks[0].outcomes.len(), 3);
let outcome_ids: Vec<&str> = hub_power.forks[0]
.outcomes
.iter()
.filter_map(|o| o.id.as_deref())
.collect();
assert!(outcome_ids.contains(&"escalate"));
assert!(outcome_ids.contains(&"stabilize"));
assert!(outcome_ids.contains(&"mediate"));
// Resolution states
assert_eq!(hub_power.resolution_states.len(), 3);
}
#[test]
fn verify_pool_candidates() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
let transit = store.districts.get("krenn.sova.transit").unwrap();
// bar_regulars pool should have 5 candidates
let bar_regulars = transit
.pools
.iter()
.find(|p| p.pool_id == "transit:bar_regulars")
.expect("bar_regulars pool should exist");
assert_eq!(bar_regulars.candidates.len(), 5);
assert_eq!(bar_regulars.category, "npc_group");
}
#[test]
fn verify_template_role_slots() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
let transit = store.districts.get("krenn.sova.transit").unwrap();
// Logistics hub should have 5 role slots
let hub = transit
.templates
.iter()
.find(|t| t.template_id == "logistics-hub")
.expect("logistics-hub template should exist");
assert_eq!(hub.role_slots.len(), 5);
// Should have v01_assignments
assert!(hub.v01_assignments.is_some());
let assignments = hub.v01_assignments.as_ref().unwrap();
assert!(assignments.contains_key("shift-supervisor"));
}
// -----------------------------------------------------------------------
// Test: NPC spawning pipeline with test fixture data
// -----------------------------------------------------------------------
#[test]
fn spawn_npc_from_content_store() {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
// Create a minimal content store with one test NPC
let mut store = ContentStore::default();
let mut district = settled_reach_server::content::loader::DistrictContent::default();
district.npc_profiles.push(NpcProfile {
canonical_id: "test-worker".to_string(),
display_name: "Test Worker".to_string(),
tier: 2,
pattern: Some("ANCHOR".to_string()),
motivation: Some("CIVILIAN".to_string()),
description: Some("A test dock worker".to_string()),
want: Some(NpcWant {
primary: "Safety".to_string(),
intensity: Some(5),
description: Some("Wants a quiet life".to_string()),
}),
secret: None,
relationships: vec![],
tolerance: Some(NpcTolerance {
threshold: Some(70),
description: None,
}),
routine: None,
information: None,
contentment: Some(NpcContentment {
level: Some(30),
description: None,
}),
personality: None,
tells: vec![],
skills: Some(NpcSkills {
combat_trained: Some(false),
skills: Some({
let mut m = BTreeMap::new();
m.insert("technical".to_string(), 5);
m
}),
}),
triangle_membership: vec![],
trust_levels: None,
friend_arc: None,
dual_lens: None,
});
store
.districts
.insert("test.district".to_string(), district);
let result = spawn_content(&mut world, &store);
// Verify entity was spawned
assert_eq!(result.npcs_spawned, 1);
assert!(result.npc_ids.contains_key("test-worker"));
// Verify ECS components
let stable_id = result.npc_ids["test-worker"];
let entity = world
.resource::<EntityRegistry>()
.to_entity(&stable_id)
.unwrap();
assert!(world.get::<npc::Npc>(entity).is_some());
let want = world.get::<npc::Want>(entity).unwrap();
assert_eq!(want.primary, npc::WantKind::Safety);
assert_eq!(want.intensity, 5);
let tolerance = world.get::<npc::ToleranceThreshold>(entity).unwrap();
assert_eq!(tolerance.threshold, 70);
let skills = world.get::<npc::SkillSet>(entity).unwrap();
assert_eq!(skills.skills[&npc::Skill::Technical], 5);
}
// -----------------------------------------------------------------------
// Test: ContentPlugin integration with bevy App
// -----------------------------------------------------------------------
#[test]
fn content_plugin_loads_via_app() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(settled_reach_server::npc::NpcPlugin);
app.insert_resource(ContentConfig {
content_root: root,
..Default::default()
});
app.add_plugins(ContentPlugin);
// Run startup systems
app.update();
// ContentStoreResource should be inserted
assert!(
app.world().contains_resource::<ContentStoreResource>(),
"ContentStoreResource should be present after startup"
);
let store = &app.world().resource::<ContentStoreResource>().0;
assert!(!store.districts.is_empty());
}
// -----------------------------------------------------------------------
// Test: Full spawn pipeline with real content — 10-axis gap closure
// -----------------------------------------------------------------------
#[test]
fn spawn_real_content_with_relationships_and_secrets() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
let store = load_content(&root).expect("content loading should succeed");
let result = spawn_content(&mut world, &store);
// All 23 profiles should spawn
assert_eq!(result.npcs_spawned, 23);
assert!(result.npc_ids.contains_key("npc:kael-davan"));
assert!(result.npc_ids.contains_key("npc:voss"));
assert!(result.npc_ids.contains_key("npc:pc-smuggler"));
assert!(result.npc_ids.contains_key("npc:nils-davan"));
// Verify ALL 20 NPCs have Want components (Option C: exact enum keywords in YAML)
let registry = world.resource::<EntityRegistry>();
let mut npcs_with_want = 0;
for (canonical_id, stable_id) in &result.npc_ids {
let entity = registry
.to_entity(stable_id)
.unwrap_or_else(|| panic!("{} should have an entity", canonical_id));
assert!(
world.get::<npc::Want>(entity).is_some(),
"NPC {} should have a Want component",
canonical_id
);
npcs_with_want += 1;
}
assert_eq!(
npcs_with_want, 23,
"All 23 NPCs should have Want components"
);
// Spot-check specific Want values
let kael_entity = registry
.to_entity(&result.npc_ids["npc:kael-davan"])
.unwrap();
let kael_want = world
.get::<npc::Want>(kael_entity)
.expect("Kael should have Want");
assert_eq!(kael_want.primary, npc::WantKind::Safety);
// Verify Kael has a Secret component
let kael_secret = world
.get::<npc::Secret>(kael_entity)
.expect("Kael should have Secret");
assert!(kael_secret.description.contains("ring"));
assert_eq!(kael_secret.severity, npc::SecretSeverity::Major);
// Verify Kael has Relationships (7 defined in YAML)
let kael_rels = world
.get::<npc::Relationships>(kael_entity)
.expect("Kael should have Relationships");
assert!(
kael_rels.entries.len() >= 5,
"Kael should have at least 5 resolved relationships, got {}",
kael_rels.entries.len()
);
// Verify Kael has KnowledgeGraph (background facts from information.knows)
let kael_kg = world
.get::<settled_reach_server::knowledge::graph::KnowledgeGraph>(kael_entity)
.expect("Kael should have KnowledgeGraph");
assert!(
kael_kg.knows_fact(&settled_reach_server::knowledge::types::FactId(
"contraband.ring_exists".to_string()
))
);
// Verify global RelationshipGraph was populated
let graph = world.resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
assert!(
graph.edge_count() >= 20,
"Expected at least 20 relationship edges, got {}",
graph.edge_count()
);
// Verify Nils (off-stage) also has correct data
let nils_entity = world
.resource::<EntityRegistry>()
.to_entity(&result.npc_ids["npc:nils-davan"])
.unwrap();
let nils_want = world
.get::<npc::Want>(nils_entity)
.expect("Nils should have Want");
assert_eq!(nils_want.primary, npc::WantKind::Power);
}
// -----------------------------------------------------------------------
// Test: EntanglementTag assignment from authored content (#176, D-029)
// -----------------------------------------------------------------------
#[test]
fn entanglement_tags_assigned_from_triangle_membership() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
let store = load_content(&root).expect("content loading should succeed");
let result = spawn_content(&mut world, &store);
// Acceptance: 17+ NPCs spawn (we have 23 authored profiles)
assert!(
result.npcs_spawned >= 17,
"Expected 17+ NPCs, got {}",
result.npcs_spawned
);
let registry = world.resource::<EntityRegistry>();
// Every NPC must have an EntanglementTag
let mut intrigue_count = 0u32;
let mut flat_count = 0u32;
for (canonical_id, stable_id) in &result.npc_ids {
let entity = registry
.to_entity(stable_id)
.unwrap_or_else(|| panic!("{} should have an entity", canonical_id));
let tag = world
.get::<npc::EntanglementTag>(entity)
.unwrap_or_else(|| panic!("{} must have EntanglementTag", canonical_id));
match tag {
npc::EntanglementTag::Intrigue => intrigue_count += 1,
npc::EntanglementTag::Flat => flat_count += 1,
npc::EntanglementTag::Mundane => {} // reserved for procedural NPCs
}
}
// Acceptance: at least one EntanglementTag::Intrigue entity
assert!(
intrigue_count >= 1,
"At least one NPC must be EntanglementTag::Intrigue, got 0"
);
// Stronger assertion: we know 13 authored NPCs have non-empty triangle_membership
assert!(
intrigue_count >= 10,
"Expected 10+ Intrigue NPCs (authored triangle members), got {}",
intrigue_count
);
// Some NPCs should be Flat (no triangle membership)
assert!(
flat_count >= 1,
"At least one NPC should be EntanglementTag::Flat, got 0"
);
// Spot-check: Kael (triangle member) must be Intrigue
let kael_entity = registry
.to_entity(&result.npc_ids["npc:kael-davan"])
.unwrap();
assert_eq!(
*world.get::<npc::EntanglementTag>(kael_entity).unwrap(),
npc::EntanglementTag::Intrigue,
"Kael (triangle member) must be Intrigue"
);
// Spot-check: Devra (empty triangle_membership) must be Flat
let devra_entity = registry
.to_entity(&result.npc_ids["npc:devra"])
.unwrap();
assert_eq!(
*world.get::<npc::EntanglementTag>(devra_entity).unwrap(),
npc::EntanglementTag::Flat,
"Devra (no triangle membership) must be Flat"
);
}
// -----------------------------------------------------------------------
// Test: Authored triangle instantiation (#188, D-087)
// -----------------------------------------------------------------------
#[test]
fn authored_triangles_instantiated_from_content() {
use settled_reach_server::content::template::{TriangleClassification, TriangleState};
use settled_reach_server::simulation::tier::ActiveSim;
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
let store = load_content(&root).expect("content loading should succeed");
let result = spawn_content(&mut world, &store);
// Query all TriangleState entities (clone to release world borrow)
let triangles: Vec<TriangleState> = {
let mut q = world.query::<&TriangleState>();
q.iter(&world).cloned().collect()
};
// Acceptance: exactly 5 authored triangles
assert_eq!(
triangles.len(),
5,
"Expected 5 authored triangles, got {}",
triangles.len()
);
// Count by classification (D-087)
let active_count = triangles
.iter()
.filter(|t| t.classification == TriangleClassification::ActiveFork)
.count();
let passive_count = triangles
.iter()
.filter(|t| t.classification == TriangleClassification::PassiveTension)
.count();
assert_eq!(
active_count, 3,
"Expected 3 ActiveFork triangles, got {}",
active_count
);
assert_eq!(
passive_count, 2,
"Expected 2 PassiveTension triangles, got {}",
passive_count
);
// All 5 must have exactly 3 role assignments (triangle = 3 NPCs)
for triangle in &triangles {
assert_eq!(
triangle.role_assignments.len(),
3,
"Triangle {:?} should have 3 role assignments, got {}",
triangle.triangle_id,
triangle.role_assignments.len()
);
}
// All role assignments must point to valid NPC entities in the registry
let registry = world.resource::<EntityRegistry>();
for triangle in &triangles {
for (role, stable_id) in &triangle.role_assignments {
assert!(
registry.to_entity(stable_id).is_some(),
"Triangle {:?} role '{}' points to StableId {:?} with no entity",
triangle.triangle_id,
role.0,
stable_id,
);
}
}
// All triangle entities must have ActiveSim marker
let mut active_query = world.query::<(&TriangleState, &ActiveSim)>();
let active_triangles: Vec<_> = active_query.iter(&world).collect();
assert_eq!(
active_triangles.len(),
5,
"All 5 triangles must have ActiveSim marker"
);
// Verify StableIds in role assignments correspond to spawned NPC canonical_ids
let all_npc_stable_ids: std::collections::BTreeSet<_> =
result.npc_ids.values().copied().collect();
for triangle in &triangles {
for (role, stable_id) in &triangle.role_assignments {
assert!(
all_npc_stable_ids.contains(stable_id),
"Triangle {:?} role '{}' StableId {:?} not in spawned NPC set",
triangle.triangle_id,
role.0,
stable_id,
);
}
}
}
// Runtime validation test (boot + tick 10 + snapshot) moved to
// server/tests/content_runtime.rs per architectural review.
-162
View File
@@ -1,162 +0,0 @@
//! Runtime validation: boot full plugin stack with real content, tick 10
//! times over TCP, assert valid ObserverSnapshot (#489).
//!
//! Separated from content_loading.rs (structural loading tests) per
//! architectural review — TCP runtime tests have different failure modes
//! and timeout characteristics.
use bevy_app::prelude::*;
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::Duration;
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
use settled_reach_server::content::{ContentConfig, ContentPlugin};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
use settled_reach_server::simulation::listening::ListeningFocus;
use settled_reach_server::simulation::monologue::{
MonologueBuffer, MonologueState, SprintAnomalyQueue,
};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
use settled_reach_server::simulation::SimulationPlugin;
fn content_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir).join("../content")
}
/// Smoke test for production content. Boots the full plugin stack with real
/// content over TCP, ticks 10 times, and asserts a valid ObserverSnapshot.
/// Catches runtime panics from broken entity references, missing components,
/// or content schema issues that pass YAML validation but fail at tick time.
#[test]
fn content_runtime_boot_tick_10_snapshot() {
use std::io::{BufReader, BufWriter};
let root = content_root();
if !root.join("content.yaml").exists() {
eprintln!("Skipping: content directory not found at {:?}", root);
return;
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let server_addr = listener.local_addr().expect("get local addr");
// Barrier keeps the server thread alive until the client has finished
// reading all snapshots, preventing a TCP RST race under parallel execution.
let barrier = Arc::new(Barrier::new(2));
let server_barrier = barrier.clone();
// Server thread: full plugin stack with real content
let server_handle = thread::spawn(move || {
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
app.add_plugins(settled_reach_server::npc::NpcPlugin);
app.insert_resource(ContentConfig {
content_root: root,
..Default::default()
});
app.add_plugins(ContentPlugin);
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
// Spawn player with all required observer pipeline components
let profile = MovementProfile::smuggler();
let mut registry = EntityRegistry::new(0);
let player = app
.world_mut()
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
CognitiveDelay::default(),
ListeningFocus::new(TilePosition::new(16, 16, 0)),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
))
.id();
registry.register(player);
app.insert_resource(registry);
// Tick 10 times — any panic here means content has a runtime bug
for _ in 0..10 {
app.update();
}
// Wait for client to finish reading before dropping the TCP socket
server_barrier.wait();
});
// Client: connect with read timeout and receive 10 snapshots
let stream = TcpStream::connect(server_addr).expect("client connect");
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set read timeout");
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
let mut writer = BufWriter::new(stream);
let mut last_snapshot = None;
for tick in 0..10 {
let payload = read_framed(&mut reader)
.unwrap_or_else(|e| panic!("read error at tick {}: {}", tick, e))
.unwrap_or_else(|| panic!("unexpected EOF at tick {}", tick));
let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload)
.unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e));
last_snapshot = Some(snapshot);
// Send empty input for next tick
let empty: Vec<PlayerInput> = vec![];
let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input");
if write_framed(&mut writer, &input_payload).is_err() {
// Server may have shut down after tick 10 — that's fine
break;
}
}
drop(reader);
drop(writer);
// Signal server thread that client is done reading
barrier.wait();
// Server thread must not have panicked
server_handle.join().expect(
"server thread panicked — content triggered a runtime error during tick processing",
);
// Validate final snapshot
let snapshot = last_snapshot.expect("should have received at least one snapshot");
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"snapshot protocol version mismatch"
);
// Content-spawned NPCs should be visible (they all spawn at 0,0,0 by default)
// The player is at 16,16 — content NPCs are far away but the player entity itself
// should always be in the snapshot
assert!(
!snapshot.entities.is_empty(),
"snapshot should contain at least the player entity"
);
}
-523
View File
@@ -1,523 +0,0 @@
//! Content scaling test (#513, D-026).
//!
//! Verifies that adding extra NPCs doesn't degrade tick timing beyond
//! acceptable bounds. Runs the Gauntlet baseline, then adds additional
//! NPCs and compares:
//! 1. Tick timing stays within D-026 budget (100ms)
//! 2. Baseline entities still behave identically (deterministic)
//!
//! Sprint 11 adds two new tests (#513 deliverable):
//! - max_npc_pack_tick_budget: 80 NPCs (D-026 Active tier ceiling), 100 ticks,
//! per-tick budget assertion (every tick < 100ms, not just average).
//! - max_npc_pack_behavioral_regression: verifies that adding 46 extra NPCs to
//! hit the Active tier ceiling doesn't change original entity behavior at tick 100.
//!
//! Run with: cargo test --test content_scaling -- --nocapture
use bevy_app::prelude::*;
use std::collections::BTreeMap;
use std::time::Instant;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::BridgePlugin;
use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId};
use settled_reach_server::knowledge::{
KnowledgeConfidence, KnowledgeGraph, KnowledgePlugin, StableId,
};
use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind};
use settled_reach_server::simulation::interaction::Interactable;
use settled_reach_server::simulation::movement::TilePosition;
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::SimulationPlugin;
/// Number of ticks to run for timing measurements.
const TIMING_TICKS: usize = 50;
/// D-026 budget: 100ms per tick maximum.
const MAX_TICK_MS: f64 = 100.0;
/// Extra NPC counts for scaling tiers.
const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50];
/// D-026 Active tier ceiling: maximum NPCs in full simulation.
const ACTIVE_TIER_NPC_CEILING: usize = 80;
/// Ticks for the full stress test (#513 spec: 100 ticks, 80 NPCs).
const STRESS_TICKS: usize = 100;
/// Known NPC count in the full Gauntlet world (all rooms, Sprint 11 included).
/// Fog Theater: 4, Occlusion Corridor: 4, Inventory Warehouse: 1, Pause Chamber: 1,
/// Dialogue Room: 4, Crowd Plaza: 15, Sprint Gauntlet: 1, Eavesdrop Alcove: 2,
/// Confrontation Stage: 2 = 34 total.
///
/// Manually maintained — update when rooms are added/changed. Future: derive
/// from StableId ranges in constants.rs to avoid manual sync.
const GAUNTLET_NPC_COUNT: usize = 34;
/// Extra NPCs to spawn on top of the Gauntlet baseline to reach Active tier ceiling.
const STRESS_EXTRA_NPCS: usize = ACTIVE_TIER_NPC_CEILING - GAUNTLET_NPC_COUNT;
/// Set up a Gauntlet world and return the app.
fn setup_baseline() -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.add_plugins(NpcPlugin);
#[cfg(feature = "gauntlet")]
settled_reach_server::test_world::setup_gauntlet(&mut app, settled_reach_server::bridge::types::CharacterArchetype::default());
app
}
/// Spawn N extra NPCs spread across the Gauntlet hub area.
/// NPCs are placed in a grid starting at (40, 48) to stay within walkable space.
fn spawn_extra_npcs(app: &mut App, count: usize) {
// Remove registry from world so we can mutate it while also spawning entities.
let mut registry = app
.world_mut()
.remove_resource::<EntityRegistry>()
.expect("EntityRegistry should exist after setup_gauntlet");
let cols = 10;
for i in 0..count {
let x = 40 + (i % cols) as i32;
let y = 48 + (i / cols) as i32;
let pos = TilePosition::new(x, y, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
pos,
Want {
primary: WantKind::Safety,
intensity: 5,
description: format!("extra_npc_{}", i),
},
Contentment { level: 0 },
ToleranceThreshold {
current_stress: 0,
threshold: 50,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
app.insert_resource(registry);
}
/// Tick the app N times and return average milliseconds per tick.
fn measure_tick_timing(app: &mut App, ticks: usize) -> f64 {
// Warm-up tick (first tick has startup overhead)
app.update();
let start = Instant::now();
for _ in 0..ticks {
app.update();
}
let elapsed = start.elapsed();
elapsed.as_secs_f64() * 1000.0 / ticks as f64
}
/// Collect snapshot entity IDs from the VisibilityGeometry and entity count.
fn count_entities(app: &App) -> usize {
let registry = app.world().resource::<EntityRegistry>();
registry.len() as usize
}
/// Collect the player's KnowledgeGraph confidence levels for all Gauntlet entities
/// (StableIds 0..=max_id). Used to detect KG-level behavioral regression.
#[cfg(feature = "gauntlet")]
fn player_kg_snapshot(app: &App, max_id: u64) -> BTreeMap<u64, KnowledgeConfidence> {
let registry = app.world().resource::<EntityRegistry>();
let player_entity = registry
.to_entity(&StableId(0))
.expect("player entity at StableId 0");
match app.world().get::<KnowledgeGraph>(player_entity) {
Some(kg) => kg
.entities
.iter()
.filter(|(id, _)| id.0 <= max_id)
.map(|(id, entry)| (id.0, entry.confidence))
.collect(),
None => BTreeMap::new(),
}
}
/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget.
#[test]
#[cfg(feature = "gauntlet")]
fn baseline_tick_timing_within_budget() {
let mut app = setup_baseline();
let entity_count = count_entities(&app);
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
eprintln!(
"Baseline: {} entities, avg {:.3}ms/tick over {} ticks",
entity_count, avg_ms, TIMING_TICKS
);
assert!(
avg_ms < MAX_TICK_MS,
"Baseline tick timing ({:.3}ms) exceeds D-026 budget ({}ms)",
avg_ms,
MAX_TICK_MS
);
}
/// Scaling test: adding NPCs keeps tick timing within D-026 budget.
/// Tests 0 (baseline), 15, and 50 extra NPCs.
#[test]
#[cfg(feature = "gauntlet")]
fn scaling_tick_timing_within_budget() {
let mut results: Vec<(usize, usize, f64)> = Vec::new();
for &extra_count in EXTRA_NPC_COUNTS {
let mut app = setup_baseline();
if extra_count > 0 {
spawn_extra_npcs(&mut app, extra_count);
}
let total_entities = count_entities(&app);
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
results.push((extra_count, total_entities, avg_ms));
}
eprintln!(
"\n=== Content Scaling Results (D-026: {}ms budget) ===",
MAX_TICK_MS
);
eprintln!("{:<12} {:<10} {:<15}", "Extra NPCs", "Total", "Avg ms/tick");
eprintln!("{:-<37}", "");
for &(extra, total, avg_ms) in &results {
let status = if avg_ms < MAX_TICK_MS { "OK" } else { "OVER" };
eprintln!("{:<12} {:<10} {:<15.3} {}", extra, total, avg_ms, status);
}
// Assert all tiers stay within budget
for &(extra, _total, avg_ms) in &results {
assert!(
avg_ms < MAX_TICK_MS,
"Tick timing with +{} NPCs ({:.3}ms) exceeds D-026 budget ({}ms)",
extra,
avg_ms,
MAX_TICK_MS
);
}
// Assert scaling is reasonable: +50 NPCs shouldn't more than 5x the baseline
if results.len() >= 2 {
let baseline_ms = results[0].2;
let max_extra_ms = results.last().unwrap().2;
let scaling_factor = max_extra_ms / baseline_ms;
eprintln!(
"\nScaling factor (baseline → +{} NPCs): {:.2}x",
results.last().unwrap().0,
scaling_factor
);
assert!(
scaling_factor < 5.0,
"Scaling factor {:.2}x exceeds 5x threshold — possible O(n^2) regression",
scaling_factor
);
}
}
/// Determinism test: baseline entities produce identical snapshots regardless
/// of extra NPCs being present. The original Gauntlet entities (StableId 0
/// through RESET_PLATE_STABLE_IDS.1) should have the same positions and
/// visibility after the same number of ticks.
#[test]
#[cfg(feature = "gauntlet")]
fn extra_npcs_dont_affect_baseline_behavior() {
// Run baseline
let mut baseline_app = setup_baseline();
for _ in 0..10 {
baseline_app.update();
}
let baseline_buffer = baseline_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
// Run with extra NPCs
let mut scaled_app = setup_baseline();
spawn_extra_npcs(&mut scaled_app, 15);
for _ in 0..10 {
scaled_app.update();
}
let scaled_buffer = scaled_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
let baseline_snap = baseline_buffer.expect("baseline should produce a snapshot");
let scaled_snap = scaled_buffer.expect("scaled should produce a snapshot");
// Same tick
assert_eq!(
baseline_snap.tick, scaled_snap.tick,
"tick count should match"
);
// Same game time
assert_eq!(
baseline_snap.game_time.time_of_day, scaled_snap.game_time.time_of_day,
"game time should match"
);
// Player position should be identical
let baseline_player = baseline_snap
.entities
.iter()
.find(|e| e.kind == EntityKind::Player);
let scaled_player = scaled_snap
.entities
.iter()
.find(|e| e.kind == EntityKind::Player);
assert!(baseline_player.is_some(), "baseline should have player");
assert!(scaled_player.is_some(), "scaled should have player");
let bp = baseline_player.unwrap();
let sp = scaled_player.unwrap();
assert_eq!(bp.x, sp.x, "player x should match");
assert_eq!(bp.y, sp.y, "player y should match");
// Original entities (entity_id <= max Gauntlet StableId) visible in baseline
// should still be visible in scaled run. Extra NPCs may add to the visible
// set, but shouldn't remove baseline visibility.
let max_baseline_id = settled_reach_server::test_world::constants::RESET_PLATE_STABLE_IDS.1;
let baseline_original_ids: Vec<u64> = baseline_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_baseline_id)
.map(|e| e.entity_id)
.collect();
let scaled_original_ids: Vec<u64> = scaled_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_baseline_id)
.map(|e| e.entity_id)
.collect();
assert_eq!(
baseline_original_ids, scaled_original_ids,
"Original Gauntlet entities (id <= max_baseline_id) should be identical in both runs"
);
}
// =============================================================================
// Sprint 11 / #513 — Max-NPC Pack Stress Tests
// =============================================================================
/// Stress test: Active tier ceiling (80 NPCs), 100 ticks, per-tick budget check.
///
/// Spawns the full Gauntlet baseline ({GAUNTLET_NPC_COUNT} NPCs) plus
/// {STRESS_EXTRA_NPCS} extra NPCs to reach the D-026 Active tier ceiling (80).
/// Runs {STRESS_TICKS} ticks and asserts that EVERY individual tick (not just
/// the average) completes within the 100ms D-026 budget.
///
/// Outputs a PERF_RESULT JSON line compatible with the perf-baseline tooling
/// (same format as tests/perf/baseline.json) so CI can compare against the
/// stored baseline.
#[test]
#[cfg(feature = "gauntlet")]
fn max_npc_pack_tick_budget() {
let mut app = setup_baseline();
spawn_extra_npcs(&mut app, STRESS_EXTRA_NPCS);
let total_entities = count_entities(&app);
// Warm-up: first tick has bevy startup overhead.
app.update();
// Measure STRESS_TICKS, recording each tick individually.
let mut per_tick_us: Vec<u64> = Vec::with_capacity(STRESS_TICKS);
for _ in 0..STRESS_TICKS {
let start = Instant::now();
app.update();
per_tick_us.push(start.elapsed().as_micros() as u64);
}
// --- Statistics ---
let min_us = *per_tick_us.iter().min().unwrap();
let max_us = *per_tick_us.iter().max().unwrap();
let sum: u64 = per_tick_us.iter().sum();
let mean_us = sum / per_tick_us.len() as u64;
let mut sorted = per_tick_us.clone();
sorted.sort_unstable();
let p95_idx = ((sorted.len() - 1) as f64 * 0.95).floor() as usize;
let p95_us = sorted[p95_idx.min(sorted.len() - 1)];
eprintln!(
"\n=== Max-NPC Pack Stress Test — D-026 tick budget ({} NPCs, {} ticks) ===",
ACTIVE_TIER_NPC_CEILING, STRESS_TICKS
);
eprintln!(
"Entities in world: {} (Gauntlet NPCs: {} extra: {})",
total_entities, GAUNTLET_NPC_COUNT, STRESS_EXTRA_NPCS
);
eprintln!(
"Timing: min={:.3}ms mean={:.3}ms p95={:.3}ms max={:.3}ms budget={}ms",
min_us as f64 / 1000.0,
mean_us as f64 / 1000.0,
p95_us as f64 / 1000.0,
max_us as f64 / 1000.0,
MAX_TICK_MS
);
// Emit PERF_RESULT in the same format as tooling/perf-baseline so output
// can be diffed against tests/perf/baseline.json by CI tooling.
println!(
"PERF_RESULT:{}",
serde_json::json!({
"test": "max_npc_pack_tick_budget",
"spec": "D-026",
"tick_timing": {
"warmup_ticks": 1,
"measured_ticks": STRESS_TICKS,
"min_us": min_us,
"max_us": max_us,
"mean_us": mean_us,
"p95_us": p95_us,
},
"entities": {
"total_in_world": total_entities,
"active_tier_npcs": ACTIVE_TIER_NPC_CEILING,
"gauntlet_npcs": GAUNTLET_NPC_COUNT,
"extra_npcs": STRESS_EXTRA_NPCS,
},
})
);
// Core assertion: EVERY tick must be within the D-026 100ms budget.
// Average-only checks can mask spikes — verify each individual tick.
let budget_us = (MAX_TICK_MS * 1000.0) as u64;
let over_budget: Vec<(usize, u64)> = per_tick_us
.iter()
.enumerate()
.filter(|(_, &us)| us > budget_us)
.map(|(i, &us)| (i, us))
.collect();
assert!(
over_budget.is_empty(),
"D-026 tick budget exceeded with {} NPCs: {} of {} ticks over {}ms\n worst: tick {} at {:.3}ms",
ACTIVE_TIER_NPC_CEILING,
over_budget.len(),
STRESS_TICKS,
MAX_TICK_MS,
over_budget[0].0,
over_budget[0].1 as f64 / 1000.0
);
}
/// Behavioral regression: 80 NPCs must not disturb original entity state at tick 100.
///
/// Runs the pure Gauntlet (GAUNTLET_NPC_COUNT NPCs) and the full 80-NPC stress
/// pack for STRESS_TICKS ticks. Asserts:
/// 1. Snapshot entity IDs for all Gauntlet entities (StableId 0..=65) are identical.
/// 2. Player's KnowledgeGraph confidence entries for Gauntlet entity range are identical.
///
/// This validates D-010 determinism: extra Active-tier NPCs must not affect the
/// simulation of original entities via LOS, KG, or ECS phase ordering.
/// Spec: #513, D-026, D-010.
#[test]
#[cfg(feature = "gauntlet")]
fn max_npc_pack_behavioral_regression() {
use settled_reach_server::test_world::constants::SPRINT11_RESET_PLATE_STABLE_IDS;
// The highest StableId belonging to a Gauntlet entity (Sprint 11 reset plates).
let max_gauntlet_id = SPRINT11_RESET_PLATE_STABLE_IDS.1;
// --- Baseline run: pure Gauntlet, no extra NPCs ---
let mut baseline_app = setup_baseline();
for _ in 0..STRESS_TICKS {
baseline_app.update();
}
let baseline_snapshot = baseline_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
let baseline_kg = player_kg_snapshot(&baseline_app, max_gauntlet_id);
// --- Stress run: Gauntlet + extra NPCs to reach 80 NPC Active tier ceiling ---
let mut stress_app = setup_baseline();
spawn_extra_npcs(&mut stress_app, STRESS_EXTRA_NPCS);
for _ in 0..STRESS_TICKS {
stress_app.update();
}
let stress_snapshot = stress_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
let stress_kg = player_kg_snapshot(&stress_app, max_gauntlet_id);
let baseline_snap = baseline_snapshot.expect("baseline Gauntlet should produce a snapshot");
let stress_snap = stress_snapshot.expect("80-NPC stress run should produce a snapshot");
// Tick index must match (same number of updates).
assert_eq!(
baseline_snap.tick, stress_snap.tick,
"tick count should match between baseline and stress run"
);
// --- 1. Snapshot entity comparison ---
// Collect and sort entity IDs for original Gauntlet entities only.
// Extra NPCs (StableId > max_gauntlet_id) are excluded from comparison.
let mut baseline_ids: Vec<u64> = baseline_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_gauntlet_id)
.map(|e| e.entity_id)
.collect();
let mut stress_ids: Vec<u64> = stress_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_gauntlet_id)
.map(|e| e.entity_id)
.collect();
baseline_ids.sort_unstable();
stress_ids.sort_unstable();
assert_eq!(
baseline_ids, stress_ids,
"Gauntlet entity visibility at tick {} must be identical: baseline {} entities vs {} with {} extra NPCs",
STRESS_TICKS,
baseline_ids.len(),
stress_ids.len(),
STRESS_EXTRA_NPCS
);
// --- 2. Knowledge graph comparison ---
// Player's KG confidence levels for Gauntlet entities (StableId 0..=max_gauntlet_id)
// must be identical in both runs. Extra NPCs in the hub may be added to the
// player's KG (higher StableIds), but must not affect original entity entries.
assert_eq!(
baseline_kg, stress_kg,
"Player KG confidence entries for Gauntlet entities (id <= {}) differ at tick {}\n baseline: {} entries stress: {} entries",
max_gauntlet_id,
STRESS_TICKS,
baseline_kg.len(),
stress_kg.len()
);
eprintln!(
"Behavioral regression PASS: {} Gauntlet entities identical at tick {} ({} NPCs vs {} NPCs)",
baseline_ids.len(),
STRESS_TICKS,
GAUNTLET_NPC_COUNT,
ACTIVE_TIER_NPC_CEILING
);
}
+2 -3
View File
@@ -352,11 +352,10 @@ fn gauntlet_deterministic_replay() {
/// path (select_dialogue_line) which consumes SimRng.
#[test]
fn different_seed_produces_different_replay() {
use settled_reach_server::content::line_pool::{
use settled_reach_server::simulation::line_pool::{
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
TrustTier,
TrustTier, LinePoolIndexResource,
};
use settled_reach_server::content::LinePoolIndexResource;
use settled_reach_server::simulation::dialogue::{
CurrentMood, DialogueCooldownTracker, DialogueProfile, DialogueResponseBuffer,
};
+1 -1
View File
@@ -24,7 +24,7 @@ use settled_reach_server::{
},
};
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::content::template::TemplateReferenceMap;
use settled_reach_server::simulation::triangle::TemplateReferenceMap;
// ---------------------------------------------------------------------------
// Helpers
+3 -19
View File
@@ -8,7 +8,6 @@
use bevy_app::prelude::*;
use std::io::{BufReader, BufWriter};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, Instant};
@@ -16,7 +15,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
use settled_reach_server::content::{ContentConfig, ContentPlugin};
use settled_reach_server::simulation::line_pool::{LinePoolIndex, LinePoolIndexResource};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
@@ -34,10 +33,6 @@ const WARMUP_TICKS: usize = 5;
const MEASURE_TICKS: usize = 50;
const TOTAL_TICKS: usize = WARMUP_TICKS + MEASURE_TICKS;
fn content_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir).join("../content")
}
fn read_rss_kb() -> Option<u64> {
std::fs::read_to_string("/proc/self/status")
@@ -64,17 +59,10 @@ fn read_rss_kb() -> Option<u64> {
#[test]
#[ignore]
fn perf_tick_timing() {
let root = content_root();
if !root.join("content.yaml").exists() {
eprintln!("Skipping: content directory not found at {:?}", root);
return;
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let server_addr = listener.local_addr().expect("get local addr");
// Server thread: full plugin stack with real content, timed ticks
let server_root = root.clone();
// Server thread: full plugin stack, timed ticks
let server_handle = thread::spawn(move || -> Vec<Duration> {
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
@@ -83,11 +71,7 @@ fn perf_tick_timing() {
app.add_plugins(BridgePlugin);
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
app.add_plugins(settled_reach_server::npc::NpcPlugin);
app.insert_resource(ContentConfig {
content_root: server_root,
..Default::default()
});
app.add_plugins(ContentPlugin);
app.insert_resource(LinePoolIndexResource(LinePoolIndex::default()));
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
+3 -3
View File
@@ -128,7 +128,7 @@ fn triangle_activation_event_inserts_routine_deviation_on_anchor_npc() {
// Inject a TriangleActivatedEvent pointing to an NPC, run one tick, assert
// RoutineDeviation is inserted on that NPC by escalate_tells_on_activation.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::{TriangleId};
use settled_reach_server::simulation::triangle::{TriangleId};
let mut app = build_storyteller_app();
// Run one tick so the world is fully initialized before we inject
@@ -163,7 +163,7 @@ fn triangle_activation_produces_routine_deviation_tell_in_snapshot() {
// End-to-end: after activation event, DerivedTellState on anchor NPC must be
// TellCategory::RoutineDeviation. This verifies the full axis-9 pipeline.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::TriangleId;
use settled_reach_server::simulation::triangle::TriangleId;
let mut app = build_storyteller_app();
app.update(); // initialize
@@ -199,7 +199,7 @@ fn routine_deviation_expires_after_duration() {
// Edge case: D-027 criterion 4 must continue to fire DURING the window
// and stop firing AFTER it. NPCs shouldn't be permanently flagged.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::TriangleId;
use settled_reach_server::simulation::triangle::TriangleId;
// NOTE: TELL_ESCALATION_DURATION_TICKS constant (= 300) expected in storyteller module.
// This test will need updating once the constant is public.
-221
View File
@@ -1,221 +0,0 @@
//! End-to-end tests for the template instantiation engine (#161).
//!
//! Verifies the full pipeline:
//! load YAML → validate → spawn NPCs → generate triangles → lifecycle
//!
//! Spec refs:
//! - D-023: three-tier content model
//! - D-024: 10-axis NPC model, minimum 2 triangles per social site
//! - D-025: social site as atomic template unit, single-ownership
//! - D-010: determinism (same seed → same layout)
use std::path::PathBuf;
use bevy_ecs::prelude::*;
use settled_reach_server::{
content::{
instantiation::{
instantiate_template, load_template_from_file, unload_template,
ActiveTemplateInstances,
},
template::{TemplateId, TemplateOwnership, TriangleState},
},
knowledge::{registry::EntityRegistry, StableEntityId},
simulation::rng::SimRng,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn templates_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data/templates")
}
fn make_test_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
// ---------------------------------------------------------------------------
// End-to-end: load logistics-hub YAML, instantiate, assert structure (#161)
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_end_to_end_logistics_hub() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def =
load_template_from_file(&path).expect("logistics-hub.yaml must load and parse");
let mut world = make_test_world();
let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
let mut rng = SimRng::new(42);
let instance = instantiate_template(&mut world, &template_def, template_id, 42, &mut rng)
.expect("logistics-hub must instantiate without validation errors");
// --- NPCs: all 4 role slots filled ---
assert_eq!(
instance.npc_entities.len(),
4,
"logistics-hub has 4 role slots — 4 NPC entities expected"
);
// --- TemplateOwnership on every NPC ---
let expected_roles = [
"logistics-manager",
"dock-worker",
"ring-contact",
"security-guard",
];
let mut seen_roles: Vec<String> = Vec::new();
for &entity in &instance.npc_entities {
let ownership = world
.get::<TemplateOwnership>(entity)
.expect("every spawned NPC must have TemplateOwnership");
assert_eq!(
ownership.template_id, template_id,
"TemplateOwnership.template_id must match the instantiated template"
);
let role = &ownership.role_id.0;
assert!(
expected_roles.contains(&role.as_str()),
"unexpected role '{}' — not in logistics-hub role list",
role,
);
seen_roles.push(role.clone());
}
// Every role slot must appear exactly once.
for role in &expected_roles {
assert_eq!(
seen_roles.iter().filter(|r| r.as_str() == *role).count(),
1,
"role '{}' must appear exactly once",
role,
);
}
// --- 2+ TriangleState entities (D-024 minimum) ---
assert!(
instance.triangle_entities.len() >= 2,
"logistics-hub must produce at least 2 TriangleState entities (D-024), got {}",
instance.triangle_entities.len(),
);
for &entity in &instance.triangle_entities {
assert!(
world.get::<TriangleState>(entity).is_some(),
"triangle entity {:?} must carry a TriangleState component",
entity,
);
}
// --- Instance registered in ActiveTemplateInstances ---
let active = world.resource::<ActiveTemplateInstances>();
assert!(
active.get(template_id).is_some(),
"instantiated template must be tracked in ActiveTemplateInstances",
);
}
// ---------------------------------------------------------------------------
// Lifecycle: unload despawns all entities
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_unload_despawns_entities() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def = load_template_from_file(&path).expect("must parse");
let mut world = make_test_world();
let template_id = TemplateId::from_seed_and_slug(99, "logistics-hub");
let mut rng = SimRng::new(99);
let instance =
instantiate_template(&mut world, &template_def, template_id, 99, &mut rng)
.expect("must instantiate");
let all_entities: Vec<Entity> = instance
.npc_entities
.iter()
.chain(instance.triangle_entities.iter())
.cloned()
.collect();
assert!(!all_entities.is_empty(), "sanity: some entities were spawned");
unload_template(&mut world, template_id);
// All spawned entities must be gone.
for entity in &all_entities {
assert!(
world.get_entity(*entity).is_err(),
"entity {:?} must be despawned after unload_template",
entity,
);
}
// Instance removed from tracking.
let active = world.resource::<ActiveTemplateInstances>();
assert!(
active.get(template_id).is_none(),
"unloaded template must be removed from ActiveTemplateInstances",
);
}
// ---------------------------------------------------------------------------
// Determinism: same seed → same NPC StableId assignment (D-010)
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_is_deterministic() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def = load_template_from_file(&path).expect("must parse");
let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
let mut world1 = make_test_world();
let instance1 =
instantiate_template(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42))
.expect("must instantiate");
let mut world2 = make_test_world();
let instance2 =
instantiate_template(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42))
.expect("must instantiate");
// Collect (role → StableId) pairs from each world and compare.
let role_stable_ids = |world: &World, entities: &[Entity]| {
let mut pairs: Vec<(String, u64)> = entities
.iter()
.map(|&e| {
let role = world.get::<TemplateOwnership>(e).unwrap().role_id.0.clone();
let sid = world.get::<StableEntityId>(e).unwrap().0 .0;
(role, sid)
})
.collect();
pairs.sort();
pairs
};
let pairs1 = role_stable_ids(&world1, &instance1.npc_entities);
let pairs2 = role_stable_ids(&world2, &instance2.npc_entities);
assert_eq!(
pairs1, pairs2,
"instantiate_template must be deterministic (D-010): same seed → same layout"
);
}
// ---------------------------------------------------------------------------
// YAML loading: invalid path returns Err
// ---------------------------------------------------------------------------
#[test]
fn load_template_from_file_nonexistent_path_returns_err() {
let path = templates_dir().join("nonexistent-template-xyzzy.yaml");
let result = load_template_from_file(&path);
assert!(
result.is_err(),
"loading a nonexistent file must return Err"
);
}
-906
View File
@@ -1,906 +0,0 @@
//! Integration tests for the template schema system (tickets #163, #164, #165, #106, #159).
//!
//! Tests YAML round-trips, validation logic, and ECS component interactions
//! against the spec decisions:
//! - D-023: three-tier content model
//! - D-024: 10-axis NPC model, triangles as atomic unit
//! - D-025: social site / single-ownership model
//! - D-028: dialogue tagged pools
//! - D-087: v0.1 triangle configuration
//! - D-089: self-contained triangle forks, no cross-triangle cascade
//! - D-010: determinism (no HashMap, FNV-1a IDs)
use settled_reach_server::content::template::{
validate_role_schemas_no_duplicate_ids, ConflictType, CrossTemplateLinkSpec, FullTemplateDef,
NpcAxis, PrivacyLevel, RelationshipConstraint, RoleId, RoleSchema, SightlineZone, SpaceSpec,
TemplateDialoguePoolRef, TemplateId, TemplateOwnership, TemplateReference,
TemplateReferenceMap, TemplateRoutineEntry, TrafficPattern, TriangleDef, TriangleId,
TrustRange,
};
use settled_reach_server::npc::{PersonalityTrait, RelationshipKind, Skill};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_role_schema(id: &str) -> RoleSchema {
RoleSchema {
role_id: RoleId::new(id),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![],
routine_template: vec![],
}
}
fn make_triangle(roles: [&str; 3], conflict: ConflictType) -> TriangleDef {
let role_arr = [
RoleId::new(roles[0]),
RoleId::new(roles[1]),
RoleId::new(roles[2]),
];
let triangle_id = TriangleId::from_seed_and_roles(42, &role_arr);
TriangleDef {
triangle_id,
roles: role_arr,
conflict_type: conflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
}
}
// ---------------------------------------------------------------------------
// #163: Role definition schema — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn role_schema_minimal_yaml_parse() {
let yaml = r#"
role_id: "guard"
skill_focus:
- Combat
- Observation
"#;
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("minimal schema must parse");
assert_eq!(schema.role_id, RoleId::new("guard"));
assert_eq!(schema.skill_focus.len(), 2);
assert!(schema.required_traits.is_empty());
assert!(schema.relationship_constraints.is_empty());
assert!(schema.routine_template.is_empty());
}
#[test]
fn role_schema_full_yaml_parse() {
let yaml = r#"
role_id: "dock-worker"
required_traits:
- Cautious
- Honest
skill_focus:
- Technical
- Observation
relationship_constraints:
- with_role: "ring-contact"
kind: Colleague
required_trust:
min: -2
max: 2
routine_template:
- phase: "morning"
location: "terminal-cargo-bay"
activity: "freight-handling"
- phase: "evening"
location: "bar-last-shift"
"#;
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("full schema must parse");
assert_eq!(schema.role_id, RoleId::new("dock-worker"));
assert_eq!(schema.required_traits.len(), 2);
assert_eq!(schema.required_traits[0], PersonalityTrait::Cautious);
assert_eq!(schema.skill_focus.len(), 2);
assert_eq!(schema.relationship_constraints.len(), 1);
assert_eq!(
schema.relationship_constraints[0].with_role,
RoleId::new("ring-contact")
);
assert_eq!(schema.relationship_constraints[0].required_trust.min, -2);
assert_eq!(schema.relationship_constraints[0].required_trust.max, 2);
assert_eq!(schema.routine_template.len(), 2);
assert_eq!(schema.routine_template[0].phase, "morning");
assert_eq!(schema.routine_template[0].activity, Some("freight-handling".to_string()));
assert_eq!(schema.routine_template[1].activity, None);
}
#[test]
fn role_schema_yaml_roundtrip_preserves_all_fields() {
let schema = RoleSchema {
role_id: RoleId::new("ring-contact"),
required_traits: vec![PersonalityTrait::Deceptive, PersonalityTrait::Social],
skill_focus: vec![Skill::Stealth, Skill::Persuasion],
relationship_constraints: vec![
RelationshipConstraint {
with_role: RoleId::new("dock-worker"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 4 },
},
RelationshipConstraint {
with_role: RoleId::new("ring-leader"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 1, max: 4 },
},
],
routine_template: vec![
TemplateRoutineEntry {
phase: "morning".into(),
location: "terminal-cargo-bay".into(),
activity: Some("oversight".into()),
},
TemplateRoutineEntry {
phase: "evening".into(),
location: "maintenance-corridor".into(),
activity: None,
},
],
};
let yaml = serde_yaml::to_string(&schema).expect("serialize");
let restored: RoleSchema = serde_yaml::from_str(&yaml).expect("deserialize");
assert_eq!(restored.role_id, schema.role_id);
assert_eq!(restored.required_traits, schema.required_traits);
assert_eq!(restored.skill_focus, schema.skill_focus);
assert_eq!(
restored.relationship_constraints.len(),
schema.relationship_constraints.len()
);
assert_eq!(
restored.relationship_constraints[0].required_trust,
schema.relationship_constraints[0].required_trust
);
assert_eq!(restored.routine_template.len(), schema.routine_template.len());
assert_eq!(
restored.routine_template[0].activity,
schema.routine_template[0].activity
);
}
// ---------------------------------------------------------------------------
// #163: Role definition schema — validation
// ---------------------------------------------------------------------------
#[test]
fn self_referential_constraint_rejected() {
let schema = RoleSchema {
role_id: RoleId::new("dock-worker"),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("dock-worker"), // same as role_id
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 4 },
}],
routine_template: vec![],
};
let result = schema.validate();
assert!(result.is_err(), "self-referential constraint must be rejected");
assert!(
result.unwrap_err().contains("self-referential"),
"error must mention self-referential"
);
}
#[test]
fn invalid_trust_range_rejected() {
let schema = RoleSchema {
role_id: RoleId::new("guard"),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("captain"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 3, max: 1 }, // invalid: min > max
}],
routine_template: vec![],
};
let result = schema.validate();
assert!(result.is_err(), "TrustRange min > max must be rejected");
assert!(
result.unwrap_err().contains("trust min"),
"error must mention trust min"
);
}
#[test]
fn collection_with_duplicate_role_ids_rejected() {
let schemas = vec![
make_role_schema("dock-worker"),
make_role_schema("ring-contact"),
make_role_schema("dock-worker"), // duplicate
];
let result = validate_role_schemas_no_duplicate_ids(&schemas);
assert!(result.is_err(), "duplicate role_ids must be rejected");
let msg = result.unwrap_err();
assert!(msg.contains("dock-worker"), "error must name the duplicate: {}", msg);
}
#[test]
fn collection_with_unique_role_ids_ok() {
let schemas = vec![
make_role_schema("dock-worker"),
make_role_schema("ring-contact"),
make_role_schema("logistics-manager"),
];
assert!(validate_role_schemas_no_duplicate_ids(&schemas).is_ok());
}
// ---------------------------------------------------------------------------
// #164: Spatial requirement specification — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn space_spec_minimal_yaml_parse() {
let yaml = r#"
tile_count_min: 30
tile_count_max: 80
privacy_level: Public
traffic_pattern: Thoroughfare
"#;
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("minimal SpaceSpec must parse");
assert_eq!(spec.tile_count_min, 30);
assert_eq!(spec.tile_count_max, 80);
assert_eq!(spec.privacy_level, PrivacyLevel::Public);
assert_eq!(spec.traffic_pattern, TrafficPattern::Thoroughfare);
assert!(spec.sightline_zones.is_empty());
}
#[test]
fn space_spec_full_yaml_parse() {
let yaml = r#"
tile_count_min: 30
tile_count_max: 80
sightline_zones:
- name: "bar-counter"
radius: 4
- name: "corner-booth"
radius: 2
privacy_level: SemiPrivate
traffic_pattern: Destination
"#;
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("full SpaceSpec must parse");
assert_eq!(spec.sightline_zones.len(), 2);
assert_eq!(spec.sightline_zones[0].name, "bar-counter");
assert_eq!(spec.sightline_zones[0].radius, 4);
assert_eq!(spec.sightline_zones[1].name, "corner-booth");
assert_eq!(spec.sightline_zones[1].radius, 2);
assert_eq!(spec.privacy_level, PrivacyLevel::SemiPrivate);
assert_eq!(spec.traffic_pattern, TrafficPattern::Destination);
}
/// D-025 scale assertion: 15-40 visual tiles = 30-80 sim tiles (D-066).
#[test]
fn space_spec_d025_tile_count_range() {
let spec = SpaceSpec {
tile_count_min: 30,
tile_count_max: 80,
sightline_zones: vec![],
privacy_level: PrivacyLevel::Public,
traffic_pattern: TrafficPattern::Destination,
};
assert!(spec.validate().is_ok(), "D-025 tile range (30-80 sim) must be valid");
}
#[test]
fn space_spec_validation_min_gt_max_fails() {
let spec = SpaceSpec {
tile_count_min: 100,
tile_count_max: 50,
sightline_zones: vec![],
privacy_level: PrivacyLevel::Private,
traffic_pattern: TrafficPattern::Restricted,
};
let result = spec.validate();
assert!(result.is_err(), "min > max must fail validation");
let msg = result.unwrap_err();
assert!(msg.contains("tile_count_min"), "error must mention tile_count_min: {}", msg);
}
#[test]
fn all_privacy_levels_yaml_roundtrip() {
for level in &[PrivacyLevel::Public, PrivacyLevel::SemiPrivate, PrivacyLevel::Private] {
let yaml = serde_yaml::to_string(level).unwrap();
let decoded: PrivacyLevel = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(level, &decoded, "{:?} must survive YAML round-trip", level);
}
}
#[test]
fn all_traffic_patterns_yaml_roundtrip() {
for pattern in &[
TrafficPattern::Thoroughfare,
TrafficPattern::Destination,
TrafficPattern::Restricted,
] {
let yaml = serde_yaml::to_string(pattern).unwrap();
let decoded: TrafficPattern = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(pattern, &decoded, "{:?} must survive YAML round-trip", pattern);
}
}
// ---------------------------------------------------------------------------
// #165: Single-ownership model — TemplateId determinism
// ---------------------------------------------------------------------------
#[test]
fn template_id_fnv1a_stable_across_calls() {
let id = TemplateId::from_seed_and_slug(0, "");
assert_eq!(
id,
TemplateId::from_seed_and_slug(0, ""),
"empty slug + seed 0 must be stable"
);
let id2 = TemplateId::from_seed_and_slug(42, "the-terminal");
assert_eq!(
id2,
TemplateId::from_seed_and_slug(42, "the-terminal"),
"non-empty slug must be stable"
);
}
#[test]
fn template_ownership_component_single_owner_invariant() {
// D-025: NPCs are owned by exactly one template, never reassigned.
let seed = 1u64;
let tid = TemplateId::from_seed_and_slug(seed, "terminal");
let rid = RoleId::new("dock-worker");
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
assert_eq!(ownership.template_id, tid);
assert_eq!(ownership.role_id, rid);
// Clone (as would happen in save-state) must preserve values.
let cloned = ownership.clone();
assert_eq!(cloned.template_id, ownership.template_id);
assert_eq!(cloned.role_id, ownership.role_id);
}
#[test]
fn template_reference_map_preserves_links_on_unload() {
// D-025: reference links must be preserved when a template is unloaded.
let mut map = TemplateReferenceMap::default();
let tid_a = TemplateId::from_seed_and_slug(1, "template-a");
let tid_b = TemplateId::from_seed_and_slug(1, "template-b");
map.add(TemplateReference {
from_template: tid_a,
to_template: tid_b,
via_role: RoleId::new("ring-contact"),
relationship_metadata: RelationshipKind::Colleague,
});
// Simulate "unload template-a" by cloning (the save path).
let preserved = map.clone();
assert_eq!(preserved.outgoing(tid_a).len(), 1);
assert_eq!(preserved.outgoing(tid_a)[0].to_template, tid_b);
}
#[test]
fn template_reference_map_btreemap_deterministic_ordering() {
// D-010: BTreeMap ensures deterministic iteration order.
let mut map = TemplateReferenceMap::default();
let tid_high = TemplateId(u64::MAX - 1);
let tid_low = TemplateId(1);
map.add(TemplateReference {
from_template: tid_high,
to_template: tid_low,
via_role: RoleId::new("role-a"),
relationship_metadata: RelationshipKind::Colleague,
});
map.add(TemplateReference {
from_template: tid_low,
to_template: tid_high,
via_role: RoleId::new("role-b"),
relationship_metadata: RelationshipKind::Colleague,
});
// Collect all references via all_references() (deterministic BTreeMap order).
let all: Vec<&TemplateReference> = map.all_references().collect();
assert_eq!(all.len(), 2);
// First entry's from_template must be the lower ID (BTreeMap key order).
assert!(
all[0].from_template <= all[1].from_template,
"BTreeMap must iterate in ascending key order"
);
}
// ---------------------------------------------------------------------------
// #106: Triangle definition schema — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn triangle_def_yaml_parse_with_computed_id() {
// TriangleId is stored in YAML but computed at world-gen time.
// Authors use 0 as placeholder; runtime overwrites with computed value.
let yaml = r#"
triangle_id: 0
roles:
- "ring-smuggler"
- "dock-worker"
- "operations-manager"
conflict_type: ResourceCompetition
interest_axes:
- Want
- Secret
- Relationships
"#;
let def: TriangleDef = serde_yaml::from_str(yaml).expect("TriangleDef must parse from YAML");
assert_eq!(def.triangle_id, TriangleId(0));
assert_eq!(def.roles[0], RoleId::new("ring-smuggler"));
assert_eq!(def.conflict_type, ConflictType::ResourceCompetition);
assert!(def.relationship_constraints.is_empty());
}
#[test]
fn triangle_def_yaml_parse_with_constraints() {
let yaml = r#"
triangle_id: 0
roles:
- "ring-leader"
- "dock-worker"
- "logistics-manager"
conflict_type: LoyaltyConflict
interest_axes:
- Relationships
- Secret
- Tolerance
relationship_constraints:
- with_role: "dock-worker"
kind: Subordinate
required_trust:
min: -2
max: 2
"#;
let def: TriangleDef =
serde_yaml::from_str(yaml).expect("TriangleDef with constraints must parse");
assert_eq!(def.conflict_type, ConflictType::LoyaltyConflict);
assert_eq!(def.relationship_constraints.len(), 1);
assert_eq!(def.relationship_constraints[0].with_role, RoleId::new("dock-worker"));
assert_eq!(def.relationship_constraints[0].kind, RelationshipKind::Subordinate);
}
#[test]
fn triangle_def_all_conflict_types_yaml_roundtrip() {
let conflict_types = [
ConflictType::ResourceCompetition,
ConflictType::LoyaltyConflict,
ConflictType::SecretExposure,
ConflictType::AuthorityChallenge,
ConflictType::LatentTension,
];
for ct in &conflict_types {
let yaml = serde_yaml::to_string(ct).unwrap();
let decoded: ConflictType = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(ct, &decoded, "{:?} must round-trip", ct);
}
}
#[test]
fn triangle_def_all_npc_axes_yaml_roundtrip() {
let axes = [
NpcAxis::Want,
NpcAxis::Secret,
NpcAxis::Relationships,
NpcAxis::Tolerance,
NpcAxis::Routine,
NpcAxis::InformationInventory,
NpcAxis::Contentment,
NpcAxis::PersonalityTraits,
NpcAxis::TellSystem,
NpcAxis::SkillSet,
];
for axis in &axes {
let yaml = serde_yaml::to_string(axis).unwrap();
let decoded: NpcAxis = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(axis, &decoded, "{:?} must round-trip", axis);
}
}
/// D-087: T1-T5 triangle configuration must be expressible in the schema.
#[test]
fn d087_v01_triangle_configurations_expressible() {
// T1: Kael-Smuggler-Ring (ResourceCompetition, active fork)
let t1 = make_triangle(
["kael-davan", "smuggler", "ring-contact"],
ConflictType::ResourceCompetition,
);
assert!(t1.validate().is_ok(), "T1 must be valid: {:?}", t1.validate());
// T2: Sera-Detective-Commission (SecretExposure, active fork)
let t2 = make_triangle(
["sera-venn", "detective", "commission-inspector"],
ConflictType::SecretExposure,
);
assert!(t2.validate().is_ok(), "T2 must be valid: {:?}", t2.validate());
// T4: Drin-System-Ring (ResourceCompetition, active fork per D-087)
let t4 = make_triangle(
["drin", "ring-system", "dock-supervisor"],
ConflictType::ResourceCompetition,
);
assert!(t4.validate().is_ok(), "T4 must be valid: {:?}", t4.validate());
// T3: passive tension (LatentTension variant per D-087)
let t3 = make_triangle(["naia", "kael-davan", "hael"], ConflictType::LatentTension);
assert!(t3.validate().is_ok(), "T3 passive tension must be valid: {:?}", t3.validate());
// T5: background worried partner (LatentTension variant)
let t5 = make_triangle(
["worried-partner", "ring-member", "neighbor"],
ConflictType::LatentTension,
);
assert!(t5.validate().is_ok(), "T5 passive tension must be valid: {:?}", t5.validate());
}
/// D-089: TriangleDef must not contain cross-triangle cascade state.
#[test]
fn d089_no_cross_triangle_cascade_fields() {
let def = make_triangle(["role-a", "role-b", "role-c"], ConflictType::ResourceCompetition);
let yaml = serde_yaml::to_string(&def).expect("serialize");
assert!(!yaml.contains("cascade"), "no cascade field should appear in serialized TriangleDef");
assert!(!yaml.contains("cross_triangle"), "no cross_triangle field should appear");
assert!(!yaml.contains("triggers"), "no triggers field should appear");
}
// ---------------------------------------------------------------------------
// #165: ECS integration — spawn two templates with cross-references
// ---------------------------------------------------------------------------
#[test]
fn ecs_two_templates_with_cross_references_and_ownerships() {
use bevy_ecs::world::World;
let seed = 999u64;
let tid_terminal = TemplateId::from_seed_and_slug(seed, "terminal-social-site");
let tid_bar = TemplateId::from_seed_and_slug(seed, "last-shift-bar");
let mut world = World::new();
world.init_resource::<TemplateReferenceMap>();
// Spawn 3 NPCs: 2 in terminal, 1 in bar.
let npc_logistics = world
.spawn(TemplateOwnership {
template_id: tid_terminal,
role_id: RoleId::new("logistics-manager"),
})
.id();
let npc_dock = world
.spawn(TemplateOwnership {
template_id: tid_terminal,
role_id: RoleId::new("dock-worker"),
})
.id();
let npc_bar_regular = world
.spawn(TemplateOwnership {
template_id: tid_bar,
role_id: RoleId::new("bar-regular"),
})
.id();
// Add cross-template reference: dock-worker at terminal references bar-regular at bar.
{
let mut ref_map = world.resource_mut::<TemplateReferenceMap>();
ref_map.add(TemplateReference {
from_template: tid_terminal,
to_template: tid_bar,
via_role: RoleId::new("dock-worker"),
relationship_metadata: RelationshipKind::Colleague,
});
}
// Verify all TemplateOwnership components are correct.
let own_logistics = world.get::<TemplateOwnership>(npc_logistics).unwrap();
assert_eq!(
own_logistics.template_id, tid_terminal,
"logistics-manager must be owned by terminal"
);
assert_eq!(own_logistics.role_id, RoleId::new("logistics-manager"));
let own_dock = world.get::<TemplateOwnership>(npc_dock).unwrap();
assert_eq!(
own_dock.template_id, tid_terminal,
"dock-worker must be owned by terminal"
);
assert_eq!(own_dock.role_id, RoleId::new("dock-worker"));
let own_bar = world.get::<TemplateOwnership>(npc_bar_regular).unwrap();
assert_eq!(own_bar.template_id, tid_bar, "bar-regular must be owned by bar");
// Verify TemplateReferenceMap entries.
let ref_map = world.resource::<TemplateReferenceMap>();
let terminal_refs = ref_map.outgoing(tid_terminal);
assert_eq!(terminal_refs.len(), 1, "terminal should have 1 cross-template reference");
assert_eq!(terminal_refs[0].to_template, tid_bar);
assert_eq!(terminal_refs[0].via_role, RoleId::new("dock-worker"));
// Bar template has no outgoing references.
assert!(
ref_map.outgoing(tid_bar).is_empty(),
"bar template has no outgoing references"
);
}
#[test]
fn template_ownership_survives_clone_for_save_state() {
// D-026: TemplateOwnership must be preserved when tier drops to State-saved.
let tid = TemplateId::from_seed_and_slug(42, "terminal");
let rid = RoleId::new("dock-worker");
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
let saved = ownership.clone();
assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)");
}
// ---------------------------------------------------------------------------
// #159: Full Tier 2 template document — FullTemplateDef
// ---------------------------------------------------------------------------
/// Build a minimal valid FullTemplateDef with two roles and two triangles.
fn minimal_full_template() -> FullTemplateDef {
FullTemplateDef {
slug: "test-site".to_string(),
display_name: "Test Social Site".to_string(),
description: None,
roles: vec![
RoleSchema {
role_id: RoleId::new("manager"),
required_traits: vec![PersonalityTrait::Cautious],
skill_focus: vec![Skill::Persuasion],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("worker"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 0, max: 5 },
}],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("worker"),
required_traits: vec![PersonalityTrait::Honest],
skill_focus: vec![Skill::Technical],
relationship_constraints: vec![],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("informant"),
required_traits: vec![PersonalityTrait::Deceptive],
skill_focus: vec![Skill::Stealth],
relationship_constraints: vec![],
routine_template: vec![],
},
],
space: SpaceSpec {
tile_count_min: 30,
tile_count_max: 80,
sightline_zones: vec![SightlineZone {
name: "main-floor".to_string(),
radius: 6,
}],
privacy_level: PrivacyLevel::SemiPrivate,
traffic_pattern: TrafficPattern::Destination,
},
triangles: vec![
TriangleDef {
triangle_id: TriangleId(0),
roles: [
RoleId::new("manager"),
RoleId::new("worker"),
RoleId::new("informant"),
],
conflict_type: ConflictType::ResourceCompetition,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
},
TriangleDef {
triangle_id: TriangleId(0),
roles: [
RoleId::new("manager"),
RoleId::new("informant"),
RoleId::new("worker"),
],
conflict_type: ConflictType::LatentTension,
interest_axes: [NpcAxis::Tolerance, NpcAxis::Contentment, NpcAxis::Routine],
relationship_constraints: vec![],
},
],
dialogue_pools: vec![TemplateDialoguePoolRef {
location: "the-hub".to_string(),
roles: vec!["manager".to_string(), "worker".to_string()],
}],
cross_template_links: vec![CrossTemplateLinkSpec {
from_role: RoleId::new("worker"),
to_template_slug: "other-site".to_string(),
relationship: RelationshipKind::Colleague,
}],
}
}
#[test]
fn full_template_def_yaml_roundtrip() {
let template = minimal_full_template();
let yaml = serde_yaml::to_string(&template).expect("serialize FullTemplateDef");
let restored: FullTemplateDef =
serde_yaml::from_str(&yaml).expect("deserialize FullTemplateDef");
assert_eq!(restored.slug, template.slug);
assert_eq!(restored.display_name, template.display_name);
assert_eq!(restored.roles.len(), template.roles.len());
assert_eq!(restored.space.tile_count_min, template.space.tile_count_min);
assert_eq!(restored.triangles.len(), template.triangles.len());
assert_eq!(restored.dialogue_pools.len(), template.dialogue_pools.len());
assert_eq!(restored.cross_template_links.len(), template.cross_template_links.len());
// Role round-trip: traits, constraints, routine entries
let role = &restored.roles[0];
assert_eq!(role.role_id, RoleId::new("manager"));
assert_eq!(role.required_traits[0], PersonalityTrait::Cautious);
assert_eq!(role.relationship_constraints[0].with_role, RoleId::new("worker"));
// Triangle round-trip: roles, conflict type, axes
let tri = &restored.triangles[0];
assert_eq!(tri.conflict_type, ConflictType::ResourceCompetition);
assert_eq!(tri.roles[0], RoleId::new("manager"));
assert_eq!(tri.interest_axes[1], NpcAxis::Secret);
// Dialogue pool round-trip
assert_eq!(restored.dialogue_pools[0].location, "the-hub");
assert_eq!(restored.dialogue_pools[0].roles.len(), 2);
// Cross-template link round-trip
assert_eq!(
restored.cross_template_links[0].from_role,
RoleId::new("worker")
);
assert_eq!(
restored.cross_template_links[0].to_template_slug,
"other-site"
);
}
#[test]
fn full_template_def_validation_passes_for_valid_template() {
let template = minimal_full_template();
assert!(
template.validate().is_ok(),
"minimal valid template must pass: {:?}",
template.validate()
);
}
#[test]
fn full_template_def_validation_rejects_fewer_than_2_triangles() {
let mut template = minimal_full_template();
template.triangles.truncate(1);
let result = template.validate();
assert!(result.is_err(), "fewer than 2 triangles must fail");
assert!(
result.unwrap_err().contains("fewer than 2 triangles"),
"error must mention triangle count"
);
}
#[test]
fn full_template_def_validation_rejects_undefined_triangle_role() {
let mut template = minimal_full_template();
// Replace a triangle role with one not in the roles list
template.triangles[0].roles[2] = RoleId::new("ghost-role");
let result = template.validate();
assert!(result.is_err(), "undefined triangle role must fail validation");
assert!(
result.unwrap_err().contains("ghost-role"),
"error must name the undefined role"
);
}
#[test]
fn full_template_def_validation_rejects_duplicate_role_ids() {
let mut template = minimal_full_template();
template.roles.push(RoleSchema {
role_id: RoleId::new("manager"), // duplicate
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![],
routine_template: vec![],
});
let result = template.validate();
assert!(result.is_err(), "duplicate role_id must fail validation");
}
#[test]
fn full_template_def_optional_fields_default_on_minimal_yaml() {
// description, dialogue_pools, cross_template_links are all optional.
let yaml = r#"
slug: "bare-minimum"
display_name: "Bare Minimum Site"
roles:
- role_id: "alpha"
- role_id: "beta"
- role_id: "gamma"
space:
tile_count_min: 30
tile_count_max: 80
privacy_level: Public
traffic_pattern: Thoroughfare
triangles:
- triangle_id: 0
roles:
- "alpha"
- "beta"
- "gamma"
conflict_type: LatentTension
interest_axes:
- Contentment
- Tolerance
- Routine
- triangle_id: 0
roles:
- "alpha"
- "gamma"
- "beta"
conflict_type: ResourceCompetition
interest_axes:
- Want
- Secret
- Relationships
"#;
let def: FullTemplateDef = serde_yaml::from_str(yaml).expect("minimal YAML must parse");
assert_eq!(def.slug, "bare-minimum");
assert!(def.description.is_none());
assert!(def.dialogue_pools.is_empty());
assert!(def.cross_template_links.is_empty());
assert!(def.validate().is_ok(), "minimal template must validate: {:?}", def.validate());
}
/// Acceptance test: the authored logistics-hub.yaml round-trips through serde_yaml.
///
/// The file lives at `server/data/templates/logistics-hub.yaml`.
/// This test is the canonical acceptance criterion for ticket #159.
#[test]
fn logistics_hub_yaml_roundtrips_cleanly() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/data/templates/logistics-hub.yaml"
);
let raw = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("could not read logistics-hub.yaml: {}", e));
let def: FullTemplateDef = serde_yaml::from_str(&raw)
.unwrap_or_else(|e| panic!("logistics-hub.yaml failed to deserialize: {}", e));
// Structural assertions
assert_eq!(def.slug, "logistics-hub");
assert_eq!(def.roles.len(), 4, "logistics hub must define 4 roles");
assert_eq!(def.triangles.len(), 2, "logistics hub must define 2 triangles");
assert!(!def.dialogue_pools.is_empty(), "dialogue_pools must be present");
assert!(!def.cross_template_links.is_empty(), "cross_template_links must be present");
// Spatial spec assertions (D-025: 3080 sim tiles)
assert!(def.space.validate().is_ok(), "space spec must validate");
assert_eq!(def.space.tile_count_min, 30);
assert_eq!(def.space.tile_count_max, 80);
// Validation must pass
assert!(
def.validate().is_ok(),
"logistics-hub.yaml must pass full validation: {:?}",
def.validate()
);
// Round-trip: serialize back to YAML then deserialize again
let reserialized = serde_yaml::to_string(&def).expect("re-serialize");
let restored: FullTemplateDef =
serde_yaml::from_str(&reserialized).expect("re-deserialize after round-trip");
assert_eq!(def.slug, restored.slug);
assert_eq!(def.roles.len(), restored.roles.len());
assert_eq!(def.triangles.len(), restored.triangles.len());
assert_eq!(def.dialogue_pools.len(), restored.dialogue_pools.len());
assert_eq!(def.cross_template_links.len(), restored.cross_template_links.len());
}
+10 -10
View File
@@ -13,7 +13,7 @@ use std::collections::BTreeMap;
use bevy_ecs::{schedule::Schedule, world::World};
use settled_reach_server::{
content::template::{
simulation::triangle::{
apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand,
ResolveTriangleQueue, TemplateId, TriangleClassification, TriangleCrisisEventQueue,
TriangleDef, TriangleId, TrianglePhase, TriangleState,
@@ -56,7 +56,7 @@ fn spawn_triangle(
tension: u8,
tension_rate: u8,
phase: TrianglePhase,
role_assignments: BTreeMap<settled_reach_server::content::template::RoleId, StableId>,
role_assignments: BTreeMap<settled_reach_server::simulation::triangle::RoleId, StableId>,
) -> bevy_ecs::entity::Entity {
world
.spawn((
@@ -135,7 +135,7 @@ fn simmering_transitions_to_active_at_expected_minute() {
let npc_b = spawn_npc_with_threshold(&mut world, 2, 25); // lowest
let npc_c = spawn_npc_with_threshold(&mut world, 3, 60);
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("role-a"), npc_a);
assignments.insert(RoleId::new("role-b"), npc_b);
@@ -185,7 +185,7 @@ fn d087_seed_dependent_escalation_timing() {
let npc = spawn_npc_with_threshold(&mut world, 1, 30);
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
@@ -222,7 +222,7 @@ fn crisis_event_trigger_npc_is_lowest_threshold() {
let npc_high = spawn_npc_with_threshold(&mut world, 1, 50); // high tolerance
let npc_low = spawn_npc_with_threshold(&mut world, 2, 10); // low tolerance — trigger
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r-high"), npc_high);
assignments.insert(RoleId::new("r-low"), npc_low);
@@ -249,7 +249,7 @@ fn no_crisis_event_below_threshold() {
let mut world = make_escalation_world();
let npc = spawn_npc_with_threshold(&mut world, 1, 100); // high threshold
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
@@ -521,7 +521,7 @@ fn crisis_events_accumulate_until_drained() {
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
@@ -548,7 +548,7 @@ fn crisis_queue_drain_clears_events() {
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
@@ -575,7 +575,7 @@ fn crisis_queue_drain_clears_events() {
/// (D-087) and those defs produce escalatable TriangleState instances.
#[test]
fn d087_all_v01_conflict_types_produce_escalatable_states() {
use settled_reach_server::content::template::{ConflictType, NpcAxis, RoleId};
use settled_reach_server::simulation::triangle::{ConflictType, NpcAxis, RoleId};
let defs = [
("kael-davan", "smuggler", "ring-contact", ConflictType::ResourceCompetition),
@@ -587,7 +587,7 @@ fn d087_all_v01_conflict_types_produce_escalatable_states() {
for (r0, r1, r2, conflict) in &defs {
let roles = [RoleId::new(r0), RoleId::new(r1), RoleId::new(r2)];
let tid = settled_reach_server::content::template::TriangleId::from_seed_and_roles(42, &roles);
let tid = settled_reach_server::simulation::triangle::TriangleId::from_seed_and_roles(42, &roles);
let def = TriangleDef {
triangle_id: tid,
roles: roles.clone(),
+1 -1
View File
@@ -9,7 +9,7 @@
//! `cargo test -p settled-reach-server -- triangle_validation`
use settled_reach_server::{
content::template::{
simulation::triangle::{
generate_cross_template_triangles, generate_intra_template_triangles,
validate_triangle_def, ConflictType, NpcAxis, RelationshipConstraint, RoleId,
TemplateId, TemplateOwnership, TriangleDef, TriangleId, TrianglePhase, TrustRange,