DisclosureCandidates component with 7 trigger gates, three-layer rate limiting, and two-stage trait filter (what + how). Cautious/Gossipy/Loyal/ Talkative predicates via TraitModifierConfig. POI component and discovery system. Implements D-081, D-082 step 2. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
//! Unprompted disclosure system (D-081).
|
||||
//!
|
||||
//! Two-system pipeline:
|
||||
//! - `derive_disclosure_candidates`: per-NPC KG filter, recomputed every 30 ticks
|
||||
//! - `process_unprompted_disclosure`: trigger gates, StableId-ordered firing
|
||||
//!
|
||||
//! NPCs check only their own KG (D-010 principle 2 — no cross-entity KG reads).
|
||||
//! Disclosure grants the fact to the player's KG via `KnowledgeGranted` event
|
||||
//! and emits a placeholder `MonologueEvent` (Layer 4 line selection in #172).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::MonologueEvent;
|
||||
use crate::knowledge::events::{
|
||||
KnowledgeEvent, KnowledgeEventType, ProcessedFactGrant, ProcessedKnowledgeGrant,
|
||||
};
|
||||
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, KnowledgeState, StableId};
|
||||
use crate::knowledge::{KnowledgeEventQueue, KnowledgeGraph, StableEntityId};
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::relationships::RelationshipGraph;
|
||||
use crate::npc::trait_modifiers::{traits_to_keys, TraitModifierConfig};
|
||||
use crate::npc::{Contentment, Npc, PersonalityTraits};
|
||||
use crate::simulation::monologue::MonologueBuffer;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Recompute `DisclosureCandidates` every N ticks (30 ticks = 3 game-minutes at 10 tps).
|
||||
const CANDIDATE_REFRESH_TICKS: u64 = 30;
|
||||
|
||||
/// Per-NPC cooldown ticks after a disclosure fires (300 = 30 game-minutes).
|
||||
const NPC_COOLDOWN_TICKS: u64 = 300;
|
||||
|
||||
/// Minimum ticks between any two disclosures (global rate limit).
|
||||
const GLOBAL_RATE_LIMIT_TICKS: u64 = 10;
|
||||
|
||||
/// Max candidates retained in `DisclosureCandidates`.
|
||||
const MAX_CANDIDATES: usize = 10;
|
||||
|
||||
/// Witness inhibition check radius (Manhattan distance, tiles).
|
||||
const WITNESS_RADIUS: u32 = 5;
|
||||
|
||||
/// Minimum NPC→player trust for Surface-tier disclosure.
|
||||
const SURFACE_TRUST: i8 = 0;
|
||||
|
||||
/// NPC→player trust at which witness inhibition is waived (Secret tier, D-081).
|
||||
const SECRET_TRUST: i8 = 7;
|
||||
|
||||
/// Trust level below which a nearby NPC counts as an untrusted witness.
|
||||
const REAL_TRUST: i8 = 3;
|
||||
|
||||
/// Player proximity range for candidate recompute (tiles). Matches voice range.
|
||||
const PLAYER_RANGE_TILES: u32 = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-NPC computed disclosure candidate pool (D-081).
|
||||
///
|
||||
/// Recomputed every `CANDIDATE_REFRESH_TICKS` ticks by `derive_disclosure_candidates`.
|
||||
/// Consumed by `process_unprompted_disclosure` when all trigger gates pass.
|
||||
///
|
||||
/// Facts are sorted: confidence desc, then `acquired_tick` desc. Capped at
|
||||
/// `MAX_CANDIDATES`.
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct DisclosureCandidates {
|
||||
/// Fact IDs eligible for disclosure, ordered by priority.
|
||||
pub candidates: Vec<FactId>,
|
||||
/// Tick when this pool was last computed. 0 = never computed.
|
||||
pub computed_tick: u64,
|
||||
}
|
||||
|
||||
/// Per-NPC disclosure cooldown state (D-081).
|
||||
///
|
||||
/// Tracks which facts have been disclosed during the current window
|
||||
/// (primary narrative quality gate) and the per-NPC silence period.
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct DisclosureCooldown {
|
||||
/// Facts disclosed this window — prevents repeating the same fact.
|
||||
pub per_fact_history: BTreeSet<FactId>,
|
||||
/// Tick when the per-NPC cooldown expires. 0 = no active cooldown.
|
||||
pub npc_cooldown_until: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Global rate limiter: at most 1 unprompted disclosure per `GLOBAL_RATE_LIMIT_TICKS`.
|
||||
///
|
||||
/// When multiple NPCs are eligible simultaneously, the one with the lowest
|
||||
/// StableId fires first (deterministic, D-010 principle 4).
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct DisclosureGlobalRateLimit {
|
||||
pub last_disclosure_tick: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// derive_disclosure_candidates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Recompute `DisclosureCandidates` for each Active NPC.
|
||||
///
|
||||
/// Runs every `CANDIDATE_REFRESH_TICKS` ticks (checked per-NPC via `computed_tick`).
|
||||
/// Filters the NPC's own KG through:
|
||||
///
|
||||
/// - `KnowledgeState::Active` only
|
||||
/// - Confidence >= `KnowsOf` (or trait-lowered threshold via `TraitModifierConfig`)
|
||||
/// - Not in `per_fact_history`
|
||||
/// - `disclosure_blocked != true`
|
||||
/// - ToldBy exclusion if Cautious trait is configured
|
||||
///
|
||||
/// Applies Stage 1 trait filters from `TraitModifierConfig`. Sorted by
|
||||
/// confidence desc then `acquired_tick` desc. Capped at `MAX_CANDIDATES`.
|
||||
pub fn derive_disclosure_candidates(
|
||||
time: Res<SimulationTime>,
|
||||
trait_config: Res<TraitModifierConfig>,
|
||||
mut npc_query: Query<
|
||||
(
|
||||
&KnowledgeGraph,
|
||||
&DisclosureCooldown,
|
||||
Option<&PersonalityTraits>,
|
||||
&mut DisclosureCandidates,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
) {
|
||||
let current_tick = time.tick;
|
||||
|
||||
for (kg, cooldown, traits_opt, mut candidates) in &mut npc_query {
|
||||
// Only recompute when the refresh interval has elapsed.
|
||||
if candidates.computed_tick != 0
|
||||
&& current_tick.saturating_sub(candidates.computed_tick) < CANDIDATE_REFRESH_TICKS
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let trait_keys = traits_opt
|
||||
.map(|t| traits_to_keys(&t.traits))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Effective confidence floor.
|
||||
// `lowest_min_confidence` returns the most permissive threshold across
|
||||
// all traits (additive expansion — e.g., Gossipy sets Suspects,
|
||||
// which wins over Cautious raising to KnowsDetails).
|
||||
let min_confidence = trait_config
|
||||
.lowest_min_confidence(&trait_keys)
|
||||
.unwrap_or(KnowledgeConfidence::KnowsOf);
|
||||
|
||||
// Cautious trait: exclude facts with ToldBy (rumour) source.
|
||||
let exclude_told_by = trait_keys.iter().any(|k| {
|
||||
trait_config
|
||||
.modifier_for(k)
|
||||
.is_some_and(|m| m.stage1.exclude_told_by)
|
||||
});
|
||||
|
||||
let mut pool: Vec<(FactId, u64, KnowledgeConfidence)> = kg
|
||||
.known_facts_iter()
|
||||
.filter(|(fact_id, fact)| {
|
||||
// Active state only.
|
||||
if fact.state != KnowledgeState::Active {
|
||||
return false;
|
||||
}
|
||||
// Not already disclosed this window.
|
||||
if cooldown.per_fact_history.contains(*fact_id) {
|
||||
return false;
|
||||
}
|
||||
// Existentially dangerous secrets never disclosed (D-080).
|
||||
if fact.disclosure_blocked {
|
||||
return false;
|
||||
}
|
||||
// Confidence threshold.
|
||||
if fact.confidence < min_confidence {
|
||||
return false;
|
||||
}
|
||||
// Cautious: skip ToldBy-source facts.
|
||||
if exclude_told_by && matches!(fact.source, KnowledgeSource::ToldBy { .. }) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.map(|(id, fact)| (id.clone(), fact.acquired_tick, fact.confidence))
|
||||
.collect();
|
||||
|
||||
// Sort: confidence desc, then acquired_tick desc (most recent first).
|
||||
pool.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
pool.truncate(MAX_CANDIDATES);
|
||||
|
||||
candidates.candidates = pool.into_iter().map(|(id, _, _)| id).collect();
|
||||
candidates.computed_tick = current_tick;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// process_unprompted_disclosure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Collected during the read pass; used for sorting and winner selection.
|
||||
struct EligibleNpc {
|
||||
entity: Entity,
|
||||
stable_id: StableId,
|
||||
pos: TilePosition,
|
||||
fact_id: FactId,
|
||||
override_witness: bool,
|
||||
}
|
||||
|
||||
/// Fire one unprompted disclosure per tick window when all trigger gates pass (D-081).
|
||||
///
|
||||
/// Trigger gates (all must pass for a given NPC):
|
||||
///
|
||||
/// 1. NPC has a `StableEntityId` (required for trust lookup)
|
||||
/// 2. `DisclosureCandidates` pool is non-empty
|
||||
/// 3. NPC→player trust >= `SURFACE_TRUST`
|
||||
/// 4. `MoodState` != `NpcMood::Hostile`
|
||||
/// 5. `Contentment.level` >= −10
|
||||
/// 6. Per-NPC cooldown not active
|
||||
/// 7. Player within `PLAYER_RANGE_TILES`
|
||||
/// 8. Witness inhibition: no untrusted NPCs within `WITNESS_RADIUS` tiles
|
||||
/// (waived if NPC→player trust >= `SECRET_TRUST` or Talkative trait)
|
||||
/// 9. Location privacy: stubbed as always-pass — full impl in #172
|
||||
///
|
||||
/// Multiple eligible NPCs sorted by ascending StableId (D-010 principle 4).
|
||||
/// First in order that also passes witness inhibition fires.
|
||||
/// Subject to global rate limit (`GLOBAL_RATE_LIMIT_TICKS`).
|
||||
pub fn process_unprompted_disclosure(
|
||||
time: Res<SimulationTime>,
|
||||
spatial: Res<NaiveSpatialIndex>,
|
||||
relationship_graph: Res<RelationshipGraph>,
|
||||
trait_config: Res<TraitModifierConfig>,
|
||||
mut rate_limit: ResMut<DisclosureGlobalRateLimit>,
|
||||
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||||
player_pos_query: Query<
|
||||
(Entity, &TilePosition, Option<&StableEntityId>),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
mut player_mono_query: Query<&mut MonologueBuffer, With<PlayerCharacter>>,
|
||||
mut npc_query: Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&StableEntityId>,
|
||||
Option<&MoodState>,
|
||||
Option<&Contentment>,
|
||||
Option<&PersonalityTraits>,
|
||||
&DisclosureCandidates,
|
||||
&mut DisclosureCooldown,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
witness_sid_query: Query<Option<&StableEntityId>, With<Npc>>,
|
||||
) {
|
||||
let current_tick = time.tick;
|
||||
|
||||
// Gate: global rate limit — at most 1 disclosure per GLOBAL_RATE_LIMIT_TICKS.
|
||||
if current_tick.saturating_sub(rate_limit.last_disclosure_tick) < GLOBAL_RATE_LIMIT_TICKS {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect player state. Single-player assumption (D-010).
|
||||
let Ok((player_entity, player_pos_ref, player_sid_opt)) = player_pos_query.single() else {
|
||||
return;
|
||||
};
|
||||
let player_pos = *player_pos_ref;
|
||||
let player_sid: Option<StableId> = player_sid_opt.map(|s| s.0);
|
||||
|
||||
// --- Read pass: collect all NPCs passing the non-spatial gates ---
|
||||
let mut eligible: Vec<EligibleNpc> = npc_query
|
||||
.iter()
|
||||
.filter_map(
|
||||
|(entity, pos, sid_opt, mood_opt, content_opt, traits_opt, candidates, cooldown)| {
|
||||
// Gate 1: must have a StableId for trust lookup.
|
||||
let npc_sid = sid_opt?.0;
|
||||
|
||||
// Gate 2: candidate pool non-empty.
|
||||
let fact_id = candidates.candidates.first()?.clone();
|
||||
|
||||
// Gate 3: NPC→player trust >= Surface.
|
||||
let npc_player_trust = player_sid
|
||||
.and_then(|psid| relationship_graph.get_relationship(&npc_sid, &psid))
|
||||
.map(|e| e.trust)
|
||||
.unwrap_or(0);
|
||||
if npc_player_trust < SURFACE_TRUST {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Gate 4: mood not Hostile.
|
||||
if mood_opt.is_some_and(|ms| ms.mood == NpcMood::Hostile) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Gate 5: contentment >= -10.
|
||||
if content_opt.is_some_and(|c| c.level < -10) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Gate 6: per-NPC cooldown not active.
|
||||
if current_tick < cooldown.npc_cooldown_until {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Gate 7: player within PLAYER_RANGE_TILES (same z-level only).
|
||||
let in_range = pos
|
||||
.manhattan_distance(&player_pos)
|
||||
.is_some_and(|d| d <= PLAYER_RANGE_TILES);
|
||||
if !in_range {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Gate 9: location privacy — stubbed always-pass.
|
||||
// Full implementation deferred to #172 (Layer 4 disclosure pipeline).
|
||||
|
||||
// Compute witness inhibition override for gate 8.
|
||||
let trait_keys = traits_opt
|
||||
.map(|t| traits_to_keys(&t.traits))
|
||||
.unwrap_or_default();
|
||||
let override_witness = npc_player_trust >= SECRET_TRUST
|
||||
|| trait_config.any_overrides_witness_inhibition(&trait_keys);
|
||||
|
||||
Some(EligibleNpc {
|
||||
entity,
|
||||
stable_id: npc_sid,
|
||||
pos: *pos,
|
||||
fact_id,
|
||||
override_witness,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
if eligible.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by ascending StableId for determinism (D-010 principle 4).
|
||||
eligible.sort_by_key(|c| c.stable_id);
|
||||
|
||||
// Gate 8: witness inhibition — find first NPC that passes spatial check.
|
||||
let winner = eligible.into_iter().find(|candidate| {
|
||||
if candidate.override_witness {
|
||||
return true;
|
||||
}
|
||||
!has_untrusted_witness(
|
||||
&candidate.pos,
|
||||
candidate.stable_id,
|
||||
player_entity,
|
||||
&spatial,
|
||||
&witness_sid_query,
|
||||
&relationship_graph,
|
||||
)
|
||||
});
|
||||
|
||||
let Some(winner) = winner else {
|
||||
return;
|
||||
};
|
||||
|
||||
// --- Fire the disclosure ---
|
||||
|
||||
// 1. Grant the fact to the player's KG via KnowledgeGranted event (ToldBy source).
|
||||
// Confidence capped at KnowsOf (same rule as NPC-to-NPC transfer, D-080).
|
||||
event_queue.push(KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick: current_tick,
|
||||
event_type: KnowledgeEventType::KnowledgeGranted {
|
||||
grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant {
|
||||
fact_id: winner.fact_id.clone(),
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
}),
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: winner.stable_id,
|
||||
tick: current_tick,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Placeholder monologue event — actual line selection deferred to #172
|
||||
// (Layer 4 unprompted disclosure pipeline reads DisclosureCandidates).
|
||||
if let Ok(mut mono_buf) = player_mono_query.single_mut() {
|
||||
mono_buf.set(MonologueEvent {
|
||||
id: format!("disclosure_{}", winner.fact_id.0),
|
||||
text: String::from("(Layer 4 line selection — ticket #172)"),
|
||||
duration_seconds: 4.0,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Update NPC cooldown state.
|
||||
if let Ok((_, _, _, _, _, _, _, mut cooldown)) = npc_query.get_mut(winner.entity) {
|
||||
cooldown.per_fact_history.insert(winner.fact_id.clone());
|
||||
cooldown.npc_cooldown_until = current_tick + NPC_COOLDOWN_TICKS;
|
||||
}
|
||||
|
||||
// 4. Advance global rate limit.
|
||||
rate_limit.last_disclosure_tick = current_tick;
|
||||
|
||||
tracing::debug!(
|
||||
tick = current_tick,
|
||||
npc_sid = ?winner.stable_id,
|
||||
fact_id = %winner.fact_id.0,
|
||||
"unprompted disclosure fired"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns true if any untrusted NPC is within `WITNESS_RADIUS` of `pos`.
|
||||
///
|
||||
/// "Untrusted" = the disclosing NPC's trust toward that witness is below
|
||||
/// `REAL_TRUST`. The player entity is excluded (they are the target).
|
||||
fn has_untrusted_witness(
|
||||
pos: &TilePosition,
|
||||
npc_sid: StableId,
|
||||
player_entity: Entity,
|
||||
spatial: &NaiveSpatialIndex,
|
||||
witness_sid_query: &Query<Option<&StableEntityId>, With<Npc>>,
|
||||
relationship_graph: &RelationshipGraph,
|
||||
) -> bool {
|
||||
for witness_entity in spatial.entities_in_range(pos, WITNESS_RADIUS) {
|
||||
if witness_entity == player_entity {
|
||||
continue;
|
||||
}
|
||||
if let Ok(Some(witness_sid)) = witness_sid_query.get(witness_entity) {
|
||||
let trust = relationship_graph
|
||||
.get_relationship(&npc_sid, &witness_sid.0)
|
||||
.map(|e| e.trust)
|
||||
.unwrap_or(0);
|
||||
if trust < REAL_TRUST {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use super::*;
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::types::{KnowledgeConfidence, KnowledgeSource, KnowledgeState};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
fn make_fact(
|
||||
confidence: KnowledgeConfidence,
|
||||
state: KnowledgeState,
|
||||
acquired_tick: u64,
|
||||
disclosure_blocked: bool,
|
||||
) -> crate::knowledge::types::FactKnowledge {
|
||||
crate::knowledge::types::FactKnowledge {
|
||||
confidence,
|
||||
source: KnowledgeSource::Background,
|
||||
state,
|
||||
acquired_tick,
|
||||
disclosure_blocked,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_kg(facts: Vec<(FactId, crate::knowledge::types::FactKnowledge)>) -> KnowledgeGraph {
|
||||
KnowledgeGraph::with_background(facts)
|
||||
}
|
||||
|
||||
/// Build a minimal App for derive_disclosure_candidates tests.
|
||||
fn build_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<SimulationTime>();
|
||||
app.init_resource::<TraitModifierConfig>();
|
||||
app.add_systems(Update, derive_disclosure_candidates);
|
||||
app
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// derive_disclosure_candidates tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn active_knowsof_fact_becomes_candidate() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("investigation.clue".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false),
|
||||
)]);
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert!(
|
||||
candidates.candidates.contains(&fact_id),
|
||||
"Active KnowsOf fact should be in pool"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disclosure_blocked_fact_excluded() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("secret.dangerous".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::KnowsDetails, KnowledgeState::Active, 5, true),
|
||||
)]);
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert!(
|
||||
!candidates.candidates.contains(&fact_id),
|
||||
"disclosure_blocked fact must never be a candidate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_fact_excluded() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("cargo.manifest".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Stale, 5, false),
|
||||
)]);
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert!(
|
||||
!candidates.candidates.contains(&fact_id),
|
||||
"Stale fact must be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_disclosed_fact_excluded() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("dock.schedule".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false),
|
||||
)]);
|
||||
|
||||
let mut cooldown = DisclosureCooldown::default();
|
||||
cooldown.per_fact_history.insert(fact_id.clone());
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, cooldown, DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert!(
|
||||
!candidates.candidates.contains(&fact_id),
|
||||
"Fact in per_fact_history must be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_capped_at_max() {
|
||||
let mut app = build_app();
|
||||
|
||||
// Spawn 15 facts — only MAX_CANDIDATES should survive.
|
||||
let facts: Vec<_> = (0..15)
|
||||
.map(|i| {
|
||||
(
|
||||
FactId(format!("fact.{:02}", i)),
|
||||
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, i as u64, false),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let kg = make_kg(facts);
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert_eq!(
|
||||
candidates.candidates.len(),
|
||||
MAX_CANDIDATES,
|
||||
"Candidate pool must be capped at MAX_CANDIDATES"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_skipped_within_interval() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("investigation.clue".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false),
|
||||
)]);
|
||||
|
||||
// Set computed_tick = 1 (non-zero). Tick 5 is within the 30-tick refresh window.
|
||||
let mut candidates = DisclosureCandidates::default();
|
||||
candidates.computed_tick = 1;
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), candidates))
|
||||
.id();
|
||||
|
||||
// Advance tick to 5 (within CANDIDATE_REFRESH_TICKS = 30).
|
||||
app.world_mut().resource_mut::<SimulationTime>().tick = 5;
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert_eq!(
|
||||
candidates.computed_tick, 1,
|
||||
"Refresh should be skipped within the interval"
|
||||
);
|
||||
assert!(
|
||||
candidates.candidates.is_empty(),
|
||||
"Candidates should remain empty (no recompute)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspects_confidence_below_default_floor_excluded() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("rumour.vague".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::Suspects, KnowledgeState::Active, 5, false),
|
||||
)]);
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert!(
|
||||
!candidates.candidates.contains(&fact_id),
|
||||
"Suspects-confidence fact must be below the KnowsOf default floor"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Implements D-024: 10-axis NPC model + CombatCapability component
|
||||
// Background tier state machines for schedule, mood, relationships, job
|
||||
|
||||
pub mod disclosure;
|
||||
pub mod generate;
|
||||
pub mod interaction;
|
||||
pub mod mood;
|
||||
@@ -9,6 +10,7 @@ pub mod relationships;
|
||||
pub mod routine;
|
||||
pub mod tell_state;
|
||||
pub mod tolerance;
|
||||
pub mod trait_modifiers;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
@@ -31,6 +33,8 @@ impl Plugin for NpcPlugin {
|
||||
.init_resource::<routine::PreviousDayPhase>()
|
||||
.init_resource::<tolerance::ToleranceBreachEventQueue>()
|
||||
.init_resource::<routine::RoutineDeviationEventQueue>()
|
||||
.init_resource::<disclosure::DisclosureGlobalRateLimit>()
|
||||
.init_resource::<trait_modifiers::TraitModifierConfig>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -61,6 +65,11 @@ impl Plugin for NpcPlugin {
|
||||
.after(mood::update_mood)
|
||||
.after(routine::detect_routine_deviation)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
disclosure::derive_disclosure_candidates
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
disclosure::process_unprompted_disclosure
|
||||
.after(disclosure::derive_disclosure_candidates)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
crate::simulation::dialogue::process_talk_interaction
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::simulation::dialogue::process_walk_away
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
//! Trait modifier system for unprompted disclosure (#173, D-081).
|
||||
//!
|
||||
//! Two-stage filter: Stage 1 (WHAT) modifies the disclosure candidate pool,
|
||||
//! Stage 2 (HOW) weights line selection via delivery tags.
|
||||
//!
|
||||
//! Traits map to filter predicates via content-authorable YAML config —
|
||||
//! not hard-coded enum dispatch. Content authors define what each trait
|
||||
//! does to the candidate pool and which delivery tags it prefers.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::knowledge::types::{FactKnowledge, KnowledgeConfidence, KnowledgeSource};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML-authored trait modifier config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full trait modifier configuration resource. Loaded from YAML.
|
||||
///
|
||||
/// Keys are trait names (lowercase, matching `PersonalityTrait` string
|
||||
/// representation): `"cautious"`, `"gossipy"`, `"loyal"`, `"talkative"`, etc.
|
||||
///
|
||||
/// BTreeMap for deterministic iteration (D-010 principle 4).
|
||||
#[derive(Resource, Debug, Clone, Default, Deserialize)]
|
||||
pub struct TraitModifierConfig {
|
||||
/// Trait name → modifier rules. Trait names are lowercase_snake_case.
|
||||
#[serde(default)]
|
||||
pub modifiers: BTreeMap<String, TraitModifier>,
|
||||
}
|
||||
|
||||
/// A single trait's filter and scoring rules.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct TraitModifier {
|
||||
/// Stage 1: candidate pool filter (WHAT gets disclosed).
|
||||
#[serde(default)]
|
||||
pub stage1: Stage1Filter,
|
||||
/// Stage 2: line pool scoring (HOW it's delivered).
|
||||
#[serde(default)]
|
||||
pub stage2: Stage2Scoring,
|
||||
}
|
||||
|
||||
/// Stage 1 filter predicates — modify the disclosure candidate pool.
|
||||
///
|
||||
/// Applied per-fact during candidate selection in `DisclosureCandidates`
|
||||
/// (#551). Multiple traits compose additively: if any trait includes a
|
||||
/// candidate that would otherwise be excluded, it's included.
|
||||
///
|
||||
/// Default values (all false/None) produce no modification to the
|
||||
/// baseline filter, which requires KnowsOf minimum confidence and
|
||||
/// includes all source types.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Stage1Filter {
|
||||
/// Minimum confidence to enter the disclosure pool.
|
||||
/// Parsed at load time: "suspects", "knows_of", "knows_details", "direct".
|
||||
/// None = use system default (KnowsOf).
|
||||
#[serde(default)]
|
||||
pub min_confidence: Option<String>,
|
||||
/// If true, exclude facts with `ToldBy` source (won't pass on rumors).
|
||||
/// Cautious trait behavior.
|
||||
#[serde(default)]
|
||||
pub exclude_told_by: bool,
|
||||
/// If true, exclude facts linked to entities with trust_level >= Real
|
||||
/// in NPC Relationships. Loyal trait behavior.
|
||||
#[serde(default)]
|
||||
pub exclude_high_trust_entities: bool,
|
||||
/// If true, override the witness inhibition gate. Talkative trait behavior.
|
||||
#[serde(default)]
|
||||
pub override_witness_inhibition: bool,
|
||||
}
|
||||
|
||||
/// Stage 2 scoring — influence line selection weighting.
|
||||
///
|
||||
/// Delivery tags in `IndexedDialogueLine.tags` are matched against
|
||||
/// the NPC's trait-derived preferred tags. Lines with matching tags
|
||||
/// receive a scoring bonus during Layer 4 selection.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Stage2Scoring {
|
||||
/// Preferred delivery tags for line selection weighting.
|
||||
/// Examples: `["cautious_delivery"]`, `["gossip_delivery", "casual_delivery"]`.
|
||||
/// Lines with matching tags receive a scoring bonus.
|
||||
#[serde(default)]
|
||||
pub delivery_tags: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter predicate evaluation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl Stage1Filter {
|
||||
/// Parse the min_confidence string into a `KnowledgeConfidence` value.
|
||||
/// Returns `None` (use system default) for unparseable or absent values.
|
||||
pub fn min_confidence_level(&self) -> Option<KnowledgeConfidence> {
|
||||
self.min_confidence.as_deref().and_then(parse_confidence)
|
||||
}
|
||||
|
||||
/// Evaluate whether a fact passes this trait's Stage 1 filter.
|
||||
///
|
||||
/// Returns `false` if the fact should be excluded by this trait.
|
||||
/// The caller (#551) composes multiple trait filters: a fact is
|
||||
/// included if it passes the composite filter.
|
||||
pub fn allows_fact(&self, fact: &FactKnowledge) -> bool {
|
||||
// Check minimum confidence
|
||||
if let Some(min) = self.min_confidence_level() {
|
||||
if fact.confidence < min {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude ToldBy-source facts (Cautious behavior)
|
||||
if self.exclude_told_by {
|
||||
if matches!(fact.source, KnowledgeSource::ToldBy { .. }) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Stage2Scoring {
|
||||
/// Check if a line's tags contain any of this trait's preferred delivery tags.
|
||||
/// Returns the number of matching tags (0 = no bonus).
|
||||
pub fn tag_match_count(&self, line_tags: &[String]) -> usize {
|
||||
self.delivery_tags
|
||||
.iter()
|
||||
.filter(|dt| line_tags.contains(dt))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Check if a line has at least one matching delivery tag.
|
||||
pub fn has_matching_tag(&self, line_tags: &[String]) -> bool {
|
||||
self.tag_match_count(line_tags) > 0
|
||||
}
|
||||
}
|
||||
|
||||
impl TraitModifierConfig {
|
||||
/// Look up the modifier for a trait by name.
|
||||
pub fn modifier_for(&self, trait_name: &str) -> Option<&TraitModifier> {
|
||||
self.modifiers.get(trait_name)
|
||||
}
|
||||
|
||||
/// Collect all Stage 2 delivery tags for a set of trait names.
|
||||
/// Returns a deduplicated, sorted list for deterministic matching.
|
||||
pub fn delivery_tags_for(&self, trait_names: &[String]) -> Vec<String> {
|
||||
let mut tags: Vec<String> = trait_names
|
||||
.iter()
|
||||
.filter_map(|name| self.modifiers.get(name.as_str()))
|
||||
.flat_map(|m| m.stage2.delivery_tags.iter().cloned())
|
||||
.collect();
|
||||
tags.sort();
|
||||
tags.dedup();
|
||||
tags
|
||||
}
|
||||
|
||||
/// Check if any trait in the set overrides witness inhibition.
|
||||
pub fn any_overrides_witness_inhibition(&self, trait_names: &[String]) -> bool {
|
||||
trait_names.iter().any(|name| {
|
||||
self.modifiers
|
||||
.get(name.as_str())
|
||||
.is_some_and(|m| m.stage1.override_witness_inhibition)
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if any trait in the set excludes high-trust entity facts.
|
||||
pub fn any_excludes_high_trust(&self, trait_names: &[String]) -> bool {
|
||||
trait_names.iter().any(|name| {
|
||||
self.modifiers
|
||||
.get(name.as_str())
|
||||
.is_some_and(|m| m.stage1.exclude_high_trust_entities)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the most permissive (lowest) min_confidence across all traits.
|
||||
/// Returns None if no traits specify a minimum (use system default).
|
||||
pub fn lowest_min_confidence(&self, trait_names: &[String]) -> Option<KnowledgeConfidence> {
|
||||
trait_names
|
||||
.iter()
|
||||
.filter_map(|name| self.modifiers.get(name.as_str()))
|
||||
.filter_map(|m| m.stage1.min_confidence_level())
|
||||
.min()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a confidence string from YAML config to enum value.
|
||||
fn parse_confidence(s: &str) -> Option<KnowledgeConfidence> {
|
||||
match s {
|
||||
"suspects" => Some(KnowledgeConfidence::Suspects),
|
||||
"knows_of" => Some(KnowledgeConfidence::KnowsOf),
|
||||
"knows_details" => Some(KnowledgeConfidence::KnowsDetails),
|
||||
"direct" => Some(KnowledgeConfidence::Direct),
|
||||
_ => {
|
||||
tracing::warn!("Unknown confidence level in trait config: {:?}", s);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `PersonalityTrait` to its lowercase YAML key.
|
||||
/// Used to look up trait modifiers from the config.
|
||||
pub fn trait_to_key(trait_val: &super::PersonalityTrait) -> &'static str {
|
||||
match trait_val {
|
||||
super::PersonalityTrait::Cautious => "cautious",
|
||||
super::PersonalityTrait::Bold => "bold",
|
||||
super::PersonalityTrait::Honest => "honest",
|
||||
super::PersonalityTrait::Deceptive => "deceptive",
|
||||
super::PersonalityTrait::Compassionate => "compassionate",
|
||||
super::PersonalityTrait::Ruthless => "ruthless",
|
||||
super::PersonalityTrait::Curious => "curious",
|
||||
super::PersonalityTrait::Incurious => "incurious",
|
||||
super::PersonalityTrait::Social => "social",
|
||||
super::PersonalityTrait::Reclusive => "reclusive",
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an NPC's personality trait list to YAML config keys.
|
||||
pub fn traits_to_keys(traits: &[super::PersonalityTrait]) -> Vec<String> {
|
||||
traits.iter().map(|t| trait_to_key(t).to_string()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_fact(confidence: KnowledgeConfidence, source: KnowledgeSource) -> FactKnowledge {
|
||||
FactKnowledge {
|
||||
confidence,
|
||||
source,
|
||||
state: crate::knowledge::types::KnowledgeState::Active,
|
||||
acquired_tick: 100,
|
||||
disclosure_blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn cautious_config() -> TraitModifierConfig {
|
||||
let yaml = r#"
|
||||
modifiers:
|
||||
cautious:
|
||||
stage1:
|
||||
min_confidence: "knows_details"
|
||||
exclude_told_by: true
|
||||
stage2:
|
||||
delivery_tags: ["cautious_delivery"]
|
||||
gossipy:
|
||||
stage1:
|
||||
min_confidence: "suspects"
|
||||
stage2:
|
||||
delivery_tags: ["gossip_delivery", "casual_delivery"]
|
||||
loyal:
|
||||
stage1:
|
||||
exclude_high_trust_entities: true
|
||||
stage2:
|
||||
delivery_tags: ["professional_delivery"]
|
||||
talkative:
|
||||
stage1:
|
||||
override_witness_inhibition: true
|
||||
min_confidence: "suspects"
|
||||
stage2:
|
||||
delivery_tags: ["casual_delivery", "gossip_delivery"]
|
||||
"#;
|
||||
serde_yaml::from_str(yaml).expect("valid trait config YAML")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_config_from_yaml() {
|
||||
let config = cautious_config();
|
||||
assert_eq!(config.modifiers.len(), 4);
|
||||
assert!(config.modifiers.contains_key("cautious"));
|
||||
assert!(config.modifiers.contains_key("gossipy"));
|
||||
assert!(config.modifiers.contains_key("loyal"));
|
||||
assert!(config.modifiers.contains_key("talkative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cautious_excludes_low_confidence() {
|
||||
let config = cautious_config();
|
||||
let cautious = &config.modifiers["cautious"].stage1;
|
||||
|
||||
let suspects_fact = make_fact(
|
||||
KnowledgeConfidence::Suspects,
|
||||
KnowledgeSource::DirectObservation { tick: 50 },
|
||||
);
|
||||
let details_fact = make_fact(
|
||||
KnowledgeConfidence::KnowsDetails,
|
||||
KnowledgeSource::DirectObservation { tick: 50 },
|
||||
);
|
||||
|
||||
assert!(!cautious.allows_fact(&suspects_fact), "Cautious excludes Suspects");
|
||||
assert!(cautious.allows_fact(&details_fact), "Cautious allows KnowsDetails");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cautious_excludes_told_by() {
|
||||
let config = cautious_config();
|
||||
let cautious = &config.modifiers["cautious"].stage1;
|
||||
|
||||
let told_fact = make_fact(
|
||||
KnowledgeConfidence::KnowsDetails,
|
||||
KnowledgeSource::ToldBy {
|
||||
source_id: crate::knowledge::types::StableId(42),
|
||||
tick: 50,
|
||||
},
|
||||
);
|
||||
assert!(!cautious.allows_fact(&told_fact), "Cautious excludes ToldBy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gossipy_includes_suspects() {
|
||||
let config = cautious_config();
|
||||
let gossipy = &config.modifiers["gossipy"].stage1;
|
||||
|
||||
let suspects_fact = make_fact(
|
||||
KnowledgeConfidence::Suspects,
|
||||
KnowledgeSource::DirectObservation { tick: 50 },
|
||||
);
|
||||
assert!(gossipy.allows_fact(&suspects_fact), "Gossipy includes Suspects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn talkative_overrides_witness_inhibition() {
|
||||
let config = cautious_config();
|
||||
let traits = vec!["talkative".to_string()];
|
||||
assert!(config.any_overrides_witness_inhibition(&traits));
|
||||
|
||||
let traits = vec!["cautious".to_string()];
|
||||
assert!(!config.any_overrides_witness_inhibition(&traits));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loyal_excludes_high_trust() {
|
||||
let config = cautious_config();
|
||||
let traits = vec!["loyal".to_string()];
|
||||
assert!(config.any_excludes_high_trust(&traits));
|
||||
|
||||
let traits = vec!["gossipy".to_string()];
|
||||
assert!(!config.any_excludes_high_trust(&traits));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowest_min_confidence_picks_most_permissive() {
|
||||
let config = cautious_config();
|
||||
// Gossipy (suspects) + Cautious (knows_details) → suspects wins
|
||||
let traits = vec!["gossipy".to_string(), "cautious".to_string()];
|
||||
assert_eq!(
|
||||
config.lowest_min_confidence(&traits),
|
||||
Some(KnowledgeConfidence::Suspects)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_tags_deduped_and_sorted() {
|
||||
let config = cautious_config();
|
||||
// Gossipy + Talkative both have "casual_delivery" and "gossip_delivery"
|
||||
let traits = vec!["gossipy".to_string(), "talkative".to_string()];
|
||||
let tags = config.delivery_tags_for(&traits);
|
||||
assert_eq!(tags, vec!["casual_delivery", "gossip_delivery"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage2_tag_matching() {
|
||||
let config = cautious_config();
|
||||
let scoring = &config.modifiers["cautious"].stage2;
|
||||
|
||||
let line_tags = vec!["cautious_delivery".to_string(), "observation".to_string()];
|
||||
assert!(scoring.has_matching_tag(&line_tags));
|
||||
assert_eq!(scoring.tag_match_count(&line_tags), 1);
|
||||
|
||||
let no_match_tags = vec!["gossip_delivery".to_string()];
|
||||
assert!(!scoring.has_matching_tag(&no_match_tags));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_trait_returns_none() {
|
||||
let config = cautious_config();
|
||||
assert!(config.modifier_for("unknown_trait").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trait_to_key_roundtrip() {
|
||||
use super::super::PersonalityTrait;
|
||||
assert_eq!(trait_to_key(&PersonalityTrait::Cautious), "cautious");
|
||||
assert_eq!(trait_to_key(&PersonalityTrait::Bold), "bold");
|
||||
assert_eq!(trait_to_key(&PersonalityTrait::Social), "social");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traits_to_keys_conversion() {
|
||||
use super::super::PersonalityTrait;
|
||||
let traits = vec![PersonalityTrait::Cautious, PersonalityTrait::Social];
|
||||
let keys = traits_to_keys(&traits);
|
||||
assert_eq!(keys, vec!["cautious", "social"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_config_is_no_op() {
|
||||
let config = TraitModifierConfig::default();
|
||||
let traits = vec!["cautious".to_string()];
|
||||
assert!(!config.any_overrides_witness_inhibition(&traits));
|
||||
assert!(!config.any_excludes_high_trust(&traits));
|
||||
assert_eq!(config.lowest_min_confidence(&traits), None);
|
||||
assert!(config.delivery_tags_for(&traits).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_filter_allows_everything() {
|
||||
let filter = Stage1Filter::default();
|
||||
let fact = make_fact(
|
||||
KnowledgeConfidence::Suspects,
|
||||
KnowledgeSource::ToldBy {
|
||||
source_id: crate::knowledge::types::StableId(1),
|
||||
tick: 10,
|
||||
},
|
||||
);
|
||||
assert!(filter.allows_fact(&fact), "Default filter allows all facts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_confidence_values() {
|
||||
assert_eq!(parse_confidence("suspects"), Some(KnowledgeConfidence::Suspects));
|
||||
assert_eq!(parse_confidence("knows_of"), Some(KnowledgeConfidence::KnowsOf));
|
||||
assert_eq!(parse_confidence("knows_details"), Some(KnowledgeConfidence::KnowsDetails));
|
||||
assert_eq!(parse_confidence("direct"), Some(KnowledgeConfidence::Direct));
|
||||
assert_eq!(parse_confidence("invalid"), None);
|
||||
}
|
||||
|
||||
// --- Coverage gap closure tests ---
|
||||
|
||||
#[test]
|
||||
fn cautious_excludes_knows_of_below_threshold() {
|
||||
// Cautious min_confidence is "knows_details". KnowsOf < KnowsDetails,
|
||||
// so a KnowsOf fact must be excluded (not just Suspects).
|
||||
let config = cautious_config();
|
||||
let cautious = &config.modifiers["cautious"].stage1;
|
||||
|
||||
let knows_of_fact = make_fact(
|
||||
KnowledgeConfidence::KnowsOf,
|
||||
KnowledgeSource::DirectObservation { tick: 50 },
|
||||
);
|
||||
assert!(
|
||||
!cautious.allows_fact(&knows_of_fact),
|
||||
"Cautious should exclude KnowsOf (below knows_details threshold)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cautious_allows_direct_confidence() {
|
||||
// Direct > KnowsDetails, so Direct passes cautious min_confidence.
|
||||
let config = cautious_config();
|
||||
let cautious = &config.modifiers["cautious"].stage1;
|
||||
|
||||
let direct_fact = make_fact(
|
||||
KnowledgeConfidence::Direct,
|
||||
KnowledgeSource::DirectObservation { tick: 50 },
|
||||
);
|
||||
assert!(
|
||||
cautious.allows_fact(&direct_fact),
|
||||
"Cautious should allow Direct confidence (above threshold)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gossipy_allows_all_confidence_levels() {
|
||||
// Gossipy min_confidence is "suspects" — all confidence levels pass.
|
||||
let config = cautious_config();
|
||||
let gossipy = &config.modifiers["gossipy"].stage1;
|
||||
|
||||
for (confidence, label) in [
|
||||
(KnowledgeConfidence::Suspects, "Suspects"),
|
||||
(KnowledgeConfidence::KnowsOf, "KnowsOf"),
|
||||
(KnowledgeConfidence::KnowsDetails, "KnowsDetails"),
|
||||
(KnowledgeConfidence::Direct, "Direct"),
|
||||
] {
|
||||
let fact = make_fact(confidence, KnowledgeSource::DirectObservation { tick: 50 });
|
||||
assert!(
|
||||
gossipy.allows_fact(&fact),
|
||||
"Gossipy should allow {} confidence",
|
||||
label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_personality_traits_map_to_unique_keys() {
|
||||
use super::super::PersonalityTrait;
|
||||
|
||||
let all_traits = vec![
|
||||
PersonalityTrait::Cautious,
|
||||
PersonalityTrait::Bold,
|
||||
PersonalityTrait::Honest,
|
||||
PersonalityTrait::Deceptive,
|
||||
PersonalityTrait::Compassionate,
|
||||
PersonalityTrait::Ruthless,
|
||||
PersonalityTrait::Curious,
|
||||
PersonalityTrait::Incurious,
|
||||
PersonalityTrait::Social,
|
||||
PersonalityTrait::Reclusive,
|
||||
];
|
||||
|
||||
let keys: Vec<&str> = all_traits.iter().map(|t| trait_to_key(t)).collect();
|
||||
|
||||
// All 10 traits produce a non-empty key
|
||||
for (trait_, key) in all_traits.iter().zip(keys.iter()) {
|
||||
assert!(!key.is_empty(), "{:?} must map to a non-empty key", trait_);
|
||||
}
|
||||
|
||||
// All keys are unique (no two traits share a key)
|
||||
let mut sorted = keys.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(
|
||||
sorted.len(),
|
||||
all_traits.len(),
|
||||
"All personality traits must map to distinct keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage2_multiple_tag_matches_counts_correctly() {
|
||||
// When a line has two matching delivery tags, tag_match_count returns 2.
|
||||
let config = cautious_config();
|
||||
// Talkative has: ["casual_delivery", "gossip_delivery"]
|
||||
let talkative_scoring = &config.modifiers["talkative"].stage2;
|
||||
|
||||
let line_tags = vec![
|
||||
"casual_delivery".to_string(),
|
||||
"gossip_delivery".to_string(),
|
||||
"unrelated_tag".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
talkative_scoring.tag_match_count(&line_tags),
|
||||
2,
|
||||
"Both delivery tags should match"
|
||||
);
|
||||
assert!(talkative_scoring.has_matching_tag(&line_tags));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gossipy_does_not_exclude_told_by() {
|
||||
// Gossipy has no exclude_told_by restriction — it should pass ToldBy facts.
|
||||
let config = cautious_config();
|
||||
let gossipy = &config.modifiers["gossipy"].stage1;
|
||||
|
||||
let told_fact = make_fact(
|
||||
KnowledgeConfidence::Suspects,
|
||||
KnowledgeSource::ToldBy {
|
||||
source_id: crate::knowledge::types::StableId(5),
|
||||
tick: 10,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
gossipy.allows_fact(&told_fact),
|
||||
"Gossipy should not exclude ToldBy-source facts"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Point of Interest data model (#148).
|
||||
//!
|
||||
//! POIs are discoverable world locations: quest-relevant places, hidden
|
||||
//! areas, landmarks, vendors, etc. They integrate with the knowledge
|
||||
//! graph via `FactId("poi.*")` namespace per D-079.
|
||||
//!
|
||||
//! Discovery system (#149) uses `KnowledgeEventType::KnowledgeGranted`
|
||||
//! with `Fact` variant to grant POI facts to observers.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::knowledge::types::FactId;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
/// Category of point of interest. Determines client-side icon and color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum PoiCategory {
|
||||
/// Named location (dock, bar, office, residential block).
|
||||
Location,
|
||||
/// Vendor or service provider (fixer, medic, data broker).
|
||||
Service,
|
||||
/// Quest-relevant target (drop point, meeting place, evidence site).
|
||||
QuestTarget,
|
||||
/// Hidden area (secret passage, concealed cache, restricted zone).
|
||||
Hidden,
|
||||
/// Navigation landmark visible from a distance.
|
||||
Landmark,
|
||||
}
|
||||
|
||||
/// How a POI was placed in the world (content provenance).
|
||||
///
|
||||
/// Distinct from visibility rules: discovery_source tracks *why* the POI
|
||||
/// exists; visibility tracks *how* it can be found.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum PoiDiscoverySource {
|
||||
/// Part of the map template — always present on this map.
|
||||
MapTemplate,
|
||||
/// Procedurally generated at world creation.
|
||||
Procedural,
|
||||
/// Created by a quest or storyline event at runtime.
|
||||
QuestGenerated,
|
||||
/// Revealed by NPC testimony via knowledge grant.
|
||||
NpcRevealed,
|
||||
}
|
||||
|
||||
/// Rules governing when an observer can discover this POI.
|
||||
///
|
||||
/// Discovery adds `FactId("poi.{poi_id}")` to the observer's knowledge
|
||||
/// graph. The discovery system (#149) evaluates these rules each tick
|
||||
/// for POIs not yet known to the observer.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum PoiVisibility {
|
||||
/// Discoverable when within line of sight (standard LOS rules).
|
||||
LineOfSight,
|
||||
/// Discoverable only within a specific tile range (Manhattan distance).
|
||||
Proximity { range: u32 },
|
||||
/// Not discoverable by observation. Requires a `KnowledgeGranted`
|
||||
/// event from dialogue, evidence, or NPC testimony.
|
||||
KnowledgeOnly,
|
||||
/// Discoverable by LOS, but only if the observer already knows a
|
||||
/// prerequisite fact. Example: a hidden door visible only if the
|
||||
/// observer knows `"quest.secret_passage_hint"`.
|
||||
RequiresFact { fact_id: String },
|
||||
}
|
||||
|
||||
/// Point of Interest ECS component (#148).
|
||||
///
|
||||
/// Attached to world entities that represent discoverable locations.
|
||||
/// When an observer discovers a POI, `FactId("poi.{poi_id}")` is added
|
||||
/// to their `KnowledgeGraph` via the discovery system (#149).
|
||||
///
|
||||
/// BTreeMap ordering note: POI entities use `StableEntityId` like all
|
||||
/// other entities. The `poi_id` string is for the fact namespace only.
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PointOfInterest {
|
||||
/// Unique identifier within the `poi.*` fact namespace.
|
||||
/// Format: `lowercase_snake_case`. Example: `"docking_bay_7"`.
|
||||
/// Must be unique across all POIs in the world.
|
||||
pub poi_id: String,
|
||||
/// Display name shown to the player after discovery.
|
||||
pub name: String,
|
||||
/// World position of the POI (center tile).
|
||||
pub position: TilePosition,
|
||||
/// Category for client-side rendering (icon, minimap marker).
|
||||
pub category: PoiCategory,
|
||||
/// Content provenance — how this POI was placed in the world.
|
||||
pub discovery_source: PoiDiscoverySource,
|
||||
/// Rules for when/how an observer can discover this POI.
|
||||
pub visibility: PoiVisibility,
|
||||
}
|
||||
|
||||
impl PointOfInterest {
|
||||
/// Generate the `FactId` for this POI in the knowledge graph.
|
||||
/// Format: `"poi.{poi_id}"` per D-079 namespace convention.
|
||||
pub fn fact_id(&self) -> FactId {
|
||||
FactId(format!("poi.{}", self.poi_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_poi(id: &str, category: PoiCategory, visibility: PoiVisibility) -> PointOfInterest {
|
||||
PointOfInterest {
|
||||
poi_id: id.to_string(),
|
||||
name: format!("Test POI {}", id),
|
||||
position: TilePosition::new(10, 20, 0),
|
||||
category,
|
||||
discovery_source: PoiDiscoverySource::MapTemplate,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fact_id_uses_poi_namespace() {
|
||||
let poi = make_poi("docking_bay_7", PoiCategory::Location, PoiVisibility::LineOfSight);
|
||||
assert_eq!(poi.fact_id(), FactId("poi.docking_bay_7".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fact_id_format_is_deterministic() {
|
||||
let poi1 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly);
|
||||
let poi2 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly);
|
||||
assert_eq!(poi1.fact_id(), poi2.fact_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_poi_ids_produce_different_fact_ids() {
|
||||
let poi1 = make_poi("bay_alpha", PoiCategory::Location, PoiVisibility::LineOfSight);
|
||||
let poi2 = make_poi("bay_beta", PoiCategory::Location, PoiVisibility::LineOfSight);
|
||||
assert_ne!(poi1.fact_id(), poi2.fact_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_visibility_stores_range() {
|
||||
let poi = make_poi(
|
||||
"hidden_cache",
|
||||
PoiCategory::Hidden,
|
||||
PoiVisibility::Proximity { range: 5 },
|
||||
);
|
||||
match poi.visibility {
|
||||
PoiVisibility::Proximity { range } => assert_eq!(range, 5),
|
||||
_ => panic!("Expected Proximity visibility"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_fact_visibility_stores_fact_id() {
|
||||
let poi = make_poi(
|
||||
"secret_door",
|
||||
PoiCategory::Hidden,
|
||||
PoiVisibility::RequiresFact {
|
||||
fact_id: "quest.secret_passage_hint".to_string(),
|
||||
},
|
||||
);
|
||||
match &poi.visibility {
|
||||
PoiVisibility::RequiresFact { fact_id } => {
|
||||
assert_eq!(fact_id, "quest.secret_passage_hint");
|
||||
}
|
||||
_ => panic!("Expected RequiresFact visibility"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_categories_are_distinct() {
|
||||
assert_ne!(PoiCategory::Location, PoiCategory::Service);
|
||||
assert_ne!(PoiCategory::QuestTarget, PoiCategory::Hidden);
|
||||
assert_ne!(PoiCategory::Hidden, PoiCategory::Landmark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_discovery_sources_are_distinct() {
|
||||
assert_ne!(PoiDiscoverySource::MapTemplate, PoiDiscoverySource::Procedural);
|
||||
assert_ne!(
|
||||
PoiDiscoverySource::QuestGenerated,
|
||||
PoiDiscoverySource::NpcRevealed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_serialization_roundtrip() {
|
||||
let poi = make_poi("med_bay", PoiCategory::Service, PoiVisibility::LineOfSight);
|
||||
let serialized = serde_yaml::to_string(&poi).expect("serialize");
|
||||
let deserialized: PointOfInterest =
|
||||
serde_yaml::from_str(&serialized).expect("deserialize");
|
||||
assert_eq!(deserialized.poi_id, "med_bay");
|
||||
assert_eq!(deserialized.category, PoiCategory::Service);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! POI discovery system (#149).
|
||||
//!
|
||||
//! Detects when the player observer discovers a Point of Interest and
|
||||
//! grants the corresponding `FactId("poi.*")` to their knowledge graph.
|
||||
//!
|
||||
//! Discovery methods handled here:
|
||||
//! - Physical discovery (LOS, proximity) — checked each tick
|
||||
//!
|
||||
//! Discovery methods handled elsewhere:
|
||||
//! - Character background — inserted at spawn time by content system
|
||||
//! - NPC tips / research — via `KnowledgeGranted` event (#546)
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::types::{
|
||||
FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState,
|
||||
};
|
||||
use crate::perception::query::VisibilityGeometry;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::poi::{PoiVisibility, PointOfInterest};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// Event emitted when the player discovers a POI.
|
||||
///
|
||||
/// Other systems (monologue, minimap update, storyteller) can react to
|
||||
/// this event. Consumed and cleared each tick.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoiDiscoveredEvent {
|
||||
/// The `poi_id` string of the discovered POI.
|
||||
pub poi_id: String,
|
||||
/// Display name for monologue/UI use.
|
||||
pub name: String,
|
||||
/// Tick when discovered.
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
/// Resource: queue of POI discovery events from the current tick.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PoiDiscoveryEventQueue {
|
||||
events: Vec<PoiDiscoveredEvent>,
|
||||
}
|
||||
|
||||
impl PoiDiscoveryEventQueue {
|
||||
pub fn push(&mut self, event: PoiDiscoveredEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<PoiDiscoveredEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// System: check for POI physical discovery by the player observer.
|
||||
///
|
||||
/// Runs after visibility geometry is computed. For each undiscovered POI,
|
||||
/// checks visibility rules against the observer's position and known facts.
|
||||
/// Discovered POIs are added as `FactId("poi.*")` facts to the observer's
|
||||
/// KnowledgeGraph with `DirectObservation` source.
|
||||
pub fn discover_pois(
|
||||
time: Res<SimulationTime>,
|
||||
geometry: Res<VisibilityGeometry>,
|
||||
mut discovery_queue: ResMut<PoiDiscoveryEventQueue>,
|
||||
poi_query: Query<&PointOfInterest>,
|
||||
mut observer_query: Query<(&TilePosition, &mut KnowledgeGraph), With<PlayerCharacter>>,
|
||||
) {
|
||||
let Ok((observer_pos, mut kg)) = observer_query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for poi in poi_query.iter() {
|
||||
let fact_id = poi.fact_id();
|
||||
|
||||
// Skip already-known POIs
|
||||
if kg.knows_fact(&fact_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if can_discover(observer_pos, &geometry, &kg, poi) {
|
||||
kg.facts.insert(
|
||||
fact_id,
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::DirectObservation { tick: time.tick },
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: time.tick,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
);
|
||||
|
||||
discovery_queue.push(PoiDiscoveredEvent {
|
||||
poi_id: poi.poi_id.clone(),
|
||||
name: poi.name.clone(),
|
||||
tick: time.tick,
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
poi_id = %poi.poi_id,
|
||||
name = %poi.name,
|
||||
tick = time.tick,
|
||||
"Player discovered POI"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate whether an observer can discover a POI based on its visibility rules.
|
||||
fn can_discover(
|
||||
observer_pos: &TilePosition,
|
||||
geometry: &VisibilityGeometry,
|
||||
kg: &KnowledgeGraph,
|
||||
poi: &PointOfInterest,
|
||||
) -> bool {
|
||||
match &poi.visibility {
|
||||
PoiVisibility::LineOfSight => {
|
||||
poi.position.z == geometry.observer_z
|
||||
&& geometry
|
||||
.visible_positions
|
||||
.contains(&(poi.position.x, poi.position.y))
|
||||
}
|
||||
PoiVisibility::Proximity { range } => observer_pos
|
||||
.manhattan_distance(&poi.position)
|
||||
.is_some_and(|d| d <= *range),
|
||||
PoiVisibility::KnowledgeOnly => {
|
||||
// Not discoverable by physical observation.
|
||||
// Requires KnowledgeGranted event from dialogue/evidence.
|
||||
false
|
||||
}
|
||||
PoiVisibility::RequiresFact { fact_id } => {
|
||||
// Must know the prerequisite fact AND see the POI in LOS.
|
||||
kg.knows_fact(&FactId(fact_id.clone()))
|
||||
&& poi.position.z == geometry.observer_z
|
||||
&& geometry
|
||||
.visible_positions
|
||||
.contains(&(poi.position.x, poi.position.y))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::poi::{PoiCategory, PoiDiscoverySource};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn make_poi(
|
||||
id: &str,
|
||||
position: TilePosition,
|
||||
visibility: PoiVisibility,
|
||||
) -> PointOfInterest {
|
||||
PointOfInterest {
|
||||
poi_id: id.to_string(),
|
||||
name: format!("Test {}", id),
|
||||
position,
|
||||
category: PoiCategory::Location,
|
||||
discovery_source: PoiDiscoverySource::MapTemplate,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_geometry(visible: &[(i32, i32)], z: i32) -> VisibilityGeometry {
|
||||
VisibilityGeometry {
|
||||
visible_tiles: vec![],
|
||||
visible_positions: visible.iter().copied().collect::<BTreeSet<_>>(),
|
||||
sector_lookup: Default::default(),
|
||||
observer_z: z,
|
||||
}
|
||||
}
|
||||
|
||||
// --- can_discover tests ---
|
||||
|
||||
#[test]
|
||||
fn los_poi_discovered_when_in_visible_positions() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight);
|
||||
let geometry = make_geometry(&[(10, 5)], 0);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
assert!(can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn los_poi_not_discovered_when_not_visible() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight);
|
||||
let geometry = make_geometry(&[(8, 5)], 0); // (10,5) not in visible set
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn los_poi_not_discovered_on_different_z() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi("bay", TilePosition::new(10, 5, 1), PoiVisibility::LineOfSight);
|
||||
let geometry = make_geometry(&[(10, 5)], 0); // observer on z=0, poi on z=1
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_poi_discovered_within_range() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"cache",
|
||||
TilePosition::new(7, 5, 0),
|
||||
PoiVisibility::Proximity { range: 3 },
|
||||
);
|
||||
let geometry = make_geometry(&[], 0);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
// Manhattan distance = 2, range = 3 → discovered
|
||||
assert!(can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_poi_not_discovered_outside_range() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"cache",
|
||||
TilePosition::new(10, 5, 0),
|
||||
PoiVisibility::Proximity { range: 3 },
|
||||
);
|
||||
let geometry = make_geometry(&[], 0);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
// Manhattan distance = 5, range = 3 → not discovered
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_poi_not_discovered_different_z() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"cache",
|
||||
TilePosition::new(5, 6, 1), // different z
|
||||
PoiVisibility::Proximity { range: 3 },
|
||||
);
|
||||
let geometry = make_geometry(&[], 0);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
// manhattan_distance returns None for different z
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_only_never_discovered_physically() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"secret",
|
||||
TilePosition::new(5, 5, 0), // same tile
|
||||
PoiVisibility::KnowledgeOnly,
|
||||
);
|
||||
let geometry = make_geometry(&[(5, 5)], 0);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_fact_discovered_when_fact_known_and_visible() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"hidden_door",
|
||||
TilePosition::new(8, 5, 0),
|
||||
PoiVisibility::RequiresFact {
|
||||
fact_id: "quest.secret_hint".to_string(),
|
||||
},
|
||||
);
|
||||
let geometry = make_geometry(&[(8, 5)], 0);
|
||||
let kg = KnowledgeGraph::with_background(vec![(
|
||||
FactId("quest.secret_hint".to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
)]);
|
||||
|
||||
assert!(can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_fact_not_discovered_without_fact() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"hidden_door",
|
||||
TilePosition::new(8, 5, 0),
|
||||
PoiVisibility::RequiresFact {
|
||||
fact_id: "quest.secret_hint".to_string(),
|
||||
},
|
||||
);
|
||||
let geometry = make_geometry(&[(8, 5)], 0);
|
||||
let kg = KnowledgeGraph::new(); // no facts
|
||||
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_fact_not_discovered_when_not_visible() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"hidden_door",
|
||||
TilePosition::new(8, 5, 0),
|
||||
PoiVisibility::RequiresFact {
|
||||
fact_id: "quest.secret_hint".to_string(),
|
||||
},
|
||||
);
|
||||
let geometry = make_geometry(&[], 0); // not visible
|
||||
let kg = KnowledgeGraph::with_background(vec![(
|
||||
FactId("quest.secret_hint".to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
)]);
|
||||
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
// --- System integration test ---
|
||||
|
||||
#[test]
|
||||
fn discover_pois_system_grants_fact() {
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
let mut world = World::new();
|
||||
|
||||
// Resources
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 50;
|
||||
world.insert_resource(time);
|
||||
world.insert_resource(make_geometry(&[(10, 5)], 0));
|
||||
world.insert_resource(PoiDiscoveryEventQueue::default());
|
||||
|
||||
// Player observer
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
|
||||
// POI entity
|
||||
world.spawn(make_poi(
|
||||
"docking_bay",
|
||||
TilePosition::new(10, 5, 0),
|
||||
PoiVisibility::LineOfSight,
|
||||
));
|
||||
|
||||
// Run system
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(discover_pois);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Verify: player now knows the POI fact
|
||||
let mut query = world.query_filtered::<&KnowledgeGraph, With<PlayerCharacter>>();
|
||||
let kg = query.single(&world).expect("player should exist");
|
||||
let fact_id = FactId("poi.docking_bay".to_string());
|
||||
assert!(kg.knows_fact(&fact_id), "Player should know poi.docking_bay");
|
||||
assert_eq!(
|
||||
kg.facts.get(&fact_id).unwrap().confidence,
|
||||
KnowledgeConfidence::KnowsOf
|
||||
);
|
||||
|
||||
// Verify: discovery event was emitted
|
||||
let queue = world.resource::<PoiDiscoveryEventQueue>();
|
||||
assert_eq!(queue.events.len(), 1);
|
||||
assert_eq!(queue.events[0].poi_id, "docking_bay");
|
||||
assert_eq!(queue.events[0].tick, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_pois_system_skips_already_known() {
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
let mut world = World::new();
|
||||
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 100;
|
||||
world.insert_resource(time);
|
||||
world.insert_resource(make_geometry(&[(10, 5)], 0));
|
||||
world.insert_resource(PoiDiscoveryEventQueue::default());
|
||||
|
||||
// Player already knows this POI
|
||||
let kg = KnowledgeGraph::with_background(vec![(
|
||||
FactId("poi.docking_bay".to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
)]);
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg));
|
||||
|
||||
world.spawn(make_poi(
|
||||
"docking_bay",
|
||||
TilePosition::new(10, 5, 0),
|
||||
PoiVisibility::LineOfSight,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(discover_pois);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// No new events — already known
|
||||
let queue = world.resource::<PoiDiscoveryEventQueue>();
|
||||
assert!(queue.is_empty(), "No discovery event for already-known POI");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user