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>
776 lines
26 KiB
Rust
776 lines
26 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|