Fix all Clippy warnings across the server codebase (2411 insertions, 1341 deletions). Raise type-complexity-threshold to 750 and too-many-arguments to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server now passes `cargo clippy -- --deny warnings` cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
775 lines
23 KiB
Rust
775 lines
23 KiB
Rust
//! 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::mood::{MoodState, NpcMood};
|
|
use crate::npc::routine::ActivityState;
|
|
use crate::npc::{
|
|
Contentment, DailyRoutine, JobPerformance, Npc, Relationships, ToleranceThreshold,
|
|
};
|
|
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.is_multiple_of(TICKS_PER_GAME_MINUTE) {
|
|
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::knowledge::types::StableId;
|
|
use crate::npc::mood::{MoodState, NpcMood};
|
|
use crate::npc::routine::ActivityState;
|
|
use crate::npc::{
|
|
Contentment, DailyRoutine, JobPerformance, Npc, Relationship, RelationshipKind,
|
|
Relationships, RoutineEntry, ToleranceThreshold,
|
|
};
|
|
use crate::simulation::movement::TilePosition;
|
|
use crate::simulation::tier::{ActiveSim, BackgroundSim};
|
|
use crate::simulation::time::{DayPhase, SimulationTime, TICKS_PER_GAME_MINUTE};
|
|
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
|
|
);
|
|
}
|
|
}
|