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;
|
||||
|
||||
@@ -7,7 +7,7 @@ use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::Schedule;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use settled_reach_server::content::template::{
|
||||
use settled_reach_server::simulation::triangle::{
|
||||
RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState,
|
||||
};
|
||||
use settled_reach_server::knowledge::types::StableId;
|
||||
|
||||
@@ -1,617 +0,0 @@
|
||||
//! Integration test: content loading pipeline.
|
||||
//!
|
||||
//! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities.
|
||||
//! Uses real content files from content/ directory for structural content,
|
||||
//! and a test fixture for isolated NPC profile spawning.
|
||||
//!
|
||||
//! Runtime validation (TCP boot + tick) is in content_runtime.rs.
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use settled_reach_server::content::loader::{load_content, ContentStore};
|
||||
use settled_reach_server::content::spawn::spawn_content;
|
||||
use settled_reach_server::content::types::*;
|
||||
use settled_reach_server::content::{ContentConfig, ContentPlugin, ContentStoreResource};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::npc;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
/// Find the content root relative to the test binary location.
|
||||
fn content_root() -> PathBuf {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
PathBuf::from(manifest_dir).join("../content")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: real content discovery and structural loading
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn discover_real_content_structure() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
// Skip if content directory is not present (e.g. CI without content)
|
||||
eprintln!("Skipping: content directory not found at {:?}", root);
|
||||
return;
|
||||
}
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
|
||||
// Manifest should be present
|
||||
assert!(store.manifest.is_some());
|
||||
let manifest = store.manifest.as_ref().unwrap();
|
||||
assert_eq!(manifest.version, "0.1.0");
|
||||
assert!(!manifest.campaigns.is_empty());
|
||||
|
||||
// At least one district should be discovered
|
||||
assert!(
|
||||
!store.districts.is_empty(),
|
||||
"should discover at least one district"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_real_transit_district() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
|
||||
// The transit district should be discovered
|
||||
let transit = store
|
||||
.districts
|
||||
.get("krenn.sova.transit")
|
||||
.expect("transit district should be discovered");
|
||||
|
||||
// District metadata
|
||||
assert!(transit.meta.is_some());
|
||||
let meta = transit.meta.as_ref().unwrap();
|
||||
assert_eq!(meta.display_name, "Sova Transit District");
|
||||
assert_eq!(meta.npc_count, 17);
|
||||
|
||||
// 5 triangles from ticket #391
|
||||
assert_eq!(transit.triangles.len(), 5);
|
||||
let triangle_ids: Vec<&str> = transit
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| t.canonical_id.as_str())
|
||||
.collect();
|
||||
assert!(triangle_ids.contains(&"hub-power"));
|
||||
assert!(triangle_ids.contains(&"worried-knowledge"));
|
||||
assert!(triangle_ids.contains(&"bar-tensions"));
|
||||
assert!(triangle_ids.contains(&"worried-partner"));
|
||||
assert!(triangle_ids.contains(&"informant-question"));
|
||||
|
||||
// Each triangle should have exactly 3 members
|
||||
for triangle in &transit.triangles {
|
||||
assert_eq!(
|
||||
triangle.members.len(),
|
||||
3,
|
||||
"Triangle {} should have 3 members",
|
||||
triangle.canonical_id
|
||||
);
|
||||
}
|
||||
|
||||
// 5 pools from ticket #389
|
||||
assert_eq!(transit.pools.len(), 5);
|
||||
let pool_ids: Vec<&str> = transit.pools.iter().map(|p| p.pool_id.as_str()).collect();
|
||||
assert!(pool_ids.contains(&"transit:friend_smuggler"));
|
||||
assert!(pool_ids.contains(&"transit:friend_detective"));
|
||||
assert!(pool_ids.contains(&"transit:bar_regulars"));
|
||||
assert!(pool_ids.contains(&"transit:compromised_inspector"));
|
||||
assert!(pool_ids.contains(&"transit:primary_contraband"));
|
||||
|
||||
// 3 templates from ticket #390
|
||||
assert_eq!(transit.templates.len(), 3);
|
||||
let template_ids: Vec<&str> = transit
|
||||
.templates
|
||||
.iter()
|
||||
.map(|t| t.template_id.as_str())
|
||||
.collect();
|
||||
assert!(template_ids.contains(&"logistics-hub"));
|
||||
assert!(template_ids.contains(&"bar"));
|
||||
assert!(template_ids.contains(&"smuggling-ring"));
|
||||
|
||||
// 23 NPC profiles: 17 NPCs + 2 PC-as-NPC + 1 extended NPC (nils-davan) + 3 Sprint 14 additions
|
||||
// Populated by #398 (wiki→YAML NPC conversion) and Sprint 14 content expansion
|
||||
assert_eq!(
|
||||
transit.npc_profiles.len(),
|
||||
23,
|
||||
"Expected 23 parseable NPC profiles"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_triangle_fork_structure() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let transit = store.districts.get("krenn.sova.transit").unwrap();
|
||||
|
||||
// Hub power triangle: should have 1 fork with 3 outcomes
|
||||
let hub_power = transit
|
||||
.triangles
|
||||
.iter()
|
||||
.find(|t| t.canonical_id == "hub-power")
|
||||
.expect("hub-power triangle should exist");
|
||||
|
||||
assert_eq!(hub_power.forks.len(), 1);
|
||||
assert_eq!(hub_power.forks[0].id, "volume-escalation");
|
||||
assert_eq!(hub_power.forks[0].outcomes.len(), 3);
|
||||
|
||||
let outcome_ids: Vec<&str> = hub_power.forks[0]
|
||||
.outcomes
|
||||
.iter()
|
||||
.filter_map(|o| o.id.as_deref())
|
||||
.collect();
|
||||
assert!(outcome_ids.contains(&"escalate"));
|
||||
assert!(outcome_ids.contains(&"stabilize"));
|
||||
assert!(outcome_ids.contains(&"mediate"));
|
||||
|
||||
// Resolution states
|
||||
assert_eq!(hub_power.resolution_states.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_pool_candidates() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let transit = store.districts.get("krenn.sova.transit").unwrap();
|
||||
|
||||
// bar_regulars pool should have 5 candidates
|
||||
let bar_regulars = transit
|
||||
.pools
|
||||
.iter()
|
||||
.find(|p| p.pool_id == "transit:bar_regulars")
|
||||
.expect("bar_regulars pool should exist");
|
||||
|
||||
assert_eq!(bar_regulars.candidates.len(), 5);
|
||||
assert_eq!(bar_regulars.category, "npc_group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_template_role_slots() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let transit = store.districts.get("krenn.sova.transit").unwrap();
|
||||
|
||||
// Logistics hub should have 5 role slots
|
||||
let hub = transit
|
||||
.templates
|
||||
.iter()
|
||||
.find(|t| t.template_id == "logistics-hub")
|
||||
.expect("logistics-hub template should exist");
|
||||
|
||||
assert_eq!(hub.role_slots.len(), 5);
|
||||
|
||||
// Should have v01_assignments
|
||||
assert!(hub.v01_assignments.is_some());
|
||||
let assignments = hub.v01_assignments.as_ref().unwrap();
|
||||
assert!(assignments.contains_key("shift-supervisor"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: NPC spawning pipeline with test fixture data
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn spawn_npc_from_content_store() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||
|
||||
// Create a minimal content store with one test NPC
|
||||
let mut store = ContentStore::default();
|
||||
let mut district = settled_reach_server::content::loader::DistrictContent::default();
|
||||
district.npc_profiles.push(NpcProfile {
|
||||
canonical_id: "test-worker".to_string(),
|
||||
display_name: "Test Worker".to_string(),
|
||||
tier: 2,
|
||||
pattern: Some("ANCHOR".to_string()),
|
||||
motivation: Some("CIVILIAN".to_string()),
|
||||
description: Some("A test dock worker".to_string()),
|
||||
want: Some(NpcWant {
|
||||
primary: "Safety".to_string(),
|
||||
intensity: Some(5),
|
||||
description: Some("Wants a quiet life".to_string()),
|
||||
}),
|
||||
secret: None,
|
||||
relationships: vec![],
|
||||
tolerance: Some(NpcTolerance {
|
||||
threshold: Some(70),
|
||||
description: None,
|
||||
}),
|
||||
routine: None,
|
||||
information: None,
|
||||
contentment: Some(NpcContentment {
|
||||
level: Some(30),
|
||||
description: None,
|
||||
}),
|
||||
personality: None,
|
||||
tells: vec![],
|
||||
skills: Some(NpcSkills {
|
||||
combat_trained: Some(false),
|
||||
skills: Some({
|
||||
let mut m = BTreeMap::new();
|
||||
m.insert("technical".to_string(), 5);
|
||||
m
|
||||
}),
|
||||
}),
|
||||
triangle_membership: vec![],
|
||||
trust_levels: None,
|
||||
friend_arc: None,
|
||||
dual_lens: None,
|
||||
});
|
||||
store
|
||||
.districts
|
||||
.insert("test.district".to_string(), district);
|
||||
|
||||
let result = spawn_content(&mut world, &store);
|
||||
|
||||
// Verify entity was spawned
|
||||
assert_eq!(result.npcs_spawned, 1);
|
||||
assert!(result.npc_ids.contains_key("test-worker"));
|
||||
|
||||
// Verify ECS components
|
||||
let stable_id = result.npc_ids["test-worker"];
|
||||
let entity = world
|
||||
.resource::<EntityRegistry>()
|
||||
.to_entity(&stable_id)
|
||||
.unwrap();
|
||||
|
||||
assert!(world.get::<npc::Npc>(entity).is_some());
|
||||
|
||||
let want = world.get::<npc::Want>(entity).unwrap();
|
||||
assert_eq!(want.primary, npc::WantKind::Safety);
|
||||
assert_eq!(want.intensity, 5);
|
||||
|
||||
let tolerance = world.get::<npc::ToleranceThreshold>(entity).unwrap();
|
||||
assert_eq!(tolerance.threshold, 70);
|
||||
|
||||
let skills = world.get::<npc::SkillSet>(entity).unwrap();
|
||||
assert_eq!(skills.skills[&npc::Skill::Technical], 5);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: ContentPlugin integration with bevy App
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn content_plugin_loads_via_app() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
app.insert_resource(ContentConfig {
|
||||
content_root: root,
|
||||
..Default::default()
|
||||
});
|
||||
app.add_plugins(ContentPlugin);
|
||||
|
||||
// Run startup systems
|
||||
app.update();
|
||||
|
||||
// ContentStoreResource should be inserted
|
||||
assert!(
|
||||
app.world().contains_resource::<ContentStoreResource>(),
|
||||
"ContentStoreResource should be present after startup"
|
||||
);
|
||||
|
||||
let store = &app.world().resource::<ContentStoreResource>().0;
|
||||
assert!(!store.districts.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Full spawn pipeline with real content — 10-axis gap closure
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn spawn_real_content_with_relationships_and_secrets() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let result = spawn_content(&mut world, &store);
|
||||
|
||||
// All 23 profiles should spawn
|
||||
assert_eq!(result.npcs_spawned, 23);
|
||||
assert!(result.npc_ids.contains_key("npc:kael-davan"));
|
||||
assert!(result.npc_ids.contains_key("npc:voss"));
|
||||
assert!(result.npc_ids.contains_key("npc:pc-smuggler"));
|
||||
assert!(result.npc_ids.contains_key("npc:nils-davan"));
|
||||
|
||||
// Verify ALL 20 NPCs have Want components (Option C: exact enum keywords in YAML)
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
let mut npcs_with_want = 0;
|
||||
for (canonical_id, stable_id) in &result.npc_ids {
|
||||
let entity = registry
|
||||
.to_entity(stable_id)
|
||||
.unwrap_or_else(|| panic!("{} should have an entity", canonical_id));
|
||||
assert!(
|
||||
world.get::<npc::Want>(entity).is_some(),
|
||||
"NPC {} should have a Want component",
|
||||
canonical_id
|
||||
);
|
||||
npcs_with_want += 1;
|
||||
}
|
||||
assert_eq!(
|
||||
npcs_with_want, 23,
|
||||
"All 23 NPCs should have Want components"
|
||||
);
|
||||
|
||||
// Spot-check specific Want values
|
||||
let kael_entity = registry
|
||||
.to_entity(&result.npc_ids["npc:kael-davan"])
|
||||
.unwrap();
|
||||
let kael_want = world
|
||||
.get::<npc::Want>(kael_entity)
|
||||
.expect("Kael should have Want");
|
||||
assert_eq!(kael_want.primary, npc::WantKind::Safety);
|
||||
|
||||
// Verify Kael has a Secret component
|
||||
let kael_secret = world
|
||||
.get::<npc::Secret>(kael_entity)
|
||||
.expect("Kael should have Secret");
|
||||
assert!(kael_secret.description.contains("ring"));
|
||||
assert_eq!(kael_secret.severity, npc::SecretSeverity::Major);
|
||||
|
||||
// Verify Kael has Relationships (7 defined in YAML)
|
||||
let kael_rels = world
|
||||
.get::<npc::Relationships>(kael_entity)
|
||||
.expect("Kael should have Relationships");
|
||||
assert!(
|
||||
kael_rels.entries.len() >= 5,
|
||||
"Kael should have at least 5 resolved relationships, got {}",
|
||||
kael_rels.entries.len()
|
||||
);
|
||||
|
||||
// Verify Kael has KnowledgeGraph (background facts from information.knows)
|
||||
let kael_kg = world
|
||||
.get::<settled_reach_server::knowledge::graph::KnowledgeGraph>(kael_entity)
|
||||
.expect("Kael should have KnowledgeGraph");
|
||||
assert!(
|
||||
kael_kg.knows_fact(&settled_reach_server::knowledge::types::FactId(
|
||||
"contraband.ring_exists".to_string()
|
||||
))
|
||||
);
|
||||
|
||||
// Verify global RelationshipGraph was populated
|
||||
let graph = world.resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
||||
assert!(
|
||||
graph.edge_count() >= 20,
|
||||
"Expected at least 20 relationship edges, got {}",
|
||||
graph.edge_count()
|
||||
);
|
||||
|
||||
// Verify Nils (off-stage) also has correct data
|
||||
let nils_entity = world
|
||||
.resource::<EntityRegistry>()
|
||||
.to_entity(&result.npc_ids["npc:nils-davan"])
|
||||
.unwrap();
|
||||
let nils_want = world
|
||||
.get::<npc::Want>(nils_entity)
|
||||
.expect("Nils should have Want");
|
||||
assert_eq!(nils_want.primary, npc::WantKind::Power);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: EntanglementTag assignment from authored content (#176, D-029)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn entanglement_tags_assigned_from_triangle_membership() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let result = spawn_content(&mut world, &store);
|
||||
|
||||
// Acceptance: 17+ NPCs spawn (we have 23 authored profiles)
|
||||
assert!(
|
||||
result.npcs_spawned >= 17,
|
||||
"Expected 17+ NPCs, got {}",
|
||||
result.npcs_spawned
|
||||
);
|
||||
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
|
||||
// Every NPC must have an EntanglementTag
|
||||
let mut intrigue_count = 0u32;
|
||||
let mut flat_count = 0u32;
|
||||
for (canonical_id, stable_id) in &result.npc_ids {
|
||||
let entity = registry
|
||||
.to_entity(stable_id)
|
||||
.unwrap_or_else(|| panic!("{} should have an entity", canonical_id));
|
||||
let tag = world
|
||||
.get::<npc::EntanglementTag>(entity)
|
||||
.unwrap_or_else(|| panic!("{} must have EntanglementTag", canonical_id));
|
||||
match tag {
|
||||
npc::EntanglementTag::Intrigue => intrigue_count += 1,
|
||||
npc::EntanglementTag::Flat => flat_count += 1,
|
||||
npc::EntanglementTag::Mundane => {} // reserved for procedural NPCs
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance: at least one EntanglementTag::Intrigue entity
|
||||
assert!(
|
||||
intrigue_count >= 1,
|
||||
"At least one NPC must be EntanglementTag::Intrigue, got 0"
|
||||
);
|
||||
|
||||
// Stronger assertion: we know 13 authored NPCs have non-empty triangle_membership
|
||||
assert!(
|
||||
intrigue_count >= 10,
|
||||
"Expected 10+ Intrigue NPCs (authored triangle members), got {}",
|
||||
intrigue_count
|
||||
);
|
||||
|
||||
// Some NPCs should be Flat (no triangle membership)
|
||||
assert!(
|
||||
flat_count >= 1,
|
||||
"At least one NPC should be EntanglementTag::Flat, got 0"
|
||||
);
|
||||
|
||||
// Spot-check: Kael (triangle member) must be Intrigue
|
||||
let kael_entity = registry
|
||||
.to_entity(&result.npc_ids["npc:kael-davan"])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
*world.get::<npc::EntanglementTag>(kael_entity).unwrap(),
|
||||
npc::EntanglementTag::Intrigue,
|
||||
"Kael (triangle member) must be Intrigue"
|
||||
);
|
||||
|
||||
// Spot-check: Devra (empty triangle_membership) must be Flat
|
||||
let devra_entity = registry
|
||||
.to_entity(&result.npc_ids["npc:devra"])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
*world.get::<npc::EntanglementTag>(devra_entity).unwrap(),
|
||||
npc::EntanglementTag::Flat,
|
||||
"Devra (no triangle membership) must be Flat"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Authored triangle instantiation (#188, D-087)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn authored_triangles_instantiated_from_content() {
|
||||
use settled_reach_server::content::template::{TriangleClassification, TriangleState};
|
||||
use settled_reach_server::simulation::tier::ActiveSim;
|
||||
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let result = spawn_content(&mut world, &store);
|
||||
|
||||
// Query all TriangleState entities (clone to release world borrow)
|
||||
let triangles: Vec<TriangleState> = {
|
||||
let mut q = world.query::<&TriangleState>();
|
||||
q.iter(&world).cloned().collect()
|
||||
};
|
||||
|
||||
// Acceptance: exactly 5 authored triangles
|
||||
assert_eq!(
|
||||
triangles.len(),
|
||||
5,
|
||||
"Expected 5 authored triangles, got {}",
|
||||
triangles.len()
|
||||
);
|
||||
|
||||
// Count by classification (D-087)
|
||||
let active_count = triangles
|
||||
.iter()
|
||||
.filter(|t| t.classification == TriangleClassification::ActiveFork)
|
||||
.count();
|
||||
let passive_count = triangles
|
||||
.iter()
|
||||
.filter(|t| t.classification == TriangleClassification::PassiveTension)
|
||||
.count();
|
||||
|
||||
assert_eq!(
|
||||
active_count, 3,
|
||||
"Expected 3 ActiveFork triangles, got {}",
|
||||
active_count
|
||||
);
|
||||
assert_eq!(
|
||||
passive_count, 2,
|
||||
"Expected 2 PassiveTension triangles, got {}",
|
||||
passive_count
|
||||
);
|
||||
|
||||
// All 5 must have exactly 3 role assignments (triangle = 3 NPCs)
|
||||
for triangle in &triangles {
|
||||
assert_eq!(
|
||||
triangle.role_assignments.len(),
|
||||
3,
|
||||
"Triangle {:?} should have 3 role assignments, got {}",
|
||||
triangle.triangle_id,
|
||||
triangle.role_assignments.len()
|
||||
);
|
||||
}
|
||||
|
||||
// All role assignments must point to valid NPC entities in the registry
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
for triangle in &triangles {
|
||||
for (role, stable_id) in &triangle.role_assignments {
|
||||
assert!(
|
||||
registry.to_entity(stable_id).is_some(),
|
||||
"Triangle {:?} role '{}' points to StableId {:?} with no entity",
|
||||
triangle.triangle_id,
|
||||
role.0,
|
||||
stable_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// All triangle entities must have ActiveSim marker
|
||||
let mut active_query = world.query::<(&TriangleState, &ActiveSim)>();
|
||||
let active_triangles: Vec<_> = active_query.iter(&world).collect();
|
||||
assert_eq!(
|
||||
active_triangles.len(),
|
||||
5,
|
||||
"All 5 triangles must have ActiveSim marker"
|
||||
);
|
||||
|
||||
// Verify StableIds in role assignments correspond to spawned NPC canonical_ids
|
||||
let all_npc_stable_ids: std::collections::BTreeSet<_> =
|
||||
result.npc_ids.values().copied().collect();
|
||||
for triangle in &triangles {
|
||||
for (role, stable_id) in &triangle.role_assignments {
|
||||
assert!(
|
||||
all_npc_stable_ids.contains(stable_id),
|
||||
"Triangle {:?} role '{}' StableId {:?} not in spawned NPC set",
|
||||
triangle.triangle_id,
|
||||
role.0,
|
||||
stable_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime validation test (boot + tick 10 + snapshot) moved to
|
||||
// server/tests/content_runtime.rs per architectural review.
|
||||
@@ -1,162 +0,0 @@
|
||||
//! Runtime validation: boot full plugin stack with real content, tick 10
|
||||
//! times over TCP, assert valid ObserverSnapshot (#489).
|
||||
//!
|
||||
//! Separated from content_loading.rs (structural loading tests) per
|
||||
//! architectural review — TCP runtime tests have different failure modes
|
||||
//! and timeout characteristics.
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
|
||||
use settled_reach_server::content::{ContentConfig, ContentPlugin};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
|
||||
use settled_reach_server::simulation::listening::ListeningFocus;
|
||||
use settled_reach_server::simulation::monologue::{
|
||||
MonologueBuffer, MonologueState, SprintAnomalyQueue,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
fn content_root() -> PathBuf {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
PathBuf::from(manifest_dir).join("../content")
|
||||
}
|
||||
|
||||
/// Smoke test for production content. Boots the full plugin stack with real
|
||||
/// content over TCP, ticks 10 times, and asserts a valid ObserverSnapshot.
|
||||
/// Catches runtime panics from broken entity references, missing components,
|
||||
/// or content schema issues that pass YAML validation but fail at tick time.
|
||||
#[test]
|
||||
fn content_runtime_boot_tick_10_snapshot() {
|
||||
use std::io::{BufReader, BufWriter};
|
||||
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
eprintln!("Skipping: content directory not found at {:?}", root);
|
||||
return;
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let server_addr = listener.local_addr().expect("get local addr");
|
||||
|
||||
// Barrier keeps the server thread alive until the client has finished
|
||||
// reading all snapshots, preventing a TCP RST race under parallel execution.
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let server_barrier = barrier.clone();
|
||||
|
||||
// Server thread: full plugin stack with real content
|
||||
let server_handle = thread::spawn(move || {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
app.insert_resource(ContentConfig {
|
||||
content_root: root,
|
||||
..Default::default()
|
||||
});
|
||||
app.add_plugins(ContentPlugin);
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
|
||||
// Spawn player with all required observer pipeline components
|
||||
let profile = MovementProfile::smuggler();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
CognitiveDelay::default(),
|
||||
ListeningFocus::new(TilePosition::new(16, 16, 0)),
|
||||
profile,
|
||||
profile.initial_stance(),
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
app.insert_resource(registry);
|
||||
|
||||
// Tick 10 times — any panic here means content has a runtime bug
|
||||
for _ in 0..10 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
// Wait for client to finish reading before dropping the TCP socket
|
||||
server_barrier.wait();
|
||||
});
|
||||
|
||||
// Client: connect with read timeout and receive 10 snapshots
|
||||
let stream = TcpStream::connect(server_addr).expect("client connect");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.expect("set read timeout");
|
||||
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
|
||||
let mut writer = BufWriter::new(stream);
|
||||
|
||||
let mut last_snapshot = None;
|
||||
for tick in 0..10 {
|
||||
let payload = read_framed(&mut reader)
|
||||
.unwrap_or_else(|e| panic!("read error at tick {}: {}", tick, e))
|
||||
.unwrap_or_else(|| panic!("unexpected EOF at tick {}", tick));
|
||||
|
||||
let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload)
|
||||
.unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e));
|
||||
|
||||
last_snapshot = Some(snapshot);
|
||||
|
||||
// Send empty input for next tick
|
||||
let empty: Vec<PlayerInput> = vec![];
|
||||
let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input");
|
||||
if write_framed(&mut writer, &input_payload).is_err() {
|
||||
// Server may have shut down after tick 10 — that's fine
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
drop(reader);
|
||||
drop(writer);
|
||||
|
||||
// Signal server thread that client is done reading
|
||||
barrier.wait();
|
||||
|
||||
// Server thread must not have panicked
|
||||
server_handle.join().expect(
|
||||
"server thread panicked — content triggered a runtime error during tick processing",
|
||||
);
|
||||
|
||||
// Validate final snapshot
|
||||
let snapshot = last_snapshot.expect("should have received at least one snapshot");
|
||||
assert_eq!(
|
||||
snapshot.version, PROTOCOL_VERSION,
|
||||
"snapshot protocol version mismatch"
|
||||
);
|
||||
// Content-spawned NPCs should be visible (they all spawn at 0,0,0 by default)
|
||||
// The player is at 16,16 — content NPCs are far away but the player entity itself
|
||||
// should always be in the snapshot
|
||||
assert!(
|
||||
!snapshot.entities.is_empty(),
|
||||
"snapshot should contain at least the player entity"
|
||||
);
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
//! Content scaling test (#513, D-026).
|
||||
//!
|
||||
//! Verifies that adding extra NPCs doesn't degrade tick timing beyond
|
||||
//! acceptable bounds. Runs the Gauntlet baseline, then adds additional
|
||||
//! NPCs and compares:
|
||||
//! 1. Tick timing stays within D-026 budget (100ms)
|
||||
//! 2. Baseline entities still behave identically (deterministic)
|
||||
//!
|
||||
//! Sprint 11 adds two new tests (#513 deliverable):
|
||||
//! - max_npc_pack_tick_budget: 80 NPCs (D-026 Active tier ceiling), 100 ticks,
|
||||
//! per-tick budget assertion (every tick < 100ms, not just average).
|
||||
//! - max_npc_pack_behavioral_regression: verifies that adding 46 extra NPCs to
|
||||
//! hit the Active tier ceiling doesn't change original entity behavior at tick 100.
|
||||
//!
|
||||
//! Run with: cargo test --test content_scaling -- --nocapture
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::BridgePlugin;
|
||||
use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use settled_reach_server::knowledge::{
|
||||
KnowledgeConfidence, KnowledgeGraph, KnowledgePlugin, StableId,
|
||||
};
|
||||
use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind};
|
||||
use settled_reach_server::simulation::interaction::Interactable;
|
||||
use settled_reach_server::simulation::movement::TilePosition;
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
/// Number of ticks to run for timing measurements.
|
||||
const TIMING_TICKS: usize = 50;
|
||||
|
||||
/// D-026 budget: 100ms per tick maximum.
|
||||
const MAX_TICK_MS: f64 = 100.0;
|
||||
|
||||
/// Extra NPC counts for scaling tiers.
|
||||
const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50];
|
||||
|
||||
/// D-026 Active tier ceiling: maximum NPCs in full simulation.
|
||||
const ACTIVE_TIER_NPC_CEILING: usize = 80;
|
||||
|
||||
/// Ticks for the full stress test (#513 spec: 100 ticks, 80 NPCs).
|
||||
const STRESS_TICKS: usize = 100;
|
||||
|
||||
/// Known NPC count in the full Gauntlet world (all rooms, Sprint 11 included).
|
||||
/// Fog Theater: 4, Occlusion Corridor: 4, Inventory Warehouse: 1, Pause Chamber: 1,
|
||||
/// Dialogue Room: 4, Crowd Plaza: 15, Sprint Gauntlet: 1, Eavesdrop Alcove: 2,
|
||||
/// Confrontation Stage: 2 = 34 total.
|
||||
///
|
||||
/// Manually maintained — update when rooms are added/changed. Future: derive
|
||||
/// from StableId ranges in constants.rs to avoid manual sync.
|
||||
const GAUNTLET_NPC_COUNT: usize = 34;
|
||||
|
||||
/// Extra NPCs to spawn on top of the Gauntlet baseline to reach Active tier ceiling.
|
||||
const STRESS_EXTRA_NPCS: usize = ACTIVE_TIER_NPC_CEILING - GAUNTLET_NPC_COUNT;
|
||||
|
||||
/// Set up a Gauntlet world and return the app.
|
||||
fn setup_baseline() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(KnowledgePlugin);
|
||||
app.add_plugins(NpcPlugin);
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
settled_reach_server::test_world::setup_gauntlet(&mut app, settled_reach_server::bridge::types::CharacterArchetype::default());
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Spawn N extra NPCs spread across the Gauntlet hub area.
|
||||
/// NPCs are placed in a grid starting at (40, 48) to stay within walkable space.
|
||||
fn spawn_extra_npcs(app: &mut App, count: usize) {
|
||||
// Remove registry from world so we can mutate it while also spawning entities.
|
||||
let mut registry = app
|
||||
.world_mut()
|
||||
.remove_resource::<EntityRegistry>()
|
||||
.expect("EntityRegistry should exist after setup_gauntlet");
|
||||
let cols = 10;
|
||||
|
||||
for i in 0..count {
|
||||
let x = 40 + (i % cols) as i32;
|
||||
let y = 48 + (i / cols) as i32;
|
||||
let pos = TilePosition::new(x, y, 0);
|
||||
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 5,
|
||||
description: format!("extra_npc_{}", i),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 50,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
app.insert_resource(registry);
|
||||
}
|
||||
|
||||
/// Tick the app N times and return average milliseconds per tick.
|
||||
fn measure_tick_timing(app: &mut App, ticks: usize) -> f64 {
|
||||
// Warm-up tick (first tick has startup overhead)
|
||||
app.update();
|
||||
|
||||
let start = Instant::now();
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
elapsed.as_secs_f64() * 1000.0 / ticks as f64
|
||||
}
|
||||
|
||||
/// Collect snapshot entity IDs from the VisibilityGeometry and entity count.
|
||||
fn count_entities(app: &App) -> usize {
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
registry.len() as usize
|
||||
}
|
||||
|
||||
/// Collect the player's KnowledgeGraph confidence levels for all Gauntlet entities
|
||||
/// (StableIds 0..=max_id). Used to detect KG-level behavioral regression.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn player_kg_snapshot(app: &App, max_id: u64) -> BTreeMap<u64, KnowledgeConfidence> {
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
let player_entity = registry
|
||||
.to_entity(&StableId(0))
|
||||
.expect("player entity at StableId 0");
|
||||
match app.world().get::<KnowledgeGraph>(player_entity) {
|
||||
Some(kg) => kg
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|(id, _)| id.0 <= max_id)
|
||||
.map(|(id, entry)| (id.0, entry.confidence))
|
||||
.collect(),
|
||||
None => BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn baseline_tick_timing_within_budget() {
|
||||
let mut app = setup_baseline();
|
||||
let entity_count = count_entities(&app);
|
||||
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
|
||||
|
||||
eprintln!(
|
||||
"Baseline: {} entities, avg {:.3}ms/tick over {} ticks",
|
||||
entity_count, avg_ms, TIMING_TICKS
|
||||
);
|
||||
|
||||
assert!(
|
||||
avg_ms < MAX_TICK_MS,
|
||||
"Baseline tick timing ({:.3}ms) exceeds D-026 budget ({}ms)",
|
||||
avg_ms,
|
||||
MAX_TICK_MS
|
||||
);
|
||||
}
|
||||
|
||||
/// Scaling test: adding NPCs keeps tick timing within D-026 budget.
|
||||
/// Tests 0 (baseline), 15, and 50 extra NPCs.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn scaling_tick_timing_within_budget() {
|
||||
let mut results: Vec<(usize, usize, f64)> = Vec::new();
|
||||
|
||||
for &extra_count in EXTRA_NPC_COUNTS {
|
||||
let mut app = setup_baseline();
|
||||
if extra_count > 0 {
|
||||
spawn_extra_npcs(&mut app, extra_count);
|
||||
}
|
||||
let total_entities = count_entities(&app);
|
||||
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
|
||||
results.push((extra_count, total_entities, avg_ms));
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"\n=== Content Scaling Results (D-026: {}ms budget) ===",
|
||||
MAX_TICK_MS
|
||||
);
|
||||
eprintln!("{:<12} {:<10} {:<15}", "Extra NPCs", "Total", "Avg ms/tick");
|
||||
eprintln!("{:-<37}", "");
|
||||
for &(extra, total, avg_ms) in &results {
|
||||
let status = if avg_ms < MAX_TICK_MS { "OK" } else { "OVER" };
|
||||
eprintln!("{:<12} {:<10} {:<15.3} {}", extra, total, avg_ms, status);
|
||||
}
|
||||
|
||||
// Assert all tiers stay within budget
|
||||
for &(extra, _total, avg_ms) in &results {
|
||||
assert!(
|
||||
avg_ms < MAX_TICK_MS,
|
||||
"Tick timing with +{} NPCs ({:.3}ms) exceeds D-026 budget ({}ms)",
|
||||
extra,
|
||||
avg_ms,
|
||||
MAX_TICK_MS
|
||||
);
|
||||
}
|
||||
|
||||
// Assert scaling is reasonable: +50 NPCs shouldn't more than 5x the baseline
|
||||
if results.len() >= 2 {
|
||||
let baseline_ms = results[0].2;
|
||||
let max_extra_ms = results.last().unwrap().2;
|
||||
let scaling_factor = max_extra_ms / baseline_ms;
|
||||
eprintln!(
|
||||
"\nScaling factor (baseline → +{} NPCs): {:.2}x",
|
||||
results.last().unwrap().0,
|
||||
scaling_factor
|
||||
);
|
||||
assert!(
|
||||
scaling_factor < 5.0,
|
||||
"Scaling factor {:.2}x exceeds 5x threshold — possible O(n^2) regression",
|
||||
scaling_factor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Determinism test: baseline entities produce identical snapshots regardless
|
||||
/// of extra NPCs being present. The original Gauntlet entities (StableId 0
|
||||
/// through RESET_PLATE_STABLE_IDS.1) should have the same positions and
|
||||
/// visibility after the same number of ticks.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn extra_npcs_dont_affect_baseline_behavior() {
|
||||
// Run baseline
|
||||
let mut baseline_app = setup_baseline();
|
||||
for _ in 0..10 {
|
||||
baseline_app.update();
|
||||
}
|
||||
let baseline_buffer = baseline_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
|
||||
// Run with extra NPCs
|
||||
let mut scaled_app = setup_baseline();
|
||||
spawn_extra_npcs(&mut scaled_app, 15);
|
||||
for _ in 0..10 {
|
||||
scaled_app.update();
|
||||
}
|
||||
let scaled_buffer = scaled_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
|
||||
let baseline_snap = baseline_buffer.expect("baseline should produce a snapshot");
|
||||
let scaled_snap = scaled_buffer.expect("scaled should produce a snapshot");
|
||||
|
||||
// Same tick
|
||||
assert_eq!(
|
||||
baseline_snap.tick, scaled_snap.tick,
|
||||
"tick count should match"
|
||||
);
|
||||
|
||||
// Same game time
|
||||
assert_eq!(
|
||||
baseline_snap.game_time.time_of_day, scaled_snap.game_time.time_of_day,
|
||||
"game time should match"
|
||||
);
|
||||
|
||||
// Player position should be identical
|
||||
let baseline_player = baseline_snap
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| e.kind == EntityKind::Player);
|
||||
let scaled_player = scaled_snap
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| e.kind == EntityKind::Player);
|
||||
assert!(baseline_player.is_some(), "baseline should have player");
|
||||
assert!(scaled_player.is_some(), "scaled should have player");
|
||||
|
||||
let bp = baseline_player.unwrap();
|
||||
let sp = scaled_player.unwrap();
|
||||
assert_eq!(bp.x, sp.x, "player x should match");
|
||||
assert_eq!(bp.y, sp.y, "player y should match");
|
||||
|
||||
// Original entities (entity_id <= max Gauntlet StableId) visible in baseline
|
||||
// should still be visible in scaled run. Extra NPCs may add to the visible
|
||||
// set, but shouldn't remove baseline visibility.
|
||||
let max_baseline_id = settled_reach_server::test_world::constants::RESET_PLATE_STABLE_IDS.1;
|
||||
let baseline_original_ids: Vec<u64> = baseline_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_baseline_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
let scaled_original_ids: Vec<u64> = scaled_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_baseline_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
baseline_original_ids, scaled_original_ids,
|
||||
"Original Gauntlet entities (id <= max_baseline_id) should be identical in both runs"
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sprint 11 / #513 — Max-NPC Pack Stress Tests
|
||||
// =============================================================================
|
||||
|
||||
/// Stress test: Active tier ceiling (80 NPCs), 100 ticks, per-tick budget check.
|
||||
///
|
||||
/// Spawns the full Gauntlet baseline ({GAUNTLET_NPC_COUNT} NPCs) plus
|
||||
/// {STRESS_EXTRA_NPCS} extra NPCs to reach the D-026 Active tier ceiling (80).
|
||||
/// Runs {STRESS_TICKS} ticks and asserts that EVERY individual tick (not just
|
||||
/// the average) completes within the 100ms D-026 budget.
|
||||
///
|
||||
/// Outputs a PERF_RESULT JSON line compatible with the perf-baseline tooling
|
||||
/// (same format as tests/perf/baseline.json) so CI can compare against the
|
||||
/// stored baseline.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn max_npc_pack_tick_budget() {
|
||||
let mut app = setup_baseline();
|
||||
spawn_extra_npcs(&mut app, STRESS_EXTRA_NPCS);
|
||||
let total_entities = count_entities(&app);
|
||||
|
||||
// Warm-up: first tick has bevy startup overhead.
|
||||
app.update();
|
||||
|
||||
// Measure STRESS_TICKS, recording each tick individually.
|
||||
let mut per_tick_us: Vec<u64> = Vec::with_capacity(STRESS_TICKS);
|
||||
for _ in 0..STRESS_TICKS {
|
||||
let start = Instant::now();
|
||||
app.update();
|
||||
per_tick_us.push(start.elapsed().as_micros() as u64);
|
||||
}
|
||||
|
||||
// --- Statistics ---
|
||||
let min_us = *per_tick_us.iter().min().unwrap();
|
||||
let max_us = *per_tick_us.iter().max().unwrap();
|
||||
let sum: u64 = per_tick_us.iter().sum();
|
||||
let mean_us = sum / per_tick_us.len() as u64;
|
||||
let mut sorted = per_tick_us.clone();
|
||||
sorted.sort_unstable();
|
||||
let p95_idx = ((sorted.len() - 1) as f64 * 0.95).floor() as usize;
|
||||
let p95_us = sorted[p95_idx.min(sorted.len() - 1)];
|
||||
|
||||
eprintln!(
|
||||
"\n=== Max-NPC Pack Stress Test — D-026 tick budget ({} NPCs, {} ticks) ===",
|
||||
ACTIVE_TIER_NPC_CEILING, STRESS_TICKS
|
||||
);
|
||||
eprintln!(
|
||||
"Entities in world: {} (Gauntlet NPCs: {} extra: {})",
|
||||
total_entities, GAUNTLET_NPC_COUNT, STRESS_EXTRA_NPCS
|
||||
);
|
||||
eprintln!(
|
||||
"Timing: min={:.3}ms mean={:.3}ms p95={:.3}ms max={:.3}ms budget={}ms",
|
||||
min_us as f64 / 1000.0,
|
||||
mean_us as f64 / 1000.0,
|
||||
p95_us as f64 / 1000.0,
|
||||
max_us as f64 / 1000.0,
|
||||
MAX_TICK_MS
|
||||
);
|
||||
|
||||
// Emit PERF_RESULT in the same format as tooling/perf-baseline so output
|
||||
// can be diffed against tests/perf/baseline.json by CI tooling.
|
||||
println!(
|
||||
"PERF_RESULT:{}",
|
||||
serde_json::json!({
|
||||
"test": "max_npc_pack_tick_budget",
|
||||
"spec": "D-026",
|
||||
"tick_timing": {
|
||||
"warmup_ticks": 1,
|
||||
"measured_ticks": STRESS_TICKS,
|
||||
"min_us": min_us,
|
||||
"max_us": max_us,
|
||||
"mean_us": mean_us,
|
||||
"p95_us": p95_us,
|
||||
},
|
||||
"entities": {
|
||||
"total_in_world": total_entities,
|
||||
"active_tier_npcs": ACTIVE_TIER_NPC_CEILING,
|
||||
"gauntlet_npcs": GAUNTLET_NPC_COUNT,
|
||||
"extra_npcs": STRESS_EXTRA_NPCS,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Core assertion: EVERY tick must be within the D-026 100ms budget.
|
||||
// Average-only checks can mask spikes — verify each individual tick.
|
||||
let budget_us = (MAX_TICK_MS * 1000.0) as u64;
|
||||
let over_budget: Vec<(usize, u64)> = per_tick_us
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &us)| us > budget_us)
|
||||
.map(|(i, &us)| (i, us))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
over_budget.is_empty(),
|
||||
"D-026 tick budget exceeded with {} NPCs: {} of {} ticks over {}ms\n worst: tick {} at {:.3}ms",
|
||||
ACTIVE_TIER_NPC_CEILING,
|
||||
over_budget.len(),
|
||||
STRESS_TICKS,
|
||||
MAX_TICK_MS,
|
||||
over_budget[0].0,
|
||||
over_budget[0].1 as f64 / 1000.0
|
||||
);
|
||||
}
|
||||
|
||||
/// Behavioral regression: 80 NPCs must not disturb original entity state at tick 100.
|
||||
///
|
||||
/// Runs the pure Gauntlet (GAUNTLET_NPC_COUNT NPCs) and the full 80-NPC stress
|
||||
/// pack for STRESS_TICKS ticks. Asserts:
|
||||
/// 1. Snapshot entity IDs for all Gauntlet entities (StableId 0..=65) are identical.
|
||||
/// 2. Player's KnowledgeGraph confidence entries for Gauntlet entity range are identical.
|
||||
///
|
||||
/// This validates D-010 determinism: extra Active-tier NPCs must not affect the
|
||||
/// simulation of original entities via LOS, KG, or ECS phase ordering.
|
||||
/// Spec: #513, D-026, D-010.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn max_npc_pack_behavioral_regression() {
|
||||
use settled_reach_server::test_world::constants::SPRINT11_RESET_PLATE_STABLE_IDS;
|
||||
|
||||
// The highest StableId belonging to a Gauntlet entity (Sprint 11 reset plates).
|
||||
let max_gauntlet_id = SPRINT11_RESET_PLATE_STABLE_IDS.1;
|
||||
|
||||
// --- Baseline run: pure Gauntlet, no extra NPCs ---
|
||||
let mut baseline_app = setup_baseline();
|
||||
for _ in 0..STRESS_TICKS {
|
||||
baseline_app.update();
|
||||
}
|
||||
let baseline_snapshot = baseline_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
let baseline_kg = player_kg_snapshot(&baseline_app, max_gauntlet_id);
|
||||
|
||||
// --- Stress run: Gauntlet + extra NPCs to reach 80 NPC Active tier ceiling ---
|
||||
let mut stress_app = setup_baseline();
|
||||
spawn_extra_npcs(&mut stress_app, STRESS_EXTRA_NPCS);
|
||||
for _ in 0..STRESS_TICKS {
|
||||
stress_app.update();
|
||||
}
|
||||
let stress_snapshot = stress_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
let stress_kg = player_kg_snapshot(&stress_app, max_gauntlet_id);
|
||||
|
||||
let baseline_snap = baseline_snapshot.expect("baseline Gauntlet should produce a snapshot");
|
||||
let stress_snap = stress_snapshot.expect("80-NPC stress run should produce a snapshot");
|
||||
|
||||
// Tick index must match (same number of updates).
|
||||
assert_eq!(
|
||||
baseline_snap.tick, stress_snap.tick,
|
||||
"tick count should match between baseline and stress run"
|
||||
);
|
||||
|
||||
// --- 1. Snapshot entity comparison ---
|
||||
// Collect and sort entity IDs for original Gauntlet entities only.
|
||||
// Extra NPCs (StableId > max_gauntlet_id) are excluded from comparison.
|
||||
let mut baseline_ids: Vec<u64> = baseline_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_gauntlet_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
let mut stress_ids: Vec<u64> = stress_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_gauntlet_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
baseline_ids.sort_unstable();
|
||||
stress_ids.sort_unstable();
|
||||
|
||||
assert_eq!(
|
||||
baseline_ids, stress_ids,
|
||||
"Gauntlet entity visibility at tick {} must be identical: baseline {} entities vs {} with {} extra NPCs",
|
||||
STRESS_TICKS,
|
||||
baseline_ids.len(),
|
||||
stress_ids.len(),
|
||||
STRESS_EXTRA_NPCS
|
||||
);
|
||||
|
||||
// --- 2. Knowledge graph comparison ---
|
||||
// Player's KG confidence levels for Gauntlet entities (StableId 0..=max_gauntlet_id)
|
||||
// must be identical in both runs. Extra NPCs in the hub may be added to the
|
||||
// player's KG (higher StableIds), but must not affect original entity entries.
|
||||
assert_eq!(
|
||||
baseline_kg, stress_kg,
|
||||
"Player KG confidence entries for Gauntlet entities (id <= {}) differ at tick {}\n baseline: {} entries stress: {} entries",
|
||||
max_gauntlet_id,
|
||||
STRESS_TICKS,
|
||||
baseline_kg.len(),
|
||||
stress_kg.len()
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"Behavioral regression PASS: {} Gauntlet entities identical at tick {} ({} NPCs vs {} NPCs)",
|
||||
baseline_ids.len(),
|
||||
STRESS_TICKS,
|
||||
GAUNTLET_NPC_COUNT,
|
||||
ACTIVE_TIER_NPC_CEILING
|
||||
);
|
||||
}
|
||||
@@ -352,11 +352,10 @@ fn gauntlet_deterministic_replay() {
|
||||
/// path (select_dialogue_line) which consumes SimRng.
|
||||
#[test]
|
||||
fn different_seed_produces_different_replay() {
|
||||
use settled_reach_server::content::line_pool::{
|
||||
use settled_reach_server::simulation::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
|
||||
TrustTier,
|
||||
TrustTier, LinePoolIndexResource,
|
||||
};
|
||||
use settled_reach_server::content::LinePoolIndexResource;
|
||||
use settled_reach_server::simulation::dialogue::{
|
||||
CurrentMood, DialogueCooldownTracker, DialogueProfile, DialogueResponseBuffer,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ use settled_reach_server::{
|
||||
},
|
||||
};
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::content::template::TemplateReferenceMap;
|
||||
use settled_reach_server::simulation::triangle::TemplateReferenceMap;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
use bevy_app::prelude::*;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -16,7 +15,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
|
||||
use settled_reach_server::content::{ContentConfig, ContentPlugin};
|
||||
use settled_reach_server::simulation::line_pool::{LinePoolIndex, LinePoolIndexResource};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
@@ -34,10 +33,6 @@ const WARMUP_TICKS: usize = 5;
|
||||
const MEASURE_TICKS: usize = 50;
|
||||
const TOTAL_TICKS: usize = WARMUP_TICKS + MEASURE_TICKS;
|
||||
|
||||
fn content_root() -> PathBuf {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
PathBuf::from(manifest_dir).join("../content")
|
||||
}
|
||||
|
||||
fn read_rss_kb() -> Option<u64> {
|
||||
std::fs::read_to_string("/proc/self/status")
|
||||
@@ -64,17 +59,10 @@ fn read_rss_kb() -> Option<u64> {
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn perf_tick_timing() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
eprintln!("Skipping: content directory not found at {:?}", root);
|
||||
return;
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let server_addr = listener.local_addr().expect("get local addr");
|
||||
|
||||
// Server thread: full plugin stack with real content, timed ticks
|
||||
let server_root = root.clone();
|
||||
// Server thread: full plugin stack, timed ticks
|
||||
let server_handle = thread::spawn(move || -> Vec<Duration> {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
|
||||
|
||||
@@ -83,11 +71,7 @@ fn perf_tick_timing() {
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
app.insert_resource(ContentConfig {
|
||||
content_root: server_root,
|
||||
..Default::default()
|
||||
});
|
||||
app.add_plugins(ContentPlugin);
|
||||
app.insert_resource(LinePoolIndexResource(LinePoolIndex::default()));
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ fn triangle_activation_event_inserts_routine_deviation_on_anchor_npc() {
|
||||
// Inject a TriangleActivatedEvent pointing to an NPC, run one tick, assert
|
||||
// RoutineDeviation is inserted on that NPC by escalate_tells_on_activation.
|
||||
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
|
||||
use settled_reach_server::content::template::{TriangleId};
|
||||
use settled_reach_server::simulation::triangle::{TriangleId};
|
||||
|
||||
let mut app = build_storyteller_app();
|
||||
// Run one tick so the world is fully initialized before we inject
|
||||
@@ -163,7 +163,7 @@ fn triangle_activation_produces_routine_deviation_tell_in_snapshot() {
|
||||
// End-to-end: after activation event, DerivedTellState on anchor NPC must be
|
||||
// TellCategory::RoutineDeviation. This verifies the full axis-9 pipeline.
|
||||
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
|
||||
use settled_reach_server::content::template::TriangleId;
|
||||
use settled_reach_server::simulation::triangle::TriangleId;
|
||||
|
||||
let mut app = build_storyteller_app();
|
||||
app.update(); // initialize
|
||||
@@ -199,7 +199,7 @@ fn routine_deviation_expires_after_duration() {
|
||||
// Edge case: D-027 criterion 4 must continue to fire DURING the window
|
||||
// and stop firing AFTER it. NPCs shouldn't be permanently flagged.
|
||||
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
|
||||
use settled_reach_server::content::template::TriangleId;
|
||||
use settled_reach_server::simulation::triangle::TriangleId;
|
||||
// NOTE: TELL_ESCALATION_DURATION_TICKS constant (= 300) expected in storyteller module.
|
||||
// This test will need updating once the constant is public.
|
||||
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
//! End-to-end tests for the template instantiation engine (#161).
|
||||
//!
|
||||
//! Verifies the full pipeline:
|
||||
//! load YAML → validate → spawn NPCs → generate triangles → lifecycle
|
||||
//!
|
||||
//! Spec refs:
|
||||
//! - D-023: three-tier content model
|
||||
//! - D-024: 10-axis NPC model, minimum 2 triangles per social site
|
||||
//! - D-025: social site as atomic template unit, single-ownership
|
||||
//! - D-010: determinism (same seed → same layout)
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use settled_reach_server::{
|
||||
content::{
|
||||
instantiation::{
|
||||
instantiate_template, load_template_from_file, unload_template,
|
||||
ActiveTemplateInstances,
|
||||
},
|
||||
template::{TemplateId, TemplateOwnership, TriangleState},
|
||||
},
|
||||
knowledge::{registry::EntityRegistry, StableEntityId},
|
||||
simulation::rng::SimRng,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn templates_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data/templates")
|
||||
}
|
||||
|
||||
fn make_test_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// End-to-end: load logistics-hub YAML, instantiate, assert structure (#161)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn template_instantiation_end_to_end_logistics_hub() {
|
||||
let path = templates_dir().join("logistics-hub.yaml");
|
||||
let template_def =
|
||||
load_template_from_file(&path).expect("logistics-hub.yaml must load and parse");
|
||||
|
||||
let mut world = make_test_world();
|
||||
let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
|
||||
let mut rng = SimRng::new(42);
|
||||
|
||||
let instance = instantiate_template(&mut world, &template_def, template_id, 42, &mut rng)
|
||||
.expect("logistics-hub must instantiate without validation errors");
|
||||
|
||||
// --- NPCs: all 4 role slots filled ---
|
||||
assert_eq!(
|
||||
instance.npc_entities.len(),
|
||||
4,
|
||||
"logistics-hub has 4 role slots — 4 NPC entities expected"
|
||||
);
|
||||
|
||||
// --- TemplateOwnership on every NPC ---
|
||||
let expected_roles = [
|
||||
"logistics-manager",
|
||||
"dock-worker",
|
||||
"ring-contact",
|
||||
"security-guard",
|
||||
];
|
||||
let mut seen_roles: Vec<String> = Vec::new();
|
||||
for &entity in &instance.npc_entities {
|
||||
let ownership = world
|
||||
.get::<TemplateOwnership>(entity)
|
||||
.expect("every spawned NPC must have TemplateOwnership");
|
||||
assert_eq!(
|
||||
ownership.template_id, template_id,
|
||||
"TemplateOwnership.template_id must match the instantiated template"
|
||||
);
|
||||
let role = &ownership.role_id.0;
|
||||
assert!(
|
||||
expected_roles.contains(&role.as_str()),
|
||||
"unexpected role '{}' — not in logistics-hub role list",
|
||||
role,
|
||||
);
|
||||
seen_roles.push(role.clone());
|
||||
}
|
||||
// Every role slot must appear exactly once.
|
||||
for role in &expected_roles {
|
||||
assert_eq!(
|
||||
seen_roles.iter().filter(|r| r.as_str() == *role).count(),
|
||||
1,
|
||||
"role '{}' must appear exactly once",
|
||||
role,
|
||||
);
|
||||
}
|
||||
|
||||
// --- 2+ TriangleState entities (D-024 minimum) ---
|
||||
assert!(
|
||||
instance.triangle_entities.len() >= 2,
|
||||
"logistics-hub must produce at least 2 TriangleState entities (D-024), got {}",
|
||||
instance.triangle_entities.len(),
|
||||
);
|
||||
for &entity in &instance.triangle_entities {
|
||||
assert!(
|
||||
world.get::<TriangleState>(entity).is_some(),
|
||||
"triangle entity {:?} must carry a TriangleState component",
|
||||
entity,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Instance registered in ActiveTemplateInstances ---
|
||||
let active = world.resource::<ActiveTemplateInstances>();
|
||||
assert!(
|
||||
active.get(template_id).is_some(),
|
||||
"instantiated template must be tracked in ActiveTemplateInstances",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle: unload despawns all entities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn template_instantiation_unload_despawns_entities() {
|
||||
let path = templates_dir().join("logistics-hub.yaml");
|
||||
let template_def = load_template_from_file(&path).expect("must parse");
|
||||
|
||||
let mut world = make_test_world();
|
||||
let template_id = TemplateId::from_seed_and_slug(99, "logistics-hub");
|
||||
let mut rng = SimRng::new(99);
|
||||
|
||||
let instance =
|
||||
instantiate_template(&mut world, &template_def, template_id, 99, &mut rng)
|
||||
.expect("must instantiate");
|
||||
|
||||
let all_entities: Vec<Entity> = instance
|
||||
.npc_entities
|
||||
.iter()
|
||||
.chain(instance.triangle_entities.iter())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
assert!(!all_entities.is_empty(), "sanity: some entities were spawned");
|
||||
|
||||
unload_template(&mut world, template_id);
|
||||
|
||||
// All spawned entities must be gone.
|
||||
for entity in &all_entities {
|
||||
assert!(
|
||||
world.get_entity(*entity).is_err(),
|
||||
"entity {:?} must be despawned after unload_template",
|
||||
entity,
|
||||
);
|
||||
}
|
||||
|
||||
// Instance removed from tracking.
|
||||
let active = world.resource::<ActiveTemplateInstances>();
|
||||
assert!(
|
||||
active.get(template_id).is_none(),
|
||||
"unloaded template must be removed from ActiveTemplateInstances",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Determinism: same seed → same NPC StableId assignment (D-010)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn template_instantiation_is_deterministic() {
|
||||
let path = templates_dir().join("logistics-hub.yaml");
|
||||
let template_def = load_template_from_file(&path).expect("must parse");
|
||||
let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
|
||||
|
||||
let mut world1 = make_test_world();
|
||||
let instance1 =
|
||||
instantiate_template(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42))
|
||||
.expect("must instantiate");
|
||||
|
||||
let mut world2 = make_test_world();
|
||||
let instance2 =
|
||||
instantiate_template(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42))
|
||||
.expect("must instantiate");
|
||||
|
||||
// Collect (role → StableId) pairs from each world and compare.
|
||||
let role_stable_ids = |world: &World, entities: &[Entity]| {
|
||||
let mut pairs: Vec<(String, u64)> = entities
|
||||
.iter()
|
||||
.map(|&e| {
|
||||
let role = world.get::<TemplateOwnership>(e).unwrap().role_id.0.clone();
|
||||
let sid = world.get::<StableEntityId>(e).unwrap().0 .0;
|
||||
(role, sid)
|
||||
})
|
||||
.collect();
|
||||
pairs.sort();
|
||||
pairs
|
||||
};
|
||||
|
||||
let pairs1 = role_stable_ids(&world1, &instance1.npc_entities);
|
||||
let pairs2 = role_stable_ids(&world2, &instance2.npc_entities);
|
||||
|
||||
assert_eq!(
|
||||
pairs1, pairs2,
|
||||
"instantiate_template must be deterministic (D-010): same seed → same layout"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML loading: invalid path returns Err
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn load_template_from_file_nonexistent_path_returns_err() {
|
||||
let path = templates_dir().join("nonexistent-template-xyzzy.yaml");
|
||||
let result = load_template_from_file(&path);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"loading a nonexistent file must return Err"
|
||||
);
|
||||
}
|
||||
@@ -1,906 +0,0 @@
|
||||
//! Integration tests for the template schema system (tickets #163, #164, #165, #106, #159).
|
||||
//!
|
||||
//! Tests YAML round-trips, validation logic, and ECS component interactions
|
||||
//! against the spec decisions:
|
||||
//! - D-023: three-tier content model
|
||||
//! - D-024: 10-axis NPC model, triangles as atomic unit
|
||||
//! - D-025: social site / single-ownership model
|
||||
//! - D-028: dialogue tagged pools
|
||||
//! - D-087: v0.1 triangle configuration
|
||||
//! - D-089: self-contained triangle forks, no cross-triangle cascade
|
||||
//! - D-010: determinism (no HashMap, FNV-1a IDs)
|
||||
|
||||
use settled_reach_server::content::template::{
|
||||
validate_role_schemas_no_duplicate_ids, ConflictType, CrossTemplateLinkSpec, FullTemplateDef,
|
||||
NpcAxis, PrivacyLevel, RelationshipConstraint, RoleId, RoleSchema, SightlineZone, SpaceSpec,
|
||||
TemplateDialoguePoolRef, TemplateId, TemplateOwnership, TemplateReference,
|
||||
TemplateReferenceMap, TemplateRoutineEntry, TrafficPattern, TriangleDef, TriangleId,
|
||||
TrustRange,
|
||||
};
|
||||
use settled_reach_server::npc::{PersonalityTrait, RelationshipKind, Skill};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_role_schema(id: &str) -> RoleSchema {
|
||||
RoleSchema {
|
||||
role_id: RoleId::new(id),
|
||||
required_traits: vec![],
|
||||
skill_focus: vec![],
|
||||
relationship_constraints: vec![],
|
||||
routine_template: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_triangle(roles: [&str; 3], conflict: ConflictType) -> TriangleDef {
|
||||
let role_arr = [
|
||||
RoleId::new(roles[0]),
|
||||
RoleId::new(roles[1]),
|
||||
RoleId::new(roles[2]),
|
||||
];
|
||||
let triangle_id = TriangleId::from_seed_and_roles(42, &role_arr);
|
||||
TriangleDef {
|
||||
triangle_id,
|
||||
roles: role_arr,
|
||||
conflict_type: conflict,
|
||||
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
|
||||
relationship_constraints: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #163: Role definition schema — YAML round-trips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn role_schema_minimal_yaml_parse() {
|
||||
let yaml = r#"
|
||||
role_id: "guard"
|
||||
skill_focus:
|
||||
- Combat
|
||||
- Observation
|
||||
"#;
|
||||
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("minimal schema must parse");
|
||||
assert_eq!(schema.role_id, RoleId::new("guard"));
|
||||
assert_eq!(schema.skill_focus.len(), 2);
|
||||
assert!(schema.required_traits.is_empty());
|
||||
assert!(schema.relationship_constraints.is_empty());
|
||||
assert!(schema.routine_template.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_schema_full_yaml_parse() {
|
||||
let yaml = r#"
|
||||
role_id: "dock-worker"
|
||||
required_traits:
|
||||
- Cautious
|
||||
- Honest
|
||||
skill_focus:
|
||||
- Technical
|
||||
- Observation
|
||||
relationship_constraints:
|
||||
- with_role: "ring-contact"
|
||||
kind: Colleague
|
||||
required_trust:
|
||||
min: -2
|
||||
max: 2
|
||||
routine_template:
|
||||
- phase: "morning"
|
||||
location: "terminal-cargo-bay"
|
||||
activity: "freight-handling"
|
||||
- phase: "evening"
|
||||
location: "bar-last-shift"
|
||||
"#;
|
||||
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("full schema must parse");
|
||||
assert_eq!(schema.role_id, RoleId::new("dock-worker"));
|
||||
assert_eq!(schema.required_traits.len(), 2);
|
||||
assert_eq!(schema.required_traits[0], PersonalityTrait::Cautious);
|
||||
assert_eq!(schema.skill_focus.len(), 2);
|
||||
assert_eq!(schema.relationship_constraints.len(), 1);
|
||||
assert_eq!(
|
||||
schema.relationship_constraints[0].with_role,
|
||||
RoleId::new("ring-contact")
|
||||
);
|
||||
assert_eq!(schema.relationship_constraints[0].required_trust.min, -2);
|
||||
assert_eq!(schema.relationship_constraints[0].required_trust.max, 2);
|
||||
assert_eq!(schema.routine_template.len(), 2);
|
||||
assert_eq!(schema.routine_template[0].phase, "morning");
|
||||
assert_eq!(schema.routine_template[0].activity, Some("freight-handling".to_string()));
|
||||
assert_eq!(schema.routine_template[1].activity, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_schema_yaml_roundtrip_preserves_all_fields() {
|
||||
let schema = RoleSchema {
|
||||
role_id: RoleId::new("ring-contact"),
|
||||
required_traits: vec![PersonalityTrait::Deceptive, PersonalityTrait::Social],
|
||||
skill_focus: vec![Skill::Stealth, Skill::Persuasion],
|
||||
relationship_constraints: vec![
|
||||
RelationshipConstraint {
|
||||
with_role: RoleId::new("dock-worker"),
|
||||
kind: RelationshipKind::Colleague,
|
||||
required_trust: TrustRange { min: 0, max: 4 },
|
||||
},
|
||||
RelationshipConstraint {
|
||||
with_role: RoleId::new("ring-leader"),
|
||||
kind: RelationshipKind::Superior,
|
||||
required_trust: TrustRange { min: 1, max: 4 },
|
||||
},
|
||||
],
|
||||
routine_template: vec![
|
||||
TemplateRoutineEntry {
|
||||
phase: "morning".into(),
|
||||
location: "terminal-cargo-bay".into(),
|
||||
activity: Some("oversight".into()),
|
||||
},
|
||||
TemplateRoutineEntry {
|
||||
phase: "evening".into(),
|
||||
location: "maintenance-corridor".into(),
|
||||
activity: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let yaml = serde_yaml::to_string(&schema).expect("serialize");
|
||||
let restored: RoleSchema = serde_yaml::from_str(&yaml).expect("deserialize");
|
||||
|
||||
assert_eq!(restored.role_id, schema.role_id);
|
||||
assert_eq!(restored.required_traits, schema.required_traits);
|
||||
assert_eq!(restored.skill_focus, schema.skill_focus);
|
||||
assert_eq!(
|
||||
restored.relationship_constraints.len(),
|
||||
schema.relationship_constraints.len()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.relationship_constraints[0].required_trust,
|
||||
schema.relationship_constraints[0].required_trust
|
||||
);
|
||||
assert_eq!(restored.routine_template.len(), schema.routine_template.len());
|
||||
assert_eq!(
|
||||
restored.routine_template[0].activity,
|
||||
schema.routine_template[0].activity
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #163: Role definition schema — validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn self_referential_constraint_rejected() {
|
||||
let schema = RoleSchema {
|
||||
role_id: RoleId::new("dock-worker"),
|
||||
required_traits: vec![],
|
||||
skill_focus: vec![],
|
||||
relationship_constraints: vec![RelationshipConstraint {
|
||||
with_role: RoleId::new("dock-worker"), // same as role_id
|
||||
kind: RelationshipKind::Colleague,
|
||||
required_trust: TrustRange { min: 0, max: 4 },
|
||||
}],
|
||||
routine_template: vec![],
|
||||
};
|
||||
let result = schema.validate();
|
||||
assert!(result.is_err(), "self-referential constraint must be rejected");
|
||||
assert!(
|
||||
result.unwrap_err().contains("self-referential"),
|
||||
"error must mention self-referential"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_trust_range_rejected() {
|
||||
let schema = RoleSchema {
|
||||
role_id: RoleId::new("guard"),
|
||||
required_traits: vec![],
|
||||
skill_focus: vec![],
|
||||
relationship_constraints: vec![RelationshipConstraint {
|
||||
with_role: RoleId::new("captain"),
|
||||
kind: RelationshipKind::Superior,
|
||||
required_trust: TrustRange { min: 3, max: 1 }, // invalid: min > max
|
||||
}],
|
||||
routine_template: vec![],
|
||||
};
|
||||
let result = schema.validate();
|
||||
assert!(result.is_err(), "TrustRange min > max must be rejected");
|
||||
assert!(
|
||||
result.unwrap_err().contains("trust min"),
|
||||
"error must mention trust min"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_with_duplicate_role_ids_rejected() {
|
||||
let schemas = vec![
|
||||
make_role_schema("dock-worker"),
|
||||
make_role_schema("ring-contact"),
|
||||
make_role_schema("dock-worker"), // duplicate
|
||||
];
|
||||
let result = validate_role_schemas_no_duplicate_ids(&schemas);
|
||||
assert!(result.is_err(), "duplicate role_ids must be rejected");
|
||||
let msg = result.unwrap_err();
|
||||
assert!(msg.contains("dock-worker"), "error must name the duplicate: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_with_unique_role_ids_ok() {
|
||||
let schemas = vec![
|
||||
make_role_schema("dock-worker"),
|
||||
make_role_schema("ring-contact"),
|
||||
make_role_schema("logistics-manager"),
|
||||
];
|
||||
assert!(validate_role_schemas_no_duplicate_ids(&schemas).is_ok());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #164: Spatial requirement specification — YAML round-trips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn space_spec_minimal_yaml_parse() {
|
||||
let yaml = r#"
|
||||
tile_count_min: 30
|
||||
tile_count_max: 80
|
||||
privacy_level: Public
|
||||
traffic_pattern: Thoroughfare
|
||||
"#;
|
||||
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("minimal SpaceSpec must parse");
|
||||
assert_eq!(spec.tile_count_min, 30);
|
||||
assert_eq!(spec.tile_count_max, 80);
|
||||
assert_eq!(spec.privacy_level, PrivacyLevel::Public);
|
||||
assert_eq!(spec.traffic_pattern, TrafficPattern::Thoroughfare);
|
||||
assert!(spec.sightline_zones.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_spec_full_yaml_parse() {
|
||||
let yaml = r#"
|
||||
tile_count_min: 30
|
||||
tile_count_max: 80
|
||||
sightline_zones:
|
||||
- name: "bar-counter"
|
||||
radius: 4
|
||||
- name: "corner-booth"
|
||||
radius: 2
|
||||
privacy_level: SemiPrivate
|
||||
traffic_pattern: Destination
|
||||
"#;
|
||||
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("full SpaceSpec must parse");
|
||||
assert_eq!(spec.sightline_zones.len(), 2);
|
||||
assert_eq!(spec.sightline_zones[0].name, "bar-counter");
|
||||
assert_eq!(spec.sightline_zones[0].radius, 4);
|
||||
assert_eq!(spec.sightline_zones[1].name, "corner-booth");
|
||||
assert_eq!(spec.sightline_zones[1].radius, 2);
|
||||
assert_eq!(spec.privacy_level, PrivacyLevel::SemiPrivate);
|
||||
assert_eq!(spec.traffic_pattern, TrafficPattern::Destination);
|
||||
}
|
||||
|
||||
/// D-025 scale assertion: 15-40 visual tiles = 30-80 sim tiles (D-066).
|
||||
#[test]
|
||||
fn space_spec_d025_tile_count_range() {
|
||||
let spec = SpaceSpec {
|
||||
tile_count_min: 30,
|
||||
tile_count_max: 80,
|
||||
sightline_zones: vec![],
|
||||
privacy_level: PrivacyLevel::Public,
|
||||
traffic_pattern: TrafficPattern::Destination,
|
||||
};
|
||||
assert!(spec.validate().is_ok(), "D-025 tile range (30-80 sim) must be valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_spec_validation_min_gt_max_fails() {
|
||||
let spec = SpaceSpec {
|
||||
tile_count_min: 100,
|
||||
tile_count_max: 50,
|
||||
sightline_zones: vec![],
|
||||
privacy_level: PrivacyLevel::Private,
|
||||
traffic_pattern: TrafficPattern::Restricted,
|
||||
};
|
||||
let result = spec.validate();
|
||||
assert!(result.is_err(), "min > max must fail validation");
|
||||
let msg = result.unwrap_err();
|
||||
assert!(msg.contains("tile_count_min"), "error must mention tile_count_min: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_privacy_levels_yaml_roundtrip() {
|
||||
for level in &[PrivacyLevel::Public, PrivacyLevel::SemiPrivate, PrivacyLevel::Private] {
|
||||
let yaml = serde_yaml::to_string(level).unwrap();
|
||||
let decoded: PrivacyLevel = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(level, &decoded, "{:?} must survive YAML round-trip", level);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_traffic_patterns_yaml_roundtrip() {
|
||||
for pattern in &[
|
||||
TrafficPattern::Thoroughfare,
|
||||
TrafficPattern::Destination,
|
||||
TrafficPattern::Restricted,
|
||||
] {
|
||||
let yaml = serde_yaml::to_string(pattern).unwrap();
|
||||
let decoded: TrafficPattern = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(pattern, &decoded, "{:?} must survive YAML round-trip", pattern);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #165: Single-ownership model — TemplateId determinism
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn template_id_fnv1a_stable_across_calls() {
|
||||
let id = TemplateId::from_seed_and_slug(0, "");
|
||||
assert_eq!(
|
||||
id,
|
||||
TemplateId::from_seed_and_slug(0, ""),
|
||||
"empty slug + seed 0 must be stable"
|
||||
);
|
||||
|
||||
let id2 = TemplateId::from_seed_and_slug(42, "the-terminal");
|
||||
assert_eq!(
|
||||
id2,
|
||||
TemplateId::from_seed_and_slug(42, "the-terminal"),
|
||||
"non-empty slug must be stable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_ownership_component_single_owner_invariant() {
|
||||
// D-025: NPCs are owned by exactly one template, never reassigned.
|
||||
let seed = 1u64;
|
||||
let tid = TemplateId::from_seed_and_slug(seed, "terminal");
|
||||
let rid = RoleId::new("dock-worker");
|
||||
|
||||
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
|
||||
assert_eq!(ownership.template_id, tid);
|
||||
assert_eq!(ownership.role_id, rid);
|
||||
|
||||
// Clone (as would happen in save-state) must preserve values.
|
||||
let cloned = ownership.clone();
|
||||
assert_eq!(cloned.template_id, ownership.template_id);
|
||||
assert_eq!(cloned.role_id, ownership.role_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_reference_map_preserves_links_on_unload() {
|
||||
// D-025: reference links must be preserved when a template is unloaded.
|
||||
let mut map = TemplateReferenceMap::default();
|
||||
let tid_a = TemplateId::from_seed_and_slug(1, "template-a");
|
||||
let tid_b = TemplateId::from_seed_and_slug(1, "template-b");
|
||||
|
||||
map.add(TemplateReference {
|
||||
from_template: tid_a,
|
||||
to_template: tid_b,
|
||||
via_role: RoleId::new("ring-contact"),
|
||||
relationship_metadata: RelationshipKind::Colleague,
|
||||
});
|
||||
|
||||
// Simulate "unload template-a" by cloning (the save path).
|
||||
let preserved = map.clone();
|
||||
assert_eq!(preserved.outgoing(tid_a).len(), 1);
|
||||
assert_eq!(preserved.outgoing(tid_a)[0].to_template, tid_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_reference_map_btreemap_deterministic_ordering() {
|
||||
// D-010: BTreeMap ensures deterministic iteration order.
|
||||
let mut map = TemplateReferenceMap::default();
|
||||
|
||||
let tid_high = TemplateId(u64::MAX - 1);
|
||||
let tid_low = TemplateId(1);
|
||||
|
||||
map.add(TemplateReference {
|
||||
from_template: tid_high,
|
||||
to_template: tid_low,
|
||||
via_role: RoleId::new("role-a"),
|
||||
relationship_metadata: RelationshipKind::Colleague,
|
||||
});
|
||||
map.add(TemplateReference {
|
||||
from_template: tid_low,
|
||||
to_template: tid_high,
|
||||
via_role: RoleId::new("role-b"),
|
||||
relationship_metadata: RelationshipKind::Colleague,
|
||||
});
|
||||
|
||||
// Collect all references via all_references() (deterministic BTreeMap order).
|
||||
let all: Vec<&TemplateReference> = map.all_references().collect();
|
||||
assert_eq!(all.len(), 2);
|
||||
// First entry's from_template must be the lower ID (BTreeMap key order).
|
||||
assert!(
|
||||
all[0].from_template <= all[1].from_template,
|
||||
"BTreeMap must iterate in ascending key order"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #106: Triangle definition schema — YAML round-trips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn triangle_def_yaml_parse_with_computed_id() {
|
||||
// TriangleId is stored in YAML but computed at world-gen time.
|
||||
// Authors use 0 as placeholder; runtime overwrites with computed value.
|
||||
let yaml = r#"
|
||||
triangle_id: 0
|
||||
roles:
|
||||
- "ring-smuggler"
|
||||
- "dock-worker"
|
||||
- "operations-manager"
|
||||
conflict_type: ResourceCompetition
|
||||
interest_axes:
|
||||
- Want
|
||||
- Secret
|
||||
- Relationships
|
||||
"#;
|
||||
let def: TriangleDef = serde_yaml::from_str(yaml).expect("TriangleDef must parse from YAML");
|
||||
assert_eq!(def.triangle_id, TriangleId(0));
|
||||
assert_eq!(def.roles[0], RoleId::new("ring-smuggler"));
|
||||
assert_eq!(def.conflict_type, ConflictType::ResourceCompetition);
|
||||
assert!(def.relationship_constraints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_def_yaml_parse_with_constraints() {
|
||||
let yaml = r#"
|
||||
triangle_id: 0
|
||||
roles:
|
||||
- "ring-leader"
|
||||
- "dock-worker"
|
||||
- "logistics-manager"
|
||||
conflict_type: LoyaltyConflict
|
||||
interest_axes:
|
||||
- Relationships
|
||||
- Secret
|
||||
- Tolerance
|
||||
relationship_constraints:
|
||||
- with_role: "dock-worker"
|
||||
kind: Subordinate
|
||||
required_trust:
|
||||
min: -2
|
||||
max: 2
|
||||
"#;
|
||||
let def: TriangleDef =
|
||||
serde_yaml::from_str(yaml).expect("TriangleDef with constraints must parse");
|
||||
assert_eq!(def.conflict_type, ConflictType::LoyaltyConflict);
|
||||
assert_eq!(def.relationship_constraints.len(), 1);
|
||||
assert_eq!(def.relationship_constraints[0].with_role, RoleId::new("dock-worker"));
|
||||
assert_eq!(def.relationship_constraints[0].kind, RelationshipKind::Subordinate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_def_all_conflict_types_yaml_roundtrip() {
|
||||
let conflict_types = [
|
||||
ConflictType::ResourceCompetition,
|
||||
ConflictType::LoyaltyConflict,
|
||||
ConflictType::SecretExposure,
|
||||
ConflictType::AuthorityChallenge,
|
||||
ConflictType::LatentTension,
|
||||
];
|
||||
for ct in &conflict_types {
|
||||
let yaml = serde_yaml::to_string(ct).unwrap();
|
||||
let decoded: ConflictType = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(ct, &decoded, "{:?} must round-trip", ct);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_def_all_npc_axes_yaml_roundtrip() {
|
||||
let axes = [
|
||||
NpcAxis::Want,
|
||||
NpcAxis::Secret,
|
||||
NpcAxis::Relationships,
|
||||
NpcAxis::Tolerance,
|
||||
NpcAxis::Routine,
|
||||
NpcAxis::InformationInventory,
|
||||
NpcAxis::Contentment,
|
||||
NpcAxis::PersonalityTraits,
|
||||
NpcAxis::TellSystem,
|
||||
NpcAxis::SkillSet,
|
||||
];
|
||||
for axis in &axes {
|
||||
let yaml = serde_yaml::to_string(axis).unwrap();
|
||||
let decoded: NpcAxis = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(axis, &decoded, "{:?} must round-trip", axis);
|
||||
}
|
||||
}
|
||||
|
||||
/// D-087: T1-T5 triangle configuration must be expressible in the schema.
|
||||
#[test]
|
||||
fn d087_v01_triangle_configurations_expressible() {
|
||||
// T1: Kael-Smuggler-Ring (ResourceCompetition, active fork)
|
||||
let t1 = make_triangle(
|
||||
["kael-davan", "smuggler", "ring-contact"],
|
||||
ConflictType::ResourceCompetition,
|
||||
);
|
||||
assert!(t1.validate().is_ok(), "T1 must be valid: {:?}", t1.validate());
|
||||
|
||||
// T2: Sera-Detective-Commission (SecretExposure, active fork)
|
||||
let t2 = make_triangle(
|
||||
["sera-venn", "detective", "commission-inspector"],
|
||||
ConflictType::SecretExposure,
|
||||
);
|
||||
assert!(t2.validate().is_ok(), "T2 must be valid: {:?}", t2.validate());
|
||||
|
||||
// T4: Drin-System-Ring (ResourceCompetition, active fork per D-087)
|
||||
let t4 = make_triangle(
|
||||
["drin", "ring-system", "dock-supervisor"],
|
||||
ConflictType::ResourceCompetition,
|
||||
);
|
||||
assert!(t4.validate().is_ok(), "T4 must be valid: {:?}", t4.validate());
|
||||
|
||||
// T3: passive tension (LatentTension variant per D-087)
|
||||
let t3 = make_triangle(["naia", "kael-davan", "hael"], ConflictType::LatentTension);
|
||||
assert!(t3.validate().is_ok(), "T3 passive tension must be valid: {:?}", t3.validate());
|
||||
|
||||
// T5: background worried partner (LatentTension variant)
|
||||
let t5 = make_triangle(
|
||||
["worried-partner", "ring-member", "neighbor"],
|
||||
ConflictType::LatentTension,
|
||||
);
|
||||
assert!(t5.validate().is_ok(), "T5 passive tension must be valid: {:?}", t5.validate());
|
||||
}
|
||||
|
||||
/// D-089: TriangleDef must not contain cross-triangle cascade state.
|
||||
#[test]
|
||||
fn d089_no_cross_triangle_cascade_fields() {
|
||||
let def = make_triangle(["role-a", "role-b", "role-c"], ConflictType::ResourceCompetition);
|
||||
let yaml = serde_yaml::to_string(&def).expect("serialize");
|
||||
assert!(!yaml.contains("cascade"), "no cascade field should appear in serialized TriangleDef");
|
||||
assert!(!yaml.contains("cross_triangle"), "no cross_triangle field should appear");
|
||||
assert!(!yaml.contains("triggers"), "no triggers field should appear");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #165: ECS integration — spawn two templates with cross-references
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ecs_two_templates_with_cross_references_and_ownerships() {
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
let seed = 999u64;
|
||||
let tid_terminal = TemplateId::from_seed_and_slug(seed, "terminal-social-site");
|
||||
let tid_bar = TemplateId::from_seed_and_slug(seed, "last-shift-bar");
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<TemplateReferenceMap>();
|
||||
|
||||
// Spawn 3 NPCs: 2 in terminal, 1 in bar.
|
||||
let npc_logistics = world
|
||||
.spawn(TemplateOwnership {
|
||||
template_id: tid_terminal,
|
||||
role_id: RoleId::new("logistics-manager"),
|
||||
})
|
||||
.id();
|
||||
let npc_dock = world
|
||||
.spawn(TemplateOwnership {
|
||||
template_id: tid_terminal,
|
||||
role_id: RoleId::new("dock-worker"),
|
||||
})
|
||||
.id();
|
||||
let npc_bar_regular = world
|
||||
.spawn(TemplateOwnership {
|
||||
template_id: tid_bar,
|
||||
role_id: RoleId::new("bar-regular"),
|
||||
})
|
||||
.id();
|
||||
|
||||
// Add cross-template reference: dock-worker at terminal references bar-regular at bar.
|
||||
{
|
||||
let mut ref_map = world.resource_mut::<TemplateReferenceMap>();
|
||||
ref_map.add(TemplateReference {
|
||||
from_template: tid_terminal,
|
||||
to_template: tid_bar,
|
||||
via_role: RoleId::new("dock-worker"),
|
||||
relationship_metadata: RelationshipKind::Colleague,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify all TemplateOwnership components are correct.
|
||||
let own_logistics = world.get::<TemplateOwnership>(npc_logistics).unwrap();
|
||||
assert_eq!(
|
||||
own_logistics.template_id, tid_terminal,
|
||||
"logistics-manager must be owned by terminal"
|
||||
);
|
||||
assert_eq!(own_logistics.role_id, RoleId::new("logistics-manager"));
|
||||
|
||||
let own_dock = world.get::<TemplateOwnership>(npc_dock).unwrap();
|
||||
assert_eq!(
|
||||
own_dock.template_id, tid_terminal,
|
||||
"dock-worker must be owned by terminal"
|
||||
);
|
||||
assert_eq!(own_dock.role_id, RoleId::new("dock-worker"));
|
||||
|
||||
let own_bar = world.get::<TemplateOwnership>(npc_bar_regular).unwrap();
|
||||
assert_eq!(own_bar.template_id, tid_bar, "bar-regular must be owned by bar");
|
||||
|
||||
// Verify TemplateReferenceMap entries.
|
||||
let ref_map = world.resource::<TemplateReferenceMap>();
|
||||
let terminal_refs = ref_map.outgoing(tid_terminal);
|
||||
assert_eq!(terminal_refs.len(), 1, "terminal should have 1 cross-template reference");
|
||||
assert_eq!(terminal_refs[0].to_template, tid_bar);
|
||||
assert_eq!(terminal_refs[0].via_role, RoleId::new("dock-worker"));
|
||||
|
||||
// Bar template has no outgoing references.
|
||||
assert!(
|
||||
ref_map.outgoing(tid_bar).is_empty(),
|
||||
"bar template has no outgoing references"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_ownership_survives_clone_for_save_state() {
|
||||
// D-026: TemplateOwnership must be preserved when tier drops to State-saved.
|
||||
let tid = TemplateId::from_seed_and_slug(42, "terminal");
|
||||
let rid = RoleId::new("dock-worker");
|
||||
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
|
||||
let saved = ownership.clone();
|
||||
assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #159: Full Tier 2 template document — FullTemplateDef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a minimal valid FullTemplateDef with two roles and two triangles.
|
||||
fn minimal_full_template() -> FullTemplateDef {
|
||||
FullTemplateDef {
|
||||
slug: "test-site".to_string(),
|
||||
display_name: "Test Social Site".to_string(),
|
||||
description: None,
|
||||
roles: vec![
|
||||
RoleSchema {
|
||||
role_id: RoleId::new("manager"),
|
||||
required_traits: vec![PersonalityTrait::Cautious],
|
||||
skill_focus: vec![Skill::Persuasion],
|
||||
relationship_constraints: vec![RelationshipConstraint {
|
||||
with_role: RoleId::new("worker"),
|
||||
kind: RelationshipKind::Superior,
|
||||
required_trust: TrustRange { min: 0, max: 5 },
|
||||
}],
|
||||
routine_template: vec![],
|
||||
},
|
||||
RoleSchema {
|
||||
role_id: RoleId::new("worker"),
|
||||
required_traits: vec![PersonalityTrait::Honest],
|
||||
skill_focus: vec![Skill::Technical],
|
||||
relationship_constraints: vec![],
|
||||
routine_template: vec![],
|
||||
},
|
||||
RoleSchema {
|
||||
role_id: RoleId::new("informant"),
|
||||
required_traits: vec![PersonalityTrait::Deceptive],
|
||||
skill_focus: vec![Skill::Stealth],
|
||||
relationship_constraints: vec![],
|
||||
routine_template: vec![],
|
||||
},
|
||||
],
|
||||
space: SpaceSpec {
|
||||
tile_count_min: 30,
|
||||
tile_count_max: 80,
|
||||
sightline_zones: vec![SightlineZone {
|
||||
name: "main-floor".to_string(),
|
||||
radius: 6,
|
||||
}],
|
||||
privacy_level: PrivacyLevel::SemiPrivate,
|
||||
traffic_pattern: TrafficPattern::Destination,
|
||||
},
|
||||
triangles: vec![
|
||||
TriangleDef {
|
||||
triangle_id: TriangleId(0),
|
||||
roles: [
|
||||
RoleId::new("manager"),
|
||||
RoleId::new("worker"),
|
||||
RoleId::new("informant"),
|
||||
],
|
||||
conflict_type: ConflictType::ResourceCompetition,
|
||||
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
|
||||
relationship_constraints: vec![],
|
||||
},
|
||||
TriangleDef {
|
||||
triangle_id: TriangleId(0),
|
||||
roles: [
|
||||
RoleId::new("manager"),
|
||||
RoleId::new("informant"),
|
||||
RoleId::new("worker"),
|
||||
],
|
||||
conflict_type: ConflictType::LatentTension,
|
||||
interest_axes: [NpcAxis::Tolerance, NpcAxis::Contentment, NpcAxis::Routine],
|
||||
relationship_constraints: vec![],
|
||||
},
|
||||
],
|
||||
dialogue_pools: vec![TemplateDialoguePoolRef {
|
||||
location: "the-hub".to_string(),
|
||||
roles: vec!["manager".to_string(), "worker".to_string()],
|
||||
}],
|
||||
cross_template_links: vec![CrossTemplateLinkSpec {
|
||||
from_role: RoleId::new("worker"),
|
||||
to_template_slug: "other-site".to_string(),
|
||||
relationship: RelationshipKind::Colleague,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_yaml_roundtrip() {
|
||||
let template = minimal_full_template();
|
||||
let yaml = serde_yaml::to_string(&template).expect("serialize FullTemplateDef");
|
||||
let restored: FullTemplateDef =
|
||||
serde_yaml::from_str(&yaml).expect("deserialize FullTemplateDef");
|
||||
|
||||
assert_eq!(restored.slug, template.slug);
|
||||
assert_eq!(restored.display_name, template.display_name);
|
||||
assert_eq!(restored.roles.len(), template.roles.len());
|
||||
assert_eq!(restored.space.tile_count_min, template.space.tile_count_min);
|
||||
assert_eq!(restored.triangles.len(), template.triangles.len());
|
||||
assert_eq!(restored.dialogue_pools.len(), template.dialogue_pools.len());
|
||||
assert_eq!(restored.cross_template_links.len(), template.cross_template_links.len());
|
||||
|
||||
// Role round-trip: traits, constraints, routine entries
|
||||
let role = &restored.roles[0];
|
||||
assert_eq!(role.role_id, RoleId::new("manager"));
|
||||
assert_eq!(role.required_traits[0], PersonalityTrait::Cautious);
|
||||
assert_eq!(role.relationship_constraints[0].with_role, RoleId::new("worker"));
|
||||
|
||||
// Triangle round-trip: roles, conflict type, axes
|
||||
let tri = &restored.triangles[0];
|
||||
assert_eq!(tri.conflict_type, ConflictType::ResourceCompetition);
|
||||
assert_eq!(tri.roles[0], RoleId::new("manager"));
|
||||
assert_eq!(tri.interest_axes[1], NpcAxis::Secret);
|
||||
|
||||
// Dialogue pool round-trip
|
||||
assert_eq!(restored.dialogue_pools[0].location, "the-hub");
|
||||
assert_eq!(restored.dialogue_pools[0].roles.len(), 2);
|
||||
|
||||
// Cross-template link round-trip
|
||||
assert_eq!(
|
||||
restored.cross_template_links[0].from_role,
|
||||
RoleId::new("worker")
|
||||
);
|
||||
assert_eq!(
|
||||
restored.cross_template_links[0].to_template_slug,
|
||||
"other-site"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_validation_passes_for_valid_template() {
|
||||
let template = minimal_full_template();
|
||||
assert!(
|
||||
template.validate().is_ok(),
|
||||
"minimal valid template must pass: {:?}",
|
||||
template.validate()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_validation_rejects_fewer_than_2_triangles() {
|
||||
let mut template = minimal_full_template();
|
||||
template.triangles.truncate(1);
|
||||
let result = template.validate();
|
||||
assert!(result.is_err(), "fewer than 2 triangles must fail");
|
||||
assert!(
|
||||
result.unwrap_err().contains("fewer than 2 triangles"),
|
||||
"error must mention triangle count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_validation_rejects_undefined_triangle_role() {
|
||||
let mut template = minimal_full_template();
|
||||
// Replace a triangle role with one not in the roles list
|
||||
template.triangles[0].roles[2] = RoleId::new("ghost-role");
|
||||
let result = template.validate();
|
||||
assert!(result.is_err(), "undefined triangle role must fail validation");
|
||||
assert!(
|
||||
result.unwrap_err().contains("ghost-role"),
|
||||
"error must name the undefined role"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_validation_rejects_duplicate_role_ids() {
|
||||
let mut template = minimal_full_template();
|
||||
template.roles.push(RoleSchema {
|
||||
role_id: RoleId::new("manager"), // duplicate
|
||||
required_traits: vec![],
|
||||
skill_focus: vec![],
|
||||
relationship_constraints: vec![],
|
||||
routine_template: vec![],
|
||||
});
|
||||
let result = template.validate();
|
||||
assert!(result.is_err(), "duplicate role_id must fail validation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_template_def_optional_fields_default_on_minimal_yaml() {
|
||||
// description, dialogue_pools, cross_template_links are all optional.
|
||||
let yaml = r#"
|
||||
slug: "bare-minimum"
|
||||
display_name: "Bare Minimum Site"
|
||||
roles:
|
||||
- role_id: "alpha"
|
||||
- role_id: "beta"
|
||||
- role_id: "gamma"
|
||||
space:
|
||||
tile_count_min: 30
|
||||
tile_count_max: 80
|
||||
privacy_level: Public
|
||||
traffic_pattern: Thoroughfare
|
||||
triangles:
|
||||
- triangle_id: 0
|
||||
roles:
|
||||
- "alpha"
|
||||
- "beta"
|
||||
- "gamma"
|
||||
conflict_type: LatentTension
|
||||
interest_axes:
|
||||
- Contentment
|
||||
- Tolerance
|
||||
- Routine
|
||||
- triangle_id: 0
|
||||
roles:
|
||||
- "alpha"
|
||||
- "gamma"
|
||||
- "beta"
|
||||
conflict_type: ResourceCompetition
|
||||
interest_axes:
|
||||
- Want
|
||||
- Secret
|
||||
- Relationships
|
||||
"#;
|
||||
let def: FullTemplateDef = serde_yaml::from_str(yaml).expect("minimal YAML must parse");
|
||||
assert_eq!(def.slug, "bare-minimum");
|
||||
assert!(def.description.is_none());
|
||||
assert!(def.dialogue_pools.is_empty());
|
||||
assert!(def.cross_template_links.is_empty());
|
||||
assert!(def.validate().is_ok(), "minimal template must validate: {:?}", def.validate());
|
||||
}
|
||||
|
||||
/// Acceptance test: the authored logistics-hub.yaml round-trips through serde_yaml.
|
||||
///
|
||||
/// The file lives at `server/data/templates/logistics-hub.yaml`.
|
||||
/// This test is the canonical acceptance criterion for ticket #159.
|
||||
#[test]
|
||||
fn logistics_hub_yaml_roundtrips_cleanly() {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/data/templates/logistics-hub.yaml"
|
||||
);
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("could not read logistics-hub.yaml: {}", e));
|
||||
|
||||
let def: FullTemplateDef = serde_yaml::from_str(&raw)
|
||||
.unwrap_or_else(|e| panic!("logistics-hub.yaml failed to deserialize: {}", e));
|
||||
|
||||
// Structural assertions
|
||||
assert_eq!(def.slug, "logistics-hub");
|
||||
assert_eq!(def.roles.len(), 4, "logistics hub must define 4 roles");
|
||||
assert_eq!(def.triangles.len(), 2, "logistics hub must define 2 triangles");
|
||||
assert!(!def.dialogue_pools.is_empty(), "dialogue_pools must be present");
|
||||
assert!(!def.cross_template_links.is_empty(), "cross_template_links must be present");
|
||||
|
||||
// Spatial spec assertions (D-025: 30–80 sim tiles)
|
||||
assert!(def.space.validate().is_ok(), "space spec must validate");
|
||||
assert_eq!(def.space.tile_count_min, 30);
|
||||
assert_eq!(def.space.tile_count_max, 80);
|
||||
|
||||
// Validation must pass
|
||||
assert!(
|
||||
def.validate().is_ok(),
|
||||
"logistics-hub.yaml must pass full validation: {:?}",
|
||||
def.validate()
|
||||
);
|
||||
|
||||
// Round-trip: serialize back to YAML then deserialize again
|
||||
let reserialized = serde_yaml::to_string(&def).expect("re-serialize");
|
||||
let restored: FullTemplateDef =
|
||||
serde_yaml::from_str(&reserialized).expect("re-deserialize after round-trip");
|
||||
assert_eq!(def.slug, restored.slug);
|
||||
assert_eq!(def.roles.len(), restored.roles.len());
|
||||
assert_eq!(def.triangles.len(), restored.triangles.len());
|
||||
assert_eq!(def.dialogue_pools.len(), restored.dialogue_pools.len());
|
||||
assert_eq!(def.cross_template_links.len(), restored.cross_template_links.len());
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use bevy_ecs::{schedule::Schedule, world::World};
|
||||
use settled_reach_server::{
|
||||
content::template::{
|
||||
simulation::triangle::{
|
||||
apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand,
|
||||
ResolveTriangleQueue, TemplateId, TriangleClassification, TriangleCrisisEventQueue,
|
||||
TriangleDef, TriangleId, TrianglePhase, TriangleState,
|
||||
@@ -56,7 +56,7 @@ fn spawn_triangle(
|
||||
tension: u8,
|
||||
tension_rate: u8,
|
||||
phase: TrianglePhase,
|
||||
role_assignments: BTreeMap<settled_reach_server::content::template::RoleId, StableId>,
|
||||
role_assignments: BTreeMap<settled_reach_server::simulation::triangle::RoleId, StableId>,
|
||||
) -> bevy_ecs::entity::Entity {
|
||||
world
|
||||
.spawn((
|
||||
@@ -135,7 +135,7 @@ fn simmering_transitions_to_active_at_expected_minute() {
|
||||
let npc_b = spawn_npc_with_threshold(&mut world, 2, 25); // lowest
|
||||
let npc_c = spawn_npc_with_threshold(&mut world, 3, 60);
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("role-a"), npc_a);
|
||||
assignments.insert(RoleId::new("role-b"), npc_b);
|
||||
@@ -185,7 +185,7 @@ fn d087_seed_dependent_escalation_timing() {
|
||||
|
||||
let npc = spawn_npc_with_threshold(&mut world, 1, 30);
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("r"), npc);
|
||||
|
||||
@@ -222,7 +222,7 @@ fn crisis_event_trigger_npc_is_lowest_threshold() {
|
||||
let npc_high = spawn_npc_with_threshold(&mut world, 1, 50); // high tolerance
|
||||
let npc_low = spawn_npc_with_threshold(&mut world, 2, 10); // low tolerance — trigger
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("r-high"), npc_high);
|
||||
assignments.insert(RoleId::new("r-low"), npc_low);
|
||||
@@ -249,7 +249,7 @@ fn no_crisis_event_below_threshold() {
|
||||
let mut world = make_escalation_world();
|
||||
let npc = spawn_npc_with_threshold(&mut world, 1, 100); // high threshold
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("r"), npc);
|
||||
|
||||
@@ -521,7 +521,7 @@ fn crisis_events_accumulate_until_drained() {
|
||||
|
||||
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("r"), npc);
|
||||
|
||||
@@ -548,7 +548,7 @@ fn crisis_queue_drain_clears_events() {
|
||||
|
||||
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("r"), npc);
|
||||
|
||||
@@ -575,7 +575,7 @@ fn crisis_queue_drain_clears_events() {
|
||||
/// (D-087) and those defs produce escalatable TriangleState instances.
|
||||
#[test]
|
||||
fn d087_all_v01_conflict_types_produce_escalatable_states() {
|
||||
use settled_reach_server::content::template::{ConflictType, NpcAxis, RoleId};
|
||||
use settled_reach_server::simulation::triangle::{ConflictType, NpcAxis, RoleId};
|
||||
|
||||
let defs = [
|
||||
("kael-davan", "smuggler", "ring-contact", ConflictType::ResourceCompetition),
|
||||
@@ -587,7 +587,7 @@ fn d087_all_v01_conflict_types_produce_escalatable_states() {
|
||||
|
||||
for (r0, r1, r2, conflict) in &defs {
|
||||
let roles = [RoleId::new(r0), RoleId::new(r1), RoleId::new(r2)];
|
||||
let tid = settled_reach_server::content::template::TriangleId::from_seed_and_roles(42, &roles);
|
||||
let tid = settled_reach_server::simulation::triangle::TriangleId::from_seed_and_roles(42, &roles);
|
||||
let def = TriangleDef {
|
||||
triangle_id: tid,
|
||||
roles: roles.clone(),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! `cargo test -p settled-reach-server -- triangle_validation`
|
||||
|
||||
use settled_reach_server::{
|
||||
content::template::{
|
||||
simulation::triangle::{
|
||||
generate_cross_template_triangles, generate_intra_template_triangles,
|
||||
validate_triangle_def, ConflictType, NpcAxis, RelationshipConstraint, RoleId,
|
||||
TemplateId, TemplateOwnership, TriangleDef, TriangleId, TrianglePhase, TrustRange,
|
||||
|
||||
Reference in New Issue
Block a user