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.
```yaml
id: person.old-teague
id: person.specimen-teague
type: person
status: canon
secrecy: 0
@@ -153,37 +153,37 @@ body: >
```
```yaml
id: fact.foreign-coin-on-the-docks
id: fact.specimen-foreign-coin
type: fact
status: canon
secrecy: 2
related: [place.specimen-wharf, person.old-teague]
related: [place.specimen-wharf, person.specimen-teague]
body: >
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.
```
```yaml
id: npc.old-teague
id: npc.specimen-teague
type: person
status: canon
start_disposition: cold
related: [person.old-teague, fact.foreign-coin-on-the-docks]
related: [person.specimen-teague, fact.specimen-foreign-coin]
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
river. Warms slowly, and when he does the dryness turns to something almost
fond.
knows:
- {fact: fact.foreign-coin-on-the-docks, gate: warm}
- {fact: fact.specimen-foreign-coin, gate: warm}
disposition_notes: >
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.
```
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`.
## Hand-authored JSON

View File

@@ -39,7 +39,8 @@ func _load_dir(dir_path: String) -> Dictionary:
var out: Dictionary = {}
var dir := DirAccess.open(dir_path)
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
for file in dir.get_files():
if not file.ends_with(".json"):

View File

@@ -52,17 +52,25 @@ func test_null_quest_ref_resolves():
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_loads_canon():
assert_true(db.has_canon("faction.the-seven"))
assert_true(db.has_canon("person.the-warden"))
assert_true(db.has_canon("rule.disposition-ladder"))
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_no_loaded_topic_has_a_body():
# Bodies are server-only (secrecy≥1 knowledge routes to content/server/topics,
# 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():
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
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)
- 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