diff --git a/server/src/bin/line_preview.rs b/server/src/bin/line_preview.rs deleted file mode 100644 index 9001aa0f9..000000000 --- a/server/src/bin/line_preview.rs +++ /dev/null @@ -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, - - /// NPC role for dialogue mode (e.g., dock-worker, bar-owner) - #[arg(long)] - role: Option, - - // -- Shared -- - - /// Location filter - #[arg(long)] - location: Option, - - // -- Monologue options -- - - /// Trigger filter for monologue (enter_location, observe_npc, etc.) - #[arg(long)] - trigger: Option, - - /// Known facts for prerequisite checking (repeatable: --knows fact_a --knows fact_b) - #[arg(long)] - knows: Vec, - - /// 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, - - // -- 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 for monologue or --role --location 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 = 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 = 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::>() - .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::>() - .join(", "), - line.mood - .iter() - .map(mood_str) - .collect::>() - .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::>() - .join(", "), - access_str(&access), - if l1 { "+" } else { "-" } - ); - println!( - " L2 situation: requires [{}], active [{}] {}", - line.situation - .iter() - .map(situation_str) - .collect::>() - .join(", "), - situations - .iter() - .map(situation_str) - .collect::>() - .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::>() - .join(", "), - line.mood - .iter() - .map(mood_str) - .collect::>() - .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(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", - } -} diff --git a/server/src/bridge/debug.rs b/server/src/bridge/debug.rs index 18b7bee8f..02991e115 100644 --- a/server/src/bridge/debug.rs +++ b/server/src/bridge/debug.rs @@ -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; diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 450a16e87..e15dad2f0 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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, + /// 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, } /// 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 for TriangleCrisisEventWire { - fn from(e: crate::content::template::TriangleCrisisEvent) -> Self { +impl From 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, /// Pending debug response, consumed once by `compute_observer_snapshot` (#580). pub pending_debug_response: Option, + /// Pending settings response, consumed once by `compute_observer_snapshot` (#627). + pub pending_settings_response: Option, } #[cfg(test)] diff --git a/server/src/content/entanglement.rs b/server/src/content/entanglement.rs deleted file mode 100644 index c6b45243a..000000000 --- a/server/src/content/entanglement.rs +++ /dev/null @@ -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 = (0u64..100).collect(); - let configs: Vec = - 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" - ); - } -} diff --git a/server/src/content/hot_reload.rs b/server/src/content/hot_reload.rs deleted file mode 100644 index bff2d2ce9..000000000 --- a/server/src/content/hot_reload.rs +++ /dev/null @@ -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, - 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, 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, - watcher: Option>, - store_res: Option>, - index_res: Option>, -) { - 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); - } -} diff --git a/server/src/content/instantiation.rs b/server/src/content/instantiation.rs deleted file mode 100644 index 88ebc9b5b..000000000 --- a/server/src/content/instantiation.rs +++ /dev/null @@ -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, - /// ECS entities for the generated `TriangleState` components. - pub triangle_entities: Vec, - /// Non-fatal warnings from triangle generation (e.g., fallback assignments). - pub warnings: Vec, -} - -/// 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::()`. -/// -/// **Determinism (D-010):** `BTreeMap` for consistent iteration order. -#[derive(Resource, Default, Debug)] -pub struct ActiveTemplateInstances { - instances: BTreeMap, -} - -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 { - 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 { - // Schema validation before any ECS mutations. - template_def.validate()?; - - // Phases 1–3: 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 = 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::(); - let previous = world - .resource_mut::() - .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::() - .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::() - .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 { - let content = std::fs::read_to_string(path) - .map_err(|e| format!("failed to read {:?}: {}", path, e))?; - serde_yaml::from_str::(&content) - .map_err(|e| format!("failed to parse {:?}: {}", path, e)) -} diff --git a/server/src/content/line_pool.rs b/server/src/content/line_pool.rs deleted file mode 100644 index 4dd5795f8..000000000 --- a/server/src/content/line_pool.rs +++ /dev/null @@ -1,1229 +0,0 @@ -//! 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. Built from the raw ContentStore after YAML deserialization. -//! -//! 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 crate::content::loader::ContentStore; -use crate::content::types; - -// --------------------------------------------------------------------------- -// 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, - pub trust: TrustTier, - pub situation: Vec, - pub topic: Vec, - pub mood: Vec, - pub tags: Vec, - pub knowledge_grant: Option, -} - -/// 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, - pub priority: u8, - pub cooldown: u32, - pub tags: Vec, -} - -// --------------------------------------------------------------------------- -// Pool index types -// --------------------------------------------------------------------------- - -/// Dialogue pool indexed for querying. -#[derive(Debug)] -pub struct IndexedDialoguePool { - pub location: String, - pub role: String, - pub lines: Vec, -} - -/// 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>, -} - -// --------------------------------------------------------------------------- -// Top-level index -// --------------------------------------------------------------------------- - -/// Top-level line pool index — the queryable runtime data structure. -/// -/// Built from ContentStore at startup (and rebuilt on hot-reload). -/// 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 { - /// Build the index from a ContentStore. - /// - /// Parses string tags into typed enums. Lines with invalid required tags - /// are skipped with a warning log. - pub fn build(store: &ContentStore) -> Self { - let mut index = Self::default(); - - for district in store.districts.values() { - for pool in &district.dialogue_pools { - index.index_dialogue_pool(pool); - } - for pool in &district.monologue_pools { - index.index_monologue_pool(pool); - } - } - - index - } - - fn index_dialogue_pool(&mut self, pool: &types::DialoguePool) { - if pool.location.is_empty() { - tracing::warn!( - "Skipping dialogue pool with empty location (role={})", - pool.role, - ); - return; - } - - let key = (pool.location.clone(), pool.role.clone()); - - let indexed_lines: Vec = - pool.lines.iter().filter_map(parse_dialogue_line).collect(); - - let entry = self - .dialogue - .entry(key) - .or_insert_with(|| IndexedDialoguePool { - location: pool.location.clone(), - role: pool.role.clone(), - lines: Vec::new(), - }); - entry.lines.extend(indexed_lines); - } - - fn index_monologue_pool(&mut self, pool: &types::MonologuePool) { - if pool.location.is_empty() { - tracing::warn!( - "Skipping monologue pool with empty location (character={})", - pool.character, - ); - return; - } - - let character = match pool.character.parse::() { - Ok(c) => c, - Err(e) => { - tracing::warn!("Skipping monologue pool: {}", e); - return; - } - }; - - let key = (character, pool.location.clone()); - let entry = self - .monologue - .entry(key) - .or_insert_with(|| IndexedMonologuePool { - character, - location: pool.location.clone(), - by_trigger: BTreeMap::new(), - }); - - for line in &pool.lines { - if let Some(indexed) = parse_monologue_line(line) { - entry - .by_trigger - .entry(indexed.trigger) - .or_default() - .push(indexed); - } - } - } - - /// 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() - } -} - -// --------------------------------------------------------------------------- -// Parsing helpers -// --------------------------------------------------------------------------- - -/// Parse a raw DialogueLine into an indexed line with typed enums. -/// Returns None if any required enum field fails to parse. -fn parse_dialogue_line(line: &types::DialogueLine) -> Option { - let access: Vec = line - .access - .iter() - .filter_map(|s| { - s.parse() - .map_err(|e: ParseEnumError| { - tracing::warn!("Line {}: {}", line.id, e); - }) - .ok() - }) - .collect(); - - if access.is_empty() { - tracing::warn!("Line {}: no valid access tiers, skipping", line.id); - return None; - } - - let trust = match line.trust.parse::() { - Ok(t) => t, - Err(e) => { - tracing::warn!("Line {}: {}, skipping", line.id, e); - return None; - } - }; - - let situation: Vec = line - .situation - .iter() - .filter_map(|s| { - s.parse() - .map_err(|e: ParseEnumError| { - tracing::warn!("Line {}: {}", line.id, e); - }) - .ok() - }) - .collect(); - - if situation.is_empty() { - tracing::warn!("Line {}: no valid situations, skipping", line.id); - return None; - } - - let topic: Vec = line - .topic - .iter() - .filter_map(|s| { - s.parse() - .map_err(|e: ParseEnumError| { - tracing::warn!("Line {}: {}", line.id, e); - }) - .ok() - }) - .collect(); - let mood: Vec = line - .mood - .iter() - .filter_map(|s| { - s.parse() - .map_err(|e: ParseEnumError| { - tracing::warn!("Line {}: {}", line.id, e); - }) - .ok() - }) - .collect(); - - Some(IndexedDialogueLine { - id: line.id.clone(), - text: line.text.clone(), - role: line.role.clone(), - access, - trust, - situation, - topic, - mood, - tags: line.tags.clone(), - knowledge_grant: line.knowledge_grant.clone(), - }) -} - -/// Parse a raw MonologueLine into an indexed line with typed enums. -/// Returns None if the trigger fails to parse. -fn parse_monologue_line(line: &types::MonologueLine) -> Option { - let trigger = match line.trigger.parse::() { - Ok(t) => t, - Err(e) => { - tracing::warn!("Line {}: {}, skipping", line.id, e); - return None; - } - }; - - Some(IndexedMonologueLine { - id: line.id.clone(), - text: line.text.clone(), - trigger, - prerequisites: line.prerequisites.clone(), - priority: line.priority.unwrap_or(5).clamp(0, 10) as u8, - cooldown: line.cooldown.unwrap_or(0).max(0) as u32, - tags: line.tags.clone(), - }) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::content::loader::{ContentStore, DistrictContent}; - - // -- Enum parsing tests -------------------------------------------------- - - #[test] - fn access_tier_parse_all_values() { - assert_eq!("public".parse::().unwrap(), AccessTier::Public); - assert_eq!( - "insider".parse::().unwrap(), - AccessTier::Insider - ); - assert_eq!( - "authority".parse::().unwrap(), - AccessTier::Authority - ); - assert_eq!("peer".parse::().unwrap(), AccessTier::Peer); - assert_eq!( - "hostile".parse::().unwrap(), - AccessTier::Hostile - ); - assert!("invalid".parse::().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", // Sprint 8 amendment (D-035) - "first_meeting", - "repeated_visit", - ]; - for v in values { - assert!( - v.parse::().is_ok(), - "Failed to parse situation: {}", - v - ); - } - assert!("invalid".parse::().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::().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::().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::().is_ok(), - "Failed to parse trigger: {}", - v - ); - } - } - - #[test] - fn character_parse() { - assert_eq!( - "smuggler".parse::().unwrap(), - Character::Smuggler - ); - assert_eq!( - "detective".parse::().unwrap(), - Character::Detective - ); - assert!("other".parse::().is_err()); - } - - // -- Helper: build a test ContentStore ----------------------------------- - - fn make_dialogue_line( - id: &str, - access: &[&str], - trust: &str, - situations: &[&str], - ) -> types::DialogueLine { - types::DialogueLine { - id: id.to_string(), - text: format!("Text for {}", id), - role: "worker".to_string(), - access: access.iter().map(|s| s.to_string()).collect(), - trust: trust.to_string(), - situation: situations.iter().map(|s| s.to_string()).collect(), - topic: vec![], - mood: vec![], - tags: vec![], - knowledge_grant: None, - } - } - - fn make_monologue_line(id: &str, trigger: &str, priority: i32) -> types::MonologueLine { - types::MonologueLine { - id: id.to_string(), - text: format!("Monologue {}", id), - trigger: trigger.to_string(), - prerequisites: None, - priority: Some(priority), - cooldown: None, - tags: vec![], - } - } - - fn test_store() -> ContentStore { - let mut store = ContentStore::default(); - - let dialogue_pools = vec![types::DialoguePool { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - lines: vec![ - make_dialogue_line( - "the-terminal_d_001", - &["public"], - "surface", - &["arrival", "social"], - ), - make_dialogue_line( - "the-terminal_d_002", - &["insider", "peer"], - "real", - &["bar_evening"], - ), - make_dialogue_line( - "the-terminal_d_003", - &["insider"], - "secret", - &["investigation"], - ), - ], - }]; - - let monologue_pools = vec![ - types::MonologuePool { - character: "smuggler".to_string(), - location: "the-terminal".to_string(), - lines: vec![ - make_monologue_line("the-terminal_m_s_001", "enter_location", 5), - make_monologue_line("the-terminal_m_s_002", "observe_npc", 7), - ], - }, - types::MonologuePool { - character: "smuggler".to_string(), - location: "general".to_string(), - lines: vec![make_monologue_line("general_m_s_001", "enter_location", 3)], - }, - ]; - - let district = DistrictContent { - dialogue_pools, - monologue_pools, - ..Default::default() - }; - store - .districts - .insert("test.district".to_string(), district); - - store - } - - // -- Index building tests ------------------------------------------------ - - #[test] - fn build_indexes_dialogue_pools() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - assert_eq!(index.dialogue.len(), 1); - let pool = index - .dialogue - .get(&("the-terminal".to_string(), "dock-worker".to_string())) - .unwrap(); - assert_eq!(pool.lines.len(), 3); - } - - #[test] - fn build_indexes_monologue_pools() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - assert_eq!(index.monologue.len(), 2); - let loc_pool = index - .monologue - .get(&(Character::Smuggler, "the-terminal".to_string())) - .unwrap(); - assert_eq!(loc_pool.by_trigger.len(), 2); - - let general_pool = index - .monologue - .get(&(Character::Smuggler, "general".to_string())) - .unwrap(); - assert_eq!(general_pool.by_trigger.len(), 1); - } - - #[test] - fn line_counts() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - assert_eq!(index.dialogue_line_count(), 3); - assert_eq!(index.monologue_line_count(), 3); - } - - // -- Dialogue query tests (D-028 Layers 1-3) ---------------------------- - - #[test] - fn query_dialogue_layer1_access_filter() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - // Public access should only get the public line - let results = index.query_dialogue( - "the-terminal", - "dock-worker", - AccessTier::Public, - &[Situation::Arrival], - TrustTier::Secret, - ); - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, "the-terminal_d_001"); - } - - #[test] - fn query_dialogue_layer2_situation_filter() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - // Insider + secret trust but only bar_evening situation - let results = index.query_dialogue( - "the-terminal", - "dock-worker", - AccessTier::Insider, - &[Situation::BarEvening], - TrustTier::Secret, - ); - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, "the-terminal_d_002"); - } - - #[test] - fn query_dialogue_layer3_trust_filter() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - // Insider + investigation but only surface trust — should miss line 003 (secret) - let results = index.query_dialogue( - "the-terminal", - "dock-worker", - AccessTier::Insider, - &[Situation::Investigation], - TrustTier::Surface, - ); - assert_eq!(results.len(), 0); - - // With real trust — still no (line 003 requires secret) - let results = index.query_dialogue( - "the-terminal", - "dock-worker", - AccessTier::Insider, - &[Situation::Investigation], - TrustTier::Real, - ); - assert_eq!(results.len(), 0); - - // With secret trust — line 003 passes - let results = index.query_dialogue( - "the-terminal", - "dock-worker", - AccessTier::Insider, - &[Situation::Investigation], - TrustTier::Secret, - ); - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, "the-terminal_d_003"); - } - - #[test] - fn query_dialogue_multiple_situations() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - // Insider with multiple situations should get lines from both - let results = index.query_dialogue( - "the-terminal", - "dock-worker", - AccessTier::Insider, - &[Situation::Arrival, Situation::BarEvening], - TrustTier::Real, - ); - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, "the-terminal_d_002"); - } - - #[test] - fn query_dialogue_nonexistent_pool() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - let results = index.query_dialogue( - "nonexistent", - "worker", - AccessTier::Public, - &[Situation::Arrival], - TrustTier::Surface, - ); - assert!(results.is_empty()); - } - - // -- Monologue query tests ----------------------------------------------- - - #[test] - fn query_monologue_location_specific() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - let results = - index.query_monologue(Character::Smuggler, "the-terminal", Trigger::ObserveNpc); - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, "the-terminal_m_s_002"); - } - - #[test] - fn query_monologue_includes_general_pool() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - // enter_location: 1 from the-terminal + 1 from general - let results = - index.query_monologue(Character::Smuggler, "the-terminal", Trigger::EnterLocation); - assert_eq!(results.len(), 2); - } - - #[test] - fn query_monologue_general_only() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - // Unknown location — only general pool matches - let results = index.query_monologue( - Character::Smuggler, - "unknown-location", - Trigger::EnterLocation, - ); - assert_eq!(results.len(), 1); - assert_eq!(results[0].id, "general_m_s_001"); - } - - #[test] - fn query_monologue_wrong_character() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - // Detective character — no pools exist - let results = - index.query_monologue(Character::Detective, "the-terminal", Trigger::EnterLocation); - assert!(results.is_empty()); - } - - #[test] - fn query_monologue_no_trigger_match() { - let store = test_store(); - let index = LinePoolIndex::build(&store); - - let results = index.query_monologue( - Character::Smuggler, - "the-terminal", - Trigger::DiscoverEvidence, - ); - assert!(results.is_empty()); - } - - #[test] - fn query_monologue_line_with_prerequisite_excluded_when_fact_absent() { - // H10: Verify prerequisite field is populated through query so callers - // can filter. query_monologue returns ALL matching lines (prerequisite - // evaluation is caller's responsibility per D-028), but a line with a - // prerequisite should carry that data through for the caller to check. - let mut store = ContentStore::default(); - let monologue_pools = vec![types::MonologuePool { - character: "smuggler".to_string(), - location: "the-terminal".to_string(), - lines: vec![ - // Line WITHOUT prerequisite — should always be available - types::MonologueLine { - id: "prereq_none".to_string(), - text: "No prereq line".to_string(), - trigger: "enter_location".to_string(), - prerequisites: None, - priority: Some(5), - cooldown: None, - tags: vec![], - }, - // Line WITH prerequisite — caller must check before using - types::MonologueLine { - id: "prereq_fact".to_string(), - text: "Requires cargo_manifest_seen".to_string(), - trigger: "enter_location".to_string(), - prerequisites: Some(types::Prerequisites { - facts: vec![types::FactPrerequisite { - fact_id: "cargo_manifest_seen".to_string(), - min_confidence: "confirmed".to_string(), - }], - entity_attributes: vec![], - relationship: None, - }), - priority: Some(8), - cooldown: None, - tags: vec![], - }, - ], - }]; - let district = DistrictContent { - monologue_pools, - ..Default::default() - }; - store - .districts - .insert("test.district".to_string(), district); - - let index = LinePoolIndex::build(&store); - let results = - index.query_monologue(Character::Smuggler, "the-terminal", Trigger::EnterLocation); - - // Both lines are returned (query doesn't filter prerequisites) - assert_eq!(results.len(), 2); - - // Verify the prerequisite-bearing line carries its prerequisites through - let prereq_line = results.iter().find(|l| l.id == "prereq_fact").unwrap(); - assert!( - prereq_line.prerequisites.is_some(), - "prerequisite field should be populated for caller to evaluate" - ); - let prereqs = prereq_line.prerequisites.as_ref().unwrap(); - assert_eq!(prereqs.facts.len(), 1); - assert_eq!(prereqs.facts[0].fact_id, "cargo_manifest_seen"); - - // The no-prerequisite line should have None - let no_prereq_line = results.iter().find(|l| l.id == "prereq_none").unwrap(); - assert!( - no_prereq_line.prerequisites.is_none(), - "line without prerequisites should have None" - ); - - // Simulate caller-side filtering: if fact is absent, exclude the line - let player_known_facts: Vec<&str> = vec![]; // empty — fact not known - let available: Vec<_> = results - .iter() - .filter(|line| { - match &line.prerequisites { - None => true, // no prerequisites = always available - Some(prereqs) => prereqs - .facts - .iter() - .all(|f| player_known_facts.contains(&f.fact_id.as_str())), - } - }) - .collect(); - assert_eq!( - available.len(), - 1, - "only the no-prerequisite line should pass when fact is absent" - ); - assert_eq!(available[0].id, "prereq_none"); - } - - // -- Parse edge cases ---------------------------------------------------- - - #[test] - fn dialogue_line_with_invalid_access_is_skipped() { - let line = types::DialogueLine { - id: "test_d_001".to_string(), - text: "test".to_string(), - role: "worker".to_string(), - access: vec!["invalid".to_string()], - trust: "surface".to_string(), - situation: vec!["arrival".to_string()], - topic: vec![], - mood: vec![], - tags: vec![], - knowledge_grant: None, - }; - assert!(parse_dialogue_line(&line).is_none()); - } - - #[test] - fn dialogue_line_with_invalid_trust_is_skipped() { - let line = types::DialogueLine { - id: "test_d_001".to_string(), - text: "test".to_string(), - role: "worker".to_string(), - access: vec!["public".to_string()], - trust: "invalid".to_string(), - situation: vec!["arrival".to_string()], - topic: vec![], - mood: vec![], - tags: vec![], - knowledge_grant: None, - }; - assert!(parse_dialogue_line(&line).is_none()); - } - - #[test] - fn monologue_line_defaults() { - let line = types::MonologueLine { - id: "test_m_s_001".to_string(), - text: "test".to_string(), - trigger: "enter_location".to_string(), - prerequisites: None, - priority: None, - cooldown: None, - tags: vec![], - }; - let indexed = parse_monologue_line(&line).unwrap(); - assert_eq!(indexed.priority, 5); // default - assert_eq!(indexed.cooldown, 0); // default - } - - #[test] - fn monologue_priority_clamped() { - let line = types::MonologueLine { - id: "test_m_s_001".to_string(), - text: "test".to_string(), - trigger: "enter_location".to_string(), - prerequisites: None, - priority: Some(15), // over max - cooldown: None, - tags: vec![], - }; - let indexed = parse_monologue_line(&line).unwrap(); - assert_eq!(indexed.priority, 10); // clamped - } -} diff --git a/server/src/content/loader.rs b/server/src/content/loader.rs deleted file mode 100644 index b941166a7..000000000 --- a/server/src/content/loader.rs +++ /dev/null @@ -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, - pub districts: BTreeMap, -} - -/// Content for a single district. -#[derive(Debug, Default)] -pub struct DistrictContent { - pub meta: Option, - pub district_path: PathBuf, - pub pools: Vec, - pub templates: Vec