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>
595 lines
18 KiB
Rust
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());
|
|
}
|
|
}
|