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:
2026-02-20 18:41:56 +01:00
co-authored by Claude Sonnet 4.6
parent 9eba259fd5
commit a14c980aad
3 changed files with 977 additions and 1 deletions
+1 -1
View File
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.12"
version = "0.1.13"
dependencies = [
"bevy_app",
"bevy_ecs",
+971
View File
@@ -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"
);
}
}
}
+5
View File
@@ -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(),