Merge remote-tracking branch 'origin/server'

This commit is contained in:
2026-02-27 19:24:42 +01:00
39 changed files with 3557 additions and 103 deletions
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.
+202
View File
@@ -0,0 +1,202 @@
# Sova Station Logistics Hub — Tier 2 social site template (#159).
#
# Spec refs: D-023 (three-tier content model), D-024 (10-axis NPC model),
# D-025 (social site as atomic template unit), D-028 (dialogue pools),
# D-087 (v0.1 triangle configuration), D-089 (self-contained forks)
#
# Tile scale (D-066): tile_count_* are SIM tiles (0.5m each).
# 1540 visual tiles (D-025) = 3080 sim tiles.
#
# triangle_id values are authoring placeholders (0).
# The instantiation engine overwrites them with
# TriangleId::from_seed_and_roles(world_seed, &roles) at runtime.
slug: "logistics-hub"
display_name: "Sova Station Logistics Hub"
description: >
The terminal's operational core — cargo processing, documentation,
and the informal relationships that keep goods moving off-manifest.
A functional cluster of four NPCs controlling access to the station's
primary freight throughput.
roles:
- role_id: "logistics-manager"
required_traits:
- Bold
- Cautious
skill_focus:
- Persuasion
- Observation
relationship_constraints:
- with_role: "dock-worker"
kind: Superior
required_trust:
min: 0
max: 5
- with_role: "ring-contact"
kind: Colleague
required_trust:
min: -5
max: 2
- with_role: "security-guard"
kind: Superior
required_trust:
min: 1
max: 5
routine_template:
- phase: morning
location: "terminal-office"
activity: "documentation-review"
- phase: afternoon
location: "terminal-cargo-bay"
activity: "cargo-inspection"
- phase: evening
location: "terminal-office"
activity: "end-of-day-reports"
- role_id: "dock-worker"
required_traits:
- Honest
- Social
skill_focus:
- Technical
- Observation
relationship_constraints:
- with_role: "logistics-manager"
kind: Subordinate
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"
activity: "freight-handling"
- phase: afternoon
location: "terminal-cargo-bay"
activity: "freight-handling"
- phase: evening
location: "bar-last-shift"
- role_id: "ring-contact"
required_traits:
- Deceptive
- Social
skill_focus:
- Stealth
- Persuasion
relationship_constraints:
- with_role: "logistics-manager"
kind: Colleague
required_trust:
min: -5
max: 2
- with_role: "dock-worker"
kind: Colleague
required_trust:
min: -4
max: 0
routine_template:
- phase: morning
location: "terminal-cargo-bay"
activity: "oversight"
- phase: afternoon
location: "maintenance-corridor"
- phase: evening
location: "bar-last-shift"
- role_id: "security-guard"
required_traits:
- Cautious
- Honest
skill_focus:
- Combat
- Observation
relationship_constraints:
- with_role: "logistics-manager"
kind: Subordinate
required_trust:
min: 1
max: 5
routine_template:
- phase: morning
location: "terminal-entrance"
activity: "access-control"
- phase: afternoon
location: "terminal-cargo-bay"
activity: "patrol"
- phase: evening
location: "terminal-entrance"
activity: "access-control"
space:
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
triangles:
- triangle_id: 0
roles:
- "ring-contact"
- "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
- triangle_id: 0
roles:
- "logistics-manager"
- "security-guard"
- "ring-contact"
conflict_type: AuthorityChallenge
interest_axes:
- Want
- Tolerance
- Secret
relationship_constraints:
- with_role: "security-guard"
kind: Superior
required_trust:
min: 0
max: 5
dialogue_pools:
- location: "the-terminal"
roles:
- "logistics-manager"
- "dock-worker"
- "ring-contact"
- "security-guard"
- location: "terminal-cargo-bay"
roles:
- "dock-worker"
- "ring-contact"
cross_template_links:
- from_role: "dock-worker"
to_template_slug: "last-shift-bar"
relationship: Colleague
- from_role: "ring-contact"
to_template_slug: "last-shift-bar"
relationship: Colleague
+23 -2
View File
@@ -91,14 +91,20 @@ impl Default for HandshakeState {
}
}
/// Receive inputs from bridge and push to InputQueue
/// Receive inputs from bridge and push to InputQueue.
/// Protocol errors (malformed input) are recoverable: the frame is skipped
/// and a SimError is pushed to the SimErrorBuffer for client reporting (#85).
pub fn receive_bridge_inputs(
bridge: Option<Res<BridgeResource>>,
mut input_queue: ResMut<crate::simulation::input::InputQueue>,
mut running: ResMut<ServerRunning>,
handshake: Res<HandshakeState>,
mut error_buffer: ResMut<SimErrorBuffer>,
time: Option<Res<crate::simulation::time::SimulationTime>>,
) {
let Some(bridge) = bridge else { return };
let current_tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
match bridge.receive_inputs() {
Ok(inputs) => {
if !inputs.is_empty() && *handshake == HandshakeState::Pending {
@@ -134,8 +140,22 @@ pub fn receive_bridge_inputs(
running.0 = false;
}
Err(BridgeError::DeserializationWithDump(ref msg)) => {
// Recoverable: skip this frame's input, don't shut down
// Recoverable: skip this frame's input, report to client (#85)
tracing::error!("Skipping malformed input frame: {}", msg);
error_buffer.push(SimError {
kind: SimErrorKind::ProtocolError,
message: format!("Malformed input frame: {}", msg),
tick: current_tick,
});
}
Err(ref e @ BridgeError::Deserialization(_)) => {
// Recoverable deserialization error without dump
tracing::error!("Skipping malformed input: {}", e);
error_buffer.push(SimError {
kind: SimErrorKind::ProtocolError,
message: format!("Deserialization error: {}", e),
tick: current_tick,
});
}
Err(e) => {
tracing::error!("Bridge receive error: {}", e);
@@ -191,6 +211,7 @@ impl Plugin for BridgePlugin {
app.init_resource::<SnapshotBuffer>()
.init_resource::<ServerRunning>()
.init_resource::<HandshakeState>()
.init_resource::<SimErrorBuffer>()
.init_resource::<crate::perception::query::VisibilityGeometry>()
.init_resource::<crate::perception::query::ActivePerceptionMode>()
.add_systems(
+4
View File
@@ -314,6 +314,8 @@ mod tests {
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
}
}
@@ -450,6 +452,8 @@ mod tests {
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
+67 -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 = 16;
pub const PROTOCOL_VERSION: u8 = 17;
/// Handshake message sent as the very first framed message after connection (#555).
/// Client reads this before entering the normal tick loop and validates
@@ -51,10 +51,12 @@ pub struct HandshakeMessage {
/// 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).
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
/// sim_errors (#85, structured error reporting to client).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 16.
/// Protocol version for forward compatibility. Current: 17.
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
@@ -157,6 +159,19 @@ pub struct ObserverSnapshot {
/// a narrative event or HUD indicator. Empty when no crises occur.
#[serde(default)]
pub triangle_crisis_events: Vec<TriangleCrisisEventWire>,
/// Fast hash of key mutable state for desync detection (#85).
/// Hash inputs: player position, NPC count, tick number.
/// Client compares against its own computed hash — mismatch indicates
/// client and server state have diverged. No auto-recovery in v0.1;
/// client logs mismatches for debugging.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_hash: Option<u64>,
/// Simulation errors reported this tick (#85).
/// Non-fatal errors (protocol errors, desync) are collected during
/// the tick and sent to the client for logging/display.
/// Empty in normal operation. Client may display a warning toast.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sim_errors: Vec<SimError>,
}
/// Game time data for client display (D-031)
@@ -714,6 +729,56 @@ impl From<crate::content::template::TriangleCrisisEvent> for TriangleCrisisEvent
}
}
/// Structured simulation error for client reporting (#85).
///
/// Sent inside `ObserverSnapshot.sim_errors` for recoverable errors
/// (protocol errors, desync warnings). For fatal errors (panics),
/// a final snapshot is sent with the error before the server exits.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimError {
/// Error category for client-side handling.
pub kind: SimErrorKind,
/// Human-readable error description.
pub message: String,
/// Tick when the error occurred (0 if unavailable).
pub tick: u64,
}
/// Categories of simulation errors (#85).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SimErrorKind {
/// Simulation system panic — fatal, server will exit after sending this.
Panic,
/// Protocol/deserialization error — recoverable, server continues.
ProtocolError,
/// Client-server state hash mismatch — informational, no auto-recovery.
DesyncDetected,
}
/// Buffer for collecting simulation errors during a tick (#85).
/// Drained by `compute_observer_snapshot` into `ObserverSnapshot.sim_errors`.
#[derive(Resource, Debug, Default)]
pub struct SimErrorBuffer {
errors: Vec<SimError>,
}
impl SimErrorBuffer {
/// Push a new error into the buffer.
pub fn push(&mut self, error: SimError) {
self.errors.push(error);
}
/// Drain all buffered errors, returning them and clearing the buffer.
pub fn drain(&mut self) -> Vec<SimError> {
std::mem::take(&mut self.errors)
}
/// Check if there are pending errors.
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
}
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
#[derive(Resource, Debug, Default)]
pub struct SnapshotBuffer {
+214
View File
@@ -0,0 +1,214 @@
//! Template instantiation engine (#161).
//!
//! Wires the full pipeline: `FullTemplateDef` → NPC spawn (via spawn.rs) →
//! triangle generation (via template.rs) → instance tracking.
//!
//! **Pipeline:**
//! 1. Validate the `FullTemplateDef` (schema-level checks).
//! 2. Call `spawn_template_npcs` to create NPC entities and wire relationships.
//! 3. Call `generate_intra_template_triangles` to generate `TriangleState` values.
//! 4. Spawn each `TriangleState` as an ECS entity with the `ActiveSim` marker.
//! 5. Register the live instance in `ActiveTemplateInstances`.
//!
//! **Instance lifecycle:**
//! Instances are tracked by `TemplateId` in `ActiveTemplateInstances`.
//! `unload_template` despawns all NPC and triangle entities and removes the
//! entry from `ActiveTemplateInstances`.
//!
//! **Determinism (D-010):** given the same `FullTemplateDef`, `TemplateId`,
//! `world_seed`, and `SimRng` state, the spawned NPC and triangle layout is
//! identical.
use std::collections::BTreeMap;
use bevy_ecs::prelude::*;
use crate::content::spawn::spawn_template_npcs;
use crate::content::template::{
generate_intra_template_triangles, FullTemplateDef, TemplateId,
};
use crate::simulation::rng::SimRng;
use crate::simulation::tier::ActiveSim;
// ===========================================================================
// Public types
// ===========================================================================
/// A live template instance — the result of `instantiate_template`.
///
/// Holds entity handles for all NPCs and triangle entities spawned from a
/// single `FullTemplateDef`. Required by `unload_template` to despawn them.
#[derive(Debug, Clone)]
pub struct TemplateInstance {
/// Template this instance was created from.
pub template_id: TemplateId,
/// ECS entities for the NPC role slots (one per `RoleSchema`).
pub npc_entities: Vec<Entity>,
/// ECS entities for the generated `TriangleState` components.
pub triangle_entities: Vec<Entity>,
/// Non-fatal warnings from triangle generation (e.g., fallback assignments).
pub warnings: Vec<String>,
}
/// Resource tracking all currently active template instances.
///
/// Key = `TemplateId.0` (deterministic u64). Initialized on demand by
/// `instantiate_template`; may also be initialized explicitly with
/// `world.init_resource::<ActiveTemplateInstances>()`.
///
/// **Determinism (D-010):** `BTreeMap` for consistent iteration order.
#[derive(Resource, Default, Debug)]
pub struct ActiveTemplateInstances {
instances: BTreeMap<u64, TemplateInstance>,
}
impl ActiveTemplateInstances {
/// Register a new instance. Overwrites any existing entry for the same ID.
pub fn insert(&mut self, instance: TemplateInstance) {
self.instances.insert(instance.template_id.0, instance);
}
/// Look up a live instance by template ID.
pub fn get(&self, template_id: TemplateId) -> Option<&TemplateInstance> {
self.instances.get(&template_id.0)
}
/// Remove and return an instance (used by `unload_template`).
pub fn remove(&mut self, template_id: TemplateId) -> Option<TemplateInstance> {
self.instances.remove(&template_id.0)
}
/// Number of active instances.
pub fn len(&self) -> usize {
self.instances.len()
}
/// `true` if no instances are active.
pub fn is_empty(&self) -> bool {
self.instances.is_empty()
}
}
// ===========================================================================
// Instantiation
// ===========================================================================
/// Instantiate a template: validate, spawn NPCs, generate triangles, register.
///
/// **Preconditions:**
/// - `EntityRegistry` must be initialized as a world resource (done by
/// `SimulationPlugin` at startup).
/// - `ActiveTemplateInstances` is initialized on demand inside this function.
///
/// **Returns** the created `TemplateInstance` (also stored in
/// `ActiveTemplateInstances`).
///
/// **Errors:** returns `Err(String)` if `template_def.validate()` fails.
pub fn instantiate_template(
world: &mut World,
template_def: &FullTemplateDef,
template_id: TemplateId,
world_seed: u64,
rng: &mut SimRng,
) -> Result<TemplateInstance, String> {
// Schema validation before any ECS mutations.
template_def.validate()?;
// Phases 13: NPC spawn + relationship wiring + cross-template ref map.
let spawn_result = spawn_template_npcs(world, template_def, template_id, world_seed, rng);
// Phase 4: Generate intra-template triangle state values.
let tri_result =
generate_intra_template_triangles(world, template_id, &template_def.triangles, rng);
let warnings = tri_result.warnings;
// Spawn each TriangleState as a dedicated ECS entity with ActiveSim so
// the escalation system can pick it up (D-087).
let triangle_entities: Vec<Entity> = tri_result
.triangles
.into_iter()
.map(|state| world.spawn((ActiveSim, state)).id())
.collect();
let instance = TemplateInstance {
template_id,
npc_entities: spawn_result.entities,
triangle_entities,
warnings,
};
// Register in ActiveTemplateInstances (init if absent).
// If a previous instance with the same ID exists, unload it first to
// prevent orphaned ECS entities (Hoshe review #2).
world.init_resource::<ActiveTemplateInstances>();
let previous = world
.resource_mut::<ActiveTemplateInstances>()
.remove(template_id);
if let Some(prev) = previous {
tracing::warn!(
"instantiate_template: overwriting live TemplateId({}) — despawning {} entities",
template_id.0,
prev.npc_entities.len() + prev.triangle_entities.len(),
);
for entity in prev.npc_entities.iter().chain(prev.triangle_entities.iter()) {
if world.get_entity(*entity).is_ok() {
world.despawn(*entity);
}
}
}
world
.resource_mut::<ActiveTemplateInstances>()
.insert(instance.clone());
Ok(instance)
}
// ===========================================================================
// Lifecycle: unload
// ===========================================================================
/// Unload a template instance: despawn all entities and remove from tracking.
///
/// No-op (with a warning log) if the given `template_id` is not active.
pub fn unload_template(world: &mut World, template_id: TemplateId) {
let instance = world
.resource_mut::<ActiveTemplateInstances>()
.remove(template_id);
let Some(instance) = instance else {
tracing::warn!(
"unload_template: TemplateId({}) not active — no-op",
template_id.0
);
return;
};
let mut despawned = 0usize;
for entity in instance.npc_entities.iter().chain(instance.triangle_entities.iter()) {
if world.get_entity(*entity).is_ok() {
world.despawn(*entity);
despawned += 1;
}
}
tracing::info!(
"unload_template: TemplateId({}) unloaded — {} entities despawned",
template_id.0,
despawned,
);
}
// ===========================================================================
// YAML loader
// ===========================================================================
/// Load a `FullTemplateDef` from a YAML file on disk.
///
/// Returns `Err(String)` if the file cannot be read or fails YAML parsing.
pub fn load_template_from_file(path: &std::path::Path) -> Result<FullTemplateDef, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {:?}: {}", path, e))?;
serde_yaml::from_str::<FullTemplateDef>(&content)
.map_err(|e| format!("failed to parse {:?}: {}", path, e))
}
+1
View File
@@ -11,6 +11,7 @@
//! handles the mapping between the two representations.
pub mod hot_reload;
pub mod instantiation;
pub mod line_pool;
pub mod loader;
pub mod spawn;
+383 -1
View File
@@ -34,6 +34,15 @@ use crate::simulation::movement::TilePosition;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::DayPhase;
// #166 — Template-to-instance mapping
use rand::Rng as _;
use crate::content::template::{
FullTemplateDef, RoleId, TemplateId, TemplateOwnership, TemplateReference, TemplateReferenceMap,
};
use crate::npc::generate::{generate_npc, RoleDefinition};
use crate::npc::{Relationship, Relationships};
use crate::simulation::rng::SimRng;
/// Stable content identifier from YAML (e.g., "kael-davan", "sera-venn").
///
/// Bridges authoring identity to ECS entities. Independent of StableId —
@@ -656,6 +665,160 @@ pub fn parse_day_phase(s: &str) -> Option<DayPhase> {
}
}
// ===========================================================================
// #166 — Template-to-instance mapping
// ===========================================================================
/// Result of spawning all NPC role slots for a template.
#[derive(Debug)]
pub struct TemplateSpawnResult {
/// Stable ID assigned to each role slot. BTreeMap for deterministic ordering (D-010).
pub role_assignments: BTreeMap<RoleId, StableId>,
/// ECS entity handles in the same order as `FullTemplateDef::roles`.
pub entities: Vec<Entity>,
}
/// Spawn NPC entities for all role slots in a `FullTemplateDef` (#166).
///
/// Three-phase process:
///
/// **Phase 1 — Spawn:** For each `RoleSchema`, build a `RoleDefinition` and
/// call `generate_npc()`. Register the entity in `EntityRegistry`, then
/// insert `StableEntityId` + `TemplateOwnership`.
///
/// **Phase 2 — Relationships:** Wire intra-template `RelationshipConstraint`s.
/// Each constraint becomes a `Relationship` entry on the NPC, with trust
/// sampled within `[min, max]` via `rng`.
///
/// **Phase 3 — Reference map:** Record cross-template links in
/// `TemplateReferenceMap`, resolving `to_template_slug` to `TemplateId`
/// via `TemplateId::from_seed_and_slug(world_seed, slug)`.
///
/// **Caller precondition:** `EntityRegistry` must be initialized as a world
/// resource (done by `SimulationPlugin`). `TemplateReferenceMap` is
/// initialized inside this function if absent.
///
/// **Determinism (D-010):** All randomness flows through `rng`. Same seed
/// and `FullTemplateDef` → same NPC layout every time.
pub fn spawn_template_npcs(
world: &mut World,
template_def: &FullTemplateDef,
template_id: TemplateId,
world_seed: u64,
rng: &mut SimRng,
) -> TemplateSpawnResult {
let mut role_assignments: BTreeMap<RoleId, StableId> = BTreeMap::new();
let mut role_entities: BTreeMap<RoleId, Entity> = BTreeMap::new();
let mut entities: Vec<Entity> = Vec::new();
// -----------------------------------------------------------------------
// Phase 1: Spawn one NPC per role slot
// -----------------------------------------------------------------------
for role_schema in &template_def.roles {
// Enable combat capability for roles whose skill focus includes Combat.
let combat_enabled = role_schema.skill_focus.contains(&crate::npc::Skill::Combat);
let role_def = RoleDefinition {
name: role_schema.role_id.0.clone(),
// Location pool is empty at template-def time — positions are resolved
// when the template is placed in the world (#161).
location_pool: vec![],
// Relationship targets are empty — Phase 2 wires them from constraints.
relationship_targets: vec![],
known_facts: vec![],
skill_focus: role_schema.skill_focus.clone(),
combat_enabled,
};
let entity = generate_npc(&role_def, world, rng);
// Vision + awareness components (#66) — must match spawn_npc().
// Without these, vision/awareness systems silently skip template-spawned NPCs.
world.entity_mut(entity).insert((
crate::npc::vision::NpcVisionState::default(),
crate::npc::vision::NpcMemory::default(),
crate::npc::awareness::PlayerAwareness::default(),
));
// Register the entity in EntityRegistry and attach stable identity.
let stable_id = world.resource_mut::<EntityRegistry>().register(entity);
world.entity_mut(entity).insert((
StableEntityId(stable_id),
TemplateOwnership {
template_id,
role_id: role_schema.role_id.clone(),
},
));
role_assignments.insert(role_schema.role_id.clone(), stable_id);
role_entities.insert(role_schema.role_id.clone(), entity);
entities.push(entity);
}
// -----------------------------------------------------------------------
// Phase 2: Wire intra-template relationship constraints
// -----------------------------------------------------------------------
for role_schema in &template_def.roles {
let Some(&from_entity) = role_entities.get(&role_schema.role_id) else {
continue;
};
for constraint in &role_schema.relationship_constraints {
let Some(&with_stable_id) = role_assignments.get(&constraint.with_role) else {
// Referenced role is not in this template — cross-template links
// are handled via TemplateReferenceMap (Phase 3), not Relationships.
tracing::debug!(
"spawn_template_npcs: constraint references role '{}' not in template '{}', skipping",
constraint.with_role.0,
template_def.slug,
);
continue;
};
// Sample trust within the authored range. If range is degenerate, use min.
let trust: i8 = if constraint.required_trust.min >= constraint.required_trust.max {
constraint.required_trust.min
} else {
rng.rng.random_range(
constraint.required_trust.min..=constraint.required_trust.max,
)
};
// Append the relationship — generate_npc starts with empty relationship_targets
// so there are no pre-existing duplicates to guard against.
if let Some(mut rels) = world.get_mut::<Relationships>(from_entity) {
rels.entries.push(Relationship {
target_id: with_stable_id,
kind: constraint.kind.clone(),
trust_level: trust,
history: vec![],
});
}
}
}
// -----------------------------------------------------------------------
// Phase 3: Record cross-template reference links in TemplateReferenceMap
// -----------------------------------------------------------------------
world.init_resource::<TemplateReferenceMap>();
for link in &template_def.cross_template_links {
let to_template_id = TemplateId::from_seed_and_slug(world_seed, &link.to_template_slug);
world
.resource_mut::<TemplateReferenceMap>()
.add(TemplateReference {
from_template: template_id,
to_template: to_template_id,
via_role: link.from_role.clone(),
relationship_metadata: link.relationship.clone(),
});
}
TemplateSpawnResult {
role_assignments,
entities,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1108,7 +1271,6 @@ mod tests {
assert_eq!(edge.trust, 7);
}
#[test]
#[test]
fn spawn_npc_combat_trained_sets_skill_flag() {
// #91: combat_trained: true in YAML sets SkillSet.combat_trained = true.
@@ -1180,4 +1342,224 @@ mod tests {
.unwrap();
assert!(world.get::<npc::Relationships>(entity).is_none());
}
// -----------------------------------------------------------------------
// #166 — Template-to-instance mapping tests
// -----------------------------------------------------------------------
fn minimal_template_def_4_roles() -> crate::content::template::FullTemplateDef {
use crate::content::template::{
ConflictType, CrossTemplateLinkSpec, FullTemplateDef, NpcAxis, PrivacyLevel,
RelationshipConstraint, RoleId, RoleSchema, SpaceSpec, TrafficPattern, TriangleDef,
TriangleId, TrustRange,
};
use crate::npc::{RelationshipKind, Skill};
FullTemplateDef {
slug: "test-hub".to_string(),
display_name: "Test Hub".to_string(),
description: None,
roles: vec![
RoleSchema {
role_id: RoleId::new("manager"),
required_traits: vec![],
skill_focus: vec![Skill::Persuasion, Skill::Observation],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("worker"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 1, max: 4 },
}],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("worker"),
required_traits: vec![],
skill_focus: vec![Skill::Technical],
relationship_constraints: vec![],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("guard"),
required_traits: vec![],
skill_focus: vec![Skill::Combat, Skill::Observation],
relationship_constraints: vec![],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("contact"),
required_traits: vec![],
skill_focus: vec![Skill::Stealth, Skill::Persuasion],
relationship_constraints: vec![],
routine_template: vec![],
},
],
space: SpaceSpec {
tile_count_min: 30,
tile_count_max: 80,
sightline_zones: vec![],
privacy_level: PrivacyLevel::SemiPrivate,
traffic_pattern: TrafficPattern::Destination,
},
triangles: vec![
TriangleDef {
triangle_id: TriangleId(0),
roles: [
RoleId::new("manager"),
RoleId::new("worker"),
RoleId::new("guard"),
],
conflict_type: ConflictType::ResourceCompetition,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
},
TriangleDef {
triangle_id: TriangleId(0),
roles: [
RoleId::new("manager"),
RoleId::new("contact"),
RoleId::new("guard"),
],
conflict_type: ConflictType::AuthorityChallenge,
interest_axes: [NpcAxis::Relationships, NpcAxis::Tolerance, NpcAxis::Secret],
relationship_constraints: vec![],
},
],
dialogue_pools: vec![],
cross_template_links: vec![CrossTemplateLinkSpec {
from_role: RoleId::new("worker"),
to_template_slug: "bar".to_string(),
relationship: crate::npc::RelationshipKind::Colleague,
}],
}
}
#[test]
fn spawn_template_npcs_fills_all_role_slots() {
use crate::content::template::{RoleId, TemplateId};
use crate::simulation::rng::SimRng;
let mut world = create_test_world();
let template_def = minimal_template_def_4_roles();
let template_id = TemplateId::from_seed_and_slug(42, "test-hub");
let mut rng = SimRng::new(42);
let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng);
assert_eq!(result.role_assignments.len(), 4, "all 4 role slots must be filled");
assert_eq!(result.entities.len(), 4, "4 entities expected");
assert!(result.role_assignments.contains_key(&RoleId::new("manager")));
assert!(result.role_assignments.contains_key(&RoleId::new("worker")));
assert!(result.role_assignments.contains_key(&RoleId::new("guard")));
assert!(result.role_assignments.contains_key(&RoleId::new("contact")));
}
#[test]
fn spawn_template_npcs_sets_template_ownership() {
use crate::content::template::{RoleId, TemplateId, TemplateOwnership};
use crate::simulation::rng::SimRng;
let mut world = create_test_world();
let template_def = minimal_template_def_4_roles();
let template_id = TemplateId::from_seed_and_slug(42, "test-hub");
let mut rng = SimRng::new(42);
let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng);
let valid_roles = [
RoleId::new("manager"),
RoleId::new("worker"),
RoleId::new("guard"),
RoleId::new("contact"),
];
for entity in &result.entities {
let ownership = world
.get::<TemplateOwnership>(*entity)
.expect("entity must have TemplateOwnership");
assert_eq!(ownership.template_id, template_id, "template_id must match");
assert!(
valid_roles.contains(&ownership.role_id),
"role_id {:?} not in expected roles",
ownership.role_id,
);
}
}
#[test]
fn spawn_template_npcs_records_cross_template_references() {
use crate::content::template::{TemplateId, TemplateReferenceMap};
use crate::simulation::rng::SimRng;
let mut world = create_test_world();
let template_def = minimal_template_def_4_roles();
let template_id = TemplateId::from_seed_and_slug(42, "test-hub");
let mut rng = SimRng::new(42);
spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng);
let ref_map = world.resource::<TemplateReferenceMap>();
let outgoing = ref_map.outgoing(template_id);
assert_eq!(outgoing.len(), 1, "one cross-template link expected");
assert_eq!(outgoing[0].from_template, template_id);
let expected_target = TemplateId::from_seed_and_slug(42, "bar");
assert_eq!(
outgoing[0].to_template, expected_target,
"target template_id must match seed+slug derivation"
);
}
#[test]
fn spawn_template_npcs_wires_relationship_constraints() {
use crate::content::template::{RoleId, TemplateId};
use crate::simulation::rng::SimRng;
let mut world = create_test_world();
let template_def = minimal_template_def_4_roles();
let template_id = TemplateId::from_seed_and_slug(42, "test-hub");
let mut rng = SimRng::new(42);
let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng);
// The "manager" role has a RelationshipConstraint toward "worker" (trust 14).
let manager_stable_id = result.role_assignments[&RoleId::new("manager")];
let worker_stable_id = result.role_assignments[&RoleId::new("worker")];
let manager_entity = world
.resource::<EntityRegistry>()
.to_entity(&manager_stable_id)
.unwrap();
let rels = world.get::<npc::Relationships>(manager_entity).unwrap();
let worker_rel = rels.entries.iter().find(|r| r.target_id == worker_stable_id);
assert!(
worker_rel.is_some(),
"manager must have a relationship toward worker (from RelationshipConstraint)"
);
let trust = worker_rel.unwrap().trust_level;
assert!(
trust >= 1 && trust <= 4,
"trust {} not in authored range [1, 4]",
trust
);
}
#[test]
fn spawn_template_npcs_is_deterministic() {
use crate::content::template::TemplateId;
use crate::simulation::rng::SimRng;
let template_def = minimal_template_def_4_roles();
let template_id = TemplateId::from_seed_and_slug(42, "test-hub");
let mut world1 = create_test_world();
let result1 =
spawn_template_npcs(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42));
let mut world2 = create_test_world();
let result2 =
spawn_template_npcs(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42));
assert_eq!(
result1.role_assignments, result2.role_assignments,
"spawn_template_npcs must be deterministic (D-010)"
);
}
}
+399 -39
View File
@@ -462,6 +462,131 @@ impl TriangleDef {
}
}
// ===========================================================================
// #159 — Full Tier 2 template document
// ===========================================================================
/// Dialogue pool reference within a template (D-028).
///
/// Refers to an existing authored dialogue pool by (location, roles).
/// The pool content lives in the campaign dialogue files; this reference
/// wires the pool to the social site for runtime line selection.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemplateDialoguePoolRef {
/// The location identifier the pool is authored under (e.g., "the-terminal").
pub location: String,
/// Role slugs from this template that draw from the pool.
/// Empty = all roles may draw from this pool.
#[serde(default)]
pub roles: Vec<String>,
}
/// Specification of a cross-template link authored in the template file (D-025).
///
/// At instantiation time the engine resolves these into `TemplateReference`
/// entries in `TemplateReferenceMap`. The actual target template is identified
/// by slug, not a pre-computed `TemplateId`, because IDs are seed-dependent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrossTemplateLinkSpec {
/// The role in THIS template that holds the cross-template relationship.
pub from_role: RoleId,
/// Slug of the external template being referenced.
pub to_template_slug: String,
/// Relationship kind for the `TemplateReference` link.
pub relationship: RelationshipKind,
}
/// Full Tier 2 social site template definition (#159).
///
/// The composite YAML document combining all sub-schemas into one canonical
/// template file. Authored in `server/data/templates/*.yaml` and loaded by
/// the template instantiation engine (#161).
///
/// Per D-023 (three-tier content model) and D-025 (social site as atomic unit):
/// one file = one social site = 48 roles + spatial spec + 2+ triangles.
///
/// **YAML authoring note:** `triangle_id` fields in authored `TriangleDef`s
/// should be set to 0 as a placeholder — the instantiation engine overwrites
/// them with `TriangleId::from_seed_and_roles(world_seed, &roles)` at runtime.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FullTemplateDef {
/// Stable slug used to derive `TemplateId` via `TemplateId::from_seed_and_slug`.
pub slug: String,
/// Human-readable display name.
pub display_name: String,
/// Optional authoring description (not surfaced to players).
#[serde(default)]
pub description: Option<String>,
/// Role definitions (D-024 10-axis NPC constraints per role, 48 roles).
pub roles: Vec<RoleSchema>,
/// Spatial requirements (D-025: 3080 sim tiles).
pub space: SpaceSpec,
/// Triangle definitions (D-024 minimum 2 per template, D-087 configuration).
pub triangles: Vec<TriangleDef>,
/// Dialogue pool references for runtime line selection (D-028).
#[serde(default)]
pub dialogue_pools: Vec<TemplateDialoguePoolRef>,
/// Cross-template link specs resolved to `TemplateReferenceMap` at instantiation.
#[serde(default)]
pub cross_template_links: Vec<CrossTemplateLinkSpec>,
}
impl FullTemplateDef {
/// Validate the full template definition.
///
/// Checks (in order):
/// 1. No duplicate `role_id`s in the roles list.
/// 2. Each role validates individually.
/// 3. Space spec validates.
/// 4. At least 2 triangles (D-024).
/// 5. Each triangle validates individually.
/// 6. All role references in triangles are defined in the roles list.
///
/// Returns `Err` with the first failure description found.
pub fn validate(&self) -> Result<(), String> {
// 1 — no duplicate role IDs
validate_role_schemas_no_duplicate_ids(&self.roles)?;
// 2 — each role validates
for role in &self.roles {
role.validate().map_err(|e| format!("role '{}': {}", role.role_id.0, e))?;
}
// 3 — space spec
self.space.validate()?;
// 4 — minimum 2 triangles
if self.triangles.len() < 2 {
return Err(format!(
"template '{}': fewer than 2 triangles ({}) — D-024 requires minimum 2",
self.slug,
self.triangles.len()
));
}
// 5 — each triangle validates
for tri in &self.triangles {
tri.validate()
.map_err(|e| format!("triangle {:?}: {}", tri.triangle_id, e))?;
}
// 6 — triangle role references must exist in roles list
let role_ids: BTreeSet<&RoleId> = self.roles.iter().map(|r| &r.role_id).collect();
for tri in &self.triangles {
for role_id in &tri.roles {
if !role_ids.contains(role_id) {
return Err(format!(
"triangle {:?}: role '{}' is not defined in template roles",
tri.triangle_id, role_id.0
));
}
}
}
Ok(())
}
}
// ===========================================================================
// #107 — Intra-template triangle generation
// ===========================================================================
@@ -557,6 +682,16 @@ pub fn generate_intra_template_triangles(
let role_to_npc: BTreeMap<RoleId, StableId> = npc_roles.into_iter().collect();
for def in defs {
// Validate triangle definition before generating TriangleState (#109).
// Mirrors the cross-template path in generate_cross_template_triangles.
if let Err(e) = validate_triangle_def(def) {
result.warnings.push(format!(
"Triangle {:?}: skipped — validation failed: {}",
def.triangle_id, e
));
continue;
}
let mut role_assignments = BTreeMap::new();
let mut assigned_npcs = BTreeSet::new();
let mut assignment_ok = true;
@@ -616,6 +751,231 @@ pub fn generate_intra_template_triangles(
result
}
// ===========================================================================
// #109 — Triangle validation
// ===========================================================================
/// Errors returned when a `TriangleDef` fails instantiation-time validation.
///
/// These checks run before role assignment to catch degenerate triangle
/// definitions that cannot produce meaningful drama.
///
/// Spec: #109, D-024, D-087
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
/// None of the three role interest_axes is `NpcAxis::Want`.
///
/// A viable conflict requires at least one role whose primary tension is
/// their Want axis — without it there is no active driver of conflict.
ConflictViability { triangle_id: TriangleId },
/// `relationship_constraints` is empty — no authored relationship links
/// the three roles together.
///
/// A coherent triangle requires at least one explicit relationship
/// constraint documenting how the roles are socially connected.
RelationshipCoherence { triangle_id: TriangleId },
/// Two or more roles share the same `interest_axes` value.
///
/// Each role must have a distinct tension axis so their interests genuinely
/// diverge. Duplicate axes indicate the triangle is underspecified.
InterestDivergence {
triangle_id: TriangleId,
duplicate_axis: NpcAxis,
},
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationError::ConflictViability { triangle_id } => write!(
f,
"Triangle {:?}: conflict viability — no NpcAxis::Want among the three interest_axes",
triangle_id
),
ValidationError::RelationshipCoherence { triangle_id } => write!(
f,
"Triangle {:?}: relationship coherence — relationship_constraints is empty",
triangle_id
),
ValidationError::InterestDivergence { triangle_id, duplicate_axis } => write!(
f,
"Triangle {:?}: interest divergence — duplicate interest_axes value {:?}",
triangle_id, duplicate_axis
),
}
}
}
/// Validate a `TriangleDef` for instantiation quality (#109).
///
/// Three checks:
/// 1. **Conflict viability** — at least one of the three `interest_axes` is
/// `NpcAxis::Want`, ensuring an active want-driven tension.
/// 2. **Relationship coherence** — `relationship_constraints` is non-empty,
/// documenting at least one social link among the three roles.
/// 3. **Interest divergence** — all three `interest_axes` are distinct, so
/// each role brings a genuinely different tension to the triangle.
///
/// Returns `Ok(())` if all checks pass, or `Err(ValidationError)` on the
/// first failure (conflict viability is checked first, then coherence, then
/// divergence).
pub fn validate_triangle_def(def: &TriangleDef) -> Result<(), ValidationError> {
// 1. Conflict viability: at least one Want axis
if !def.interest_axes.iter().any(|a| *a == NpcAxis::Want) {
return Err(ValidationError::ConflictViability {
triangle_id: def.triangle_id,
});
}
// 2. Relationship coherence: at least one relationship constraint
if def.relationship_constraints.is_empty() {
return Err(ValidationError::RelationshipCoherence {
triangle_id: def.triangle_id,
});
}
// 3. Interest divergence: all three axes must be distinct
let [a0, a1, a2] = def.interest_axes;
if a0 == a1 {
return Err(ValidationError::InterestDivergence {
triangle_id: def.triangle_id,
duplicate_axis: a0,
});
}
if a0 == a2 {
return Err(ValidationError::InterestDivergence {
triangle_id: def.triangle_id,
duplicate_axis: a0,
});
}
if a1 == a2 {
return Err(ValidationError::InterestDivergence {
triangle_id: def.triangle_id,
duplicate_axis: a1,
});
}
Ok(())
}
// ===========================================================================
// #108 — Cross-template triangle generation
// ===========================================================================
/// Generate triangle instances that span two social site templates (#108).
///
/// Implements the 1 cross-template triangle required by D-024 ("2 per template
/// minimum, 1 cross-template"). Role assignments draw from NPCs owned by
/// *either* `template_a_id` or `template_b_id` — the combined pool is used
/// for role lookup.
///
/// The generated `TriangleState` is owned by `template_a_id`. D-025 ownership
/// model: NPCs are owned by one template but can hold reference roles in
/// another; the cross-template triangle represents this social link.
///
/// **Validation:** each `TriangleDef` is validated via `validate_triangle_def`
/// before role assignment. Invalid defs are skipped with a warning added to
/// the result.
///
/// **Fallback behavior:** same as `generate_intra_template_triangles` — if no
/// NPC satisfies a role constraint, the closest available NPC is used and a
/// warning is logged. Generation never panics.
///
/// All randomness flows through `rng` for determinism (D-010).
pub fn generate_cross_template_triangles(
world: &mut World,
template_a_id: TemplateId,
template_b_id: TemplateId,
defs: &[TriangleDef],
rng: &mut SimRng,
) -> TriangleGenerationResult {
let mut result = TriangleGenerationResult {
triangles: Vec::new(),
warnings: Vec::new(),
};
// Collect NPCs from both templates into a single role → StableId lookup.
// BTreeMap for determinism (D-010). If both templates define the same
// role slug, template_a wins (insertion order: a first, b second via
// entry().or_insert).
let role_to_npc: BTreeMap<RoleId, StableId> = {
let mut q = world.query::<(&TemplateOwnership, &StableEntityId)>();
let mut map = BTreeMap::new();
for (own, sid) in q.iter(world) {
if own.template_id == template_a_id || own.template_id == template_b_id {
map.entry(own.role_id.clone()).or_insert(sid.0);
}
}
map
};
for def in defs {
// Validate the def before attempting role assignment.
if let Err(e) = validate_triangle_def(def) {
result.warnings.push(format!(
"Cross-template triangle {:?}: skipped — validation failed: {}",
def.triangle_id, e
));
continue;
}
let mut role_assignments = BTreeMap::new();
let mut assigned_npcs = BTreeSet::new();
let mut assignment_ok = true;
for role_id in &def.roles {
if let Some(&stable_id) = role_to_npc.get(role_id) {
if !assigned_npcs.contains(&stable_id) {
role_assignments.insert(role_id.clone(), stable_id);
assigned_npcs.insert(stable_id);
continue;
}
}
// Fallback: first available NPC not already in this triangle.
let fallback = role_to_npc
.values()
.find(|sid| !assigned_npcs.contains(sid));
if let Some(&fallback_sid) = fallback {
result.warnings.push(format!(
"Cross-template triangle {:?}: no NPC for role '{}' — assigned fallback StableId({})",
def.triangle_id, role_id.0, fallback_sid.0
));
role_assignments.insert(role_id.clone(), fallback_sid);
assigned_npcs.insert(fallback_sid);
} else {
result.warnings.push(format!(
"Cross-template triangle {:?}: no NPC available for role '{}' — skipping",
def.triangle_id, role_id.0
));
assignment_ok = false;
break;
}
}
if !assignment_ok {
continue;
}
let initial_tension: u8 = rng.rng.random_range(5_u8..=25);
let tension_rate: u8 = rng.rng.random_range(1_u8..=5);
result.triangles.push(TriangleState {
triangle_id: def.triangle_id,
role_assignments,
tension: initial_tension,
phase: TrianglePhase::Simmering,
tension_rate,
template_id: template_a_id, // cross-template triangle owned by template_a
});
}
result
}
// ===========================================================================
// #250 — Triangle escalation system
// ===========================================================================
@@ -1013,7 +1373,11 @@ mod tests {
],
conflict_type: ConflictType::LoyaltyConflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("waitstaff"),
kind: crate::npc::RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 5 },
}],
},
TriangleDef {
triangle_id: TriangleId::from_seed_and_roles(
@@ -1031,7 +1395,11 @@ mod tests {
],
conflict_type: ConflictType::ResourceCompetition,
interest_axes: [NpcAxis::Want, NpcAxis::Tolerance, NpcAxis::Contentment],
relationship_constraints: vec![],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("bouncer"),
kind: crate::npc::RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 3 },
}],
},
];
@@ -1073,8 +1441,12 @@ mod tests {
RoleId::new("missing_role"), // no NPC has this role
],
conflict_type: ConflictType::SecretExposure,
interest_axes: [NpcAxis::Secret, NpcAxis::Secret, NpcAxis::Secret],
relationship_constraints: vec![],
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Tolerance],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("bouncer"),
kind: crate::npc::RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 5 },
}],
},
TriangleDef {
triangle_id: TriangleId(101),
@@ -1084,8 +1456,12 @@ mod tests {
RoleId::new("also_missing"),
],
conflict_type: ConflictType::LatentTension,
interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance],
relationship_constraints: vec![],
interest_axes: [NpcAxis::Want, NpcAxis::Contentment, NpcAxis::Tolerance],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("bouncer"),
kind: crate::npc::RelationshipKind::Rival,
required_trust: TrustRange { min: -5, max: 0 },
}],
},
];
@@ -1125,8 +1501,12 @@ mod tests {
RoleId::new("regular"),
],
conflict_type: ConflictType::ResourceCompetition,
interest_axes: [NpcAxis::Want, NpcAxis::Want, NpcAxis::Want],
relationship_constraints: vec![],
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Tolerance],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("bouncer"),
kind: crate::npc::RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 5 },
}],
}];
// Should succeed with 1 triangle but log an error about < 2
@@ -1148,7 +1528,11 @@ mod tests {
],
conflict_type: ConflictType::LoyaltyConflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("b"),
kind: crate::npc::RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 5 },
}],
},
TriangleDef {
triangle_id: TriangleId(301),
@@ -1158,8 +1542,12 @@ mod tests {
RoleId::new("d"),
],
conflict_type: ConflictType::LatentTension,
interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance],
relationship_constraints: vec![],
interest_axes: [NpcAxis::Want, NpcAxis::Contentment, NpcAxis::Tolerance],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("c"),
kind: crate::npc::RelationshipKind::Rival,
required_trust: TrustRange { min: -5, max: 0 },
}],
},
];
@@ -1214,7 +1602,6 @@ mod tests {
// #250 — Triangle escalation system tests
// -----------------------------------------------------------------------
use crate::npc::Npc;
use crate::simulation::time::SimulationTime;
use bevy_ecs::schedule::Schedule;
@@ -1227,33 +1614,6 @@ mod tests {
world
}
/// Helper: spawn an NPC with a known StableId and ToleranceThreshold,
/// and register it in the EntityRegistry.
fn spawn_escalation_npc(
world: &mut bevy_ecs::world::World,
stable_id: u64,
threshold: i16,
) -> bevy_ecs::entity::Entity {
let sid = StableId(stable_id);
let entity = world
.spawn((
Npc,
ActiveSim,
StableEntityId(sid),
ToleranceThreshold {
current_stress: 0,
threshold,
},
))
.id();
world
.resource_mut::<EntityRegistry>()
.register_existing(entity, sid);
entity
}
// escalation_simmering_to_active, resolve, dormant_skip, resolved_skip,
// game-minute-only, active-sim-only, saturation, resolve-targeting — all
// covered by integration tests in tests/triangle_escalation.rs.
+105 -1
View File
@@ -176,11 +176,43 @@ fn main() {
// Targets ~20 ticks/sec (2 game-minutes/sec). The TCP bridge uses
// non-blocking reads, so without throttling this loop would spin.
// Remaining frame budget is available for NPC AI and pathfinding.
//
// Panic supervision (#85): each tick is wrapped in catch_unwind.
// On panic, the server sends a structured SimError to the client
// before shutting down, rather than an abrupt disconnect.
let target_frame_time = std::time::Duration::from_millis(50);
loop {
let frame_start = std::time::Instant::now();
app.update();
// Wrap app.update() in catch_unwind to handle system panics (#85).
// AssertUnwindSafe is required because App is not UnwindSafe.
let tick_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
app.update();
}));
match tick_result {
Ok(()) => {}
Err(panic_payload) => {
// Extract panic message for error reporting
let panic_msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
};
tracing::error!("Simulation panic caught: {}", panic_msg);
// Attempt to send a final SimError snapshot to the client.
// Best-effort: if the bridge is unavailable, we just log and exit.
send_panic_error(&app, &panic_msg);
tracing::error!("Server shutting down after panic");
break;
}
}
if !app.world().resource::<ServerRunning>().0 {
break;
}
@@ -200,6 +232,78 @@ fn main() {
tracing::info!("Simulation server shutting down");
}
/// Best-effort: send a final SimError snapshot to the client on panic (#85).
///
/// Builds a minimal ObserverSnapshot with the panic error and sends it
/// through the bridge. If the bridge is unavailable or sending fails,
/// the error is logged but not fatal (we're already crashing).
fn send_panic_error(app: &App, panic_msg: &str) {
use settled_reach_server::bridge::types::*;
use settled_reach_server::simulation::time::{DayPhase, TickRate};
let world = app.world();
// Try to read current tick from SimulationTime
let tick = world
.get_resource::<settled_reach_server::simulation::time::SimulationTime>()
.map(|t| t.tick)
.unwrap_or(0);
let bridge = match world.get_resource::<BridgeResource>() {
Some(b) => b,
None => {
tracing::error!("Cannot send panic error: no BridgeResource");
return;
}
};
// Build a minimal snapshot carrying the panic error
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
tick_rate: TickRate::Paused,
},
player_facing: FacingDirection::North,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities: vec![],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![SimError {
kind: SimErrorKind::Panic,
message: format!("Simulation panic: {}", panic_msg),
tick,
}],
};
if let Err(e) = bridge.send_snapshot(&snapshot) {
tracing::error!("Failed to send panic error to client: {}", e);
} else {
tracing::info!("Sent panic SimError to client at tick {}", tick);
}
}
/// Print bevy_ecs schedule graph and exit.
/// Invoked by --dump-schedule CLI flag (#346).
///
+42 -2
View File
@@ -9,6 +9,7 @@
use bevy_ecs::prelude::*;
use std::collections::BTreeSet;
use crate::bridge::types::*;
use crate::knowledge::graph::filter_by_access;
use crate::knowledge::types::{AccessRule, KnowledgeState};
@@ -103,6 +104,8 @@ pub fn compute_observer_snapshot(
mut crisis_queue: ResMut<TriangleCrisisEventQueue>,
sim_rng: Option<Res<SimRng>>,
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
error_buffer: Option<ResMut<SimErrorBuffer>>,
npc_count_query: Query<Entity, With<crate::npc::Npc>>,
) {
let Ok((
observer_entity,
@@ -390,12 +393,47 @@ pub fn compute_observer_snapshot(
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 triangle crisis events (#250) and filter role_assignments against
// observer KG (D-010 principle 2: information boundaries are universal).
// NPCs unknown to the observer are redacted from the wire event.
let triangle_crisis_events: Vec<TriangleCrisisEventWire> = crisis_queue
.drain()
.into_iter()
.map(TriangleCrisisEventWire::from)
.map(|e| {
let mut wire = TriangleCrisisEventWire::from(e);
wire.role_assignments.retain(|(_, npc_id)| {
observer_kg.knows_entity(&crate::knowledge::types::StableId(*npc_id))
});
wire
})
.collect();
// Compute state hash for desync detection (#85).
// Hash inputs: player position (x, y, z), NPC count, tick number.
// Uses FNV-1a (64-bit) for determinism across Rust versions — DefaultHasher
// is explicitly prohibited by D-010 principle 4 (see template.rs module docs).
let state_hash = {
let mut hash: u64 = 0xcbf29ce484222325; // FNV-1a offset basis
let fnv_fold = |h: &mut u64, bytes: &[u8]| {
for &b in bytes {
*h ^= b as u64;
*h = h.wrapping_mul(0x100000001b3); // FNV-1a prime
}
};
fnv_fold(&mut hash, &time.tick.to_le_bytes());
fnv_fold(&mut hash, &observer_pos.x.to_le_bytes());
fnv_fold(&mut hash, &observer_pos.y.to_le_bytes());
fnv_fold(&mut hash, &observer_pos.z.to_le_bytes());
let npc_count = npc_count_query.iter().count() as u64;
fnv_fold(&mut hash, &npc_count.to_le_bytes());
Some(hash)
};
// Drain sim errors collected this tick (#85)
let sim_errors = error_buffer
.map(|mut buf| buf.drain())
.unwrap_or_default();
buffer.snapshot = Some(ObserverSnapshot {
version: crate::bridge::types::PROTOCOL_VERSION,
tick: time.tick,
@@ -424,6 +462,8 @@ pub fn compute_observer_snapshot(
player_knowledge,
save_result,
triangle_crisis_events,
state_hash,
sim_errors,
});
}
+73 -47
View File
@@ -19,7 +19,7 @@ use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEve
use crate::knowledge::EntityRegistry;
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold};
use crate::simulation::interaction::CLOSE_RANGE;
use crate::simulation::interaction::{ObjectType, CLOSE_RANGE};
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::time::SimulationTime;
@@ -50,6 +50,14 @@ pub struct ExamineResultEvent {
pub target_entity_id: u64,
}
/// Authored examine text for a non-NPC entity (#246).
///
/// Attach to any examinable object (Readable, Terminal, etc.) to provide
/// a fixed description returned when the player examines it.
/// If absent, examining a non-NPC entity returns a generic fallback.
#[derive(Component, Debug, Clone)]
pub struct ExamineText(pub String);
/// Buffer holding the examine result for snapshot inclusion.
///
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
@@ -171,6 +179,11 @@ pub fn generate_examine_text(
/// Process examine interaction: generate character-filtered observation text,
/// push DirectObservation to KnowledgeGraph, write result to ExamineResultBuffer.
///
/// Handles two target types:
/// - NPC entities: generate character-filtered text from NPC component state.
/// - Non-NPC entities with `ExamineText`: use the authored text directly.
/// - Non-NPC entities without `ExamineText`: generic fallback text.
///
/// System ordering: after process_player_input, before compute_observer_snapshot.
#[allow(clippy::type_complexity)]
pub fn process_examine_interaction(
@@ -188,12 +201,16 @@ pub fn process_examine_interaction(
),
With<PlayerCharacter>,
>,
npc_query: Query<(
&TilePosition,
Option<&MoodState>,
Option<&ToleranceThreshold>,
Option<&PersonalityTraits>,
)>,
npc_query: Query<
(
&TilePosition,
Option<&MoodState>,
Option<&ToleranceThreshold>,
Option<&PersonalityTraits>,
),
Without<ObjectType>,
>,
examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>,
) {
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
player_query.single_mut()
@@ -204,55 +221,64 @@ pub fn process_examine_interaction(
let target = examine_req.target;
let archetype = archetype_opt.copied().unwrap_or_default();
// Range check — examine requires close range (same as Talk/Confront)
let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) else {
tracing::warn!(?target, "process_examine_interaction: target not in query");
commands.entity(player_entity).remove::<ExamineRequest>();
return;
};
// Try NPC examine path first
if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) {
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
if distance > CLOSE_RANGE {
tracing::info!(distance, "Examine: NPC target out of range (max {})", CLOSE_RANGE);
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
if distance > CLOSE_RANGE {
tracing::info!(
distance,
"Examine: target out of range (max {})",
CLOSE_RANGE
);
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
kg_events.push(KnowledgeEvent {
observer: player_entity,
tick: time.tick,
event_type: KnowledgeEventType::DirectObservation {
target,
position: *target_pos,
},
});
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
tracing::warn!(?target, "Examine: NPC not in EntityRegistry, using bits");
target.to_bits()
});
result_buffer.result = Some(ExamineResultEvent { text, target_entity_id });
tracing::debug!(target_entity_id, "Examine: NPC result written to buffer");
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
// Object examine path: entity has a TilePosition but no NPC mood components.
if let Ok((target_pos, examine_text_opt)) = examine_text_query.get(target) {
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
if distance > CLOSE_RANGE {
tracing::info!(distance, "Examine: object target out of range (max {})", CLOSE_RANGE);
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
let text = examine_text_opt
.map(|et| et.0.clone())
.unwrap_or_else(|| "No further details are apparent.".to_string());
// Push DirectObservation to KnowledgeEventQueue
kg_events.push(KnowledgeEvent {
observer: player_entity,
tick: time.tick,
event_type: KnowledgeEventType::DirectObservation {
target,
position: *target_pos,
},
});
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
tracing::warn!(?target, "Examine: object not in EntityRegistry, using bits");
target.to_bits()
});
// Resolve target wire ID for snapshot
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
tracing::warn!(?target, "Examine: target not in EntityRegistry, using bits");
target.to_bits()
});
result_buffer.result = Some(ExamineResultEvent {
text,
target_entity_id,
});
tracing::debug!(
target_entity_id,
"Examine: DirectObservation pushed, result written to buffer"
);
result_buffer.result = Some(ExamineResultEvent { text, target_entity_id });
tracing::debug!(target_entity_id, "Examine: object result written to buffer");
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
tracing::warn!(?target, "process_examine_interaction: target has no position component");
commands.entity(player_entity).remove::<ExamineRequest>();
}
+118 -1
View File
@@ -2,9 +2,10 @@
// Timestamped player input events for deterministic simulation (D-010 principle 4)
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance)
use crate::bridge::types::{FacingDirection, PlayerAction, PlayerInput};
use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput};
use crate::knowledge::{EntityRegistry, StableId};
use crate::perception::vision_cone::{facing_from_delta, Facing};
use crate::simulation::interaction::{DoorInteractRequest, DoorState, TerminalInteractRequest};
use crate::simulation::inventory::{
find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS,
};
@@ -97,6 +98,8 @@ pub fn process_player_input(
reset_triggers: Query<&RoomResetTrigger>,
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
mut save_load: Option<ResMut<SaveLoadPending>>,
door_states: Query<&DoorState>,
object_types: Query<&ObjectType>,
) {
let current_tick = time.tick;
let paused = time.paused();
@@ -256,6 +259,25 @@ pub fn process_player_input(
current_tick,
);
}
// #246: Door and Terminal behavior
Some("Open") | Some("Close") => {
handle_door_interact(
&mut commands,
&registry,
&player_query,
&door_states,
target_entity_id,
);
}
Some("Use") => {
handle_terminal_interact(
&mut commands,
&registry,
&player_query,
&object_types,
target_entity_id,
);
}
_ => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only",
@@ -872,6 +894,101 @@ fn handle_reset(
);
}
/// Handle door Open/Close: insert `DoorInteractRequest` on the player entity (#246).
///
/// The actual walkability toggle is done by `process_door_interaction` which
/// reads the request and modifies `WalkabilityMap`. The split keeps system
/// ordering explicit and avoids mutable resource conflicts in one system.
fn handle_door_interact(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(
Entity,
&TilePosition,
Option<&mut Stance>,
Option<&mut PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
door_states: &Query<&DoorState>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Door verb without target_entity_id");
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Door interact: target entity not in registry");
return;
};
// Verify target has DoorState before inserting request
if door_states.get(target_entity).is_err() {
tracing::warn!(target_id, "Door verb on entity without DoorState — ignored");
return;
}
let Ok((player_entity, _, _, _)) = player_query.single() else {
return;
};
commands
.entity(player_entity)
.insert(DoorInteractRequest { door_entity: target_entity });
tracing::debug!(target_id, "Door interact: DoorInteractRequest inserted on player");
}
/// Handle Terminal Use: insert `TerminalInteractRequest` on the player entity (#246).
fn handle_terminal_interact(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(
Entity,
&TilePosition,
Option<&mut Stance>,
Option<&mut PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
object_types: &Query<&ObjectType>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Use verb without target_entity_id");
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Terminal interact: target entity not in registry");
return;
};
// Verify target is a Terminal
match object_types.get(target_entity) {
Ok(ObjectType::Terminal) => {}
_ => {
tracing::warn!(target_id, "Use verb on non-Terminal entity — ignored");
return;
}
}
let Ok((player_entity, _, _, _)) = player_query.single() else {
return;
};
commands
.entity(player_entity)
.insert(TerminalInteractRequest { terminal_entity: target_entity });
tracing::debug!(target_id, "Terminal interact: TerminalInteractRequest inserted on player");
}
/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491).
///
/// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning
+158 -1
View File
@@ -1,6 +1,7 @@
// Interaction system — proximity detection + multi-verb InteractionOptions
// Implements #404: server-side verb computation for context-sensitive [E] key
// Extended by #421: ObjectType component + verb sets per type (D-057)
// Extended by #246: door toggle, terminal event, examine-text (#246)
// Spec: docs/design/interaction-verbs-v0.1.md
// D-060: actions[] renamed to verbs[] across all surfaces
//
@@ -10,14 +11,17 @@
// Phase 2 filtering (KG-gated verbs) handled by #422.
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
// Re-export ObjectType for backward compatibility — definition moved to bridge::types (#422).
pub use crate::bridge::types::ObjectType;
use crate::bridge::types::{EntityKind, MovementStance, NearbyInteraction, VerbKind, VerbOption};
use crate::knowledge::EntityRegistry;
use crate::knowledge::types::StableId;
use crate::npc::Npc;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::stance::Stance;
use crate::simulation::time::SimulationTime;
/// Interaction range thresholds (Manhattan distance, same z-level)
pub(crate) const CLOSE_RANGE: u32 = 2;
@@ -317,6 +321,159 @@ impl NearbyInteractionBuffer {
}
}
// ===========================================================================
// #246 — Door behavior (toggle walkability)
// ===========================================================================
/// Component tracking the open/closed state of a door and its blocking tile.
///
/// Attach to any entity with `ObjectType::Door`. The `blocking_tile` is the
/// tile that becomes walkable when the door opens and impassable when it closes.
///
/// Door state is persisted in `SaveStateV1.open_doors` (D-010).
#[derive(Component, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DoorState {
/// Whether the door is currently open (walkable) or closed (blocking).
pub is_open: bool,
/// The tile whose walkability is toggled by this door.
pub blocking_tile: TilePosition,
}
impl DoorState {
pub fn new(blocking_tile: TilePosition) -> Self {
DoorState {
is_open: false,
blocking_tile,
}
}
}
/// Marker: player requested a door interaction (Open or Close) this tick.
///
/// Inserted by `process_player_input` when verb is "Open" or "Close" on a
/// Door entity. Consumed and removed by `process_door_interaction`.
#[derive(Component, Debug)]
pub struct DoorInteractRequest {
/// The ECS entity of the door to toggle.
pub door_entity: Entity,
}
/// Event emitted when a player uses a Terminal (#246).
///
/// Downstream systems (dialogue hook — future work) subscribe to this queue.
/// The queue is not automatically drained — consumers must call `drain()`.
#[derive(Debug, Clone)]
pub struct TerminalInteracted {
/// Stable ID of the terminal entity.
pub terminal_id: StableId,
/// Tick when the interaction occurred.
pub tick: u64,
}
/// Resource: queue of terminal interaction events (#246).
#[derive(Resource, Default)]
pub struct TerminalInteractedQueue {
pub events: Vec<TerminalInteracted>,
}
impl TerminalInteractedQueue {
pub fn push(&mut self, event: TerminalInteracted) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<TerminalInteracted> {
std::mem::take(&mut self.events)
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
/// Marker: player requested a Terminal interaction this tick.
///
/// Inserted by `process_player_input` when verb is "Use" on a Terminal entity.
/// Consumed and removed by `process_terminal_interaction`.
#[derive(Component, Debug)]
pub struct TerminalInteractRequest {
pub terminal_entity: Entity,
}
/// System: toggle door open/closed state and update walkability map (#246).
///
/// Reads `DoorInteractRequest` on the player entity. Toggles `DoorState.is_open`
/// and updates `WalkabilityMap` for the door's `blocking_tile`.
///
/// Ordering: after `process_player_input`, before movement validation.
pub fn process_door_interaction(
mut commands: Commands,
walkability: Option<ResMut<WalkabilityMap>>,
player_query: Query<(Entity, &DoorInteractRequest), With<PlayerCharacter>>,
mut door_query: Query<&mut DoorState>,
) {
let Ok((player_entity, req)) = player_query.single() else {
return;
};
let door_entity = req.door_entity;
commands.entity(player_entity).remove::<DoorInteractRequest>();
let Ok(mut door) = door_query.get_mut(door_entity) else {
tracing::warn!(?door_entity, "process_door_interaction: no DoorState on target");
return;
};
// Toggle state
door.is_open = !door.is_open;
let walkable = door.is_open;
let tile = door.blocking_tile;
if let Some(mut walkability) = walkability {
walkability.set_walkable(&tile, walkable);
}
tracing::info!(
?tile,
is_open = door.is_open,
"Door toggled: tile walkability set to {walkable}"
);
}
/// System: emit TerminalInteracted event when player uses a Terminal (#246).
///
/// Reads `TerminalInteractRequest` on the player entity, emits to
/// `TerminalInteractedQueue`, and removes the request.
pub fn process_terminal_interaction(
mut commands: Commands,
time: Res<SimulationTime>,
registry: Res<EntityRegistry>,
player_query: Query<(Entity, &TerminalInteractRequest), With<PlayerCharacter>>,
mut queue: ResMut<TerminalInteractedQueue>,
) {
let Ok((player_entity, req)) = player_query.single() else {
return;
};
let terminal_entity = req.terminal_entity;
commands.entity(player_entity).remove::<TerminalInteractRequest>();
let terminal_id = registry
.to_stable(terminal_entity)
.unwrap_or(StableId(terminal_entity.to_bits()));
queue.push(TerminalInteracted {
terminal_id,
tick: time.tick,
});
tracing::info!(
?terminal_entity,
terminal_id = terminal_id.0,
tick = time.tick,
"Terminal used: TerminalInteracted event queued"
);
}
#[cfg(test)]
mod tests {
use super::*;
+7
View File
@@ -62,6 +62,7 @@ impl Plugin for SimulationPlugin {
// Init here so SimulationPlugin works standalone in tests without those plugins.
.init_resource::<crate::npc::relationships::RelationshipGraph>()
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
.init_resource::<interaction::TerminalInteractedQueue>()
.add_systems(
Update,
(
@@ -92,6 +93,12 @@ impl Plugin for SimulationPlugin {
poi_discovery::discover_pois
.after(crate::perception::observer::compute_visibility_geometry)
.before(crate::perception::observer::compute_observer_snapshot),
interaction::process_door_interaction
.after(input::process_player_input)
.before(movement::validate_movement),
interaction::process_terminal_interaction
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
examine::process_examine_interaction
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
+36
View File
@@ -26,6 +26,8 @@ use crate::simulation::rng::SimRng;
use crate::simulation::save_state::{
deserialize_npc_from_frozen, serialize_npc_to_frozen, SaveStateV1, SAVE_FORMAT_VERSION,
};
use crate::knowledge::types::StableId;
use crate::simulation::interaction::DoorState;
use crate::simulation::tier::BackgroundSim;
use crate::simulation::time::SimulationTime;
@@ -130,6 +132,17 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
npc_states,
template_references,
triangle_states,
open_doors: {
use crate::knowledge::registry::StableEntityId;
let mut q = world.query::<(&DoorState, &StableEntityId)>();
let mut ids: Vec<_> = q
.iter(world)
.filter(|(ds, _)| ds.is_open)
.map(|(_, sid)| sid.0)
.collect();
ids.sort_by_key(|id| id.0);
ids
},
};
let bytes = state
@@ -230,6 +243,28 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro
}
world.insert_resource(SimRng::new(state.seed));
// 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 door_entities: Vec<(Entity, StableId)> = {
let mut q = world.query::<(Entity, &crate::knowledge::registry::StableEntityId, &DoorState)>();
q.iter(world)
.filter(|(_, sid, _)| open_set.contains(&sid.0))
.map(|(e, sid, _)| (e, sid.0))
.collect()
};
for (entity, sid) in door_entities {
if let Some(mut door) = world.get_mut::<DoorState>(entity) {
door.is_open = true;
let tile = door.blocking_tile;
if let Some(mut wmap) = world.get_resource_mut::<crate::simulation::movement::WalkabilityMap>() {
wmap.set_walkable(&tile, true);
}
tracing::debug!(stable_id = sid.0, "load: restored open door state");
}
}
}
// Update the player entity's KnowledgeGraph if a player exists.
let player_entity = {
let mut q = world.query_filtered::<Entity, With<PlayerCharacter>>();
@@ -522,6 +557,7 @@ mod tests {
npc_states: vec![],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
open_doors: vec![],
};
let bytes = bad_state.to_bytes().expect("serialize");
let path = temp_path();
+7
View File
@@ -94,6 +94,11 @@ pub struct SaveStateV1 {
/// for deterministic serialization (D-010).
#[serde(default)]
pub triangle_states: Vec<TriangleState>,
/// Stable IDs of doors that are currently open (#246).
/// Doors not in this list are assumed closed on load. Sorted ascending
/// for deterministic serialization (D-010).
#[serde(default)]
pub open_doors: Vec<StableId>,
}
/// Per-NPC state snapshot for `SaveStateV1`.
@@ -405,6 +410,7 @@ mod tests {
npc_states: vec![],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
open_doors: vec![],
}
}
@@ -801,6 +807,7 @@ mod tests {
npc_states: vec![frozen],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
open_doors: vec![],
};
let bytes = save.to_bytes().expect("serialize");
+2
View File
@@ -73,6 +73,8 @@ fn snapshot_roundtrip_over_unix_socket() {
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
};
bridge
+2
View File
@@ -59,6 +59,8 @@ fn snapshot_roundtrip_over_tcp() {
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
};
bridge
+449
View File
@@ -0,0 +1,449 @@
//! Integration tests for basic environmental interaction (#246).
//!
//! Covers the acceptance criteria from the sprint briefing:
//! - Door toggle: player interacts with a Door, walkability flips; interacts again, flips back.
//! - Readable examine: Examine on a Readable entity returns non-empty text.
//! - Terminal interaction: Use on a Terminal emits TerminalInteracted event.
//! - DoorState persists in SaveStateV1.open_doors.
use bevy_ecs::{prelude::*, schedule::Schedule, world::World};
use settled_reach_server::{
knowledge::{registry::EntityRegistry, registry::StableEntityId, types::StableId},
npc::relationships::RelationshipGraph,
simulation::{
examine::{
process_examine_interaction, ExamineRequest, ExamineResultBuffer, ExamineText,
},
interaction::{
process_door_interaction, process_terminal_interaction, DoorInteractRequest,
DoorState, Interactable, ObjectType, TerminalInteractRequest, TerminalInteractedQueue,
},
movement::{PlayerCharacter, TilePosition, WalkabilityMap},
save_state::{SaveStateV1, SAVE_FORMAT_VERSION},
time::{SimulationTime, TickRate},
},
};
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::content::template::TemplateReferenceMap;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_world_with_walkability(width: i32, height: i32) -> World {
let mut world = World::new();
world.insert_resource(WalkabilityMap::new(width, height, 1));
world.init_resource::<EntityRegistry>();
world
}
fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity {
world
.spawn((
PlayerCharacter,
TilePosition::new(x, y, 0),
))
.id()
}
fn spawn_door(world: &mut World, x: i32, y: i32, blocking_x: i32, blocking_y: i32) -> Entity {
world
.spawn((
TilePosition::new(x, y, 0),
Interactable,
ObjectType::Door,
DoorState::new(TilePosition::new(blocking_x, blocking_y, 0)),
))
.id()
}
// ---------------------------------------------------------------------------
// Door behavior tests
// ---------------------------------------------------------------------------
/// Acceptance: player interacts with Door, walkability flips; interacts again, flips back.
#[test]
fn door_toggle_flips_walkability_both_ways() {
let mut world = make_world_with_walkability(20, 20);
// Block the door tile initially
world
.resource_mut::<WalkabilityMap>()
.set_walkable(&TilePosition::new(10, 5, 0), false);
let player = spawn_player(&mut world, 10, 6);
let door = spawn_door(&mut world, 10, 6, 10, 5); // door tile at (10,5)
let mut schedule = Schedule::default();
schedule.add_systems(process_door_interaction);
// First interaction: open the door
world
.entity_mut(player)
.insert(DoorInteractRequest { door_entity: door });
schedule.run(&mut world);
assert!(
world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)),
"after Open: blocking tile must become walkable"
);
assert!(
world.get::<DoorState>(door).unwrap().is_open,
"DoorState.is_open must be true after opening"
);
// Request should be consumed
assert!(
world.get::<DoorInteractRequest>(player).is_none(),
"DoorInteractRequest must be removed after processing"
);
// Second interaction: close the door
world
.entity_mut(player)
.insert(DoorInteractRequest { door_entity: door });
schedule.run(&mut world);
assert!(
!world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)),
"after Close: blocking tile must be impassable again"
);
assert!(
!world.get::<DoorState>(door).unwrap().is_open,
"DoorState.is_open must be false after closing"
);
}
/// Door starts open: toggling closes it (walkability → blocked).
#[test]
fn door_starts_open_toggle_closes_it() {
let mut world = make_world_with_walkability(20, 20);
// Start with door open (tile walkable, is_open = true)
let player = spawn_player(&mut world, 10, 6);
let door = world
.spawn((
TilePosition::new(10, 6, 0),
Interactable,
ObjectType::Door,
DoorState {
is_open: true,
blocking_tile: TilePosition::new(10, 5, 0),
},
))
.id();
// Tile starts walkable (default map is all walkable)
assert!(
world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)),
"precondition: tile is walkable when door starts open"
);
let mut schedule = Schedule::default();
schedule.add_systems(process_door_interaction);
world
.entity_mut(player)
.insert(DoorInteractRequest { door_entity: door });
schedule.run(&mut world);
assert!(
!world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)),
"toggling an open door must block the tile"
);
assert!(
!world.get::<DoorState>(door).unwrap().is_open,
"DoorState.is_open must be false after closing an open door"
);
}
/// Missing DoorState on target: system logs warning and removes request without panic.
#[test]
fn door_interact_without_door_state_does_not_panic() {
let mut world = make_world_with_walkability(10, 10);
let player = spawn_player(&mut world, 5, 5);
let not_a_door = world.spawn(TilePosition::new(5, 6, 0)).id();
world
.entity_mut(player)
.insert(DoorInteractRequest { door_entity: not_a_door });
let mut schedule = Schedule::default();
schedule.add_systems(process_door_interaction);
schedule.run(&mut world);
// Should not panic; request removed
assert!(
world.get::<DoorInteractRequest>(player).is_none(),
"DoorInteractRequest must be consumed even when target lacks DoorState"
);
}
// ---------------------------------------------------------------------------
// Readable examine tests
// ---------------------------------------------------------------------------
/// Acceptance: Examine on a Readable entity with ExamineText returns non-empty text.
#[test]
fn examine_readable_returns_authored_text() {
use settled_reach_server::knowledge::events::KnowledgeEventQueue;
let mut world = World::new();
world.init_resource::<KnowledgeEventQueue>();
world.init_resource::<EntityRegistry>();
world.init_resource::<SimulationTime>();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
ExamineResultBuffer::default(),
))
.id();
let readable = world
.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Readable,
ExamineText("A logistics manifest. Freight records dating back three cycles.".to_string()),
))
.id();
world
.entity_mut(player)
.insert(ExamineRequest { target: readable });
let mut schedule = Schedule::default();
schedule.add_systems(process_examine_interaction);
schedule.run(&mut world);
let event = world
.get_mut::<ExamineResultBuffer>(player)
.unwrap()
.take();
assert!(
event.is_some(),
"ExamineResultBuffer must contain a result after examining a Readable"
);
let text = event.unwrap().text;
assert!(
!text.is_empty(),
"examine result text must be non-empty for a Readable entity"
);
assert!(
text.contains("manifest") || text.contains("Freight") || text.contains("records"),
"text should match the authored ExamineText, got: '{text}'"
);
}
/// Examine on a Readable entity WITHOUT ExamineText returns a generic non-empty fallback.
#[test]
fn examine_readable_without_examine_text_returns_fallback() {
use settled_reach_server::knowledge::events::KnowledgeEventQueue;
let mut world = World::new();
world.init_resource::<KnowledgeEventQueue>();
world.init_resource::<EntityRegistry>();
world.init_resource::<SimulationTime>();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
ExamineResultBuffer::default(),
))
.id();
// Readable but no ExamineText component
let readable = world
.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Readable,
))
.id();
world
.entity_mut(player)
.insert(ExamineRequest { target: readable });
let mut schedule = Schedule::default();
schedule.add_systems(process_examine_interaction);
schedule.run(&mut world);
let event = world
.get_mut::<ExamineResultBuffer>(player)
.unwrap()
.take();
assert!(
event.is_some(),
"ExamineResultBuffer must contain a result even without ExamineText"
);
let text = event.unwrap().text;
assert!(!text.is_empty(), "fallback text must be non-empty, got: '{text}'");
}
/// Examine out of range returns no result.
#[test]
fn examine_readable_out_of_range_returns_no_result() {
use settled_reach_server::knowledge::events::KnowledgeEventQueue;
let mut world = World::new();
world.init_resource::<KnowledgeEventQueue>();
world.init_resource::<EntityRegistry>();
world.init_resource::<SimulationTime>();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
ExamineResultBuffer::default(),
))
.id();
// Distance 8 > CLOSE_RANGE (2)
let readable = world
.spawn((
TilePosition::new(5, 13, 0),
Interactable,
ObjectType::Readable,
ExamineText("Out of range text".to_string()),
))
.id();
world
.entity_mut(player)
.insert(ExamineRequest { target: readable });
let mut schedule = Schedule::default();
schedule.add_systems(process_examine_interaction);
schedule.run(&mut world);
let event = world
.get_mut::<ExamineResultBuffer>(player)
.unwrap()
.take();
assert!(event.is_none(), "examining an out-of-range Readable must not produce a result");
}
// ---------------------------------------------------------------------------
// Terminal interaction tests
// ---------------------------------------------------------------------------
/// Use on a Terminal emits TerminalInteracted event.
#[test]
fn terminal_use_emits_terminal_interacted_event() {
let mut world = World::new();
world.init_resource::<TerminalInteractedQueue>();
world.init_resource::<EntityRegistry>();
world.init_resource::<SimulationTime>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let terminal = world
.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Terminal,
))
.id();
// Register terminal in EntityRegistry so stable ID resolves
let terminal_sid = StableId(42);
world.entity_mut(terminal).insert(StableEntityId(terminal_sid));
world.resource_mut::<EntityRegistry>().register_existing(terminal, terminal_sid);
world
.entity_mut(player)
.insert(TerminalInteractRequest { terminal_entity: terminal });
let mut schedule = Schedule::default();
schedule.add_systems(process_terminal_interaction);
schedule.run(&mut world);
let queue = world.resource::<TerminalInteractedQueue>();
assert_eq!(queue.events.len(), 1, "one TerminalInteracted event must be emitted");
assert_eq!(
queue.events[0].terminal_id, terminal_sid,
"terminal_id must match the interacted terminal"
);
}
/// TerminalInteractRequest is removed after processing.
#[test]
fn terminal_interact_request_consumed_after_processing() {
let mut world = World::new();
world.init_resource::<TerminalInteractedQueue>();
world.init_resource::<EntityRegistry>();
world.init_resource::<SimulationTime>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let terminal = world.spawn(TilePosition::new(5, 6, 0)).id();
world
.entity_mut(player)
.insert(TerminalInteractRequest { terminal_entity: terminal });
let mut schedule = Schedule::default();
schedule.add_systems(process_terminal_interaction);
schedule.run(&mut world);
assert!(
world.get::<TerminalInteractRequest>(player).is_none(),
"TerminalInteractRequest must be removed after processing"
);
}
// ---------------------------------------------------------------------------
// SaveStateV1.open_doors field
// ---------------------------------------------------------------------------
fn minimal_save() -> SaveStateV1 {
SaveStateV1 {
format_version: SAVE_FORMAT_VERSION,
tick: 0,
tick_rate: TickRate::Full,
seed: 42,
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
open_doors: vec![],
}
}
/// SaveStateV1.open_doors round-trips through MessagePack serialization.
#[test]
fn save_state_open_doors_roundtrip() {
let mut save = minimal_save();
save.open_doors = vec![StableId(10), StableId(20), StableId(30)];
let bytes = save.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(
recovered.open_doors,
vec![StableId(10), StableId(20), StableId(30)],
"open_doors must round-trip through MessagePack"
);
}
/// Older save files (no open_doors field) deserialize without error.
/// The field defaults to empty vec via #[serde(default)].
#[test]
fn save_state_open_doors_defaults_to_empty_on_old_saves() {
let save = minimal_save();
assert!(
save.open_doors.is_empty(),
"open_doors must default to empty vec (backward compat with older saves)"
);
}
+314
View File
@@ -0,0 +1,314 @@
//! Error handling integration tests (#85).
//!
//! Tests the three error categories:
//! 1. Protocol errors: malformed input → SimError + server continues
//! 2. Desync detection: state_hash field populated in snapshots
//! 3. SimError wire format roundtrip
use bevy_app::prelude::*;
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
use settled_reach_server::npc::relationships::TrustEventQueue;
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::SimulationPlugin;
use std::io::{BufReader, BufWriter};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Barrier};
use std::thread;
/// Acceptance test (#85): send a malformed message mid-session, assert the
/// server emits a SimError in the next snapshot and continues running.
///
/// Uses barriers to synchronize the server and client threads, ensuring
/// the malformed data arrives before the server's receive_inputs call.
#[test]
fn malformed_input_produces_sim_error_and_server_continues() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let server_addr = listener.local_addr().expect("get local addr");
// Barriers for tick synchronization between server and client.
// Each barrier is used once: client signals "data sent", server proceeds to tick.
let barrier_tick1 = Arc::new(Barrier::new(2));
let barrier_tick2 = Arc::new(Barrier::new(2));
let barrier_tick3 = Arc::new(Barrier::new(2));
let b1_server = Arc::clone(&barrier_tick1);
let b2_server = Arc::clone(&barrier_tick2);
let b3_server = Arc::clone(&barrier_tick3);
// Server thread
let server_handle = thread::spawn(move || {
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.init_resource::<TrustEventQueue>();
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
app.world_mut().spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
MonologueState::default(),
));
// Tick 1: wait for client to send valid input, then process
b1_server.wait();
app.update();
assert!(
app.world().resource::<ServerRunning>().0,
"server should be running after tick 1"
);
// Tick 2: wait for client to send malformed input, then process
b2_server.wait();
app.update();
assert!(
app.world().resource::<ServerRunning>().0,
"server must continue running after malformed input"
);
// Tick 3: wait for client to send valid input, then process
b3_server.wait();
app.update();
assert!(
app.world().resource::<ServerRunning>().0,
"server should still be running after tick 3"
);
});
// Client: connect and interact with synchronization
let stream = TcpStream::connect(server_addr).expect("client connect");
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
let mut writer = BufWriter::new(stream);
// --- Tick 1: send valid input ---
let valid_inputs = vec![PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
}];
let payload = rmp_serde::to_vec_named(&valid_inputs).expect("serialize");
write_framed(&mut writer, &payload).expect("send valid input");
barrier_tick1.wait(); // Signal: valid input sent
// Read tick 1 snapshot
let snap1_bytes = read_framed(&mut reader)
.expect("read snapshot 1")
.expect("not EOF");
let snap1: ObserverSnapshot =
rmp_serde::from_slice(&snap1_bytes).expect("deserialize snapshot 1");
assert!(
snap1.sim_errors.is_empty(),
"no errors expected on tick 1"
);
// --- Tick 2: send malformed input (properly framed but garbage payload) ---
let garbage_payload: Vec<u8> = vec![0xFF, 0xFE, 0xFD, 0xFC, 0xAB, 0xCD, 0xEF];
write_framed(&mut writer, &garbage_payload).expect("send malformed input");
barrier_tick2.wait(); // Signal: malformed input sent
// Read tick 2 snapshot — should contain SimError
let snap2_bytes = read_framed(&mut reader)
.expect("read snapshot 2")
.expect("not EOF");
let snap2: ObserverSnapshot =
rmp_serde::from_slice(&snap2_bytes).expect("deserialize snapshot 2");
assert!(
!snap2.sim_errors.is_empty(),
"sim_errors must contain the protocol error from malformed input"
);
assert_eq!(
snap2.sim_errors[0].kind,
SimErrorKind::ProtocolError,
"error kind must be ProtocolError"
);
assert!(
snap2.sim_errors[0].message.contains("Malformed input frame")
|| snap2.sim_errors[0].message.contains("Deserialization error"),
"error message should describe the deserialization failure, got: {}",
snap2.sim_errors[0].message,
);
// --- Tick 3: send valid input again — server must still work ---
let valid_inputs2 = vec![PlayerInput {
tick: 2,
action: PlayerAction::MoveSouth,
}];
let payload2 = rmp_serde::to_vec_named(&valid_inputs2).expect("serialize");
write_framed(&mut writer, &payload2).expect("send valid input after error");
barrier_tick3.wait(); // Signal: valid input sent
// Read tick 3 snapshot — no errors, server recovered
let snap3_bytes = read_framed(&mut reader)
.expect("read snapshot 3")
.expect("not EOF");
let snap3: ObserverSnapshot =
rmp_serde::from_slice(&snap3_bytes).expect("deserialize snapshot 3");
assert!(
snap3.sim_errors.is_empty(),
"no errors expected on tick 3 — server recovered"
);
// Clean up
drop(reader);
drop(writer);
server_handle.join().expect("server thread should not panic");
}
/// State hash is populated in every snapshot and is deterministic for same state.
#[test]
fn state_hash_populated_in_snapshot() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let server_addr = listener.local_addr().expect("get local addr");
let server_handle = thread::spawn(move || {
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.init_resource::<TrustEventQueue>();
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
app.world_mut().spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
MonologueState::default(),
));
// Run one tick
app.update();
});
let stream = TcpStream::connect(server_addr).expect("client connect");
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
let mut writer = BufWriter::new(stream);
// Send empty input batch (no movement)
let inputs: Vec<PlayerInput> = vec![];
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize");
write_framed(&mut writer, &payload).expect("send empty input");
// Read snapshot
let snap_bytes = read_framed(&mut reader)
.expect("read snapshot")
.expect("not EOF");
let snap: ObserverSnapshot =
rmp_serde::from_slice(&snap_bytes).expect("deserialize snapshot");
assert!(
snap.state_hash.is_some(),
"state_hash must be populated in snapshot"
);
assert_ne!(
snap.state_hash.unwrap(),
0,
"state_hash should be a non-trivial hash value"
);
drop(reader);
drop(writer);
server_handle.join().expect("server thread should not panic");
}
/// SimError roundtrips through MessagePack serialization.
#[test]
fn sim_error_roundtrip() {
let error = SimError {
kind: SimErrorKind::ProtocolError,
message: "test protocol error".into(),
tick: 42,
};
let bytes = rmp_serde::to_vec_named(&error).expect("serialize SimError");
let decoded: SimError = rmp_serde::from_slice(&bytes).expect("deserialize SimError");
assert_eq!(decoded.kind, SimErrorKind::ProtocolError);
assert_eq!(decoded.message, "test protocol error");
assert_eq!(decoded.tick, 42);
}
/// SimErrorKind::Panic variant roundtrips.
#[test]
fn sim_error_panic_variant_roundtrip() {
let error = SimError {
kind: SimErrorKind::Panic,
message: "simulation system panicked".into(),
tick: 100,
};
let bytes = rmp_serde::to_vec_named(&error).expect("serialize");
let decoded: SimError = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.kind, SimErrorKind::Panic);
assert_eq!(decoded.message, "simulation system panicked");
assert_eq!(decoded.tick, 100);
}
/// Snapshot with sim_errors populates correctly through serialization.
#[test]
fn snapshot_with_sim_errors_roundtrips() {
use settled_reach_server::simulation::time::{DayPhase, TickRate};
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 10,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::North,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities: vec![],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: Some(0xDEADBEEF),
sim_errors: vec![
SimError {
kind: SimErrorKind::ProtocolError,
message: "bad frame".into(),
tick: 10,
},
],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.state_hash, Some(0xDEADBEEF));
assert_eq!(decoded.sim_errors.len(), 1);
assert_eq!(decoded.sim_errors[0].kind, SimErrorKind::ProtocolError);
assert_eq!(decoded.sim_errors[0].message, "bad frame");
}
+6
View File
@@ -49,6 +49,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
}
}
@@ -241,6 +243,8 @@ fn generate_msgpack_fixtures() {
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
};
write_fixture(
"snapshot_v2_full",
@@ -403,6 +407,8 @@ fn generate_msgpack_fixtures() {
}],
}),
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
};
write_fixture(
"snapshot_full",
+2 -1
View File
@@ -72,9 +72,10 @@
"rng_seed": 42,
"scan_events": [],
"sound_events": [],
"state_hash": 14452262397297540338,
"tick": 8,
"triangle_crisis_events": [],
"version": 16,
"version": 17,
"visible_tiles": [
{
"tile_kind": "Wall",
+1
View File
@@ -205,6 +205,7 @@ fn save_state_npc_kg_isolation() {
npc_states: vec![npc_a_state, npc_b_state],
template_references: Default::default(),
triangle_states: vec![],
open_doors: vec![],
};
// Roundtrip: serialize → deserialize.
+7 -1
View File
@@ -37,6 +37,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
}
}
@@ -296,6 +298,8 @@ fn snapshot_v2_fields_roundtrip() {
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -350,7 +354,7 @@ fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 16,
PROTOCOL_VERSION, 17,
"bump this assertion when protocol version changes"
);
}
@@ -401,6 +405,8 @@ fn all_facing_direction_variants_roundtrip() {
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
+221
View File
@@ -0,0 +1,221 @@
//! End-to-end tests for the template instantiation engine (#161).
//!
//! Verifies the full pipeline:
//! load YAML → validate → spawn NPCs → generate triangles → lifecycle
//!
//! Spec refs:
//! - D-023: three-tier content model
//! - D-024: 10-axis NPC model, minimum 2 triangles per social site
//! - D-025: social site as atomic template unit, single-ownership
//! - D-010: determinism (same seed → same layout)
use std::path::PathBuf;
use bevy_ecs::prelude::*;
use settled_reach_server::{
content::{
instantiation::{
instantiate_template, load_template_from_file, unload_template,
ActiveTemplateInstances,
},
template::{TemplateId, TemplateOwnership, TriangleState},
},
knowledge::{registry::EntityRegistry, StableEntityId},
simulation::rng::SimRng,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn templates_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data/templates")
}
fn make_test_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
// ---------------------------------------------------------------------------
// End-to-end: load logistics-hub YAML, instantiate, assert structure (#161)
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_end_to_end_logistics_hub() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def =
load_template_from_file(&path).expect("logistics-hub.yaml must load and parse");
let mut world = make_test_world();
let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
let mut rng = SimRng::new(42);
let instance = instantiate_template(&mut world, &template_def, template_id, 42, &mut rng)
.expect("logistics-hub must instantiate without validation errors");
// --- NPCs: all 4 role slots filled ---
assert_eq!(
instance.npc_entities.len(),
4,
"logistics-hub has 4 role slots — 4 NPC entities expected"
);
// --- TemplateOwnership on every NPC ---
let expected_roles = [
"logistics-manager",
"dock-worker",
"ring-contact",
"security-guard",
];
let mut seen_roles: Vec<String> = Vec::new();
for &entity in &instance.npc_entities {
let ownership = world
.get::<TemplateOwnership>(entity)
.expect("every spawned NPC must have TemplateOwnership");
assert_eq!(
ownership.template_id, template_id,
"TemplateOwnership.template_id must match the instantiated template"
);
let role = &ownership.role_id.0;
assert!(
expected_roles.contains(&role.as_str()),
"unexpected role '{}' — not in logistics-hub role list",
role,
);
seen_roles.push(role.clone());
}
// Every role slot must appear exactly once.
for role in &expected_roles {
assert_eq!(
seen_roles.iter().filter(|r| r.as_str() == *role).count(),
1,
"role '{}' must appear exactly once",
role,
);
}
// --- 2+ TriangleState entities (D-024 minimum) ---
assert!(
instance.triangle_entities.len() >= 2,
"logistics-hub must produce at least 2 TriangleState entities (D-024), got {}",
instance.triangle_entities.len(),
);
for &entity in &instance.triangle_entities {
assert!(
world.get::<TriangleState>(entity).is_some(),
"triangle entity {:?} must carry a TriangleState component",
entity,
);
}
// --- Instance registered in ActiveTemplateInstances ---
let active = world.resource::<ActiveTemplateInstances>();
assert!(
active.get(template_id).is_some(),
"instantiated template must be tracked in ActiveTemplateInstances",
);
}
// ---------------------------------------------------------------------------
// Lifecycle: unload despawns all entities
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_unload_despawns_entities() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def = load_template_from_file(&path).expect("must parse");
let mut world = make_test_world();
let template_id = TemplateId::from_seed_and_slug(99, "logistics-hub");
let mut rng = SimRng::new(99);
let instance =
instantiate_template(&mut world, &template_def, template_id, 99, &mut rng)
.expect("must instantiate");
let all_entities: Vec<Entity> = instance
.npc_entities
.iter()
.chain(instance.triangle_entities.iter())
.cloned()
.collect();
assert!(!all_entities.is_empty(), "sanity: some entities were spawned");
unload_template(&mut world, template_id);
// All spawned entities must be gone.
for entity in &all_entities {
assert!(
world.get_entity(*entity).is_err(),
"entity {:?} must be despawned after unload_template",
entity,
);
}
// Instance removed from tracking.
let active = world.resource::<ActiveTemplateInstances>();
assert!(
active.get(template_id).is_none(),
"unloaded template must be removed from ActiveTemplateInstances",
);
}
// ---------------------------------------------------------------------------
// Determinism: same seed → same NPC StableId assignment (D-010)
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_is_deterministic() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def = load_template_from_file(&path).expect("must parse");
let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
let mut world1 = make_test_world();
let instance1 =
instantiate_template(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42))
.expect("must instantiate");
let mut world2 = make_test_world();
let instance2 =
instantiate_template(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42))
.expect("must instantiate");
// Collect (role → StableId) pairs from each world and compare.
let role_stable_ids = |world: &World, entities: &[Entity]| {
let mut pairs: Vec<(String, u64)> = entities
.iter()
.map(|&e| {
let role = world.get::<TemplateOwnership>(e).unwrap().role_id.0.clone();
let sid = world.get::<StableEntityId>(e).unwrap().0 .0;
(role, sid)
})
.collect();
pairs.sort();
pairs
};
let pairs1 = role_stable_ids(&world1, &instance1.npc_entities);
let pairs2 = role_stable_ids(&world2, &instance2.npc_entities);
assert_eq!(
pairs1, pairs2,
"instantiate_template must be deterministic (D-010): same seed → same layout"
);
}
// ---------------------------------------------------------------------------
// YAML loading: invalid path returns Err
// ---------------------------------------------------------------------------
#[test]
fn load_template_from_file_nonexistent_path_returns_err() {
let path = templates_dir().join("nonexistent-template-xyzzy.yaml");
let result = load_template_from_file(&path);
assert!(
result.is_err(),
"loading a nonexistent file must return Err"
);
}
+272 -5
View File
@@ -1,19 +1,21 @@
//! Integration tests for the template schema system (tickets #163, #164, #165, #106).
//! Integration tests for the template schema system (tickets #163, #164, #165, #106, #159).
//!
//! 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-028: dialogue tagged pools
//! - 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,
validate_role_schemas_no_duplicate_ids, ConflictType, CrossTemplateLinkSpec, FullTemplateDef,
NpcAxis, PrivacyLevel, RelationshipConstraint, RoleId, RoleSchema, SightlineZone, SpaceSpec,
TemplateDialoguePoolRef, TemplateId, TemplateOwnership, TemplateReference,
TemplateReferenceMap, TemplateRoutineEntry, TrafficPattern, TriangleDef, TriangleId,
TrustRange,
};
use settled_reach_server::npc::{PersonalityTrait, RelationshipKind, Skill};
@@ -637,3 +639,268 @@ fn template_ownership_survives_clone_for_save_state() {
let saved = ownership.clone();
assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)");
}
// ---------------------------------------------------------------------------
// #159: Full Tier 2 template document — FullTemplateDef
// ---------------------------------------------------------------------------
/// Build a minimal valid FullTemplateDef with two roles and two triangles.
fn minimal_full_template() -> FullTemplateDef {
FullTemplateDef {
slug: "test-site".to_string(),
display_name: "Test Social Site".to_string(),
description: None,
roles: vec![
RoleSchema {
role_id: RoleId::new("manager"),
required_traits: vec![PersonalityTrait::Cautious],
skill_focus: vec![Skill::Persuasion],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("worker"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 0, max: 5 },
}],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("worker"),
required_traits: vec![PersonalityTrait::Honest],
skill_focus: vec![Skill::Technical],
relationship_constraints: vec![],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("informant"),
required_traits: vec![PersonalityTrait::Deceptive],
skill_focus: vec![Skill::Stealth],
relationship_constraints: vec![],
routine_template: vec![],
},
],
space: SpaceSpec {
tile_count_min: 30,
tile_count_max: 80,
sightline_zones: vec![SightlineZone {
name: "main-floor".to_string(),
radius: 6,
}],
privacy_level: PrivacyLevel::SemiPrivate,
traffic_pattern: TrafficPattern::Destination,
},
triangles: vec![
TriangleDef {
triangle_id: TriangleId(0),
roles: [
RoleId::new("manager"),
RoleId::new("worker"),
RoleId::new("informant"),
],
conflict_type: ConflictType::ResourceCompetition,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
},
TriangleDef {
triangle_id: TriangleId(0),
roles: [
RoleId::new("manager"),
RoleId::new("informant"),
RoleId::new("worker"),
],
conflict_type: ConflictType::LatentTension,
interest_axes: [NpcAxis::Tolerance, NpcAxis::Contentment, NpcAxis::Routine],
relationship_constraints: vec![],
},
],
dialogue_pools: vec![TemplateDialoguePoolRef {
location: "the-hub".to_string(),
roles: vec!["manager".to_string(), "worker".to_string()],
}],
cross_template_links: vec![CrossTemplateLinkSpec {
from_role: RoleId::new("worker"),
to_template_slug: "other-site".to_string(),
relationship: RelationshipKind::Colleague,
}],
}
}
#[test]
fn full_template_def_yaml_roundtrip() {
let template = minimal_full_template();
let yaml = serde_yaml::to_string(&template).expect("serialize FullTemplateDef");
let restored: FullTemplateDef =
serde_yaml::from_str(&yaml).expect("deserialize FullTemplateDef");
assert_eq!(restored.slug, template.slug);
assert_eq!(restored.display_name, template.display_name);
assert_eq!(restored.roles.len(), template.roles.len());
assert_eq!(restored.space.tile_count_min, template.space.tile_count_min);
assert_eq!(restored.triangles.len(), template.triangles.len());
assert_eq!(restored.dialogue_pools.len(), template.dialogue_pools.len());
assert_eq!(restored.cross_template_links.len(), template.cross_template_links.len());
// Role round-trip: traits, constraints, routine entries
let role = &restored.roles[0];
assert_eq!(role.role_id, RoleId::new("manager"));
assert_eq!(role.required_traits[0], PersonalityTrait::Cautious);
assert_eq!(role.relationship_constraints[0].with_role, RoleId::new("worker"));
// Triangle round-trip: roles, conflict type, axes
let tri = &restored.triangles[0];
assert_eq!(tri.conflict_type, ConflictType::ResourceCompetition);
assert_eq!(tri.roles[0], RoleId::new("manager"));
assert_eq!(tri.interest_axes[1], NpcAxis::Secret);
// Dialogue pool round-trip
assert_eq!(restored.dialogue_pools[0].location, "the-hub");
assert_eq!(restored.dialogue_pools[0].roles.len(), 2);
// Cross-template link round-trip
assert_eq!(
restored.cross_template_links[0].from_role,
RoleId::new("worker")
);
assert_eq!(
restored.cross_template_links[0].to_template_slug,
"other-site"
);
}
#[test]
fn full_template_def_validation_passes_for_valid_template() {
let template = minimal_full_template();
assert!(
template.validate().is_ok(),
"minimal valid template must pass: {:?}",
template.validate()
);
}
#[test]
fn full_template_def_validation_rejects_fewer_than_2_triangles() {
let mut template = minimal_full_template();
template.triangles.truncate(1);
let result = template.validate();
assert!(result.is_err(), "fewer than 2 triangles must fail");
assert!(
result.unwrap_err().contains("fewer than 2 triangles"),
"error must mention triangle count"
);
}
#[test]
fn full_template_def_validation_rejects_undefined_triangle_role() {
let mut template = minimal_full_template();
// Replace a triangle role with one not in the roles list
template.triangles[0].roles[2] = RoleId::new("ghost-role");
let result = template.validate();
assert!(result.is_err(), "undefined triangle role must fail validation");
assert!(
result.unwrap_err().contains("ghost-role"),
"error must name the undefined role"
);
}
#[test]
fn full_template_def_validation_rejects_duplicate_role_ids() {
let mut template = minimal_full_template();
template.roles.push(RoleSchema {
role_id: RoleId::new("manager"), // duplicate
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![],
routine_template: vec![],
});
let result = template.validate();
assert!(result.is_err(), "duplicate role_id must fail validation");
}
#[test]
fn full_template_def_optional_fields_default_on_minimal_yaml() {
// description, dialogue_pools, cross_template_links are all optional.
let yaml = r#"
slug: "bare-minimum"
display_name: "Bare Minimum Site"
roles:
- role_id: "alpha"
- role_id: "beta"
- role_id: "gamma"
space:
tile_count_min: 30
tile_count_max: 80
privacy_level: Public
traffic_pattern: Thoroughfare
triangles:
- triangle_id: 0
roles:
- "alpha"
- "beta"
- "gamma"
conflict_type: LatentTension
interest_axes:
- Contentment
- Tolerance
- Routine
- triangle_id: 0
roles:
- "alpha"
- "gamma"
- "beta"
conflict_type: ResourceCompetition
interest_axes:
- Want
- Secret
- Relationships
"#;
let def: FullTemplateDef = serde_yaml::from_str(yaml).expect("minimal YAML must parse");
assert_eq!(def.slug, "bare-minimum");
assert!(def.description.is_none());
assert!(def.dialogue_pools.is_empty());
assert!(def.cross_template_links.is_empty());
assert!(def.validate().is_ok(), "minimal template must validate: {:?}", def.validate());
}
/// Acceptance test: the authored logistics-hub.yaml round-trips through serde_yaml.
///
/// The file lives at `server/data/templates/logistics-hub.yaml`.
/// This test is the canonical acceptance criterion for ticket #159.
#[test]
fn logistics_hub_yaml_roundtrips_cleanly() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/data/templates/logistics-hub.yaml"
);
let raw = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("could not read logistics-hub.yaml: {}", e));
let def: FullTemplateDef = serde_yaml::from_str(&raw)
.unwrap_or_else(|e| panic!("logistics-hub.yaml failed to deserialize: {}", e));
// Structural assertions
assert_eq!(def.slug, "logistics-hub");
assert_eq!(def.roles.len(), 4, "logistics hub must define 4 roles");
assert_eq!(def.triangles.len(), 2, "logistics hub must define 2 triangles");
assert!(!def.dialogue_pools.is_empty(), "dialogue_pools must be present");
assert!(!def.cross_template_links.is_empty(), "cross_template_links must be present");
// Spatial spec assertions (D-025: 3080 sim tiles)
assert!(def.space.validate().is_ok(), "space spec must validate");
assert_eq!(def.space.tile_count_min, 30);
assert_eq!(def.space.tile_count_max, 80);
// Validation must pass
assert!(
def.validate().is_ok(),
"logistics-hub.yaml must pass full validation: {:?}",
def.validate()
);
// Round-trip: serialize back to YAML then deserialize again
let reserialized = serde_yaml::to_string(&def).expect("re-serialize");
let restored: FullTemplateDef =
serde_yaml::from_str(&reserialized).expect("re-deserialize after round-trip");
assert_eq!(def.slug, restored.slug);
assert_eq!(def.roles.len(), restored.roles.len());
assert_eq!(def.triangles.len(), restored.triangles.len());
assert_eq!(def.dialogue_pools.len(), restored.dialogue_pools.len());
assert_eq!(def.cross_template_links.len(), restored.cross_template_links.len());
}
+442
View File
@@ -0,0 +1,442 @@
//! Integration tests for triangle validation and cross-template generation (#108, #109).
//!
//! Spec references:
//! - D-024: NPC generation model — minimum 2 triangles per template, 1 cross-template
//! - D-087: v0.1 triangle configuration — 3 active forks, 2 passive tensions
//! - D-025: social site as atomic template unit — cross-template reference links
//!
//! Test naming follows the cargo test filter target:
//! `cargo test -p settled-reach-server -- triangle_validation`
use settled_reach_server::{
content::template::{
generate_cross_template_triangles, generate_intra_template_triangles,
validate_triangle_def, ConflictType, NpcAxis, RelationshipConstraint, RoleId,
TemplateId, TemplateOwnership, TriangleDef, TriangleId, TrianglePhase, TrustRange,
ValidationError,
},
knowledge::{registry::StableEntityId, types::StableId},
npc::{Npc, RelationshipKind},
simulation::rng::SimRng,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Minimal valid TriangleDef that passes all three validation checks.
///
/// - interest_axes: [Want, Secret, Relationships] → all distinct, has Want
/// - relationship_constraints: non-empty
fn valid_triangle_def(id: u64) -> TriangleDef {
TriangleDef {
triangle_id: TriangleId(id),
roles: [
RoleId::new("ops-manager"),
RoleId::new("freight-handler"),
RoleId::new("inspector"),
],
conflict_type: ConflictType::LoyaltyConflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("freight-handler"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 1, max: 5 },
}],
}
}
/// Spawn an NPC entity with TemplateOwnership in the given world.
fn spawn_template_npc(
world: &mut bevy_ecs::world::World,
template_id: TemplateId,
role: &str,
stable_id: u64,
) {
world.spawn((
Npc,
TemplateOwnership {
template_id,
role_id: RoleId::new(role),
},
StableEntityId(StableId(stable_id)),
));
}
// ---------------------------------------------------------------------------
// #109 — Triangle validation: unit tests for each failure mode
// ---------------------------------------------------------------------------
/// A valid triangle passes all three validation checks.
#[test]
fn triangle_validation_valid_triangle_passes_all_checks() {
let def = valid_triangle_def(1);
assert!(
validate_triangle_def(&def).is_ok(),
"valid triangle must pass all checks: {:?}",
validate_triangle_def(&def)
);
}
/// Conflict viability fails when no interest_axes entry is NpcAxis::Want.
///
/// D-024: active conflict requires at least one role whose tension is Want-driven.
#[test]
fn triangle_validation_conflict_viability_fails_without_want_axis() {
let def = TriangleDef {
triangle_id: TriangleId(10),
roles: [
RoleId::new("worker-a"),
RoleId::new("worker-b"),
RoleId::new("supervisor"),
],
conflict_type: ConflictType::LatentTension,
// No Want axis — all passive tensions
interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("worker-b"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 3 },
}],
};
let result = validate_triangle_def(&def);
assert!(
matches!(result, Err(ValidationError::ConflictViability { triangle_id: TriangleId(10) })),
"expected ConflictViability error, got: {:?}",
result
);
}
/// Relationship coherence fails when relationship_constraints is empty.
///
/// A coherent triangle must document at least one social link among the three roles.
#[test]
fn triangle_validation_relationship_coherence_fails_without_constraints() {
let def = TriangleDef {
triangle_id: TriangleId(20),
roles: [
RoleId::new("smuggler"),
RoleId::new("detective"),
RoleId::new("informant"),
],
conflict_type: ConflictType::SecretExposure,
// Has Want axis (passes conflict viability)
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
// Empty constraints — fails coherence
relationship_constraints: vec![],
};
let result = validate_triangle_def(&def);
assert!(
matches!(result, Err(ValidationError::RelationshipCoherence { triangle_id: TriangleId(20) })),
"expected RelationshipCoherence error, got: {:?}",
result
);
}
/// Interest divergence fails when two roles share the same interest_axis.
///
/// All three axes must be distinct so each role brings a different tension.
#[test]
fn triangle_validation_interest_divergence_fails_with_duplicate_axes() {
let def = TriangleDef {
triangle_id: TriangleId(30),
roles: [
RoleId::new("dock-worker"),
RoleId::new("cargo-lead"),
RoleId::new("port-officer"),
],
conflict_type: ConflictType::ResourceCompetition,
// Two roles both have Want — duplicate axis
interest_axes: [NpcAxis::Want, NpcAxis::Want, NpcAxis::Secret],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("cargo-lead"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 5 },
}],
};
let result = validate_triangle_def(&def);
assert!(
matches!(
result,
Err(ValidationError::InterestDivergence {
triangle_id: TriangleId(30),
duplicate_axis: NpcAxis::Want,
})
),
"expected InterestDivergence(Want) error, got: {:?}",
result
);
}
/// Divergence check catches the third axis duplicating the first.
///
/// Edge case: axes[0] == axes[2], but axes[1] is different.
#[test]
fn triangle_validation_interest_divergence_first_last_duplicate() {
let def = TriangleDef {
triangle_id: TriangleId(31),
roles: [
RoleId::new("role-a"),
RoleId::new("role-b"),
RoleId::new("role-c"),
],
conflict_type: ConflictType::AuthorityChallenge,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Want], // 0 == 2
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("role-b"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 5 },
}],
};
let result = validate_triangle_def(&def);
assert!(
matches!(
result,
Err(ValidationError::InterestDivergence {
triangle_id: TriangleId(31),
duplicate_axis: NpcAxis::Want,
})
),
"expected InterestDivergence(Want) for axes[0]==axes[2], got: {:?}",
result
);
}
/// Validation checks are ordered: ConflictViability fires before Coherence.
///
/// A def with no Want axis AND empty constraints should fail with
/// ConflictViability, not RelationshipCoherence.
#[test]
fn triangle_validation_conflict_viability_checked_before_coherence() {
let def = TriangleDef {
triangle_id: TriangleId(40),
roles: [
RoleId::new("a"),
RoleId::new("b"),
RoleId::new("c"),
],
conflict_type: ConflictType::LatentTension,
interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine],
relationship_constraints: vec![], // also fails coherence
};
let result = validate_triangle_def(&def);
assert!(
matches!(result, Err(ValidationError::ConflictViability { .. })),
"ConflictViability must be checked before RelationshipCoherence, got: {:?}",
result
);
}
// ---------------------------------------------------------------------------
// #108 — Cross-template triangle generation
// ---------------------------------------------------------------------------
/// Two instantiated templates produce 1 cross-template TriangleState
/// with role assignments spanning both templates.
///
/// Spec: D-024 ("1 cross-template" requirement), D-025 (ownership model)
#[test]
fn triangle_validation_cross_template_spans_two_templates() {
let mut world = bevy_ecs::world::World::new();
let mut rng = SimRng::new(42);
let hub_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
let bar_id = TemplateId::from_seed_and_slug(42, "last-shift-bar");
// Logistics hub roles
spawn_template_npc(&mut world, hub_id, "ops-manager", 1);
spawn_template_npc(&mut world, hub_id, "freight-handler", 2);
// Bar roles
spawn_template_npc(&mut world, bar_id, "bartender", 3);
// Cross-template triangle: ops-manager (hub) + freight-handler (hub) + bartender (bar)
let def = valid_triangle_def(999);
let overridden = TriangleDef {
roles: [
RoleId::new("ops-manager"),
RoleId::new("freight-handler"),
RoleId::new("bartender"),
],
..def
};
let result = generate_cross_template_triangles(
&mut world,
hub_id,
bar_id,
&[overridden],
&mut rng,
);
assert!(
result.warnings.is_empty(),
"no warnings expected for valid cross-template triangle: {:?}",
result.warnings
);
assert_eq!(
result.triangles.len(),
1,
"should generate exactly 1 cross-template triangle"
);
let state = &result.triangles[0];
assert_eq!(
state.template_id, hub_id,
"cross-template triangle must be owned by template_a (hub)"
);
assert_eq!(state.role_assignments.len(), 3);
// Verify role assignments span both templates
let ops = state.role_assignments[&RoleId::new("ops-manager")];
let freight = state.role_assignments[&RoleId::new("freight-handler")];
let bartender = state.role_assignments[&RoleId::new("bartender")];
assert_eq!(ops, StableId(1), "ops-manager must map to hub NPC 1");
assert_eq!(freight, StableId(2), "freight-handler must map to hub NPC 2");
assert_eq!(bartender, StableId(3), "bartender must map to bar NPC 3");
assert_eq!(state.phase, TrianglePhase::Simmering);
assert!(state.tension >= 5 && state.tension <= 25, "tension in seeded range");
assert!(state.tension_rate >= 1 && state.tension_rate <= 5, "rate in seeded range");
}
/// Cross-template generation skips defs that fail validation, adding a warning.
#[test]
fn triangle_validation_cross_template_skips_invalid_defs() {
let mut world = bevy_ecs::world::World::new();
let mut rng = SimRng::new(42);
let hub_id = TemplateId::from_seed_and_slug(1, "hub");
let bar_id = TemplateId::from_seed_and_slug(1, "bar");
spawn_template_npc(&mut world, hub_id, "ops-manager", 1);
spawn_template_npc(&mut world, hub_id, "freight-handler", 2);
spawn_template_npc(&mut world, bar_id, "bartender", 3);
// Invalid def: no Want axis (fails conflict viability)
let invalid = TriangleDef {
triangle_id: TriangleId(50),
roles: [
RoleId::new("ops-manager"),
RoleId::new("freight-handler"),
RoleId::new("bartender"),
],
conflict_type: ConflictType::LatentTension,
interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("freight-handler"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 3 },
}],
};
let result = generate_cross_template_triangles(&mut world, hub_id, bar_id, &[invalid], &mut rng);
assert_eq!(result.triangles.len(), 0, "invalid def must be skipped");
assert_eq!(result.warnings.len(), 1, "exactly one warning for the skipped def");
assert!(
result.warnings[0].contains("validation failed"),
"warning must mention validation failure: {}",
result.warnings[0]
);
}
/// Cross-template generation is deterministic for the same seed (D-010).
#[test]
fn triangle_validation_cross_template_deterministic() {
let hub_id = TemplateId::from_seed_and_slug(42, "hub");
let bar_id = TemplateId::from_seed_and_slug(42, "bar");
let def = valid_triangle_def(1);
let make_world = || {
let mut world = bevy_ecs::world::World::new();
spawn_template_npc(&mut world, hub_id, "ops-manager", 1);
spawn_template_npc(&mut world, hub_id, "freight-handler", 2);
spawn_template_npc(&mut world, bar_id, "inspector", 3);
world
};
let overridden = TriangleDef {
roles: [
RoleId::new("ops-manager"),
RoleId::new("freight-handler"),
RoleId::new("inspector"),
],
..def.clone()
};
let mut world1 = make_world();
let result1 = generate_cross_template_triangles(
&mut world1,
hub_id,
bar_id,
&[overridden.clone()],
&mut SimRng::new(42),
);
let mut world2 = make_world();
let result2 = generate_cross_template_triangles(
&mut world2,
hub_id,
bar_id,
&[overridden],
&mut SimRng::new(42),
);
assert_eq!(result1.triangles.len(), 1);
assert_eq!(result2.triangles.len(), 1);
assert_eq!(
result1.triangles[0].tension,
result2.triangles[0].tension,
"cross-template generation must be deterministic (D-010)"
);
assert_eq!(
result1.triangles[0].tension_rate,
result2.triangles[0].tension_rate
);
}
/// D-024: cross-template generation is separate from intra-template generation.
/// Intra-template only sees its own template's NPCs.
#[test]
fn triangle_validation_intra_template_does_not_see_other_template_npcs() {
let mut world = bevy_ecs::world::World::new();
let mut rng = SimRng::new(42);
let hub_id = TemplateId::from_seed_and_slug(1, "hub");
let bar_id = TemplateId::from_seed_and_slug(1, "bar");
// Only hub NPCs for roles ops-manager, freight-handler
spawn_template_npc(&mut world, hub_id, "ops-manager", 1);
spawn_template_npc(&mut world, hub_id, "freight-handler", 2);
// Bar NPC exists but should NOT be used by intra-template hub generation
spawn_template_npc(&mut world, bar_id, "inspector", 3);
// Triangle requiring ops-manager + freight-handler + inspector
// intra-template hub generation cannot find "inspector" in hub NPCs
let def = TriangleDef {
triangle_id: TriangleId(5),
roles: [
RoleId::new("ops-manager"),
RoleId::new("freight-handler"),
RoleId::new("inspector"),
],
conflict_type: ConflictType::LoyaltyConflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
};
let result = generate_intra_template_triangles(&mut world, hub_id, &[def, valid_triangle_def(6)], &mut rng);
// The def needing "inspector" should fall back (inspector is in bar, not hub)
// At least one warning about the missing role
assert!(
!result.warnings.is_empty(),
"intra-template must not find bar NPCs — warning expected for missing 'inspector' role"
);
}