Merge remote-tracking branch 'origin/server'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-02-28 23:55:42 +01:00
25 changed files with 2618 additions and 25 deletions
+8
View File
@@ -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<Modification> 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)
@@ -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
@@ -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
+114 -4
View File
@@ -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
// ---------------------------------------------------------------------------
+37
View File
@@ -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();
+3
View File
@@ -196,6 +196,9 @@ pub struct Triangle {
pub forks: Vec<Fork>,
#[serde(default)]
pub resolution_states: Vec<Resolution>,
/// D-087 classification: "active_fork" (default) or "passive_tension".
#[serde(default)]
pub classification: Option<String>,
}
#[derive(Debug, Deserialize)]
+2
View File
@@ -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
+19
View File
@@ -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)
// ---------------------------------------------------------------------------
+576
View File
@@ -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<bool>;
/// Tile type identifier (references visual tile set). Stub.
pub type TileId = String;
/// Entity identifier for mutation causality tracking. Stub.
pub type EntityId = u64;
/// Action identifier for player-caused mutations. Stub.
pub type ActionId = String;
/// Object instance identifier within a chunk. Stub.
pub type ObjectId = u64;
/// Placed object (item, furniture, fixture) within a chunk. Stub.
pub type PlacedObject = String;
// ---------------------------------------------------------------------------
// World and complexity tier enums
// ---------------------------------------------------------------------------
/// Network importance of a world in the galaxy.
/// Determines simulation fidelity budget and NPC complexity ceiling.
///
/// Source: tyre-round4.md §2.1, workshop-outcomes.md
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum WorldTier {
/// Background system — minimal simulation, sparse NPCs. Pure environmental.
Peripheral,
/// Standard Commonwealth system — full simulation, complex social sites.
Connected,
/// Major hub — maximum fidelity, multi-faction politics, all triangle types.
Core,
}
/// Generator content budget for a district.
/// Derived from WorldTier + SettingType at Phase 1.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum ComplexityTier {
/// Full social architecture. All Tier 1+2 guarantees. 20-80+ NPCs.
Full,
/// Moderate social architecture. Tier 1 + partial Tier 2. 8-20 NPCs.
Moderate,
/// Minimal social architecture. Tier 1 only. 1-8 NPCs.
Minimal,
/// No social architecture. Pure terrain. 0 NPCs. No guarantees.
Empty,
}
/// Physical setting type for a district.
/// Merged from Gestalt's SettingGeometry and Tyre's TerrainType.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum SettingType {
Station,
Urban,
Agricultural,
Maritime,
Wilderness { biome: Biome },
Water { water_type: WaterType },
Transitional,
Orbital,
Specialized { function: SpecializedFunction },
}
/// Classification of a district (high-level function).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum DistrictType {
LogisticsHub,
Residential,
Commercial,
Industrial,
Administrative,
Entertainment,
MixedUse,
Transit,
Specialized,
}
/// How blocks are placed within the district's 512×512 footprint.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum DistrictLayoutMode {
/// Standard Cartesian grid — perpendicular streets.
Grid,
/// Organic placement with per-block offsets and rotations.
Organic {
placements: [[BlockPlacement; 4]; 4],
},
}
/// Zoning classification for a block or floor zone.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum ZoningType {
Commercial,
Residential,
Industrial,
Administrative,
Transit,
Recreational,
Restricted,
Mixed,
}
/// Reservation function — what purpose a multi-block reservation serves.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum ReservationFunction {
Skyscraper,
Park,
Terminal,
Plaza,
Monument,
IndustrialComplex,
MilitaryBase,
ResearchFacility,
UndergroundComplex,
}
/// Access tier — who is allowed into a zone under normal circumstances.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum AccessTier {
/// Anyone can enter.
Public,
/// Requires employment or residence credential.
Credentialed,
/// Requires explicit invitation or escort.
Restricted,
/// Law enforcement / military only.
Secured,
/// Breach access only — normally sealed.
BreachOnly,
}
/// Vertical corridor type (how floors connect in a multi-level reservation).
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum VerticalCorridorType {
Stairwell,
Elevator,
FreightLift,
AccessHatch,
EmergencyLadder,
}
/// Load state for a z-level within a reservation.
/// Controls lazy loading of floor data during play.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum ZLevelLoadState {
/// Floor is fully generated and in memory.
Loaded(GeneratorChunkData),
/// Floor skeleton only — full data not yet generated.
Skeleton(FloorZone),
/// Floor not yet generated (deferred to Phase 2 on first entry).
Ungenerated,
}
/// Cause of a chunk mutation (for save/load audit trail).
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum MutationCause {
Explosion { radius: u8, source: EntityId },
Fire { spread_from: Option<(u16, u16)> },
Construction { builder: EntityId },
Decay { time_since_maintenance: u32 },
PlayerAction { action: ActionId },
}
/// Type of structural change to a region of chunk tiles.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum StructuralChangeType {
WallDestroyed,
FloorCollapsed,
CeilingBreached,
AreaSealed,
WallConstructed,
}
/// Purpose classification for a triangle within a social site.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum TrianglePurpose {
Investigation,
Economic,
Social,
Political,
Tactical,
Mundane,
}
/// What exists on the far side of a wall tile (for destructible geometry).
/// Every wall tile in a generated chunk is tagged with one of these.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum WallBackside {
/// Another room/corridor exists (tiles already generated).
AdjacentSpace,
/// Solid structural material — 1-2 tiles of fill, then another wall.
StructuralFill,
/// Narrow utility gap (1-3 tiles): pipes, conduits, non-navigable.
ServiceVoid,
/// Edge of chunk — adjacent chunk's boundary tiles on the far side.
ChunkBoundary,
/// Faces outside (hull, exterior wall) — breach has catastrophic consequences.
Exterior,
}
/// Era cause — why a block has the era tag it has.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum EraCause {
Original,
CorporateMerger,
EmergencyExtension,
OrganicGrowth,
InstitutionalIncursion,
EconomicDisruption,
CulturalShift,
}
// ---------------------------------------------------------------------------
// Supporting structs
// ---------------------------------------------------------------------------
/// Organic layout placement for a single block.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct BlockPlacement {
/// Offset from grid-aligned position (±16 sim tiles per axis max).
pub offset: (i16, i16),
/// Rotation in 15° increments (03, max 45°).
pub rotation_steps: u8,
/// Street width in basis points (10000 = 1.0×, range 750020000 for 0.752.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 (03, 03).
pub position: (u8, u8),
pub zoning: ZoningType,
/// Which multi-block reservation this block belongs to (if any).
pub reservation: Option<ReservationId>,
pub chunk_layout: ChunkLayout,
pub hosted_sites: Vec<SocialSiteId>,
pub era: Era,
pub era_modifications: Vec<EraModification>,
pub era_cause: Option<EraCause>,
/// Build density percentage (0 = open/empty, 100 = fully built-up).
/// Integer to avoid f32 non-determinism (D-010).
pub density_pct: u8,
pub landmark: Option<LandmarkSlot>,
}
/// Floor zone within a multi-level reservation.
///
/// **D-110:** `z_level` is `i8` — negative values represent basements
/// (e.g. `z_level = -1` for a sub-basement). This differs from `z_levels: u8`
/// on the parent reservation which counts total floors (always ≥ 1).
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct FloorZone {
/// Absolute z-level of this floor. Negative for sub-ground levels (D-110).
pub z_level: i8,
pub zone_type: ZoningType,
pub zone_palette: ZonePalette,
pub access_tier: AccessTier,
}
/// Vertical connection spec within a multi-level reservation.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct VerticalCorridorSpec {
/// Which blocks this vertical corridor passes through.
pub block_coords: Vec<(u8, u8)>,
/// Which z-bands (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<u8>,
pub access_tier: AccessTier,
pub corridor_type: VerticalCorridorType,
}
/// Visual palette for a zone — base material + contextual modifiers.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ZonePalette {
pub base: BasePalette,
pub modifiers: Vec<PaletteModifier>,
}
/// Contextual modifier applied on top of a base palette.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum PaletteModifier {
EconomicFunction(EconomicModifier),
Era(Era),
FactionPresence(FactionModifier),
Condition(ConditionModifier),
Heritage(HeritageRoot),
Season(Season),
}
/// Social site placement within a district.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SocialSitePlacement {
pub site_id: SocialSiteId,
/// Which blocks this site spans.
pub blocks: Vec<(u8, u8)>,
pub template_tag: String,
pub access_tier: AccessTier,
pub triangles: Vec<TriangleAssignment>,
pub role_slots: Vec<RoleSlot>,
pub active_phases: Vec<DayPhase>,
}
/// Triangle assignment within a social site.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TriangleAssignment {
pub template: TriangleTemplate,
pub purposes: Vec<TrianglePurpose>,
pub participants: Vec<RoleSlotId>,
/// Which block provides the primary staging area (optional for multi-block sites).
pub staging_block: Option<(u8, u8)>,
}
/// Mutations applied to an already-generated chunk.
/// Stored alongside the chunk in the save file.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct ChunkMutations {
pub tile_overrides: Vec<TileOverride>,
pub structural_changes: Vec<StructuralChange>,
pub placed_objects: Vec<PlacedObject>,
pub removed_objects: Vec<ObjectId>,
}
/// Single tile override within a chunk mutation.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TileOverride {
/// (x, y, z) within the chunk (z is relative to chunk base, always ≥ 0).
pub position: (u16, u16, u8),
pub new_tile: TileId,
pub cause: MutationCause,
}
/// Rectangular structural change within a chunk.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct StructuralChange {
pub min: (u16, u16, u8),
pub max: (u16, u16, u8),
pub change_type: StructuralChangeType,
}
// ---------------------------------------------------------------------------
// Multi-block reservation
// ---------------------------------------------------------------------------
/// A reservation spanning multiple blocks within a district.
///
/// Used for skyscrapers, parks, transit terminals, plazas, and any structure
/// that requires more than one 128×128 block.
///
/// **D-110:** `base_z: i8` — the lowest floor of this reservation.
/// Negative values represent sub-ground levels (basements, underground complexes).
/// `z_levels: u8` remains unsigned — it counts total floors (always ≥ 1).
///
/// Example: a building with a 3-level basement at ground + 20 floors above:
/// `base_z: -3, z_levels: 24`
/// A deep mine shaft descending 30 floors below ground:
/// `base_z: -30, z_levels: 30`
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct MultiBlockReservation {
/// Which blocks (grid positions) are part of this reservation.
pub blocks: Vec<(u8, u8)>,
pub template_tag: String,
pub function: ReservationFunction,
/// Total number of floors (always ≥ 1, unsigned). D-110.
pub z_levels: u8,
/// Lowest floor's z-level (can be negative for sub-basement). D-110.
pub base_z: i8,
/// Per-floor zone specifications.
pub floor_zones: Vec<FloorZone>,
/// Number of z-bands (logical groupings of floors by zoning).
pub z_band_count: u8,
/// Zone definitions for each z-band.
pub z_band_zones: Vec<ZoneDefinition>,
/// Vertical connection specs (stairs, elevators, ladders).
pub vertical_corridors: Vec<VerticalCorridorSpec>,
pub hosted_sites: Vec<SocialSiteId>,
}
// ---------------------------------------------------------------------------
// District skeleton
// ---------------------------------------------------------------------------
/// Phase 1 generator output for one district.
///
/// Produced by the async background generation pipeline.
/// Consumed by Phase 2 (chunk-by-chunk fill, on demand) and by
/// the production simulation startup path.
///
/// ## Z-level naming convention (D-110)
///
/// - `z_levels: u8` — how many floor levels exist in this district (count, always ≥ 1)
/// - `base_z` does not appear on `DistrictSkeleton` itself — the district's
/// ground level is always 0. Sub-ground structures use [`MultiBlockReservation`]
/// with a negative `base_z` field.
///
/// ## Determinism (D-010)
///
/// All `Vec<_>` fields are stable-ordered at generation time (sorted by a
/// deterministic key). Generation reproduces identical output for the same seed.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DistrictSkeleton {
// ── Identity ──────────────────────────────────────────
pub district_id: DistrictId,
/// Deterministic seed (derived from master seed via SeedChain).
pub seed: u64,
pub district_type: DistrictType,
/// World context (system, world, neighboring districts).
pub context: DistrictContext,
// ── Classification ────────────────────────────────────
pub world_tier: WorldTier,
pub complexity: ComplexityTier,
pub setting: SettingType,
pub layout_mode: DistrictLayoutMode,
// ── Spatial Structure ─────────────────────────────────
/// The 4×4 block grid (each block = 128×128 sim tiles = 2×2 chunks).
pub blocks: [[BlockSkeleton; 4]; 4],
/// Multi-block reservations (skyscrapers, parks, terminals, plazas).
pub reservations: Vec<MultiBlockReservation>,
/// Corridor spines connecting key access points.
pub corridors: Vec<CorridorSpine>,
/// Number of floor levels in this district (count, unsigned — D-110).
pub z_levels: u8,
// ── Social Structure ──────────────────────────────────
pub social_sites: Vec<SocialSitePlacement>,
pub access_points: Vec<AccessPoint>,
// ── Cultural / World Context ──────────────────────────
pub society_profile: SocietyProfileRef,
pub zone_palette: Vec<ZoneDefinition>,
// ── Boundary System ───────────────────────────────────
pub boundaries: DistrictBoundaries,
// ── Validation ────────────────────────────────────────
/// Guarantee audit result. `None` for Empty districts.
pub guarantee_audit: Option<GuaranteeAuditResult>,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// D-110: FloorZone.z_level must be i8 (signed) to support negative sub-levels.
#[test]
fn floor_zone_z_level_is_signed() {
let fz = FloorZone {
z_level: -2,
zone_type: ZoningType::Restricted,
zone_palette: ZonePalette {
base: String::new(),
modifiers: vec![],
},
access_tier: AccessTier::BreachOnly,
};
assert_eq!(fz.z_level, -2, "sub-basement z_level must round-trip as i8");
}
/// D-110: MultiBlockReservation.base_z must be i8 (signed) for underground buildings.
#[test]
fn reservation_base_z_is_signed() {
let r = MultiBlockReservation {
blocks: vec![(0, 0)],
template_tag: "deep-mine".into(),
function: ReservationFunction::UndergroundComplex,
z_levels: 30,
base_z: -30,
floor_zones: vec![],
z_band_count: 5,
z_band_zones: vec![],
vertical_corridors: vec![],
hosted_sites: vec![],
};
assert_eq!(r.base_z, -30, "deep mine base_z must be representable as i8");
assert_eq!(r.z_levels, 30u8, "z_levels count must remain u8");
}
/// D-110: z_levels (count) remains u8 on DistrictSkeleton.
#[test]
fn district_skeleton_z_levels_is_unsigned() {
// Verify z_levels is u8 (cannot hold negative value). Static assertion:
// if this compiled with z_levels = 255u8, the type is correct.
fn assert_u8(_: u8) {}
let z_levels: u8 = 4;
assert_u8(z_levels);
assert!(z_levels > 0, "z_levels is always at least 1");
}
}
+11
View File
@@ -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::<crate::npc::relationships::RelationshipGraph>()
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
.init_resource::<interaction::TerminalInteractedQueue>()
// Zone crossing event queue (#512, D-077): detect when player moves between zones.
.init_resource::<zone::ZoneCrossEventQueue>()
.init_resource::<zone::PreviousPlayerZone>()
.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");
+114
View File
@@ -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<Modification>,
}
#[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());
}
}
+153 -6
View File
@@ -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::<ContaminationActive>()
.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<Entity> = {
let mut q = world.query_filtered::<Entity, With<TriangleState>>();
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<ActiveSim>) can see them.
for ts in &state.triangle_states {
world.spawn(ts.clone());
world.spawn((ts.clone(), ActiveSim));
}
{
let mut t = world.resource_mut::<SimulationTime>();
@@ -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::<EntityRegistry>();
w.init_resource::<ContaminationActive>();
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<ActiveSim>)
/// can see them.
#[test]
fn load_from_file_restores_triangles_with_active_sim() {
let mut world = minimal_world();
world.init_resource::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
world.init_resource::<TriangleCrisisEventQueue>();
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::<Entity, (With<TriangleState>, With<ActiveSim>)>();
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::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
world.init_resource::<TriangleCrisisEventQueue>();
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::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
world.init_resource::<TriangleCrisisEventQueue>();
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<u8> = {
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);
}
}
+72
View File
@@ -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<StableId>,
/// 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<Modification>,
/// 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");
}
}
+67
View File
@@ -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<u16>,
/// Zone the player entered (None = no zone on new tile).
pub to: Option<u16>,
}
/// Resource: queue of zone-crossing events (drained each tick by consumers).
#[derive(Resource, Default)]
pub struct ZoneCrossEventQueue {
pub events: Vec<ZoneCrossEvent>,
}
impl ZoneCrossEventQueue {
pub fn push(&mut self, event: ZoneCrossEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<ZoneCrossEvent> {
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<u16>);
/// 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<Res<ZoneMap>>,
mut previous_zone: ResMut<PreviousPlayerZone>,
player_query: Query<&TilePosition, With<PlayerCharacter>>,
mut queue: ResMut<ZoneCrossEventQueue>,
) {
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::*;
+305 -6
View File
@@ -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).
/// 0100 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<ContaminationEvent>,
}
impl ContaminationEventQueue {
pub fn push(&mut self, event: ContaminationEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<ContaminationEvent> {
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::<ContaminationActive>()
.init_resource::<ContaminationEventQueue>()
.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<SimulationTime>,
mut contamination: ResMut<ContaminationActive>,
mut event_queue: ResMut<ContaminationEventQueue>,
mut triangles: Query<&mut TriangleState, With<ActiveSim>>,
) {
// 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::<SimulationTime>();
world.init_resource::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
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::<ContaminationActive>().0);
assert!(world.resource::<ContaminationEventQueue>().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::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS;
schedule.run(&mut world);
assert!(world.resource::<ContaminationActive>().0);
assert!(!world.resource::<ContaminationEventQueue>().is_empty());
let events = world.resource_mut::<ContaminationEventQueue>().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::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS;
schedule.run(&mut world);
// ActiveFork should have tension increased
let fork_state = world.get::<TriangleState>(fork_entity).unwrap();
assert_eq!(fork_state.tension, CONTAMINATION_PRESSURE_DELTA);
// PassiveTension should remain at 0
let passive_state = world.get::<TriangleState>(passive_entity).unwrap();
assert_eq!(passive_state.tension, 0);
// Event should report 1 affected (only the ActiveFork)
let events = world.resource_mut::<ContaminationEventQueue>().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::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS;
schedule.run(&mut world);
let tension_after_first = world.get::<TriangleState>(entity).unwrap().tension;
assert_eq!(tension_after_first, CONTAMINATION_PRESSURE_DELTA);
// Drain events
world.resource_mut::<ContaminationEventQueue>().drain();
// Second run — should NOT apply pressure again
world.resource_mut::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS + 100;
schedule.run(&mut world);
let tension_after_second = world.get::<TriangleState>(entity).unwrap().tension;
assert_eq!(
tension_after_second, tension_after_first,
"one-shot: tension should not increase on subsequent ticks"
);
assert!(
world.resource::<ContaminationEventQueue>().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::<SimulationTime>().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);
}
}
+56 -2
View File
@@ -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() {
+87 -1
View File
@@ -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::<TilePosition>(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
);
}
}
}
+1
View File
@@ -17,3 +17,4 @@ pub mod pause_chamber;
pub mod shift_change;
pub mod sound_lab;
pub mod sprint_gauntlet;
pub mod zone_gate;
+279
View File
@@ -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::<ZoneCrossEventQueue>();
world.init_resource::<PreviousPlayerZone>();
world.init_resource::<EntityRegistry>();
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::<WalkabilityMap>();
// 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::<ZoneMap>();
// 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::<ZoneCrossEventQueue>().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::<ZoneCrossEventQueue>().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::<ZoneCrossEventQueue>().drain();
// Move back into Terminal zone.
world.entity_mut(player).insert(OBSERVER_POS);
sched.run(&mut world);
let events = world.resource_mut::<ZoneCrossEventQueue>().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::<ZoneCrossEventQueue>().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::<ZoneCrossEventQueue>().drain();
assert!(
events.is_empty(),
"No ZoneCrossEvent must fire when player stays in Terminal zone"
);
}
}
+154
View File
@@ -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::<SimulationTime>();
world.init_resource::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
// 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::<SimulationTime>().tick = tick;
schedule.run(&mut world);
}
// Assert ContaminationActive is set
assert!(
world.resource::<ContaminationActive>().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::<TriangleState>(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::<TriangleState>(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::<ContaminationEventQueue>().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);
}
+192
View File
@@ -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::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
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::<EntityRegistry>();
// 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::<npc::EntanglementTag>(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::<npc::EntanglementTag>(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::<npc::EntanglementTag>(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::<EntityRegistry>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
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<TriangleState> = {
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::<EntityRegistry>();
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.
@@ -418,6 +418,8 @@ fn minimal_save() -> SaveStateV1 {
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
open_doors: vec![],
modifications: vec![],
contamination_active: false,
}
}
+348
View File
@@ -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 510 tiles each plus corridors, a well-generated map
/// comfortably exceeds this floor. A count below this flags a degenerate
/// layout (e.g., all room-placement attempts rejected on a seed).
/// Value chosen as ~2× the guaranteed-minimum fallback room (6×6 = 36 tiles).
const MIN_WALKABLE_TILES: usize = 200;
// ── Data types ────────────────────────────────────────────────────────────────
struct Room {
x: i32,
y: i32,
w: i32,
h: i32,
}
/// A door placement with its two walkable neighbours (one on each side).
struct DoorPlacement {
pos: TilePosition,
side_a: TilePosition,
side_b: TilePosition,
}
/// A fully generated procedural map ready for invariant checks.
struct ProceduralMap {
walkability: WalkabilityMap,
/// Player start position — guaranteed walkable.
player_start: TilePosition,
/// All entity positions: player start + door positions.
entities: Vec<TilePosition>,
/// All door placements with neighbour tiles pre-computed.
doors: Vec<DoorPlacement>,
/// Number of walkable tiles (pre-counted for Invariant 4).
walkable_count: usize,
}
// ── Map generator ─────────────────────────────────────────────────────────────
fn generate_map(seed: u64) -> ProceduralMap {
let mut rng = SimRng::new(seed).rng;
let mut wm = WalkabilityMap::new_blocked(MAP_W, MAP_H, 1);
let mut rooms: Vec<Room> = Vec::new();
let mut door_placements: Vec<DoorPlacement> = Vec::new();
// ── Room placement (up to 200 attempts for NUM_ROOMS non-overlapping rooms) ──
for _ in 0..200 {
if rooms.len() >= NUM_ROOMS {
break;
}
let w: i32 = rng.random_range(5_i32..=10);
let h: i32 = rng.random_range(5_i32..=10);
// Keep 2-tile margin from map edges.
let x: i32 = rng.random_range(2_i32..(MAP_W - w - 2));
let y: i32 = rng.random_range(2_i32..(MAP_H - h - 2));
// Reject if overlaps an existing room (1-tile padding).
let overlaps = rooms.iter().any(|r| {
x < r.x + r.w + 1 && x + w + 1 > r.x && y < r.y + r.h + 1 && y + h + 1 > r.y
});
if !overlaps {
for ry in y..(y + h) {
for rx in x..(x + w) {
wm.set_walkable(&TilePosition::new(rx, ry, 0), true);
}
}
rooms.push(Room { x, y, w, h });
}
}
// Guarantee at least one room to ensure a valid player start.
if rooms.is_empty() {
let x = 5;
let y = 5;
for ry in y..(y + 6) {
for rx in x..(x + 6) {
wm.set_walkable(&TilePosition::new(rx, ry, 0), true);
}
}
rooms.push(Room { x, y, w: 6, h: 6 });
}
// ── Connect consecutive rooms with L-shaped corridors ─────────────────────
for i in 1..rooms.len() {
let prev_cx = rooms[i - 1].x + rooms[i - 1].w / 2;
let prev_cy = rooms[i - 1].y + rooms[i - 1].h / 2;
let curr_cx = rooms[i].x + rooms[i].w / 2;
let curr_cy = rooms[i].y + rooms[i].h / 2;
// Horizontal segment at prev_cy, from prev_cx to curr_cx.
let x_min = prev_cx.min(curr_cx);
let x_max = prev_cx.max(curr_cx);
for x in x_min..=x_max {
wm.set_walkable(&TilePosition::new(x, prev_cy, 0), true);
}
// Vertical segment at curr_cx, from prev_cy to curr_cy.
let y_min = prev_cy.min(curr_cy);
let y_max = prev_cy.max(curr_cy);
for y in y_min..=y_max {
wm.set_walkable(&TilePosition::new(curr_cx, y, 0), true);
}
// Place a door at the elbow (curr_cx, prev_cy).
// Neighbours: (curr_cx-1, prev_cy) and (curr_cx+1, prev_cy).
if curr_cx > 0 && curr_cx < MAP_W - 1 {
let door_pos = TilePosition::new(curr_cx, prev_cy, 0);
let side_a = TilePosition::new(curr_cx - 1, prev_cy, 0);
let side_b = TilePosition::new(curr_cx + 1, prev_cy, 0);
if wm.can_move_to(&door_pos) && wm.can_move_to(&side_a) && wm.can_move_to(&side_b) {
door_placements.push(DoorPlacement {
pos: door_pos,
side_a,
side_b,
});
}
}
}
// ── Count walkable tiles ───────────────────────────────────────────────────
let mut walkable_count = 0;
for y in 0..MAP_H {
for x in 0..MAP_W {
if wm.can_move_to(&TilePosition::new(x, y, 0)) {
walkable_count += 1;
}
}
}
// Player start: centre of first room (always walkable by construction).
let player_start = TilePosition::new(
rooms[0].x + rooms[0].w / 2,
rooms[0].y + rooms[0].h / 2,
0,
);
let mut entities = vec![player_start];
entities.extend(door_placements.iter().map(|d| d.pos));
ProceduralMap {
walkability: wm,
player_start,
entities,
doors: door_placements,
walkable_count,
}
}
// ── Invariant checks ──────────────────────────────────────────────────────────
/// Invariant 1: all walkable tiles reachable from player start via BFS.
fn check_connectivity(map: &ProceduralMap, seed: u64) -> Result<(), String> {
if !map.walkability.can_move_to(&map.player_start) {
return Err(format!(
"[seed {seed}] Player start {:?} is not walkable",
map.player_start
));
}
let mut visited = std::collections::BTreeSet::new();
let mut queue = VecDeque::new();
queue.push_back(map.player_start);
visited.insert(map.player_start);
while let Some(pos) = queue.pop_front() {
for neighbor in pos.cardinal_neighbors() {
if neighbor.x >= 0
&& neighbor.x < MAP_W
&& neighbor.y >= 0
&& neighbor.y < MAP_H
&& map.walkability.can_move_to(&neighbor)
&& !visited.contains(&neighbor)
{
visited.insert(neighbor);
queue.push_back(neighbor);
}
}
}
if visited.len() != map.walkable_count {
Err(format!(
"[seed {seed}] Connectivity: {} walkable tiles but only {} reachable from {:?}",
map.walkable_count,
visited.len(),
map.player_start
))
} else {
Ok(())
}
}
/// Invariant 2: every entity position is within map bounds.
fn check_entity_bounds(map: &ProceduralMap, seed: u64) -> Result<(), String> {
for pos in &map.entities {
if pos.x < 0 || pos.x >= MAP_W || pos.y < 0 || pos.y >= MAP_H {
return Err(format!(
"[seed {seed}] Entity at {:?} is outside map bounds ({MAP_W}×{MAP_H})",
pos
));
}
}
Ok(())
}
/// Invariant 3: every door has at least one walkable tile on each side.
fn check_door_adjacency(map: &ProceduralMap, seed: u64) -> Result<(), String> {
for door in &map.doors {
if !map.walkability.can_move_to(&door.side_a) {
return Err(format!(
"[seed {seed}] Door at {:?}: side_a {:?} is not walkable",
door.pos, door.side_a
));
}
if !map.walkability.can_move_to(&door.side_b) {
return Err(format!(
"[seed {seed}] Door at {:?}: side_b {:?} is not walkable",
door.pos, door.side_b
));
}
}
Ok(())
}
/// Invariant 4: walkable tile count meets the minimum floor.
///
/// Catches degenerate maps where the generator failed to carve usable space.
/// BSP room carvers don't have a fixed density target — they have natural
/// variance from room sizes and corridor lengths. A minimum floor is the
/// correct invariant for this generator type.
fn check_tile_count(map: &ProceduralMap, seed: u64) -> Result<(), String> {
if map.walkable_count < MIN_WALKABLE_TILES {
Err(format!(
"[seed {seed}] Tile count {}: expected at least {MIN_WALKABLE_TILES} walkable tiles \
(map too sparse generator may have failed to place rooms)",
map.walkable_count,
))
} else {
Ok(())
}
}
// ── Main fuzzy test ───────────────────────────────────────────────────────────
/// Runs all 4 structural invariants across 50 deterministic seeds.
///
/// Each seed produces a unique procedurally generated map. All 4 invariants
/// must hold for every seed.
///
/// Acceptance: `cargo test -p server -- fuzzy_map_50_seeds_all_invariants`
#[test]
fn fuzzy_map_50_seeds_all_invariants() {
let mut failures: Vec<String> = Vec::new();
let mut seeds_ok = 0u32;
for seed in 0..50u64 {
let map = generate_map(seed);
let mut seed_failures: Vec<String> = Vec::new();
if let Err(e) = check_connectivity(&map, seed) {
seed_failures.push(e);
}
if let Err(e) = check_entity_bounds(&map, seed) {
seed_failures.push(e);
}
if let Err(e) = check_door_adjacency(&map, seed) {
seed_failures.push(e);
}
if let Err(e) = check_tile_count(&map, seed) {
seed_failures.push(e);
}
if seed_failures.is_empty() {
seeds_ok += 1;
} else {
failures.extend(seed_failures);
}
}
assert!(
failures.is_empty(),
"{} seed(s) passed, {} invariant violation(s):\n{}",
seeds_ok,
failures.len(),
failures.join("\n")
);
}
/// Individual connectivity test — all walkable tiles reachable from player start.
#[test]
fn fuzzy_map_connectivity_holds_across_seeds() {
for seed in 0..50u64 {
let map = generate_map(seed);
check_connectivity(&map, seed).unwrap_or_else(|e| panic!("{}", e));
}
}
/// Individual bounds test — all entity positions within map bounds.
#[test]
fn fuzzy_map_entity_bounds_respected_across_seeds() {
for seed in 0..50u64 {
let map = generate_map(seed);
check_entity_bounds(&map, seed).unwrap_or_else(|e| panic!("{}", e));
}
}
/// Individual door adjacency test — walkable tiles on both sides of every door.
#[test]
fn fuzzy_map_door_adjacency_holds_across_seeds() {
for seed in 0..50u64 {
let map = generate_map(seed);
check_door_adjacency(&map, seed).unwrap_or_else(|e| panic!("{}", e));
}
}
/// Individual tile count test — walkable count meets minimum floor across all seeds.
#[test]
fn fuzzy_map_tile_count_meets_minimum_across_seeds() {
for seed in 0..50u64 {
let map = generate_map(seed);
check_tile_count(&map, seed).unwrap_or_else(|e| panic!("{}", e));
}
}
+2
View File
@@ -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.
+10 -2
View File
@@ -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,