- Add character_archetype to StartupMessage with serde default (Detective) - Bump PROTOCOL_VERSION to 19 - Add escalate_tells_on_activation() and expire_routine_deviations() systems - RoutineDeviation inserted on triangle NPCs with 300-tick TTL - Add TickerPool resource with deterministic SimRng rotation (200 ticks) - Emit current_ticker in ObserverSnapshot when player is in bar zone - Load ticker YAML from district content directories Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -384,6 +384,17 @@ impl Plugin for StorytellerPlugin {
|
||||
.after(append_player_history)
|
||||
.after(tick_contamination_activation)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
escalate_tells_on_activation
|
||||
.after(activation_pass)
|
||||
.before(crate::npc::tell_state::derive_tell_state),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
expire_routine_deviations
|
||||
.before(crate::npc::tell_state::derive_tell_state),
|
||||
);
|
||||
|
||||
tracing::debug!("StorytellerPlugin initialized");
|
||||
@@ -602,6 +613,75 @@ pub fn activation_pass(
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tell escalation (#589)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// System: insert RoutineDeviation on triangle NPCs when a triangle activates (#589).
|
||||
///
|
||||
/// Reads TriangleActivatedQueue without draining — other consumers remain unaffected.
|
||||
/// For each activation event, finds all NPCs assigned to that triangle and inserts
|
||||
/// a time-limited RoutineDeviation component. RoutineDeviation is priority-1 in the
|
||||
/// tell derivation hierarchy (D-024), making the NPCs immediately observable as anomalous.
|
||||
///
|
||||
/// Runs after activation_pass (which populates the queue).
|
||||
pub fn escalate_tells_on_activation(
|
||||
time: Res<SimulationTime>,
|
||||
queue: Res<TriangleActivatedQueue>,
|
||||
triangle_query: Query<&TriangleState>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
use crate::npc::{DeviationTrigger, RoutineDeviation, TELL_ESCALATION_DURATION_TICKS};
|
||||
|
||||
if queue.events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for event in &queue.events {
|
||||
// Find the TriangleState matching this activation event
|
||||
for triangle in triangle_query.iter() {
|
||||
if triangle.triangle_id != event.triangle_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert RoutineDeviation on every NPC in the triangle
|
||||
for stable_id in triangle.role_assignments.values() {
|
||||
let Some(entity) = registry.to_entity(stable_id) else {
|
||||
tracing::warn!(
|
||||
npc_id = stable_id.0,
|
||||
"escalate_tells_on_activation: NPC not found in registry"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
commands.entity(entity).insert(RoutineDeviation {
|
||||
trigger: DeviationTrigger::TriangleEscalation,
|
||||
tick: time.tick,
|
||||
expires_at_tick: time.tick + TELL_ESCALATION_DURATION_TICKS,
|
||||
});
|
||||
}
|
||||
break; // each triangle_id appears at most once
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System: remove expired RoutineDeviation components (#589).
|
||||
///
|
||||
/// Each tick, removes RoutineDeviation from any entity whose expires_at_tick
|
||||
/// has elapsed. A zero expires_at_tick means "never expires" (legacy behavior
|
||||
/// for WalkAway/Confrontation deviations that predate this cleanup system).
|
||||
pub fn expire_routine_deviations(
|
||||
time: Res<SimulationTime>,
|
||||
deviations: Query<(Entity, &crate::npc::RoutineDeviation)>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
for (entity, deviation) in deviations.iter() {
|
||||
if deviation.expires_at_tick > 0 && time.tick >= deviation.expires_at_tick {
|
||||
commands.entity(entity).remove::<crate::npc::RoutineDeviation>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1081,4 +1161,121 @@ mod tests {
|
||||
// capped at 5 * 2.0 = 10.0
|
||||
assert!((score - 10.0_f32).abs() < 0.001, "score={score}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// #589: escalate_tells_on_activation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn escalate_inserts_routine_deviation_on_triangle_npcs() {
|
||||
use crate::knowledge::registry::EntityRegistry;
|
||||
use crate::knowledge::types::StableId;
|
||||
use crate::npc::{Npc, RoutineDeviation};
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.init_resource::<TriangleActivatedQueue>();
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Spawn two NPCs and register them
|
||||
let npc_a = world.spawn(Npc).id();
|
||||
let npc_b = world.spawn(Npc).id();
|
||||
registry.register(npc_a);
|
||||
registry.register(npc_b);
|
||||
let sid_a = registry.to_stable(npc_a).unwrap();
|
||||
let sid_b = registry.to_stable(npc_b).unwrap();
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
// Spawn a triangle with these two NPCs
|
||||
let triangle_id = TriangleId::from_seed_and_slug(0, "test-escalation");
|
||||
let mut role_assignments = BTreeMap::new();
|
||||
role_assignments.insert(RoleId::new("role_a"), sid_a);
|
||||
role_assignments.insert(RoleId::new("role_b"), sid_b);
|
||||
world.spawn((
|
||||
TriangleState {
|
||||
triangle_id,
|
||||
role_assignments,
|
||||
tension: 50,
|
||||
phase: TrianglePhase::Active,
|
||||
tension_rate: 1,
|
||||
template_id: TemplateId::from_seed_and_slug(0, "test"),
|
||||
classification: TriangleClassification::ActiveFork,
|
||||
},
|
||||
ActiveSim,
|
||||
));
|
||||
|
||||
// Populate the activation queue
|
||||
world.resource_mut::<TriangleActivatedQueue>().push(TriangleActivatedEvent {
|
||||
triangle_id,
|
||||
tick: 100,
|
||||
anchor_entity: npc_a,
|
||||
anchor_score: 15.0,
|
||||
});
|
||||
|
||||
// Run system
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(escalate_tells_on_activation);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Both NPCs should have RoutineDeviation
|
||||
assert!(world.get::<RoutineDeviation>(npc_a).is_some(), "NPC A should have RoutineDeviation");
|
||||
assert!(world.get::<RoutineDeviation>(npc_b).is_some(), "NPC B should have RoutineDeviation");
|
||||
let dev = world.get::<RoutineDeviation>(npc_a).unwrap();
|
||||
assert_eq!(dev.trigger, crate::npc::DeviationTrigger::TriangleEscalation);
|
||||
assert_eq!(dev.expires_at_tick, crate::npc::TELL_ESCALATION_DURATION_TICKS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expire_routine_deviations_removes_expired() {
|
||||
use crate::npc::{DeviationTrigger, RoutineDeviation};
|
||||
|
||||
let mut world = World::new();
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 500;
|
||||
world.insert_resource(time);
|
||||
|
||||
// Spawn entity with expired deviation
|
||||
let entity = world.spawn(RoutineDeviation {
|
||||
trigger: DeviationTrigger::TriangleEscalation,
|
||||
tick: 100,
|
||||
expires_at_tick: 400, // expired at tick 500
|
||||
}).id();
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(expire_routine_deviations);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<RoutineDeviation>(entity).is_none(),
|
||||
"Expired RoutineDeviation should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expire_routine_deviations_keeps_non_expired() {
|
||||
use crate::npc::{DeviationTrigger, RoutineDeviation};
|
||||
|
||||
let mut world = World::new();
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 200;
|
||||
world.insert_resource(time);
|
||||
|
||||
// Deviation that expires at tick 500 — should survive at tick 200
|
||||
let entity = world.spawn(RoutineDeviation {
|
||||
trigger: DeviationTrigger::TriangleEscalation,
|
||||
tick: 100,
|
||||
expires_at_tick: 500,
|
||||
}).id();
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(expire_routine_deviations);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<RoutineDeviation>(entity).is_some(),
|
||||
"Non-expired RoutineDeviation should not be removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user