fix(content): repair client consumers broken by the Duncarrow purge

The purge plan assumed content/world/** was pure build output. The client
reads it: content_db.gd loads content/world/topics/ (now absent, since every
topic was Duncarrow-generated) and its tests asserted on deleted ids.

- content_db: a missing content dir is an empty dir, not an error (§13)
- test_content_db: repoint at cosmology canon; drop deleted npc.mera-fenn
- test_content_db: assert the no-body-on-client invariant structurally, so it
  cannot pass vacuously when the topic set is empty
- schema.md: finish repointing the worked example onto specimen.md's ids
- restore a local real-bible --check smoke test (was only in CI)
- roadmap: record that generated content has client consumers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 16:00:06 -05:00
parent fae39d9c79
commit dc4b14d3e5
5 changed files with 50 additions and 17 deletions

View File

@@ -142,7 +142,7 @@ build failure.
A guarded fisherman who warms up and, if trusted, names who really owns the boats. A guarded fisherman who warms up and, if trusted, names who really owns the boats.
```yaml ```yaml
id: person.old-teague id: person.specimen-teague
type: person type: person
status: canon status: canon
secrecy: 0 secrecy: 0
@@ -153,37 +153,37 @@ body: >
``` ```
```yaml ```yaml
id: fact.foreign-coin-on-the-docks id: fact.specimen-foreign-coin
type: fact type: fact
status: canon status: canon
secrecy: 2 secrecy: 2
related: [place.specimen-wharf, person.old-teague] related: [place.specimen-wharf, person.specimen-teague]
body: > body: >
The dock hands are paid in pass-country coin, not town mint. Someone outside the The dock hands are paid in pass-country coin, not town mint. Someone outside the
charter is buying the wharf's silence, a little at a time. charter is buying the wharf's silence, a little at a time.
``` ```
```yaml ```yaml
id: npc.old-teague id: npc.specimen-teague
type: person type: person
status: canon status: canon
start_disposition: cold start_disposition: cold
related: [person.old-teague, fact.foreign-coin-on-the-docks] related: [person.specimen-teague, fact.specimen-foreign-coin]
body: > body: >
Same man as person.old-teague; his interactive layer. Curt with strangers, not Same man as person.specimen-teague; his interactive layer. Curt with strangers, not
from malice but from a lifetime of watching talkers end up face-down in the from malice but from a lifetime of watching talkers end up face-down in the
river. Warms slowly, and when he does the dryness turns to something almost river. Warms slowly, and when he does the dryness turns to something almost
fond. fond.
knows: knows:
- {fact: fact.foreign-coin-on-the-docks, gate: warm} - {fact: fact.specimen-foreign-coin, gate: warm}
disposition_notes: > disposition_notes: >
Starts cold. Buying his catch, not his story, is what moves him. At warm he Starts cold. Buying his catch, not his story, is what moves him. At warm he
names the coin; he never speculates aloud about who mints it. names the coin; he never speculates aloud about who mints it.
``` ```
Note: `person.old-teague` (secrecy 0, public) and `npc.old-teague` (the layer, Note: `person.specimen-teague` (secrecy 0, public) and `npc.specimen-teague` (the layer,
persona server-only) are two entries. The fact is secrecy 2, gated at `warm`. persona server-only) are two entries. The fact is secrecy 2, gated at `warm`.
## Hand-authored JSON ## Hand-authored JSON

View File

@@ -39,7 +39,8 @@ func _load_dir(dir_path: String) -> Dictionary:
var out: Dictionary = {} var out: Dictionary = {}
var dir := DirAccess.open(dir_path) var dir := DirAccess.open(dir_path)
if dir == null: if dir == null:
push_error("content dir not found: %s" % dir_path) # A missing content dir means "no content of this kind authored yet",
# not an error — e.g. content/world/topics/ before any gated content exists.
return out return out
for file in dir.get_files(): for file in dir.get_files():
if not file.ends_with(".json"): if not file.ends_with(".json"):

View File

@@ -52,17 +52,25 @@ func test_null_quest_ref_resolves():
assert_eq(db.unresolved_refs(o), []) assert_eq(db.unresolved_refs(o), [])
func test_loads_canon_and_topics(): func test_loads_canon():
assert_true(db.has_canon("town.duncarrow")) assert_true(db.has_canon("faction.the-seven"))
assert_true(db.has_canon("place.the-white-antlers")) assert_true(db.has_canon("person.the-warden"))
assert_true(db.has_topic("rumor.travelers-go-missing")) assert_true(db.has_canon("rule.disposition-ladder"))
func test_topic_skeleton_has_no_body(): func test_no_loaded_topic_has_a_body():
var t: Dictionary = db.topic("secret.crell-runs-slave-trade") # Bodies are server-only (secrecy≥1 knowledge routes to content/server/topics,
assert_false(t.has("body")) # bodies are server-only # never the client). Structural, so it stays a real guard even when the
# topic set is empty (as it is now — the last authored topics were purged)
# and automatically starts testing something the moment a topic exists.
# One assertion, always executed (not per-loop-item), so an empty topic
# set still exercises the check instead of asserting zero times.
var leaked: Array = []
for id in db.topics:
if db.topics[id].has("body"):
leaked.append(id)
assert_eq(leaked, [], "topic(s) leaked a body to the client")
func test_legacy_npcs_still_load(): func test_legacy_npcs_still_load():
assert_true(db.has_npc("fenn")) assert_true(db.has_npc("fenn"))
assert_true(db.has_npc("npc.mera-fenn"))

View File

@@ -25,6 +25,14 @@ order the Margreave is being written. Source of truth is `content/lore/*.md`;
example. Not game canon. The Duncarrow scaffolding (Crell chain, Mera Fenn) is example. Not game canon. The Duncarrow scaffolding (Crell chain, Mera Fenn) is
deleted. deleted.
## Lesson from the Duncarrow purge
Generated content under `content/world/` has a client-side consumer:
`client/scripts/content/content_db.gd` (loaded via `client/scripts/harness/npc_harness.gd`)
and its GUT tests in `client/tests/unit/test_content_db.gd`. Deleting generated
content — a whole namespace of topics/npcs/canon — is a **client change**, not
just a content change; check `client/` before purging.
## Pending (not yet authored) ## Pending (not yet authored)
- Per-town knowledge that grounds the cosmology: possessed NPCs, blood-harvest - Per-town knowledge that grounds the cosmology: possessed NPCs, blood-harvest

View File

@@ -0,0 +1,16 @@
"""Smoke gate: the real Margreave bible builds clean. Local mirror of the CI
content-build workflow's `--check` step — asserts freshness/validity only,
never specific entities (that's what made the old Duncarrow-era test brittle)."""
from pathlib import Path
from content_build.__main__ import check
REPO_ROOT = Path(__file__).parents[3]
def test_real_bible_check_is_clean():
lore = REPO_ROOT / "content" / "lore"
world = REPO_ROOT / "content" / "world"
server = REPO_ROOT / "content" / "server" # absent today — check() must tolerate that
assert check(lore, world, server) == 0