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:
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 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<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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user