Merge branch 'server' into main — Sprint 4 server delivery (#18)
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
},
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
|
||||
+2
-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.
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
+2
-1
@@ -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
|
||||
+101
@@ -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]
|
||||
+106
@@ -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]
|
||||
+88
@@ -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]
|
||||
+49
-1
@@ -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.
|
||||
|
||||
+70
-1
@@ -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.
|
||||
|
||||
+56
-1
@@ -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.
|
||||
|
||||
+63
-1
@@ -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.
|
||||
|
||||
+67
-1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.*
|
||||
Generated
+32
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod bridge;
|
||||
pub mod cause_chain;
|
||||
pub mod content;
|
||||
pub mod knowledge;
|
||||
pub mod npc;
|
||||
pub mod perception;
|
||||
|
||||
@@ -49,6 +49,7 @@ pub enum WantKind {
|
||||
Freedom,
|
||||
Justice,
|
||||
Revenge,
|
||||
Happiness,
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Generated
+349
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
Generated
+376
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user