Merge branch 'server' into main — Sprint 4 server delivery (#18)

This commit is contained in:
2026-02-13 01:21:02 +01:00
65 changed files with 7459 additions and 55 deletions
+3
View File
@@ -1,6 +1,9 @@
# Build and cache
.cache/
server/target/
tooling/content-converter/target/
tooling/line-previewer/target/
content-ron/
# Godot client (further ignores managed by client team)
client/.godot/
+17
View File
@@ -6,6 +6,23 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- Content loader Phase 2 (#408) — 2-phase spawn pipeline loads real YAML content into ECS entities: enums, entity attributes, pools, templates, triangles, NPC profiles with Want/Tolerance/Contentment/Personality/Tells/Skills axes plus cross-reference resolution for Secrets, Relationships, Information, and DailyRoutine
- Global enum YAML files (#387) — 9 enum definitions (situations, topics, moods, triggers, access-tiers, trust-tiers, activities, patterns, motivations) from D-035 taxonomy
- Entity attributes YAML (#388) — 16 canonical knowledge graph attribute keys from D-024 with A7 workshop updates
- Seed-time pools (#389) — 5 single-candidate pools for deterministic v0.1 testing
- Social site templates (#390) — 3 templates (logistics-hub, bar, smuggling-ring) with role slot definitions per D-025
- Triangle YAML files (#391) — 5 v0.1 triangles (3 active fork, 2 passive) per D-024 workshop synthesis
- Seed configuration schema design (#394) — design document defining game-start randomization: FRIEND selections, pool draws, template assignments, entanglement config, ChaCha20 RNG protocol
- YAML to RON converter tool (#403) — build-time converter in tooling/content-converter/, runs via `make content-ron`
- Line previewer CLI (#407) — 4 subcommands (dialogue, monologue, coverage, sequence) for content authors to test line selection without running the full game
- Happiness added to WantKind enum — Harek remapped from Safety to Happiness
### Fixed
- Dialogue-pool schema corrected to use arrays for situation/topic/mood per D-035 (were incorrectly single strings)
- NPC want.primary changed from narrative strings to WantKind enum keywords — fixes silent Want component drop at spawn time
- Schema enum constraint added to npc-profile.schema.json for want.primary validation
### Added
- Sprint 4 "Feel" team briefings — copy (8 tickets), server (9), client (1 carry-over), CI (1), joint coordination
- Interaction prompt system (#405) — server-driven "E - Talk" prompt decoding v4 nearby_interactions with nested VerbOption structs, fade animation, extensible get_interaction_target/get_selected_verb interface for future radial verb menu
+6 -1
View File
@@ -2,7 +2,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build client server test lint ci ci-client ci-server clean \
decisions-sync decisions-coverage decisions-active decisions-orphan \
db-backup db-install validate-content
db-backup db-install validate-content content-ron
# --- Configuration ---
@@ -31,6 +31,7 @@ help:
@echo " make decisions-active List active decisions"
@echo " make decisions-orphan Decisions without implementing tickets"
@echo " make validate-content Validate content YAML against schemas"
@echo " make content-ron Convert content YAML to RON (build-time)"
@echo ""
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
@@ -133,6 +134,10 @@ decisions-orphan:
validate-content:
@tooling/validate-content
content-ron:
cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
# --- Clean ---
clean:
+32 -19
View File
@@ -59,29 +59,42 @@
"description": "Minimum trust level required"
},
"situation": {
"type": "string",
"enum": [
"arrival", "shift_start", "shift_end", "shift_transition",
"bar_evening", "night_shift", "investigation", "confrontation",
"social", "alone", "emergency", "routine", "observation"
],
"description": "Situation context when this line can fire"
"type": "array",
"items": {
"type": "string",
"enum": [
"arrival", "shift_start", "shift_end", "shift_transition",
"bar_evening", "night_shift", "investigation", "confrontation",
"social", "alone", "emergency", "routine", "observation"
]
},
"minItems": 1,
"uniqueItems": true,
"description": "Situation contexts when this line can fire (D-035: list<enum>)"
},
"topic": {
"type": "string",
"enum": [
"colleague", "routine", "cargo", "money", "trust",
"danger", "institution", "personal", "investigation"
],
"description": "Topic tag for selection weighting"
"type": "array",
"items": {
"type": "string",
"enum": [
"colleague", "routine", "cargo", "money", "trust",
"danger", "institution", "personal", "investigation"
]
},
"uniqueItems": true,
"description": "Topic tags for selection weighting (D-035: list<enum>)"
},
"mood": {
"type": "string",
"enum": [
"fond", "comfortable", "worried", "suspicious",
"analytical", "conflicted", "concerned", "relieved"
],
"description": "Mood tag for selection weighting"
"type": "array",
"items": {
"type": "string",
"enum": [
"fond", "comfortable", "worried", "suspicious",
"analytical", "conflicted", "concerned", "relieved"
]
},
"uniqueItems": true,
"description": "Mood tags for selection weighting (D-035: list<enum>)"
},
"tags": {
"type": "array",
+1 -1
View File
@@ -38,7 +38,7 @@
"type": "object",
"description": "Primary want/need driving this NPC",
"properties": {
"primary": { "type": "string" },
"primary": { "type": "string", "enum": ["Wealth", "Safety", "Knowledge", "Connection", "Power", "Freedom", "Justice", "Revenge", "Happiness"] },
"intensity": { "type": "integer", "minimum": 0, "maximum": 10 },
"description": { "type": "string" }
},
@@ -17,9 +17,10 @@ description: >
regular seat at The Last Shift.
want:
primary: "keep the operation running smoothly and profitably"
primary: "Wealth"
intensity: 7
description: >
Keep the operation running smoothly and profitably.
Devra is the ring's operational brain. She manages external supply chain
coordination, schedules handoffs, and maintains timing between gate transits
and dock processing. She is good at this — better than Nils, though she would
@@ -18,9 +18,10 @@ description: >
commission.
want:
primary: "clear the debt and stop looking over his shoulder"
primary: "Freedom"
intensity: 8
description: >
Clear the debt and stop looking over his shoulder.
Drin wants to return to when the job was just a job — maintenance rounds, dock
inspections, clocking out, going home. The work was repetitive and that was
fine. Now every inspection cycle carries a second calculation: which containers
@@ -18,9 +18,10 @@ description: >
because investigating would mean confronting what the card game created.
want:
primary: "maintain his comfortable position"
primary: "Happiness"
intensity: 4
description: >
Maintain his comfortable position.
Harek has a good thing: security officer with a predictable schedule, card games
at the bar, social capital. He doesn't want promotions (more responsibility, more
visibility). He doesn't want trouble. Secondary want: keep Drin's debt
@@ -20,9 +20,10 @@ description: >
contradiction surfaces.
want:
primary: "protect the operation and the people in it"
primary: "Safety"
intensity: 8
description: >
Protect the operation and the people in it.
Two years in, Kael is shifting from "keep things running" to "keep people
safe." The operation is escalating — higher volume, tighter margins, more
risk. Secondary want: protect Naia from the truth and the consequences.
@@ -18,9 +18,10 @@ description: >
solvent.
want:
primary: "keep the bar viable"
primary: "Wealth"
intensity: 7
description: >
Keep the bar viable.
The Last Shift operates on thin margins. The after-shift crowd is what keeps
the lights on. Lera knows some regulars have grey-market income — she does not
ask where the money comes from because the spending keeps her solvent. Secondary
@@ -22,9 +22,10 @@ description: >
on her personal lattice storage.
want:
primary: "understand the manifest discrepancies"
primary: "Knowledge"
intensity: 7
description: >
Understand the manifest discrepancies.
Maret processes freight schedules and the numbers are not adding up. Container
counts do not reconcile, manifests are revised after Voss's schedule adjustments,
Drin's inspection reports are suspiciously clean. She is paralyzed between duty
@@ -16,9 +16,10 @@ description: >
exactly what she appears to be: a teacher worried about her partner.
want:
primary: "understand what is happening with Kael"
primary: "Knowledge"
intensity: 8
description: >
Understand what is happening with Kael.
Naia and Kael have been together 6 years. It was good — stable, warm,
predictable. Then Kael started coming home late. Evasive answers. Money
that did not match the hours. Stress that Kael will not explain. Naia does
@@ -18,8 +18,9 @@ description: >
without seeing him. Tier 1 in schema; functionally off-stage (tier 0).
want:
primary: "control"
primary: "Power"
description: >
Control.
Grow the operation. Increase volume. Maintain authority over the ring from
a distance. Nils sets volume targets, applies escalation pressure, and makes
personnel decisions. The ring's growth imperative comes from Nils; the
@@ -15,8 +15,9 @@ description: >
attention, convenience is suspicious. Ambiguity is deliberate and unresolved.
want:
primary: "employment"
primary: "Safety"
description: >
Employment.
Find work. Settle in. Start over. Whether this is genuine or a cover story
is left deliberately ambiguous for v0.1.
@@ -18,9 +18,10 @@ description: >
threat to avoid. Second playthrough: "That's me. That's what I was doing."
want:
primary: "answers"
primary: "Knowledge"
intensity: 5
description: >
Answers.
Wants to understand the freight discrepancies. Follows the investigation
methodically. Does not make dramatic decisions on the neutral path -- no
confrontations, no leverage plays, no dramatic revelations.
@@ -19,9 +19,10 @@ description: >
neutral path and the player's actual choices IS the narrative distance traveled.
want:
primary: "stability"
primary: "Safety"
intensity: 4
description: >
Stability.
Keep the day smooth. Keep the operation running, the team safe, the shift
uneventful. Does not make dramatic decisions on the neutral path.
@@ -18,9 +18,10 @@ description: >
a handler.
want:
primary: "get out without getting killed"
primary: "Freedom"
intensity: 9
description: >
Get out without getting killed.
Pell is past the point of wanting to stay. The escalating volume, the increasing
risk, the paranoia — the ring has gone from manageable supplement to existential
threat. Secondary want: find someone — anyone — who can offer protection in
@@ -18,9 +18,11 @@ description: >
wavering to Devra.
want:
primary: "stay useful to the ring without getting caught"
primary: "Power"
intensity: 6
description: >
Prove loyalty to the ring and earn trust.
Stay useful without getting caught.
Renn is the physical courier — lifting, carrying, timing runs through
maintenance corridors during Meridian dead spots. Takes pride in the execution.
The job feels important. Secondary want: prove loyalty to Nils and Devra. Renn
@@ -18,9 +18,10 @@ description: >
match what she observes.
want:
primary: "prove herself at the job"
primary: "Power"
intensity: 5
description: >
Prove herself at the job.
Resha wants to be competent, reliable, valued. She studies shift protocols,
asks questions about procedures, and takes notes. Some of those questions —
about locked bays, irregular schedule changes, containers with routing codes
@@ -17,8 +17,9 @@ description: >
ring members' dialogue.
want:
primary: "profit"
primary: "Wealth"
description: >
Profit.
Deliver. Get paid. Maintain the supply chain. If the operation on Sova is
compromised, Sabel cuts the connection and finds another distribution point.
@@ -21,9 +21,10 @@ description: >
evidence from the detective she calls a friend. The quiet life she wanted is gone.
want:
primary: "keep her life stable and her friendships intact"
primary: "Connection"
intensity: 8
description: >
Keep her life stable and her friendships intact.
Sova was supposed to be quiet — routine calibration checks, scheduled
inspections, paperwork. For 12 months, it was exactly that. Then Naia started
confiding about Kael's late nights, and Sera's competence made her curious, and
@@ -14,8 +14,9 @@ description: >
full of people with secrets, Sess's lack of hidden agenda is camouflage.
want:
primary: "stability"
primary: "Safety"
description: >
Stability.
Do the job. Go home. Don't get involved. The work is steady, the tips are
adequate, the regulars are predictable. No ambitions beyond maintaining
this equilibrium.
@@ -16,8 +16,9 @@ description: >
on watch but visibly nervous to anyone who knows what to look for.
want:
primary: "belonging"
primary: "Connection"
description: >
Belonging.
Do the watch. Don't get noticed. Prove useful. The role is simple — watch,
signal, move — but it is the first thing Tav has been trusted with and
the consequences of failure are immediate and severe.
@@ -17,9 +17,10 @@ description: >
without intent.
want:
primary: "enjoy the good life while it lasts"
primary: "Wealth"
intensity: 5
description: >
Enjoy the good life while it lasts.
Torek is young, single, and flush for the first time. He buys rounds, tips well,
has a new jacket and lattice accessories. He is performing generosity — and the
performance is the problem. In a district where everyone knows everyone's salary,
@@ -18,9 +18,10 @@ description: >
Voss's schedule first.
want:
primary: "operational stability"
primary: "Safety"
intensity: 6
description: >
Operational stability.
Keep his shift running without incident. Clean handoffs, zero incident reports.
The smuggling operation introduces variables he cannot control, but the cut he
receives smooths the rough edges of a supervisor's salary. Secondary want:
@@ -0,0 +1,78 @@
# Pool definitions for Sova Transit District
# Seed-time selection system. Architecture supports N candidates; v0.1 has 1 each.
# Format per seed-configuration-schema.md section 5.2
# Referenced by: seed config generation (Sprint 5+), content loader (#408)
pools:
- pool_id: "transit:friend_smuggler"
category: npc_role
description: >
Smuggler's FRIEND (D-034) — closest colleague, emotional anchor.
Production-level complex NPC exercising every content pipeline.
v0.1: Kael Davan (dock worker, ring member). v0.2+: expand to
multiple dock worker candidates with different contradiction arcs.
constraints:
- must_be_in_template: "logistics-hub"
- must_have_pattern: "FRIEND"
- bonded_character: "smuggler"
candidates:
- npc_id: "npc:kael-davan"
weight: 1
- pool_id: "transit:friend_detective"
category: npc_role
description: >
Detective's FRIEND (D-034) — social anchor, information holder.
Production-level complex NPC. Contradiction: sitting on unreported
evidence about Kael's manifest discrepancies, protecting Naia.
v0.1: Sera Venn (Commission field tech, bar regular).
constraints:
- must_have_pattern: "FRIEND"
- bonded_character: "detective"
candidates:
- npc_id: "npc:sera-venn"
weight: 1
- pool_id: "transit:bar_regulars"
category: npc_group
description: >
NPCs who frequent The Last Shift as regulars. Fills bar-regular
template slots. v0.1: fixed set (all selected). v0.2+: seeder
draws a subset from a larger candidate pool for variety.
Note: regulars include cross-template visitors (reference links)
who frequent the bar location but are owned by other templates.
constraints:
- frequents_location: "the-last-shift"
candidates:
- npc_id: "npc:sera-venn"
weight: 1
- npc_id: "npc:resha"
weight: 1
- npc_id: "npc:kael-davan"
weight: 1
- npc_id: "npc:torek-lintar"
weight: 1
- npc_id: "npc:drin"
weight: 1
- pool_id: "transit:compromised_inspector"
category: npc_role
description: >
The Commission inspector compromised by the ring. Access to authority
systems, gambling debt makes them vulnerable. v0.1: Torek Lintar.
constraints:
- must_have_access: "authority"
- must_have_faction: "Commission"
candidates:
- npc_id: "npc:torek-lintar"
weight: 1
- pool_id: "transit:primary_contraband"
category: contraband
description: >
What the ring is smuggling (D-037). v0.1: unlicensed lattice
components — aftermarket neural lattice modifications. Moral
ambiguity: the ring is smuggling access, not weapons.
candidates:
- id: "contraband:lattice-components"
weight: 1
@@ -0,0 +1,101 @@
# Social site template: The Last Shift (bar)
# D-025: functional cluster as atomic template unit
# 4 role slots, 4+ NPCs in v0.1 (regulars are reference links). 4-8 NPC capacity.
template_id: bar
display_name: "The Last Shift"
description: >
Mid-grade bar in Sova Transit District. Converted maintenance staging area.
Irregular layout, long bar, corner booth. Where working people go after shift.
Informally known as "Lera's." The social nexus where investigation and
mundane life overlap.
location: the-last-shift
capacity:
min: 4
max: 8
role_slots:
- role: bar-owner
display_name: "Bar Owner"
count: 1
required: true
description: >
Owns and operates the bar. Observant, careful, knows everyone's business.
ANCHOR pattern — the stabilizing social presence. Hears everything,
says little. Access tier: insider for regulars.
pool_ref: null
flags:
- social_anchor
- information_hub
- bar_permanent
- role: bartender
display_name: "Bartender"
count: 1
required: true
description: >
Serves drinks, manages the floor. Different from the owner — the
bartender is staff, not the proprietor. Younger, less established.
pool_ref: null
flags:
- bar_permanent
- role: bar-regular
display_name: "Bar Regular"
count:
min: 2
max: 5
required: true
description: >
Off-duty workers and social visitors. Regulars are typically owned by
other templates (logistics hub, Commission) with reference links here.
Their presence during bar_evening creates the social observation space.
pool_ref: "transit:bar_regulars"
flags:
- evening_presence
- cross_template
# v0.1 NPC assignments
v01_assignments:
bar-owner: "npc:lera-sessik"
bartender: "npc:pell"
bar-regular-1: "npc:sera-venn" # Reference: Commission field tech, evening regular
bar-regular-2: "npc:resha" # Owned by bar template
# Additional regulars via reference links:
# npc:kael-davan (from logistics-hub, evening presence)
# npc:torek-lintar (from Commission, evening presence)
# npc:drin (from logistics-hub, occasional)
# Reference links — NPCs owned by other templates who visit the bar
reference_links:
- npc: "npc:sera-venn"
owning_template: null # Sera is Commission, not bar staff
relationship: "social anchor — evening regular"
presence_phases: [evening]
- npc: "npc:kael-davan"
owning_template: logistics-hub
relationship: "after-shift regular"
presence_phases: [evening]
- npc: "npc:torek-lintar"
owning_template: null # Commission inspector
relationship: "off-duty regular, gambling participant"
presence_phases: [evening]
- npc: "npc:drin"
owning_template: logistics-hub
relationship: "occasional visitor"
presence_phases: [evening]
triangle_constraints:
- triangle: bar-tensions
required_roles:
- bar-owner # Lera: owner mediating
- bar-regular # Resha: regular with grievance
- bartender # Pell: caught in the middle
- triangle: worried-knowledge
required_roles:
- bar-regular # Sera: avoiding Torek at the bar
dialogue_pools:
- location: the-last-shift
roles: [bar-owner, bartender, bar-regular]
@@ -0,0 +1,106 @@
# Social site template: The Terminal (logistics hub)
# D-025: functional cluster as atomic template unit
# 5 role slots, 6 NPCs in v0.1. 4-8 NPC capacity.
template_id: logistics-hub
display_name: "The Terminal"
description: >
Freight logistics hub — the beating heart of Sova Transit District. Cargo
processing, manifest management, shift rotations. The smuggler's workplace
and the ring's operational cover.
location: the-terminal
# Capacity range per D-025: 4-8 NPCs in connected space with internal sightlines
capacity:
min: 4
max: 8
role_slots:
- role: shift-supervisor
display_name: "Shift Supervisor"
count: 1
required: true
description: >
Authority figure. Oversees dock operations, manages shift schedules,
enforces procedures. Access tier: authority within the hub.
pool_ref: null # Fixed assignment in v0.1
flags:
- authority_figure
- hub_permanent
- role: dock-worker
display_name: "Dock Worker"
count:
min: 2
max: 4
required: true
description: >
Core workforce. Processes cargo, handles manifests, operates equipment.
One dock-worker slot is reserved for the smuggler's FRIEND (pool draw).
pool_ref: "transit:friend_smuggler" # One slot drawn from FRIEND pool
flags:
- hub_permanent
- ring_candidate
- role: new-hire
display_name: "New Hire"
count: 1
required: false
description: >
Recent arrival to the logistics hub. Asks questions, doesn't know the
social norms yet, provides a fresh perspective. CATALYST pattern.
pool_ref: null
flags:
- catalyst
- newcomer
- role: scheduler
display_name: "Freight Scheduler"
count: 1
required: true
description: >
Manages freight scheduling and manifest coordination. Has access to
cargo records — a key information source for both smuggler and detective.
pool_ref: null
flags:
- hub_permanent
- information_access
- role: courier
display_name: "Courier"
count: 1
required: false
description: >
Moves between locations. Off-duty security or delivery runner.
Bridge NPC who connects the hub to other social sites.
pool_ref: null
flags:
- bridge_npc
# v0.1 NPC assignments (matches seed config design doc section 4.4)
v01_assignments:
shift-supervisor: "npc:voss"
dock-worker-1: "npc:kael-davan"
dock-worker-2: "npc:drin"
new-hire: "npc:renn"
scheduler: "npc:maret-korr"
courier: "npc:harek"
# Triangles that involve members of this template
triangle_constraints:
- triangle: hub-power
required_roles:
- shift-supervisor # Voss: authority
- dock-worker # Kael: subordinate
- scheduler # Maret: caught-between
- triangle: worried-partner
required_roles:
- dock-worker # Kael: partner under ring pressure
- triangle: informant-question
required_roles:
- dock-worker # Drin: notices Torek's inspection irregularities
# Dialogue pool locations (maps to dialogue/ subdirectory)
dialogue_pools:
- location: the-terminal
roles: [shift-supervisor, dock-worker, new-hire, scheduler, courier]
@@ -0,0 +1,88 @@
# Social site template: Smuggling Ring (maintenance corridors)
# D-025: functional cluster as atomic template unit
# Smallest template: 2-4 NPCs. Operates in infrastructure gaps.
template_id: smuggling-ring
display_name: "The Ring"
description: >
Smuggling operation using repurposed maintenance infrastructure. Not a
dedicated room — a corridor between hub sections where old and new
construction don't quite meet. Access through service hatches. The ring
didn't build this space; they found it. Operates during night shift and
shift transitions when oversight is thinnest.
location: maintenance-corridors
capacity:
min: 2
max: 4
role_slots:
- role: ring-operative
display_name: "Ring Coordinator"
count: 1
required: true
description: >
Coordinates ring operations. HANDLER motivation — manages logistics,
maintains contacts, makes decisions. Capable, not theatrical.
pool_ref: null
flags:
- ring_leader
- night_presence
- restricted_access
- role: ring-contact
display_name: "Ring Contact"
count:
min: 1
max: 2
required: true
description: >
Active ring member with dual role — legitimate job at the logistics hub
by day, ring operations by night. Reference link from logistics-hub
template. Their presence here is the core secret the smuggler protects
and the detective investigates.
pool_ref: "transit:friend_smuggler" # The FRIEND is the primary ring contact
flags:
- dual_role
- night_presence
- ring_member
- role: external-contact
display_name: "External Contact"
count: 1
required: false
description: >
Off-station connection. Arrives with shipments, departs quickly.
Not a permanent presence — appears during ring operation windows.
pool_ref: null
flags:
- transient
- external
# v0.1 NPC assignments
v01_assignments:
ring-operative: "npc:devra"
ring-contact: "npc:kael-davan" # Reference from logistics-hub (dual role)
external-contact: "npc:sabel" # Off-system contact
reference_links:
- npc: "npc:kael-davan"
owning_template: logistics-hub
relationship: "ring member — dual role, night operations"
presence_phases: [night]
- npc: "npc:sabel"
owning_template: null # External, transient
relationship: "off-station buyer/receiver"
presence_phases: [night] # Irregular, tied to shipments
triangle_constraints:
- triangle: worried-partner
required_roles:
- ring-contact # Kael: under pressure from ring work
- triangle: hub-power
required_roles:
- ring-contact # Kael: subordinate to Voss by day, ring by night
dialogue_pools:
- location: maintenance-corridors
roles: [ring-operative]
@@ -1 +1,49 @@
# Triangle: bar-tensions
# Triangle 3: Bar Tensions — mundane social friction at The Last Shift
# Passive — runs on background simulation, no storyteller intervention
canonical_id: bar-tensions
display_name: "Bar Tensions"
description: >
Mundane social friction at The Last Shift. Lera Sessik (bar owner) runs
a tight establishment. Resha (regular) has a grievance — could be about
the bar, another regular, or community politics. Pell (bartender) is
caught in the middle, trying to keep both the boss and the customers
happy. This is the 50% of life that has nothing to do with conspiracy —
the noise floor that makes investigation signal meaningful.
members:
- npc: "npc:lera-sessik"
role: "owner — mediates disputes, protects her establishment"
- npc: "npc:resha"
role: "regular — has a grievance, vocal about it"
- npc: "npc:pell"
role: "bartender — caught between boss and customers"
forks:
- id: grievance-escalation
condition: >
Resha's grievance reaches a tipping point. Lera must decide whether
to take sides or maintain neutrality. Pell gets drawn in.
outcomes:
- id: lera-sides-with-resha
description: >
Lera validates Resha's complaint. Pell adjusts service accordingly.
Social dynamics shift — Resha becomes more central, the bar's
atmosphere changes slightly.
effects:
- "resha.behavior_flags += vindicated"
- "pell.behavior_flags += accommodating"
- "bar atmosphere = tense but resolving"
- id: lera-shuts-it-down
description: >
Lera tells Resha to drop it. Her bar, her rules. Resha sulks.
Pell is relieved. The grievance goes underground.
effects:
- "resha.behavior_flags += resentful,quiet"
- "pell.behavior_flags += relieved"
- "bar atmosphere = business as usual"
resolution_states:
- id: grievance-resolved
description: The dispute is settled one way or another. Normal bar dynamics resume.
- id: grievance-simmering
description: The issue goes underground. Occasional flare-ups provide social texture.
@@ -1 +1,70 @@
# Triangle: hub-power
# Triangle 1: Hub Power — workplace hierarchy under ring pressure
# Active fork — smuggler decision (3 paths, self-contained per D-053/A-17)
canonical_id: hub-power
display_name: "Hub Power"
description: >
Workplace hierarchy tension at The Terminal. Voss (shift supervisor) has
positional authority but takes operational orders from the ring. Kael
(dock worker, THE FRIEND) is caught between his boss and the ring's demands.
Maret (freight scheduler) sees manifest discrepancies and doesn't know
whom to tell. The triangle's resting tension: Voss needs quiet, Kael
needs everything to hold together, Maret is too smart not to notice.
members:
- npc: "npc:voss"
role: "authority — shift supervisor with ring complicity"
- npc: "npc:kael-davan"
role: "subordinate — dock worker, ring member, caught between boss and ring"
- npc: "npc:maret-korr"
role: "caught-between — freight scheduler who sees the discrepancies"
forks:
- id: volume-escalation
condition: >
Ring demands double-volume shipment via intermediary. Smuggler
must decide how to handle the pressure on Voss and Kael.
outcomes:
- id: escalate
description: >
Side with ring (escalate). Convince Voss to widen the shift gap.
Investigation accelerates — manifest discrepancies spike, Voss
becomes visibly anxious. Detective reads organizational stress.
effects:
- "voss.behavior_flags += nervous,schedule_changes,evasive"
- "voss.position_integrity = thin"
- "ring.exposure_risk increases"
- "detective.investigation_pace = accelerated"
- id: stabilize
description: >
Side with Voss (stabilize). Reject or delay the double shipment.
Hub runs quiet but Kael absorbs redirected pressure from ring
coordinator. Detective reads personal stress on Kael instead.
effects:
- "kael.behavior_flags += lattice_checking,distracted,evasive"
- "kael.trust_level (from ring) drops"
- "friend_arc timing = accelerated"
- "detective.investigation_pace = slowed"
- id: mediate
description: >
Compromise — half volume, phased across two shipments. Nobody's
happy, nobody's exposed. Detective reads inconsistent operational
tempo. Creates a deferred second decision point.
effects:
- "ring.exposure_risk = moderate"
- "smuggler.operational_trust (from ring) drops"
- "kael.behavior_flags += uncertain,distracted"
- "detective reads organizational confusion"
resolution_states:
- id: ring-exposed-via-voss
description: >
Voss's anxiety becomes the detective's thread to pull. Investigation
arrives at the ring through institutional signals.
- id: ring-exposed-via-kael
description: >
Kael's personal strain becomes the detective's signal. Investigation
arrives through behavioral tells on THE FRIEND.
- id: ring-exposed-via-inconsistency
description: >
Irregular operational tempo reveals internal disagreement. Detective
infers organizational structure from pattern disruption.
@@ -1 +1,56 @@
# Triangle: informant-question
# Triangle 5: Informant Question — mundane workplace gossip and suspicion
# Passive — runs on background simulation, provides investigation noise
canonical_id: informant-question
display_name: "The Informant Question"
description: >
Torek Lintar (dock inspector, compromised by gambling debt) is observed
by Drin (dock worker) and Olin (bystander/new arrival). Drin has noticed
Torek's inspection patterns are irregular. Olin overhears workplace gossip
and doesn't know what's significant. This triangle generates the kind
of low-level workplace suspicion that exists in any tight community —
people notice things, talk about them, and form theories. For the
detective, it's signal mixed with noise.
members:
- npc: "npc:torek-lintar"
role: "inspector — compromised, inspection patterns irregular"
- npc: "npc:drin"
role: "worker — notices Torek's odd behavior, gossips about it"
- npc: "npc:olin"
role: "bystander — overhears gossip, forms theories, asks questions"
forks:
- id: gossip-spreads
condition: >
Drin's observations about Torek's inspection patterns reach
enough people that the gossip becomes ambient social knowledge.
outcomes:
- id: dismissed-as-gossip
description: >
The community dismisses Drin's observations as workplace gossip.
Torek's reputation survives. The information exists but isn't
treated as significant — noise floor functioning as intended.
effects:
- "torek.position_integrity unchanged"
- "community treats observations as routine gossip"
- "detective must distinguish signal from noise"
- id: torek-noticed
description: >
Enough people notice Torek's patterns that his behavior becomes
a topic of casual conversation. Not accusatory — just curious.
The detective can pick up on ambient awareness.
effects:
- "torek.behavior_flags += noticed,defensive"
- "ambient dialogue includes references to inspection patterns"
- "detective gains investigation.inspection_anomaly (suspects level)"
resolution_states:
- id: gossip-fades
description: >
The topic loses interest. Workplace gossip moves on. Torek's
irregularities are forgotten by everyone except potentially
the detective.
- id: ambient-suspicion
description: >
A low-level awareness persists in the community. Nothing actionable,
but Torek is slightly more careful and slightly more defensive.
@@ -1 +1,63 @@
# Triangle: worried-knowledge
# Triangle 2: Worried Knowledge — unreported evidence and protective silence
# Active fork — Sera's choice about what to do with what she knows
canonical_id: worried-knowledge
display_name: "Worried Knowledge"
description: >
Sera Venn (Commission field tech, detective's FRIEND) is sitting on
unreported evidence about Kael's manifest discrepancies. She's protecting
Naia Tamm (Kael's partner, who confided in her about Kael's late nights).
Torek Lintar (compromised inspector) is the subject — his gambling debt
makes him the institutional weak point, and Sera's evidence implicates
his inspection lapses. Sera's avoidance of Torek at the bar is the
observable tell.
members:
- npc: "npc:sera-venn"
role: "holder — Commission tech sitting on unreported evidence"
- npc: "npc:torek-lintar"
role: "subject — compromised inspector whose lapses Sera has documented"
- npc: "npc:naia-tamm"
role: "protected — Kael's partner, confided worries to Sera"
forks:
- id: evidence-disclosure
condition: >
Detective builds enough trust with Sera to trigger the disclosure
decision. Sera must choose between institutional duty, friendship
with Naia, and protecting the detective from a compromised colleague.
outcomes:
- id: sera-reports
description: >
Sera reports her evidence through official channels. Torek's
inspection lapses are flagged. The investigation gains institutional
data but Naia loses her protective buffer.
effects:
- "torek.position_integrity = cracking"
- "sera.trust_level (from naia) drops"
- "detective gains investigation.inspection_lapses fact"
- id: sera-protects
description: >
Sera stays silent to protect Naia. The evidence remains buried.
Detective must find the institutional thread through other means.
Sera's avoidance pattern intensifies as she manages the secret.
effects:
- "sera.behavior_flags += avoidance_pattern,evasive"
- "sera.position_integrity = thin"
- "naia remains protected"
- id: sera-confides
description: >
Sera tells the detective privately, off the record. The detective
gains the information but must decide what to do with it. Tests
the detective's institutional loyalty.
effects:
- "detective gains investigation.inspection_lapses fact (informal)"
- "sera.trust_level (toward detective) = trusted"
- "sera.risk_assessment = moderate (off-book disclosure)"
resolution_states:
- id: torek-exposed
description: Torek's compromised status becomes known to the investigation.
- id: knowledge-buried
description: Sera's evidence stays unreported. Avoidance pattern is the only tell.
- id: informal-disclosure
description: Detective knows but has no official evidence. Must find formal proof.
@@ -1 +1,67 @@
# Triangle: worried-partner
# Triangle 4: Worried Partner — ring pressure on personal relationships
# Active fork — the emotional heart of the smuggler's FRIEND arc
canonical_id: worried-partner
display_name: "Worried Partner"
description: >
Kael Davan (dock worker, ring member, smuggler's FRIEND) is under
increasing ring pressure from Devra (ring coordinator). Naia Tamm
(Kael's partner) is worried about his late nights and evasive answers.
The smuggler knows why Kael is busy and can't say. This is the most
emotionally loaded triangle — it connects THE FRIEND's contradiction
arc to personal relationships. If Naia asks the smuggler directly,
there's no good answer.
members:
- npc: "npc:kael-davan"
role: "partner under pressure — ring work destroying home life"
- npc: "npc:naia-tamm"
role: "worried — sees the changes, doesn't understand the cause"
- npc: "npc:devra"
role: "cause — ring coordinator whose demands increase Kael's burden"
forks:
- id: naia-confronts
condition: >
Naia's worry reaches a threshold. She confronts Kael directly, or
asks the smuggler what's going on. The smuggler faces a loyalty test.
outcomes:
- id: smuggler-deflects
description: >
Smuggler deflects Naia's questions. Protects Kael's cover but
damages trust with Naia. Kael is grateful but the lie costs.
effects:
- "naia.trust_level (toward smuggler) drops"
- "kael.behavior_flags += relieved,guilty"
- "smuggler.moral_weight (toward naia) = complicit"
- id: smuggler-hints
description: >
Smuggler gives Naia a partial truth — Kael's under work pressure,
it'll pass. Not a lie, but not the truth. Naia is temporarily
reassured but the pattern continues.
effects:
- "naia.behavior_flags += cautiously_reassured"
- "kael.behavior_flags += unaware"
- "smuggler.moral_weight (toward naia) = peripheral"
- id: kael-snaps
description: >
Before the smuggler can intervene, Kael snaps at Naia under
pressure. The argument is public enough to be observed. Devra's
pressure has become visible through personal fallout.
effects:
- "kael.behavior_flags += agitated,public_argument"
- "naia.behavior_flags += hurt,withdrawn"
- "detective observes domestic stress (behavioral signal)"
resolution_states:
- id: relationship-strained
description: >
Kael and Naia's relationship is visibly strained. The domestic
tension becomes an observable signal for the detective.
- id: temporary-calm
description: >
The immediate crisis passes but the underlying cause remains.
Another confrontation is inevitable.
- id: naia-investigates
description: >
Naia starts looking into Kael's activities independently. She
becomes a potential variable the ring didn't account for.
+41 -1
View File
@@ -1 +1,41 @@
# Access tiers: public, insider, authority, peer, hostile
# 5 access tier values (D-035, D-028 Layer 1)
# Hard filter on every dialogue line — determines who can hear what
name: access-tiers
decision_ref: D-035
description: >
Access tiers control which dialogue lines are available to the player based
on their current social standing at the NPC's social site. This is a hard
filter — lines are invisible if access doesn't match. A line can be eligible
for multiple access tiers (access is a list). The detective's institutional
Commission lattice registers as 'authority'; the smuggler's ring membership
provides 'insider' access.
values:
- value: public
description: >
Available to anyone. Surface-level conversation, safe topics, the social
wallpaper that everyone hears. Greetings, weather, the local complaint.
- value: insider
description: >
Available to people who belong. Ring members, established regulars, anyone
the NPC considers part of their social group. The smuggler starts here at
the logistics hub.
- value: authority
description: >
Available to institutional figures. Commission agents, station security,
anyone with formal investigative standing. The detective's default tier.
NPCs are more careful, more formal, less revealing.
- value: peer
description: >
Available to social equals who have earned personal trust. Not institutional,
not group membership — individual relationship. Requires repeated positive
interactions. The tier where real conversation happens.
- value: hostile
description: >
Available when the relationship has broken down. Accusations, threats,
defensive responses. Activates when trust drops below threshold or after
a confrontation event.
+55
View File
@@ -0,0 +1,55 @@
# NPC activity types for routine scheduling
# Derived from Gestalt routine format + Miri cultural patterns (Sova Transit District)
name: activities
decision_ref: D-024
description: >
Activity categories for NPC routine entries. Each routine schedule entry has a
phase (morning/afternoon/evening/night) and an activity describing what the NPC
is doing. Activities are used by the observation event generator to determine
what the player sees when observing an NPC, and by the situation activation
system to set dialogue context.
values:
- value: working
description: On-shift job duties — processing cargo, inspecting manifests, operating equipment
- value: supervising
description: Overseeing others' work — shift supervisor role, checking quality, giving orders
- value: maintaining
description: Maintenance and repair tasks — fixing equipment, cleaning, infrastructure upkeep
- value: patrolling
description: Security rounds — checking access points, monitoring areas, enforcement presence
- value: socializing
description: Casual social interaction — chatting at the bar, break room conversation, gossip
- value: drinking
description: At The Last Shift or similar — consuming drinks, unwinding after shift
- value: eating
description: Meal time — food vendors, break room, bar food
- value: resting
description: Off-shift downtime — in quarters, low activity, recuperating
- value: commuting
description: Moving between locations — corridor transit, shift change movement
- value: gambling
description: Card game at the bar — the regular social ritual, information exchange venue
- value: trading
description: Legitimate commerce — buying, selling, negotiating, market activity
- value: operating
description: >
Ring operations — contraband handling, manifest doctoring, coordination.
Only visible to characters with sufficient knowledge or direct observation.
- value: waiting
description: Idle between activities — lingering, watching, killing time
- value: meeting
description: Arranged or coincidental encounter with specific NPCs — planned conversation
+36 -1
View File
@@ -1 +1,36 @@
# 8 mood values
# 8 mood values (D-035, D-028 Layer 4)
# Mood tags for weighted dialogue selection
name: moods
decision_ref: D-035
description: >
NPC mood tags for Layer 4 weighted selection. The engine matches NPC current
mood state against line mood tags to weight selection. NPC mood changes based
on simulation events (triangle pressure, time of day, recent interactions).
Lines can be appropriate for multiple moods.
values:
- value: fond
description: Warm, affectionate. Genuine positive feeling toward the listener.
- value: comfortable
description: At ease, relaxed. Default positive-neutral state.
- value: worried
description: Anxious about something specific. Distracted, checking surroundings.
- value: suspicious
description: Guarded, measuring words. Something feels off to this NPC.
- value: analytical
description: Thoughtful, assessing. Processing information rather than emoting.
- value: conflicted
description: >
Torn between competing impulses. Loyalty vs. self-preservation, duty vs.
friendship. The mood of someone who hasn't decided what to do yet.
- value: concerned
description: Worried about someone else's wellbeing. Empathetic distress.
- value: relieved
description: Tension has eased. Something feared didn't happen, or a burden lifted.
+41 -1
View File
@@ -1 +1,41 @@
# 6 functional motivations (System B)
# 6 functional motivations System B (D-050)
# NPC functional role in the conspiracy/investigation structure
name: motivations
decision_ref: D-050
description: >
Functional motivations (System B) define an NPC's role relative to the
conspiracy and investigation structure. Combined with thematic patterns
(System A) to produce the full NPC generation model. Motivations are
uppercase enum values referenced by npc-profile.schema.json.
values:
- value: HANDLER
description: >
Manages or coordinates others within the ring structure. Has authority
over ring operations and knowledge of the network. Devra-type.
- value: WITNESS
description: >
Has seen or knows something significant, possibly without realizing it.
Their testimony or observations are valuable to the investigation.
- value: TURNCOAT
description: >
Wavering loyalty. Could flip sides given the right pressure or
incentive. The uncertain element in any conspiracy.
- value: CIVILIAN
description: >
Genuinely uninvolved. Their mundane concerns and daily life provide
the substrate that makes the conspiracy invisible. The honest majority.
- value: OPERATOR
description: >
Active participant in ring operations. Handles logistics, moves product,
takes direct action. Kael, Renn — the workers of the conspiracy.
- value: SKEPTIC
description: >
Questions the status quo — institutional, criminal, or both. May be
suspicious of the ring, critical of the Commission, or both. Asks
uncomfortable questions.
+57 -1
View File
@@ -1 +1,57 @@
# 9 thematic patterns (System A)
# 9 thematic patterns System A (D-050)
# NPC archetype patterns for character generation and narrative role
name: patterns
decision_ref: D-050
description: >
Thematic patterns (System A) define an NPC's narrative archetype — their
structural role in the story. Combined with functional motivations (System B)
to produce the full NPC generation model. Patterns are uppercase enum values
referenced by npc-profile.schema.json.
values:
- value: FRIEND
description: >
Production-level complex NPC bonded to a playable character (D-034).
Full 10-axis depth, contradiction arc, multi-phase relationship.
One per playable character in v0.1.
- value: MIRROR
description: >
Reflects the player character's situation from a different angle.
What the player could become, or what they might have been.
- value: ANCHOR
description: >
Stabilizing presence in the community. The person everyone knows,
who provides continuity and normalcy. The bar owner, the shift lead.
- value: GHOST
description: >
Absent or barely-present figure whose influence is felt through
others. Referenced in dialogue, effects visible, person rarely seen.
- value: CATALYST
description: >
Triggers change in others. Their actions or arrival destabilize
existing equilibria. The new hire, the returning contact.
- value: THRESHOLD
description: >
Gatekeeper between social worlds. Controls access to information,
spaces, or people. Their cooperation or opposition shapes player options.
- value: REMNANT
description: >
Carries the past. Remembers how things were before the current
situation. Provides historical context and perspective.
- value: SYSTEM
description: >
Represents institutional power. Their actions reflect organizational
priorities, not personal ones. The Commission agent, the inspector.
- value: NOBODY
description: >
Deliberately flat. Social wallpaper with one memorable trait.
Part of the 30% truly flat population (D-029). Provides the noise
floor that makes investigation signal meaningful.
+53 -1
View File
@@ -1 +1,53 @@
# 13 situation values (D-035)
# 13 situation values (D-035, D-028 Layer 2)
# Situation context for dialogue line eligibility — engine-state-to-situation mapping
name: situations
decision_ref: D-035
description: >
Dialogue situation tags. The engine activates situations based on simulation
state (time of day, interaction history, NPC routine state, triangle pressure).
Content authors tag lines with situations; the engine decides which situations
are currently active for a given NPC encounter.
values:
- value: arrival
description: Player has just arrived at this location (first visit this game-day)
- value: shift_start
description: NPC is beginning their work shift — settling in, task-focused
- value: shift_end
description: NPC is wrapping up their work shift — tired, ready to leave
- value: shift_transition
description: >
The gap between shifts when oversight is thinnest. Key operational window
for the ring. People are moving, handoffs happening, attention divided.
- value: bar_evening
description: Social hours at The Last Shift — off-duty, relaxed, drinks flowing
- value: night_shift
description: Late hours, skeleton crew. Quieter, more intimate conversations.
- value: investigation
description: Player is actively asking pointed or probing questions
- value: confrontation
description: >
Triangle tension has escalated past the pressure threshold. NPCs are
agitated, defensive, or aggressive. Activated by triangle pressure system.
- value: social
description: NPC is in a social activity — bar, break room, casual conversation
- value: alone
description: NPC is by themselves, no other NPCs nearby
- value: emergency
description: An urgent event is occurring — alarm, accident, security incident
- value: routine
description: Normal daily activity, nothing notable happening
- value: observation
description: Player is watching from a distance, not directly interacting
+44 -1
View File
@@ -1 +1,44 @@
# 9 topic values
# 9 topic values (D-035, D-028 Layer 4)
# Topic tags for weighted dialogue selection
name: topics
decision_ref: D-035
description: >
Dialogue topic tags for Layer 4 weighted selection. The engine matches NPC
current concern topics against line topic tags to weight selection. Lines can
cover multiple topics. Note: 'crime' is deliberately excluded — NPCs think
of smuggling as 'cargo' or 'money', not crime. The dual-lens interpretation
is what makes those topics crime-adjacent for the detective.
values:
- value: colleague
description: Talk about coworkers, workplace relationships, team dynamics
- value: routine
description: Daily schedules, habits, shift patterns, the rhythm of station life
- value: cargo
description: >
Freight, manifests, shipments — setting-specific. For ring members,
this covers contraband discussion without calling it that.
- value: money
description: Finances, costs, wages, spending. The economic squeeze.
- value: trust
description: Reliability, loyalty, who can be counted on, who's changed
- value: danger
description: Threats, risks, things to watch out for, security concerns
- value: institution
description: >
The Commission, station administration, regulations, official procedures.
How institutional power affects daily life.
- value: personal
description: Family, health, aspirations, feelings — non-work life
- value: investigation
description: >
Direct references to the detective's work, evidence, suspicions.
Typically only surfaces at higher trust levels or under pressure.
+56 -1
View File
@@ -1 +1,56 @@
# 9 monologue trigger types
# 9 monologue trigger types (D-035, D-032)
# What causes an internal monologue line to fire
name: triggers
decision_ref: D-035
description: >
Monologue trigger types. The observation event generator detects simulation
events and fires triggers consumed by the monologue system. Each trigger type
selects from character-specific pools (D-032 hard partition) and applies
prerequisite knowledge gates before selection.
values:
- value: enter_location
description: >
Player enters a new area. First impressions, atmosphere, spatial awareness.
"The Last Shift. Lera's already got my usual poured."
- value: observe_npc
description: >
Player sees an NPC doing something notable — an activity, a posture, a
tell. "Kael's at the corner booth. He looks rough tonight."
- value: hear_sound
description: >
Sound event from fog edge — voices through walls, footsteps in corridors,
machinery changes. The player interprets what they can't see.
- value: observe_anomaly
description: >
NPC deviates from known routine. The character notices something out of
place. "That's not where they usually go." Requires prerequisite knowledge
of the NPC's normal pattern.
- value: post_conversation
description: >
After dialogue ends. The character reflects on what was said, what wasn't
said, what felt off. Processing and interpretation.
- value: discover_evidence
description: >
Player examines an informational object — a manifest, a terminal, a
personal item. The character interprets what they find.
- value: witness_interaction
description: >
Player sees two NPCs interacting with each other. The character reads the
body language, the tone, the context. Social observation.
- value: time_idle
description: >
Player hasn't acted for a while. The character's mind wanders — ambient
thoughts, rumination, noticing background details.
- value: return_visit
description: >
Player returns to a previously visited area. Changed perspective, new
details noticed, comparison to last time. "Something's different."
+28 -1
View File
@@ -1 +1,28 @@
# Trust tiers: surface, real, secret
# 3 trust tier values (D-035, D-028 Layer 3)
# Hard filter for trust-gated gossip disclosure
name: trust-tiers
decision_ref: D-035
description: >
Trust tiers control information disclosure depth. Each NPC tracks trust
progression per player character, starting at surface. Trust advances
through repeated positive interactions. This is a hard filter — lines at
'real' or 'secret' trust are invisible until the player earns that level.
values:
- value: surface
description: >
Default tier. Safe, non-committal responses. What the NPC tells
strangers and acquaintances. The public face.
- value: real
description: >
Earned through repeated positive interactions. The NPC shares genuine
opinions, workplace complaints, personal concerns. What they tell
people they trust.
- value: secret
description: >
Highest tier. The NPC reveals information they'd normally hide —
involvement in the ring, knowledge of the smuggling operation,
fears and vulnerabilities. Reaching this tier with THE FRIEND is
where the contradiction arc becomes explicit.
+235 -1
View File
@@ -1 +1,235 @@
# Entity attributes — 16 canonical EntityKnowledge keys (D-055)
# Entity Knowledge Attributes — 16 canonical keys
# Source: D-024 (10-axis model), wiki-review workshop A7, v01-content-scoping workshop
# All keys are observer->target, stored in EntityKnowledge.known_attributes BTreeMap<String, String>
# Reference: docs/wiki/knowledge/entity-attributes.md
# Categories: identity (4), social (2), behavioral (2), leverage (2), investigation (2), role-perspective (4)
# History: 14 original keys + 4 new role-perspective keys + 2 renames (secret_held -> leverage_held, secret_confidence -> leverage_confidence)
attributes:
# --- Identity Attributes (4) ---
- key: name
category: identity
value_type: freeform
description: >
Full name as known to the observer. If absent or generic,
monologue and dialogue use role descriptor instead.
examples:
- "Kael Davan"
- "Kael"
- "the dock worker"
- key: role
category: identity
value_type: freeform
description: >
Occupational or social role. Default descriptor when name is unknown.
Used in access tier calculation.
examples:
- "dock worker"
- "shift supervisor"
- "bartender"
- "ring coordinator"
- key: faction
category: identity
value_type: freeform
description: >
Institutional or organizational allegiance. Commission-tagged individuals
trigger different NPC behavior. Used for authority/peer dialogue filtering.
examples:
- "Commission"
- "civilian"
- "ring member"
- "unknown"
- key: species
category: identity
value_type: freeform
description: >
Species identifier. Not load-bearing in v0.1 (all human).
Placeholder for future non-human NPCs.
examples:
- "human"
# --- Social Attributes (2) ---
- key: relationship_type
category: social
value_type: freeform
description: >
Relationship category from observer's perspective.
Drives RelationshipState calculation and monologue emotional tone.
examples:
- "colleague"
- "friend"
- "authority figure"
- "suspect"
- "partner"
- "stranger"
- key: trust_level
category: social
value_type: enum
values:
- trusted
- reliable
- uncertain
- suspicious
- compromised
description: >
Trust assessment — observer's subjective judgment. Encompasses both the
read action and the state. No separate trust_read key. Gates trust-tier
dialogue (surface/real/secret).
# --- Behavioral Attributes (2) ---
- key: routine_pattern
category: behavioral
value_type: freeform
description: >
Observed routine description. Deviation detection triggers monologue
when current behavior doesn't match known pattern.
examples:
- "morning shift 06:00-14:00, bar after shift"
- "arrives 06:00, break 10:30, departs 14:00"
- "irregular schedule, seen at odd hours"
- key: behavior_flags
category: behavioral
value_type: freeform
description: >
Comma-separated behavioral observations. Multiple flags accumulate
as evidence. Detective's analytical lattice auto-flags some behaviors.
examples:
- "nervous,lattice_checking"
- "avoidance_pattern,evasive"
- "spending_beyond_means"
- "reliable,punctual"
# --- Leverage Attributes (2) ---
# Renamed from secret_held/secret_confidence per workshop A7
- key: leverage_held
category: leverage
value_type: freeform
renamed_from: secret_held
description: >
What the observer believes gives them power over this entity.
Covers secrets, debts, promises, obligations, and compromising positions.
Drives PersonOfInterest state. Gates confrontation dialogue.
examples:
- "ring membership"
- "unreported evidence"
- "gambling debt"
- "exit attempt"
- "owes favor to Voss"
- key: leverage_confidence
category: leverage
value_type: enum
renamed_from: secret_confidence
values:
- suspected
- likely
- confirmed
description: >
How certain the observer is about the leverage. Suspected allows
fishing questions; confirmed enables direct confrontation.
# --- Investigation Attributes (2) ---
- key: tell_observed
category: investigation
value_type: freeform
description: >
Specific tell noted by observer. Captures the observer's interpretive
conclusion about behavior. Accumulated tells build case strength.
Monologue references specific tells when they recur.
examples:
- "looks left when lying"
- "leaves when Torek arrives"
- "checks lattice before answering"
- "forced casualness after shift supervisor passes"
implementation_note: >
Server may track tells via behavior_flags internally. tell_observed is
retained as a content-authoring key for its distinct narrative purpose.
- key: contradiction_flagged
category: investigation
value_type: freeform
description: >
Boolean-ish flag — presence of value means contradiction detected.
THE FRIEND arc trigger. When set, monologue tone shifts, dialogue
options change, RelationshipState moves toward PersonOfInterest.
examples:
- "meeting_unknown_contact"
- "avoidance_inconsistency"
- "manifest_access_mismatch"
implementation_note: >
Server may track via behavior_flags with contradiction: prefix.
Retained as documented key for arc transition gating.
# --- Role-Perspective Attributes (4 new per workshop A7) ---
# Same enums across all archetypes; interpretation is archetype-dependent.
# Smuggler reads "risk" as operational exposure; detective reads it as
# threat to investigation. See wiki entity-attributes.md for full mapping.
- key: risk_assessment
category: role-perspective
value_type: enum
values:
- none
- low
- moderate
- high
- critical
description: >
Observer's assessment of how much danger this entity poses or attracts.
Gates operational monologue. High-risk triggers cautionary dialogue.
Nature of "risk" depends on observer archetype.
- key: loyalty_assessment
category: role-perspective
value_type: enum
values:
- solid
- dependable
- uncertain
- wavering
- hostile
description: >
Observer's assessment of this entity's reliability and loyalty.
Determines how much the observer relies on or confides in the target.
Wavering triggers monitoring; hostile triggers defensive posture.
- key: position_integrity
category: role-perspective
value_type: enum
values:
- solid
- thin
- cracking
- blown
- "N/A"
description: >
Observer's assessment of this entity's cover, position stability,
or facade status. When degraded, monologue reflects increasing anxiety
or strategic recalculation. N/A when cover concept does not apply.
- key: moral_weight
category: role-perspective
value_type: enum
values:
- innocent
- peripheral
- complicit
- compromised
- willing
description: >
Observer's assessment of this entity's moral involvement. Shapes the
observer's moral arc and internal conflict. Smuggler assesses complicity
in ring operations; detective assesses culpability in the crime.
Same scale, different lens.
@@ -0,0 +1,815 @@
# Seed Configuration Schema — Design Document
**Ticket:** #394
**Author:** Tyre (Technical Architect)
**Status:** Draft
**Date:** 2026-02-13
**Decisions referenced:** D-010, D-024, D-025, D-027, D-029, D-034, D-035, D-036, D-037, D-041
---
## 1. Purpose
The seed configuration is the **handoff contract** between authored content (pools, templates, triangles in `content/`) and a running game instance (ECS entities in memory). It answers one question: *given this content and this seed value, what specific world do we instantiate?*
At game start, the seeder reads content definitions and a seed value, then produces a `SeedConfig` — a deterministic, serializable record of every randomized selection. This record is saved with the game state and replayed identically on reload.
**What the seed config is NOT:**
- Not a content authoring format (content authors write pools/templates/triangles — the seed config *consumes* them)
- Not a runtime state snapshot (that's the ECS world — the seed config is the *recipe* that built it)
- Not a save file (the save file *contains* the seed config alongside mutable game state)
## 2. Design Constraints
| Constraint | Source | Impact |
|-----------|--------|--------|
| Deterministic reproduction | D-010 principle 4, D-030 #7 | Same seed + same content version = identical `SeedConfig`. No HashMap iteration, no platform-dependent RNG. |
| BTreeMap for ordered collections | D-041 | All maps in the seed config use BTreeMap, not HashMap. |
| 30/50/20 entanglement ratio (variable) | D-029 | ~30% flat, ~50% mundane triangles, ~20% intrigue-entangled. Ratios vary per seed to prevent metagaming. |
| Single-candidate pools in v0.1 | Sprint briefing | Architecture supports N candidates; v0.1 pools contain exactly 1 candidate each. |
| Template instantiation via role slots | D-025 | Social sites define roles; the seed assigns NPCs to roles. Single ownership with reference links. |
| Two playable characters | D-027 | Smuggler + detective. Seed config records which character the player selected. |
| Saved with game state | Ticket #394 | Serialized into save files. Must be self-contained (no external content references that could drift). |
| Content version pinning | Implicit | Seed config records content version to detect content/save incompatibility. |
## 3. Schema Overview
```
SeedConfig
├── meta
│ ├── seed: u64
│ ├── content_version: String
│ ├── schema_version: u32
│ └── generated_at: String (ISO 8601)
├── character_selection: CharacterSelection
├── pool_draws: BTreeMap<PoolId, PoolDraw>
├── template_assignments: BTreeMap<TemplateId, TemplateAssignment>
├── triangle_config: TriangleConfig
├── entanglement: EntanglementConfig
├── contraband: ContrabandSelection
└── starting_knowledge: BTreeMap<CharacterId, Vec<KnowledgeEntry>>
```
## 4. Schema Detail
### 4.1 Meta
```rust
struct SeedMeta {
/// The seed value. u64 for sufficient randomness space.
/// v0.1: displayed nowhere; v0.2+: player can enter a seed for shared runs.
seed: u64,
/// Content version string from content/content.yaml.
/// If the save's content_version doesn't match the loaded content,
/// the loader warns or refuses to load (prevents desync).
content_version: String,
/// Schema version for forward compatibility. Increment on breaking changes.
schema_version: u32,
/// ISO 8601 timestamp of generation (informational, not used in logic).
generated_at: String,
}
```
**Rationale:** `seed` is the root of determinism — every randomized decision traces back to this value through a deterministic RNG (see section 6). `content_version` pins the content snapshot to prevent save/content drift.
### 4.2 Character Selection
```rust
struct CharacterSelection {
/// Which character the player chose. Determines starting knowledge,
/// access tiers, monologue pools, perception modes.
player_character: CharacterId,
/// All available characters for this campaign (for reference/validation).
available_characters: Vec<CharacterDefinition>,
}
/// CharacterId is a string enum matching content definitions.
/// v0.1: "smuggler" | "detective"
type CharacterId = String;
struct CharacterDefinition {
id: CharacterId,
display_name: String,
/// Starting social site (determines spawn location)
home_template: TemplateId,
/// Starting access tiers for NPC interactions
default_access: Vec<AccessTier>,
}
```
**Note:** Character selection is the one non-deterministic input — the player chooses. Everything else flows from `seed` + `player_character`.
### 4.3 Pool Draws
Pools are the core randomization mechanism. Each pool defines N candidates for a role; the seeder draws one.
```rust
/// Pool identifier matching content pool definitions.
/// Format: "{scope}:{pool_name}" — e.g., "transit:friend_smuggler"
type PoolId = String;
struct PoolDraw {
/// Which pool this draw came from
pool_id: PoolId,
/// The selected candidate's NPC canonical ID
selected: NpcId,
/// All candidates that were available (for debugging/replay verification)
candidates: Vec<NpcId>,
/// Index into candidates that was selected (for replay verification)
selected_index: usize,
}
```
**v0.1 pools (single-candidate each):**
| Pool ID | Selected | Purpose |
|---------|----------|---------|
| `transit:friend_smuggler` | `npc:kael-davan` | Smuggler's FRIEND (D-034) |
| `transit:friend_detective` | `npc:sera-venn` | Detective's FRIEND (D-034) |
| `transit:bar_regulars` | (set of NPCs) | Bar regular population |
| `transit:compromised_inspector` | `npc:torek-lintar` | The compromised Commission inspector |
| `transit:primary_contraband` | `contraband:lattice-components` | What's being smuggled |
**v0.2+ expansion:** Pools grow to N candidates. `friend_smuggler` might offer 3 dock workers who could each be the FRIEND, with different contradiction arcs. The seeder draws one. Same schema, more candidates.
**Pool categories:**
```rust
enum PoolCategory {
/// Selects one NPC for a named narrative role
NpcRole,
/// Selects a set of NPCs for a group (bar regulars, shift workers)
NpcGroup,
/// Selects a contraband type
Contraband,
/// Selects an entanglement pattern (which NPCs are intrigue-connected)
EntanglementPattern,
}
```
### 4.4 Template Assignments
Templates (D-025 social sites) define role slots; the seeder fills them with NPCs.
```rust
/// Template identifier matching content template definitions.
/// Format: location slug — e.g., "logistics-hub", "bar", "smuggling-ring"
type TemplateId = String;
struct TemplateAssignment {
template_id: TemplateId,
/// Which location(s) this template is instantiated in
locations: Vec<LocationId>,
/// Role slot → NPC assignments
role_assignments: BTreeMap<RoleSlotId, RoleAssignment>,
}
/// Role slot identifier from template definition.
/// Format: "{template}:{role}" — e.g., "logistics-hub:shift-supervisor"
type RoleSlotId = String;
struct RoleAssignment {
/// The NPC assigned to this role slot
npc_id: NpcId,
/// Whether this NPC is the primary owner of this template (D-025 single ownership)
is_owner: bool,
/// If not owner, this is a reference link with relationship metadata
reference_metadata: Option<ReferenceLink>,
}
struct ReferenceLink {
/// The template that owns this NPC
owning_template: TemplateId,
/// Why this NPC appears in this template (relationship context)
relationship: String,
/// How many time phases this NPC spends at this template's location
presence_phases: Vec<DayPhase>,
}
```
**Example — v0.1 Sova Transit District:**
```yaml
# Logistics Hub template
template: logistics-hub
locations: [the-terminal]
roles:
shift-supervisor:
npc: npc:voss
is_owner: true
dock-worker-1:
npc: npc:kael-davan
is_owner: true
dock-worker-2:
npc: npc:drin
is_owner: true
new-hire:
npc: npc:renn
is_owner: true
scheduler:
npc: npc:maret-korr
is_owner: true
courier:
npc: npc:harek
is_owner: true
# Bar template
template: bar
locations: [the-last-shift]
roles:
bar-owner:
npc: npc:lera-sessik
is_owner: true
bartender:
npc: npc:pell
is_owner: true
bar-regular-1:
npc: npc:sera-venn
is_owner: false
reference:
owning_template: null # Sera owns herself (Commission field tech, not bar staff)
relationship: "social anchor — evening regular"
presence_phases: [evening]
bar-regular-2:
npc: npc:resha
is_owner: true
# Smuggling ring template
template: smuggling-ring
locations: [maintenance-corridors]
roles:
ring-operative:
npc: npc:devra
is_owner: true
contact:
npc: npc:kael-davan
is_owner: false
reference:
owning_template: logistics-hub
relationship: "ring member — dual role"
presence_phases: [night]
```
### 4.5 Triangle Configuration
```rust
struct TriangleConfig {
/// All triangles instantiated in this seed
triangles: BTreeMap<TriangleId, TriangleInstance>,
/// Which triangles are initially active (storyteller can activate others later)
initially_active: Vec<TriangleId>,
}
/// Triangle identifier matching content triangle definitions.
type TriangleId = String;
struct TriangleInstance {
triangle_id: TriangleId,
/// The 3 NPCs assigned to this triangle's member slots.
/// Maps triangle role → NPC ID.
members: BTreeMap<String, NpcId>,
/// Initial fork state. In v0.1: all triangles start at their default state.
/// In v0.2+: seed can randomize starting fork positions for variety.
initial_fork_state: Option<String>,
/// Whether this triangle is "active" (storyteller managing) or "passive" (running on its own)
activation_mode: TriangleActivationMode,
}
enum TriangleActivationMode {
/// Storyteller actively manages fork progression based on player proximity
Active,
/// Triangle runs on background simulation, forks resolve without storyteller intervention
Passive,
}
```
**v0.1 triangle instances:**
| Triangle | Members | Mode | Notes |
|----------|---------|------|-------|
| `hub-power` | Voss (authority), Kael (subordinate), Maret (caught-between) | Active | Workplace hierarchy tension |
| `worried-knowledge` | Sera (holder), Torek (subject), Naia (protected) | Active | Sera's unreported evidence |
| `bar-tensions` | Lera (owner), Resha (regular), Pell (bartender) | Passive | Mundane social friction |
| `worried-partner` | Kael (partner), Naia (worried), Devra (cause) | Active | Ring pressure on relationship |
| `informant-question` | Torek (inspector), Drin (worker), Olin (bystander) | Passive | Mundane workplace gossip |
### 4.6 Entanglement Configuration
```rust
struct EntanglementConfig {
/// The target ratio for this seed (varies around 30/50/20 per D-029)
target_ratio: EntanglementRatio,
/// The actual ratio achieved after assignment (may differ slightly due to rounding)
actual_ratio: EntanglementRatio,
/// Per-NPC entanglement tier assignment
npc_tiers: BTreeMap<NpcId, EntanglementTier>,
/// Module attachment ratio: known vs stranger NPCs for intrigue connections
module_attachment_ratio: ModuleAttachmentRatio,
}
struct EntanglementRatio {
/// Percentage of NPCs that are truly flat (routine + greeting only)
flat_pct: u8,
/// Percentage of NPCs in mundane triangles (no conspiracy connection)
mundane_pct: u8,
/// Percentage of NPCs entangled with intrigue content
entangled_pct: u8,
}
struct ModuleAttachmentRatio {
/// Percentage of intrigue-connected NPCs that are known to the player character
known_pct: u8,
/// Percentage that are strangers
stranger_pct: u8,
}
enum EntanglementTier {
/// Routine + greeting, no triangle membership, social wallpaper
Flat,
/// Member of mundane triangle(s), no conspiracy connection
Mundane,
/// Connected to intrigue content (ring member, compromised, witness, etc.)
Entangled,
}
```
**v0.1 entanglement breakdown (17 NPCs):**
| Tier | Count | Pct | NPCs |
|------|-------|-----|------|
| Flat | 5 | 29% | Renn, Harek, Sess, Sabel, Tav |
| Mundane | 9 | 53% | Voss, Lera, Pell, Resha, Maret, Drin, Olin, Naia, Torek* |
| Entangled | 3 | 18% | Kael (ring), Devra (ring), Sera (witness) |
*Torek straddles mundane/entangled — he's compromised (entangled) but his triangle surface reads as mundane institutional friction. The seed config records him as entangled; the player discovers this through gameplay.*
**Revised breakdown with Torek entangled:**
| Tier | Count | Pct | NPCs |
|------|-------|-----|------|
| Flat | 5 | 29% | Renn, Harek, Sess, Sabel, Tav |
| Mundane | 8 | 47% | Voss, Lera, Pell, Resha, Maret, Drin, Olin, Naia |
| Entangled | 4 | 24% | Kael, Devra, Sera, Torek |
This lands at 29/47/24 — within D-029's variable range around 30/50/20.
### 4.7 Contraband Selection
```rust
struct ContrabandSelection {
/// Primary contraband type for this seed
primary: ContrabandType,
/// Secondary contraband types available (for variety in future seeds)
secondary: Vec<ContrabandType>,
}
struct ContrabandType {
/// Identifier matching content/global/knowledge/contraband.yaml
id: String,
/// Display name for content systems (dialogue lines reference this)
display_name: String,
/// What the ring calls it internally (used in insider-access dialogue)
ring_codename: String,
/// Moral valence — affects monologue tone when player discovers it
moral_ambiguity: MoralAmbiguity,
}
enum MoralAmbiguity {
/// Clearly wrong (weapons, poisons)
Clear,
/// Morally complex (medical supplies, access tech)
Ambiguous,
/// Arguably justified (survival supplies, freedom tech)
Sympathetic,
}
```
**v0.1:** Single contraband type — unlicensed lattice components (D-037). `moral_ambiguity: Ambiguous`. The ring is smuggling *access*, not weapons.
### 4.8 Starting Knowledge
```rust
/// Per-character starting knowledge state.
/// Loaded into KnowledgeGraph components at entity creation time (D-041).
struct StartingKnowledge {
/// Facts this character knows at game start
facts: Vec<StartingFact>,
/// Entity knowledge at game start (NPCs the character already knows about)
entities: Vec<StartingEntityKnowledge>,
}
struct StartingFact {
/// Fact ID from content/global/knowledge/*.yaml
fact_id: String,
/// Starting confidence level
confidence: ConfidenceLevel,
/// Source of this knowledge
source: KnowledgeSource,
}
struct StartingEntityKnowledge {
/// NPC stable ID
entity_id: NpcId,
/// What attributes the character knows about this NPC at start
known_attributes: BTreeMap<String, AttributeKnowledge>,
/// Starting confidence
confidence: ConfidenceLevel,
/// Source
source: KnowledgeSource,
}
/// Maps to D-041's 4-level hierarchy
enum ConfidenceLevel {
Suspects,
KnowsOf,
KnowsDetails,
Direct,
}
enum KnowledgeSource {
/// Character background — they knew this before game start
Background,
/// Institutional knowledge — comes with the job
Institutional,
}
```
**Smuggler starting knowledge:**
- Knows colleagues at logistics hub (KnowsOf: Voss, Drin, Renn, Maret, Harek)
- Knows FRIEND deeply (KnowsDetails: Kael)
- Knows bar regulars casually (Suspects: Lera, Pell)
- Knows ring exists, knows Devra (KnowsDetails: Devra, ring operations)
- Does NOT know Sera, Torek, or Commission personnel (detective's world)
- Knows contraband type (KnowsDetails: lattice components)
**Detective starting knowledge:**
- Knows Commission chain of command (KnowsOf: Torek)
- Knows FRIEND (KnowsDetails: Sera)
- Knows bar casually (Suspects: Lera — goes there off-duty)
- Knows assignment briefing (Suspects: smuggling activity on Sova)
- Does NOT know ring members, specific smugglers, or insider logistics operations
- Does NOT know contraband type (investigation target)
## 5. File Format and Location
### 5.1 Authored content (input to seeder)
Lives in `content/` under the campaign hierarchy. Relevant files:
```
content/
├── global/
│ └── knowledge/
│ └── contraband.yaml # Contraband type definitions
├── campaigns/main/systems/krenn/stations/sova/districts/transit/
│ ├── npcs/*.yaml # NPC profiles (candidates for pool draws)
│ ├── triangles/*.yaml # Triangle definitions (instantiated by seed)
│ ├── locations/*.yaml # Location definitions
│ ├── routines/schedules.yaml # NPC daily schedules
│ └── pools.yaml # Pool definitions (NEW — ticket #389)
```
### 5.2 Pool definition format (content/...pools.yaml)
```yaml
# Pool definitions for Sova Transit District
# Each pool defines N candidates for a named role.
# The seeder draws from these pools using the seed value.
pools:
- pool_id: "transit:friend_smuggler"
category: npc_role
description: "Smuggler's FRIEND — closest colleague, emotional anchor"
constraints:
- must_be_in_template: "logistics-hub"
- must_have_pattern: "FRIEND"
- bonded_character: "smuggler"
candidates:
- npc_id: "npc:kael-davan"
weight: 1 # v0.1: only candidate
# v0.2+: additional candidates with different contradiction arcs
- pool_id: "transit:friend_detective"
category: npc_role
description: "Detective's FRIEND — social anchor, information holder"
constraints:
- bonded_character: "detective"
candidates:
- npc_id: "npc:sera-venn"
weight: 1
- pool_id: "transit:compromised_inspector"
category: npc_role
description: "The Commission inspector compromised by the ring"
constraints:
- must_have_access: "authority"
candidates:
- npc_id: "npc:torek-lintar"
weight: 1
- pool_id: "transit:primary_contraband"
category: contraband
description: "What the ring is smuggling"
candidates:
- id: "contraband:lattice-components"
weight: 1
```
### 5.3 Generated seed config (runtime output)
**Format:** RON (Rusty Object Notation) for Rust-native deserialization. Mirrors the Rust structs from section 4.
**Location:** Embedded in save files. Not a standalone file during gameplay — the seeder generates it in memory, the ECS consumes it, and the save system serializes it alongside game state.
**Debug dump location:** `runtime/debug/seed-config-{seed}.ron` — written only in debug builds or when `--dump-seed` flag is passed. Useful for content authors testing pool behavior.
### 5.4 Schema file
A JSON Schema for validating pool definition files goes into `content/_schema/pools.schema.json`. The seed config itself is validated at the Rust type level (serde deserialization), not via JSON Schema.
## 6. Generation Algorithm
### 6.1 Seeder pipeline
```
Input: seed: u64, player_character: CharacterId, content: LoadedContent
Output: SeedConfig
1. Initialize deterministic RNG from seed
└── Use `rand_chacha::ChaCha20Rng::seed_from_u64(seed)`
└── ChaCha20 is platform-independent, deterministic, cryptographically strong
2. Draw pool selections (order: alphabetical by pool_id for determinism)
├── For each pool in sorted order:
│ ├── Compute weighted random selection from candidates
│ ├── Record PoolDraw { selected, candidates, selected_index }
│ └── Advance RNG state (consumed regardless of pool size)
└── Validate: no NPC selected for conflicting roles
3. Assign templates
├── For each template in sorted order:
│ ├── Fill mandatory role slots from pool draws
│ ├── Fill remaining slots from available NPCs (weighted by fit)
│ ├── Record ownership (first template assigned = owner)
│ └── Create reference links for cross-template NPCs
└── Validate: every NPC has exactly one owning template
4. Configure triangles
├── Map pool-drawn NPCs into triangle member slots
├── Determine activation mode per triangle
│ └── Active if any member is entangled; passive otherwise
└── Set initial fork states (v0.1: all default)
5. Compute entanglement
├── Generate target ratio (vary around 30/50/20 using seed RNG)
│ └── flat_pct: 25-35 (uniform draw)
│ └── entangled_pct: 15-25 (uniform draw)
│ └── mundane_pct: 100 - flat - entangled
├── Classify NPCs: pool draws determine entangled set, triangle
│ membership determines mundane, remainder is flat
└── Record actual ratio achieved
6. Select contraband
└── Draw from contraband pool (v0.1: single candidate)
7. Generate starting knowledge
├── For each character:
│ ├── Background knowledge from character definition
│ ├── Institutional knowledge from character role
│ ├── Social knowledge from template assignments
│ │ └── Character knows NPCs in their home template (KnowsOf)
│ │ └── Character knows FRIEND deeply (KnowsDetails)
│ └── Investigation knowledge (detective only: assignment briefing)
└── Validate: no character knows things they shouldn't
8. Assemble SeedConfig and return
```
### 6.2 Determinism guarantees
The seeder MUST produce identical output given identical inputs. This requires:
1. **Platform-independent RNG:** ChaCha20 (not platform `thread_rng`). Same byte stream on Linux, macOS, Windows.
2. **Sorted iteration:** All collections iterated in sorted order (BTreeMap handles this; Vec collections must be pre-sorted or iteration order must be specified).
3. **No floating-point in selection logic:** Weights are integers. Selection uses integer arithmetic only.
4. **Content version pinning:** The `content_version` field detects if content changed between save and load.
5. **RNG consumption order:** The RNG advances in a fixed order regardless of pool sizes or skip conditions. This prevents "butterfly effect" where adding a candidate to one pool shifts all subsequent draws.
### 6.3 RNG consumption protocol
To prevent butterfly effects when pools change size between content versions:
```
For each pool (sorted alphabetically):
1. Consume exactly `max_candidates` RNG values (configurable per pool, default 8)
2. Use the first consumed value to select from actual candidates
3. Remaining consumed values are discarded
This means adding a candidate to pool A doesn't shift the RNG
sequence for pool B.
```
**v0.1 simplification:** With single-candidate pools, all draws are deterministic regardless. The protocol matters for v0.2+ when pools have real variation.
## 7. Integration Points
### 7.1 Who writes seed configs
| Component | Responsibility |
|-----------|---------------|
| **Seeder system** (Rust, `server/src/simulation/seeder.rs`) | Generates `SeedConfig` from seed + content |
| **Save system** (Rust) | Serializes `SeedConfig` into save files |
| **Content loader** (Rust, ticket #408) | Reads pool definitions from content YAML |
| **Debug CLI** (Rust, `--dump-seed` flag) | Writes debug RON dump |
### 7.2 Who reads seed configs
| Component | What it reads | Why |
|-----------|--------------|-----|
| **Entity spawner** | Pool draws, template assignments | Creates ECS entities with correct components |
| **Knowledge initializer** | Starting knowledge | Populates KnowledgeGraph components (D-041) |
| **Storyteller** | Triangle config, entanglement | Knows which triangles to manage, which NPCs are intrigue-relevant |
| **Save/load** | Full SeedConfig | Restores game state from save |
| **Replay system** | SeedMeta | Verifies deterministic reproduction |
| **Line previewer** (ticket #407) | Full SeedConfig | Simulates line selection for a given seed |
### 7.3 Content author workflow
1. Author writes NPC profiles, templates, triangles, pools in `content/`
2. Author runs `make validate-content` to check schemas
3. Author runs line previewer (`tooling/line-previewer`) with a test seed to verify line selection
4. Author can inspect seed config via `--dump-seed` to verify NPC assignments match expectations
## 8. v0.1 vs v0.2+ Scope
| Aspect | v0.1 | v0.2+ |
|--------|------|-------|
| Pool candidates | 1 per pool (deterministic) | N per pool (randomized) |
| Entanglement ratio | Fixed at ~29/47/24 | Variable per seed (25-35 / 40-55 / 15-25) |
| Contraband types | 1 (lattice components) | 3+ with different moral valences |
| Triangle initial states | All default | Seed-randomized starting positions |
| Module attachment | Fixed known/stranger split | Variable per seed (D-029: 60-70/30-40) |
| Starting knowledge | Hardcoded per character | Generated from character definition + pool draws |
| Cross-district pools | N/A (one district) | NPCs can be drawn across district boundaries |
| Template variants | Fixed templates | Template variants (same social site, different layouts) |
## 9. Validation Rules
The seeder validates invariants after generation:
```rust
fn validate(config: &SeedConfig, content: &LoadedContent) -> Result<(), SeedError> {
// 1. Every NPC has exactly one owning template
assert_single_ownership(&config.template_assignments)?;
// 2. FRIEND NPCs are assigned to the correct character
assert_friend_bonds(&config.pool_draws)?;
// 3. Triangle members match NPC assignments
assert_triangle_consistency(&config.triangle_config, &config.template_assignments)?;
// 4. Entanglement ratio is within acceptable range
assert_entanglement_bounds(&config.entanglement)?;
// 5. No circular ownership in reference links
assert_no_circular_refs(&config.template_assignments)?;
// 6. Starting knowledge respects information boundaries
// (smuggler doesn't know detective-only facts, etc.)
assert_knowledge_boundaries(&config.starting_knowledge, content)?;
// 7. All NPC IDs reference valid content profiles
assert_npc_ids_valid(&config, content)?;
// 8. Content version matches
assert_content_version(&config.meta, content)?;
Ok(())
}
```
## 10. Serialization Format
The `SeedConfig` serializes to RON for save files and debug dumps:
```ron
SeedConfig(
meta: SeedMeta(
seed: 42,
content_version: "0.1.0",
schema_version: 1,
generated_at: "2026-02-13T10:00:00Z",
),
character_selection: CharacterSelection(
player_character: "smuggler",
available_characters: [
CharacterDefinition(
id: "smuggler",
display_name: "Dock Worker",
home_template: "logistics-hub",
default_access: [Public, Insider],
),
CharacterDefinition(
id: "detective",
display_name: "Commission Investigator",
home_template: "logistics-hub",
default_access: [Public, Authority],
),
],
),
pool_draws: {
"transit:compromised_inspector": PoolDraw(
pool_id: "transit:compromised_inspector",
selected: "npc:torek-lintar",
candidates: ["npc:torek-lintar"],
selected_index: 0,
),
"transit:friend_detective": PoolDraw(
pool_id: "transit:friend_detective",
selected: "npc:sera-venn",
candidates: ["npc:sera-venn"],
selected_index: 0,
),
"transit:friend_smuggler": PoolDraw(
pool_id: "transit:friend_smuggler",
selected: "npc:kael-davan",
candidates: ["npc:kael-davan"],
selected_index: 0,
),
"transit:primary_contraband": PoolDraw(
pool_id: "transit:primary_contraband",
selected: "contraband:lattice-components",
candidates: ["contraband:lattice-components"],
selected_index: 0,
),
},
// ... (template_assignments, triangle_config, etc.)
)
```
## 11. Crate Dependencies
```toml
# In server/Cargo.toml or wherever the seeder lives
[dependencies]
serde = { version = "1", features = ["derive"] }
ron = "0.8" # RON serialization
rand = "0.8" # RNG traits
rand_chacha = "0.3" # Platform-independent deterministic RNG
```
No new dependencies beyond what the server already uses. `rand` and `serde` are existing deps. `ron` and `rand_chacha` are standard Rust ecosystem crates with no transitive bloat.
## 12. Open Questions
| # | Question | Impact | Suggested Resolution |
|---|----------|--------|---------------------|
| 1 | Should the seed config include routine schedule overrides, or should routines be purely content-driven? | If the seeder can modify routines (e.g., FRIEND's deviation schedule depends on which FRIEND was drawn), routines become partially generated. If not, content must pre-author all variants. | Content-driven for v0.1 (one candidate = one routine). v0.2+: seeder generates routine deviations based on drawn contradiction arcs. |
| 2 | Should `max_candidates` (RNG consumption budget per pool) be configurable per pool or global? | Per-pool allows fine-grained control but complicates the protocol. Global is simpler but wastes RNG state for small pools. | Global default of 8, with per-pool override in pool definition YAML. 8 handles up to 8 candidates without waste, which covers v0.2 comfortably. |
| 3 | How does the seed config interact with the storyteller's module activation system (D-023 Tier 1)? | The storyteller needs to know which Tier 1 modules are *available* (not yet activated) vs *activated* vs *completed*. Does the seed config pre-select available modules, or does the storyteller draw from its own pool at runtime? | Seed config pre-selects *available* modules from a module pool. Storyteller activates them based on player proximity. This keeps all randomization in the seeder for determinism. |
| 4 | Should the debug dump include a human-readable narrative summary (e.g., "Kael Davan is the smuggler's FRIEND, smuggling lattice components...")? | Useful for content authors, trivial to generate, but adds code surface. | Yes. Add a `summary: String` field to `SeedMeta` generated at dump time. Not serialized into save files. |
## 13. Implementation Sequence
This is a design document. Implementation is Sprint 5+. Suggested build order:
1. **Rust types** — Define `SeedConfig` and all sub-structs with serde derives. ~1 day.
2. **Pool definition schema**`pools.schema.json` in `content/_schema/`. ~0.5 day.
3. **Pool loader** — Extend content loader to parse pools.yaml. ~1 day.
4. **Seeder system**`server/src/simulation/seeder.rs`. Core generation logic. ~2 days.
5. **Validation** — Invariant checks from section 9. ~1 day.
6. **Save integration** — Serialize/deserialize SeedConfig in save system. ~0.5 day.
7. **Debug dump**`--dump-seed` CLI flag. ~0.5 day.
8. **Starting knowledge generation** — Section 4.8 logic. ~1 day.
**Total estimate:** ~7-8 developer-days. Parallelizable with content authoring work.
---
*Design document for ticket #394. Implementation deferred to Sprint 5+.*
*Cross-references: D-010, D-024, D-025, D-027, D-029, D-034, D-035, D-036, D-037, D-041.*
+32
View File
@@ -616,6 +616,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "js-sys"
version = "0.3.85"
@@ -902,6 +908,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "semver"
version = "1.0.27"
@@ -938,6 +950,19 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "settled-reach-server"
version = "0.1.0"
@@ -950,6 +975,7 @@ dependencies = [
"rand_chacha",
"rmp-serde",
"serde",
"serde_yaml",
"thiserror",
"tracing",
"tracing-subscriber",
@@ -1164,6 +1190,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "uuid"
version = "1.20.0"
+1
View File
@@ -7,6 +7,7 @@ edition = "2021"
bevy_ecs = "0.18"
bevy_app = "0.18"
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
rmp-serde = "1"
bincode = "1"
rand = "0.9"
+619
View File
@@ -0,0 +1,619 @@
//! 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>,
}
/// 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);
}
// BTreeMap ordering guarantees deterministic district processing,
// but sort the discovery order too for consistency.
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);
}
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();
tracing::info!(
"District loaded: {} NPCs, {} triangles, {} templates, {} pools, {} dialogue pools, {} monologue pools",
npc_count, triangle_count, template_count, pool_count, dialogue_count, monologue_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);
}
}
}
/// Check if a YAML file contains only comments and whitespace (stub file).
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
}
}
}
}
}
+85
View File
@@ -0,0 +1,85 @@
//! Content loading and entity spawning system.
//!
//! Phase 2 content loader (ticket #408): loads YAML content files from disk,
//! deserializes into intermediate types, and spawns ECS entities.
//!
//! 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)
//!
//! Content schema is decoupled from ECS components. The spawn module
//! handles the mapping between the two representations.
pub mod loader;
pub mod spawn;
pub mod types;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use std::path::PathBuf;
/// 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,
}
impl Default for ContentConfig {
fn default() -> Self {
Self {
content_root: PathBuf::from("content"),
}
}
}
/// Content loading plugin.
///
/// Loads content from YAML files at startup and spawns ECS entities.
/// Requires ContentConfig resource to be inserted before the plugin runs.
pub struct ContentPlugin;
impl Plugin for ContentPlugin {
fn build(&self, app: &mut App) {
if !app.world().contains_resource::<ContentConfig>() {
app.insert_resource(ContentConfig::default());
}
app.add_systems(Startup, load_and_spawn_content);
tracing::debug!("ContentPlugin initialized");
}
}
/// Startup system: load content from disk and spawn entities.
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
);
// Insert the content store as a resource for runtime access
// (triangle queries, pool lookups, dialogue selection)
world.insert_resource(ContentStoreResource(store));
}
Err(e) => {
tracing::error!("Failed to load content: {}", e);
// Insert empty store so downstream systems don't panic on missing resource
world.insert_resource(ContentStoreResource(loader::ContentStore::default()));
}
}
}
/// 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);
File diff suppressed because it is too large Load Diff
+555
View File
@@ -0,0 +1,555 @@
//! 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,
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>,
}
#[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>,
#[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>,
}
#[derive(Debug, Deserialize)]
pub struct KnowledgeGrant {
pub fact_id: String,
pub 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>,
#[serde(default)]
pub cooldown: Option<i32>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, 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, Deserialize)]
pub struct FactPrerequisite {
pub fact_id: String,
pub min_confidence: String,
}
#[derive(Debug, Deserialize)]
pub struct AttributePrerequisite {
pub entity: String,
pub key: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub struct RelationshipPrerequisite {
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub state: Option<String>,
}
+1
View File
@@ -3,6 +3,7 @@
pub mod bridge;
pub mod cause_chain;
pub mod content;
pub mod knowledge;
pub mod npc;
pub mod perception;
+1
View File
@@ -49,6 +49,7 @@ pub enum WantKind {
Freedom,
Justice,
Revenge,
Happiness,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
+405
View File
@@ -0,0 +1,405 @@
//! 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.
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"));
// 20 NPC profiles: 17 NPCs + 2 PC-as-NPC + 1 extended NPC (nils-davan)
// Populated by #398 (wiki→YAML NPC conversion)
assert_eq!(
transit.npc_profiles.len(),
20,
"Expected 20 parseable NPC profiles (17 NPCs + 2 PCs + 1 extended)"
);
}
#[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>();
// 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,
});
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::npc::relationships::RelationshipGraph>();
let store = load_content(&root).expect("content loading should succeed");
let result = spawn_content(&mut world, &store);
// All 20 profiles should spawn
assert_eq!(result.npcs_spawned, 20);
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, 20, "All 20 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);
}
+349
View File
@@ -0,0 +1,349 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
dependencies = [
"serde_core",
]
[[package]]
name = "clap"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "content-converter"
version = "0.1.0"
dependencies = [
"clap",
"ron",
"serde",
"serde_yaml",
"thiserror",
"walkdir",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ron"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
dependencies = [
"base64",
"bitflags",
"serde",
"serde_derive",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "content-converter"
version = "0.1.0"
edition = "2021"
description = "Build-time converter: reads content YAML, emits RON for faster runtime deserialization."
[[bin]]
name = "content-converter"
path = "src/main.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
ron = "0.8"
clap = { version = "4", features = ["derive"] }
walkdir = "2"
thiserror = "2"
+286
View File
@@ -0,0 +1,286 @@
//! content-converter: reads content YAML, emits RON.
//!
//! Build-time tool for converting authored YAML content files into RON (Rusty Object Notation)
//! for faster runtime deserialization. Not on v0.1 critical path — engine consumes YAML directly.
//! RON is future-proofing for runtime performance.
use clap::Parser;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
mod types;
use types::ContentFile;
#[derive(Parser)]
#[command(name = "content-converter", about = "Convert content YAML to RON")]
struct Cli {
/// Path to the content directory (default: content/)
#[arg(short, long, default_value = "content")]
input: PathBuf,
/// Path to write RON output (default: content-ron/)
#[arg(short, long, default_value = "content-ron")]
output: PathBuf,
/// Only convert files matching this content type
#[arg(short = 't', long)]
content_type: Option<ContentTypeFilter>,
/// Print what would be converted without writing files
#[arg(long)]
dry_run: bool,
/// Print verbose conversion details
#[arg(short, long)]
verbose: bool,
}
#[derive(Clone, Debug)]
enum ContentTypeFilter {
Npc,
Dialogue,
Monologue,
Triangle,
Location,
Routine,
District,
Faction,
}
impl std::str::FromStr for ContentTypeFilter {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"npc" => Ok(Self::Npc),
"dialogue" => Ok(Self::Dialogue),
"monologue" => Ok(Self::Monologue),
"triangle" => Ok(Self::Triangle),
"location" => Ok(Self::Location),
"routine" => Ok(Self::Routine),
"district" => Ok(Self::District),
"faction" => Ok(Self::Faction),
_ => Err(format!(
"Unknown content type: {s}. Valid: npc, dialogue, monologue, triangle, location, routine, district, faction"
)),
}
}
}
fn main() {
let cli = Cli::parse();
if !cli.input.exists() {
eprintln!(
"Error: content directory not found: {}",
cli.input.display()
);
std::process::exit(1);
}
let mut converted = 0u32;
let mut skipped = 0u32;
let mut errors = 0u32;
for entry in WalkDir::new(&cli.input)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.is_some_and(|ext| ext == "yaml" || ext == "yml")
})
.filter(|e| !is_schema_or_meta(e.path()))
{
let path = entry.path();
let content_type = classify_file(path);
if let Some(ref filter) = cli.content_type {
if !matches_filter(&content_type, filter) {
continue;
}
}
match convert_file(
path,
&cli.input,
&cli.output,
&content_type,
cli.dry_run,
cli.verbose,
) {
Ok(true) => converted += 1,
Ok(false) => skipped += 1,
Err(e) => {
eprintln!("Error converting {}: {e}", path.display());
errors += 1;
}
}
}
println!(
"Content conversion complete: {converted} converted, {skipped} skipped, {errors} errors"
);
if errors > 0 {
std::process::exit(1);
}
}
fn is_schema_or_meta(path: &Path) -> bool {
let path_str = path.to_string_lossy();
path_str.contains("_schema") || path_str.contains("_meta")
}
#[derive(Debug)]
enum ContentType {
NpcProfile,
DialoguePool,
MonologuePool,
Triangle,
Location,
Routine,
District,
Campaign,
System,
Station,
ContentManifest,
Faction,
Enum,
KnowledgeCatalog,
Unknown,
}
fn classify_file(path: &Path) -> ContentType {
let path_str = path.to_string_lossy();
if path_str.contains("/npcs/") {
ContentType::NpcProfile
} else if path_str.contains("/dialogue/") {
ContentType::DialoguePool
} else if path_str.contains("/monologue/") {
ContentType::MonologuePool
} else if path_str.contains("/triangles/") {
ContentType::Triangle
} else if path_str.contains("/locations/") {
ContentType::Location
} else if path_str.contains("/routines/") {
ContentType::Routine
} else if path_str.contains("/factions/") {
ContentType::Faction
} else if path_str.contains("/enums/") {
ContentType::Enum
} else if path_str.contains("/knowledge/") {
ContentType::KnowledgeCatalog
} else if path_str.ends_with("district.yaml") {
ContentType::District
} else if path_str.ends_with("station.yaml") {
ContentType::Station
} else if path_str.ends_with("system.yaml") {
ContentType::System
} else if path_str.ends_with("campaign.yaml") {
ContentType::Campaign
} else if path_str.ends_with("content.yaml") {
ContentType::ContentManifest
} else {
ContentType::Unknown
}
}
fn matches_filter(content_type: &ContentType, filter: &ContentTypeFilter) -> bool {
matches!(
(content_type, filter),
(ContentType::NpcProfile, ContentTypeFilter::Npc)
| (ContentType::DialoguePool, ContentTypeFilter::Dialogue)
| (ContentType::MonologuePool, ContentTypeFilter::Monologue)
| (ContentType::Triangle, ContentTypeFilter::Triangle)
| (ContentType::Location, ContentTypeFilter::Location)
| (ContentType::Routine, ContentTypeFilter::Routine)
| (ContentType::District, ContentTypeFilter::District)
| (ContentType::Faction, ContentTypeFilter::Faction)
)
}
fn convert_file(
path: &Path,
input_root: &Path,
output_root: &Path,
content_type: &ContentType,
dry_run: bool,
verbose: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
let yaml_str = std::fs::read_to_string(path)?;
// Skip stub files (comment-only, no real YAML content)
let trimmed = yaml_str
.lines()
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
.collect::<Vec<_>>()
.join("\n");
if trimmed.is_empty() {
if verbose {
println!(" skip (stub): {}", path.display());
}
return Ok(false);
}
let content_file = parse_yaml(&trimmed, content_type)?;
let ron_config = ron::ser::PrettyConfig::default()
.struct_names(true)
.enumerate_arrays(false);
let ron_str = ron::ser::to_string_pretty(&content_file, ron_config)?;
// Compute output path: replace input root with output root, .yaml -> .ron
let rel_path = path.strip_prefix(input_root)?;
let mut out_path = output_root.join(rel_path);
out_path.set_extension("ron");
if dry_run {
println!(
" would convert: {} -> {}",
path.display(),
out_path.display()
);
return Ok(true);
}
if verbose {
println!(" convert: {} -> {}", path.display(), out_path.display());
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out_path, ron_str)?;
Ok(true)
}
fn parse_yaml(
yaml_str: &str,
content_type: &ContentType,
) -> Result<ContentFile, Box<dyn std::error::Error>> {
let file = match content_type {
ContentType::NpcProfile => {
ContentFile::NpcProfile(Box::new(serde_yaml::from_str(yaml_str)?))
}
ContentType::DialoguePool => ContentFile::DialoguePool(serde_yaml::from_str(yaml_str)?),
ContentType::MonologuePool => ContentFile::MonologuePool(serde_yaml::from_str(yaml_str)?),
ContentType::Triangle => ContentFile::Triangle(serde_yaml::from_str(yaml_str)?),
ContentType::Location => ContentFile::Location(serde_yaml::from_str(yaml_str)?),
ContentType::Routine => ContentFile::Routine(serde_yaml::from_str(yaml_str)?),
ContentType::District => ContentFile::District(serde_yaml::from_str(yaml_str)?),
ContentType::Campaign => ContentFile::Campaign(serde_yaml::from_str(yaml_str)?),
ContentType::System | ContentType::Station => {
ContentFile::Metadata(serde_yaml::from_str(yaml_str)?)
}
ContentType::ContentManifest => ContentFile::Manifest(serde_yaml::from_str(yaml_str)?),
ContentType::Faction => ContentFile::Generic(serde_yaml::from_str(yaml_str)?),
ContentType::Enum | ContentType::KnowledgeCatalog => {
ContentFile::Generic(serde_yaml::from_str(yaml_str)?)
}
ContentType::Unknown => ContentFile::Generic(serde_yaml::from_str(yaml_str)?),
};
Ok(file)
}
+443
View File
@@ -0,0 +1,443 @@
//! Content types mirroring the JSON Schema definitions in content/_schema/.
//!
//! These types are used for YAML deserialization and RON serialization.
//! They do NOT need to match the server's ECS component types — this is a
//! build-time conversion tool, not runtime code.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Top-level enum wrapping all content file types for RON output.
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ContentFile {
NpcProfile(Box<NpcProfile>),
DialoguePool(DialoguePool),
MonologuePool(MonologuePool),
Triangle(Triangle),
Location(Location),
Routine(RoutineFile),
District(District),
Campaign(Campaign),
Manifest(ContentManifest),
Metadata(GenericMetadata),
Generic(serde_yaml::Value),
}
// --- NPC Profile (npc-profile.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct NpcProfile {
pub canonical_id: String,
pub display_name: String,
pub tier: u8,
pub pattern: String,
pub motivation: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub want: Option<Want>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relationships: Vec<Relationship>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tolerance: Option<Tolerance>,
#[serde(skip_serializing_if = "Option::is_none")]
pub routine: Option<RoutineSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub information: Option<Information>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contentment: Option<Contentment>,
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tells: Vec<Tell>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills: Option<Skills>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub triangle_membership: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trust_levels: Option<TrustLevels>,
#[serde(skip_serializing_if = "Option::is_none")]
pub friend_arc: Option<FriendArc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dual_lens: Option<DualLens>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Want {
pub primary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub intensity: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Relationship {
pub target: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub trust: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Tolerance {
#[serde(skip_serializing_if = "Option::is_none")]
pub threshold: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RoutineSummary {
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Information {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub knows: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub access_tier: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Contentment {
#[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Tell {
pub trigger: String,
pub behavior: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub visible_to: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Skills {
#[serde(skip_serializing_if = "Option::is_none")]
pub combat_trained: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills: Option<BTreeMap<String, i32>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TrustLevels {
#[serde(skip_serializing_if = "Option::is_none")]
pub surface: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub real: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FriendArc {
pub bonded_character: String,
pub phases: Vec<FriendPhase>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FriendPhase {
pub phase: u8,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub trigger: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub routine_deviation: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DualLens {
#[serde(skip_serializing_if = "Option::is_none")]
pub smuggler: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detective: Option<String>,
}
// --- Dialogue Pool (dialogue-pool.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct DialoguePool {
pub location: String,
pub role: String,
pub lines: Vec<DialogueLine>,
}
#[derive(Debug, Serialize, 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, skip_serializing_if = "Vec::is_empty")]
pub topic: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mood: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub knowledge_grant: Option<KnowledgeGrant>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KnowledgeGrant {
pub fact_id: String,
pub confidence: String,
}
// --- Monologue Pool (monologue-pool.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct MonologuePool {
pub character: String,
pub location: String,
pub lines: Vec<MonologueLine>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MonologueLine {
pub id: String,
pub text: String,
pub trigger: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub prerequisites: Option<Prerequisites>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cooldown: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Prerequisites {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub facts: Vec<FactPrerequisite>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entity_attributes: Vec<AttributePrerequisite>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relationship: Option<RelationshipPrerequisite>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FactPrerequisite {
pub fact_id: String,
pub min_confidence: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttributePrerequisite {
pub entity: String,
pub key: String,
pub value: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RelationshipPrerequisite {
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
// --- Triangle (triangle.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct Triangle {
pub canonical_id: String,
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub members: Vec<TriangleMember>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub forks: Vec<Fork>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub resolution_states: Vec<Resolution>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TriangleMember {
pub npc: String,
pub role: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Fork {
pub id: String,
pub condition: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outcomes: Vec<ForkOutcome>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ForkOutcome {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Resolution {
pub id: String,
pub description: String,
}
// --- Location (location.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct Location {
pub canonical_id: String,
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tile_bounds: Option<TileBounds>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sightlines: Option<Sightlines>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ambient_sound: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub social_site: Option<String>,
}
#[derive(Debug, Serialize, 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, Serialize, Deserialize)]
pub struct Sightlines {
#[serde(skip_serializing_if = "Option::is_none")]
pub open: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
// --- Routine (routine.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct RoutineFile {
pub district: String,
pub schedules: Vec<NpcSchedule>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NpcSchedule {
pub npc: String,
pub entries: Vec<RoutineEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deviations: Vec<Deviation>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RoutineEntry {
pub phase: String,
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tile: Option<TileCoord>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activity: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Deviation {
pub trigger: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tile: Option<TileCoord>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activity: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TileCoord {
pub x: i32,
pub y: i32,
}
// --- District (district.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct District {
pub display_name: String,
pub description: String,
pub locations: Vec<String>,
pub npc_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub canonical_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub station: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub district: Option<String>,
}
// --- Campaign (campaign.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct Campaign {
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
// --- Content manifest (content.yaml) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct ContentManifest {
pub version: String,
pub campaigns: Vec<CampaignRef>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CampaignRef {
pub id: String,
pub path: String,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub discovery: Option<Discovery>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Discovery {
#[serde(skip_serializing_if = "Option::is_none")]
pub districts: Option<String>,
}
// --- Generic metadata (system.yaml, station.yaml) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct GenericMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_yaml::Value>,
}
+376
View File
@@ -0,0 +1,376 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "libc"
version = "0.2.181"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
[[package]]
name = "line-previewer"
version = "0.1.0"
dependencies = [
"clap",
"rand",
"rand_chacha",
"serde",
"serde_yaml",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "zerocopy"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "line-previewer"
version = "0.1.0"
edition = "2021"
description = "CLI tool to preview dialogue/monologue line selection for a given game state."
[[bin]]
name = "line-previewer"
path = "src/main.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
clap = { version = "4", features = ["derive"] }
rand = "0.9"
rand_chacha = "0.9"
+234
View File
@@ -0,0 +1,234 @@
//! line-previewer: CLI tool for previewing dialogue/monologue line selection.
//!
//! Answers: "Given this NPC state and player state, what line fires?"
//! Authoring tool for content authors to test tag behavior without running the game.
//! Implements the 4-layer dialogue selection pipeline (D-028) in standalone mode.
use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod pipeline;
mod types;
use pipeline::{
evaluate_dialogue, evaluate_monologue, print_coverage_report, print_dialogue_results,
print_monologue_results, DialogueContext, MonologueContext,
};
use types::{DialoguePool, MonologuePool};
#[derive(Parser)]
#[command(
name = "line-previewer",
about = "Preview dialogue/monologue line selection"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Preview dialogue line selection
Dialogue {
/// Path to dialogue YAML file
#[arg(short, long)]
file: PathBuf,
/// Access tier(s): public, insider, authority, peer, hostile
#[arg(short, long, value_delimiter = ',')]
access: Vec<String>,
/// Trust level: surface, real, secret
#[arg(short, long, default_value = "surface")]
trust: String,
/// Active situation(s): arrival, shift_start, shift_end, etc.
#[arg(short, long, value_delimiter = ',')]
situation: Vec<String>,
/// Topic filter(s): colleague, routine, cargo, etc.
#[arg(long, value_delimiter = ',')]
topic: Vec<String>,
/// Mood filter(s): fond, comfortable, worried, etc.
#[arg(long, value_delimiter = ',')]
mood: Vec<String>,
/// Show detailed filter pass/fail for each line
#[arg(short, long)]
verbose: bool,
/// RNG seed for weighted selection (default: 42)
#[arg(long, default_value = "42")]
seed: u64,
},
/// Preview monologue line selection
Monologue {
/// Path to monologue YAML file
#[arg(short, long)]
file: PathBuf,
/// Character: smuggler or detective
#[arg(short, long)]
character: String,
/// Trigger type: enter_location, observe_npc, etc.
#[arg(short, long)]
trigger: String,
/// Known fact IDs (for prerequisite evaluation)
#[arg(long, value_delimiter = ',')]
known_facts: Vec<String>,
/// Show detailed prerequisite evaluation for each line
#[arg(short, long)]
verbose: bool,
/// RNG seed for weighted selection (default: 42)
#[arg(long, default_value = "42")]
seed: u64,
},
/// Show coverage report — which filter combinations have zero eligible lines
Coverage {
/// Path to dialogue or monologue YAML file
#[arg(short, long)]
file: PathBuf,
},
/// Preview a sequence of monologue lines (walk-through simulation)
Sequence {
/// Path to monologue YAML file
#[arg(short, long)]
file: PathBuf,
/// Character: smuggler or detective
#[arg(short, long)]
character: String,
/// Trigger sequence (comma-separated): enter_location,observe_npc,time_idle
#[arg(short, long, value_delimiter = ',')]
triggers: Vec<String>,
/// RNG seed (default: 42)
#[arg(long, default_value = "42")]
seed: u64,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Command::Dialogue {
file,
access,
trust,
situation,
topic,
mood,
verbose,
seed,
} => {
let pool = load_dialogue(&file);
let ctx = DialogueContext {
access,
trust,
situation,
topic,
mood,
seed,
};
let results = evaluate_dialogue(&pool, &ctx);
print_dialogue_results(&results, verbose, seed);
}
Command::Monologue {
file,
character,
trigger,
known_facts,
verbose,
seed,
} => {
let pool = load_monologue(&file);
if pool.character != character {
eprintln!(
"Warning: file character '{}' doesn't match requested '{}'",
pool.character, character
);
}
let ctx = MonologueContext {
trigger,
known_facts,
seed,
};
let results = evaluate_monologue(&pool, &ctx);
print_monologue_results(&results, verbose, seed);
}
Command::Coverage { file } => {
let yaml_str = std::fs::read_to_string(&file).unwrap_or_else(|e| {
eprintln!("Error reading {}: {e}", file.display());
std::process::exit(1);
});
print_coverage_report(&yaml_str, &file);
}
Command::Sequence {
file,
character,
triggers,
seed,
} => {
let pool = load_monologue(&file);
if pool.character != character {
eprintln!(
"Warning: file character '{}' doesn't match requested '{}'",
pool.character, character
);
}
println!("[Sequence: {}{}]", file.display(), character);
let mut used_ids: Vec<String> = Vec::new();
for (i, trigger) in triggers.iter().enumerate() {
let ctx = MonologueContext {
trigger: trigger.clone(),
known_facts: vec![],
seed: seed.wrapping_add(i as u64),
};
let results = evaluate_monologue(&pool, &ctx);
// Filter out recently used lines
let eligible: Vec<_> = results
.iter()
.filter(|r| r.passed && !used_ids.contains(&r.line_id))
.collect();
if let Some(selected) = eligible.first() {
println!("{}. ({}) {:?}", i + 1, trigger, selected.text);
used_ids.push(selected.line_id.clone());
} else {
println!("{}. ({}) [no eligible line]", i + 1, trigger);
}
}
}
}
}
fn load_dialogue(path: &PathBuf) -> DialoguePool {
let yaml_str = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("Error reading {}: {e}", path.display());
std::process::exit(1);
});
serde_yaml::from_str(&yaml_str).unwrap_or_else(|e| {
eprintln!("Error parsing dialogue YAML: {e}");
std::process::exit(1);
})
}
fn load_monologue(path: &PathBuf) -> MonologuePool {
let yaml_str = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("Error reading {}: {e}", path.display());
std::process::exit(1);
});
serde_yaml::from_str(&yaml_str).unwrap_or_else(|e| {
eprintln!("Error parsing monologue YAML: {e}");
std::process::exit(1);
})
}
+585
View File
@@ -0,0 +1,585 @@
//! 4-layer dialogue selection pipeline (D-028) and monologue trigger evaluation.
//!
//! Layer 1: Access tier filter (hard filter)
//! Layer 2: Situation filter (hard filter)
//! Layer 3: Trust level filter (hard filter)
//! Layer 4: Weighted selection by topic + mood match
use crate::types::{DialogueLine, DialoguePool, MonologueLine, MonologuePool};
use rand::prelude::*;
use rand_chacha::ChaCha20Rng;
use std::path::Path;
// --- Dialogue pipeline ---
pub struct DialogueContext {
pub access: Vec<String>,
pub trust: String,
pub situation: Vec<String>,
pub topic: Vec<String>,
pub mood: Vec<String>,
#[allow(dead_code)]
pub seed: u64,
}
pub struct DialogueResult {
pub line_id: String,
pub text: String,
pub passed: bool,
pub weight: f64,
pub access_pass: bool,
pub trust_pass: bool,
pub situation_pass: bool,
pub topic_match: f64,
pub mood_match: f64,
pub access_detail: String,
pub trust_detail: String,
pub situation_detail: String,
}
pub fn evaluate_dialogue(pool: &DialoguePool, ctx: &DialogueContext) -> Vec<DialogueResult> {
pool.lines
.iter()
.map(|line| evaluate_dialogue_line(line, ctx))
.collect()
}
fn evaluate_dialogue_line(line: &DialogueLine, ctx: &DialogueContext) -> DialogueResult {
// Layer 1: Access tier — at least one of the player's access tiers must match
let access_pass = line.access.iter().any(|a| ctx.access.contains(a));
let access_detail = if access_pass {
let matched: Vec<_> = line
.access
.iter()
.filter(|a| ctx.access.contains(a))
.collect();
format!(
"matched: {}",
matched
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
)
} else {
format!(
"line requires [{}], player has [{}]",
line.access.join(", "),
ctx.access.join(", ")
)
};
// Layer 2: Situation — at least one active situation must match
let situation_pass = if ctx.situation.is_empty() {
true // no situation filter = all situations match
} else {
line.situation.iter().any(|s| ctx.situation.contains(s))
};
let situation_detail = if situation_pass {
if ctx.situation.is_empty() {
"no filter applied".to_string()
} else {
let matched: Vec<_> = line
.situation
.iter()
.filter(|s| ctx.situation.contains(s))
.collect();
format!(
"matched: {}",
matched
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
)
}
} else {
format!(
"line requires [{}], active: [{}]",
line.situation.join(", "),
ctx.situation.join(", ")
)
};
// Layer 3: Trust — line's trust level must be <= player's trust level
let trust_order = trust_rank(&line.trust);
let player_trust = trust_rank(&ctx.trust);
let trust_pass = trust_order <= player_trust;
let trust_detail = if trust_pass {
format!("line={} <= player={}", line.trust, ctx.trust)
} else {
format!(
"line requires {} but player only has {}",
line.trust, ctx.trust
)
};
// Layer 4: Weighted selection by topic + mood overlap
let topic_match = if ctx.topic.is_empty() || line.topic.is_empty() {
0.5 // neutral weight when no topic filter
} else {
let matches = line.topic.iter().filter(|t| ctx.topic.contains(t)).count();
if matches > 0 {
0.5 + 0.5 * (matches as f64 / line.topic.len().max(1) as f64)
} else {
0.3 // slight penalty for topic mismatch but not exclusion
}
};
let mood_match = if ctx.mood.is_empty() || line.mood.is_empty() {
0.5
} else {
let matches = line.mood.iter().filter(|m| ctx.mood.contains(m)).count();
if matches > 0 {
0.5 + 0.5 * (matches as f64 / line.mood.len().max(1) as f64)
} else {
0.3
}
};
let passed = access_pass && situation_pass && trust_pass;
let weight = if passed {
topic_match * mood_match
} else {
0.0
};
DialogueResult {
line_id: line.id.clone(),
text: line.text.clone(),
passed,
weight,
access_pass,
trust_pass,
situation_pass,
topic_match,
mood_match,
access_detail,
trust_detail,
situation_detail,
}
}
fn trust_rank(trust: &str) -> u8 {
match trust {
"surface" => 0,
"real" => 1,
"secret" => 2,
_ => 0,
}
}
pub fn print_dialogue_results(results: &[DialogueResult], verbose: bool, seed: u64) {
let passing: Vec<_> = results.iter().filter(|r| r.passed).collect();
let failing: Vec<_> = results.iter().filter(|r| !r.passed).collect();
println!(
"Dialogue evaluation: {} total, {} eligible, {} filtered out\n",
results.len(),
passing.len(),
failing.len()
);
for r in &passing {
println!(" [PASS] {}: {:?}", r.line_id, r.text);
println!(
" weight={:.2} topic={:.2} mood={:.2}",
r.weight, r.topic_match, r.mood_match
);
if verbose {
println!(" access: {}", r.access_detail);
println!(" trust: {}", r.trust_detail);
println!(" situation: {}", r.situation_detail);
}
}
if verbose && !failing.is_empty() {
println!("\n Filtered out:");
for r in &failing {
let reasons: Vec<&str> = [
if !r.access_pass { Some("access") } else { None },
if !r.trust_pass { Some("trust") } else { None },
if !r.situation_pass {
Some("situation")
} else {
None
},
]
.iter()
.filter_map(|x| *x)
.collect();
println!(
" [FAIL] {}: {:?} (failed: {})",
r.line_id,
r.text,
reasons.join(", ")
);
if !r.access_pass {
println!(" access: {}", r.access_detail);
}
if !r.trust_pass {
println!(" trust: {}", r.trust_detail);
}
if !r.situation_pass {
println!(" situation: {}", r.situation_detail);
}
}
}
// Weighted selection
if !passing.is_empty() {
let total_weight: f64 = passing.iter().map(|r| r.weight).sum();
if total_weight > 0.0 {
let mut rng = ChaCha20Rng::seed_from_u64(seed);
let roll: f64 = rng.random::<f64>() * total_weight;
let mut cumulative = 0.0;
for r in &passing {
cumulative += r.weight;
if cumulative >= roll {
println!(
"\n Selected: {} (weight={:.2}/{:.2})",
r.line_id, r.weight, total_weight
);
break;
}
}
}
} else {
println!("\n No eligible lines for this context.");
}
}
// --- Monologue pipeline ---
pub struct MonologueContext {
pub trigger: String,
pub known_facts: Vec<String>,
#[allow(dead_code)]
pub seed: u64,
}
pub struct MonologueResult {
pub line_id: String,
pub text: String,
pub passed: bool,
pub trigger_pass: bool,
pub prereq_pass: bool,
pub priority: i32,
pub trigger_detail: String,
pub prereq_detail: String,
}
pub fn evaluate_monologue(pool: &MonologuePool, ctx: &MonologueContext) -> Vec<MonologueResult> {
let mut results: Vec<_> = pool
.lines
.iter()
.map(|line| evaluate_monologue_line(line, ctx))
.collect();
// Sort by priority (higher first), then by line_id for determinism
results.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| a.line_id.cmp(&b.line_id))
});
results
}
fn evaluate_monologue_line(line: &MonologueLine, ctx: &MonologueContext) -> MonologueResult {
// Trigger match
let trigger_pass = line.trigger == ctx.trigger;
let trigger_detail = if trigger_pass {
format!("matched: {}", ctx.trigger)
} else {
format!("line={}, context={}", line.trigger, ctx.trigger)
};
// Prerequisite evaluation
let (prereq_pass, prereq_detail) = if let Some(ref prereqs) = line.prerequisites {
evaluate_prerequisites(prereqs, &ctx.known_facts)
} else {
(true, "no prerequisites".to_string())
};
let passed = trigger_pass && prereq_pass;
let priority = line.priority.unwrap_or(5);
MonologueResult {
line_id: line.id.clone(),
text: line.text.clone(),
passed,
trigger_pass,
prereq_pass,
priority,
trigger_detail,
prereq_detail,
}
}
fn evaluate_prerequisites(
prereqs: &crate::types::Prerequisites,
known_facts: &[String],
) -> (bool, String) {
let mut details = Vec::new();
let mut all_pass = true;
for fact_req in &prereqs.facts {
let has_fact = known_facts.contains(&fact_req.fact_id);
if has_fact {
details.push(format!(
"fact:{} >= {} [PASS]",
fact_req.fact_id, fact_req.min_confidence
));
} else {
details.push(format!(
"fact:{} >= {} [FAIL: not known]",
fact_req.fact_id, fact_req.min_confidence
));
all_pass = false;
}
}
// Entity attributes and relationships are not yet checkable without full game state
if !prereqs.entity_attributes.is_empty() {
details.push(format!(
"{} entity_attribute prereqs (skipped: no game state)",
prereqs.entity_attributes.len()
));
}
if prereqs.relationship.is_some() {
details.push("relationship prereq (skipped: no game state)".to_string());
}
if details.is_empty() {
(true, "no prerequisites".to_string())
} else {
(all_pass, details.join("; "))
}
}
pub fn print_monologue_results(results: &[MonologueResult], verbose: bool, _seed: u64) {
let passing: Vec<_> = results.iter().filter(|r| r.passed).collect();
let failing: Vec<_> = results.iter().filter(|r| !r.passed).collect();
println!(
"Monologue evaluation: {} total, {} eligible, {} filtered out\n",
results.len(),
passing.len(),
failing.len()
);
for r in &passing {
println!(
" [PASS] {} (priority={}): {:?}",
r.line_id, r.priority, r.text
);
if verbose {
println!(" trigger: {}", r.trigger_detail);
println!(" prereqs: {}", r.prereq_detail);
}
}
if verbose && !failing.is_empty() {
println!("\n Filtered out:");
for r in &failing {
let reasons: Vec<&str> = [
if !r.trigger_pass {
Some("trigger")
} else {
None
},
if !r.prereq_pass {
Some("prerequisites")
} else {
None
},
]
.iter()
.filter_map(|x| *x)
.collect();
println!(
" [FAIL] {}: {:?} (failed: {})",
r.line_id,
r.text,
reasons.join(", ")
);
if !r.trigger_pass {
println!(" trigger: {}", r.trigger_detail);
}
if !r.prereq_pass {
println!(" prereqs: {}", r.prereq_detail);
}
}
}
// Selection: highest priority eligible line
if let Some(selected) = passing.first() {
println!(
"\n Selected: {} (priority={})",
selected.line_id, selected.priority
);
} else {
println!("\n No eligible lines for this trigger.");
}
}
// --- Coverage report ---
pub fn print_coverage_report(yaml_str: &str, path: &Path) {
// Try dialogue first, then monologue
if let Ok(pool) = serde_yaml::from_str::<DialoguePool>(yaml_str) {
print_dialogue_coverage(&pool, path);
} else if let Ok(pool) = serde_yaml::from_str::<MonologuePool>(yaml_str) {
print_monologue_coverage(&pool, path);
} else {
eprintln!("Error: file is neither dialogue nor monologue YAML");
std::process::exit(1);
}
}
fn print_dialogue_coverage(pool: &DialoguePool, path: &Path) {
let access_tiers = ["public", "insider", "authority", "peer", "hostile"];
let trust_levels = ["surface", "real", "secret"];
let situations = [
"arrival",
"shift_start",
"shift_end",
"shift_transition",
"bar_evening",
"night_shift",
"investigation",
"confrontation",
"social",
"alone",
"emergency",
"routine",
"observation",
];
println!(
"Coverage report: {} (role: {}, location: {})\n",
path.display(),
pool.role,
pool.location
);
let mut gaps = Vec::new();
for access in &access_tiers {
for trust in &trust_levels {
for situation in &situations {
let eligible = pool
.lines
.iter()
.filter(|line| {
line.access.iter().any(|a| a == access)
&& trust_rank(&line.trust) <= trust_rank(trust)
&& line.situation.iter().any(|s| s == situation)
})
.count();
if eligible == 0 {
gaps.push(format!(
" access={:<10} trust={:<8} situation={:<18} -> 0 lines",
access, trust, situation
));
}
}
}
}
if gaps.is_empty() {
println!(" Full coverage! Every access/trust/situation combination has at least one eligible line.");
} else {
println!(
" {} gaps found (access/trust/situation combos with zero eligible lines):\n",
gaps.len()
);
// Show first 20 gaps
for gap in gaps.iter().take(20) {
println!("{gap}");
}
if gaps.len() > 20 {
println!(" ... and {} more", gaps.len() - 20);
}
}
println!(
"\n Total lines: {}, Access tiers used: {:?}, Trust levels used: {:?}",
pool.lines.len(),
pool.lines
.iter()
.flat_map(|l| l.access.iter())
.collect::<std::collections::BTreeSet<_>>(),
pool.lines
.iter()
.map(|l| l.trust.as_str())
.collect::<std::collections::BTreeSet<_>>(),
);
}
fn print_monologue_coverage(pool: &MonologuePool, path: &Path) {
let triggers = [
"enter_location",
"observe_npc",
"hear_sound",
"observe_anomaly",
"post_conversation",
"discover_evidence",
"witness_interaction",
"time_idle",
"return_visit",
];
println!(
"Coverage report: {} (character: {}, location: {})\n",
path.display(),
pool.character,
pool.location
);
let mut covered = Vec::new();
let mut uncovered = Vec::new();
for trigger in &triggers {
let count = pool
.lines
.iter()
.filter(|line| line.trigger == *trigger)
.count();
if count > 0 {
covered.push(format!(" {:<25} {} lines", trigger, count));
} else {
uncovered.push(format!(" {:<25} 0 lines", trigger));
}
}
if !covered.is_empty() {
println!(" Covered triggers:");
for line in &covered {
println!("{line}");
}
}
if !uncovered.is_empty() {
println!("\n Uncovered triggers:");
for line in &uncovered {
println!("{line}");
}
}
let with_prereqs = pool
.lines
.iter()
.filter(|l| l.prerequisites.is_some())
.count();
println!(
"\n Total lines: {}, With prerequisites: {}",
pool.lines.len(),
with_prereqs
);
}
+90
View File
@@ -0,0 +1,90 @@
//! Content types for dialogue and monologue pools.
//! Mirrors the JSON Schema definitions in content/_schema/.
//! Fields that appear unused are required for YAML deserialization fidelity.
#![allow(dead_code)]
use serde::Deserialize;
// --- Dialogue Pool ---
#[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>,
pub knowledge_grant: Option<KnowledgeGrant>,
}
#[derive(Debug, Deserialize)]
pub struct KnowledgeGrant {
pub fact_id: String,
pub confidence: String,
}
// --- Monologue Pool ---
#[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,
pub prerequisites: Option<Prerequisites>,
#[serde(default)]
pub priority: Option<i32>,
#[serde(default)]
pub cooldown: Option<i32>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct Prerequisites {
#[serde(default)]
pub facts: Vec<FactPrerequisite>,
#[serde(default)]
pub entity_attributes: Vec<AttributePrerequisite>,
pub relationship: Option<RelationshipPrerequisite>,
}
#[derive(Debug, Deserialize)]
pub struct FactPrerequisite {
pub fact_id: String,
pub min_confidence: String,
}
#[derive(Debug, Deserialize)]
pub struct AttributePrerequisite {
pub entity: String,
pub key: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub struct RelationshipPrerequisite {
pub target: Option<String>,
pub state: Option<String>,
}
@@ -0,0 +1,48 @@
# Test fixture: dock-worker dialogue at The Terminal
location: the-terminal
role: dock-worker
lines:
- id: "terminal_d_001"
text: "Morning. Manifest's ready if you need it."
role: dock-worker
access: [public, insider]
trust: surface
situation: [shift_start, routine]
topic: [routine, cargo]
mood: [comfortable]
- id: "terminal_d_002"
text: "You're new around here? Or just... visiting?"
role: dock-worker
access: [public, authority]
trust: surface
situation: [arrival, social]
topic: [colleague]
mood: [comfortable, suspicious]
- id: "terminal_d_003"
text: "Between you and me, the shift change is when things get... creative."
role: dock-worker
access: [insider]
trust: real
situation: [shift_transition, social]
topic: [cargo, money]
mood: [comfortable]
- id: "terminal_d_004"
text: "I know what's in those crates. And I know you know."
role: dock-worker
access: [insider]
trust: secret
situation: [confrontation, investigation]
topic: [cargo, trust, danger]
mood: [worried, conflicted]
- id: "terminal_d_005"
text: "Just another shift. Nothing special."
role: dock-worker
access: [public]
trust: surface
situation: [routine, shift_start, shift_end]
topic: [routine]
mood: [comfortable]
@@ -0,0 +1,36 @@
# Test fixture: smuggler monologue at The Terminal
character: smuggler
location: the-terminal
lines:
- id: "terminal_m_s_001"
text: "The Terminal. Same hum, same faces, same game."
trigger: enter_location
priority: 5
- id: "terminal_m_s_002"
text: "Kael's at his station. Looks tense today."
trigger: observe_npc
priority: 7
- id: "terminal_m_s_003"
text: "That manifest doesn't add up. Kael knows something."
trigger: observe_anomaly
prerequisites:
facts:
- fact_id: manifest_discrepancy
min_confidence: suspects
priority: 9
- id: "terminal_m_s_004"
text: "Should check in on the next shipment. Can't be too careful."
trigger: time_idle
priority: 3
- id: "terminal_m_s_005"
text: "Something's different today. The usual rhythm is off."
trigger: enter_location
prerequisites:
facts:
- fact_id: ring_pressure_increasing
min_confidence: suspects
priority: 8