feat(simulation): character archetype, tell escalation, and news ticker (#587, #589, #591)

- 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:
2026-03-05 09:13:03 +01:00
co-authored by Claude Opus 4.6
parent fc24de6128
commit 3194a6e491
16 changed files with 567 additions and 22 deletions
+197
View File
@@ -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"
);
}
}