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
+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();