Critical fixes: - storyteller: replace .expect() with guard + log in activation_pass (Hoshe #1) - content/loader: validate inverted tile_bounds before iteration (Hoshe #C) - save_io: persist ActivationState on save/load (Tyre #7) High-priority fixes: - storyteller: f64 intermediate for observation_time_ticks scoring (Hoshe #A) - storyteller: deduplicate copresent entities before scoring (Hoshe #B) - storyteller: skip activation_pass at tick 0 (Hoshe #G) - storyteller: explicit .before(advance_tick) ordering (Tyre #9) - save_state: insert EngagementRecord on NPC deserialize (Tyre #10) - save_io: reset TriangleActivatedQueue + MovementHistoryBuffer on load (Tyre #8) - debug: validate teleport target walkability (Hoshe #E) - debug: reject SkipToContamination when tick past delay (Hoshe #F) - debug: DebugEnabled defaults to cfg!(debug_assertions) (Hoshe #3) - debug: log when response overwritten (Hoshe #2) - content/loader: error on tile dimension mismatch (Hoshe #5) - content/types: DistrictMeta.description optional (Hoshe #D) - types: version docs updated to v18 (Tyre #1, #2, #3) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1085 lines
40 KiB
Rust
1085 lines
40 KiB
Rust
//! Storyteller module — Rimworld-style narrative pacing (#254).
|
||
//!
|
||
//! The storyteller is the game's hidden director. It manages event timing,
|
||
//! pressure escalation, and the hubris wall (future). Its first concrete
|
||
//! mechanic is **contamination activation**: after a configurable delay,
|
||
//! the investigation's proximity begins pressuring smuggling-ring triangles.
|
||
//!
|
||
//! ## Contamination (#254)
|
||
//!
|
||
//! After `CONTAMINATION_DELAY_TICKS` (default 300 = 30 game-minutes at
|
||
//! 10 ticks/game-minute per D-031), the storyteller:
|
||
//! 1. Sets `ContaminationActive` resource to true (one-shot)
|
||
//! 2. Applies a tension delta to all `ActiveFork` triangles
|
||
//! 3. Emits a `ContaminationEvent` for downstream systems (monologue, etc.)
|
||
//!
|
||
//! ## Activation Lifecycle (#572)
|
||
//!
|
||
//! After contamination, the activation pass (#162, #579) runs on a 10-tick
|
||
//! cadence, scoring NPCs by engagement and activating the best-fit triangle.
|
||
//!
|
||
//! **v0.1 lifecycle rules (single-activation model):**
|
||
//!
|
||
//! 1. **One activation per session.** The vertical slice is a ~30-minute
|
||
//! experience. Once a triangle is activated, the storyteller does not
|
||
//! activate additional triangles. `ActivationState::Activated` is the
|
||
//! terminal state for the storyteller within a single playthrough.
|
||
//!
|
||
//! 2. **No concurrent activations.** At most one triangle can be in the
|
||
//! `TrianglePhase::Active` state at any time. This is trivially enforced
|
||
//! by rule 1 for v0.1 — future versions (v0.3+) will need a concurrency
|
||
//! limit and priority queue.
|
||
//!
|
||
//! 3. **No cooldown.** Since only one activation fires, there is no cooldown
|
||
//! period between activations. Future multi-activation will need
|
||
//! `ACTIVATION_COOLDOWN_TICKS` — stub the constant now at 0.
|
||
//!
|
||
//! 4. **Resolution is terminal.** When a triangle reaches `TrianglePhase::Resolved`
|
||
//! (player completes investigation or consequences fire), it stays resolved.
|
||
//! The storyteller does not re-activate resolved triangles. For v0.1 this
|
||
//! is moot (one-shot), but the activation pass must still check for it.
|
||
//!
|
||
//! 5. **Activation cadence.** The activation pass polls every
|
||
//! `ACTIVATION_CADENCE_TICKS` (10 ticks = 1 game-second). This is cheap
|
||
//! because the pass no-ops if `ActivationState` is already `Activated`.
|
||
//!
|
||
//! **Future extensions (v0.3+):**
|
||
//! - `MAX_CONCURRENT_ACTIVATIONS` > 1
|
||
//! - `ACTIVATION_COOLDOWN_TICKS` > 0 between successive activations
|
||
//! - `SelectionStrategy::WeightedRandom` alongside `HighestScore`
|
||
//! - Hubris wall gating (activation blocked above a threshold)
|
||
|
||
use std::collections::VecDeque;
|
||
|
||
use bevy_app::prelude::*;
|
||
use bevy_ecs::prelude::*;
|
||
use rand::Rng;
|
||
|
||
use crate::content::template::{TriangleClassification, TriangleId, TrianglePhase, TriangleState};
|
||
use crate::knowledge::EntityRegistry;
|
||
use crate::npc::Npc;
|
||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||
use crate::simulation::rng::SimRng;
|
||
use crate::simulation::tier::ActiveSim;
|
||
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Tick at which contamination activates (30 game-minutes × 10 ticks/minute = 300).
|
||
pub const CONTAMINATION_DELAY_TICKS: u64 = 30 * TICKS_PER_GAME_MINUTE;
|
||
|
||
/// Tension delta applied to ActiveFork triangles when contamination fires.
|
||
pub const CONTAMINATION_PRESSURE_DELTA: u8 = 10;
|
||
|
||
/// Threshold above which confrontation mechanics trigger (Q-017 fallback).
|
||
/// 0–100 scale. Not yet consumed — placeholder for future confrontation system.
|
||
pub const CONFRONTATION_THRESHOLD: u8 = 75;
|
||
|
||
// --- Activation lifecycle constants (#572) ----------------------------------
|
||
|
||
/// How often the activation pass runs, in ticks.
|
||
/// 10 ticks = 1 game-second (D-031). Cheap — no-ops if already activated.
|
||
pub const ACTIVATION_CADENCE_TICKS: u64 = 10;
|
||
|
||
/// Maximum concurrent triangle activations. v0.1 = 1 (single-activation).
|
||
pub const MAX_CONCURRENT_ACTIVATIONS: u32 = 1;
|
||
|
||
/// Cooldown between successive activations, in ticks. v0.1 = 0 (one-shot).
|
||
/// Future multi-activation (v0.3+) will set this to e.g. 600 (1 game-minute).
|
||
pub const ACTIVATION_COOLDOWN_TICKS: u64 = 0;
|
||
|
||
/// Engagement window for NPC co-presence scoring (#162 step 2).
|
||
/// 3000 ticks = 5 game-minutes = ~5 real minutes at 10 tps.
|
||
pub const ENGAGEMENT_WINDOW_TICKS: u64 = 3000;
|
||
|
||
// --- Engagement scoring weights (#162 step 3) -------------------------------
|
||
|
||
/// Weight per conversation (capped at `CONVERSATION_CAP`).
|
||
pub const ENGAGEMENT_WEIGHT_CONVERSATION: f32 = 2.0;
|
||
|
||
/// Maximum conversations counted toward engagement score.
|
||
pub const CONVERSATION_CAP: u32 = 5;
|
||
|
||
/// Weight per tick of observation time.
|
||
/// 200 ticks (~20 real seconds) = 1 point.
|
||
pub const ENGAGEMENT_WEIGHT_OBSERVATION: f32 = 0.005;
|
||
|
||
/// Weight per monologue trigger — richest signal.
|
||
pub const ENGAGEMENT_WEIGHT_MONOLOGUE: f32 = 8.0;
|
||
|
||
/// Proximity threshold (Chebyshev tiles) for co-presence detection in activation_pass.
|
||
/// Matches NaturalVision forward_range (D-015, D-017): 20 tiles.
|
||
pub const COPRESENCE_THRESHOLD: i32 = 20;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Resources
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Per-NPC engagement metrics for storyteller activation scoring (#570, #162).
|
||
///
|
||
/// Tracks cumulative engagement signals the player has generated with a specific NPC.
|
||
/// Used by `activation_pass()` (#579) to score NPCs and select the best-fit triangle.
|
||
///
|
||
/// All three fields are additive counters — never overwritten, only incremented.
|
||
///
|
||
/// **Write sites:**
|
||
/// - `observation_time_ticks`: `perception::observation::emit_observation_events` — +1 per tick in LOS
|
||
/// - `conversation_count`: `simulation::dialogue::process_talk_interaction` — +1 on Talk start
|
||
/// - `monologue_trigger_count`:`simulation::monologue::trigger_event_monologue` — +1 on NPC-context fire
|
||
#[derive(Component, Debug, Clone, Default)]
|
||
pub struct EngagementRecord {
|
||
/// Cumulative ticks this NPC was in the player's direct LOS.
|
||
pub observation_time_ticks: u64,
|
||
/// Number of player-initiated conversation starts with this NPC.
|
||
pub conversation_count: u32,
|
||
/// Number of monologue triggers fired in this NPC's context
|
||
/// (observe_npc or post_conversation referencing this entity).
|
||
pub monologue_trigger_count: u32,
|
||
}
|
||
|
||
/// Whether contamination has been activated by the storyteller.
|
||
///
|
||
/// Once set to `true`, it stays true for the remainder of the session.
|
||
/// Persisted in `SaveStateV1` to prevent double-firing on save/load.
|
||
#[derive(Resource, Debug, Clone, Default)]
|
||
pub struct ContaminationActive(pub bool);
|
||
|
||
/// Storyteller activation state (#572).
|
||
///
|
||
/// Tracks how many triangles have been activated this session. For v0.1 this
|
||
/// is a simple boolean gate — once `activated_count >= MAX_CONCURRENT_ACTIVATIONS`,
|
||
/// the activation pass no-ops. Persisted in `SaveStateV1`.
|
||
#[derive(Resource, Debug, Clone)]
|
||
pub struct ActivationState {
|
||
/// Number of triangles activated this session.
|
||
pub activated_count: u32,
|
||
/// Tick at which the most recent activation occurred. `None` if no activation yet.
|
||
pub last_activation_tick: Option<u64>,
|
||
}
|
||
|
||
impl Default for ActivationState {
|
||
fn default() -> Self {
|
||
Self {
|
||
activated_count: 0,
|
||
last_activation_tick: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ActivationState {
|
||
/// Whether the activation pass should attempt to activate a new triangle.
|
||
///
|
||
/// For v0.1 this returns `false` after the first activation. Future versions
|
||
/// will also check cooldown elapsed since `last_activation_tick`.
|
||
pub fn can_activate(&self, _current_tick: u64) -> bool {
|
||
if self.activated_count >= MAX_CONCURRENT_ACTIVATIONS {
|
||
return false;
|
||
}
|
||
// v0.1: cooldown is 0, so no cooldown check needed.
|
||
// Future: check (current_tick - last_activation_tick) >= ACTIVATION_COOLDOWN_TICKS
|
||
if ACTIVATION_COOLDOWN_TICKS > 0 {
|
||
if let Some(last) = self.last_activation_tick {
|
||
if _current_tick.saturating_sub(last) < ACTIVATION_COOLDOWN_TICKS {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Record that a triangle was activated at the given tick.
|
||
pub fn record_activation(&mut self, tick: u64) {
|
||
self.activated_count += 1;
|
||
self.last_activation_tick = Some(tick);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Events
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Emitted once when contamination activates (#254).
|
||
///
|
||
/// Downstream consumers (monologue system, NPC behavioral shifts) can
|
||
/// listen for this to trigger one-shot reactions.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ContaminationEvent {
|
||
/// Tick at which contamination activated.
|
||
pub tick: u64,
|
||
/// Number of ActiveFork triangles that received pressure.
|
||
pub triangles_affected: u32,
|
||
}
|
||
|
||
/// Resource queue for contamination events.
|
||
///
|
||
/// Follows the same pattern as `TriangleCrisisEventQueue` —
|
||
/// populated by the storyteller system, drained by consumers.
|
||
#[derive(Resource, Default)]
|
||
pub struct ContaminationEventQueue {
|
||
pub events: Vec<ContaminationEvent>,
|
||
}
|
||
|
||
impl ContaminationEventQueue {
|
||
pub fn push(&mut self, event: ContaminationEvent) {
|
||
self.events.push(event);
|
||
}
|
||
|
||
pub fn drain(&mut self) -> Vec<ContaminationEvent> {
|
||
std::mem::take(&mut self.events)
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.events.is_empty()
|
||
}
|
||
}
|
||
|
||
/// Emitted when the activation pass selects and activates a triangle (#162, #572).
|
||
///
|
||
/// Downstream consumers:
|
||
/// - Tell system (D-024 axis 9): escalate tell behavior frequency
|
||
/// - Routine scheduler: activated triangle NPCs may deviate from routine
|
||
/// - Monologue system (D-016): unlock `triangle_activated` context lines
|
||
/// - FRIEND arc manager (D-034): advance relationship phase if applicable
|
||
#[derive(Debug, Clone)]
|
||
pub struct TriangleActivatedEvent {
|
||
/// Which triangle was activated.
|
||
pub triangle_id: TriangleId,
|
||
/// Tick at which activation occurred.
|
||
pub tick: u64,
|
||
/// The NPC entity that scored highest (activation anchor).
|
||
pub anchor_entity: Entity,
|
||
/// Engagement score of the anchor NPC.
|
||
pub anchor_score: f32,
|
||
}
|
||
|
||
/// Resource queue for triangle activation events.
|
||
///
|
||
/// Same drain pattern as `ContaminationEventQueue`. Populated by
|
||
/// `activation_pass()` (#579), consumed by downstream systems.
|
||
#[derive(Resource, Default)]
|
||
pub struct TriangleActivatedQueue {
|
||
pub events: Vec<TriangleActivatedEvent>,
|
||
}
|
||
|
||
impl TriangleActivatedQueue {
|
||
pub fn push(&mut self, event: TriangleActivatedEvent) {
|
||
self.events.push(event);
|
||
}
|
||
|
||
pub fn drain(&mut self) -> Vec<TriangleActivatedEvent> {
|
||
std::mem::take(&mut self.events)
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.events.is_empty()
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Movement history (#571)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Ring buffer of recent player tile positions for storyteller proximity scoring.
|
||
///
|
||
/// Retains the player's path over the last `ENGAGEMENT_WINDOW_TICKS` ticks.
|
||
/// Provides `npcs_copresent_in_window` to identify which NPCs have shared
|
||
/// space with the player recently, feeding the activation pass (#162, #579).
|
||
#[derive(Resource, Debug, Clone)]
|
||
pub struct MovementHistoryBuffer {
|
||
positions: VecDeque<TilePosition>,
|
||
}
|
||
|
||
impl Default for MovementHistoryBuffer {
|
||
fn default() -> Self {
|
||
Self {
|
||
positions: VecDeque::with_capacity(ENGAGEMENT_WINDOW_TICKS as usize),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl MovementHistoryBuffer {
|
||
/// Append the player's current position for this tick.
|
||
///
|
||
/// Maintains a maximum of `ENGAGEMENT_WINDOW_TICKS` entries by evicting
|
||
/// the oldest position when the buffer is full.
|
||
pub fn append(&mut self, pos: TilePosition) {
|
||
if self.positions.len() >= ENGAGEMENT_WINDOW_TICKS as usize {
|
||
self.positions.pop_front();
|
||
}
|
||
self.positions.push_back(pos);
|
||
}
|
||
|
||
/// Returns entities from `npc_positions` whose position is within
|
||
/// `threshold` tiles (Chebyshev distance) of any recorded player position
|
||
/// in the buffer on the same z-level.
|
||
///
|
||
/// Used by the activation pass (#579) to identify which NPCs the player
|
||
/// was co-present with during the engagement window.
|
||
pub fn npcs_copresent_in_window(
|
||
&self,
|
||
npc_positions: impl Iterator<Item = (Entity, TilePosition)>,
|
||
threshold: i32,
|
||
) -> Vec<Entity> {
|
||
npc_positions
|
||
.filter(|(_, npc_pos)| {
|
||
self.positions.iter().any(|player_pos| {
|
||
player_pos.z == npc_pos.z
|
||
&& (player_pos.x - npc_pos.x).abs() <= threshold
|
||
&& (player_pos.y - npc_pos.y).abs() <= threshold
|
||
})
|
||
})
|
||
.map(|(entity, _)| entity)
|
||
.collect()
|
||
}
|
||
|
||
/// Number of recorded positions in the buffer.
|
||
pub fn len(&self) -> usize {
|
||
self.positions.len()
|
||
}
|
||
|
||
/// True if the buffer has no recorded positions.
|
||
pub fn is_empty(&self) -> bool {
|
||
self.positions.is_empty()
|
||
}
|
||
}
|
||
|
||
/// System: append the player's current position to the movement history buffer.
|
||
///
|
||
/// Runs each tick after `validate_movement`. Maintains the sliding
|
||
/// `ENGAGEMENT_WINDOW_TICKS` window consumed by the activation pass (#162).
|
||
pub fn append_player_history(
|
||
mut history: ResMut<MovementHistoryBuffer>,
|
||
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
||
) {
|
||
if let Ok(pos) = player_query.single() {
|
||
history.append(*pos);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Plugin
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Storyteller plugin — manages narrative pacing and event generation.
|
||
pub struct StorytellerPlugin;
|
||
|
||
impl Plugin for StorytellerPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.init_resource::<ContaminationActive>()
|
||
.init_resource::<ContaminationEventQueue>()
|
||
.init_resource::<ActivationState>()
|
||
.init_resource::<TriangleActivatedQueue>()
|
||
.init_resource::<MovementHistoryBuffer>()
|
||
.add_systems(Update, tick_contamination_activation)
|
||
.add_systems(
|
||
Update,
|
||
append_player_history
|
||
.after(crate::simulation::movement::validate_movement),
|
||
)
|
||
.add_systems(
|
||
Update,
|
||
activation_pass
|
||
.after(append_player_history)
|
||
.after(tick_contamination_activation)
|
||
.before(crate::simulation::time::advance_tick),
|
||
);
|
||
|
||
tracing::debug!("StorytellerPlugin initialized");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Systems
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// System: activate contamination after the configured delay (#254).
|
||
///
|
||
/// One-shot: checks every tick whether the delay has elapsed. When it fires:
|
||
/// 1. Sets `ContaminationActive` to true
|
||
/// 2. Increments `tension` on all `ActiveFork` triangles by `CONTAMINATION_PRESSURE_DELTA`
|
||
/// 3. Emits a `ContaminationEvent`
|
||
///
|
||
/// After activation, the system early-returns on subsequent ticks.
|
||
pub fn tick_contamination_activation(
|
||
time: Res<SimulationTime>,
|
||
mut contamination: ResMut<ContaminationActive>,
|
||
mut event_queue: ResMut<ContaminationEventQueue>,
|
||
mut triangles: Query<&mut TriangleState, With<ActiveSim>>,
|
||
) {
|
||
// Already activated — nothing to do
|
||
if contamination.0 {
|
||
return;
|
||
}
|
||
|
||
// Not yet time
|
||
if time.tick < CONTAMINATION_DELAY_TICKS {
|
||
return;
|
||
}
|
||
|
||
// Activate contamination
|
||
contamination.0 = true;
|
||
|
||
let mut affected = 0u32;
|
||
for mut state in &mut triangles {
|
||
if state.classification == TriangleClassification::ActiveFork {
|
||
state.tension = state.tension.saturating_add(CONTAMINATION_PRESSURE_DELTA);
|
||
affected += 1;
|
||
}
|
||
}
|
||
|
||
event_queue.push(ContaminationEvent {
|
||
tick: time.tick,
|
||
triangles_affected: affected,
|
||
});
|
||
|
||
tracing::info!(
|
||
"Contamination activated at tick {} — {} ActiveFork triangles pressured (+{})",
|
||
time.tick,
|
||
affected,
|
||
CONTAMINATION_PRESSURE_DELTA,
|
||
);
|
||
}
|
||
|
||
/// Helper: compute engagement score from an EngagementRecord.
|
||
///
|
||
/// Formula: capped conversations × weight + observation ticks × weight + monologue triggers × weight.
|
||
/// Implements #162 step 3 scoring.
|
||
fn compute_engagement_score(record: &EngagementRecord) -> f32 {
|
||
let conv_score = record.conversation_count.min(CONVERSATION_CAP) as f32
|
||
* ENGAGEMENT_WEIGHT_CONVERSATION;
|
||
// Use f64 intermediate to avoid precision loss beyond ~16.8M ticks (2^24),
|
||
// where f32 can no longer distinguish adjacent integers.
|
||
let obs_score = (record.observation_time_ticks as f64 * ENGAGEMENT_WEIGHT_OBSERVATION as f64) as f32;
|
||
let mono_score = record.monologue_trigger_count as f32 * ENGAGEMENT_WEIGHT_MONOLOGUE;
|
||
conv_score + obs_score + mono_score
|
||
}
|
||
|
||
/// System: storyteller activation pass — select and activate the best-fit triangle.
|
||
///
|
||
/// Implements #162 steps 1–6. Runs on a 10-tick cadence after contamination fires.
|
||
///
|
||
/// Steps:
|
||
/// 1. Gate: contamination active + activation limit not reached + cadence check.
|
||
/// 2. Co-presence query: find NPCs near any recorded player position (MovementHistoryBuffer).
|
||
/// 3. Engagement scoring: score each co-present NPC via EngagementRecord.
|
||
/// 4+5. Triangle routing: find Simmering triangle containing highest-scoring co-present NPC.
|
||
/// v0.1: NPCs not assigned to any triangle are excluded (D-025 reference link routing deferred to v0.3+).
|
||
/// Holds (no activation) if no triangle-assigned NPC is co-present.
|
||
/// 6. Activation event: emit TriangleActivatedEvent, record in ActivationState.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn activation_pass(
|
||
time: Res<SimulationTime>,
|
||
contamination: Res<ContaminationActive>,
|
||
mut activation_state: ResMut<ActivationState>,
|
||
history: Res<MovementHistoryBuffer>,
|
||
registry: Res<EntityRegistry>,
|
||
mut rng: ResMut<SimRng>,
|
||
mut triangles: Query<(Entity, &mut TriangleState), With<ActiveSim>>,
|
||
npcs: Query<(Entity, &TilePosition, Option<&EngagementRecord>), (With<Npc>, With<ActiveSim>)>,
|
||
mut event_queue: ResMut<TriangleActivatedQueue>,
|
||
) {
|
||
// Gate 1: contamination must be active
|
||
if !contamination.0 {
|
||
return;
|
||
}
|
||
// Gate 2: activation limit
|
||
if !activation_state.can_activate(time.tick) {
|
||
return;
|
||
}
|
||
// Gate 3: poll cadence — only run every ACTIVATION_CADENCE_TICKS.
|
||
// Guard: at tick 0 all engagement scores are 0.0 and selection is random
|
||
// (e.g. contamination pre-set in a save file). Skip tick 0.
|
||
if time.tick == 0 || time.tick % ACTIVATION_CADENCE_TICKS != 0 {
|
||
return;
|
||
}
|
||
|
||
// Step 2: co-presence query
|
||
let npc_positions: Vec<(Entity, TilePosition)> =
|
||
npcs.iter().map(|(e, pos, _)| (e, *pos)).collect();
|
||
let mut copresent = history.npcs_copresent_in_window(npc_positions.into_iter(), COPRESENCE_THRESHOLD);
|
||
// Deduplicate: the co-presence query can return the same entity more than once
|
||
// if multiple player positions fall near the same NPC. Without dedup, the NPC
|
||
// gets added to candidates twice, doubling its RNG weight.
|
||
copresent.sort_unstable();
|
||
copresent.dedup();
|
||
|
||
// Snapshot simmering triangles for read (avoids double-borrow during activation write)
|
||
let simmering: Vec<(Entity, TriangleState)> = triangles
|
||
.iter()
|
||
.filter(|(_, s)| s.phase == TrianglePhase::Simmering)
|
||
.map(|(e, s)| (e, s.clone()))
|
||
.collect();
|
||
|
||
if simmering.is_empty() {
|
||
tracing::warn!("activation_pass: no Simmering triangles — holding");
|
||
return;
|
||
}
|
||
|
||
// Steps 3+4: score co-present NPCs that belong to a Simmering triangle.
|
||
// Unentangled NPCs (not in any triangle) are excluded; v0.1 skips D-025 routing.
|
||
let mut candidates: Vec<(Entity, Entity, f32)> = Vec::new(); // (npc_entity, tri_entity, score)
|
||
for &npc_entity in &copresent {
|
||
let Some(stable_id) = registry.to_stable(npc_entity) else {
|
||
continue;
|
||
};
|
||
let score = npcs
|
||
.get(npc_entity)
|
||
.ok()
|
||
.and_then(|(_, _, r)| r)
|
||
.map(compute_engagement_score)
|
||
.unwrap_or(0.0);
|
||
for (tri_entity, tri_state) in &simmering {
|
||
if tri_state
|
||
.role_assignments
|
||
.values()
|
||
.any(|sid| *sid == stable_id)
|
||
{
|
||
candidates.push((npc_entity, *tri_entity, score));
|
||
break; // each NPC is assigned to at most one triangle per pass
|
||
}
|
||
}
|
||
}
|
||
|
||
if candidates.is_empty() {
|
||
tracing::warn!(
|
||
"activation_pass: no co-present NPC is assigned to a Simmering triangle at tick {} — holding",
|
||
time.tick
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Step 5: select best candidate; SimRng tie-break among equal top scores (D-010)
|
||
candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
|
||
let top_score = candidates[0].2;
|
||
let top_count = candidates
|
||
.iter()
|
||
.take_while(|(_, _, s)| (*s - top_score).abs() <= f32::EPSILON * top_score.abs().max(1.0))
|
||
.count();
|
||
let selected_idx = if top_count > 1 {
|
||
rng.rng.random_range(0..top_count)
|
||
} else {
|
||
0
|
||
};
|
||
let (anchor_entity, tri_entity, anchor_score) = candidates[selected_idx];
|
||
|
||
// Retrieve triangle_id for the event.
|
||
// Guard: the entity was in the simmering snapshot we collected above, but in
|
||
// theory it could have been despawned between snapshot and lookup (unlikely but
|
||
// a panic in the gameplay loop is unacceptable).
|
||
let Some(triangle_id) = simmering
|
||
.iter()
|
||
.find(|(e, _)| *e == tri_entity)
|
||
.map(|(_, s)| s.triangle_id)
|
||
else {
|
||
tracing::error!(
|
||
"activation_pass: target triangle entity {:?} missing from simmering snapshot — skipping activation at tick {}",
|
||
tri_entity, time.tick
|
||
);
|
||
return;
|
||
};
|
||
|
||
// Step 6: set triangle phase to Active
|
||
if let Ok((_, mut tri_state)) = triangles.get_mut(tri_entity) {
|
||
tri_state.phase = TrianglePhase::Active;
|
||
}
|
||
|
||
// Step 7: record activation and emit event
|
||
activation_state.record_activation(time.tick);
|
||
event_queue.push(TriangleActivatedEvent {
|
||
triangle_id,
|
||
tick: time.tick,
|
||
anchor_entity,
|
||
anchor_score,
|
||
});
|
||
|
||
tracing::info!(
|
||
"activation_pass: activated triangle {} at tick {} (anchor score: {:.2})",
|
||
triangle_id.0,
|
||
time.tick,
|
||
anchor_score,
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::content::template::{
|
||
RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState,
|
||
};
|
||
use crate::knowledge::types::StableId;
|
||
use std::collections::BTreeMap;
|
||
|
||
fn make_triangle(classification: TriangleClassification) -> TriangleState {
|
||
let mut role_assignments = BTreeMap::new();
|
||
role_assignments.insert(RoleId::new("a"), StableId(1));
|
||
role_assignments.insert(RoleId::new("b"), StableId(2));
|
||
role_assignments.insert(RoleId::new("c"), StableId(3));
|
||
|
||
TriangleState {
|
||
triangle_id: TriangleId::from_seed_and_slug(0, "test"),
|
||
role_assignments,
|
||
tension: 0,
|
||
phase: match classification {
|
||
TriangleClassification::ActiveFork => TrianglePhase::Simmering,
|
||
TriangleClassification::PassiveTension => TrianglePhase::Dormant,
|
||
},
|
||
tension_rate: 1,
|
||
template_id: TemplateId::from_seed_and_slug(0, "test"),
|
||
classification,
|
||
}
|
||
}
|
||
|
||
fn setup_world() -> (World, Schedule) {
|
||
let mut world = World::new();
|
||
world.init_resource::<SimulationTime>();
|
||
world.init_resource::<ContaminationActive>();
|
||
world.init_resource::<ContaminationEventQueue>();
|
||
|
||
let mut schedule = Schedule::default();
|
||
schedule.add_systems(tick_contamination_activation);
|
||
|
||
(world, schedule)
|
||
}
|
||
|
||
#[test]
|
||
fn no_activation_before_delay() {
|
||
let (mut world, mut schedule) = setup_world();
|
||
|
||
// Spawn an ActiveFork triangle
|
||
world.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim));
|
||
|
||
// Run at tick 0 — should not activate
|
||
schedule.run(&mut world);
|
||
assert!(!world.resource::<ContaminationActive>().0);
|
||
assert!(world.resource::<ContaminationEventQueue>().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn activates_at_delay_tick() {
|
||
let (mut world, mut schedule) = setup_world();
|
||
|
||
world.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim));
|
||
|
||
// Set tick to exactly the delay threshold
|
||
world.resource_mut::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS;
|
||
schedule.run(&mut world);
|
||
|
||
assert!(world.resource::<ContaminationActive>().0);
|
||
assert!(!world.resource::<ContaminationEventQueue>().is_empty());
|
||
|
||
let events = world.resource_mut::<ContaminationEventQueue>().drain();
|
||
assert_eq!(events.len(), 1);
|
||
assert_eq!(events[0].tick, CONTAMINATION_DELAY_TICKS);
|
||
assert_eq!(events[0].triangles_affected, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn only_active_fork_triangles_pressured() {
|
||
let (mut world, mut schedule) = setup_world();
|
||
|
||
// Spawn one ActiveFork and one PassiveTension
|
||
let fork_entity = world
|
||
.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim))
|
||
.id();
|
||
let passive_entity = world
|
||
.spawn((
|
||
make_triangle(TriangleClassification::PassiveTension),
|
||
ActiveSim,
|
||
))
|
||
.id();
|
||
|
||
world.resource_mut::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS;
|
||
schedule.run(&mut world);
|
||
|
||
// ActiveFork should have tension increased
|
||
let fork_state = world.get::<TriangleState>(fork_entity).unwrap();
|
||
assert_eq!(fork_state.tension, CONTAMINATION_PRESSURE_DELTA);
|
||
|
||
// PassiveTension should remain at 0
|
||
let passive_state = world.get::<TriangleState>(passive_entity).unwrap();
|
||
assert_eq!(passive_state.tension, 0);
|
||
|
||
// Event should report 1 affected (only the ActiveFork)
|
||
let events = world.resource_mut::<ContaminationEventQueue>().drain();
|
||
assert_eq!(events[0].triangles_affected, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn one_shot_does_not_fire_twice() {
|
||
let (mut world, mut schedule) = setup_world();
|
||
|
||
let entity = world
|
||
.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim))
|
||
.id();
|
||
|
||
// First activation
|
||
world.resource_mut::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS;
|
||
schedule.run(&mut world);
|
||
|
||
let tension_after_first = world.get::<TriangleState>(entity).unwrap().tension;
|
||
assert_eq!(tension_after_first, CONTAMINATION_PRESSURE_DELTA);
|
||
|
||
// Drain events
|
||
world.resource_mut::<ContaminationEventQueue>().drain();
|
||
|
||
// Second run — should NOT apply pressure again
|
||
world.resource_mut::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS + 100;
|
||
schedule.run(&mut world);
|
||
|
||
let tension_after_second = world.get::<TriangleState>(entity).unwrap().tension;
|
||
assert_eq!(
|
||
tension_after_second, tension_after_first,
|
||
"one-shot: tension should not increase on subsequent ticks"
|
||
);
|
||
assert!(
|
||
world.resource::<ContaminationEventQueue>().is_empty(),
|
||
"one-shot: no new events after first activation"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tension_saturates_at_max() {
|
||
let (mut world, mut schedule) = setup_world();
|
||
|
||
// Start with tension near max
|
||
let mut triangle = make_triangle(TriangleClassification::ActiveFork);
|
||
triangle.tension = 250;
|
||
world.spawn((triangle, ActiveSim));
|
||
|
||
world.resource_mut::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS;
|
||
schedule.run(&mut world);
|
||
|
||
// Should saturate at 255, not overflow
|
||
let mut q = world.query::<&TriangleState>();
|
||
let state = q.single(&world).unwrap();
|
||
assert_eq!(state.tension, 255);
|
||
}
|
||
|
||
// --- ActivationState lifecycle tests (#572) ---
|
||
|
||
#[test]
|
||
fn activation_state_allows_first_activation() {
|
||
let state = ActivationState::default();
|
||
assert!(state.can_activate(500));
|
||
}
|
||
|
||
#[test]
|
||
fn activation_state_blocks_after_max() {
|
||
let mut state = ActivationState::default();
|
||
state.record_activation(500);
|
||
// v0.1: MAX_CONCURRENT_ACTIVATIONS = 1, so second is blocked
|
||
assert!(!state.can_activate(600));
|
||
}
|
||
|
||
#[test]
|
||
fn activation_state_records_tick() {
|
||
let mut state = ActivationState::default();
|
||
assert_eq!(state.activated_count, 0);
|
||
assert!(state.last_activation_tick.is_none());
|
||
|
||
state.record_activation(1000);
|
||
assert_eq!(state.activated_count, 1);
|
||
assert_eq!(state.last_activation_tick, Some(1000));
|
||
}
|
||
|
||
#[test]
|
||
fn triangle_activated_queue_drain() {
|
||
let mut queue = TriangleActivatedQueue::default();
|
||
assert!(queue.is_empty());
|
||
|
||
let mut world = World::new();
|
||
let entity = world.spawn_empty().id();
|
||
queue.push(TriangleActivatedEvent {
|
||
triangle_id: TriangleId::from_seed_and_slug(0, "test"),
|
||
tick: 500,
|
||
anchor_entity: entity,
|
||
anchor_score: 12.5,
|
||
});
|
||
assert!(!queue.is_empty());
|
||
|
||
let events = queue.drain();
|
||
assert_eq!(events.len(), 1);
|
||
assert!(queue.is_empty());
|
||
}
|
||
|
||
// --- MovementHistoryBuffer tests (#571) ---
|
||
|
||
#[test]
|
||
fn movement_history_append_and_len() {
|
||
let mut buf = MovementHistoryBuffer::default();
|
||
assert!(buf.is_empty());
|
||
|
||
buf.append(TilePosition::new(1, 2, 0));
|
||
buf.append(TilePosition::new(3, 4, 0));
|
||
assert_eq!(buf.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn movement_history_evicts_oldest_at_capacity() {
|
||
let mut buf = MovementHistoryBuffer::default();
|
||
// Fill to capacity
|
||
for i in 0..ENGAGEMENT_WINDOW_TICKS as i32 {
|
||
buf.append(TilePosition::new(i, 0, 0));
|
||
}
|
||
assert_eq!(buf.len(), ENGAGEMENT_WINDOW_TICKS as usize);
|
||
|
||
// One more — should evict x=0
|
||
buf.append(TilePosition::new(9999, 0, 0));
|
||
assert_eq!(
|
||
buf.len(),
|
||
ENGAGEMENT_WINDOW_TICKS as usize,
|
||
"buffer must not grow beyond ENGAGEMENT_WINDOW_TICKS"
|
||
);
|
||
// The oldest entry (x=0) should be gone; newest (x=9999) should be present
|
||
let mut world = World::new();
|
||
let entity_old = world.spawn_empty().id();
|
||
let entity_new = world.spawn_empty().id();
|
||
let copresent = buf.npcs_copresent_in_window(
|
||
[(entity_old, TilePosition::new(0, 0, 0))].into_iter(),
|
||
0,
|
||
);
|
||
assert!(
|
||
copresent.is_empty(),
|
||
"NPC at evicted position x=0 should not be copresent"
|
||
);
|
||
let copresent_new = buf.npcs_copresent_in_window(
|
||
[(entity_new, TilePosition::new(9999, 0, 0))].into_iter(),
|
||
0,
|
||
);
|
||
assert_eq!(copresent_new.len(), 1, "NPC at newest position should be copresent");
|
||
}
|
||
|
||
#[test]
|
||
fn npcs_copresent_finds_nearby_npc() {
|
||
let mut buf = MovementHistoryBuffer::default();
|
||
buf.append(TilePosition::new(10, 10, 0));
|
||
buf.append(TilePosition::new(11, 10, 0));
|
||
|
||
let mut world = World::new();
|
||
let nearby = world.spawn_empty().id();
|
||
let distant = world.spawn_empty().id();
|
||
|
||
// NPC 1 tile from a history position — within threshold 2
|
||
let result = buf.npcs_copresent_in_window(
|
||
[
|
||
(nearby, TilePosition::new(10, 11, 0)),
|
||
(distant, TilePosition::new(50, 50, 0)),
|
||
]
|
||
.into_iter(),
|
||
2,
|
||
);
|
||
|
||
assert!(result.contains(&nearby), "nearby NPC should be copresent");
|
||
assert!(!result.contains(&distant), "distant NPC should not be copresent");
|
||
}
|
||
|
||
#[test]
|
||
fn npcs_copresent_different_z_not_included() {
|
||
let mut buf = MovementHistoryBuffer::default();
|
||
buf.append(TilePosition::new(10, 10, 0));
|
||
|
||
let mut world = World::new();
|
||
let npc = world.spawn_empty().id();
|
||
|
||
// Same x/y but different z
|
||
let result = buf.npcs_copresent_in_window(
|
||
[(npc, TilePosition::new(10, 10, 1))].into_iter(),
|
||
0,
|
||
);
|
||
assert!(
|
||
result.is_empty(),
|
||
"NPC on different z-level must not be copresent"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn append_player_history_system_appends_position() {
|
||
let mut world = World::new();
|
||
world.init_resource::<MovementHistoryBuffer>();
|
||
world.spawn((PlayerCharacter, TilePosition::new(5, 7, 0)));
|
||
|
||
let mut schedule = Schedule::default();
|
||
schedule.add_systems(append_player_history);
|
||
schedule.run(&mut world);
|
||
|
||
let buf = world.resource::<MovementHistoryBuffer>();
|
||
assert_eq!(buf.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn append_player_history_system_no_player_no_panic() {
|
||
// Should silently no-op if no player entity is present
|
||
let mut world = World::new();
|
||
world.init_resource::<MovementHistoryBuffer>();
|
||
|
||
let mut schedule = Schedule::default();
|
||
schedule.add_systems(append_player_history);
|
||
schedule.run(&mut world);
|
||
|
||
assert!(world.resource::<MovementHistoryBuffer>().is_empty());
|
||
}
|
||
|
||
// --- activation_pass tests (#579) ---
|
||
|
||
fn setup_activation_world() -> (World, Schedule) {
|
||
let mut world = World::new();
|
||
world.init_resource::<SimulationTime>();
|
||
world.insert_resource(ContaminationActive(true));
|
||
world.init_resource::<ActivationState>();
|
||
world.init_resource::<MovementHistoryBuffer>();
|
||
world.init_resource::<EntityRegistry>();
|
||
world.init_resource::<TriangleActivatedQueue>();
|
||
world.insert_resource(crate::simulation::rng::SimRng::new(42));
|
||
let mut schedule = Schedule::default();
|
||
schedule.add_systems(activation_pass);
|
||
(world, schedule)
|
||
}
|
||
|
||
#[test]
|
||
fn activation_pass_blocked_before_contamination() {
|
||
let (mut world, mut schedule) = setup_activation_world();
|
||
world.insert_resource(ContaminationActive(false));
|
||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
|
||
|
||
schedule.run(&mut world);
|
||
|
||
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
|
||
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn activation_pass_blocked_off_cadence() {
|
||
let (mut world, mut schedule) = setup_activation_world();
|
||
// Off-cadence tick (not divisible by ACTIVATION_CADENCE_TICKS)
|
||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS + 3;
|
||
|
||
schedule.run(&mut world);
|
||
|
||
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn activation_pass_blocked_after_max_activations() {
|
||
let (mut world, mut schedule) = setup_activation_world();
|
||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
|
||
// Record activation up to the limit
|
||
world.resource_mut::<ActivationState>().record_activation(0);
|
||
|
||
schedule.run(&mut world);
|
||
|
||
// Should not fire again
|
||
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn activation_pass_holds_when_no_triangle_npc_copresent() {
|
||
// No NPCs present at all — no candidates, should hold (not activate a random triangle)
|
||
let (mut world, mut schedule) = setup_activation_world();
|
||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
|
||
|
||
// Spawn a Simmering triangle (but no NPCs)
|
||
world.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim));
|
||
|
||
schedule.run(&mut world);
|
||
|
||
// Should NOT activate — no co-present NPC is assigned to any triangle
|
||
assert!(
|
||
world.resource::<TriangleActivatedQueue>().is_empty(),
|
||
"activation should hold when no triangle-assigned NPC is copresent"
|
||
);
|
||
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn activation_pass_activates_triangle_via_copresent_npc() {
|
||
let (mut world, mut schedule) = setup_activation_world();
|
||
let tick = ACTIVATION_CADENCE_TICKS;
|
||
world.resource_mut::<SimulationTime>().tick = tick;
|
||
|
||
// Register NPC entity in EntityRegistry to get a StableId
|
||
let npc_entity = world.spawn((crate::npc::Npc, ActiveSim, TilePosition::new(5, 5, 0))).id();
|
||
let stable_id = world.resource_mut::<EntityRegistry>().register(npc_entity);
|
||
|
||
// Spawn a Simmering triangle with the NPC in a role
|
||
let mut tri = make_triangle(TriangleClassification::ActiveFork);
|
||
tri.role_assignments.insert(RoleId::new("a"), stable_id);
|
||
let tri_entity = world.spawn((tri, ActiveSim)).id();
|
||
|
||
// Put the player nearby in the history buffer
|
||
world.resource_mut::<MovementHistoryBuffer>().append(TilePosition::new(5, 5, 0));
|
||
|
||
schedule.run(&mut world);
|
||
|
||
let events = world.resource_mut::<TriangleActivatedQueue>().drain();
|
||
assert_eq!(events.len(), 1);
|
||
let tri_state = world.get::<TriangleState>(tri_entity).unwrap();
|
||
assert_eq!(tri_state.phase, TrianglePhase::Active, "triangle containing copresent NPC should be Active");
|
||
}
|
||
|
||
#[test]
|
||
fn activation_pass_does_not_fire_twice() {
|
||
// Verify the one-shot guard: a real first activation must happen, then confirm
|
||
// the second pass is blocked by ActivationState (not vacuously by empty candidates).
|
||
let (mut world, mut schedule) = setup_activation_world();
|
||
|
||
// Register an NPC and assign it to a triangle role so candidates are non-empty
|
||
let npc = world.spawn((crate::npc::Npc, ActiveSim, TilePosition::new(1, 1, 0))).id();
|
||
let stable_id = world.resource_mut::<EntityRegistry>().register(npc);
|
||
|
||
let mut tri = make_triangle(TriangleClassification::ActiveFork);
|
||
tri.role_assignments.insert(RoleId::new("a"), stable_id);
|
||
world.spawn((tri, ActiveSim));
|
||
|
||
// Put the player nearby so the NPC is copresent
|
||
world.resource_mut::<MovementHistoryBuffer>().append(TilePosition::new(1, 1, 0));
|
||
|
||
// First run — should activate
|
||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
|
||
schedule.run(&mut world);
|
||
let first_events = world.resource_mut::<TriangleActivatedQueue>().drain();
|
||
assert_eq!(first_events.len(), 1, "first pass must activate the triangle");
|
||
assert_eq!(
|
||
world.resource::<ActivationState>().activated_count, 1,
|
||
"ActivationState must record the first activation"
|
||
);
|
||
|
||
// Second run — ActivationState blocks it (v0.1 one-shot)
|
||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS * 2;
|
||
schedule.run(&mut world);
|
||
assert!(
|
||
world.resource::<TriangleActivatedQueue>().is_empty(),
|
||
"activation must not fire a second time (v0.1 one-shot)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compute_engagement_score_formula() {
|
||
let record = EngagementRecord {
|
||
observation_time_ticks: 200,
|
||
conversation_count: 3,
|
||
monologue_trigger_count: 1,
|
||
};
|
||
let score = compute_engagement_score(&record);
|
||
// 3 * 2.0 + 200 * 0.005 + 1 * 8.0 = 6.0 + 1.0 + 8.0 = 15.0
|
||
assert!((score - 15.0_f32).abs() < 0.001, "score={score}");
|
||
}
|
||
|
||
#[test]
|
||
fn compute_engagement_score_caps_conversations() {
|
||
let record = EngagementRecord {
|
||
observation_time_ticks: 0,
|
||
conversation_count: 100, // way above CONVERSATION_CAP = 5
|
||
monologue_trigger_count: 0,
|
||
};
|
||
let score = compute_engagement_score(&record);
|
||
// capped at 5 * 2.0 = 10.0
|
||
assert!((score - 10.0_f32).abs() < 0.001, "score={score}");
|
||
}
|
||
}
|