InteractionMemory component tracks interaction_count, last_interaction_tick, and notable_events per NPC. Drives D-028 Layer 2 situation activation: first_meeting (count==0) and repeated_visit (count>=3). warm_active in mood system now derives from InteractionMemory within a 300-tick window. Trust progression wired into dialogue systems: talk completion (+1), walk-away (-1), confrontation (-2) emit TrustEvents consumed by update_trust. InteractionEvent (WalkAway, Confrontation) recorded in notable_events for fast per-pair access. Adds FirstMeeting and RepeatedVisit Situation variants. 18 unit tests in interaction.rs. All arithmetic integer-only (D-010 determinism). No HashMap. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1220 lines
38 KiB
Rust
1220 lines
38 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. Built from the raw ContentStore after YAML deserialization.
|
|
//!
|
|
//! 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 crate::content::loader::ContentStore;
|
|
use crate::content::types;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
#[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,
|
|
/// 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),
|
|
"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.
|
|
/// D-035 amendment (Sprint 8): `Focused` added as 9th variant.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub enum Mood {
|
|
Fond,
|
|
Comfortable,
|
|
Worried,
|
|
Suspicious,
|
|
Analytical,
|
|
Conflicted,
|
|
Concerned,
|
|
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 {
|
|
"fond" => Ok(Self::Fond),
|
|
"comfortable" => Ok(Self::Comfortable),
|
|
"worried" => Ok(Self::Worried),
|
|
"suspicious" => Ok(Self::Suspicious),
|
|
"analytical" => Ok(Self::Analytical),
|
|
"conflicted" => Ok(Self::Conflicted),
|
|
"concerned" => Ok(Self::Concerned),
|
|
"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<types::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<types::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.
|
|
///
|
|
/// Built from ContentStore at startup (and rebuilt on hot-reload).
|
|
/// 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 {
|
|
/// Build the index from a ContentStore.
|
|
///
|
|
/// Parses string tags into typed enums. Lines with invalid required tags
|
|
/// are skipped with a warning log.
|
|
pub fn build(store: &ContentStore) -> Self {
|
|
let mut index = Self::default();
|
|
|
|
for district in store.districts.values() {
|
|
for pool in &district.dialogue_pools {
|
|
index.index_dialogue_pool(pool);
|
|
}
|
|
for pool in &district.monologue_pools {
|
|
index.index_monologue_pool(pool);
|
|
}
|
|
}
|
|
|
|
index
|
|
}
|
|
|
|
fn index_dialogue_pool(&mut self, pool: &types::DialoguePool) {
|
|
if pool.location.is_empty() {
|
|
tracing::warn!(
|
|
"Skipping dialogue pool with empty location (role={})",
|
|
pool.role,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let key = (pool.location.clone(), pool.role.clone());
|
|
|
|
let indexed_lines: Vec<IndexedDialogueLine> =
|
|
pool.lines.iter().filter_map(parse_dialogue_line).collect();
|
|
|
|
let entry = self
|
|
.dialogue
|
|
.entry(key)
|
|
.or_insert_with(|| IndexedDialoguePool {
|
|
location: pool.location.clone(),
|
|
role: pool.role.clone(),
|
|
lines: Vec::new(),
|
|
});
|
|
entry.lines.extend(indexed_lines);
|
|
}
|
|
|
|
fn index_monologue_pool(&mut self, pool: &types::MonologuePool) {
|
|
if pool.location.is_empty() {
|
|
tracing::warn!(
|
|
"Skipping monologue pool with empty location (character={})",
|
|
pool.character,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let character = match pool.character.parse::<Character>() {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
tracing::warn!("Skipping monologue pool: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let key = (character, pool.location.clone());
|
|
let entry = self
|
|
.monologue
|
|
.entry(key)
|
|
.or_insert_with(|| IndexedMonologuePool {
|
|
character,
|
|
location: pool.location.clone(),
|
|
by_trigger: BTreeMap::new(),
|
|
});
|
|
|
|
for line in &pool.lines {
|
|
if let Some(indexed) = parse_monologue_line(line) {
|
|
entry
|
|
.by_trigger
|
|
.entry(indexed.trigger)
|
|
.or_default()
|
|
.push(indexed);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parsing helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Parse a raw DialogueLine into an indexed line with typed enums.
|
|
/// Returns None if any required enum field fails to parse.
|
|
fn parse_dialogue_line(line: &types::DialogueLine) -> Option<IndexedDialogueLine> {
|
|
let access: Vec<AccessTier> = line
|
|
.access
|
|
.iter()
|
|
.filter_map(|s| {
|
|
s.parse()
|
|
.map_err(|e: ParseEnumError| {
|
|
tracing::warn!("Line {}: {}", line.id, e);
|
|
})
|
|
.ok()
|
|
})
|
|
.collect();
|
|
|
|
if access.is_empty() {
|
|
tracing::warn!("Line {}: no valid access tiers, skipping", line.id);
|
|
return None;
|
|
}
|
|
|
|
let trust = match line.trust.parse::<TrustTier>() {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
tracing::warn!("Line {}: {}, skipping", line.id, e);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let situation: Vec<Situation> = line
|
|
.situation
|
|
.iter()
|
|
.filter_map(|s| {
|
|
s.parse()
|
|
.map_err(|e: ParseEnumError| {
|
|
tracing::warn!("Line {}: {}", line.id, e);
|
|
})
|
|
.ok()
|
|
})
|
|
.collect();
|
|
|
|
if situation.is_empty() {
|
|
tracing::warn!("Line {}: no valid situations, skipping", line.id);
|
|
return None;
|
|
}
|
|
|
|
let topic: Vec<Topic> = line
|
|
.topic
|
|
.iter()
|
|
.filter_map(|s| {
|
|
s.parse()
|
|
.map_err(|e: ParseEnumError| {
|
|
tracing::warn!("Line {}: {}", line.id, e);
|
|
})
|
|
.ok()
|
|
})
|
|
.collect();
|
|
let mood: Vec<Mood> = line
|
|
.mood
|
|
.iter()
|
|
.filter_map(|s| {
|
|
s.parse()
|
|
.map_err(|e: ParseEnumError| {
|
|
tracing::warn!("Line {}: {}", line.id, e);
|
|
})
|
|
.ok()
|
|
})
|
|
.collect();
|
|
|
|
Some(IndexedDialogueLine {
|
|
id: line.id.clone(),
|
|
text: line.text.clone(),
|
|
role: line.role.clone(),
|
|
access,
|
|
trust,
|
|
situation,
|
|
topic,
|
|
mood,
|
|
tags: line.tags.clone(),
|
|
knowledge_grant: line.knowledge_grant.clone(),
|
|
})
|
|
}
|
|
|
|
/// Parse a raw MonologueLine into an indexed line with typed enums.
|
|
/// Returns None if the trigger fails to parse.
|
|
fn parse_monologue_line(line: &types::MonologueLine) -> Option<IndexedMonologueLine> {
|
|
let trigger = match line.trigger.parse::<Trigger>() {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
tracing::warn!("Line {}: {}, skipping", line.id, e);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
Some(IndexedMonologueLine {
|
|
id: line.id.clone(),
|
|
text: line.text.clone(),
|
|
trigger,
|
|
prerequisites: line.prerequisites.clone(),
|
|
priority: line.priority.unwrap_or(5).clamp(0, 10) as u8,
|
|
cooldown: line.cooldown.unwrap_or(0).max(0) as u32,
|
|
tags: line.tags.clone(),
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::content::loader::{ContentStore, DistrictContent};
|
|
|
|
// -- Enum parsing tests --------------------------------------------------
|
|
|
|
#[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",
|
|
];
|
|
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 = [
|
|
"fond",
|
|
"comfortable",
|
|
"worried",
|
|
"suspicious",
|
|
"analytical",
|
|
"conflicted",
|
|
"concerned",
|
|
"relieved",
|
|
];
|
|
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());
|
|
}
|
|
|
|
// -- Helper: build a test ContentStore -----------------------------------
|
|
|
|
fn make_dialogue_line(
|
|
id: &str,
|
|
access: &[&str],
|
|
trust: &str,
|
|
situations: &[&str],
|
|
) -> types::DialogueLine {
|
|
types::DialogueLine {
|
|
id: id.to_string(),
|
|
text: format!("Text for {}", id),
|
|
role: "worker".to_string(),
|
|
access: access.iter().map(|s| s.to_string()).collect(),
|
|
trust: trust.to_string(),
|
|
situation: situations.iter().map(|s| s.to_string()).collect(),
|
|
topic: vec![],
|
|
mood: vec![],
|
|
tags: vec![],
|
|
knowledge_grant: None,
|
|
}
|
|
}
|
|
|
|
fn make_monologue_line(id: &str, trigger: &str, priority: i32) -> types::MonologueLine {
|
|
types::MonologueLine {
|
|
id: id.to_string(),
|
|
text: format!("Monologue {}", id),
|
|
trigger: trigger.to_string(),
|
|
prerequisites: None,
|
|
priority: Some(priority),
|
|
cooldown: None,
|
|
tags: vec![],
|
|
}
|
|
}
|
|
|
|
fn test_store() -> ContentStore {
|
|
let mut store = ContentStore::default();
|
|
|
|
let dialogue_pools = vec![types::DialoguePool {
|
|
location: "the-terminal".to_string(),
|
|
role: "dock-worker".to_string(),
|
|
lines: vec![
|
|
make_dialogue_line(
|
|
"the-terminal_d_001",
|
|
&["public"],
|
|
"surface",
|
|
&["arrival", "social"],
|
|
),
|
|
make_dialogue_line(
|
|
"the-terminal_d_002",
|
|
&["insider", "peer"],
|
|
"real",
|
|
&["bar_evening"],
|
|
),
|
|
make_dialogue_line(
|
|
"the-terminal_d_003",
|
|
&["insider"],
|
|
"secret",
|
|
&["investigation"],
|
|
),
|
|
],
|
|
}];
|
|
|
|
let monologue_pools = vec![
|
|
types::MonologuePool {
|
|
character: "smuggler".to_string(),
|
|
location: "the-terminal".to_string(),
|
|
lines: vec![
|
|
make_monologue_line("the-terminal_m_s_001", "enter_location", 5),
|
|
make_monologue_line("the-terminal_m_s_002", "observe_npc", 7),
|
|
],
|
|
},
|
|
types::MonologuePool {
|
|
character: "smuggler".to_string(),
|
|
location: "general".to_string(),
|
|
lines: vec![make_monologue_line("general_m_s_001", "enter_location", 3)],
|
|
},
|
|
];
|
|
|
|
let district = DistrictContent {
|
|
dialogue_pools,
|
|
monologue_pools,
|
|
..Default::default()
|
|
};
|
|
store
|
|
.districts
|
|
.insert("test.district".to_string(), district);
|
|
|
|
store
|
|
}
|
|
|
|
// -- Index building tests ------------------------------------------------
|
|
|
|
#[test]
|
|
fn build_indexes_dialogue_pools() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
assert_eq!(index.dialogue.len(), 1);
|
|
let pool = index
|
|
.dialogue
|
|
.get(&("the-terminal".to_string(), "dock-worker".to_string()))
|
|
.unwrap();
|
|
assert_eq!(pool.lines.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn build_indexes_monologue_pools() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
assert_eq!(index.monologue.len(), 2);
|
|
let loc_pool = index
|
|
.monologue
|
|
.get(&(Character::Smuggler, "the-terminal".to_string()))
|
|
.unwrap();
|
|
assert_eq!(loc_pool.by_trigger.len(), 2);
|
|
|
|
let general_pool = index
|
|
.monologue
|
|
.get(&(Character::Smuggler, "general".to_string()))
|
|
.unwrap();
|
|
assert_eq!(general_pool.by_trigger.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn line_counts() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
assert_eq!(index.dialogue_line_count(), 3);
|
|
assert_eq!(index.monologue_line_count(), 3);
|
|
}
|
|
|
|
// -- Dialogue query tests (D-028 Layers 1-3) ----------------------------
|
|
|
|
#[test]
|
|
fn query_dialogue_layer1_access_filter() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
// Public access should only get the public line
|
|
let results = index.query_dialogue(
|
|
"the-terminal",
|
|
"dock-worker",
|
|
AccessTier::Public,
|
|
&[Situation::Arrival],
|
|
TrustTier::Secret,
|
|
);
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].id, "the-terminal_d_001");
|
|
}
|
|
|
|
#[test]
|
|
fn query_dialogue_layer2_situation_filter() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
// Insider + secret trust but only bar_evening situation
|
|
let results = index.query_dialogue(
|
|
"the-terminal",
|
|
"dock-worker",
|
|
AccessTier::Insider,
|
|
&[Situation::BarEvening],
|
|
TrustTier::Secret,
|
|
);
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].id, "the-terminal_d_002");
|
|
}
|
|
|
|
#[test]
|
|
fn query_dialogue_layer3_trust_filter() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
// Insider + investigation but only surface trust — should miss line 003 (secret)
|
|
let results = index.query_dialogue(
|
|
"the-terminal",
|
|
"dock-worker",
|
|
AccessTier::Insider,
|
|
&[Situation::Investigation],
|
|
TrustTier::Surface,
|
|
);
|
|
assert_eq!(results.len(), 0);
|
|
|
|
// With real trust — still no (line 003 requires secret)
|
|
let results = index.query_dialogue(
|
|
"the-terminal",
|
|
"dock-worker",
|
|
AccessTier::Insider,
|
|
&[Situation::Investigation],
|
|
TrustTier::Real,
|
|
);
|
|
assert_eq!(results.len(), 0);
|
|
|
|
// With secret trust — line 003 passes
|
|
let results = index.query_dialogue(
|
|
"the-terminal",
|
|
"dock-worker",
|
|
AccessTier::Insider,
|
|
&[Situation::Investigation],
|
|
TrustTier::Secret,
|
|
);
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].id, "the-terminal_d_003");
|
|
}
|
|
|
|
#[test]
|
|
fn query_dialogue_multiple_situations() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
// Insider with multiple situations should get lines from both
|
|
let results = index.query_dialogue(
|
|
"the-terminal",
|
|
"dock-worker",
|
|
AccessTier::Insider,
|
|
&[Situation::Arrival, Situation::BarEvening],
|
|
TrustTier::Real,
|
|
);
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].id, "the-terminal_d_002");
|
|
}
|
|
|
|
#[test]
|
|
fn query_dialogue_nonexistent_pool() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
let results = index.query_dialogue(
|
|
"nonexistent",
|
|
"worker",
|
|
AccessTier::Public,
|
|
&[Situation::Arrival],
|
|
TrustTier::Surface,
|
|
);
|
|
assert!(results.is_empty());
|
|
}
|
|
|
|
// -- Monologue query tests -----------------------------------------------
|
|
|
|
#[test]
|
|
fn query_monologue_location_specific() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
let results =
|
|
index.query_monologue(Character::Smuggler, "the-terminal", Trigger::ObserveNpc);
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].id, "the-terminal_m_s_002");
|
|
}
|
|
|
|
#[test]
|
|
fn query_monologue_includes_general_pool() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
// enter_location: 1 from the-terminal + 1 from general
|
|
let results =
|
|
index.query_monologue(Character::Smuggler, "the-terminal", Trigger::EnterLocation);
|
|
assert_eq!(results.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn query_monologue_general_only() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
// Unknown location — only general pool matches
|
|
let results = index.query_monologue(
|
|
Character::Smuggler,
|
|
"unknown-location",
|
|
Trigger::EnterLocation,
|
|
);
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].id, "general_m_s_001");
|
|
}
|
|
|
|
#[test]
|
|
fn query_monologue_wrong_character() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
// Detective character — no pools exist
|
|
let results =
|
|
index.query_monologue(Character::Detective, "the-terminal", Trigger::EnterLocation);
|
|
assert!(results.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn query_monologue_no_trigger_match() {
|
|
let store = test_store();
|
|
let index = LinePoolIndex::build(&store);
|
|
|
|
let results = index.query_monologue(
|
|
Character::Smuggler,
|
|
"the-terminal",
|
|
Trigger::DiscoverEvidence,
|
|
);
|
|
assert!(results.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn query_monologue_line_with_prerequisite_excluded_when_fact_absent() {
|
|
// H10: Verify prerequisite field is populated through query so callers
|
|
// can filter. query_monologue returns ALL matching lines (prerequisite
|
|
// evaluation is caller's responsibility per D-028), but a line with a
|
|
// prerequisite should carry that data through for the caller to check.
|
|
let mut store = ContentStore::default();
|
|
let monologue_pools = vec![types::MonologuePool {
|
|
character: "smuggler".to_string(),
|
|
location: "the-terminal".to_string(),
|
|
lines: vec![
|
|
// Line WITHOUT prerequisite — should always be available
|
|
types::MonologueLine {
|
|
id: "prereq_none".to_string(),
|
|
text: "No prereq line".to_string(),
|
|
trigger: "enter_location".to_string(),
|
|
prerequisites: None,
|
|
priority: Some(5),
|
|
cooldown: None,
|
|
tags: vec![],
|
|
},
|
|
// Line WITH prerequisite — caller must check before using
|
|
types::MonologueLine {
|
|
id: "prereq_fact".to_string(),
|
|
text: "Requires cargo_manifest_seen".to_string(),
|
|
trigger: "enter_location".to_string(),
|
|
prerequisites: Some(types::Prerequisites {
|
|
facts: vec![types::FactPrerequisite {
|
|
fact_id: "cargo_manifest_seen".to_string(),
|
|
min_confidence: "confirmed".to_string(),
|
|
}],
|
|
entity_attributes: vec![],
|
|
relationship: None,
|
|
}),
|
|
priority: Some(8),
|
|
cooldown: None,
|
|
tags: vec![],
|
|
},
|
|
],
|
|
}];
|
|
let district = DistrictContent {
|
|
monologue_pools,
|
|
..Default::default()
|
|
};
|
|
store
|
|
.districts
|
|
.insert("test.district".to_string(), district);
|
|
|
|
let index = LinePoolIndex::build(&store);
|
|
let results =
|
|
index.query_monologue(Character::Smuggler, "the-terminal", Trigger::EnterLocation);
|
|
|
|
// Both lines are returned (query doesn't filter prerequisites)
|
|
assert_eq!(results.len(), 2);
|
|
|
|
// Verify the prerequisite-bearing line carries its prerequisites through
|
|
let prereq_line = results.iter().find(|l| l.id == "prereq_fact").unwrap();
|
|
assert!(
|
|
prereq_line.prerequisites.is_some(),
|
|
"prerequisite field should be populated for caller to evaluate"
|
|
);
|
|
let prereqs = prereq_line.prerequisites.as_ref().unwrap();
|
|
assert_eq!(prereqs.facts.len(), 1);
|
|
assert_eq!(prereqs.facts[0].fact_id, "cargo_manifest_seen");
|
|
|
|
// The no-prerequisite line should have None
|
|
let no_prereq_line = results.iter().find(|l| l.id == "prereq_none").unwrap();
|
|
assert!(
|
|
no_prereq_line.prerequisites.is_none(),
|
|
"line without prerequisites should have None"
|
|
);
|
|
|
|
// Simulate caller-side filtering: if fact is absent, exclude the line
|
|
let player_known_facts: Vec<&str> = vec![]; // empty — fact not known
|
|
let available: Vec<_> = results
|
|
.iter()
|
|
.filter(|line| {
|
|
match &line.prerequisites {
|
|
None => true, // no prerequisites = always available
|
|
Some(prereqs) => prereqs
|
|
.facts
|
|
.iter()
|
|
.all(|f| player_known_facts.contains(&f.fact_id.as_str())),
|
|
}
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
available.len(),
|
|
1,
|
|
"only the no-prerequisite line should pass when fact is absent"
|
|
);
|
|
assert_eq!(available[0].id, "prereq_none");
|
|
}
|
|
|
|
// -- Parse edge cases ----------------------------------------------------
|
|
|
|
#[test]
|
|
fn dialogue_line_with_invalid_access_is_skipped() {
|
|
let line = types::DialogueLine {
|
|
id: "test_d_001".to_string(),
|
|
text: "test".to_string(),
|
|
role: "worker".to_string(),
|
|
access: vec!["invalid".to_string()],
|
|
trust: "surface".to_string(),
|
|
situation: vec!["arrival".to_string()],
|
|
topic: vec![],
|
|
mood: vec![],
|
|
tags: vec![],
|
|
knowledge_grant: None,
|
|
};
|
|
assert!(parse_dialogue_line(&line).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn dialogue_line_with_invalid_trust_is_skipped() {
|
|
let line = types::DialogueLine {
|
|
id: "test_d_001".to_string(),
|
|
text: "test".to_string(),
|
|
role: "worker".to_string(),
|
|
access: vec!["public".to_string()],
|
|
trust: "invalid".to_string(),
|
|
situation: vec!["arrival".to_string()],
|
|
topic: vec![],
|
|
mood: vec![],
|
|
tags: vec![],
|
|
knowledge_grant: None,
|
|
};
|
|
assert!(parse_dialogue_line(&line).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn monologue_line_defaults() {
|
|
let line = types::MonologueLine {
|
|
id: "test_m_s_001".to_string(),
|
|
text: "test".to_string(),
|
|
trigger: "enter_location".to_string(),
|
|
prerequisites: None,
|
|
priority: None,
|
|
cooldown: None,
|
|
tags: vec![],
|
|
};
|
|
let indexed = parse_monologue_line(&line).unwrap();
|
|
assert_eq!(indexed.priority, 5); // default
|
|
assert_eq!(indexed.cooldown, 0); // default
|
|
}
|
|
|
|
#[test]
|
|
fn monologue_priority_clamped() {
|
|
let line = types::MonologueLine {
|
|
id: "test_m_s_001".to_string(),
|
|
text: "test".to_string(),
|
|
trigger: "enter_location".to_string(),
|
|
prerequisites: None,
|
|
priority: Some(15), // over max
|
|
cooldown: None,
|
|
tags: vec![],
|
|
};
|
|
let indexed = parse_monologue_line(&line).unwrap();
|
|
assert_eq!(indexed.priority, 10); // clamped
|
|
}
|
|
}
|