Merge remote-tracking branch 'origin/server'
# Conflicts: # CHANGELOG.md # client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack # client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack # client/tests/fixtures/msgpack/snapshot_empty.msgpack # client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack # client/tests/fixtures/msgpack/snapshot_one_npc.msgpack # client/tests/fixtures/msgpack/snapshot_player.msgpack # client/tests/fixtures/msgpack/snapshot_v2_full.msgpack # server/src/bridge/types.rs
This commit is contained in:
@@ -17,6 +17,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- Smuggling ring v0.1 stub module (`content/modules/tier1/smuggling_ring_v0_1.yaml`) — vertical slice Tier 1 module with 6 NPC roles, dual event sequences, 5 outcomes (#158)
|
||||
- Line ID authoring guide (`docs/design/line-id-authoring-guide.md`) — dual-namespace conventions for hand-authored and auto-generated NPC content
|
||||
- Tier 1 module authoring guide (`docs/design/tier1-module-authoring.md`) — field reference, NPC pattern/motivation tables, design principles, pre-submission checklist
|
||||
- Background tier state machines — schedule, mood, relationships, job tick once per game-minute for Background NPCs (#95, D-026)
|
||||
- NPC vision system — symmetric shadowcasting for Active-tier NPCs, NpcMemory with last-known-position and zone inference (#115, D-011)
|
||||
- NPC player-awareness behavior — PlayerAwareness component tracks LOS duration, suspicion accumulation, routine deviation triggers (#244)
|
||||
- Skill system & combat flag — SkillSet component (BTreeMap<String, u8>), CombatCapability marker from combat_trained skill (#91, D-024)
|
||||
- Player-action social propagation — three-order trust ripple (100%/40%/20%) through RelationshipGraph with cycle prevention (#249, D-029)
|
||||
- Examine mechanic — process_examine_interaction with character-filtered observation text, KG DirectObservation write, examine_result in ObserverSnapshot (#242)
|
||||
- Character goal/pressure framework — CharacterPressure component (exposure/institutional/relationship), wired to snapshot HUD data (#248)
|
||||
- Save state data model — SaveStateV1 struct with MessagePack serialization, roundtrip tests for entity/KG/relationship/clock state (#256)
|
||||
- Tell state derivation wired into ObserverSnapshot — integration tests for Nervous tell on Major secret + high stress (#337)
|
||||
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
|
||||
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
|
||||
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -306,6 +306,8 @@ mod tests {
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
examine_result: None,
|
||||
character_pressure: None,
|
||||
sound_events: vec![],
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
@@ -439,6 +441,8 @@ mod tests {
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
examine_result: None,
|
||||
character_pressure: None,
|
||||
sound_events: vec![],
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
|
||||
@@ -37,7 +37,7 @@ pub const PROTOCOL_VERSION: u8 = 14;
|
||||
/// v13 adds: tell_state on VisibleEntity (#90, D-024 tell system — for future client use),
|
||||
/// follow_state (#241, follow mechanic HUD state).
|
||||
/// v14 adds: poi_list (#151, discovered POIs for minimap rendering),
|
||||
/// examine_result (#174, character-filtered observation text from Examine verb),
|
||||
/// examine_result (#242, character-filtered examine observation text),
|
||||
/// player_knowledge (#264, partial KG dump for journal/knowledge panel).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -110,6 +110,15 @@ pub struct ObserverSnapshot {
|
||||
/// Client shows follow indicator with distance, LOS, and tension.
|
||||
#[serde(default)]
|
||||
pub follow_state: Option<crate::simulation::follow::FollowStateWire>,
|
||||
/// Examine result from Examine verb interaction (#242).
|
||||
/// Present when the player examined an NPC or object this tick.
|
||||
/// Client displays character-filtered detail text in an observation panel.
|
||||
#[serde(default)]
|
||||
pub examine_result: Option<crate::simulation::examine::ExamineResultEvent>,
|
||||
/// Character pressure state for client HUD widget (#248).
|
||||
/// Present when pressure is non-zero. Client renders tension indicator.
|
||||
#[serde(default)]
|
||||
pub character_pressure: Option<crate::simulation::pressure::CharacterPressureWire>,
|
||||
/// RNG seed active at this tick for deterministic replay (#527).
|
||||
/// The WRONG button writes this to seed.txt so replays reproduce observed bugs.
|
||||
/// None when the RNG resource is unavailable (should not occur in practice).
|
||||
|
||||
@@ -205,6 +205,14 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
|
||||
// TODO: CombatCapability — no content schema type exists yet. When combat content
|
||||
// is authored, add weapon_proficiency + combat_style mapping here.
|
||||
|
||||
// Vision + awareness components (#115, #244) — must match generate_npc().
|
||||
// Without these, vision/awareness systems silently skip content-spawned NPCs.
|
||||
entity_commands.insert((
|
||||
npc::vision::NpcVisionState::default(),
|
||||
npc::vision::NpcMemory::default(),
|
||||
npc::awareness::PlayerAwareness::default(),
|
||||
));
|
||||
|
||||
let entity = entity_commands.id();
|
||||
|
||||
// Register in EntityRegistry for StableId mapping
|
||||
@@ -1100,6 +1108,49 @@ mod tests {
|
||||
assert_eq!(edge.trust, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[test]
|
||||
fn spawn_npc_combat_trained_sets_skill_flag() {
|
||||
// #91: combat_trained: true in YAML sets SkillSet.combat_trained = true.
|
||||
// Known gap: CombatCapability is NOT yet attached for content-spawned NPCs
|
||||
// (see TODO in spawn.rs near "Supporting axis 3: Skills"). The procedural
|
||||
// path (generate.rs) correctly attaches CombatCapability. This test documents
|
||||
// current behavior so the gap is visible in CI.
|
||||
let mut world = create_test_world();
|
||||
let mut profile = create_test_profile();
|
||||
profile.skills = Some(NpcSkills {
|
||||
combat_trained: Some(true),
|
||||
skills: Some({
|
||||
let mut m = std::collections::BTreeMap::new();
|
||||
m.insert("combat".to_string(), 7);
|
||||
m
|
||||
}),
|
||||
});
|
||||
|
||||
let mut result = SpawnResult::default();
|
||||
spawn_npc(&mut world, &profile, &mut result);
|
||||
|
||||
let entity = world
|
||||
.resource::<EntityRegistry>()
|
||||
.to_entity(&result.npc_ids["test-npc"])
|
||||
.unwrap();
|
||||
|
||||
// SkillSet.combat_trained is correctly set from YAML (#91 — done)
|
||||
let skills = world.get::<npc::SkillSet>(entity).unwrap();
|
||||
assert!(
|
||||
skills.combat_trained,
|
||||
"SkillSet.combat_trained should be true when YAML sets combat_trained: true"
|
||||
);
|
||||
|
||||
// Known gap: CombatCapability not yet attached in content-spawn path.
|
||||
// The procedural path (generate.rs) does attach it — content path has TODO.
|
||||
// Update this assertion when the TODO is resolved.
|
||||
assert!(
|
||||
world.get::<npc::CombatCapability>(entity).is_none(),
|
||||
"CombatCapability not yet attached in content-spawn path (known gap — see spawn.rs TODO)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_relationships_skips_unknown_targets() {
|
||||
let mut world = create_test_world();
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
//! NPC player-awareness system (#244).
|
||||
//!
|
||||
//! Tracks how aware an NPC is of the player's attention. When the player
|
||||
//! lingers in an NPC's field of view, the NPC's suspicion accumulates and
|
||||
//! feeds stress into `ToleranceThreshold` (D-024 axis 4).
|
||||
//!
|
||||
//! Reads `NpcVisionState.player_visible` from the NPC vision system (#115).
|
||||
//! Separate from the follow mechanic (#241): follow tracks player-initiated
|
||||
//! proximity, awareness tracks NPC-perceived attention.
|
||||
//!
|
||||
//! ## System ordering
|
||||
//!
|
||||
//! `detect_player_awareness` runs:
|
||||
//! - after `vision::compute_npc_vision` (needs player_visible)
|
||||
//! - before `tolerance::check_tolerance_threshold` (feeds stress)
|
||||
//!
|
||||
//! ## Suspicion lifecycle
|
||||
//!
|
||||
//! 1. Player enters NPC's LOS → `consecutive_los_ticks` starts counting
|
||||
//! 2. After `AWARENESS_NOTICE_TICKS` consecutive ticks → suspicion starts building
|
||||
//! 3. Each tick past threshold: `suspicion_level` increases, stress added to tolerance
|
||||
//! 4. Player leaves LOS → `consecutive_los_ticks` resets, suspicion decays slowly
|
||||
//!
|
||||
//! All arithmetic is integer-only (D-010 determinism).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::npc::vision::NpcVisionState;
|
||||
use crate::npc::{Npc, ToleranceThreshold};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Consecutive ticks the player must remain in an NPC's LOS before suspicion
|
||||
/// starts building. 30 ticks = 3 game-minutes (D-031: 10 ticks = 1 game-minute).
|
||||
pub const AWARENESS_NOTICE_TICKS: u64 = 30;
|
||||
|
||||
/// Stress increment per tick applied to `ToleranceThreshold` once the NPC
|
||||
/// notices sustained player attention. Lighter than follow stress (2/tick)
|
||||
/// because watching is less intrusive than tailing.
|
||||
pub const AWARENESS_STRESS_PER_TICK: i16 = 1;
|
||||
|
||||
/// Ticks between suspicion decay checks when player is NOT in LOS.
|
||||
/// 10 ticks = 1 game-minute (D-031).
|
||||
pub const AWARENESS_DECAY_INTERVAL: u64 = 10;
|
||||
|
||||
/// Suspicion decay amount per interval when player is not in LOS.
|
||||
pub const AWARENESS_DECAY_AMOUNT: i16 = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tracks an NPC's awareness of sustained player attention (#244).
|
||||
///
|
||||
/// Updated each tick by `detect_player_awareness` for Active-tier NPCs.
|
||||
/// Feeds stress into `ToleranceThreshold` when `suspicion_level` rises.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `player_in_los` — mirrors `NpcVisionState.player_visible` for downstream queries
|
||||
/// - `consecutive_los_ticks` — resets when player leaves LOS
|
||||
/// - `suspicion_level` — 0–100, accumulates while player watches, decays when they leave
|
||||
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PlayerAwareness {
|
||||
/// Whether the player is currently in this NPC's line of sight.
|
||||
pub player_in_los: bool,
|
||||
/// Consecutive ticks the player has been in this NPC's LOS.
|
||||
pub consecutive_los_ticks: u64,
|
||||
/// Accumulated suspicion level (0–100, integer for D-010).
|
||||
pub suspicion_level: i16,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detect sustained player attention and build NPC suspicion.
|
||||
///
|
||||
/// For each Active-tier NPC with `PlayerAwareness`:
|
||||
/// - Sync `player_in_los` from `NpcVisionState.player_visible`
|
||||
/// - If player visible: increment `consecutive_los_ticks`; once past
|
||||
/// `AWARENESS_NOTICE_TICKS`, increase `suspicion_level` and apply
|
||||
/// stress to `ToleranceThreshold`
|
||||
/// - If player NOT visible: reset `consecutive_los_ticks`, decay
|
||||
/// `suspicion_level` periodically
|
||||
pub fn detect_player_awareness(
|
||||
time: Res<SimulationTime>,
|
||||
mut npc_query: Query<
|
||||
(
|
||||
&NpcVisionState,
|
||||
&mut PlayerAwareness,
|
||||
&mut ToleranceThreshold,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
) {
|
||||
for (vision, mut awareness, mut tolerance) in npc_query.iter_mut() {
|
||||
awareness.player_in_los = vision.player_visible;
|
||||
|
||||
if vision.player_visible {
|
||||
awareness.consecutive_los_ticks += 1;
|
||||
|
||||
// Once the NPC has noticed sustained player attention, build suspicion
|
||||
if awareness.consecutive_los_ticks >= AWARENESS_NOTICE_TICKS {
|
||||
awareness.suspicion_level =
|
||||
(awareness.suspicion_level + AWARENESS_STRESS_PER_TICK).min(100);
|
||||
tolerance.current_stress = tolerance
|
||||
.current_stress
|
||||
.saturating_add(AWARENESS_STRESS_PER_TICK);
|
||||
}
|
||||
} else {
|
||||
// Player left LOS — reset consecutive counter
|
||||
awareness.consecutive_los_ticks = 0;
|
||||
|
||||
// Decay suspicion slowly when player is not visible
|
||||
if time.tick % AWARENESS_DECAY_INTERVAL == 0 && awareness.suspicion_level > 0 {
|
||||
awareness.suspicion_level =
|
||||
(awareness.suspicion_level - AWARENESS_DECAY_AMOUNT).max(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::vision::NpcVisionState;
|
||||
use crate::npc::{Npc, ToleranceThreshold};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world
|
||||
}
|
||||
|
||||
fn run_system(world: &mut World) {
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(detect_player_awareness);
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
fn default_tolerance() -> ToleranceThreshold {
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 80,
|
||||
}
|
||||
}
|
||||
|
||||
fn vision_with_player(visible: bool) -> NpcVisionState {
|
||||
NpcVisionState {
|
||||
player_visible: visible,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Basic LOS tracking
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn player_in_los_increments_consecutive_ticks() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness::default(),
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert!(awareness.player_in_los);
|
||||
assert_eq!(awareness.consecutive_los_ticks, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_leaving_los_resets_consecutive_ticks() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: 20,
|
||||
suspicion_level: 5,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert!(!awareness.player_in_los);
|
||||
assert_eq!(awareness.consecutive_los_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_in_los_syncs_from_vision_state() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: false, // was false
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert!(awareness.player_in_los, "should sync from NpcVisionState");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Suspicion threshold — no stress before notice ticks
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn no_stress_before_notice_threshold() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 2, // not yet at threshold
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, 0,
|
||||
"suspicion should not build before notice threshold"
|
||||
);
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress, 0,
|
||||
"stress should not increase before notice threshold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_starts_at_notice_threshold() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 1, // will reach threshold
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.consecutive_los_ticks, AWARENESS_NOTICE_TICKS,
|
||||
"consecutive ticks should reach threshold"
|
||||
);
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, AWARENESS_STRESS_PER_TICK,
|
||||
"suspicion should increase at threshold"
|
||||
);
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress, AWARENESS_STRESS_PER_TICK,
|
||||
"stress should increase at threshold"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Suspicion accumulation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn suspicion_accumulates_past_threshold() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 5,
|
||||
suspicion_level: 10,
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: 20,
|
||||
threshold: 80,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level,
|
||||
10 + AWARENESS_STRESS_PER_TICK,
|
||||
"suspicion should accumulate"
|
||||
);
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress,
|
||||
20 + AWARENESS_STRESS_PER_TICK,
|
||||
"stress should accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspicion_caps_at_100() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 100,
|
||||
suspicion_level: 100,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(awareness.suspicion_level, 100, "suspicion should cap at 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerance_stress_saturates() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS,
|
||||
suspicion_level: 50,
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: i16::MAX - 1,
|
||||
threshold: i16::MAX,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress,
|
||||
i16::MAX,
|
||||
"stress should saturate, not overflow"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Suspicion decay
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn suspicion_decays_when_player_not_in_los_on_interval() {
|
||||
let mut world = setup_world();
|
||||
// Set tick to a decay interval boundary
|
||||
world.resource_mut::<SimulationTime>().tick = 20; // divisible by AWARENESS_DECAY_INTERVAL
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: false,
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 10,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level,
|
||||
10 - AWARENESS_DECAY_AMOUNT,
|
||||
"suspicion should decay on interval tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspicion_does_not_decay_off_interval() {
|
||||
let mut world = setup_world();
|
||||
// Set tick to a non-interval boundary
|
||||
world.resource_mut::<SimulationTime>().tick = 13; // not divisible by AWARENESS_DECAY_INTERVAL
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: false,
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 10,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, 10,
|
||||
"suspicion should not decay on non-interval tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspicion_does_not_go_below_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: false,
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, 0,
|
||||
"suspicion should not go below zero"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Background NPC not processed
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn background_npc_not_processed() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
crate::simulation::tier::BackgroundSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness::default(),
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.consecutive_los_ticks, 0,
|
||||
"background NPC should not have awareness processed"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// No panic with missing components
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn npc_without_awareness_no_panic() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// NPC with vision but no PlayerAwareness — system should skip
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
default_tolerance(),
|
||||
));
|
||||
|
||||
run_system(&mut world); // should not panic
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Integration: follow + awareness stress stacking
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn awareness_stress_stacks_with_existing_stress() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// NPC already has stress from other sources (e.g., follow mechanic)
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS, // past threshold
|
||||
suspicion_level: 5,
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: 30, // pre-existing stress
|
||||
threshold: 80,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress,
|
||||
30 + AWARENESS_STRESS_PER_TICK,
|
||||
"awareness stress should stack with existing stress"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constant value assertions (#244 spec compliance)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn awareness_constants_have_expected_values() {
|
||||
assert_eq!(
|
||||
AWARENESS_NOTICE_TICKS, 30,
|
||||
"#244: NPC notices sustained attention after 30 ticks (3 game-minutes)"
|
||||
);
|
||||
assert_eq!(
|
||||
AWARENESS_STRESS_PER_TICK, 1,
|
||||
"#244: stress per tick lighter than follow stress (D-010 integer)"
|
||||
);
|
||||
assert_eq!(
|
||||
AWARENESS_DECAY_INTERVAL, 10,
|
||||
"#244: suspicion decays every 10 ticks (1 game-minute, D-031)"
|
||||
);
|
||||
assert_eq!(
|
||||
AWARENESS_DECAY_AMOUNT, 1,
|
||||
"#244: suspicion decays by 1 per interval"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
//! Background tier state machines (#95, D-026).
|
||||
//!
|
||||
//! Four lightweight state machines for Background-tier NPCs, firing once per
|
||||
//! game-minute (D-031: `TICKS_PER_GAME_MINUTE` = 10).
|
||||
//!
|
||||
//! ## State machines
|
||||
//! 1. **Schedule** — set `ActivityState` from `DailyRoutine` + `DayPhase` (no pathfinding)
|
||||
//! 2. **Mood** — stress-based derivation (simplified: no phase or warm flag)
|
||||
//! 3. **Relationships** — per-NPC `trust_level` drifts toward 0 (baseline)
|
||||
//! 4. **Job** — `JobPerformance.score` drifts based on `Contentment.level`
|
||||
//!
|
||||
//! ## Design constraints (D-026)
|
||||
//! - No pathfinding, LOS, or dialogue — those are Active-tier only.
|
||||
//! - All arithmetic is integer-only (D-010 determinism requirement).
|
||||
//! - Background NPCs promoted to Active retain their state machine state (no reset on promotion).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::npc::{
|
||||
Contentment, DailyRoutine, JobPerformance, Npc, Relationships, ToleranceThreshold,
|
||||
};
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::routine::ActivityState;
|
||||
use crate::simulation::tier::BackgroundSim;
|
||||
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mood derivation (background-tier)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive simplified mood for a Background-tier NPC.
|
||||
///
|
||||
/// No phase or warm-flag considerations — background NPCs have no active
|
||||
/// interactions. Priority order:
|
||||
/// 1. Hostile — stress ≥ threshold
|
||||
/// 2. Anxious — stress ≥ 60% of threshold (integer arithmetic, D-010)
|
||||
/// 3. Content — stress < 20
|
||||
/// 4. Neutral — otherwise
|
||||
pub fn derive_background_mood(current_stress: i16, threshold: i16) -> NpcMood {
|
||||
// 1. Hostile: at or above threshold
|
||||
if current_stress >= threshold {
|
||||
return NpcMood::Hostile;
|
||||
}
|
||||
|
||||
// 2. Anxious: 60% of threshold reached
|
||||
// Guard: skip if threshold == 0 (entity already Hostile from rule 1).
|
||||
if threshold > 0 && (current_stress as i32) * 100 >= (threshold as i32) * 60 {
|
||||
return NpcMood::Anxious;
|
||||
}
|
||||
|
||||
// 3. Content: low stress
|
||||
if current_stress < 20 {
|
||||
return NpcMood::Content;
|
||||
}
|
||||
|
||||
// 4. Neutral: default
|
||||
NpcMood::Neutral
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job performance drift
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Drift job performance score one point per game-minute based on contentment.
|
||||
///
|
||||
/// - contentment > 0 → score + 1 (clamped at 100)
|
||||
/// - contentment < 0 → score - 1 (clamped at 0)
|
||||
/// - contentment = 0 → unchanged
|
||||
pub fn drift_job_performance(score: i16, contentment_level: i16) -> i16 {
|
||||
match contentment_level.cmp(&0) {
|
||||
std::cmp::Ordering::Greater => (score + 1).min(100),
|
||||
std::cmp::Ordering::Less => (score - 1).max(0),
|
||||
std::cmp::Ordering::Equal => score,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System: background_tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// System: run all four background-tier state machines once per game-minute.
|
||||
///
|
||||
/// Fires when `time.tick % TICKS_PER_GAME_MINUTE == 0`. Scoped to
|
||||
/// `With<BackgroundSim>` — Active-tier NPCs are handled by their dedicated
|
||||
/// per-tick systems.
|
||||
///
|
||||
/// Machine execution order per NPC:
|
||||
/// 1. Schedule — insert/update `ActivityState` from `DailyRoutine` + `DayPhase`
|
||||
/// 2. Mood — update `MoodState` from stress/threshold (simplified)
|
||||
/// 3. Relationships — drift `Relationships.entries[].trust_level` toward 0
|
||||
/// 4. Job — drift `JobPerformance.score` from `Contentment.level`
|
||||
///
|
||||
/// Commands for `ActivityState` are deferred (applied after system runs).
|
||||
/// Mutable component mutations happen immediately within the iteration.
|
||||
pub fn background_tick(
|
||||
time: Res<SimulationTime>,
|
||||
mut commands: Commands,
|
||||
mut query: Query<
|
||||
(
|
||||
Entity,
|
||||
Option<&ActivityState>,
|
||||
&mut MoodState,
|
||||
Option<&mut Relationships>,
|
||||
Option<&ToleranceThreshold>,
|
||||
Option<&DailyRoutine>,
|
||||
Option<&Contentment>,
|
||||
Option<&mut JobPerformance>,
|
||||
),
|
||||
(With<Npc>, With<BackgroundSim>),
|
||||
>,
|
||||
) {
|
||||
// Fire once per game-minute (D-031: 10 ticks/minute)
|
||||
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let phase = time.day_phase();
|
||||
let tick = time.tick;
|
||||
|
||||
for (
|
||||
entity,
|
||||
activity_opt,
|
||||
mut mood_state,
|
||||
rels_opt,
|
||||
tolerance_opt,
|
||||
routine_opt,
|
||||
contentment_opt,
|
||||
job_opt,
|
||||
) in query.iter_mut()
|
||||
{
|
||||
// --- 1. Schedule: sync ActivityState to current DayPhase ---
|
||||
// Background NPCs don't pathfind — we directly declare the activity.
|
||||
if let Some(routine) = routine_opt {
|
||||
if let Some(entry) = routine.entry_for_phase(phase) {
|
||||
let needs_update = match activity_opt {
|
||||
Some(a) => a.phase != phase || a.activity != entry.activity,
|
||||
None => true,
|
||||
};
|
||||
if needs_update {
|
||||
commands.entity(entity).insert(ActivityState {
|
||||
activity: entry.activity.clone(),
|
||||
phase,
|
||||
started_tick: tick,
|
||||
});
|
||||
}
|
||||
} else if activity_opt.is_some() {
|
||||
// No routine entry for this phase — clear stale activity
|
||||
commands.entity(entity).remove::<ActivityState>();
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Mood: simplified stress-based derivation ---
|
||||
let (stress, threshold) = tolerance_opt
|
||||
.map(|t| (t.current_stress, t.threshold))
|
||||
.unwrap_or((0, 50)); // Default: no stress, moderate threshold
|
||||
let new_mood = derive_background_mood(stress, threshold);
|
||||
if mood_state.mood != new_mood {
|
||||
mood_state.mood = new_mood;
|
||||
mood_state.changed_tick = tick;
|
||||
}
|
||||
|
||||
// --- 3. Relationships: trust drift toward 0 (baseline) ---
|
||||
if let Some(mut rels) = rels_opt {
|
||||
for rel in &mut rels.entries {
|
||||
if rel.trust_level > 0 {
|
||||
rel.trust_level -= 1;
|
||||
} else if rel.trust_level < 0 {
|
||||
rel.trust_level += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Job: performance drift from contentment ---
|
||||
if let Some(mut job) = job_opt {
|
||||
let contentment = contentment_opt.map(|c| c.level).unwrap_or(0);
|
||||
job.score = drift_job_performance(job.score, contentment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::{
|
||||
Contentment, DailyRoutine, JobPerformance, Npc, Relationship, RelationshipKind,
|
||||
Relationships, RoutineEntry, ToleranceThreshold,
|
||||
};
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::routine::ActivityState;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::tier::{ActiveSim, BackgroundSim};
|
||||
use crate::simulation::time::{DayPhase, SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||
use crate::knowledge::types::StableId;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
// --- derive_background_mood ---
|
||||
|
||||
#[test]
|
||||
fn background_mood_hostile_at_threshold() {
|
||||
assert_eq!(derive_background_mood(50, 50), NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_hostile_above_threshold() {
|
||||
assert_eq!(derive_background_mood(80, 50), NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_anxious_at_60_percent() {
|
||||
// 60% of threshold=100 is 60. stress=60 → Anxious (60*100 >= 100*60)
|
||||
assert_eq!(derive_background_mood(60, 100), NpcMood::Anxious);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_anxious_boundary_below_threshold() {
|
||||
// threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000)
|
||||
assert_eq!(derive_background_mood(30, 50), NpcMood::Anxious);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_content_low_stress() {
|
||||
// stress=10 < 20 → Content (not hostile, not anxious)
|
||||
assert_eq!(derive_background_mood(10, 50), NpcMood::Content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_neutral_moderate_stress() {
|
||||
// stress=25, threshold=50: not hostile, not anxious (25*100=2500 < 50*60=3000),
|
||||
// not content (25 >= 20) → Neutral
|
||||
assert_eq!(derive_background_mood(25, 50), NpcMood::Neutral);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_zero_threshold_is_hostile() {
|
||||
// stress=0 >= threshold=0 → Hostile
|
||||
assert_eq!(derive_background_mood(0, 0), NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_zero_stress_moderate_threshold_is_content() {
|
||||
// stress=0 < 20 → Content
|
||||
assert_eq!(derive_background_mood(0, 50), NpcMood::Content);
|
||||
}
|
||||
|
||||
// --- drift_job_performance ---
|
||||
|
||||
#[test]
|
||||
fn job_drift_up_when_positive_contentment() {
|
||||
assert_eq!(drift_job_performance(50, 10), 51);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_down_when_negative_contentment() {
|
||||
assert_eq!(drift_job_performance(50, -10), 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_unchanged_at_zero_contentment() {
|
||||
assert_eq!(drift_job_performance(50, 0), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_clamps_at_100() {
|
||||
assert_eq!(drift_job_performance(100, 5), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_clamps_at_0() {
|
||||
assert_eq!(drift_job_performance(0, -5), 0);
|
||||
}
|
||||
|
||||
// --- background_tick system integration tests ---
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world
|
||||
}
|
||||
|
||||
fn run_system(world: &mut World) {
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(background_tick);
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
fn make_routine(phase: DayPhase, activity: &str) -> DailyRoutine {
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase,
|
||||
location: TilePosition::new(5, 5, 0),
|
||||
activity: activity.to_string(),
|
||||
}],
|
||||
description: "Test routine".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tick gating ---
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_on_non_minute_tick() {
|
||||
let mut world = setup_world();
|
||||
// Set tick to 5 — not a multiple of TICKS_PER_GAME_MINUTE
|
||||
world.resource_mut::<SimulationTime>().tick = 5;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
// Mood should NOT have updated (Hostile would fire if it ran)
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Neutral, "should not fire at tick=5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_at_tick_zero() {
|
||||
let mut world = setup_world();
|
||||
// tick=0 is 0 % 10 == 0, so it fires
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_at_tick_multiple_of_ticks_per_game_minute() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 5;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||
}
|
||||
|
||||
// --- Active-tier NPCs not processed ---
|
||||
|
||||
#[test]
|
||||
fn active_npcs_not_processed() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// ActiveSim NPC — must NOT be processed by background_tick
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
MoodState {
|
||||
mood: NpcMood::Warm,
|
||||
changed_tick: 0,
|
||||
},
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Warm, "ActiveSim NPC must not be updated by background_tick");
|
||||
}
|
||||
|
||||
// --- Mood state machine ---
|
||||
|
||||
#[test]
|
||||
fn mood_hostile_when_stress_at_threshold() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 50, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_content_when_no_tolerance() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// No ToleranceThreshold → defaults (0, 50) → Content (0 < 20)
|
||||
let npc = world
|
||||
.spawn((Npc, BackgroundSim, MoodState::default()))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_records_changed_tick() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 3;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState { mood: NpcMood::Warm, changed_tick: 0 },
|
||||
ToleranceThreshold { current_stress: 55, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||
assert_eq!(mood.changed_tick, TICKS_PER_GAME_MINUTE * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_unchanged_tick_not_updated() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// Already Content, will derive Content → no change to changed_tick
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState { mood: NpcMood::Content, changed_tick: 42 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Content);
|
||||
assert_eq!(mood.changed_tick, 42, "changed_tick must not update when mood unchanged");
|
||||
}
|
||||
|
||||
// --- Schedule state machine ---
|
||||
|
||||
#[test]
|
||||
fn schedule_sets_activity_state_for_current_phase() {
|
||||
let mut world = setup_world();
|
||||
// tick=0 → DayPhase::Morning
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
make_routine(DayPhase::Morning, "Work"),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let activity = world.get::<ActivityState>(npc).unwrap();
|
||||
assert_eq!(activity.activity, "Work");
|
||||
assert_eq!(activity.phase, DayPhase::Morning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_no_activity_when_no_routine_entry_for_phase() {
|
||||
let mut world = setup_world();
|
||||
// tick=0 → Morning, but routine only has Afternoon
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
make_routine(DayPhase::Afternoon, "Meeting"),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
// No ActivityState should be inserted (Morning has no entry)
|
||||
assert!(world.get::<ActivityState>(npc).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_preserves_correct_activity_state() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0; // Morning
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
make_routine(DayPhase::Morning, "Work"),
|
||||
// Already has correct ActivityState — should not be re-inserted
|
||||
ActivityState {
|
||||
activity: "Work".into(),
|
||||
phase: DayPhase::Morning,
|
||||
started_tick: 0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let activity = world.get::<ActivityState>(npc).unwrap();
|
||||
assert_eq!(activity.activity, "Work");
|
||||
}
|
||||
|
||||
// --- Relationships state machine ---
|
||||
|
||||
#[test]
|
||||
fn relationships_positive_trust_drifts_toward_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
Relationships {
|
||||
entries: vec![Relationship {
|
||||
target_id: StableId(1),
|
||||
kind: RelationshipKind::Friend,
|
||||
trust_level: 5,
|
||||
history: vec![],
|
||||
}],
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let rels = world.get::<Relationships>(npc).unwrap();
|
||||
assert_eq!(rels.entries[0].trust_level, 4, "positive trust decrements by 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_negative_trust_drifts_toward_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
Relationships {
|
||||
entries: vec![Relationship {
|
||||
target_id: StableId(2),
|
||||
kind: RelationshipKind::Rival,
|
||||
trust_level: -4,
|
||||
history: vec![],
|
||||
}],
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let rels = world.get::<Relationships>(npc).unwrap();
|
||||
assert_eq!(rels.entries[0].trust_level, -3, "negative trust increments by 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_zero_trust_stays_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
Relationships {
|
||||
entries: vec![Relationship {
|
||||
target_id: StableId(3),
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust_level: 0,
|
||||
history: vec![],
|
||||
}],
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let rels = world.get::<Relationships>(npc).unwrap();
|
||||
assert_eq!(rels.entries[0].trust_level, 0, "zero trust unchanged");
|
||||
}
|
||||
|
||||
// --- Job state machine ---
|
||||
|
||||
#[test]
|
||||
fn job_performance_rises_with_positive_contentment() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
JobPerformance { score: 60 },
|
||||
Contentment { level: 20 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||
assert_eq!(job.score, 61);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_performance_falls_with_negative_contentment() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
JobPerformance { score: 60 },
|
||||
Contentment { level: -15 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||
assert_eq!(job.score, 59);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_performance_unchanged_at_zero_contentment() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
JobPerformance { score: 50 },
|
||||
Contentment { level: 0 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||
assert_eq!(job.score, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_performance_without_component_no_panic() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// No JobPerformance — system must not panic
|
||||
let _npc = world.spawn((Npc, BackgroundSim, MoodState::default())).id();
|
||||
|
||||
run_system(&mut world); // must not panic
|
||||
}
|
||||
|
||||
// --- Multiple NPCs independent ---
|
||||
|
||||
#[test]
|
||||
fn multiple_background_npcs_processed_independently() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let calm = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 5, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
let hostile = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 55, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
assert_eq!(world.get::<MoodState>(calm).unwrap().mood, NpcMood::Content);
|
||||
assert_eq!(world.get::<MoodState>(hostile).unwrap().mood, NpcMood::Hostile);
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,15 @@ use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
|
||||
use crate::npc::{
|
||||
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, KnownFact,
|
||||
Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind, Relationships,
|
||||
RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem, TellTrigger,
|
||||
ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
|
||||
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, JobPerformance,
|
||||
KnownFact, Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind,
|
||||
Relationships, RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem,
|
||||
TellTrigger, ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
|
||||
};
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::npc::mood::MoodState;
|
||||
use crate::npc::awareness::PlayerAwareness;
|
||||
use crate::npc::vision::{NpcMemory, NpcVisionState};
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
@@ -266,7 +269,7 @@ fn gen_routine(rng: &mut SimRng, location_pool: &[(DayPhase, TilePosition)]) ->
|
||||
|
||||
DailyRoutine {
|
||||
entries,
|
||||
description: format!("Routine schedule"),
|
||||
description: "Routine schedule".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,6 +483,16 @@ pub fn generate_npc(role: &RoleDefinition, world: &mut World, rng: &mut SimRng)
|
||||
tells,
|
||||
skills,
|
||||
MoodState::default(),
|
||||
JobPerformance::default(),
|
||||
));
|
||||
|
||||
// Vision + knowledge + awareness components (#115, #244, D-041).
|
||||
// Separate insert to stay within tuple bundle element limit.
|
||||
entity_builder.insert((
|
||||
KnowledgeGraph::new(),
|
||||
NpcVisionState::default(),
|
||||
NpcMemory::default(),
|
||||
PlayerAwareness::default(),
|
||||
));
|
||||
|
||||
if let Some(cap) = combat_opt {
|
||||
@@ -876,6 +889,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Combat capability — positive case (#91, D-024)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn combat_role_probabilistically_produces_combat_capability() {
|
||||
// #91 positive-case: at least one seed must produce CombatCapability for
|
||||
// a combat-enabled role. The probability is 2/3 per seed (RNG < 2/3 range),
|
||||
// so 30 seeds is overwhelmingly likely to produce at least one match.
|
||||
let role = minimal_role(); // combat_enabled = true
|
||||
let mut any_combat = false;
|
||||
for seed in 0..30_u64 {
|
||||
let mut world = World::new();
|
||||
let mut rng = make_rng(seed);
|
||||
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||
if world.get::<CombatCapability>(entity).is_some() {
|
||||
any_combat = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
any_combat,
|
||||
"combat-enabled role must produce CombatCapability for at least one seed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combat_trained_flag_matches_combat_capability_presence() {
|
||||
// #91 invariant: SkillSet.combat_trained must be consistent with CombatCapability.
|
||||
// If CombatCapability is present, combat_trained must be true and vice versa.
|
||||
let role = minimal_role(); // combat_enabled = true
|
||||
for seed in 0..50_u64 {
|
||||
let mut world = World::new();
|
||||
let mut rng = make_rng(seed);
|
||||
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||
let skills = world.get::<SkillSet>(entity).unwrap();
|
||||
let has_cap = world.get::<CombatCapability>(entity).is_some();
|
||||
assert_eq!(
|
||||
skills.combat_trained, has_cap,
|
||||
"seed {seed}: SkillSet.combat_trained={} must match CombatCapability presence={}",
|
||||
skills.combat_trained, has_cap
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combat_capability_proficiency_in_valid_range() {
|
||||
// #91: CombatCapability.weapon_proficiency must be in 3-8 range per gen_skills().
|
||||
let role = minimal_role(); // combat_enabled = true
|
||||
for seed in 0..100_u64 {
|
||||
let mut world = World::new();
|
||||
let mut rng = make_rng(seed);
|
||||
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||
if let Some(cap) = world.get::<CombatCapability>(entity) {
|
||||
assert!(
|
||||
cap.weapon_proficiency >= 3 && cap.weapon_proficiency <= 8,
|
||||
"seed {seed}: weapon_proficiency {} out of valid range 3-8",
|
||||
cap.weapon_proficiency
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ActiveSim tier
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Implements D-024: 10-axis NPC model + CombatCapability component
|
||||
// Background tier state machines for schedule, mood, relationships, job
|
||||
|
||||
pub mod awareness;
|
||||
pub mod background;
|
||||
pub mod disclosure;
|
||||
pub mod generate;
|
||||
pub mod interaction;
|
||||
@@ -11,6 +13,7 @@ pub mod routine;
|
||||
pub mod tell_state;
|
||||
pub mod tolerance;
|
||||
pub mod trait_modifiers;
|
||||
pub mod vision;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
@@ -29,6 +32,8 @@ impl Plugin for NpcPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<relationships::RelationshipGraph>()
|
||||
.init_resource::<relationships::TrustEventQueue>()
|
||||
.init_resource::<relationships::PropagationQueue>()
|
||||
.init_resource::<relationships::DelayedTrustQueue>()
|
||||
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
|
||||
.init_resource::<routine::PreviousDayPhase>()
|
||||
.init_resource::<tolerance::ToleranceBreachEventQueue>()
|
||||
@@ -49,6 +54,9 @@ impl Plugin for NpcPlugin {
|
||||
.after(crate::simulation::dialogue::process_confrontation_response)
|
||||
.after(crate::simulation::dialogue::process_dialogue_response)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
relationships::propagate_social_actions
|
||||
.after(relationships::update_trust)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
relationships::update_relationship_dynamics
|
||||
.after(relationships::update_trust)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
@@ -65,11 +73,29 @@ impl Plugin for NpcPlugin {
|
||||
.after(mood::update_mood)
|
||||
.after(routine::detect_routine_deviation)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
background::background_tick
|
||||
.after(crate::simulation::movement::validate_movement)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
disclosure::derive_disclosure_candidates
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
disclosure::process_unprompted_disclosure
|
||||
.after(disclosure::derive_disclosure_candidates)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
// NPC player-awareness (#244)
|
||||
awareness::detect_player_awareness
|
||||
.after(vision::compute_npc_vision)
|
||||
.before(tolerance::check_tolerance_threshold),
|
||||
// NPC vision system (#115, D-011)
|
||||
vision::compute_npc_vision
|
||||
.after(crate::simulation::movement::validate_movement)
|
||||
.after(crate::simulation::tier::update_tier_markers)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
vision::emit_npc_vision_events
|
||||
.after(vision::compute_npc_vision)
|
||||
.before(crate::knowledge::events::process_knowledge_events),
|
||||
vision::degrade_npc_inferences
|
||||
.after(vision::emit_npc_vision_events)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
crate::simulation::dialogue::process_talk_interaction
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::simulation::dialogue::process_walk_away
|
||||
@@ -268,6 +294,30 @@ pub struct Contentment {
|
||||
pub level: i16, // -100..+100, integer for determinism (D-010)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job performance (D-026 background state machine — feeds from Contentment)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tracks how well an NPC performs their job role.
|
||||
///
|
||||
/// Drifts based on `Contentment` level in the background-tier state machine:
|
||||
/// - contentment > 0 → score increases toward 100
|
||||
/// - contentment < 0 → score decreases toward 0
|
||||
/// - contentment = 0 → no change
|
||||
///
|
||||
/// Updated by `background::background_tick` once per game-minute for
|
||||
/// Background-tier NPCs. Persists through tier promotions (D-026).
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobPerformance {
|
||||
pub score: i16, // 0..=100, integer for determinism (D-010)
|
||||
}
|
||||
|
||||
impl Default for JobPerformance {
|
||||
fn default() -> Self {
|
||||
Self { score: 50 }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supporting axis 1: Personality traits (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -141,7 +141,8 @@ impl RelationshipGraph {
|
||||
}
|
||||
|
||||
/// Get all entities who have feelings about a target.
|
||||
/// O(N) full scan of all edges — use for event detection, not per-tick queries.
|
||||
/// O(N) full scan of all edges. Called once per game-minute (every 10 ticks)
|
||||
/// by the pressure system — acceptable at v0.1 NPC counts.
|
||||
pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
|
||||
self.edges
|
||||
.iter()
|
||||
@@ -201,16 +202,121 @@ impl RelationshipGraph {
|
||||
// System: update_trust (#324)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Social propagation (#249, D-029)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimum absolute trust required on a relationship edge for that edge
|
||||
/// to carry propagation (D-029: "strong relationships", trust > 3).
|
||||
pub const PROPAGATION_TRUST_THRESHOLD: i8 = 3;
|
||||
|
||||
/// Delay in ticks before third-order propagation is applied.
|
||||
/// 30 ticks ≈ 3 game-minutes (D-031: 10 ticks/minute).
|
||||
pub const THIRD_ORDER_DELAY_TICKS: u64 = 30;
|
||||
|
||||
/// A first-order trust change that needs social propagation.
|
||||
///
|
||||
/// Produced by `update_trust` for each processed event, consumed by
|
||||
/// `propagate_social_actions` to fan out second- and third-order effects.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PropagationEvent {
|
||||
/// The NPC directly affected by the player action (first-order subject).
|
||||
pub npc: StableId,
|
||||
/// The player entity (target of how NPCs feel).
|
||||
pub player: StableId,
|
||||
/// The first-order delta (same as what was applied to the graph).
|
||||
pub delta: i8,
|
||||
}
|
||||
|
||||
/// Queue of propagation events emitted by `update_trust`.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PropagationQueue {
|
||||
events: Vec<PropagationEvent>,
|
||||
}
|
||||
|
||||
impl PropagationQueue {
|
||||
pub fn push(&mut self, event: PropagationEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<PropagationEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
}
|
||||
|
||||
/// A deferred trust change for third-order propagation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DelayedTrustChange {
|
||||
/// NPC whose trust toward the player will be adjusted.
|
||||
pub npc: StableId,
|
||||
/// Player entity.
|
||||
pub player: StableId,
|
||||
/// Scaled delta to apply (already clamped to meaningful range).
|
||||
pub delta: i8,
|
||||
/// Tick at which this change should be applied.
|
||||
pub apply_at_tick: u64,
|
||||
}
|
||||
|
||||
/// Queue of deferred third-order trust changes.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct DelayedTrustQueue {
|
||||
pending: Vec<DelayedTrustChange>,
|
||||
}
|
||||
|
||||
impl DelayedTrustQueue {
|
||||
pub fn push(&mut self, change: DelayedTrustChange) {
|
||||
self.pending.push(change);
|
||||
}
|
||||
|
||||
/// Drain changes that are due at or before `current_tick`.
|
||||
pub fn drain_due(&mut self, current_tick: u64) -> Vec<DelayedTrustChange> {
|
||||
let mut due = Vec::new();
|
||||
let mut remaining = Vec::new();
|
||||
for change in self.pending.drain(..) {
|
||||
if change.apply_at_tick <= current_tick {
|
||||
due.push(change);
|
||||
} else {
|
||||
remaining.push(change);
|
||||
}
|
||||
}
|
||||
self.pending = remaining;
|
||||
due
|
||||
}
|
||||
|
||||
/// Number of pending deferred changes.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale a first-order delta by a propagation factor, using integer arithmetic
|
||||
/// (D-010: integer-only determinism). Returns 0 when the scaled value would
|
||||
/// round to zero — small deltas naturally attenuate to nothing.
|
||||
///
|
||||
/// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 20%).
|
||||
///
|
||||
/// Rounding: away from zero (ceiling of abs value, preserving sign).
|
||||
fn scale_delta(delta: i8, factor_tenths: i8) -> i8 {
|
||||
// Multiply by factor, round up (ceiling of absolute value)
|
||||
let scaled_abs = (delta.unsigned_abs() as i16 * factor_tenths as i16 + 9) / 10;
|
||||
let scaled = scaled_abs.min(10) as i8;
|
||||
if delta < 0 { -(scaled as i8) } else { scaled as i8 }
|
||||
}
|
||||
|
||||
/// Drain pending trust events and apply deltas to the RelationshipGraph.
|
||||
///
|
||||
/// Each event adjusts the NPC→player trust edge. If no edge exists,
|
||||
/// one is created with default Colleague kind and trust 0 before applying
|
||||
/// the delta. Trust is clamped to [-10, +10] per D-010.
|
||||
///
|
||||
/// Also queues a `PropagationEvent` for each processed event so that
|
||||
/// `propagate_social_actions` can fan out second- and third-order effects.
|
||||
///
|
||||
/// System ordering: after dialogue systems (which emit the events),
|
||||
/// before advance_tick.
|
||||
pub fn update_trust(
|
||||
mut queue: ResMut<TrustEventQueue>,
|
||||
mut propagation_queue: ResMut<PropagationQueue>,
|
||||
mut graph: ResMut<RelationshipGraph>,
|
||||
registry: Res<EntityRegistry>,
|
||||
time: Res<SimulationTime>,
|
||||
@@ -244,6 +350,116 @@ pub fn update_trust(
|
||||
new_trust = edge.trust,
|
||||
"Trust updated"
|
||||
);
|
||||
|
||||
// Queue propagation event for second/third-order effects (#249)
|
||||
propagation_queue.push(PropagationEvent {
|
||||
npc: npc_sid,
|
||||
player: player_sid,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// System: fan out player-action trust changes through the social graph (#249, D-029).
|
||||
///
|
||||
/// Processes `PropagationQueue` events (produced by `update_trust`) and applies:
|
||||
/// - **Second-order** (immediate, 40%): NPCs with trust > 3 toward the first-order NPC
|
||||
/// - **Third-order** (delayed 30 ticks, 15%): one further hop, same threshold
|
||||
///
|
||||
/// Cycle prevention: a visited set per propagation pass prevents A→B→A loops.
|
||||
/// Propagation topology varies per seed (D-029 anti-metagaming property) because
|
||||
/// the `RelationshipGraph` is seeded differently per run.
|
||||
///
|
||||
/// System ordering: after `update_trust`, before `advance_tick`.
|
||||
pub fn propagate_social_actions(
|
||||
mut propagation_queue: ResMut<PropagationQueue>,
|
||||
mut delay_queue: ResMut<DelayedTrustQueue>,
|
||||
mut graph: ResMut<RelationshipGraph>,
|
||||
time: Res<SimulationTime>,
|
||||
) {
|
||||
// --- Apply any due delayed third-order changes first ---
|
||||
for change in delay_queue.drain_due(time.tick) {
|
||||
if change.delta != 0 {
|
||||
graph.ensure_edge(change.npc, change.player, time.tick);
|
||||
graph.adjust_trust(&change.npc, &change.player, change.delta);
|
||||
tracing::trace!(
|
||||
npc = change.npc.0,
|
||||
player = change.player.0,
|
||||
delta = change.delta,
|
||||
"Third-order trust propagated (delayed)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fan out new propagation events ---
|
||||
for event in propagation_queue.drain() {
|
||||
let PropagationEvent { npc, player, delta } = event;
|
||||
|
||||
// Visited set prevents cycles (D-029)
|
||||
let mut visited = std::collections::BTreeSet::new();
|
||||
visited.insert(npc);
|
||||
|
||||
// --- Second-order (immediate, 40% of delta) ---
|
||||
let second_delta = scale_delta(delta, 4); // 40%
|
||||
if second_delta != 0 {
|
||||
// Find NPCs that the first-order NPC trusts strongly
|
||||
let second_order: Vec<StableId> = graph
|
||||
.relationships_of(&npc)
|
||||
.into_iter()
|
||||
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
|
||||
.map(|(target, _)| *target)
|
||||
.collect();
|
||||
|
||||
for second in &second_order {
|
||||
if visited.contains(second) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(*second);
|
||||
graph.ensure_edge(*second, player, time.tick);
|
||||
graph.adjust_trust(second, &player, second_delta);
|
||||
tracing::trace!(
|
||||
first_order_npc = npc.0,
|
||||
second_order_npc = second.0,
|
||||
player = player.0,
|
||||
delta = second_delta,
|
||||
"Second-order trust propagated"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Third-order (delayed 30 ticks, 15% of delta) ---
|
||||
let third_delta = scale_delta(delta, 2); // ~15% (2/10 = 20%, nearest integer approx)
|
||||
if third_delta != 0 {
|
||||
let apply_at = time.tick + THIRD_ORDER_DELAY_TICKS;
|
||||
for second in &second_order {
|
||||
let third_order: Vec<StableId> = graph
|
||||
.relationships_of(second)
|
||||
.into_iter()
|
||||
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
|
||||
.map(|(target, _)| *target)
|
||||
.collect();
|
||||
|
||||
for third in third_order {
|
||||
if visited.contains(&third) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(third);
|
||||
delay_queue.push(DelayedTrustChange {
|
||||
npc: third,
|
||||
player,
|
||||
delta: third_delta,
|
||||
apply_at_tick: apply_at,
|
||||
});
|
||||
tracing::trace!(
|
||||
third_order_npc = third.0,
|
||||
player = player.0,
|
||||
delta = third_delta,
|
||||
apply_at,
|
||||
"Third-order trust queued for delayed propagation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +709,7 @@ mod tests {
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<RelationshipGraph>();
|
||||
world.init_resource::<TrustEventQueue>();
|
||||
world.init_resource::<PropagationQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
@@ -748,6 +965,380 @@ mod tests {
|
||||
assert_eq!(edge.trust, 6); // 5 + 1
|
||||
}
|
||||
|
||||
// -- PropagationQueue tests (#249) ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn propagation_queue_push_and_drain() {
|
||||
let mut q = PropagationQueue::default();
|
||||
q.push(PropagationEvent {
|
||||
npc: StableId(1),
|
||||
player: StableId(2),
|
||||
delta: 1,
|
||||
});
|
||||
q.push(PropagationEvent {
|
||||
npc: StableId(3),
|
||||
player: StableId(2),
|
||||
delta: -2,
|
||||
});
|
||||
let drained = q.drain();
|
||||
assert_eq!(drained.len(), 2);
|
||||
assert!(q.drain().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_trust_queue_drain_due_filters_by_tick() {
|
||||
let mut q = DelayedTrustQueue::default();
|
||||
q.push(DelayedTrustChange {
|
||||
npc: StableId(1),
|
||||
player: StableId(10),
|
||||
delta: 1,
|
||||
apply_at_tick: 50,
|
||||
});
|
||||
q.push(DelayedTrustChange {
|
||||
npc: StableId(2),
|
||||
player: StableId(10),
|
||||
delta: -1,
|
||||
apply_at_tick: 100,
|
||||
});
|
||||
|
||||
// Only change at tick 50 is due at tick 60
|
||||
let due = q.drain_due(60);
|
||||
assert_eq!(due.len(), 1);
|
||||
assert_eq!(due[0].npc, StableId(1));
|
||||
// Change at tick 100 is still pending
|
||||
assert_eq!(q.pending_count(), 1);
|
||||
|
||||
// At tick 100 it becomes due
|
||||
let due2 = q.drain_due(100);
|
||||
assert_eq!(due2.len(), 1);
|
||||
assert_eq!(due2[0].npc, StableId(2));
|
||||
assert_eq!(q.pending_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_delta_forty_percent() {
|
||||
// factor_tenths=4 → 40%
|
||||
assert_eq!(scale_delta(1, 4), 1); // 0.4 → rounds up to 1
|
||||
assert_eq!(scale_delta(2, 4), 1); // 0.8 → rounds up to 1
|
||||
assert_eq!(scale_delta(5, 4), 2); // 2.0 → 2
|
||||
assert_eq!(scale_delta(10, 4), 4); // 4.0 → 4
|
||||
assert_eq!(scale_delta(-2, 4), -1); // negative preserved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_delta_zero_when_too_small() {
|
||||
// delta=0 → always 0
|
||||
assert_eq!(scale_delta(0, 4), 0);
|
||||
}
|
||||
|
||||
// -- propagate_social_actions system tests (#249) -------------------------
|
||||
|
||||
fn setup_propagation_world() -> bevy_ecs::world::World {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.init_resource::<RelationshipGraph>();
|
||||
world.init_resource::<PropagationQueue>();
|
||||
world.init_resource::<DelayedTrustQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_order_trust_propagates_immediately() {
|
||||
// Spec (#249, D-029): NPCs strongly connected to the first-order NPC
|
||||
// receive 40% of the delta in the same tick.
|
||||
//
|
||||
// Graph: A → B (trust 5, > threshold 3)
|
||||
// A → player (will be first-order)
|
||||
// Event: player action affects A (delta=+2)
|
||||
// Expected: B gains 40% of 2 = 1 (ceil) toward player
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
// A strongly trusts B (A→B trust=5)
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
|
||||
// Queue propagation from A
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 2,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// B should now have a trust edge toward the player (positive)
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_b, &player).expect("B should have edge to player");
|
||||
assert!(
|
||||
edge.trust > 0,
|
||||
"Second-order NPC should trust player more after positive first-order event"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_relationship_does_not_propagate() {
|
||||
// Spec (#249): Only edges with trust > PROPAGATION_TRUST_THRESHOLD (3) carry propagation.
|
||||
//
|
||||
// Graph: A → B (trust 2, ≤ threshold 3)
|
||||
// Event: player action affects A (delta=+5)
|
||||
// Expected: B gets no propagation (trust ≤ threshold)
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
// A weakly trusts B (below threshold)
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Colleague, 2),
|
||||
);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// B should NOT have any edge to the player
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
assert!(
|
||||
graph.get_relationship(&npc_b, &player).is_none(),
|
||||
"Weak relationship should not carry propagation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn third_order_trust_is_deferred_by_thirty_ticks() {
|
||||
// Spec (#249): Third-order changes are queued for 30 ticks in the future.
|
||||
//
|
||||
// Graph: A → B (trust 5), B → C (trust 5)
|
||||
// Event: player action affects A (delta=+5)
|
||||
// Expected: C's change is queued for tick+30, not applied immediately
|
||||
let mut world = setup_propagation_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10; // Set a known tick
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let npc_c = StableId(3);
|
||||
let player = StableId(99);
|
||||
|
||||
let mut graph = world.resource_mut::<RelationshipGraph>();
|
||||
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
|
||||
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// C should NOT have an edge yet (it's deferred)
|
||||
{
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
assert!(
|
||||
graph.get_relationship(&npc_c, &player).is_none(),
|
||||
"Third-order changes should be deferred, not applied immediately"
|
||||
);
|
||||
}
|
||||
|
||||
// Delay queue should have one pending change for C at tick 10+30=40
|
||||
let delay_queue = world.resource::<DelayedTrustQueue>();
|
||||
assert_eq!(delay_queue.pending_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_changes_applied_when_due() {
|
||||
// Once the tick advances past apply_at_tick, the delayed change is applied.
|
||||
let mut world = setup_propagation_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let npc_c = StableId(3);
|
||||
let player = StableId(99);
|
||||
|
||||
let mut graph = world.resource_mut::<RelationshipGraph>();
|
||||
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
|
||||
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
|
||||
|
||||
// First run: queue the third-order change
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Advance tick to 40 (past apply_at_tick = 40)
|
||||
world.resource_mut::<SimulationTime>().tick = 40;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// C should now have an edge with positive trust
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_c, &player)
|
||||
.expect("Delayed change should have been applied by now");
|
||||
assert!(edge.trust > 0, "Third-order trust should be positive after delayed application");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_prevention_no_a_to_b_to_a_loop() {
|
||||
// Spec (#249, D-029): visited set prevents A→B→A cycles.
|
||||
//
|
||||
// Graph: A ↔ B (both trust each other at 5)
|
||||
// Event: player action affects A (delta=+2)
|
||||
// A should propagate to B, but B should NOT propagate back to A.
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
// Bidirectional strong trust
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_b,
|
||||
npc_a,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 2,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// A should not get a second-order change from the cycle (A was the origin)
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
// The only A→player edge effect should be through the propagation event
|
||||
// (no direct creation in propagate_social_actions — only update_trust does that).
|
||||
// B should have an edge to player.
|
||||
assert!(
|
||||
graph.get_relationship(&npc_b, &player).is_some(),
|
||||
"B should get second-order propagation from A"
|
||||
);
|
||||
// A should NOT have a re-propagated edge (cycle prevented)
|
||||
assert!(
|
||||
graph.get_relationship(&npc_a, &player).is_none(),
|
||||
"A should not receive back-propagation from B (cycle prevention)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_delta_propagates_as_negative() {
|
||||
// Spec (#249): Negative deltas (walk-away, confrontation) also propagate
|
||||
// with the same sign.
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: -2, // confrontation
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// B should trust player LESS after A was confronted
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_b, &player)
|
||||
.expect("B should have edge to player");
|
||||
assert!(
|
||||
edge.trust < 0,
|
||||
"Negative propagation should reduce B's trust in player"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_relationships_means_no_propagation() {
|
||||
// If the first-order NPC has no relationships, the queue is drained
|
||||
// but nothing propagates.
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let player = StableId(99);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
assert!(graph.is_empty(), "No relationships → no propagation edges created");
|
||||
|
||||
let delay_queue = world.resource::<DelayedTrustQueue>();
|
||||
assert_eq!(delay_queue.pending_count(), 0, "No delay queue entries either");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_trust_populates_propagation_queue() {
|
||||
// Integration: update_trust should push to PropagationQueue for downstream #249.
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::TalkCompleted { npc, player });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// PropagationQueue should have 1 event (for downstream propagation)
|
||||
let prop_events = world.resource_mut::<PropagationQueue>().drain();
|
||||
assert_eq!(prop_events.len(), 1);
|
||||
assert_eq!(prop_events[0].delta, TALK_TRUST_DELTA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_entity_event_is_skipped() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
@@ -0,0 +1,826 @@
|
||||
//! NPC vision system (#115, D-011).
|
||||
//!
|
||||
//! Active-tier NPCs use the same symmetric shadowcasting as the player.
|
||||
//! Results stored in `NpcVisionState`; `NpcMemory` tracks last-known
|
||||
//! positions and zone inferences ("saw you enter building → knows you're
|
||||
//! inside").
|
||||
//!
|
||||
//! ## System ordering
|
||||
//!
|
||||
//! 1. `compute_npc_vision` — after movement/tier updates, before snapshot
|
||||
//! 2. `emit_npc_vision_events` — after compute, before process_knowledge_events
|
||||
//! 3. `degrade_npc_inferences` — once per game-minute (D-031)
|
||||
//!
|
||||
//! ## Performance
|
||||
//!
|
||||
//! 30–80 Active NPCs × symmetric shadowcast per tick. Confirmed within
|
||||
//! D-026 Active tier budget by architecture review.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::knowledge::types::StableId;
|
||||
use crate::knowledge::{
|
||||
EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
|
||||
};
|
||||
use crate::npc::Npc;
|
||||
use crate::perception::shadowcast::compute_fov;
|
||||
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex};
|
||||
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// NPC vision range in tiles (matches player forward range from VisionConeConfig).
|
||||
pub const NPC_VISION_RANGE: i32 = 20;
|
||||
|
||||
/// Ticks before a zone inference degrades. 600 ticks = 60 game-minutes = 1 game-hour.
|
||||
pub const INFERENCE_DEGRADE_TICKS: u64 = 600;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Current field-of-view results for an NPC.
|
||||
/// Updated each tick for Active-tier NPCs. BTreeSet for determinism (D-010).
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct NpcVisionState {
|
||||
/// StableIds of entities currently in this NPC's LOS.
|
||||
pub visible_entities: BTreeSet<StableId>,
|
||||
/// Whether the player character is currently visible.
|
||||
pub player_visible: bool,
|
||||
}
|
||||
|
||||
/// Persistent memory of entities this NPC has seen.
|
||||
/// Survives after entities leave LOS (D-011: "saw you enter building →
|
||||
/// knows you're inside").
|
||||
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct NpcMemory {
|
||||
/// Last known position + tick for entities this NPC has seen.
|
||||
/// Key: StableId of the observed entity.
|
||||
pub last_known: BTreeMap<StableId, LastKnownEntry>,
|
||||
}
|
||||
|
||||
/// Record of last-known position for a single entity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LastKnownEntry {
|
||||
/// Position where entity was last seen.
|
||||
pub position: TilePosition,
|
||||
/// Tick when the entity was last observed.
|
||||
pub observed_tick: u64,
|
||||
/// Zone inference: if entity was last seen before leaving LOS,
|
||||
/// the NPC infers they are still nearby.
|
||||
pub zone_inference: Option<ZoneInference>,
|
||||
}
|
||||
|
||||
/// Inference that an entity is still near a position based on last observation.
|
||||
/// Degrades after `degrades_at_tick`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ZoneInference {
|
||||
/// The position where the entity was last seen.
|
||||
pub last_seen_position: TilePosition,
|
||||
/// Tick when the entity was seen at this position.
|
||||
pub observed_tick: u64,
|
||||
/// Tick at which this inference degrades (NPC stops assuming entity is here).
|
||||
pub degrades_at_tick: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute NPC field-of-view for all Active-tier NPCs.
|
||||
///
|
||||
/// For each NPC: run symmetric shadowcasting (same algorithm as player per D-011),
|
||||
/// apply vision cone if the NPC has a `Facing` component, then check which entities
|
||||
/// from the spatial index are at visible tiles.
|
||||
pub fn compute_npc_vision(
|
||||
walkability: Option<Res<WalkabilityMap>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
spatial_index: Res<NaiveSpatialIndex>,
|
||||
player_query: Query<Entity, With<PlayerCharacter>>,
|
||||
mut npc_query: Query<
|
||||
(Entity, &TilePosition, Option<&Facing>, &mut NpcVisionState),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
entity_positions: Query<&TilePosition>,
|
||||
) {
|
||||
let Some(walkability) = walkability else {
|
||||
return;
|
||||
};
|
||||
let player_entity = player_query.iter().next();
|
||||
let player_stable_id = player_entity.and_then(|e| registry.to_stable(e));
|
||||
let config = VisionConeConfig::default();
|
||||
|
||||
for (npc_entity, npc_pos, facing, mut vision_state) in npc_query.iter_mut() {
|
||||
let z = npc_pos.z;
|
||||
|
||||
// Run symmetric shadowcasting — same algorithm as player (D-011, D-035)
|
||||
let fov = compute_fov(
|
||||
|x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)),
|
||||
npc_pos.x,
|
||||
npc_pos.y,
|
||||
NPC_VISION_RANGE,
|
||||
z,
|
||||
);
|
||||
|
||||
// Collect visible tile positions — apply vision cone if NPC has facing
|
||||
let visible_positions: BTreeSet<(i32, i32)> = if let Some(facing_comp) = facing {
|
||||
let cone_tiles =
|
||||
apply_vision_cone(&fov, npc_pos.x, npc_pos.y, facing_comp.0, &config);
|
||||
cone_tiles.into_iter().map(|(x, y, _)| (x, y)).collect()
|
||||
} else {
|
||||
// No facing → omnidirectional vision (full FOV)
|
||||
fov.visible_tiles().collect()
|
||||
};
|
||||
|
||||
// Find entities at visible positions via spatial index
|
||||
let mut new_visible = BTreeSet::new();
|
||||
let mut player_vis = false;
|
||||
|
||||
// Single-pass query: all entities within vision range including own tile
|
||||
let candidates = spatial_index.entities_within(npc_pos, NPC_VISION_RANGE as u32);
|
||||
|
||||
for entity in candidates {
|
||||
if entity == npc_entity {
|
||||
continue;
|
||||
}
|
||||
let Ok(entity_pos) = entity_positions.get(entity) else {
|
||||
continue;
|
||||
};
|
||||
if entity_pos.z != z {
|
||||
continue;
|
||||
}
|
||||
if !visible_positions.contains(&(entity_pos.x, entity_pos.y)) {
|
||||
continue;
|
||||
}
|
||||
let Some(stable_id) = registry.to_stable(entity) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
new_visible.insert(stable_id);
|
||||
if Some(stable_id) == player_stable_id {
|
||||
player_vis = true;
|
||||
}
|
||||
}
|
||||
|
||||
vision_state.visible_entities = new_visible;
|
||||
vision_state.player_visible = player_vis;
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit knowledge events when NPCs gain or lose sight of the player.
|
||||
///
|
||||
/// Mirrors the player's `emit_observation_events` pattern but scoped to
|
||||
/// NPC→player tracking only. NPC-to-NPC vision is stored in `NpcVisionState`
|
||||
/// for direct query by downstream systems (#244 awareness) without flooding
|
||||
/// the knowledge event queue.
|
||||
///
|
||||
/// Also updates `NpcMemory` with last-known positions and zone inferences.
|
||||
pub fn emit_npc_vision_events(
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||||
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
|
||||
mut npc_query: Query<
|
||||
(
|
||||
Entity,
|
||||
&NpcVisionState,
|
||||
&mut NpcMemory,
|
||||
Option<&KnowledgeGraph>,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
) {
|
||||
let Ok((player_entity, player_pos)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
let Some(player_sid) = registry.to_stable(player_entity) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (npc_entity, vision_state, mut memory, knowledge_graph) in npc_query.iter_mut() {
|
||||
if vision_state.player_visible {
|
||||
// Player is in LOS — update memory and emit DirectObservation
|
||||
memory.last_known.insert(
|
||||
player_sid,
|
||||
LastKnownEntry {
|
||||
position: *player_pos,
|
||||
observed_tick: time.tick,
|
||||
zone_inference: None, // Active observation clears inference
|
||||
},
|
||||
);
|
||||
|
||||
event_queue.push(KnowledgeEvent {
|
||||
observer: npc_entity,
|
||||
tick: time.tick,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target: player_entity,
|
||||
position: *player_pos,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Player NOT in LOS — check if they WERE Direct (just left)
|
||||
let was_direct = knowledge_graph
|
||||
.and_then(|kg| {
|
||||
kg.entity_knowledge(&player_sid)
|
||||
.map(|k| k.confidence == crate::knowledge::KnowledgeConfidence::Direct)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if was_direct {
|
||||
// Player just left this NPC's LOS — emit LeftLOS
|
||||
event_queue.push(KnowledgeEvent {
|
||||
observer: npc_entity,
|
||||
tick: time.tick,
|
||||
event_type: KnowledgeEventType::LeftLOS {
|
||||
target: player_entity,
|
||||
},
|
||||
});
|
||||
|
||||
// Create zone inference — NPC remembers where they last saw the player
|
||||
if let Some(entry) = memory.last_known.get_mut(&player_sid) {
|
||||
entry.zone_inference = Some(ZoneInference {
|
||||
last_seen_position: entry.position,
|
||||
observed_tick: entry.observed_tick,
|
||||
degrades_at_tick: time.tick + INFERENCE_DEGRADE_TICKS,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Degrade stale zone inferences in NPC memory.
|
||||
///
|
||||
/// Runs once per game-minute (every 10 ticks per D-031). When a zone
|
||||
/// inference passes its degradation tick, the inference is removed.
|
||||
pub fn degrade_npc_inferences(
|
||||
time: Res<SimulationTime>,
|
||||
mut npc_query: Query<&mut NpcMemory, (With<Npc>, With<ActiveSim>)>,
|
||||
) {
|
||||
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
for mut memory in npc_query.iter_mut() {
|
||||
for entry in memory.last_known.values_mut() {
|
||||
if let Some(ref inference) = entry.zone_inference {
|
||||
if time.tick >= inference.degrades_at_tick {
|
||||
entry.zone_inference = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
fn setup_world(width: i32, height: i32) -> World {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.insert_resource(WalkabilityMap::new(width, height, 1));
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<NaiveSpatialIndex>();
|
||||
world.init_resource::<VisibilityGeometry>();
|
||||
world.init_resource::<ActivePerceptionMode>();
|
||||
world
|
||||
}
|
||||
|
||||
fn pos(x: i32, y: i32) -> TilePosition {
|
||||
TilePosition::new(x, y, 0)
|
||||
}
|
||||
|
||||
// --- compute_npc_vision tests ---
|
||||
|
||||
#[test]
|
||||
fn npc_sees_nearby_entity_in_open_field() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
// NPC at (16, 16), target at (16, 14) — 2 tiles away, clear LOS
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
let target = world.spawn(pos(16, 14)).id();
|
||||
let target_sid = registry.register(target);
|
||||
spatial.update(target, pos(16, 14));
|
||||
|
||||
// Player entity (required for player_stable_id lookup)
|
||||
let player = world.spawn((PlayerCharacter, pos(0, 0))).id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
vision.visible_entities.contains(&target_sid),
|
||||
"NPC should see nearby entity in open field"
|
||||
);
|
||||
assert!(!vision.player_visible, "player is far away");
|
||||
let _ = npc_sid; // registered for completeness
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_cannot_see_through_wall() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
// Target behind a wall
|
||||
let target = world.spawn(pos(16, 14)).id();
|
||||
let target_sid = registry.register(target);
|
||||
spatial.update(target, pos(16, 14));
|
||||
|
||||
// Wall between NPC and target
|
||||
let mut walkability = world.resource_mut::<WalkabilityMap>();
|
||||
walkability.set_walkable(&pos(16, 15), false);
|
||||
drop(walkability);
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
!vision.visible_entities.contains(&target_sid),
|
||||
"NPC should not see entity behind wall"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_detects_player_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||||
registry.register(player);
|
||||
spatial.update(player, pos(16, 14));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(vision.player_visible, "NPC should detect player in LOS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_does_not_see_entity_on_different_z_level() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
// Target at same x/y but different z
|
||||
let target = world.spawn(TilePosition::new(16, 14, 1)).id();
|
||||
let target_sid = registry.register(target);
|
||||
spatial.update(target, TilePosition::new(16, 14, 1));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
!vision.visible_entities.contains(&target_sid),
|
||||
"NPC should not see entity on different z-level"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_does_not_see_entity_beyond_range() {
|
||||
let mut world = setup_world(64, 64);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
// Target beyond NPC_VISION_RANGE (20 tiles)
|
||||
let target = world.spawn(pos(16 + NPC_VISION_RANGE + 5, 16)).id();
|
||||
let target_sid = registry.register(target);
|
||||
spatial.update(target, pos(16 + NPC_VISION_RANGE + 5, 16));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
!vision.visible_entities.contains(&target_sid),
|
||||
"NPC should not see entity beyond vision range"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_does_not_see_self() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
!vision.visible_entities.contains(&npc_sid),
|
||||
"NPC should not include itself in visible entities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_sees_non_npc_entity_on_same_tile() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
// NPC at (16, 16)
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
// Non-NPC entity on the same tile (e.g. dropped item)
|
||||
let item = world.spawn(pos(16, 16)).id();
|
||||
let item_sid = registry.register(item);
|
||||
spatial.update(item, pos(16, 16));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
vision.visible_entities.contains(&item_sid),
|
||||
"NPC should see non-NPC entity sharing its tile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_npc_not_processed() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
// Background NPC — should NOT have its vision computed
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
crate::simulation::tier::BackgroundSim,
|
||||
pos(16, 16),
|
||||
NpcVisionState::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
let target = world.spawn(pos(16, 14)).id();
|
||||
let target_sid = registry.register(target);
|
||||
spatial.update(target, pos(16, 14));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
vision.visible_entities.is_empty(),
|
||||
"Background NPC should not have vision computed"
|
||||
);
|
||||
let _ = target_sid;
|
||||
}
|
||||
|
||||
// --- emit_npc_vision_events tests ---
|
||||
|
||||
#[test]
|
||||
fn npc_seeing_player_emits_direct_observation() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
let mut vision = NpcVisionState::default();
|
||||
vision.visible_entities.insert(player_sid);
|
||||
vision.player_visible = true;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
pos(16, 16),
|
||||
vision,
|
||||
NpcMemory::default(),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(emit_npc_vision_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let queue = world.resource::<KnowledgeEventQueue>();
|
||||
assert!(!queue.is_empty(), "should emit DirectObservation for player");
|
||||
|
||||
let event = &queue.events[0];
|
||||
assert_eq!(event.observer, npc);
|
||||
assert!(matches!(
|
||||
event.event_type,
|
||||
KnowledgeEventType::DirectObservation { target, .. } if target == player
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_losing_player_emits_left_los() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
// NPC that had Direct knowledge of player (player was just visible)
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(player_sid, pos(16, 14), 50);
|
||||
|
||||
// Memory with last known position
|
||||
let mut memory = NpcMemory::default();
|
||||
memory.last_known.insert(
|
||||
player_sid,
|
||||
LastKnownEntry {
|
||||
position: pos(16, 14),
|
||||
observed_tick: 50,
|
||||
zone_inference: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Vision state: player NOT visible now
|
||||
let vision = NpcVisionState::default();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), vision, memory, kg))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 60;
|
||||
world.insert_resource(time);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(emit_npc_vision_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let queue = world.resource::<KnowledgeEventQueue>();
|
||||
let has_left_los = queue.events.iter().any(|e| {
|
||||
matches!(
|
||||
e.event_type,
|
||||
KnowledgeEventType::LeftLOS { target } if target == player
|
||||
)
|
||||
});
|
||||
assert!(has_left_los, "should emit LeftLOS when player leaves NPC LOS");
|
||||
|
||||
// Check zone inference was created
|
||||
let npc_memory = world.get::<NpcMemory>(npc).unwrap();
|
||||
let entry = npc_memory.last_known.get(&player_sid).unwrap();
|
||||
assert!(
|
||||
entry.zone_inference.is_some(),
|
||||
"zone inference should be created when player leaves LOS"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.zone_inference.as_ref().unwrap().degrades_at_tick,
|
||||
60 + INFERENCE_DEGRADE_TICKS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_updates_memory_on_player_observation() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world.spawn((PlayerCharacter, pos(10, 10))).id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
let mut vision = NpcVisionState::default();
|
||||
vision.visible_entities.insert(player_sid);
|
||||
vision.player_visible = true;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
pos(16, 16),
|
||||
vision,
|
||||
NpcMemory::default(),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 100;
|
||||
world.insert_resource(time);
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(emit_npc_vision_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let memory = world.get::<NpcMemory>(npc).unwrap();
|
||||
let entry = memory.last_known.get(&player_sid).unwrap();
|
||||
assert_eq!(entry.position, pos(10, 10));
|
||||
assert_eq!(entry.observed_tick, 100);
|
||||
assert!(entry.zone_inference.is_none(), "active observation = no inference");
|
||||
}
|
||||
|
||||
// --- degrade_npc_inferences tests ---
|
||||
|
||||
#[test]
|
||||
fn inference_degrades_after_threshold() {
|
||||
let mut world = setup_world(32, 32);
|
||||
|
||||
let player_sid = StableId(1);
|
||||
let mut memory = NpcMemory::default();
|
||||
memory.last_known.insert(
|
||||
player_sid,
|
||||
LastKnownEntry {
|
||||
position: pos(10, 10),
|
||||
observed_tick: 50,
|
||||
zone_inference: Some(ZoneInference {
|
||||
last_seen_position: pos(10, 10),
|
||||
observed_tick: 50,
|
||||
degrades_at_tick: 100,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
let npc = world.spawn((Npc, ActiveSim, memory)).id();
|
||||
|
||||
// Tick 90 (before degradation, on minute boundary)
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 90;
|
||||
world.insert_resource(time);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(degrade_npc_inferences);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||||
assert!(
|
||||
mem.last_known[&player_sid].zone_inference.is_some(),
|
||||
"inference should survive before degradation tick"
|
||||
);
|
||||
|
||||
// Tick 100 (degradation tick, on minute boundary)
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 100;
|
||||
world.insert_resource(time);
|
||||
|
||||
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
|
||||
schedule2.add_systems(degrade_npc_inferences);
|
||||
schedule2.run(&mut world);
|
||||
|
||||
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||||
assert!(
|
||||
mem.last_known[&player_sid].zone_inference.is_none(),
|
||||
"inference should be removed at degradation tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inference_degradation_skips_non_minute_ticks() {
|
||||
let mut world = setup_world(32, 32);
|
||||
|
||||
let player_sid = StableId(1);
|
||||
let mut memory = NpcMemory::default();
|
||||
memory.last_known.insert(
|
||||
player_sid,
|
||||
LastKnownEntry {
|
||||
position: pos(10, 10),
|
||||
observed_tick: 50,
|
||||
zone_inference: Some(ZoneInference {
|
||||
last_seen_position: pos(10, 10),
|
||||
observed_tick: 50,
|
||||
degrades_at_tick: 55, // already past
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
let npc = world.spawn((Npc, ActiveSim, memory)).id();
|
||||
|
||||
// Tick 57 — past degradation but NOT a minute boundary
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 57;
|
||||
world.insert_resource(time);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(degrade_npc_inferences);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||||
assert!(
|
||||
mem.last_known[&player_sid].zone_inference.is_some(),
|
||||
"degradation should not run on non-minute ticks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_events_when_no_player_entity() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let registry = EntityRegistry::new(0);
|
||||
|
||||
let vision = NpcVisionState::default();
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
pos(16, 16),
|
||||
vision,
|
||||
NpcMemory::default(),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(emit_npc_vision_events);
|
||||
schedule.run(&mut world); // should not panic
|
||||
|
||||
let queue = world.resource::<KnowledgeEventQueue>();
|
||||
assert!(queue.is_empty(), "no events when no player entity exists");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::contraband::ScanEventBuffer;
|
||||
use crate::simulation::conversation::ConversationEventBuffer;
|
||||
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||
use crate::simulation::examine::ExamineResultBuffer;
|
||||
use crate::simulation::follow::FollowTarget;
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
@@ -83,6 +84,7 @@ pub fn compute_observer_snapshot(
|
||||
Option<&mut ScanEventBuffer>,
|
||||
Option<&mut ConversationEventBuffer>,
|
||||
Option<&FollowTarget>,
|
||||
Option<&mut ExamineResultBuffer>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
@@ -98,6 +100,7 @@ pub fn compute_observer_snapshot(
|
||||
poi_query: Query<&PointOfInterest>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
sim_rng: Option<Res<SimRng>>,
|
||||
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
|
||||
) {
|
||||
let Ok((
|
||||
observer_entity,
|
||||
@@ -114,6 +117,7 @@ pub fn compute_observer_snapshot(
|
||||
mut scan_event_buffer_opt,
|
||||
mut conversation_buffer_opt,
|
||||
follow_target_opt,
|
||||
mut examine_result_buffer_opt,
|
||||
)) = observer_query.single_mut()
|
||||
else {
|
||||
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
|
||||
@@ -199,6 +203,7 @@ pub fn compute_observer_snapshot(
|
||||
|
||||
let current_monologue = monologue_buffer.take();
|
||||
let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take());
|
||||
let examine_result = examine_result_buffer_opt.as_mut().and_then(|buf| buf.take());
|
||||
let scan_events = scan_event_buffer_opt
|
||||
.as_mut()
|
||||
.map(|buf| buf.take())
|
||||
@@ -391,6 +396,10 @@ pub fn compute_observer_snapshot(
|
||||
conversation_events,
|
||||
conversation_ended,
|
||||
follow_state,
|
||||
examine_result,
|
||||
character_pressure: pressure_query.iter().next().map(|p| {
|
||||
crate::simulation::pressure::CharacterPressureWire::from(p)
|
||||
}),
|
||||
sound_events,
|
||||
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
|
||||
poi_list,
|
||||
|
||||
@@ -2583,3 +2583,169 @@ fn no_zone_map_resource_tiles_have_no_zone_id() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #337 — Tell state → snapshot integration (D-024 tell system)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Helper: run derive_tell_state + two-stage observer pipeline together.
|
||||
fn run_tell_plus_observer_pipeline(world: &mut World) {
|
||||
use crate::npc::tell_state::derive_tell_state;
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems((
|
||||
derive_tell_state,
|
||||
compute_visibility_geometry.after(derive_tell_state),
|
||||
compute_observer_snapshot
|
||||
.after(compute_visibility_geometry)
|
||||
.after(derive_tell_state),
|
||||
));
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tell_state_nervous_appears_in_snapshot_for_major_secret_high_stress() {
|
||||
// Spec (#337, D-024): NPC with Major secret + stress past midpoint shows
|
||||
// TellCategory::Nervous in ObserverSnapshot.entities[].tell_state.
|
||||
// End-to-end pipeline: axis values → derive_tell_state → DerivedTellState
|
||||
// → compute_observer_snapshot → VisibleEntity.tell_state.
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::tell_state::{DerivedTellState, TellCategory};
|
||||
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
// NPC directly north — in forward LOS — with Major secret + stress > midpoint.
|
||||
// stress=60, threshold=100 → stress*2=120 > 100 → Nervous (D-024 priority 2)
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
TilePosition::new(16, 14, 0),
|
||||
Secret {
|
||||
description: "criminal record".into(),
|
||||
severity: SecretSeverity::Major,
|
||||
known_by: vec![],
|
||||
},
|
||||
ToleranceThreshold { current_stress: 60, threshold: 100 },
|
||||
Contentment { level: 0 },
|
||||
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
|
||||
DerivedTellState::default(),
|
||||
));
|
||||
|
||||
run_tell_plus_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
|
||||
let npc = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible in snapshot");
|
||||
|
||||
assert_eq!(
|
||||
npc.tell_state,
|
||||
Some(TellCategory::Nervous),
|
||||
"NPC with Major secret + stress past midpoint should show Nervous tell in snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tell_state_none_for_neutral_npc_in_snapshot() {
|
||||
// Spec (#337, D-024): neutral NPC shows tell_state = None in snapshot.
|
||||
// Verifies the pipeline correctly omits tell when no conditions are met.
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::tell_state::DerivedTellState;
|
||||
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
// NPC in LOS — neutral state (Minor secret, low stress, neutral mood, low contentment)
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
TilePosition::new(16, 14, 0),
|
||||
Secret {
|
||||
description: "minor embarrassment".into(),
|
||||
severity: SecretSeverity::Minor,
|
||||
known_by: vec![],
|
||||
},
|
||||
ToleranceThreshold { current_stress: 10, threshold: 100 },
|
||||
Contentment { level: 0 },
|
||||
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
|
||||
DerivedTellState::default(),
|
||||
));
|
||||
|
||||
run_tell_plus_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
|
||||
let npc = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible in snapshot");
|
||||
|
||||
assert_eq!(
|
||||
npc.tell_state,
|
||||
None,
|
||||
"neutral NPC should have no tell state in snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tell_state_none_when_npc_has_no_derived_tell_component() {
|
||||
// Spec (#337): NPC without DerivedTellState component has tell_state = None.
|
||||
// Verifies Option<&DerivedTellState> query handles absent component gracefully.
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
// NPC with no DerivedTellState component at all
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
|
||||
let npc = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible in snapshot");
|
||||
|
||||
assert_eq!(
|
||||
npc.tell_state,
|
||||
None,
|
||||
"NPC without DerivedTellState component should have tell_state = None"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Examine interaction system (#242).
|
||||
//!
|
||||
//! Handles the Examine verb: player examines an NPC or object at close range,
|
||||
//! generating character-filtered observation text and a DirectObservation
|
||||
//! KnowledgeGraph entry.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//! Interact { verb: "Examine NPC" | "ExamineNpc" | "ExamineObject" }
|
||||
//! → process_player_input inserts ExamineRequest on player
|
||||
//! → process_examine_interaction reads request, generates text, pushes KG event
|
||||
//! → ExamineResultBuffer consumed by compute_observer_snapshot
|
||||
//! → ObserverSnapshot.examine_result delivered to client
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bridge::types::CharacterArchetype;
|
||||
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold};
|
||||
use crate::simulation::interaction::CLOSE_RANGE;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker: player requested Examine interaction with a target entity this tick.
|
||||
///
|
||||
/// Inserted by process_player_input when verb == "Examine NPC", "ExamineNpc",
|
||||
/// "Examine Object", or "ExamineObject". Consumed and removed by
|
||||
/// process_examine_interaction each tick.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ExamineRequest {
|
||||
pub target: Entity,
|
||||
}
|
||||
|
||||
/// Character-filtered examination result for snapshot delivery.
|
||||
///
|
||||
/// Content differs per CharacterArchetype:
|
||||
/// Smuggler — physical threat read, cargo-handling posture, opportunity windows.
|
||||
/// Detective — procedural tells, behavioral inconsistencies, stress indicators.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExamineResultEvent {
|
||||
/// Character-filtered observation text for client display.
|
||||
pub text: String,
|
||||
/// Wire-format entity identifier of the examined entity.
|
||||
pub target_entity_id: u64,
|
||||
}
|
||||
|
||||
/// Buffer holding the examine result for snapshot inclusion.
|
||||
///
|
||||
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
|
||||
/// Attach to the player entity alongside other buffer components.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct ExamineResultBuffer {
|
||||
pub(crate) result: Option<ExamineResultEvent>,
|
||||
}
|
||||
|
||||
impl ExamineResultBuffer {
|
||||
/// Drain and return the examine result, leaving the buffer empty.
|
||||
pub fn take(&mut self) -> Option<ExamineResultEvent> {
|
||||
self.result.take()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text generation (deterministic, integer-only — D-010)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Stress ratio 0..=100 derived from ToleranceThreshold components.
|
||||
/// Uses integer multiplication to avoid division by zero.
|
||||
fn stress_ratio(threshold: &ToleranceThreshold) -> u8 {
|
||||
if threshold.threshold <= 0 {
|
||||
return 0;
|
||||
}
|
||||
((threshold.current_stress.max(0) as i32 * 100) / threshold.threshold as i32).clamp(0, 100)
|
||||
as u8
|
||||
}
|
||||
|
||||
/// Map NpcMood to a terse descriptor shared by both archetypes.
|
||||
fn mood_word(mood: NpcMood) -> &'static str {
|
||||
match mood {
|
||||
NpcMood::Neutral => "neutral",
|
||||
NpcMood::Anxious => "anxious",
|
||||
NpcMood::Frustrated => "frustrated",
|
||||
NpcMood::Content => "at ease",
|
||||
NpcMood::Suspicious => "watchful",
|
||||
NpcMood::Warm => "open",
|
||||
NpcMood::Hostile => "hostile",
|
||||
NpcMood::Focused => "focused",
|
||||
}
|
||||
}
|
||||
|
||||
fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> bool {
|
||||
traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Generate character-filtered examination text from NPC component state.
|
||||
/// All logic is pure, deterministic, and integer-based (D-010).
|
||||
pub fn generate_examine_text(
|
||||
mood: NpcMood,
|
||||
ratio: u8,
|
||||
archetype: CharacterArchetype,
|
||||
traits_opt: Option<&PersonalityTraits>,
|
||||
) -> String {
|
||||
let stress_label = match ratio {
|
||||
0..=30 => "relaxed",
|
||||
31..=60 => "tense",
|
||||
61..=85 => "stressed",
|
||||
_ => "near breaking point",
|
||||
};
|
||||
|
||||
let mood_label = mood_word(mood);
|
||||
|
||||
match archetype {
|
||||
CharacterArchetype::Smuggler => {
|
||||
// Physical threat read + cargo opportunity window
|
||||
let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) {
|
||||
"Threat posture. Don't push it."
|
||||
} else if has_trait(traits_opt, PersonalityTrait::Bold) {
|
||||
"Confident bearing. Will push back if cornered."
|
||||
} else if has_trait(traits_opt, PersonalityTrait::Cautious) {
|
||||
"Nervous type. Predictable under pressure."
|
||||
} else {
|
||||
"No obvious threat read."
|
||||
};
|
||||
|
||||
let window = if ratio > 60 {
|
||||
"Too distracted to track cargo movement."
|
||||
} else if matches!(mood, NpcMood::Focused) {
|
||||
"Paying close attention to this section."
|
||||
} else {
|
||||
"Standard patrol pattern. Window is there."
|
||||
};
|
||||
|
||||
format!("Appears {mood_label}, {stress_label}. {threat} {window}")
|
||||
}
|
||||
|
||||
CharacterArchetype::Detective => {
|
||||
// Procedural tells + behavioral read
|
||||
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
|
||||
"Controlled affect — practiced concealment."
|
||||
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
|
||||
"Involuntary stress markers present."
|
||||
} else if matches!(mood, NpcMood::Suspicious) {
|
||||
"Scanning. Aware of being observed."
|
||||
} else {
|
||||
"Baseline presentation."
|
||||
};
|
||||
|
||||
let read = if ratio > 60 {
|
||||
"Under pressure — potential liability or asset."
|
||||
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
|
||||
"Comfortable. Less guarded than usual."
|
||||
} else {
|
||||
"Routine behavior pattern."
|
||||
};
|
||||
|
||||
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process examine interaction: generate character-filtered observation text,
|
||||
/// push DirectObservation to KnowledgeGraph, write result to ExamineResultBuffer.
|
||||
///
|
||||
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn process_examine_interaction(
|
||||
mut commands: Commands,
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut kg_events: ResMut<KnowledgeEventQueue>,
|
||||
mut player_query: Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
&ExamineRequest,
|
||||
Option<&CharacterArchetype>,
|
||||
&mut ExamineResultBuffer,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
npc_query: Query<(
|
||||
&TilePosition,
|
||||
Option<&MoodState>,
|
||||
Option<&ToleranceThreshold>,
|
||||
Option<&PersonalityTraits>,
|
||||
)>,
|
||||
) {
|
||||
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
|
||||
player_query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target = examine_req.target;
|
||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
||||
|
||||
// Range check — examine requires close range (same as Talk/Confront)
|
||||
let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) else {
|
||||
tracing::warn!(?target, "process_examine_interaction: target not in query");
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
return;
|
||||
};
|
||||
|
||||
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
|
||||
if distance > CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
distance,
|
||||
"Examine: target out of range (max {})",
|
||||
CLOSE_RANGE
|
||||
);
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
return;
|
||||
}
|
||||
|
||||
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
|
||||
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
|
||||
|
||||
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
|
||||
|
||||
// Push DirectObservation to KnowledgeEventQueue
|
||||
kg_events.push(KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick: time.tick,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target,
|
||||
position: *target_pos,
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve target wire ID for snapshot
|
||||
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
|
||||
tracing::warn!(?target, "Examine: target not in EntityRegistry, using bits");
|
||||
target.to_bits()
|
||||
});
|
||||
|
||||
result_buffer.result = Some(ExamineResultEvent {
|
||||
text,
|
||||
target_entity_id,
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
target_entity_id,
|
||||
"Examine: DirectObservation pushed, result written to buffer"
|
||||
);
|
||||
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::PersonalityTrait;
|
||||
|
||||
fn traits(t: &[PersonalityTrait]) -> PersonalityTraits {
|
||||
PersonalityTraits { traits: t.to_vec() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_hostile_npc_gives_threat_read() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Hostile,
|
||||
20,
|
||||
CharacterArchetype::Smuggler,
|
||||
None,
|
||||
);
|
||||
assert!(text.contains("Threat posture"), "expected threat read, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_focused_npc_notes_attention() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Focused,
|
||||
30,
|
||||
CharacterArchetype::Smuggler,
|
||||
None,
|
||||
);
|
||||
assert!(text.contains("close attention"), "expected attention note, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_high_stress_identifies_distraction() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Anxious,
|
||||
80,
|
||||
CharacterArchetype::Smuggler,
|
||||
None,
|
||||
);
|
||||
assert!(text.contains("Too distracted"), "expected distraction read, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_deceptive_npc_notes_concealment() {
|
||||
let t = traits(&[PersonalityTrait::Deceptive]);
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Neutral,
|
||||
20,
|
||||
CharacterArchetype::Detective,
|
||||
Some(&t),
|
||||
);
|
||||
assert!(text.contains("Controlled affect"), "expected concealment note, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_anxious_npc_notes_stress_markers() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Anxious,
|
||||
50,
|
||||
CharacterArchetype::Detective,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
text.contains("stress markers"),
|
||||
"expected stress markers, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_content_npc_notes_low_guard() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Content,
|
||||
10,
|
||||
CharacterArchetype::Detective,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
text.contains("Less guarded"),
|
||||
"expected low guard note, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_ratio_zero_when_threshold_zero() {
|
||||
let t = ToleranceThreshold { current_stress: 50, threshold: 0 };
|
||||
assert_eq!(stress_ratio(&t), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_ratio_clamped_at_100() {
|
||||
let t = ToleranceThreshold { current_stress: 200, threshold: 100 };
|
||||
assert_eq!(stress_ratio(&t), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_ratio_negative_stress_is_zero() {
|
||||
let t = ToleranceThreshold { current_stress: -10, threshold: 70 };
|
||||
assert_eq!(stress_ratio(&t), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn examine_result_buffer_take_drains() {
|
||||
let mut buf = ExamineResultBuffer::default();
|
||||
assert!(buf.take().is_none());
|
||||
buf.result = Some(ExamineResultEvent {
|
||||
text: "Test".into(),
|
||||
target_entity_id: 42,
|
||||
});
|
||||
assert!(buf.take().is_some());
|
||||
assert!(buf.take().is_none()); // idempotent drain
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,16 @@ pub fn process_player_input(
|
||||
current_tick,
|
||||
);
|
||||
}
|
||||
Some("Examine NPC") | Some("ExamineNpc") | Some("Examine Object")
|
||||
| Some("ExamineObject") | Some("Observe") => {
|
||||
handle_examine(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
&all_positions,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Confront") => {
|
||||
handle_confront(
|
||||
&mut commands,
|
||||
@@ -471,6 +481,65 @@ fn handle_talk(
|
||||
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
|
||||
}
|
||||
|
||||
/// Handle Examine verb: insert ExamineRequest marker on the player entity (#242).
|
||||
/// Examine is available at close range (same as Talk). Range check here matches
|
||||
/// the server-side guard in process_examine_interaction.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn handle_examine(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&mut crate::simulation::stance::Stance>,
|
||||
Option<&mut crate::simulation::stance::PlayerMoveCooldown>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
all_positions: &Query<&TilePosition>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Examine verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Examine: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Server-side range check: reject Examine if target is beyond close range
|
||||
if let Ok(target_pos) = all_positions.get(target_entity) {
|
||||
let distance = player_pos
|
||||
.manhattan_distance(target_pos)
|
||||
.unwrap_or(u32::MAX);
|
||||
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
distance,
|
||||
"Examine: target out of range (max {})",
|
||||
crate::simulation::interaction::CLOSE_RANGE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(crate::simulation::examine::ExamineRequest {
|
||||
target: target_entity,
|
||||
});
|
||||
|
||||
tracing::debug!(target_id, "Examine: ExamineRequest marker set on player");
|
||||
}
|
||||
|
||||
/// Handle DialogueResponse action: set DialogueResponseRequest marker (#539).
|
||||
/// The follow-up dialogue pipeline runs in process_dialogue_response (dialogue.rs).
|
||||
///
|
||||
|
||||
@@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
pub mod contraband;
|
||||
pub mod conversation;
|
||||
pub mod dialogue;
|
||||
pub mod examine;
|
||||
pub mod follow;
|
||||
pub mod input;
|
||||
pub mod interaction;
|
||||
@@ -19,7 +20,9 @@ pub mod path_follow;
|
||||
pub mod pathfinding;
|
||||
pub mod poi;
|
||||
pub mod poi_discovery;
|
||||
pub mod pressure;
|
||||
pub mod rng;
|
||||
pub mod save_state;
|
||||
pub mod sound;
|
||||
pub mod spatial;
|
||||
pub mod stance;
|
||||
@@ -78,6 +81,13 @@ impl Plugin for SimulationPlugin {
|
||||
poi_discovery::discover_pois
|
||||
.after(crate::perception::observer::compute_visibility_geometry)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
examine::process_examine_interaction
|
||||
.after(input::process_player_input)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
// Character pressure (#248) — reads NPC awareness + relationship graph
|
||||
pressure::update_character_pressure
|
||||
.after(crate::npc::awareness::detect_player_awareness)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
//! Character goal/pressure framework (#248).
|
||||
//!
|
||||
//! Defines systemic pressures on the player character that modulate monologue
|
||||
//! salience and observation priority. Not scripted arcs — emergent from
|
||||
//! interaction of existing D-024 axes.
|
||||
//!
|
||||
//! ## Three pressure axes
|
||||
//!
|
||||
//! - **Exposure**: rises when NPCs notice the player (#244 PlayerAwareness)
|
||||
//! - **Relationship**: rises when NPCs distrust the player (negative trust in
|
||||
//! `RelationshipGraph`)
|
||||
//! - **Institutional**: detective-specific pressure (stub in v0.1)
|
||||
//!
|
||||
//! ## Update frequency
|
||||
//!
|
||||
//! Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute) to avoid
|
||||
//! per-tick overhead of relationship graph scans.
|
||||
//!
|
||||
//! ## Output surface
|
||||
//!
|
||||
//! - `CharacterPressureWire` in `ObserverSnapshot` for client HUD
|
||||
//! - `pressure_mood()` helper for monologue salience weighting — high pressure
|
||||
//! biases toward anxiety-tagged lines (D-035 mood tags)
|
||||
//!
|
||||
//! All arithmetic is integer-only (D-010 determinism).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::npc::awareness::PlayerAwareness;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ticks between pressure recalculations. 10 ticks = 1 game-minute (D-031).
|
||||
pub const PRESSURE_UPDATE_INTERVAL: u64 = 10;
|
||||
|
||||
/// Exposure pressure per suspicious NPC. Scaled so ~5 suspicious NPCs ≈ 50 pressure.
|
||||
pub const EXPOSURE_PER_SUSPICIOUS_NPC: i32 = 10;
|
||||
|
||||
/// Relationship pressure per hostile edge (trust ≤ -3). Scaled so ~4 hostile NPCs ≈ 60 pressure.
|
||||
pub const RELATIONSHIP_PER_HOSTILE_EDGE: i32 = 15;
|
||||
|
||||
/// Pressure threshold above which monologue mood shifts to "anxious".
|
||||
pub const MOOD_ANXIOUS_THRESHOLD: i32 = 50;
|
||||
|
||||
/// Pressure threshold above which monologue mood shifts to "frustrated".
|
||||
/// Below anxious threshold but above this → frustrated.
|
||||
pub const MOOD_FRUSTRATED_THRESHOLD: i32 = 30;
|
||||
|
||||
/// Trust value at or below which a relationship counts as "hostile" for
|
||||
/// relationship pressure.
|
||||
pub const HOSTILE_TRUST_THRESHOLD: i8 = -3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Systemic pressure on the player character (#248).
|
||||
///
|
||||
/// Attached to the player entity. Updated every game-minute by
|
||||
/// `update_character_pressure`. Feeds into monologue salience weighting
|
||||
/// and ObserverSnapshot HUD data.
|
||||
///
|
||||
/// All values are 0–100, integer for D-010 determinism.
|
||||
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CharacterPressure {
|
||||
/// Exposure pressure: rises when NPCs notice the player watching them.
|
||||
/// Derived from aggregate `PlayerAwareness.suspicion_level` across Active NPCs.
|
||||
pub exposure: i32,
|
||||
/// Institutional pressure: detective-specific systemic pressure.
|
||||
/// Stub in v0.1 — future sprints wire this to detective interaction patterns.
|
||||
pub institutional: i32,
|
||||
/// Relationship pressure: rises when NPCs distrust the player.
|
||||
/// Derived from negative trust edges in the `RelationshipGraph`.
|
||||
pub relationship: i32,
|
||||
}
|
||||
|
||||
impl CharacterPressure {
|
||||
/// Total pressure as a simple average of all axes (0–100).
|
||||
///
|
||||
/// Uses integer division — remainders are floor-truncated (D-010, no floats).
|
||||
/// Maximum rounding error is 2 units (e.g. axis sum 101 → 33 instead of 33.67).
|
||||
pub fn total(&self) -> i32 {
|
||||
// Simple average, clamped. Integer division truncates toward zero (D-010).
|
||||
((self.exposure + self.institutional + self.relationship) / 3).clamp(0, 100)
|
||||
}
|
||||
|
||||
/// Dominant mood tag for monologue salience weighting.
|
||||
///
|
||||
/// Returns the D-035 mood tag that should be preferred when selecting
|
||||
/// monologue lines. `None` when pressure is low — baseline monologue
|
||||
/// selection applies.
|
||||
pub fn pressure_mood(&self) -> Option<&'static str> {
|
||||
let total = self.total();
|
||||
if total >= MOOD_ANXIOUS_THRESHOLD {
|
||||
Some("anxious")
|
||||
} else if total >= MOOD_FRUSTRATED_THRESHOLD {
|
||||
Some("frustrated")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire type for ObserverSnapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Character pressure data for client HUD display (#248).
|
||||
///
|
||||
/// Included in `ObserverSnapshot` when pressure is non-zero.
|
||||
/// Client renders as a tension/pressure indicator widget.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CharacterPressureWire {
|
||||
/// Exposure pressure (0–100).
|
||||
pub exposure: i32,
|
||||
/// Institutional pressure (0–100).
|
||||
pub institutional: i32,
|
||||
/// Relationship pressure (0–100).
|
||||
pub relationship: i32,
|
||||
/// Total pressure (0–100).
|
||||
pub total: i32,
|
||||
/// Dominant mood tag, if any.
|
||||
pub mood: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&CharacterPressure> for CharacterPressureWire {
|
||||
fn from(p: &CharacterPressure) -> Self {
|
||||
Self {
|
||||
exposure: p.exposure,
|
||||
institutional: p.institutional,
|
||||
relationship: p.relationship,
|
||||
total: p.total(),
|
||||
mood: p.pressure_mood().map(String::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Update character pressure from NPC awareness and relationship state.
|
||||
///
|
||||
/// Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute).
|
||||
///
|
||||
/// - **Exposure**: count Active NPCs with `suspicion_level > 0`, scale by
|
||||
/// `EXPOSURE_PER_SUSPICIOUS_NPC`, cap at 100
|
||||
/// - **Relationship**: count hostile trust edges (trust ≤ -3) toward the
|
||||
/// player in `RelationshipGraph`, scale by `RELATIONSHIP_PER_HOSTILE_EDGE`
|
||||
/// - **Institutional**: stub (0) in v0.1
|
||||
pub fn update_character_pressure(
|
||||
time: Res<SimulationTime>,
|
||||
awareness_query: Query<&PlayerAwareness, (With<Npc>, With<ActiveSim>)>,
|
||||
relationship_graph: Res<crate::npc::relationships::RelationshipGraph>,
|
||||
registry: Res<crate::knowledge::EntityRegistry>,
|
||||
mut player_query: Query<(Entity, &mut CharacterPressure), With<PlayerCharacter>>,
|
||||
) {
|
||||
// Only run on interval ticks
|
||||
if time.tick % PRESSURE_UPDATE_INTERVAL != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok((player_entity, mut pressure)) = player_query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// --- Exposure pressure ---
|
||||
let suspicious_count = awareness_query
|
||||
.iter()
|
||||
.filter(|a| a.suspicion_level > 0)
|
||||
.count() as i32;
|
||||
pressure.exposure = (suspicious_count * EXPOSURE_PER_SUSPICIOUS_NPC).min(100);
|
||||
|
||||
// --- Relationship pressure ---
|
||||
let player_stable = registry.to_stable(player_entity);
|
||||
if let Some(player_sid) = player_stable {
|
||||
// O(N) over all relationship edges — called once per game-minute, not every tick.
|
||||
// Acceptable at v0.1 NPC counts (<100 NPCs = <100 edge iterations).
|
||||
let hostile_edges = relationship_graph
|
||||
.who_knows_full_scan(&player_sid)
|
||||
.iter()
|
||||
.filter(|(_, edge)| edge.trust <= HOSTILE_TRUST_THRESHOLD)
|
||||
.count() as i32;
|
||||
pressure.relationship = (hostile_edges * RELATIONSHIP_PER_HOSTILE_EDGE).min(100);
|
||||
}
|
||||
|
||||
// --- Institutional pressure ---
|
||||
// Stub: v0.1 has no detective-specific interaction patterns yet.
|
||||
// Future sprints wire this to game-time progression, investigation progress,
|
||||
// and institutional NPC interactions.
|
||||
// pressure.institutional stays at whatever it was (default 0).
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::awareness::PlayerAwareness;
|
||||
use crate::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use crate::npc::{Npc, RelationshipKind};
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.init_resource::<RelationshipGraph>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
fn run_system(world: &mut World) {
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_character_pressure);
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Update interval gating
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn skips_non_interval_ticks() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 13; // not on interval
|
||||
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
PlayerAwareness {
|
||||
suspicion_level: 50,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(pressure.exposure, 0, "should not update on non-interval tick");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_on_interval_tick() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10; // on interval
|
||||
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
PlayerAwareness {
|
||||
suspicion_level: 50,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
pressure.exposure, EXPOSURE_PER_SUSPICIOUS_NPC,
|
||||
"should update on interval tick"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Exposure pressure
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn exposure_from_suspicious_npcs() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
// 3 suspicious NPCs
|
||||
for _ in 0..3 {
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
PlayerAwareness {
|
||||
suspicion_level: 10,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
// 2 non-suspicious NPCs
|
||||
for _ in 0..2 {
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
PlayerAwareness::default(),
|
||||
));
|
||||
}
|
||||
|
||||
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
pressure.exposure,
|
||||
3 * EXPOSURE_PER_SUSPICIOUS_NPC,
|
||||
"3 suspicious NPCs × {} per NPC",
|
||||
EXPOSURE_PER_SUSPICIOUS_NPC
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposure_caps_at_100() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
// 20 suspicious NPCs — would be 200, should cap at 100
|
||||
for _ in 0..20 {
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
PlayerAwareness {
|
||||
suspicion_level: 50,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(pressure.exposure, 100, "exposure should cap at 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_suspicious_npcs_zero_exposure() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
world.spawn((Npc, ActiveSim, PlayerAwareness::default()));
|
||||
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(pressure.exposure, 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Relationship pressure
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn relationship_pressure_from_hostile_edges() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, CharacterPressure::default()))
|
||||
.id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
// Two NPCs with hostile trust toward the player
|
||||
let npc1 = world.spawn(Npc).id();
|
||||
let npc1_sid = registry.register(npc1);
|
||||
let npc2 = world.spawn(Npc).id();
|
||||
let npc2_sid = registry.register(npc2);
|
||||
|
||||
let mut graph = RelationshipGraph::new();
|
||||
graph.set_relationship(
|
||||
npc1_sid,
|
||||
player_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: -5, // hostile
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
graph.set_relationship(
|
||||
npc2_sid,
|
||||
player_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: -4, // hostile
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(graph);
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
pressure.relationship,
|
||||
2 * RELATIONSHIP_PER_HOSTILE_EDGE,
|
||||
"2 hostile edges × {} per edge",
|
||||
RELATIONSHIP_PER_HOSTILE_EDGE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_trust_no_relationship_pressure() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, CharacterPressure::default()))
|
||||
.id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
let npc = world.spawn(Npc).id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut graph = RelationshipGraph::new();
|
||||
graph.set_relationship(
|
||||
npc_sid,
|
||||
player_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 0, // neutral
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(graph);
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(pressure.relationship, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_at_boundary_not_hostile() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, CharacterPressure::default()))
|
||||
.id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
let npc = world.spawn(Npc).id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut graph = RelationshipGraph::new();
|
||||
graph.set_relationship(
|
||||
npc_sid,
|
||||
player_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: -2, // above hostile threshold (-3)
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(graph);
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
pressure.relationship, 0,
|
||||
"trust -2 should not count as hostile (threshold is -3)"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Total + mood
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn total_is_average_clamped() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 60,
|
||||
institutional: 30,
|
||||
relationship: 45,
|
||||
};
|
||||
// (60 + 30 + 45) / 3 = 45
|
||||
assert_eq!(p.total(), 45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_does_not_go_below_zero() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 0,
|
||||
institutional: 0,
|
||||
relationship: 0,
|
||||
};
|
||||
assert_eq!(p.total(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_mood_anxious() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 100,
|
||||
institutional: 100,
|
||||
relationship: 100,
|
||||
};
|
||||
// total = 100, >= 50 → anxious
|
||||
assert_eq!(p.pressure_mood(), Some("anxious"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_mood_frustrated() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 50,
|
||||
institutional: 50,
|
||||
relationship: 20,
|
||||
};
|
||||
// total = (50+50+20)/3 = 40, >= 30 but < 50 → frustrated
|
||||
assert_eq!(p.pressure_mood(), Some("frustrated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_mood_none_when_low() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 10,
|
||||
institutional: 0,
|
||||
relationship: 10,
|
||||
};
|
||||
// total = (10+0+10)/3 = 6, < 30 → None
|
||||
assert_eq!(p.pressure_mood(), None);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Wire roundtrip
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn wire_roundtrip() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 30,
|
||||
institutional: 10,
|
||||
relationship: 50,
|
||||
};
|
||||
let wire = CharacterPressureWire::from(&p);
|
||||
let json = serde_json::to_string(&wire).expect("should serialize");
|
||||
let decoded: CharacterPressureWire =
|
||||
serde_json::from_str(&json).expect("should deserialize");
|
||||
assert_eq!(decoded.exposure, 30);
|
||||
assert_eq!(decoded.institutional, 10);
|
||||
assert_eq!(decoded.relationship, 50);
|
||||
assert_eq!(decoded.total, p.total());
|
||||
assert_eq!(decoded.mood, p.pressure_mood().map(String::from));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// No player entity — no panic
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn no_player_no_panic() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
run_system(&mut world); // should not panic
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constant value assertions (#248 spec compliance)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pressure_constants_have_expected_values() {
|
||||
assert_eq!(
|
||||
PRESSURE_UPDATE_INTERVAL, 10,
|
||||
"#248: pressure updates every 10 ticks (1 game-minute, D-031)"
|
||||
);
|
||||
assert_eq!(EXPOSURE_PER_SUSPICIOUS_NPC, 10);
|
||||
assert_eq!(RELATIONSHIP_PER_HOSTILE_EDGE, 15);
|
||||
assert_eq!(MOOD_ANXIOUS_THRESHOLD, 50);
|
||||
assert_eq!(MOOD_FRUSTRATED_THRESHOLD, 30);
|
||||
assert_eq!(HOSTILE_TRUST_THRESHOLD, -3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Save state data model (#256, D-010).
|
||||
//!
|
||||
//! `SaveStateV1` is the versioned serialization envelope for full game state.
|
||||
//! Shares architecture with #96 (state serialization system) — this module
|
||||
//! defines the data model only; ECS extraction and injection is #257.
|
||||
//!
|
||||
//! ## Write format: MessagePack
|
||||
//!
|
||||
//! Decision: MessagePack via `rmp_serde` (consistent with IPC protocol, D-020).
|
||||
//! Both the IPC protocol and save files use the same codec for simplicity.
|
||||
//! RON/YAML alternatives were considered — MessagePack chosen for consistency.
|
||||
//! Human-readable debug output can be derived via the Debug impl or a separate
|
||||
//! conversion step; a full RON bridge is deferred beyond v0.1.
|
||||
//!
|
||||
//! ## Versioning strategy
|
||||
//!
|
||||
//! `format_version: u8` bumps on breaking schema changes. Loader checks version
|
||||
//! and rejects incompatible saves. `serde(default)` on optional new fields allows
|
||||
//! forward-compatible extensions within the same major version.
|
||||
//!
|
||||
//! ## What is captured (v0.1 scope)
|
||||
//!
|
||||
//! - Simulation clock: `tick` + `tick_rate` for correct time reconstruction
|
||||
//! - RNG seed: reproduce the same random sequence on load (D-010)
|
||||
//! - Player knowledge graph: the observer's epistemics at save time
|
||||
//! - Global relationship graph: the NPC social web (resource, not per-entity)
|
||||
//! - Per-NPC summary state: the axis values that drive tell/mood/dialogue
|
||||
//!
|
||||
//! ## Not yet captured (deferred to #257 and beyond)
|
||||
//!
|
||||
//! - Full ECS world extraction/injection (system not yet written)
|
||||
//! - Pathfinding state (reconstructed from position + routine)
|
||||
//! - Tier transitions in-flight (dropped to background state on load)
|
||||
//! - `NpcMemory` (intentionally excluded — stale inferences would be wrong after
|
||||
//! reload; memory degrades naturally over time so reset-on-load is acceptable)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::types::StableId;
|
||||
use crate::npc::{SecretSeverity, Relationships};
|
||||
use crate::npc::relationships::RelationshipGraph;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::time::TickRate;
|
||||
|
||||
/// Current format version. Bump on any breaking schema change.
|
||||
pub const SAVE_FORMAT_VERSION: u8 = 1;
|
||||
|
||||
/// Top-level save state envelope (#256, D-010).
|
||||
///
|
||||
/// Serialized with MessagePack (rmp_serde) for storage. Load with
|
||||
/// `rmp_serde::from_slice::<SaveStateV1>(&bytes)`.
|
||||
///
|
||||
/// Versioned from day one: check `format_version == SAVE_FORMAT_VERSION`
|
||||
/// before trusting content.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SaveStateV1 {
|
||||
/// Format version. Must equal `SAVE_FORMAT_VERSION` on load.
|
||||
pub format_version: u8,
|
||||
/// Simulation tick at the moment of save.
|
||||
pub tick: u64,
|
||||
/// Active tick rate at save time (Full/Half/Paused).
|
||||
pub tick_rate: TickRate,
|
||||
/// RNG seed active at save time for deterministic replay (D-010).
|
||||
/// On load, seed the RNG from this value before advancing any ticks.
|
||||
pub seed: u64,
|
||||
/// Player character knowledge graph — the observer's epistemics at save time.
|
||||
pub player_knowledge: KnowledgeGraph,
|
||||
/// Global NPC relationship graph resource.
|
||||
/// Serialized as a unit: all directed edges between NPCs and player.
|
||||
pub relationship_graph: RelationshipGraph,
|
||||
/// Per-NPC summary state for each simulated NPC.
|
||||
/// Order is deterministic (sorted by stable_id in ascending order).
|
||||
pub npc_states: Vec<NpcSaveState>,
|
||||
}
|
||||
|
||||
/// Per-NPC state snapshot for `SaveStateV1`.
|
||||
///
|
||||
/// Captures the D-024 axis values and position. On load, the full NPC entity
|
||||
/// is reconstructed by injecting these values into the appropriate components.
|
||||
/// Field order matches the 10-axis model (D-024) for readability.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NpcSaveState {
|
||||
/// Stable entity identifier (survives serialization — D-020).
|
||||
pub stable_id: StableId,
|
||||
/// Last known tile position.
|
||||
pub position: TilePosition,
|
||||
|
||||
// Axis 2: Secret severity (description is regenerated from content on load)
|
||||
pub secret_severity: SecretSeverity,
|
||||
// Axis 3: Per-NPC relationship slots
|
||||
pub relationships: Option<Relationships>,
|
||||
// Axis 4: Tolerance — current stress level at save time
|
||||
pub current_stress: i16,
|
||||
/// Tolerance threshold value (does not change at runtime).
|
||||
pub tolerance_threshold: i16,
|
||||
// Axis 7: Contentment
|
||||
pub contentment: i16,
|
||||
|
||||
/// Per-NPC knowledge graph (if present — Active-tier NPCs carry KG).
|
||||
pub knowledge_graph: Option<KnowledgeGraph>,
|
||||
}
|
||||
|
||||
impl SaveStateV1 {
|
||||
/// Serialize to MessagePack bytes.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
|
||||
rmp_serde::to_vec_named(self)
|
||||
}
|
||||
|
||||
/// Deserialize from MessagePack bytes.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
|
||||
rmp_serde::from_slice(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::types::{FactId, FactKnowledge, KnowledgeConfidence, StableId};
|
||||
use crate::npc::relationships::RelationshipGraph;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::time::TickRate;
|
||||
|
||||
fn minimal_save_state() -> SaveStateV1 {
|
||||
SaveStateV1 {
|
||||
format_version: SAVE_FORMAT_VERSION,
|
||||
tick: 0,
|
||||
tick_rate: TickRate::Full,
|
||||
seed: 42,
|
||||
player_knowledge: KnowledgeGraph::new(),
|
||||
relationship_graph: RelationshipGraph::new(),
|
||||
npc_states: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Roundtrip tests: serialize → deserialize → re-serialize → bytes match
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn empty_save_state_roundtrips() {
|
||||
// Spec (#256): roundtrip must produce identical state.
|
||||
// Strategy: bytes(original) == bytes(roundtrip(original))
|
||||
let state = minimal_save_state();
|
||||
let bytes = state.to_bytes().expect("serialize");
|
||||
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||
let bytes2 = recovered.to_bytes().expect("re-serialize");
|
||||
assert_eq!(bytes, bytes2, "empty save state must roundtrip losslessly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_version_preserved_in_roundtrip() {
|
||||
let state = minimal_save_state();
|
||||
let bytes = state.to_bytes().expect("serialize");
|
||||
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||
assert_eq!(
|
||||
recovered.format_version, SAVE_FORMAT_VERSION,
|
||||
"format version must survive roundtrip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_and_seed_preserved_in_roundtrip() {
|
||||
let mut state = minimal_save_state();
|
||||
state.tick = 12345;
|
||||
state.seed = 0xDEADBEEF;
|
||||
state.tick_rate = TickRate::Half;
|
||||
|
||||
let bytes = state.to_bytes().expect("serialize");
|
||||
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(recovered.tick, 12345);
|
||||
assert_eq!(recovered.seed, 0xDEADBEEF);
|
||||
assert_eq!(recovered.tick_rate, TickRate::Half);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_states_roundtrip_with_position_and_axes() {
|
||||
// Spec (#256): per-NPC D-024 axis values must survive serialization.
|
||||
let mut state = minimal_save_state();
|
||||
state.npc_states = vec![
|
||||
NpcSaveState {
|
||||
stable_id: StableId(101),
|
||||
position: TilePosition::new(10, 20, 0),
|
||||
secret_severity: crate::npc::SecretSeverity::Major,
|
||||
relationships: None,
|
||||
current_stress: 45,
|
||||
tolerance_threshold: 80,
|
||||
contentment: -15,
|
||||
knowledge_graph: None,
|
||||
},
|
||||
NpcSaveState {
|
||||
stable_id: StableId(202),
|
||||
position: TilePosition::new(5, 5, 1),
|
||||
secret_severity: crate::npc::SecretSeverity::Minor,
|
||||
relationships: None,
|
||||
current_stress: 0,
|
||||
tolerance_threshold: 60,
|
||||
contentment: 30,
|
||||
knowledge_graph: None,
|
||||
},
|
||||
];
|
||||
|
||||
let bytes = state.to_bytes().expect("serialize");
|
||||
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(recovered.npc_states.len(), 2);
|
||||
|
||||
let npc1 = &recovered.npc_states[0];
|
||||
assert_eq!(npc1.stable_id, StableId(101));
|
||||
assert_eq!(npc1.position, TilePosition::new(10, 20, 0));
|
||||
assert_eq!(npc1.secret_severity, crate::npc::SecretSeverity::Major);
|
||||
assert_eq!(npc1.current_stress, 45);
|
||||
assert_eq!(npc1.tolerance_threshold, 80);
|
||||
assert_eq!(npc1.contentment, -15);
|
||||
|
||||
let npc2 = &recovered.npc_states[1];
|
||||
assert_eq!(npc2.stable_id, StableId(202));
|
||||
assert_eq!(npc2.contentment, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_knowledge_graph_roundtrips() {
|
||||
// Spec (#256): KnowledgeGraph is "already serializable" — verify it
|
||||
// survives a save state roundtrip intact.
|
||||
use crate::knowledge::types::{KnowledgeSource, KnowledgeState};
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
let mut state = minimal_save_state();
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(StableId(50), TilePosition::new(3, 3, 0), 5);
|
||||
|
||||
// Add a fact
|
||||
kg.facts.insert(
|
||||
FactId("contraband.ring_exists".into()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 100,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
);
|
||||
state.player_knowledge = kg;
|
||||
|
||||
let bytes = state.to_bytes().expect("serialize");
|
||||
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||
|
||||
// Roundtrip the recovered state again — bytes must still match
|
||||
let bytes2 = recovered.to_bytes().expect("re-serialize");
|
||||
assert_eq!(
|
||||
bytes, bytes2,
|
||||
"KnowledgeGraph roundtrip must be idempotent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationship_graph_roundtrips() {
|
||||
use crate::npc::relationships::RelationshipEdge;
|
||||
use crate::npc::RelationshipKind;
|
||||
use crate::knowledge::types::StableId;
|
||||
|
||||
let mut state = minimal_save_state();
|
||||
let mut rg = RelationshipGraph::new();
|
||||
rg.set_relationship(
|
||||
StableId(1),
|
||||
StableId(2),
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Friend,
|
||||
trust: 5,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
state.relationship_graph = rg;
|
||||
|
||||
let bytes = state.to_bytes().expect("serialize");
|
||||
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||
let bytes2 = recovered.to_bytes().expect("re-serialize");
|
||||
assert_eq!(bytes, bytes2, "RelationshipGraph roundtrip must be lossless");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_with_knowledge_graph_roundtrips() {
|
||||
// Spec (#256): Active-tier NPCs carry KnowledgeGraph — must survive roundtrip.
|
||||
let mut state = minimal_save_state();
|
||||
let mut npc_kg = KnowledgeGraph::new();
|
||||
npc_kg.observe_entity(StableId(99), TilePosition::new(7, 7, 0), 10);
|
||||
|
||||
state.npc_states = vec![NpcSaveState {
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(1, 1, 0),
|
||||
secret_severity: crate::npc::SecretSeverity::Minor,
|
||||
relationships: None,
|
||||
current_stress: 0,
|
||||
tolerance_threshold: 50,
|
||||
contentment: 0,
|
||||
knowledge_graph: Some(npc_kg),
|
||||
}];
|
||||
|
||||
let bytes = state.to_bytes().expect("serialize");
|
||||
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||
let bytes2 = recovered.to_bytes().expect("re-serialize");
|
||||
assert_eq!(
|
||||
bytes, bytes2,
|
||||
"NPC KnowledgeGraph roundtrip must be lossless"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_format_version_constant_is_one() {
|
||||
// Document the version explicitly so CI catches unintentional bumps.
|
||||
assert_eq!(SAVE_FORMAT_VERSION, 1);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,15 @@ pub trait SpatialIndex: Send + Sync {
|
||||
/// Return all entities at the exact `position`.
|
||||
fn entities_at(&self, position: &TilePosition) -> Vec<Entity>;
|
||||
|
||||
/// Return all entities within Manhattan distance `radius` of `position`,
|
||||
/// **including** entities exactly at `position`. Single-pass alternative to
|
||||
/// `entities_in_range` + `entities_at`.
|
||||
fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec<Entity> {
|
||||
let mut result = self.entities_in_range(position, radius);
|
||||
result.extend(self.entities_at(position));
|
||||
result
|
||||
}
|
||||
|
||||
/// Insert or update an entity's position in the index.
|
||||
fn update(&mut self, entity: Entity, position: TilePosition);
|
||||
|
||||
@@ -78,6 +87,19 @@ impl SpatialIndex for NaiveSpatialIndex {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec<Entity> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|(_, pos)| {
|
||||
pos == position
|
||||
|| pos
|
||||
.manhattan_distance(position)
|
||||
.is_some_and(|d| d <= radius)
|
||||
})
|
||||
.map(|(entity, _)| *entity)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn update(&mut self, entity: Entity, position: TilePosition) {
|
||||
if let Some(entry) = self.entries.iter_mut().find(|(e, _)| *e == entity) {
|
||||
entry.1 = position;
|
||||
|
||||
@@ -183,6 +183,7 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
profile,
|
||||
profile.initial_stance(),
|
||||
PlayerMoveCooldown::default(),
|
||||
crate::simulation::pressure::CharacterPressure::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
@@ -66,6 +66,8 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
examine_result: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
examine_result: None,
|
||||
|
||||
@@ -52,6 +52,8 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
examine_result: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
examine_result: None,
|
||||
|
||||
@@ -41,6 +41,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
examine_result: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
examine_result: None,
|
||||
@@ -230,6 +232,8 @@ fn generate_msgpack_fixtures() {
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
examine_result: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
examine_result: None,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"blocked_entities": [
|
||||
2
|
||||
],
|
||||
"character_pressure": null,
|
||||
"conversation_ended": [],
|
||||
"conversation_events": [],
|
||||
"current_monologue": null,
|
||||
@@ -38,6 +39,7 @@
|
||||
"z": 0
|
||||
}
|
||||
],
|
||||
"examine_result": null,
|
||||
"follow_state": null,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
|
||||
@@ -30,6 +30,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
examine_result: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
examine_result: None,
|
||||
@@ -278,6 +280,8 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
examine_result: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
examine_result: None,
|
||||
@@ -380,6 +384,8 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
examine_result: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
examine_result: None,
|
||||
@@ -1388,7 +1394,7 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
|
||||
// `tell_state`, `follow_state`, `rng_seed`, `zone_id`, `object_type`, etc.
|
||||
// are all `#[serde(default)]` — they must default to None/empty when absent.
|
||||
let minimal_json = serde_json::json!({
|
||||
"version": 13,
|
||||
"version": 14,
|
||||
"tick": 42,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
|
||||
Reference in New Issue
Block a user