Exact source used by this build. SHA-256: 57ecf9c7d5d081fb4731a97eadc803e515f0d829804511fee6b9a3b98a89af90.
1 # Content authoring
2
3 Claude drafts batches, Nick edits and curates them, and `content:apply` merges only
4 approved entries. Keep drafts outside the live catalog until they pass this gate.
5 The tools run locally in Bun; they add no game system, network endpoint, UI, or
6 runtime dependency. Follow [extending.md](extending.md) when adding domain schemas
7 or content files.
8
9 ## Draft, curate, apply
10
11 1. Create `content-batches/YYYY-MM-DD-topic.json` at the project root. Use a real
12 date and a lowercase, hyphenated topic. Each proposed entry starts with
13 `status: "proposed"` and `editedBy: null`.
14 2. Claude proposes changes; Nick reviews the actual words, edits them, sets
15 `editedBy` to his name, and marks each entry `approved` or `rejected`.
16 `editedBy` records the review claim; it is not authentication or proof that a
17 particular person performed the review. Automation must not mark its own drafts
18 approved on Nick's behalf.
19 3. Preview, then apply the curated batch:
20
21 ```sh
22 bun run content:apply content-batches/2026-10-22-crop-names.json --dry-run
23 bun run content:apply content-batches/2026-10-22-crop-names.json
24 bun run check
25 bun run --cwd apps/client build
26 ```
27
28 4. Review the diff. If the batch includes dialogue, also run `bun run content:swap`
29 after applying and resolve its findings with Nick. Keep the batch as the review
30 record. Applying it again makes no changes if the content already matches.
31
32 The following is a format template, deliberately still proposed and unshippable:
33
34 ```json
35 {
36 "version": 1,
37 "entries": [
38 {
39 "file": "items/items.json",
40 "collection": "",
41 "id": "crop_01",
42 "status": "proposed",
43 "editedBy": null,
44 "changes": {
45 "name": "TBD",
46 "description": "TBD"
47 }
48 }
49 ]
50 }
51 ```
52
53 `file` is relative to `packages/content/data/`. `collection` is an optional
54 RFC 6901 JSON pointer to an array or singleton record; the empty string means
55 the file's root. For example, `/professions` selects the professions array,
56 and `/areas/0` selects the first area record. Escape `~` as `~0` and `/` as `~1`
57 inside pointer segments. The selected record must have the matching string `id`.
58 Entries in an array are matched by ID, never by position.
59
60 `changes` shallowly merges fields into the matching record. Omitted fields remain
61 intact; arrays and nested objects supplied in the patch replace that whole field.
62 Supply the complete nested value when editing one. IDs cannot be renamed. A new
63 ID can be appended to an existing array only with an explicit matching `id`,
64 `name`, and `description` in `changes`; provide all other fields required by that
65 domain's schema as well. Create new JSON files and register new domains through
66 the existing extension workflow first. The apply tool does not create or delete
67 content files, remove records, register domains, or rewrite batch statuses.
68
69 Only approved entries require a nonblank `editedBy`. Proposed and rejected entries
70 are skipped, but malformed metadata, unknown fields, duplicate batch targets,
71 ambiguous target IDs, path traversal, symlinked content paths, and unsafe object
72 keys refuse the batch. Every approved record is linted with `TBD` treated as an
73 error, including unchanged fields on that record. The full candidate catalog is
74 also linted to detect name collisions across files; unrelated placeholders remain
75 warnings. No writes happen when any lint error exists.
76
77 All files are checked before replacement; concurrent edits detected during
78 validation cause refusal. Each replacement is atomic. A later write failure
79 attempts to restore earlier replacements, preserving another writer's intervening
80 changes and reporting anything needing review. This is not a multi-file filesystem
81 transaction: run only one apply at a time. An interrupted process can leave a
82 partial multi-file update; inspect Git and rerun the idempotent batch. Git remains
83 the checkpoint and rollback system.
84
85 The lint gate checks writing, not domain completeness. `bun run check` remains
86 required for Zod schemas, cross-domain references, simulation and server tests.
87 The gate does not verify that a newly created file has been registered for runtime
88 loading (items and crops use the automatic catalog generator).
89
90 ## Lint commands and scope
91
92 ```sh
93 bun run content:lint # development: TBD warns
94 bun run content:lint:release # every TBD fails
95 bun run content:lint --tbd fail --json # machine-readable full diagnostics
96 bun run content:lint --all # all diagnostics, no display limit
97 bun run content:lint --root /tmp/release-content --tbd fail
98 bun run content:lint --config tools/content/config.json
99 ```
100
101 Every JSON file recursively under the chosen root belongs to that content set,
102 including unregistered files. The root must contain domain folders (`items/`,
103 `villagers/`, etc.), like `packages/content/data/`. Stage a smaller release set
104 with that same layout to check it separately. Missing roots, malformed JSON,
105 symlinks inside the content tree, invalid settings, and invalid regexes fail
106 rather than silently skipping files. `--tbd` overrides the configuration policy.
107 `bun run check` includes development lint and strict TypeScript checking for the
108 authoring tools. Release validation is an explicit command until placeholders
109 have been curated.
110
111 Diagnostics include file, JSON pointer, rule, and severity. The default human
112 output shows errors first, then warnings, up to 40 diagnostics plus a total.
113 `--all` and `--json` include everything. Lint exits 1 on any error and 0 when only
114 warnings remain.
115
116 Fields are selected recursively by `creativeFields` and `textContainers` in
117 `tools/content/config.json`. These cover names, descriptions, dialogue strings,
118 keyed dialogue variants, mail subjects and bodies, festival text, and voice-sheet
119 examples. Dialogue/mail/festival domain roots also accept strings or string arrays.
120 IDs, asset keys, conditions, and similar metadata are excluded by
121 `technicalFields`. Localized strings inside a creative field keep that field's
122 rules. Add new prose keys/containers to the configuration when introducing a
123 schema, and add a fixture proving those strings are scanned.
124
125 Tiled `name` fields identify layers, objects, tilesets, and property keys; they
126 are not displayed names. The scanner skips those technical names and checks
127 player-facing custom property values such as `label`, `name`, and `description`.
128 Ordinary JSON names outside a Tiled map remain subject to all name rules.
129
130 ## Writing rules
131
132 - Creative content uses numbered IDs and `name: "TBD"` / `description: "TBD"`
133 until curated. Use typed placeholder slots for dialogue, mail, and festival
134 copy. An approved entry cannot contain `TBD`; development warnings do not make
135 it ready to ship. Empty creative strings also fail.
136 - Item descriptions have at most 12 words. Unicode letters/numbers count;
137 apostrophes and internal hyphens join a word. Other domains do not inherit this
138 length limit.
139 - Banned words and phrases include cozy, whimsical, quaint, a touch of, nestled,
140 tapestry, testament, I suppose, and quite the. Matching ignores case, handles
141 curly apostrophes and whitespace, and uses word boundaries.
142 - Banned patterns include em dashes, stock `Ah, <name>!` openers, three-part lists,
143 and moral endings. These are deliberately visible heuristics, not a claim that
144 code understands literary style. Curate the regexes and their fixtures when a
145 false positive or missed pattern is found.
146 - Names are compared globally after case, accents, whitespace, and punctuation
147 normalization. Exact duplicates fail. Near duplicates use Levenshtein distance:
148 by default both names need at least four characters, edit distance at most one,
149 and distance divided by the longer name length at most 0.2.
150 - `reserved-names.json` holds a curated reference denylist across villagers, crops,
151 fish, items, and places. It includes the full roster in `content-targets.md`
152 and broad documented/known English names; it is not exhaustive. Names only
153 are stored there, never game dialogue or descriptions.
154 - `softFantasyPatterns` rejects configured generic name shapes and compounds.
155 These patterns apply to names, not ordinary prose.
156
157 Plain material and tool words are explicitly allowed by domain/category in
158 `plainNames`; this does not exempt their descriptions from lint. The existing
159 functional area labels `Farm` and `Beach` have exceptions restricted to their
160 specific IDs in the farm domain. These exceptions do not permit a villager or
161 item to inherit a reserved place name. Two plain labels in different domains
162 may refer to the same tool; duplicate names within a domain still fail.
163 Near-spelling comparisons between two allowlisted plain terms are skipped.
164 Matching crop and produce IDs may share a name across `crops` and `items`, as
165 listed in `sharedNameDomains`. Different IDs still collide.
166
167 Do not add arbitrary exemptions to push a draft through. Keep exceptions narrow
168 and explained. Naming and prose remain original; the reference denylist is a
169 rejection aid, not a content source. Visuals continue to use placeholder shapes
170 and manifest keys such as `fish_01.icon`; this pipeline does not create artwork.
171
172 ## Extend the policy
173
174 Edit `tools/content/banned.json` for words/phrases and prose patterns. A pattern is:
175
176 ```json
177 {
178 "id": "specific-rule-id",
179 "source": "a JavaScript regular expression",
180 "flags": "iu",
181 "message": "A concrete instruction for the editor."
182 }
183 ```
184
185 Regex backslashes must be doubled in JSON. Pattern IDs must be unique within
186 their list. Flags may use `i`, `m`, and `u`; stateful `g`/`y` matching is forbidden.
187 Add a failing and a passing fixture under `tools/content/`, then run
188 `bun test tools/content` and `bun run check`. Add reserved names in the appropriate
189 category and name-shape patterns in `config.json`. `--config` accepts partial
190 policy overrides merged over defaults; nested objects and arrays replace their
191 whole default value. Unknown keys fail to catch policy typos. `content:apply`
192 always uses repository policy through its CLI, so preview configuration changes
193 with lint before adopting them.
194
195 ## Voice swap review
196
197 Voice sheets live at `packages/content/data/villagers/voices/*.json`. The folder
198 may be absent. Each file uses exactly this shape:
199
200 ```json
201 {
202 "speakerId": "villager_01",
203 "examples": ["TBD"],
204 "petWords": []
205 }
206 ```
207
208 Nick supplies independently curated example lines and characteristic words or
209 phrases. Do not use the same unreviewed dialogue as both the baseline and the
210 sample. `TBD` examples cannot train a profile; at least two trained speakers are
211 needed. The example above reserves a typed slot and is not a trained voice.
212
213 ```sh
214 bun run content:swap
215 bun run content:swap --sample 40 --offset 1
216 bun run content:swap --voices /tmp/voices --content packages/content/data
217 ```
218
219 The helper deterministically selects up to 20 lines per speaker by default from
220 stable sorted, evenly spaced positions. `--offset` rotates the sample. Raise
221 `--sample` above the available count to review all lines. It extracts attributed
222 `dialogue`, `dialogues`, `lines`, `line`, and `text` using `speakerId`, `villagerId`,
223 `speaker`, or a numbered villager record ID. Voice-sheet files are excluded from
224 sampling. Other schemas can call the exported `analyzeSwap` with explicit
225 `{ speakerId, id, text, file? }` records.
226
227 Scores compare word count, punctuation frequencies, and pet-word frequencies
228 using a common vocabulary across speakers. The report shows each contribution
229 and the closest alternative speaker. A line fails review when another voice fits
230 better or the advantage is within the tie tolerance (default 0.05, configurable
231 with `--tie-tolerance`). This is an explainable style check; it cannot assess
232 meaning, characterization, or whether the line is good writing.
233
234 Absent sheets skip successfully. Unknown/untrained speakers, placeholder lines,
235 and insufficient trained speakers are reported as skips. Malformed existing
236 sheets fail. Exit codes: 0 for no findings or absence, 1 for mismatch/tie findings,
237 2 for invalid input. Swap findings require editorial review and are not silently
238 folded into the automatic apply gate.
239