Files
settled-reach/server/src/content/line_pool.rs
T
jpmschweitzerandClaude Sonnet 4.6 dcd15a330f feat(simulation): interaction tracking + trust progression (#325, #324)
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>
2026-02-20 18:52:23 +01:00

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
}
}