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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 09:07:57 +01:00
co-authored by Claude Sonnet 4.6
parent b979cab41e
commit ce0df2f320
39 changed files with 721 additions and 8943 deletions
+6 -6
View File
@@ -23,11 +23,11 @@ use rand::Rng;
use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState};
use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName};
use crate::storyteller::EngagementRecord;
use crate::content::line_pool::{
use crate::simulation::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
use crate::content::types::KnowledgeGrant;
use crate::content::LinePoolIndexResource;
use crate::simulation::knowledge_grant::KnowledgeGrant;
use crate::simulation::line_pool::LinePoolIndexResource;
use crate::knowledge::content_registry::ContentEntityRegistry;
use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant};
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId};
@@ -356,7 +356,7 @@ pub fn select_dialogue_line<'a>(
/// avoid duplicating the L1-L4 query + scoring logic. Callers handle the
/// result differently (initial Talk sets ActiveDialogue; follow-up may clear it).
fn run_dialogue_pipeline<'a>(
line_pool: &'a crate::content::line_pool::LinePoolIndex,
line_pool: &'a crate::simulation::line_pool::LinePoolIndex,
location: &str,
role: &str,
relationship: RelationshipState,
@@ -1110,11 +1110,11 @@ pub fn process_dialogue_response(
#[cfg(test)]
mod tests {
use super::*;
use crate::content::line_pool::{
use crate::simulation::line_pool::{
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
Topic, TrustTier,
};
use crate::content::LinePoolIndexResource;
use crate::simulation::line_pool::LinePoolIndexResource;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::Npc;
+64
View File
@@ -0,0 +1,64 @@
//! Knowledge grant types for dialogue and monologue lines.
//!
//! `KnowledgeGrant` describes the knowledge a player gains from a dialogue line.
//! `Prerequisites` describes preconditions for a monologue line to fire.
use serde::Deserialize;
use std::collections::BTreeMap;
/// Knowledge grant attached to a dialogue line (D-079).
///
/// Untagged enum — serde tries each variant in order:
/// `Fact` matches YAML with `fact_id` field.
/// `Entity` matches YAML with `entity_ref` field.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum KnowledgeGrant {
/// Grant knowledge of a non-entity fact.
/// Format: fact_id "category.topic", confidence string.
Fact {
fact_id: String,
confidence: String,
},
/// Grant knowledge of an entity (creates EntityKnowledge entry in observer's KG).
/// Required for contradiction detection: testimony must create ToldBy EntityKnowledge
/// so a subsequent DirectObservation can detect a discrepancy (D-079, D-083).
Entity {
entity_ref: String,
#[serde(default)]
attributes: BTreeMap<String, String>,
confidence: String,
},
}
/// Prerequisite set for a monologue line.
#[derive(Debug, Clone, Deserialize)]
pub struct Prerequisites {
#[serde(default)]
pub facts: Vec<FactPrerequisite>,
#[serde(default)]
pub entity_attributes: Vec<AttributePrerequisite>,
#[serde(default)]
pub relationship: Option<RelationshipPrerequisite>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FactPrerequisite {
pub fact_id: String,
pub min_confidence: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AttributePrerequisite {
pub entity: String,
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RelationshipPrerequisite {
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub state: Option<String>,
}
+530
View File
@@ -0,0 +1,530 @@
//! Indexed line pool data structures and query API (D-028 four-layer filtering).
//!
//! Provides typed, pre-indexed runtime representations of dialogue and monologue
//! content pools. Populated by the v0.2 generator pipeline.
//!
//! Indexing strategy (D-041 determinism):
//! - All maps use BTreeMap for deterministic iteration order
//! - Dialogue: BTreeMap<(location, role), pool> with lines ready for filtering
//! - Monologue: BTreeMap<(character, location), pool> with trigger grouping
use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use bevy_ecs::prelude::*;
use crate::simulation::knowledge_grant::{KnowledgeGrant, Prerequisites};
// ---------------------------------------------------------------------------
// Tag enums (D-035 converged taxonomy)
// ---------------------------------------------------------------------------
/// D-028 Layer 1: Access tier — hard filter on who can hear this line.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AccessTier {
Public,
Insider,
Authority,
Peer,
Hostile,
}
impl FromStr for AccessTier {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"public" => Ok(Self::Public),
"insider" => Ok(Self::Insider),
"authority" => Ok(Self::Authority),
"peer" => Ok(Self::Peer),
"hostile" => Ok(Self::Hostile),
_ => Err(ParseEnumError {
kind: "AccessTier",
value: s.to_string(),
}),
}
}
}
/// D-028 Layer 3: Trust tier — hard filter on relationship depth.
///
/// Ordering: Surface < Real < Secret (derived from PartialOrd on discriminant).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TrustTier {
Surface,
Real,
Secret,
}
impl TrustTier {
/// Returns true if `self` meets or exceeds the `required` tier.
pub fn meets(self, required: TrustTier) -> bool {
self >= required
}
}
impl FromStr for TrustTier {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"surface" => Ok(Self::Surface),
"real" => Ok(Self::Real),
"secret" => Ok(Self::Secret),
_ => Err(ParseEnumError {
kind: "TrustTier",
value: s.to_string(),
}),
}
}
}
/// D-028 Layer 2: Situation context — when this line can fire.
///
/// 14 v0.1 values: 13 original + Greeting added Sprint 8 (D-035 amendment)
/// for PC dialogue pools initial contact lines.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Situation {
Arrival,
ShiftStart,
ShiftEnd,
ShiftTransition,
BarEvening,
NightShift,
Investigation,
Confrontation,
Social,
Alone,
Emergency,
Routine,
Observation,
/// Added Sprint 8 (D-035 amendment): PC dialogue initial contact lines.
Greeting,
/// First player-NPC interaction — interaction_count == 0 (#325, D-028 Layer 2).
FirstMeeting,
/// Player has talked to this NPC 3+ times — interaction_count >= 3 (#325, D-028 Layer 2).
RepeatedVisit,
}
impl FromStr for Situation {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"arrival" => Ok(Self::Arrival),
"shift_start" => Ok(Self::ShiftStart),
"shift_end" => Ok(Self::ShiftEnd),
"shift_transition" => Ok(Self::ShiftTransition),
"bar_evening" => Ok(Self::BarEvening),
"night_shift" => Ok(Self::NightShift),
"investigation" => Ok(Self::Investigation),
"confrontation" => Ok(Self::Confrontation),
"social" => Ok(Self::Social),
"alone" => Ok(Self::Alone),
"emergency" => Ok(Self::Emergency),
"routine" => Ok(Self::Routine),
"observation" => Ok(Self::Observation),
"greeting" => Ok(Self::Greeting),
"first_meeting" => Ok(Self::FirstMeeting),
"repeated_visit" => Ok(Self::RepeatedVisit),
_ => Err(ParseEnumError {
kind: "Situation",
value: s.to_string(),
}),
}
}
}
/// D-028 Layer 4: Topic tag — influences weighted selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Topic {
Colleague,
Routine,
Cargo,
Money,
Trust,
Danger,
Institution,
Personal,
Investigation,
}
impl FromStr for Topic {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"colleague" => Ok(Self::Colleague),
"routine" => Ok(Self::Routine),
"cargo" => Ok(Self::Cargo),
"money" => Ok(Self::Money),
"trust" => Ok(Self::Trust),
"danger" => Ok(Self::Danger),
"institution" => Ok(Self::Institution),
"personal" => Ok(Self::Personal),
"investigation" => Ok(Self::Investigation),
_ => Err(ParseEnumError {
kind: "Topic",
value: s.to_string(),
}),
}
}
}
/// D-028 Layer 4: Mood tag — influences weighted selection.
///
/// 8 v0.1 values aligned to voice guide vocabulary (Sprint 14 rename).
/// D-035 amendment (Sprint 8): `Focused` added as 9th variant.
/// Neutral mood is represented by omitting the mood tag (untagged = baseline).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Mood {
Anxious,
Frustrated,
Content,
Suspicious,
Warm,
Hostile,
Relieved,
/// D-035 amendment (Sprint 8): task-focused NPC mood — used at The Terminal
/// and maintenance corridors. Maps from NpcMood::Focused.
Focused,
}
impl FromStr for Mood {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"anxious" => Ok(Self::Anxious),
"frustrated" => Ok(Self::Frustrated),
"content" => Ok(Self::Content),
"suspicious" => Ok(Self::Suspicious),
"warm" => Ok(Self::Warm),
"hostile" => Ok(Self::Hostile),
"relieved" => Ok(Self::Relieved),
"focused" => Ok(Self::Focused),
_ => Err(ParseEnumError {
kind: "Mood",
value: s.to_string(),
}),
}
}
}
/// Monologue trigger type — what causes this line to fire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Trigger {
EnterLocation,
ObserveNpc,
HearSound,
ObserveAnomaly,
PostConversation,
DiscoverEvidence,
WitnessInteraction,
TimeIdle,
ReturnVisit,
}
impl FromStr for Trigger {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"enter_location" => Ok(Self::EnterLocation),
"observe_npc" => Ok(Self::ObserveNpc),
"hear_sound" => Ok(Self::HearSound),
"observe_anomaly" => Ok(Self::ObserveAnomaly),
"post_conversation" => Ok(Self::PostConversation),
"discover_evidence" => Ok(Self::DiscoverEvidence),
"witness_interaction" => Ok(Self::WitnessInteraction),
"time_idle" => Ok(Self::TimeIdle),
"return_visit" => Ok(Self::ReturnVisit),
_ => Err(ParseEnumError {
kind: "Trigger",
value: s.to_string(),
}),
}
}
}
/// Hard character partition for monologue pools (D-032).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Character {
Smuggler,
Detective,
}
impl FromStr for Character {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"smuggler" => Ok(Self::Smuggler),
"detective" => Ok(Self::Detective),
_ => Err(ParseEnumError {
kind: "Character",
value: s.to_string(),
}),
}
}
}
/// Error type for enum parsing failures.
#[derive(Debug)]
pub struct ParseEnumError {
pub kind: &'static str,
pub value: String,
}
impl fmt::Display for ParseEnumError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid {} value: {:?}", self.kind, self.value)
}
}
impl std::error::Error for ParseEnumError {}
// ---------------------------------------------------------------------------
// Indexed line types — typed runtime representations
// ---------------------------------------------------------------------------
/// A dialogue line with typed enum fields, ready for filtering.
#[derive(Debug, Clone)]
pub struct IndexedDialogueLine {
pub id: String,
pub text: String,
pub role: String,
pub access: Vec<AccessTier>,
pub trust: TrustTier,
pub situation: Vec<Situation>,
pub topic: Vec<Topic>,
pub mood: Vec<Mood>,
pub tags: Vec<String>,
pub knowledge_grant: Option<KnowledgeGrant>,
}
/// A monologue line with typed enum fields, ready for filtering.
#[derive(Debug, Clone)]
pub struct IndexedMonologueLine {
pub id: String,
pub text: String,
pub trigger: Trigger,
pub prerequisites: Option<Prerequisites>,
pub priority: u8,
pub cooldown: u32,
pub tags: Vec<String>,
}
// ---------------------------------------------------------------------------
// Pool index types
// ---------------------------------------------------------------------------
/// Dialogue pool indexed for querying.
#[derive(Debug)]
pub struct IndexedDialoguePool {
pub location: String,
pub role: String,
pub lines: Vec<IndexedDialogueLine>,
}
/// Monologue pool indexed by trigger for fast lookup.
#[derive(Debug)]
pub struct IndexedMonologuePool {
pub character: Character,
pub location: String,
/// Lines grouped by trigger type (BTreeMap for deterministic iteration).
pub by_trigger: BTreeMap<Trigger, Vec<IndexedMonologueLine>>,
}
// ---------------------------------------------------------------------------
// Top-level index
// ---------------------------------------------------------------------------
/// Top-level line pool index — the queryable runtime data structure.
///
/// All internal maps use BTreeMap per D-041 determinism requirement.
#[derive(Debug, Default)]
pub struct LinePoolIndex {
/// Dialogue pools indexed by (location, role).
pub dialogue: BTreeMap<(String, String), IndexedDialoguePool>,
/// Monologue pools indexed by (character, location).
pub monologue: BTreeMap<(Character, String), IndexedMonologuePool>,
}
impl LinePoolIndex {
/// Query dialogue lines through Layers 1-3 of the D-028 pipeline.
///
/// Returns lines that pass:
/// - Layer 1: player's access tier is in line.access
/// - Layer 2: any active situation is in line.situation
/// - Layer 3: player's trust >= line.trust
///
/// Layer 4 (topic+mood scoring) is handled by the selection pipeline (#305).
pub fn query_dialogue(
&self,
location: &str,
role: &str,
player_access: AccessTier,
active_situations: &[Situation],
player_trust: TrustTier,
) -> Vec<&IndexedDialogueLine> {
let key = (location.to_string(), role.to_string());
let Some(pool) = self.dialogue.get(&key) else {
return Vec::new();
};
pool.lines
.iter()
.filter(|line| {
// Layer 1: Access filter (hard)
line.access.contains(&player_access)
})
.filter(|line| {
// Layer 2: Situation filter (context)
line.situation.iter().any(|s| active_situations.contains(s))
})
.filter(|line| {
// Layer 3: Trust filter (hard)
player_trust.meets(line.trust)
})
.collect()
}
/// Query monologue lines for a trigger event.
///
/// Returns lines matching character + trigger from both:
/// - Location-specific pool (exact match)
/// - General pool (location = "general")
///
/// Prerequisite evaluation and cooldown checking are the caller's
/// responsibility (they require KG state and tick tracking).
pub fn query_monologue(
&self,
character: Character,
location: &str,
trigger: Trigger,
) -> Vec<&IndexedMonologueLine> {
let mut results = Vec::new();
// Location-specific pool
let key = (character, location.to_string());
if let Some(pool) = self.monologue.get(&key) {
if let Some(lines) = pool.by_trigger.get(&trigger) {
results.extend(lines.iter());
}
}
// General pool fallback
if location != "general" {
let general_key = (character, "general".to_string());
if let Some(pool) = self.monologue.get(&general_key) {
if let Some(lines) = pool.by_trigger.get(&trigger) {
results.extend(lines.iter());
}
}
}
results
}
/// Returns total number of indexed dialogue lines.
pub fn dialogue_line_count(&self) -> usize {
self.dialogue.values().map(|p| p.lines.len()).sum()
}
/// Returns total number of indexed monologue lines.
pub fn monologue_line_count(&self) -> usize {
self.monologue
.values()
.flat_map(|p| p.by_trigger.values())
.map(|lines| lines.len())
.sum()
}
}
// ---------------------------------------------------------------------------
// Wrapper resource
// ---------------------------------------------------------------------------
/// Wrapper resource holding the indexed line pools.
/// Available for runtime systems that need to query dialogue/monologue lines
/// through the D-028 four-layer filtering pipeline.
#[derive(Resource, Debug)]
pub struct LinePoolIndexResource(pub LinePoolIndex);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_tier_parse_all_values() {
assert_eq!("public".parse::<AccessTier>().unwrap(), AccessTier::Public);
assert_eq!("insider".parse::<AccessTier>().unwrap(), AccessTier::Insider);
assert_eq!("authority".parse::<AccessTier>().unwrap(), AccessTier::Authority);
assert_eq!("peer".parse::<AccessTier>().unwrap(), AccessTier::Peer);
assert_eq!("hostile".parse::<AccessTier>().unwrap(), AccessTier::Hostile);
assert!("invalid".parse::<AccessTier>().is_err());
}
#[test]
fn trust_tier_ordering() {
assert!(TrustTier::Surface < TrustTier::Real);
assert!(TrustTier::Real < TrustTier::Secret);
assert!(TrustTier::Secret.meets(TrustTier::Secret));
assert!(TrustTier::Secret.meets(TrustTier::Surface));
assert!(!TrustTier::Surface.meets(TrustTier::Real));
}
#[test]
fn situation_parse_all_values() {
let values = [
"arrival", "shift_start", "shift_end", "shift_transition", "bar_evening",
"night_shift", "investigation", "confrontation", "social", "alone",
"emergency", "routine", "observation", "greeting", "first_meeting", "repeated_visit",
];
for v in values {
assert!(v.parse::<Situation>().is_ok(), "Failed to parse situation: {}", v);
}
assert!("invalid".parse::<Situation>().is_err());
}
#[test]
fn topic_parse_all_values() {
let values = [
"colleague", "routine", "cargo", "money", "trust", "danger",
"institution", "personal", "investigation",
];
for v in values {
assert!(v.parse::<Topic>().is_ok(), "Failed to parse topic: {}", v);
}
}
#[test]
fn mood_parse_all_values() {
let values = [
"anxious", "frustrated", "content", "suspicious", "warm",
"hostile", "relieved", "focused",
];
for v in values {
assert!(v.parse::<Mood>().is_ok(), "Failed to parse mood: {}", v);
}
}
#[test]
fn trigger_parse_all_values() {
let values = [
"enter_location", "observe_npc", "hear_sound", "observe_anomaly",
"post_conversation", "discover_evidence", "witness_interaction",
"time_idle", "return_visit",
];
for v in values {
assert!(v.parse::<Trigger>().is_ok(), "Failed to parse trigger: {}", v);
}
}
#[test]
fn character_parse() {
assert_eq!("smuggler".parse::<Character>().unwrap(), Character::Smuggler);
assert_eq!("detective".parse::<Character>().unwrap(), Character::Detective);
assert!("other".parse::<Character>().is_err());
}
}
+22 -4
View File
@@ -14,6 +14,8 @@ pub mod generator;
pub mod input;
pub mod interaction;
pub mod inventory;
pub mod knowledge_grant;
pub mod line_pool;
pub mod listening;
pub mod modification;
pub mod monologue;
@@ -33,6 +35,7 @@ pub mod stance;
pub mod tier;
pub mod time;
pub mod ticker;
pub mod triangle;
pub mod zone;
/// Core simulation plugin
@@ -58,8 +61,8 @@ impl Plugin for SimulationPlugin {
.init_resource::<monologue::PostConversationQueue>()
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
// Triangle escalation resources (#250)
.init_resource::<crate::content::template::TriangleCrisisEventQueue>()
.init_resource::<crate::content::template::ResolveTriangleQueue>()
.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>()
.init_resource::<crate::simulation::triangle::ResolveTriangleQueue>()
// discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin).
// Init here so SimulationPlugin works standalone in tests without PerceptionPlugin.
.init_resource::<crate::perception::query::VisibilityGeometry>()
@@ -116,11 +119,11 @@ impl Plugin for SimulationPlugin {
.after(crate::npc::awareness::detect_player_awareness)
.before(crate::perception::observer::compute_observer_snapshot),
// Triangle escalation (#250) — runs on game-minute boundaries (every 10 ticks)
crate::content::template::tick_triangle_escalation
crate::simulation::triangle::tick_triangle_escalation
.after(crate::npc::tolerance::check_tolerance_threshold)
.before(crate::perception::observer::compute_observer_snapshot),
// Triangle resolution (#250, D-089) — apply player resolve commands
crate::content::template::apply_resolve_triangle
crate::simulation::triangle::apply_resolve_triangle
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
time::advance_tick.after(path_follow::cleanup_path_blocked),
@@ -145,6 +148,21 @@ impl Plugin for SimulationPlugin {
.before(crate::perception::observer::compute_observer_snapshot),
);
// Voice enrichment (D-138, Phase 3) — rewrite NPC text with voiced
// variants from cache before the observer snapshot is assembled.
// No-op when VoiceCacheResource is absent (voice pipeline disabled).
app.add_systems(
Update,
(
crate::voice::integration::voice_enrich_dialogue_response
.after(crate::simulation::dialogue::process_talk_interaction)
.before(crate::perception::observer::compute_observer_snapshot),
crate::voice::integration::voice_enrich_conversation_events
.after(conversation::run_npc_conversations)
.before(crate::perception::observer::compute_observer_snapshot),
),
);
// Initialize TickerPool with empty default; populated by ContentPlugin at Startup.
app.init_resource::<ticker::TickerPool>();
+12 -445
View File
@@ -14,7 +14,6 @@ use bevy_ecs::prelude::*;
use rand::Rng;
use crate::bridge::types::MonologueEvent;
use crate::content::ContentStoreResource;
use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
use crate::perception::interpretation::ObservationTrigger;
use crate::simulation::conversation::NpcName;
@@ -23,13 +22,6 @@ use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::storyteller::EngagementRecord;
/// Minimum ticks between monologue lines (prevents spam).
/// At 10 ticks/game-minute, 300 ticks = 30 game-minutes.
const COOLDOWN_TICKS: u64 = 300;
/// Ticks of idle (no movement) before a time_idle monologue fires.
/// 100 ticks = 10 game-minutes.
const IDLE_THRESHOLD_TICKS: u64 = 100;
/// Display duration for monologue text on client (seconds).
const DISPLAY_DURATION: f32 = 5.0;
@@ -310,7 +302,6 @@ pub fn process_sprint_anomaly_monologue(
/// System ordering: after trigger_monologue, before process_sprint_anomaly_monologue.
pub fn trigger_recognition_monologue(
time: Res<SimulationTime>,
content: Option<Res<ContentStoreResource>>,
mut rng: ResMut<SimRng>,
mut query: Query<
(
@@ -355,21 +346,11 @@ pub fn trigger_recognition_monologue(
return;
};
// Try content pools for observe_anomaly trigger lines
let line = content
.as_deref()
.and_then(|c| select_pool_line("observe_anomaly", &state, c, &mut rng.rng));
// Use content pool line or hardcoded fallback
let (id, text) = if let Some((id, text)) = line {
(id, text)
} else {
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
(
RECOGNITION_LINES[i].0.to_string(),
RECOGNITION_LINES[i].1.to_string(),
)
};
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
let (id, text) = (
RECOGNITION_LINES[i].0.to_string(),
RECOGNITION_LINES[i].1.to_string(),
);
buffer.event = Some(MonologueEvent {
id: id.clone(),
@@ -395,63 +376,6 @@ pub fn trigger_recognition_monologue(
// Shared content pool selection (#119)
// ---------------------------------------------------------------------------
/// Select a monologue line from content pools, matching trigger and character.
/// Returns (id, text) or None if no matching lines exist.
/// Prefers unseen lines; falls back to repeats if all have been shown.
fn select_pool_line(
trigger: &str,
state: &MonologueState,
content: &ContentStoreResource,
rng: &mut impl Rng,
) -> Option<(String, String)> {
let character = state.character.as_str();
let mut candidates: Vec<(&str, &str)> = Vec::new();
for district in content.0.districts.values() {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != trigger {
continue;
}
if state.shown_ids.contains(&line.id) {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
if candidates.is_empty() {
// Fallback: allow repeats
for district in content.0.districts.values() {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != trigger {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
}
if candidates.is_empty() {
return None;
}
let index = rng.random_range(0..candidates.len());
Some((
candidates[index].0.to_string(),
candidates[index].1.to_string(),
))
}
/// Select from hardcoded fallback lines for the given trigger type.
fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, String) {
let lines = match trigger {
@@ -486,7 +410,7 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
///
/// Checks observation events, sound events, overheard conversations, and
/// completed dialogues for monologue-worthy triggers. Fires at most one
/// monologue per tick. Bypasses normal COOLDOWN_TICKS (event-driven),
/// monologue per tick. Bypasses cooldown (event-driven),
/// but updates last_fired_tick for periodic trigger cooldown tracking.
///
/// Priority order (first match wins):
@@ -500,7 +424,6 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
#[allow(clippy::too_many_arguments)]
pub fn trigger_event_monologue(
time: Res<SimulationTime>,
content: Option<Res<ContentStoreResource>>,
mut rng: ResMut<SimRng>,
observation_queue: Option<Res<crate::perception::interpretation::ObservationEventQueue>>,
sound_queue: Option<Res<crate::simulation::sound::SoundEventQueue>>,
@@ -567,16 +490,7 @@ pub fn trigger_event_monologue(
let Some(trigger) = trigger else { return };
// Select line: content pool first, hardcoded fallback second
let (id, text) = if let Some(ref content) = content {
if let Some(line) = select_pool_line(trigger, &state, content, &mut rng.rng) {
line
} else {
select_hardcoded_fallback(trigger, &mut rng.rng)
}
} else {
select_hardcoded_fallback(trigger, &mut rng.rng)
};
let (id, text) = select_hardcoded_fallback(trigger, &mut rng.rng);
buffer.event = Some(MonologueEvent {
id: id.clone(),
@@ -670,112 +584,16 @@ fn has_hear_sound_event(
///
/// v0.1 triggers:
/// - `enter_location`: fires once on first tick (session start)
/// - `time_idle`: fires after IDLE_THRESHOLD_TICKS of no player movement
/// - `time_idle`: fires after idle threshold of no player movement
pub fn trigger_monologue(
time: Res<SimulationTime>,
content: Option<Res<ContentStoreResource>>,
mut rng: ResMut<SimRng>,
mut query: Query<
_time: Res<SimulationTime>,
_rng: ResMut<SimRng>,
_query: Query<
(&TilePosition, &mut MonologueState, &mut MonologueBuffer),
With<PlayerCharacter>,
>,
) {
let Some(content) = content else { return };
let Ok((pos, mut state, mut buffer)) = query.single_mut() else {
return;
};
// Track idle time
let current_pos = (pos.x, pos.y);
if let Some(last) = state.last_position {
if last == current_pos {
state.idle_ticks += 1;
} else {
state.idle_ticks = 0;
}
}
state.last_position = Some(current_pos);
// Cooldown check
if time.tick > 0 && time.tick - state.last_fired_tick < COOLDOWN_TICKS {
return;
}
// Determine which trigger to attempt
let trigger = if !state.entered {
state.entered = true;
Some("enter_location")
} else if state.idle_ticks >= IDLE_THRESHOLD_TICKS {
Some("time_idle")
} else {
None
};
let Some(trigger) = trigger else { return };
// Collect candidate lines from all district monologue pools
let character = state.character.as_str();
let mut candidates: Vec<(&str, &str)> = Vec::new(); // (id, text)
for district in content.0.districts.values() {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != trigger {
continue;
}
if state.shown_ids.contains(&line.id) {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
if candidates.is_empty() {
// All lines for this trigger have been shown; allow repeats
for district in content.0.districts.values() {
for pool in &district.monologue_pools {
if pool.character != character {
continue;
}
for line in &pool.lines {
if line.trigger != trigger {
continue;
}
candidates.push((&line.id, &line.text));
}
}
}
}
if candidates.is_empty() {
return;
}
// Select a random line
let index = rng.rng.random_range(0..candidates.len());
let (id, text) = candidates[index];
buffer.event = Some(MonologueEvent {
id: id.to_string(),
text: text.to_string(),
duration_seconds: DISPLAY_DURATION,
});
state.shown_ids.insert(id.to_string());
state.last_fired_tick = time.tick;
// Reset idle counter so time_idle doesn't fire again immediately
state.idle_ticks = 0;
tracing::debug!(
"Monologue fired: trigger={}, id={}, tick={}",
trigger,
id,
time.tick
);
// v0.2: content pool removed; line selection deferred to generator pipeline
}
// ---------------------------------------------------------------------------
@@ -877,137 +695,10 @@ pub fn process_contradiction_monologue(
#[cfg(test)]
mod tests {
use super::*;
use crate::content::loader::{ContentStore, DistrictContent};
use crate::content::types::{MonologueLine, MonologuePool};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use bevy_ecs::world::World;
fn setup_world_with_content() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
// Create test monologue content
let pool = MonologuePool {
character: "detective".to_string(),
location: "general".to_string(),
lines: vec![
MonologueLine {
id: "test_enter_001".to_string(),
text: "Sova Transit District. Let's narrow that down.".to_string(),
trigger: "enter_location".to_string(),
prerequisites: None,
priority: None,
cooldown: None,
tags: vec![],
},
MonologueLine {
id: "test_idle_001".to_string(),
text: "Everyone knows I'm Commission.".to_string(),
trigger: "time_idle".to_string(),
prerequisites: None,
priority: None,
cooldown: None,
tags: vec![],
},
],
};
let mut district = DistrictContent::default();
district.monologue_pools.push(pool);
let mut store = ContentStore::default();
store.districts.insert("test".to_string(), district);
world.insert_resource(ContentStoreResource(store));
world
}
#[test]
fn enter_location_fires_on_first_tick() {
let mut world = setup_world_with_content();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_some());
let event = buffer.event.as_ref().unwrap();
assert_eq!(event.id, "test_enter_001");
}
#[test]
fn cooldown_prevents_spam() {
let mut world = setup_world_with_content();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_monologue);
// First tick: should fire enter_location
schedule.run(&mut world);
// Consume the buffer
let mut query = world.query::<&mut MonologueBuffer>();
query.single_mut(&mut world).unwrap().take();
// Advance a few ticks (still in cooldown)
world.resource_mut::<SimulationTime>().tick = 10;
// Set idle ticks high to try to trigger time_idle
let mut state_query = world.query::<&mut MonologueState>();
state_query.single_mut(&mut world).unwrap().idle_ticks = IDLE_THRESHOLD_TICKS + 1;
schedule.run(&mut world);
// Should NOT fire — cooldown active
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_none());
}
#[test]
fn time_idle_fires_after_threshold() {
let mut world = setup_world_with_content();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState {
entered: true, // Skip enter_location
last_position: Some((5, 5)),
idle_ticks: IDLE_THRESHOLD_TICKS, // At threshold
..Default::default()
},
MonologueBuffer::default(),
));
// Advance past cooldown
world.resource_mut::<SimulationTime>().tick = COOLDOWN_TICKS + 1;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_some());
let event = buffer.event.as_ref().unwrap();
assert_eq!(event.id, "test_idle_001");
}
// -----------------------------------------------------------------------
// SprintAnomalyQueue unit tests (#428, D-055)
// -----------------------------------------------------------------------
@@ -2095,58 +1786,6 @@ mod tests {
);
}
#[test]
fn event_uses_content_pool_when_available() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
// Set up content pool with a witness_interaction line
let pool = MonologuePool {
character: "detective".to_string(),
location: "general".to_string(),
lines: vec![MonologueLine {
id: "pool_witness_01".to_string(),
text: "She's lying to him.".to_string(),
trigger: "witness_interaction".to_string(),
prerequisites: None,
priority: None,
cooldown: None,
tags: vec![],
}],
};
let mut district = DistrictContent::default();
district.monologue_pools.push(pool);
let mut store = ContentStore::default();
store.districts.insert("test".to_string(), district);
world.insert_resource(ContentStoreResource(store));
// Push a conversation event
world
.get_mut::<ConversationEventBuffer>(player)
.unwrap()
.events
.push(crate::simulation::conversation::ConversationEvent {
occluded_line: "Test".to_string(),
speaker_id: 100,
target_id: 101,
speaker_name: "A".to_string(),
target_name: "B".to_string(),
speaker_color_index: 0,
target_color_index: 1,
});
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(buf.event.is_some());
assert_eq!(
buf.event.as_ref().unwrap().id,
"pool_witness_01",
"should use content pool line over hardcoded fallback"
);
}
#[test]
fn hardcoded_lines_all_valid() {
for lines in &[
@@ -2167,13 +1806,6 @@ mod tests {
// Constant assertions
// -----------------------------------------------------------------------
#[test]
fn cooldown_ticks_constant_is_300() {
// D-035: 300 ticks = 30 game-minutes at 10 ticks/game-minute (D-031).
// If this changes, players will see more/less monologue spam.
assert_eq!(COOLDOWN_TICKS, 300, "D-035: COOLDOWN_TICKS must be 300");
}
// -----------------------------------------------------------------------
// hear_sound: only Machinery and Alert trigger (not Voice/Ambient/Footstep)
// -----------------------------------------------------------------------
@@ -2230,71 +1862,6 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// observe_anomaly content pool integration via recognition monologue
// -----------------------------------------------------------------------
#[test]
fn recognition_monologue_uses_observe_anomaly_content_pool_key() {
// When a content pool has lines with trigger="observe_anomaly",
// trigger_recognition_monologue should select from that pool (not hardcoded fallback).
// This verifies the content key matches the implementation.
let mut world = setup_recognition_world();
let pool = MonologuePool {
character: "detective".to_string(),
location: "general".to_string(),
lines: vec![MonologueLine {
id: "observe_anomaly_pool_01".to_string(),
text: "That person shouldn't be here.".to_string(),
trigger: "observe_anomaly".to_string(),
prerequisites: None,
priority: None,
cooldown: None,
tags: vec![],
}],
};
let mut district = DistrictContent::default();
district.monologue_pools.push(pool);
let mut store = ContentStore::default();
store.districts.insert("test".to_string(), district);
world.insert_resource(ContentStoreResource(store));
let target = world.spawn_empty().id();
let mut cd = CognitiveDelay::default();
cd.push(PendingRecognition {
target,
stable_id: StableId(1),
position: TilePosition::new(5, 5, 0),
delay_until_tick: NORMAL_DELAY_TICKS,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
world.spawn((
PlayerCharacter,
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
cd,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(trigger_recognition_monologue);
schedule.run(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buffer = buf_query.single(&world).unwrap();
assert!(buffer.event.is_some(), "recognition monologue should fire");
assert_eq!(
buffer.event.as_ref().unwrap().id,
"observe_anomaly_pool_01",
"should use content pool line with trigger='observe_anomaly' key"
);
}
#[test]
fn observation_tick_tracking_updated() {
let mut world = setup_event_world();
+4 -4
View File
@@ -16,7 +16,7 @@ use thiserror::Error;
use crate::bridge::types::SaveLoadResultWire;
use crate::bridge::types::SnapshotBuffer;
use crate::content::template::{TemplateReferenceMap, TriangleState};
use crate::simulation::triangle::{TemplateReferenceMap, TriangleState};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::Npc;
@@ -30,7 +30,7 @@ use crate::knowledge::types::StableId;
use crate::simulation::interaction::DoorState;
use crate::simulation::tier::{ActiveSim, BackgroundSim};
use crate::simulation::time::SimulationTime;
use crate::content::template::TriangleCrisisEventQueue;
use crate::simulation::triangle::TriangleCrisisEventQueue;
use crate::storyteller::{
ActivationState, ContaminationActive, ContaminationEventQueue, MovementHistoryBuffer,
TriangleActivatedQueue,
@@ -594,7 +594,7 @@ mod tests {
#[test]
fn load_from_file_rejects_wrong_format_version() {
use crate::content::template::TemplateReferenceMap;
use crate::simulation::triangle::TemplateReferenceMap;
// Craft a save with a wrong format_version
let bad_state = SaveStateV1 {
format_version: 0xFF, // deliberately wrong
@@ -775,7 +775,7 @@ mod tests {
// -----------------------------------------------------------------------
fn make_test_triangle(slug: &str, tension: u8) -> TriangleState {
use crate::content::template::{
use crate::simulation::triangle::{
RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase,
};
let mut role_assignments = std::collections::BTreeMap::new();
+1 -1
View File
@@ -39,7 +39,7 @@ use bevy_ecs::entity::Entity;
use bevy_ecs::world::World;
use serde::{Deserialize, Serialize};
use crate::content::template::{TemplateOwnership, TemplateReferenceMap, TriangleState};
use crate::simulation::triangle::{TemplateOwnership, TemplateReferenceMap, TriangleState};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::StableId;
File diff suppressed because it is too large Load Diff