Merge remote-tracking branch 'origin/server'

This commit is contained in:
2026-02-25 23:50:56 +01:00
37 changed files with 2863 additions and 5 deletions
+14
View File
@@ -7,6 +7,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- Social site template schema — RoleSchema (#163), SpaceSpec (#164), TriangleDef (#106) with YAML deserialization, sample templates at server/data/templates/
- Single-ownership model — TemplateOwnership component, TemplateReferenceMap resource, cross-template reference links preserved across save/load and tier eviction (#165, D-025)
- Triangle generation — intra-template constraint satisfaction assigns NPCs to triangle roles, minimum 2 triangles per template with fallback on imperfect seeds (#107)
- Triangle escalation system — tick_triangle_escalation runs per game-minute, tension increments toward ToleranceThreshold, TriangleCrisisEvent emitted on Active phase entry, ResolveTriangle stub command (#250, D-087)
- Protocol v16 — TriangleCrisisEventWire on ObserverSnapshot for future client rendering of triangle crises
- D-093: Sova Transit District spatial layout — 4 social sites (Terminal, Bar, Gate Cluster, Sector 3), 2 encounter nodes, zone palette, gate cluster 7-zone spec, z-level scheme (z=0 maintenance, z=1 main, z=2 observation gallery), 3 investigation paths, corridor widths
- D-094: Spatial hierarchy — chunk (64×64 sim) → block (128×128 sim) → district (4×4 blocks, 256×256 visual), supersedes D-014 estimate
- D-095: Horizon stations and transport lore — span gates (human-built, dual-use), horizon stations (alien-built, 4-8 apertures), "The Ring" per-system naming, sequential hop travel, The Loop internal tram
@@ -14,6 +19,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- SnapshotEventRouter — callable-based snapshot dispatch replaces inline if-has blocks in main.gd (#559)
- YamlParser shared utility — unified YAML parsing for UI strings and checklist conditions (#560)
### Fixed
- Wire triangle crisis event queue into observer snapshot — clients now receive TriangleCrisisEventWire via protocol v16 (was always empty)
- Persist TriangleState in SaveStateV1 — triangle phase and tension survive save/load cycles
- Validate dangling with_role references in TriangleDef constraint validation
- Replace O(n²) fallback NPC assignment with BTreeSet; prevent same NPC assigned to two roles in one triangle
- Replace O(N*M) scan in apply_resolve_triangle with BTreeMap index for O(1) per-command lookup
- Add From impls for RoleId, TriangleId, StableId, TriangleCrisisEventWire — eliminate fragile .0 newtype access
- Consolidate near-identical unit tests with integration counterparts
### Changed
- Sova station profile updated — horizon gates located at The Krenn Ring (800 AU), not on Station Sova; Admin Hub houses transit processing facility only
- game_state.gd: stationary_ticks and zone_id now read from server snapshot with deprecated client-side fallbacks (#557, D-020)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.15"
version = "0.1.19"
dependencies = [
"bevy_app",
"bevy_ecs",
+31
View File
@@ -0,0 +1,31 @@
# Sample role schema for the dock-worker role in the terminal social site.
# Spec ref: D-023 (Tier 2 templates), D-024 (10-axis model), D-025 (social site)
#
# Tile scale note (D-066): tile_count_* fields are in SIM tiles (0.5m each).
# 1 visual tile = 2 sim tiles. A 15-40 visual tile space = 30-80 sim tiles.
role_id: "dock-worker"
required_traits:
- Honest
- Social
skill_focus:
- Technical
- Observation
relationship_constraints:
- with_role: "logistics-manager"
kind: Colleague
required_trust:
min: -2
max: 4
- with_role: "ring-contact"
kind: Colleague
required_trust:
min: -4
max: 0
routine_template:
- phase: morning
location: "terminal-cargo-bay"
- phase: afternoon
location: "terminal-cargo-bay"
- phase: evening
location: "bar-last-shift"
@@ -0,0 +1,18 @@
# Sample space spec for the terminal social site (Sova Logistics Hub).
# Spec ref: D-025 (15-40 visual tiles = 30-80 sim tiles), D-064 (dual-scale grid)
#
# Tile scale (D-066): all tile counts are SIM tiles (0.5m each).
# Terminal: 44×28 visual tiles = 88×56 sim tiles = 4928 sim tiles.
# Using a subsection for one functional zone: ~44×8 visual = 88×16 sim = 1408 sim.
tile_count_min: 30
tile_count_max: 80
sightline_zones:
- name: "loading-floor"
radius: 8 # 4m clear sightline across the loading area
- name: "reception-desk"
radius: 4 # 2m clear sightline at the desk
- name: "cargo-staging"
radius: 6 # 3m clear sightline in staging area
privacy_level: SemiPrivate
traffic_pattern: Destination
@@ -0,0 +1,24 @@
# Sample triangle definition: T4 Drin-System-Ring (D-087 active fork).
# Spec ref: D-024 (triangles as atomic social unit), D-087 (T4 configuration),
# D-089 (self-contained, no cross-triangle cascade)
#
# Note: triangle_id is computed at world-gen time from seed + roles.
# The value below is a placeholder for YAML authoring — the runtime
# calls TriangleId::from_seed_and_roles() to derive the actual ID.
triangle_id: 0
roles:
- "ring-leader"
- "dock-worker"
- "logistics-manager"
conflict_type: ResourceCompetition
interest_axes:
- Want
- Secret
- Relationships
relationship_constraints:
- with_role: "dock-worker"
kind: Subordinate
required_trust:
min: -2
max: 2
+2
View File
@@ -313,6 +313,7 @@ mod tests {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
}
}
@@ -448,6 +449,7 @@ mod tests {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
+40 -2
View File
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 15;
pub const PROTOCOL_VERSION: u8 = 16;
/// Handshake message sent as the very first framed message after connection (#555).
/// Client reads this before entering the normal tick loop and validates
@@ -50,10 +50,11 @@ pub struct HandshakeMessage {
/// examine_result (#242, character-filtered examine observation text),
/// player_knowledge (#264, partial KG dump for journal/knowledge panel).
/// v15 adds: save_result (#553, save/load operation result for client confirmation).
/// v16 adds: triangle_crisis_events (#250, D-087 triangle escalation for future client rendering).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 15.
/// Protocol version for forward compatibility. Current: 16.
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
@@ -151,6 +152,11 @@ pub struct ObserverSnapshot {
/// Client shows a confirmation toast (success) or error modal (failure).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub save_result: Option<SaveLoadResultWire>,
/// Triangle crisis events this tick (#250, D-087).
/// Emitted when a triangle enters Active phase. Client may render
/// a narrative event or HUD indicator. Empty when no crises occur.
#[serde(default)]
pub triangle_crisis_events: Vec<TriangleCrisisEventWire>,
}
/// Game time data for client display (D-031)
@@ -676,6 +682,38 @@ pub struct SaveLoadResultWire {
pub error: Option<String>,
}
/// Triangle crisis event for future client rendering (#250, D-087).
///
/// Emitted when a triangle transitions from Simmering to Active.
/// Client may display a narrative beat, HUD indicator, or tension meter.
/// Wire format uses primitives for cross-boundary safety.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriangleCrisisEventWire {
/// Triangle identifier (TriangleId as u64).
pub triangle_id: u64,
/// NPC role assignments: (role_slug, stable_npc_id).
pub role_assignments: Vec<(String, u64)>,
/// The NPC whose tolerance threshold triggered the crisis.
pub trigger_npc_id: u64,
/// Tick when the crisis was triggered.
pub tick: u64,
}
impl From<crate::content::template::TriangleCrisisEvent> for TriangleCrisisEventWire {
fn from(e: crate::content::template::TriangleCrisisEvent) -> Self {
Self {
triangle_id: e.triangle_id.into(),
role_assignments: e
.role_assignments
.into_iter()
.map(|(role, sid)| (String::from(role), u64::from(sid)))
.collect(),
trigger_npc_id: e.trigger_npc.into(),
tick: e.tick,
}
}
}
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
#[derive(Resource, Debug, Default)]
pub struct SnapshotBuffer {
+1
View File
@@ -14,6 +14,7 @@ pub mod hot_reload;
pub mod line_pool;
pub mod loader;
pub mod spawn;
pub mod template;
pub mod types;
use bevy_app::prelude::*;
File diff suppressed because it is too large Load Diff
+6
View File
@@ -18,6 +18,12 @@ use crate::simulation::movement::TilePosition;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct StableId(pub u64);
impl From<StableId> for u64 {
fn from(id: StableId) -> Self {
id.0
}
}
/// Typed fact identifier for non-entity knowledge.
/// Format: "category.topic" (e.g., "contraband.ring_exists").
/// Lexicographic ordering in BTreeMap provides deterministic iteration.
+1
View File
@@ -213,6 +213,7 @@ mod tests {
world.init_resource::<ObservationEventQueue>();
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
world
}
+1
View File
@@ -168,6 +168,7 @@ mod tests {
world.init_resource::<EntityRegistry>();
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
world
}
+10
View File
@@ -27,6 +27,7 @@ use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::poi::PointOfInterest;
use crate::simulation::rng::SimRng;
use crate::content::template::TriangleCrisisEventQueue;
use crate::simulation::sound::SoundEventQueue;
use crate::simulation::stance::Stance;
use crate::simulation::time::SimulationTime;
@@ -99,6 +100,7 @@ pub fn compute_observer_snapshot(
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
poi_query: Query<&PointOfInterest>,
mut buffer: ResMut<SnapshotBuffer>,
mut crisis_queue: ResMut<TriangleCrisisEventQueue>,
sim_rng: Option<Res<SimRng>>,
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
) {
@@ -387,6 +389,13 @@ pub fn compute_observer_snapshot(
// Consume pending save/load result for this tick (#553).
let save_result = buffer.pending_save_result.take();
// Drain triangle crisis events (#250) and convert to wire format.
let triangle_crisis_events = crisis_queue
.drain()
.into_iter()
.map(TriangleCrisisEventWire::from)
.collect();
buffer.snapshot = Some(ObserverSnapshot {
version: crate::bridge::types::PROTOCOL_VERSION,
tick: time.tick,
@@ -414,6 +423,7 @@ pub fn compute_observer_snapshot(
examine_result,
player_knowledge,
save_result,
triangle_crisis_events,
});
}
+1
View File
@@ -16,6 +16,7 @@ fn setup_world(width: i32, height: i32) -> World {
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world.init_resource::<crate::simulation::sound::SoundEventQueue>();
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
world
}
+11
View File
@@ -51,6 +51,9 @@ impl Plugin for SimulationPlugin {
.init_resource::<follow::FollowEndEventQueue>()
.init_resource::<monologue::PostConversationQueue>()
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
// Triangle escalation resources (#250)
.init_resource::<crate::content::template::TriangleCrisisEventQueue>()
.init_resource::<crate::content::template::ResolveTriangleQueue>()
// discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin).
// Init here so SimulationPlugin works standalone in tests without PerceptionPlugin.
.init_resource::<crate::perception::query::VisibilityGeometry>()
@@ -96,6 +99,14 @@ impl Plugin for SimulationPlugin {
pressure::update_character_pressure
.after(crate::npc::awareness::detect_player_awareness)
.before(crate::perception::observer::compute_observer_snapshot),
// Triangle escalation (#250) — runs on game-minute boundaries (every 10 ticks)
crate::content::template::tick_triangle_escalation
.after(crate::npc::tolerance::check_tolerance_threshold)
.before(crate::perception::observer::compute_observer_snapshot),
// Triangle resolution (#250, D-089) — apply player resolve commands
crate::content::template::apply_resolve_triangle
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
time::advance_tick.after(path_follow::cleanup_path_blocked),
),
);
+26
View File
@@ -16,6 +16,7 @@ use thiserror::Error;
use crate::bridge::types::SaveLoadResultWire;
use crate::bridge::types::SnapshotBuffer;
use crate::content::template::{TemplateReferenceMap, TriangleState};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::Npc;
@@ -105,6 +106,20 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
npc_states.sort_by_key(|s| s.stable_id.0);
let npc_count = npc_states.len();
// Capture TemplateReferenceMap if present — default to empty if not yet initialised.
let template_references = world
.get_resource::<TemplateReferenceMap>()
.cloned()
.unwrap_or_default();
// Capture TriangleState components — sorted by triangle_id for determinism (D-010).
let mut triangle_states: Vec<TriangleState> = {
let mut q = world.query::<&TriangleState>();
q.iter(world).cloned().collect()
};
triangle_states.sort_by_key(|t| t.triangle_id.0);
let state = SaveStateV1 {
format_version: SAVE_FORMAT_VERSION,
tick,
@@ -113,6 +128,8 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
player_knowledge,
relationship_graph,
npc_states,
template_references,
triangle_states,
};
let bytes = state
@@ -200,6 +217,12 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro
// Restore simulation resources.
world.insert_resource(state.relationship_graph);
world.insert_resource(state.template_references);
// Restore triangle states (#250) — spawn dedicated entities for each.
for ts in &state.triangle_states {
world.spawn(ts.clone());
}
{
let mut t = world.resource_mut::<SimulationTime>();
t.tick = state.tick;
@@ -487,6 +510,7 @@ mod tests {
#[test]
fn load_from_file_rejects_wrong_format_version() {
use crate::content::template::TemplateReferenceMap;
// Craft a save with a wrong format_version
let bad_state = SaveStateV1 {
format_version: 0xFF, // deliberately wrong
@@ -496,6 +520,8 @@ mod tests {
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
};
let bytes = bad_state.to_bytes().expect("serialize");
let path = temp_path();
+30
View File
@@ -39,6 +39,7 @@ use bevy_ecs::entity::Entity;
use bevy_ecs::world::World;
use serde::{Deserialize, Serialize};
use crate::content::template::{TemplateOwnership, TemplateReferenceMap, TriangleState};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::StableId;
@@ -83,6 +84,16 @@ pub struct SaveStateV1 {
/// Per-NPC summary state for each simulated NPC.
/// Order is deterministic (sorted by stable_id in ascending order).
pub npc_states: Vec<NpcSaveState>,
/// Cross-template reference links (#165).
/// Preserved across save/load so that tier-evicted templates retain their
/// relationship metadata even when their NPCs are not in Active tier.
#[serde(default)]
pub template_references: TemplateReferenceMap,
/// Triangle escalation states (#250).
/// Persisted so tension/phase survive save/load. Sorted by triangle_id
/// for deterministic serialization (D-010).
#[serde(default)]
pub triangle_states: Vec<TriangleState>,
}
/// Per-NPC state snapshot for `SaveStateV1`.
@@ -184,6 +195,12 @@ pub struct NpcSaveState {
/// Job performance score — drifts over time, persist across tier transitions.
#[serde(default)]
pub job_performance: Option<JobPerformance>,
/// Template ownership (#165): which template owns this NPC and which role it fills.
/// `None` for NPCs that predate the template system or were hand-authored without
/// template assignment. Preserved across tier transitions (D-025 single-ownership).
#[serde(default)]
pub template_ownership: Option<TemplateOwnership>,
}
impl SaveStateV1 {
@@ -258,6 +275,7 @@ pub fn serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState {
combat_capability: world.get::<CombatCapability>(entity).cloned(),
mood_state: world.get::<MoodState>(entity).cloned(),
job_performance: world.get::<JobPerformance>(entity).cloned(),
template_ownership: world.get::<TemplateOwnership>(entity).cloned(),
}
}
@@ -353,6 +371,10 @@ pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> E
if let Some(combat) = state.combat_capability.clone() {
em.insert(combat);
}
// Restore template ownership if present — never reassigned after initial spawn (D-025).
if let Some(ownership) = state.template_ownership.clone() {
em.insert(ownership);
}
}
entity
@@ -381,6 +403,8 @@ mod tests {
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
}
}
@@ -449,6 +473,7 @@ mod tests {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
},
NpcSaveState {
stable_id: StableId(202),
@@ -469,6 +494,7 @@ mod tests {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
},
];
@@ -577,6 +603,7 @@ mod tests {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
}];
let bytes = state.to_bytes().expect("serialize");
@@ -734,6 +761,7 @@ mod tests {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
};
let mut world = World::new();
@@ -771,6 +799,8 @@ mod tests {
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![frozen],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
};
let bytes = save.to_bytes().expect("serialize");
+1
View File
@@ -72,6 +72,7 @@ fn snapshot_roundtrip_over_unix_socket() {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
bridge
+1
View File
@@ -58,6 +58,7 @@ fn snapshot_roundtrip_over_tcp() {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
bridge
+3
View File
@@ -48,6 +48,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
}
}
@@ -239,6 +240,7 @@ fn generate_msgpack_fixtures() {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
write_fixture(
"snapshot_v2_full",
@@ -400,6 +402,7 @@ fn generate_msgpack_fixtures() {
acquired_tick: 10,
}],
}),
triangle_crisis_events: vec![],
};
write_fixture(
"snapshot_full",
+2 -1
View File
@@ -73,7 +73,8 @@
"scan_events": [],
"sound_events": [],
"tick": 8,
"version": 15,
"triangle_crisis_events": [],
"version": 16,
"visible_tiles": [
{
"tile_kind": "Wall",
+4
View File
@@ -169,6 +169,7 @@ fn save_state_npc_kg_isolation() {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
};
// NPC_B (Background tier) does not carry a KG.
@@ -191,6 +192,7 @@ fn save_state_npc_kg_isolation() {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
};
let save = SaveStateV1 {
@@ -201,6 +203,8 @@ fn save_state_npc_kg_isolation() {
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![npc_a_state, npc_b_state],
template_references: Default::default(),
triangle_states: vec![],
};
// Roundtrip: serialize → deserialize.
+4 -1
View File
@@ -36,6 +36,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
}
}
@@ -294,6 +295,7 @@ fn snapshot_v2_fields_roundtrip() {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -348,7 +350,7 @@ fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 15,
PROTOCOL_VERSION, 16,
"bump this assertion when protocol version changes"
);
}
@@ -398,6 +400,7 @@ fn all_facing_direction_variants_roundtrip() {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
+639
View File
@@ -0,0 +1,639 @@
//! Integration tests for the template schema system (tickets #163, #164, #165, #106).
//!
//! Tests YAML round-trips, validation logic, and ECS component interactions
//! against the spec decisions:
//! - D-023: three-tier content model
//! - D-024: 10-axis NPC model, triangles as atomic unit
//! - D-025: social site / single-ownership model
//! - D-087: v0.1 triangle configuration
//! - D-089: self-contained triangle forks, no cross-triangle cascade
//! - D-010: determinism (no HashMap, FNV-1a IDs)
use settled_reach_server::content::template::{
validate_role_schemas_no_duplicate_ids, ConflictType, NpcAxis, PrivacyLevel,
RelationshipConstraint, RoleId, RoleSchema, SpaceSpec, TemplateId,
TemplateOwnership, TemplateReference, TemplateReferenceMap, TemplateRoutineEntry,
TrafficPattern, TriangleDef, TriangleId, TrustRange,
};
use settled_reach_server::npc::{PersonalityTrait, RelationshipKind, Skill};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_role_schema(id: &str) -> RoleSchema {
RoleSchema {
role_id: RoleId::new(id),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![],
routine_template: vec![],
}
}
fn make_triangle(roles: [&str; 3], conflict: ConflictType) -> TriangleDef {
let role_arr = [
RoleId::new(roles[0]),
RoleId::new(roles[1]),
RoleId::new(roles[2]),
];
let triangle_id = TriangleId::from_seed_and_roles(42, &role_arr);
TriangleDef {
triangle_id,
roles: role_arr,
conflict_type: conflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
}
}
// ---------------------------------------------------------------------------
// #163: Role definition schema — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn role_schema_minimal_yaml_parse() {
let yaml = r#"
role_id: "guard"
skill_focus:
- Combat
- Observation
"#;
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("minimal schema must parse");
assert_eq!(schema.role_id, RoleId::new("guard"));
assert_eq!(schema.skill_focus.len(), 2);
assert!(schema.required_traits.is_empty());
assert!(schema.relationship_constraints.is_empty());
assert!(schema.routine_template.is_empty());
}
#[test]
fn role_schema_full_yaml_parse() {
let yaml = r#"
role_id: "dock-worker"
required_traits:
- Cautious
- Honest
skill_focus:
- Technical
- Observation
relationship_constraints:
- with_role: "ring-contact"
kind: Colleague
required_trust:
min: -2
max: 2
routine_template:
- phase: "morning"
location: "terminal-cargo-bay"
activity: "freight-handling"
- phase: "evening"
location: "bar-last-shift"
"#;
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("full schema must parse");
assert_eq!(schema.role_id, RoleId::new("dock-worker"));
assert_eq!(schema.required_traits.len(), 2);
assert_eq!(schema.required_traits[0], PersonalityTrait::Cautious);
assert_eq!(schema.skill_focus.len(), 2);
assert_eq!(schema.relationship_constraints.len(), 1);
assert_eq!(
schema.relationship_constraints[0].with_role,
RoleId::new("ring-contact")
);
assert_eq!(schema.relationship_constraints[0].required_trust.min, -2);
assert_eq!(schema.relationship_constraints[0].required_trust.max, 2);
assert_eq!(schema.routine_template.len(), 2);
assert_eq!(schema.routine_template[0].phase, "morning");
assert_eq!(schema.routine_template[0].activity, Some("freight-handling".to_string()));
assert_eq!(schema.routine_template[1].activity, None);
}
#[test]
fn role_schema_yaml_roundtrip_preserves_all_fields() {
let schema = RoleSchema {
role_id: RoleId::new("ring-contact"),
required_traits: vec![PersonalityTrait::Deceptive, PersonalityTrait::Social],
skill_focus: vec![Skill::Stealth, Skill::Persuasion],
relationship_constraints: vec![
RelationshipConstraint {
with_role: RoleId::new("dock-worker"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 4 },
},
RelationshipConstraint {
with_role: RoleId::new("ring-leader"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 1, max: 4 },
},
],
routine_template: vec![
TemplateRoutineEntry {
phase: "morning".into(),
location: "terminal-cargo-bay".into(),
activity: Some("oversight".into()),
},
TemplateRoutineEntry {
phase: "evening".into(),
location: "maintenance-corridor".into(),
activity: None,
},
],
};
let yaml = serde_yaml::to_string(&schema).expect("serialize");
let restored: RoleSchema = serde_yaml::from_str(&yaml).expect("deserialize");
assert_eq!(restored.role_id, schema.role_id);
assert_eq!(restored.required_traits, schema.required_traits);
assert_eq!(restored.skill_focus, schema.skill_focus);
assert_eq!(
restored.relationship_constraints.len(),
schema.relationship_constraints.len()
);
assert_eq!(
restored.relationship_constraints[0].required_trust,
schema.relationship_constraints[0].required_trust
);
assert_eq!(restored.routine_template.len(), schema.routine_template.len());
assert_eq!(
restored.routine_template[0].activity,
schema.routine_template[0].activity
);
}
// ---------------------------------------------------------------------------
// #163: Role definition schema — validation
// ---------------------------------------------------------------------------
#[test]
fn self_referential_constraint_rejected() {
let schema = RoleSchema {
role_id: RoleId::new("dock-worker"),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("dock-worker"), // same as role_id
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 4 },
}],
routine_template: vec![],
};
let result = schema.validate();
assert!(result.is_err(), "self-referential constraint must be rejected");
assert!(
result.unwrap_err().contains("self-referential"),
"error must mention self-referential"
);
}
#[test]
fn invalid_trust_range_rejected() {
let schema = RoleSchema {
role_id: RoleId::new("guard"),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("captain"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 3, max: 1 }, // invalid: min > max
}],
routine_template: vec![],
};
let result = schema.validate();
assert!(result.is_err(), "TrustRange min > max must be rejected");
assert!(
result.unwrap_err().contains("trust min"),
"error must mention trust min"
);
}
#[test]
fn collection_with_duplicate_role_ids_rejected() {
let schemas = vec![
make_role_schema("dock-worker"),
make_role_schema("ring-contact"),
make_role_schema("dock-worker"), // duplicate
];
let result = validate_role_schemas_no_duplicate_ids(&schemas);
assert!(result.is_err(), "duplicate role_ids must be rejected");
let msg = result.unwrap_err();
assert!(msg.contains("dock-worker"), "error must name the duplicate: {}", msg);
}
#[test]
fn collection_with_unique_role_ids_ok() {
let schemas = vec![
make_role_schema("dock-worker"),
make_role_schema("ring-contact"),
make_role_schema("logistics-manager"),
];
assert!(validate_role_schemas_no_duplicate_ids(&schemas).is_ok());
}
// ---------------------------------------------------------------------------
// #164: Spatial requirement specification — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn space_spec_minimal_yaml_parse() {
let yaml = r#"
tile_count_min: 30
tile_count_max: 80
privacy_level: Public
traffic_pattern: Thoroughfare
"#;
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("minimal SpaceSpec must parse");
assert_eq!(spec.tile_count_min, 30);
assert_eq!(spec.tile_count_max, 80);
assert_eq!(spec.privacy_level, PrivacyLevel::Public);
assert_eq!(spec.traffic_pattern, TrafficPattern::Thoroughfare);
assert!(spec.sightline_zones.is_empty());
}
#[test]
fn space_spec_full_yaml_parse() {
let yaml = r#"
tile_count_min: 30
tile_count_max: 80
sightline_zones:
- name: "bar-counter"
radius: 4
- name: "corner-booth"
radius: 2
privacy_level: SemiPrivate
traffic_pattern: Destination
"#;
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("full SpaceSpec must parse");
assert_eq!(spec.sightline_zones.len(), 2);
assert_eq!(spec.sightline_zones[0].name, "bar-counter");
assert_eq!(spec.sightline_zones[0].radius, 4);
assert_eq!(spec.sightline_zones[1].name, "corner-booth");
assert_eq!(spec.sightline_zones[1].radius, 2);
assert_eq!(spec.privacy_level, PrivacyLevel::SemiPrivate);
assert_eq!(spec.traffic_pattern, TrafficPattern::Destination);
}
/// D-025 scale assertion: 15-40 visual tiles = 30-80 sim tiles (D-066).
#[test]
fn space_spec_d025_tile_count_range() {
let spec = SpaceSpec {
tile_count_min: 30,
tile_count_max: 80,
sightline_zones: vec![],
privacy_level: PrivacyLevel::Public,
traffic_pattern: TrafficPattern::Destination,
};
assert!(spec.validate().is_ok(), "D-025 tile range (30-80 sim) must be valid");
}
#[test]
fn space_spec_validation_min_gt_max_fails() {
let spec = SpaceSpec {
tile_count_min: 100,
tile_count_max: 50,
sightline_zones: vec![],
privacy_level: PrivacyLevel::Private,
traffic_pattern: TrafficPattern::Restricted,
};
let result = spec.validate();
assert!(result.is_err(), "min > max must fail validation");
let msg = result.unwrap_err();
assert!(msg.contains("tile_count_min"), "error must mention tile_count_min: {}", msg);
}
#[test]
fn all_privacy_levels_yaml_roundtrip() {
for level in &[PrivacyLevel::Public, PrivacyLevel::SemiPrivate, PrivacyLevel::Private] {
let yaml = serde_yaml::to_string(level).unwrap();
let decoded: PrivacyLevel = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(level, &decoded, "{:?} must survive YAML round-trip", level);
}
}
#[test]
fn all_traffic_patterns_yaml_roundtrip() {
for pattern in &[
TrafficPattern::Thoroughfare,
TrafficPattern::Destination,
TrafficPattern::Restricted,
] {
let yaml = serde_yaml::to_string(pattern).unwrap();
let decoded: TrafficPattern = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(pattern, &decoded, "{:?} must survive YAML round-trip", pattern);
}
}
// ---------------------------------------------------------------------------
// #165: Single-ownership model — TemplateId determinism
// ---------------------------------------------------------------------------
#[test]
fn template_id_fnv1a_stable_across_calls() {
let id = TemplateId::from_seed_and_slug(0, "");
assert_eq!(
id,
TemplateId::from_seed_and_slug(0, ""),
"empty slug + seed 0 must be stable"
);
let id2 = TemplateId::from_seed_and_slug(42, "the-terminal");
assert_eq!(
id2,
TemplateId::from_seed_and_slug(42, "the-terminal"),
"non-empty slug must be stable"
);
}
#[test]
fn template_ownership_component_single_owner_invariant() {
// D-025: NPCs are owned by exactly one template, never reassigned.
let seed = 1u64;
let tid = TemplateId::from_seed_and_slug(seed, "terminal");
let rid = RoleId::new("dock-worker");
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
assert_eq!(ownership.template_id, tid);
assert_eq!(ownership.role_id, rid);
// Clone (as would happen in save-state) must preserve values.
let cloned = ownership.clone();
assert_eq!(cloned.template_id, ownership.template_id);
assert_eq!(cloned.role_id, ownership.role_id);
}
#[test]
fn template_reference_map_preserves_links_on_unload() {
// D-025: reference links must be preserved when a template is unloaded.
let mut map = TemplateReferenceMap::default();
let tid_a = TemplateId::from_seed_and_slug(1, "template-a");
let tid_b = TemplateId::from_seed_and_slug(1, "template-b");
map.add(TemplateReference {
from_template: tid_a,
to_template: tid_b,
via_role: RoleId::new("ring-contact"),
relationship_metadata: RelationshipKind::Colleague,
});
// Simulate "unload template-a" by cloning (the save path).
let preserved = map.clone();
assert_eq!(preserved.outgoing(tid_a).len(), 1);
assert_eq!(preserved.outgoing(tid_a)[0].to_template, tid_b);
}
#[test]
fn template_reference_map_btreemap_deterministic_ordering() {
// D-010: BTreeMap ensures deterministic iteration order.
let mut map = TemplateReferenceMap::default();
let tid_high = TemplateId(u64::MAX - 1);
let tid_low = TemplateId(1);
map.add(TemplateReference {
from_template: tid_high,
to_template: tid_low,
via_role: RoleId::new("role-a"),
relationship_metadata: RelationshipKind::Colleague,
});
map.add(TemplateReference {
from_template: tid_low,
to_template: tid_high,
via_role: RoleId::new("role-b"),
relationship_metadata: RelationshipKind::Colleague,
});
// Collect all references via all_references() (deterministic BTreeMap order).
let all: Vec<&TemplateReference> = map.all_references().collect();
assert_eq!(all.len(), 2);
// First entry's from_template must be the lower ID (BTreeMap key order).
assert!(
all[0].from_template <= all[1].from_template,
"BTreeMap must iterate in ascending key order"
);
}
// ---------------------------------------------------------------------------
// #106: Triangle definition schema — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn triangle_def_yaml_parse_with_computed_id() {
// TriangleId is stored in YAML but computed at world-gen time.
// Authors use 0 as placeholder; runtime overwrites with computed value.
let yaml = r#"
triangle_id: 0
roles:
- "ring-smuggler"
- "dock-worker"
- "operations-manager"
conflict_type: ResourceCompetition
interest_axes:
- Want
- Secret
- Relationships
"#;
let def: TriangleDef = serde_yaml::from_str(yaml).expect("TriangleDef must parse from YAML");
assert_eq!(def.triangle_id, TriangleId(0));
assert_eq!(def.roles[0], RoleId::new("ring-smuggler"));
assert_eq!(def.conflict_type, ConflictType::ResourceCompetition);
assert!(def.relationship_constraints.is_empty());
}
#[test]
fn triangle_def_yaml_parse_with_constraints() {
let yaml = r#"
triangle_id: 0
roles:
- "ring-leader"
- "dock-worker"
- "logistics-manager"
conflict_type: LoyaltyConflict
interest_axes:
- Relationships
- Secret
- Tolerance
relationship_constraints:
- with_role: "dock-worker"
kind: Subordinate
required_trust:
min: -2
max: 2
"#;
let def: TriangleDef =
serde_yaml::from_str(yaml).expect("TriangleDef with constraints must parse");
assert_eq!(def.conflict_type, ConflictType::LoyaltyConflict);
assert_eq!(def.relationship_constraints.len(), 1);
assert_eq!(def.relationship_constraints[0].with_role, RoleId::new("dock-worker"));
assert_eq!(def.relationship_constraints[0].kind, RelationshipKind::Subordinate);
}
#[test]
fn triangle_def_all_conflict_types_yaml_roundtrip() {
let conflict_types = [
ConflictType::ResourceCompetition,
ConflictType::LoyaltyConflict,
ConflictType::SecretExposure,
ConflictType::AuthorityChallenge,
ConflictType::LatentTension,
];
for ct in &conflict_types {
let yaml = serde_yaml::to_string(ct).unwrap();
let decoded: ConflictType = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(ct, &decoded, "{:?} must round-trip", ct);
}
}
#[test]
fn triangle_def_all_npc_axes_yaml_roundtrip() {
let axes = [
NpcAxis::Want,
NpcAxis::Secret,
NpcAxis::Relationships,
NpcAxis::Tolerance,
NpcAxis::Routine,
NpcAxis::InformationInventory,
NpcAxis::Contentment,
NpcAxis::PersonalityTraits,
NpcAxis::TellSystem,
NpcAxis::SkillSet,
];
for axis in &axes {
let yaml = serde_yaml::to_string(axis).unwrap();
let decoded: NpcAxis = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(axis, &decoded, "{:?} must round-trip", axis);
}
}
/// D-087: T1-T5 triangle configuration must be expressible in the schema.
#[test]
fn d087_v01_triangle_configurations_expressible() {
// T1: Kael-Smuggler-Ring (ResourceCompetition, active fork)
let t1 = make_triangle(
["kael-davan", "smuggler", "ring-contact"],
ConflictType::ResourceCompetition,
);
assert!(t1.validate().is_ok(), "T1 must be valid: {:?}", t1.validate());
// T2: Sera-Detective-Commission (SecretExposure, active fork)
let t2 = make_triangle(
["sera-venn", "detective", "commission-inspector"],
ConflictType::SecretExposure,
);
assert!(t2.validate().is_ok(), "T2 must be valid: {:?}", t2.validate());
// T4: Drin-System-Ring (ResourceCompetition, active fork per D-087)
let t4 = make_triangle(
["drin", "ring-system", "dock-supervisor"],
ConflictType::ResourceCompetition,
);
assert!(t4.validate().is_ok(), "T4 must be valid: {:?}", t4.validate());
// T3: passive tension (LatentTension variant per D-087)
let t3 = make_triangle(["naia", "kael-davan", "hael"], ConflictType::LatentTension);
assert!(t3.validate().is_ok(), "T3 passive tension must be valid: {:?}", t3.validate());
// T5: background worried partner (LatentTension variant)
let t5 = make_triangle(
["worried-partner", "ring-member", "neighbor"],
ConflictType::LatentTension,
);
assert!(t5.validate().is_ok(), "T5 passive tension must be valid: {:?}", t5.validate());
}
/// D-089: TriangleDef must not contain cross-triangle cascade state.
#[test]
fn d089_no_cross_triangle_cascade_fields() {
let def = make_triangle(["role-a", "role-b", "role-c"], ConflictType::ResourceCompetition);
let yaml = serde_yaml::to_string(&def).expect("serialize");
assert!(!yaml.contains("cascade"), "no cascade field should appear in serialized TriangleDef");
assert!(!yaml.contains("cross_triangle"), "no cross_triangle field should appear");
assert!(!yaml.contains("triggers"), "no triggers field should appear");
}
// ---------------------------------------------------------------------------
// #165: ECS integration — spawn two templates with cross-references
// ---------------------------------------------------------------------------
#[test]
fn ecs_two_templates_with_cross_references_and_ownerships() {
use bevy_ecs::world::World;
let seed = 999u64;
let tid_terminal = TemplateId::from_seed_and_slug(seed, "terminal-social-site");
let tid_bar = TemplateId::from_seed_and_slug(seed, "last-shift-bar");
let mut world = World::new();
world.init_resource::<TemplateReferenceMap>();
// Spawn 3 NPCs: 2 in terminal, 1 in bar.
let npc_logistics = world
.spawn(TemplateOwnership {
template_id: tid_terminal,
role_id: RoleId::new("logistics-manager"),
})
.id();
let npc_dock = world
.spawn(TemplateOwnership {
template_id: tid_terminal,
role_id: RoleId::new("dock-worker"),
})
.id();
let npc_bar_regular = world
.spawn(TemplateOwnership {
template_id: tid_bar,
role_id: RoleId::new("bar-regular"),
})
.id();
// Add cross-template reference: dock-worker at terminal references bar-regular at bar.
{
let mut ref_map = world.resource_mut::<TemplateReferenceMap>();
ref_map.add(TemplateReference {
from_template: tid_terminal,
to_template: tid_bar,
via_role: RoleId::new("dock-worker"),
relationship_metadata: RelationshipKind::Colleague,
});
}
// Verify all TemplateOwnership components are correct.
let own_logistics = world.get::<TemplateOwnership>(npc_logistics).unwrap();
assert_eq!(
own_logistics.template_id, tid_terminal,
"logistics-manager must be owned by terminal"
);
assert_eq!(own_logistics.role_id, RoleId::new("logistics-manager"));
let own_dock = world.get::<TemplateOwnership>(npc_dock).unwrap();
assert_eq!(
own_dock.template_id, tid_terminal,
"dock-worker must be owned by terminal"
);
assert_eq!(own_dock.role_id, RoleId::new("dock-worker"));
let own_bar = world.get::<TemplateOwnership>(npc_bar_regular).unwrap();
assert_eq!(own_bar.template_id, tid_bar, "bar-regular must be owned by bar");
// Verify TemplateReferenceMap entries.
let ref_map = world.resource::<TemplateReferenceMap>();
let terminal_refs = ref_map.outgoing(tid_terminal);
assert_eq!(terminal_refs.len(), 1, "terminal should have 1 cross-template reference");
assert_eq!(terminal_refs[0].to_template, tid_bar);
assert_eq!(terminal_refs[0].via_role, RoleId::new("dock-worker"));
// Bar template has no outgoing references.
assert!(
ref_map.outgoing(tid_bar).is_empty(),
"bar template has no outgoing references"
);
}
#[test]
fn template_ownership_survives_clone_for_save_state() {
// D-026: TemplateOwnership must be preserved when tier drops to State-saved.
let tid = TemplateId::from_seed_and_slug(42, "terminal");
let rid = RoleId::new("dock-worker");
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
let saved = ownership.clone();
assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)");
}
+613
View File
@@ -0,0 +1,613 @@
//! Integration tests for the triangle escalation system (#250).
//!
//! Covers the public API from a black-box perspective:
//! - D-087: seed-dependent tension rates produce different 30-min arc timings
//! - D-089: resolution does not cascade (only targeted triangle changes)
//! - D-026: escalation only runs on Active-tier entities
//! - D-031: escalation runs once per game-minute (every 10 ticks)
//!
//! These tests complement the lib unit tests in `src/content/template.rs`
//! with integration-level coverage using the public crate API.
use std::collections::BTreeMap;
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,
},
knowledge::{registry::EntityRegistry, types::StableId, StableEntityId},
npc::ToleranceThreshold,
simulation::{
tier::ActiveSim,
time::SimulationTime,
},
};
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/// Minimal world with all resources required by `tick_triangle_escalation`.
fn make_escalation_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.init_resource::<TriangleCrisisEventQueue>();
world.init_resource::<EntityRegistry>();
world
}
/// Spawn an NPC with a known StableId and ToleranceThreshold.
fn spawn_npc_with_threshold(world: &mut World, stable_id_val: u64, threshold: i16) -> StableId {
let sid = StableId(stable_id_val);
let entity = world
.spawn((ActiveSim, StableEntityId(sid), ToleranceThreshold { current_stress: 0, threshold }))
.id();
world.resource_mut::<EntityRegistry>().register_existing(entity, sid);
sid
}
/// Spawn a triangle entity with the given state (ActiveSim marker included).
fn spawn_triangle(
world: &mut World,
triangle_id: u64,
tension: u8,
tension_rate: u8,
phase: TrianglePhase,
role_assignments: BTreeMap<settled_reach_server::content::template::RoleId, StableId>,
) -> bevy_ecs::entity::Entity {
world
.spawn((
ActiveSim,
TriangleState {
triangle_id: TriangleId(triangle_id),
role_assignments,
tension,
phase,
tension_rate,
template_id: TemplateId(1),
},
))
.id()
}
/// Run the escalation schedule at a specific tick.
fn run_at_tick(world: &mut World, schedule: &mut Schedule, tick: u64) {
world.resource_mut::<SimulationTime>().tick = tick;
schedule.run(world);
}
// ---------------------------------------------------------------------------
// #250: Escalation happy path
// ---------------------------------------------------------------------------
/// D-031: escalation runs once per game-minute. 10 ticks = 1 game-minute.
/// Tension should only increment on multiples of 10.
#[test]
fn escalation_only_fires_on_game_minute_boundaries() {
let mut world = make_escalation_world();
let entity = spawn_triangle(
&mut world,
1,
0,
5,
TrianglePhase::Simmering,
BTreeMap::new(),
);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
// Ticks 1-9: not a game-minute, tension must not change.
for tick in 1..10 {
run_at_tick(&mut world, &mut schedule, tick);
}
assert_eq!(
world.get::<TriangleState>(entity).unwrap().tension,
0,
"tension must not change on sub-minute ticks"
);
// Tick 10: first game-minute, tension should increment.
run_at_tick(&mut world, &mut schedule, 10);
assert_eq!(
world.get::<TriangleState>(entity).unwrap().tension,
5,
"tension must increment at tick 10 (first game-minute)"
);
}
/// Simmering → Active transition at the expected game-minute.
///
/// Known setup:
/// - tension_rate = 5, starting tension = 0
/// - Lowest NPC threshold = 25
/// - After 5 game-minutes (50 ticks): tension = 25, not > 25 → Simmering
/// - After 6 game-minutes (60 ticks): tension = 30, 30 > 25 → Active
#[test]
fn simmering_transitions_to_active_at_expected_minute() {
let mut world = make_escalation_world();
let npc_a = spawn_npc_with_threshold(&mut world, 1, 40);
let npc_b = spawn_npc_with_threshold(&mut world, 2, 25); // lowest
let npc_c = spawn_npc_with_threshold(&mut world, 3, 60);
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("role-a"), npc_a);
assignments.insert(RoleId::new("role-b"), npc_b);
assignments.insert(RoleId::new("role-c"), npc_c);
let entity = spawn_triangle(&mut world, 42, 0, 5, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
// Run through 50 ticks (5 game-minutes): should remain Simmering.
for tick in 1..=50 {
run_at_tick(&mut world, &mut schedule, tick);
}
let state = world.get::<TriangleState>(entity).unwrap();
assert_eq!(
state.phase,
TrianglePhase::Simmering,
"after 5 game-minutes (tension=25), must still be Simmering (not > 25)"
);
assert_eq!(state.tension, 25);
// Run through tick 60 (6th game-minute): tension becomes 30, > 25 → Active.
for tick in 51..=60 {
run_at_tick(&mut world, &mut schedule, tick);
}
let state = world.get::<TriangleState>(entity).unwrap();
assert_eq!(
state.phase,
TrianglePhase::Active,
"at tick 60 (tension=30 > threshold=25), must transition to Active"
);
assert_eq!(state.tension, 30);
}
/// D-087: different seeds produce different escalation timings.
/// Verify that two triangles with different tension rates escalate at different times.
#[test]
fn d087_seed_dependent_escalation_timing() {
// Triangle A: slower escalation (rate 2)
// Triangle B: faster escalation (rate 8)
// Both share same NPC threshold (30).
// A triggers at: ceil(30 / 2) + 1 = 16th game-minute (tension hits 32 at minute 16)
// B triggers at: ceil(30 / 8) + 1 = 5th game-minute (tension hits 32 at minute 4)
let mut world = make_escalation_world();
let npc = spawn_npc_with_threshold(&mut world, 1, 30);
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
// Spawn as separate triangles.
let slow = spawn_triangle(&mut world, 10, 0, 2, TrianglePhase::Simmering, assignments.clone());
let fast = spawn_triangle(&mut world, 20, 0, 8, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
// Run 40 game-minutes (400 ticks).
for tick in 1..=400 {
run_at_tick(&mut world, &mut schedule, tick);
}
// Both should be Active by 400 ticks.
assert_eq!(world.get::<TriangleState>(slow).unwrap().phase, TrianglePhase::Active);
assert_eq!(world.get::<TriangleState>(fast).unwrap().phase, TrianglePhase::Active);
// Fast triangle should have activated earlier (higher tension accumulated faster).
let fast_tension = world.get::<TriangleState>(fast).unwrap().tension;
let slow_tension = world.get::<TriangleState>(slow).unwrap().tension;
assert!(
fast_tension > slow_tension,
"fast triangle (rate=8) should have higher tension than slow (rate=2) after equal time"
);
}
/// The trigger NPC in the crisis event is the one with the lowest threshold.
#[test]
fn crisis_event_trigger_npc_is_lowest_threshold() {
let mut world = make_escalation_world();
let npc_high = spawn_npc_with_threshold(&mut world, 1, 50); // high tolerance
let npc_low = spawn_npc_with_threshold(&mut world, 2, 10); // low tolerance — trigger
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r-high"), npc_high);
assignments.insert(RoleId::new("r-low"), npc_low);
// tension_rate = 11 so after 1 game-minute tension = 11 > 10.
spawn_triangle(&mut world, 99, 0, 11, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
let queue = world.resource::<TriangleCrisisEventQueue>();
assert_eq!(queue.events.len(), 1, "exactly one crisis event");
assert_eq!(
queue.events[0].trigger_npc, npc_low,
"trigger NPC must be the one with the lowest threshold"
);
assert_eq!(queue.events[0].tick, 10, "crisis tick must match the game-minute");
}
/// No crisis event when tension hasn't exceeded the threshold.
#[test]
fn no_crisis_event_below_threshold() {
let mut world = make_escalation_world();
let npc = spawn_npc_with_threshold(&mut world, 1, 100); // high threshold
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
spawn_triangle(&mut world, 1, 0, 5, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
let queue = world.resource::<TriangleCrisisEventQueue>();
assert!(queue.is_empty(), "no crisis event when tension (5) < threshold (100)");
}
// ---------------------------------------------------------------------------
// #250: Active phase behavior
// ---------------------------------------------------------------------------
/// Active triangle continues incrementing tension (narrative tracking).
/// No additional crisis event emitted.
#[test]
fn active_triangle_continues_incrementing_no_new_event() {
let mut world = make_escalation_world();
spawn_triangle(&mut world, 1, 50, 3, TrianglePhase::Active, BTreeMap::new());
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
run_at_tick(&mut world, &mut schedule, 20);
let queue = world.resource::<TriangleCrisisEventQueue>();
assert!(queue.is_empty(), "no crisis event for already-Active triangle");
}
/// Active triangle tension saturates at u8::MAX (255).
#[test]
fn active_triangle_tension_saturates_at_u8_max() {
let mut world = make_escalation_world();
spawn_triangle(&mut world, 1, 252, 10, TrianglePhase::Active, BTreeMap::new());
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
// First call: 252 + 10 = 262, saturates to 255
let entity = world.query::<bevy_ecs::entity::Entity>().iter(&world).next().unwrap();
// Can't query TriangleState after mutable borrow; check via resource
// (we verify by spawning directly and checking post-run)
let _ = entity; // entity used to ensure spawn worked
// Re-run test cleanly
let mut world2 = make_escalation_world();
let e2 = spawn_triangle(&mut world2, 2, 254, 50, TrianglePhase::Active, BTreeMap::new());
let mut sched2 = Schedule::default();
sched2.add_systems(tick_triangle_escalation);
run_at_tick(&mut world2, &mut sched2, 10);
let state = world2.get::<TriangleState>(e2).unwrap();
assert_eq!(state.tension, 255, "tension saturates at u8::MAX");
}
// ---------------------------------------------------------------------------
// #250: D-026 tier boundary
// ---------------------------------------------------------------------------
/// Triangles without ActiveSim marker are NOT escalated (D-026 tier boundary).
#[test]
fn d026_non_active_tier_triangle_not_escalated() {
let mut world = make_escalation_world();
world.resource_mut::<SimulationTime>().tick = 10;
// Spawn WITHOUT ActiveSim.
let entity = world
.spawn(TriangleState {
triangle_id: TriangleId(1),
role_assignments: BTreeMap::new(),
tension: 10,
phase: TrianglePhase::Simmering,
tension_rate: 5,
template_id: TemplateId(1),
})
.id();
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
schedule.run(&mut world);
assert_eq!(
world.get::<TriangleState>(entity).unwrap().tension,
10,
"D-026: triangle without ActiveSim must not be escalated"
);
}
/// Dormant triangles are skipped even when in Active tier.
#[test]
fn dormant_triangle_not_escalated() {
let mut world = make_escalation_world();
let entity = spawn_triangle(&mut world, 1, 0, 10, TrianglePhase::Dormant, BTreeMap::new());
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
assert_eq!(
world.get::<TriangleState>(entity).unwrap().tension,
0,
"Dormant triangle must not be escalated"
);
}
/// Resolved triangles are skipped (D-089: resolution is permanent).
#[test]
fn resolved_triangle_not_escalated() {
let mut world = make_escalation_world();
let entity = spawn_triangle(&mut world, 1, 50, 5, TrianglePhase::Resolved, BTreeMap::new());
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
assert_eq!(
world.get::<TriangleState>(entity).unwrap().tension,
50,
"Resolved triangle must not be escalated (D-089)"
);
}
// ---------------------------------------------------------------------------
// #250: Resolution (D-089)
// ---------------------------------------------------------------------------
/// ResolveTriangleCommand sets the target triangle to Resolved.
#[test]
fn resolve_command_sets_phase_to_resolved() {
let mut world = World::new();
world.init_resource::<ResolveTriangleQueue>();
let entity = world
.spawn(TriangleState {
triangle_id: TriangleId(100),
role_assignments: BTreeMap::new(),
tension: 50,
phase: TrianglePhase::Active,
tension_rate: 3,
template_id: TemplateId(1),
})
.id();
world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100)));
let mut schedule = Schedule::default();
schedule.add_systems(apply_resolve_triangle);
schedule.run(&mut world);
let state = world.get::<TriangleState>(entity).unwrap();
assert_eq!(state.phase, TrianglePhase::Resolved, "resolve command must set phase to Resolved");
assert_eq!(state.tension, 50, "tension must not change on resolve");
}
/// D-089: Resolution does NOT cascade to other triangles.
#[test]
fn d089_resolve_does_not_cascade() {
let mut world = World::new();
world.init_resource::<ResolveTriangleQueue>();
let target = world
.spawn(TriangleState {
triangle_id: TriangleId(100),
role_assignments: BTreeMap::new(),
tension: 50,
phase: TrianglePhase::Active,
tension_rate: 3,
template_id: TemplateId(1),
})
.id();
let bystander_a = world
.spawn(TriangleState {
triangle_id: TriangleId(200),
role_assignments: BTreeMap::new(),
tension: 20,
phase: TrianglePhase::Simmering,
tension_rate: 2,
template_id: TemplateId(1),
})
.id();
let bystander_b = world
.spawn(TriangleState {
triangle_id: TriangleId(300),
role_assignments: BTreeMap::new(),
tension: 80,
phase: TrianglePhase::Active,
tension_rate: 4,
template_id: TemplateId(1),
})
.id();
// Resolve only triangle 100.
world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100)));
let mut schedule = Schedule::default();
schedule.add_systems(apply_resolve_triangle);
schedule.run(&mut world);
assert_eq!(
world.get::<TriangleState>(target).unwrap().phase,
TrianglePhase::Resolved
);
assert_eq!(
world.get::<TriangleState>(bystander_a).unwrap().phase,
TrianglePhase::Simmering,
"D-089: bystander_a must remain Simmering"
);
assert_eq!(
world.get::<TriangleState>(bystander_b).unwrap().phase,
TrianglePhase::Active,
"D-089: bystander_b must remain Active"
);
}
/// Resolving the same triangle twice is idempotent.
#[test]
fn resolve_twice_is_idempotent() {
let mut world = World::new();
world.init_resource::<ResolveTriangleQueue>();
let entity = world
.spawn(TriangleState {
triangle_id: TriangleId(100),
role_assignments: BTreeMap::new(),
tension: 30,
phase: TrianglePhase::Active,
tension_rate: 1,
template_id: TemplateId(1),
})
.id();
let mut schedule = Schedule::default();
schedule.add_systems(apply_resolve_triangle);
world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100)));
schedule.run(&mut world);
world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100)));
schedule.run(&mut world);
assert_eq!(
world.get::<TriangleState>(entity).unwrap().phase,
TrianglePhase::Resolved,
"double-resolve must remain Resolved"
);
}
// ---------------------------------------------------------------------------
// #250: Crisis event queue behavior
// ---------------------------------------------------------------------------
/// Crisis events accumulate in the queue until drained.
#[test]
fn crisis_events_accumulate_until_drained() {
let mut world = make_escalation_world();
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
// Two triangles that will both escalate.
spawn_triangle(&mut world, 10, 0, 6, TrianglePhase::Simmering, assignments.clone());
spawn_triangle(&mut world, 20, 0, 6, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
let queue = world.resource::<TriangleCrisisEventQueue>();
assert_eq!(
queue.events.len(),
2,
"both triangles should emit crisis events in the same game-minute"
);
}
/// `TriangleCrisisEventQueue::drain` clears the queue.
#[test]
fn crisis_queue_drain_clears_events() {
let mut world = make_escalation_world();
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
spawn_triangle(&mut world, 1, 0, 10, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
// Drain the queue.
let drained = world.resource_mut::<TriangleCrisisEventQueue>().drain();
assert_eq!(drained.len(), 1, "drain should return the 1 event");
assert!(
world.resource::<TriangleCrisisEventQueue>().is_empty(),
"queue must be empty after drain"
);
}
// ---------------------------------------------------------------------------
// #250: YAML triangle def → escalation pipeline
// ---------------------------------------------------------------------------
/// End-to-end: TriangleDef from YAML can describe all 5 v0.1 triangles
/// (D-087) and those defs produce escalatable TriangleState instances.
#[test]
fn d087_all_v01_conflict_types_produce_escalatable_states() {
use settled_reach_server::content::template::{ConflictType, NpcAxis, RoleId};
let defs = [
("kael-davan", "smuggler", "ring-contact", ConflictType::ResourceCompetition),
("sera-venn", "detective", "commission-inspector", ConflictType::SecretExposure),
("naia", "kael-davan", "hael", ConflictType::LatentTension),
("drin", "ring-system", "dock-supervisor", ConflictType::ResourceCompetition),
("worried-partner", "ring-member", "neighbor", ConflictType::LatentTension),
];
for (r0, r1, r2, conflict) in &defs {
let roles = [RoleId::new(r0), RoleId::new(r1), RoleId::new(r2)];
let tid = settled_reach_server::content::template::TriangleId::from_seed_and_roles(42, &roles);
let def = TriangleDef {
triangle_id: tid,
roles: roles.clone(),
conflict_type: *conflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
};
assert!(def.validate().is_ok(), "D-087 triangle must be valid: {:?}", def.validate());
// Can construct a TriangleState from the def.
let mut assignments = BTreeMap::new();
for role in &roles {
assignments.insert(role.clone(), StableId(0));
}
let state = TriangleState {
triangle_id: tid,
role_assignments: assignments,
tension: 0,
phase: TrianglePhase::Simmering,
tension_rate: 3,
template_id: TemplateId(1),
};
assert_eq!(
state.phase,
TrianglePhase::Simmering,
"{:?} triangle must start Simmering",
conflict
);
}
}