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