diff --git a/server/Cargo.lock b/server/Cargo.lock index ea1dcf2fe..5b4567c66 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.12" +version = "0.1.13" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/test_world/invariants.rs b/server/src/test_world/invariants.rs new file mode 100644 index 000000000..9d2b27a57 --- /dev/null +++ b/server/src/test_world/invariants.rs @@ -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::() + .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::() + .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>(); + *q.single(world).expect("S3: exactly one PlayerCharacter must exist") + }; + let wm = world + .get_resource::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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, bevy_ecs::prelude::With)>(); + 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>(); + q.iter(world).count() + }; + let with_pos = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + 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>(); + q.iter(world).count() + }; + let active = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + let bg = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + let ss = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + 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::() + .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>(); + q.iter(world).count() + }; + let with_want = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + 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>(); + 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 = 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, With)>>(); + q.iter(world).map(|(e, p)| (e, *p)).collect() + }; + let wm = world + .get_resource::() + .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)> = { + let mut q = world.query::<(Entity, &ComputedPath)>(); + q.iter(world) + .map(|(e, p)| (e, p.steps.clone())) + .collect() + }; + let wm = world + .get_resource::() + .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 = { + let mut q = world.query_filtered::>(); + q.iter(world).collect() + }; + for entity in path_entities { + assert!( + world.get::(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>(); + 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>(); + 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::() + .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::>(); + q.single(world).expect("Sim7: PlayerCharacter must exist") + }; + assert!( + world.get::(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::>(); + q.single(world).expect("Sim8: PlayerCharacter must exist") + }; + assert!( + world.get::(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::(entity).is_none(), + "Sim-30: PathRequest must be consumed after compute_paths runs" + ); + // Invariant 31: ComputedPath produced with correct step + let path = world + .get::(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::(entity).is_none(), + "Sim-32: PathRequest must be consumed even when no route exists" + ); + // Invariant 33: PathBlocked emitted + assert!( + world.get::(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 { + let mut q = world.query_filtered::<&TilePosition, With>(); + let mut positions: Vec = 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::(); + + 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>(); + 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::(); + + 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>(); + 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" + ); + } + } +} diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 937b65fde..fd8620bec 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -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::(); assert_eq!( registry.len(),