Merge remote-tracking branch 'origin/server'
This commit is contained in:
@@ -6,6 +6,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- ContentType::Factual — lines with numbers, denials, causal chains bypass LLM and serve base text directly (#650, D-138)
|
||||
- Voice pipeline observer integration — enrichment systems rewrite dialogue/conversation text with voiced versions before snapshot assembly (#652)
|
||||
- SQLite settings storage — per-player persistent settings via rusqlite (bundled), IPC protocol v20 with ChangeSettings/RequestAllSettings/DeleteSetting commands (#627)
|
||||
- Composable behavior engine — three-layer action+modifier+context primitives replace flat culture×zone×role behavior strings (#633, D-139, Q-057 resolved)
|
||||
- Stronger few-shot examples for Friendly and RoutineDeviation tells (#651)
|
||||
|
||||
### Removed
|
||||
- v0.1 content loading system — server/src/content/ module (8200 lines), tooling/content-converter/, tooling/validate-content, content-ron/, content/_meta/ (#655, D-122)
|
||||
|
||||
### Changed
|
||||
- Protocol version bumped to 20 — ObserverSnapshot includes settings_response field (#627)
|
||||
|
||||
## [v0.1.25] — 2026-03-07
|
||||
|
||||
### Fixed
|
||||
|
||||
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.
Binary file not shown.
Binary file not shown.
+16
-1
@@ -433,6 +433,21 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
- **Resolves:** Q-057 (composable behavior generation), Q-012 (generation expansion method)
|
||||
- **Cross-reference:** [D-010](architecture.md#d-010) (information boundaries), [D-121](#d-121-voice-is-culture-driven--job-as-modifier) (culture-primary voice), [D-122](#d-122-all-npcs-generated--named-npcs-deferred) (all NPCs generated), [D-128](#d-128-culture-implicit-in-starting-location--krenn-system-equals-krenn-culture) (culture as generator input), [D-029](#d-029-population-entanglement-ratio--305020) (NPC tier model), [D-092](perception.md#d-092) (anchor lines)
|
||||
|
||||
### D-139: Composable behavior primitives — three-layer assembly model
|
||||
- **Date:** 2026-03-13
|
||||
- **Decision:** Observable NPC behaviors are composed from three layers rather than authored as flat strings per culture×zone×role combination. The three layers are:
|
||||
1. **BehaviorPrimitive** (on `RoleSpec`): role-generic physical stage directions (e.g., "moves freight containers", "runs diagnostics on a terminal"). Tagged with a `BehaviorContext` (OnShift/OffDuty/Social/Any) that gates when the behavior is eligible. Optional `modifier_hint` for category matching.
|
||||
2. **BehaviorModifier** (on `CultureProfile`): culture-specific clauses appended to actions (e.g., "with mechanical efficiency", "with a watchful eye"). Categorized for matching (work_style, demeanor, social, etc.).
|
||||
3. **Assembly function** (`assemble_behaviors`): at NpcBlueprint instantiation time, filters primitives by context, selects a culture modifier per primitive (preferring hint matches, falling back to any), concatenates `action + " " + modifier.clause`. No modifier → action text used as-is.
|
||||
- **Rationale:** The current model has `typical_behaviors: Vec<String>` on RoleSpec — flat strings per culture×zone×role. At ~50 behaviors × 4 roles × N zones × M cultures, this is O(roles × zones × cultures) custom content. Decomposition to primitives + modifiers reduces to O(roles + cultures) authored content. Assembly is deterministic via SimRng, so behavior output is reproducible for a given seed. The three-layer model maps directly to the existing CultureProfile/RoleSpec/NpcBlueprint data flow — no new serialization formats or pipeline stages.
|
||||
- **Implementation:** `server/src/npc/blueprint.rs` — `BehaviorPrimitive`, `BehaviorContext`, `BehaviorModifier` structs + `assemble_behaviors()` function. `RoleSpec.behavior_primitives: Vec<BehaviorPrimitive>` (serde default, backward-compatible). `CultureProfile.behavior_modifiers: Vec<BehaviorModifier>` (serde default). Legacy `typical_behaviors` field preserved until content migration complete.
|
||||
- **Migration path:** Copy team populates `behavior_primitives` on zone spec RON files and `behavior_modifiers` on culture RON files. Generator switches from `typical_behaviors` to `assemble_behaviors()` when primitives are present. Once verified equivalent for seed 42, legacy field can be removed.
|
||||
- **Source:** Sprint 26, ticket #633 (implements Q-057)
|
||||
- **Raised by:** Tyre (Technical Architect)
|
||||
- **Dissent:** None (design resolves the scaling problem identified in #630 sprint review)
|
||||
- **Resolves:** Q-057 (composable behavior generation — data structure definition)
|
||||
- **Cross-reference:** [D-138](#d-138-llm-re-voicing-pipeline-for-npc-voice) (resolved pipeline, this resolves data format), [D-121](#d-121-voice-is-culture-driven--job-as-modifier) (culture-primary voice), [D-122](#d-122-all-npcs-generated--named-npcs-deferred) (all NPCs generated)
|
||||
|
||||
---
|
||||
|
||||
*38 decisions. Last updated: 2026-03-07 (D-138 amended with Spike 2 findings: stdio IPC, tell differentiation results, double-prompt technique, ContentType::Factual, negative injectors moved to per-culture; D-123 amended; D-124 superseded — LLM Voice Pipeline Workshop)*
|
||||
*39 decisions. Last updated: 2026-03-13 (D-139 composable behavior primitives — Sprint 26 #633)*
|
||||
|
||||
@@ -193,15 +193,15 @@ Narrative, NPCs, dialogue, templates, setting, worldbuilding, and storyteller me
|
||||
|
||||
### Q-057: Composable behavior generation — decompose culture × role × context into assembled behaviors
|
||||
|
||||
- **Status:** Open
|
||||
- **Status:** Resolved — D-139 (Sprint 26, #633)
|
||||
- **Raised:** Sprint 25, ticket #630 review discussion
|
||||
- **Priority:** High (blocks scaling beyond hand-authored content)
|
||||
- **Context:** Current behavior pools are hand-authored per culture×zone×role combination (`typical_behaviors` arrays in zone spec RON files). At ~50 behaviors per role × 4 roles × N zone types × M cultures, this is O(roles × zones × cultures) custom content. Each cell is effectively a unique location — "rural zone spec" is really "Krenn rural settlement content" with the name filed off. This doesn't scale to multiple cultures or zone types.
|
||||
- **Question:** Should the generator compose observable behaviors from smaller primitives instead of drawing from pre-written complete sentences? Proposed decomposition: (1) **role action templates** — generic observable stage directions per role, culture-neutral, (2) **culture modifier sets** — culture-specific flavoring (Krenn mannerisms, speech patterns, social norms) that overlay role actions, (3) **context tags** — on-shift, off-duty, break-room, social-site-type that filter/weight which behaviors are available. The generator assembles these at runtime.
|
||||
- **Implications:** Changes the content authoring model from "write 50 sentences per role per zone per culture" to "write role actions once, write culture modifiers once, compose at runtime." Server needs a composition engine (#633); copy needs to author the decomposed format (#634). Part of the Sprint 25 PoC spike.
|
||||
- **Cross-reference:** #630 (behavior pool expansion), #633 (server: composition engine), #634 (copy: decomposed content format), D-121 (voice is culture-driven), D-122 (all NPCs generated)
|
||||
- **Resolution:** Yes. D-139 defines the three-layer composable behavior model: `BehaviorPrimitive` (role actions with context tags), `BehaviorModifier` (culture overlays), and `BehaviorContext` (on-shift/off-duty/social/any filtering). The `assemble_behaviors()` function composes at runtime. Generator spike updated to use assembly when `behavior_primitives` are present, falling back to `typical_behaviors` for backward compatibility. Copy team (#634) authors the decomposed format.
|
||||
- **Cross-reference:** #630 (behavior pool expansion), #633 (server: composition engine), #634 (copy: decomposed content format), D-121 (voice is culture-driven), D-122 (all NPCs generated), D-139 (composable behavior assembly)
|
||||
- **Assigned to:** Tyre, Mellanie, Miri
|
||||
|
||||
---
|
||||
|
||||
*21 questions (5 resolved, 2 partially resolved, 14 open). Last updated: 2026-03-07 (Q-057 added — composable behaviors)*
|
||||
*21 questions (6 resolved, 2 partially resolved, 13 open). Last updated: 2026-03-13 (Q-057 resolved by D-139)*
|
||||
|
||||
Generated
+105
-3
@@ -2,6 +2,18 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -226,7 +238,7 @@ dependencies = [
|
||||
"critical-section",
|
||||
"foldhash",
|
||||
"futures-channel",
|
||||
"hashbrown",
|
||||
"hashbrown 0.16.1",
|
||||
"js-sys",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
@@ -371,6 +383,16 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -585,12 +607,30 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -686,6 +726,15 @@ dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -697,6 +746,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.9.2"
|
||||
@@ -721,7 +779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -767,6 +825,17 @@ version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
@@ -931,6 +1000,12 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -1056,6 +1131,20 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -1147,7 +1236,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.24"
|
||||
version = "0.1.25"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
@@ -1159,6 +1248,7 @@ dependencies = [
|
||||
"rand_chacha",
|
||||
"rmp-serde",
|
||||
"ron",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -1177,6 +1267,12 @@ dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -1451,6 +1547,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
||||
@@ -21,6 +21,7 @@ clap = { version = "4", features = ["derive"] }
|
||||
crossbeam-channel = "0.5"
|
||||
sysinfo = "0.35"
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
[features]
|
||||
default = ["gauntlet"]
|
||||
|
||||
@@ -31,8 +31,9 @@ use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
use settled_reach_server::npc::blueprint::{
|
||||
BlueprintRelationship, CultureProfile, CulturalMarkers, CulturalValues, NamingConventions,
|
||||
NpcBlueprint, NpcWant, RoleSpec, SocialSiteSpec, SpeechPatterns, SpikeOutput, ZoneSpec,
|
||||
assemble_behaviors, BlueprintRelationship, CultureProfile, CulturalMarkers, CulturalValues,
|
||||
NamingConventions, NpcBlueprint, NpcWant, RoleSpec, SocialSiteSpec, SpeechPatterns,
|
||||
SpikeOutput, ZoneSpec,
|
||||
};
|
||||
use settled_reach_server::npc::PersonalityTrait;
|
||||
use settled_reach_server::simulation::rng::SimRng;
|
||||
@@ -93,6 +94,7 @@ fn hardcoded_rural_zone() -> ZoneSpec {
|
||||
"repairs equipment by hand".into(),
|
||||
"watches the horizon with a practiced eye".into(),
|
||||
],
|
||||
behavior_primitives: vec![],
|
||||
},
|
||||
RoleSpec {
|
||||
id: "mechanic".into(),
|
||||
@@ -105,6 +107,7 @@ fn hardcoded_rural_zone() -> ZoneSpec {
|
||||
"wipes grease on coveralls between tasks".into(),
|
||||
"explains repairs in terse technical shorthand".into(),
|
||||
],
|
||||
behavior_primitives: vec![],
|
||||
},
|
||||
RoleSpec {
|
||||
id: "trader".into(),
|
||||
@@ -117,6 +120,7 @@ fn hardcoded_rural_zone() -> ZoneSpec {
|
||||
"haggles with quiet persistence".into(),
|
||||
"watches foot traffic from market stall".into(),
|
||||
],
|
||||
behavior_primitives: vec![],
|
||||
},
|
||||
RoleSpec {
|
||||
id: "militia".into(),
|
||||
@@ -129,6 +133,7 @@ fn hardcoded_rural_zone() -> ZoneSpec {
|
||||
"checks credentials at the gate".into(),
|
||||
"leans on rifle while scanning the horizon".into(),
|
||||
],
|
||||
behavior_primitives: vec![],
|
||||
},
|
||||
],
|
||||
social_sites: vec![
|
||||
@@ -163,6 +168,7 @@ fn hardcoded_industrial_zone() -> ZoneSpec {
|
||||
"waits at a loading bay with arms crossed".into(),
|
||||
"calls out bay numbers to a colleague".into(),
|
||||
],
|
||||
behavior_primitives: vec![],
|
||||
},
|
||||
RoleSpec {
|
||||
id: "technician".into(),
|
||||
@@ -175,6 +181,7 @@ fn hardcoded_industrial_zone() -> ZoneSpec {
|
||||
"traces conduit runs along a ceiling with a flashlight".into(),
|
||||
"replaces a component panel with practiced speed".into(),
|
||||
],
|
||||
behavior_primitives: vec![],
|
||||
},
|
||||
RoleSpec {
|
||||
id: "foreman".into(),
|
||||
@@ -187,6 +194,7 @@ fn hardcoded_industrial_zone() -> ZoneSpec {
|
||||
"walks the floor with a datapad under one arm".into(),
|
||||
"pulls aside a worker for a quiet word".into(),
|
||||
],
|
||||
behavior_primitives: vec![],
|
||||
},
|
||||
RoleSpec {
|
||||
id: "security".into(),
|
||||
@@ -199,6 +207,7 @@ fn hardcoded_industrial_zone() -> ZoneSpec {
|
||||
"checks IDs at the freight elevator".into(),
|
||||
"stands at post near restricted equipment bays".into(),
|
||||
],
|
||||
behavior_primitives: vec![],
|
||||
},
|
||||
],
|
||||
social_sites: vec![
|
||||
@@ -251,6 +260,7 @@ fn hardcoded_krenn_culture() -> CultureProfile {
|
||||
voice_persona: None,
|
||||
voice_examples: vec![],
|
||||
occasional_injections: vec![],
|
||||
behavior_modifiers: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,10 +480,29 @@ fn pick_role<'a>(rng: &mut SimRng, zone: &'a ZoneSpec) -> &'a RoleSpec {
|
||||
/// The pools are consumed as NPCs are generated: each NPC pops the front.
|
||||
/// When a pool is exhausted (more NPCs of that role than behaviors), `gen_behaviors`
|
||||
/// falls back to a random repeat with an eprintln warning — not a crash.
|
||||
fn build_behavior_pools(rng: &mut SimRng, zone: &ZoneSpec) -> BTreeMap<String, VecDeque<String>> {
|
||||
///
|
||||
/// When a role has non-empty `behavior_primitives`, uses `assemble_behaviors()`
|
||||
/// (D-139) to compose culture×role behaviors instead of flat `typical_behaviors`.
|
||||
/// Falls back to `typical_behaviors` when primitives are empty — backward compatible.
|
||||
fn build_behavior_pools(
|
||||
rng: &mut SimRng,
|
||||
zone: &ZoneSpec,
|
||||
culture: &CultureProfile,
|
||||
) -> BTreeMap<String, VecDeque<String>> {
|
||||
let mut pools: BTreeMap<String, VecDeque<String>> = BTreeMap::new();
|
||||
for role in &zone.roles {
|
||||
let mut behaviors: Vec<String> = role.typical_behaviors.clone();
|
||||
let mut behaviors: Vec<String> = if !role.behavior_primitives.is_empty() {
|
||||
// D-139: composable assembly — primitives + culture modifiers
|
||||
assemble_behaviors(
|
||||
&role.behavior_primitives,
|
||||
&culture.behavior_modifiers,
|
||||
None, // no context filter during pool building
|
||||
rng,
|
||||
)
|
||||
} else {
|
||||
// Legacy path: flat typical_behaviors strings
|
||||
role.typical_behaviors.clone()
|
||||
};
|
||||
let n = behaviors.len();
|
||||
// Fisher-Yates shuffle using the main RNG (deterministic order is zone-seeded).
|
||||
for i in 0..n {
|
||||
@@ -1020,7 +1049,8 @@ fn main() {
|
||||
let name_pool = build_name_pool(args.seed, &args.zone, &culture, npc_count);
|
||||
|
||||
// Pre-shuffle behavior pools per role (#629 fix: dedup within a zone run).
|
||||
let mut behavior_pools = build_behavior_pools(&mut rng, &zone);
|
||||
// D-139: uses assemble_behaviors() when behavior_primitives are present.
|
||||
let mut behavior_pools = build_behavior_pools(&mut rng, &zone, &culture);
|
||||
|
||||
// First pass: generate all NPCs (no relationships yet)
|
||||
let mut npcs: Vec<NpcBlueprint> = name_pool
|
||||
|
||||
@@ -1,645 +0,0 @@
|
||||
//! Line previewer CLI — content authoring tool (#193).
|
||||
//!
|
||||
//! Loads YAML content packs and previews dialogue/monologue lines with
|
||||
//! simulated filter context. Designed for content authors to verify line
|
||||
//! gating, prerequisite logic, and selection ordering before runtime.
|
||||
//!
|
||||
//! # Examples
|
||||
//!
|
||||
//! ```sh
|
||||
//! # Monologue: show lines for smuggler character
|
||||
//! cargo run --bin line_preview -- --character smuggler
|
||||
//!
|
||||
//! # Monologue with knowledge context and explain mode
|
||||
//! cargo run --bin line_preview -- --character smuggler --knows smuggling_operation --explain
|
||||
//!
|
||||
//! # Dialogue: show lines for dock-worker at the-last-shift
|
||||
//! cargo run --bin line_preview -- --role dock-worker --location the-last-shift \
|
||||
//! --access insider --trust real --situation bar_evening
|
||||
//!
|
||||
//! # Monologue sequence (priority-ordered)
|
||||
//! cargo run --bin line_preview -- --character smuggler --location the-terminal --sequence
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use settled_reach_server::content::line_pool::*;
|
||||
use settled_reach_server::content::loader;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "line_preview",
|
||||
about = "Preview dialogue and monologue lines from content packs"
|
||||
)]
|
||||
struct Args {
|
||||
/// Content directory root (must contain content.yaml)
|
||||
#[arg(long, default_value = "content")]
|
||||
content_root: PathBuf,
|
||||
|
||||
// -- Mode detection --
|
||||
|
||||
/// Character for monologue mode (smuggler, detective)
|
||||
#[arg(long)]
|
||||
character: Option<String>,
|
||||
|
||||
/// NPC role for dialogue mode (e.g., dock-worker, bar-owner)
|
||||
#[arg(long)]
|
||||
role: Option<String>,
|
||||
|
||||
// -- Shared --
|
||||
|
||||
/// Location filter
|
||||
#[arg(long)]
|
||||
location: Option<String>,
|
||||
|
||||
// -- Monologue options --
|
||||
|
||||
/// Trigger filter for monologue (enter_location, observe_npc, etc.)
|
||||
#[arg(long)]
|
||||
trigger: Option<String>,
|
||||
|
||||
/// Known facts for prerequisite checking (repeatable: --knows fact_a --knows fact_b)
|
||||
#[arg(long)]
|
||||
knows: Vec<String>,
|
||||
|
||||
/// Show priority-ordered monologue sequence
|
||||
#[arg(long)]
|
||||
sequence: bool,
|
||||
|
||||
// -- Dialogue options --
|
||||
|
||||
/// Player access tier for dialogue (public, insider, authority, peer, hostile)
|
||||
#[arg(long, default_value = "public")]
|
||||
access: String,
|
||||
|
||||
/// Player trust tier for dialogue (surface, real, secret)
|
||||
#[arg(long, default_value = "surface")]
|
||||
trust: String,
|
||||
|
||||
/// Active situations for dialogue (comma-separated: --situation arrival,bar_evening)
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
situation: Vec<String>,
|
||||
|
||||
// -- Output control --
|
||||
|
||||
/// Show filter reasoning for each line
|
||||
#[arg(long)]
|
||||
explain: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
// Load content
|
||||
let store = match loader::load_content(&args.content_root) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Error: failed to load content from {:?}: {}",
|
||||
args.content_root, e
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Build line pool index
|
||||
let index = LinePoolIndex::build(&store);
|
||||
eprintln!(
|
||||
"Loaded: {} dialogue lines, {} monologue lines",
|
||||
index.dialogue_line_count(),
|
||||
index.monologue_line_count()
|
||||
);
|
||||
|
||||
// Route to mode based on flags
|
||||
if args.character.is_some() {
|
||||
run_monologue(&index, &args);
|
||||
} else if args.role.is_some() {
|
||||
run_dialogue(&index, &args);
|
||||
} else {
|
||||
print_summary(&index);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary mode — no mode flags, show what's available
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn print_summary(index: &LinePoolIndex) {
|
||||
println!("=== Content Summary ===\n");
|
||||
|
||||
if !index.dialogue.is_empty() {
|
||||
println!("Dialogue pools:");
|
||||
for ((loc, role), pool) in &index.dialogue {
|
||||
println!(" {loc} / {role}: {} lines", pool.lines.len());
|
||||
}
|
||||
}
|
||||
|
||||
if !index.monologue.is_empty() {
|
||||
println!("\nMonologue pools:");
|
||||
for ((character, loc), pool) in &index.monologue {
|
||||
let line_count: usize = pool.by_trigger.values().map(|v| v.len()).sum();
|
||||
let triggers: Vec<&str> = pool.by_trigger.keys().map(trigger_str).collect();
|
||||
println!(
|
||||
" {} @ {loc}: {line_count} lines [{triggers}]",
|
||||
character_str(character),
|
||||
triggers = triggers.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nUse --character <name> for monologue or --role <role> --location <loc> for dialogue.");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monologue mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_monologue(index: &LinePoolIndex, args: &Args) {
|
||||
let char_str = args.character.as_deref().unwrap();
|
||||
let character: Character = parse_or_exit(char_str, "character", "smuggler, detective");
|
||||
let known_facts: BTreeSet<&str> = args.knows.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let trigger_filter: Option<Trigger> = args.trigger.as_deref().map(|t| {
|
||||
parse_or_exit(
|
||||
t,
|
||||
"trigger",
|
||||
"enter_location, observe_npc, hear_sound, observe_anomaly, \
|
||||
post_conversation, discover_evidence, witness_interaction, time_idle, return_visit",
|
||||
)
|
||||
});
|
||||
|
||||
// Header
|
||||
println!("Mode: monologue");
|
||||
println!("Character: {}", character_str(&character));
|
||||
if let Some(loc) = &args.location {
|
||||
println!("Location: {}", loc);
|
||||
}
|
||||
if let Some(tf) = &trigger_filter {
|
||||
println!("Trigger: {}", trigger_str(tf));
|
||||
}
|
||||
if !known_facts.is_empty() {
|
||||
println!("Known facts: {}", args.knows.join(", "));
|
||||
}
|
||||
println!();
|
||||
|
||||
// Collect matching pools
|
||||
let pools: Vec<_> = index
|
||||
.monologue
|
||||
.iter()
|
||||
.filter(|((c, loc), _)| {
|
||||
*c == character && args.location.as_ref().map_or(true, |l| loc == l)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if pools.is_empty() {
|
||||
println!("No monologue pools found for {}", character_str(&character));
|
||||
if let Some(loc) = &args.location {
|
||||
println!(" (location filter: {})", loc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if args.sequence {
|
||||
run_monologue_sequence(&pools, &known_facts, trigger_filter.as_ref());
|
||||
return;
|
||||
}
|
||||
|
||||
let mut pass_count = 0u32;
|
||||
let mut fail_count = 0u32;
|
||||
|
||||
for ((_, loc), pool) in &pools {
|
||||
println!("--- {} ---", loc);
|
||||
|
||||
for (trigger, lines) in &pool.by_trigger {
|
||||
let trigger_match = trigger_filter.as_ref().map_or(true, |tf| trigger == tf);
|
||||
|
||||
for line in lines {
|
||||
let prereq_pass = check_prerequisites(line, &known_facts);
|
||||
let overall = trigger_match && prereq_pass;
|
||||
|
||||
if args.explain {
|
||||
let mark = if overall { "PASS" } else { "FAIL" };
|
||||
println!(
|
||||
"\n [{}] {} (pri:{} cd:{})",
|
||||
mark, line.id, line.priority, line.cooldown
|
||||
);
|
||||
if trigger_filter.is_some() {
|
||||
println!(
|
||||
" trigger: {} {}",
|
||||
trigger_str(trigger),
|
||||
if trigger_match { "+" } else { "- (filtered)" }
|
||||
);
|
||||
} else {
|
||||
println!(" trigger: {}", trigger_str(trigger));
|
||||
}
|
||||
print_prereq_detail(line, &known_facts);
|
||||
if !line.tags.is_empty() {
|
||||
println!(" tags: [{}]", line.tags.join(", "));
|
||||
}
|
||||
println!(" \"{}\"", line.text);
|
||||
} else if overall {
|
||||
println!(
|
||||
" [{:>2}] [{}] {} \"{}\"",
|
||||
line.priority,
|
||||
trigger_str(trigger),
|
||||
line.id,
|
||||
line.text
|
||||
);
|
||||
}
|
||||
|
||||
if overall {
|
||||
pass_count += 1;
|
||||
} else {
|
||||
fail_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{} matched, {} filtered", pass_count, fail_count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monologue sequence mode — priority-ordered preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_monologue_sequence(
|
||||
pools: &[(&(Character, String), &IndexedMonologuePool)],
|
||||
known_facts: &BTreeSet<&str>,
|
||||
trigger_filter: Option<&Trigger>,
|
||||
) {
|
||||
println!("=== Sequence Preview (priority order) ===\n");
|
||||
|
||||
// Collect all passing lines across pools and triggers
|
||||
let mut all_lines: Vec<(&str, &Trigger, &IndexedMonologueLine)> = Vec::new();
|
||||
|
||||
for ((_, loc), pool) in pools {
|
||||
for (trigger, lines) in &pool.by_trigger {
|
||||
if let Some(tf) = trigger_filter {
|
||||
if trigger != tf {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for line in lines {
|
||||
if check_prerequisites(line, known_facts) {
|
||||
all_lines.push((loc.as_str(), trigger, line));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority descending, then by id for determinism
|
||||
all_lines.sort_by(|a, b| {
|
||||
b.2.priority
|
||||
.cmp(&a.2.priority)
|
||||
.then_with(|| a.2.id.cmp(&b.2.id))
|
||||
});
|
||||
|
||||
if all_lines.is_empty() {
|
||||
println!(" (no matching lines)");
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, (loc, trigger, line)) in all_lines.iter().enumerate() {
|
||||
println!(
|
||||
" {:>2}. [pri:{:>2}] [{}] [{}] {}",
|
||||
i + 1,
|
||||
line.priority,
|
||||
trigger_str(trigger),
|
||||
loc,
|
||||
line.id,
|
||||
);
|
||||
println!(" \"{}\"", line.text);
|
||||
}
|
||||
|
||||
println!("\n{} lines in sequence", all_lines.len());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialogue mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_dialogue(index: &LinePoolIndex, args: &Args) {
|
||||
let role = args.role.as_deref().unwrap();
|
||||
let location = args.location.as_deref().unwrap_or_else(|| {
|
||||
eprintln!("Error: --location is required for dialogue mode");
|
||||
process::exit(1)
|
||||
});
|
||||
|
||||
let access: AccessTier = parse_or_exit(
|
||||
&args.access,
|
||||
"access",
|
||||
"public, insider, authority, peer, hostile",
|
||||
);
|
||||
let trust: TrustTier = parse_or_exit(&args.trust, "trust", "surface, real, secret");
|
||||
|
||||
let situations: Vec<Situation> = if args.situation.is_empty() {
|
||||
vec![Situation::Arrival]
|
||||
} else {
|
||||
args.situation
|
||||
.iter()
|
||||
.map(|s| {
|
||||
parse_or_exit(
|
||||
s,
|
||||
"situation",
|
||||
"arrival, shift_start, shift_end, shift_transition, bar_evening, \
|
||||
night_shift, investigation, confrontation, social, alone, \
|
||||
emergency, routine, observation, greeting, first_meeting, \
|
||||
repeated_visit",
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Header
|
||||
println!("Mode: dialogue");
|
||||
println!("Location: {}, Role: {}", location, role);
|
||||
println!(
|
||||
"Access: {}, Trust: {}",
|
||||
access_str(&access),
|
||||
trust_str(&trust)
|
||||
);
|
||||
println!(
|
||||
"Situations: [{}]",
|
||||
situations
|
||||
.iter()
|
||||
.map(situation_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
println!();
|
||||
|
||||
let key = (location.to_string(), role.to_string());
|
||||
let Some(pool) = index.dialogue.get(&key) else {
|
||||
println!(
|
||||
"No dialogue pool found for {} / {}",
|
||||
location, role
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
if args.explain {
|
||||
run_dialogue_explain(pool, access, trust, &situations);
|
||||
} else {
|
||||
let results = index.query_dialogue(location, role, access, &situations, trust);
|
||||
|
||||
if results.is_empty() {
|
||||
println!("No matching lines.");
|
||||
return;
|
||||
}
|
||||
|
||||
for line in &results {
|
||||
println!(" {} \"{}\"", line.id, line.text);
|
||||
if !line.topic.is_empty() || !line.mood.is_empty() {
|
||||
println!(
|
||||
" topic: [{}] mood: [{}]",
|
||||
line.topic
|
||||
.iter()
|
||||
.map(topic_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
line.mood
|
||||
.iter()
|
||||
.map(mood_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{} lines matched", results.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn run_dialogue_explain(
|
||||
pool: &IndexedDialoguePool,
|
||||
access: AccessTier,
|
||||
trust: TrustTier,
|
||||
situations: &[Situation],
|
||||
) {
|
||||
let mut pass_count = 0u32;
|
||||
let mut fail_count = 0u32;
|
||||
|
||||
for line in &pool.lines {
|
||||
let l1 = line.access.contains(&access);
|
||||
let l2 = line.situation.iter().any(|s| situations.contains(s));
|
||||
let l3 = trust.meets(line.trust);
|
||||
let overall = l1 && l2 && l3;
|
||||
let mark = if overall { "PASS" } else { "FAIL" };
|
||||
|
||||
println!("[{}] {}", mark, line.id);
|
||||
println!(
|
||||
" L1 access: requires [{}], player has {} {}",
|
||||
line.access
|
||||
.iter()
|
||||
.map(access_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
access_str(&access),
|
||||
if l1 { "+" } else { "-" }
|
||||
);
|
||||
println!(
|
||||
" L2 situation: requires [{}], active [{}] {}",
|
||||
line.situation
|
||||
.iter()
|
||||
.map(situation_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
situations
|
||||
.iter()
|
||||
.map(situation_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
if l2 { "+" } else { "-" }
|
||||
);
|
||||
println!(
|
||||
" L3 trust: requires {}, player has {} {}",
|
||||
trust_str(&line.trust),
|
||||
trust_str(&trust),
|
||||
if l3 { "+" } else { "-" }
|
||||
);
|
||||
if !line.topic.is_empty() || !line.mood.is_empty() {
|
||||
println!(
|
||||
" L4 topic: [{}], mood: [{}]",
|
||||
line.topic
|
||||
.iter()
|
||||
.map(topic_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
line.mood
|
||||
.iter()
|
||||
.map(mood_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
println!(" \"{}\"", line.text);
|
||||
println!();
|
||||
|
||||
if overall {
|
||||
pass_count += 1;
|
||||
} else {
|
||||
fail_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!("{} passed, {} filtered", pass_count, fail_count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prerequisite checking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check monologue line prerequisites against known facts.
|
||||
///
|
||||
/// Fact prerequisites pass if the fact_id is in the known set.
|
||||
/// Entity attributes and relationships require runtime state and are
|
||||
/// treated as passing (shown as unchecked in explain mode).
|
||||
fn check_prerequisites(line: &IndexedMonologueLine, known_facts: &BTreeSet<&str>) -> bool {
|
||||
let Some(prereqs) = &line.prerequisites else {
|
||||
return true;
|
||||
};
|
||||
|
||||
prereqs
|
||||
.facts
|
||||
.iter()
|
||||
.all(|f| known_facts.contains(f.fact_id.as_str()))
|
||||
}
|
||||
|
||||
/// Print prerequisite detail for explain mode.
|
||||
fn print_prereq_detail(line: &IndexedMonologueLine, known_facts: &BTreeSet<&str>) {
|
||||
let Some(prereqs) = &line.prerequisites else {
|
||||
println!(" prerequisites: none");
|
||||
return;
|
||||
};
|
||||
|
||||
println!(" prerequisites:");
|
||||
|
||||
for fact in &prereqs.facts {
|
||||
let has_it = known_facts.contains(fact.fact_id.as_str());
|
||||
println!(
|
||||
" fact {} >= {} {}",
|
||||
fact.fact_id,
|
||||
fact.min_confidence,
|
||||
if has_it { "+" } else { "- (not in --knows)" }
|
||||
);
|
||||
}
|
||||
|
||||
for attr in &prereqs.entity_attributes {
|
||||
println!(
|
||||
" entity_attr {}.{} == {} ? (unchecked — needs runtime)",
|
||||
attr.entity, attr.key, attr.value
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(rel) = &prereqs.relationship {
|
||||
let target = rel.target.as_deref().unwrap_or("?");
|
||||
let state = rel.state.as_deref().unwrap_or("?");
|
||||
println!(
|
||||
" relationship {} state={} ? (unchecked — needs runtime)",
|
||||
target, state
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enum → string helpers (mirrors FromStr in line_pool.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn parse_or_exit<T: std::str::FromStr>(s: &str, kind: &str, valid: &str) -> T {
|
||||
s.parse().unwrap_or_else(|_| {
|
||||
eprintln!("Error: invalid {} '{}'. Valid: {}", kind, s, valid);
|
||||
process::exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
fn character_str(c: &Character) -> &'static str {
|
||||
match c {
|
||||
Character::Smuggler => "smuggler",
|
||||
Character::Detective => "detective",
|
||||
}
|
||||
}
|
||||
|
||||
fn access_str(t: &AccessTier) -> &'static str {
|
||||
match t {
|
||||
AccessTier::Public => "public",
|
||||
AccessTier::Insider => "insider",
|
||||
AccessTier::Authority => "authority",
|
||||
AccessTier::Peer => "peer",
|
||||
AccessTier::Hostile => "hostile",
|
||||
}
|
||||
}
|
||||
|
||||
fn trust_str(t: &TrustTier) -> &'static str {
|
||||
match t {
|
||||
TrustTier::Surface => "surface",
|
||||
TrustTier::Real => "real",
|
||||
TrustTier::Secret => "secret",
|
||||
}
|
||||
}
|
||||
|
||||
fn situation_str(s: &Situation) -> &'static str {
|
||||
match s {
|
||||
Situation::Arrival => "arrival",
|
||||
Situation::ShiftStart => "shift_start",
|
||||
Situation::ShiftEnd => "shift_end",
|
||||
Situation::ShiftTransition => "shift_transition",
|
||||
Situation::BarEvening => "bar_evening",
|
||||
Situation::NightShift => "night_shift",
|
||||
Situation::Investigation => "investigation",
|
||||
Situation::Confrontation => "confrontation",
|
||||
Situation::Social => "social",
|
||||
Situation::Alone => "alone",
|
||||
Situation::Emergency => "emergency",
|
||||
Situation::Routine => "routine",
|
||||
Situation::Observation => "observation",
|
||||
Situation::Greeting => "greeting",
|
||||
Situation::FirstMeeting => "first_meeting",
|
||||
Situation::RepeatedVisit => "repeated_visit",
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_str(t: &Trigger) -> &'static str {
|
||||
match t {
|
||||
Trigger::EnterLocation => "enter_location",
|
||||
Trigger::ObserveNpc => "observe_npc",
|
||||
Trigger::HearSound => "hear_sound",
|
||||
Trigger::ObserveAnomaly => "observe_anomaly",
|
||||
Trigger::PostConversation => "post_conversation",
|
||||
Trigger::DiscoverEvidence => "discover_evidence",
|
||||
Trigger::WitnessInteraction => "witness_interaction",
|
||||
Trigger::TimeIdle => "time_idle",
|
||||
Trigger::ReturnVisit => "return_visit",
|
||||
}
|
||||
}
|
||||
|
||||
fn topic_str(t: &Topic) -> &'static str {
|
||||
match t {
|
||||
Topic::Colleague => "colleague",
|
||||
Topic::Routine => "routine",
|
||||
Topic::Cargo => "cargo",
|
||||
Topic::Money => "money",
|
||||
Topic::Trust => "trust",
|
||||
Topic::Danger => "danger",
|
||||
Topic::Institution => "institution",
|
||||
Topic::Personal => "personal",
|
||||
Topic::Investigation => "investigation",
|
||||
}
|
||||
}
|
||||
|
||||
fn mood_str(m: &Mood) -> &'static str {
|
||||
match m {
|
||||
Mood::Anxious => "anxious",
|
||||
Mood::Frustrated => "frustrated",
|
||||
Mood::Content => "content",
|
||||
Mood::Suspicious => "suspicious",
|
||||
Mood::Warm => "warm",
|
||||
Mood::Hostile => "hostile",
|
||||
Mood::Relieved => "relieved",
|
||||
Mood::Focused => "focused",
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use bevy_ecs::prelude::*;
|
||||
use crate::bridge::types::{
|
||||
DebugCommandKind, DebugEnabled, DebugResponsePayload, SnapshotBuffer,
|
||||
};
|
||||
use crate::content::template::TriangleState;
|
||||
use crate::simulation::triangle::TriangleState;
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::conversation::NpcName;
|
||||
|
||||
@@ -319,6 +319,7 @@ mod tests {
|
||||
sim_errors: vec![],
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +460,7 @@ mod tests {
|
||||
sim_errors: vec![],
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
|
||||
@@ -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 = 19;
|
||||
pub const PROTOCOL_VERSION: u8 = 20;
|
||||
|
||||
/// Handshake message sent as the very first framed message after connection (#555).
|
||||
/// Client reads this before entering the normal tick loop and validates
|
||||
@@ -80,10 +80,11 @@ pub struct StartupMessage {
|
||||
/// sim_errors (#85, structured error reporting to client).
|
||||
/// v18 adds: debug_response (#580, debug console server — command/response wire).
|
||||
/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591).
|
||||
/// v20 adds: settings_response (#627, SQLite settings IPC).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 19.
|
||||
/// Protocol version for forward compatibility. See [`PROTOCOL_VERSION`].
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
@@ -210,6 +211,11 @@ pub struct ObserverSnapshot {
|
||||
/// None when player is outside the bar or no ticker content is loaded.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ticker: Option<TickerLine>,
|
||||
/// Settings response (#627, SQLite settings IPC).
|
||||
/// Present for exactly one tick after a settings operation completes.
|
||||
/// Client reads to confirm setting changes or to populate the settings UI.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub settings_response: Option<crate::settings::types::SettingsResponseWire>,
|
||||
}
|
||||
|
||||
/// A single news ticker headline crossing the wire boundary (#591).
|
||||
@@ -523,6 +529,18 @@ pub enum PlayerAction {
|
||||
/// Debug console command (#580). Only processed when `DebugEnabled` is true.
|
||||
/// Response delivered via `ObserverSnapshot.debug_response`.
|
||||
DebugCommand(DebugCommandKind),
|
||||
/// Change a single setting (#627). Server persists to SQLite and sends
|
||||
/// a `SettingsResponseWire` confirmation in the next snapshot.
|
||||
ChangeSetting {
|
||||
key: String,
|
||||
value: crate::settings::types::SettingValue,
|
||||
},
|
||||
/// Request a full settings dump (#627). Server responds with all current
|
||||
/// settings in `ObserverSnapshot.settings_response`.
|
||||
RequestAllSettings,
|
||||
/// Delete a single setting (#627). Restores the key to its default
|
||||
/// (absent from the database). Confirmation via `settings_response`.
|
||||
DeleteSetting { key: String },
|
||||
}
|
||||
|
||||
impl PlayerAction {
|
||||
@@ -839,8 +857,8 @@ pub struct TriangleCrisisEventWire {
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
impl From<crate::content::template::TriangleCrisisEvent> for TriangleCrisisEventWire {
|
||||
fn from(e: crate::content::template::TriangleCrisisEvent) -> Self {
|
||||
impl From<crate::simulation::triangle::TriangleCrisisEvent> for TriangleCrisisEventWire {
|
||||
fn from(e: crate::simulation::triangle::TriangleCrisisEvent) -> Self {
|
||||
Self {
|
||||
triangle_id: e.triangle_id.into(),
|
||||
role_assignments: e
|
||||
@@ -912,6 +930,8 @@ pub struct SnapshotBuffer {
|
||||
pub pending_save_result: Option<SaveLoadResultWire>,
|
||||
/// Pending debug response, consumed once by `compute_observer_snapshot` (#580).
|
||||
pub pending_debug_response: Option<DebugResponsePayload>,
|
||||
/// Pending settings response, consumed once by `compute_observer_snapshot` (#627).
|
||||
pub pending_settings_response: Option<crate::settings::types::SettingsResponseWire>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
//! EntanglementConfig — per-seed NPC population entanglement ratios (D-029, #175, #178).
|
||||
//!
|
||||
//! Per D-029: NPC population split is ~30% flat / ~50% mundane / ~20% intrigue.
|
||||
//! The entanglement rate varies per world seed to prevent player metagaming calibration
|
||||
//! across playthroughs. Two runs with the same seed must produce identical ratios;
|
||||
//! two runs with different seeds must (in ≥90% of cases) produce different ratios.
|
||||
//!
|
||||
//! ## Acceptance criteria (#175 / #178)
|
||||
//!
|
||||
//! 1. `EntanglementConfig::from_seed(seed_a) == EntanglementConfig::from_seed(seed_a)` (deterministic)
|
||||
//! 2. `EntanglementConfig::from_seed(seed_a) != EntanglementConfig::from_seed(seed_b)` for ≥90% of random pairs
|
||||
//! 3. `flat_ratio + mundane_ratio + intrigue_ratio == 100`
|
||||
//! 4. Ratios stay within bounds: flat ∈ [25,35], mundane ∈ [45,55], intrigue ∈ [15,25]
|
||||
//!
|
||||
//! ## Wire format (#175)
|
||||
//!
|
||||
//! The world seed flows: client new_game() → world_seed field in session startup IPC →
|
||||
//! server reads seed → SimRng::from_seed(seed) → EntanglementConfig::from_rng(&mut rng).
|
||||
//! This means two clients using the same seed produce identical NPC populations.
|
||||
|
||||
use crate::simulation::rng::SimRng;
|
||||
use rand::Rng;
|
||||
|
||||
/// NPC population entanglement ratios for one world seed.
|
||||
///
|
||||
/// All ratios are percentages (integer, sum to 100).
|
||||
/// Ranges per D-029: flat 25-35%, mundane 45-55%, intrigue 15-25%.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EntanglementConfig {
|
||||
/// % of NPCs with purely flat routines — social wallpaper, no triangle involvement
|
||||
pub flat_ratio: u8,
|
||||
/// % of NPCs in mundane triangles — neighbor disputes, workplace rivalries, no conspiracy
|
||||
pub mundane_ratio: u8,
|
||||
/// % of NPCs entangled with intrigue content — connected to conspiracy modules
|
||||
pub intrigue_ratio: u8,
|
||||
}
|
||||
|
||||
impl EntanglementConfig {
|
||||
/// Sample entanglement ratios from the given RNG.
|
||||
///
|
||||
/// Must be called exactly once at session start after `SimRng::new(world_seed)`.
|
||||
/// Subsequent calls to the same seeded RNG will produce different values
|
||||
/// (the RNG state advances), so `from_seed()` is the canonical API for tests.
|
||||
pub fn from_rng(rng: &mut SimRng) -> Self {
|
||||
// Sample flat_ratio ∈ [25, 35] — step of 1%
|
||||
let flat: u8 = rng.rng.random_range(25u8..=35u8);
|
||||
// Constrain intrigue range so mundane = 100 - flat - intrigue stays in [45, 55].
|
||||
// mundane ≥ 45 → intrigue ≤ 55 - flat; mundane ≤ 55 → intrigue ≥ 45 - flat.
|
||||
// Intersect with D-029 base range [15, 25].
|
||||
let intrigue_min: u8 = (45u8.saturating_sub(flat)).max(15);
|
||||
let intrigue_max: u8 = (55u8.saturating_sub(flat)).min(25);
|
||||
let intrigue: u8 = rng.rng.random_range(intrigue_min..=intrigue_max);
|
||||
// Mundane fills the remainder (ensures sum = 100, stays in [45, 55])
|
||||
let mundane: u8 = 100 - flat - intrigue;
|
||||
Self {
|
||||
flat_ratio: flat,
|
||||
mundane_ratio: mundane,
|
||||
intrigue_ratio: intrigue,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: create EntanglementConfig from a raw seed value.
|
||||
///
|
||||
/// Equivalent to `EntanglementConfig::from_rng(&mut SimRng::new(seed))`.
|
||||
/// Use in tests for determinism assertions.
|
||||
pub fn from_seed(seed: u64) -> Self {
|
||||
let mut rng = SimRng::new(seed);
|
||||
Self::from_rng(&mut rng)
|
||||
}
|
||||
|
||||
/// Verify internal consistency: ratios must sum to 100.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.flat_ratio as u16 + self.mundane_ratio as u16 + self.intrigue_ratio as u16 == 100
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Acceptance criterion 1: Determinism (#178)
|
||||
// EntanglementConfig::from_seed(seed_A) == EntanglementConfig::from_seed(seed_A)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn same_seed_produces_same_config() {
|
||||
// D-010 / D-029: deterministic simulation must produce identical NPC populations
|
||||
// for the same world seed across all playthroughs.
|
||||
let config_a = EntanglementConfig::from_seed(42);
|
||||
let config_b = EntanglementConfig::from_seed(42);
|
||||
assert_eq!(
|
||||
config_a, config_b,
|
||||
"Same world seed must produce identical EntanglementConfig (D-010 determinism)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_holds_for_multiple_seeds() {
|
||||
// Spot-check several seeds to ensure the determinism invariant holds broadly.
|
||||
for seed in [0u64, 1, 100, 9999, u64::MAX / 2, u64::MAX] {
|
||||
let c1 = EntanglementConfig::from_seed(seed);
|
||||
let c2 = EntanglementConfig::from_seed(seed);
|
||||
assert_eq!(
|
||||
c1, c2,
|
||||
"Seed {seed}: EntanglementConfig must be deterministic"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Acceptance criterion 2: Variation (#178)
|
||||
// from_seed(A) != from_seed(B) for ≥90% of random seed pairs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn different_seeds_produce_different_configs_at_least_90_percent() {
|
||||
// D-029: entanglement rate varies per seed to prevent metagaming calibration.
|
||||
// ≥90% of random seed pairs must produce distinct EntanglementConfig values.
|
||||
let test_seeds: Vec<u64> = (0u64..100).collect();
|
||||
let configs: Vec<EntanglementConfig> =
|
||||
test_seeds.iter().map(|&s| EntanglementConfig::from_seed(s)).collect();
|
||||
|
||||
let mut distinct_pairs: usize = 0;
|
||||
let mut total_pairs: usize = 0;
|
||||
for i in 0..configs.len() {
|
||||
for j in (i + 1)..configs.len() {
|
||||
total_pairs += 1;
|
||||
if configs[i] != configs[j] {
|
||||
distinct_pairs += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ratio = distinct_pairs as f64 / total_pairs as f64;
|
||||
assert!(
|
||||
ratio >= 0.90,
|
||||
"Only {}/{} ({:.1}%) seed pairs produced distinct EntanglementConfig — need ≥90% (D-029)",
|
||||
distinct_pairs,
|
||||
total_pairs,
|
||||
ratio * 100.0
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Acceptance criterion 3: Ratios sum to 100
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ratios_sum_to_100() {
|
||||
// Invariant: flat + mundane + intrigue == 100 for any seed.
|
||||
for seed in [0u64, 1, 42, 12345, u64::MAX] {
|
||||
let c = EntanglementConfig::from_seed(seed);
|
||||
assert!(
|
||||
c.is_valid(),
|
||||
"Seed {seed}: ratios must sum to 100, got {}+{}+{}={}",
|
||||
c.flat_ratio,
|
||||
c.mundane_ratio,
|
||||
c.intrigue_ratio,
|
||||
c.flat_ratio as u16 + c.mundane_ratio as u16 + c.intrigue_ratio as u16
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Acceptance criterion 4: Ratios within D-029 bounds
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn flat_ratio_within_bounds() {
|
||||
// D-029: flat ∈ [25, 35]%
|
||||
for seed in 0u64..200 {
|
||||
let c = EntanglementConfig::from_seed(seed);
|
||||
assert!(
|
||||
c.flat_ratio >= 25 && c.flat_ratio <= 35,
|
||||
"Seed {seed}: flat_ratio {} out of [25, 35] bounds",
|
||||
c.flat_ratio
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mundane_ratio_within_bounds() {
|
||||
// D-029: mundane ∈ [45, 55]%
|
||||
// Achieved by constraining intrigue range based on flat value so that
|
||||
// mundane = 100 - flat - intrigue always stays within spec bounds.
|
||||
for seed in 0u64..200 {
|
||||
let c = EntanglementConfig::from_seed(seed);
|
||||
assert!(
|
||||
c.is_valid(),
|
||||
"Seed {seed}: ratios must sum to 100"
|
||||
);
|
||||
assert!(
|
||||
c.mundane_ratio >= 45 && c.mundane_ratio <= 55,
|
||||
"Seed {seed}: mundane_ratio {} out of D-029 [45, 55] bounds",
|
||||
c.mundane_ratio
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intrigue_ratio_within_bounds() {
|
||||
// D-029: intrigue ∈ [15, 25]%
|
||||
for seed in 0u64..200 {
|
||||
let c = EntanglementConfig::from_seed(seed);
|
||||
assert!(
|
||||
c.intrigue_ratio >= 15 && c.intrigue_ratio <= 25,
|
||||
"Seed {seed}: intrigue_ratio {} out of [15, 25] bounds",
|
||||
c.intrigue_ratio
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn seed_zero_produces_valid_config() {
|
||||
let c = EntanglementConfig::from_seed(0);
|
||||
assert!(c.is_valid(), "Seed 0 must produce valid config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_max_produces_valid_config() {
|
||||
let c = EntanglementConfig::from_seed(u64::MAX);
|
||||
assert!(c.is_valid(), "Seed u64::MAX must produce valid config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_rng_and_from_seed_are_consistent() {
|
||||
// from_seed() is the canonical API; from_rng() is the runtime API.
|
||||
// When given a freshly-seeded SimRng, from_rng() must match from_seed().
|
||||
let seed = 999u64;
|
||||
let via_seed = EntanglementConfig::from_seed(seed);
|
||||
let mut rng = SimRng::new(seed);
|
||||
let via_rng = EntanglementConfig::from_rng(&mut rng);
|
||||
assert_eq!(
|
||||
via_seed, via_rng,
|
||||
"from_seed() and from_rng(SimRng::new(seed)) must produce identical results"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
//! Content hot-reload via timestamp polling (dev-only).
|
||||
//!
|
||||
//! Periodically checks content YAML files for modifications and triggers
|
||||
//! a full reload when changes are detected. Designed for the authoring
|
||||
//! workflow — not enabled in production builds.
|
||||
//!
|
||||
//! Check interval: every 20 ticks (~2s at 10 tps per D-031).
|
||||
//! Failures are non-critical: previous content is preserved on reload error.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::content::line_pool::LinePoolIndex;
|
||||
use crate::content::loader;
|
||||
use crate::content::{ContentConfig, ContentStoreResource, LinePoolIndexResource};
|
||||
|
||||
/// How often to check for content changes (in system ticks).
|
||||
/// At 10 tps (D-031), 20 ticks = 2 seconds.
|
||||
const CHECK_INTERVAL_TICKS: u64 = 20;
|
||||
|
||||
/// Consecutive reload failures before escalating to a warning.
|
||||
const FAILURE_WARN_THRESHOLD: u32 = 5;
|
||||
|
||||
/// Resource tracking content file timestamps for change detection.
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct ContentWatcher {
|
||||
file_timestamps: BTreeMap<PathBuf, SystemTime>,
|
||||
ticks_since_check: u64,
|
||||
/// Consecutive reload failures. Resets on success.
|
||||
consecutive_failures: u32,
|
||||
}
|
||||
|
||||
impl ContentWatcher {
|
||||
/// Create a new watcher and perform initial timestamp scan.
|
||||
/// Returns a watcher with no tracked files if content_root is invalid.
|
||||
pub fn new(content_root: &Path) -> Self {
|
||||
let mut watcher = Self {
|
||||
file_timestamps: BTreeMap::new(),
|
||||
ticks_since_check: 0,
|
||||
consecutive_failures: 0,
|
||||
};
|
||||
if content_root.as_os_str().is_empty() || !content_root.is_dir() {
|
||||
tracing::warn!(
|
||||
"ContentWatcher: invalid content root {:?}, hot-reload disabled",
|
||||
content_root,
|
||||
);
|
||||
return watcher;
|
||||
}
|
||||
watcher.scan(content_root);
|
||||
watcher
|
||||
}
|
||||
|
||||
/// Scan content directory tree and record all YAML file timestamps.
|
||||
fn scan(&mut self, content_root: &Path) {
|
||||
self.file_timestamps.clear();
|
||||
walk_yaml(content_root, &mut self.file_timestamps, 0);
|
||||
tracing::debug!(
|
||||
"ContentWatcher: tracking {} content files",
|
||||
self.file_timestamps.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Check for changes and rescan. Returns true if any files changed.
|
||||
fn check_and_rescan(&mut self, content_root: &Path) -> bool {
|
||||
let mut new_timestamps = BTreeMap::new();
|
||||
walk_yaml(content_root, &mut new_timestamps, 0);
|
||||
let changed = new_timestamps != self.file_timestamps;
|
||||
if changed {
|
||||
self.file_timestamps = new_timestamps;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Number of tracked files (for diagnostics).
|
||||
pub fn tracked_file_count(&self) -> usize {
|
||||
self.file_timestamps.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum recursion depth for directory walking (guards against symlink loops).
|
||||
const MAX_WALK_DEPTH: usize = 100;
|
||||
|
||||
/// Recursively walk a directory, recording .yaml file modification timestamps.
|
||||
/// Stops recursing at MAX_WALK_DEPTH to guard against symlink loops.
|
||||
fn walk_yaml(dir: &Path, timestamps: &mut BTreeMap<PathBuf, SystemTime>, depth: usize) {
|
||||
if depth >= MAX_WALK_DEPTH {
|
||||
tracing::warn!(
|
||||
"walk_yaml: max depth {} reached at {:?}, stopping",
|
||||
MAX_WALK_DEPTH,
|
||||
dir
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk_yaml(&path, timestamps, depth + 1);
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
|
||||
if let Ok(meta) = std::fs::metadata(&path) {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
timestamps.insert(path, modified);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System: periodically check for content file changes and reload.
|
||||
///
|
||||
/// Only runs when a ContentWatcher resource exists (hot-reload enabled).
|
||||
/// Runs in PostUpdate to avoid interfering with the current tick.
|
||||
pub fn hot_reload_content(
|
||||
config: Res<ContentConfig>,
|
||||
watcher: Option<ResMut<ContentWatcher>>,
|
||||
store_res: Option<ResMut<ContentStoreResource>>,
|
||||
index_res: Option<ResMut<LinePoolIndexResource>>,
|
||||
) {
|
||||
let Some(mut watcher) = watcher else {
|
||||
return;
|
||||
};
|
||||
let Some(mut store_res) = store_res else {
|
||||
return;
|
||||
};
|
||||
let Some(mut index_res) = index_res else {
|
||||
return;
|
||||
};
|
||||
|
||||
watcher.ticks_since_check += 1;
|
||||
if watcher.ticks_since_check < CHECK_INTERVAL_TICKS {
|
||||
return;
|
||||
}
|
||||
watcher.ticks_since_check = 0;
|
||||
|
||||
if !watcher.check_and_rescan(&config.content_root) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::info!("Content files changed, reloading...");
|
||||
|
||||
match loader::load_content(&config.content_root) {
|
||||
Ok(store) => {
|
||||
let index = LinePoolIndex::build(&store);
|
||||
let d_count = index.dialogue_line_count();
|
||||
let m_count = index.monologue_line_count();
|
||||
store_res.0 = store;
|
||||
index_res.0 = index;
|
||||
watcher.consecutive_failures = 0;
|
||||
tracing::info!(
|
||||
"Content hot-reloaded: {} dialogue lines, {} monologue lines",
|
||||
d_count,
|
||||
m_count
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
watcher.consecutive_failures += 1;
|
||||
if watcher.consecutive_failures >= FAILURE_WARN_THRESHOLD {
|
||||
tracing::warn!(
|
||||
"Content hot-reload failed {} consecutive times (keeping previous): {}",
|
||||
watcher.consecutive_failures,
|
||||
e,
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("Content hot-reload failed (keeping previous): {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn watcher_tracks_yaml_files() {
|
||||
let dir = std::env::temp_dir().join("sr_hotreload_test_track");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
fs::write(dir.join("test.yaml"), "key: value\n").unwrap();
|
||||
fs::write(dir.join("other.txt"), "ignored\n").unwrap();
|
||||
|
||||
let watcher = ContentWatcher::new(&dir);
|
||||
assert_eq!(watcher.tracked_file_count(), 1);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_detects_new_file() {
|
||||
let dir = std::env::temp_dir().join("sr_hotreload_test_new");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
fs::write(dir.join("a.yaml"), "key: a\n").unwrap();
|
||||
|
||||
let mut watcher = ContentWatcher::new(&dir);
|
||||
assert!(!watcher.check_and_rescan(&dir)); // no change yet
|
||||
|
||||
fs::write(dir.join("b.yaml"), "key: b\n").unwrap();
|
||||
assert!(watcher.check_and_rescan(&dir)); // new file detected
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_detects_deleted_file() {
|
||||
let dir = std::env::temp_dir().join("sr_hotreload_test_del");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
fs::write(dir.join("a.yaml"), "key: a\n").unwrap();
|
||||
fs::write(dir.join("b.yaml"), "key: b\n").unwrap();
|
||||
|
||||
let mut watcher = ContentWatcher::new(&dir);
|
||||
assert_eq!(watcher.tracked_file_count(), 2);
|
||||
|
||||
fs::remove_file(dir.join("b.yaml")).unwrap();
|
||||
assert!(watcher.check_and_rescan(&dir));
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_detects_modification() {
|
||||
let dir = std::env::temp_dir().join("sr_hotreload_test_mod");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
fs::write(dir.join("a.yaml"), "key: a\n").unwrap();
|
||||
|
||||
let mut watcher = ContentWatcher::new(&dir);
|
||||
|
||||
// Sleep briefly to ensure modification time differs
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
fs::write(dir.join("a.yaml"), "key: modified\n").unwrap();
|
||||
|
||||
assert!(watcher.check_and_rescan(&dir));
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_recurses_subdirectories() {
|
||||
let dir = std::env::temp_dir().join("sr_hotreload_test_recurse");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let sub = dir.join("sub/deep");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
|
||||
fs::write(dir.join("root.yaml"), "key: root\n").unwrap();
|
||||
fs::write(sub.join("deep.yaml"), "key: deep\n").unwrap();
|
||||
|
||||
let watcher = ContentWatcher::new(&dir);
|
||||
assert_eq!(watcher.tracked_file_count(), 2);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
//! 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 1–3: 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))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,952 +0,0 @@
|
||||
//! Content discovery and deserialization.
|
||||
//!
|
||||
//! Reads content.yaml, discovers campaigns and districts via directory
|
||||
//! structure, deserializes YAML files into intermediate content types.
|
||||
//! Comment-only YAML files (stubs) are skipped gracefully.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::content::types::*;
|
||||
|
||||
/// All content loaded from disk, organized by district.
|
||||
/// Inserted as a bevy Resource after loading completes.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ContentStore {
|
||||
pub manifest: Option<ContentManifest>,
|
||||
pub districts: BTreeMap<String, DistrictContent>,
|
||||
}
|
||||
|
||||
/// Content for a single district.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DistrictContent {
|
||||
pub meta: Option<DistrictMeta>,
|
||||
pub district_path: PathBuf,
|
||||
pub pools: Vec<Pool>,
|
||||
pub templates: Vec<Template>,
|
||||
pub triangles: Vec<Triangle>,
|
||||
pub npc_profiles: Vec<NpcProfile>,
|
||||
pub locations: Vec<Location>,
|
||||
pub routines: Option<RoutineFile>,
|
||||
pub dialogue_pools: Vec<DialoguePool>,
|
||||
pub monologue_pools: Vec<MonologuePool>,
|
||||
/// Ticker headlines loaded from `ticker/*.yaml` files (#591).
|
||||
pub ticker_headlines: Vec<crate::content::types::TickerHeadline>,
|
||||
}
|
||||
|
||||
/// Errors that can occur during content loading.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ContentError {
|
||||
#[error("IO error: {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("YAML parse error: {path}: {source}")]
|
||||
Yaml {
|
||||
path: PathBuf,
|
||||
source: serde_yaml::Error,
|
||||
},
|
||||
#[error("Content manifest not found at {0}")]
|
||||
ManifestNotFound(PathBuf),
|
||||
}
|
||||
|
||||
/// Load all content from the given root directory.
|
||||
///
|
||||
/// The root should contain `content.yaml` and the campaign directories.
|
||||
/// Comment-only YAML stubs are skipped (logged at debug level).
|
||||
pub fn load_content(content_root: &Path) -> Result<ContentStore, ContentError> {
|
||||
let mut store = ContentStore::default();
|
||||
|
||||
// 1. Load content manifest
|
||||
let manifest_path = content_root.join("content.yaml");
|
||||
if !manifest_path.exists() {
|
||||
return Err(ContentError::ManifestNotFound(manifest_path));
|
||||
}
|
||||
let manifest: ContentManifest = load_yaml(&manifest_path)?;
|
||||
|
||||
// 2. Discover districts for each enabled campaign
|
||||
for campaign in &manifest.campaigns {
|
||||
if !campaign.enabled {
|
||||
tracing::debug!("Skipping disabled campaign: {}", campaign.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let campaign_path = content_root.join(&campaign.path);
|
||||
let district_dirs = discover_districts(&campaign_path);
|
||||
|
||||
for district_dir in district_dirs {
|
||||
let district_id = derive_district_id(content_root, &district_dir);
|
||||
tracing::info!("Loading district: {} from {:?}", district_id, district_dir);
|
||||
|
||||
let content = load_district(&district_dir)?;
|
||||
store.districts.insert(district_id, content);
|
||||
}
|
||||
}
|
||||
|
||||
store.manifest = Some(manifest);
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Discover district directories by recursively searching for district.yaml.
|
||||
fn discover_districts(campaign_path: &Path) -> Vec<PathBuf> {
|
||||
let mut districts = Vec::new();
|
||||
let systems_path = campaign_path.join("systems");
|
||||
if systems_path.is_dir() {
|
||||
walk_for_districts(&systems_path, &mut districts);
|
||||
}
|
||||
// Discovery order from fs::read_dir is platform-dependent. Sort the Vec
|
||||
// here so districts load in a deterministic order regardless of OS.
|
||||
// ContentStore.districts uses BTreeMap for deterministic *iteration* later,
|
||||
// but sorted discovery ensures deterministic *load* order (and thus
|
||||
// deterministic ID derivation and log output).
|
||||
districts.sort();
|
||||
districts
|
||||
}
|
||||
|
||||
/// Recursively walk directories looking for district.yaml files.
|
||||
fn walk_for_districts(dir: &Path, results: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Collect and sort entries for deterministic traversal order
|
||||
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
||||
sorted_entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in sorted_entries {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let district_yaml = path.join("district.yaml");
|
||||
if district_yaml.exists() {
|
||||
results.push(path);
|
||||
} else {
|
||||
walk_for_districts(&path, results);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a district ID from its filesystem path.
|
||||
/// e.g. campaigns/main/systems/krenn/stations/sova/districts/transit → krenn.sova.transit
|
||||
fn derive_district_id(content_root: &Path, district_dir: &Path) -> String {
|
||||
let rel = district_dir
|
||||
.strip_prefix(content_root)
|
||||
.unwrap_or(district_dir);
|
||||
let components: Vec<&str> = rel
|
||||
.components()
|
||||
.filter_map(|c| c.as_os_str().to_str())
|
||||
.collect();
|
||||
|
||||
// Extract meaningful path segments: system, station, district name
|
||||
// Path pattern: campaigns/{id}/systems/{system}/stations/{station}/districts/{district}
|
||||
let mut parts = Vec::new();
|
||||
let mut iter = components.iter().peekable();
|
||||
while let Some(&segment) = iter.next() {
|
||||
match segment {
|
||||
"systems" | "stations" | "districts" => {
|
||||
if let Some(&&name) = iter.peek() {
|
||||
parts.push(name.to_string());
|
||||
iter.next();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
// Fallback: use the directory name
|
||||
district_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
} else {
|
||||
parts.join(".")
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all content for a single district directory.
|
||||
fn load_district(district_dir: &Path) -> Result<DistrictContent, ContentError> {
|
||||
let mut content = DistrictContent {
|
||||
district_path: district_dir.to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// District metadata
|
||||
let meta_path = district_dir.join("district.yaml");
|
||||
if meta_path.exists() {
|
||||
match load_yaml::<DistrictMeta>(&meta_path) {
|
||||
Ok(meta) => content.meta = Some(meta),
|
||||
Err(e) => tracing::warn!("Failed to parse district metadata: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// Pools
|
||||
let pools_path = district_dir.join("pools.yaml");
|
||||
if pools_path.exists() {
|
||||
match load_yaml::<PoolFile>(&pools_path) {
|
||||
Ok(pool_file) => content.pools = pool_file.pools,
|
||||
Err(e) => tracing::warn!("Failed to parse pools: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// Templates
|
||||
let templates_dir = district_dir.join("templates");
|
||||
if templates_dir.is_dir() {
|
||||
content.templates = load_yaml_dir::<Template>(&templates_dir);
|
||||
}
|
||||
|
||||
// Triangles
|
||||
let triangles_dir = district_dir.join("triangles");
|
||||
if triangles_dir.is_dir() {
|
||||
content.triangles = load_yaml_dir::<Triangle>(&triangles_dir);
|
||||
}
|
||||
|
||||
// NPC profiles
|
||||
let npcs_dir = district_dir.join("npcs");
|
||||
if npcs_dir.is_dir() {
|
||||
content.npc_profiles = load_yaml_dir::<NpcProfile>(&npcs_dir);
|
||||
}
|
||||
|
||||
// Locations
|
||||
let locations_dir = district_dir.join("locations");
|
||||
if locations_dir.is_dir() {
|
||||
content.locations = load_yaml_dir::<Location>(&locations_dir);
|
||||
}
|
||||
|
||||
// Routines
|
||||
let routines_path = district_dir.join("routines").join("schedules.yaml");
|
||||
if routines_path.exists() {
|
||||
match load_yaml::<RoutineFile>(&routines_path) {
|
||||
Ok(routines) => content.routines = Some(routines),
|
||||
Err(e) => tracing::debug!("Skipping routines (stub or invalid): {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogue pools
|
||||
let dialogue_dir = district_dir.join("dialogue");
|
||||
if dialogue_dir.is_dir() {
|
||||
content.dialogue_pools = load_yaml_recursive::<DialoguePool>(&dialogue_dir);
|
||||
}
|
||||
|
||||
// Monologue pools
|
||||
let monologue_dir = district_dir.join("monologue");
|
||||
if monologue_dir.is_dir() {
|
||||
content.monologue_pools = load_yaml_recursive::<MonologuePool>(&monologue_dir);
|
||||
}
|
||||
|
||||
// Ticker headlines (#591): scan ticker/*.yaml in this district
|
||||
let ticker_dir = district_dir.join("ticker");
|
||||
if ticker_dir.is_dir() {
|
||||
let ticker_files = load_yaml_dir::<crate::content::types::TickerFile>(&ticker_dir);
|
||||
for tf in ticker_files {
|
||||
tracing::info!(
|
||||
"Ticker loaded: {} headlines from '{}' (feed: {})",
|
||||
tf.headlines.len(),
|
||||
tf.location,
|
||||
tf.feed,
|
||||
);
|
||||
content.ticker_headlines.extend(tf.headlines);
|
||||
}
|
||||
}
|
||||
|
||||
let npc_count = content.npc_profiles.len();
|
||||
let triangle_count = content.triangles.len();
|
||||
let template_count = content.templates.len();
|
||||
let pool_count = content.pools.len();
|
||||
let dialogue_count = content.dialogue_pools.len();
|
||||
let monologue_count = content.monologue_pools.len();
|
||||
let ticker_count = content.ticker_headlines.len();
|
||||
|
||||
tracing::info!(
|
||||
"District loaded: {} NPCs, {} triangles, {} templates, {} pools, {} dialogue pools, {} monologue pools, {} ticker headlines",
|
||||
npc_count, triangle_count, template_count, pool_count, dialogue_count, monologue_count, ticker_count
|
||||
);
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// Load and parse a single YAML file.
|
||||
fn load_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, ContentError> {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| ContentError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
serde_yaml::from_str(&text).map_err(|e| ContentError::Yaml {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load all YAML files in a directory (non-recursive), skipping stubs.
|
||||
fn load_yaml_dir<T: serde::de::DeserializeOwned>(dir: &Path) -> Vec<T> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
||||
sorted_entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
let mut results = Vec::new();
|
||||
for entry in sorted_entries {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
||||
continue;
|
||||
}
|
||||
match load_yaml::<T>(&path) {
|
||||
Ok(item) => results.push(item),
|
||||
Err(e) => {
|
||||
// Check if this is a comment-only stub
|
||||
if is_comment_only_file(&path) {
|
||||
tracing::debug!("Skipping stub file: {:?}", path);
|
||||
} else {
|
||||
tracing::warn!("Failed to parse {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// Load all YAML files recursively under a directory, skipping stubs.
|
||||
fn load_yaml_recursive<T: serde::de::DeserializeOwned>(dir: &Path) -> Vec<T> {
|
||||
let mut results = Vec::new();
|
||||
walk_yaml_files(dir, &mut |path| match load_yaml::<T>(path) {
|
||||
Ok(item) => results.push(item),
|
||||
Err(e) => {
|
||||
if is_comment_only_file(path) {
|
||||
tracing::debug!("Skipping stub: {:?}", path);
|
||||
} else {
|
||||
tracing::warn!("Failed to parse {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
results
|
||||
}
|
||||
|
||||
/// Walk a directory recursively, calling the callback for each .yaml file.
|
||||
fn walk_yaml_files(dir: &Path, callback: &mut impl FnMut(&Path)) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
||||
sorted_entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in sorted_entries {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk_yaml_files(&path, callback);
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
|
||||
callback(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tile loading (#577)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use crate::simulation::movement::{TileKind, TilePosition, WalkabilityMap};
|
||||
|
||||
/// Parse a tile character into a TileKind.
|
||||
/// Returns `None` for unrecognized characters.
|
||||
fn parse_tile_char(ch: char) -> Option<TileKind> {
|
||||
match ch {
|
||||
'F' => Some(TileKind::Floor),
|
||||
'W' => Some(TileKind::Wall),
|
||||
'V' => Some(TileKind::Void),
|
||||
'R' => Some(TileKind::Restricted),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load tile data from all locations in a ContentStore into a WalkabilityMap.
|
||||
///
|
||||
/// For each location that has both `tile_bounds` and `tiles`, parses the tile
|
||||
/// rows and calls `set_walkable` + `set_tile_kind` on the WalkabilityMap.
|
||||
///
|
||||
/// Logs warnings for:
|
||||
/// - Row count mismatch vs tile_bounds height
|
||||
/// - Column count mismatch vs tile_bounds width
|
||||
/// - Unrecognized tile characters
|
||||
///
|
||||
/// Returns the number of locations that had tile data applied.
|
||||
pub fn load_location_tiles(store: &ContentStore, walkability: &mut WalkabilityMap) -> u32 {
|
||||
let mut locations_loaded = 0u32;
|
||||
|
||||
for (_district_id, district) in &store.districts {
|
||||
for location in &district.locations {
|
||||
if apply_location_tiles(location, walkability) {
|
||||
locations_loaded += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locations_loaded
|
||||
}
|
||||
|
||||
/// Apply tile data from a single Location to the WalkabilityMap.
|
||||
/// Returns true if tiles were applied, false if skipped.
|
||||
fn apply_location_tiles(location: &Location, walkability: &mut WalkabilityMap) -> bool {
|
||||
let (Some(bounds), Some(tiles)) = (&location.tile_bounds, &location.tiles) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Guard: inverted bounds cause (y_max - y_min) to be negative, which wraps to
|
||||
// ~18 quintillion when cast to usize, silently writing tiles at garbage positions.
|
||||
if bounds.x_min > bounds.x_max || bounds.y_min > bounds.y_max {
|
||||
tracing::error!(
|
||||
"Location '{}': inverted tile_bounds (x: {}..={}, y: {}..={}), skipping",
|
||||
location.canonical_id,
|
||||
bounds.x_min, bounds.x_max,
|
||||
bounds.y_min, bounds.y_max,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let expected_height = (bounds.y_max - bounds.y_min + 1) as usize;
|
||||
let expected_width = (bounds.x_max - bounds.x_min + 1) as usize;
|
||||
|
||||
if tiles.len() != expected_height {
|
||||
tracing::error!(
|
||||
"Location '{}': tile row count {} != expected height {} (from tile_bounds) — skipping to prevent walkability holes",
|
||||
location.canonical_id,
|
||||
tiles.len(),
|
||||
expected_height,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (row_idx, row) in tiles.iter().enumerate() {
|
||||
let y = bounds.y_min + row_idx as i32;
|
||||
|
||||
if row.len() != expected_width {
|
||||
tracing::error!(
|
||||
"Location '{}' row {}: length {} != expected width {} — skipping row to prevent walkability holes",
|
||||
location.canonical_id,
|
||||
row_idx,
|
||||
row.len(),
|
||||
expected_width,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (col_idx, ch) in row.chars().enumerate() {
|
||||
let x = bounds.x_min + col_idx as i32;
|
||||
let pos = TilePosition::new(x, y, bounds.z);
|
||||
|
||||
match parse_tile_char(ch) {
|
||||
Some(kind) => {
|
||||
let walkable = matches!(kind, TileKind::Floor);
|
||||
walkability.set_walkable(&pos, walkable);
|
||||
walkability.set_tile_kind(&pos, kind);
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"Location '{}' row {} col {}: unrecognized tile char '{}'",
|
||||
location.canonical_id,
|
||||
row_idx,
|
||||
col_idx,
|
||||
ch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Loaded tiles for location '{}': {}x{} at ({},{}) z={}",
|
||||
location.canonical_id,
|
||||
expected_width,
|
||||
expected_height,
|
||||
bounds.x_min,
|
||||
bounds.y_min,
|
||||
bounds.z,
|
||||
);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn is_comment_only_file(path: &Path) -> bool {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return false;
|
||||
};
|
||||
text.lines()
|
||||
.all(|line| line.trim().is_empty() || line.trim().starts_with('#'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn create_temp_content(dir: &Path) {
|
||||
// Create content.yaml
|
||||
fs::write(
|
||||
dir.join("content.yaml"),
|
||||
r#"version: "0.1.0"
|
||||
campaigns:
|
||||
- id: test
|
||||
path: campaigns/test
|
||||
enabled: true
|
||||
discovery:
|
||||
districts: "systems/**/districts/*/district.yaml"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create district directory structure
|
||||
let district_dir = dir.join("campaigns/test/systems/alpha/stations/beta/districts/gamma");
|
||||
fs::create_dir_all(&district_dir).unwrap();
|
||||
|
||||
// district.yaml
|
||||
fs::write(
|
||||
district_dir.join("district.yaml"),
|
||||
r#"display_name: "Test District"
|
||||
description: "A test district"
|
||||
locations: ["loc-a"]
|
||||
npc_count: 2
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// pools.yaml
|
||||
fs::write(
|
||||
district_dir.join("pools.yaml"),
|
||||
r#"pools:
|
||||
- pool_id: "test:pool_a"
|
||||
category: npc_role
|
||||
candidates:
|
||||
- npc_id: "npc:alice"
|
||||
weight: 1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// triangles/
|
||||
let tri_dir = district_dir.join("triangles");
|
||||
fs::create_dir_all(&tri_dir).unwrap();
|
||||
fs::write(
|
||||
tri_dir.join("test-triangle.yaml"),
|
||||
r#"canonical_id: test-triangle
|
||||
display_name: "Test Triangle"
|
||||
members:
|
||||
- npc: "npc:alice"
|
||||
role: "role-a"
|
||||
- npc: "npc:bob"
|
||||
role: "role-b"
|
||||
- npc: "npc:carol"
|
||||
role: "role-c"
|
||||
forks: []
|
||||
resolution_states: []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// templates/
|
||||
let tpl_dir = district_dir.join("templates");
|
||||
fs::create_dir_all(&tpl_dir).unwrap();
|
||||
fs::write(
|
||||
tpl_dir.join("test-site.yaml"),
|
||||
r#"template_id: test-site
|
||||
display_name: "Test Site"
|
||||
location: loc-a
|
||||
role_slots:
|
||||
- role: worker
|
||||
display_name: "Worker"
|
||||
count: 1
|
||||
required: true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// npcs/ — one stub, one real
|
||||
let npc_dir = district_dir.join("npcs");
|
||||
fs::create_dir_all(&npc_dir).unwrap();
|
||||
fs::write(
|
||||
npc_dir.join("alice.yaml"),
|
||||
r#"canonical_id: alice
|
||||
display_name: "Alice"
|
||||
tier: 1
|
||||
pattern: "FRIEND"
|
||||
motivation: "HANDLER"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
npc_dir.join("bob.yaml"),
|
||||
"# NPC Profile: bob\n# canonical_id: test.bob\n",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_content_discovers_district() {
|
||||
let dir = std::env::temp_dir().join("sr_content_test_discover");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
create_temp_content(&dir);
|
||||
|
||||
let store = load_content(&dir).unwrap();
|
||||
assert!(store.manifest.is_some());
|
||||
assert_eq!(store.districts.len(), 1);
|
||||
|
||||
let (id, content) = store.districts.iter().next().unwrap();
|
||||
assert_eq!(id, "alpha.beta.gamma");
|
||||
assert!(content.meta.is_some());
|
||||
assert_eq!(content.meta.as_ref().unwrap().display_name, "Test District");
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_content_parses_pools() {
|
||||
let dir = std::env::temp_dir().join("sr_content_test_pools");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
create_temp_content(&dir);
|
||||
|
||||
let store = load_content(&dir).unwrap();
|
||||
let content = store.districts.values().next().unwrap();
|
||||
assert_eq!(content.pools.len(), 1);
|
||||
assert_eq!(content.pools[0].pool_id, "test:pool_a");
|
||||
assert_eq!(content.pools[0].candidates.len(), 1);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_content_parses_triangles() {
|
||||
let dir = std::env::temp_dir().join("sr_content_test_triangles");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
create_temp_content(&dir);
|
||||
|
||||
let store = load_content(&dir).unwrap();
|
||||
let content = store.districts.values().next().unwrap();
|
||||
assert_eq!(content.triangles.len(), 1);
|
||||
assert_eq!(content.triangles[0].canonical_id, "test-triangle");
|
||||
assert_eq!(content.triangles[0].members.len(), 3);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_content_skips_stub_npcs() {
|
||||
let dir = std::env::temp_dir().join("sr_content_test_stubs");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
create_temp_content(&dir);
|
||||
|
||||
let store = load_content(&dir).unwrap();
|
||||
let content = store.districts.values().next().unwrap();
|
||||
// Only alice.yaml should parse; bob.yaml is a stub
|
||||
assert_eq!(content.npc_profiles.len(), 1);
|
||||
assert_eq!(content.npc_profiles[0].canonical_id, "alice");
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_content_parses_templates() {
|
||||
let dir = std::env::temp_dir().join("sr_content_test_templates");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
create_temp_content(&dir);
|
||||
|
||||
let store = load_content(&dir).unwrap();
|
||||
let content = store.districts.values().next().unwrap();
|
||||
assert_eq!(content.templates.len(), 1);
|
||||
assert_eq!(content.templates[0].template_id, "test-site");
|
||||
assert_eq!(content.templates[0].role_slots.len(), 1);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_district_id_from_path() {
|
||||
let root = Path::new("/content");
|
||||
let district =
|
||||
Path::new("/content/campaigns/main/systems/krenn/stations/sova/districts/transit");
|
||||
let id = derive_district_id(root, district);
|
||||
assert_eq!(id, "krenn.sova.transit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_comment_only_detects_stubs() {
|
||||
let dir = std::env::temp_dir().join("sr_content_test_comment");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let stub = dir.join("stub.yaml");
|
||||
fs::write(&stub, "# comment\n# another\n").unwrap();
|
||||
assert!(is_comment_only_file(&stub));
|
||||
|
||||
let real = dir.join("real.yaml");
|
||||
fs::write(&real, "key: value\n").unwrap();
|
||||
assert!(!is_comment_only_file(&real));
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_count_range_deserialization() {
|
||||
// RoleCount::Range uses untagged enum — verify {min, max} object parses
|
||||
let yaml = r#"
|
||||
template_id: test
|
||||
display_name: "Test"
|
||||
role_slots:
|
||||
- role: worker
|
||||
display_name: "Worker"
|
||||
count:
|
||||
min: 2
|
||||
max: 4
|
||||
required: true
|
||||
- role: manager
|
||||
display_name: "Manager"
|
||||
count: 1
|
||||
required: true
|
||||
"#;
|
||||
let template: crate::content::types::Template =
|
||||
serde_yaml::from_str(yaml).expect("template with RoleCount::Range should parse");
|
||||
assert_eq!(template.role_slots.len(), 2);
|
||||
|
||||
match &template.role_slots[0].count {
|
||||
crate::content::types::RoleCount::Range { min, max } => {
|
||||
assert_eq!(*min, 2);
|
||||
assert_eq!(*max, 4);
|
||||
}
|
||||
other => panic!("Expected RoleCount::Range, got {:?}", other),
|
||||
}
|
||||
|
||||
match &template.role_slots[1].count {
|
||||
crate::content::types::RoleCount::Fixed(n) => assert_eq!(*n, 1),
|
||||
other => panic!("Expected RoleCount::Fixed, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_constraint_deserialization() {
|
||||
// PoolConstraint uses untagged enum — verify both variants parse
|
||||
let yaml = r#"
|
||||
pools:
|
||||
- pool_id: "test:pool"
|
||||
category: npc_role
|
||||
constraints:
|
||||
- must_be_in_template: "logistics-hub"
|
||||
- must_have_pattern: "FRIEND"
|
||||
- bonded_character: "smuggler"
|
||||
candidates:
|
||||
- npc_id: "npc:alice"
|
||||
weight: 1
|
||||
"#;
|
||||
let pool_file: crate::content::types::PoolFile =
|
||||
serde_yaml::from_str(yaml).expect("pool with constraints should parse");
|
||||
assert_eq!(pool_file.pools.len(), 1);
|
||||
assert_eq!(pool_file.pools[0].constraints.len(), 3);
|
||||
|
||||
// All constraints in this format are key-value strings (plain scalars)
|
||||
// which match PoolConstraint::KeyValue
|
||||
for constraint in &pool_file.pools[0].constraints {
|
||||
match constraint {
|
||||
crate::content::types::PoolConstraint::KeyValue(s) => {
|
||||
assert!(!s.is_empty());
|
||||
}
|
||||
crate::content::types::PoolConstraint::Structured(_) => {
|
||||
// Structured constraints are also valid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tile loading tests (#577)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn make_location_with_tiles(tiles: Vec<&str>) -> Location {
|
||||
Location {
|
||||
canonical_id: "test-loc".to_string(),
|
||||
display_name: "Test Location".to_string(),
|
||||
description: None,
|
||||
tile_bounds: Some(TileBounds {
|
||||
x_min: 0,
|
||||
y_min: 0,
|
||||
x_max: tiles.first().map_or(0, |r| r.len() as i32 - 1),
|
||||
y_max: tiles.len() as i32 - 1,
|
||||
z: 0,
|
||||
}),
|
||||
tiles: Some(tiles.iter().map(|s| s.to_string()).collect()),
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tile_char_all_kinds() {
|
||||
assert_eq!(parse_tile_char('F'), Some(TileKind::Floor));
|
||||
assert_eq!(parse_tile_char('W'), Some(TileKind::Wall));
|
||||
assert_eq!(parse_tile_char('V'), Some(TileKind::Void));
|
||||
assert_eq!(parse_tile_char('R'), Some(TileKind::Restricted));
|
||||
assert_eq!(parse_tile_char('X'), None);
|
||||
assert_eq!(parse_tile_char(' '), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_stamps_walkability() {
|
||||
let loc = make_location_with_tiles(vec![
|
||||
"FWF",
|
||||
"FFF",
|
||||
"WFW",
|
||||
]);
|
||||
let mut map = WalkabilityMap::new(4, 4, 1);
|
||||
|
||||
let applied = apply_location_tiles(&loc, &mut map);
|
||||
assert!(applied);
|
||||
|
||||
// Row 0: F W F
|
||||
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(1, 0, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(2, 0, 0)));
|
||||
|
||||
// Row 1: F F F
|
||||
assert!(map.can_move_to(&TilePosition::new(0, 1, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(1, 1, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(2, 1, 0)));
|
||||
|
||||
// Row 2: W F W
|
||||
assert!(!map.can_move_to(&TilePosition::new(0, 2, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(1, 2, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(2, 2, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_stamps_tile_kind() {
|
||||
let loc = make_location_with_tiles(vec![
|
||||
"FWVR",
|
||||
]);
|
||||
let mut map = WalkabilityMap::new(4, 1, 1);
|
||||
|
||||
apply_location_tiles(&loc, &mut map);
|
||||
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(1, 0, 0)), TileKind::Wall);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(2, 0, 0)), TileKind::Void);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(3, 0, 0)), TileKind::Restricted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_with_offset() {
|
||||
let loc = Location {
|
||||
canonical_id: "offset-loc".to_string(),
|
||||
display_name: "Offset".to_string(),
|
||||
description: None,
|
||||
tile_bounds: Some(TileBounds {
|
||||
x_min: 10,
|
||||
y_min: 20,
|
||||
x_max: 12,
|
||||
y_max: 21,
|
||||
z: 0,
|
||||
}),
|
||||
tiles: Some(vec!["FWF".to_string(), "WFW".to_string()]),
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
};
|
||||
let mut map = WalkabilityMap::new(32, 32, 1);
|
||||
|
||||
apply_location_tiles(&loc, &mut map);
|
||||
|
||||
// (10,20) = F, (11,20) = W, (12,20) = F
|
||||
assert!(map.can_move_to(&TilePosition::new(10, 20, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(11, 20, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(12, 20, 0)));
|
||||
|
||||
// (10,21) = W, (11,21) = F, (12,21) = W
|
||||
assert!(!map.can_move_to(&TilePosition::new(10, 21, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(11, 21, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(12, 21, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_skips_without_tiles() {
|
||||
let loc = Location {
|
||||
canonical_id: "no-tiles".to_string(),
|
||||
display_name: "No Tiles".to_string(),
|
||||
description: None,
|
||||
tile_bounds: Some(TileBounds {
|
||||
x_min: 0, y_min: 0, x_max: 4, y_max: 4, z: 0,
|
||||
}),
|
||||
tiles: None,
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
};
|
||||
let mut map = WalkabilityMap::new(5, 5, 1);
|
||||
assert!(!apply_location_tiles(&loc, &mut map));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_skips_without_bounds() {
|
||||
let loc = Location {
|
||||
canonical_id: "no-bounds".to_string(),
|
||||
display_name: "No Bounds".to_string(),
|
||||
description: None,
|
||||
tile_bounds: None,
|
||||
tiles: Some(vec!["FFF".to_string()]),
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
};
|
||||
let mut map = WalkabilityMap::new(5, 5, 1);
|
||||
assert!(!apply_location_tiles(&loc, &mut map));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_location_tiles_from_store() {
|
||||
let mut store = ContentStore::default();
|
||||
let mut district = DistrictContent::default();
|
||||
district.locations.push(make_location_with_tiles(vec![
|
||||
"FW",
|
||||
"WF",
|
||||
]));
|
||||
store.districts.insert("test".to_string(), district);
|
||||
|
||||
let mut map = WalkabilityMap::new(4, 4, 1);
|
||||
let count = load_location_tiles(&store, &mut map);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(1, 0, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(0, 1, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(1, 1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn location_yaml_with_tiles_deserializes() {
|
||||
let yaml = r#"
|
||||
canonical_id: test-room
|
||||
display_name: "Test Room"
|
||||
tile_bounds:
|
||||
x_min: 5
|
||||
y_min: 10
|
||||
x_max: 9
|
||||
y_max: 12
|
||||
z: 0
|
||||
tiles:
|
||||
- "FFFFF"
|
||||
- "FWWWF"
|
||||
- "FFFFF"
|
||||
"#;
|
||||
let loc: Location = serde_yaml::from_str(yaml).expect("location with tiles should parse");
|
||||
assert_eq!(loc.canonical_id, "test-room");
|
||||
assert!(loc.tiles.is_some());
|
||||
let tiles = loc.tiles.unwrap();
|
||||
assert_eq!(tiles.len(), 3);
|
||||
assert_eq!(tiles[0], "FFFFF");
|
||||
assert_eq!(tiles[1], "FWWWF");
|
||||
assert_eq!(tiles[2], "FFFFF");
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
//! Content loading, indexing, and entity spawning system.
|
||||
//!
|
||||
//! Architecture (per Tyre's D-020 guidance):
|
||||
//! 1. Deserialize YAML -> intermediate content types (types.rs)
|
||||
//! 2. Content discovery + loading (loader.rs) -> ContentStore resource
|
||||
//! 3. ContentStore -> ECS entity spawning (spawn.rs)
|
||||
//! 4. ContentStore -> indexed line pools (line_pool.rs) -> LinePoolIndex resource
|
||||
//! 5. Optional hot-reload (hot_reload.rs) for dev/authoring workflow
|
||||
//!
|
||||
//! Content schema is decoupled from ECS components. The spawn module
|
||||
//! handles the mapping between the two representations.
|
||||
|
||||
pub mod entanglement;
|
||||
pub mod hot_reload;
|
||||
pub mod instantiation;
|
||||
pub mod line_pool;
|
||||
pub mod loader;
|
||||
pub mod spawn;
|
||||
pub mod template;
|
||||
pub mod types;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::knowledge::ContentEntityRegistry;
|
||||
|
||||
/// Configuration for the content loader.
|
||||
/// Set the content root path before adding ContentPlugin.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct ContentConfig {
|
||||
/// Root directory containing content.yaml and campaign directories.
|
||||
pub content_root: PathBuf,
|
||||
/// Enable hot-reload (timestamp polling). Dev-only, not for production.
|
||||
pub hot_reload: bool,
|
||||
}
|
||||
|
||||
impl Default for ContentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
content_root: PathBuf::from("content"),
|
||||
hot_reload: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content loading plugin.
|
||||
///
|
||||
/// Loads content from YAML files at startup, spawns ECS entities,
|
||||
/// and builds the indexed line pools for dialogue/monologue queries.
|
||||
/// Optionally enables hot-reload for the authoring workflow.
|
||||
pub struct ContentPlugin;
|
||||
|
||||
impl Plugin for ContentPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
if !app.world().contains_resource::<ContentConfig>() {
|
||||
app.insert_resource(ContentConfig::default());
|
||||
}
|
||||
|
||||
// ContentEntityRegistry is required by spawn_npc (D-079).
|
||||
// Init here so ContentPlugin works standalone without KnowledgePlugin.
|
||||
app.init_resource::<ContentEntityRegistry>();
|
||||
|
||||
app.add_systems(Startup, load_and_spawn_content);
|
||||
app.add_systems(PostUpdate, hot_reload::hot_reload_content);
|
||||
|
||||
tracing::debug!("ContentPlugin initialized");
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup system: load content from disk, spawn entities, and build line pool index.
|
||||
fn load_and_spawn_content(world: &mut World) {
|
||||
let config = world.resource::<ContentConfig>().clone();
|
||||
|
||||
tracing::info!("Loading content from: {:?}", config.content_root);
|
||||
|
||||
match loader::load_content(&config.content_root) {
|
||||
Ok(store) => {
|
||||
let result = spawn::spawn_content(world, &store);
|
||||
tracing::info!("Content loaded and spawned: {} NPCs", result.npcs_spawned);
|
||||
|
||||
// Stamp location tile data onto WalkabilityMap (#577)
|
||||
if world.contains_resource::<crate::simulation::movement::WalkabilityMap>() {
|
||||
let mut walkability = world.resource_mut::<crate::simulation::movement::WalkabilityMap>();
|
||||
let tiles_loaded = loader::load_location_tiles(&store, &mut walkability);
|
||||
if tiles_loaded > 0 {
|
||||
tracing::info!("Loaded tile data for {} locations", tiles_loaded);
|
||||
}
|
||||
}
|
||||
|
||||
// Build line pool index
|
||||
let index = line_pool::LinePoolIndex::build(&store);
|
||||
tracing::info!(
|
||||
"Line pool index built: {} dialogue lines, {} monologue lines",
|
||||
index.dialogue_line_count(),
|
||||
index.monologue_line_count()
|
||||
);
|
||||
|
||||
// Build TickerPool from loaded headlines (#591).
|
||||
let ticker_lines: Vec<crate::bridge::types::TickerLine> = store
|
||||
.districts
|
||||
.values()
|
||||
.flat_map(|d| &d.ticker_headlines)
|
||||
.map(|h| crate::bridge::types::TickerLine {
|
||||
id: h.id.clone(),
|
||||
text: h.text.clone(),
|
||||
category: h.category.clone(),
|
||||
})
|
||||
.collect();
|
||||
let ticker_count = ticker_lines.len();
|
||||
if ticker_count > 0 {
|
||||
world.insert_resource(
|
||||
crate::simulation::ticker::TickerPool::from_lines(ticker_lines),
|
||||
);
|
||||
tracing::info!("TickerPool built: {} headlines loaded", ticker_count);
|
||||
} else {
|
||||
tracing::warn!("TickerPool: no ticker headlines found — current_ticker will be None");
|
||||
}
|
||||
|
||||
world.insert_resource(ContentStoreResource(store));
|
||||
world.insert_resource(LinePoolIndexResource(index));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to load content: {}", e);
|
||||
world.insert_resource(ContentStoreResource(loader::ContentStore::default()));
|
||||
world.insert_resource(LinePoolIndexResource(line_pool::LinePoolIndex::default()));
|
||||
}
|
||||
}
|
||||
|
||||
// Set up hot-reload if enabled
|
||||
if config.hot_reload {
|
||||
let watcher = hot_reload::ContentWatcher::new(&config.content_root);
|
||||
tracing::info!(
|
||||
"Content hot-reload enabled — tracking {} files, polling every ~2s",
|
||||
watcher.tracked_file_count()
|
||||
);
|
||||
world.insert_resource(watcher);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper resource holding the loaded content store.
|
||||
/// Available for runtime systems that need to query content data
|
||||
/// (e.g., dialogue selection, triangle fork evaluation).
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct ContentStoreResource(pub loader::ContentStore);
|
||||
|
||||
/// Wrapper resource holding the indexed line pools.
|
||||
/// Available for runtime systems that need to query dialogue/monologue lines
|
||||
/// through the D-028 four-layer filtering pipeline.
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct LinePoolIndexResource(pub line_pool::LinePoolIndex);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,620 +0,0 @@
|
||||
//! Intermediate content types for YAML deserialization.
|
||||
//!
|
||||
//! These types mirror the JSON Schema definitions in content/_schema/.
|
||||
//! They are decoupled from ECS components — the spawn module handles
|
||||
//! the mapping from content types to bevy_ecs Components/Resources.
|
||||
//!
|
||||
//! Load order: content files → seed config → entity instantiation.
|
||||
//! Per Tyre's architecture guidance (D-020, #394).
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content manifest (content.yaml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ContentManifest {
|
||||
pub version: String,
|
||||
pub campaigns: Vec<CampaignRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CampaignRef {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub discovery: Option<Discovery>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Discovery {
|
||||
#[serde(default)]
|
||||
pub districts: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// District metadata (district.yaml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DistrictMeta {
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub locations: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub npc_count: u32,
|
||||
#[serde(default)]
|
||||
pub canonical_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pool configuration (pools.yaml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PoolFile {
|
||||
pub pools: Vec<Pool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Pool {
|
||||
pub pool_id: String,
|
||||
pub category: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub constraints: Vec<PoolConstraint>,
|
||||
#[serde(default)]
|
||||
pub candidates: Vec<PoolCandidate>,
|
||||
}
|
||||
|
||||
/// Pool constraints are stored as key-value strings.
|
||||
/// The seed system interprets them; the loader just preserves them.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PoolConstraint {
|
||||
KeyValue(String),
|
||||
Structured(BTreeMap<String, String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PoolCandidate {
|
||||
/// NPC candidates use `npc_id`, contraband uses `id`.
|
||||
#[serde(alias = "id")]
|
||||
pub npc_id: Option<String>,
|
||||
#[serde(default = "default_weight")]
|
||||
pub weight: u32,
|
||||
}
|
||||
|
||||
fn default_weight() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template configuration (templates/*.yaml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Template {
|
||||
pub template_id: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub location: Option<String>,
|
||||
#[serde(default)]
|
||||
pub capacity: Option<Capacity>,
|
||||
#[serde(default)]
|
||||
pub role_slots: Vec<RoleSlot>,
|
||||
#[serde(default)]
|
||||
pub v01_assignments: Option<BTreeMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub reference_links: Vec<ReferenceLink>,
|
||||
#[serde(default)]
|
||||
pub triangle_constraints: Vec<TriangleConstraint>,
|
||||
#[serde(default)]
|
||||
pub dialogue_pools: Vec<DialoguePoolRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Capacity {
|
||||
pub min: u32,
|
||||
pub max: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RoleSlot {
|
||||
pub role: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub count: RoleCount,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pool_ref: Option<String>,
|
||||
#[serde(default)]
|
||||
pub flags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Role count can be a plain integer or a {min, max} object.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum RoleCount {
|
||||
Fixed(u32),
|
||||
Range { min: u32, max: u32 },
|
||||
}
|
||||
|
||||
impl Default for RoleCount {
|
||||
fn default() -> Self {
|
||||
Self::Fixed(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReferenceLink {
|
||||
pub npc: String,
|
||||
#[serde(default)]
|
||||
pub owning_template: Option<String>,
|
||||
#[serde(default)]
|
||||
pub relationship: Option<String>,
|
||||
#[serde(default)]
|
||||
pub presence_phases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TriangleConstraint {
|
||||
pub triangle: String,
|
||||
#[serde(default)]
|
||||
pub required_roles: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DialoguePoolRef {
|
||||
pub location: String,
|
||||
#[serde(default)]
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Triangle (triangles/*.yaml) — mirrors triangle.schema.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Triangle {
|
||||
pub canonical_id: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
pub members: Vec<TriangleMember>,
|
||||
#[serde(default)]
|
||||
pub forks: Vec<Fork>,
|
||||
#[serde(default)]
|
||||
pub resolution_states: Vec<Resolution>,
|
||||
/// D-087 classification: "active_fork" (default) or "passive_tension".
|
||||
#[serde(default)]
|
||||
pub classification: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TriangleMember {
|
||||
pub npc: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Fork {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<String>,
|
||||
#[serde(default)]
|
||||
pub outcomes: Vec<ForkOutcome>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ForkOutcome {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub effects: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Resolution {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NPC Profile (npcs/*.yaml) — mirrors npc-profile.schema.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcProfile {
|
||||
pub canonical_id: String,
|
||||
pub display_name: String,
|
||||
#[serde(default = "default_tier")]
|
||||
pub tier: u8,
|
||||
#[serde(default)]
|
||||
pub pattern: Option<String>,
|
||||
#[serde(default)]
|
||||
pub motivation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub want: Option<NpcWant>,
|
||||
#[serde(default)]
|
||||
pub secret: Option<String>,
|
||||
#[serde(default)]
|
||||
pub relationships: Vec<NpcRelationship>,
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<NpcTolerance>,
|
||||
#[serde(default)]
|
||||
pub routine: Option<NpcRoutineSummary>,
|
||||
#[serde(default)]
|
||||
pub information: Option<NpcInformation>,
|
||||
#[serde(default)]
|
||||
pub contentment: Option<NpcContentment>,
|
||||
#[serde(default)]
|
||||
pub personality: Option<BTreeMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub tells: Vec<NpcTell>,
|
||||
#[serde(default)]
|
||||
pub skills: Option<NpcSkills>,
|
||||
#[serde(default)]
|
||||
pub triangle_membership: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub trust_levels: Option<NpcTrustLevels>,
|
||||
#[serde(default)]
|
||||
pub friend_arc: Option<NpcFriendArc>,
|
||||
#[serde(default)]
|
||||
pub dual_lens: Option<NpcDualLens>,
|
||||
}
|
||||
|
||||
fn default_tier() -> u8 {
|
||||
3
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcWant {
|
||||
pub primary: String,
|
||||
#[serde(default)]
|
||||
pub intensity: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcRelationship {
|
||||
pub target: String,
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub trust: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcTolerance {
|
||||
#[serde(default)]
|
||||
pub threshold: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcRoutineSummary {
|
||||
#[serde(default)]
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcInformation {
|
||||
#[serde(default)]
|
||||
pub knows: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub access_tier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcContentment {
|
||||
#[serde(default)]
|
||||
pub level: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcTell {
|
||||
pub trigger: String,
|
||||
pub behavior: String,
|
||||
#[serde(default)]
|
||||
pub visible_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcSkills {
|
||||
#[serde(default)]
|
||||
pub combat_trained: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub skills: Option<BTreeMap<String, i32>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcTrustLevels {
|
||||
#[serde(default)]
|
||||
pub surface: Option<String>,
|
||||
#[serde(default)]
|
||||
pub real: Option<String>,
|
||||
#[serde(default)]
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcFriendArc {
|
||||
pub bonded_character: String,
|
||||
#[serde(default)]
|
||||
pub phases: Vec<NpcFriendPhase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcFriendPhase {
|
||||
pub phase: u8,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub trigger: Option<String>,
|
||||
#[serde(default)]
|
||||
pub routine_deviation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcDualLens {
|
||||
#[serde(default)]
|
||||
pub smuggler: Option<String>,
|
||||
#[serde(default)]
|
||||
pub detective: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Location (locations/*.yaml) — mirrors location.schema.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Location {
|
||||
pub canonical_id: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tile_bounds: Option<TileBounds>,
|
||||
/// Tile layout for this location (#577).
|
||||
///
|
||||
/// Array of strings, one row per string, left-to-right = +x, top-to-bottom = +y.
|
||||
/// Each character maps to a server-side TileKind:
|
||||
/// `F` = Floor (walkable, open space)
|
||||
/// `W` = Wall (solid obstacle, blocks movement and LOS)
|
||||
/// `V` = Void (out-of-bounds / unloaded)
|
||||
/// `R` = Restricted (blocked but traversable by specific entities)
|
||||
///
|
||||
/// Row 0 is placed at `tile_bounds.y_min`, column 0 at `tile_bounds.x_min`.
|
||||
/// Requires `tile_bounds` to be set. Row count must equal
|
||||
/// `y_max - y_min + 1`, and each row length must equal `x_max - x_min + 1`.
|
||||
#[serde(default)]
|
||||
pub tiles: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub sightlines: Option<Sightlines>,
|
||||
#[serde(default)]
|
||||
pub ambient_sound: Option<String>,
|
||||
#[serde(default)]
|
||||
pub social_site: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TileBounds {
|
||||
pub x_min: i32,
|
||||
pub y_min: i32,
|
||||
pub x_max: i32,
|
||||
pub y_max: i32,
|
||||
pub z: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Sightlines {
|
||||
#[serde(default)]
|
||||
pub open: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routine schedules (routines/schedules.yaml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RoutineFile {
|
||||
pub district: String,
|
||||
pub schedules: Vec<NpcSchedule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NpcSchedule {
|
||||
pub npc: String,
|
||||
pub entries: Vec<RoutineEntry>,
|
||||
#[serde(default)]
|
||||
pub deviations: Vec<Deviation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RoutineEntry {
|
||||
pub phase: String,
|
||||
pub location: String,
|
||||
#[serde(default)]
|
||||
pub tile: Option<TileCoord>,
|
||||
#[serde(default)]
|
||||
pub activity: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Deviation {
|
||||
pub trigger: String,
|
||||
#[serde(default)]
|
||||
pub phase: Option<String>,
|
||||
pub location: String,
|
||||
#[serde(default)]
|
||||
pub tile: Option<TileCoord>,
|
||||
#[serde(default)]
|
||||
pub activity: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TileCoord {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialogue pool (dialogue/**/*.yaml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DialoguePool {
|
||||
pub location: String,
|
||||
pub role: String,
|
||||
pub lines: Vec<DialogueLine>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DialogueLine {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub role: String,
|
||||
pub access: Vec<String>,
|
||||
pub trust: String,
|
||||
pub situation: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub topic: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub mood: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub knowledge_grant: Option<KnowledgeGrant>,
|
||||
}
|
||||
|
||||
/// Knowledge grant attached to a dialogue line (D-079).
|
||||
///
|
||||
/// Untagged enum — serde tries each variant in order:
|
||||
/// `Fact` matches YAML with `fact_id` field.
|
||||
/// `Entity` matches YAML with `entity_ref` field.
|
||||
/// `Compound` variant deferred to Sprint 18.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum KnowledgeGrant {
|
||||
/// Grant knowledge of a non-entity fact.
|
||||
/// Format: fact_id "category.topic", confidence string.
|
||||
Fact {
|
||||
fact_id: String,
|
||||
confidence: String,
|
||||
},
|
||||
/// Grant knowledge of an entity (creates EntityKnowledge entry in observer's KG).
|
||||
/// Required for contradiction detection: testimony must create ToldBy EntityKnowledge
|
||||
/// so a subsequent DirectObservation can detect a discrepancy (D-079, D-083).
|
||||
Entity {
|
||||
entity_ref: String,
|
||||
#[serde(default)]
|
||||
attributes: BTreeMap<String, String>,
|
||||
confidence: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monologue pool (monologue/**/*.yaml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MonologuePool {
|
||||
pub character: String,
|
||||
pub location: String,
|
||||
pub lines: Vec<MonologueLine>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MonologueLine {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub trigger: String,
|
||||
#[serde(default)]
|
||||
pub prerequisites: Option<Prerequisites>,
|
||||
#[serde(default)]
|
||||
pub priority: Option<i32>,
|
||||
/// Per-line cooldown in ticks. `None` (omitted in YAML) means no per-line
|
||||
/// cooldown — fire-once lines rely on trigger semantics instead (e.g.,
|
||||
/// `first_*` and `contradiction_detected` triggers fire once by design).
|
||||
#[serde(default)]
|
||||
pub cooldown: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Prerequisites {
|
||||
#[serde(default)]
|
||||
pub facts: Vec<FactPrerequisite>,
|
||||
#[serde(default)]
|
||||
pub entity_attributes: Vec<AttributePrerequisite>,
|
||||
#[serde(default)]
|
||||
pub relationship: Option<RelationshipPrerequisite>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FactPrerequisite {
|
||||
pub fact_id: String,
|
||||
pub min_confidence: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AttributePrerequisite {
|
||||
pub entity: String,
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RelationshipPrerequisite {
|
||||
#[serde(default)]
|
||||
pub target: Option<String>,
|
||||
#[serde(default)]
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ticker content (#591)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Intermediate type for deserializing a news ticker YAML file.
|
||||
/// The `dual_lens` field is authoring metadata — not deserialized or forwarded.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TickerFile {
|
||||
/// Location slug this ticker belongs to (e.g. "the-last-shift").
|
||||
pub location: String,
|
||||
/// Feed identifier (e.g. "meridian").
|
||||
pub feed: String,
|
||||
pub headlines: Vec<TickerHeadline>,
|
||||
}
|
||||
|
||||
/// A single headline entry in a ticker YAML file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TickerHeadline {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub category: String,
|
||||
// dual_lens is intentionally omitted — authoring metadata only
|
||||
}
|
||||
+1
-1
@@ -3,10 +3,10 @@
|
||||
|
||||
pub mod bridge;
|
||||
pub mod cause_chain;
|
||||
pub mod content;
|
||||
pub mod knowledge;
|
||||
pub mod npc;
|
||||
pub mod perception;
|
||||
pub mod settings;
|
||||
pub mod simulation;
|
||||
pub mod storyteller;
|
||||
pub mod voice;
|
||||
|
||||
+25
-7
@@ -150,19 +150,36 @@ fn main() {
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
// Content root is at the repo root, one level up from server/
|
||||
app.insert_resource(settled_reach_server::content::ContentConfig {
|
||||
content_root: std::path::PathBuf::from("../content"),
|
||||
hot_reload: false,
|
||||
});
|
||||
app.add_plugins(settled_reach_server::content::ContentPlugin);
|
||||
app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin);
|
||||
app.add_plugins(settled_reach_server::settings::SettingsPlugin);
|
||||
|
||||
// Initialize SQLite settings store (#627).
|
||||
// Path: alongside save files in the server's working directory.
|
||||
let settings_path = std::path::PathBuf::from("settings.db");
|
||||
match settled_reach_server::settings::SettingsStore::open(&settings_path) {
|
||||
Ok(store) => {
|
||||
tracing::info!("Settings store opened: {:?}", settings_path);
|
||||
app.insert_resource(settled_reach_server::settings::SettingsStoreResource::new(
|
||||
store,
|
||||
"default".to_string(),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to open settings store: {}. Settings will not persist.", e);
|
||||
}
|
||||
}
|
||||
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
app.insert_resource(HandshakeState::Complete);
|
||||
|
||||
// Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0)
|
||||
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
|
||||
|
||||
// Initialize empty line pool index (populated by generator pipeline in v0.2).
|
||||
app.insert_resource(settled_reach_server::simulation::line_pool::LinePoolIndexResource(
|
||||
settled_reach_server::simulation::line_pool::LinePoolIndex::default(),
|
||||
));
|
||||
|
||||
// Character archetype from client's StartupMessage (#587).
|
||||
let archetype = startup.character_archetype;
|
||||
|
||||
@@ -305,6 +322,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
sim_errors: vec![SimError {
|
||||
kind: SimErrorKind::Panic,
|
||||
message: format!("Simulation panic: {}", panic_msg),
|
||||
@@ -337,8 +355,8 @@ fn dump_schedule_graph() {
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
app.add_plugins(settled_reach_server::content::ContentPlugin);
|
||||
app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin);
|
||||
app.add_plugins(settled_reach_server::settings::SettingsPlugin);
|
||||
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(0));
|
||||
|
||||
// Access Schedules resource directly — schedules are populated by plugins
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! RON (Rusty Object Notation) — struct-aware, supports enums and comments.
|
||||
//! Validate with: `tooling/validate-ron <file.ron>`
|
||||
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::npc::PersonalityTrait;
|
||||
@@ -66,7 +67,15 @@ pub struct RoleSpec {
|
||||
/// Whether this role may have combat capability.
|
||||
pub combat_eligible: bool,
|
||||
/// Observable behaviors typical for this role (generator picks from these).
|
||||
/// Legacy field — preserved for backward compatibility until composable
|
||||
/// assembly produces verified-equivalent output.
|
||||
pub typical_behaviors: Vec<String>,
|
||||
/// Composable behavior primitives (#633, Q-057).
|
||||
/// Role-generic physical stage directions that get composed with culture
|
||||
/// modifiers at NpcBlueprint instantiation time. When present, the assembly
|
||||
/// function uses these instead of `typical_behaviors`.
|
||||
#[serde(default)]
|
||||
pub behavior_primitives: Vec<BehaviorPrimitive>,
|
||||
}
|
||||
|
||||
/// A social site template within a zone.
|
||||
@@ -84,6 +93,134 @@ pub struct SocialSiteSpec {
|
||||
pub max_npcs: u8,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composable behavior primitives (#633, Q-057)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A composable behavior primitive — a role-generic stage direction.
|
||||
///
|
||||
/// The assembly function composes: `action_text` + optional culture modifier
|
||||
/// → final observable behavior string. Context tags gate when the behavior
|
||||
/// is eligible for selection.
|
||||
///
|
||||
/// Example:
|
||||
/// action_text: "moves freight containers"
|
||||
/// context: OnShift
|
||||
/// → assembled with Krenn modifier "with mechanical efficiency"
|
||||
/// → "moves freight containers with mechanical efficiency"
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BehaviorPrimitive {
|
||||
/// The action text — a role-generic physical stage direction.
|
||||
/// Must be a complete sentence fragment that can stand alone or
|
||||
/// accept a trailing modifier clause.
|
||||
pub action: String,
|
||||
/// Context tag gating when this behavior is eligible.
|
||||
pub context: BehaviorContext,
|
||||
/// Optional modifier slot hint. If set, the assembly function
|
||||
/// prefers modifiers matching this category. If empty, any
|
||||
/// compatible modifier may be selected.
|
||||
#[serde(default)]
|
||||
pub modifier_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Context tags that gate when a behavior primitive is eligible.
|
||||
///
|
||||
/// The generator checks the NPC's current assignment against these tags
|
||||
/// to filter the behavior pool before selection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum BehaviorContext {
|
||||
/// Available during work shifts at the NPC's assigned zone.
|
||||
OnShift,
|
||||
/// Available when the NPC is off duty (break rooms, bars, quarters).
|
||||
OffDuty,
|
||||
/// Available at social sites (taverns, break rooms, cantinas).
|
||||
Social,
|
||||
/// Available anywhere — no context restriction.
|
||||
Any,
|
||||
}
|
||||
|
||||
impl Default for BehaviorContext {
|
||||
fn default() -> Self {
|
||||
Self::Any
|
||||
}
|
||||
}
|
||||
|
||||
/// A culture-specific behavior modifier clause.
|
||||
///
|
||||
/// Appended to a `BehaviorPrimitive.action` during assembly to add
|
||||
/// cultural flavor. The composition engine selects modifiers from the
|
||||
/// culture profile and concatenates them with the action text.
|
||||
///
|
||||
/// Example:
|
||||
/// category: "work_style"
|
||||
/// clause: "with mechanical efficiency"
|
||||
/// → "moves freight containers with mechanical efficiency"
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BehaviorModifier {
|
||||
/// Modifier category for matching against `BehaviorPrimitive.modifier_hint`.
|
||||
pub category: String,
|
||||
/// The modifier clause text. Concatenated to action text with a space.
|
||||
pub clause: String,
|
||||
}
|
||||
|
||||
/// Assemble observable behavior strings from primitives + culture modifiers.
|
||||
///
|
||||
/// This is the core composition function (#633). It replaces the flat
|
||||
/// `typical_behaviors` selection with a composable pipeline:
|
||||
///
|
||||
/// 1. Filter primitives by context (pass `None` for no filtering)
|
||||
/// 2. For each eligible primitive, select a culture modifier
|
||||
/// 3. Concatenate: `action + " " + modifier.clause`
|
||||
/// 4. If no modifier matches, use the action text as-is
|
||||
///
|
||||
/// Returns the assembled strings in the same order as the input primitives.
|
||||
pub fn assemble_behaviors(
|
||||
primitives: &[BehaviorPrimitive],
|
||||
modifiers: &[BehaviorModifier],
|
||||
context_filter: Option<BehaviorContext>,
|
||||
rng: &mut crate::simulation::rng::SimRng,
|
||||
) -> Vec<String> {
|
||||
let eligible: Vec<&BehaviorPrimitive> = primitives
|
||||
.iter()
|
||||
.filter(|p| match context_filter {
|
||||
Some(ctx) => p.context == ctx || p.context == BehaviorContext::Any,
|
||||
None => true,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut results = Vec::with_capacity(eligible.len());
|
||||
|
||||
for prim in &eligible {
|
||||
// Find matching modifiers: prefer hint match, fall back to any
|
||||
let matching: Vec<&BehaviorModifier> = if let Some(ref hint) = prim.modifier_hint {
|
||||
let hinted: Vec<&BehaviorModifier> =
|
||||
modifiers.iter().filter(|m| &m.category == hint).collect();
|
||||
if hinted.is_empty() {
|
||||
tracing::trace!(
|
||||
"modifier_hint '{}' matched no category; falling back to all modifiers",
|
||||
hint
|
||||
);
|
||||
modifiers.iter().collect()
|
||||
} else {
|
||||
hinted
|
||||
}
|
||||
} else {
|
||||
modifiers.iter().collect()
|
||||
};
|
||||
|
||||
if matching.is_empty() {
|
||||
// No modifiers available — use action text as-is
|
||||
results.push(prim.action.clone());
|
||||
} else {
|
||||
let idx = rng.rng.random_range(0..matching.len());
|
||||
let modifier = matching[idx];
|
||||
results.push(format!("{} {}", prim.action, modifier.clause));
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Culture profile (input — filled by copy team, ticket #610)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -116,6 +253,11 @@ pub struct CultureProfile {
|
||||
/// Some cultures have none, others several. No cap on count.
|
||||
#[serde(default)]
|
||||
pub occasional_injections: Vec<OccasionalInjection>,
|
||||
/// Culture-specific behavior modifier clauses (#633, Q-057).
|
||||
/// Composed with `BehaviorPrimitive` actions during NPC generation.
|
||||
/// Empty means no cultural modifier — action text used as-is.
|
||||
#[serde(default)]
|
||||
pub behavior_modifiers: Vec<BehaviorModifier>,
|
||||
}
|
||||
|
||||
/// Naming conventions for NPC name generation.
|
||||
@@ -338,6 +480,7 @@ mod tests {
|
||||
skill_focus: vec!["technical".into()],
|
||||
combat_eligible: false,
|
||||
typical_behaviors: vec!["tends crops".into()],
|
||||
behavior_primitives: vec![],
|
||||
}],
|
||||
social_sites: vec![SocialSiteSpec {
|
||||
site_type: "tavern".into(),
|
||||
@@ -382,6 +525,7 @@ mod tests {
|
||||
voice_persona: None,
|
||||
voice_examples: vec![],
|
||||
occasional_injections: vec![],
|
||||
behavior_modifiers: vec![],
|
||||
};
|
||||
|
||||
let ron_str =
|
||||
@@ -436,4 +580,176 @@ mod tests {
|
||||
assert_eq!(deserialized.zone_type, "rural");
|
||||
assert_eq!(deserialized.seed, 42);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Composable behavior assembly tests (#633)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn assemble_behaviors_with_modifiers() {
|
||||
use crate::simulation::rng::SimRng;
|
||||
let mut rng = SimRng::new(42);
|
||||
|
||||
let primitives = vec![
|
||||
BehaviorPrimitive {
|
||||
action: "moves freight containers".into(),
|
||||
context: BehaviorContext::OnShift,
|
||||
modifier_hint: Some("work_style".into()),
|
||||
},
|
||||
BehaviorPrimitive {
|
||||
action: "patrols the perimeter".into(),
|
||||
context: BehaviorContext::OnShift,
|
||||
modifier_hint: None,
|
||||
},
|
||||
];
|
||||
let modifiers = vec![
|
||||
BehaviorModifier {
|
||||
category: "work_style".into(),
|
||||
clause: "with mechanical efficiency".into(),
|
||||
},
|
||||
BehaviorModifier {
|
||||
category: "demeanor".into(),
|
||||
clause: "with a watchful eye".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = assemble_behaviors(&primitives, &modifiers, Some(BehaviorContext::OnShift), &mut rng);
|
||||
assert_eq!(result.len(), 2);
|
||||
// First primitive has hint "work_style" → should match the work_style modifier
|
||||
assert_eq!(result[0], "moves freight containers with mechanical efficiency");
|
||||
// Second primitive has no hint → either modifier is valid
|
||||
assert!(result[1].starts_with("patrols the perimeter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_behaviors_no_modifiers_returns_action_text() {
|
||||
use crate::simulation::rng::SimRng;
|
||||
let mut rng = SimRng::new(42);
|
||||
|
||||
let primitives = vec![BehaviorPrimitive {
|
||||
action: "tends crops in the field".into(),
|
||||
context: BehaviorContext::Any,
|
||||
modifier_hint: None,
|
||||
}];
|
||||
|
||||
let result = assemble_behaviors(&primitives, &[], None, &mut rng);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0], "tends crops in the field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_behaviors_context_filter() {
|
||||
use crate::simulation::rng::SimRng;
|
||||
let mut rng = SimRng::new(42);
|
||||
|
||||
let primitives = vec![
|
||||
BehaviorPrimitive {
|
||||
action: "works shift".into(),
|
||||
context: BehaviorContext::OnShift,
|
||||
modifier_hint: None,
|
||||
},
|
||||
BehaviorPrimitive {
|
||||
action: "drinks at bar".into(),
|
||||
context: BehaviorContext::Social,
|
||||
modifier_hint: None,
|
||||
},
|
||||
BehaviorPrimitive {
|
||||
action: "stretches".into(),
|
||||
context: BehaviorContext::Any,
|
||||
modifier_hint: None,
|
||||
},
|
||||
];
|
||||
|
||||
// OnShift filter: should return OnShift + Any
|
||||
let on_shift = assemble_behaviors(&primitives, &[], Some(BehaviorContext::OnShift), &mut rng);
|
||||
assert_eq!(on_shift.len(), 2);
|
||||
assert_eq!(on_shift[0], "works shift");
|
||||
assert_eq!(on_shift[1], "stretches");
|
||||
|
||||
// Social filter: should return Social + Any
|
||||
let social = assemble_behaviors(&primitives, &[], Some(BehaviorContext::Social), &mut rng);
|
||||
assert_eq!(social.len(), 2);
|
||||
assert_eq!(social[0], "drinks at bar");
|
||||
assert_eq!(social[1], "stretches");
|
||||
|
||||
// No filter: all three
|
||||
let all = assemble_behaviors(&primitives, &[], None, &mut rng);
|
||||
assert_eq!(all.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_behaviors_off_duty_context_filter() {
|
||||
use crate::simulation::rng::SimRng;
|
||||
let mut rng = SimRng::new(42);
|
||||
|
||||
let primitives = vec![
|
||||
BehaviorPrimitive {
|
||||
action: "operates crane".into(),
|
||||
context: BehaviorContext::OnShift,
|
||||
modifier_hint: None,
|
||||
},
|
||||
BehaviorPrimitive {
|
||||
action: "sleeps in bunk".into(),
|
||||
context: BehaviorContext::OffDuty,
|
||||
modifier_hint: None,
|
||||
},
|
||||
BehaviorPrimitive {
|
||||
action: "stretches".into(),
|
||||
context: BehaviorContext::Any,
|
||||
modifier_hint: None,
|
||||
},
|
||||
];
|
||||
|
||||
// OffDuty filter: should return OffDuty + Any, exclude OnShift
|
||||
let off_duty = assemble_behaviors(&primitives, &[], Some(BehaviorContext::OffDuty), &mut rng);
|
||||
assert_eq!(off_duty.len(), 2);
|
||||
assert_eq!(off_duty[0], "sleeps in bunk");
|
||||
assert_eq!(off_duty[1], "stretches");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_behaviors_hint_fallback_to_any_modifier() {
|
||||
use crate::simulation::rng::SimRng;
|
||||
let mut rng = SimRng::new(42);
|
||||
|
||||
let primitives = vec![BehaviorPrimitive {
|
||||
action: "runs diagnostics".into(),
|
||||
context: BehaviorContext::Any,
|
||||
modifier_hint: Some("nonexistent_category".into()),
|
||||
}];
|
||||
let modifiers = vec![BehaviorModifier {
|
||||
category: "demeanor".into(),
|
||||
clause: "with focused intensity".into(),
|
||||
}];
|
||||
|
||||
// Hint doesn't match any modifier category → falls back to any modifier
|
||||
let result = assemble_behaviors(&primitives, &modifiers, None, &mut rng);
|
||||
assert_eq!(result[0], "runs diagnostics with focused intensity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavior_primitive_round_trips_through_ron() {
|
||||
let prim = BehaviorPrimitive {
|
||||
action: "moves freight containers".into(),
|
||||
context: BehaviorContext::OnShift,
|
||||
modifier_hint: Some("work_style".into()),
|
||||
};
|
||||
let ron_str = ron::ser::to_string_pretty(&prim, ron::ser::PrettyConfig::default()).unwrap();
|
||||
let deserialized: BehaviorPrimitive = ron::from_str(&ron_str).unwrap();
|
||||
assert_eq!(deserialized.action, "moves freight containers");
|
||||
assert_eq!(deserialized.context, BehaviorContext::OnShift);
|
||||
assert_eq!(deserialized.modifier_hint, Some("work_style".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavior_modifier_round_trips_through_ron() {
|
||||
let modifier = BehaviorModifier {
|
||||
category: "work_style".into(),
|
||||
clause: "with mechanical efficiency".into(),
|
||||
};
|
||||
let ron_str = ron::ser::to_string_pretty(&modifier, ron::ser::PrettyConfig::default()).unwrap();
|
||||
let deserialized: BehaviorModifier = ron::from_str(&ron_str).unwrap();
|
||||
assert_eq!(deserialized.category, "work_style");
|
||||
assert_eq!(deserialized.clause, "with mechanical efficiency");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,16 @@ impl Plugin for NpcPlugin {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct Npc;
|
||||
|
||||
/// Voice culture identifier for an NPC entity (D-138).
|
||||
///
|
||||
/// Stores the culture_id used to key the voice cache. Attached at spawn time
|
||||
/// by the content system or generator. Absent NPCs fall back to base text.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct NpcVoiceProfile {
|
||||
pub culture_id: String,
|
||||
}
|
||||
|
||||
|
||||
/// NPC animation tier (D-047).
|
||||
///
|
||||
/// Tier 1 (clear): public daily activities — instantly readable.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::content::line_pool::Mood as ContentMood;
|
||||
use crate::simulation::line_pool::Mood as ContentMood;
|
||||
use crate::npc::interaction::InteractionMemory;
|
||||
use crate::npc::{Npc, ToleranceThreshold};
|
||||
use crate::simulation::dialogue::CurrentMood;
|
||||
|
||||
@@ -213,7 +213,7 @@ mod tests {
|
||||
world.init_resource::<ObservationEventQueue>();
|
||||
world.init_resource::<VisibilityGeometry>();
|
||||
world.init_resource::<ActivePerceptionMode>();
|
||||
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
|
||||
world.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ mod tests {
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<VisibilityGeometry>();
|
||||
world.init_resource::<ActivePerceptionMode>();
|
||||
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
|
||||
world.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::poi::PointOfInterest;
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::content::template::TriangleCrisisEventQueue;
|
||||
use crate::simulation::triangle::TriangleCrisisEventQueue;
|
||||
use crate::simulation::sound::SoundEventQueue;
|
||||
use crate::simulation::stance::Stance;
|
||||
use crate::simulation::ticker::{TickerPool, LAST_SHIFT_ZONE_ID};
|
||||
@@ -394,6 +394,9 @@ pub fn compute_observer_snapshot(
|
||||
// Consume pending save/load result for this tick (#553).
|
||||
let save_result = buffer.pending_save_result.take();
|
||||
|
||||
// Consume pending settings response for this tick (#627).
|
||||
let settings_response = buffer.pending_settings_response.take();
|
||||
|
||||
// Consume pending debug response for this tick (#580).
|
||||
let debug_response = buffer.pending_debug_response.take();
|
||||
|
||||
@@ -483,6 +486,7 @@ pub fn compute_observer_snapshot(
|
||||
sim_errors,
|
||||
debug_response,
|
||||
current_ticker,
|
||||
settings_response,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ fn setup_world(width: i32, height: i32) -> World {
|
||||
world.init_resource::<VisibilityGeometry>();
|
||||
world.init_resource::<ActivePerceptionMode>();
|
||||
world.init_resource::<crate::simulation::sound::SoundEventQueue>();
|
||||
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
|
||||
world.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Settings module (#627)
|
||||
// Persistent settings via SQLite on the server side.
|
||||
// Architecture: settings live on the SERVER in a SQLite database with per-player
|
||||
// tables. The client sends ChangeSettings commands over IPC, same as any other
|
||||
// player action. The client never touches the database directly.
|
||||
//
|
||||
// Scope: keybindings, audio volume, display preferences, accessibility options,
|
||||
// AI-Enhanced Dialogue toggle (#646).
|
||||
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub use store::SettingsStore;
|
||||
pub use types::{SettingEntry, SettingValue, SettingsResponseWire};
|
||||
|
||||
/// Bevy resource wrapping the SQLite settings store.
|
||||
///
|
||||
/// Initialized at server startup with a path derived from the save directory.
|
||||
/// v0.1: single player, player_id = "default".
|
||||
/// Multiplayer-ready: per-player isolation via player_id column (D-010).
|
||||
///
|
||||
/// Uses Mutex because rusqlite::Connection is !Sync. The lock is held only
|
||||
/// for the duration of each operation (sub-millisecond for SQLite).
|
||||
#[derive(Resource)]
|
||||
pub struct SettingsStoreResource {
|
||||
store: Mutex<SettingsStore>,
|
||||
/// Player ID for settings isolation. v0.1: always "default".
|
||||
player_id: String,
|
||||
}
|
||||
|
||||
impl SettingsStoreResource {
|
||||
pub fn new(store: SettingsStore, player_id: String) -> Self {
|
||||
Self {
|
||||
store: Mutex::new(store),
|
||||
player_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a single setting.
|
||||
pub fn get(&self, key: &str) -> Option<SettingValue> {
|
||||
let store = self.store.lock().expect("settings mutex poisoned");
|
||||
match store.get(&self.player_id, key) {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
tracing::error!("settings get({}) failed: {}", key, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a single setting. Returns true on success.
|
||||
pub fn set(&self, key: &str, value: &SettingValue) -> bool {
|
||||
let store = self.store.lock().expect("settings mutex poisoned");
|
||||
match store.set(&self.player_id, key, value) {
|
||||
Ok(()) => {
|
||||
tracing::debug!("settings set: {} = {}", key, value);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("settings set({}) failed: {}", key, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a single setting. Returns true if it existed.
|
||||
pub fn delete(&self, key: &str) -> bool {
|
||||
let store = self.store.lock().expect("settings mutex poisoned");
|
||||
match store.delete(&self.player_id, key) {
|
||||
Ok(deleted) => deleted,
|
||||
Err(e) => {
|
||||
tracing::error!("settings delete({}) failed: {}", key, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all settings as wire entries.
|
||||
pub fn get_all_entries(&self) -> Vec<SettingEntry> {
|
||||
let store = self.store.lock().expect("settings mutex poisoned");
|
||||
match store.get_all_entries(&self.player_id) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
tracing::error!("settings get_all failed: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffer for pending settings commands received via IPC.
|
||||
/// Drained by `process_settings_commands` each tick.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct SettingsCommandBuffer {
|
||||
commands: Vec<SettingsCommand>,
|
||||
}
|
||||
|
||||
impl SettingsCommandBuffer {
|
||||
pub fn push(&mut self, cmd: SettingsCommand) {
|
||||
self.commands.push(cmd);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<SettingsCommand> {
|
||||
std::mem::take(&mut self.commands)
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings command variants queued from PlayerAction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SettingsCommand {
|
||||
/// Change a single setting.
|
||||
Change { key: String, value: SettingValue },
|
||||
/// Request a full settings dump.
|
||||
RequestAll,
|
||||
/// Delete a single setting (restore to default).
|
||||
Delete { key: String },
|
||||
}
|
||||
|
||||
/// Process settings commands from the IPC buffer.
|
||||
/// Reads SettingsCommandBuffer, writes to SettingsStoreResource,
|
||||
/// and stages a response in SnapshotBuffer.pending_settings_response.
|
||||
pub fn process_settings_commands(
|
||||
settings_store: Option<Res<SettingsStoreResource>>,
|
||||
mut cmd_buffer: ResMut<SettingsCommandBuffer>,
|
||||
mut snapshot_buffer: ResMut<crate::bridge::types::SnapshotBuffer>,
|
||||
) {
|
||||
let commands = cmd_buffer.drain();
|
||||
if commands.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(store) = settings_store else {
|
||||
tracing::warn!("settings commands received but SettingsStoreResource not registered");
|
||||
return;
|
||||
};
|
||||
|
||||
// Process all commands, last response wins (same tick batching).
|
||||
// If multiple settings commands arrive in one tick, only the final response
|
||||
// reaches the client. This is acceptable: the client sends one action per tick
|
||||
// in normal use, and batch changes via RequestAll after bulk updates.
|
||||
for cmd in commands {
|
||||
match cmd {
|
||||
SettingsCommand::Change { ref key, ref value } => {
|
||||
if store.set(key, value) {
|
||||
snapshot_buffer.pending_settings_response = Some(SettingsResponseWire {
|
||||
success: true,
|
||||
kind: "ack".into(),
|
||||
settings: vec![SettingEntry {
|
||||
key: key.clone(),
|
||||
value: value.clone(),
|
||||
}],
|
||||
error: None,
|
||||
});
|
||||
} else {
|
||||
snapshot_buffer.pending_settings_response = Some(SettingsResponseWire {
|
||||
success: false,
|
||||
kind: "ack".into(),
|
||||
settings: vec![],
|
||||
error: Some(format!("failed to set setting: {}", key)),
|
||||
});
|
||||
}
|
||||
}
|
||||
SettingsCommand::RequestAll => {
|
||||
let entries = store.get_all_entries();
|
||||
snapshot_buffer.pending_settings_response = Some(SettingsResponseWire {
|
||||
success: true,
|
||||
kind: "full".into(),
|
||||
settings: entries,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
SettingsCommand::Delete { ref key } => {
|
||||
// Idempotent: deleting a non-existent key is a silent no-op.
|
||||
let _deleted = store.delete(key);
|
||||
snapshot_buffer.pending_settings_response = Some(SettingsResponseWire {
|
||||
success: true,
|
||||
kind: "ack".into(),
|
||||
settings: vec![],
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings plugin. Registers resources and the processing system.
|
||||
pub struct SettingsPlugin;
|
||||
|
||||
impl Plugin for SettingsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SettingsCommandBuffer>()
|
||||
.add_systems(
|
||||
Update,
|
||||
process_settings_commands
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
);
|
||||
tracing::debug!("SettingsPlugin initialized");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// SQLite settings storage backend (#627)
|
||||
// Persistent key-value settings with typed columns via rusqlite (bundled).
|
||||
// Per-player isolation via player_id column — multiplayer-ready (D-010).
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use super::types::{SettingEntry, SettingValue};
|
||||
|
||||
/// SQLite-backed settings store.
|
||||
///
|
||||
/// Architecture: single `settings` table with typed value columns.
|
||||
/// Each setting is a row keyed by (player_id, key). No JSON blobs —
|
||||
/// future settings are just new rows, zero migrations.
|
||||
///
|
||||
/// The `bundled` feature compiles SQLite into the binary, so there's
|
||||
/// no runtime dependency on a system SQLite library.
|
||||
pub struct SettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
/// Open (or create) the settings database at `path`.
|
||||
/// Creates the schema if the table doesn't exist.
|
||||
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;",
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
player_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_type TEXT NOT NULL,
|
||||
value_text TEXT,
|
||||
value_int INTEGER,
|
||||
value_float REAL,
|
||||
value_bool INTEGER,
|
||||
PRIMARY KEY (player_id, key)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Open an in-memory database (for tests).
|
||||
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
conn.execute(
|
||||
"CREATE TABLE settings (
|
||||
player_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_type TEXT NOT NULL,
|
||||
value_text TEXT,
|
||||
value_int INTEGER,
|
||||
value_float REAL,
|
||||
value_bool INTEGER,
|
||||
PRIMARY KEY (player_id, key)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Set a single setting for a player. Upserts (insert or replace).
|
||||
pub fn set(
|
||||
&self,
|
||||
player_id: &str,
|
||||
key: &str,
|
||||
value: &SettingValue,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
let (vtype, vtext, vint, vfloat, vbool) = value_to_columns(value);
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO settings
|
||||
(player_id, key, value_type, value_text, value_int, value_float, value_bool)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![player_id, key, vtype, vtext, vint, vfloat, vbool],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a single setting for a player. Returns None if not found.
|
||||
pub fn get(&self, player_id: &str, key: &str) -> Result<Option<SettingValue>, rusqlite::Error> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT value_type, value_text, value_int, value_float, value_bool
|
||||
FROM settings WHERE player_id = ?1 AND key = ?2",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![player_id, key])?;
|
||||
match rows.next()? {
|
||||
Some(row) => {
|
||||
let vtype: String = row.get(0)?;
|
||||
Ok(Some(columns_to_value(&vtype, row, 1)?))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all settings for a player as a sorted map.
|
||||
pub fn get_all(
|
||||
&self,
|
||||
player_id: &str,
|
||||
) -> Result<BTreeMap<String, SettingValue>, rusqlite::Error> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT key, value_type, value_text, value_int, value_float, value_bool
|
||||
FROM settings WHERE player_id = ?1 ORDER BY key",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![player_id])?;
|
||||
let mut result = BTreeMap::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
let key: String = row.get(0)?;
|
||||
let vtype: String = row.get(1)?;
|
||||
let value = columns_to_value(&vtype, row, 2)?;
|
||||
result.insert(key, value);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Delete a single setting for a player. Returns true if a row was deleted.
|
||||
pub fn delete(&self, player_id: &str, key: &str) -> Result<bool, rusqlite::Error> {
|
||||
let count = self.conn.execute(
|
||||
"DELETE FROM settings WHERE player_id = ?1 AND key = ?2",
|
||||
params![player_id, key],
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Get all settings as a Vec<SettingEntry> for wire serialization.
|
||||
pub fn get_all_entries(
|
||||
&self,
|
||||
player_id: &str,
|
||||
) -> Result<Vec<SettingEntry>, rusqlite::Error> {
|
||||
self.get_all(player_id).map(|map| {
|
||||
map.into_iter()
|
||||
.map(|(key, value)| SettingEntry { key, value })
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a SettingValue to SQLite column values.
|
||||
fn value_to_columns(
|
||||
value: &SettingValue,
|
||||
) -> (
|
||||
&'static str,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<f64>,
|
||||
Option<bool>,
|
||||
) {
|
||||
match value {
|
||||
SettingValue::String(s) => ("string", Some(s.clone()), None, None, None),
|
||||
SettingValue::Int(i) => ("int", None, Some(*i), None, None),
|
||||
SettingValue::Float(f) => ("float", None, None, Some(*f), None),
|
||||
SettingValue::Bool(b) => ("bool", None, None, None, Some(*b)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a SettingValue from a row where value columns start at `offset`.
|
||||
/// `offset` is the index of the value_text column (value_int = offset+1, etc.).
|
||||
///
|
||||
/// Used by `get()` (offset=1) and `get_all()` (offset=2) which select different
|
||||
/// column prefixes before the value columns.
|
||||
fn columns_to_value(
|
||||
vtype: &str,
|
||||
row: &rusqlite::Row<'_>,
|
||||
offset: usize,
|
||||
) -> Result<SettingValue, rusqlite::Error> {
|
||||
match vtype {
|
||||
"string" => Ok(SettingValue::String(row.get::<_, String>(offset)?)),
|
||||
"int" => Ok(SettingValue::Int(row.get::<_, i64>(offset + 1)?)),
|
||||
"float" => Ok(SettingValue::Float(row.get::<_, f64>(offset + 2)?)),
|
||||
"bool" => Ok(SettingValue::Bool(row.get::<_, bool>(offset + 3)?)),
|
||||
other => {
|
||||
tracing::warn!("unknown settings value_type '{}', treating as String", other);
|
||||
Ok(SettingValue::String(format!("<unknown type: {}>", other)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_store() -> SettingsStore {
|
||||
SettingsStore::open_in_memory().expect("open in-memory store")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_get_string() {
|
||||
let store = test_store();
|
||||
store
|
||||
.set("p1", "display.theme", &SettingValue::String("dark".into()))
|
||||
.unwrap();
|
||||
let val = store.get("p1", "display.theme").unwrap();
|
||||
assert_eq!(val, Some(SettingValue::String("dark".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_get_int() {
|
||||
let store = test_store();
|
||||
store
|
||||
.set("p1", "audio.volume", &SettingValue::Int(75))
|
||||
.unwrap();
|
||||
let val = store.get("p1", "audio.volume").unwrap();
|
||||
assert_eq!(val, Some(SettingValue::Int(75)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_get_float() {
|
||||
let store = test_store();
|
||||
store
|
||||
.set("p1", "audio.master", &SettingValue::Float(0.85))
|
||||
.unwrap();
|
||||
let val = store.get("p1", "audio.master").unwrap();
|
||||
assert_eq!(val, Some(SettingValue::Float(0.85)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_get_bool() {
|
||||
let store = test_store();
|
||||
store
|
||||
.set("p1", "ai_dialogue.enabled", &SettingValue::Bool(true))
|
||||
.unwrap();
|
||||
let val = store.get("p1", "ai_dialogue.enabled").unwrap();
|
||||
assert_eq!(val, Some(SettingValue::Bool(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_missing_returns_none() {
|
||||
let store = test_store();
|
||||
let val = store.get("p1", "nonexistent").unwrap();
|
||||
assert_eq!(val, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_overwrites() {
|
||||
let store = test_store();
|
||||
store
|
||||
.set("p1", "audio.volume", &SettingValue::Int(50))
|
||||
.unwrap();
|
||||
store
|
||||
.set("p1", "audio.volume", &SettingValue::Int(80))
|
||||
.unwrap();
|
||||
let val = store.get("p1", "audio.volume").unwrap();
|
||||
assert_eq!(val, Some(SettingValue::Int(80)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_isolation() {
|
||||
let store = test_store();
|
||||
store
|
||||
.set("p1", "volume", &SettingValue::Int(50))
|
||||
.unwrap();
|
||||
store
|
||||
.set("p2", "volume", &SettingValue::Int(90))
|
||||
.unwrap();
|
||||
assert_eq!(store.get("p1", "volume").unwrap(), Some(SettingValue::Int(50)));
|
||||
assert_eq!(store.get("p2", "volume").unwrap(), Some(SettingValue::Int(90)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_all_sorted() {
|
||||
let store = test_store();
|
||||
store.set("p1", "z_key", &SettingValue::Int(1)).unwrap();
|
||||
store.set("p1", "a_key", &SettingValue::Int(2)).unwrap();
|
||||
store.set("p1", "m_key", &SettingValue::Int(3)).unwrap();
|
||||
let all = store.get_all("p1").unwrap();
|
||||
let keys: Vec<&String> = all.keys().collect();
|
||||
assert_eq!(keys, vec!["a_key", "m_key", "z_key"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_setting() {
|
||||
let store = test_store();
|
||||
store.set("p1", "key", &SettingValue::Int(1)).unwrap();
|
||||
assert!(store.delete("p1", "key").unwrap());
|
||||
assert_eq!(store.get("p1", "key").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_nonexistent_returns_false() {
|
||||
let store = test_store();
|
||||
assert!(!store.delete("p1", "nope").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_all_entries_wire_format() {
|
||||
let store = test_store();
|
||||
store
|
||||
.set("p1", "audio.volume", &SettingValue::Float(0.5))
|
||||
.unwrap();
|
||||
store
|
||||
.set("p1", "ai_dialogue.enabled", &SettingValue::Bool(true))
|
||||
.unwrap();
|
||||
let entries = store.get_all_entries("p1").unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].key, "ai_dialogue.enabled");
|
||||
assert_eq!(entries[1].key, "audio.volume");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Settings type definitions (#627)
|
||||
// Wire types for settings IPC and typed value storage.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Typed setting value. Extensible key-value with typed columns —
|
||||
/// future settings don't require migrations.
|
||||
///
|
||||
/// Wire format: MessagePack via serde, same as all bridge types.
|
||||
/// SQLite mapping: each variant maps to a dedicated column in the settings table.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SettingValue {
|
||||
String(String),
|
||||
Int(i64),
|
||||
Float(f64),
|
||||
Bool(bool),
|
||||
}
|
||||
|
||||
impl SettingValue {
|
||||
/// Type tag for SQLite storage (value_type column).
|
||||
pub fn type_tag(&self) -> &'static str {
|
||||
match self {
|
||||
Self::String(_) => "string",
|
||||
Self::Int(_) => "int",
|
||||
Self::Float(_) => "float",
|
||||
Self::Bool(_) => "bool",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SettingValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::String(s) => write!(f, "{}", s),
|
||||
Self::Int(i) => write!(f, "{}", i),
|
||||
Self::Float(v) => write!(f, "{}", v),
|
||||
Self::Bool(b) => write!(f, "{}", b),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings response wire type included in ObserverSnapshot (#627).
|
||||
/// Present for exactly one tick after a settings operation completes.
|
||||
///
|
||||
/// Two kinds:
|
||||
/// - `Ack`: confirms a single setting was changed.
|
||||
/// - `Full`: full settings dump (response to RequestAllSettings, or initial load).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsResponseWire {
|
||||
/// Whether the operation succeeded.
|
||||
pub success: bool,
|
||||
/// "ack" for single-change confirmation, "full" for dump.
|
||||
pub kind: String,
|
||||
/// All current settings (populated for "full", single entry for "ack").
|
||||
pub settings: Vec<SettingEntry>,
|
||||
/// Error message if `success` is false.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A single key-value setting entry on the wire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingEntry {
|
||||
pub key: String,
|
||||
pub value: SettingValue,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn setting_value_type_tags() {
|
||||
assert_eq!(SettingValue::String("x".into()).type_tag(), "string");
|
||||
assert_eq!(SettingValue::Int(42).type_tag(), "int");
|
||||
assert_eq!(SettingValue::Float(1.5).type_tag(), "float");
|
||||
assert_eq!(SettingValue::Bool(true).type_tag(), "bool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_value_roundtrip() {
|
||||
let values = vec![
|
||||
SettingValue::String("hello".into()),
|
||||
SettingValue::Int(-1),
|
||||
SettingValue::Float(3.14),
|
||||
SettingValue::Bool(false),
|
||||
];
|
||||
for val in &values {
|
||||
let bytes = rmp_serde::to_vec_named(val).expect("serialize");
|
||||
let decoded: SettingValue = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(&decoded, val);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_response_wire_roundtrip() {
|
||||
let resp = SettingsResponseWire {
|
||||
success: true,
|
||||
kind: "full".into(),
|
||||
settings: vec![
|
||||
SettingEntry {
|
||||
key: "audio.volume".into(),
|
||||
value: SettingValue::Float(0.8),
|
||||
},
|
||||
SettingEntry {
|
||||
key: "ai_dialogue.enabled".into(),
|
||||
value: SettingValue::Bool(true),
|
||||
},
|
||||
],
|
||||
error: None,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&resp).expect("serialize");
|
||||
let decoded: SettingsResponseWire = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert!(decoded.success);
|
||||
assert_eq!(decoded.settings.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,11 @@ use rand::Rng;
|
||||
use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState};
|
||||
use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName};
|
||||
use crate::storyteller::EngagementRecord;
|
||||
use crate::content::line_pool::{
|
||||
use crate::simulation::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
|
||||
};
|
||||
use crate::content::types::KnowledgeGrant;
|
||||
use crate::content::LinePoolIndexResource;
|
||||
use crate::simulation::knowledge_grant::KnowledgeGrant;
|
||||
use crate::simulation::line_pool::LinePoolIndexResource;
|
||||
use crate::knowledge::content_registry::ContentEntityRegistry;
|
||||
use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant};
|
||||
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId};
|
||||
@@ -356,7 +356,7 @@ pub fn select_dialogue_line<'a>(
|
||||
/// avoid duplicating the L1-L4 query + scoring logic. Callers handle the
|
||||
/// result differently (initial Talk sets ActiveDialogue; follow-up may clear it).
|
||||
fn run_dialogue_pipeline<'a>(
|
||||
line_pool: &'a crate::content::line_pool::LinePoolIndex,
|
||||
line_pool: &'a crate::simulation::line_pool::LinePoolIndex,
|
||||
location: &str,
|
||||
role: &str,
|
||||
relationship: RelationshipState,
|
||||
@@ -1110,11 +1110,11 @@ pub fn process_dialogue_response(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::content::line_pool::{
|
||||
use crate::simulation::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
|
||||
Topic, TrustTier,
|
||||
};
|
||||
use crate::content::LinePoolIndexResource;
|
||||
use crate::simulation::line_pool::LinePoolIndexResource;
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::registry::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use crate::bridge::debug::DebugCommandBuffer;
|
||||
use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput};
|
||||
use crate::settings::{SettingsCommand, SettingsCommandBuffer};
|
||||
use crate::knowledge::{EntityRegistry, StableId};
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::simulation::interaction::{DoorInteractRequest, DoorState, TerminalInteractRequest};
|
||||
@@ -100,6 +101,7 @@ pub fn process_player_input(
|
||||
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
|
||||
mut save_load: Option<ResMut<SaveLoadPending>>,
|
||||
mut debug_cmd_buffer: Option<ResMut<DebugCommandBuffer>>,
|
||||
mut settings_cmd_buffer: Option<ResMut<SettingsCommandBuffer>>,
|
||||
door_states: Query<&DoorState>,
|
||||
object_types: Query<&ObjectType>,
|
||||
) {
|
||||
@@ -122,6 +124,9 @@ pub fn process_player_input(
|
||||
| PlayerAction::SaveGame { .. }
|
||||
| PlayerAction::LoadGame { .. }
|
||||
| PlayerAction::DebugCommand(_)
|
||||
| PlayerAction::ChangeSetting { .. }
|
||||
| PlayerAction::RequestAllSettings
|
||||
| PlayerAction::DeleteSetting { .. }
|
||||
)
|
||||
{
|
||||
continue;
|
||||
@@ -374,6 +379,27 @@ pub fn process_player_input(
|
||||
tracing::warn!("DebugCommand received but DebugCommandBuffer not registered");
|
||||
}
|
||||
}
|
||||
PlayerAction::ChangeSetting { key, value } => {
|
||||
if let Some(ref mut buf) = settings_cmd_buffer {
|
||||
buf.push(SettingsCommand::Change { key, value });
|
||||
} else {
|
||||
tracing::warn!("ChangeSetting received but SettingsCommandBuffer not registered");
|
||||
}
|
||||
}
|
||||
PlayerAction::RequestAllSettings => {
|
||||
if let Some(ref mut buf) = settings_cmd_buffer {
|
||||
buf.push(SettingsCommand::RequestAll);
|
||||
} else {
|
||||
tracing::warn!("RequestAllSettings received but SettingsCommandBuffer not registered");
|
||||
}
|
||||
}
|
||||
PlayerAction::DeleteSetting { key } => {
|
||||
if let Some(ref mut buf) = settings_cmd_buffer {
|
||||
buf.push(SettingsCommand::Delete { key });
|
||||
} else {
|
||||
tracing::warn!("DeleteSetting received but SettingsCommandBuffer not registered");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Knowledge grant types for dialogue and monologue lines.
|
||||
//!
|
||||
//! `KnowledgeGrant` describes the knowledge a player gains from a dialogue line.
|
||||
//! `Prerequisites` describes preconditions for a monologue line to fire.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Knowledge grant attached to a dialogue line (D-079).
|
||||
///
|
||||
/// Untagged enum — serde tries each variant in order:
|
||||
/// `Fact` matches YAML with `fact_id` field.
|
||||
/// `Entity` matches YAML with `entity_ref` field.
|
||||
///
|
||||
/// **Ambiguity hazard**: if a YAML object contains both `fact_id` and `entity_ref`,
|
||||
/// serde(untagged) silently selects `Fact` (first variant wins) and ignores `entity_ref`.
|
||||
/// Use [`KnowledgeGrant::validate`] at load time to catch this case.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum KnowledgeGrant {
|
||||
/// Grant knowledge of a non-entity fact.
|
||||
/// Format: fact_id "category.topic", confidence string.
|
||||
Fact {
|
||||
fact_id: String,
|
||||
confidence: String,
|
||||
},
|
||||
/// Grant knowledge of an entity (creates EntityKnowledge entry in observer's KG).
|
||||
/// Required for contradiction detection: testimony must create ToldBy EntityKnowledge
|
||||
/// so a subsequent DirectObservation can detect a discrepancy (D-079, D-083).
|
||||
Entity {
|
||||
entity_ref: String,
|
||||
#[serde(default)]
|
||||
attributes: BTreeMap<String, String>,
|
||||
confidence: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl KnowledgeGrant {
|
||||
/// Validates that the grant is internally consistent.
|
||||
///
|
||||
/// Specifically catches the serde(untagged) ambiguity: a raw YAML value that has
|
||||
/// both `fact_id` and `entity_ref` would parse silently as `Fact`, losing the
|
||||
/// entity reference. This can only be detected after deserializing the surrounding
|
||||
/// structure — call this on every grant after loading.
|
||||
///
|
||||
/// Currently there is no post-deserialization raw-value access, so this serves as
|
||||
/// a place to add future cross-field validation. Returns `Ok(())` for now.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
match self {
|
||||
KnowledgeGrant::Fact { fact_id, .. } => {
|
||||
if fact_id.is_empty() {
|
||||
return Err("KnowledgeGrant::Fact has empty fact_id".to_string());
|
||||
}
|
||||
}
|
||||
KnowledgeGrant::Entity { entity_ref, .. } => {
|
||||
if entity_ref.is_empty() {
|
||||
return Err("KnowledgeGrant::Entity has empty entity_ref".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Prerequisite set for a monologue line.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Prerequisites {
|
||||
#[serde(default)]
|
||||
pub facts: Vec<FactPrerequisite>,
|
||||
#[serde(default)]
|
||||
pub entity_attributes: Vec<AttributePrerequisite>,
|
||||
#[serde(default)]
|
||||
pub relationship: Option<RelationshipPrerequisite>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FactPrerequisite {
|
||||
pub fact_id: String,
|
||||
pub min_confidence: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AttributePrerequisite {
|
||||
pub entity: String,
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RelationshipPrerequisite {
|
||||
#[serde(default)]
|
||||
pub target: Option<String>,
|
||||
#[serde(default)]
|
||||
pub state: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Indexed line pool data structures and query API (D-028 four-layer filtering).
|
||||
//!
|
||||
//! Provides typed, pre-indexed runtime representations of dialogue and monologue
|
||||
//! content pools. Populated by the v0.2 generator pipeline.
|
||||
//!
|
||||
//! Indexing strategy (D-041 determinism):
|
||||
//! - All maps use BTreeMap for deterministic iteration order
|
||||
//! - Dialogue: BTreeMap<(location, role), pool> with lines ready for filtering
|
||||
//! - Monologue: BTreeMap<(character, location), pool> with trigger grouping
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use tracing::trace;
|
||||
|
||||
use crate::simulation::knowledge_grant::{KnowledgeGrant, Prerequisites};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tag enums (D-035 converged taxonomy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// D-028 Layer 1: Access tier — hard filter on who can hear this line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum AccessTier {
|
||||
Public,
|
||||
Insider,
|
||||
Authority,
|
||||
Peer,
|
||||
Hostile,
|
||||
}
|
||||
|
||||
impl FromStr for AccessTier {
|
||||
type Err = ParseEnumError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"public" => Ok(Self::Public),
|
||||
"insider" => Ok(Self::Insider),
|
||||
"authority" => Ok(Self::Authority),
|
||||
"peer" => Ok(Self::Peer),
|
||||
"hostile" => Ok(Self::Hostile),
|
||||
_ => Err(ParseEnumError {
|
||||
kind: "AccessTier",
|
||||
value: s.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// D-028 Layer 3: Trust tier — hard filter on relationship depth.
|
||||
///
|
||||
/// Ordering: Surface < Real < Secret (derived from PartialOrd on discriminant).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum TrustTier {
|
||||
Surface,
|
||||
Real,
|
||||
Secret,
|
||||
}
|
||||
|
||||
impl TrustTier {
|
||||
/// Returns true if `self` meets or exceeds the `required` tier.
|
||||
pub fn meets(self, required: TrustTier) -> bool {
|
||||
self >= required
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for TrustTier {
|
||||
type Err = ParseEnumError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"surface" => Ok(Self::Surface),
|
||||
"real" => Ok(Self::Real),
|
||||
"secret" => Ok(Self::Secret),
|
||||
_ => Err(ParseEnumError {
|
||||
kind: "TrustTier",
|
||||
value: s.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// D-028 Layer 2: Situation context — when this line can fire.
|
||||
///
|
||||
/// 14 v0.1 values: 13 original + Greeting added Sprint 8 (D-035 amendment)
|
||||
/// for PC dialogue pools initial contact lines.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Situation {
|
||||
Arrival,
|
||||
ShiftStart,
|
||||
ShiftEnd,
|
||||
ShiftTransition,
|
||||
BarEvening,
|
||||
NightShift,
|
||||
Investigation,
|
||||
Confrontation,
|
||||
Social,
|
||||
Alone,
|
||||
Emergency,
|
||||
Routine,
|
||||
Observation,
|
||||
/// Added Sprint 8 (D-035 amendment): PC dialogue initial contact lines.
|
||||
Greeting,
|
||||
/// First player-NPC interaction — interaction_count == 0 (#325, D-028 Layer 2).
|
||||
FirstMeeting,
|
||||
/// Player has talked to this NPC 3+ times — interaction_count >= 3 (#325, D-028 Layer 2).
|
||||
RepeatedVisit,
|
||||
}
|
||||
|
||||
impl FromStr for Situation {
|
||||
type Err = ParseEnumError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"arrival" => Ok(Self::Arrival),
|
||||
"shift_start" => Ok(Self::ShiftStart),
|
||||
"shift_end" => Ok(Self::ShiftEnd),
|
||||
"shift_transition" => Ok(Self::ShiftTransition),
|
||||
"bar_evening" => Ok(Self::BarEvening),
|
||||
"night_shift" => Ok(Self::NightShift),
|
||||
"investigation" => Ok(Self::Investigation),
|
||||
"confrontation" => Ok(Self::Confrontation),
|
||||
"social" => Ok(Self::Social),
|
||||
"alone" => Ok(Self::Alone),
|
||||
"emergency" => Ok(Self::Emergency),
|
||||
"routine" => Ok(Self::Routine),
|
||||
"observation" => Ok(Self::Observation),
|
||||
"greeting" => Ok(Self::Greeting),
|
||||
"first_meeting" => Ok(Self::FirstMeeting),
|
||||
"repeated_visit" => Ok(Self::RepeatedVisit),
|
||||
_ => Err(ParseEnumError {
|
||||
kind: "Situation",
|
||||
value: s.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// D-028 Layer 4: Topic tag — influences weighted selection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Topic {
|
||||
Colleague,
|
||||
Routine,
|
||||
Cargo,
|
||||
Money,
|
||||
Trust,
|
||||
Danger,
|
||||
Institution,
|
||||
Personal,
|
||||
Investigation,
|
||||
}
|
||||
|
||||
impl FromStr for Topic {
|
||||
type Err = ParseEnumError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"colleague" => Ok(Self::Colleague),
|
||||
"routine" => Ok(Self::Routine),
|
||||
"cargo" => Ok(Self::Cargo),
|
||||
"money" => Ok(Self::Money),
|
||||
"trust" => Ok(Self::Trust),
|
||||
"danger" => Ok(Self::Danger),
|
||||
"institution" => Ok(Self::Institution),
|
||||
"personal" => Ok(Self::Personal),
|
||||
"investigation" => Ok(Self::Investigation),
|
||||
_ => Err(ParseEnumError {
|
||||
kind: "Topic",
|
||||
value: s.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// D-028 Layer 4: Mood tag — influences weighted selection.
|
||||
///
|
||||
/// 8 v0.1 values aligned to voice guide vocabulary (Sprint 14 rename).
|
||||
/// D-035 amendment (Sprint 8): `Focused` added as 9th variant.
|
||||
/// Neutral mood is represented by omitting the mood tag (untagged = baseline).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Mood {
|
||||
Anxious,
|
||||
Frustrated,
|
||||
Content,
|
||||
Suspicious,
|
||||
Warm,
|
||||
Hostile,
|
||||
Relieved,
|
||||
/// D-035 amendment (Sprint 8): task-focused NPC mood — used at The Terminal
|
||||
/// and maintenance corridors. Maps from NpcMood::Focused.
|
||||
Focused,
|
||||
}
|
||||
|
||||
impl FromStr for Mood {
|
||||
type Err = ParseEnumError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"anxious" => Ok(Self::Anxious),
|
||||
"frustrated" => Ok(Self::Frustrated),
|
||||
"content" => Ok(Self::Content),
|
||||
"suspicious" => Ok(Self::Suspicious),
|
||||
"warm" => Ok(Self::Warm),
|
||||
"hostile" => Ok(Self::Hostile),
|
||||
"relieved" => Ok(Self::Relieved),
|
||||
"focused" => Ok(Self::Focused),
|
||||
_ => Err(ParseEnumError {
|
||||
kind: "Mood",
|
||||
value: s.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Monologue trigger type — what causes this line to fire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Trigger {
|
||||
EnterLocation,
|
||||
ObserveNpc,
|
||||
HearSound,
|
||||
ObserveAnomaly,
|
||||
PostConversation,
|
||||
DiscoverEvidence,
|
||||
WitnessInteraction,
|
||||
TimeIdle,
|
||||
ReturnVisit,
|
||||
}
|
||||
|
||||
impl FromStr for Trigger {
|
||||
type Err = ParseEnumError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"enter_location" => Ok(Self::EnterLocation),
|
||||
"observe_npc" => Ok(Self::ObserveNpc),
|
||||
"hear_sound" => Ok(Self::HearSound),
|
||||
"observe_anomaly" => Ok(Self::ObserveAnomaly),
|
||||
"post_conversation" => Ok(Self::PostConversation),
|
||||
"discover_evidence" => Ok(Self::DiscoverEvidence),
|
||||
"witness_interaction" => Ok(Self::WitnessInteraction),
|
||||
"time_idle" => Ok(Self::TimeIdle),
|
||||
"return_visit" => Ok(Self::ReturnVisit),
|
||||
_ => Err(ParseEnumError {
|
||||
kind: "Trigger",
|
||||
value: s.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard character partition for monologue pools (D-032).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Character {
|
||||
Smuggler,
|
||||
Detective,
|
||||
}
|
||||
|
||||
impl FromStr for Character {
|
||||
type Err = ParseEnumError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"smuggler" => Ok(Self::Smuggler),
|
||||
"detective" => Ok(Self::Detective),
|
||||
_ => Err(ParseEnumError {
|
||||
kind: "Character",
|
||||
value: s.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type for enum parsing failures.
|
||||
#[derive(Debug)]
|
||||
pub struct ParseEnumError {
|
||||
pub kind: &'static str,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseEnumError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "invalid {} value: {:?}", self.kind, self.value)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseEnumError {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Indexed line types — typed runtime representations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A dialogue line with typed enum fields, ready for filtering.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexedDialogueLine {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub role: String,
|
||||
pub access: Vec<AccessTier>,
|
||||
pub trust: TrustTier,
|
||||
pub situation: Vec<Situation>,
|
||||
pub topic: Vec<Topic>,
|
||||
pub mood: Vec<Mood>,
|
||||
pub tags: Vec<String>,
|
||||
pub knowledge_grant: Option<KnowledgeGrant>,
|
||||
}
|
||||
|
||||
/// A monologue line with typed enum fields, ready for filtering.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexedMonologueLine {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub trigger: Trigger,
|
||||
pub prerequisites: Option<Prerequisites>,
|
||||
pub priority: u8,
|
||||
pub cooldown: u32,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pool index types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Dialogue pool indexed for querying.
|
||||
#[derive(Debug)]
|
||||
pub struct IndexedDialoguePool {
|
||||
pub location: String,
|
||||
pub role: String,
|
||||
pub lines: Vec<IndexedDialogueLine>,
|
||||
}
|
||||
|
||||
/// Monologue pool indexed by trigger for fast lookup.
|
||||
#[derive(Debug)]
|
||||
pub struct IndexedMonologuePool {
|
||||
pub character: Character,
|
||||
pub location: String,
|
||||
/// Lines grouped by trigger type (BTreeMap for deterministic iteration).
|
||||
pub by_trigger: BTreeMap<Trigger, Vec<IndexedMonologueLine>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Top-level line pool index — the queryable runtime data structure.
|
||||
///
|
||||
/// All internal maps use BTreeMap per D-041 determinism requirement.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LinePoolIndex {
|
||||
/// Dialogue pools indexed by (location, role).
|
||||
pub dialogue: BTreeMap<(String, String), IndexedDialoguePool>,
|
||||
/// Monologue pools indexed by (character, location).
|
||||
pub monologue: BTreeMap<(Character, String), IndexedMonologuePool>,
|
||||
}
|
||||
|
||||
impl LinePoolIndex {
|
||||
/// Query dialogue lines through Layers 1-3 of the D-028 pipeline.
|
||||
///
|
||||
/// Returns lines that pass:
|
||||
/// - Layer 1: player's access tier is in line.access
|
||||
/// - Layer 2: any active situation is in line.situation
|
||||
/// - Layer 3: player's trust >= line.trust
|
||||
///
|
||||
/// Layer 4 (topic+mood scoring) is handled by the selection pipeline (#305).
|
||||
pub fn query_dialogue(
|
||||
&self,
|
||||
location: &str,
|
||||
role: &str,
|
||||
player_access: AccessTier,
|
||||
active_situations: &[Situation],
|
||||
player_trust: TrustTier,
|
||||
) -> Vec<&IndexedDialogueLine> {
|
||||
if active_situations.is_empty() {
|
||||
trace!(
|
||||
location,
|
||||
role,
|
||||
"query_dialogue: active_situations is empty — Layer 2 will filter all lines"
|
||||
);
|
||||
}
|
||||
|
||||
let key = (location.to_string(), role.to_string());
|
||||
let Some(pool) = self.dialogue.get(&key) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
pool.lines
|
||||
.iter()
|
||||
.filter(|line| {
|
||||
// Layer 1: Access filter (hard)
|
||||
line.access.contains(&player_access)
|
||||
})
|
||||
.filter(|line| {
|
||||
// Layer 2: Situation filter (context)
|
||||
line.situation.iter().any(|s| active_situations.contains(s))
|
||||
})
|
||||
.filter(|line| {
|
||||
// Layer 3: Trust filter (hard)
|
||||
player_trust.meets(line.trust)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Query monologue lines for a trigger event.
|
||||
///
|
||||
/// Returns lines matching character + trigger from both:
|
||||
/// - Location-specific pool (exact match)
|
||||
/// - General pool (location = "general")
|
||||
///
|
||||
/// Prerequisite evaluation and cooldown checking are the caller's
|
||||
/// responsibility (they require KG state and tick tracking).
|
||||
pub fn query_monologue(
|
||||
&self,
|
||||
character: Character,
|
||||
location: &str,
|
||||
trigger: Trigger,
|
||||
) -> Vec<&IndexedMonologueLine> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Location-specific pool
|
||||
let key = (character, location.to_string());
|
||||
if let Some(pool) = self.monologue.get(&key) {
|
||||
if let Some(lines) = pool.by_trigger.get(&trigger) {
|
||||
results.extend(lines.iter());
|
||||
}
|
||||
}
|
||||
|
||||
// General pool fallback
|
||||
if location != "general" {
|
||||
let general_key = (character, "general".to_string());
|
||||
if let Some(pool) = self.monologue.get(&general_key) {
|
||||
if let Some(lines) = pool.by_trigger.get(&trigger) {
|
||||
results.extend(lines.iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Returns total number of indexed dialogue lines.
|
||||
pub fn dialogue_line_count(&self) -> usize {
|
||||
self.dialogue.values().map(|p| p.lines.len()).sum()
|
||||
}
|
||||
|
||||
/// Returns total number of indexed monologue lines.
|
||||
pub fn monologue_line_count(&self) -> usize {
|
||||
self.monologue
|
||||
.values()
|
||||
.flat_map(|p| p.by_trigger.values())
|
||||
.map(|lines| lines.len())
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wrapper resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wrapper resource holding the indexed line pools.
|
||||
/// Available for runtime systems that need to query dialogue/monologue lines
|
||||
/// through the D-028 four-layer filtering pipeline.
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct LinePoolIndexResource(pub LinePoolIndex);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn access_tier_parse_all_values() {
|
||||
assert_eq!("public".parse::<AccessTier>().unwrap(), AccessTier::Public);
|
||||
assert_eq!("insider".parse::<AccessTier>().unwrap(), AccessTier::Insider);
|
||||
assert_eq!("authority".parse::<AccessTier>().unwrap(), AccessTier::Authority);
|
||||
assert_eq!("peer".parse::<AccessTier>().unwrap(), AccessTier::Peer);
|
||||
assert_eq!("hostile".parse::<AccessTier>().unwrap(), AccessTier::Hostile);
|
||||
assert!("invalid".parse::<AccessTier>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_tier_ordering() {
|
||||
assert!(TrustTier::Surface < TrustTier::Real);
|
||||
assert!(TrustTier::Real < TrustTier::Secret);
|
||||
assert!(TrustTier::Secret.meets(TrustTier::Secret));
|
||||
assert!(TrustTier::Secret.meets(TrustTier::Surface));
|
||||
assert!(!TrustTier::Surface.meets(TrustTier::Real));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn situation_parse_all_values() {
|
||||
let values = [
|
||||
"arrival", "shift_start", "shift_end", "shift_transition", "bar_evening",
|
||||
"night_shift", "investigation", "confrontation", "social", "alone",
|
||||
"emergency", "routine", "observation", "greeting", "first_meeting", "repeated_visit",
|
||||
];
|
||||
for v in values {
|
||||
assert!(v.parse::<Situation>().is_ok(), "Failed to parse situation: {}", v);
|
||||
}
|
||||
assert!("invalid".parse::<Situation>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_parse_all_values() {
|
||||
let values = [
|
||||
"colleague", "routine", "cargo", "money", "trust", "danger",
|
||||
"institution", "personal", "investigation",
|
||||
];
|
||||
for v in values {
|
||||
assert!(v.parse::<Topic>().is_ok(), "Failed to parse topic: {}", v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_parse_all_values() {
|
||||
let values = [
|
||||
"anxious", "frustrated", "content", "suspicious", "warm",
|
||||
"hostile", "relieved", "focused",
|
||||
];
|
||||
for v in values {
|
||||
assert!(v.parse::<Mood>().is_ok(), "Failed to parse mood: {}", v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_parse_all_values() {
|
||||
let values = [
|
||||
"enter_location", "observe_npc", "hear_sound", "observe_anomaly",
|
||||
"post_conversation", "discover_evidence", "witness_interaction",
|
||||
"time_idle", "return_visit",
|
||||
];
|
||||
for v in values {
|
||||
assert!(v.parse::<Trigger>().is_ok(), "Failed to parse trigger: {}", v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_parse() {
|
||||
assert_eq!("smuggler".parse::<Character>().unwrap(), Character::Smuggler);
|
||||
assert_eq!("detective".parse::<Character>().unwrap(), Character::Detective);
|
||||
assert!("other".parse::<Character>().is_err());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pub mod generator;
|
||||
pub mod input;
|
||||
pub mod interaction;
|
||||
pub mod inventory;
|
||||
pub mod knowledge_grant;
|
||||
pub mod line_pool;
|
||||
pub mod listening;
|
||||
pub mod modification;
|
||||
pub mod monologue;
|
||||
@@ -33,6 +35,7 @@ pub mod stance;
|
||||
pub mod tier;
|
||||
pub mod time;
|
||||
pub mod ticker;
|
||||
pub mod triangle;
|
||||
pub mod zone;
|
||||
|
||||
/// Core simulation plugin
|
||||
@@ -58,8 +61,8 @@ impl Plugin for SimulationPlugin {
|
||||
.init_resource::<monologue::PostConversationQueue>()
|
||||
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
|
||||
// Triangle escalation resources (#250)
|
||||
.init_resource::<crate::content::template::TriangleCrisisEventQueue>()
|
||||
.init_resource::<crate::content::template::ResolveTriangleQueue>()
|
||||
.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>()
|
||||
.init_resource::<crate::simulation::triangle::ResolveTriangleQueue>()
|
||||
// discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin).
|
||||
// Init here so SimulationPlugin works standalone in tests without PerceptionPlugin.
|
||||
.init_resource::<crate::perception::query::VisibilityGeometry>()
|
||||
@@ -116,11 +119,11 @@ impl Plugin for SimulationPlugin {
|
||||
.after(crate::npc::awareness::detect_player_awareness)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
// Triangle escalation (#250) — runs on game-minute boundaries (every 10 ticks)
|
||||
crate::content::template::tick_triangle_escalation
|
||||
crate::simulation::triangle::tick_triangle_escalation
|
||||
.after(crate::npc::tolerance::check_tolerance_threshold)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
// Triangle resolution (#250, D-089) — apply player resolve commands
|
||||
crate::content::template::apply_resolve_triangle
|
||||
crate::simulation::triangle::apply_resolve_triangle
|
||||
.after(input::process_player_input)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
||||
@@ -145,6 +148,21 @@ impl Plugin for SimulationPlugin {
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
);
|
||||
|
||||
// Voice enrichment (D-138, Phase 3) — rewrite NPC text with voiced
|
||||
// variants from cache before the observer snapshot is assembled.
|
||||
// No-op when VoiceCacheResource is absent (voice pipeline disabled).
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
crate::voice::integration::voice_enrich_dialogue_response
|
||||
.after(crate::simulation::dialogue::process_talk_interaction)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
crate::voice::integration::voice_enrich_conversation_events
|
||||
.after(conversation::run_npc_conversations)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
),
|
||||
);
|
||||
|
||||
// Initialize TickerPool with empty default; populated by ContentPlugin at Startup.
|
||||
app.init_resource::<ticker::TickerPool>();
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::bridge::types::MonologueEvent;
|
||||
use crate::content::ContentStoreResource;
|
||||
use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
|
||||
use crate::perception::interpretation::ObservationTrigger;
|
||||
use crate::simulation::conversation::NpcName;
|
||||
@@ -23,13 +22,6 @@ use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::storyteller::EngagementRecord;
|
||||
|
||||
/// Minimum ticks between monologue lines (prevents spam).
|
||||
/// At 10 ticks/game-minute, 300 ticks = 30 game-minutes.
|
||||
const COOLDOWN_TICKS: u64 = 300;
|
||||
|
||||
/// Ticks of idle (no movement) before a time_idle monologue fires.
|
||||
/// 100 ticks = 10 game-minutes.
|
||||
const IDLE_THRESHOLD_TICKS: u64 = 100;
|
||||
|
||||
/// Display duration for monologue text on client (seconds).
|
||||
const DISPLAY_DURATION: f32 = 5.0;
|
||||
@@ -310,7 +302,6 @@ pub fn process_sprint_anomaly_monologue(
|
||||
/// System ordering: after trigger_monologue, before process_sprint_anomaly_monologue.
|
||||
pub fn trigger_recognition_monologue(
|
||||
time: Res<SimulationTime>,
|
||||
content: Option<Res<ContentStoreResource>>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut query: Query<
|
||||
(
|
||||
@@ -355,21 +346,11 @@ pub fn trigger_recognition_monologue(
|
||||
return;
|
||||
};
|
||||
|
||||
// Try content pools for observe_anomaly trigger lines
|
||||
let line = content
|
||||
.as_deref()
|
||||
.and_then(|c| select_pool_line("observe_anomaly", &state, c, &mut rng.rng));
|
||||
|
||||
// Use content pool line or hardcoded fallback
|
||||
let (id, text) = if let Some((id, text)) = line {
|
||||
(id, text)
|
||||
} else {
|
||||
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
|
||||
(
|
||||
RECOGNITION_LINES[i].0.to_string(),
|
||||
RECOGNITION_LINES[i].1.to_string(),
|
||||
)
|
||||
};
|
||||
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
|
||||
let (id, text) = (
|
||||
RECOGNITION_LINES[i].0.to_string(),
|
||||
RECOGNITION_LINES[i].1.to_string(),
|
||||
);
|
||||
|
||||
buffer.event = Some(MonologueEvent {
|
||||
id: id.clone(),
|
||||
@@ -395,63 +376,6 @@ pub fn trigger_recognition_monologue(
|
||||
// Shared content pool selection (#119)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Select a monologue line from content pools, matching trigger and character.
|
||||
/// Returns (id, text) or None if no matching lines exist.
|
||||
/// Prefers unseen lines; falls back to repeats if all have been shown.
|
||||
fn select_pool_line(
|
||||
trigger: &str,
|
||||
state: &MonologueState,
|
||||
content: &ContentStoreResource,
|
||||
rng: &mut impl Rng,
|
||||
) -> Option<(String, String)> {
|
||||
let character = state.character.as_str();
|
||||
let mut candidates: Vec<(&str, &str)> = Vec::new();
|
||||
|
||||
for district in content.0.districts.values() {
|
||||
for pool in &district.monologue_pools {
|
||||
if pool.character != character {
|
||||
continue;
|
||||
}
|
||||
for line in &pool.lines {
|
||||
if line.trigger != trigger {
|
||||
continue;
|
||||
}
|
||||
if state.shown_ids.contains(&line.id) {
|
||||
continue;
|
||||
}
|
||||
candidates.push((&line.id, &line.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
// Fallback: allow repeats
|
||||
for district in content.0.districts.values() {
|
||||
for pool in &district.monologue_pools {
|
||||
if pool.character != character {
|
||||
continue;
|
||||
}
|
||||
for line in &pool.lines {
|
||||
if line.trigger != trigger {
|
||||
continue;
|
||||
}
|
||||
candidates.push((&line.id, &line.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let index = rng.random_range(0..candidates.len());
|
||||
Some((
|
||||
candidates[index].0.to_string(),
|
||||
candidates[index].1.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Select from hardcoded fallback lines for the given trigger type.
|
||||
fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, String) {
|
||||
let lines = match trigger {
|
||||
@@ -486,8 +410,8 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
|
||||
///
|
||||
/// Checks observation events, sound events, overheard conversations, and
|
||||
/// completed dialogues for monologue-worthy triggers. Fires at most one
|
||||
/// monologue per tick. Bypasses normal COOLDOWN_TICKS (event-driven),
|
||||
/// but updates last_fired_tick for periodic trigger cooldown tracking.
|
||||
/// monologue per tick. Event-driven — no cooldown gate. Updates
|
||||
/// `last_fired_tick` so v0.2 periodic triggers can respect the recency window.
|
||||
///
|
||||
/// Priority order (first match wins):
|
||||
/// 1. observe_npc (new entity spotted — uses previous-tick observation events)
|
||||
@@ -500,7 +424,6 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn trigger_event_monologue(
|
||||
time: Res<SimulationTime>,
|
||||
content: Option<Res<ContentStoreResource>>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
observation_queue: Option<Res<crate::perception::interpretation::ObservationEventQueue>>,
|
||||
sound_queue: Option<Res<crate::simulation::sound::SoundEventQueue>>,
|
||||
@@ -567,16 +490,7 @@ pub fn trigger_event_monologue(
|
||||
|
||||
let Some(trigger) = trigger else { return };
|
||||
|
||||
// Select line: content pool first, hardcoded fallback second
|
||||
let (id, text) = if let Some(ref content) = content {
|
||||
if let Some(line) = select_pool_line(trigger, &state, content, &mut rng.rng) {
|
||||
line
|
||||
} else {
|
||||
select_hardcoded_fallback(trigger, &mut rng.rng)
|
||||
}
|
||||
} else {
|
||||
select_hardcoded_fallback(trigger, &mut rng.rng)
|
||||
};
|
||||
let (id, text) = select_hardcoded_fallback(trigger, &mut rng.rng);
|
||||
|
||||
buffer.event = Some(MonologueEvent {
|
||||
id: id.clone(),
|
||||
@@ -663,119 +577,21 @@ fn has_hear_sound_event(
|
||||
})
|
||||
}
|
||||
|
||||
/// Monologue trigger system.
|
||||
/// Monologue trigger system — **stub**.
|
||||
///
|
||||
/// Runs each tick. Checks trigger conditions against loaded content pools
|
||||
/// and writes a MonologueEvent to MonologueBuffer when a line should fire.
|
||||
///
|
||||
/// v0.1 triggers:
|
||||
/// - `enter_location`: fires once on first tick (session start)
|
||||
/// - `time_idle`: fires after IDLE_THRESHOLD_TICKS of no player movement
|
||||
/// v0.1 content pool triggers were removed (#655). This system remains
|
||||
/// registered in the schedule as a sequencing anchor: `trigger_recognition_monologue`
|
||||
/// and `process_sprint_anomaly_monologue` are ordered `.after(trigger_monologue)`.
|
||||
/// Remove this stub when those systems' ordering constraints are refactored.
|
||||
pub fn trigger_monologue(
|
||||
time: Res<SimulationTime>,
|
||||
content: Option<Res<ContentStoreResource>>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut query: Query<
|
||||
_time: Res<SimulationTime>,
|
||||
_rng: ResMut<SimRng>,
|
||||
_query: Query<
|
||||
(&TilePosition, &mut MonologueState, &mut MonologueBuffer),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
) {
|
||||
let Some(content) = content else { return };
|
||||
let Ok((pos, mut state, mut buffer)) = query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Track idle time
|
||||
let current_pos = (pos.x, pos.y);
|
||||
if let Some(last) = state.last_position {
|
||||
if last == current_pos {
|
||||
state.idle_ticks += 1;
|
||||
} else {
|
||||
state.idle_ticks = 0;
|
||||
}
|
||||
}
|
||||
state.last_position = Some(current_pos);
|
||||
|
||||
// Cooldown check
|
||||
if time.tick > 0 && time.tick - state.last_fired_tick < COOLDOWN_TICKS {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which trigger to attempt
|
||||
let trigger = if !state.entered {
|
||||
state.entered = true;
|
||||
Some("enter_location")
|
||||
} else if state.idle_ticks >= IDLE_THRESHOLD_TICKS {
|
||||
Some("time_idle")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let Some(trigger) = trigger else { return };
|
||||
|
||||
// Collect candidate lines from all district monologue pools
|
||||
let character = state.character.as_str();
|
||||
let mut candidates: Vec<(&str, &str)> = Vec::new(); // (id, text)
|
||||
|
||||
for district in content.0.districts.values() {
|
||||
for pool in &district.monologue_pools {
|
||||
if pool.character != character {
|
||||
continue;
|
||||
}
|
||||
for line in &pool.lines {
|
||||
if line.trigger != trigger {
|
||||
continue;
|
||||
}
|
||||
if state.shown_ids.contains(&line.id) {
|
||||
continue;
|
||||
}
|
||||
candidates.push((&line.id, &line.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
// All lines for this trigger have been shown; allow repeats
|
||||
for district in content.0.districts.values() {
|
||||
for pool in &district.monologue_pools {
|
||||
if pool.character != character {
|
||||
continue;
|
||||
}
|
||||
for line in &pool.lines {
|
||||
if line.trigger != trigger {
|
||||
continue;
|
||||
}
|
||||
candidates.push((&line.id, &line.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Select a random line
|
||||
let index = rng.rng.random_range(0..candidates.len());
|
||||
let (id, text) = candidates[index];
|
||||
|
||||
buffer.event = Some(MonologueEvent {
|
||||
id: id.to_string(),
|
||||
text: text.to_string(),
|
||||
duration_seconds: DISPLAY_DURATION,
|
||||
});
|
||||
|
||||
state.shown_ids.insert(id.to_string());
|
||||
state.last_fired_tick = time.tick;
|
||||
// Reset idle counter so time_idle doesn't fire again immediately
|
||||
state.idle_ticks = 0;
|
||||
|
||||
tracing::debug!(
|
||||
"Monologue fired: trigger={}, id={}, tick={}",
|
||||
trigger,
|
||||
id,
|
||||
time.tick
|
||||
);
|
||||
// Intentional no-op — kept as schedule ordering anchor. See doc comment.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -877,137 +693,10 @@ pub fn process_contradiction_monologue(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::content::loader::{ContentStore, DistrictContent};
|
||||
use crate::content::types::{MonologueLine, MonologuePool};
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
fn setup_world_with_content() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
|
||||
// Create test monologue content
|
||||
let pool = MonologuePool {
|
||||
character: "detective".to_string(),
|
||||
location: "general".to_string(),
|
||||
lines: vec![
|
||||
MonologueLine {
|
||||
id: "test_enter_001".to_string(),
|
||||
text: "Sova Transit District. Let's narrow that down.".to_string(),
|
||||
trigger: "enter_location".to_string(),
|
||||
prerequisites: None,
|
||||
priority: None,
|
||||
cooldown: None,
|
||||
tags: vec![],
|
||||
},
|
||||
MonologueLine {
|
||||
id: "test_idle_001".to_string(),
|
||||
text: "Everyone knows I'm Commission.".to_string(),
|
||||
trigger: "time_idle".to_string(),
|
||||
prerequisites: None,
|
||||
priority: None,
|
||||
cooldown: None,
|
||||
tags: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut district = DistrictContent::default();
|
||||
district.monologue_pools.push(pool);
|
||||
let mut store = ContentStore::default();
|
||||
store.districts.insert("test".to_string(), district);
|
||||
world.insert_resource(ContentStoreResource(store));
|
||||
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_location_fires_on_first_tick() {
|
||||
let mut world = setup_world_with_content();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut query = world.query::<&MonologueBuffer>();
|
||||
let buffer = query.single(&world).unwrap();
|
||||
assert!(buffer.event.is_some());
|
||||
let event = buffer.event.as_ref().unwrap();
|
||||
assert_eq!(event.id, "test_enter_001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_prevents_spam() {
|
||||
let mut world = setup_world_with_content();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_monologue);
|
||||
|
||||
// First tick: should fire enter_location
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Consume the buffer
|
||||
let mut query = world.query::<&mut MonologueBuffer>();
|
||||
query.single_mut(&mut world).unwrap().take();
|
||||
|
||||
// Advance a few ticks (still in cooldown)
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
// Set idle ticks high to try to trigger time_idle
|
||||
let mut state_query = world.query::<&mut MonologueState>();
|
||||
state_query.single_mut(&mut world).unwrap().idle_ticks = IDLE_THRESHOLD_TICKS + 1;
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Should NOT fire — cooldown active
|
||||
let mut query = world.query::<&MonologueBuffer>();
|
||||
let buffer = query.single(&world).unwrap();
|
||||
assert!(buffer.event.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_idle_fires_after_threshold() {
|
||||
let mut world = setup_world_with_content();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
MonologueState {
|
||||
entered: true, // Skip enter_location
|
||||
last_position: Some((5, 5)),
|
||||
idle_ticks: IDLE_THRESHOLD_TICKS, // At threshold
|
||||
..Default::default()
|
||||
},
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
// Advance past cooldown
|
||||
world.resource_mut::<SimulationTime>().tick = COOLDOWN_TICKS + 1;
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut query = world.query::<&MonologueBuffer>();
|
||||
let buffer = query.single(&world).unwrap();
|
||||
assert!(buffer.event.is_some());
|
||||
let event = buffer.event.as_ref().unwrap();
|
||||
assert_eq!(event.id, "test_idle_001");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SprintAnomalyQueue unit tests (#428, D-055)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -2095,58 +1784,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_uses_content_pool_when_available() {
|
||||
let mut world = setup_event_world();
|
||||
let player = spawn_event_player(&mut world);
|
||||
|
||||
// Set up content pool with a witness_interaction line
|
||||
let pool = MonologuePool {
|
||||
character: "detective".to_string(),
|
||||
location: "general".to_string(),
|
||||
lines: vec![MonologueLine {
|
||||
id: "pool_witness_01".to_string(),
|
||||
text: "She's lying to him.".to_string(),
|
||||
trigger: "witness_interaction".to_string(),
|
||||
prerequisites: None,
|
||||
priority: None,
|
||||
cooldown: None,
|
||||
tags: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let mut district = DistrictContent::default();
|
||||
district.monologue_pools.push(pool);
|
||||
let mut store = ContentStore::default();
|
||||
store.districts.insert("test".to_string(), district);
|
||||
world.insert_resource(ContentStoreResource(store));
|
||||
|
||||
// Push a conversation event
|
||||
world
|
||||
.get_mut::<ConversationEventBuffer>(player)
|
||||
.unwrap()
|
||||
.events
|
||||
.push(crate::simulation::conversation::ConversationEvent {
|
||||
occluded_line: "Test".to_string(),
|
||||
speaker_id: 100,
|
||||
target_id: 101,
|
||||
speaker_name: "A".to_string(),
|
||||
target_name: "B".to_string(),
|
||||
speaker_color_index: 0,
|
||||
target_color_index: 1,
|
||||
});
|
||||
|
||||
run_event_system(&mut world);
|
||||
|
||||
let buf = world.get::<MonologueBuffer>(player).unwrap();
|
||||
assert!(buf.event.is_some());
|
||||
assert_eq!(
|
||||
buf.event.as_ref().unwrap().id,
|
||||
"pool_witness_01",
|
||||
"should use content pool line over hardcoded fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardcoded_lines_all_valid() {
|
||||
for lines in &[
|
||||
@@ -2167,13 +1804,6 @@ mod tests {
|
||||
// Constant assertions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cooldown_ticks_constant_is_300() {
|
||||
// D-035: 300 ticks = 30 game-minutes at 10 ticks/game-minute (D-031).
|
||||
// If this changes, players will see more/less monologue spam.
|
||||
assert_eq!(COOLDOWN_TICKS, 300, "D-035: COOLDOWN_TICKS must be 300");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// hear_sound: only Machinery and Alert trigger (not Voice/Ambient/Footstep)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -2230,71 +1860,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// observe_anomaly content pool integration via recognition monologue
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_uses_observe_anomaly_content_pool_key() {
|
||||
// When a content pool has lines with trigger="observe_anomaly",
|
||||
// trigger_recognition_monologue should select from that pool (not hardcoded fallback).
|
||||
// This verifies the content key matches the implementation.
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
let pool = MonologuePool {
|
||||
character: "detective".to_string(),
|
||||
location: "general".to_string(),
|
||||
lines: vec![MonologueLine {
|
||||
id: "observe_anomaly_pool_01".to_string(),
|
||||
text: "That person shouldn't be here.".to_string(),
|
||||
trigger: "observe_anomaly".to_string(),
|
||||
prerequisites: None,
|
||||
priority: None,
|
||||
cooldown: None,
|
||||
tags: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let mut district = DistrictContent::default();
|
||||
district.monologue_pools.push(pool);
|
||||
let mut store = ContentStore::default();
|
||||
store.districts.insert("test".to_string(), district);
|
||||
world.insert_resource(ContentStoreResource(store));
|
||||
|
||||
let target = world.spawn_empty().id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
cd.push(PendingRecognition {
|
||||
target,
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
cd,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buf_query = world.query::<&MonologueBuffer>();
|
||||
let buffer = buf_query.single(&world).unwrap();
|
||||
assert!(buffer.event.is_some(), "recognition monologue should fire");
|
||||
assert_eq!(
|
||||
buffer.event.as_ref().unwrap().id,
|
||||
"observe_anomaly_pool_01",
|
||||
"should use content pool line with trigger='observe_anomaly' key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observation_tick_tracking_updated() {
|
||||
let mut world = setup_event_world();
|
||||
|
||||
@@ -16,7 +16,7 @@ use thiserror::Error;
|
||||
|
||||
use crate::bridge::types::SaveLoadResultWire;
|
||||
use crate::bridge::types::SnapshotBuffer;
|
||||
use crate::content::template::{TemplateReferenceMap, TriangleState};
|
||||
use crate::simulation::triangle::{TemplateReferenceMap, TriangleState};
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::registry::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
@@ -30,7 +30,7 @@ use crate::knowledge::types::StableId;
|
||||
use crate::simulation::interaction::DoorState;
|
||||
use crate::simulation::tier::{ActiveSim, BackgroundSim};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::content::template::TriangleCrisisEventQueue;
|
||||
use crate::simulation::triangle::TriangleCrisisEventQueue;
|
||||
use crate::storyteller::{
|
||||
ActivationState, ContaminationActive, ContaminationEventQueue, MovementHistoryBuffer,
|
||||
TriangleActivatedQueue,
|
||||
@@ -594,7 +594,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn load_from_file_rejects_wrong_format_version() {
|
||||
use crate::content::template::TemplateReferenceMap;
|
||||
use crate::simulation::triangle::TemplateReferenceMap;
|
||||
// Craft a save with a wrong format_version
|
||||
let bad_state = SaveStateV1 {
|
||||
format_version: 0xFF, // deliberately wrong
|
||||
@@ -775,7 +775,7 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn make_test_triangle(slug: &str, tension: u8) -> TriangleState {
|
||||
use crate::content::template::{
|
||||
use crate::simulation::triangle::{
|
||||
RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase,
|
||||
};
|
||||
let mut role_assignments = std::collections::BTreeMap::new();
|
||||
|
||||
@@ -39,7 +39,7 @@ use bevy_ecs::entity::Entity;
|
||||
use bevy_ecs::world::World;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::content::template::{TemplateOwnership, TemplateReferenceMap, TriangleState};
|
||||
use crate::simulation::triangle::{TemplateOwnership, TemplateReferenceMap, TriangleState};
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::registry::StableEntityId;
|
||||
use crate::knowledge::types::StableId;
|
||||
|
||||
@@ -55,7 +55,7 @@ use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::content::template::{TriangleClassification, TriangleId, TrianglePhase, TriangleState};
|
||||
use crate::simulation::triangle::{TriangleClassification, TriangleId, TrianglePhase, TriangleState};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
@@ -691,7 +691,7 @@ pub fn expire_routine_deviations(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::content::template::{
|
||||
use crate::simulation::triangle::{
|
||||
RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState,
|
||||
};
|
||||
use crate::knowledge::types::StableId;
|
||||
|
||||
@@ -220,6 +220,7 @@ impl Serialize for ContentType {
|
||||
match self {
|
||||
ContentType::Dialogue => serializer.serialize_u8(0),
|
||||
ContentType::Behavior => serializer.serialize_u8(1),
|
||||
ContentType::Factual => serializer.serialize_u8(2),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,6 +231,7 @@ impl<'de> Deserialize<'de> for ContentType {
|
||||
match v {
|
||||
0 => Ok(ContentType::Dialogue),
|
||||
1 => Ok(ContentType::Behavior),
|
||||
2 => Ok(ContentType::Factual),
|
||||
_ => Err(serde::de::Error::custom("invalid ContentType")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Observer integration for the voice pipeline (D-138, Phase 3).
|
||||
//!
|
||||
//! Two enrichment systems run before `compute_observer_snapshot` and rewrite
|
||||
//! NPC text in the dialogue and conversation buffers with voiced variants
|
||||
//! looked up from the cache. Cache miss → base text (never blocks).
|
||||
//!
|
||||
//! ## System ordering
|
||||
//!
|
||||
//! ```text
|
||||
//! process_talk_interaction ─┐
|
||||
//! run_npc_conversations ─┤─► voice_enrich_dialogue_response ─┐
|
||||
//! └─► voice_enrich_conversation_events ─► compute_observer_snapshot
|
||||
//! ```
|
||||
//!
|
||||
//! ## Content index derivation
|
||||
//!
|
||||
//! The voice cache key uses a `content_index: u16` to distinguish individual
|
||||
//! lines for the same NPC. For dialogue lines the index is derived via
|
||||
//! FNV-1a hash of `line_id`, matching the derivation used when requests
|
||||
//! are submitted to the worker queue for pre-voicing.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::knowledge::{EntityRegistry, StableId};
|
||||
use crate::npc::tell_state::DerivedTellState;
|
||||
use crate::npc::{Npc, NpcVoiceProfile};
|
||||
use crate::simulation::conversation::ConversationEventBuffer;
|
||||
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::zone::ZoneMap;
|
||||
use crate::voice::lookup::{voiced_behavior, VoiceCacheResource};
|
||||
use crate::voice::prompt_builder::ContentType;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content index derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive a deterministic content_index (u16) from a string line identifier.
|
||||
///
|
||||
/// Uses FNV-1a (64-bit) truncated to u16 — same algorithm used by the worker
|
||||
/// queue when submitting pre-voicing requests, ensuring cache key consistency.
|
||||
pub fn content_index_from_line_id(line_id: &str) -> u16 {
|
||||
let mut hash: u64 = 0xcbf29ce484222325;
|
||||
for b in line_id.bytes() {
|
||||
hash ^= b as u64;
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash as u16
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Player zone resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn player_zone_id(
|
||||
player_query: &Query<&TilePosition, With<PlayerCharacter>>,
|
||||
zone_map: Option<&ZoneMap>,
|
||||
) -> u32 {
|
||||
let Ok(pos) = player_query.single() else {
|
||||
return 0;
|
||||
};
|
||||
zone_map
|
||||
.and_then(|zm| zm.zone_at(pos.x, pos.y, pos.z))
|
||||
.map(|id| id as u32)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Enrich the dialogue response buffer with a voiced line from the cache.
|
||||
///
|
||||
/// Must run after `process_talk_interaction` and before
|
||||
/// `compute_observer_snapshot`. No-op when the voice cache resource is absent.
|
||||
///
|
||||
/// The system looks up the NPC's voice culture and current tell state, then
|
||||
/// calls `voiced_behavior()` to retrieve a cached voiced line. On cache miss
|
||||
/// the buffer text is unchanged (base text serves as fallback).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn voice_enrich_dialogue_response(
|
||||
voice_cache: Option<Res<VoiceCacheResource>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
zone_map: Option<Res<ZoneMap>>,
|
||||
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
||||
npc_voice_query: Query<(&NpcVoiceProfile, Option<&DerivedTellState>), With<Npc>>,
|
||||
mut dialogue_buffer: Query<&mut DialogueResponseBuffer, With<PlayerCharacter>>,
|
||||
) {
|
||||
let Some(voice_cache) = voice_cache else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(mut buffer) = dialogue_buffer.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(ref mut response) = buffer.response else {
|
||||
return;
|
||||
};
|
||||
|
||||
let zone_id = player_zone_id(&player_query, zone_map.as_deref());
|
||||
|
||||
let speaker_entity = registry.to_entity(&StableId(response.speaker_entity_id));
|
||||
let Some(entity) = speaker_entity else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((voice_profile, tell_state_opt)) = npc_voice_query.get(entity) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let tell_state = tell_state_opt.and_then(|t| t.category);
|
||||
let content_index = content_index_from_line_id(&response.line_id);
|
||||
|
||||
let voiced = voiced_behavior(
|
||||
&voice_cache.cache,
|
||||
zone_id,
|
||||
&voice_profile.culture_id,
|
||||
response.speaker_entity_id,
|
||||
ContentType::Dialogue,
|
||||
content_index,
|
||||
tell_state,
|
||||
&response.text,
|
||||
false,
|
||||
);
|
||||
|
||||
response.text = voiced;
|
||||
}
|
||||
|
||||
/// Enrich the conversation event buffer with voiced lines from the cache.
|
||||
///
|
||||
/// Must run after `run_npc_conversations` and before
|
||||
/// `compute_observer_snapshot`. No-op when the voice cache resource is absent.
|
||||
///
|
||||
/// Each event in the buffer is processed: the pre-occlusion base text is
|
||||
/// not available at this point (occlusion has already been applied), so the
|
||||
/// `occluded_line` is treated as the base text for the voice lookup.
|
||||
/// This means the voice register wraps the already-occluded line.
|
||||
///
|
||||
/// ## Accepted risk: re-voicing of heavily occluded text
|
||||
///
|
||||
/// When many words are dropped by D-078 occlusion, the remaining text may
|
||||
/// be fragmentary ("... the ... came in ..."). Re-voicing such fragments can
|
||||
/// produce incoherent output. This is acceptable for two reasons:
|
||||
/// 1. Cache misses are common for conversation text (no pre-baking pipeline),
|
||||
/// so the base text fallback in `voiced_behavior()` fires most of the time.
|
||||
/// 2. Even incoherent voiced output is no worse than the already-degraded
|
||||
/// overheard line — the occlusion itself has already broken coherence.
|
||||
/// The player's inability to fully parse overheard speech is the mechanic.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn voice_enrich_conversation_events(
|
||||
voice_cache: Option<Res<VoiceCacheResource>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
zone_map: Option<Res<ZoneMap>>,
|
||||
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
||||
npc_voice_query: Query<(&NpcVoiceProfile, Option<&DerivedTellState>), With<Npc>>,
|
||||
mut conversation_buffer: Query<&mut ConversationEventBuffer, With<PlayerCharacter>>,
|
||||
) {
|
||||
let Some(voice_cache) = voice_cache else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(mut buffer) = conversation_buffer.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if buffer.events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let zone_id = player_zone_id(&player_query, zone_map.as_deref());
|
||||
|
||||
for event in &mut buffer.events {
|
||||
let speaker_entity = registry.to_entity(&StableId(event.speaker_id));
|
||||
let Some(entity) = speaker_entity else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok((voice_profile, tell_state_opt)) = npc_voice_query.get(entity) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let tell_state = tell_state_opt.and_then(|t| t.category);
|
||||
// Conversation lines don't have a stable line_id; derive content_index
|
||||
// from the occluded line text. The full cache key is
|
||||
// (culture_id, npc_stable_id, content_type, content_index, tell_state),
|
||||
// so a u16 hash collision requires two different lines from the same
|
||||
// speaker with the same tell state to hash identically — at ~65K
|
||||
// possible values this is extremely rare, and the worst outcome is
|
||||
// a stale voiced line being served instead of base text. Acceptable.
|
||||
let content_index = content_index_from_line_id(&event.occluded_line);
|
||||
|
||||
let voiced = voiced_behavior(
|
||||
&voice_cache.cache,
|
||||
zone_id,
|
||||
&voice_profile.culture_id,
|
||||
event.speaker_id,
|
||||
ContentType::Dialogue,
|
||||
content_index,
|
||||
tell_state,
|
||||
&event.occluded_line,
|
||||
false,
|
||||
);
|
||||
|
||||
event.occluded_line = voiced;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn content_index_is_deterministic() {
|
||||
let a = content_index_from_line_id("dock_d_001");
|
||||
let b = content_index_from_line_id("dock_d_001");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_index_differs_for_different_ids() {
|
||||
let a = content_index_from_line_id("dock_d_001");
|
||||
let b = content_index_from_line_id("dock_d_002");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_index_empty_string() {
|
||||
// Should not panic
|
||||
let _ = content_index_from_line_id("");
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
//! Voice cache lookup for behavior text (D-138, Phase 3 stub).
|
||||
//! Voice cache lookup for behavior text (D-138, Phase 3).
|
||||
//!
|
||||
//! Provides the integration point between the voice cache and any system
|
||||
//! that serves NPC text to the client. Not yet wired into a runtime system —
|
||||
//! `observable_behaviors` on `NpcBlueprint` is generator output only.
|
||||
//!
|
||||
//! Wire `voiced_behavior()` into the behavior-serving path once it exists.
|
||||
//! that serves NPC text to the client. Wired into the observer pipeline
|
||||
//! via `voice::integration` enrichment systems.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::npc::tell_state::TellCategory;
|
||||
use crate::voice::cache::{CacheKey, VoiceCacheStore};
|
||||
use crate::voice::prompt_builder::ContentType;
|
||||
|
||||
/// ECS resource wrapping the voice cache store.
|
||||
///
|
||||
/// Optional — systems that use it gracefully degrade to base text when absent.
|
||||
/// Populated at startup by the voice subsystem when the pipeline is enabled.
|
||||
#[derive(Resource)]
|
||||
pub struct VoiceCacheResource {
|
||||
pub cache: Arc<Mutex<VoiceCacheStore>>,
|
||||
}
|
||||
|
||||
/// Look up a voiced behavior from cache, falling back to base text.
|
||||
///
|
||||
/// Tell behaviors (from `NpcBlueprint.tell_behaviors`) are NEVER re-voiced —
|
||||
@@ -33,6 +41,11 @@ pub fn voiced_behavior(
|
||||
return base_text.to_string();
|
||||
}
|
||||
|
||||
// Factual content is always base text — no LLM, no cache lookup.
|
||||
if content_type == ContentType::Factual {
|
||||
return base_text.to_string();
|
||||
}
|
||||
|
||||
let key = CacheKey {
|
||||
culture_id: culture_id.to_string(),
|
||||
npc_stable_id,
|
||||
@@ -99,6 +112,36 @@ mod tests {
|
||||
assert_eq!(result, "Base line.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factual_content_always_passthrough() {
|
||||
let cache = test_cache();
|
||||
// Store a voiced version under the Factual key — it must never be returned.
|
||||
let key = CacheKey {
|
||||
culture_id: "krenn".into(),
|
||||
npc_stable_id: 42,
|
||||
content_type: ContentType::Factual,
|
||||
content_index: 0,
|
||||
tell_state: None,
|
||||
};
|
||||
cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.store(100, key, "Voiced factual (should be ignored).".into());
|
||||
|
||||
let result = voiced_behavior(
|
||||
&cache,
|
||||
100,
|
||||
"krenn",
|
||||
42,
|
||||
ContentType::Factual,
|
||||
0,
|
||||
None,
|
||||
"14 crates in bay seven.",
|
||||
false,
|
||||
);
|
||||
assert_eq!(result, "14 crates in bay seven.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tell_behavior_always_passthrough() {
|
||||
let cache = test_cache();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
pub mod cache;
|
||||
pub mod hardware;
|
||||
pub mod integration;
|
||||
pub mod lookup;
|
||||
pub mod prompt_builder;
|
||||
pub mod queue;
|
||||
|
||||
@@ -22,6 +22,13 @@ pub enum ContentType {
|
||||
Dialogue,
|
||||
/// Observable behavior description — re-voiced with "Describe".
|
||||
Behavior,
|
||||
/// Fact-bearing line — numbers, causal chains, denials.
|
||||
///
|
||||
/// Factual lines bypass the LLM entirely and are served as base text.
|
||||
/// Spike 2 showed 2B models corrupt quantitative content ("14 crates in
|
||||
/// bay seven" → "fourteen crates are missing") and invert denials.
|
||||
/// The simulation is the truth layer — the LLM only handles register.
|
||||
Factual,
|
||||
}
|
||||
|
||||
/// Result of prompt building, including which injections fired.
|
||||
@@ -57,6 +64,15 @@ OUTPUT CONSTRAINTS:\n\
|
||||
/// "make sentences shorter" instruction causes destructive compression that
|
||||
/// strips facts. Long-Angry instead preserves the full claim and focuses
|
||||
/// intensity on one sentence.
|
||||
///
|
||||
/// ## Iteration history for Friendly and RoutineDeviation
|
||||
///
|
||||
/// Spike 2 showed these two tells produce output indistinguishable from neutral
|
||||
/// on Gemma 2B. Tracked against a 3-iteration budget (#651):
|
||||
///
|
||||
/// - **Iteration 1** (Sprint 26): Replaced abstract instructions with concrete
|
||||
/// surface-pattern examples. Friendly: closing aside pattern. RoutineDeviation:
|
||||
/// self-correction/echo pattern. Both more imitable for a 2B model.
|
||||
fn tell_injector(category: TellCategory) -> &'static str {
|
||||
match category {
|
||||
TellCategory::Nervous => {
|
||||
@@ -70,9 +86,12 @@ fn tell_injector(category: TellCategory) -> &'static str {
|
||||
Do not say they seem angry."
|
||||
}
|
||||
TellCategory::Friendly => {
|
||||
"TONE: Add one small extra detail or aside that wasn't strictly necessary. \
|
||||
Example: \"Inspection's tomorrow — should be fine, though.\" \
|
||||
Do not add compliments or forced warmth."
|
||||
// Iteration 1: replaced abstract "add detail" with a concrete closing-aside
|
||||
// pattern. 2B models need a recognisable syntactic target, not a description
|
||||
// of intent. "actually" / "so that's something" are learnable short tokens.
|
||||
"TONE: End with a brief unprompted aside — a phrase the person didn't need to say. \
|
||||
Example: \"Parts are in, so that's something.\" or \"Shift's been quiet, actually.\" \
|
||||
Do not use praise or warm adjectives. One short beat after the main point."
|
||||
}
|
||||
TellCategory::Guarded => {
|
||||
"TONE: Use formal, precise words. Answer exactly what was asked, nothing extra. \
|
||||
@@ -80,9 +99,12 @@ fn tell_injector(category: TellCategory) -> &'static str {
|
||||
Do not say they seem guarded."
|
||||
}
|
||||
TellCategory::RoutineDeviation => {
|
||||
"TONE: Start the sentence on topic, then add a brief unfinished thought about something else. \
|
||||
Example: \"Pressure's fine. I was going to — anyway, it's logged.\" \
|
||||
Do not explain what they were thinking about."
|
||||
// Iteration 1: replaced the two-step interrupted-thought pattern (too complex
|
||||
// for 2B) with a simpler self-correction / word-echo pattern. The model only
|
||||
// needs to repeat a key word or phrase — one concrete surface action.
|
||||
"TONE: Repeat a key word or phrase, as if catching mid-thought. \
|
||||
Example: \"Logged it. Got it logged, yeah.\" or \"Pressure's — pressure's holding.\" \
|
||||
Do not explain. Just the echo."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,6 +258,30 @@ pub fn build_prompt(
|
||||
// 6. Occasional injections — imperative, positioned near TASK for 2B attention.
|
||||
// The composition engine already controls frequency — once an injection fires,
|
||||
// the model must execute it without discretion.
|
||||
//
|
||||
// ## Multiple INJECT blocks: known ambiguity
|
||||
//
|
||||
// When more than one injection fires on the same prompt, the model sees
|
||||
// multiple blocks with the same `INJECT:` label and no numeric index:
|
||||
//
|
||||
// INJECT: Include the phrase from this example in your output.
|
||||
// INPUT: discovers a critical part is missing
|
||||
// OUTPUT: Void take it. The coupling's not here.
|
||||
//
|
||||
// INJECT: Include the phrase from this example in your output.
|
||||
// INPUT: (second injection)
|
||||
// OUTPUT: (second output)
|
||||
//
|
||||
// 2B models have no mechanism to distinguish them. In practice they tend
|
||||
// to honour the last block (recency bias) and may ignore earlier ones.
|
||||
// The REMEMBER reminder at the end of the prompt references only the first
|
||||
// fired injection, which compounds the asymmetry.
|
||||
//
|
||||
// Accepted limitation: true multi-injection compliance is out of scope for
|
||||
// a 2B model. The low per-injection frequency (typically ≤0.25) means
|
||||
// simultaneous fires are rare. Cultures with multiple injections should
|
||||
// keep the set small and frequencies low enough that co-firing is a
|
||||
// statistical edge case rather than expected behaviour.
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(seed);
|
||||
for (i, injection) in culture.occasional_injections.iter().enumerate() {
|
||||
// Gate off for suppressive tells
|
||||
@@ -269,6 +315,11 @@ pub fn build_prompt(
|
||||
"Describe the following action as a third-person observer. \
|
||||
Preserve all individual actions in sequence. Do not extract conclusions"
|
||||
}
|
||||
ContentType::Factual => {
|
||||
// Factual lines must be intercepted before reaching build_prompt.
|
||||
// If this branch is hit, the caller has a bug.
|
||||
unreachable!("ContentType::Factual must not reach build_prompt — bypass at worker")
|
||||
}
|
||||
};
|
||||
parts.push(String::new());
|
||||
parts.push(format!("TASK: {}.", task_verb));
|
||||
@@ -370,6 +421,7 @@ mod tests {
|
||||
TellCategory::Friendly,
|
||||
],
|
||||
}],
|
||||
behavior_modifiers: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +451,7 @@ mod tests {
|
||||
voice_persona: None,
|
||||
voice_examples: vec![],
|
||||
occasional_injections: vec![],
|
||||
behavior_modifiers: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use crossbeam_channel::Receiver;
|
||||
|
||||
use crate::npc::blueprint::CultureProfile;
|
||||
use crate::voice::cache::{CacheKey, VoiceCacheStore};
|
||||
use crate::voice::prompt_builder;
|
||||
use crate::voice::prompt_builder::{self, ContentType};
|
||||
use crate::voice::queue::VoiceRequest;
|
||||
|
||||
/// Minimum token count for a valid response. Below this, retry once.
|
||||
@@ -311,8 +311,26 @@ fn worker_loop(id: usize, config: VoiceProcessConfig, ctx: WorkerContext) {
|
||||
}
|
||||
|
||||
ctx.active_count.fetch_add(1, Ordering::Relaxed);
|
||||
process_request(id, &mut pipe, &request, &ctx);
|
||||
let pipe_alive = process_request(id, &mut pipe, &request, &ctx);
|
||||
ctx.active_count.fetch_sub(1, Ordering::Relaxed);
|
||||
|
||||
if !pipe_alive {
|
||||
// sr-voice child has exited. Attempt a one-shot respawn so the
|
||||
// worker can continue serving requests. If the respawn fails
|
||||
// (binary missing, OOM, config error) we exit rather than
|
||||
// spinning and flooding the log with identical error lines.
|
||||
tracing::warn!(id, "sr-voice child process died — attempting respawn");
|
||||
match VoicePipe::spawn(&config) {
|
||||
Ok(new_pipe) => {
|
||||
tracing::info!(id, "sr-voice child respawned successfully");
|
||||
pipe = new_pipe;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(id, error = %e, "sr-voice respawn failed — worker exiting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(id, "voice worker stopped");
|
||||
@@ -320,12 +338,23 @@ fn worker_loop(id: usize, config: VoiceProcessConfig, ctx: WorkerContext) {
|
||||
}
|
||||
|
||||
/// Process a single voice request: build prompt → infer → validate → cache.
|
||||
///
|
||||
/// Returns `true` if the pipe is still alive after processing, `false` if
|
||||
/// the sr-voice child process has exited. The caller is responsible for
|
||||
/// respawning or breaking the worker loop on `false`.
|
||||
fn process_request(
|
||||
worker_id: usize,
|
||||
pipe: &mut VoicePipe,
|
||||
request: &VoiceRequest,
|
||||
ctx: &WorkerContext,
|
||||
) {
|
||||
) -> bool {
|
||||
// Factual lines bypass the LLM entirely — serve base text directly.
|
||||
// 2B models corrupt numbers and invert denials (Spike 2 finding, D-138).
|
||||
if request.content_type == ContentType::Factual {
|
||||
cache_base_text(request, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
let culture = match ctx.cultures.get(&request.culture_id) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
@@ -334,7 +363,7 @@ fn process_request(
|
||||
"unknown culture — serving base text"
|
||||
);
|
||||
cache_base_text(request, ctx);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -353,6 +382,7 @@ fn process_request(
|
||||
match result {
|
||||
Ok(text) if text.split_whitespace().count() >= MIN_TOKENS => {
|
||||
cache_result(request, &text, ctx);
|
||||
true
|
||||
}
|
||||
Ok(_short_text) => {
|
||||
// Empty output guard: retry once with different seed
|
||||
@@ -371,6 +401,18 @@ fn process_request(
|
||||
match pipe.generate(&retry_built.prompt, Some(request.seed.wrapping_add(1))) {
|
||||
Ok(text) if text.split_whitespace().count() >= MIN_TOKENS => {
|
||||
cache_result(request, &text, ctx);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
let pipe_dead = pipe.child.try_wait().ok().flatten().is_some();
|
||||
tracing::warn!(
|
||||
worker_id,
|
||||
error = %e,
|
||||
pipe_dead,
|
||||
"sr-voice retry failed — serving base text"
|
||||
);
|
||||
cache_base_text(request, ctx);
|
||||
!pipe_dead
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!(
|
||||
@@ -379,16 +421,23 @@ fn process_request(
|
||||
"retry also short — caching base text"
|
||||
);
|
||||
cache_base_text(request, ctx);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Check whether the child process has exited — if so, signal the
|
||||
// caller to respawn. Without this, every subsequent request fails
|
||||
// with the same error and floods the log indefinitely.
|
||||
let pipe_dead = pipe.child.try_wait().ok().flatten().is_some();
|
||||
tracing::warn!(
|
||||
worker_id,
|
||||
error = %e,
|
||||
pipe_dead,
|
||||
"sr-voice request failed — serving base text"
|
||||
);
|
||||
cache_base_text(request, ctx);
|
||||
!pipe_dead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -63,6 +63,7 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -7,7 +7,7 @@ use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::Schedule;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use settled_reach_server::content::template::{
|
||||
use settled_reach_server::simulation::triangle::{
|
||||
RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState,
|
||||
};
|
||||
use settled_reach_server::knowledge::types::StableId;
|
||||
|
||||
@@ -1,617 +0,0 @@
|
||||
//! Integration test: content loading pipeline.
|
||||
//!
|
||||
//! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities.
|
||||
//! Uses real content files from content/ directory for structural content,
|
||||
//! and a test fixture for isolated NPC profile spawning.
|
||||
//!
|
||||
//! Runtime validation (TCP boot + tick) is in content_runtime.rs.
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use settled_reach_server::content::loader::{load_content, ContentStore};
|
||||
use settled_reach_server::content::spawn::spawn_content;
|
||||
use settled_reach_server::content::types::*;
|
||||
use settled_reach_server::content::{ContentConfig, ContentPlugin, ContentStoreResource};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::npc;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
/// Find the content root relative to the test binary location.
|
||||
fn content_root() -> PathBuf {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
PathBuf::from(manifest_dir).join("../content")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: real content discovery and structural loading
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn discover_real_content_structure() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
// Skip if content directory is not present (e.g. CI without content)
|
||||
eprintln!("Skipping: content directory not found at {:?}", root);
|
||||
return;
|
||||
}
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
|
||||
// Manifest should be present
|
||||
assert!(store.manifest.is_some());
|
||||
let manifest = store.manifest.as_ref().unwrap();
|
||||
assert_eq!(manifest.version, "0.1.0");
|
||||
assert!(!manifest.campaigns.is_empty());
|
||||
|
||||
// At least one district should be discovered
|
||||
assert!(
|
||||
!store.districts.is_empty(),
|
||||
"should discover at least one district"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_real_transit_district() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
|
||||
// The transit district should be discovered
|
||||
let transit = store
|
||||
.districts
|
||||
.get("krenn.sova.transit")
|
||||
.expect("transit district should be discovered");
|
||||
|
||||
// District metadata
|
||||
assert!(transit.meta.is_some());
|
||||
let meta = transit.meta.as_ref().unwrap();
|
||||
assert_eq!(meta.display_name, "Sova Transit District");
|
||||
assert_eq!(meta.npc_count, 17);
|
||||
|
||||
// 5 triangles from ticket #391
|
||||
assert_eq!(transit.triangles.len(), 5);
|
||||
let triangle_ids: Vec<&str> = transit
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| t.canonical_id.as_str())
|
||||
.collect();
|
||||
assert!(triangle_ids.contains(&"hub-power"));
|
||||
assert!(triangle_ids.contains(&"worried-knowledge"));
|
||||
assert!(triangle_ids.contains(&"bar-tensions"));
|
||||
assert!(triangle_ids.contains(&"worried-partner"));
|
||||
assert!(triangle_ids.contains(&"informant-question"));
|
||||
|
||||
// Each triangle should have exactly 3 members
|
||||
for triangle in &transit.triangles {
|
||||
assert_eq!(
|
||||
triangle.members.len(),
|
||||
3,
|
||||
"Triangle {} should have 3 members",
|
||||
triangle.canonical_id
|
||||
);
|
||||
}
|
||||
|
||||
// 5 pools from ticket #389
|
||||
assert_eq!(transit.pools.len(), 5);
|
||||
let pool_ids: Vec<&str> = transit.pools.iter().map(|p| p.pool_id.as_str()).collect();
|
||||
assert!(pool_ids.contains(&"transit:friend_smuggler"));
|
||||
assert!(pool_ids.contains(&"transit:friend_detective"));
|
||||
assert!(pool_ids.contains(&"transit:bar_regulars"));
|
||||
assert!(pool_ids.contains(&"transit:compromised_inspector"));
|
||||
assert!(pool_ids.contains(&"transit:primary_contraband"));
|
||||
|
||||
// 3 templates from ticket #390
|
||||
assert_eq!(transit.templates.len(), 3);
|
||||
let template_ids: Vec<&str> = transit
|
||||
.templates
|
||||
.iter()
|
||||
.map(|t| t.template_id.as_str())
|
||||
.collect();
|
||||
assert!(template_ids.contains(&"logistics-hub"));
|
||||
assert!(template_ids.contains(&"bar"));
|
||||
assert!(template_ids.contains(&"smuggling-ring"));
|
||||
|
||||
// 23 NPC profiles: 17 NPCs + 2 PC-as-NPC + 1 extended NPC (nils-davan) + 3 Sprint 14 additions
|
||||
// Populated by #398 (wiki→YAML NPC conversion) and Sprint 14 content expansion
|
||||
assert_eq!(
|
||||
transit.npc_profiles.len(),
|
||||
23,
|
||||
"Expected 23 parseable NPC profiles"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_triangle_fork_structure() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let transit = store.districts.get("krenn.sova.transit").unwrap();
|
||||
|
||||
// Hub power triangle: should have 1 fork with 3 outcomes
|
||||
let hub_power = transit
|
||||
.triangles
|
||||
.iter()
|
||||
.find(|t| t.canonical_id == "hub-power")
|
||||
.expect("hub-power triangle should exist");
|
||||
|
||||
assert_eq!(hub_power.forks.len(), 1);
|
||||
assert_eq!(hub_power.forks[0].id, "volume-escalation");
|
||||
assert_eq!(hub_power.forks[0].outcomes.len(), 3);
|
||||
|
||||
let outcome_ids: Vec<&str> = hub_power.forks[0]
|
||||
.outcomes
|
||||
.iter()
|
||||
.filter_map(|o| o.id.as_deref())
|
||||
.collect();
|
||||
assert!(outcome_ids.contains(&"escalate"));
|
||||
assert!(outcome_ids.contains(&"stabilize"));
|
||||
assert!(outcome_ids.contains(&"mediate"));
|
||||
|
||||
// Resolution states
|
||||
assert_eq!(hub_power.resolution_states.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_pool_candidates() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let transit = store.districts.get("krenn.sova.transit").unwrap();
|
||||
|
||||
// bar_regulars pool should have 5 candidates
|
||||
let bar_regulars = transit
|
||||
.pools
|
||||
.iter()
|
||||
.find(|p| p.pool_id == "transit:bar_regulars")
|
||||
.expect("bar_regulars pool should exist");
|
||||
|
||||
assert_eq!(bar_regulars.candidates.len(), 5);
|
||||
assert_eq!(bar_regulars.category, "npc_group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_template_role_slots() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let transit = store.districts.get("krenn.sova.transit").unwrap();
|
||||
|
||||
// Logistics hub should have 5 role slots
|
||||
let hub = transit
|
||||
.templates
|
||||
.iter()
|
||||
.find(|t| t.template_id == "logistics-hub")
|
||||
.expect("logistics-hub template should exist");
|
||||
|
||||
assert_eq!(hub.role_slots.len(), 5);
|
||||
|
||||
// Should have v01_assignments
|
||||
assert!(hub.v01_assignments.is_some());
|
||||
let assignments = hub.v01_assignments.as_ref().unwrap();
|
||||
assert!(assignments.contains_key("shift-supervisor"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: NPC spawning pipeline with test fixture data
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn spawn_npc_from_content_store() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||
|
||||
// Create a minimal content store with one test NPC
|
||||
let mut store = ContentStore::default();
|
||||
let mut district = settled_reach_server::content::loader::DistrictContent::default();
|
||||
district.npc_profiles.push(NpcProfile {
|
||||
canonical_id: "test-worker".to_string(),
|
||||
display_name: "Test Worker".to_string(),
|
||||
tier: 2,
|
||||
pattern: Some("ANCHOR".to_string()),
|
||||
motivation: Some("CIVILIAN".to_string()),
|
||||
description: Some("A test dock worker".to_string()),
|
||||
want: Some(NpcWant {
|
||||
primary: "Safety".to_string(),
|
||||
intensity: Some(5),
|
||||
description: Some("Wants a quiet life".to_string()),
|
||||
}),
|
||||
secret: None,
|
||||
relationships: vec![],
|
||||
tolerance: Some(NpcTolerance {
|
||||
threshold: Some(70),
|
||||
description: None,
|
||||
}),
|
||||
routine: None,
|
||||
information: None,
|
||||
contentment: Some(NpcContentment {
|
||||
level: Some(30),
|
||||
description: None,
|
||||
}),
|
||||
personality: None,
|
||||
tells: vec![],
|
||||
skills: Some(NpcSkills {
|
||||
combat_trained: Some(false),
|
||||
skills: Some({
|
||||
let mut m = BTreeMap::new();
|
||||
m.insert("technical".to_string(), 5);
|
||||
m
|
||||
}),
|
||||
}),
|
||||
triangle_membership: vec![],
|
||||
trust_levels: None,
|
||||
friend_arc: None,
|
||||
dual_lens: None,
|
||||
});
|
||||
store
|
||||
.districts
|
||||
.insert("test.district".to_string(), district);
|
||||
|
||||
let result = spawn_content(&mut world, &store);
|
||||
|
||||
// Verify entity was spawned
|
||||
assert_eq!(result.npcs_spawned, 1);
|
||||
assert!(result.npc_ids.contains_key("test-worker"));
|
||||
|
||||
// Verify ECS components
|
||||
let stable_id = result.npc_ids["test-worker"];
|
||||
let entity = world
|
||||
.resource::<EntityRegistry>()
|
||||
.to_entity(&stable_id)
|
||||
.unwrap();
|
||||
|
||||
assert!(world.get::<npc::Npc>(entity).is_some());
|
||||
|
||||
let want = world.get::<npc::Want>(entity).unwrap();
|
||||
assert_eq!(want.primary, npc::WantKind::Safety);
|
||||
assert_eq!(want.intensity, 5);
|
||||
|
||||
let tolerance = world.get::<npc::ToleranceThreshold>(entity).unwrap();
|
||||
assert_eq!(tolerance.threshold, 70);
|
||||
|
||||
let skills = world.get::<npc::SkillSet>(entity).unwrap();
|
||||
assert_eq!(skills.skills[&npc::Skill::Technical], 5);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: ContentPlugin integration with bevy App
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn content_plugin_loads_via_app() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
app.insert_resource(ContentConfig {
|
||||
content_root: root,
|
||||
..Default::default()
|
||||
});
|
||||
app.add_plugins(ContentPlugin);
|
||||
|
||||
// Run startup systems
|
||||
app.update();
|
||||
|
||||
// ContentStoreResource should be inserted
|
||||
assert!(
|
||||
app.world().contains_resource::<ContentStoreResource>(),
|
||||
"ContentStoreResource should be present after startup"
|
||||
);
|
||||
|
||||
let store = &app.world().resource::<ContentStoreResource>().0;
|
||||
assert!(!store.districts.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Full spawn pipeline with real content — 10-axis gap closure
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn spawn_real_content_with_relationships_and_secrets() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let result = spawn_content(&mut world, &store);
|
||||
|
||||
// All 23 profiles should spawn
|
||||
assert_eq!(result.npcs_spawned, 23);
|
||||
assert!(result.npc_ids.contains_key("npc:kael-davan"));
|
||||
assert!(result.npc_ids.contains_key("npc:voss"));
|
||||
assert!(result.npc_ids.contains_key("npc:pc-smuggler"));
|
||||
assert!(result.npc_ids.contains_key("npc:nils-davan"));
|
||||
|
||||
// Verify ALL 20 NPCs have Want components (Option C: exact enum keywords in YAML)
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
let mut npcs_with_want = 0;
|
||||
for (canonical_id, stable_id) in &result.npc_ids {
|
||||
let entity = registry
|
||||
.to_entity(stable_id)
|
||||
.unwrap_or_else(|| panic!("{} should have an entity", canonical_id));
|
||||
assert!(
|
||||
world.get::<npc::Want>(entity).is_some(),
|
||||
"NPC {} should have a Want component",
|
||||
canonical_id
|
||||
);
|
||||
npcs_with_want += 1;
|
||||
}
|
||||
assert_eq!(
|
||||
npcs_with_want, 23,
|
||||
"All 23 NPCs should have Want components"
|
||||
);
|
||||
|
||||
// Spot-check specific Want values
|
||||
let kael_entity = registry
|
||||
.to_entity(&result.npc_ids["npc:kael-davan"])
|
||||
.unwrap();
|
||||
let kael_want = world
|
||||
.get::<npc::Want>(kael_entity)
|
||||
.expect("Kael should have Want");
|
||||
assert_eq!(kael_want.primary, npc::WantKind::Safety);
|
||||
|
||||
// Verify Kael has a Secret component
|
||||
let kael_secret = world
|
||||
.get::<npc::Secret>(kael_entity)
|
||||
.expect("Kael should have Secret");
|
||||
assert!(kael_secret.description.contains("ring"));
|
||||
assert_eq!(kael_secret.severity, npc::SecretSeverity::Major);
|
||||
|
||||
// Verify Kael has Relationships (7 defined in YAML)
|
||||
let kael_rels = world
|
||||
.get::<npc::Relationships>(kael_entity)
|
||||
.expect("Kael should have Relationships");
|
||||
assert!(
|
||||
kael_rels.entries.len() >= 5,
|
||||
"Kael should have at least 5 resolved relationships, got {}",
|
||||
kael_rels.entries.len()
|
||||
);
|
||||
|
||||
// Verify Kael has KnowledgeGraph (background facts from information.knows)
|
||||
let kael_kg = world
|
||||
.get::<settled_reach_server::knowledge::graph::KnowledgeGraph>(kael_entity)
|
||||
.expect("Kael should have KnowledgeGraph");
|
||||
assert!(
|
||||
kael_kg.knows_fact(&settled_reach_server::knowledge::types::FactId(
|
||||
"contraband.ring_exists".to_string()
|
||||
))
|
||||
);
|
||||
|
||||
// Verify global RelationshipGraph was populated
|
||||
let graph = world.resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
||||
assert!(
|
||||
graph.edge_count() >= 20,
|
||||
"Expected at least 20 relationship edges, got {}",
|
||||
graph.edge_count()
|
||||
);
|
||||
|
||||
// Verify Nils (off-stage) also has correct data
|
||||
let nils_entity = world
|
||||
.resource::<EntityRegistry>()
|
||||
.to_entity(&result.npc_ids["npc:nils-davan"])
|
||||
.unwrap();
|
||||
let nils_want = world
|
||||
.get::<npc::Want>(nils_entity)
|
||||
.expect("Nils should have Want");
|
||||
assert_eq!(nils_want.primary, npc::WantKind::Power);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: EntanglementTag assignment from authored content (#176, D-029)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn entanglement_tags_assigned_from_triangle_membership() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let result = spawn_content(&mut world, &store);
|
||||
|
||||
// Acceptance: 17+ NPCs spawn (we have 23 authored profiles)
|
||||
assert!(
|
||||
result.npcs_spawned >= 17,
|
||||
"Expected 17+ NPCs, got {}",
|
||||
result.npcs_spawned
|
||||
);
|
||||
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
|
||||
// Every NPC must have an EntanglementTag
|
||||
let mut intrigue_count = 0u32;
|
||||
let mut flat_count = 0u32;
|
||||
for (canonical_id, stable_id) in &result.npc_ids {
|
||||
let entity = registry
|
||||
.to_entity(stable_id)
|
||||
.unwrap_or_else(|| panic!("{} should have an entity", canonical_id));
|
||||
let tag = world
|
||||
.get::<npc::EntanglementTag>(entity)
|
||||
.unwrap_or_else(|| panic!("{} must have EntanglementTag", canonical_id));
|
||||
match tag {
|
||||
npc::EntanglementTag::Intrigue => intrigue_count += 1,
|
||||
npc::EntanglementTag::Flat => flat_count += 1,
|
||||
npc::EntanglementTag::Mundane => {} // reserved for procedural NPCs
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance: at least one EntanglementTag::Intrigue entity
|
||||
assert!(
|
||||
intrigue_count >= 1,
|
||||
"At least one NPC must be EntanglementTag::Intrigue, got 0"
|
||||
);
|
||||
|
||||
// Stronger assertion: we know 13 authored NPCs have non-empty triangle_membership
|
||||
assert!(
|
||||
intrigue_count >= 10,
|
||||
"Expected 10+ Intrigue NPCs (authored triangle members), got {}",
|
||||
intrigue_count
|
||||
);
|
||||
|
||||
// Some NPCs should be Flat (no triangle membership)
|
||||
assert!(
|
||||
flat_count >= 1,
|
||||
"At least one NPC should be EntanglementTag::Flat, got 0"
|
||||
);
|
||||
|
||||
// Spot-check: Kael (triangle member) must be Intrigue
|
||||
let kael_entity = registry
|
||||
.to_entity(&result.npc_ids["npc:kael-davan"])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
*world.get::<npc::EntanglementTag>(kael_entity).unwrap(),
|
||||
npc::EntanglementTag::Intrigue,
|
||||
"Kael (triangle member) must be Intrigue"
|
||||
);
|
||||
|
||||
// Spot-check: Devra (empty triangle_membership) must be Flat
|
||||
let devra_entity = registry
|
||||
.to_entity(&result.npc_ids["npc:devra"])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
*world.get::<npc::EntanglementTag>(devra_entity).unwrap(),
|
||||
npc::EntanglementTag::Flat,
|
||||
"Devra (no triangle membership) must be Flat"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Authored triangle instantiation (#188, D-087)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn authored_triangles_instantiated_from_content() {
|
||||
use settled_reach_server::content::template::{TriangleClassification, TriangleState};
|
||||
use settled_reach_server::simulation::tier::ActiveSim;
|
||||
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
let result = spawn_content(&mut world, &store);
|
||||
|
||||
// Query all TriangleState entities (clone to release world borrow)
|
||||
let triangles: Vec<TriangleState> = {
|
||||
let mut q = world.query::<&TriangleState>();
|
||||
q.iter(&world).cloned().collect()
|
||||
};
|
||||
|
||||
// Acceptance: exactly 5 authored triangles
|
||||
assert_eq!(
|
||||
triangles.len(),
|
||||
5,
|
||||
"Expected 5 authored triangles, got {}",
|
||||
triangles.len()
|
||||
);
|
||||
|
||||
// Count by classification (D-087)
|
||||
let active_count = triangles
|
||||
.iter()
|
||||
.filter(|t| t.classification == TriangleClassification::ActiveFork)
|
||||
.count();
|
||||
let passive_count = triangles
|
||||
.iter()
|
||||
.filter(|t| t.classification == TriangleClassification::PassiveTension)
|
||||
.count();
|
||||
|
||||
assert_eq!(
|
||||
active_count, 3,
|
||||
"Expected 3 ActiveFork triangles, got {}",
|
||||
active_count
|
||||
);
|
||||
assert_eq!(
|
||||
passive_count, 2,
|
||||
"Expected 2 PassiveTension triangles, got {}",
|
||||
passive_count
|
||||
);
|
||||
|
||||
// All 5 must have exactly 3 role assignments (triangle = 3 NPCs)
|
||||
for triangle in &triangles {
|
||||
assert_eq!(
|
||||
triangle.role_assignments.len(),
|
||||
3,
|
||||
"Triangle {:?} should have 3 role assignments, got {}",
|
||||
triangle.triangle_id,
|
||||
triangle.role_assignments.len()
|
||||
);
|
||||
}
|
||||
|
||||
// All role assignments must point to valid NPC entities in the registry
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
for triangle in &triangles {
|
||||
for (role, stable_id) in &triangle.role_assignments {
|
||||
assert!(
|
||||
registry.to_entity(stable_id).is_some(),
|
||||
"Triangle {:?} role '{}' points to StableId {:?} with no entity",
|
||||
triangle.triangle_id,
|
||||
role.0,
|
||||
stable_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// All triangle entities must have ActiveSim marker
|
||||
let mut active_query = world.query::<(&TriangleState, &ActiveSim)>();
|
||||
let active_triangles: Vec<_> = active_query.iter(&world).collect();
|
||||
assert_eq!(
|
||||
active_triangles.len(),
|
||||
5,
|
||||
"All 5 triangles must have ActiveSim marker"
|
||||
);
|
||||
|
||||
// Verify StableIds in role assignments correspond to spawned NPC canonical_ids
|
||||
let all_npc_stable_ids: std::collections::BTreeSet<_> =
|
||||
result.npc_ids.values().copied().collect();
|
||||
for triangle in &triangles {
|
||||
for (role, stable_id) in &triangle.role_assignments {
|
||||
assert!(
|
||||
all_npc_stable_ids.contains(stable_id),
|
||||
"Triangle {:?} role '{}' StableId {:?} not in spawned NPC set",
|
||||
triangle.triangle_id,
|
||||
role.0,
|
||||
stable_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime validation test (boot + tick 10 + snapshot) moved to
|
||||
// server/tests/content_runtime.rs per architectural review.
|
||||
@@ -1,162 +0,0 @@
|
||||
//! Runtime validation: boot full plugin stack with real content, tick 10
|
||||
//! times over TCP, assert valid ObserverSnapshot (#489).
|
||||
//!
|
||||
//! Separated from content_loading.rs (structural loading tests) per
|
||||
//! architectural review — TCP runtime tests have different failure modes
|
||||
//! and timeout characteristics.
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
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};
|
||||
use settled_reach_server::content::{ContentConfig, ContentPlugin};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
|
||||
use settled_reach_server::simulation::listening::ListeningFocus;
|
||||
use settled_reach_server::simulation::monologue::{
|
||||
MonologueBuffer, MonologueState, SprintAnomalyQueue,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
fn content_root() -> PathBuf {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
PathBuf::from(manifest_dir).join("../content")
|
||||
}
|
||||
|
||||
/// Smoke test for production content. Boots the full plugin stack with real
|
||||
/// content over TCP, ticks 10 times, and asserts a valid ObserverSnapshot.
|
||||
/// Catches runtime panics from broken entity references, missing components,
|
||||
/// or content schema issues that pass YAML validation but fail at tick time.
|
||||
#[test]
|
||||
fn content_runtime_boot_tick_10_snapshot() {
|
||||
use std::io::{BufReader, BufWriter};
|
||||
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
eprintln!("Skipping: content directory not found at {:?}", root);
|
||||
return;
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let server_addr = listener.local_addr().expect("get local addr");
|
||||
|
||||
// Barrier keeps the server thread alive until the client has finished
|
||||
// reading all snapshots, preventing a TCP RST race under parallel execution.
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let server_barrier = barrier.clone();
|
||||
|
||||
// Server thread: full plugin stack with real content
|
||||
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(settled_reach_server::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
app.insert_resource(ContentConfig {
|
||||
content_root: root,
|
||||
..Default::default()
|
||||
});
|
||||
app.add_plugins(ContentPlugin);
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
|
||||
// Spawn player with all required observer pipeline components
|
||||
let profile = MovementProfile::smuggler();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
CognitiveDelay::default(),
|
||||
ListeningFocus::new(TilePosition::new(16, 16, 0)),
|
||||
profile,
|
||||
profile.initial_stance(),
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
app.insert_resource(registry);
|
||||
|
||||
// Tick 10 times — any panic here means content has a runtime bug
|
||||
for _ in 0..10 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
// Wait for client to finish reading before dropping the TCP socket
|
||||
server_barrier.wait();
|
||||
});
|
||||
|
||||
// Client: connect with read timeout and receive 10 snapshots
|
||||
let stream = TcpStream::connect(server_addr).expect("client connect");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.expect("set read timeout");
|
||||
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
|
||||
let mut writer = BufWriter::new(stream);
|
||||
|
||||
let mut last_snapshot = None;
|
||||
for tick in 0..10 {
|
||||
let payload = read_framed(&mut reader)
|
||||
.unwrap_or_else(|e| panic!("read error at tick {}: {}", tick, e))
|
||||
.unwrap_or_else(|| panic!("unexpected EOF at tick {}", tick));
|
||||
|
||||
let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload)
|
||||
.unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e));
|
||||
|
||||
last_snapshot = Some(snapshot);
|
||||
|
||||
// Send empty input for next tick
|
||||
let empty: Vec<PlayerInput> = vec![];
|
||||
let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input");
|
||||
if write_framed(&mut writer, &input_payload).is_err() {
|
||||
// Server may have shut down after tick 10 — that's fine
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
drop(reader);
|
||||
drop(writer);
|
||||
|
||||
// Signal server thread that client is done reading
|
||||
barrier.wait();
|
||||
|
||||
// Server thread must not have panicked
|
||||
server_handle.join().expect(
|
||||
"server thread panicked — content triggered a runtime error during tick processing",
|
||||
);
|
||||
|
||||
// Validate final snapshot
|
||||
let snapshot = last_snapshot.expect("should have received at least one snapshot");
|
||||
assert_eq!(
|
||||
snapshot.version, PROTOCOL_VERSION,
|
||||
"snapshot protocol version mismatch"
|
||||
);
|
||||
// Content-spawned NPCs should be visible (they all spawn at 0,0,0 by default)
|
||||
// The player is at 16,16 — content NPCs are far away but the player entity itself
|
||||
// should always be in the snapshot
|
||||
assert!(
|
||||
!snapshot.entities.is_empty(),
|
||||
"snapshot should contain at least the player entity"
|
||||
);
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
//! Content scaling test (#513, D-026).
|
||||
//!
|
||||
//! Verifies that adding extra NPCs doesn't degrade tick timing beyond
|
||||
//! acceptable bounds. Runs the Gauntlet baseline, then adds additional
|
||||
//! NPCs and compares:
|
||||
//! 1. Tick timing stays within D-026 budget (100ms)
|
||||
//! 2. Baseline entities still behave identically (deterministic)
|
||||
//!
|
||||
//! Sprint 11 adds two new tests (#513 deliverable):
|
||||
//! - max_npc_pack_tick_budget: 80 NPCs (D-026 Active tier ceiling), 100 ticks,
|
||||
//! per-tick budget assertion (every tick < 100ms, not just average).
|
||||
//! - max_npc_pack_behavioral_regression: verifies that adding 46 extra NPCs to
|
||||
//! hit the Active tier ceiling doesn't change original entity behavior at tick 100.
|
||||
//!
|
||||
//! Run with: cargo test --test content_scaling -- --nocapture
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::BridgePlugin;
|
||||
use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use settled_reach_server::knowledge::{
|
||||
KnowledgeConfidence, KnowledgeGraph, KnowledgePlugin, StableId,
|
||||
};
|
||||
use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind};
|
||||
use settled_reach_server::simulation::interaction::Interactable;
|
||||
use settled_reach_server::simulation::movement::TilePosition;
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
/// Number of ticks to run for timing measurements.
|
||||
const TIMING_TICKS: usize = 50;
|
||||
|
||||
/// D-026 budget: 100ms per tick maximum.
|
||||
const MAX_TICK_MS: f64 = 100.0;
|
||||
|
||||
/// Extra NPC counts for scaling tiers.
|
||||
const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50];
|
||||
|
||||
/// D-026 Active tier ceiling: maximum NPCs in full simulation.
|
||||
const ACTIVE_TIER_NPC_CEILING: usize = 80;
|
||||
|
||||
/// Ticks for the full stress test (#513 spec: 100 ticks, 80 NPCs).
|
||||
const STRESS_TICKS: usize = 100;
|
||||
|
||||
/// Known NPC count in the full Gauntlet world (all rooms, Sprint 11 included).
|
||||
/// Fog Theater: 4, Occlusion Corridor: 4, Inventory Warehouse: 1, Pause Chamber: 1,
|
||||
/// Dialogue Room: 4, Crowd Plaza: 15, Sprint Gauntlet: 1, Eavesdrop Alcove: 2,
|
||||
/// Confrontation Stage: 2 = 34 total.
|
||||
///
|
||||
/// Manually maintained — update when rooms are added/changed. Future: derive
|
||||
/// from StableId ranges in constants.rs to avoid manual sync.
|
||||
const GAUNTLET_NPC_COUNT: usize = 34;
|
||||
|
||||
/// Extra NPCs to spawn on top of the Gauntlet baseline to reach Active tier ceiling.
|
||||
const STRESS_EXTRA_NPCS: usize = ACTIVE_TIER_NPC_CEILING - GAUNTLET_NPC_COUNT;
|
||||
|
||||
/// Set up a Gauntlet world and return the app.
|
||||
fn setup_baseline() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(KnowledgePlugin);
|
||||
app.add_plugins(NpcPlugin);
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
settled_reach_server::test_world::setup_gauntlet(&mut app, settled_reach_server::bridge::types::CharacterArchetype::default());
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Spawn N extra NPCs spread across the Gauntlet hub area.
|
||||
/// NPCs are placed in a grid starting at (40, 48) to stay within walkable space.
|
||||
fn spawn_extra_npcs(app: &mut App, count: usize) {
|
||||
// Remove registry from world so we can mutate it while also spawning entities.
|
||||
let mut registry = app
|
||||
.world_mut()
|
||||
.remove_resource::<EntityRegistry>()
|
||||
.expect("EntityRegistry should exist after setup_gauntlet");
|
||||
let cols = 10;
|
||||
|
||||
for i in 0..count {
|
||||
let x = 40 + (i % cols) as i32;
|
||||
let y = 48 + (i / cols) as i32;
|
||||
let pos = TilePosition::new(x, y, 0);
|
||||
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 5,
|
||||
description: format!("extra_npc_{}", i),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 50,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
app.insert_resource(registry);
|
||||
}
|
||||
|
||||
/// Tick the app N times and return average milliseconds per tick.
|
||||
fn measure_tick_timing(app: &mut App, ticks: usize) -> f64 {
|
||||
// Warm-up tick (first tick has startup overhead)
|
||||
app.update();
|
||||
|
||||
let start = Instant::now();
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
elapsed.as_secs_f64() * 1000.0 / ticks as f64
|
||||
}
|
||||
|
||||
/// Collect snapshot entity IDs from the VisibilityGeometry and entity count.
|
||||
fn count_entities(app: &App) -> usize {
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
registry.len() as usize
|
||||
}
|
||||
|
||||
/// Collect the player's KnowledgeGraph confidence levels for all Gauntlet entities
|
||||
/// (StableIds 0..=max_id). Used to detect KG-level behavioral regression.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn player_kg_snapshot(app: &App, max_id: u64) -> BTreeMap<u64, KnowledgeConfidence> {
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
let player_entity = registry
|
||||
.to_entity(&StableId(0))
|
||||
.expect("player entity at StableId 0");
|
||||
match app.world().get::<KnowledgeGraph>(player_entity) {
|
||||
Some(kg) => kg
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|(id, _)| id.0 <= max_id)
|
||||
.map(|(id, entry)| (id.0, entry.confidence))
|
||||
.collect(),
|
||||
None => BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn baseline_tick_timing_within_budget() {
|
||||
let mut app = setup_baseline();
|
||||
let entity_count = count_entities(&app);
|
||||
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
|
||||
|
||||
eprintln!(
|
||||
"Baseline: {} entities, avg {:.3}ms/tick over {} ticks",
|
||||
entity_count, avg_ms, TIMING_TICKS
|
||||
);
|
||||
|
||||
assert!(
|
||||
avg_ms < MAX_TICK_MS,
|
||||
"Baseline tick timing ({:.3}ms) exceeds D-026 budget ({}ms)",
|
||||
avg_ms,
|
||||
MAX_TICK_MS
|
||||
);
|
||||
}
|
||||
|
||||
/// Scaling test: adding NPCs keeps tick timing within D-026 budget.
|
||||
/// Tests 0 (baseline), 15, and 50 extra NPCs.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn scaling_tick_timing_within_budget() {
|
||||
let mut results: Vec<(usize, usize, f64)> = Vec::new();
|
||||
|
||||
for &extra_count in EXTRA_NPC_COUNTS {
|
||||
let mut app = setup_baseline();
|
||||
if extra_count > 0 {
|
||||
spawn_extra_npcs(&mut app, extra_count);
|
||||
}
|
||||
let total_entities = count_entities(&app);
|
||||
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
|
||||
results.push((extra_count, total_entities, avg_ms));
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"\n=== Content Scaling Results (D-026: {}ms budget) ===",
|
||||
MAX_TICK_MS
|
||||
);
|
||||
eprintln!("{:<12} {:<10} {:<15}", "Extra NPCs", "Total", "Avg ms/tick");
|
||||
eprintln!("{:-<37}", "");
|
||||
for &(extra, total, avg_ms) in &results {
|
||||
let status = if avg_ms < MAX_TICK_MS { "OK" } else { "OVER" };
|
||||
eprintln!("{:<12} {:<10} {:<15.3} {}", extra, total, avg_ms, status);
|
||||
}
|
||||
|
||||
// Assert all tiers stay within budget
|
||||
for &(extra, _total, avg_ms) in &results {
|
||||
assert!(
|
||||
avg_ms < MAX_TICK_MS,
|
||||
"Tick timing with +{} NPCs ({:.3}ms) exceeds D-026 budget ({}ms)",
|
||||
extra,
|
||||
avg_ms,
|
||||
MAX_TICK_MS
|
||||
);
|
||||
}
|
||||
|
||||
// Assert scaling is reasonable: +50 NPCs shouldn't more than 5x the baseline
|
||||
if results.len() >= 2 {
|
||||
let baseline_ms = results[0].2;
|
||||
let max_extra_ms = results.last().unwrap().2;
|
||||
let scaling_factor = max_extra_ms / baseline_ms;
|
||||
eprintln!(
|
||||
"\nScaling factor (baseline → +{} NPCs): {:.2}x",
|
||||
results.last().unwrap().0,
|
||||
scaling_factor
|
||||
);
|
||||
assert!(
|
||||
scaling_factor < 5.0,
|
||||
"Scaling factor {:.2}x exceeds 5x threshold — possible O(n^2) regression",
|
||||
scaling_factor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Determinism test: baseline entities produce identical snapshots regardless
|
||||
/// of extra NPCs being present. The original Gauntlet entities (StableId 0
|
||||
/// through RESET_PLATE_STABLE_IDS.1) should have the same positions and
|
||||
/// visibility after the same number of ticks.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn extra_npcs_dont_affect_baseline_behavior() {
|
||||
// Run baseline
|
||||
let mut baseline_app = setup_baseline();
|
||||
for _ in 0..10 {
|
||||
baseline_app.update();
|
||||
}
|
||||
let baseline_buffer = baseline_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
|
||||
// Run with extra NPCs
|
||||
let mut scaled_app = setup_baseline();
|
||||
spawn_extra_npcs(&mut scaled_app, 15);
|
||||
for _ in 0..10 {
|
||||
scaled_app.update();
|
||||
}
|
||||
let scaled_buffer = scaled_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
|
||||
let baseline_snap = baseline_buffer.expect("baseline should produce a snapshot");
|
||||
let scaled_snap = scaled_buffer.expect("scaled should produce a snapshot");
|
||||
|
||||
// Same tick
|
||||
assert_eq!(
|
||||
baseline_snap.tick, scaled_snap.tick,
|
||||
"tick count should match"
|
||||
);
|
||||
|
||||
// Same game time
|
||||
assert_eq!(
|
||||
baseline_snap.game_time.time_of_day, scaled_snap.game_time.time_of_day,
|
||||
"game time should match"
|
||||
);
|
||||
|
||||
// Player position should be identical
|
||||
let baseline_player = baseline_snap
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| e.kind == EntityKind::Player);
|
||||
let scaled_player = scaled_snap
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| e.kind == EntityKind::Player);
|
||||
assert!(baseline_player.is_some(), "baseline should have player");
|
||||
assert!(scaled_player.is_some(), "scaled should have player");
|
||||
|
||||
let bp = baseline_player.unwrap();
|
||||
let sp = scaled_player.unwrap();
|
||||
assert_eq!(bp.x, sp.x, "player x should match");
|
||||
assert_eq!(bp.y, sp.y, "player y should match");
|
||||
|
||||
// Original entities (entity_id <= max Gauntlet StableId) visible in baseline
|
||||
// should still be visible in scaled run. Extra NPCs may add to the visible
|
||||
// set, but shouldn't remove baseline visibility.
|
||||
let max_baseline_id = settled_reach_server::test_world::constants::RESET_PLATE_STABLE_IDS.1;
|
||||
let baseline_original_ids: Vec<u64> = baseline_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_baseline_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
let scaled_original_ids: Vec<u64> = scaled_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_baseline_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
baseline_original_ids, scaled_original_ids,
|
||||
"Original Gauntlet entities (id <= max_baseline_id) should be identical in both runs"
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sprint 11 / #513 — Max-NPC Pack Stress Tests
|
||||
// =============================================================================
|
||||
|
||||
/// Stress test: Active tier ceiling (80 NPCs), 100 ticks, per-tick budget check.
|
||||
///
|
||||
/// Spawns the full Gauntlet baseline ({GAUNTLET_NPC_COUNT} NPCs) plus
|
||||
/// {STRESS_EXTRA_NPCS} extra NPCs to reach the D-026 Active tier ceiling (80).
|
||||
/// Runs {STRESS_TICKS} ticks and asserts that EVERY individual tick (not just
|
||||
/// the average) completes within the 100ms D-026 budget.
|
||||
///
|
||||
/// Outputs a PERF_RESULT JSON line compatible with the perf-baseline tooling
|
||||
/// (same format as tests/perf/baseline.json) so CI can compare against the
|
||||
/// stored baseline.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn max_npc_pack_tick_budget() {
|
||||
let mut app = setup_baseline();
|
||||
spawn_extra_npcs(&mut app, STRESS_EXTRA_NPCS);
|
||||
let total_entities = count_entities(&app);
|
||||
|
||||
// Warm-up: first tick has bevy startup overhead.
|
||||
app.update();
|
||||
|
||||
// Measure STRESS_TICKS, recording each tick individually.
|
||||
let mut per_tick_us: Vec<u64> = Vec::with_capacity(STRESS_TICKS);
|
||||
for _ in 0..STRESS_TICKS {
|
||||
let start = Instant::now();
|
||||
app.update();
|
||||
per_tick_us.push(start.elapsed().as_micros() as u64);
|
||||
}
|
||||
|
||||
// --- Statistics ---
|
||||
let min_us = *per_tick_us.iter().min().unwrap();
|
||||
let max_us = *per_tick_us.iter().max().unwrap();
|
||||
let sum: u64 = per_tick_us.iter().sum();
|
||||
let mean_us = sum / per_tick_us.len() as u64;
|
||||
let mut sorted = per_tick_us.clone();
|
||||
sorted.sort_unstable();
|
||||
let p95_idx = ((sorted.len() - 1) as f64 * 0.95).floor() as usize;
|
||||
let p95_us = sorted[p95_idx.min(sorted.len() - 1)];
|
||||
|
||||
eprintln!(
|
||||
"\n=== Max-NPC Pack Stress Test — D-026 tick budget ({} NPCs, {} ticks) ===",
|
||||
ACTIVE_TIER_NPC_CEILING, STRESS_TICKS
|
||||
);
|
||||
eprintln!(
|
||||
"Entities in world: {} (Gauntlet NPCs: {} extra: {})",
|
||||
total_entities, GAUNTLET_NPC_COUNT, STRESS_EXTRA_NPCS
|
||||
);
|
||||
eprintln!(
|
||||
"Timing: min={:.3}ms mean={:.3}ms p95={:.3}ms max={:.3}ms budget={}ms",
|
||||
min_us as f64 / 1000.0,
|
||||
mean_us as f64 / 1000.0,
|
||||
p95_us as f64 / 1000.0,
|
||||
max_us as f64 / 1000.0,
|
||||
MAX_TICK_MS
|
||||
);
|
||||
|
||||
// Emit PERF_RESULT in the same format as tooling/perf-baseline so output
|
||||
// can be diffed against tests/perf/baseline.json by CI tooling.
|
||||
println!(
|
||||
"PERF_RESULT:{}",
|
||||
serde_json::json!({
|
||||
"test": "max_npc_pack_tick_budget",
|
||||
"spec": "D-026",
|
||||
"tick_timing": {
|
||||
"warmup_ticks": 1,
|
||||
"measured_ticks": STRESS_TICKS,
|
||||
"min_us": min_us,
|
||||
"max_us": max_us,
|
||||
"mean_us": mean_us,
|
||||
"p95_us": p95_us,
|
||||
},
|
||||
"entities": {
|
||||
"total_in_world": total_entities,
|
||||
"active_tier_npcs": ACTIVE_TIER_NPC_CEILING,
|
||||
"gauntlet_npcs": GAUNTLET_NPC_COUNT,
|
||||
"extra_npcs": STRESS_EXTRA_NPCS,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Core assertion: EVERY tick must be within the D-026 100ms budget.
|
||||
// Average-only checks can mask spikes — verify each individual tick.
|
||||
let budget_us = (MAX_TICK_MS * 1000.0) as u64;
|
||||
let over_budget: Vec<(usize, u64)> = per_tick_us
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &us)| us > budget_us)
|
||||
.map(|(i, &us)| (i, us))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
over_budget.is_empty(),
|
||||
"D-026 tick budget exceeded with {} NPCs: {} of {} ticks over {}ms\n worst: tick {} at {:.3}ms",
|
||||
ACTIVE_TIER_NPC_CEILING,
|
||||
over_budget.len(),
|
||||
STRESS_TICKS,
|
||||
MAX_TICK_MS,
|
||||
over_budget[0].0,
|
||||
over_budget[0].1 as f64 / 1000.0
|
||||
);
|
||||
}
|
||||
|
||||
/// Behavioral regression: 80 NPCs must not disturb original entity state at tick 100.
|
||||
///
|
||||
/// Runs the pure Gauntlet (GAUNTLET_NPC_COUNT NPCs) and the full 80-NPC stress
|
||||
/// pack for STRESS_TICKS ticks. Asserts:
|
||||
/// 1. Snapshot entity IDs for all Gauntlet entities (StableId 0..=65) are identical.
|
||||
/// 2. Player's KnowledgeGraph confidence entries for Gauntlet entity range are identical.
|
||||
///
|
||||
/// This validates D-010 determinism: extra Active-tier NPCs must not affect the
|
||||
/// simulation of original entities via LOS, KG, or ECS phase ordering.
|
||||
/// Spec: #513, D-026, D-010.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn max_npc_pack_behavioral_regression() {
|
||||
use settled_reach_server::test_world::constants::SPRINT11_RESET_PLATE_STABLE_IDS;
|
||||
|
||||
// The highest StableId belonging to a Gauntlet entity (Sprint 11 reset plates).
|
||||
let max_gauntlet_id = SPRINT11_RESET_PLATE_STABLE_IDS.1;
|
||||
|
||||
// --- Baseline run: pure Gauntlet, no extra NPCs ---
|
||||
let mut baseline_app = setup_baseline();
|
||||
for _ in 0..STRESS_TICKS {
|
||||
baseline_app.update();
|
||||
}
|
||||
let baseline_snapshot = baseline_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
let baseline_kg = player_kg_snapshot(&baseline_app, max_gauntlet_id);
|
||||
|
||||
// --- Stress run: Gauntlet + extra NPCs to reach 80 NPC Active tier ceiling ---
|
||||
let mut stress_app = setup_baseline();
|
||||
spawn_extra_npcs(&mut stress_app, STRESS_EXTRA_NPCS);
|
||||
for _ in 0..STRESS_TICKS {
|
||||
stress_app.update();
|
||||
}
|
||||
let stress_snapshot = stress_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
let stress_kg = player_kg_snapshot(&stress_app, max_gauntlet_id);
|
||||
|
||||
let baseline_snap = baseline_snapshot.expect("baseline Gauntlet should produce a snapshot");
|
||||
let stress_snap = stress_snapshot.expect("80-NPC stress run should produce a snapshot");
|
||||
|
||||
// Tick index must match (same number of updates).
|
||||
assert_eq!(
|
||||
baseline_snap.tick, stress_snap.tick,
|
||||
"tick count should match between baseline and stress run"
|
||||
);
|
||||
|
||||
// --- 1. Snapshot entity comparison ---
|
||||
// Collect and sort entity IDs for original Gauntlet entities only.
|
||||
// Extra NPCs (StableId > max_gauntlet_id) are excluded from comparison.
|
||||
let mut baseline_ids: Vec<u64> = baseline_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_gauntlet_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
let mut stress_ids: Vec<u64> = stress_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_gauntlet_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
baseline_ids.sort_unstable();
|
||||
stress_ids.sort_unstable();
|
||||
|
||||
assert_eq!(
|
||||
baseline_ids, stress_ids,
|
||||
"Gauntlet entity visibility at tick {} must be identical: baseline {} entities vs {} with {} extra NPCs",
|
||||
STRESS_TICKS,
|
||||
baseline_ids.len(),
|
||||
stress_ids.len(),
|
||||
STRESS_EXTRA_NPCS
|
||||
);
|
||||
|
||||
// --- 2. Knowledge graph comparison ---
|
||||
// Player's KG confidence levels for Gauntlet entities (StableId 0..=max_gauntlet_id)
|
||||
// must be identical in both runs. Extra NPCs in the hub may be added to the
|
||||
// player's KG (higher StableIds), but must not affect original entity entries.
|
||||
assert_eq!(
|
||||
baseline_kg, stress_kg,
|
||||
"Player KG confidence entries for Gauntlet entities (id <= {}) differ at tick {}\n baseline: {} entries stress: {} entries",
|
||||
max_gauntlet_id,
|
||||
STRESS_TICKS,
|
||||
baseline_kg.len(),
|
||||
stress_kg.len()
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"Behavioral regression PASS: {} Gauntlet entities identical at tick {} ({} NPCs vs {} NPCs)",
|
||||
baseline_ids.len(),
|
||||
STRESS_TICKS,
|
||||
GAUNTLET_NPC_COUNT,
|
||||
ACTIVE_TIER_NPC_CEILING
|
||||
);
|
||||
}
|
||||
@@ -352,11 +352,10 @@ fn gauntlet_deterministic_replay() {
|
||||
/// path (select_dialogue_line) which consumes SimRng.
|
||||
#[test]
|
||||
fn different_seed_produces_different_replay() {
|
||||
use settled_reach_server::content::line_pool::{
|
||||
use settled_reach_server::simulation::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
|
||||
TrustTier,
|
||||
TrustTier, LinePoolIndexResource,
|
||||
};
|
||||
use settled_reach_server::content::LinePoolIndexResource;
|
||||
use settled_reach_server::simulation::dialogue::{
|
||||
CurrentMood, DialogueCooldownTracker, DialogueProfile, DialogueResponseBuffer,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ use settled_reach_server::{
|
||||
},
|
||||
};
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::content::template::TemplateReferenceMap;
|
||||
use settled_reach_server::simulation::triangle::TemplateReferenceMap;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
||||
@@ -304,6 +304,7 @@ fn snapshot_with_sim_errors_roundtrips() {
|
||||
},
|
||||
],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
|
||||
@@ -53,6 +53,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +250,7 @@ fn generate_msgpack_fixtures() {
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_v2_full",
|
||||
@@ -415,6 +417,7 @@ fn generate_msgpack_fixtures() {
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_full",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
use bevy_app::prelude::*;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -16,7 +15,7 @@ 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};
|
||||
use settled_reach_server::content::{ContentConfig, ContentPlugin};
|
||||
use settled_reach_server::simulation::line_pool::{LinePoolIndex, LinePoolIndexResource};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
@@ -34,10 +33,6 @@ const WARMUP_TICKS: usize = 5;
|
||||
const MEASURE_TICKS: usize = 50;
|
||||
const TOTAL_TICKS: usize = WARMUP_TICKS + MEASURE_TICKS;
|
||||
|
||||
fn content_root() -> PathBuf {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
PathBuf::from(manifest_dir).join("../content")
|
||||
}
|
||||
|
||||
fn read_rss_kb() -> Option<u64> {
|
||||
std::fs::read_to_string("/proc/self/status")
|
||||
@@ -64,17 +59,10 @@ fn read_rss_kb() -> Option<u64> {
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn perf_tick_timing() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
eprintln!("Skipping: content directory not found at {:?}", root);
|
||||
return;
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let server_addr = listener.local_addr().expect("get local addr");
|
||||
|
||||
// Server thread: full plugin stack with real content, timed ticks
|
||||
let server_root = root.clone();
|
||||
// Server thread: full plugin stack, timed ticks
|
||||
let server_handle = thread::spawn(move || -> Vec<Duration> {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
|
||||
|
||||
@@ -83,11 +71,7 @@ fn perf_tick_timing() {
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
app.insert_resource(ContentConfig {
|
||||
content_root: server_root,
|
||||
..Default::default()
|
||||
});
|
||||
app.add_plugins(ContentPlugin);
|
||||
app.insert_resource(LinePoolIndexResource(LinePoolIndex::default()));
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +305,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
@@ -358,7 +360,7 @@ fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 19,
|
||||
PROTOCOL_VERSION, 20,
|
||||
"bump this assertion when protocol version changes"
|
||||
);
|
||||
}
|
||||
@@ -413,6 +415,7 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
@@ -128,7 +128,7 @@ fn triangle_activation_event_inserts_routine_deviation_on_anchor_npc() {
|
||||
// Inject a TriangleActivatedEvent pointing to an NPC, run one tick, assert
|
||||
// RoutineDeviation is inserted on that NPC by escalate_tells_on_activation.
|
||||
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
|
||||
use settled_reach_server::content::template::{TriangleId};
|
||||
use settled_reach_server::simulation::triangle::{TriangleId};
|
||||
|
||||
let mut app = build_storyteller_app();
|
||||
// Run one tick so the world is fully initialized before we inject
|
||||
@@ -163,7 +163,7 @@ fn triangle_activation_produces_routine_deviation_tell_in_snapshot() {
|
||||
// End-to-end: after activation event, DerivedTellState on anchor NPC must be
|
||||
// TellCategory::RoutineDeviation. This verifies the full axis-9 pipeline.
|
||||
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
|
||||
use settled_reach_server::content::template::TriangleId;
|
||||
use settled_reach_server::simulation::triangle::TriangleId;
|
||||
|
||||
let mut app = build_storyteller_app();
|
||||
app.update(); // initialize
|
||||
@@ -199,7 +199,7 @@ fn routine_deviation_expires_after_duration() {
|
||||
// Edge case: D-027 criterion 4 must continue to fire DURING the window
|
||||
// and stop firing AFTER it. NPCs shouldn't be permanently flagged.
|
||||
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
|
||||
use settled_reach_server::content::template::TriangleId;
|
||||
use settled_reach_server::simulation::triangle::TriangleId;
|
||||
// NOTE: TELL_ESCALATION_DURATION_TICKS constant (= 300) expected in storyteller module.
|
||||
// This test will need updating once the constant is public.
|
||||
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
@@ -1,906 +0,0 @@
|
||||
//! 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, 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};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_role_schema(id: &str) -> RoleSchema {
|
||||
RoleSchema {
|
||||
role_id: RoleId::new(id),
|
||||
required_traits: vec![],
|
||||
skill_focus: vec![],
|
||||
relationship_constraints: vec![],
|
||||
routine_template: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_triangle(roles: [&str; 3], conflict: ConflictType) -> TriangleDef {
|
||||
let role_arr = [
|
||||
RoleId::new(roles[0]),
|
||||
RoleId::new(roles[1]),
|
||||
RoleId::new(roles[2]),
|
||||
];
|
||||
let triangle_id = TriangleId::from_seed_and_roles(42, &role_arr);
|
||||
TriangleDef {
|
||||
triangle_id,
|
||||
roles: role_arr,
|
||||
conflict_type: conflict,
|
||||
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
|
||||
relationship_constraints: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #163: Role definition schema — YAML round-trips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn role_schema_minimal_yaml_parse() {
|
||||
let yaml = r#"
|
||||
role_id: "guard"
|
||||
skill_focus:
|
||||
- Combat
|
||||
- Observation
|
||||
"#;
|
||||
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("minimal schema must parse");
|
||||
assert_eq!(schema.role_id, RoleId::new("guard"));
|
||||
assert_eq!(schema.skill_focus.len(), 2);
|
||||
assert!(schema.required_traits.is_empty());
|
||||
assert!(schema.relationship_constraints.is_empty());
|
||||
assert!(schema.routine_template.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_schema_full_yaml_parse() {
|
||||
let yaml = r#"
|
||||
role_id: "dock-worker"
|
||||
required_traits:
|
||||
- Cautious
|
||||
- Honest
|
||||
skill_focus:
|
||||
- Technical
|
||||
- Observation
|
||||
relationship_constraints:
|
||||
- with_role: "ring-contact"
|
||||
kind: Colleague
|
||||
required_trust:
|
||||
min: -2
|
||||
max: 2
|
||||
routine_template:
|
||||
- phase: "morning"
|
||||
location: "terminal-cargo-bay"
|
||||
activity: "freight-handling"
|
||||
- phase: "evening"
|
||||
location: "bar-last-shift"
|
||||
"#;
|
||||
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("full schema must parse");
|
||||
assert_eq!(schema.role_id, RoleId::new("dock-worker"));
|
||||
assert_eq!(schema.required_traits.len(), 2);
|
||||
assert_eq!(schema.required_traits[0], PersonalityTrait::Cautious);
|
||||
assert_eq!(schema.skill_focus.len(), 2);
|
||||
assert_eq!(schema.relationship_constraints.len(), 1);
|
||||
assert_eq!(
|
||||
schema.relationship_constraints[0].with_role,
|
||||
RoleId::new("ring-contact")
|
||||
);
|
||||
assert_eq!(schema.relationship_constraints[0].required_trust.min, -2);
|
||||
assert_eq!(schema.relationship_constraints[0].required_trust.max, 2);
|
||||
assert_eq!(schema.routine_template.len(), 2);
|
||||
assert_eq!(schema.routine_template[0].phase, "morning");
|
||||
assert_eq!(schema.routine_template[0].activity, Some("freight-handling".to_string()));
|
||||
assert_eq!(schema.routine_template[1].activity, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_schema_yaml_roundtrip_preserves_all_fields() {
|
||||
let schema = RoleSchema {
|
||||
role_id: RoleId::new("ring-contact"),
|
||||
required_traits: vec![PersonalityTrait::Deceptive, PersonalityTrait::Social],
|
||||
skill_focus: vec![Skill::Stealth, Skill::Persuasion],
|
||||
relationship_constraints: vec![
|
||||
RelationshipConstraint {
|
||||
with_role: RoleId::new("dock-worker"),
|
||||
kind: RelationshipKind::Colleague,
|
||||
required_trust: TrustRange { min: 0, max: 4 },
|
||||
},
|
||||
RelationshipConstraint {
|
||||
with_role: RoleId::new("ring-leader"),
|
||||
kind: RelationshipKind::Superior,
|
||||
required_trust: TrustRange { min: 1, max: 4 },
|
||||
},
|
||||
],
|
||||
routine_template: vec![
|
||||
TemplateRoutineEntry {
|
||||
phase: "morning".into(),
|
||||
location: "terminal-cargo-bay".into(),
|
||||
activity: Some("oversight".into()),
|
||||
},
|
||||
TemplateRoutineEntry {
|
||||
phase: "evening".into(),
|
||||
location: "maintenance-corridor".into(),
|
||||
activity: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let yaml = serde_yaml::to_string(&schema).expect("serialize");
|
||||
let restored: RoleSchema = serde_yaml::from_str(&yaml).expect("deserialize");
|
||||
|
||||
assert_eq!(restored.role_id, schema.role_id);
|
||||
assert_eq!(restored.required_traits, schema.required_traits);
|
||||
assert_eq!(restored.skill_focus, schema.skill_focus);
|
||||
assert_eq!(
|
||||
restored.relationship_constraints.len(),
|
||||
schema.relationship_constraints.len()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.relationship_constraints[0].required_trust,
|
||||
schema.relationship_constraints[0].required_trust
|
||||
);
|
||||
assert_eq!(restored.routine_template.len(), schema.routine_template.len());
|
||||
assert_eq!(
|
||||
restored.routine_template[0].activity,
|
||||
schema.routine_template[0].activity
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #163: Role definition schema — validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn self_referential_constraint_rejected() {
|
||||
let schema = RoleSchema {
|
||||
role_id: RoleId::new("dock-worker"),
|
||||
required_traits: vec![],
|
||||
skill_focus: vec![],
|
||||
relationship_constraints: vec![RelationshipConstraint {
|
||||
with_role: RoleId::new("dock-worker"), // same as role_id
|
||||
kind: RelationshipKind::Colleague,
|
||||
required_trust: TrustRange { min: 0, max: 4 },
|
||||
}],
|
||||
routine_template: vec![],
|
||||
};
|
||||
let result = schema.validate();
|
||||
assert!(result.is_err(), "self-referential constraint must be rejected");
|
||||
assert!(
|
||||
result.unwrap_err().contains("self-referential"),
|
||||
"error must mention self-referential"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_trust_range_rejected() {
|
||||
let schema = RoleSchema {
|
||||
role_id: RoleId::new("guard"),
|
||||
required_traits: vec![],
|
||||
skill_focus: vec![],
|
||||
relationship_constraints: vec![RelationshipConstraint {
|
||||
with_role: RoleId::new("captain"),
|
||||
kind: RelationshipKind::Superior,
|
||||
required_trust: TrustRange { min: 3, max: 1 }, // invalid: min > max
|
||||
}],
|
||||
routine_template: vec![],
|
||||
};
|
||||
let result = schema.validate();
|
||||
assert!(result.is_err(), "TrustRange min > max must be rejected");
|
||||
assert!(
|
||||
result.unwrap_err().contains("trust min"),
|
||||
"error must mention trust min"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_with_duplicate_role_ids_rejected() {
|
||||
let schemas = vec![
|
||||
make_role_schema("dock-worker"),
|
||||
make_role_schema("ring-contact"),
|
||||
make_role_schema("dock-worker"), // duplicate
|
||||
];
|
||||
let result = validate_role_schemas_no_duplicate_ids(&schemas);
|
||||
assert!(result.is_err(), "duplicate role_ids must be rejected");
|
||||
let msg = result.unwrap_err();
|
||||
assert!(msg.contains("dock-worker"), "error must name the duplicate: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_with_unique_role_ids_ok() {
|
||||
let schemas = vec![
|
||||
make_role_schema("dock-worker"),
|
||||
make_role_schema("ring-contact"),
|
||||
make_role_schema("logistics-manager"),
|
||||
];
|
||||
assert!(validate_role_schemas_no_duplicate_ids(&schemas).is_ok());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #164: Spatial requirement specification — YAML round-trips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn space_spec_minimal_yaml_parse() {
|
||||
let yaml = r#"
|
||||
tile_count_min: 30
|
||||
tile_count_max: 80
|
||||
privacy_level: Public
|
||||
traffic_pattern: Thoroughfare
|
||||
"#;
|
||||
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("minimal SpaceSpec must parse");
|
||||
assert_eq!(spec.tile_count_min, 30);
|
||||
assert_eq!(spec.tile_count_max, 80);
|
||||
assert_eq!(spec.privacy_level, PrivacyLevel::Public);
|
||||
assert_eq!(spec.traffic_pattern, TrafficPattern::Thoroughfare);
|
||||
assert!(spec.sightline_zones.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_spec_full_yaml_parse() {
|
||||
let yaml = r#"
|
||||
tile_count_min: 30
|
||||
tile_count_max: 80
|
||||
sightline_zones:
|
||||
- name: "bar-counter"
|
||||
radius: 4
|
||||
- name: "corner-booth"
|
||||
radius: 2
|
||||
privacy_level: SemiPrivate
|
||||
traffic_pattern: Destination
|
||||
"#;
|
||||
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("full SpaceSpec must parse");
|
||||
assert_eq!(spec.sightline_zones.len(), 2);
|
||||
assert_eq!(spec.sightline_zones[0].name, "bar-counter");
|
||||
assert_eq!(spec.sightline_zones[0].radius, 4);
|
||||
assert_eq!(spec.sightline_zones[1].name, "corner-booth");
|
||||
assert_eq!(spec.sightline_zones[1].radius, 2);
|
||||
assert_eq!(spec.privacy_level, PrivacyLevel::SemiPrivate);
|
||||
assert_eq!(spec.traffic_pattern, TrafficPattern::Destination);
|
||||
}
|
||||
|
||||
/// D-025 scale assertion: 15-40 visual tiles = 30-80 sim tiles (D-066).
|
||||
#[test]
|
||||
fn space_spec_d025_tile_count_range() {
|
||||
let spec = SpaceSpec {
|
||||
tile_count_min: 30,
|
||||
tile_count_max: 80,
|
||||
sightline_zones: vec![],
|
||||
privacy_level: PrivacyLevel::Public,
|
||||
traffic_pattern: TrafficPattern::Destination,
|
||||
};
|
||||
assert!(spec.validate().is_ok(), "D-025 tile range (30-80 sim) must be valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_spec_validation_min_gt_max_fails() {
|
||||
let spec = SpaceSpec {
|
||||
tile_count_min: 100,
|
||||
tile_count_max: 50,
|
||||
sightline_zones: vec![],
|
||||
privacy_level: PrivacyLevel::Private,
|
||||
traffic_pattern: TrafficPattern::Restricted,
|
||||
};
|
||||
let result = spec.validate();
|
||||
assert!(result.is_err(), "min > max must fail validation");
|
||||
let msg = result.unwrap_err();
|
||||
assert!(msg.contains("tile_count_min"), "error must mention tile_count_min: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_privacy_levels_yaml_roundtrip() {
|
||||
for level in &[PrivacyLevel::Public, PrivacyLevel::SemiPrivate, PrivacyLevel::Private] {
|
||||
let yaml = serde_yaml::to_string(level).unwrap();
|
||||
let decoded: PrivacyLevel = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(level, &decoded, "{:?} must survive YAML round-trip", level);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_traffic_patterns_yaml_roundtrip() {
|
||||
for pattern in &[
|
||||
TrafficPattern::Thoroughfare,
|
||||
TrafficPattern::Destination,
|
||||
TrafficPattern::Restricted,
|
||||
] {
|
||||
let yaml = serde_yaml::to_string(pattern).unwrap();
|
||||
let decoded: TrafficPattern = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(pattern, &decoded, "{:?} must survive YAML round-trip", pattern);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #165: Single-ownership model — TemplateId determinism
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn template_id_fnv1a_stable_across_calls() {
|
||||
let id = TemplateId::from_seed_and_slug(0, "");
|
||||
assert_eq!(
|
||||
id,
|
||||
TemplateId::from_seed_and_slug(0, ""),
|
||||
"empty slug + seed 0 must be stable"
|
||||
);
|
||||
|
||||
let id2 = TemplateId::from_seed_and_slug(42, "the-terminal");
|
||||
assert_eq!(
|
||||
id2,
|
||||
TemplateId::from_seed_and_slug(42, "the-terminal"),
|
||||
"non-empty slug must be stable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_ownership_component_single_owner_invariant() {
|
||||
// D-025: NPCs are owned by exactly one template, never reassigned.
|
||||
let seed = 1u64;
|
||||
let tid = TemplateId::from_seed_and_slug(seed, "terminal");
|
||||
let rid = RoleId::new("dock-worker");
|
||||
|
||||
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
|
||||
assert_eq!(ownership.template_id, tid);
|
||||
assert_eq!(ownership.role_id, rid);
|
||||
|
||||
// Clone (as would happen in save-state) must preserve values.
|
||||
let cloned = ownership.clone();
|
||||
assert_eq!(cloned.template_id, ownership.template_id);
|
||||
assert_eq!(cloned.role_id, ownership.role_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_reference_map_preserves_links_on_unload() {
|
||||
// D-025: reference links must be preserved when a template is unloaded.
|
||||
let mut map = TemplateReferenceMap::default();
|
||||
let tid_a = TemplateId::from_seed_and_slug(1, "template-a");
|
||||
let tid_b = TemplateId::from_seed_and_slug(1, "template-b");
|
||||
|
||||
map.add(TemplateReference {
|
||||
from_template: tid_a,
|
||||
to_template: tid_b,
|
||||
via_role: RoleId::new("ring-contact"),
|
||||
relationship_metadata: RelationshipKind::Colleague,
|
||||
});
|
||||
|
||||
// Simulate "unload template-a" by cloning (the save path).
|
||||
let preserved = map.clone();
|
||||
assert_eq!(preserved.outgoing(tid_a).len(), 1);
|
||||
assert_eq!(preserved.outgoing(tid_a)[0].to_template, tid_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_reference_map_btreemap_deterministic_ordering() {
|
||||
// D-010: BTreeMap ensures deterministic iteration order.
|
||||
let mut map = TemplateReferenceMap::default();
|
||||
|
||||
let tid_high = TemplateId(u64::MAX - 1);
|
||||
let tid_low = TemplateId(1);
|
||||
|
||||
map.add(TemplateReference {
|
||||
from_template: tid_high,
|
||||
to_template: tid_low,
|
||||
via_role: RoleId::new("role-a"),
|
||||
relationship_metadata: RelationshipKind::Colleague,
|
||||
});
|
||||
map.add(TemplateReference {
|
||||
from_template: tid_low,
|
||||
to_template: tid_high,
|
||||
via_role: RoleId::new("role-b"),
|
||||
relationship_metadata: RelationshipKind::Colleague,
|
||||
});
|
||||
|
||||
// Collect all references via all_references() (deterministic BTreeMap order).
|
||||
let all: Vec<&TemplateReference> = map.all_references().collect();
|
||||
assert_eq!(all.len(), 2);
|
||||
// First entry's from_template must be the lower ID (BTreeMap key order).
|
||||
assert!(
|
||||
all[0].from_template <= all[1].from_template,
|
||||
"BTreeMap must iterate in ascending key order"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #106: Triangle definition schema — YAML round-trips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn triangle_def_yaml_parse_with_computed_id() {
|
||||
// TriangleId is stored in YAML but computed at world-gen time.
|
||||
// Authors use 0 as placeholder; runtime overwrites with computed value.
|
||||
let yaml = r#"
|
||||
triangle_id: 0
|
||||
roles:
|
||||
- "ring-smuggler"
|
||||
- "dock-worker"
|
||||
- "operations-manager"
|
||||
conflict_type: ResourceCompetition
|
||||
interest_axes:
|
||||
- Want
|
||||
- Secret
|
||||
- Relationships
|
||||
"#;
|
||||
let def: TriangleDef = serde_yaml::from_str(yaml).expect("TriangleDef must parse from YAML");
|
||||
assert_eq!(def.triangle_id, TriangleId(0));
|
||||
assert_eq!(def.roles[0], RoleId::new("ring-smuggler"));
|
||||
assert_eq!(def.conflict_type, ConflictType::ResourceCompetition);
|
||||
assert!(def.relationship_constraints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_def_yaml_parse_with_constraints() {
|
||||
let yaml = r#"
|
||||
triangle_id: 0
|
||||
roles:
|
||||
- "ring-leader"
|
||||
- "dock-worker"
|
||||
- "logistics-manager"
|
||||
conflict_type: LoyaltyConflict
|
||||
interest_axes:
|
||||
- Relationships
|
||||
- Secret
|
||||
- Tolerance
|
||||
relationship_constraints:
|
||||
- with_role: "dock-worker"
|
||||
kind: Subordinate
|
||||
required_trust:
|
||||
min: -2
|
||||
max: 2
|
||||
"#;
|
||||
let def: TriangleDef =
|
||||
serde_yaml::from_str(yaml).expect("TriangleDef with constraints must parse");
|
||||
assert_eq!(def.conflict_type, ConflictType::LoyaltyConflict);
|
||||
assert_eq!(def.relationship_constraints.len(), 1);
|
||||
assert_eq!(def.relationship_constraints[0].with_role, RoleId::new("dock-worker"));
|
||||
assert_eq!(def.relationship_constraints[0].kind, RelationshipKind::Subordinate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_def_all_conflict_types_yaml_roundtrip() {
|
||||
let conflict_types = [
|
||||
ConflictType::ResourceCompetition,
|
||||
ConflictType::LoyaltyConflict,
|
||||
ConflictType::SecretExposure,
|
||||
ConflictType::AuthorityChallenge,
|
||||
ConflictType::LatentTension,
|
||||
];
|
||||
for ct in &conflict_types {
|
||||
let yaml = serde_yaml::to_string(ct).unwrap();
|
||||
let decoded: ConflictType = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(ct, &decoded, "{:?} must round-trip", ct);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_def_all_npc_axes_yaml_roundtrip() {
|
||||
let axes = [
|
||||
NpcAxis::Want,
|
||||
NpcAxis::Secret,
|
||||
NpcAxis::Relationships,
|
||||
NpcAxis::Tolerance,
|
||||
NpcAxis::Routine,
|
||||
NpcAxis::InformationInventory,
|
||||
NpcAxis::Contentment,
|
||||
NpcAxis::PersonalityTraits,
|
||||
NpcAxis::TellSystem,
|
||||
NpcAxis::SkillSet,
|
||||
];
|
||||
for axis in &axes {
|
||||
let yaml = serde_yaml::to_string(axis).unwrap();
|
||||
let decoded: NpcAxis = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(axis, &decoded, "{:?} must round-trip", axis);
|
||||
}
|
||||
}
|
||||
|
||||
/// D-087: T1-T5 triangle configuration must be expressible in the schema.
|
||||
#[test]
|
||||
fn d087_v01_triangle_configurations_expressible() {
|
||||
// T1: Kael-Smuggler-Ring (ResourceCompetition, active fork)
|
||||
let t1 = make_triangle(
|
||||
["kael-davan", "smuggler", "ring-contact"],
|
||||
ConflictType::ResourceCompetition,
|
||||
);
|
||||
assert!(t1.validate().is_ok(), "T1 must be valid: {:?}", t1.validate());
|
||||
|
||||
// T2: Sera-Detective-Commission (SecretExposure, active fork)
|
||||
let t2 = make_triangle(
|
||||
["sera-venn", "detective", "commission-inspector"],
|
||||
ConflictType::SecretExposure,
|
||||
);
|
||||
assert!(t2.validate().is_ok(), "T2 must be valid: {:?}", t2.validate());
|
||||
|
||||
// T4: Drin-System-Ring (ResourceCompetition, active fork per D-087)
|
||||
let t4 = make_triangle(
|
||||
["drin", "ring-system", "dock-supervisor"],
|
||||
ConflictType::ResourceCompetition,
|
||||
);
|
||||
assert!(t4.validate().is_ok(), "T4 must be valid: {:?}", t4.validate());
|
||||
|
||||
// T3: passive tension (LatentTension variant per D-087)
|
||||
let t3 = make_triangle(["naia", "kael-davan", "hael"], ConflictType::LatentTension);
|
||||
assert!(t3.validate().is_ok(), "T3 passive tension must be valid: {:?}", t3.validate());
|
||||
|
||||
// T5: background worried partner (LatentTension variant)
|
||||
let t5 = make_triangle(
|
||||
["worried-partner", "ring-member", "neighbor"],
|
||||
ConflictType::LatentTension,
|
||||
);
|
||||
assert!(t5.validate().is_ok(), "T5 passive tension must be valid: {:?}", t5.validate());
|
||||
}
|
||||
|
||||
/// D-089: TriangleDef must not contain cross-triangle cascade state.
|
||||
#[test]
|
||||
fn d089_no_cross_triangle_cascade_fields() {
|
||||
let def = make_triangle(["role-a", "role-b", "role-c"], ConflictType::ResourceCompetition);
|
||||
let yaml = serde_yaml::to_string(&def).expect("serialize");
|
||||
assert!(!yaml.contains("cascade"), "no cascade field should appear in serialized TriangleDef");
|
||||
assert!(!yaml.contains("cross_triangle"), "no cross_triangle field should appear");
|
||||
assert!(!yaml.contains("triggers"), "no triggers field should appear");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #165: ECS integration — spawn two templates with cross-references
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ecs_two_templates_with_cross_references_and_ownerships() {
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
let seed = 999u64;
|
||||
let tid_terminal = TemplateId::from_seed_and_slug(seed, "terminal-social-site");
|
||||
let tid_bar = TemplateId::from_seed_and_slug(seed, "last-shift-bar");
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<TemplateReferenceMap>();
|
||||
|
||||
// Spawn 3 NPCs: 2 in terminal, 1 in bar.
|
||||
let npc_logistics = world
|
||||
.spawn(TemplateOwnership {
|
||||
template_id: tid_terminal,
|
||||
role_id: RoleId::new("logistics-manager"),
|
||||
})
|
||||
.id();
|
||||
let npc_dock = world
|
||||
.spawn(TemplateOwnership {
|
||||
template_id: tid_terminal,
|
||||
role_id: RoleId::new("dock-worker"),
|
||||
})
|
||||
.id();
|
||||
let npc_bar_regular = world
|
||||
.spawn(TemplateOwnership {
|
||||
template_id: tid_bar,
|
||||
role_id: RoleId::new("bar-regular"),
|
||||
})
|
||||
.id();
|
||||
|
||||
// Add cross-template reference: dock-worker at terminal references bar-regular at bar.
|
||||
{
|
||||
let mut ref_map = world.resource_mut::<TemplateReferenceMap>();
|
||||
ref_map.add(TemplateReference {
|
||||
from_template: tid_terminal,
|
||||
to_template: tid_bar,
|
||||
via_role: RoleId::new("dock-worker"),
|
||||
relationship_metadata: RelationshipKind::Colleague,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify all TemplateOwnership components are correct.
|
||||
let own_logistics = world.get::<TemplateOwnership>(npc_logistics).unwrap();
|
||||
assert_eq!(
|
||||
own_logistics.template_id, tid_terminal,
|
||||
"logistics-manager must be owned by terminal"
|
||||
);
|
||||
assert_eq!(own_logistics.role_id, RoleId::new("logistics-manager"));
|
||||
|
||||
let own_dock = world.get::<TemplateOwnership>(npc_dock).unwrap();
|
||||
assert_eq!(
|
||||
own_dock.template_id, tid_terminal,
|
||||
"dock-worker must be owned by terminal"
|
||||
);
|
||||
assert_eq!(own_dock.role_id, RoleId::new("dock-worker"));
|
||||
|
||||
let own_bar = world.get::<TemplateOwnership>(npc_bar_regular).unwrap();
|
||||
assert_eq!(own_bar.template_id, tid_bar, "bar-regular must be owned by bar");
|
||||
|
||||
// Verify TemplateReferenceMap entries.
|
||||
let ref_map = world.resource::<TemplateReferenceMap>();
|
||||
let terminal_refs = ref_map.outgoing(tid_terminal);
|
||||
assert_eq!(terminal_refs.len(), 1, "terminal should have 1 cross-template reference");
|
||||
assert_eq!(terminal_refs[0].to_template, tid_bar);
|
||||
assert_eq!(terminal_refs[0].via_role, RoleId::new("dock-worker"));
|
||||
|
||||
// Bar template has no outgoing references.
|
||||
assert!(
|
||||
ref_map.outgoing(tid_bar).is_empty(),
|
||||
"bar template has no outgoing references"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_ownership_survives_clone_for_save_state() {
|
||||
// D-026: TemplateOwnership must be preserved when tier drops to State-saved.
|
||||
let tid = TemplateId::from_seed_and_slug(42, "terminal");
|
||||
let rid = RoleId::new("dock-worker");
|
||||
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
|
||||
let saved = ownership.clone();
|
||||
assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #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: 30–80 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());
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use bevy_ecs::{schedule::Schedule, world::World};
|
||||
use settled_reach_server::{
|
||||
content::template::{
|
||||
simulation::triangle::{
|
||||
apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand,
|
||||
ResolveTriangleQueue, TemplateId, TriangleClassification, TriangleCrisisEventQueue,
|
||||
TriangleDef, TriangleId, TrianglePhase, TriangleState,
|
||||
@@ -56,7 +56,7 @@ fn spawn_triangle(
|
||||
tension: u8,
|
||||
tension_rate: u8,
|
||||
phase: TrianglePhase,
|
||||
role_assignments: BTreeMap<settled_reach_server::content::template::RoleId, StableId>,
|
||||
role_assignments: BTreeMap<settled_reach_server::simulation::triangle::RoleId, StableId>,
|
||||
) -> bevy_ecs::entity::Entity {
|
||||
world
|
||||
.spawn((
|
||||
@@ -135,7 +135,7 @@ fn simmering_transitions_to_active_at_expected_minute() {
|
||||
let npc_b = spawn_npc_with_threshold(&mut world, 2, 25); // lowest
|
||||
let npc_c = spawn_npc_with_threshold(&mut world, 3, 60);
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("role-a"), npc_a);
|
||||
assignments.insert(RoleId::new("role-b"), npc_b);
|
||||
@@ -185,7 +185,7 @@ fn d087_seed_dependent_escalation_timing() {
|
||||
|
||||
let npc = spawn_npc_with_threshold(&mut world, 1, 30);
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("r"), npc);
|
||||
|
||||
@@ -222,7 +222,7 @@ fn crisis_event_trigger_npc_is_lowest_threshold() {
|
||||
let npc_high = spawn_npc_with_threshold(&mut world, 1, 50); // high tolerance
|
||||
let npc_low = spawn_npc_with_threshold(&mut world, 2, 10); // low tolerance — trigger
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("r-high"), npc_high);
|
||||
assignments.insert(RoleId::new("r-low"), npc_low);
|
||||
@@ -249,7 +249,7 @@ fn no_crisis_event_below_threshold() {
|
||||
let mut world = make_escalation_world();
|
||||
let npc = spawn_npc_with_threshold(&mut world, 1, 100); // high threshold
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("r"), npc);
|
||||
|
||||
@@ -521,7 +521,7 @@ fn crisis_events_accumulate_until_drained() {
|
||||
|
||||
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("r"), npc);
|
||||
|
||||
@@ -548,7 +548,7 @@ fn crisis_queue_drain_clears_events() {
|
||||
|
||||
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
|
||||
|
||||
use settled_reach_server::content::template::RoleId;
|
||||
use settled_reach_server::simulation::triangle::RoleId;
|
||||
let mut assignments = BTreeMap::new();
|
||||
assignments.insert(RoleId::new("r"), npc);
|
||||
|
||||
@@ -575,7 +575,7 @@ fn crisis_queue_drain_clears_events() {
|
||||
/// (D-087) and those defs produce escalatable TriangleState instances.
|
||||
#[test]
|
||||
fn d087_all_v01_conflict_types_produce_escalatable_states() {
|
||||
use settled_reach_server::content::template::{ConflictType, NpcAxis, RoleId};
|
||||
use settled_reach_server::simulation::triangle::{ConflictType, NpcAxis, RoleId};
|
||||
|
||||
let defs = [
|
||||
("kael-davan", "smuggler", "ring-contact", ConflictType::ResourceCompetition),
|
||||
@@ -587,7 +587,7 @@ fn d087_all_v01_conflict_types_produce_escalatable_states() {
|
||||
|
||||
for (r0, r1, r2, conflict) in &defs {
|
||||
let roles = [RoleId::new(r0), RoleId::new(r1), RoleId::new(r2)];
|
||||
let tid = settled_reach_server::content::template::TriangleId::from_seed_and_roles(42, &roles);
|
||||
let tid = settled_reach_server::simulation::triangle::TriangleId::from_seed_and_roles(42, &roles);
|
||||
let def = TriangleDef {
|
||||
triangle_id: tid,
|
||||
roles: roles.clone(),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! `cargo test -p settled-reach-server -- triangle_validation`
|
||||
|
||||
use settled_reach_server::{
|
||||
content::template::{
|
||||
simulation::triangle::{
|
||||
generate_cross_template_triangles, generate_intra_template_triangles,
|
||||
validate_triangle_def, ConflictType, NpcAxis, RelationshipConstraint, RoleId,
|
||||
TemplateId, TemplateOwnership, TriangleDef, TriangleId, TrianglePhase, TrustRange,
|
||||
|
||||
@@ -127,6 +127,7 @@ fn krenn_culture() -> CultureProfile {
|
||||
TellCategory::Friendly,
|
||||
],
|
||||
}],
|
||||
behavior_modifiers: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ fn krenn_culture() -> CultureProfile {
|
||||
TellCategory::Friendly,
|
||||
],
|
||||
}],
|
||||
behavior_modifiers: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user