test(simulation): add fuzzy tests for procedural map generation (#509)
50-seed randomized testing against 4 structural invariants: walkable connectivity (BFS), entity bounds, door adjacency, and minimum tile count floor. Includes generator module for test-scoped procedural map creation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
//! World generator data model — district skeleton and mobile chunk types.
|
||||
//!
|
||||
//! Phase 1 generator output: the `DistrictSkeleton` produced by the world generation
|
||||
//! pipeline. Consumed by Phase 2 (chunk fill) and by the simulation startup path.
|
||||
//!
|
||||
//! **D-110 (signed z-levels):** All z-level *position* fields use `i8` (negative values
|
||||
//! represent basements/sub-levels). Z-level *count* fields use `u8` (always ≥ 1).
|
||||
//! The distinction:
|
||||
//! - `base_z: i8` — where the bottom floor starts (can be negative)
|
||||
//! - `z_levels: u8` — how many floors total (always positive)
|
||||
//!
|
||||
//! **D-108 (MobileChunk):** Vessel/vehicle interior chunks attached to mobile entities.
|
||||
//! Uses the same chunk primitives as static districts in a simpler flat structure
|
||||
//! (no Phase 1/Phase 2 split, template-stamped interiors).
|
||||
//!
|
||||
//! Sources: workshop-outcomes.md, tyre-round4.md
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concept ID type aliases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Unique district identifier (content-addressable via hash of world position + seed).
|
||||
pub type DistrictId = u64;
|
||||
/// Unique multi-block reservation identifier within a district.
|
||||
pub type ReservationId = u64;
|
||||
/// Unique social site identifier within a district.
|
||||
pub type SocialSiteId = u64;
|
||||
/// Unique role slot identifier within a social site.
|
||||
pub type RoleSlotId = u64;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub types for concepts not yet implemented (defined here to allow
|
||||
// the data model to compile; will be replaced with proper types when
|
||||
// the respective systems are implemented)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Biome classification for wilderness districts. Stub — full taxonomy deferred.
|
||||
pub type Biome = String;
|
||||
/// Water body sub-classification. Stub — full taxonomy deferred.
|
||||
pub type WaterType = String;
|
||||
/// Specialized district function (e.g. military, medical, penal). Stub.
|
||||
pub type SpecializedFunction = String;
|
||||
/// Cultural / architectural era tag. Stub.
|
||||
pub type Era = String;
|
||||
/// Single era modification applied to a block (conversion, overlay, hybrid). Stub.
|
||||
pub type EraModification = String;
|
||||
/// Landmark slot description within a block. Stub.
|
||||
pub type LandmarkSlot = String;
|
||||
/// Chunk layout specification within a block (how the 2×2 chunks are arranged). Stub.
|
||||
pub type ChunkLayout = String;
|
||||
/// Corridor spine connecting district access points. Stub.
|
||||
pub type CorridorSpine = String;
|
||||
/// District context — neighboring districts, world position, system. Stub.
|
||||
pub type DistrictContext = String;
|
||||
/// Society profile reference (Miri's cultural ingredients). Stub.
|
||||
pub type SocietyProfileRef = String;
|
||||
/// Zone definition — base palette + modifiers + zone name. Stub.
|
||||
pub type ZoneDefinition = String;
|
||||
/// District boundary system — edge descriptors with neighbors. Stub.
|
||||
pub type DistrictBoundaries = String;
|
||||
/// Guarantee audit result — tier-appropriate spatial invariant checks. Stub.
|
||||
pub type GuaranteeAuditResult = String;
|
||||
/// District access point (entry/exit to neighboring district). Stub.
|
||||
pub type AccessPoint = String;
|
||||
/// Base visual palette for a zone. Stub.
|
||||
pub type BasePalette = String;
|
||||
/// Economic modifier on a zone palette. Stub.
|
||||
pub type EconomicModifier = String;
|
||||
/// Faction presence modifier on a zone palette. Stub.
|
||||
pub type FactionModifier = String;
|
||||
/// Condition modifier on a zone palette (worn, pristine, damaged). Stub.
|
||||
pub type ConditionModifier = String;
|
||||
/// Heritage root modifier (Commonwealth cultural grammar layer). Stub.
|
||||
pub type HeritageRoot = String;
|
||||
/// Season modifier (affects palette and ambient conditions). Stub.
|
||||
pub type Season = String;
|
||||
/// Role slot within a social site template. Stub.
|
||||
pub type RoleSlot = String;
|
||||
/// Day phase (morning, afternoon, evening, night, late-night). Stub.
|
||||
pub type DayPhase = String;
|
||||
/// Triangle template reference (links to content/templates/). Stub.
|
||||
pub type TriangleTemplate = String;
|
||||
/// Raw chunk tile data for generator use (64×64 bool grid, true = walkable). Stub.
|
||||
pub type GeneratorChunkData = Vec<bool>;
|
||||
/// Tile type identifier (references visual tile set). Stub.
|
||||
pub type TileId = String;
|
||||
/// Entity identifier for mutation causality tracking. Stub.
|
||||
pub type EntityId = u64;
|
||||
/// Action identifier for player-caused mutations. Stub.
|
||||
pub type ActionId = String;
|
||||
/// Object instance identifier within a chunk. Stub.
|
||||
pub type ObjectId = u64;
|
||||
/// Placed object (item, furniture, fixture) within a chunk. Stub.
|
||||
pub type PlacedObject = String;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// World and complexity tier enums
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Network importance of a world in the galaxy.
|
||||
/// Determines simulation fidelity budget and NPC complexity ceiling.
|
||||
///
|
||||
/// Source: tyre-round4.md §2.1, workshop-outcomes.md
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum WorldTier {
|
||||
/// Background system — minimal simulation, sparse NPCs. Pure environmental.
|
||||
Peripheral,
|
||||
/// Standard Commonwealth system — full simulation, complex social sites.
|
||||
Connected,
|
||||
/// Major hub — maximum fidelity, multi-faction politics, all triangle types.
|
||||
Core,
|
||||
}
|
||||
|
||||
/// Generator content budget for a district.
|
||||
/// Derived from WorldTier + SettingType at Phase 1.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ComplexityTier {
|
||||
/// Full social architecture. All Tier 1+2 guarantees. 20-80+ NPCs.
|
||||
Full,
|
||||
/// Moderate social architecture. Tier 1 + partial Tier 2. 8-20 NPCs.
|
||||
Moderate,
|
||||
/// Minimal social architecture. Tier 1 only. 1-8 NPCs.
|
||||
Minimal,
|
||||
/// No social architecture. Pure terrain. 0 NPCs. No guarantees.
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// Physical setting type for a district.
|
||||
/// Merged from Gestalt's SettingGeometry and Tyre's TerrainType.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum SettingType {
|
||||
Station,
|
||||
Urban,
|
||||
Agricultural,
|
||||
Maritime,
|
||||
Wilderness { biome: Biome },
|
||||
Water { water_type: WaterType },
|
||||
Transitional,
|
||||
Orbital,
|
||||
Specialized { function: SpecializedFunction },
|
||||
}
|
||||
|
||||
/// Classification of a district (high-level function).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum DistrictType {
|
||||
LogisticsHub,
|
||||
Residential,
|
||||
Commercial,
|
||||
Industrial,
|
||||
Administrative,
|
||||
Entertainment,
|
||||
MixedUse,
|
||||
Transit,
|
||||
Specialized,
|
||||
}
|
||||
|
||||
/// How blocks are placed within the district's 512×512 footprint.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum DistrictLayoutMode {
|
||||
/// Standard Cartesian grid — perpendicular streets.
|
||||
Grid,
|
||||
/// Organic placement with per-block offsets and rotations.
|
||||
Organic {
|
||||
placements: [[BlockPlacement; 4]; 4],
|
||||
},
|
||||
}
|
||||
|
||||
/// Zoning classification for a block or floor zone.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ZoningType {
|
||||
Commercial,
|
||||
Residential,
|
||||
Industrial,
|
||||
Administrative,
|
||||
Transit,
|
||||
Recreational,
|
||||
Restricted,
|
||||
Mixed,
|
||||
}
|
||||
|
||||
/// Reservation function — what purpose a multi-block reservation serves.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum ReservationFunction {
|
||||
Skyscraper,
|
||||
Park,
|
||||
Terminal,
|
||||
Plaza,
|
||||
Monument,
|
||||
IndustrialComplex,
|
||||
MilitaryBase,
|
||||
ResearchFacility,
|
||||
UndergroundComplex,
|
||||
}
|
||||
|
||||
/// Access tier — who is allowed into a zone under normal circumstances.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AccessTier {
|
||||
/// Anyone can enter.
|
||||
Public,
|
||||
/// Requires employment or residence credential.
|
||||
Credentialed,
|
||||
/// Requires explicit invitation or escort.
|
||||
Restricted,
|
||||
/// Law enforcement / military only.
|
||||
Secured,
|
||||
/// Breach access only — normally sealed.
|
||||
BreachOnly,
|
||||
}
|
||||
|
||||
/// Vertical corridor type (how floors connect in a multi-level reservation).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum VerticalCorridorType {
|
||||
Stairwell,
|
||||
Elevator,
|
||||
FreightLift,
|
||||
AccessHatch,
|
||||
EmergencyLadder,
|
||||
}
|
||||
|
||||
/// Load state for a z-level within a reservation.
|
||||
/// Controls lazy loading of floor data during play.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum ZLevelLoadState {
|
||||
/// Floor is fully generated and in memory.
|
||||
Loaded(GeneratorChunkData),
|
||||
/// Floor skeleton only — full data not yet generated.
|
||||
Skeleton(FloorZone),
|
||||
/// Floor not yet generated (deferred to Phase 2 on first entry).
|
||||
Ungenerated,
|
||||
}
|
||||
|
||||
/// Cause of a chunk mutation (for save/load audit trail).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum MutationCause {
|
||||
Explosion { radius: u8, source: EntityId },
|
||||
Fire { spread_from: Option<(u16, u16)> },
|
||||
Construction { builder: EntityId },
|
||||
Decay { time_since_maintenance: u32 },
|
||||
PlayerAction { action: ActionId },
|
||||
}
|
||||
|
||||
/// Type of structural change to a region of chunk tiles.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum StructuralChangeType {
|
||||
WallDestroyed,
|
||||
FloorCollapsed,
|
||||
CeilingBreached,
|
||||
AreaSealed,
|
||||
WallConstructed,
|
||||
}
|
||||
|
||||
/// Purpose classification for a triangle within a social site.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TrianglePurpose {
|
||||
Investigation,
|
||||
Economic,
|
||||
Social,
|
||||
Political,
|
||||
Tactical,
|
||||
Mundane,
|
||||
}
|
||||
|
||||
/// What exists on the far side of a wall tile (for destructible geometry).
|
||||
/// Every wall tile in a generated chunk is tagged with one of these.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum WallBackside {
|
||||
/// Another room/corridor exists (tiles already generated).
|
||||
AdjacentSpace,
|
||||
/// Solid structural material — 1-2 tiles of fill, then another wall.
|
||||
StructuralFill,
|
||||
/// Narrow utility gap (1-3 tiles): pipes, conduits, non-navigable.
|
||||
ServiceVoid,
|
||||
/// Edge of chunk — adjacent chunk's boundary tiles on the far side.
|
||||
ChunkBoundary,
|
||||
/// Faces outside (hull, exterior wall) — breach has catastrophic consequences.
|
||||
Exterior,
|
||||
}
|
||||
|
||||
/// Era cause — why a block has the era tag it has.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum EraCause {
|
||||
Original,
|
||||
CorporateMerger,
|
||||
EmergencyExtension,
|
||||
OrganicGrowth,
|
||||
InstitutionalIncursion,
|
||||
EconomicDisruption,
|
||||
CulturalShift,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supporting structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Organic layout placement for a single block.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct BlockPlacement {
|
||||
/// Offset from grid-aligned position (±16 sim tiles per axis max).
|
||||
pub offset: (i16, i16),
|
||||
/// Rotation in 15° increments (0–3, max 45°).
|
||||
pub rotation_steps: u8,
|
||||
/// Street width multiplier (0.75–2.0; default 1.0 = 4 visual tiles).
|
||||
pub street_width_factor: f32,
|
||||
}
|
||||
|
||||
/// Single block within a district's 4×4 block grid.
|
||||
/// Each block = 128×128 sim tiles = 2×2 chunks.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct BlockSkeleton {
|
||||
/// Grid position (0–3, 0–3).
|
||||
pub position: (u8, u8),
|
||||
pub zoning: ZoningType,
|
||||
/// Which multi-block reservation this block belongs to (if any).
|
||||
pub reservation: Option<ReservationId>,
|
||||
pub chunk_layout: ChunkLayout,
|
||||
pub hosted_sites: Vec<SocialSiteId>,
|
||||
pub era: Era,
|
||||
pub era_modifications: Vec<EraModification>,
|
||||
pub era_cause: Option<EraCause>,
|
||||
/// Build density scalar (0.0 = open/empty, 1.0 = fully built-up).
|
||||
pub density: f32,
|
||||
pub landmark: Option<LandmarkSlot>,
|
||||
}
|
||||
|
||||
/// Floor zone within a multi-level reservation.
|
||||
///
|
||||
/// **D-110:** `z_level` is `i8` — negative values represent basements
|
||||
/// (e.g. `z_level = -1` for a sub-basement). This differs from `z_levels: u8`
|
||||
/// on the parent reservation which counts total floors (always ≥ 1).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct FloorZone {
|
||||
/// Absolute z-level of this floor. Negative for sub-ground levels (D-110).
|
||||
pub z_level: i8,
|
||||
pub zone_type: ZoningType,
|
||||
pub zone_palette: ZonePalette,
|
||||
pub access_tier: AccessTier,
|
||||
}
|
||||
|
||||
/// Vertical connection spec within a multi-level reservation.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct VerticalCorridorSpec {
|
||||
/// Which blocks this vertical corridor passes through.
|
||||
pub block_coords: Vec<(u8, u8)>,
|
||||
/// Which z-bands this corridor connects.
|
||||
pub z_bands_connected: Vec<u8>,
|
||||
pub access_tier: AccessTier,
|
||||
pub corridor_type: VerticalCorridorType,
|
||||
}
|
||||
|
||||
/// Visual palette for a zone — base material + contextual modifiers.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ZonePalette {
|
||||
pub base: BasePalette,
|
||||
pub modifiers: Vec<PaletteModifier>,
|
||||
}
|
||||
|
||||
/// Contextual modifier applied on top of a base palette.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum PaletteModifier {
|
||||
EconomicFunction(EconomicModifier),
|
||||
Era(Era),
|
||||
FactionPresence(FactionModifier),
|
||||
Condition(ConditionModifier),
|
||||
Heritage(HeritageRoot),
|
||||
Season(Season),
|
||||
}
|
||||
|
||||
/// Social site placement within a district.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SocialSitePlacement {
|
||||
pub site_id: SocialSiteId,
|
||||
/// Which blocks this site spans.
|
||||
pub blocks: Vec<(u8, u8)>,
|
||||
pub template_tag: String,
|
||||
pub access_tier: AccessTier,
|
||||
pub triangles: Vec<TriangleAssignment>,
|
||||
pub role_slots: Vec<RoleSlot>,
|
||||
pub active_phases: Vec<DayPhase>,
|
||||
}
|
||||
|
||||
/// Triangle assignment within a social site.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct TriangleAssignment {
|
||||
pub template: TriangleTemplate,
|
||||
pub purposes: Vec<TrianglePurpose>,
|
||||
pub participants: Vec<RoleSlotId>,
|
||||
/// Which block provides the primary staging area (optional for multi-block sites).
|
||||
pub staging_block: Option<(u8, u8)>,
|
||||
}
|
||||
|
||||
/// Mutations applied to an already-generated chunk.
|
||||
/// Stored alongside the chunk in the save file.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct ChunkMutations {
|
||||
pub tile_overrides: Vec<TileOverride>,
|
||||
pub structural_changes: Vec<StructuralChange>,
|
||||
pub placed_objects: Vec<PlacedObject>,
|
||||
pub removed_objects: Vec<ObjectId>,
|
||||
}
|
||||
|
||||
/// Single tile override within a chunk mutation.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct TileOverride {
|
||||
/// (x, y, z) within the chunk (z is relative to chunk base, always ≥ 0).
|
||||
pub position: (u16, u16, u8),
|
||||
pub new_tile: TileId,
|
||||
pub cause: MutationCause,
|
||||
}
|
||||
|
||||
/// Rectangular structural change within a chunk.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct StructuralChange {
|
||||
pub min: (u16, u16, u8),
|
||||
pub max: (u16, u16, u8),
|
||||
pub change_type: StructuralChangeType,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-block reservation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A reservation spanning multiple blocks within a district.
|
||||
///
|
||||
/// Used for skyscrapers, parks, transit terminals, plazas, and any structure
|
||||
/// that requires more than one 128×128 block.
|
||||
///
|
||||
/// **D-110:** `base_z: i8` — the lowest floor of this reservation.
|
||||
/// Negative values represent sub-ground levels (basements, underground complexes).
|
||||
/// `z_levels: u8` remains unsigned — it counts total floors (always ≥ 1).
|
||||
///
|
||||
/// Example: a building with a 3-level basement at ground + 20 floors above:
|
||||
/// `base_z: -3, z_levels: 24`
|
||||
/// A deep mine shaft descending 30 floors below ground:
|
||||
/// `base_z: -30, z_levels: 30`
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct MultiBlockReservation {
|
||||
/// Which blocks (grid positions) are part of this reservation.
|
||||
pub blocks: Vec<(u8, u8)>,
|
||||
pub template_tag: String,
|
||||
pub function: ReservationFunction,
|
||||
/// Total number of floors (always ≥ 1, unsigned). D-110.
|
||||
pub z_levels: u8,
|
||||
/// Lowest floor's z-level (can be negative for sub-basement). D-110.
|
||||
pub base_z: i8,
|
||||
/// Per-floor zone specifications.
|
||||
pub floor_zones: Vec<FloorZone>,
|
||||
/// Number of z-bands (logical groupings of floors by zoning).
|
||||
pub z_band_count: u8,
|
||||
/// Zone definitions for each z-band.
|
||||
pub z_band_zones: Vec<ZoneDefinition>,
|
||||
/// Vertical connection specs (stairs, elevators, ladders).
|
||||
pub vertical_corridors: Vec<VerticalCorridorSpec>,
|
||||
pub hosted_sites: Vec<SocialSiteId>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// District skeleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Phase 1 generator output for one district.
|
||||
///
|
||||
/// Produced by the async background generation pipeline.
|
||||
/// Consumed by Phase 2 (chunk-by-chunk fill, on demand) and by
|
||||
/// the production simulation startup path.
|
||||
///
|
||||
/// ## Z-level naming convention (D-110)
|
||||
///
|
||||
/// - `z_levels: u8` — how many floor levels exist in this district (count, always ≥ 1)
|
||||
/// - `base_z` does not appear on `DistrictSkeleton` itself — the district's
|
||||
/// ground level is always 0. Sub-ground structures use [`MultiBlockReservation`]
|
||||
/// with a negative `base_z` field.
|
||||
///
|
||||
/// ## Determinism (D-010)
|
||||
///
|
||||
/// All `Vec<_>` fields are stable-ordered at generation time (sorted by a
|
||||
/// deterministic key). Generation reproduces identical output for the same seed.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct DistrictSkeleton {
|
||||
// ── Identity ──────────────────────────────────────────
|
||||
pub district_id: DistrictId,
|
||||
/// Deterministic seed (derived from master seed via SeedChain).
|
||||
pub seed: u64,
|
||||
pub district_type: DistrictType,
|
||||
/// World context (system, world, neighboring districts).
|
||||
pub context: DistrictContext,
|
||||
|
||||
// ── Classification ────────────────────────────────────
|
||||
pub world_tier: WorldTier,
|
||||
pub complexity: ComplexityTier,
|
||||
pub setting: SettingType,
|
||||
pub layout_mode: DistrictLayoutMode,
|
||||
|
||||
// ── Spatial Structure ─────────────────────────────────
|
||||
/// The 4×4 block grid (each block = 128×128 sim tiles = 2×2 chunks).
|
||||
pub blocks: [[BlockSkeleton; 4]; 4],
|
||||
/// Multi-block reservations (skyscrapers, parks, terminals, plazas).
|
||||
pub reservations: Vec<MultiBlockReservation>,
|
||||
/// Corridor spines connecting key access points.
|
||||
pub corridors: Vec<CorridorSpine>,
|
||||
/// Number of floor levels in this district (count, unsigned — D-110).
|
||||
pub z_levels: u8,
|
||||
|
||||
// ── Social Structure ──────────────────────────────────
|
||||
pub social_sites: Vec<SocialSitePlacement>,
|
||||
pub access_points: Vec<AccessPoint>,
|
||||
|
||||
// ── Cultural / World Context ──────────────────────────
|
||||
pub society_profile: SocietyProfileRef,
|
||||
pub zone_palette: Vec<ZoneDefinition>,
|
||||
|
||||
// ── Boundary System ───────────────────────────────────
|
||||
pub boundaries: DistrictBoundaries,
|
||||
|
||||
// ── Validation ────────────────────────────────────────
|
||||
/// Guarantee audit result. `None` for Empty districts.
|
||||
pub guarantee_audit: Option<GuaranteeAuditResult>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// D-110: FloorZone.z_level must be i8 (signed) to support negative sub-levels.
|
||||
#[test]
|
||||
fn floor_zone_z_level_is_signed() {
|
||||
let fz = FloorZone {
|
||||
z_level: -2,
|
||||
zone_type: ZoningType::Restricted,
|
||||
zone_palette: ZonePalette {
|
||||
base: String::new(),
|
||||
modifiers: vec![],
|
||||
},
|
||||
access_tier: AccessTier::BreachOnly,
|
||||
};
|
||||
assert_eq!(fz.z_level, -2, "sub-basement z_level must round-trip as i8");
|
||||
}
|
||||
|
||||
/// D-110: MultiBlockReservation.base_z must be i8 (signed) for underground buildings.
|
||||
#[test]
|
||||
fn reservation_base_z_is_signed() {
|
||||
let r = MultiBlockReservation {
|
||||
blocks: vec![(0, 0)],
|
||||
template_tag: "deep-mine".into(),
|
||||
function: ReservationFunction::UndergroundComplex,
|
||||
z_levels: 30,
|
||||
base_z: -30,
|
||||
floor_zones: vec![],
|
||||
z_band_count: 5,
|
||||
z_band_zones: vec![],
|
||||
vertical_corridors: vec![],
|
||||
hosted_sites: vec![],
|
||||
};
|
||||
assert_eq!(r.base_z, -30, "deep mine base_z must be representable as i8");
|
||||
assert_eq!(r.z_levels, 30u8, "z_levels count must remain u8");
|
||||
}
|
||||
|
||||
/// D-110: z_levels (count) remains u8 on DistrictSkeleton.
|
||||
#[test]
|
||||
fn district_skeleton_z_levels_is_unsigned() {
|
||||
// Verify z_levels is u8 (cannot hold negative value). Static assertion:
|
||||
// if this compiled with z_levels = 255u8, the type is correct.
|
||||
fn assert_u8(_: u8) {}
|
||||
let z_levels: u8 = 4;
|
||||
assert_u8(z_levels);
|
||||
assert!(z_levels > 0, "z_levels is always at least 1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Fuzzy tests for procedurally generated maps (QA #509, QA epic #455).
|
||||
//!
|
||||
//! Tests structural invariants across 50 random seeds. Each seed produces
|
||||
//! a unique map; all 4 invariants must hold for every seed.
|
||||
//!
|
||||
//! Invariants tested:
|
||||
//! 1. Connectivity — all walkable tiles reachable from player start (BFS)
|
||||
//! 2. Entity bounds — all entity positions within map bounds
|
||||
//! 3. Door adjacency — every door has walkable tiles on both sides
|
||||
//! 4. Tile count — walkable tile count meets minimum floor (MIN_WALKABLE_TILES)
|
||||
//!
|
||||
//! Spec reference: D-010 (deterministic simulation, SimRng seeding), D-030 (testability)
|
||||
|
||||
use rand::Rng;
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use settled_reach_server::simulation::movement::{TilePosition, WalkabilityMap};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
// ── Map generation constants ────────────────────────────────────────────────
|
||||
|
||||
const MAP_W: i32 = 50;
|
||||
const MAP_H: i32 = 50;
|
||||
const NUM_ROOMS: usize = 5;
|
||||
|
||||
/// Minimum walkable tiles the generator must produce per map.
|
||||
///
|
||||
/// With 5 rooms of 5–10 tiles each plus corridors, a well-generated map
|
||||
/// comfortably exceeds this floor. A count below this flags a degenerate
|
||||
/// layout (e.g., all room-placement attempts rejected on a seed).
|
||||
/// Value chosen as ~2× the guaranteed-minimum fallback room (6×6 = 36 tiles).
|
||||
const MIN_WALKABLE_TILES: usize = 200;
|
||||
|
||||
// ── Data types ────────────────────────────────────────────────────────────────
|
||||
|
||||
struct Room {
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
}
|
||||
|
||||
/// A door placement with its two walkable neighbours (one on each side).
|
||||
struct DoorPlacement {
|
||||
pos: TilePosition,
|
||||
side_a: TilePosition,
|
||||
side_b: TilePosition,
|
||||
}
|
||||
|
||||
/// A fully generated procedural map ready for invariant checks.
|
||||
struct ProceduralMap {
|
||||
walkability: WalkabilityMap,
|
||||
/// Player start position — guaranteed walkable.
|
||||
player_start: TilePosition,
|
||||
/// All entity positions: player start + door positions.
|
||||
entities: Vec<TilePosition>,
|
||||
/// All door placements with neighbour tiles pre-computed.
|
||||
doors: Vec<DoorPlacement>,
|
||||
/// Number of walkable tiles (pre-counted for Invariant 4).
|
||||
walkable_count: usize,
|
||||
}
|
||||
|
||||
// ── Map generator ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn generate_map(seed: u64) -> ProceduralMap {
|
||||
let mut rng = ChaCha20Rng::seed_from_u64(seed);
|
||||
let mut wm = WalkabilityMap::new_blocked(MAP_W, MAP_H, 1);
|
||||
let mut rooms: Vec<Room> = Vec::new();
|
||||
let mut door_placements: Vec<DoorPlacement> = Vec::new();
|
||||
|
||||
// ── Room placement (up to 200 attempts for NUM_ROOMS non-overlapping rooms) ──
|
||||
for _ in 0..200 {
|
||||
if rooms.len() >= NUM_ROOMS {
|
||||
break;
|
||||
}
|
||||
let w: i32 = rng.random_range(5_i32..=10);
|
||||
let h: i32 = rng.random_range(5_i32..=10);
|
||||
// Keep 2-tile margin from map edges.
|
||||
let x: i32 = rng.random_range(2_i32..(MAP_W - w - 2));
|
||||
let y: i32 = rng.random_range(2_i32..(MAP_H - h - 2));
|
||||
|
||||
// Reject if overlaps an existing room (1-tile padding).
|
||||
let overlaps = rooms.iter().any(|r| {
|
||||
x < r.x + r.w + 1 && x + w + 1 > r.x && y < r.y + r.h + 1 && y + h + 1 > r.y
|
||||
});
|
||||
|
||||
if !overlaps {
|
||||
for ry in y..(y + h) {
|
||||
for rx in x..(x + w) {
|
||||
wm.set_walkable(&TilePosition::new(rx, ry, 0), true);
|
||||
}
|
||||
}
|
||||
rooms.push(Room { x, y, w, h });
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee at least one room to ensure a valid player start.
|
||||
if rooms.is_empty() {
|
||||
let x = 5;
|
||||
let y = 5;
|
||||
for ry in y..(y + 6) {
|
||||
for rx in x..(x + 6) {
|
||||
wm.set_walkable(&TilePosition::new(rx, ry, 0), true);
|
||||
}
|
||||
}
|
||||
rooms.push(Room { x, y, w: 6, h: 6 });
|
||||
}
|
||||
|
||||
// ── Connect consecutive rooms with L-shaped corridors ─────────────────────
|
||||
for i in 1..rooms.len() {
|
||||
let prev_cx = rooms[i - 1].x + rooms[i - 1].w / 2;
|
||||
let prev_cy = rooms[i - 1].y + rooms[i - 1].h / 2;
|
||||
let curr_cx = rooms[i].x + rooms[i].w / 2;
|
||||
let curr_cy = rooms[i].y + rooms[i].h / 2;
|
||||
|
||||
// Horizontal segment at prev_cy, from prev_cx to curr_cx.
|
||||
let x_min = prev_cx.min(curr_cx);
|
||||
let x_max = prev_cx.max(curr_cx);
|
||||
for x in x_min..=x_max {
|
||||
wm.set_walkable(&TilePosition::new(x, prev_cy, 0), true);
|
||||
}
|
||||
|
||||
// Vertical segment at curr_cx, from prev_cy to curr_cy.
|
||||
let y_min = prev_cy.min(curr_cy);
|
||||
let y_max = prev_cy.max(curr_cy);
|
||||
for y in y_min..=y_max {
|
||||
wm.set_walkable(&TilePosition::new(curr_cx, y, 0), true);
|
||||
}
|
||||
|
||||
// Place a door at the elbow (curr_cx, prev_cy).
|
||||
// Neighbours: (curr_cx-1, prev_cy) and (curr_cx+1, prev_cy).
|
||||
if curr_cx > 0 && curr_cx < MAP_W - 1 {
|
||||
let door_pos = TilePosition::new(curr_cx, prev_cy, 0);
|
||||
let side_a = TilePosition::new(curr_cx - 1, prev_cy, 0);
|
||||
let side_b = TilePosition::new(curr_cx + 1, prev_cy, 0);
|
||||
if wm.can_move_to(&door_pos) && wm.can_move_to(&side_a) && wm.can_move_to(&side_b) {
|
||||
door_placements.push(DoorPlacement {
|
||||
pos: door_pos,
|
||||
side_a,
|
||||
side_b,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Count walkable tiles ───────────────────────────────────────────────────
|
||||
let mut walkable_count = 0;
|
||||
for y in 0..MAP_H {
|
||||
for x in 0..MAP_W {
|
||||
if wm.can_move_to(&TilePosition::new(x, y, 0)) {
|
||||
walkable_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Player start: centre of first room (always walkable by construction).
|
||||
let player_start = TilePosition::new(
|
||||
rooms[0].x + rooms[0].w / 2,
|
||||
rooms[0].y + rooms[0].h / 2,
|
||||
0,
|
||||
);
|
||||
|
||||
let mut entities = vec![player_start];
|
||||
entities.extend(door_placements.iter().map(|d| d.pos));
|
||||
|
||||
ProceduralMap {
|
||||
walkability: wm,
|
||||
player_start,
|
||||
entities,
|
||||
doors: door_placements,
|
||||
walkable_count,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant checks ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Invariant 1: all walkable tiles reachable from player start via BFS.
|
||||
fn check_connectivity(map: &ProceduralMap, seed: u64) -> Result<(), String> {
|
||||
if !map.walkability.can_move_to(&map.player_start) {
|
||||
return Err(format!(
|
||||
"[seed {seed}] Player start {:?} is not walkable",
|
||||
map.player_start
|
||||
));
|
||||
}
|
||||
|
||||
let mut visited = std::collections::BTreeSet::new();
|
||||
let mut queue = VecDeque::new();
|
||||
queue.push_back(map.player_start);
|
||||
visited.insert(map.player_start);
|
||||
|
||||
while let Some(pos) = queue.pop_front() {
|
||||
for neighbor in pos.cardinal_neighbors() {
|
||||
if neighbor.x >= 0
|
||||
&& neighbor.x < MAP_W
|
||||
&& neighbor.y >= 0
|
||||
&& neighbor.y < MAP_H
|
||||
&& map.walkability.can_move_to(&neighbor)
|
||||
&& !visited.contains(&neighbor)
|
||||
{
|
||||
visited.insert(neighbor);
|
||||
queue.push_back(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if visited.len() != map.walkable_count {
|
||||
Err(format!(
|
||||
"[seed {seed}] Connectivity: {} walkable tiles but only {} reachable from {:?}",
|
||||
map.walkable_count,
|
||||
visited.len(),
|
||||
map.player_start
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Invariant 2: every entity position is within map bounds.
|
||||
fn check_entity_bounds(map: &ProceduralMap, seed: u64) -> Result<(), String> {
|
||||
for pos in &map.entities {
|
||||
if pos.x < 0 || pos.x >= MAP_W || pos.y < 0 || pos.y >= MAP_H {
|
||||
return Err(format!(
|
||||
"[seed {seed}] Entity at {:?} is outside map bounds ({MAP_W}×{MAP_H})",
|
||||
pos
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invariant 3: every door has at least one walkable tile on each side.
|
||||
fn check_door_adjacency(map: &ProceduralMap, seed: u64) -> Result<(), String> {
|
||||
for door in &map.doors {
|
||||
if !map.walkability.can_move_to(&door.side_a) {
|
||||
return Err(format!(
|
||||
"[seed {seed}] Door at {:?}: side_a {:?} is not walkable",
|
||||
door.pos, door.side_a
|
||||
));
|
||||
}
|
||||
if !map.walkability.can_move_to(&door.side_b) {
|
||||
return Err(format!(
|
||||
"[seed {seed}] Door at {:?}: side_b {:?} is not walkable",
|
||||
door.pos, door.side_b
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invariant 4: walkable tile count meets the minimum floor.
|
||||
///
|
||||
/// Catches degenerate maps where the generator failed to carve usable space.
|
||||
/// BSP room carvers don't have a fixed density target — they have natural
|
||||
/// variance from room sizes and corridor lengths. A minimum floor is the
|
||||
/// correct invariant for this generator type.
|
||||
fn check_tile_count(map: &ProceduralMap, seed: u64) -> Result<(), String> {
|
||||
if map.walkable_count < MIN_WALKABLE_TILES {
|
||||
Err(format!(
|
||||
"[seed {seed}] Tile count {}: expected at least {MIN_WALKABLE_TILES} walkable tiles \
|
||||
(map too sparse — generator may have failed to place rooms)",
|
||||
map.walkable_count,
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main fuzzy test ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Runs all 4 structural invariants across 50 deterministic seeds.
|
||||
///
|
||||
/// Each seed produces a unique procedurally generated map. All 4 invariants
|
||||
/// must hold for every seed.
|
||||
///
|
||||
/// Acceptance: `cargo test -p server -- fuzzy_map_50_seeds_all_invariants`
|
||||
#[test]
|
||||
fn fuzzy_map_50_seeds_all_invariants() {
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
let mut seeds_ok = 0u32;
|
||||
|
||||
for seed in 0..50u64 {
|
||||
let map = generate_map(seed);
|
||||
|
||||
let mut seed_failures: Vec<String> = Vec::new();
|
||||
|
||||
if let Err(e) = check_connectivity(&map, seed) {
|
||||
seed_failures.push(e);
|
||||
}
|
||||
if let Err(e) = check_entity_bounds(&map, seed) {
|
||||
seed_failures.push(e);
|
||||
}
|
||||
if let Err(e) = check_door_adjacency(&map, seed) {
|
||||
seed_failures.push(e);
|
||||
}
|
||||
if let Err(e) = check_tile_count(&map, seed) {
|
||||
seed_failures.push(e);
|
||||
}
|
||||
|
||||
if seed_failures.is_empty() {
|
||||
seeds_ok += 1;
|
||||
} else {
|
||||
failures.extend(seed_failures);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"{} seed(s) passed, {} invariant violation(s):\n{}",
|
||||
seeds_ok,
|
||||
failures.len(),
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// Individual connectivity test — all walkable tiles reachable from player start.
|
||||
#[test]
|
||||
fn fuzzy_map_connectivity_holds_across_seeds() {
|
||||
for seed in 0..50u64 {
|
||||
let map = generate_map(seed);
|
||||
check_connectivity(&map, seed).unwrap_or_else(|e| panic!("{}", e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual bounds test — all entity positions within map bounds.
|
||||
#[test]
|
||||
fn fuzzy_map_entity_bounds_respected_across_seeds() {
|
||||
for seed in 0..50u64 {
|
||||
let map = generate_map(seed);
|
||||
check_entity_bounds(&map, seed).unwrap_or_else(|e| panic!("{}", e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual door adjacency test — walkable tiles on both sides of every door.
|
||||
#[test]
|
||||
fn fuzzy_map_door_adjacency_holds_across_seeds() {
|
||||
for seed in 0..50u64 {
|
||||
let map = generate_map(seed);
|
||||
check_door_adjacency(&map, seed).unwrap_or_else(|e| panic!("{}", e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual tile count test — walkable count meets minimum floor across all seeds.
|
||||
#[test]
|
||||
fn fuzzy_map_tile_count_meets_minimum_across_seeds() {
|
||||
for seed in 0..50u64 {
|
||||
let map = generate_map(seed);
|
||||
check_tile_count(&map, seed).unwrap_or_else(|e| panic!("{}", e));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user