Compare commits
17 Commits
1304f0aee7
...
bbaa59fad4
| Author | SHA1 | Date | |
|---|---|---|---|
| bbaa59fad4 | |||
| c09b6e89a0 | |||
| f84e55ab4b | |||
| c2d67be76a | |||
| a160d3e967 | |||
| 7ce5e4584c | |||
| 11c4d6dcd7 | |||
| 973af41a6e | |||
| 0542516312 | |||
| e3c35614e7 | |||
| 9141cd1e9f | |||
| f21d7be4d9 | |||
| bd293c1701 | |||
| 40c3a7fc4b | |||
| 16527e9d60 | |||
| db5ee247e7 | |||
| aeb9ee4a9d |
19
.github/workflows/content-build.yml
vendored
Normal file
19
.github/workflows/content-build.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: content-build
|
||||
on:
|
||||
push:
|
||||
paths: ["content/**", "tools/content_build/**", ".github/workflows/content-build.yml"]
|
||||
pull_request:
|
||||
paths: ["content/**", "tools/content_build/**"]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install -r tools/requirements-dev.txt
|
||||
- name: Unit tests
|
||||
run: PYTHONPATH=tools python -m pytest tools/content_build/tests -q
|
||||
- name: Content freshness + validity
|
||||
run: PYTHONPATH=tools python -m content_build --check
|
||||
@@ -8,6 +8,8 @@ var locations: Dictionary = {}
|
||||
var npcs: Dictionary = {}
|
||||
var quests: Dictionary = {}
|
||||
var items: Dictionary = {}
|
||||
var canon_entities: Dictionary = {}
|
||||
var topics: Dictionary = {}
|
||||
|
||||
|
||||
static func default_content_root() -> String:
|
||||
@@ -29,6 +31,8 @@ func load_from(content_root: String) -> void:
|
||||
npcs = _load_dir(world.path_join("npcs"))
|
||||
quests = _load_dir(world.path_join("quests"))
|
||||
items = _load_dir(world.path_join("items"))
|
||||
canon_entities = _load_dir(world.path_join("canon"))
|
||||
topics = _load_dir(world.path_join("topics"))
|
||||
|
||||
|
||||
func _load_dir(dir_path: String) -> Dictionary:
|
||||
@@ -56,6 +60,10 @@ func has_location(id: String) -> bool: return locations.has(id)
|
||||
func has_npc(id: String) -> bool: return npcs.has(id)
|
||||
func has_quest(id: String) -> bool: return quests.has(id)
|
||||
func has_item(id: String) -> bool: return items.has(id)
|
||||
func canon(id: String) -> Dictionary: return canon_entities.get(id, {})
|
||||
func topic(id: String) -> Dictionary: return topics.get(id, {})
|
||||
func has_canon(id: String) -> bool: return canon_entities.has(id)
|
||||
func has_topic(id: String) -> bool: return topics.has(id)
|
||||
|
||||
|
||||
func companions() -> Array:
|
||||
|
||||
@@ -50,3 +50,19 @@ func test_null_quest_ref_resolves():
|
||||
var o := _deserter()
|
||||
o["start_quest_id"] = null
|
||||
assert_eq(db.unresolved_refs(o), [])
|
||||
|
||||
|
||||
func test_loads_canon_and_topics():
|
||||
assert_true(db.has_canon("town.duncarrow"))
|
||||
assert_true(db.has_canon("place.the-white-antlers"))
|
||||
assert_true(db.has_topic("rumor.travelers-go-missing"))
|
||||
|
||||
|
||||
func test_topic_skeleton_has_no_body():
|
||||
var t: Dictionary = db.topic("secret.crell-runs-slave-trade")
|
||||
assert_false(t.has("body")) # bodies are server-only
|
||||
|
||||
|
||||
func test_legacy_npcs_still_load():
|
||||
assert_true(db.has_npc("fenn"))
|
||||
assert_true(db.has_npc("npc.mera-fenn"))
|
||||
|
||||
18
content/lore/canon-roadmap.md
Normal file
18
content/lore/canon-roadmap.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Canon Roadmap
|
||||
|
||||
The world's answer to `docs/roadmap.md`: what is authored vs pending, and the
|
||||
order the Margreave is being written. Source of truth is `content/lore/*.md`;
|
||||
`content/world` + `content/server` are BUILT from it (`python -m content_build`).
|
||||
|
||||
## Authored (status: canon)
|
||||
|
||||
- **duncarrow.md** — Duncarrow (the Tallow Reach market town), the White Antlers
|
||||
shrine, the Crell secret chain, and NPCs Mera Fenn (witness), Mayor Crell
|
||||
(control), Harn Blackwood (texture). The bounded-dialogue / disposition-gate
|
||||
specimen.
|
||||
|
||||
## Pending (not yet authored)
|
||||
|
||||
- The rest of the Tallow Reach region; the elven road-peoples beyond the stub
|
||||
`faction.elves`; the seven worldbuilding topics tracked in the
|
||||
canon-architecture direction.
|
||||
298
content/lore/duncarrow.md
Normal file
298
content/lore/duncarrow.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# DUNCARROW — status: canon
|
||||
|
||||
> The bounded-dialogue / disposition-gate specimen, promoted from
|
||||
> `candidate-town.md`. Every fact_id resolves, every gate is a legal
|
||||
> disposition value, every knowledge link points at a real fact, and nothing
|
||||
> here contradicts existing canon.
|
||||
>
|
||||
> Disposition ladder referenced by all gates:
|
||||
> `hostile / cold / neutral / warm / trusted`
|
||||
>
|
||||
> Two guard axes, kept separate on purpose:
|
||||
> - **secrecy** lives on the fact (author-facing, static, mis-authoring guard)
|
||||
> - **gate** lives on the knowledge link (character-facing, runtime disposition)
|
||||
> `never` is a real gate: the NPC knows the fact but no disposition unlocks it.
|
||||
|
||||
---
|
||||
|
||||
## WORLD RULE (canon dependency)
|
||||
|
||||
```yaml
|
||||
id: rule.disposition-ladder
|
||||
type: rule
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: []
|
||||
rungs: [hostile, cold, neutral, warm, trusted]
|
||||
body: >
|
||||
NPC trust toward the player is one of five ordered states:
|
||||
hostile < cold < neutral < warm < trusted. Where a given player sits with a
|
||||
given NPC is runtime state owned by code. The ladder itself is canon. Every
|
||||
knowledge-link gate references one of these five names, plus the special
|
||||
gate `never` (fact is known but never revealed through dialogue at any rung).
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: region.the-tallow-reach
|
||||
type: region
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: []
|
||||
body: >
|
||||
The Tallow Reach — the lowland march of grain towns and wool roads that
|
||||
Duncarrow sits at the edge of, feeding the pass trade.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: faction.elves
|
||||
type: faction
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: []
|
||||
body: >
|
||||
The elven travellers of the road — no single polity, but the people whose
|
||||
passage through the White Antlers, and its recent absence, the town notices.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TOWN — public layer (tier 0, freely given)
|
||||
|
||||
```yaml
|
||||
id: town.duncarrow
|
||||
type: town
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: [region.the-tallow-reach, place.the-white-antlers, person.mayor-oswin-crell]
|
||||
body: >
|
||||
Duncarrow is a walled market town at the edge of the Tallow Reach, grown fat
|
||||
on the road trade between the lowland grain towns and the pass. Timber and
|
||||
wool go out; salt, iron, and coin come in. Ten militia hold the walls under
|
||||
the mayor's charter. It is, by every visible measure, a prosperous and orderly
|
||||
place, which is exactly the reputation its mayor has spent fifteen years
|
||||
building and depends on.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: place.the-white-antlers
|
||||
type: place
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: [town.duncarrow, faction.elves]
|
||||
body: >
|
||||
An old elven shrine a half-hour's walk north of Duncarrow, in the birch stand
|
||||
off the pass road. Weathered antler-carvings, a dry basin, nothing worth
|
||||
stealing. Elves on the road have historically stopped here. Locals know it as
|
||||
a curiosity and a good spot to water horses.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: person.mayor-oswin-crell
|
||||
type: person
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: [town.duncarrow]
|
||||
body: >
|
||||
Mayor of Duncarrow for fifteen years. Warm, unhurried, remembers names and
|
||||
the names of your dead. Runs a clean council and a clean set of books. Widely
|
||||
liked. The visible locked door of this town: pleasant to every player at every
|
||||
disposition, and revealing of nothing.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## THE SECRET CHAIN (the ladder the town exists to test)
|
||||
|
||||
Ordered from freely-observed to `never`. Each is its own atomic fact so different
|
||||
NPCs can hold the same fact at different gates.
|
||||
|
||||
```yaml
|
||||
id: rumor.elves-avoid-the-shrine
|
||||
type: rumor
|
||||
status: canon
|
||||
secrecy: 1
|
||||
related: [place.the-white-antlers, faction.elves]
|
||||
body: >
|
||||
Elves don't stop at the White Antlers anymore. Have not, for a couple of
|
||||
years. Anyone observant notices; most townsfolk assume the elves simply moved
|
||||
on or the road changed.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: rumor.travelers-go-missing
|
||||
type: rumor
|
||||
status: canon
|
||||
secrecy: 2
|
||||
related: [town.duncarrow, place.the-white-antlers]
|
||||
body: >
|
||||
Small parties on the north road — always small, always few enough not to be
|
||||
missed — sometimes don't arrive where they were going. Locals know it as bad
|
||||
luck on a bad stretch of road. They do not like discussing it.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: fact.militia-never-investigates
|
||||
type: fact
|
||||
status: canon
|
||||
secrecy: 2
|
||||
related: [town.duncarrow, rumor.travelers-go-missing]
|
||||
body: >
|
||||
When travelers go missing near the shrine, the town militia does not ride out.
|
||||
There is always a reason — jurisdiction, weather, the road being the pass's
|
||||
problem not the town's. The pattern is the tell: this is protected, not
|
||||
neglected.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: fact.crells-guard-acts-alone
|
||||
type: fact
|
||||
status: canon
|
||||
secrecy: 3
|
||||
related: [person.mayor-oswin-crell, fact.militia-never-investigates]
|
||||
body: >
|
||||
The mayor keeps three personal guardsmen who answer only to him, separate from
|
||||
the town militia of ten. They come and go on the north road at odd hours on
|
||||
the mayor's word alone. Someone close enough to the militia notices they
|
||||
operate outside its command entirely.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: secret.crell-runs-slave-trade
|
||||
type: secret
|
||||
status: canon
|
||||
secrecy: 4
|
||||
related: [person.mayor-oswin-crell, place.the-white-antlers, faction.elves]
|
||||
body: >
|
||||
Mayor Crell runs an elf-slaving operation. When a small enough elven party
|
||||
camps near the White Antlers — few enough that no one will come looking — his
|
||||
three personal guard take them on the road and Crell sells them into slavery
|
||||
in towns beyond the pass. The town's prosperity and order are partly the
|
||||
proceeds and partly the cover.
|
||||
|
||||
POC CONSTRAINT: this fact is learnable but not actionable in this build. No NPC
|
||||
proposes a move to stop it. The witness's top rung delivers understanding, not
|
||||
a quest. Faction/positional combat that acting on this would require is out of
|
||||
POC scope.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NPC — THE WITNESS (deep — the variable)
|
||||
|
||||
This is the NPC the POC is really testing. The reachable top rung. Whether
|
||||
climbing her ladder feels like relationship or like a lockpick minigame IS the
|
||||
question.
|
||||
|
||||
```yaml
|
||||
id: npc.mera-fenn
|
||||
type: person
|
||||
status: canon
|
||||
secrecy: 0
|
||||
start_disposition: cold
|
||||
related: [town.duncarrow, fact.crells-guard-acts-alone]
|
||||
body: >
|
||||
Mera Fenn runs the north gate's night watch — militia, low rank, overlooked,
|
||||
which is how she sees things she shouldn't. Dry, tired, careful. She saw the
|
||||
mayor's guard bring a covered cart through her gate before dawn and was told,
|
||||
pleasantly, to log it as grain. She has told no one. She is afraid, and her
|
||||
fear reads as prickliness to strangers — she withholds MORE than a stranger
|
||||
would, not less, until you've earned otherwise.
|
||||
|
||||
knows:
|
||||
- {fact: rumor.elves-avoid-the-shrine, gate: neutral}
|
||||
- {fact: rumor.travelers-go-missing, gate: warm}
|
||||
- {fact: fact.militia-never-investigates, gate: warm}
|
||||
- {fact: fact.crells-guard-acts-alone, gate: trusted}
|
||||
# the reachable ceiling
|
||||
# She does NOT hold secret.crell-runs-slave-trade. She saw the guard, the cart,
|
||||
# the pattern — she never saw a sale. Her top rung is naming what she witnessed,
|
||||
# not stating the whole truth. The player infers the secret; the game never
|
||||
# hands it over as a line. This is deliberate: understanding earned, not told.
|
||||
|
||||
disposition_notes: >
|
||||
Starts at `cold`, not `neutral` — she's guarded by default. Hostile is
|
||||
reachable (push her, threaten, side visibly with the mayor) and at hostile she
|
||||
reveals nothing, not even the tier-1 rumor a stranger would get. This is the
|
||||
fear-driven withholding the 5-rung ladder exists for.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NPC — THE MAYOR (medium — the control)
|
||||
|
||||
Holds the secret at `never`. His job is to be the locked door the witness
|
||||
explains. Do not judge him by the aliveness bar; he is the control, not the
|
||||
variable.
|
||||
|
||||
```yaml
|
||||
id: npc.mayor-oswin-crell
|
||||
type: person
|
||||
status: canon
|
||||
secrecy: 0
|
||||
start_disposition: neutral
|
||||
related: [person.mayor-oswin-crell, secret.crell-runs-slave-trade]
|
||||
body: >
|
||||
Same person as person.mayor-oswin-crell; this is his interactive NPC layer.
|
||||
Warm to every player at every rung. Rises to `warm` easily and stalls there —
|
||||
no disposition moves him past it, because there is nothing past it he will give.
|
||||
|
||||
knows:
|
||||
- {fact: rumor.elves-avoid-the-shrine, gate: cold}
|
||||
# dismisses it lightly
|
||||
- {fact: secret.crell-runs-slave-trade, gate: never}
|
||||
# knows; unlocked by no rung
|
||||
- {fact: fact.crells-guard-acts-alone, gate: never}
|
||||
# knows; explains it away if pressed
|
||||
|
||||
disposition_notes: >
|
||||
The `never` gates are the point: he KNOWS these facts, the Improviser can
|
||||
reason with them, and the eight-move vocabulary demonstrably cannot leak them
|
||||
at any disposition. If a player at `trusted` can get anything out of him about
|
||||
the guard or the trade, the bounded-dialogue guarantee has failed and the POC
|
||||
has found a real bug — which is itself a valuable POC result.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NPC — ONE PUBLIC-LAYER TOWNSPERSON (shallow — texture + a clean guarded-personal test)
|
||||
|
||||
Proves the "eloped daughter tier": low-secrecy fact that's still personally
|
||||
guarded, unrelated to the main secret, so you can test disposition-gating on
|
||||
something with no plot weight.
|
||||
|
||||
```yaml
|
||||
id: npc.harn-blackwood
|
||||
type: person
|
||||
status: canon
|
||||
secrecy: 0
|
||||
start_disposition: neutral
|
||||
related: [town.duncarrow]
|
||||
body: >
|
||||
Harn Blackwood, the town smith. Gruff, fair, talks freely about iron, the
|
||||
road, and the price of salt. Carries one small private grief he will not hand
|
||||
a stranger.
|
||||
|
||||
knows:
|
||||
- {fact: rumor.travelers-go-missing, gate: warm}
|
||||
# locals know, say little
|
||||
- {fact: fact.harn-daughter-left, gate: warm}
|
||||
# personal, no plot weight
|
||||
|
||||
disposition_notes: >
|
||||
Control case for the OTHER half of the test: does disposition-gating feel good
|
||||
on a fact that costs nothing to reveal and spoils nothing? If earning the
|
||||
daughter story feels warm and human, gating works. If it feels like grinding a
|
||||
vendor, the mechanic has a problem the plot-secret would only hide.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: fact.harn-daughter-left
|
||||
type: fact
|
||||
status: canon
|
||||
secrecy: 1
|
||||
related: [npc.harn-blackwood]
|
||||
body: >
|
||||
Harn's daughter left Duncarrow two winters ago with a wool trader's caravan
|
||||
and does not write. It shames and grieves him. Spoils nothing; guarded purely
|
||||
because it is his.
|
||||
```
|
||||
5
content/server/npcs/harn-blackwood.json
Normal file
5
content/server/npcs/harn-blackwood.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"disposition_notes": "Control case for the OTHER half of the test: does disposition-gating feel good on a fact that costs nothing to reveal and spoils nothing? If earning the daughter story feels warm and human, gating works. If it feels like grinding a vendor, the mechanic has a problem the plot-secret would only hide.",
|
||||
"id": "npc.harn-blackwood",
|
||||
"persona": "Harn Blackwood, the town smith. Gruff, fair, talks freely about iron, the road, and the price of salt. Carries one small private grief he will not hand a stranger.\n"
|
||||
}
|
||||
5
content/server/npcs/mayor-oswin-crell.json
Normal file
5
content/server/npcs/mayor-oswin-crell.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"disposition_notes": "The `never` gates are the point: he KNOWS these facts, the Improviser can reason with them, and the eight-move vocabulary demonstrably cannot leak them at any disposition. If a player at `trusted` can get anything out of him about the guard or the trade, the bounded-dialogue guarantee has failed and the POC has found a real bug \u2014 which is itself a valuable POC result.",
|
||||
"id": "npc.mayor-oswin-crell",
|
||||
"persona": "Same person as person.mayor-oswin-crell; this is his interactive NPC layer. Warm to every player at every rung. Rises to `warm` easily and stalls there \u2014 no disposition moves him past it, because there is nothing past it he will give.\n"
|
||||
}
|
||||
5
content/server/npcs/mera-fenn.json
Normal file
5
content/server/npcs/mera-fenn.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"disposition_notes": "Starts at `cold`, not `neutral` \u2014 she's guarded by default. Hostile is reachable (push her, threaten, side visibly with the mayor) and at hostile she reveals nothing, not even the tier-1 rumor a stranger would get. This is the fear-driven withholding the 5-rung ladder exists for.",
|
||||
"id": "npc.mera-fenn",
|
||||
"persona": "Mera Fenn runs the north gate's night watch \u2014 militia, low rank, overlooked, which is how she sees things she shouldn't. Dry, tired, careful. She saw the mayor's guard bring a covered cart through her gate before dawn and was told, pleasantly, to log it as grain. She has told no one. She is afraid, and her fear reads as prickliness to strangers \u2014 she withholds MORE than a stranger would, not less, until you've earned otherwise.\n"
|
||||
}
|
||||
5
content/server/topics/crell-runs-slave-trade.json
Normal file
5
content/server/topics/crell-runs-slave-trade.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"body": "Mayor Crell runs an elf-slaving operation. When a small enough elven party camps near the White Antlers \u2014 few enough that no one will come looking \u2014 his three personal guard take them on the road and Crell sells them into slavery in towns beyond the pass. The town's prosperity and order are partly the proceeds and partly the cover.\nPOC CONSTRAINT: this fact is learnable but not actionable in this build. No NPC proposes a move to stop it. The witness's top rung delivers understanding, not a quest. Faction/positional combat that acting on this would require is out of POC scope.",
|
||||
"id": "secret.crell-runs-slave-trade",
|
||||
"secrecy": 4
|
||||
}
|
||||
5
content/server/topics/crells-guard-acts-alone.json
Normal file
5
content/server/topics/crells-guard-acts-alone.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"body": "The mayor keeps three personal guardsmen who answer only to him, separate from the town militia of ten. They come and go on the north road at odd hours on the mayor's word alone. Someone close enough to the militia notices they operate outside its command entirely.",
|
||||
"id": "fact.crells-guard-acts-alone",
|
||||
"secrecy": 3
|
||||
}
|
||||
5
content/server/topics/elves-avoid-the-shrine.json
Normal file
5
content/server/topics/elves-avoid-the-shrine.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"body": "Elves don't stop at the White Antlers anymore. Have not, for a couple of years. Anyone observant notices; most townsfolk assume the elves simply moved on or the road changed.",
|
||||
"id": "rumor.elves-avoid-the-shrine",
|
||||
"secrecy": 1
|
||||
}
|
||||
5
content/server/topics/harn-daughter-left.json
Normal file
5
content/server/topics/harn-daughter-left.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"body": "Harn's daughter left Duncarrow two winters ago with a wool trader's caravan and does not write. It shames and grieves him. Spoils nothing; guarded purely because it is his.",
|
||||
"id": "fact.harn-daughter-left",
|
||||
"secrecy": 1
|
||||
}
|
||||
5
content/server/topics/militia-never-investigates.json
Normal file
5
content/server/topics/militia-never-investigates.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"body": "When travelers go missing near the shrine, the town militia does not ride out. There is always a reason \u2014 jurisdiction, weather, the road being the pass's problem not the town's. The pattern is the tell: this is protected, not neglected.",
|
||||
"id": "fact.militia-never-investigates",
|
||||
"secrecy": 2
|
||||
}
|
||||
5
content/server/topics/travelers-go-missing.json
Normal file
5
content/server/topics/travelers-go-missing.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"body": "Small parties on the north road \u2014 always small, always few enough not to be missed \u2014 sometimes don't arrive where they were going. Locals know it as bad luck on a bad stretch of road. They do not like discussing it.",
|
||||
"id": "rumor.travelers-go-missing",
|
||||
"secrecy": 2
|
||||
}
|
||||
13
content/world/canon/disposition-ladder.json
Normal file
13
content/world/canon/disposition-ladder.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"body": "NPC trust toward the player is one of five ordered states: hostile < cold < neutral < warm < trusted. Where a given player sits with a given NPC is runtime state owned by code. The ladder itself is canon. Every knowledge-link gate references one of these five names, plus the special gate `never` (fact is known but never revealed through dialogue at any rung).",
|
||||
"id": "rule.disposition-ladder",
|
||||
"related": [],
|
||||
"rungs": [
|
||||
"hostile",
|
||||
"cold",
|
||||
"neutral",
|
||||
"warm",
|
||||
"trusted"
|
||||
],
|
||||
"type": "rule"
|
||||
}
|
||||
10
content/world/canon/duncarrow.json
Normal file
10
content/world/canon/duncarrow.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"body": "Duncarrow is a walled market town at the edge of the Tallow Reach, grown fat on the road trade between the lowland grain towns and the pass. Timber and wool go out; salt, iron, and coin come in. Ten militia hold the walls under the mayor's charter. It is, by every visible measure, a prosperous and orderly place, which is exactly the reputation its mayor has spent fifteen years building and depends on.",
|
||||
"id": "town.duncarrow",
|
||||
"related": [
|
||||
"region.the-tallow-reach",
|
||||
"place.the-white-antlers",
|
||||
"person.mayor-oswin-crell"
|
||||
],
|
||||
"type": "town"
|
||||
}
|
||||
6
content/world/canon/elves.json
Normal file
6
content/world/canon/elves.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"body": "The elven travellers of the road \u2014 no single polity, but the people whose passage through the White Antlers, and its recent absence, the town notices.",
|
||||
"id": "faction.elves",
|
||||
"related": [],
|
||||
"type": "faction"
|
||||
}
|
||||
8
content/world/canon/mayor-oswin-crell.json
Normal file
8
content/world/canon/mayor-oswin-crell.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"body": "Mayor of Duncarrow for fifteen years. Warm, unhurried, remembers names and the names of your dead. Runs a clean council and a clean set of books. Widely liked. The visible locked door of this town: pleasant to every player at every disposition, and revealing of nothing.",
|
||||
"id": "person.mayor-oswin-crell",
|
||||
"related": [
|
||||
"town.duncarrow"
|
||||
],
|
||||
"type": "person"
|
||||
}
|
||||
6
content/world/canon/the-tallow-reach.json
Normal file
6
content/world/canon/the-tallow-reach.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"body": "The Tallow Reach \u2014 the lowland march of grain towns and wool roads that Duncarrow sits at the edge of, feeding the pass trade.",
|
||||
"id": "region.the-tallow-reach",
|
||||
"related": [],
|
||||
"type": "region"
|
||||
}
|
||||
9
content/world/canon/the-white-antlers.json
Normal file
9
content/world/canon/the-white-antlers.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"body": "An old elven shrine a half-hour's walk north of Duncarrow, in the birch stand off the pass road. Weathered antler-carvings, a dry basin, nothing worth stealing. Elves on the road have historically stopped here. Locals know it as a curiosity and a good spot to water horses.",
|
||||
"id": "place.the-white-antlers",
|
||||
"related": [
|
||||
"town.duncarrow",
|
||||
"faction.elves"
|
||||
],
|
||||
"type": "place"
|
||||
}
|
||||
18
content/world/npcs/harn-blackwood.json
Normal file
18
content/world/npcs/harn-blackwood.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "npc.harn-blackwood",
|
||||
"knows": [
|
||||
{
|
||||
"fact_id": "rumor.travelers-go-missing",
|
||||
"gate": "warm"
|
||||
},
|
||||
{
|
||||
"fact_id": "fact.harn-daughter-left",
|
||||
"gate": "warm"
|
||||
}
|
||||
],
|
||||
"related": [
|
||||
"town.duncarrow"
|
||||
],
|
||||
"start_disposition": "neutral",
|
||||
"type": "person"
|
||||
}
|
||||
23
content/world/npcs/mayor-oswin-crell.json
Normal file
23
content/world/npcs/mayor-oswin-crell.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "npc.mayor-oswin-crell",
|
||||
"knows": [
|
||||
{
|
||||
"fact_id": "rumor.elves-avoid-the-shrine",
|
||||
"gate": "cold"
|
||||
},
|
||||
{
|
||||
"fact_id": "secret.crell-runs-slave-trade",
|
||||
"gate": "never"
|
||||
},
|
||||
{
|
||||
"fact_id": "fact.crells-guard-acts-alone",
|
||||
"gate": "never"
|
||||
}
|
||||
],
|
||||
"related": [
|
||||
"person.mayor-oswin-crell",
|
||||
"secret.crell-runs-slave-trade"
|
||||
],
|
||||
"start_disposition": "neutral",
|
||||
"type": "person"
|
||||
}
|
||||
27
content/world/npcs/mera-fenn.json
Normal file
27
content/world/npcs/mera-fenn.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "npc.mera-fenn",
|
||||
"knows": [
|
||||
{
|
||||
"fact_id": "rumor.elves-avoid-the-shrine",
|
||||
"gate": "neutral"
|
||||
},
|
||||
{
|
||||
"fact_id": "rumor.travelers-go-missing",
|
||||
"gate": "warm"
|
||||
},
|
||||
{
|
||||
"fact_id": "fact.militia-never-investigates",
|
||||
"gate": "warm"
|
||||
},
|
||||
{
|
||||
"fact_id": "fact.crells-guard-acts-alone",
|
||||
"gate": "trusted"
|
||||
}
|
||||
],
|
||||
"related": [
|
||||
"town.duncarrow",
|
||||
"fact.crells-guard-acts-alone"
|
||||
],
|
||||
"start_disposition": "cold",
|
||||
"type": "person"
|
||||
}
|
||||
9
content/world/topics/crell-runs-slave-trade.json
Normal file
9
content/world/topics/crell-runs-slave-trade.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "secret.crell-runs-slave-trade",
|
||||
"related": [
|
||||
"person.mayor-oswin-crell",
|
||||
"place.the-white-antlers",
|
||||
"faction.elves"
|
||||
],
|
||||
"type": "secret"
|
||||
}
|
||||
8
content/world/topics/crells-guard-acts-alone.json
Normal file
8
content/world/topics/crells-guard-acts-alone.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "fact.crells-guard-acts-alone",
|
||||
"related": [
|
||||
"person.mayor-oswin-crell",
|
||||
"fact.militia-never-investigates"
|
||||
],
|
||||
"type": "fact"
|
||||
}
|
||||
8
content/world/topics/elves-avoid-the-shrine.json
Normal file
8
content/world/topics/elves-avoid-the-shrine.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "rumor.elves-avoid-the-shrine",
|
||||
"related": [
|
||||
"place.the-white-antlers",
|
||||
"faction.elves"
|
||||
],
|
||||
"type": "rumor"
|
||||
}
|
||||
7
content/world/topics/harn-daughter-left.json
Normal file
7
content/world/topics/harn-daughter-left.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "fact.harn-daughter-left",
|
||||
"related": [
|
||||
"npc.harn-blackwood"
|
||||
],
|
||||
"type": "fact"
|
||||
}
|
||||
8
content/world/topics/militia-never-investigates.json
Normal file
8
content/world/topics/militia-never-investigates.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "fact.militia-never-investigates",
|
||||
"related": [
|
||||
"town.duncarrow",
|
||||
"rumor.travelers-go-missing"
|
||||
],
|
||||
"type": "fact"
|
||||
}
|
||||
8
content/world/topics/travelers-go-missing.json
Normal file
8
content/world/topics/travelers-go-missing.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "rumor.travelers-go-missing",
|
||||
"related": [
|
||||
"town.duncarrow",
|
||||
"place.the-white-antlers"
|
||||
],
|
||||
"type": "rumor"
|
||||
}
|
||||
1440
docs/superpowers/plans/2026-07-11-content-canon-build-tool.md
Normal file
1440
docs/superpowers/plans/2026-07-11-content-canon-build-tool.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
# Content & Canon Build Tool — Design
|
||||
|
||||
- **Date:** 2026-07-11
|
||||
- **Status:** Design approved; not yet planned/implemented.
|
||||
- **Implements:** the build tool whose *contract* was fixed by
|
||||
[`2026-07-11-content-canon-schema-design.md`](2026-07-11-content-canon-schema-design.md)
|
||||
(the locked schema — envelope, two categories, NPC layer, gate logic,
|
||||
role-based split, canon home, promotion/validation). **That schema is locked;
|
||||
this doc does not re-litigate it** — it specifies the compiler that realizes it.
|
||||
- **Specimen:** `candidate-town.md` (Duncarrow) at repo root — the bible the tool
|
||||
parses and, as its acceptance test, promotes.
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Build the **compiler** that turns the authored Markdown bible
|
||||
(`content/lore/*.md`, prose + fenced `yaml` blocks) into validated runtime JSON,
|
||||
split by role into a client tree and a server-only tree, with validation as a
|
||||
hard gate. Promote Duncarrow as the tool's first real use and end-to-end proof.
|
||||
|
||||
**Charter alignment:** §2 (code owns state, AI owns text — this is a build-time
|
||||
transform, no runtime state), §4 (the server/ split keeps spoilers + personas off
|
||||
the client), §6 (the gate skeleton feeds `available_moves`), §16 (`/content`
|
||||
layout, "prompts/content are source, reviewed and versioned"), §18 (branch off
|
||||
`dev`).
|
||||
|
||||
---
|
||||
|
||||
## Locked decisions (from the brainstorm)
|
||||
|
||||
| # | Decision | Choice |
|
||||
|---|---|---|
|
||||
| L1 | Language / runtime | **Python**, standalone package under `tools/` — reuses the repo's Python + `jsonschema`/pytest stack; no Godot dependency to run a build; not bundled into the `api` proxy image. |
|
||||
| L2 | Output layout | **Per-category dirs** that fit the existing readers. Minimal, additive loader change; no per-town bundle rewrite. |
|
||||
| L3 | When it runs | **Manual command + CI `--check`.** Built JSON is committed; CI fails on stale-or-invalid. |
|
||||
| L4 | `start_disposition` → integer | **Band midpoint.** *Refinement (L4a):* only the client needs band integers at runtime, so the midpoint mapping lives client-side; the build stays **number-free** and emits the rung name. |
|
||||
| L5 | Promote Duncarrow this cycle | **Yes** — the tool's first real use and acceptance test. |
|
||||
|
||||
### Refinements settled in the brainstorm (explicitly approved)
|
||||
|
||||
- **L4a — the build needs zero disposition numbers.** The only runtime consumer
|
||||
of band *integers* is the client (it owns disposition state and computes
|
||||
`available_moves`). The build validates gate **names** only. So band numbers +
|
||||
the rung→midpoint mapping live in **one** client GDScript const (the
|
||||
`palette.gd` pattern); the build emits `start_disposition` as the **rung name**
|
||||
and passes it through. Legal rung names are read from a structured `rungs: [...]`
|
||||
field on `rule.disposition-ladder` in the bible — the bible owns names/order
|
||||
(D1), code owns numbers, and there is no cross-language table to keep in sync.
|
||||
- **L5a — the validator rejects `knows → canon-entity`.** Spec category A: canon
|
||||
entities never appear in a `knows` list. The specimen's Mayor and Harn carry
|
||||
`knows: town.duncarrow`; the validator enforces the rule and would reject them.
|
||||
Promotion **drops those links** (the town is public/tier-0, spoken freely, and
|
||||
needs no gated knows-link). This is intended — the validator catching real
|
||||
authoring drift.
|
||||
|
||||
---
|
||||
|
||||
## Scope boundary
|
||||
|
||||
**In scope (this cycle):**
|
||||
|
||||
- The `tools/content_build/` compiler: parse → resolve/validate → emit → split.
|
||||
- The validation gate (all rules below), abort-on-failure with source-line context.
|
||||
- A `--check` mode for CI (stale-or-invalid → nonzero exit).
|
||||
- Promoting Duncarrow: `candidate-town.md` → `content/lore/duncarrow.md`,
|
||||
reformat blocks to valid YAML, drop the `knows → canon-entity` links, add
|
||||
`rungs:` to the ladder, flip entries `candidate → canon`, add
|
||||
`content/lore/canon-roadmap.md`.
|
||||
- Committing the built `content/world/**` + `content/server/**` artifacts.
|
||||
- A **minimal, additive** `content_db.gd` change so the client loader tolerates
|
||||
the new `canon/` and `topics/` dirs (existing dirs + legacy `fenn.json`
|
||||
untouched).
|
||||
|
||||
**Out of scope (next cycle / deferred):**
|
||||
|
||||
- **Runtime consumption** — the client computing gated `available_moves` from the
|
||||
new npc skeletons, the api reading personas from `content/server/`, and voicing
|
||||
Mera Fenn end-to-end. Promotion proves the **compiler**, not the runtime.
|
||||
- **Legacy Fenn migration** — `content/world/npcs/fenn.json`'s old
|
||||
persona+knowledge+capabilities shape stays as-is; it is superseded when Fenn
|
||||
gets his own bible (flagged deferred in the schema doc).
|
||||
- **Godot export packaging** — deliberately including `world/` and excluding
|
||||
`server/` from the client export. `/content` already sits *outside* `res://`,
|
||||
so `server/` is out of the export by default; the packaging step is the
|
||||
pre-existing debt, **unblocked** by this clean split but not built here.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
`tools/content_build/` — a Python package, run as `python -m content_build`.
|
||||
|
||||
```
|
||||
tools/content_build/
|
||||
__init__.py
|
||||
__main__.py # CLI entry: build [--check] [--lore DIR] [--world DIR] [--server DIR]
|
||||
parse.py # Markdown → list of raw entries (dict + source line)
|
||||
model.py # Entry / NpcLayer / KnowsLink dataclasses; envelope + category rules
|
||||
resolve.py # id map, cross-ref resolution, the validation gate
|
||||
rungs.py # reads ladder `rungs:`; legal-gate set = rungs + {never}
|
||||
emit.py # route fields per D4; serialize the two JSON trees
|
||||
errors.py # BuildError carrying (entry id, source file:line, message)
|
||||
tests/
|
||||
fixtures/ # tiny valid + deliberately-broken bibles
|
||||
test_parse.py
|
||||
test_resolve.py # one test per gate rule (each broken fixture)
|
||||
test_emit.py # split routing + secrecy safety-check
|
||||
test_duncarrow.py # the real end-to-end round-trip
|
||||
```
|
||||
|
||||
Dependencies: `PyYAML` (block parsing). Added to a **dev** requirements file,
|
||||
not `api/requirements.txt` — the proxy runtime never imports the build tool.
|
||||
|
||||
Each module has one job and a narrow interface:
|
||||
|
||||
- **parse** — `parse_bible(path) -> list[RawEntry]`. Knows Markdown fences and
|
||||
YAML; knows nothing about the schema's meaning.
|
||||
- **model** — the dataclasses + envelope/category predicates. Pure data + rules,
|
||||
no I/O.
|
||||
- **resolve** — `resolve(entries) -> World | raises BuildError`. The gate. Pure
|
||||
function over parsed entries; no file writes.
|
||||
- **rungs** — the one place that knows the legal gate vocabulary, sourced from the
|
||||
ladder entry.
|
||||
- **emit** — `emit(world, world_dir, server_dir)`. Routing + serialization only.
|
||||
- **__main__** — orchestration + `--check` diffing + exit codes.
|
||||
|
||||
---
|
||||
|
||||
## Pipeline
|
||||
|
||||
### 1. Parse
|
||||
|
||||
For each `content/lore/*.md`: extract every ` ```yaml `-fenced block, `yaml.safe_load`
|
||||
each into a dict, attach source `file:line`. Prose between blocks is ignored. One
|
||||
block = one entry.
|
||||
|
||||
The promoted bible **must contain valid YAML** — the specimen's current
|
||||
`- fact: x gate: y` one-liners are *not* valid YAML and become
|
||||
`{fact: x, gate: y}` (or the two-line block form) during promotion (spec D3:
|
||||
"formalized as real YAML so parsing is trivial").
|
||||
|
||||
### 2. Resolve & validate — the gate
|
||||
|
||||
Build the id → entry map, then enforce every rule below. **Any failure aborts the
|
||||
build, emits nothing, and prints the offending id + source `file:line`.**
|
||||
|
||||
- **No id collision** — two entries with the same `id` → fail.
|
||||
- **id/type agreement** — the `id` prefix must be a **legal namespace** from the
|
||||
known set `{town, place, region, faction, person, rule, rumor, fact, secret,
|
||||
npc}`, and `type` must be **consistent** with it: prefix `npc` → `type` is
|
||||
`person` (the interactive layer vs the `person.*` world-record); otherwise the
|
||||
prefix equals `type`. A non-namespace prefix (e.g. the specimen's `shrine.`) →
|
||||
fail; such ids are renamed to their type namespace at promotion
|
||||
(`shrine.the-white-antlers` → `place.the-white-antlers`).
|
||||
- **`related` resolves** — every id in every `related` list is a real entry
|
||||
**defined in the built world** (strict, per schema §Build). The specimen
|
||||
references `region.the-tallow-reach` and `faction.elves` without defining them;
|
||||
promotion adds minimal canon stubs for both so the graph stays closed (there is
|
||||
no forward-ref-to-pending concept — the closed graph is the invariant).
|
||||
- **NPC-layer shape** — `npc.*` entries carry `knows` **and** `start_disposition`;
|
||||
`person.*` records carry **neither**.
|
||||
- **`knows` targets** — every `knows[].fact` resolves to a **knowledge entry**
|
||||
(`rumor`/`fact`/`secret`). A canon-entity id in a `knows` list → fail (L5a).
|
||||
- **Gate legality** — every `knows[].gate` ∈ `rungs` (read from
|
||||
`rule.disposition-ladder`) ∪ `{never}`.
|
||||
- **Ladder coverage** — exactly one `rule.disposition-ladder` entry exists and
|
||||
carries a non-empty `rungs:` list.
|
||||
- **Secrecy safety-check** — after routing (stage 4), no `secrecy ≥ 3` body may
|
||||
appear in any client (`content/world/**`) artifact → fail. Defense-in-depth
|
||||
behind the structural routing, not the router itself (schema D4).
|
||||
- **`start_disposition` rung legality** — the rung is one of `rungs`.
|
||||
|
||||
Contradiction-with-existing-canon is **author-asserted**, not auto-checked — it is
|
||||
resolved by the human at the `candidate → canon` promotion (schema §Build).
|
||||
|
||||
### 3. Emit
|
||||
|
||||
Only `status: canon` entries build into artifacts. `candidate` entries are
|
||||
dev-visible (they parse and validate) but are **not emitted** — with an
|
||||
all-`candidate` bible the artifacts are empty, which is why L5 promotes Duncarrow
|
||||
to `canon` this cycle.
|
||||
|
||||
### 4. Split (routing per schema D4)
|
||||
|
||||
**Client — `content/world/`** (bundled in the export; also readable by the server):
|
||||
|
||||
| Dir | Contents |
|
||||
|---|---|
|
||||
| `npcs/<slug>.json` | npc **skeleton**: `{id, type:"person", start_disposition:"<rung>", related, knows:[{fact_id, gate}]}` — no persona/body |
|
||||
| `canon/<slug>.json` | each public canon entity (`town/place/region/faction/person/rule`) **whole**, including its public `body` and (for the ladder) `rungs` |
|
||||
| `topics/<slug>.json` | knowledge-entry **skeleton**: `{id, type, related}` — **no body** |
|
||||
|
||||
**Server — `content/server/`** (server-only; excluded from the Godot export):
|
||||
|
||||
| Dir | Contents |
|
||||
|---|---|
|
||||
| `npcs/<slug>.json` | `{id, persona:<body>, disposition_notes}` |
|
||||
| `topics/<slug>.json` | `{id, body, secrecy}` — the knowledge bodies `/npc/speak` voices |
|
||||
|
||||
- Canon-entity `body` is **public** (category A, `secrecy:0`) → ships in
|
||||
`world/canon/`.
|
||||
- Knowledge-entry `body` and NPC persona/`disposition_notes` → `content/server/`
|
||||
only.
|
||||
- The server process reads **both** trees (it has full filesystem access); only
|
||||
the **client export** excludes `content/server/`.
|
||||
|
||||
`<slug>` = the id with its `<type>.`/`npc.` prefix stripped
|
||||
(`npc.mera-fenn` → `mera-fenn`, `town.duncarrow` → `duncarrow`).
|
||||
|
||||
---
|
||||
|
||||
## Client loader change (minimal, additive)
|
||||
|
||||
`client/scripts/content/content_db.gd` gains two dirs:
|
||||
|
||||
- `load_from()` also loads `world/canon/` and `world/topics/` into generic
|
||||
id-keyed dictionaries (`canon`, `topics`), mirroring the existing
|
||||
`_load_dir` pattern.
|
||||
- Existing `locations/npcs/quests/items` loading is untouched; the legacy
|
||||
`fenn.json` still loads (it has an `id`, which is all `_load_dir` requires).
|
||||
- Add `canon(id)` / `topic(id)` / `has_canon` / `has_topic` accessors.
|
||||
|
||||
No api change this cycle — runtime consumption of the new shapes is next cycle
|
||||
(scope boundary).
|
||||
|
||||
---
|
||||
|
||||
## CLI & CI
|
||||
|
||||
- `python -m content_build` — rebuild `content/world/**` + `content/server/**`
|
||||
from `content/lore/*.md`.
|
||||
- `python -m content_build --check` — build to memory, diff against the committed
|
||||
JSON, exit **nonzero** if the tree is **stale** (would-differ) **or** validation
|
||||
fails. This is the CI gate.
|
||||
- Dir overrides (`--lore/--world/--server`) exist for tests to run against
|
||||
fixtures in a temp dir.
|
||||
|
||||
Built JSON is **committed** (schema/prompt "content is source, reviewed" culture);
|
||||
`--check` keeps the committed artifacts honest.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- **Parse** — a fixture bible with prose + multiple `yaml` blocks parses to the
|
||||
expected entry list with source lines.
|
||||
- **Resolve** — **one deliberately-broken fixture per gate rule** (id collision,
|
||||
bad id/type, dangling `related`, `person.*` with `knows`, `knows → canon-entity`,
|
||||
illegal gate, missing ladder, `secrecy ≥ 3` body routed to client), each
|
||||
asserting the specific `BuildError` + that nothing is emitted.
|
||||
- **Emit** — a valid fixture routes every field to the correct tree; the
|
||||
secrecy safety-check passes for a legal body and fails for an illegal one.
|
||||
- **Duncarrow round-trip** (`test_duncarrow.py`) — the real acceptance test: the
|
||||
promoted `content/lore/duncarrow.md` builds green, and the emitted client/server
|
||||
JSON matches expectations (npc skeletons carry no persona; the `secret.crell-*`
|
||||
body lands only in `content/server/topics/`; `world/canon/` holds the public
|
||||
entities incl. the ladder `rungs`).
|
||||
|
||||
---
|
||||
|
||||
## Promotion checklist (Duncarrow — the acceptance run)
|
||||
|
||||
1. `git mv candidate-town.md content/lore/duncarrow.md`.
|
||||
2. Reformat every fenced block to a ` ```yaml ` fence with valid YAML (fix the
|
||||
`knows` one-liners to `{fact, gate}`).
|
||||
3. Drop the `knows: town.duncarrow` links from Mayor and Harn (L5a).
|
||||
4. Rename `shrine.the-white-antlers` → `place.the-white-antlers` and update every
|
||||
`related` reference to it (id/type-agreement rule).
|
||||
5. Add canon stubs for `region.the-tallow-reach` and `faction.elves` so all
|
||||
`related` ids resolve (closed-graph rule).
|
||||
6. Drop `secret.crell-runs-slave-trade` from the **public** `person.mayor-oswin-crell`
|
||||
record's `related` — a public record must not point at a secret; that graph
|
||||
edge lives on the secret entry (server-side). The `npc.*` layer still holds the
|
||||
secret at `gate: never`, which is the actual bounded-move test.
|
||||
7. Add `rungs: [hostile, cold, neutral, warm, trusted]` to
|
||||
`rule.disposition-ladder`.
|
||||
8. Add `content/lore/canon-roadmap.md` (the authored-vs-pending index, schema D5).
|
||||
9. Run the tool; fix validation errors; flip each entry `candidate → canon` as it
|
||||
passes.
|
||||
10. Commit the promoted bible + built `content/world/**` + `content/server/**`.
|
||||
11. `--check` is green in CI.
|
||||
|
||||
---
|
||||
|
||||
## Open / deferred (carried, not decided here)
|
||||
|
||||
- **Band numbers** — tunable placeholder in the client const; settle by playtest
|
||||
(schema D1). The build never sees them (L4a).
|
||||
- **Runtime consumption / legacy Fenn migration / export packaging** — the scope
|
||||
boundary above; each is its own later cycle.
|
||||
- **`make`/`just` wrapper** — a convenience target for the build command can be
|
||||
added when a task runner lands; not required for this cycle.
|
||||
- **ids are self-describing (spoiler property, schema-level).** Per D4 the gate
|
||||
skeleton ships each NPC's `knows:[{fact_id, gate}]` to the client, so a
|
||||
descriptive slug like `secret.crell-runs-slave-trade` reaches the client binary
|
||||
even when held at `gate: never` (the body never does). This is a property of the
|
||||
**locked** schema, not the build tool — opaque/hashed topic ids are a possible
|
||||
future hardening if datamining becomes a concern. Noted, not reopened.
|
||||
255
docs/superpowers/specs/2026-07-11-content-canon-schema-design.md
Normal file
255
docs/superpowers/specs/2026-07-11-content-canon-schema-design.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Content & Canon Schema — Design
|
||||
|
||||
- **Date:** 2026-07-11
|
||||
- **Status:** Design approved; not yet planned/implemented.
|
||||
- **Specimen:** `candidate-town.md` (Duncarrow) at repo root — the example town that
|
||||
drives this schema bottom-up.
|
||||
- **Supersedes/realizes:** the `canon-architecture-direction` memory (the storage
|
||||
architecture agreed in discussion; this doc is the concrete schema built from the
|
||||
specimen). Consumes the disposition-integer model from
|
||||
[`2026-07-10-bounded-npc-conversation-design.md`](2026-07-10-bounded-npc-conversation-design.md)
|
||||
and the canon-log contract from
|
||||
[`2026-07-09-canon-log-schema-design.md`](2026-07-09-canon-log-schema-design.md).
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Lock the **content schema** for authored world canon, using Duncarrow as the
|
||||
specimen. Decide the authoring format, the physical client/server (spoiler) split,
|
||||
and the canon home in the repo. Reconcile the town's named-rung disposition gates
|
||||
with the engine's integer disposition.
|
||||
|
||||
This is a **schema lock**, not a content-authoring pass. Duncarrow's content
|
||||
correctness is secondary; its *shape* is the deliverable.
|
||||
|
||||
**Charter alignment:** §2 (code owns state, AI owns text), §4 (proxy hides
|
||||
spoilers/prompts), §6 (bounded NPC dialogue, `available_moves`), §11 (canon log),
|
||||
§16 (repo layout, `/content`). Nothing here introduces a new runtime system — the
|
||||
schema is an **authoring layer over §6**.
|
||||
|
||||
---
|
||||
|
||||
## Decisions (the lock)
|
||||
|
||||
### D1 — Disposition: named rungs are bands over the integer
|
||||
|
||||
The engine stores disposition as an integer (−100..100); `adjust_disposition` does
|
||||
integer math (clamped ±15; `become_hostile` = −100 floor). The town authors gates as
|
||||
**named rungs**. These meet by defining threshold **bands** over the integer:
|
||||
|
||||
| Rung | Integer band |
|
||||
|---|---|
|
||||
| hostile | ≤ −45 |
|
||||
| cold | −44 .. −15 |
|
||||
| neutral | −14 .. +14 |
|
||||
| warm | +15 .. +44 |
|
||||
| trusted | ≥ +45 |
|
||||
|
||||
- The **integer is the runtime truth.** Bands are an evaluation/authoring layer.
|
||||
`adjust_disposition` math is unchanged.
|
||||
- **Code owns the numbers** (one shared constant, single source — same pattern as
|
||||
`palette.gd` for colour). The **bible owns the rung names/order** via
|
||||
`rule.disposition-ladder`.
|
||||
- The band numbers above are a **tunable placeholder** — the model is fixed, the
|
||||
exact cut points get playtested. Everything else in this doc is the lock.
|
||||
- `never` is a real gate that **no band satisfies** — the fact is held but `reveal`
|
||||
never admits it at any disposition.
|
||||
|
||||
### D2 — A town "fact" is an engine "topic" (authoring layer over §6)
|
||||
|
||||
A gated knowledge entry **is** a §6 reveal-topic. `fact_id = topic_id`. The `gate`
|
||||
is the disposition-band condition that admits `reveal(fact_id)` into that NPC's
|
||||
`available_moves`. `never` = held-but-never-admitted. **No new runtime system** — the
|
||||
schema feeds gate logic the client already runs.
|
||||
|
||||
### D3 — Authoring format: Markdown bible → build → JSON
|
||||
|
||||
- **Source of truth:** a readable Markdown doc per town/topic — section headers,
|
||||
prose commentary between blocks — with **fenced YAML blocks** for the structured
|
||||
entries (Duncarrow's exact form, with the blocks formalized as real YAML so
|
||||
parsing is trivial).
|
||||
- **Runtime:** JSON (the client `ContentDB` and the API content resolver already
|
||||
read JSON). A **build step** extracts the YAML blocks and emits JSON.
|
||||
- One source, reads as a bible, machine-read as data.
|
||||
|
||||
### D4 — Client/server split: by role, with `secrecy` as a safety check
|
||||
|
||||
The build sorts each piece by **what it is for**, not by a number:
|
||||
|
||||
- **Ships in the game** (`content/world`, bundled in the client export):
|
||||
- Public **canon entities** — whole entry (town/place/region/faction/person-record/rule).
|
||||
- The **gate skeleton** of knowledge entries — `id`, `type`, `related`, and each
|
||||
NPC's `knows: [{fact_id, gate}]`. Enough to compute `available_moves`. **No body.**
|
||||
- **Stays on the server** (`content/server`, API-only, excluded from the client export):
|
||||
- The **body** of every knowledge entry (rumor/fact/secret) — it is an NPC
|
||||
knowledge payload, spoken through `/npc/speak`, never rendered by the client.
|
||||
- NPC **persona/voice** bodies and `disposition_notes` (§6: personas are server-only).
|
||||
|
||||
`secrecy` (0..4) is the author's spoiler rating and becomes a **build-time safety
|
||||
check**, not the router: if a `secrecy:≥3` body lands in a game artifact, **the build
|
||||
fails**. Defense in depth — structural routing does the sort, secrecy lints it.
|
||||
|
||||
### D5 — Canon home
|
||||
|
||||
```
|
||||
content/
|
||||
lore/ # authored Markdown bibles (SOURCE OF TRUTH)
|
||||
canon-roadmap.md # index: authored-vs-pending, sequences the world
|
||||
duncarrow.md # <- candidate-town.md, promoted here
|
||||
world/ # BUILT, ships in the game (client-bundled)
|
||||
duncarrow.client.json
|
||||
server/ # BUILT, api-only (excluded from Godot export)
|
||||
duncarrow.server.json
|
||||
```
|
||||
|
||||
- Authored bibles: `content/lore/*.md`.
|
||||
- Built game artifact: `content/world/` — where the client already reads.
|
||||
- Built server artifact: `content/server/` — the Godot export must **exclude** this
|
||||
subtree (same class of concern as the deferred "Fallback-JSON export packaging"
|
||||
and "spoilers-in-client-bundle" notes; this schema is the trigger to make the split
|
||||
real).
|
||||
|
||||
---
|
||||
|
||||
## Schema reference
|
||||
|
||||
### Common envelope (every entry)
|
||||
|
||||
```yaml
|
||||
id: <type>.<slug> # namespaced, unique. e.g. town.duncarrow, npc.mera-fenn
|
||||
type: town | place | region | faction | person | rule # canon entities
|
||||
| rumor | fact | secret # knowledge entries
|
||||
status: candidate | canon # only `canon` builds into shipping artifacts
|
||||
secrecy: 0..4 # author's spoiler rating; drives the D4 safety check
|
||||
related: [<id>, ...] # graph links; every id must resolve
|
||||
body: > # prose (routing per D4)
|
||||
```
|
||||
|
||||
### Two categories
|
||||
|
||||
**A. Canon entities** — `town/place/region/faction/person/rule`. Public world
|
||||
objects. `secrecy:0`. Whole entry → game (`content/world`). Never appear in a
|
||||
`knows` list; referenced via `related`.
|
||||
|
||||
**B. Knowledge entries** — `rumor/fact/secret`. Gated, speakable. Atomic, so
|
||||
different NPCs can hold the same entry at different gates. Skeleton → game; **body →
|
||||
server**. `secrecy` free to be 0..4.
|
||||
|
||||
### NPC layer
|
||||
|
||||
An NPC is a `person`-type entry with a knowledge layer, kept **separate** from its
|
||||
world-record `person.*` (a being can have both — e.g. `person.mayor-oswin-crell`
|
||||
the record and `npc.mayor-oswin-crell` the interactive layer). The build
|
||||
distinguishes the two by **id prefix**: `npc.*` is an interactive layer (carries
|
||||
`knows` + `start_disposition`); `person.*` is a public world-record (canon entity,
|
||||
category A). A `person.*` record never carries a `knows` list.
|
||||
|
||||
```yaml
|
||||
id: npc.mera-fenn
|
||||
type: person
|
||||
status: candidate
|
||||
secrecy: 0
|
||||
start_disposition: cold # rung a stranger meets them at (mapped to an integer)
|
||||
related: [town.duncarrow]
|
||||
body: > # persona / voice — SERVER ONLY (§6)
|
||||
knows:
|
||||
- fact: rumor.elves-avoid-the-shrine gate: neutral
|
||||
- fact: rumor.travelers-go-missing gate: warm
|
||||
- fact: fact.crells-guard-acts-alone gate: trusted # reachable ceiling
|
||||
- fact: secret.crell-runs-slave-trade gate: never # not held / never told
|
||||
disposition_notes: > # author-facing — SERVER ONLY
|
||||
```
|
||||
|
||||
- **Client receives per NPC:** `{ id, start_disposition, knows: [{fact_id, gate}] }`.
|
||||
- **Server receives per NPC:** persona `body`, `disposition_notes`, and the resolved
|
||||
fact **bodies** (fed to the `/npc/speak` prompt as the knowledge list).
|
||||
|
||||
### Gate logic (the whole of it)
|
||||
|
||||
`reveal(fact_id)` enters an NPC's `available_moves` **iff**:
|
||||
|
||||
1. the NPC `knows` `fact_id`, **and**
|
||||
2. `gate ≠ never`, **and**
|
||||
3. `band(current_disposition) ≥ band(gate)`, **and**
|
||||
4. `fact_id` is not already in `revealed_topics`.
|
||||
|
||||
This is §6's existing `available_moves` ("reveal if not revealed") enriched with the
|
||||
band-gate check (2–3). No other runtime change.
|
||||
|
||||
### The disposition rule entry
|
||||
|
||||
`rule.disposition-ladder` (type `rule`, canon entity) states the five rung
|
||||
names and their order plus the `never` gate — the **semantic** ladder. The integer
|
||||
band numbers live in **code** (D1), not the bible.
|
||||
|
||||
---
|
||||
|
||||
## Build & validation
|
||||
|
||||
The build step (`content/lore/*.md` → `content/world` + `content/server`):
|
||||
|
||||
1. **Parse** — extract fenced YAML blocks from each bible; ignore prose commentary.
|
||||
2. **Resolve & validate (promotion gate)** — refuse to emit unless:
|
||||
- every `knows` `fact_id` resolves to a real knowledge entry;
|
||||
- every `gate` is a legal rung (`hostile/cold/neutral/warm/trusted`) or `never`;
|
||||
- every `related` id resolves;
|
||||
- no id collision;
|
||||
- **secrecy safety check** — no `secrecy:≥3` body appears in a game artifact.
|
||||
3. **Emit** — only `status: canon` entries build into shipping artifacts;
|
||||
`candidate` entries are dev-only (surfaced, not shipped).
|
||||
4. **Split** — route each field per D4 into `*.client.json` (game) and
|
||||
`*.server.json` (api-only).
|
||||
|
||||
Contradiction-with-existing-canon is **author-asserted** (not auto-checkable) — the
|
||||
promotion from `candidate` to `canon` is where a human confirms it.
|
||||
|
||||
---
|
||||
|
||||
## The Duncarrow specimen (worked example)
|
||||
|
||||
`candidate-town.md` exercises every part of the schema:
|
||||
|
||||
- **Canon entities:** `town.duncarrow`, `shrine.the-white-antlers` (place),
|
||||
`person.mayor-oswin-crell` (record), `rule.disposition-ladder`.
|
||||
- **A secret chain** of atomic knowledge entries at rising secrecy (1→4):
|
||||
`rumor.elves-avoid-the-shrine` → `rumor.travelers-go-missing` →
|
||||
`fact.militia-never-investigates` → `fact.crells-guard-acts-alone` →
|
||||
`secret.crell-runs-slave-trade`.
|
||||
- **The witness** (`npc.mera-fenn`, `start_disposition: cold`) whose reachable
|
||||
ceiling is `fact.crells-guard-acts-alone` at `trusted` — she never holds the
|
||||
`secret` itself. The player **infers** the trade; the game never hands it over as
|
||||
a line.
|
||||
- **The control** (`npc.mayor-oswin-crell`) holding the secret at `gate: never` — a
|
||||
live test that the bounded-move vocabulary cannot leak it at any band.
|
||||
- **The texture case** (`npc.harn-blackwood`) with a low-stakes personal fact
|
||||
(`fact.harn-daughter-left`, secrecy 1, `gate: warm`) — tests disposition-gating on
|
||||
something that spoils nothing.
|
||||
|
||||
Promoting Duncarrow = moving `candidate-town.md` to `content/lore/duncarrow.md`,
|
||||
flipping `status: candidate → canon` per entry as each passes validation.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (this schema)
|
||||
|
||||
- **Acting on** the Crell secret — no quest/faction/positional combat to stop the
|
||||
trade (the specimen's own POC constraint). The witness's ceiling delivers
|
||||
understanding, not a quest.
|
||||
- **Content authoring** beyond the specimen — new towns/NPCs are their own passes.
|
||||
- **The build tool implementation** — this doc specifies its contract; the code is a
|
||||
separate spec → plan → implementation cycle.
|
||||
|
||||
---
|
||||
|
||||
## Open / deferred
|
||||
|
||||
- **Band numbers** (D1) — tunable placeholder, settle by playtest.
|
||||
- **`start_disposition` → integer** — a rung maps to a representative integer for
|
||||
the runtime start (e.g. the band's midpoint, or its lower edge). Pick at
|
||||
implementation.
|
||||
- **Existing `fenn.json` migration** — today it crams server persona + client
|
||||
capabilities in one hand-authored file (the flagged debt). The build supersedes it
|
||||
with generated `*.client.json` / `*.server.json`; migrate Fenn when the build lands.
|
||||
- **Canon-log `race` field / class-id enum widening** — tracked in the races &
|
||||
classes spec, not here.
|
||||
228
docs/superpowers/specs/2026-07-11-races-and-classes-design.md
Normal file
228
docs/superpowers/specs/2026-07-11-races-and-classes-design.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Races & Classes — mechanical design
|
||||
|
||||
**Date:** 2026-07-11
|
||||
**Status:** Approved (design). Feeds M4 (character creation); combat kit hands off to M5.
|
||||
**Charter refs:** §7 (Luck), §8 (stats & classes), §10 (combat), §11 (canon log), §16/§17 (mockups + the flagged reconciles).
|
||||
|
||||
This spec settles the **mechanics** of races and callings so M4 character creation has
|
||||
rules to read. It resolves the §17 class-name reconcile (charter §8 vs the mock's
|
||||
callings) and gives every race trait a concrete rule. **Lore/flavor is deliberately
|
||||
out of scope** — it lives in a separate canon/worldbuilding effort; placeholders here
|
||||
are marked `[CANON-TBD]`.
|
||||
|
||||
Scope choice (agreed): **full mechanical spec** — everything creation + the character
|
||||
sheet read. The **combat ability kit is deferred to M5** (the action economy, damage
|
||||
resolution, and DC bands aren't designed yet); this spec marks that seam everywhere it
|
||||
touches it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Foundations (the resolution spine)
|
||||
|
||||
### Attributes (6)
|
||||
|
||||
`STR · DEX · CON · FTH · MAG · LCK`. Modifier = `floor((score − 10) / 2)` (matches the
|
||||
Character Sheet mock's "score N" + modifier display). FTH/MAG replace WIS/INT (§8).
|
||||
|
||||
### Saving throws (5, not 6)
|
||||
|
||||
One save per attribute **except LCK**. LCK is never a save and never rolled — it is the
|
||||
borderline-selector (§7). Saves: **STR · DEX · CON · FTH · MAG**. This keeps §7 airtight:
|
||||
LCK cannot leak onto the character sheet as a number.
|
||||
|
||||
### Resolution model — deterministic + Luck-selects-borderline
|
||||
|
||||
The check model §7 actually describes (chosen over a random d20, which would reintroduce
|
||||
the per-roll variance §7 exists to remove):
|
||||
|
||||
```
|
||||
check value = ability_mod + (proficiency_bonus if proficient) + situational
|
||||
compare to a DC:
|
||||
value − DC ≥ +band → success (deterministic, repeatable, plannable)
|
||||
value − DC ≤ −band → failure (deterministic, repeatable)
|
||||
within the band → LCK selects the branch (§7)
|
||||
```
|
||||
|
||||
`band` size is **M5 tuning**. Consequences:
|
||||
|
||||
- **LCK is the only randomizer in the game.** Every other number is reproducible.
|
||||
- Race/class features are **flat modifiers** that shift you across thresholds — never
|
||||
dice, never advantage/disadvantage. (Mock talents using "advantage" language, e.g.
|
||||
*Reckless Swing*, translate to flat mods.)
|
||||
- **Death saves** become the purest expression of the model: a downed character sits in
|
||||
the borderline band and **LCK selects** stabilize-vs-slip. No new system; reuses the
|
||||
band; delivers §7's whole "am I cursed?" thesis at the highest-stakes moment.
|
||||
|
||||
### HP, MP, and the anti-spam gate
|
||||
|
||||
- **HP:** `L1 max HP = hit_die_max + CON_mod`. Per-class hit die (§4). Level curve → M5.
|
||||
- **MP:** caster callings (FTH/MAG-primary) get an MP pool sized off their casting stat;
|
||||
martials have **0 MP**. Pool formula + spell costs → M5.
|
||||
- **The spam-gate is the action economy, not MP.** One **Action** + one **Bonus** + one
|
||||
**Move** per turn (§10) caps everyone at one big thing per turn. On top of that:
|
||||
- **Casters** spend **MP** — a *depleting reservoir* (decision: attrition — dump or ration).
|
||||
- **Martials** use **cooldowns** — a *recharging cadence* (decision: rhythm — is it back yet?).
|
||||
- Basic attack is the free, repeatable option; signature abilities compete for the one Action.
|
||||
|
||||
This is why §10 says the tactics live "in the action economy and resources, not the map."
|
||||
With no grid, decisions live in what you spend your Action on and whether your good options
|
||||
are available (MP left / cooldown ready).
|
||||
|
||||
### Proficiency bonus
|
||||
|
||||
+2 at level 1 (mock). Scales with level — curve deferred (creation is L1 only).
|
||||
|
||||
---
|
||||
|
||||
## 2. The six attributes — roles + skill map
|
||||
|
||||
Adopts the mock's **9 skills verbatim**. LCK owns no save, no skill, no display.
|
||||
|
||||
| Attr | Save | Skills | Combat role | Out-of-combat (§8) |
|
||||
|---|---|---|---|---|
|
||||
| **STR** | STR | Athletics, Intimidation | Melee attack + damage | Carry, forcing things |
|
||||
| **DEX** | DEX | Stealth, Sleight of Hand, Acrobatics | Initiative, finesse/ranged attack, light-armor AC | Sleight, escape |
|
||||
| **CON** | CON | Endurance | HP pool | Resist poison / exhaustion / infection (affliction track) |
|
||||
| **FTH** | FTH | Perception, Faith-Lore | Divine casting, spell DC | Conviction in dialogue, awareness |
|
||||
| **MAG** | MAG | Sorcery | Arcane casting, spell DC | Recall, deduction |
|
||||
| **LCK** | — | — | Selects borderline branches, crit thresholds | Fires Improviser; boon vs humiliation |
|
||||
|
||||
- **Perception under FTH** (not a standalone WIS) honors §8's FTH/MAG-replace-WIS/INT — a
|
||||
Priest/Bonesetter is also the party's best lookout, giving FTH its out-of-combat teeth.
|
||||
- **Death saves = LCK** (see §1).
|
||||
|
||||
**Flags (adopting the mock as-is, noting thinness — not expanding now):**
|
||||
|
||||
- MAG has only 1 skill (Sorcery), CON only 1 (Endurance). §8 says MAG governs recall/
|
||||
deduction — an "Arcana/Lore" skill would fit. **Deferred** to a later content pass.
|
||||
- **SPEED** (mock's 30', "Old Knee −5") has no grid to matter on (§10) → treated as
|
||||
**narrative-only flavor** (chases/fleeing in prose), not a tactical value.
|
||||
|
||||
---
|
||||
|
||||
## 3. Races (4)
|
||||
|
||||
Under **features only, no stat mods**, and **flat modifiers**. Each race = one headline
|
||||
feature with real teeth + one minor feature. All upside; downsides live in flavor.
|
||||
|
||||
**Nightsight ruling** (resolves darkvision/low-light with no light grid): the character
|
||||
**ignores darkness-based penalties on checks** — a situational flat modifier (others take
|
||||
−X in the dark, you take 0). Bites on the DM's exploration awareness checks.
|
||||
|
||||
| Race | Headline feature | Minor feature | Mock trait resolved |
|
||||
|---|---|---|---|
|
||||
| **Human** | **+1 to all 5 saves** | **+1 skill proficiency of choice** at creation | "+1 to every save" |
|
||||
| **Elf** | **Perception proficiency** (+2) | **Nightsight** | "low-light sight · keen senses" |
|
||||
| **Dwarf** | **+2 to CON saves vs poison & the infection/affliction track** (may also halve duration) | **Nightsight** | "poison resist · darkvision" |
|
||||
| **Beastfolk** | **Natural claws** — innate unarmed attack (STR or DEX, light-weapon damage, always available even disarmed/grappled) | **Keen scent** — flat situational bonus to detect hidden / track by smell | "natural claws · keen scent" |
|
||||
|
||||
- **Dwarf's resist maps onto the mock's own affliction track** ("Infected Bite: CON save
|
||||
each dawn") — the character who shrugs off the rot the gritty tone (§3) inflicts.
|
||||
- **Beastfolk is the only race with a combat-*action* feature** (claws) — the aggressive pick.
|
||||
- **No race touches an attribute score** — keeps the point-buy pure.
|
||||
- **Human's bonus skill** is an addition beyond the mock's bare "+1 all saves" (agreed:
|
||||
keep it) so Human = "the adaptable one" with an actual creation choice.
|
||||
- **Beastfolk's "taxed twice at every gate"** (a mechanical shop/disposition downside) is
|
||||
**deferred** to the canon session + the shop economy (M8). Race mechanics stay
|
||||
upside-only for now.
|
||||
|
||||
---
|
||||
|
||||
## 4. Callings (7)
|
||||
|
||||
The full charter-7 (§8), gritty calling names (§17 reconcile resolved in favor of the
|
||||
mock's village-names, which honor §8's "named for the role in the world" better than
|
||||
§8's own list). Two need names from the canon session — marked `[CANON-TBD]`.
|
||||
|
||||
Static kit only — **the combat ability kit (the bar) is deferred to M5.** This is what
|
||||
creation + the character sheet read.
|
||||
|
||||
| Calling | Archetype | Primary | Hit die | Save profs | Skill profs | Resource | Armor | L1 talent (stub) |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| **Sellsword** | Fighter | STR | d10 | STR, CON | 2 | Cooldowns | Heavy | **Second Wind** — once/rest, claw back HP |
|
||||
| **[CANON-TBD]** ⚔ | Barbarian | STR | d12 | STR, CON | 2 | Cooldowns | Medium | **Blood Fury** — rage: soak + hit harder, per-encounter |
|
||||
| **Cutpurse** | Rogue | DEX | d8 | DEX, MAG | 4 | Cooldowns | Light | **Backstab** — bonus dmg vs an unaware/first-strike target |
|
||||
| **Trapper** | Ranger | DEX | d10 | DEX, CON | 3 | Cooldowns | Light | **Set Snare** — place a control trap, cooldown |
|
||||
| **Hedge-Mage** | Wizard | MAG | d6 | MAG, FTH | 2 | MP (MAG) | None | **Hexbolt** — signature MP-free arcane basic |
|
||||
| **Bonesetter** | Cleric | FTH | d8 | FTH, CON | 2 | MP (FTH) | Medium | **Mend** — heal HP, gritty field-medic |
|
||||
| **[CANON-TBD]** 🔥 | Warlock | FTH | d8 | FTH, MAG | 2 | MP (FTH) | Light | **Pact Mark** — curse a target, FTH/MP |
|
||||
|
||||
- **Two STR martials differentiated by shape, not stat:** Sellsword = disciplined, heavy
|
||||
armor + d10 + sustain (Second Wind); the Barbarian-calling = reckless, d12 + medium
|
||||
armor + rage (soak via fury, not plate). Same primary, opposite feel.
|
||||
- **Cutpurse gets 4 skills** (breadth is the rogue's identity); Trapper 3; others 2. Drawn
|
||||
from the class's stat-appropriate slice of the 9-skill list, plus what race grants.
|
||||
- **Casters** (Hedge-Mage / Bonesetter / Warlock-calling) have **MP**; martials have 0 and
|
||||
live on cooldowns. MP pool formula → M5.
|
||||
- **Backstab** — the mock's "advantage" phrasing becomes a **flat bonus vs an unaware/
|
||||
first-strike target** under the resolution model; no positioning needed, fits gridless (§10).
|
||||
- **Trapper is a pure martial** (traps = cooldown gadgets, no MP) — the D&D half-caster
|
||||
ranger is deliberately not ported, to keep spell math out of the POC.
|
||||
- **Armor categories** (Heavy/Medium/Light/None) are proficiency categories; specific gear
|
||||
comes from origin grants + the item system (M7).
|
||||
- **L1 talents are stubs** — full kit at M5.
|
||||
|
||||
---
|
||||
|
||||
## 5. Character-creation integration (M4) + canon-log contract
|
||||
|
||||
Folds into the proven point-buy model (rolled base + additive spend, LCK hidden).
|
||||
|
||||
**Order of operations:**
|
||||
|
||||
1. **Roll base attributes** — 3d6 with floors per stat. **LCK rolled hidden** (avg of 5 +
|
||||
origin `luck_modifier`, §7) — never shown.
|
||||
2. **Spend the +3 additive pool** — additive only; LCK untouchable.
|
||||
3. **Apply race** — features only, no stat mods (saves / skill profs / Nightsight / claws).
|
||||
4. **Apply calling** — hit die → `HP = hit_die_max + CON_mod`; save profs; skill profs; MP
|
||||
pool if caster (from casting stat); armor category; L1 talent.
|
||||
5. **Origin overlays** — `build_constraints.allowed_classes` gates offered callings;
|
||||
`luck_modifier` → LCK; inventory/disposition grants.
|
||||
|
||||
**Derived at creation** (all deterministic): HP, MP, AC (base + armor + DEX), the 5 saves,
|
||||
9 skills, initiative (DEX), spell DC for casters (`8 + prof + casting_mod`), prof bonus +2.
|
||||
|
||||
**Two contract migrations this forces (flagged, do them at M4, not silently):**
|
||||
|
||||
- **Canon log gains a `race` field.** §11's player block is `{name, class, luck_descriptor}`
|
||||
today; the Narrator/NPC need `race` to describe the player. Small schema bump to the M0
|
||||
contract (`docs/schemas/canon-log.schema.json`). The full mechanical sheet stays in client
|
||||
`GameState` — the canon log carries only what the AI narrates from (name, **race**, class,
|
||||
luck_descriptor).
|
||||
- **The deserter origin is stale.** `content/origins/deserter.json` has
|
||||
`allowed_classes: ["sellsword","assassin","priest"]` — old charter names. Migrate to the
|
||||
new calling ids (`cutpurse`/`bonesetter`/etc.) and the 7-calling roster. Origins gate
|
||||
races only if they choose to (`allowed_races` omitted = all).
|
||||
|
||||
Creation stays **100% deterministic except the hidden LCK roll** — every derived number is
|
||||
reproducible from the choices, which is exactly what §10's per-encounter seeding needs.
|
||||
Character creation is the first place seeding matters.
|
||||
|
||||
---
|
||||
|
||||
## 6. Explicitly deferred (the seams)
|
||||
|
||||
Nothing here silently pre-empts a later brainstorm.
|
||||
|
||||
**→ M5 (combat):**
|
||||
- Full ability kits / the bar; MP pool formula + spell + cooldown costs.
|
||||
- The **damage-determinism reconcile** (§7/§10 vs the Combat HUD mock's ranges).
|
||||
- The **borderline band size** (DC bands).
|
||||
- Leveling — hit-die progression, prof-bonus scaling, talent tiers, XP curve. (Creation is L1.)
|
||||
|
||||
**→ Canon session (lore):**
|
||||
- The two **[CANON-TBD]** calling names (Barbarian ⚔ / Warlock 🔥).
|
||||
- All race/calling flavor, descriptions, origin fragments.
|
||||
- The **beastfolk social tax** (mechanical downside, ties to M8 shop).
|
||||
|
||||
**→ Later content pass:**
|
||||
- MAG/CON skill thinness (an Arcana/Lore skill).
|
||||
- SPEED as narrative-only flavor.
|
||||
|
||||
---
|
||||
|
||||
## §2 accounting
|
||||
|
||||
Every system here is **state** (code owns it). Races and callings are code-owned data the
|
||||
AI only *narrates from* — the canon log's new `race` field is the sole text-facing surface,
|
||||
and it carries a value code decided. No AI system mutates any of this. Consistent with §2.
|
||||
0
tools/content_build/__init__.py
Normal file
0
tools/content_build/__init__.py
Normal file
100
tools/content_build/__main__.py
Normal file
100
tools/content_build/__main__.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""CLI entry: `python -m content_build [--check]`. Orchestrates
|
||||
parse -> model -> resolve -> emit. --check builds in memory and diffs against
|
||||
the on-disk committed JSON (stale-or-invalid -> exit 1)."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .emit import build_trees, check_secrecy, dump_json, write_trees
|
||||
from .errors import BuildError
|
||||
from .model import entry_from_raw
|
||||
from .parse import parse_bible
|
||||
from .resolve import resolve
|
||||
|
||||
|
||||
def _load_world(lore_dir: Path):
|
||||
raws = []
|
||||
for md in sorted(Path(lore_dir).glob("*.md")):
|
||||
raws.extend(parse_bible(md))
|
||||
return resolve([entry_from_raw(r) for r in raws])
|
||||
|
||||
|
||||
def build(lore_dir, world_dir, server_dir) -> None:
|
||||
world = _load_world(lore_dir)
|
||||
client_files, server_files = build_trees(world)
|
||||
check_secrecy(client_files, world)
|
||||
write_trees(client_files, server_files, world_dir, server_dir)
|
||||
|
||||
|
||||
def _diff(path: Path, obj) -> list[str]:
|
||||
want = dump_json(obj)
|
||||
if not path.exists():
|
||||
return [f"missing {path}"]
|
||||
if path.read_text() != want:
|
||||
return [f"differs {path}"]
|
||||
return []
|
||||
|
||||
|
||||
def _orphans(root, expected_relpaths: set, is_client: bool) -> list[str]:
|
||||
"""Build-owned .json files on disk that the build no longer produces.
|
||||
Client npcs/ is shared with legacy hand-authored files (bare ids), so there
|
||||
only generated npc.* files are treated as build-owned."""
|
||||
owned = ["canon", "topics", "npcs"] if is_client else ["npcs", "topics"]
|
||||
out: list[str] = []
|
||||
for sub in owned:
|
||||
d = Path(root) / sub
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in sorted(d.glob("*.json")):
|
||||
relpath = f"{sub}/{f.name}"
|
||||
if relpath in expected_relpaths:
|
||||
continue
|
||||
if is_client and sub == "npcs":
|
||||
try:
|
||||
data = json.loads(f.read_text())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not str(data.get("id", "")).startswith("npc."):
|
||||
continue # legacy hand-authored file — not build-owned
|
||||
out.append(f"orphan {f}")
|
||||
return out
|
||||
|
||||
|
||||
def check(lore_dir, world_dir, server_dir) -> int:
|
||||
world = _load_world(lore_dir)
|
||||
client_files, server_files = build_trees(world)
|
||||
check_secrecy(client_files, world)
|
||||
stale: list[str] = []
|
||||
for relpath, obj in client_files.items():
|
||||
stale += _diff(Path(world_dir) / relpath, obj)
|
||||
for relpath, obj in server_files.items():
|
||||
stale += _diff(Path(server_dir) / relpath, obj)
|
||||
stale += _orphans(world_dir, set(client_files.keys()), is_client=True)
|
||||
stale += _orphans(server_dir, set(server_files.keys()), is_client=False)
|
||||
for s in stale:
|
||||
print("STALE:", s, file=sys.stderr)
|
||||
return 1 if stale else 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(prog="content_build")
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="verify committed JSON is fresh + valid; no writes")
|
||||
ap.add_argument("--lore", default="content/lore")
|
||||
ap.add_argument("--world", default="content/world")
|
||||
ap.add_argument("--server", default="content/server")
|
||||
args = ap.parse_args(argv)
|
||||
try:
|
||||
if args.check:
|
||||
return check(args.lore, args.world, args.server)
|
||||
build(args.lore, args.world, args.server)
|
||||
return 0
|
||||
except BuildError as e:
|
||||
print("BUILD FAILED:", e, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
92
tools/content_build/emit.py
Normal file
92
tools/content_build/emit.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Route each canon entry's fields into the client tree (content/world) and the
|
||||
server-only tree (content/server), per the schema's role split. build_trees is
|
||||
pure (returns dicts); write_trees does the I/O; check_secrecy is the
|
||||
after-routing defense-in-depth lint."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import BuildError
|
||||
from .model import Entry
|
||||
from .resolve import World
|
||||
from .rungs import LADDER_ID
|
||||
|
||||
|
||||
def dump_json(obj) -> str:
|
||||
return json.dumps(obj, indent=2, sort_keys=True) + "\n"
|
||||
|
||||
|
||||
def _client_relpath(e: Entry) -> str:
|
||||
if e.is_npc_layer:
|
||||
return f"npcs/{e.slug}.json"
|
||||
if e.is_knowledge:
|
||||
return f"topics/{e.slug}.json"
|
||||
return f"canon/{e.slug}.json"
|
||||
|
||||
|
||||
def _client_record(e: Entry) -> dict:
|
||||
if e.is_npc_layer:
|
||||
return {
|
||||
"id": e.id,
|
||||
"type": e.type,
|
||||
"start_disposition": e.start_disposition,
|
||||
"related": e.related,
|
||||
"knows": [{"fact_id": k.fact_id, "gate": k.gate} for k in e.knows],
|
||||
}
|
||||
if e.is_knowledge:
|
||||
return {"id": e.id, "type": e.type, "related": e.related}
|
||||
rec = {"id": e.id, "type": e.type, "related": e.related}
|
||||
if e.body is not None:
|
||||
rec["body"] = e.body
|
||||
if e.id == LADDER_ID and e.rungs:
|
||||
rec["rungs"] = e.rungs
|
||||
return rec
|
||||
|
||||
|
||||
def _server_entry(e: Entry):
|
||||
"""Return (relpath, record) for the server tree, or None for canon entities
|
||||
(their bodies are public and ship client-side)."""
|
||||
if e.is_npc_layer:
|
||||
rec = {"id": e.id, "persona": e.body}
|
||||
if e.disposition_notes is not None:
|
||||
rec["disposition_notes"] = e.disposition_notes
|
||||
return f"npcs/{e.slug}.json", rec
|
||||
if e.is_knowledge:
|
||||
return f"topics/{e.slug}.json", {"id": e.id, "body": e.body,
|
||||
"secrecy": e.secrecy}
|
||||
return None
|
||||
|
||||
|
||||
def build_trees(world: World) -> tuple[dict, dict]:
|
||||
client: dict[str, dict] = {}
|
||||
server: dict[str, dict] = {}
|
||||
for e in world.entries:
|
||||
if e.status != "canon":
|
||||
continue
|
||||
client[_client_relpath(e)] = _client_record(e)
|
||||
se = _server_entry(e)
|
||||
if se is not None:
|
||||
relpath, rec = se
|
||||
server[relpath] = rec
|
||||
return client, server
|
||||
|
||||
|
||||
def check_secrecy(client_files: dict, world: World) -> None:
|
||||
for relpath, rec in client_files.items():
|
||||
if "body" in rec and world.by_id[rec["id"]].secrecy >= 3:
|
||||
e = world.by_id[rec["id"]]
|
||||
raise BuildError(
|
||||
f"secrecy {e.secrecy} body routed to client artifact {relpath}",
|
||||
source=e.source, entry_id=e.id)
|
||||
|
||||
|
||||
def write_trees(client_files: dict, server_files: dict,
|
||||
world_dir: Path, server_dir: Path) -> None:
|
||||
for relpath, obj in client_files.items():
|
||||
p = Path(world_dir) / relpath
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(dump_json(obj))
|
||||
for relpath, obj in server_files.items():
|
||||
p = Path(server_dir) / relpath
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(dump_json(obj))
|
||||
12
tools/content_build/errors.py
Normal file
12
tools/content_build/errors.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""The single failure type. Every validation/parse problem raises BuildError so
|
||||
the CLI can print a uniform 'BUILD FAILED: <what> [<id>] (<file:line>)'."""
|
||||
|
||||
|
||||
class BuildError(Exception):
|
||||
def __init__(self, message: str, source: str = "", entry_id: str = ""):
|
||||
self.message = message
|
||||
self.source = source
|
||||
self.entry_id = entry_id
|
||||
loc = f" [{entry_id}]" if entry_id else ""
|
||||
src = f" ({source})" if source else ""
|
||||
super().__init__(f"{message}{loc}{src}")
|
||||
76
tools/content_build/model.py
Normal file
76
tools/content_build/model.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Typed entries + the envelope/category rules. Pure data; no I/O. The npc
|
||||
interactive layer vs the person world-record is distinguished by id NAMESPACE
|
||||
(prefix), not by `type` (both are type 'person')."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .errors import BuildError
|
||||
from .parse import RawEntry
|
||||
|
||||
CANON_TYPES = {"town", "place", "region", "faction", "person", "rule"}
|
||||
KNOWLEDGE_TYPES = {"rumor", "fact", "secret"}
|
||||
LEGAL_NAMESPACES = {"town", "place", "region", "faction", "person", "rule",
|
||||
"rumor", "fact", "secret", "npc"}
|
||||
STATUSES = {"candidate", "canon"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowsLink:
|
||||
fact_id: str
|
||||
gate: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
id: str
|
||||
type: str
|
||||
status: str
|
||||
secrecy: int
|
||||
related: list
|
||||
body: str | None
|
||||
source: str
|
||||
start_disposition: str | None = None
|
||||
knows: list = field(default_factory=list) # list[KnowsLink]
|
||||
disposition_notes: str | None = None
|
||||
rungs: list | None = None
|
||||
|
||||
@property
|
||||
def namespace(self) -> str:
|
||||
return self.id.split(".", 1)[0]
|
||||
|
||||
@property
|
||||
def slug(self) -> str:
|
||||
parts = self.id.split(".", 1)
|
||||
return parts[1] if len(parts) == 2 else parts[0]
|
||||
|
||||
@property
|
||||
def is_npc_layer(self) -> bool:
|
||||
return self.namespace == "npc"
|
||||
|
||||
@property
|
||||
def is_knowledge(self) -> bool:
|
||||
return self.type in KNOWLEDGE_TYPES
|
||||
|
||||
@property
|
||||
def is_canon_entity(self) -> bool:
|
||||
return (not self.is_npc_layer) and self.type in CANON_TYPES
|
||||
|
||||
|
||||
def entry_from_raw(raw: RawEntry) -> Entry:
|
||||
d = raw.data
|
||||
for key in ("id", "type", "status", "secrecy", "related"):
|
||||
if key not in d:
|
||||
raise BuildError(f"missing required field '{key}'",
|
||||
source=raw.source, entry_id=d.get("id", ""))
|
||||
knows: list[KnowsLink] = []
|
||||
for k in d.get("knows", []):
|
||||
if "fact" not in k or "gate" not in k:
|
||||
raise BuildError("knows entry needs 'fact' and 'gate'",
|
||||
source=raw.source, entry_id=d.get("id", ""))
|
||||
knows.append(KnowsLink(fact_id=k["fact"], gate=k["gate"]))
|
||||
return Entry(
|
||||
id=d["id"], type=d["type"], status=d["status"], secrecy=d["secrecy"],
|
||||
related=list(d["related"]), body=d.get("body"), source=raw.source,
|
||||
start_disposition=d.get("start_disposition"), knows=knows,
|
||||
disposition_notes=d.get("disposition_notes"), rungs=d.get("rungs"),
|
||||
)
|
||||
40
tools/content_build/parse.py
Normal file
40
tools/content_build/parse.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Markdown bible -> raw yaml-block entries. Knows only fences + YAML; nothing
|
||||
about the schema's meaning. A block is opened by a line that is exactly ```yaml
|
||||
(after strip) and closed by a line that is exactly ```."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from .errors import BuildError
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawEntry:
|
||||
data: dict
|
||||
source: str # "duncarrow.md:53"
|
||||
|
||||
|
||||
def parse_bible(path: Path) -> list[RawEntry]:
|
||||
lines = path.read_text().splitlines()
|
||||
entries: list[RawEntry] = []
|
||||
i, n = 0, len(lines)
|
||||
while i < n:
|
||||
if lines[i].strip() == "```yaml":
|
||||
fence_line = i + 1 # 1-based line of the opening fence
|
||||
i += 1
|
||||
block: list[str] = []
|
||||
while i < n and lines[i].strip() != "```":
|
||||
block.append(lines[i])
|
||||
i += 1
|
||||
if i >= n:
|
||||
raise BuildError("unterminated ```yaml block",
|
||||
source=f"{path.name}:{fence_line}")
|
||||
data = yaml.safe_load("\n".join(block))
|
||||
if not isinstance(data, dict):
|
||||
raise BuildError("yaml block is not a mapping",
|
||||
source=f"{path.name}:{fence_line}")
|
||||
entries.append(RawEntry(data=data, source=f"{path.name}:{fence_line}"))
|
||||
i += 1
|
||||
return entries
|
||||
95
tools/content_build/resolve.py
Normal file
95
tools/content_build/resolve.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""The validation gate. Pure function over parsed entries: builds the id map and
|
||||
enforces every schema rule, raising BuildError on the first violation. Emits
|
||||
nothing. Validates ALL entries (candidate + canon) so authors get errors early;
|
||||
emit (Task 4) filters to canon."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .errors import BuildError
|
||||
from .model import Entry, KNOWLEDGE_TYPES, LEGAL_NAMESPACES, STATUSES
|
||||
from .rungs import ladder_rungs, legal_gates
|
||||
|
||||
|
||||
@dataclass
|
||||
class World:
|
||||
entries: list # list[Entry]
|
||||
by_id: dict # id -> Entry
|
||||
|
||||
|
||||
def resolve(entries: list[Entry]) -> World:
|
||||
by_id: dict[str, Entry] = {}
|
||||
for e in entries:
|
||||
if e.id in by_id:
|
||||
raise BuildError("duplicate id", source=e.source, entry_id=e.id)
|
||||
by_id[e.id] = e
|
||||
|
||||
for e in entries:
|
||||
if isinstance(e.secrecy, bool) or not isinstance(e.secrecy, int):
|
||||
raise BuildError("secrecy must be an integer",
|
||||
source=e.source, entry_id=e.id)
|
||||
if not isinstance(e.related, list):
|
||||
raise BuildError("related must be a list",
|
||||
source=e.source, entry_id=e.id)
|
||||
if e.status not in STATUSES:
|
||||
raise BuildError(f"illegal status '{e.status}'",
|
||||
source=e.source, entry_id=e.id)
|
||||
if e.namespace not in LEGAL_NAMESPACES:
|
||||
raise BuildError(f"illegal id namespace '{e.namespace}'",
|
||||
source=e.source, entry_id=e.id)
|
||||
if e.is_canon_entity and e.secrecy != 0:
|
||||
raise BuildError("canon entity must have secrecy 0",
|
||||
source=e.source, entry_id=e.id)
|
||||
if e.namespace == "npc":
|
||||
if e.type != "person":
|
||||
raise BuildError("npc.* entries must have type 'person'",
|
||||
source=e.source, entry_id=e.id)
|
||||
elif e.type != e.namespace:
|
||||
raise BuildError(
|
||||
f"id namespace '{e.namespace}' must equal type '{e.type}'",
|
||||
source=e.source, entry_id=e.id)
|
||||
|
||||
for e in entries:
|
||||
for rid in e.related:
|
||||
if rid not in by_id:
|
||||
raise BuildError(f"related id '{rid}' does not resolve",
|
||||
source=e.source, entry_id=e.id)
|
||||
|
||||
for e in entries:
|
||||
if e.is_npc_layer:
|
||||
if e.start_disposition is None:
|
||||
raise BuildError("npc.* requires start_disposition",
|
||||
source=e.source, entry_id=e.id)
|
||||
if not e.knows:
|
||||
raise BuildError("npc.* requires a non-empty knows list",
|
||||
source=e.source, entry_id=e.id)
|
||||
else:
|
||||
if e.knows:
|
||||
raise BuildError("non-npc entry must not carry a knows list",
|
||||
source=e.source, entry_id=e.id)
|
||||
if e.start_disposition is not None:
|
||||
raise BuildError("non-npc entry must not carry start_disposition",
|
||||
source=e.source, entry_id=e.id)
|
||||
|
||||
gates = legal_gates(entries) # enforces the single ladder + rungs
|
||||
rungs = set(ladder_rungs(entries))
|
||||
for e in entries:
|
||||
if not e.is_npc_layer:
|
||||
continue
|
||||
if e.start_disposition not in rungs:
|
||||
raise BuildError(
|
||||
f"start_disposition '{e.start_disposition}' is not a legal rung",
|
||||
source=e.source, entry_id=e.id)
|
||||
for link in e.knows:
|
||||
if link.fact_id not in by_id:
|
||||
raise BuildError(f"knows fact '{link.fact_id}' does not resolve",
|
||||
source=e.source, entry_id=e.id)
|
||||
target = by_id[link.fact_id]
|
||||
if target.type not in KNOWLEDGE_TYPES:
|
||||
raise BuildError(
|
||||
f"knows target '{link.fact_id}' is not a knowledge entry "
|
||||
f"(is '{target.type}')", source=e.source, entry_id=e.id)
|
||||
if link.gate not in gates:
|
||||
raise BuildError(f"illegal gate '{link.gate}'",
|
||||
source=e.source, entry_id=e.id)
|
||||
|
||||
return World(entries=entries, by_id=by_id)
|
||||
23
tools/content_build/rungs.py
Normal file
23
tools/content_build/rungs.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""The rung vocabulary, sourced from the bible's disposition-ladder entry. The
|
||||
bible owns rung names/order; band NUMBERS live in client code and never here."""
|
||||
|
||||
from .errors import BuildError
|
||||
from .model import Entry
|
||||
|
||||
LADDER_ID = "rule.disposition-ladder"
|
||||
NEVER = "never"
|
||||
|
||||
|
||||
def ladder_rungs(entries: list[Entry]) -> list[str]:
|
||||
ladders = [e for e in entries if e.id == LADDER_ID]
|
||||
if len(ladders) != 1:
|
||||
raise BuildError(
|
||||
f"exactly one '{LADDER_ID}' entry required, found {len(ladders)}")
|
||||
if not isinstance(ladders[0].rungs, list) or not ladders[0].rungs:
|
||||
raise BuildError("disposition-ladder needs a non-empty 'rungs:' list",
|
||||
source=ladders[0].source, entry_id=LADDER_ID)
|
||||
return list(ladders[0].rungs)
|
||||
|
||||
|
||||
def legal_gates(entries: list[Entry]) -> set[str]:
|
||||
return set(ladder_rungs(entries)) | {NEVER}
|
||||
0
tools/content_build/tests/__init__.py
Normal file
0
tools/content_build/tests/__init__.py
Normal file
58
tools/content_build/tests/fixtures/valid/world.md
vendored
Normal file
58
tools/content_build/tests/fixtures/valid/world.md
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
# Test bible
|
||||
|
||||
```yaml
|
||||
id: rule.disposition-ladder
|
||||
type: rule
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: []
|
||||
rungs: [hostile, cold, neutral, warm, trusted]
|
||||
body: >
|
||||
The five-rung ladder.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: town.testburg
|
||||
type: town
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: []
|
||||
body: >
|
||||
A test town.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: rumor.something
|
||||
type: rumor
|
||||
status: canon
|
||||
secrecy: 1
|
||||
related: [town.testburg]
|
||||
body: >
|
||||
A rumor body.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: secret.big-twist
|
||||
type: secret
|
||||
status: canon
|
||||
secrecy: 4
|
||||
related: [town.testburg]
|
||||
body: >
|
||||
The twist body. Server-only.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: npc.tess
|
||||
type: person
|
||||
status: canon
|
||||
secrecy: 0
|
||||
start_disposition: cold
|
||||
related: [town.testburg]
|
||||
body: >
|
||||
Tess persona.
|
||||
knows:
|
||||
- {fact: rumor.something, gate: neutral}
|
||||
- {fact: secret.big-twist, gate: never}
|
||||
disposition_notes: >
|
||||
Author notes.
|
||||
```
|
||||
83
tools/content_build/tests/test_cli.py
Normal file
83
tools/content_build/tests/test_cli.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from content_build.__main__ import main
|
||||
|
||||
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "valid"
|
||||
|
||||
|
||||
def _lore(tmp_path):
|
||||
lore = tmp_path / "lore"
|
||||
lore.mkdir()
|
||||
shutil.copy(FIXTURE_DIR / "world.md", lore / "world.md")
|
||||
return lore
|
||||
|
||||
|
||||
def test_build_then_check_is_clean(tmp_path):
|
||||
lore = _lore(tmp_path)
|
||||
world, server = tmp_path / "world", tmp_path / "server"
|
||||
argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)]
|
||||
assert main(argv) == 0
|
||||
assert (world / "npcs" / "tess.json").exists()
|
||||
assert (server / "topics" / "big-twist.json").exists()
|
||||
# a fresh --check against just-written output is clean
|
||||
assert main(argv + ["--check"]) == 0
|
||||
|
||||
|
||||
def test_check_detects_stale(tmp_path):
|
||||
lore = _lore(tmp_path)
|
||||
world, server = tmp_path / "world", tmp_path / "server"
|
||||
argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)]
|
||||
assert main(argv) == 0
|
||||
# mutate a committed artifact -> --check must fail
|
||||
(world / "canon" / "testburg.json").write_text('{"id": "town.testburg"}\n')
|
||||
assert main(argv + ["--check"]) == 1
|
||||
|
||||
|
||||
def test_check_detects_missing(tmp_path):
|
||||
lore = _lore(tmp_path)
|
||||
world, server = tmp_path / "world", tmp_path / "server"
|
||||
argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)]
|
||||
assert main(argv) == 0
|
||||
(world / "npcs" / "tess.json").unlink()
|
||||
assert main(argv + ["--check"]) == 1
|
||||
|
||||
|
||||
def test_invalid_bible_fails_build(tmp_path):
|
||||
lore = tmp_path / "lore"
|
||||
lore.mkdir()
|
||||
(lore / "bad.md").write_text(
|
||||
"```yaml\nid: town.a\ntype: town\nstatus: canon\nsecrecy: 0\n"
|
||||
"related: [does.not-exist]\nbody: x\n```\n")
|
||||
argv = ["--lore", str(lore), "--world", str(tmp_path / "w"),
|
||||
"--server", str(tmp_path / "s")]
|
||||
assert main(argv) == 1
|
||||
|
||||
|
||||
def test_check_detects_orphan_file(tmp_path):
|
||||
lore = _lore(tmp_path)
|
||||
world, server = tmp_path / "world", tmp_path / "server"
|
||||
argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)]
|
||||
assert main(argv) == 0
|
||||
(world / "canon" / "ghost.json").write_text('{"id": "town.ghost"}\n')
|
||||
assert main(argv + ["--check"]) == 1
|
||||
|
||||
|
||||
def test_check_ignores_legacy_npc_files(tmp_path):
|
||||
lore = _lore(tmp_path)
|
||||
world, server = tmp_path / "world", tmp_path / "server"
|
||||
argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)]
|
||||
assert main(argv) == 0
|
||||
(world / "npcs").mkdir(parents=True, exist_ok=True)
|
||||
(world / "npcs" / "legacy.json").write_text('{"id": "legacy"}\n') # bare id
|
||||
assert main(argv + ["--check"]) == 0 # legacy file is not a build orphan
|
||||
|
||||
|
||||
def test_check_detects_orphan_generated_npc(tmp_path):
|
||||
lore = _lore(tmp_path)
|
||||
world, server = tmp_path / "world", tmp_path / "server"
|
||||
argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)]
|
||||
assert main(argv) == 0
|
||||
(world / "npcs").mkdir(parents=True, exist_ok=True)
|
||||
(world / "npcs" / "ghost.json").write_text('{"id": "npc.ghost"}\n')
|
||||
assert main(argv + ["--check"]) == 1 # a generated npc.* orphan in the shared dir IS caught
|
||||
37
tools/content_build/tests/test_duncarrow.py
Normal file
37
tools/content_build/tests/test_duncarrow.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""End-to-end: the real promoted Duncarrow bible builds green and routes the
|
||||
Crell secret correctly (body server-only; the id ships in the gate skeleton)."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from content_build.__main__ import _load_world, check
|
||||
from content_build.emit import build_trees
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
LORE = REPO / "content" / "lore"
|
||||
WORLD = REPO / "content" / "world"
|
||||
SERVER = REPO / "content" / "server"
|
||||
|
||||
|
||||
def test_duncarrow_builds_and_check_is_clean():
|
||||
assert check(LORE, WORLD, SERVER) == 0
|
||||
|
||||
|
||||
def test_secret_body_is_server_only():
|
||||
client, server = build_trees(_load_world(LORE))
|
||||
assert "topics/crell-runs-slave-trade.json" in server
|
||||
# the secret's descriptive id ships in the gate skeleton (schema property)...
|
||||
npc = client["npcs/mayor-oswin-crell.json"]
|
||||
assert any(k["fact_id"] == "secret.crell-runs-slave-trade"
|
||||
and k["gate"] == "never" for k in npc["knows"])
|
||||
# ...but its BODY never reaches any client artifact
|
||||
blob = json.dumps(client)
|
||||
assert "elf-slaving" not in blob and "sells them into slavery" not in blob
|
||||
|
||||
|
||||
def test_mera_never_holds_the_secret():
|
||||
client, _ = build_trees(_load_world(LORE))
|
||||
fenn = client["npcs/mera-fenn.json"]
|
||||
facts = {k["fact_id"] for k in fenn["knows"]}
|
||||
assert "secret.crell-runs-slave-trade" not in facts
|
||||
assert "fact.crells-guard-acts-alone" in facts
|
||||
75
tools/content_build/tests/test_emit.py
Normal file
75
tools/content_build/tests/test_emit.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from content_build.emit import build_trees, check_secrecy, write_trees, dump_json
|
||||
from content_build.model import entry_from_raw, Entry
|
||||
from content_build.parse import parse_bible
|
||||
from content_build.resolve import resolve
|
||||
from content_build.errors import BuildError
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "valid" / "world.md"
|
||||
|
||||
|
||||
def _world():
|
||||
raws = parse_bible(FIXTURE)
|
||||
return resolve([entry_from_raw(r) for r in raws])
|
||||
|
||||
|
||||
def test_npc_skeleton_has_no_persona_client_side():
|
||||
client, _ = build_trees(_world())
|
||||
npc = client["npcs/tess.json"]
|
||||
assert "persona" not in npc and "body" not in npc
|
||||
assert npc["start_disposition"] == "cold"
|
||||
assert {"fact_id": "secret.big-twist", "gate": "never"} in npc["knows"]
|
||||
|
||||
|
||||
def test_persona_goes_server_side():
|
||||
_, server = build_trees(_world())
|
||||
assert server["npcs/tess.json"]["persona"].startswith("Tess")
|
||||
|
||||
|
||||
def test_secret_body_only_server_side():
|
||||
client, server = build_trees(_world())
|
||||
assert "topics/big-twist.json" in server
|
||||
assert server["topics/big-twist.json"]["body"].startswith("The twist")
|
||||
# client topic skeleton carries no body
|
||||
assert "body" not in client["topics/big-twist.json"]
|
||||
|
||||
|
||||
def test_canon_entity_body_ships_client_side():
|
||||
client, _ = build_trees(_world())
|
||||
assert client["canon/testburg.json"]["body"].startswith("A test town")
|
||||
assert client["canon/disposition-ladder.json"]["rungs"][0] == "hostile"
|
||||
|
||||
|
||||
def test_check_secrecy_passes_on_valid_world():
|
||||
client, _ = build_trees(_world())
|
||||
check_secrecy(client, _world()) # no raise
|
||||
|
||||
|
||||
def test_check_secrecy_fails_when_secret_body_reaches_client():
|
||||
world = _world()
|
||||
client, _ = build_trees(world)
|
||||
# simulate a routing regression: a secrecy-4 body in a client record
|
||||
client["canon/leak.json"] = {"id": "secret.big-twist", "body": "leak"}
|
||||
with pytest.raises(BuildError):
|
||||
check_secrecy(client, world)
|
||||
|
||||
|
||||
def test_write_trees_serializes_deterministically(tmp_path):
|
||||
client, server = build_trees(_world())
|
||||
write_trees(client, server, tmp_path / "world", tmp_path / "server")
|
||||
written = (tmp_path / "world" / "npcs" / "tess.json").read_text()
|
||||
assert written == dump_json(client["npcs/tess.json"])
|
||||
|
||||
|
||||
def test_candidate_entries_are_excluded_from_both_trees():
|
||||
world = _world()
|
||||
world.entries.append(Entry(id="town.draft", type="town", status="candidate",
|
||||
secrecy=0, related=[], body="draft town",
|
||||
source="t:99"))
|
||||
client, server = build_trees(world)
|
||||
# a candidate entry ships in NEITHER tree
|
||||
assert "canon/draft.json" not in client
|
||||
assert all("draft" not in relpath for relpath in list(client) + list(server))
|
||||
50
tools/content_build/tests/test_model.py
Normal file
50
tools/content_build/tests/test_model.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
|
||||
from content_build.errors import BuildError
|
||||
from content_build.model import Entry, entry_from_raw
|
||||
from content_build.parse import RawEntry
|
||||
|
||||
|
||||
def _raw(**over) -> RawEntry:
|
||||
data = {"id": "town.a", "type": "town", "status": "canon",
|
||||
"secrecy": 0, "related": []}
|
||||
data.update(over)
|
||||
return RawEntry(data=data, source="t:1")
|
||||
|
||||
|
||||
def test_namespace_and_slug():
|
||||
e = entry_from_raw(_raw(id="npc.mera-fenn", type="person",
|
||||
start_disposition="cold",
|
||||
knows=[{"fact": "rumor.x", "gate": "warm"}]))
|
||||
assert e.namespace == "npc"
|
||||
assert e.slug == "mera-fenn"
|
||||
|
||||
|
||||
def test_category_predicates():
|
||||
npc = entry_from_raw(_raw(id="npc.t", type="person",
|
||||
start_disposition="cold",
|
||||
knows=[{"fact": "rumor.x", "gate": "warm"}]))
|
||||
person = entry_from_raw(_raw(id="person.p", type="person"))
|
||||
rumor = entry_from_raw(_raw(id="rumor.x", type="rumor", secrecy=1))
|
||||
assert npc.is_npc_layer and not npc.is_canon_entity
|
||||
assert person.is_canon_entity and not person.is_npc_layer
|
||||
assert rumor.is_knowledge and not rumor.is_canon_entity
|
||||
|
||||
|
||||
def test_knows_parsed_into_links():
|
||||
e = entry_from_raw(_raw(id="npc.t", type="person", start_disposition="cold",
|
||||
knows=[{"fact": "rumor.x", "gate": "neutral"}]))
|
||||
assert e.knows[0].fact_id == "rumor.x"
|
||||
assert e.knows[0].gate == "neutral"
|
||||
|
||||
|
||||
def test_missing_required_field_raises():
|
||||
bad = RawEntry(data={"id": "town.a", "type": "town"}, source="t:1")
|
||||
with pytest.raises(BuildError):
|
||||
entry_from_raw(bad)
|
||||
|
||||
|
||||
def test_knows_missing_gate_raises():
|
||||
with pytest.raises(BuildError):
|
||||
entry_from_raw(_raw(id="npc.t", type="person", start_disposition="cold",
|
||||
knows=[{"fact": "rumor.x"}]))
|
||||
61
tools/content_build/tests/test_parse.py
Normal file
61
tools/content_build/tests/test_parse.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from content_build.errors import BuildError
|
||||
from content_build.parse import parse_bible
|
||||
|
||||
BIBLE = """# A bible
|
||||
|
||||
Some prose that must be ignored.
|
||||
|
||||
```yaml
|
||||
id: town.a
|
||||
type: town
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: []
|
||||
body: >
|
||||
Town A.
|
||||
```
|
||||
|
||||
More prose.
|
||||
|
||||
```yaml
|
||||
id: rumor.x
|
||||
type: rumor
|
||||
status: canon
|
||||
secrecy: 1
|
||||
related: [town.a]
|
||||
body: >
|
||||
A rumor.
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def _write(tmp_path: Path, text: str) -> Path:
|
||||
p = tmp_path / "bible.md"
|
||||
p.write_text(text)
|
||||
return p
|
||||
|
||||
|
||||
def test_extracts_yaml_blocks_and_ignores_prose(tmp_path):
|
||||
entries = parse_bible(_write(tmp_path, BIBLE))
|
||||
assert [e.data["id"] for e in entries] == ["town.a", "rumor.x"]
|
||||
|
||||
|
||||
def test_source_carries_filename_and_line(tmp_path):
|
||||
entries = parse_bible(_write(tmp_path, BIBLE))
|
||||
assert entries[0].source.startswith("bible.md:")
|
||||
|
||||
|
||||
def test_unterminated_block_raises(tmp_path):
|
||||
bad = "```yaml\nid: town.a\n" # no closing fence
|
||||
with pytest.raises(BuildError):
|
||||
parse_bible(_write(tmp_path, bad))
|
||||
|
||||
|
||||
def test_non_mapping_block_raises(tmp_path):
|
||||
bad = "```yaml\n- just\n- a\n- list\n```\n"
|
||||
with pytest.raises(BuildError):
|
||||
parse_bible(_write(tmp_path, bad))
|
||||
154
tools/content_build/tests/test_resolve.py
Normal file
154
tools/content_build/tests/test_resolve.py
Normal file
@@ -0,0 +1,154 @@
|
||||
import pytest
|
||||
|
||||
from content_build.errors import BuildError
|
||||
from content_build.model import Entry, KnowsLink
|
||||
from content_build.resolve import resolve
|
||||
|
||||
|
||||
def ladder():
|
||||
return Entry(id="rule.disposition-ladder", type="rule", status="canon",
|
||||
secrecy=0, related=[], body="x", source="t:1",
|
||||
rungs=["hostile", "cold", "neutral", "warm", "trusted"])
|
||||
|
||||
|
||||
def town():
|
||||
return Entry(id="town.a", type="town", status="canon", secrecy=0,
|
||||
related=[], body="A town.", source="t:2")
|
||||
|
||||
|
||||
def rumor():
|
||||
return Entry(id="rumor.x", type="rumor", status="canon", secrecy=1,
|
||||
related=["town.a"], body="A rumor.", source="t:3")
|
||||
|
||||
|
||||
def npc(**over):
|
||||
base = dict(id="npc.t", type="person", status="canon", secrecy=0,
|
||||
related=["town.a"], body="Persona.", source="t:4",
|
||||
start_disposition="cold",
|
||||
knows=[KnowsLink("rumor.x", "neutral")])
|
||||
base.update(over)
|
||||
return Entry(**base)
|
||||
|
||||
|
||||
def test_valid_world_resolves():
|
||||
world = resolve([ladder(), town(), rumor(), npc()])
|
||||
assert world.by_id["npc.t"].id == "npc.t"
|
||||
|
||||
|
||||
def test_duplicate_id():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), town()])
|
||||
|
||||
|
||||
def test_illegal_namespace():
|
||||
bad = Entry(id="shrine.s", type="place", status="canon", secrecy=0,
|
||||
related=[], body="x", source="t:9")
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), bad])
|
||||
|
||||
|
||||
def test_id_type_mismatch():
|
||||
bad = Entry(id="place.s", type="town", status="canon", secrecy=0,
|
||||
related=[], body="x", source="t:9")
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), bad])
|
||||
|
||||
|
||||
def test_npc_type_must_be_person():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(), npc(type="town")])
|
||||
|
||||
|
||||
def test_dangling_related():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(),
|
||||
npc(related=["town.nowhere"])])
|
||||
|
||||
|
||||
def test_person_record_with_knows_rejected():
|
||||
rec = Entry(id="person.p", type="person", status="canon", secrecy=0,
|
||||
related=[], body="x", source="t:9",
|
||||
knows=[KnowsLink("rumor.x", "warm")])
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(), rec])
|
||||
|
||||
|
||||
def test_npc_missing_start_disposition():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(), npc(start_disposition=None)])
|
||||
|
||||
|
||||
def test_knows_target_must_be_knowledge_entry():
|
||||
# points at the town (a canon entity), not a knowledge entry
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(),
|
||||
npc(knows=[KnowsLink("town.a", "cold")])])
|
||||
|
||||
|
||||
def test_illegal_gate():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(),
|
||||
npc(knows=[KnowsLink("rumor.x", "besties")])])
|
||||
|
||||
|
||||
def test_never_is_a_legal_gate():
|
||||
world = resolve([ladder(), town(), rumor(),
|
||||
npc(knows=[KnowsLink("rumor.x", "never")])])
|
||||
assert world.by_id["npc.t"].knows[0].gate == "never"
|
||||
|
||||
|
||||
def test_missing_ladder():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([town()])
|
||||
|
||||
|
||||
def test_illegal_start_disposition():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(), npc(start_disposition="besties")])
|
||||
|
||||
|
||||
def test_illegal_status():
|
||||
bad = Entry(id="town.a", type="town", status="draft", secrecy=0,
|
||||
related=[], body="x", source="t:9")
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), bad])
|
||||
|
||||
|
||||
def test_npc_empty_knows_rejected():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(), npc(knows=[])])
|
||||
|
||||
|
||||
def test_non_npc_with_start_disposition_rejected():
|
||||
rec = Entry(id="person.p", type="person", status="canon", secrecy=0,
|
||||
related=[], body="x", source="t:9", start_disposition="cold")
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(), rec])
|
||||
|
||||
|
||||
def test_empty_rungs_rejected():
|
||||
empty = Entry(id="rule.disposition-ladder", type="rule", status="canon",
|
||||
secrecy=0, related=[], body="x", source="t:1", rungs=[])
|
||||
with pytest.raises(BuildError):
|
||||
resolve([empty])
|
||||
|
||||
|
||||
def test_canon_entity_nonzero_secrecy_rejected():
|
||||
bad = Entry(id="town.a", type="town", status="canon", secrecy=2,
|
||||
related=[], body="x", source="t:9")
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), bad])
|
||||
|
||||
|
||||
def test_non_int_secrecy_raises():
|
||||
bad = Entry(id="town.a", type="town", status="canon", secrecy="high",
|
||||
related=[], body="x", source="t:9")
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), bad])
|
||||
|
||||
|
||||
def test_non_list_rungs_raises():
|
||||
bad_ladder = Entry(id="rule.disposition-ladder", type="rule", status="canon",
|
||||
secrecy=0, related=[], body="x", source="t:1", rungs="trusted")
|
||||
with pytest.raises(BuildError):
|
||||
resolve([bad_ladder])
|
||||
3
tools/requirements-dev.txt
Normal file
3
tools/requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# Build-tool dev/CI deps. NOT installed into the api proxy runtime.
|
||||
PyYAML==6.0.3
|
||||
pytest==8.3.2
|
||||
Reference in New Issue
Block a user