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

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

776 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, RelationshipState, 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)
});
// Loyal trait: exclude facts sourced from high-trust entities.
// "Don't gossip about your friends" — if ToldBy source has Friendly
// relationship in this NPC's KG, suppress the fact. D-081.
let exclude_high_trust = trait_config.any_excludes_high_trust(&trait_keys);
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;
}
// Loyal: skip facts from high-trust (Friendly) source entities.
if exclude_high_trust {
if let KnowledgeSource::ToldBy { source_id, .. } = &fact.source {
if kg.relationship_with(source_id) == RelationshipState::Friendly {
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.
// Takes highest-priority candidate (sorted by confidence desc,
// then recency desc in derive_disclosure_candidates). Full Layer 4
// line selection with variety tracking is deferred to #172.
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.
// Note: re-queries npc_query mutably after the read pass above. This is
// safe because the read pass only borrows shared refs and completes before
// this point. The two-phase pattern (read → select winner → write) avoids
// holding a mutable borrow during iteration.
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"
);
}
}