Merge remote-tracking branch 'origin/server'
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user