diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd2bc863..3ba6da4cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Updated D-015/D-017 perception decisions to reflect simplified cone model - Moved connector scripts from db/connectors/ to tooling/db/ (#274) — backwards-compat symlink removed in #568 +### Added (server) +- Production NPC pool generation — 23 authored Sova NPCs spawn with EntanglementTag (Flat/Intrigue) based on triangle membership (#176, D-029) +- Authored triangle instantiation — 5 Sova triangles (3 active forks, 2 passive tensions) loaded from content YAML with deterministic IDs (#188, D-087) +- Contamination activation mechanic — timer-based storyteller fires after 30 game-minutes, pressures active triangles, emits ContaminationEvent (#254) +- Modifications data model stub — Vec on chunk entities, round-trips through save/load for future construction DLC (#567, D-112) +- Zone Gate gauntlet room — two-zone test room with door boundary, zone crossing detection system (#512) +- Fuzzy map tests — 50-seed randomized testing of procedural maps against 4 structural invariants (#509) + ### Removed - db/connectors symlink — all references now use tooling/db/ directly (#568) diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-knowledge.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-knowledge.yaml index 77745045c..76a5d6034 100644 --- a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-knowledge.yaml +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-knowledge.yaml @@ -1,7 +1,8 @@ -# Triangle 2: Worried Knowledge — unreported evidence and protective silence -# Active fork — Sera's choice about what to do with what she knows +# Triangle 3: Worried Knowledge — unreported evidence and protective silence +# Passive tension (D-087) — background pressure, not a direct player decision point canonical_id: worried-knowledge display_name: "Worried Knowledge" +classification: passive_tension description: > Sera Venn (Commission field tech, detective's FRIEND) is sitting on unreported evidence about Kael's manifest discrepancies. She's protecting diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-partner.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-partner.yaml index 2a2d31e2f..2a38e23dc 100644 --- a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-partner.yaml +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-partner.yaml @@ -1,7 +1,8 @@ -# Triangle 4: Worried Partner — ring pressure on personal relationships -# Active fork — the emotional heart of the smuggler's FRIEND arc +# Triangle 5: Worried Partner — ring pressure on personal relationships +# Passive tension (D-087) — background pressure, not a direct player decision point canonical_id: worried-partner display_name: "Worried Partner" +classification: passive_tension description: > Kael Davan (dock worker, ring member, smuggler's FRIEND) is under increasing ring pressure from Devra (ring coordinator). Naia Tamm diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 7b63e3400..bb9fd7bed 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -1,4 +1,4 @@ -//! Content → ECS entity spawning (two-phase). +//! Content → ECS entity spawning (three-phase). //! //! Maps intermediate content types from the loader into bevy_ecs //! Components and Resources. The separation ensures content schema @@ -38,6 +38,7 @@ use crate::simulation::time::DayPhase; use rand::Rng as _; use crate::content::template::{ FullTemplateDef, RoleId, TemplateId, TemplateOwnership, TemplateReference, TemplateReferenceMap, + TriangleClassification, TriangleId, TrianglePhase, TriangleState, }; use crate::npc::generate::{generate_npc, RoleDefinition}; use crate::npc::{Relationship, Relationships}; @@ -68,7 +69,8 @@ pub struct SpawnResult { /// Spawn all loaded content into the ECS world. /// /// This is the main entry point for content → ECS conversion. -/// Runs Phase 1 (entity spawning) then Phase 2 (cross-reference resolution). +/// Runs Phase 1 (entity spawning), Phase 2 (cross-reference resolution), +/// then Phase 3 (authored triangle instantiation, #188). pub fn spawn_content(world: &mut World, store: &ContentStore) -> SpawnResult { // Phase 1: spawn entities and build canonical_id → StableId map let result = spawn_entities(world, store); @@ -76,9 +78,13 @@ pub fn spawn_content(world: &mut World, store: &ContentStore) -> SpawnResult { // Phase 2: resolve cross-references using the id map resolve_cross_references(world, store, &result); + // Phase 3: instantiate authored triangles (#188, D-087) + let triangles_spawned = instantiate_authored_triangles(world, store, &result); + tracing::info!( - "Content spawn complete: {} NPCs (phase 1), cross-references resolved (phase 2)", - result.npcs_spawned + "Content spawn complete: {} NPCs (phase 1), cross-references resolved (phase 2), {} triangles (phase 3)", + result.npcs_spawned, + triangles_spawned ); result @@ -214,6 +220,16 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR // TODO: CombatCapability — no content schema type exists yet. When combat content // is authored, add weapon_proficiency + combat_style mapping here. + // Entanglement tag (D-029, #176) — derived from authored triangle_membership. + // Non-empty triangle_membership → Intrigue (active narrative participant). + // Empty → Flat (background population). Mundane reserved for procedural NPCs. + let entanglement = if profile.triangle_membership.is_empty() { + npc::EntanglementTag::Flat + } else { + npc::EntanglementTag::Intrigue + }; + entity_commands.insert(entanglement); + // Vision + awareness components (#115, #244) — must match generate_npc(). // Without these, vision/awareness systems silently skip content-spawned NPCs. entity_commands.insert(( @@ -535,6 +551,100 @@ fn resolve_routines( tracing::debug!("Resolved routines for {} NPCs", routines_resolved); } +// =========================================================================== +// Phase 3: Authored triangle instantiation (#188, D-087) +// =========================================================================== + +/// Phase 3: Instantiate authored triangles from content YAML. +/// +/// Authored triangles (triangles/*.yaml) define fixed narrative structures +/// with NPC members referenced by canonical_id. Unlike template triangles +/// (generated from TriangleDef role triples), authored triangles carry +/// a canonical slug, explicit member assignments, and D-087 classification. +/// +/// Returns the number of triangles successfully spawned. +fn instantiate_authored_triangles( + world: &mut World, + store: &ContentStore, + result: &SpawnResult, +) -> u32 { + let mut count = 0u32; + + // Sentinel template_id for authored (non-template) triangles. + let authored_template_id = TemplateId::from_seed_and_slug(0, "authored"); + + for (district_id, content) in &store.districts { + for triangle in &content.triangles { + // Resolve NPC member references to StableIds. + // YAML uses "npc:kael-davan" — matches NpcProfile.canonical_id directly. + let mut role_assignments = BTreeMap::new(); + let mut all_resolved = true; + + for member in &triangle.members { + let Some(&stable_id) = result.npc_ids.get(&member.npc) else { + tracing::warn!( + "Triangle '{}' in district '{}': cannot resolve member '{}' — NPC not in npc_ids map", + triangle.canonical_id, + district_id, + member.npc, + ); + all_resolved = false; + break; + }; + + role_assignments.insert(RoleId::new(&member.role), stable_id); + } + + if !all_resolved { + continue; + } + + // Determine D-087 classification from YAML field. + let classification = match triangle.classification.as_deref() { + Some("passive_tension") => TriangleClassification::PassiveTension, + _ => TriangleClassification::ActiveFork, + }; + + // Initial phase: ActiveFork starts Simmering (tension building), + // PassiveTension starts Dormant (background, awaiting conditions). + let phase = match classification { + TriangleClassification::ActiveFork => TrianglePhase::Simmering, + TriangleClassification::PassiveTension => TrianglePhase::Dormant, + }; + + // Deterministic TriangleId from canonical_id slug (D-010). + let triangle_id = TriangleId::from_seed_and_slug(0, &triangle.canonical_id); + + let state = TriangleState { + triangle_id, + role_assignments, + tension: 0, + phase, + tension_rate: 1, + template_id: authored_template_id, + classification, + }; + + world.spawn((state, ActiveSim)); + count += 1; + + tracing::debug!( + "Spawned authored triangle: {} ({:?}, {:?})", + triangle.canonical_id, + classification, + phase, + ); + } + } + + tracing::info!( + "Phase 3 complete: {} authored triangles spawned", + count + ); + + count +} + // --------------------------------------------------------------------------- // Content value → ECS enum mapping functions // --------------------------------------------------------------------------- diff --git a/server/src/content/template.rs b/server/src/content/template.rs index 95eab01fa..622704f23 100644 --- a/server/src/content/template.rs +++ b/server/src/content/template.rs @@ -332,6 +332,19 @@ impl TemplateReferenceMap { pub struct TriangleId(pub u64); impl TriangleId { + /// Compute a deterministic `TriangleId` from a seed and a canonical slug. + /// + /// Used for authored triangles loaded from content YAML (#188). + /// Mirrors `TemplateId::from_seed_and_slug` — same FNV-1a pattern (D-010). + pub fn from_seed_and_slug(seed: u64, slug: &str) -> Self { + let mut hash = seed ^ 0xcbf29ce484222325; // FNV-1a offset basis, XOR'd with seed + for byte in slug.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); // FNV-1a prime + } + TriangleId(hash) + } + /// Compute a deterministic `TriangleId` from a seed and three role IDs. /// /// Roles are sorted before hashing to ensure the same triple always produces @@ -591,6 +604,20 @@ impl FullTemplateDef { // #107 — Intra-template triangle generation // =========================================================================== +/// Narrative classification of a triangle (D-087, #188). +/// +/// Active forks drive narrative conflict — the player's decisions directly +/// affect outcomes. Passive tensions provide background pressure — observable +/// behavioral signals without a direct player decision point. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum TriangleClassification { + /// Drives narrative conflict — player decisions affect outcomes (D-087). + #[default] + ActiveFork, + /// Background tension — observable tells without direct decision point. + PassiveTension, +} + /// Phase of a triangle's lifecycle. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TrianglePhase { @@ -624,6 +651,9 @@ pub struct TriangleState { pub tension_rate: u8, /// Which template owns this triangle. pub template_id: TemplateId, + /// Narrative classification (D-087, #188): active fork vs passive tension. + #[serde(default)] + pub classification: TriangleClassification, } /// Result of triangle generation for a single template. @@ -745,6 +775,7 @@ pub fn generate_intra_template_triangles( phase: TrianglePhase::Simmering, tension_rate, template_id, + classification: TriangleClassification::ActiveFork, }); } @@ -970,6 +1001,7 @@ pub fn generate_cross_template_triangles( phase: TrianglePhase::Simmering, tension_rate, template_id: template_a_id, // cross-template triangle owned by template_a + classification: TriangleClassification::ActiveFork, }); } @@ -1591,6 +1623,7 @@ mod tests { phase: TrianglePhase::Simmering, tension_rate: 3, template_id: TemplateId(100), + classification: TriangleClassification::ActiveFork, }; let bytes = rmp_serde::to_vec_named(&state).expect("serialize"); @@ -1632,6 +1665,7 @@ mod tests { phase: TrianglePhase::Active, tension_rate: 5, template_id: TemplateId(1), + classification: TriangleClassification::ActiveFork, }, ActiveSim, )) @@ -1676,6 +1710,7 @@ mod tests { phase: TrianglePhase::Active, tension_rate: 5, template_id: TemplateId(1), + classification: TriangleClassification::ActiveFork, }, ActiveSim, )) @@ -1702,6 +1737,7 @@ mod tests { phase: TrianglePhase::Active, tension_rate: 3, template_id: TemplateId(1), + classification: TriangleClassification::ActiveFork, }) .id(); @@ -1713,6 +1749,7 @@ mod tests { phase: TrianglePhase::Simmering, tension_rate: 2, template_id: TemplateId(1), + classification: TriangleClassification::ActiveFork, }) .id(); diff --git a/server/src/content/types.rs b/server/src/content/types.rs index 50b9e6c7a..695ced368 100644 --- a/server/src/content/types.rs +++ b/server/src/content/types.rs @@ -196,6 +196,9 @@ pub struct Triangle { pub forks: Vec, #[serde(default)] pub resolution_states: Vec, + /// D-087 classification: "active_fork" (default) or "passive_tension". + #[serde(default)] + pub classification: Option, } #[derive(Debug, Deserialize)] diff --git a/server/src/main.rs b/server/src/main.rs index ae05739df..91e97c2b0 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -156,6 +156,7 @@ fn main() { hot_reload: false, }); app.add_plugins(settled_reach_server::content::ContentPlugin); + app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin); app.insert_resource(BridgeResource::new(bridge)); app.insert_resource(HandshakeState::Complete); @@ -332,6 +333,7 @@ fn dump_schedule_graph() { app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin); app.add_plugins(settled_reach_server::npc::NpcPlugin); app.add_plugins(settled_reach_server::content::ContentPlugin); + app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin); app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(0)); // Access Schedules resource directly — schedules are populated by plugins diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 9df5d6087..e9e4be756 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -152,6 +152,25 @@ pub enum DeviationTrigger { Confrontation, } +// --------------------------------------------------------------------------- +// Entanglement tag (D-029, #176) +// --------------------------------------------------------------------------- + +/// Marks an NPC's narrative entanglement level (D-029). +/// +/// - `Flat`: background population — no triangle involvement, minimal story role. +/// - `Mundane`: has routines and personality but no active triangle membership. +/// - `Intrigue`: participates in at least one triangle — drives narrative tension. +/// +/// For authored NPCs: determined by YAML `triangle_membership` field. +/// For procedural NPCs: assigned by the 30/50/20 ratio via SimRng. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum EntanglementTag { + Flat, + Mundane, + Intrigue, +} + // --------------------------------------------------------------------------- // Axis 1: Want (D-024) // --------------------------------------------------------------------------- diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs new file mode 100644 index 000000000..7684fe700 --- /dev/null +++ b/server/src/simulation/generator.rs @@ -0,0 +1,576 @@ +//! 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; +/// 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 in basis points (10000 = 1.0×, range 7500–20000 for 0.75–2.0×). + /// Default 10000 = 4 visual tiles. Uses integer to avoid f32 non-determinism (D-010). + pub street_width_bps: u16, +} + +/// 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, + pub chunk_layout: ChunkLayout, + pub hosted_sites: Vec, + pub era: Era, + pub era_modifications: Vec, + pub era_cause: Option, + /// Build density percentage (0 = open/empty, 100 = fully built-up). + /// Integer to avoid f32 non-determinism (D-010). + pub density_pct: u8, + pub landmark: Option, +} + +/// 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 (0-indexed band indices, not absolute z-levels) this + /// corridor connects. See D-110 for signed z-level coordinate system. + pub z_bands_connected: Vec, + 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, +} + +/// 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, + pub role_slots: Vec, + pub active_phases: Vec, +} + +/// Triangle assignment within a social site. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TriangleAssignment { + pub template: TriangleTemplate, + pub purposes: Vec, + pub participants: Vec, + /// 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, + pub structural_changes: Vec, + pub placed_objects: Vec, + pub removed_objects: Vec, +} + +/// 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, + /// 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, + /// Vertical connection specs (stairs, elevators, ladders). + pub vertical_corridors: Vec, + pub hosted_sites: Vec, +} + +// --------------------------------------------------------------------------- +// 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, + /// Corridor spines connecting key access points. + pub corridors: Vec, + /// Number of floor levels in this district (count, unsigned — D-110). + pub z_levels: u8, + + // ── Social Structure ────────────────────────────────── + pub social_sites: Vec, + pub access_points: Vec, + + // ── Cultural / World Context ────────────────────────── + pub society_profile: SocietyProfileRef, + pub zone_palette: Vec, + + // ── Boundary System ─────────────────────────────────── + pub boundaries: DistrictBoundaries, + + // ── Validation ──────────────────────────────────────── + /// Guarantee audit result. `None` for Empty districts. + pub guarantee_audit: Option, +} + +// --------------------------------------------------------------------------- +// 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"); + } +} diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index f0d6958d5..048b4a655 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -9,10 +9,12 @@ pub mod conversation; pub mod dialogue; pub mod examine; pub mod follow; +pub mod generator; pub mod input; pub mod interaction; pub mod inventory; pub mod listening; +pub mod modification; pub mod monologue; pub mod movement; pub mod npc_knowledge_transfer; @@ -63,6 +65,9 @@ impl Plugin for SimulationPlugin { .init_resource::() .init_resource::() .init_resource::() + // Zone crossing event queue (#512, D-077): detect when player moves between zones. + .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -116,6 +121,12 @@ impl Plugin for SimulationPlugin { .before(crate::perception::observer::compute_observer_snapshot), time::advance_tick.after(path_follow::cleanup_path_blocked), ), + ) + // Zone crossing detection (#512, D-077) — separate call to stay within + // Bevy's 20-element system tuple limit. + .add_systems( + Update, + zone::detect_zone_crossings.after(movement::validate_movement), ); tracing::debug!("SimulationPlugin initialized"); diff --git a/server/src/simulation/modification.rs b/server/src/simulation/modification.rs new file mode 100644 index 000000000..940028b4d --- /dev/null +++ b/server/src/simulation/modification.rs @@ -0,0 +1,114 @@ +//! Modification data model stub (D-111/D-112, #567). +//! +//! Entry point for the future player construction system (DLC scope). +//! Tracks player-placed modifications to static and mobile chunks. +//! No construction logic ships in v0.1 — this is a data model stub only. +//! +//! The `Modifications` component can be attached to any entity that represents +//! a spatial chunk (static or mobile). Currently unused at runtime; the +//! `SaveStateV1` field ensures the data model round-trips through save/load +//! so future DLC can populate it without a save format migration. + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::simulation::movement::TilePosition; + +/// A single player-placed modification to a chunk. +/// +/// Records what was placed, where, and when. The `modification_type` enum +/// will be extended by the construction DLC — the stub contains only a +/// `Placeholder` variant to keep the enum non-empty. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Modification { + /// Tile position of this modification within the chunk. + pub position: TilePosition, + /// What kind of modification was placed. + pub modification_type: ModificationType, + /// Simulation tick when this modification was placed. + pub placed_at_tick: u64, +} + +/// Type of modification placed by the player. +/// +/// Stub enum — will be extended by the construction DLC with variants +/// like `Wall`, `Floor`, `Furniture`, `Terminal`, etc. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ModificationType { + /// Placeholder variant. Prevents the enum from being uninhabited + /// and allows save format round-tripping before real variants ship. + Placeholder, +} + +/// Component: player-placed modifications on a chunk entity. +/// +/// Attach to any entity that represents a modifiable spatial region +/// (static chunk, mobile chunk, etc.). Initially empty for all entities. +#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)] +pub struct Modifications { + pub entries: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn modification_type_placeholder_serializes() { + let mod_type = ModificationType::Placeholder; + let bytes = rmp_serde::to_vec_named(&mod_type).expect("serialize"); + let recovered: ModificationType = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(mod_type, recovered); + } + + #[test] + fn modification_roundtrips_via_messagepack() { + let modification = Modification { + position: TilePosition::new(5, 10, 0), + modification_type: ModificationType::Placeholder, + placed_at_tick: 42, + }; + + let bytes = rmp_serde::to_vec_named(&modification).expect("serialize"); + let recovered: Modification = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(modification, recovered); + } + + #[test] + fn modifications_component_defaults_to_empty() { + let mods = Modifications::default(); + assert!(mods.entries.is_empty()); + } + + #[test] + fn modifications_with_entries_roundtrips() { + let mods = Modifications { + entries: vec![ + Modification { + position: TilePosition::new(1, 2, 0), + modification_type: ModificationType::Placeholder, + placed_at_tick: 100, + }, + Modification { + position: TilePosition::new(3, 4, 1), + modification_type: ModificationType::Placeholder, + placed_at_tick: 200, + }, + ], + }; + + let bytes = rmp_serde::to_vec_named(&mods).expect("serialize"); + let recovered: Modifications = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(mods.entries.len(), recovered.entries.len()); + assert_eq!(mods.entries[0], recovered.entries[0]); + assert_eq!(mods.entries[1], recovered.entries[1]); + } + + #[test] + fn empty_modifications_roundtrips() { + let mods = Modifications::default(); + let bytes = rmp_serde::to_vec_named(&mods).expect("serialize"); + let recovered: Modifications = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert!(recovered.entries.is_empty()); + } +} diff --git a/server/src/simulation/save_io.rs b/server/src/simulation/save_io.rs index 16519a59f..ac14a2ece 100644 --- a/server/src/simulation/save_io.rs +++ b/server/src/simulation/save_io.rs @@ -28,8 +28,10 @@ use crate::simulation::save_state::{ }; use crate::knowledge::types::StableId; use crate::simulation::interaction::DoorState; -use crate::simulation::tier::BackgroundSim; +use crate::simulation::tier::{ActiveSim, BackgroundSim}; use crate::simulation::time::SimulationTime; +use crate::content::template::TriangleCrisisEventQueue; +use crate::storyteller::{ContaminationActive, ContaminationEventQueue}; /// Errors from save/load operations (#553). #[derive(Debug, Error)] @@ -143,6 +145,10 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> ids.sort_by_key(|id| id.0); ids }, + modifications: vec![], // TODO: persist when modification system is implemented + contamination_active: world + .get_resource::() + .map_or(false, |c| c.0), }; let bytes = state @@ -169,8 +175,11 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> /// 3. Re-spawn each NPC via `deserialize_npc_from_frozen`; register with /// `register_existing`; insert `BackgroundSim` tier marker. /// 4. Advance `EntityRegistry` counter past all restored IDs. -/// 5. Restore `RelationshipGraph`, `SimulationTime`, and `SimRng` resources. -/// 6. Update the player entity's `KnowledgeGraph` if a player entity exists. +/// 5. Despawn existing triangle entities (separate from NPCs — no `Npc` marker). +/// 6. Restore triangle states with `ActiveSim` so escalation/contamination systems see them. +/// 7. Restore resources: `RelationshipGraph`, `SimulationTime`, `SimRng`, +/// `ContaminationActive`, event queues (reset to prevent stale cross-load leakage). +/// 8. Restore door open states and player `KnowledgeGraph`. /// /// **Gotcha (D-010):** Bevy `Entity` handles are generational. `NpcSaveState` uses /// `StableId(u64)` throughout — `EntityRegistry` maps restored `StableId`s to the @@ -232,9 +241,22 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro world.insert_resource(state.relationship_graph); world.insert_resource(state.template_references); - // Restore triangle states (#250) — spawn dedicated entities for each. + // Despawn existing triangle entities before restoring from save. + // Triangle entities are separate from NPC entities (no Npc component), + // so the NPC despawn loop above does not catch them. Without this, + // loading a save would create duplicates — doubling tension escalation. + let triangle_entities: Vec = { + let mut q = world.query_filtered::>(); + q.iter(world).collect() + }; + for entity in triangle_entities { + world.despawn(entity); + } + + // Restore triangle states (#250) — spawn with ActiveSim so escalation + // and contamination systems (which filter With) can see them. for ts in &state.triangle_states { - world.spawn(ts.clone()); + world.spawn((ts.clone(), ActiveSim)); } { let mut t = world.resource_mut::(); @@ -243,9 +265,17 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro } world.insert_resource(SimRng::new(state.seed)); + // Restore contamination state (#254) — prevents double-firing on reload. + world.insert_resource(ContaminationActive(state.contamination_active)); + + // Reset event queues — prevent stale events from the pre-load world + // leaking into the post-load simulation. + world.insert_resource(ContaminationEventQueue::default()); + world.insert_resource(TriangleCrisisEventQueue::default()); + // Restore door open states (#246) — find door entities by StableId and toggle. if !state.open_doors.is_empty() { - let open_set: std::collections::HashSet<_> = state.open_doors.iter().copied().collect(); + let open_set: std::collections::BTreeSet<_> = state.open_doors.iter().copied().collect(); let door_entities: Vec<(Entity, StableId)> = { let mut q = world.query::<(Entity, &crate::knowledge::registry::StableEntityId, &DoorState)>(); q.iter(world) @@ -370,6 +400,7 @@ mod tests { w.insert_resource(SimRng::new(42)); w.insert_resource(RelationshipGraph::new()); w.init_resource::(); + w.init_resource::(); w } @@ -558,6 +589,8 @@ mod tests { template_references: TemplateReferenceMap::default(), triangle_states: vec![], open_doors: vec![], + modifications: vec![], + contamination_active: false, }; let bytes = bad_state.to_bytes().expect("serialize"); let path = temp_path(); @@ -715,4 +748,118 @@ mod tests { assert!(e2.to_string().contains("expected 1")); assert!(e2.to_string().contains("found 2")); } + + // ----------------------------------------------------------------------- + // Triangle state roundtrip (regression tests for missing ActiveSim + // and duplicate triangle entities on load) + // ----------------------------------------------------------------------- + + fn make_test_triangle(slug: &str, tension: u8) -> TriangleState { + use crate::content::template::{ + RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, + }; + let mut role_assignments = std::collections::BTreeMap::new(); + role_assignments.insert(RoleId::new("a"), StableId(1)); + role_assignments.insert(RoleId::new("b"), StableId(2)); + role_assignments.insert(RoleId::new("c"), StableId(3)); + + TriangleState { + triangle_id: TriangleId::from_seed_and_slug(0, slug), + role_assignments, + tension, + phase: TrianglePhase::Simmering, + tension_rate: 1, + template_id: TemplateId::from_seed_and_slug(0, "test"), + classification: TriangleClassification::ActiveFork, + } + } + + /// Regression: loaded triangle entities must have ActiveSim so that + /// escalation and contamination systems (which filter With) + /// can see them. + #[test] + fn load_from_file_restores_triangles_with_active_sim() { + let mut world = minimal_world(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + world.spawn((make_test_triangle("hub", 15), ActiveSim)); + world.spawn((make_test_triangle("bar", 30), ActiveSim)); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + load_from_file(&path, &mut world).expect("load"); + + // All restored triangles must have both TriangleState and ActiveSim. + let with_active_sim = { + let mut q = world.query_filtered::, With)>(); + q.iter(&world).count() + }; + assert_eq!( + with_active_sim, 2, + "loaded triangles must have ActiveSim — escalation/contamination systems require it" + ); + + let _ = std::fs::remove_file(&path); + } + + /// Regression: loading must not duplicate triangle entities — existing + /// triangles must be despawned before restoring from save. + #[test] + fn load_from_file_does_not_duplicate_triangles() { + let mut world = minimal_world(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + world.spawn((make_test_triangle("hub", 10), ActiveSim)); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + + // Load twice — should not accumulate triangles. + load_from_file(&path, &mut world).expect("load 1"); + load_from_file(&path, &mut world).expect("load 2"); + + let count = { + let mut q = world.query::<&TriangleState>(); + q.iter(&world).count() + }; + assert_eq!( + count, 1, + "loading twice must not create duplicate triangle entities" + ); + + let _ = std::fs::remove_file(&path); + } + + /// Triangle tension values must survive save/load roundtrip. + #[test] + fn load_from_file_preserves_triangle_tension() { + let mut world = minimal_world(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + world.spawn((make_test_triangle("hub", 42), ActiveSim)); + world.spawn((make_test_triangle("bar", 99), ActiveSim)); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + load_from_file(&path, &mut world).expect("load"); + + let mut tensions: Vec = { + let mut q = world.query::<&TriangleState>(); + q.iter(&world).map(|ts| ts.tension).collect() + }; + tensions.sort(); + assert_eq!( + tensions, + vec![42, 99], + "triangle tension values must survive save/load roundtrip" + ); + + let _ = std::fs::remove_file(&path); + } } diff --git a/server/src/simulation/save_state.rs b/server/src/simulation/save_state.rs index 5beddc540..4e52b2084 100644 --- a/server/src/simulation/save_state.rs +++ b/server/src/simulation/save_state.rs @@ -43,6 +43,7 @@ use crate::content::template::{TemplateOwnership, TemplateReferenceMap, Triangle use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::registry::StableEntityId; use crate::knowledge::types::StableId; +use crate::simulation::modification::Modification; use crate::npc::{ CombatCapability, Contentment, DailyRoutine, InformationInventory, JobPerformance, Npc, PersonalityTraits, Relationships, Secret, SecretSeverity, SkillSet, TellSystem, @@ -99,6 +100,17 @@ pub struct SaveStateV1 { /// for deterministic serialization (D-010). #[serde(default)] pub open_doors: Vec, + /// Player-placed modifications to map chunks (D-111/D-112, #567). + /// DLC stub — empty in v0.1. The save slot exists so future construction + /// DLC can populate it without a save format migration. + #[serde(default)] + pub modifications: Vec, + /// Whether contamination has already activated (#254). + /// Persisted to prevent double-firing on save/load — without this, + /// reloading a save after tick 300 would re-trigger contamination + /// and apply a duplicate tension delta to all ActiveFork triangles. + #[serde(default)] + pub contamination_active: bool, } /// Per-NPC state snapshot for `SaveStateV1`. @@ -411,6 +423,8 @@ mod tests { template_references: TemplateReferenceMap::default(), triangle_states: vec![], open_doors: vec![], + modifications: vec![], + contamination_active: false, } } @@ -808,6 +822,8 @@ mod tests { template_references: TemplateReferenceMap::default(), triangle_states: vec![], open_doors: vec![], + modifications: vec![], + contamination_active: false, }; let bytes = save.to_bytes().expect("serialize"); @@ -828,4 +844,60 @@ mod tests { })); assert!(result.is_err(), "must panic without StableEntityId"); } + + // ----------------------------------------------------------------------- + // Modifications stub round-trip (#567, D-111/D-112) + // ----------------------------------------------------------------------- + + #[test] + fn empty_modifications_roundtrips_in_save_state() { + // Acceptance (#567): empty modifications field survives save/load. + let state = minimal_save_state(); + assert!(state.modifications.is_empty()); + + let bytes = state.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + assert!( + recovered.modifications.is_empty(), + "empty modifications must roundtrip" + ); + + let bytes2 = recovered.to_bytes().expect("re-serialize"); + assert_eq!(bytes, bytes2, "modifications roundtrip must be idempotent"); + } + + #[test] + fn populated_modifications_roundtrips_in_save_state() { + // Acceptance (#567): non-empty modifications field survives save/load. + use crate::simulation::modification::{ModificationType, Modification}; + + let mut state = minimal_save_state(); + state.modifications = vec![ + Modification { + position: TilePosition::new(10, 20, 0), + modification_type: ModificationType::Placeholder, + placed_at_tick: 500, + }, + Modification { + position: TilePosition::new(3, 7, -1), + modification_type: ModificationType::Placeholder, + placed_at_tick: 1200, + }, + ]; + + let bytes = state.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + assert_eq!( + recovered.modifications.len(), + 2, + "two modifications must survive roundtrip" + ); + assert_eq!(recovered.modifications[0].position, TilePosition::new(10, 20, 0)); + assert_eq!(recovered.modifications[0].placed_at_tick, 500); + assert_eq!(recovered.modifications[1].position, TilePosition::new(3, 7, -1)); + assert_eq!(recovered.modifications[1].placed_at_tick, 1200); + + let bytes2 = recovered.to_bytes().expect("re-serialize"); + assert_eq!(bytes, bytes2, "modifications roundtrip must be idempotent"); + } } diff --git a/server/src/simulation/zone.rs b/server/src/simulation/zone.rs index ebefd79ef..e1dff9c00 100644 --- a/server/src/simulation/zone.rs +++ b/server/src/simulation/zone.rs @@ -4,6 +4,7 @@ //! - Observer snapshot: enriches VisibleTile with zone_id //! - Client AudioManager: zone crossfade triggers (D-073) //! - Client fog shader: deep fog temperature tint (D-059 layer 3) +//! - Zone Gate Gauntlet room: crossing-event detection (QA #512) //! //! BTreeMap per D-010 principle 4 (deterministic iteration). @@ -11,6 +12,8 @@ use std::collections::BTreeMap; use bevy_ecs::prelude::*; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; + /// Server-authoritative zone assignment for tiles. /// /// Each tile position maps to a zone ID. Tiles outside any defined zone @@ -48,6 +51,70 @@ impl ZoneMap { } } +/// Event emitted when the player crosses a zone boundary (D-077). +/// +/// Queued once per tick when the player's tile zone_id differs from the +/// previously recorded zone. `from` or `to` may be None for tiles outside +/// any defined zone (corridors, unzoned transitions). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ZoneCrossEvent { + /// Zone the player left (None = no zone on previous tile). + pub from: Option, + /// Zone the player entered (None = no zone on new tile). + pub to: Option, +} + +/// Resource: queue of zone-crossing events (drained each tick by consumers). +#[derive(Resource, Default)] +pub struct ZoneCrossEventQueue { + pub events: Vec, +} + +impl ZoneCrossEventQueue { + pub fn push(&mut self, event: ZoneCrossEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } +} + +/// Tracks the player's zone from the previous tick. +/// +/// Used by `detect_zone_crossings` to compare against the current tick's +/// zone and emit a `ZoneCrossEvent` when they differ. +#[derive(Resource, Debug, Default)] +pub struct PreviousPlayerZone(pub Option); + +/// System: detect zone crossings and queue a `ZoneCrossEvent`. +/// +/// Runs after `validate_movement` so the player position is current. +/// Compares the player's current tile zone (from `ZoneMap`) against +/// `PreviousPlayerZone`. Queues a `ZoneCrossEvent` on change and updates +/// the resource for the next tick. +pub fn detect_zone_crossings( + zone_map: Option>, + mut previous_zone: ResMut, + player_query: Query<&TilePosition, With>, + mut queue: ResMut, +) { + let Some(zone_map) = zone_map else { + return; + }; + let Ok(pos) = player_query.single() else { + return; + }; + let current_zone = zone_map.zone_at(pos.x, pos.y, pos.z); + if current_zone != previous_zone.0 { + queue.push(ZoneCrossEvent { + from: previous_zone.0, + to: current_zone, + }); + previous_zone.0 = current_zone; + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/src/storyteller/mod.rs b/server/src/storyteller/mod.rs index 34a0fc9f7..4b980210f 100644 --- a/server/src/storyteller/mod.rs +++ b/server/src/storyteller/mod.rs @@ -1,15 +1,314 @@ -// Storyteller module - Rimworld-style storyteller system -// Event generation, pacing, hubris wall mechanics +//! Storyteller module — Rimworld-style narrative pacing (#254). +//! +//! The storyteller is the game's hidden director. It manages event timing, +//! pressure escalation, and the hubris wall (future). Its first concrete +//! mechanic is **contamination activation**: after a configurable delay, +//! the investigation's proximity begins pressuring smuggling-ring triangles. +//! +//! ## Contamination (#254) +//! +//! After `CONTAMINATION_DELAY_TICKS` (default 300 = 30 game-minutes at +//! 10 ticks/game-minute per D-031), the storyteller: +//! 1. Sets `ContaminationActive` resource to true (one-shot) +//! 2. Applies a tension delta to all `ActiveFork` triangles +//! 3. Emits a `ContaminationEvent` for downstream systems (monologue, etc.) use bevy_app::prelude::*; +use bevy_ecs::prelude::*; -/// Storyteller plugin -/// Manages narrative pacing and event generation +use crate::content::template::{TriangleClassification, TriangleState}; +use crate::simulation::tier::ActiveSim; +use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Tick at which contamination activates (30 game-minutes × 10 ticks/minute = 300). +pub const CONTAMINATION_DELAY_TICKS: u64 = 30 * TICKS_PER_GAME_MINUTE; + +/// Tension delta applied to ActiveFork triangles when contamination fires. +pub const CONTAMINATION_PRESSURE_DELTA: u8 = 10; + +/// Threshold above which confrontation mechanics trigger (Q-017 fallback). +/// 0–100 scale. Not yet consumed — placeholder for future confrontation system. +pub const CONFRONTATION_THRESHOLD: u8 = 75; + +// --------------------------------------------------------------------------- +// Resources +// --------------------------------------------------------------------------- + +/// Whether contamination has been activated by the storyteller. +/// +/// Once set to `true`, it stays true for the remainder of the session. +/// Persisted in `SaveStateV1` to prevent double-firing on save/load. +#[derive(Resource, Debug, Clone, Default)] +pub struct ContaminationActive(pub bool); + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +/// Emitted once when contamination activates (#254). +/// +/// Downstream consumers (monologue system, NPC behavioral shifts) can +/// listen for this to trigger one-shot reactions. +#[derive(Debug, Clone)] +pub struct ContaminationEvent { + /// Tick at which contamination activated. + pub tick: u64, + /// Number of ActiveFork triangles that received pressure. + pub triangles_affected: u32, +} + +/// Resource queue for contamination events. +/// +/// Follows the same pattern as `TriangleCrisisEventQueue` — +/// populated by the storyteller system, drained by consumers. +#[derive(Resource, Default)] +pub struct ContaminationEventQueue { + pub events: Vec, +} + +impl ContaminationEventQueue { + pub fn push(&mut self, event: ContaminationEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +/// Storyteller plugin — manages narrative pacing and event generation. pub struct StorytellerPlugin; impl Plugin for StorytellerPlugin { - fn build(&self, _app: &mut App) { - // Stub implementation - will be populated in phase 2 + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .add_systems(Update, tick_contamination_activation); + tracing::debug!("StorytellerPlugin initialized"); } } + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +/// System: activate contamination after the configured delay (#254). +/// +/// One-shot: checks every tick whether the delay has elapsed. When it fires: +/// 1. Sets `ContaminationActive` to true +/// 2. Increments `tension` on all `ActiveFork` triangles by `CONTAMINATION_PRESSURE_DELTA` +/// 3. Emits a `ContaminationEvent` +/// +/// After activation, the system early-returns on subsequent ticks. +pub fn tick_contamination_activation( + time: Res, + mut contamination: ResMut, + mut event_queue: ResMut, + mut triangles: Query<&mut TriangleState, With>, +) { + // Already activated — nothing to do + if contamination.0 { + return; + } + + // Not yet time + if time.tick < CONTAMINATION_DELAY_TICKS { + return; + } + + // Activate contamination + contamination.0 = true; + + let mut affected = 0u32; + for mut state in &mut triangles { + if state.classification == TriangleClassification::ActiveFork { + state.tension = state.tension.saturating_add(CONTAMINATION_PRESSURE_DELTA); + affected += 1; + } + } + + event_queue.push(ContaminationEvent { + tick: time.tick, + triangles_affected: affected, + }); + + tracing::info!( + "Contamination activated at tick {} — {} ActiveFork triangles pressured (+{})", + time.tick, + affected, + CONTAMINATION_PRESSURE_DELTA, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::template::{ + RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState, + }; + use crate::knowledge::types::StableId; + use std::collections::BTreeMap; + + fn make_triangle(classification: TriangleClassification) -> TriangleState { + let mut role_assignments = BTreeMap::new(); + role_assignments.insert(RoleId::new("a"), StableId(1)); + role_assignments.insert(RoleId::new("b"), StableId(2)); + role_assignments.insert(RoleId::new("c"), StableId(3)); + + TriangleState { + triangle_id: TriangleId::from_seed_and_slug(0, "test"), + role_assignments, + tension: 0, + phase: match classification { + TriangleClassification::ActiveFork => TrianglePhase::Simmering, + TriangleClassification::PassiveTension => TrianglePhase::Dormant, + }, + tension_rate: 1, + template_id: TemplateId::from_seed_and_slug(0, "test"), + classification, + } + } + + fn setup_world() -> (World, Schedule) { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_contamination_activation); + + (world, schedule) + } + + #[test] + fn no_activation_before_delay() { + let (mut world, mut schedule) = setup_world(); + + // Spawn an ActiveFork triangle + world.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim)); + + // Run at tick 0 — should not activate + schedule.run(&mut world); + assert!(!world.resource::().0); + assert!(world.resource::().is_empty()); + } + + #[test] + fn activates_at_delay_tick() { + let (mut world, mut schedule) = setup_world(); + + world.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim)); + + // Set tick to exactly the delay threshold + world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS; + schedule.run(&mut world); + + assert!(world.resource::().0); + assert!(!world.resource::().is_empty()); + + let events = world.resource_mut::().drain(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].tick, CONTAMINATION_DELAY_TICKS); + assert_eq!(events[0].triangles_affected, 1); + } + + #[test] + fn only_active_fork_triangles_pressured() { + let (mut world, mut schedule) = setup_world(); + + // Spawn one ActiveFork and one PassiveTension + let fork_entity = world + .spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim)) + .id(); + let passive_entity = world + .spawn(( + make_triangle(TriangleClassification::PassiveTension), + ActiveSim, + )) + .id(); + + world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS; + schedule.run(&mut world); + + // ActiveFork should have tension increased + let fork_state = world.get::(fork_entity).unwrap(); + assert_eq!(fork_state.tension, CONTAMINATION_PRESSURE_DELTA); + + // PassiveTension should remain at 0 + let passive_state = world.get::(passive_entity).unwrap(); + assert_eq!(passive_state.tension, 0); + + // Event should report 1 affected (only the ActiveFork) + let events = world.resource_mut::().drain(); + assert_eq!(events[0].triangles_affected, 1); + } + + #[test] + fn one_shot_does_not_fire_twice() { + let (mut world, mut schedule) = setup_world(); + + let entity = world + .spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim)) + .id(); + + // First activation + world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS; + schedule.run(&mut world); + + let tension_after_first = world.get::(entity).unwrap().tension; + assert_eq!(tension_after_first, CONTAMINATION_PRESSURE_DELTA); + + // Drain events + world.resource_mut::().drain(); + + // Second run — should NOT apply pressure again + world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS + 100; + schedule.run(&mut world); + + let tension_after_second = world.get::(entity).unwrap().tension; + assert_eq!( + tension_after_second, tension_after_first, + "one-shot: tension should not increase on subsequent ticks" + ); + assert!( + world.resource::().is_empty(), + "one-shot: no new events after first activation" + ); + } + + #[test] + fn tension_saturates_at_max() { + let (mut world, mut schedule) = setup_world(); + + // Start with tension near max + let mut triangle = make_triangle(TriangleClassification::ActiveFork); + triangle.tension = 250; + world.spawn((triangle, ActiveSim)); + + world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS; + schedule.run(&mut world); + + // Should saturate at 255, not overflow + let mut q = world.query::<&TriangleState>(); + let state = q.single(&world).unwrap(); + assert_eq!(state.tension, 255); + } +} diff --git a/server/src/test_world/constants.rs b/server/src/test_world/constants.rs index 1459779bf..5a0766ac9 100644 --- a/server/src/test_world/constants.rs +++ b/server/src/test_world/constants.rs @@ -255,6 +255,49 @@ pub const SHIFT_CHANGE: GauntletRoom = GauntletRoom { reset_plate: Some(TilePosition { x: 72, y: 78, z: 0 }), }; +/// Zone Gate — Room 15 (16x22) +/// Tests D-077 zone assignment and zone-crossing detection (#512). +/// +/// Room is split at x=72 (absolute) into two zones: +/// Terminal side (x=64-71): ZONE_GATE_TERMINAL_ZONE_ID (zone_id=100) +/// Corridor side (x=72-79): ZONE_GATE_CORRIDOR_ZONE_ID (zone_id=101) +/// +/// A door entity sits on the boundary at (72, 112, 0). Moving from the +/// Terminal side to the Corridor side (or vice versa) fires ZoneCrossEvent. +/// +/// Observer starts on the Terminal side at (70, 112, 0), facing East. +pub const ZONE_GATE: GauntletRoom = GauntletRoom { + name: "zone_gate", + origin: TilePosition { + x: 64, + y: 102, + z: 0, + }, + size: (16, 22), + spawn: TilePosition { + x: 70, + y: 112, + z: 0, + }, + observer: TilePosition { + x: 70, + y: 112, + z: 0, + }, + observer_facing: Facing(FacingDirection::East), + reset_plate: Some(TilePosition { + x: 72, + y: 102, + z: 0, + }), +}; + +/// Zone ID for the Terminal (left/west) half of the Zone Gate room. +pub const ZONE_GATE_TERMINAL_ZONE_ID: u16 = 100; + +/// Zone ID for the Corridor (right/east) half of the Zone Gate room. +pub const ZONE_GATE_CORRIDOR_ZONE_ID: u16 = 101; + /// All rooms in canonical spawn order. /// THIS ORDER DETERMINES STABLEID ASSIGNMENT. /// Do not reorder existing entries. Append new rooms at the end. @@ -273,6 +316,7 @@ pub const ROOMS: &[GauntletRoom] = &[ SOUND_LAB, DECAY_OBSERVATORY, SHIFT_CHANGE, + ZONE_GATE, ]; /// Look up which room a position falls in. @@ -323,6 +367,11 @@ pub const DECAY_OBSERVATORY_STABLE_IDS: (u64, u64) = (69, 69); pub const SHIFT_CHANGE_STABLE_IDS: (u64, u64) = (70, 71); /// Reset plates for Sprint 13 rooms (sound_lab, decay_observatory, shift_change). pub const SPRINT13_RESET_PLATE_STABLE_IDS: (u64, u64) = (72, 74); +// Sprint 22 rooms — appended after SPRINT13_RESET_PLATE_STABLE_IDS per additive-only rule. +/// Zone Gate room: door entity at the zone boundary. +pub const ZONE_GATE_STABLE_IDS: (u64, u64) = (75, 75); +/// Reset plate for Sprint 22 rooms (zone_gate). +pub const SPRINT22_RESET_PLATE_STABLE_IDS: (u64, u64) = (76, 76); /// Number of actively-spawned entities in the current Gauntlet build. /// Derived from StableId ranges of all rooms + player + reset plates. @@ -343,7 +392,9 @@ pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0) + (SOUND_LAB_STABLE_IDS.1 - SOUND_LAB_STABLE_IDS.0 + 1) as usize + (DECAY_OBSERVATORY_STABLE_IDS.1 - DECAY_OBSERVATORY_STABLE_IDS.0 + 1) as usize + (SHIFT_CHANGE_STABLE_IDS.1 - SHIFT_CHANGE_STABLE_IDS.0 + 1) as usize - + (SPRINT13_RESET_PLATE_STABLE_IDS.1 - SPRINT13_RESET_PLATE_STABLE_IDS.0 + 1) as usize; + + (SPRINT13_RESET_PLATE_STABLE_IDS.1 - SPRINT13_RESET_PLATE_STABLE_IDS.0 + 1) as usize + + (ZONE_GATE_STABLE_IDS.1 - ZONE_GATE_STABLE_IDS.0 + 1) as usize + + (SPRINT22_RESET_PLATE_STABLE_IDS.1 - SPRINT22_RESET_PLATE_STABLE_IDS.0 + 1) as usize; #[cfg(test)] mod tests { @@ -452,7 +503,7 @@ mod tests { #[test] fn all_rooms_in_correct_order() { - assert_eq!(ROOMS.len(), 14); + assert_eq!(ROOMS.len(), 15); assert_eq!(ROOMS[0].name, "central_hub"); assert_eq!(ROOMS[1].name, "fog_theater"); assert_eq!(ROOMS[2].name, "occlusion_corridor"); @@ -467,6 +518,7 @@ mod tests { assert_eq!(ROOMS[11].name, "sound_lab"); assert_eq!(ROOMS[12].name, "decay_observatory"); assert_eq!(ROOMS[13].name, "shift_change"); + assert_eq!(ROOMS[14].name, "zone_gate"); } #[test] @@ -533,6 +585,8 @@ mod tests { DECAY_OBSERVATORY_STABLE_IDS, SHIFT_CHANGE_STABLE_IDS, SPRINT13_RESET_PLATE_STABLE_IDS, + ZONE_GATE_STABLE_IDS, + SPRINT22_RESET_PLATE_STABLE_IDS, ]; for (i, a) in ranges.iter().enumerate() { for (j, b) in ranges.iter().enumerate() { diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 363344845..91f14c0ab 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -16,7 +16,7 @@ //! - Entities spawned in canonical order → StableId assignment is deterministic //! - Additive-only: existing rooms/entities never reordered //! -//! StableId ranges (from gestalt-round3.md + Sprint 11 + Sprint 13): +//! StableId ranges (from gestalt-round3.md + Sprint 11 + Sprint 13 + Sprint 22): //! Player: 0 //! Hub signs: 1-4 //! Fog Theater: 5-8 @@ -35,6 +35,8 @@ //! Decay Observatory: 69 //! Shift Change: 70-71 //! Reset plates (Sprint 13 rooms): 72-74 +//! Zone Gate: 75 (door entity) +//! Reset plates (Sprint 22 rooms): 76 #[cfg(feature = "gauntlet")] pub mod constants; @@ -113,6 +115,8 @@ pub fn setup_gauntlet(app: &mut App) { carve_room_interior(&mut walkability, 0, 104, 34, 20); // Sound Lab carve_room_interior(&mut walkability, 0, 24, 24, 14); // Decay Observatory carve_room_interior(&mut walkability, 64, 78, 16, 24); // Shift Change + // Sprint 22 rooms + carve_room_interior(&mut walkability, 64, 102, 16, 22); // Zone Gate // Carve corridors between hub and rooms carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater @@ -144,8 +148,13 @@ pub fn setup_gauntlet(app: &mut App) { // Zone map: assign zone IDs per room bounding box (D-077, D-073). // Zone IDs are sequential per ROOMS order. Corridors remain unzoned (None). + // Exception: Zone Gate (index 14) uses two explicit zone IDs for its dual-zone split. let mut zone_map = ZoneMap::default(); for (i, room) in constants::ROOMS.iter().enumerate() { + // Zone Gate handled separately below — skip in the sequential loop. + if room.name == "zone_gate" { + continue; + } let zone_id = i as u16; zone_map.set_rect( room.origin.x, @@ -156,6 +165,23 @@ pub fn setup_gauntlet(app: &mut App) { zone_id, ); } + // Zone Gate: Terminal side (left 8 cols, x=64-71) and Corridor side (right 8 cols, x=72-79). + zone_map.set_rect( + constants::ZONE_GATE.origin.x, + constants::ZONE_GATE.origin.y, + constants::ZONE_GATE.size.0 / 2, + constants::ZONE_GATE.size.1, + constants::ZONE_GATE.origin.z, + constants::ZONE_GATE_TERMINAL_ZONE_ID, + ); + zone_map.set_rect( + constants::ZONE_GATE.origin.x + constants::ZONE_GATE.size.0 / 2, + constants::ZONE_GATE.origin.y, + constants::ZONE_GATE.size.0 / 2, + constants::ZONE_GATE.size.1, + constants::ZONE_GATE.origin.z, + constants::ZONE_GATE_CORRIDOR_ZONE_ID, + ); app.insert_resource(zone_map); // Entity spawning in canonical StableId order. @@ -333,6 +359,9 @@ pub fn setup_gauntlet(app: &mut App) { } // --- Sprint 13 reset plates (StableId 72-74) --- + // NOTE: Sprint 22 reset plate (zone_gate) is spawned AFTER these to maintain additive order. + // The sprint13 block is numbered 72-74; zone_gate entities are 75; sprint22 plate is 76. + // The spawn order below matches the StableId allocation table in the module doc. let sprint13_reset_plates: &[(&str, TilePosition)] = &[ ( "sound_lab", @@ -370,6 +399,34 @@ pub fn setup_gauntlet(app: &mut App) { .insert(StableEntityId(sid)); } + // --- Zone Gate (StableId 75) --- + // Spawned AFTER Sprint 13 reset plates so it gets ID 75 per the allocation table. + rooms::zone_gate::spawn_entities(app, &mut registry); + + // --- Sprint 22 reset plates (StableId 76) --- + let sprint22_reset_plates: &[(&str, TilePosition)] = &[( + "zone_gate", + constants::ZONE_GATE + .reset_plate + .expect("zone_gate should have a reset_plate"), + )]; + for &(room_name, pos) in sprint22_reset_plates { + let entity = app + .world_mut() + .spawn(( + Interactable, + RoomResetTrigger { + room_name: room_name.to_string(), + }, + pos, + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + } + // --- Populate RoomSnapshots for reset mechanism (#490) --- let mut snapshots = RoomSnapshots::default(); @@ -495,6 +552,15 @@ pub fn setup_gauntlet(app: &mut App) { } } + // Zone Gate entities (StableId 75): door only, no floor items + for id in constants::ZONE_GATE_STABLE_IDS.0..=constants::ZONE_GATE_STABLE_IDS.1 { + if let Some(entity) = registry.to_entity(&StableId(id)) { + if let Some(pos) = app.world().get::(entity) { + snapshots.record("zone_gate", entity, *pos, false); + } + } + } + app.insert_resource(snapshots); // --- Sprint 14 component fixup --- @@ -864,5 +930,25 @@ mod tests { id ); } + + // Zone Gate at 75 + for id in constants::ZONE_GATE_STABLE_IDS.0..=constants::ZONE_GATE_STABLE_IDS.1 { + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Zone Gate at StableId {}", + id + ); + } + + // Sprint 22 reset plate at 76 + for id in constants::SPRINT22_RESET_PLATE_STABLE_IDS.0 + ..=constants::SPRINT22_RESET_PLATE_STABLE_IDS.1 + { + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Sprint 22 reset plate at StableId {}", + id + ); + } } } diff --git a/server/src/test_world/rooms/mod.rs b/server/src/test_world/rooms/mod.rs index 14889c1cc..86332f274 100644 --- a/server/src/test_world/rooms/mod.rs +++ b/server/src/test_world/rooms/mod.rs @@ -17,3 +17,4 @@ pub mod pause_chamber; pub mod shift_change; pub mod sound_lab; pub mod sprint_gauntlet; +pub mod zone_gate; diff --git a/server/src/test_world/rooms/zone_gate.rs b/server/src/test_world/rooms/zone_gate.rs new file mode 100644 index 000000000..718848b14 --- /dev/null +++ b/server/src/test_world/rooms/zone_gate.rs @@ -0,0 +1,279 @@ +//! Zone Gate — Room 15 (16x22) +//! +//! Tests D-077 zone assignment and zone-crossing detection (QA #512). +//! +//! The room is split at x=72 (absolute) into two zones: +//! Terminal side (x=64-71): ZONE_GATE_TERMINAL_ZONE_ID (100) +//! Corridor side (x=72-79): ZONE_GATE_CORRIDOR_ZONE_ID (101) +//! +//! Interior bounds after 2-tile wall carve: x=66-77, y=104-121. +//! Zone boundary runs vertically at x=71/72 through the interior. +//! +//! The door entity (StableId 75) sits at (72, 112, 0) — the first tile +//! of the Corridor zone. Moving from (71, 112, 0) to (72, 112, 0) crosses +//! the zone boundary and triggers ZoneCrossEvent. +//! +//! Observer position: (70, 112, 0) absolute, facing East. +//! +//! Entities (StableId 75): +//! door_zone_gate (75): open door at the Terminal/Corridor boundary + +use bevy_app::prelude::*; + +use crate::knowledge::registry::{EntityRegistry, StableEntityId}; +use crate::simulation::interaction::{DoorState, Interactable, ObjectType}; +use crate::simulation::movement::TilePosition; + +/// Observer start position — Terminal side of the zone boundary. +pub const OBSERVER_POS: TilePosition = TilePosition { x: 70, y: 112, z: 0 }; + +/// Door position — first tile of the Corridor zone (zone boundary). +pub const DOOR_POS: TilePosition = TilePosition { x: 72, y: 112, z: 0 }; + +/// Last walkable tile of the Terminal zone before the boundary. +pub const TERMINAL_SIDE_POS: TilePosition = TilePosition { x: 71, y: 112, z: 0 }; + +/// First walkable tile of the Corridor zone after the boundary. +pub const CORRIDOR_SIDE_POS: TilePosition = TilePosition { x: 73, y: 112, z: 0 }; + +/// Spawn Zone Gate entities in canonical order (StableId 75). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + // door_zone_gate (StableId 75): open door at the zone boundary. + // Starts open so the player can cross without an interact action. + let entity = app + .world_mut() + .spawn(( + Interactable, + ObjectType::Door, + DoorState { + is_open: true, + blocking_tile: DOOR_POS, + }, + DOOR_POS, + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_ecs::schedule::Schedule; + + use crate::knowledge::registry::EntityRegistry; + use crate::simulation::movement::{PlayerCharacter, WalkabilityMap}; + use crate::simulation::zone::{ + detect_zone_crossings, PreviousPlayerZone, ZoneCrossEventQueue, ZoneMap, + }; + use crate::test_world::constants::{ZONE_GATE_CORRIDOR_ZONE_ID, ZONE_GATE_TERMINAL_ZONE_ID}; + + /// Build a minimal world representing the Zone Gate layout. + /// + /// Carves the room interior (x=66-77, y=104-121) as walkable. + /// Assigns Terminal zone to the left half and Corridor zone to the right half. + fn setup_zone_gate_world() -> bevy_ecs::world::World { + let mut world = bevy_ecs::world::World::new(); + + // WalkabilityMap: carve Zone Gate interior (x=66-77, y=104-121). + let mut wm = WalkabilityMap::new_blocked(117, 125, 1); + for y in 104..=121 { + for x in 66..=77 { + wm.set_walkable(&TilePosition::new(x, y, 0), true); + } + } + world.insert_resource(wm); + + // ZoneMap: Terminal (x=64-71) and Corridor (x=72-79) halves. + let mut zone_map = ZoneMap::default(); + zone_map.set_rect(64, 102, 8, 22, 0, ZONE_GATE_TERMINAL_ZONE_ID); + zone_map.set_rect(72, 102, 8, 22, 0, ZONE_GATE_CORRIDOR_ZONE_ID); + world.insert_resource(zone_map); + + // Zone crossing queue infrastructure. + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + world + } + + /// Golden structure test: Zone Gate interior is correctly carved. + #[test] + fn zone_gate_interior_is_walkable() { + let world = setup_zone_gate_world(); + let wm = world.resource::(); + + // Interior tiles are walkable. + assert!( + wm.can_move_to(&OBSERVER_POS), + "Observer position (70, 112) must be walkable" + ); + assert!( + wm.can_move_to(&TERMINAL_SIDE_POS), + "Terminal side (71, 112) must be walkable" + ); + assert!( + wm.can_move_to(&DOOR_POS), + "Door position (72, 112) must be walkable when door is open" + ); + assert!( + wm.can_move_to(&CORRIDOR_SIDE_POS), + "Corridor side (73, 112) must be walkable" + ); + + // Outer wall tiles remain blocked. + assert!( + !wm.can_move_to(&TilePosition::new(65, 112, 0)), + "Left wall at x=65 must be blocked" + ); + assert!( + !wm.can_move_to(&TilePosition::new(78, 112, 0)), + "Right wall at x=78 must be blocked" + ); + } + + /// Golden structure test: zone assignments are correct on both sides. + #[test] + fn zone_gate_zone_assignments_correct() { + let world = setup_zone_gate_world(); + let zone_map = world.resource::(); + + // Terminal side (x≤71) is in zone 100. + assert_eq!( + zone_map.zone_at(OBSERVER_POS.x, OBSERVER_POS.y, OBSERVER_POS.z), + Some(ZONE_GATE_TERMINAL_ZONE_ID), + "Observer pos must be in Terminal zone ({})", + ZONE_GATE_TERMINAL_ZONE_ID + ); + assert_eq!( + zone_map.zone_at(TERMINAL_SIDE_POS.x, TERMINAL_SIDE_POS.y, TERMINAL_SIDE_POS.z), + Some(ZONE_GATE_TERMINAL_ZONE_ID), + "Tile (71,112) must be in Terminal zone" + ); + + // Corridor side (x≥72) is in zone 101. + assert_eq!( + zone_map.zone_at(DOOR_POS.x, DOOR_POS.y, DOOR_POS.z), + Some(ZONE_GATE_CORRIDOR_ZONE_ID), + "Door tile (72,112) must be in Corridor zone ({})", + ZONE_GATE_CORRIDOR_ZONE_ID + ); + assert_eq!( + zone_map.zone_at( + CORRIDOR_SIDE_POS.x, + CORRIDOR_SIDE_POS.y, + CORRIDOR_SIDE_POS.z + ), + Some(ZONE_GATE_CORRIDOR_ZONE_ID), + "Tile (73,112) must be in Corridor zone" + ); + } + + /// Acceptance test: ZoneCrossEvent queued when player crosses the zone boundary. + /// + /// Player starts in Terminal zone at (70, 112, 0). + /// Player moves to Corridor zone at (72, 112, 0). + /// detect_zone_crossings must queue a ZoneCrossEvent: + /// from: Some(TERMINAL_ZONE_ID), to: Some(CORRIDOR_ZONE_ID) + #[test] + fn zone_gate_crossing_fires_zone_cross_event() { + let mut world = setup_zone_gate_world(); + + // Spawn player in Terminal zone. + let player = world.spawn((PlayerCharacter, OBSERVER_POS)).id(); + + // Tick 1: player at Observer pos — PreviousPlayerZone is None (initial), + // current zone is Terminal. Run detect_zone_crossings to initialise. + let mut sched = Schedule::default(); + sched.add_systems(detect_zone_crossings); + sched.run(&mut world); + + // Drain the first event (None → Terminal transition on init). + world.resource_mut::().drain(); + + // Move player to DOOR_POS (x=72) — crosses into Corridor zone. + world.entity_mut(player).insert(DOOR_POS); + sched.run(&mut world); + + // Read the crossing event. + let events = world.resource_mut::().drain(); + let crossing = events.into_iter().next(); + + assert!( + crossing.is_some(), + "ZoneCrossEvent must fire when player moves from Terminal to Corridor zone" + ); + let ev = crossing.unwrap(); + assert_eq!( + ev.from, + Some(ZONE_GATE_TERMINAL_ZONE_ID), + "ZoneCrossEvent.from must be Terminal zone ({})", + ZONE_GATE_TERMINAL_ZONE_ID + ); + assert_eq!( + ev.to, + Some(ZONE_GATE_CORRIDOR_ZONE_ID), + "ZoneCrossEvent.to must be Corridor zone ({})", + ZONE_GATE_CORRIDOR_ZONE_ID + ); + } + + /// Crossing in reverse (Corridor → Terminal) also queues a ZoneCrossEvent. + #[test] + fn zone_gate_reverse_crossing_fires_event() { + let mut world = setup_zone_gate_world(); + + // Spawn player in Corridor zone. + let player = world.spawn((PlayerCharacter, DOOR_POS)).id(); + + let mut sched = Schedule::default(); + sched.add_systems(detect_zone_crossings); + + // Initialise PreviousPlayerZone. + sched.run(&mut world); + world.resource_mut::().drain(); + + // Move back into Terminal zone. + world.entity_mut(player).insert(OBSERVER_POS); + sched.run(&mut world); + + let events = world.resource_mut::().drain(); + let crossing = events.into_iter().next(); + + assert!( + crossing.is_some(), + "ZoneCrossEvent must fire when player returns to Terminal zone" + ); + let ev = crossing.unwrap(); + assert_eq!(ev.from, Some(ZONE_GATE_CORRIDOR_ZONE_ID)); + assert_eq!(ev.to, Some(ZONE_GATE_TERMINAL_ZONE_ID)); + } + + /// No event queued when player stays within the same zone. + #[test] + fn zone_gate_no_event_within_same_zone() { + let mut world = setup_zone_gate_world(); + + let player = world.spawn((PlayerCharacter, OBSERVER_POS)).id(); + + let mut sched = Schedule::default(); + sched.add_systems(detect_zone_crossings); + + // Initialise. + sched.run(&mut world); + world.resource_mut::().drain(); + + // Move within Terminal zone (same zone, different tile). + world.entity_mut(player).insert(TilePosition::new(68, 112, 0)); + sched.run(&mut world); + + let events = world.resource_mut::().drain(); + assert!( + events.is_empty(), + "No ZoneCrossEvent must fire when player stays in Terminal zone" + ); + } +} diff --git a/server/tests/contamination.rs b/server/tests/contamination.rs new file mode 100644 index 000000000..07b82b4dc --- /dev/null +++ b/server/tests/contamination.rs @@ -0,0 +1,154 @@ +//! Integration test: contamination activation mechanic (#254). +//! +//! Verifies that the storyteller activates contamination after the configured +//! delay and that all ActiveFork triangles receive pressure. + +use bevy_ecs::prelude::*; +use bevy_ecs::schedule::Schedule; +use std::collections::BTreeMap; + +use settled_reach_server::content::template::{ + RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState, +}; +use settled_reach_server::knowledge::types::StableId; +use settled_reach_server::simulation::tier::ActiveSim; +use settled_reach_server::simulation::time::SimulationTime; +use settled_reach_server::storyteller::{ + tick_contamination_activation, ContaminationActive, ContaminationEventQueue, + CONTAMINATION_DELAY_TICKS, CONTAMINATION_PRESSURE_DELTA, +}; + +fn make_triangle( + slug: &str, + classification: TriangleClassification, + base_ids: [u64; 3], +) -> TriangleState { + let mut role_assignments = BTreeMap::new(); + role_assignments.insert(RoleId::new("role-a"), StableId(base_ids[0])); + role_assignments.insert(RoleId::new("role-b"), StableId(base_ids[1])); + role_assignments.insert(RoleId::new("role-c"), StableId(base_ids[2])); + + TriangleState { + triangle_id: TriangleId::from_seed_and_slug(0, slug), + role_assignments, + tension: 0, + phase: match classification { + TriangleClassification::ActiveFork => TrianglePhase::Simmering, + TriangleClassification::PassiveTension => TrianglePhase::Dormant, + }, + tension_rate: 1, + template_id: TemplateId::from_seed_and_slug(0, "authored"), + classification, + } +} + +/// Acceptance test: run past CONTAMINATION_DELAY_TICKS, assert ContaminationActive +/// is set and all ActiveFork TriangleState entities have tension > 0. +#[test] +fn contamination_activates_after_delay() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + // Spawn 3 ActiveFork + 2 PassiveTension triangles (matching real content) + let fork1 = world + .spawn(( + make_triangle("hub-power", TriangleClassification::ActiveFork, [1, 2, 3]), + ActiveSim, + )) + .id(); + let fork2 = world + .spawn(( + make_triangle( + "bar-tensions", + TriangleClassification::ActiveFork, + [4, 5, 6], + ), + ActiveSim, + )) + .id(); + let fork3 = world + .spawn(( + make_triangle( + "informant-question", + TriangleClassification::ActiveFork, + [7, 8, 9], + ), + ActiveSim, + )) + .id(); + let passive1 = world + .spawn(( + make_triangle( + "worried-knowledge", + TriangleClassification::PassiveTension, + [10, 11, 12], + ), + ActiveSim, + )) + .id(); + let passive2 = world + .spawn(( + make_triangle( + "worried-partner", + TriangleClassification::PassiveTension, + [13, 14, 15], + ), + ActiveSim, + )) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_contamination_activation); + + // Simulate past the delay threshold (CONTAMINATION_DELAY_TICKS = 300) + for tick in 0..=CONTAMINATION_DELAY_TICKS { + world.resource_mut::().tick = tick; + schedule.run(&mut world); + } + + // Assert ContaminationActive is set + assert!( + world.resource::().0, + "ContaminationActive must be true after CONTAMINATION_DELAY_TICKS ({}) ticks", + CONTAMINATION_DELAY_TICKS + ); + + // Assert all ActiveFork triangles have tension > 0 + for (label, entity) in [("hub-power", fork1), ("bar-tensions", fork2), ("informant-question", fork3)] { + let state = world + .get::(entity) + .unwrap_or_else(|| panic!("{} triangle entity should exist", label)); + assert!( + state.tension > 0, + "ActiveFork triangle '{}' should have tension > 0 after contamination, got {}", + label, + state.tension + ); + assert_eq!( + state.tension, CONTAMINATION_PRESSURE_DELTA, + "ActiveFork triangle '{}' tension should be exactly {} (contamination delta)", + label, + CONTAMINATION_PRESSURE_DELTA + ); + } + + // Assert PassiveTension triangles were NOT pressured + for (label, entity) in [("worried-knowledge", passive1), ("worried-partner", passive2)] { + let state = world + .get::(entity) + .unwrap_or_else(|| panic!("{} triangle entity should exist", label)); + assert_eq!( + state.tension, 0, + "PassiveTension triangle '{}' should have tension 0 (not affected by contamination)", + label + ); + } + + // Assert exactly one ContaminationEvent was emitted + let events = world.resource_mut::().drain(); + assert_eq!(events.len(), 1, "exactly one ContaminationEvent expected"); + assert_eq!(events[0].tick, CONTAMINATION_DELAY_TICKS); + assert_eq!(events[0].triangles_affected, 3); +} diff --git a/server/tests/content_loading.rs b/server/tests/content_loading.rs index bedfbd9bf..b93416764 100644 --- a/server/tests/content_loading.rs +++ b/server/tests/content_loading.rs @@ -421,5 +421,197 @@ fn spawn_real_content_with_relationships_and_secrets() { assert_eq!(nils_want.primary, npc::WantKind::Power); } +// ----------------------------------------------------------------------- +// Test: EntanglementTag assignment from authored content (#176, D-029) +// ----------------------------------------------------------------------- + +#[test] +fn entanglement_tags_assigned_from_triangle_membership() { + let root = content_root(); + if !root.join("content.yaml").exists() { + return; + } + + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let store = load_content(&root).expect("content loading should succeed"); + let result = spawn_content(&mut world, &store); + + // Acceptance: 17+ NPCs spawn (we have 23 authored profiles) + assert!( + result.npcs_spawned >= 17, + "Expected 17+ NPCs, got {}", + result.npcs_spawned + ); + + let registry = world.resource::(); + + // Every NPC must have an EntanglementTag + let mut intrigue_count = 0u32; + let mut flat_count = 0u32; + for (canonical_id, stable_id) in &result.npc_ids { + let entity = registry + .to_entity(stable_id) + .unwrap_or_else(|| panic!("{} should have an entity", canonical_id)); + let tag = world + .get::(entity) + .unwrap_or_else(|| panic!("{} must have EntanglementTag", canonical_id)); + match tag { + npc::EntanglementTag::Intrigue => intrigue_count += 1, + npc::EntanglementTag::Flat => flat_count += 1, + npc::EntanglementTag::Mundane => {} // reserved for procedural NPCs + } + } + + // Acceptance: at least one EntanglementTag::Intrigue entity + assert!( + intrigue_count >= 1, + "At least one NPC must be EntanglementTag::Intrigue, got 0" + ); + + // Stronger assertion: we know 13 authored NPCs have non-empty triangle_membership + assert!( + intrigue_count >= 10, + "Expected 10+ Intrigue NPCs (authored triangle members), got {}", + intrigue_count + ); + + // Some NPCs should be Flat (no triangle membership) + assert!( + flat_count >= 1, + "At least one NPC should be EntanglementTag::Flat, got 0" + ); + + // Spot-check: Kael (triangle member) must be Intrigue + let kael_entity = registry + .to_entity(&result.npc_ids["npc:kael-davan"]) + .unwrap(); + assert_eq!( + *world.get::(kael_entity).unwrap(), + npc::EntanglementTag::Intrigue, + "Kael (triangle member) must be Intrigue" + ); + + // Spot-check: Devra (empty triangle_membership) must be Flat + let devra_entity = registry + .to_entity(&result.npc_ids["npc:devra"]) + .unwrap(); + assert_eq!( + *world.get::(devra_entity).unwrap(), + npc::EntanglementTag::Flat, + "Devra (no triangle membership) must be Flat" + ); +} + +// ----------------------------------------------------------------------- +// Test: Authored triangle instantiation (#188, D-087) +// ----------------------------------------------------------------------- + +#[test] +fn authored_triangles_instantiated_from_content() { + use settled_reach_server::content::template::{TriangleClassification, TriangleState}; + use settled_reach_server::simulation::tier::ActiveSim; + + let root = content_root(); + if !root.join("content.yaml").exists() { + return; + } + + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let store = load_content(&root).expect("content loading should succeed"); + let result = spawn_content(&mut world, &store); + + // Query all TriangleState entities (clone to release world borrow) + let triangles: Vec = { + let mut q = world.query::<&TriangleState>(); + q.iter(&world).cloned().collect() + }; + + // Acceptance: exactly 5 authored triangles + assert_eq!( + triangles.len(), + 5, + "Expected 5 authored triangles, got {}", + triangles.len() + ); + + // Count by classification (D-087) + let active_count = triangles + .iter() + .filter(|t| t.classification == TriangleClassification::ActiveFork) + .count(); + let passive_count = triangles + .iter() + .filter(|t| t.classification == TriangleClassification::PassiveTension) + .count(); + + assert_eq!( + active_count, 3, + "Expected 3 ActiveFork triangles, got {}", + active_count + ); + assert_eq!( + passive_count, 2, + "Expected 2 PassiveTension triangles, got {}", + passive_count + ); + + // All 5 must have exactly 3 role assignments (triangle = 3 NPCs) + for triangle in &triangles { + assert_eq!( + triangle.role_assignments.len(), + 3, + "Triangle {:?} should have 3 role assignments, got {}", + triangle.triangle_id, + triangle.role_assignments.len() + ); + } + + // All role assignments must point to valid NPC entities in the registry + let registry = world.resource::(); + for triangle in &triangles { + for (role, stable_id) in &triangle.role_assignments { + assert!( + registry.to_entity(stable_id).is_some(), + "Triangle {:?} role '{}' points to StableId {:?} with no entity", + triangle.triangle_id, + role.0, + stable_id, + ); + } + } + + // All triangle entities must have ActiveSim marker + let mut active_query = world.query::<(&TriangleState, &ActiveSim)>(); + let active_triangles: Vec<_> = active_query.iter(&world).collect(); + assert_eq!( + active_triangles.len(), + 5, + "All 5 triangles must have ActiveSim marker" + ); + + // Verify StableIds in role assignments correspond to spawned NPC canonical_ids + let all_npc_stable_ids: std::collections::BTreeSet<_> = + result.npc_ids.values().copied().collect(); + for triangle in &triangles { + for (role, stable_id) in &triangle.role_assignments { + assert!( + all_npc_stable_ids.contains(stable_id), + "Triangle {:?} role '{}' StableId {:?} not in spawned NPC set", + triangle.triangle_id, + role.0, + stable_id, + ); + } + } +} + // Runtime validation test (boot + tick 10 + snapshot) moved to // server/tests/content_runtime.rs per architectural review. diff --git a/server/tests/environmental_interaction.rs b/server/tests/environmental_interaction.rs index f3d2b72b1..fe9d01635 100644 --- a/server/tests/environmental_interaction.rs +++ b/server/tests/environmental_interaction.rs @@ -418,6 +418,8 @@ fn minimal_save() -> SaveStateV1 { template_references: TemplateReferenceMap::default(), triangle_states: vec![], open_doors: vec![], + modifications: vec![], + contamination_active: false, } } diff --git a/server/tests/fuzzy_map.rs b/server/tests/fuzzy_map.rs new file mode 100644 index 000000000..492f0bf54 --- /dev/null +++ b/server/tests/fuzzy_map.rs @@ -0,0 +1,348 @@ +//! Fuzzy tests for procedurally generated maps (QA #509, QA epic #455). +//! +//! Tests structural invariants across 50 random seeds. Each seed produces +//! a unique map; all 4 invariants must hold for every seed. +//! +//! Invariants tested: +//! 1. Connectivity — all walkable tiles reachable from player start (BFS) +//! 2. Entity bounds — all entity positions within map bounds +//! 3. Door adjacency — every door has walkable tiles on both sides +//! 4. Tile count — walkable tile count meets minimum floor (MIN_WALKABLE_TILES) +//! +//! Spec reference: D-010 (deterministic simulation, SimRng seeding), D-030 (testability) + +use rand::Rng; +use settled_reach_server::simulation::movement::{TilePosition, WalkabilityMap}; +use settled_reach_server::simulation::rng::SimRng; +use std::collections::VecDeque; + +// ── Map generation constants ──────────────────────────────────────────────── + +const MAP_W: i32 = 50; +const MAP_H: i32 = 50; +const NUM_ROOMS: usize = 5; + +/// Minimum walkable tiles the generator must produce per map. +/// +/// With 5 rooms of 5–10 tiles each plus corridors, a well-generated map +/// comfortably exceeds this floor. A count below this flags a degenerate +/// layout (e.g., all room-placement attempts rejected on a seed). +/// Value chosen as ~2× the guaranteed-minimum fallback room (6×6 = 36 tiles). +const MIN_WALKABLE_TILES: usize = 200; + +// ── Data types ──────────────────────────────────────────────────────────────── + +struct Room { + x: i32, + y: i32, + w: i32, + h: i32, +} + +/// A door placement with its two walkable neighbours (one on each side). +struct DoorPlacement { + pos: TilePosition, + side_a: TilePosition, + side_b: TilePosition, +} + +/// A fully generated procedural map ready for invariant checks. +struct ProceduralMap { + walkability: WalkabilityMap, + /// Player start position — guaranteed walkable. + player_start: TilePosition, + /// All entity positions: player start + door positions. + entities: Vec, + /// All door placements with neighbour tiles pre-computed. + doors: Vec, + /// Number of walkable tiles (pre-counted for Invariant 4). + walkable_count: usize, +} + +// ── Map generator ───────────────────────────────────────────────────────────── + +fn generate_map(seed: u64) -> ProceduralMap { + let mut rng = SimRng::new(seed).rng; + let mut wm = WalkabilityMap::new_blocked(MAP_W, MAP_H, 1); + let mut rooms: Vec = Vec::new(); + let mut door_placements: Vec = 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 = Vec::new(); + let mut seeds_ok = 0u32; + + for seed in 0..50u64 { + let map = generate_map(seed); + + let mut seed_failures: Vec = 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)); + } +} diff --git a/server/tests/information_boundaries.rs b/server/tests/information_boundaries.rs index 27e6c134a..8bee8cc75 100644 --- a/server/tests/information_boundaries.rs +++ b/server/tests/information_boundaries.rs @@ -206,6 +206,8 @@ fn save_state_npc_kg_isolation() { template_references: Default::default(), triangle_states: vec![], open_doors: vec![], + modifications: vec![], + contamination_active: false, }; // Roundtrip: serialize → deserialize. diff --git a/server/tests/triangle_escalation.rs b/server/tests/triangle_escalation.rs index 78007c317..71c4987f3 100644 --- a/server/tests/triangle_escalation.rs +++ b/server/tests/triangle_escalation.rs @@ -15,8 +15,8 @@ use bevy_ecs::{schedule::Schedule, world::World}; use settled_reach_server::{ content::template::{ apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand, - ResolveTriangleQueue, TemplateId, TriangleCrisisEventQueue, TriangleDef, TriangleId, - TrianglePhase, TriangleState, + ResolveTriangleQueue, TemplateId, TriangleClassification, TriangleCrisisEventQueue, + TriangleDef, TriangleId, TrianglePhase, TriangleState, }, knowledge::{registry::EntityRegistry, types::StableId, StableEntityId}, npc::ToleranceThreshold, @@ -68,6 +68,7 @@ fn spawn_triangle( phase, tension_rate, template_id: TemplateId(1), + classification: TriangleClassification::default(), }, )) .id() @@ -329,6 +330,7 @@ fn d026_non_active_tier_triangle_not_escalated() { phase: TrianglePhase::Simmering, tension_rate: 5, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -395,6 +397,7 @@ fn resolve_command_sets_phase_to_resolved() { phase: TrianglePhase::Active, tension_rate: 3, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -423,6 +426,7 @@ fn d089_resolve_does_not_cascade() { phase: TrianglePhase::Active, tension_rate: 3, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -434,6 +438,7 @@ fn d089_resolve_does_not_cascade() { phase: TrianglePhase::Simmering, tension_rate: 2, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -445,6 +450,7 @@ fn d089_resolve_does_not_cascade() { phase: TrianglePhase::Active, tension_rate: 4, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -485,6 +491,7 @@ fn resolve_twice_is_idempotent() { phase: TrianglePhase::Active, tension_rate: 1, template_id: TemplateId(1), + classification: TriangleClassification::default(), }) .id(); @@ -602,6 +609,7 @@ fn d087_all_v01_conflict_types_produce_escalatable_states() { phase: TrianglePhase::Simmering, tension_rate: 3, template_id: TemplateId(1), + classification: TriangleClassification::default(), }; assert_eq!( state.phase,