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:
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user