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>
726 lines
23 KiB
Rust
726 lines
23 KiB
Rust
//! Integration tests for the triangle escalation system (#250).
|
|
//!
|
|
//! Covers the public API from a black-box perspective:
|
|
//! - D-087: seed-dependent tension rates produce different 30-min arc timings
|
|
//! - D-089: resolution does not cascade (only targeted triangle changes)
|
|
//! - D-026: escalation only runs on Active-tier entities
|
|
//! - D-031: escalation runs once per game-minute (every 10 ticks)
|
|
//!
|
|
//! These tests complement the lib unit tests in `src/content/template.rs`
|
|
//! with integration-level coverage using the public crate API.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use bevy_ecs::{schedule::Schedule, world::World};
|
|
use settled_reach_server::{
|
|
knowledge::{registry::EntityRegistry, types::StableId, StableEntityId},
|
|
npc::ToleranceThreshold,
|
|
simulation::triangle::{
|
|
apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand,
|
|
ResolveTriangleQueue, TemplateId, TriangleClassification, TriangleCrisisEventQueue,
|
|
TriangleDef, TriangleId, TrianglePhase, TriangleState,
|
|
},
|
|
simulation::{tier::ActiveSim, time::SimulationTime},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Minimal world with all resources required by `tick_triangle_escalation`.
|
|
fn make_escalation_world() -> World {
|
|
let mut world = World::new();
|
|
world.init_resource::<SimulationTime>();
|
|
world.init_resource::<TriangleCrisisEventQueue>();
|
|
world.init_resource::<EntityRegistry>();
|
|
world
|
|
}
|
|
|
|
/// Spawn an NPC with a known StableId and ToleranceThreshold.
|
|
fn spawn_npc_with_threshold(world: &mut World, stable_id_val: u64, threshold: i16) -> StableId {
|
|
let sid = StableId(stable_id_val);
|
|
let entity = world
|
|
.spawn((
|
|
ActiveSim,
|
|
StableEntityId(sid),
|
|
ToleranceThreshold {
|
|
current_stress: 0,
|
|
threshold,
|
|
},
|
|
))
|
|
.id();
|
|
world
|
|
.resource_mut::<EntityRegistry>()
|
|
.register_existing(entity, sid);
|
|
sid
|
|
}
|
|
|
|
/// Spawn a triangle entity with the given state (ActiveSim marker included).
|
|
fn spawn_triangle(
|
|
world: &mut World,
|
|
triangle_id: u64,
|
|
tension: u8,
|
|
tension_rate: u8,
|
|
phase: TrianglePhase,
|
|
role_assignments: BTreeMap<settled_reach_server::simulation::triangle::RoleId, StableId>,
|
|
) -> bevy_ecs::entity::Entity {
|
|
world
|
|
.spawn((
|
|
ActiveSim,
|
|
TriangleState {
|
|
triangle_id: TriangleId(triangle_id),
|
|
role_assignments,
|
|
tension,
|
|
phase,
|
|
tension_rate,
|
|
template_id: TemplateId(1),
|
|
classification: TriangleClassification::default(),
|
|
},
|
|
))
|
|
.id()
|
|
}
|
|
|
|
/// Run the escalation schedule at a specific tick.
|
|
fn run_at_tick(world: &mut World, schedule: &mut Schedule, tick: u64) {
|
|
world.resource_mut::<SimulationTime>().tick = tick;
|
|
schedule.run(world);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #250: Escalation happy path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// D-031: escalation runs once per game-minute. 10 ticks = 1 game-minute.
|
|
/// Tension should only increment on multiples of 10.
|
|
#[test]
|
|
fn escalation_only_fires_on_game_minute_boundaries() {
|
|
let mut world = make_escalation_world();
|
|
let entity = spawn_triangle(
|
|
&mut world,
|
|
1,
|
|
0,
|
|
5,
|
|
TrianglePhase::Simmering,
|
|
BTreeMap::new(),
|
|
);
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
|
|
// Ticks 1-9: not a game-minute, tension must not change.
|
|
for tick in 1..10 {
|
|
run_at_tick(&mut world, &mut schedule, tick);
|
|
}
|
|
assert_eq!(
|
|
world.get::<TriangleState>(entity).unwrap().tension,
|
|
0,
|
|
"tension must not change on sub-minute ticks"
|
|
);
|
|
|
|
// Tick 10: first game-minute, tension should increment.
|
|
run_at_tick(&mut world, &mut schedule, 10);
|
|
assert_eq!(
|
|
world.get::<TriangleState>(entity).unwrap().tension,
|
|
5,
|
|
"tension must increment at tick 10 (first game-minute)"
|
|
);
|
|
}
|
|
|
|
/// Simmering → Active transition at the expected game-minute.
|
|
///
|
|
/// Known setup:
|
|
/// - tension_rate = 5, starting tension = 0
|
|
/// - Lowest NPC threshold = 25
|
|
/// - After 5 game-minutes (50 ticks): tension = 25, not > 25 → Simmering
|
|
/// - After 6 game-minutes (60 ticks): tension = 30, 30 > 25 → Active
|
|
#[test]
|
|
fn simmering_transitions_to_active_at_expected_minute() {
|
|
let mut world = make_escalation_world();
|
|
|
|
let npc_a = spawn_npc_with_threshold(&mut world, 1, 40);
|
|
let npc_b = spawn_npc_with_threshold(&mut world, 2, 25); // lowest
|
|
let npc_c = spawn_npc_with_threshold(&mut world, 3, 60);
|
|
|
|
use settled_reach_server::simulation::triangle::RoleId;
|
|
let mut assignments = BTreeMap::new();
|
|
assignments.insert(RoleId::new("role-a"), npc_a);
|
|
assignments.insert(RoleId::new("role-b"), npc_b);
|
|
assignments.insert(RoleId::new("role-c"), npc_c);
|
|
|
|
let entity = spawn_triangle(&mut world, 42, 0, 5, TrianglePhase::Simmering, assignments);
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
|
|
// Run through 50 ticks (5 game-minutes): should remain Simmering.
|
|
for tick in 1..=50 {
|
|
run_at_tick(&mut world, &mut schedule, tick);
|
|
}
|
|
let state = world.get::<TriangleState>(entity).unwrap();
|
|
assert_eq!(
|
|
state.phase,
|
|
TrianglePhase::Simmering,
|
|
"after 5 game-minutes (tension=25), must still be Simmering (not > 25)"
|
|
);
|
|
assert_eq!(state.tension, 25);
|
|
|
|
// Run through tick 60 (6th game-minute): tension becomes 30, > 25 → Active.
|
|
for tick in 51..=60 {
|
|
run_at_tick(&mut world, &mut schedule, tick);
|
|
}
|
|
let state = world.get::<TriangleState>(entity).unwrap();
|
|
assert_eq!(
|
|
state.phase,
|
|
TrianglePhase::Active,
|
|
"at tick 60 (tension=30 > threshold=25), must transition to Active"
|
|
);
|
|
assert_eq!(state.tension, 30);
|
|
}
|
|
|
|
/// D-087: different seeds produce different escalation timings.
|
|
/// Verify that two triangles with different tension rates escalate at different times.
|
|
#[test]
|
|
fn d087_seed_dependent_escalation_timing() {
|
|
// Triangle A: slower escalation (rate 2)
|
|
// Triangle B: faster escalation (rate 8)
|
|
// Both share same NPC threshold (30).
|
|
// A triggers at: ceil(30 / 2) + 1 = 16th game-minute (tension hits 32 at minute 16)
|
|
// B triggers at: ceil(30 / 8) + 1 = 5th game-minute (tension hits 32 at minute 4)
|
|
|
|
let mut world = make_escalation_world();
|
|
|
|
let npc = spawn_npc_with_threshold(&mut world, 1, 30);
|
|
|
|
use settled_reach_server::simulation::triangle::RoleId;
|
|
let mut assignments = BTreeMap::new();
|
|
assignments.insert(RoleId::new("r"), npc);
|
|
|
|
// Spawn as separate triangles.
|
|
let slow = spawn_triangle(
|
|
&mut world,
|
|
10,
|
|
0,
|
|
2,
|
|
TrianglePhase::Simmering,
|
|
assignments.clone(),
|
|
);
|
|
let fast = spawn_triangle(&mut world, 20, 0, 8, TrianglePhase::Simmering, assignments);
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
|
|
// Run 40 game-minutes (400 ticks).
|
|
for tick in 1..=400 {
|
|
run_at_tick(&mut world, &mut schedule, tick);
|
|
}
|
|
|
|
// Both should be Active by 400 ticks.
|
|
assert_eq!(
|
|
world.get::<TriangleState>(slow).unwrap().phase,
|
|
TrianglePhase::Active
|
|
);
|
|
assert_eq!(
|
|
world.get::<TriangleState>(fast).unwrap().phase,
|
|
TrianglePhase::Active
|
|
);
|
|
|
|
// Fast triangle should have activated earlier (higher tension accumulated faster).
|
|
let fast_tension = world.get::<TriangleState>(fast).unwrap().tension;
|
|
let slow_tension = world.get::<TriangleState>(slow).unwrap().tension;
|
|
assert!(
|
|
fast_tension > slow_tension,
|
|
"fast triangle (rate=8) should have higher tension than slow (rate=2) after equal time"
|
|
);
|
|
}
|
|
|
|
/// The trigger NPC in the crisis event is the one with the lowest threshold.
|
|
#[test]
|
|
fn crisis_event_trigger_npc_is_lowest_threshold() {
|
|
let mut world = make_escalation_world();
|
|
|
|
let npc_high = spawn_npc_with_threshold(&mut world, 1, 50); // high tolerance
|
|
let npc_low = spawn_npc_with_threshold(&mut world, 2, 10); // low tolerance — trigger
|
|
|
|
use settled_reach_server::simulation::triangle::RoleId;
|
|
let mut assignments = BTreeMap::new();
|
|
assignments.insert(RoleId::new("r-high"), npc_high);
|
|
assignments.insert(RoleId::new("r-low"), npc_low);
|
|
|
|
// tension_rate = 11 so after 1 game-minute tension = 11 > 10.
|
|
spawn_triangle(&mut world, 99, 0, 11, TrianglePhase::Simmering, assignments);
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
run_at_tick(&mut world, &mut schedule, 10);
|
|
|
|
let queue = world.resource::<TriangleCrisisEventQueue>();
|
|
assert_eq!(queue.events.len(), 1, "exactly one crisis event");
|
|
assert_eq!(
|
|
queue.events[0].trigger_npc, npc_low,
|
|
"trigger NPC must be the one with the lowest threshold"
|
|
);
|
|
assert_eq!(
|
|
queue.events[0].tick, 10,
|
|
"crisis tick must match the game-minute"
|
|
);
|
|
}
|
|
|
|
/// No crisis event when tension hasn't exceeded the threshold.
|
|
#[test]
|
|
fn no_crisis_event_below_threshold() {
|
|
let mut world = make_escalation_world();
|
|
let npc = spawn_npc_with_threshold(&mut world, 1, 100); // high threshold
|
|
|
|
use settled_reach_server::simulation::triangle::RoleId;
|
|
let mut assignments = BTreeMap::new();
|
|
assignments.insert(RoleId::new("r"), npc);
|
|
|
|
spawn_triangle(&mut world, 1, 0, 5, TrianglePhase::Simmering, assignments);
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
run_at_tick(&mut world, &mut schedule, 10);
|
|
|
|
let queue = world.resource::<TriangleCrisisEventQueue>();
|
|
assert!(
|
|
queue.is_empty(),
|
|
"no crisis event when tension (5) < threshold (100)"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #250: Active phase behavior
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Active triangle continues incrementing tension (narrative tracking).
|
|
/// No additional crisis event emitted.
|
|
#[test]
|
|
fn active_triangle_continues_incrementing_no_new_event() {
|
|
let mut world = make_escalation_world();
|
|
|
|
spawn_triangle(&mut world, 1, 50, 3, TrianglePhase::Active, BTreeMap::new());
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
run_at_tick(&mut world, &mut schedule, 10);
|
|
run_at_tick(&mut world, &mut schedule, 20);
|
|
|
|
let queue = world.resource::<TriangleCrisisEventQueue>();
|
|
assert!(
|
|
queue.is_empty(),
|
|
"no crisis event for already-Active triangle"
|
|
);
|
|
}
|
|
|
|
/// Active triangle tension saturates at u8::MAX (255).
|
|
#[test]
|
|
fn active_triangle_tension_saturates_at_u8_max() {
|
|
let mut world = make_escalation_world();
|
|
spawn_triangle(
|
|
&mut world,
|
|
1,
|
|
252,
|
|
10,
|
|
TrianglePhase::Active,
|
|
BTreeMap::new(),
|
|
);
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
run_at_tick(&mut world, &mut schedule, 10);
|
|
|
|
// First call: 252 + 10 = 262, saturates to 255
|
|
let entity = world
|
|
.query::<bevy_ecs::entity::Entity>()
|
|
.iter(&world)
|
|
.next()
|
|
.unwrap();
|
|
// Can't query TriangleState after mutable borrow; check via resource
|
|
// (we verify by spawning directly and checking post-run)
|
|
let _ = entity; // entity used to ensure spawn worked
|
|
|
|
// Re-run test cleanly
|
|
let mut world2 = make_escalation_world();
|
|
let e2 = spawn_triangle(
|
|
&mut world2,
|
|
2,
|
|
254,
|
|
50,
|
|
TrianglePhase::Active,
|
|
BTreeMap::new(),
|
|
);
|
|
let mut sched2 = Schedule::default();
|
|
sched2.add_systems(tick_triangle_escalation);
|
|
run_at_tick(&mut world2, &mut sched2, 10);
|
|
|
|
let state = world2.get::<TriangleState>(e2).unwrap();
|
|
assert_eq!(state.tension, 255, "tension saturates at u8::MAX");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #250: D-026 tier boundary
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Triangles without ActiveSim marker are NOT escalated (D-026 tier boundary).
|
|
#[test]
|
|
fn d026_non_active_tier_triangle_not_escalated() {
|
|
let mut world = make_escalation_world();
|
|
world.resource_mut::<SimulationTime>().tick = 10;
|
|
|
|
// Spawn WITHOUT ActiveSim.
|
|
let entity = world
|
|
.spawn(TriangleState {
|
|
triangle_id: TriangleId(1),
|
|
role_assignments: BTreeMap::new(),
|
|
tension: 10,
|
|
phase: TrianglePhase::Simmering,
|
|
tension_rate: 5,
|
|
template_id: TemplateId(1),
|
|
classification: TriangleClassification::default(),
|
|
})
|
|
.id();
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
schedule.run(&mut world);
|
|
|
|
assert_eq!(
|
|
world.get::<TriangleState>(entity).unwrap().tension,
|
|
10,
|
|
"D-026: triangle without ActiveSim must not be escalated"
|
|
);
|
|
}
|
|
|
|
/// Dormant triangles are skipped even when in Active tier.
|
|
#[test]
|
|
fn dormant_triangle_not_escalated() {
|
|
let mut world = make_escalation_world();
|
|
let entity = spawn_triangle(
|
|
&mut world,
|
|
1,
|
|
0,
|
|
10,
|
|
TrianglePhase::Dormant,
|
|
BTreeMap::new(),
|
|
);
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
run_at_tick(&mut world, &mut schedule, 10);
|
|
|
|
assert_eq!(
|
|
world.get::<TriangleState>(entity).unwrap().tension,
|
|
0,
|
|
"Dormant triangle must not be escalated"
|
|
);
|
|
}
|
|
|
|
/// Resolved triangles are skipped (D-089: resolution is permanent).
|
|
#[test]
|
|
fn resolved_triangle_not_escalated() {
|
|
let mut world = make_escalation_world();
|
|
let entity = spawn_triangle(
|
|
&mut world,
|
|
1,
|
|
50,
|
|
5,
|
|
TrianglePhase::Resolved,
|
|
BTreeMap::new(),
|
|
);
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
run_at_tick(&mut world, &mut schedule, 10);
|
|
|
|
assert_eq!(
|
|
world.get::<TriangleState>(entity).unwrap().tension,
|
|
50,
|
|
"Resolved triangle must not be escalated (D-089)"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #250: Resolution (D-089)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// ResolveTriangleCommand sets the target triangle to Resolved.
|
|
#[test]
|
|
fn resolve_command_sets_phase_to_resolved() {
|
|
let mut world = World::new();
|
|
world.init_resource::<ResolveTriangleQueue>();
|
|
|
|
let entity = world
|
|
.spawn(TriangleState {
|
|
triangle_id: TriangleId(100),
|
|
role_assignments: BTreeMap::new(),
|
|
tension: 50,
|
|
phase: TrianglePhase::Active,
|
|
tension_rate: 3,
|
|
template_id: TemplateId(1),
|
|
classification: TriangleClassification::default(),
|
|
})
|
|
.id();
|
|
|
|
world
|
|
.resource_mut::<ResolveTriangleQueue>()
|
|
.push(ResolveTriangleCommand(TriangleId(100)));
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(apply_resolve_triangle);
|
|
schedule.run(&mut world);
|
|
|
|
let state = world.get::<TriangleState>(entity).unwrap();
|
|
assert_eq!(
|
|
state.phase,
|
|
TrianglePhase::Resolved,
|
|
"resolve command must set phase to Resolved"
|
|
);
|
|
assert_eq!(state.tension, 50, "tension must not change on resolve");
|
|
}
|
|
|
|
/// D-089: Resolution does NOT cascade to other triangles.
|
|
#[test]
|
|
fn d089_resolve_does_not_cascade() {
|
|
let mut world = World::new();
|
|
world.init_resource::<ResolveTriangleQueue>();
|
|
|
|
let target = world
|
|
.spawn(TriangleState {
|
|
triangle_id: TriangleId(100),
|
|
role_assignments: BTreeMap::new(),
|
|
tension: 50,
|
|
phase: TrianglePhase::Active,
|
|
tension_rate: 3,
|
|
template_id: TemplateId(1),
|
|
classification: TriangleClassification::default(),
|
|
})
|
|
.id();
|
|
|
|
let bystander_a = world
|
|
.spawn(TriangleState {
|
|
triangle_id: TriangleId(200),
|
|
role_assignments: BTreeMap::new(),
|
|
tension: 20,
|
|
phase: TrianglePhase::Simmering,
|
|
tension_rate: 2,
|
|
template_id: TemplateId(1),
|
|
classification: TriangleClassification::default(),
|
|
})
|
|
.id();
|
|
|
|
let bystander_b = world
|
|
.spawn(TriangleState {
|
|
triangle_id: TriangleId(300),
|
|
role_assignments: BTreeMap::new(),
|
|
tension: 80,
|
|
phase: TrianglePhase::Active,
|
|
tension_rate: 4,
|
|
template_id: TemplateId(1),
|
|
classification: TriangleClassification::default(),
|
|
})
|
|
.id();
|
|
|
|
// Resolve only triangle 100.
|
|
world
|
|
.resource_mut::<ResolveTriangleQueue>()
|
|
.push(ResolveTriangleCommand(TriangleId(100)));
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(apply_resolve_triangle);
|
|
schedule.run(&mut world);
|
|
|
|
assert_eq!(
|
|
world.get::<TriangleState>(target).unwrap().phase,
|
|
TrianglePhase::Resolved
|
|
);
|
|
assert_eq!(
|
|
world.get::<TriangleState>(bystander_a).unwrap().phase,
|
|
TrianglePhase::Simmering,
|
|
"D-089: bystander_a must remain Simmering"
|
|
);
|
|
assert_eq!(
|
|
world.get::<TriangleState>(bystander_b).unwrap().phase,
|
|
TrianglePhase::Active,
|
|
"D-089: bystander_b must remain Active"
|
|
);
|
|
}
|
|
|
|
/// Resolving the same triangle twice is idempotent.
|
|
#[test]
|
|
fn resolve_twice_is_idempotent() {
|
|
let mut world = World::new();
|
|
world.init_resource::<ResolveTriangleQueue>();
|
|
|
|
let entity = world
|
|
.spawn(TriangleState {
|
|
triangle_id: TriangleId(100),
|
|
role_assignments: BTreeMap::new(),
|
|
tension: 30,
|
|
phase: TrianglePhase::Active,
|
|
tension_rate: 1,
|
|
template_id: TemplateId(1),
|
|
classification: TriangleClassification::default(),
|
|
})
|
|
.id();
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(apply_resolve_triangle);
|
|
|
|
world
|
|
.resource_mut::<ResolveTriangleQueue>()
|
|
.push(ResolveTriangleCommand(TriangleId(100)));
|
|
schedule.run(&mut world);
|
|
world
|
|
.resource_mut::<ResolveTriangleQueue>()
|
|
.push(ResolveTriangleCommand(TriangleId(100)));
|
|
schedule.run(&mut world);
|
|
|
|
assert_eq!(
|
|
world.get::<TriangleState>(entity).unwrap().phase,
|
|
TrianglePhase::Resolved,
|
|
"double-resolve must remain Resolved"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #250: Crisis event queue behavior
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Crisis events accumulate in the queue until drained.
|
|
#[test]
|
|
fn crisis_events_accumulate_until_drained() {
|
|
let mut world = make_escalation_world();
|
|
|
|
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
|
|
|
|
use settled_reach_server::simulation::triangle::RoleId;
|
|
let mut assignments = BTreeMap::new();
|
|
assignments.insert(RoleId::new("r"), npc);
|
|
|
|
// Two triangles that will both escalate.
|
|
spawn_triangle(
|
|
&mut world,
|
|
10,
|
|
0,
|
|
6,
|
|
TrianglePhase::Simmering,
|
|
assignments.clone(),
|
|
);
|
|
spawn_triangle(&mut world, 20, 0, 6, TrianglePhase::Simmering, assignments);
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
run_at_tick(&mut world, &mut schedule, 10);
|
|
|
|
let queue = world.resource::<TriangleCrisisEventQueue>();
|
|
assert_eq!(
|
|
queue.events.len(),
|
|
2,
|
|
"both triangles should emit crisis events in the same game-minute"
|
|
);
|
|
}
|
|
|
|
/// `TriangleCrisisEventQueue::drain` clears the queue.
|
|
#[test]
|
|
fn crisis_queue_drain_clears_events() {
|
|
let mut world = make_escalation_world();
|
|
|
|
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
|
|
|
|
use settled_reach_server::simulation::triangle::RoleId;
|
|
let mut assignments = BTreeMap::new();
|
|
assignments.insert(RoleId::new("r"), npc);
|
|
|
|
spawn_triangle(&mut world, 1, 0, 10, TrianglePhase::Simmering, assignments);
|
|
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(tick_triangle_escalation);
|
|
run_at_tick(&mut world, &mut schedule, 10);
|
|
|
|
// Drain the queue.
|
|
let drained = world.resource_mut::<TriangleCrisisEventQueue>().drain();
|
|
assert_eq!(drained.len(), 1, "drain should return the 1 event");
|
|
assert!(
|
|
world.resource::<TriangleCrisisEventQueue>().is_empty(),
|
|
"queue must be empty after drain"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #250: YAML triangle def → escalation pipeline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// End-to-end: TriangleDef from YAML can describe all 5 v0.1 triangles
|
|
/// (D-087) and those defs produce escalatable TriangleState instances.
|
|
#[test]
|
|
fn d087_all_v01_conflict_types_produce_escalatable_states() {
|
|
use settled_reach_server::simulation::triangle::{ConflictType, NpcAxis, RoleId};
|
|
|
|
let defs = [
|
|
(
|
|
"kael-davan",
|
|
"smuggler",
|
|
"ring-contact",
|
|
ConflictType::ResourceCompetition,
|
|
),
|
|
(
|
|
"sera-venn",
|
|
"detective",
|
|
"commission-inspector",
|
|
ConflictType::SecretExposure,
|
|
),
|
|
("naia", "kael-davan", "hael", ConflictType::LatentTension),
|
|
(
|
|
"drin",
|
|
"ring-system",
|
|
"dock-supervisor",
|
|
ConflictType::ResourceCompetition,
|
|
),
|
|
(
|
|
"worried-partner",
|
|
"ring-member",
|
|
"neighbor",
|
|
ConflictType::LatentTension,
|
|
),
|
|
];
|
|
|
|
for (r0, r1, r2, conflict) in &defs {
|
|
let roles = [RoleId::new(r0), RoleId::new(r1), RoleId::new(r2)];
|
|
let tid =
|
|
settled_reach_server::simulation::triangle::TriangleId::from_seed_and_roles(42, &roles);
|
|
let def = TriangleDef {
|
|
triangle_id: tid,
|
|
roles: roles.clone(),
|
|
conflict_type: *conflict,
|
|
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
|
|
relationship_constraints: vec![],
|
|
};
|
|
assert!(
|
|
def.validate().is_ok(),
|
|
"D-087 triangle must be valid: {:?}",
|
|
def.validate()
|
|
);
|
|
|
|
// Can construct a TriangleState from the def.
|
|
let mut assignments = BTreeMap::new();
|
|
for role in &roles {
|
|
assignments.insert(role.clone(), StableId(0));
|
|
}
|
|
let state = TriangleState {
|
|
triangle_id: tid,
|
|
role_assignments: assignments,
|
|
tension: 0,
|
|
phase: TrianglePhase::Simmering,
|
|
tension_rate: 3,
|
|
template_id: TemplateId(1),
|
|
classification: TriangleClassification::default(),
|
|
};
|
|
assert_eq!(
|
|
state.phase,
|
|
TrianglePhase::Simmering,
|
|
"{:?} triangle must start Simmering",
|
|
conflict
|
|
);
|
|
}
|
|
}
|