Merge remote-tracking branch 'origin/server'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-02-28 23:55:42 +01:00
25 changed files with 2618 additions and 25 deletions
+154
View File
@@ -0,0 +1,154 @@
//! Integration test: contamination activation mechanic (#254).
//!
//! Verifies that the storyteller activates contamination after the configured
//! delay and that all ActiveFork triangles receive pressure.
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::Schedule;
use std::collections::BTreeMap;
use settled_reach_server::content::template::{
RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState,
};
use settled_reach_server::knowledge::types::StableId;
use settled_reach_server::simulation::tier::ActiveSim;
use settled_reach_server::simulation::time::SimulationTime;
use settled_reach_server::storyteller::{
tick_contamination_activation, ContaminationActive, ContaminationEventQueue,
CONTAMINATION_DELAY_TICKS, CONTAMINATION_PRESSURE_DELTA,
};
fn make_triangle(
slug: &str,
classification: TriangleClassification,
base_ids: [u64; 3],
) -> TriangleState {
let mut role_assignments = BTreeMap::new();
role_assignments.insert(RoleId::new("role-a"), StableId(base_ids[0]));
role_assignments.insert(RoleId::new("role-b"), StableId(base_ids[1]));
role_assignments.insert(RoleId::new("role-c"), StableId(base_ids[2]));
TriangleState {
triangle_id: TriangleId::from_seed_and_slug(0, slug),
role_assignments,
tension: 0,
phase: match classification {
TriangleClassification::ActiveFork => TrianglePhase::Simmering,
TriangleClassification::PassiveTension => TrianglePhase::Dormant,
},
tension_rate: 1,
template_id: TemplateId::from_seed_and_slug(0, "authored"),
classification,
}
}
/// Acceptance test: run past CONTAMINATION_DELAY_TICKS, assert ContaminationActive
/// is set and all ActiveFork TriangleState entities have tension > 0.
#[test]
fn contamination_activates_after_delay() {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.init_resource::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
// Spawn 3 ActiveFork + 2 PassiveTension triangles (matching real content)
let fork1 = world
.spawn((
make_triangle("hub-power", TriangleClassification::ActiveFork, [1, 2, 3]),
ActiveSim,
))
.id();
let fork2 = world
.spawn((
make_triangle(
"bar-tensions",
TriangleClassification::ActiveFork,
[4, 5, 6],
),
ActiveSim,
))
.id();
let fork3 = world
.spawn((
make_triangle(
"informant-question",
TriangleClassification::ActiveFork,
[7, 8, 9],
),
ActiveSim,
))
.id();
let passive1 = world
.spawn((
make_triangle(
"worried-knowledge",
TriangleClassification::PassiveTension,
[10, 11, 12],
),
ActiveSim,
))
.id();
let passive2 = world
.spawn((
make_triangle(
"worried-partner",
TriangleClassification::PassiveTension,
[13, 14, 15],
),
ActiveSim,
))
.id();
let mut schedule = Schedule::default();
schedule.add_systems(tick_contamination_activation);
// Simulate past the delay threshold (CONTAMINATION_DELAY_TICKS = 300)
for tick in 0..=CONTAMINATION_DELAY_TICKS {
world.resource_mut::<SimulationTime>().tick = tick;
schedule.run(&mut world);
}
// Assert ContaminationActive is set
assert!(
world.resource::<ContaminationActive>().0,
"ContaminationActive must be true after CONTAMINATION_DELAY_TICKS ({}) ticks",
CONTAMINATION_DELAY_TICKS
);
// Assert all ActiveFork triangles have tension > 0
for (label, entity) in [("hub-power", fork1), ("bar-tensions", fork2), ("informant-question", fork3)] {
let state = world
.get::<TriangleState>(entity)
.unwrap_or_else(|| panic!("{} triangle entity should exist", label));
assert!(
state.tension > 0,
"ActiveFork triangle '{}' should have tension > 0 after contamination, got {}",
label,
state.tension
);
assert_eq!(
state.tension, CONTAMINATION_PRESSURE_DELTA,
"ActiveFork triangle '{}' tension should be exactly {} (contamination delta)",
label,
CONTAMINATION_PRESSURE_DELTA
);
}
// Assert PassiveTension triangles were NOT pressured
for (label, entity) in [("worried-knowledge", passive1), ("worried-partner", passive2)] {
let state = world
.get::<TriangleState>(entity)
.unwrap_or_else(|| panic!("{} triangle entity should exist", label));
assert_eq!(
state.tension, 0,
"PassiveTension triangle '{}' should have tension 0 (not affected by contamination)",
label
);
}
// Assert exactly one ContaminationEvent was emitted
let events = world.resource_mut::<ContaminationEventQueue>().drain();
assert_eq!(events.len(), 1, "exactly one ContaminationEvent expected");
assert_eq!(events[0].tick, CONTAMINATION_DELAY_TICKS);
assert_eq!(events[0].triangles_affected, 3);
}
+192
View File
@@ -421,5 +421,197 @@ fn spawn_real_content_with_relationships_and_secrets() {
assert_eq!(nils_want.primary, npc::WantKind::Power);
}
// -----------------------------------------------------------------------
// Test: EntanglementTag assignment from authored content (#176, D-029)
// -----------------------------------------------------------------------
#[test]
fn entanglement_tags_assigned_from_triangle_membership() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
let store = load_content(&root).expect("content loading should succeed");
let result = spawn_content(&mut world, &store);
// Acceptance: 17+ NPCs spawn (we have 23 authored profiles)
assert!(
result.npcs_spawned >= 17,
"Expected 17+ NPCs, got {}",
result.npcs_spawned
);
let registry = world.resource::<EntityRegistry>();
// Every NPC must have an EntanglementTag
let mut intrigue_count = 0u32;
let mut flat_count = 0u32;
for (canonical_id, stable_id) in &result.npc_ids {
let entity = registry
.to_entity(stable_id)
.unwrap_or_else(|| panic!("{} should have an entity", canonical_id));
let tag = world
.get::<npc::EntanglementTag>(entity)
.unwrap_or_else(|| panic!("{} must have EntanglementTag", canonical_id));
match tag {
npc::EntanglementTag::Intrigue => intrigue_count += 1,
npc::EntanglementTag::Flat => flat_count += 1,
npc::EntanglementTag::Mundane => {} // reserved for procedural NPCs
}
}
// Acceptance: at least one EntanglementTag::Intrigue entity
assert!(
intrigue_count >= 1,
"At least one NPC must be EntanglementTag::Intrigue, got 0"
);
// Stronger assertion: we know 13 authored NPCs have non-empty triangle_membership
assert!(
intrigue_count >= 10,
"Expected 10+ Intrigue NPCs (authored triangle members), got {}",
intrigue_count
);
// Some NPCs should be Flat (no triangle membership)
assert!(
flat_count >= 1,
"At least one NPC should be EntanglementTag::Flat, got 0"
);
// Spot-check: Kael (triangle member) must be Intrigue
let kael_entity = registry
.to_entity(&result.npc_ids["npc:kael-davan"])
.unwrap();
assert_eq!(
*world.get::<npc::EntanglementTag>(kael_entity).unwrap(),
npc::EntanglementTag::Intrigue,
"Kael (triangle member) must be Intrigue"
);
// Spot-check: Devra (empty triangle_membership) must be Flat
let devra_entity = registry
.to_entity(&result.npc_ids["npc:devra"])
.unwrap();
assert_eq!(
*world.get::<npc::EntanglementTag>(devra_entity).unwrap(),
npc::EntanglementTag::Flat,
"Devra (no triangle membership) must be Flat"
);
}
// -----------------------------------------------------------------------
// Test: Authored triangle instantiation (#188, D-087)
// -----------------------------------------------------------------------
#[test]
fn authored_triangles_instantiated_from_content() {
use settled_reach_server::content::template::{TriangleClassification, TriangleState};
use settled_reach_server::simulation::tier::ActiveSim;
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
let store = load_content(&root).expect("content loading should succeed");
let result = spawn_content(&mut world, &store);
// Query all TriangleState entities (clone to release world borrow)
let triangles: Vec<TriangleState> = {
let mut q = world.query::<&TriangleState>();
q.iter(&world).cloned().collect()
};
// Acceptance: exactly 5 authored triangles
assert_eq!(
triangles.len(),
5,
"Expected 5 authored triangles, got {}",
triangles.len()
);
// Count by classification (D-087)
let active_count = triangles
.iter()
.filter(|t| t.classification == TriangleClassification::ActiveFork)
.count();
let passive_count = triangles
.iter()
.filter(|t| t.classification == TriangleClassification::PassiveTension)
.count();
assert_eq!(
active_count, 3,
"Expected 3 ActiveFork triangles, got {}",
active_count
);
assert_eq!(
passive_count, 2,
"Expected 2 PassiveTension triangles, got {}",
passive_count
);
// All 5 must have exactly 3 role assignments (triangle = 3 NPCs)
for triangle in &triangles {
assert_eq!(
triangle.role_assignments.len(),
3,
"Triangle {:?} should have 3 role assignments, got {}",
triangle.triangle_id,
triangle.role_assignments.len()
);
}
// All role assignments must point to valid NPC entities in the registry
let registry = world.resource::<EntityRegistry>();
for triangle in &triangles {
for (role, stable_id) in &triangle.role_assignments {
assert!(
registry.to_entity(stable_id).is_some(),
"Triangle {:?} role '{}' points to StableId {:?} with no entity",
triangle.triangle_id,
role.0,
stable_id,
);
}
}
// All triangle entities must have ActiveSim marker
let mut active_query = world.query::<(&TriangleState, &ActiveSim)>();
let active_triangles: Vec<_> = active_query.iter(&world).collect();
assert_eq!(
active_triangles.len(),
5,
"All 5 triangles must have ActiveSim marker"
);
// Verify StableIds in role assignments correspond to spawned NPC canonical_ids
let all_npc_stable_ids: std::collections::BTreeSet<_> =
result.npc_ids.values().copied().collect();
for triangle in &triangles {
for (role, stable_id) in &triangle.role_assignments {
assert!(
all_npc_stable_ids.contains(stable_id),
"Triangle {:?} role '{}' StableId {:?} not in spawned NPC set",
triangle.triangle_id,
role.0,
stable_id,
);
}
}
}
// Runtime validation test (boot + tick 10 + snapshot) moved to
// server/tests/content_runtime.rs per architectural review.
@@ -418,6 +418,8 @@ fn minimal_save() -> SaveStateV1 {
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
open_doors: vec![],
modifications: vec![],
contamination_active: false,
}
}
+348
View File
@@ -0,0 +1,348 @@
//! Fuzzy tests for procedurally generated maps (QA #509, QA epic #455).
//!
//! Tests structural invariants across 50 random seeds. Each seed produces
//! a unique map; all 4 invariants must hold for every seed.
//!
//! Invariants tested:
//! 1. Connectivity — all walkable tiles reachable from player start (BFS)
//! 2. Entity bounds — all entity positions within map bounds
//! 3. Door adjacency — every door has walkable tiles on both sides
//! 4. Tile count — walkable tile count meets minimum floor (MIN_WALKABLE_TILES)
//!
//! Spec reference: D-010 (deterministic simulation, SimRng seeding), D-030 (testability)
use rand::Rng;
use settled_reach_server::simulation::movement::{TilePosition, WalkabilityMap};
use settled_reach_server::simulation::rng::SimRng;
use std::collections::VecDeque;
// ── Map generation constants ────────────────────────────────────────────────
const MAP_W: i32 = 50;
const MAP_H: i32 = 50;
const NUM_ROOMS: usize = 5;
/// Minimum walkable tiles the generator must produce per map.
///
/// With 5 rooms of 5–10 tiles each plus corridors, a well-generated map
/// comfortably exceeds this floor. A count below this flags a degenerate
/// layout (e.g., all room-placement attempts rejected on a seed).
/// Value chosen as ~2× the guaranteed-minimum fallback room (6×6 = 36 tiles).
const MIN_WALKABLE_TILES: usize = 200;
// ── Data types ────────────────────────────────────────────────────────────────
struct Room {
x: i32,
y: i32,
w: i32,
h: i32,
}
/// A door placement with its two walkable neighbours (one on each side).
struct DoorPlacement {
pos: TilePosition,
side_a: TilePosition,
side_b: TilePosition,
}
/// A fully generated procedural map ready for invariant checks.
struct ProceduralMap {
walkability: WalkabilityMap,
/// Player start position — guaranteed walkable.
player_start: TilePosition,
/// All entity positions: player start + door positions.
entities: Vec<TilePosition>,
/// All door placements with neighbour tiles pre-computed.
doors: Vec<DoorPlacement>,
/// Number of walkable tiles (pre-counted for Invariant 4).
walkable_count: usize,
}
// ── Map generator ─────────────────────────────────────────────────────────────
fn generate_map(seed: u64) -> ProceduralMap {
let mut rng = SimRng::new(seed).rng;
let mut wm = WalkabilityMap::new_blocked(MAP_W, MAP_H, 1);
let mut rooms: Vec<Room> = Vec::new();
let mut door_placements: Vec<DoorPlacement> = Vec::new();
// ── Room placement (up to 200 attempts for NUM_ROOMS non-overlapping rooms) ──
for _ in 0..200 {
if rooms.len() >= NUM_ROOMS {
break;
}
let w: i32 = rng.random_range(5_i32..=10);
let h: i32 = rng.random_range(5_i32..=10);
// Keep 2-tile margin from map edges.
let x: i32 = rng.random_range(2_i32..(MAP_W - w - 2));
let y: i32 = rng.random_range(2_i32..(MAP_H - h - 2));
// Reject if overlaps an existing room (1-tile padding).
let overlaps = rooms.iter().any(|r| {
x < r.x + r.w + 1 && x + w + 1 > r.x && y < r.y + r.h + 1 && y + h + 1 > r.y
});
if !overlaps {
for ry in y..(y + h) {
for rx in x..(x + w) {
wm.set_walkable(&TilePosition::new(rx, ry, 0), true);
}
}
rooms.push(Room { x, y, w, h });
}
}
// Guarantee at least one room to ensure a valid player start.
if rooms.is_empty() {
let x = 5;
let y = 5;
for ry in y..(y + 6) {
for rx in x..(x + 6) {
wm.set_walkable(&TilePosition::new(rx, ry, 0), true);
}
}
rooms.push(Room { x, y, w: 6, h: 6 });
}
// ── Connect consecutive rooms with L-shaped corridors ─────────────────────
for i in 1..rooms.len() {
let prev_cx = rooms[i - 1].x + rooms[i - 1].w / 2;
let prev_cy = rooms[i - 1].y + rooms[i - 1].h / 2;
let curr_cx = rooms[i].x + rooms[i].w / 2;
let curr_cy = rooms[i].y + rooms[i].h / 2;
// Horizontal segment at prev_cy, from prev_cx to curr_cx.
let x_min = prev_cx.min(curr_cx);
let x_max = prev_cx.max(curr_cx);
for x in x_min..=x_max {
wm.set_walkable(&TilePosition::new(x, prev_cy, 0), true);
}
// Vertical segment at curr_cx, from prev_cy to curr_cy.
let y_min = prev_cy.min(curr_cy);
let y_max = prev_cy.max(curr_cy);
for y in y_min..=y_max {
wm.set_walkable(&TilePosition::new(curr_cx, y, 0), true);
}
// Place a door at the elbow (curr_cx, prev_cy).
// Neighbours: (curr_cx-1, prev_cy) and (curr_cx+1, prev_cy).
if curr_cx > 0 && curr_cx < MAP_W - 1 {
let door_pos = TilePosition::new(curr_cx, prev_cy, 0);
let side_a = TilePosition::new(curr_cx - 1, prev_cy, 0);
let side_b = TilePosition::new(curr_cx + 1, prev_cy, 0);
if wm.can_move_to(&door_pos) && wm.can_move_to(&side_a) && wm.can_move_to(&side_b) {
door_placements.push(DoorPlacement {
pos: door_pos,
side_a,
side_b,
});
}
}
}
// ── Count walkable tiles ───────────────────────────────────────────────────
let mut walkable_count = 0;
for y in 0..MAP_H {
for x in 0..MAP_W {
if wm.can_move_to(&TilePosition::new(x, y, 0)) {
walkable_count += 1;
}
}
}
// Player start: centre of first room (always walkable by construction).
let player_start = TilePosition::new(
rooms[0].x + rooms[0].w / 2,
rooms[0].y + rooms[0].h / 2,
0,
);
let mut entities = vec![player_start];
entities.extend(door_placements.iter().map(|d| d.pos));
ProceduralMap {
walkability: wm,
player_start,
entities,
doors: door_placements,
walkable_count,
}
}
// ── Invariant checks ──────────────────────────────────────────────────────────
/// Invariant 1: all walkable tiles reachable from player start via BFS.
fn check_connectivity(map: &ProceduralMap, seed: u64) -> Result<(), String> {
if !map.walkability.can_move_to(&map.player_start) {
return Err(format!(
"[seed {seed}] Player start {:?} is not walkable",
map.player_start
));
}
let mut visited = std::collections::BTreeSet::new();
let mut queue = VecDeque::new();
queue.push_back(map.player_start);
visited.insert(map.player_start);
while let Some(pos) = queue.pop_front() {
for neighbor in pos.cardinal_neighbors() {
if neighbor.x >= 0
&& neighbor.x < MAP_W
&& neighbor.y >= 0
&& neighbor.y < MAP_H
&& map.walkability.can_move_to(&neighbor)
&& !visited.contains(&neighbor)
{
visited.insert(neighbor);
queue.push_back(neighbor);
}
}
}
if visited.len() != map.walkable_count {
Err(format!(
"[seed {seed}] Connectivity: {} walkable tiles but only {} reachable from {:?}",
map.walkable_count,
visited.len(),
map.player_start
))
} else {
Ok(())
}
}
/// Invariant 2: every entity position is within map bounds.
fn check_entity_bounds(map: &ProceduralMap, seed: u64) -> Result<(), String> {
for pos in &map.entities {
if pos.x < 0 || pos.x >= MAP_W || pos.y < 0 || pos.y >= MAP_H {
return Err(format!(
"[seed {seed}] Entity at {:?} is outside map bounds ({MAP_W}×{MAP_H})",
pos
));
}
}
Ok(())
}
/// Invariant 3: every door has at least one walkable tile on each side.
fn check_door_adjacency(map: &ProceduralMap, seed: u64) -> Result<(), String> {
for door in &map.doors {
if !map.walkability.can_move_to(&door.side_a) {
return Err(format!(
"[seed {seed}] Door at {:?}: side_a {:?} is not walkable",
door.pos, door.side_a
));
}
if !map.walkability.can_move_to(&door.side_b) {
return Err(format!(
"[seed {seed}] Door at {:?}: side_b {:?} is not walkable",
door.pos, door.side_b
));
}
}
Ok(())
}
/// Invariant 4: walkable tile count meets the minimum floor.
///
/// Catches degenerate maps where the generator failed to carve usable space.
/// BSP room carvers don't have a fixed density target — they have natural
/// variance from room sizes and corridor lengths. A minimum floor is the
/// correct invariant for this generator type.
fn check_tile_count(map: &ProceduralMap, seed: u64) -> Result<(), String> {
if map.walkable_count < MIN_WALKABLE_TILES {
Err(format!(
"[seed {seed}] Tile count {}: expected at least {MIN_WALKABLE_TILES} walkable tiles \
(map too sparse — generator may have failed to place rooms)",
map.walkable_count,
))
} else {
Ok(())
}
}
// ── Main fuzzy test ───────────────────────────────────────────────────────────
/// Runs all 4 structural invariants across 50 deterministic seeds.
///
/// Each seed produces a unique procedurally generated map. All 4 invariants
/// must hold for every seed.
///
/// Acceptance: `cargo test -p server -- fuzzy_map_50_seeds_all_invariants`
#[test]
fn fuzzy_map_50_seeds_all_invariants() {
let mut failures: Vec<String> = Vec::new();
let mut seeds_ok = 0u32;
for seed in 0..50u64 {
let map = generate_map(seed);
let mut seed_failures: Vec<String> = Vec::new();
if let Err(e) = check_connectivity(&map, seed) {
seed_failures.push(e);
}
if let Err(e) = check_entity_bounds(&map, seed) {
seed_failures.push(e);
}
if let Err(e) = check_door_adjacency(&map, seed) {
seed_failures.push(e);
}
if let Err(e) = check_tile_count(&map, seed) {
seed_failures.push(e);
}
if seed_failures.is_empty() {
seeds_ok += 1;
} else {
failures.extend(seed_failures);
}
}
assert!(
failures.is_empty(),
"{} seed(s) passed, {} invariant violation(s):\n{}",
seeds_ok,
failures.len(),
failures.join("\n")
);
}
/// Individual connectivity test — all walkable tiles reachable from player start.
#[test]
fn fuzzy_map_connectivity_holds_across_seeds() {
for seed in 0..50u64 {
let map = generate_map(seed);
check_connectivity(&map, seed).unwrap_or_else(|e| panic!("{}", e));
}
}
/// Individual bounds test — all entity positions within map bounds.
#[test]
fn fuzzy_map_entity_bounds_respected_across_seeds() {
for seed in 0..50u64 {
let map = generate_map(seed);
check_entity_bounds(&map, seed).unwrap_or_else(|e| panic!("{}", e));
}
}
/// Individual door adjacency test — walkable tiles on both sides of every door.
#[test]
fn fuzzy_map_door_adjacency_holds_across_seeds() {
for seed in 0..50u64 {
let map = generate_map(seed);
check_door_adjacency(&map, seed).unwrap_or_else(|e| panic!("{}", e));
}
}
/// Individual tile count test — walkable count meets minimum floor across all seeds.
#[test]
fn fuzzy_map_tile_count_meets_minimum_across_seeds() {
for seed in 0..50u64 {
let map = generate_map(seed);
check_tile_count(&map, seed).unwrap_or_else(|e| panic!("{}", e));
}
}
+2
View File
@@ -206,6 +206,8 @@ fn save_state_npc_kg_isolation() {
template_references: Default::default(),
triangle_states: vec![],
open_doors: vec![],
modifications: vec![],
contamination_active: false,
};
// Roundtrip: serialize → deserialize.
+10 -2
View File
@@ -15,8 +15,8 @@ use bevy_ecs::{schedule::Schedule, world::World};
use settled_reach_server::{
content::template::{
apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand,
ResolveTriangleQueue, TemplateId, TriangleCrisisEventQueue, TriangleDef, TriangleId,
TrianglePhase, TriangleState,
ResolveTriangleQueue, TemplateId, TriangleClassification, TriangleCrisisEventQueue,
TriangleDef, TriangleId, TrianglePhase, TriangleState,
},
knowledge::{registry::EntityRegistry, types::StableId, StableEntityId},
npc::ToleranceThreshold,
@@ -68,6 +68,7 @@ fn spawn_triangle(
phase,
tension_rate,
template_id: TemplateId(1),
classification: TriangleClassification::default(),
},
))
.id()
@@ -329,6 +330,7 @@ fn d026_non_active_tier_triangle_not_escalated() {
phase: TrianglePhase::Simmering,
tension_rate: 5,
template_id: TemplateId(1),
classification: TriangleClassification::default(),
})
.id();
@@ -395,6 +397,7 @@ fn resolve_command_sets_phase_to_resolved() {
phase: TrianglePhase::Active,
tension_rate: 3,
template_id: TemplateId(1),
classification: TriangleClassification::default(),
})
.id();
@@ -423,6 +426,7 @@ fn d089_resolve_does_not_cascade() {
phase: TrianglePhase::Active,
tension_rate: 3,
template_id: TemplateId(1),
classification: TriangleClassification::default(),
})
.id();
@@ -434,6 +438,7 @@ fn d089_resolve_does_not_cascade() {
phase: TrianglePhase::Simmering,
tension_rate: 2,
template_id: TemplateId(1),
classification: TriangleClassification::default(),
})
.id();
@@ -445,6 +450,7 @@ fn d089_resolve_does_not_cascade() {
phase: TrianglePhase::Active,
tension_rate: 4,
template_id: TemplateId(1),
classification: TriangleClassification::default(),
})
.id();
@@ -485,6 +491,7 @@ fn resolve_twice_is_idempotent() {
phase: TrianglePhase::Active,
tension_rate: 1,
template_id: TemplateId(1),
classification: TriangleClassification::default(),
})
.id();
@@ -602,6 +609,7 @@ fn d087_all_v01_conflict_types_produce_escalatable_states() {
phase: TrianglePhase::Simmering,
tension_rate: 3,
template_id: TemplateId(1),
classification: TriangleClassification::default(),
};
assert_eq!(
state.phase,