Merge remote-tracking branch 'origin/server'
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+3
-2
@@ -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
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 (0–3, max 45°).
|
||||
pub rotation_steps: u8,
|
||||
/// Street width in basis points (10000 = 1.0×, range 7500–20000 for 0.75–2.0×).
|
||||
/// Default 10000 = 4 visual tiles. Uses integer to avoid f32 non-determinism (D-010).
|
||||
pub street_width_bps: u16,
|
||||
}
|
||||
|
||||
/// Single block within a district's 4×4 block grid.
|
||||
/// Each block = 128×128 sim tiles = 2×2 chunks.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct BlockSkeleton {
|
||||
/// Grid position (0–3, 0–3).
|
||||
pub position: (u8, u8),
|
||||
pub zoning: ZoningType,
|
||||
/// Which multi-block reservation this block belongs to (if any).
|
||||
pub reservation: Option<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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -1,15 +1,314 @@
|
||||
// Storyteller module - Rimworld-style storyteller system
|
||||
// Event generation, pacing, hubris wall mechanics
|
||||
//! Storyteller module — Rimworld-style narrative pacing (#254).
|
||||
//!
|
||||
//! The storyteller is the game's hidden director. It manages event timing,
|
||||
//! pressure escalation, and the hubris wall (future). Its first concrete
|
||||
//! mechanic is **contamination activation**: after a configurable delay,
|
||||
//! the investigation's proximity begins pressuring smuggling-ring triangles.
|
||||
//!
|
||||
//! ## Contamination (#254)
|
||||
//!
|
||||
//! After `CONTAMINATION_DELAY_TICKS` (default 300 = 30 game-minutes at
|
||||
//! 10 ticks/game-minute per D-031), the storyteller:
|
||||
//! 1. Sets `ContaminationActive` resource to true (one-shot)
|
||||
//! 2. Applies a tension delta to all `ActiveFork` triangles
|
||||
//! 3. Emits a `ContaminationEvent` for downstream systems (monologue, etc.)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
/// Storyteller plugin
|
||||
/// Manages narrative pacing and event generation
|
||||
use crate::content::template::{TriangleClassification, TriangleState};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tick at which contamination activates (30 game-minutes × 10 ticks/minute = 300).
|
||||
pub const CONTAMINATION_DELAY_TICKS: u64 = 30 * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
/// Tension delta applied to ActiveFork triangles when contamination fires.
|
||||
pub const CONTAMINATION_PRESSURE_DELTA: u8 = 10;
|
||||
|
||||
/// Threshold above which confrontation mechanics trigger (Q-017 fallback).
|
||||
/// 0–100 scale. Not yet consumed — placeholder for future confrontation system.
|
||||
pub const CONFRONTATION_THRESHOLD: u8 = 75;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether contamination has been activated by the storyteller.
|
||||
///
|
||||
/// Once set to `true`, it stays true for the remainder of the session.
|
||||
/// Persisted in `SaveStateV1` to prevent double-firing on save/load.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct ContaminationActive(pub bool);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Emitted once when contamination activates (#254).
|
||||
///
|
||||
/// Downstream consumers (monologue system, NPC behavioral shifts) can
|
||||
/// listen for this to trigger one-shot reactions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContaminationEvent {
|
||||
/// Tick at which contamination activated.
|
||||
pub tick: u64,
|
||||
/// Number of ActiveFork triangles that received pressure.
|
||||
pub triangles_affected: u32,
|
||||
}
|
||||
|
||||
/// Resource queue for contamination events.
|
||||
///
|
||||
/// Follows the same pattern as `TriangleCrisisEventQueue` —
|
||||
/// populated by the storyteller system, drained by consumers.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct ContaminationEventQueue {
|
||||
pub events: Vec<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,4 @@ pub mod pause_chamber;
|
||||
pub mod shift_change;
|
||||
pub mod sound_lab;
|
||||
pub mod sprint_gauntlet;
|
||||
pub mod zone_gate;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Fuzzy tests for procedurally generated maps (QA #509, QA epic #455).
|
||||
//!
|
||||
//! Tests structural invariants across 50 random seeds. Each seed produces
|
||||
//! a unique map; all 4 invariants must hold for every seed.
|
||||
//!
|
||||
//! Invariants tested:
|
||||
//! 1. Connectivity — all walkable tiles reachable from player start (BFS)
|
||||
//! 2. Entity bounds — all entity positions within map bounds
|
||||
//! 3. Door adjacency — every door has walkable tiles on both sides
|
||||
//! 4. Tile count — walkable tile count meets minimum floor (MIN_WALKABLE_TILES)
|
||||
//!
|
||||
//! Spec reference: D-010 (deterministic simulation, SimRng seeding), D-030 (testability)
|
||||
|
||||
use rand::Rng;
|
||||
use settled_reach_server::simulation::movement::{TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::rng::SimRng;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
// ── Map generation constants ────────────────────────────────────────────────
|
||||
|
||||
const MAP_W: i32 = 50;
|
||||
const MAP_H: i32 = 50;
|
||||
const NUM_ROOMS: usize = 5;
|
||||
|
||||
/// Minimum walkable tiles the generator must produce per map.
|
||||
///
|
||||
/// With 5 rooms of 5–10 tiles each plus corridors, a well-generated map
|
||||
/// comfortably exceeds this floor. A count below this flags a degenerate
|
||||
/// layout (e.g., all room-placement attempts rejected on a seed).
|
||||
/// Value chosen as ~2× the guaranteed-minimum fallback room (6×6 = 36 tiles).
|
||||
const MIN_WALKABLE_TILES: usize = 200;
|
||||
|
||||
// ── Data types ────────────────────────────────────────────────────────────────
|
||||
|
||||
struct Room {
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
}
|
||||
|
||||
/// A door placement with its two walkable neighbours (one on each side).
|
||||
struct DoorPlacement {
|
||||
pos: TilePosition,
|
||||
side_a: TilePosition,
|
||||
side_b: TilePosition,
|
||||
}
|
||||
|
||||
/// A fully generated procedural map ready for invariant checks.
|
||||
struct ProceduralMap {
|
||||
walkability: WalkabilityMap,
|
||||
/// Player start position — guaranteed walkable.
|
||||
player_start: TilePosition,
|
||||
/// All entity positions: player start + door positions.
|
||||
entities: Vec<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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user