- 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:
Generated
+1
-1
@@ -1092,7 +1092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
@@ -318,6 +318,7 @@ mod tests {
|
||||
state_hash: None,
|
||||
sim_errors: vec![],
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +458,7 @@ mod tests {
|
||||
state_hash: None,
|
||||
sim_errors: vec![],
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
|
||||
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 18;
|
||||
pub const PROTOCOL_VERSION: u8 = 19;
|
||||
|
||||
/// Handshake message sent as the very first framed message after connection (#555).
|
||||
/// Client reads this before entering the normal tick loop and validates
|
||||
@@ -46,6 +46,12 @@ pub struct StartupMessage {
|
||||
/// Generated by SessionManager.new_game() on the client.
|
||||
/// Same seed → same EntanglementConfig → same NPC population (D-029).
|
||||
pub world_seed: u64,
|
||||
/// Character archetype selected by the player (#587).
|
||||
/// Gates monologue pool selection, verb labels, and examine text.
|
||||
/// Defaults to Detective for backward compatibility (old clients
|
||||
/// that omit this field).
|
||||
#[serde(default)]
|
||||
pub character_archetype: CharacterArchetype,
|
||||
}
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
@@ -73,10 +79,11 @@ pub struct StartupMessage {
|
||||
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
|
||||
/// sim_errors (#85, structured error reporting to client).
|
||||
/// v18 adds: debug_response (#580, debug console server — command/response wire).
|
||||
/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 18.
|
||||
/// Protocol version for forward compatibility. Current: 19.
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
@@ -197,6 +204,26 @@ pub struct ObserverSnapshot {
|
||||
/// the tilde console overlay. None in normal gameplay.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub debug_response: Option<DebugResponsePayload>,
|
||||
/// Current news ticker headline (#591).
|
||||
/// Populated only when player is in The Last Shift zone.
|
||||
/// Rotates every TICKER_ROTATION_TICKS ticks (deterministic via SimRng).
|
||||
/// None when player is outside the bar or no ticker content is loaded.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ticker: Option<TickerLine>,
|
||||
}
|
||||
|
||||
/// A single news ticker headline crossing the wire boundary (#591).
|
||||
///
|
||||
/// Populated from `ticker/the-last-shift.yaml`. The `dual_lens` field
|
||||
/// in the source YAML is authoring metadata only — it is NOT included here.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TickerLine {
|
||||
/// Stable identifier for this headline (e.g. "ticker_001").
|
||||
pub id: String,
|
||||
/// The headline text displayed in the HUD ticker.
|
||||
pub text: String,
|
||||
/// Thematic category (freight, politics, infrastructure, sports, etc.).
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
/// Game time data for client display (D-031)
|
||||
@@ -421,6 +448,16 @@ pub enum CharacterArchetype {
|
||||
Detective,
|
||||
}
|
||||
|
||||
impl CharacterArchetype {
|
||||
/// String key for monologue pool filtering (#587).
|
||||
pub fn as_monologue_key(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Smuggler => "smuggler",
|
||||
Self::Detective => "detective",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic player actions, not raw key events (D-020)
|
||||
/// Timestamped for deterministic processing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -906,16 +943,50 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn startup_message_roundtrip() {
|
||||
let msg = StartupMessage { world_seed: 0xDEADBEEF };
|
||||
let msg = StartupMessage {
|
||||
world_seed: 0xDEADBEEF,
|
||||
character_archetype: CharacterArchetype::Detective,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded, msg);
|
||||
assert_eq!(decoded.world_seed, 0xDEADBEEF);
|
||||
assert_eq!(decoded.character_archetype, CharacterArchetype::Detective);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_smuggler_roundtrip() {
|
||||
let msg = StartupMessage {
|
||||
world_seed: 42,
|
||||
character_archetype: CharacterArchetype::Smuggler,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded, msg);
|
||||
assert_eq!(decoded.character_archetype, CharacterArchetype::Smuggler);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_missing_archetype_defaults_to_detective() {
|
||||
// Simulate an old client that sends only world_seed (no character_archetype).
|
||||
// serde(default) on StartupMessage.character_archetype should default to Detective.
|
||||
#[derive(Serialize)]
|
||||
struct OldStartupMessage {
|
||||
world_seed: u64,
|
||||
}
|
||||
let old = OldStartupMessage { world_seed: 99 };
|
||||
let bytes = rmp_serde::to_vec_named(&old).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded.world_seed, 99);
|
||||
assert_eq!(decoded.character_archetype, CharacterArchetype::Detective);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_zero_seed() {
|
||||
let msg = StartupMessage { world_seed: 0 };
|
||||
let msg = StartupMessage {
|
||||
world_seed: 0,
|
||||
character_archetype: CharacterArchetype::default(),
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded.world_seed, 0);
|
||||
@@ -923,7 +994,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn startup_message_max_seed() {
|
||||
let msg = StartupMessage { world_seed: u64::MAX };
|
||||
let msg = StartupMessage {
|
||||
world_seed: u64::MAX,
|
||||
character_archetype: CharacterArchetype::default(),
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded.world_seed, u64::MAX);
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct DistrictContent {
|
||||
pub routines: Option<RoutineFile>,
|
||||
pub dialogue_pools: Vec<DialoguePool>,
|
||||
pub monologue_pools: Vec<MonologuePool>,
|
||||
/// Ticker headlines loaded from `ticker/*.yaml` files (#591).
|
||||
pub ticker_headlines: Vec<crate::content::types::TickerHeadline>,
|
||||
}
|
||||
|
||||
/// Errors that can occur during content loading.
|
||||
@@ -234,16 +236,32 @@ fn load_district(district_dir: &Path) -> Result<DistrictContent, ContentError> {
|
||||
content.monologue_pools = load_yaml_recursive::<MonologuePool>(&monologue_dir);
|
||||
}
|
||||
|
||||
// Ticker headlines (#591): scan ticker/*.yaml in this district
|
||||
let ticker_dir = district_dir.join("ticker");
|
||||
if ticker_dir.is_dir() {
|
||||
let ticker_files = load_yaml_dir::<crate::content::types::TickerFile>(&ticker_dir);
|
||||
for tf in ticker_files {
|
||||
tracing::info!(
|
||||
"Ticker loaded: {} headlines from '{}' (feed: {})",
|
||||
tf.headlines.len(),
|
||||
tf.location,
|
||||
tf.feed,
|
||||
);
|
||||
content.ticker_headlines.extend(tf.headlines);
|
||||
}
|
||||
}
|
||||
|
||||
let npc_count = content.npc_profiles.len();
|
||||
let triangle_count = content.triangles.len();
|
||||
let template_count = content.templates.len();
|
||||
let pool_count = content.pools.len();
|
||||
let dialogue_count = content.dialogue_pools.len();
|
||||
let monologue_count = content.monologue_pools.len();
|
||||
let ticker_count = content.ticker_headlines.len();
|
||||
|
||||
tracing::info!(
|
||||
"District loaded: {} NPCs, {} triangles, {} templates, {} pools, {} dialogue pools, {} monologue pools",
|
||||
npc_count, triangle_count, template_count, pool_count, dialogue_count, monologue_count
|
||||
"District loaded: {} NPCs, {} triangles, {} templates, {} pools, {} dialogue pools, {} monologue pools, {} ticker headlines",
|
||||
npc_count, triangle_count, template_count, pool_count, dialogue_count, monologue_count, ticker_count
|
||||
);
|
||||
|
||||
Ok(content)
|
||||
|
||||
@@ -96,6 +96,27 @@ fn load_and_spawn_content(world: &mut World) {
|
||||
index.monologue_line_count()
|
||||
);
|
||||
|
||||
// Build TickerPool from loaded headlines (#591).
|
||||
let ticker_lines: Vec<crate::bridge::types::TickerLine> = store
|
||||
.districts
|
||||
.values()
|
||||
.flat_map(|d| &d.ticker_headlines)
|
||||
.map(|h| crate::bridge::types::TickerLine {
|
||||
id: h.id.clone(),
|
||||
text: h.text.clone(),
|
||||
category: h.category.clone(),
|
||||
})
|
||||
.collect();
|
||||
let ticker_count = ticker_lines.len();
|
||||
if ticker_count > 0 {
|
||||
world.insert_resource(
|
||||
crate::simulation::ticker::TickerPool::from_lines(ticker_lines),
|
||||
);
|
||||
tracing::info!("TickerPool built: {} headlines loaded", ticker_count);
|
||||
} else {
|
||||
tracing::warn!("TickerPool: no ticker headlines found — current_ticker will be None");
|
||||
}
|
||||
|
||||
world.insert_resource(ContentStoreResource(store));
|
||||
world.insert_resource(LinePoolIndexResource(index));
|
||||
}
|
||||
|
||||
@@ -594,3 +594,27 @@ pub struct RelationshipPrerequisite {
|
||||
#[serde(default)]
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ticker content (#591)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Intermediate type for deserializing a news ticker YAML file.
|
||||
/// The `dual_lens` field is authoring metadata — not deserialized or forwarded.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TickerFile {
|
||||
/// Location slug this ticker belongs to (e.g. "the-last-shift").
|
||||
pub location: String,
|
||||
/// Feed identifier (e.g. "meridian").
|
||||
pub feed: String,
|
||||
pub headlines: Vec<TickerHeadline>,
|
||||
}
|
||||
|
||||
/// A single headline entry in a ticker YAML file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TickerHeadline {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub category: String,
|
||||
// dual_lens is intentionally omitted — authoring metadata only
|
||||
}
|
||||
|
||||
+12
-5
@@ -163,17 +163,20 @@ fn main() {
|
||||
// Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0)
|
||||
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
|
||||
|
||||
// Character archetype from client's StartupMessage (#587).
|
||||
let archetype = startup.character_archetype;
|
||||
|
||||
// Gauntlet test world for --test-mode, proof room for normal mode.
|
||||
if test_mode {
|
||||
#[cfg(feature = "gauntlet")]
|
||||
settled_reach_server::test_world::setup_gauntlet(&mut app);
|
||||
settled_reach_server::test_world::setup_gauntlet(&mut app, archetype);
|
||||
#[cfg(not(feature = "gauntlet"))]
|
||||
{
|
||||
eprintln!("--test-mode requires the 'gauntlet' feature");
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else {
|
||||
setup_proof_room(&mut app);
|
||||
setup_proof_room(&mut app, archetype);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -301,6 +304,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
sim_errors: vec![SimError {
|
||||
kind: SimErrorKind::Panic,
|
||||
message: format!("Simulation panic: {}", panic_msg),
|
||||
@@ -362,7 +366,7 @@ fn dump_schedule_graph() {
|
||||
|
||||
/// Proof room: 32x32 map, wall at (16,14), player at (16,16), 3 NPCs.
|
||||
/// Extracted from the original inline setup for reuse by both test-mode and normal mode.
|
||||
fn setup_proof_room(app: &mut App) {
|
||||
fn setup_proof_room(app: &mut App, archetype: settled_reach_server::bridge::types::CharacterArchetype) {
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
@@ -394,8 +398,10 @@ fn setup_proof_room(app: &mut App) {
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Player at (16,16) — smuggler archetype (#418, D-053)
|
||||
// Player at (16,16) — archetype from StartupMessage (#587, D-053)
|
||||
let profile = MovementProfile::smuggler();
|
||||
let mut monologue_state = MonologueState::default();
|
||||
monologue_state.character = archetype.as_monologue_key().to_string();
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
@@ -404,11 +410,12 @@ fn setup_proof_room(app: &mut App) {
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueState::default(),
|
||||
monologue_state,
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
CognitiveDelay::default(),
|
||||
ListeningFocus::new(TilePosition::new(16, 16, 0)),
|
||||
archetype,
|
||||
profile,
|
||||
profile.initial_stance(),
|
||||
PlayerMoveCooldown::default(),
|
||||
|
||||
@@ -132,15 +132,25 @@ pub enum AnimationTier {
|
||||
Tier2,
|
||||
}
|
||||
|
||||
/// Duration (ticks) a storyteller-escalated RoutineDeviation persists.
|
||||
/// 300 ticks = 30 game-minutes at 10 ticks/game-minute (D-031).
|
||||
pub const TELL_ESCALATION_DURATION_TICKS: u64 = 300;
|
||||
|
||||
/// Tracks why and when an NPC's routine deviated from normal (D-064 Phase 2).
|
||||
///
|
||||
/// Inserted when a player action causes an NPC to break from their scheduled
|
||||
/// behavior. Acts as a hook for the storyteller system and affects future
|
||||
/// interactions (e.g., second-approach dialogue differences).
|
||||
///
|
||||
/// `expires_at_tick`: tick at which this component should be removed.
|
||||
/// 0 means "never expires" (legacy default for old insertions).
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct RoutineDeviation {
|
||||
pub trigger: DeviationTrigger,
|
||||
pub tick: u64,
|
||||
/// Tick at which this deviation expires (component removed by cleanup system).
|
||||
/// Set to `tick + TELL_ESCALATION_DURATION_TICKS` for time-limited deviations.
|
||||
pub expires_at_tick: u64,
|
||||
}
|
||||
|
||||
/// What caused an NPC's routine deviation.
|
||||
@@ -150,6 +160,8 @@ pub enum DeviationTrigger {
|
||||
WalkAway,
|
||||
/// Player delivered a confrontation (D-063).
|
||||
Confrontation,
|
||||
/// Storyteller activated a triangle this NPC belongs to (#589, D-024 axis 9).
|
||||
TriangleEscalation,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -247,6 +247,7 @@ mod tests {
|
||||
NpcRoutineDeviation {
|
||||
trigger: DeviationTrigger::WalkAway,
|
||||
tick: 100,
|
||||
expires_at_tick: 500,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ use crate::simulation::rng::SimRng;
|
||||
use crate::content::template::TriangleCrisisEventQueue;
|
||||
use crate::simulation::sound::SoundEventQueue;
|
||||
use crate::simulation::stance::Stance;
|
||||
use crate::simulation::ticker::{TickerPool, LAST_SHIFT_ZONE_ID};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::zone::ZoneMap;
|
||||
|
||||
@@ -106,6 +107,7 @@ pub fn compute_observer_snapshot(
|
||||
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
|
||||
error_buffer: Option<ResMut<SimErrorBuffer>>,
|
||||
npc_count_query: Query<Entity, With<crate::npc::Npc>>,
|
||||
ticker_pool: Option<Res<TickerPool>>,
|
||||
) {
|
||||
let Ok((
|
||||
observer_entity,
|
||||
@@ -437,6 +439,19 @@ pub fn compute_observer_snapshot(
|
||||
.map(|mut buf| buf.drain())
|
||||
.unwrap_or_default();
|
||||
|
||||
// News ticker (#591): populate when player is in The Last Shift zone.
|
||||
let player_zone = zone_map
|
||||
.as_deref()
|
||||
.map(|zm| zm.zone_at(observer_pos.x, observer_pos.y, observer_pos.z))
|
||||
.flatten();
|
||||
let current_ticker = if player_zone == Some(LAST_SHIFT_ZONE_ID) {
|
||||
ticker_pool
|
||||
.as_deref()
|
||||
.and_then(|pool| pool.current_line().cloned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: crate::bridge::types::PROTOCOL_VERSION,
|
||||
tick: time.tick,
|
||||
@@ -468,6 +483,7 @@ pub fn compute_observer_snapshot(
|
||||
state_hash,
|
||||
sim_errors,
|
||||
debug_response,
|
||||
current_ticker,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -765,6 +765,7 @@ pub fn process_walk_away(
|
||||
.insert(crate::npc::RoutineDeviation {
|
||||
trigger: crate::npc::DeviationTrigger::WalkAway,
|
||||
tick: time.tick,
|
||||
expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS,
|
||||
});
|
||||
|
||||
// Phase 3: Emit IncompleteInteraction knowledge event
|
||||
@@ -868,6 +869,7 @@ pub fn process_confrontation_response(
|
||||
crate::npc::RoutineDeviation {
|
||||
trigger: crate::npc::DeviationTrigger::Confrontation,
|
||||
tick: time.tick,
|
||||
expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS,
|
||||
},
|
||||
));
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod spatial;
|
||||
pub mod stance;
|
||||
pub mod tier;
|
||||
pub mod time;
|
||||
pub mod ticker;
|
||||
pub mod zone;
|
||||
|
||||
/// Core simulation plugin
|
||||
@@ -136,8 +137,17 @@ impl Plugin for SimulationPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
chunk_streaming::chunk_streaming.before(input::process_player_input),
|
||||
)
|
||||
// News ticker rotation (#591) — deterministic via SimRng, before snapshot.
|
||||
.add_systems(
|
||||
Update,
|
||||
ticker::tick_news_ticker
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
);
|
||||
|
||||
// Initialize TickerPool with empty default; populated by ContentPlugin at Startup.
|
||||
app.init_resource::<ticker::TickerPool>();
|
||||
|
||||
tracing::debug!("SimulationPlugin initialized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ pub struct MonologueState {
|
||||
pub entered: bool,
|
||||
/// IDs of lines already shown (dedup within session).
|
||||
pub shown_ids: BTreeSet<String>,
|
||||
/// Character type for pool filtering. v0.1: always "detective".
|
||||
/// Character type for pool filtering. Set from CharacterArchetype (#587).
|
||||
pub character: String,
|
||||
/// Tick of the last observation event we reacted to (#119, observe_npc).
|
||||
/// Observation events arrive one tick after the snapshot that caused them,
|
||||
@@ -171,7 +171,7 @@ impl Default for MonologueState {
|
||||
idle_ticks: 0,
|
||||
entered: false,
|
||||
shown_ids: BTreeSet::new(),
|
||||
// v0.1: default to detective; character selection sets this
|
||||
// Default to detective; overridden by CharacterArchetype at spawn (#587)
|
||||
character: "detective".to_string(),
|
||||
last_observation_tick: 0,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! News ticker pool for The Last Shift bar (#591).
|
||||
//!
|
||||
//! Holds the loaded headline pool and tracks which headline is currently
|
||||
//! displayed. Rotates every `TICKER_ROTATION_TICKS` ticks using `SimRng`
|
||||
//! for determinism (D-010 principle 4).
|
||||
//!
|
||||
//! Zone detection: headlines are only emitted when the player is in
|
||||
//! `LAST_SHIFT_ZONE_ID` (The Last Shift bar tile region).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::bridge::types::TickerLine;
|
||||
|
||||
/// Zone ID assigned to The Last Shift bar tiles (#591, D-077).
|
||||
///
|
||||
/// Must match the zone_id used in the production location YAML
|
||||
/// when the transit district ZoneMap is populated.
|
||||
pub const LAST_SHIFT_ZONE_ID: u16 = 1;
|
||||
|
||||
/// How often the displayed headline rotates, in ticks.
|
||||
/// 200 ticks = 20 game-minutes at 10 ticks/game-minute (D-031).
|
||||
pub const TICKER_ROTATION_TICKS: u64 = 200;
|
||||
|
||||
/// Resource: loaded ticker pool for the news feed (#591).
|
||||
///
|
||||
/// Built at startup from `ticker/the-last-shift.yaml`.
|
||||
/// Holds all headlines and tracks the current display index.
|
||||
/// Rotation is deterministic — always use `SimRng`, never system randomness.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct TickerPool {
|
||||
lines: Vec<TickerLine>,
|
||||
current_index: usize,
|
||||
last_rotated_tick: u64,
|
||||
}
|
||||
|
||||
impl TickerPool {
|
||||
/// Build a pool from the given headline list.
|
||||
pub fn from_lines(lines: Vec<TickerLine>) -> Self {
|
||||
Self {
|
||||
lines,
|
||||
current_index: 0,
|
||||
last_rotated_tick: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the pool has at least one headline.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lines.is_empty()
|
||||
}
|
||||
|
||||
/// The currently active headline, or `None` if the pool is empty.
|
||||
pub fn current_line(&self) -> Option<&TickerLine> {
|
||||
self.lines.get(self.current_index)
|
||||
}
|
||||
|
||||
/// Advance to a new headline using SimRng if `TICKER_ROTATION_TICKS` have elapsed.
|
||||
///
|
||||
/// Called each tick by `tick_news_ticker`. Deterministic: same seed → same rotation.
|
||||
pub fn maybe_rotate(&mut self, current_tick: u64, rng: &mut crate::simulation::rng::SimRng) {
|
||||
if self.lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
if current_tick > 0
|
||||
&& current_tick.saturating_sub(self.last_rotated_tick) >= TICKER_ROTATION_TICKS
|
||||
{
|
||||
self.current_index = rng.rng.random_range(0..self.lines.len());
|
||||
self.last_rotated_tick = current_tick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System: advance ticker rotation each tick (#591, D-010).
|
||||
///
|
||||
/// Runs before `compute_observer_snapshot` so the correct headline
|
||||
/// is available when the snapshot is assembled.
|
||||
pub fn tick_news_ticker(
|
||||
time: Res<crate::simulation::time::SimulationTime>,
|
||||
mut pool: ResMut<TickerPool>,
|
||||
mut rng: ResMut<crate::simulation::rng::SimRng>,
|
||||
) {
|
||||
pool.maybe_rotate(time.tick, &mut rng);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::rng::SimRng;
|
||||
|
||||
fn make_pool(count: usize) -> TickerPool {
|
||||
let lines: Vec<TickerLine> = (0..count)
|
||||
.map(|i| TickerLine {
|
||||
id: format!("ticker_{:03}", i),
|
||||
text: format!("Headline {}", i),
|
||||
category: "test".to_string(),
|
||||
})
|
||||
.collect();
|
||||
TickerPool::from_lines(lines)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pool_returns_none() {
|
||||
let pool = TickerPool::default();
|
||||
assert!(pool.current_line().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_empty_pool_returns_first_line() {
|
||||
let pool = make_pool(5);
|
||||
assert_eq!(pool.current_line().unwrap().id, "ticker_000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_advances_after_interval() {
|
||||
let mut pool = make_pool(30);
|
||||
let mut rng = SimRng::new(42);
|
||||
|
||||
// No rotation at tick 0
|
||||
pool.maybe_rotate(0, &mut rng);
|
||||
assert_eq!(pool.current_index, 0);
|
||||
|
||||
// Rotation fires at tick TICKER_ROTATION_TICKS
|
||||
pool.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng);
|
||||
// index changed (RNG picks something; just verify it's in range)
|
||||
assert!(pool.current_index < 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_is_deterministic() {
|
||||
let mut pool_a = make_pool(30);
|
||||
let mut rng_a = SimRng::new(42);
|
||||
pool_a.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng_a);
|
||||
let idx_a = pool_a.current_index;
|
||||
|
||||
let mut pool_b = make_pool(30);
|
||||
let mut rng_b = SimRng::new(42);
|
||||
pool_b.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng_b);
|
||||
let idx_b = pool_b.current_index;
|
||||
|
||||
assert_eq!(idx_a, idx_b, "Same seed must produce same rotation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_double_rotation_in_same_window() {
|
||||
let mut pool = make_pool(30);
|
||||
let mut rng = SimRng::new(42);
|
||||
|
||||
pool.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng);
|
||||
let idx_after_first = pool.current_index;
|
||||
|
||||
// One tick later — within same window, should not rotate
|
||||
pool.maybe_rotate(TICKER_ROTATION_TICKS + 1, &mut rng);
|
||||
assert_eq!(
|
||||
pool.current_index, idx_after_first,
|
||||
"Should not rotate within the same window"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ pub const MAP_HEIGHT: i32 = 125;
|
||||
/// is intentional for deterministic test setups but should be revisited
|
||||
/// if Gauntlet is ever served by the production startup pipeline.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub fn setup_gauntlet(app: &mut App) {
|
||||
pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterArchetype) {
|
||||
// Start with a fully blocked map, then carve rooms and corridors.
|
||||
let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1);
|
||||
|
||||
@@ -192,6 +192,8 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
// Spawn at Hub center: absolute (50, 58)
|
||||
let profile = MovementProfile::smuggler();
|
||||
let player_pos = TilePosition::new(50, 58, 0);
|
||||
let mut monologue_state = MonologueState::default();
|
||||
monologue_state.character = archetype.as_monologue_key().to_string();
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
@@ -200,7 +202,7 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueState::default(),
|
||||
monologue_state,
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
ScanEventBuffer::default(),
|
||||
@@ -212,6 +214,7 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
crate::simulation::pressure::CharacterPressure::default(),
|
||||
))
|
||||
.id();
|
||||
app.world_mut().entity_mut(player).insert(archetype);
|
||||
registry.register(player);
|
||||
|
||||
// --- Hub signs (StableId 1-4) ---
|
||||
@@ -695,7 +698,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default());
|
||||
|
||||
// Run all 29 world-query invariants against the fully-initialized gauntlet world.
|
||||
invariants::run_invariants(app.world_mut());
|
||||
@@ -715,7 +718,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default());
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// Hub center at (50, 58) must be walkable
|
||||
@@ -729,7 +732,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default());
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// North wall segment at absolute (90, 54) should be blocked
|
||||
@@ -745,7 +748,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default());
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// corridor-E center should be walkable
|
||||
@@ -759,7 +762,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default());
|
||||
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user