test(simulation): map-agnostic invariant tests, 36 invariants (#508)
Adds test_world::invariants with 29 world-query invariants (structural, perception, population, simulation) and 7 system-execution tests. All invariants are gated behind the gauntlet feature and run against the fully-initialized gauntlet world. Documents StableId ranges through Sprint 14 rooms. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
-1
@@ -1092,7 +1092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
@@ -0,0 +1,971 @@
|
||||
//! Map-agnostic invariant tests — ticket #508.
|
||||
//!
|
||||
//! 36 invariants across 4 categories that must hold for ANY valid gauntlet map:
|
||||
//! - Structural (8): tile counts, wall connectivity, spawn point validity
|
||||
//! - Perception (5): LOS symmetry, sound range boundaries
|
||||
//! - Population (8): NPC count limits, tier assignment correctness
|
||||
//! - Simulation (8): no entity at blocked tile, determinism, pathfinder
|
||||
//! termination, interaction buffer cleared on sprint
|
||||
//!
|
||||
//! The `run_invariants(world: &mut World)` function covers the 29 structural,
|
||||
//! perception, population, and simulation invariants checkable via pure
|
||||
//! world queries. Additional `#[test]` functions exercise the 7 invariants
|
||||
//! that require system execution (pathfinding, movement, interaction systems).
|
||||
//!
|
||||
//! Spec references: D-010 (determinism), D-018 (sound ranges), D-026 (tiers),
|
||||
//! D-030 (testability), D-035 (symmetric shadowcasting), D-054 (tile movement),
|
||||
//! D-055 (sprint suppresses interaction buffer).
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::knowledge::types::SoundRange;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::npc::{Npc, Want};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::perception::shadowcast::compute_fov;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::monologue::MonologueBuffer;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::pathfinding::{ComputedPath, PathBlocked};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::sound::SoundEvent;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::tier::{ActiveSim, BackgroundSim, StateSaved};
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use super::constants::{CROWD_PLAZA, EXPECTED_ENTITY_COUNT, ROOMS};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use super::{MAP_HEIGHT, MAP_WIDTH};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Assert that all 29 world-query invariants hold for the given world.
|
||||
///
|
||||
/// Call this after gauntlet room setup to confirm structural, perception,
|
||||
/// population, and simulation properties are satisfied.
|
||||
///
|
||||
/// Panics with a descriptive message if any invariant is violated.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub fn run_invariants(world: &mut World) {
|
||||
// --- Structural (8) ---
|
||||
inv_s1_walkable_tiles_exist(world);
|
||||
inv_s2_walkable_count_in_map_bounds(world);
|
||||
inv_s3_player_spawn_walkable(world);
|
||||
inv_s4_room_spawns_walkable(world);
|
||||
inv_s5_room_observers_walkable(world);
|
||||
inv_s6_rooms_non_overlapping(world);
|
||||
inv_s7_room_interiors_have_walkable_tiles(world);
|
||||
inv_s8_hub_center_walkable(world);
|
||||
|
||||
// --- Perception (5) ---
|
||||
inv_p1_close_sound_range_spec(world);
|
||||
inv_p2_medium_sound_range_spec(world);
|
||||
inv_p3_long_sound_range_spec(world);
|
||||
inv_p4_los_symmetry_at_hub(world);
|
||||
inv_p5_los_range_bounded(world);
|
||||
|
||||
// --- Population (8) ---
|
||||
inv_pop1_active_npc_count_within_limit(world);
|
||||
inv_pop2_all_npcs_have_tile_position(world);
|
||||
inv_pop3_all_npcs_have_exactly_one_tier(world);
|
||||
inv_pop4_no_same_layer_collision(world);
|
||||
inv_pop5_entity_count_matches_expected(world);
|
||||
inv_pop6_all_npcs_have_want(world);
|
||||
inv_pop7_crowd_plaza_npc_count(world);
|
||||
inv_pop8_stable_ids_unique(world);
|
||||
|
||||
// --- Simulation (8) ---
|
||||
inv_sim1_no_entity_at_blocked_tile(world);
|
||||
inv_sim2_computed_path_steps_walkable(world);
|
||||
inv_sim3_no_path_and_blocked_combined(world);
|
||||
inv_sim4_player_entity_present(world);
|
||||
inv_sim5_npc_positions_in_bounds(world);
|
||||
inv_sim6_registry_has_entities(world);
|
||||
inv_sim7_player_has_monologue_buffer(world);
|
||||
inv_sim8_player_has_interaction_buffer(world);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Category: Structural (8 invariants)
|
||||
// ===========================================================================
|
||||
|
||||
/// S1: The WalkabilityMap resource exists and has at least one walkable tile.
|
||||
/// A fully-blocked map would make the game unplayable and indicates a setup error.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_s1_walkable_tiles_exist(world: &mut World) {
|
||||
let wm = world
|
||||
.get_resource::<WalkabilityMap>()
|
||||
.expect("S1: WalkabilityMap resource must exist");
|
||||
let mut found = false;
|
||||
'outer: for y in 0..MAP_HEIGHT {
|
||||
for x in 0..MAP_WIDTH {
|
||||
if wm.can_move_to(&TilePosition::new(x, y, 0)) {
|
||||
found = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found,
|
||||
"S1: WalkabilityMap must have at least one walkable tile in the gauntlet bounds"
|
||||
);
|
||||
}
|
||||
|
||||
/// S2: Walkable tile count is within the possible map area.
|
||||
/// Counts walkable tiles and asserts they do not exceed the map bounding box.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_s2_walkable_count_in_map_bounds(world: &mut World) {
|
||||
let wm = world
|
||||
.get_resource::<WalkabilityMap>()
|
||||
.expect("S2: WalkabilityMap resource must exist");
|
||||
let max_tiles = (MAP_WIDTH as usize) * (MAP_HEIGHT as usize);
|
||||
let mut count = 0usize;
|
||||
for y in 0..MAP_HEIGHT {
|
||||
for x in 0..MAP_WIDTH {
|
||||
if wm.can_move_to(&TilePosition::new(x, y, 0)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
count <= max_tiles,
|
||||
"S2: walkable tile count ({}) must not exceed map area ({}x{}={})",
|
||||
count,
|
||||
MAP_WIDTH,
|
||||
MAP_HEIGHT,
|
||||
max_tiles
|
||||
);
|
||||
}
|
||||
|
||||
/// S3: The player entity's spawn tile is walkable.
|
||||
/// A player spawned into a wall tile cannot move and blocks all room tests.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_s3_player_spawn_walkable(world: &mut World) {
|
||||
let player_pos = {
|
||||
let mut q = world.query_filtered::<&TilePosition, bevy_ecs::prelude::With<PlayerCharacter>>();
|
||||
*q.single(world).expect("S3: exactly one PlayerCharacter must exist")
|
||||
};
|
||||
let wm = world
|
||||
.get_resource::<WalkabilityMap>()
|
||||
.expect("S3: WalkabilityMap resource must exist");
|
||||
assert!(
|
||||
wm.can_move_to(&player_pos),
|
||||
"S3: player spawn {:?} must be on a walkable tile",
|
||||
player_pos
|
||||
);
|
||||
}
|
||||
|
||||
/// S4: Every gauntlet room's designated spawn position is walkable.
|
||||
/// NPCs and the player teleported to room spawns must have valid starting tiles.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_s4_room_spawns_walkable(world: &mut World) {
|
||||
let wm = world
|
||||
.get_resource::<WalkabilityMap>()
|
||||
.expect("S4: WalkabilityMap resource must exist");
|
||||
for room in ROOMS {
|
||||
assert!(
|
||||
wm.can_move_to(&room.spawn),
|
||||
"S4: spawn {:?} in room '{}' must be walkable",
|
||||
room.spawn,
|
||||
room.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// S5: Every gauntlet room's designated observer (golden-file) position is walkable.
|
||||
/// Observer positions used for snapshot tests must be valid standing tiles.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_s5_room_observers_walkable(world: &mut World) {
|
||||
let wm = world
|
||||
.get_resource::<WalkabilityMap>()
|
||||
.expect("S5: WalkabilityMap resource must exist");
|
||||
for room in ROOMS {
|
||||
assert!(
|
||||
wm.can_move_to(&room.observer),
|
||||
"S5: observer {:?} in room '{}' must be walkable",
|
||||
room.observer,
|
||||
room.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// S6: No two gauntlet room bounding boxes overlap.
|
||||
/// Overlapping rooms create ambiguous zone assignment and StableId conflicts.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_s6_rooms_non_overlapping(_world: &mut World) {
|
||||
for (i, a) in ROOMS.iter().enumerate() {
|
||||
for (j, b) in ROOMS.iter().enumerate() {
|
||||
if i >= j {
|
||||
continue;
|
||||
}
|
||||
let overlap_x =
|
||||
a.origin.x < b.origin.x + b.size.0 && a.origin.x + a.size.0 > b.origin.x;
|
||||
let overlap_y =
|
||||
a.origin.y < b.origin.y + b.size.1 && a.origin.y + a.size.1 > b.origin.y;
|
||||
assert!(
|
||||
!(overlap_x && overlap_y),
|
||||
"S6: rooms '{}' and '{}' have overlapping bounding boxes",
|
||||
a.name,
|
||||
b.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// S7: Each room has at least one walkable interior tile.
|
||||
/// A room whose interior is fully blocked has no usable space for entities.
|
||||
/// Interior is [origin+2, origin+size-2) in both axes (2-tile walls on each side).
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_s7_room_interiors_have_walkable_tiles(world: &mut World) {
|
||||
let wm = world
|
||||
.get_resource::<WalkabilityMap>()
|
||||
.expect("S7: WalkabilityMap resource must exist");
|
||||
for room in ROOMS {
|
||||
let x_start = room.origin.x + 2;
|
||||
let x_end = room.origin.x + room.size.0 - 2;
|
||||
let y_start = room.origin.y + 2;
|
||||
let y_end = room.origin.y + room.size.1 - 2;
|
||||
let mut found = false;
|
||||
'outer: for y in y_start..y_end {
|
||||
for x in x_start..x_end {
|
||||
if wm.can_move_to(&TilePosition::new(x, y, room.origin.z)) {
|
||||
found = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found,
|
||||
"S7: room '{}' (interior x={}..{}, y={}..{}) must have at least one walkable tile",
|
||||
room.name,
|
||||
x_start,
|
||||
x_end,
|
||||
y_start,
|
||||
y_end
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// S8: The Hub center tile (50, 58) is walkable.
|
||||
/// This is the global player starting position; a blocked hub center breaks navigation.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_s8_hub_center_walkable(world: &mut World) {
|
||||
let wm = world
|
||||
.get_resource::<WalkabilityMap>()
|
||||
.expect("S8: WalkabilityMap resource must exist");
|
||||
let hub_center = TilePosition::new(50, 58, 0);
|
||||
assert!(
|
||||
wm.can_move_to(&hub_center),
|
||||
"S8: Hub center {:?} must be walkable — it is the global player starting position",
|
||||
hub_center
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Category: Perception (5 invariants)
|
||||
// ===========================================================================
|
||||
|
||||
/// P1: Close sound range ceiling matches D-018 spec (3 tiles).
|
||||
/// Tests that the constant hasn't drifted from the design decision.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_p1_close_sound_range_spec(_world: &mut World) {
|
||||
assert_eq!(
|
||||
SoundEvent::max_range_tiles(SoundRange::Close),
|
||||
3,
|
||||
"P1: Close sound range must be 3 tiles per D-018"
|
||||
);
|
||||
}
|
||||
|
||||
/// P2: Medium sound range ceiling matches D-018 spec (8 tiles).
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_p2_medium_sound_range_spec(_world: &mut World) {
|
||||
assert_eq!(
|
||||
SoundEvent::max_range_tiles(SoundRange::Medium),
|
||||
8,
|
||||
"P2: Medium sound range must be 8 tiles per D-018"
|
||||
);
|
||||
}
|
||||
|
||||
/// P3: Long sound range ceiling matches D-018 spec (20 tiles).
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_p3_long_sound_range_spec(_world: &mut World) {
|
||||
assert_eq!(
|
||||
SoundEvent::max_range_tiles(SoundRange::Long),
|
||||
20,
|
||||
"P3: Long sound range must be 20 tiles per D-018"
|
||||
);
|
||||
}
|
||||
|
||||
/// P4: Symmetric shadowcasting satisfies LOS symmetry (D-035).
|
||||
///
|
||||
/// If tile A can see tile B, then B must also be able to see A.
|
||||
/// Tested at the Hub: observer at (50, 58), target at (54, 58) — clear line.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_p4_los_symmetry_at_hub(world: &mut World) {
|
||||
let wm = world
|
||||
.get_resource::<WalkabilityMap>()
|
||||
.expect("P4: WalkabilityMap resource must exist");
|
||||
|
||||
let is_opaque = |x: i32, y: i32| !wm.can_move_to(&TilePosition::new(x, y, 0));
|
||||
|
||||
// Two open-floor positions in the Hub: (50, 58) and (54, 58)
|
||||
let (ax, ay, bx, by) = (50i32, 58i32, 54i32, 58i32);
|
||||
let range = 12;
|
||||
|
||||
let fov_a = compute_fov(&is_opaque, ax, ay, range, 0);
|
||||
let fov_b = compute_fov(&is_opaque, bx, by, range, 0);
|
||||
|
||||
// A sees B → B must see A (symmetric shadowcasting guarantee, D-035)
|
||||
if fov_a.is_visible(bx, by) {
|
||||
assert!(
|
||||
fov_b.is_visible(ax, ay),
|
||||
"P4: LOS symmetry violated — ({},{}) sees ({},{}) but the reverse is false (D-035)",
|
||||
ax,
|
||||
ay,
|
||||
bx,
|
||||
by
|
||||
);
|
||||
}
|
||||
// B sees A → A must see B
|
||||
if fov_b.is_visible(ax, ay) {
|
||||
assert!(
|
||||
fov_a.is_visible(bx, by),
|
||||
"P4: LOS symmetry violated — ({},{}) sees ({},{}) but the reverse is false (D-035)",
|
||||
bx,
|
||||
by,
|
||||
ax,
|
||||
ay
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// P5: Tiles beyond the FOV range are not visible from the origin (Chebyshev).
|
||||
///
|
||||
/// Computes FOV with range=6 from the Hub center and asserts that a tile at
|
||||
/// Chebyshev distance > 6 does not appear in the visible set.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_p5_los_range_bounded(world: &mut World) {
|
||||
let wm = world
|
||||
.get_resource::<WalkabilityMap>()
|
||||
.expect("P5: WalkabilityMap resource must exist");
|
||||
|
||||
let is_opaque = |x: i32, y: i32| !wm.can_move_to(&TilePosition::new(x, y, 0));
|
||||
let range = 6;
|
||||
let (ox, oy) = (50i32, 58i32);
|
||||
|
||||
let fov = compute_fov(&is_opaque, ox, oy, range, 0);
|
||||
|
||||
// Tile at Chebyshev distance = range+2 must not be visible.
|
||||
let (out_x, out_y) = (ox + range + 2, oy);
|
||||
assert!(
|
||||
!fov.is_visible(out_x, out_y),
|
||||
"P5: tile ({},{}) at Chebyshev distance {} from origin ({},{}) must not be visible \
|
||||
with range={}",
|
||||
out_x,
|
||||
out_y,
|
||||
range + 2,
|
||||
ox,
|
||||
oy,
|
||||
range
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Category: Population (8 invariants)
|
||||
// ===========================================================================
|
||||
|
||||
/// Pop1: Active-tier NPC count does not exceed the D-026 tick-budget limit (80).
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_pop1_active_npc_count_within_limit(world: &mut World) {
|
||||
let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With<Npc>, bevy_ecs::prelude::With<ActiveSim>)>();
|
||||
let count = q.iter(world).count();
|
||||
assert!(
|
||||
count <= 80,
|
||||
"Pop1: active NPC count ({}) must not exceed 80 (D-026 ActiveSim limit)",
|
||||
count
|
||||
);
|
||||
}
|
||||
|
||||
/// Pop2: Every Npc entity has a TilePosition component.
|
||||
/// A positionless NPC is invisible to perception and pathfinding systems.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_pop2_all_npcs_have_tile_position(world: &mut World) {
|
||||
let total = {
|
||||
let mut q = world.query_filtered::<(), bevy_ecs::prelude::With<Npc>>();
|
||||
q.iter(world).count()
|
||||
};
|
||||
let with_pos = {
|
||||
let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With<Npc>, bevy_ecs::prelude::With<TilePosition>)>();
|
||||
q.iter(world).count()
|
||||
};
|
||||
assert_eq!(
|
||||
with_pos,
|
||||
total,
|
||||
"Pop2: all {} NPCs must have TilePosition; only {} do",
|
||||
total,
|
||||
with_pos
|
||||
);
|
||||
}
|
||||
|
||||
/// Pop3: Every NPC has exactly one simulation tier marker (ActiveSim, BackgroundSim, StateSaved).
|
||||
/// Missing or double-tagged NPCs cause behaviour-system duplicates or silent omissions.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_pop3_all_npcs_have_exactly_one_tier(world: &mut World) {
|
||||
let total = {
|
||||
let mut q = world.query_filtered::<(), bevy_ecs::prelude::With<Npc>>();
|
||||
q.iter(world).count()
|
||||
};
|
||||
let active = {
|
||||
let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With<Npc>, bevy_ecs::prelude::With<ActiveSim>)>();
|
||||
q.iter(world).count()
|
||||
};
|
||||
let bg = {
|
||||
let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With<Npc>, bevy_ecs::prelude::With<BackgroundSim>)>();
|
||||
q.iter(world).count()
|
||||
};
|
||||
let ss = {
|
||||
let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With<Npc>, bevy_ecs::prelude::With<StateSaved>)>();
|
||||
q.iter(world).count()
|
||||
};
|
||||
let tier_sum = active + bg + ss;
|
||||
assert_eq!(
|
||||
tier_sum,
|
||||
total,
|
||||
"Pop3: each of {} NPCs must have exactly one tier marker; \
|
||||
found {} Active + {} Background + {} StateSaved = {} (should equal {})",
|
||||
total,
|
||||
active,
|
||||
bg,
|
||||
ss,
|
||||
tier_sum,
|
||||
total
|
||||
);
|
||||
}
|
||||
|
||||
/// Pop4: No two entities share the same tile in the same posture layer (D-054).
|
||||
/// Same-layer collision on spawn is a world-setup error.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_pop4_no_same_layer_collision(world: &mut World) {
|
||||
use std::collections::BTreeSet;
|
||||
use crate::simulation::movement::TilePresence;
|
||||
use bevy_ecs::prelude::Entity;
|
||||
|
||||
let mut q = world.query::<(Entity, &TilePosition, Option<&TilePresence>)>();
|
||||
let occupied: Vec<(TilePosition, TilePresence, Entity)> = q
|
||||
.iter(world)
|
||||
.map(|(e, pos, pres)| (*pos, pres.copied().unwrap_or_default(), e))
|
||||
.collect();
|
||||
|
||||
let mut seen: BTreeSet<(TilePosition, TilePresence)> = BTreeSet::new();
|
||||
for (pos, layer, entity) in occupied {
|
||||
assert!(
|
||||
seen.insert((pos, layer)),
|
||||
"Pop4: entity {:?} shares tile {:?} + layer {:?} with another entity — \
|
||||
same-layer collision violates D-054",
|
||||
entity,
|
||||
pos,
|
||||
layer
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop5: EntityRegistry entity count matches EXPECTED_ENTITY_COUNT from StableId ranges.
|
||||
/// Drift indicates a room was added or removed without updating constants.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_pop5_entity_count_matches_expected(world: &mut World) {
|
||||
let registry = world
|
||||
.get_resource::<EntityRegistry>()
|
||||
.expect("Pop5: EntityRegistry resource must exist");
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
EXPECTED_ENTITY_COUNT,
|
||||
"Pop5: EntityRegistry has {} entities; expected {} from StableId range constants",
|
||||
registry.len(),
|
||||
EXPECTED_ENTITY_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
/// Pop6: Every Npc entity has a Want component (D-024 axis 1 is mandatory).
|
||||
/// An NPC without a Want cannot be scored by the storyteller system.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_pop6_all_npcs_have_want(world: &mut World) {
|
||||
let total = {
|
||||
let mut q = world.query_filtered::<(), bevy_ecs::prelude::With<Npc>>();
|
||||
q.iter(world).count()
|
||||
};
|
||||
let with_want = {
|
||||
let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With<Npc>, bevy_ecs::prelude::With<Want>)>();
|
||||
q.iter(world).count()
|
||||
};
|
||||
assert_eq!(
|
||||
with_want,
|
||||
total,
|
||||
"Pop6: all {} NPCs must have a Want component; only {} do (D-024 axis 1)",
|
||||
total,
|
||||
with_want
|
||||
);
|
||||
}
|
||||
|
||||
/// Pop7: Crowd Plaza contains exactly 15 NPCs (D-026 density stress test — 5×3 grid).
|
||||
/// The grid layout is fixed; deviations indicate the room builder changed.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_pop7_crowd_plaza_npc_count(world: &mut World) {
|
||||
let cp = &CROWD_PLAZA;
|
||||
let mut q = world.query_filtered::<&TilePosition, bevy_ecs::prelude::With<Npc>>();
|
||||
let count = q
|
||||
.iter(world)
|
||||
.filter(|pos| {
|
||||
pos.x >= cp.origin.x
|
||||
&& pos.x < cp.origin.x + cp.size.0
|
||||
&& pos.y >= cp.origin.y
|
||||
&& pos.y < cp.origin.y + cp.size.1
|
||||
&& pos.z == cp.origin.z
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
count,
|
||||
15,
|
||||
"Pop7: Crowd Plaza must contain exactly 15 NPCs; found {}",
|
||||
count
|
||||
);
|
||||
}
|
||||
|
||||
/// Pop8: All StableEntityId components attached to entities are unique.
|
||||
/// Duplicate StableIds corrupt knowledge-graph references and snapshot output.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_pop8_stable_ids_unique(world: &mut World) {
|
||||
use std::collections::BTreeSet;
|
||||
use crate::knowledge::types::StableId;
|
||||
use bevy_ecs::prelude::Entity;
|
||||
|
||||
let mut q = world.query::<(Entity, &StableEntityId)>();
|
||||
let ids: Vec<(StableId, Entity)> = q
|
||||
.iter(world)
|
||||
.map(|(e, sid)| (sid.0, e))
|
||||
.collect();
|
||||
|
||||
let mut seen: BTreeSet<StableId> = BTreeSet::new();
|
||||
for (id, entity) in ids {
|
||||
assert!(
|
||||
seen.insert(id),
|
||||
"Pop8: duplicate StableId {:?} found on entity {:?} — StableIds must be unique",
|
||||
id,
|
||||
entity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Category: Simulation (8 invariants)
|
||||
// ===========================================================================
|
||||
|
||||
/// Sim1: No movable entity (NPC or PlayerCharacter) is positioned on a non-walkable tile.
|
||||
///
|
||||
/// Fixtures such as reset plates and signs may be placed in wall tiles intentionally
|
||||
/// (they are interacted with from adjacent tiles, not stood upon). This invariant
|
||||
/// targets entities that are expected to move: NPCs and the player character.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_sim1_no_entity_at_blocked_tile(world: &mut World) {
|
||||
use bevy_ecs::prelude::{Entity, Or, With};
|
||||
|
||||
// Only check movable entities: Npc + PlayerCharacter. Fixtures/signs/reset plates
|
||||
// may legitimately sit in wall tiles (interactable from range, not traversed).
|
||||
let positions: Vec<(Entity, TilePosition)> = {
|
||||
let mut q = world.query_filtered::<(Entity, &TilePosition), Or<(With<Npc>, With<PlayerCharacter>)>>();
|
||||
q.iter(world).map(|(e, p)| (e, *p)).collect()
|
||||
};
|
||||
let wm = world
|
||||
.get_resource::<WalkabilityMap>()
|
||||
.expect("Sim1: WalkabilityMap resource must exist");
|
||||
|
||||
for (entity, pos) in positions {
|
||||
assert!(
|
||||
wm.can_move_to(&pos),
|
||||
"Sim1: movable entity {:?} is at blocked tile {:?} — NPCs and players must \
|
||||
spawn on walkable tiles",
|
||||
entity,
|
||||
pos
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sim2: All steps in any ComputedPath are walkable tiles.
|
||||
/// A path through a wall tile would cause the entity to move through geometry.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_sim2_computed_path_steps_walkable(world: &mut World) {
|
||||
use bevy_ecs::prelude::Entity;
|
||||
|
||||
let paths: Vec<(Entity, Vec<TilePosition>)> = {
|
||||
let mut q = world.query::<(Entity, &ComputedPath)>();
|
||||
q.iter(world)
|
||||
.map(|(e, p)| (e, p.steps.clone()))
|
||||
.collect()
|
||||
};
|
||||
let wm = world
|
||||
.get_resource::<WalkabilityMap>()
|
||||
.expect("Sim2: WalkabilityMap resource must exist");
|
||||
|
||||
for (entity, steps) in paths {
|
||||
for (step_idx, step) in steps.iter().enumerate() {
|
||||
assert!(
|
||||
wm.can_move_to(step),
|
||||
"Sim2: entity {:?} ComputedPath step {} at {:?} is not walkable",
|
||||
entity,
|
||||
step_idx,
|
||||
step
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sim3: PathBlocked and ComputedPath are mutually exclusive on any entity.
|
||||
/// Having both indicates the pathfinder produced contradictory output.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_sim3_no_path_and_blocked_combined(world: &mut World) {
|
||||
use bevy_ecs::prelude::{Entity, With};
|
||||
|
||||
let path_entities: Vec<Entity> = {
|
||||
let mut q = world.query_filtered::<Entity, With<ComputedPath>>();
|
||||
q.iter(world).collect()
|
||||
};
|
||||
for entity in path_entities {
|
||||
assert!(
|
||||
world.get::<PathBlocked>(entity).is_none(),
|
||||
"Sim3: entity {:?} has both ComputedPath and PathBlocked — mutually exclusive",
|
||||
entity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sim4: Exactly one PlayerCharacter entity exists.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_sim4_player_entity_present(world: &mut World) {
|
||||
use bevy_ecs::prelude::With;
|
||||
|
||||
let mut q = world.query_filtered::<(), With<PlayerCharacter>>();
|
||||
let count = q.iter(world).count();
|
||||
assert_eq!(
|
||||
count,
|
||||
1,
|
||||
"Sim4: exactly one PlayerCharacter must exist; found {}",
|
||||
count
|
||||
);
|
||||
}
|
||||
|
||||
/// Sim5: All NPC TilePositions are within the gauntlet map bounds.
|
||||
/// Out-of-bounds positions indicate a misconfigured room builder.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_sim5_npc_positions_in_bounds(world: &mut World) {
|
||||
use bevy_ecs::prelude::{Entity, With};
|
||||
|
||||
let npc_positions: Vec<(Entity, TilePosition)> = {
|
||||
let mut q = world.query_filtered::<(Entity, &TilePosition), With<Npc>>();
|
||||
q.iter(world).map(|(e, p)| (e, *p)).collect()
|
||||
};
|
||||
for (entity, pos) in npc_positions {
|
||||
assert!(
|
||||
pos.x >= 0 && pos.x < MAP_WIDTH && pos.y >= 0 && pos.y < MAP_HEIGHT && pos.z >= 0,
|
||||
"Sim5: NPC {:?} has out-of-bounds position {:?} (map bounds: {}x{} z>=0)",
|
||||
entity,
|
||||
pos,
|
||||
MAP_WIDTH,
|
||||
MAP_HEIGHT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sim6: EntityRegistry contains at least one entity (the player).
|
||||
/// An empty registry indicates gauntlet setup failed entirely.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_sim6_registry_has_entities(world: &mut World) {
|
||||
let registry = world
|
||||
.get_resource::<EntityRegistry>()
|
||||
.expect("Sim6: EntityRegistry resource must exist");
|
||||
assert!(
|
||||
registry.len() > 0,
|
||||
"Sim6: EntityRegistry must contain at least one entity (the player)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sim7: The PlayerCharacter entity has a MonologueBuffer component.
|
||||
/// Missing MonologueBuffer silently drops all monologue events for the player.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_sim7_player_has_monologue_buffer(world: &mut World) {
|
||||
use bevy_ecs::prelude::{Entity, With};
|
||||
|
||||
let player: Entity = {
|
||||
let mut q = world.query_filtered::<Entity, With<PlayerCharacter>>();
|
||||
q.single(world).expect("Sim7: PlayerCharacter must exist")
|
||||
};
|
||||
assert!(
|
||||
world.get::<MonologueBuffer>(player).is_some(),
|
||||
"Sim7: PlayerCharacter must have MonologueBuffer component"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sim8: The PlayerCharacter entity has a NearbyInteractionBuffer component.
|
||||
/// Missing NearbyInteractionBuffer causes all interaction verbs to be silently dropped.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn inv_sim8_player_has_interaction_buffer(world: &mut World) {
|
||||
use bevy_ecs::prelude::{Entity, With};
|
||||
|
||||
let player: Entity = {
|
||||
let mut q = world.query_filtered::<Entity, With<PlayerCharacter>>();
|
||||
q.single(world).expect("Sim8: PlayerCharacter must exist")
|
||||
};
|
||||
assert!(
|
||||
world.get::<NearbyInteractionBuffer>(player).is_some(),
|
||||
"Sim8: PlayerCharacter must have NearbyInteractionBuffer component"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Additional system-execution tests (7 more invariants, via #[test])
|
||||
// Tests 30-36 exercise behaviour that requires running ECS systems.
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(all(test, feature = "gauntlet"))]
|
||||
mod system_tests {
|
||||
use super::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::Schedule;
|
||||
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::{compute_nearby_interactions, Interactable, NearbyInteractionBuffer};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::pathfinding::{compute_paths, ComputedPath, PathBlocked, PathRequest};
|
||||
use crate::simulation::stance::Stance;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::bridge::types::MovementStance;
|
||||
use crate::knowledge::registry::EntityRegistry;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Invariant 30-31: Pathfinder terminates on adjacent tile
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Sim-30/31: compute_paths consumes PathRequest and produces ComputedPath
|
||||
/// for an adjacent walkable tile (happy path — pathfinder must terminate).
|
||||
#[test]
|
||||
fn pathfinder_terminates_on_adjacent_tile() {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
PathRequest {
|
||||
goal: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(compute_paths);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Invariant 30: PathRequest consumed (pathfinder must not hang)
|
||||
assert!(
|
||||
world.get::<PathRequest>(entity).is_none(),
|
||||
"Sim-30: PathRequest must be consumed after compute_paths runs"
|
||||
);
|
||||
// Invariant 31: ComputedPath produced with correct step
|
||||
let path = world
|
||||
.get::<ComputedPath>(entity)
|
||||
.expect("Sim-31: ComputedPath must be produced for a reachable goal");
|
||||
assert_eq!(
|
||||
path.steps,
|
||||
vec![TilePosition::new(5, 4, 0)],
|
||||
"Sim-31: adjacent-tile path must contain exactly one step"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Invariant 32-33: Pathfinder returns PathBlocked when no route exists
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Sim-32/33: compute_paths on an unreachable goal emits PathBlocked and
|
||||
/// still consumes the PathRequest (pathfinder terminates on blocked goals).
|
||||
#[test]
|
||||
fn pathfinder_blocked_when_no_route() {
|
||||
let mut world = World::new();
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
let goal = TilePosition::new(5, 3, 0);
|
||||
for neighbor in goal.cardinal_neighbors() {
|
||||
map.set_walkable(&neighbor, false);
|
||||
}
|
||||
world.insert_resource(map);
|
||||
|
||||
let entity = world
|
||||
.spawn((TilePosition::new(5, 5, 0), PathRequest { goal }))
|
||||
.id();
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(compute_paths);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Invariant 32: PathRequest consumed even when no route exists
|
||||
assert!(
|
||||
world.get::<PathRequest>(entity).is_none(),
|
||||
"Sim-32: PathRequest must be consumed even when no route exists"
|
||||
);
|
||||
// Invariant 33: PathBlocked emitted
|
||||
assert!(
|
||||
world.get::<PathBlocked>(entity).is_some(),
|
||||
"Sim-33: PathBlocked must be inserted when goal is unreachable"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Invariant 34: Determinism — same setup produces same NPC positions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Sim-34: Two independent worlds with identical state run through compute_paths
|
||||
/// and produce identical NPC TilePositions (D-010 determinism).
|
||||
#[test]
|
||||
fn simulation_determinism_same_positions() {
|
||||
fn build_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(WalkabilityMap::new(20, 20, 1));
|
||||
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
TilePosition::new(4, 5, 0),
|
||||
PathRequest {
|
||||
goal: TilePosition::new(8, 5, 0),
|
||||
},
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 5,
|
||||
description: "det-test".to_string(),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 40,
|
||||
},
|
||||
));
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
TilePosition::new(10, 10, 0),
|
||||
PathRequest {
|
||||
goal: TilePosition::new(2, 2, 0),
|
||||
},
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 3,
|
||||
description: "det-test-2".to_string(),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 40,
|
||||
},
|
||||
));
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(compute_paths);
|
||||
schedule.run(&mut world);
|
||||
world
|
||||
}
|
||||
|
||||
fn npc_positions(world: &mut World) -> Vec<TilePosition> {
|
||||
let mut q = world.query_filtered::<&TilePosition, With<Npc>>();
|
||||
let mut positions: Vec<TilePosition> = q.iter(world).copied().collect();
|
||||
positions.sort();
|
||||
positions
|
||||
}
|
||||
|
||||
let mut world1 = build_world();
|
||||
let mut world2 = build_world();
|
||||
|
||||
assert_eq!(
|
||||
npc_positions(&mut world1),
|
||||
npc_positions(&mut world2),
|
||||
"Sim-34: identical world setup must produce identical NPC positions (D-010 determinism)"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Invariant 35-36: Sprint suppresses interaction buffer (D-055)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Sim-35: Sprint stance must suppress the interaction buffer entirely.
|
||||
/// Sim-36: Walk stance must populate the buffer when an NPC is adjacent.
|
||||
#[test]
|
||||
fn sprint_suppresses_interaction_buffer_walk_populates() {
|
||||
// --- Sim-35: Sprint case — buffer must be empty ---
|
||||
{
|
||||
let mut world = World::new();
|
||||
world.insert_resource(WalkabilityMap::new(20, 20, 1));
|
||||
world.init_resource::<EntityRegistry>();
|
||||
|
||||
let npc_pos = TilePosition::new(6, 5, 0);
|
||||
world.spawn((Npc, ActiveSim, Interactable, npc_pos));
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
NearbyInteractionBuffer::default(),
|
||||
Stance(MovementStance::Sprint),
|
||||
));
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut q = world
|
||||
.query_filtered::<&mut NearbyInteractionBuffer, With<PlayerCharacter>>();
|
||||
let mut buf = q.single_mut(&mut world).expect("player must exist");
|
||||
let interactions = buf.take();
|
||||
assert!(
|
||||
interactions.is_empty(),
|
||||
"Sim-35: Sprint stance must suppress the interaction buffer per D-055; \
|
||||
found {} interaction(s)",
|
||||
interactions.len()
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sim-36: Walk case — buffer must contain adjacent NPC ---
|
||||
{
|
||||
let mut world = World::new();
|
||||
world.insert_resource(WalkabilityMap::new(20, 20, 1));
|
||||
world.init_resource::<EntityRegistry>();
|
||||
|
||||
let npc_pos = TilePosition::new(6, 5, 0);
|
||||
world.spawn((Npc, ActiveSim, Interactable, npc_pos));
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
NearbyInteractionBuffer::default(),
|
||||
Stance(MovementStance::Walk),
|
||||
));
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut q = world
|
||||
.query_filtered::<&mut NearbyInteractionBuffer, With<PlayerCharacter>>();
|
||||
let mut buf = q.single_mut(&mut world).expect("player must exist");
|
||||
let interactions = buf.take();
|
||||
assert!(
|
||||
!interactions.is_empty(),
|
||||
"Sim-36: Walk stance must populate interaction buffer for a nearby NPC; \
|
||||
buffer was empty"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod constants;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod invariants;
|
||||
pub mod reset;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod rooms;
|
||||
@@ -536,6 +538,9 @@ mod tests {
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
// Run all 29 world-query invariants against the fully-initialized gauntlet world.
|
||||
invariants::run_invariants(app.world_mut());
|
||||
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
|
||||
Reference in New Issue
Block a user