@@ -12,11 +12,54 @@
//! 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 , TriangleState } ;
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 } ;
@@ -34,10 +77,68 @@ pub const CONTAMINATION_PRESSURE_DELTA: u8 = 10;
/// 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.
@@ -45,6 +146,56 @@ pub const CONFRONTATION_THRESHOLD: u8 = 75;
#[ 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
// ---------------------------------------------------------------------------
@@ -84,6 +235,129 @@ impl ContaminationEventQueue {
}
}
/// 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
// ---------------------------------------------------------------------------
@@ -95,7 +369,21 @@ impl Plugin for StorytellerPlugin {
fn build ( & self , app : & mut App ) {
app . init_resource ::< ContaminationActive > ( )
. init_resource ::< ContaminationEventQueue > ( )
. add_systems ( Update , tick_contamination_activation ) ;
. 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 ) ,
) ;
tracing ::debug! ( " StorytellerPlugin initialized " ) ;
}
@@ -153,6 +441,147 @@ pub fn tick_contamination_activation(
) ;
}
/// 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 ;
let obs_score = record . observation_time_ticks as f32 * ENGAGEMENT_WEIGHT_OBSERVATION ;
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.
/// Fallback (unentangled NPC, D-025): highest-tension Simmering triangle.
/// 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
if 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 copresent = history . npcs_copresent_in_window ( npc_positions . into_iter ( ) , COPRESENCE_THRESHOLD ) ;
// 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
let triangle_id = simmering
. iter ( )
. find ( | ( e , _ ) | * e = = tri_entity )
. map ( | ( _ , s ) | s . triangle_id )
. expect ( " target triangle must be in simmering snapshot " ) ;
// 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
// ---------------------------------------------------------------------------
@@ -311,4 +740,325 @@ mod tests {
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} " ) ;
}
}