Files
settled-reach/server/src/simulation/line_pool.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

595 lines
18 KiB
Rust

//! 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 tracing::trace;
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> {
if active_situations.is_empty() {
trace!(
location,
role,
"query_dialogue: active_situations is empty — Layer 2 will filter all lines"
);
}
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());
}
}