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