@heroiclands/package-build 22.19.0 → 22.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ /**
4
+ * The note type a standing is held in.
5
+ *
6
+ * A standing is always held in a body: the entry is keyed by that body's
7
+ * Address, which is what lets the rung and the post be checked against what
8
+ * the body itself declares.
9
+ */
10
+ export const STANDING_BODY_TYPES = Object.freeze(["affiliation"]);
11
+
12
+ /**
13
+ * The two keys one standing carries.
14
+ *
15
+ * `rank` is the level on the body's own `governance.ranks` ladder, as a number.
16
+ * `office` is a key of its `governance.offices` map, as a string. Both are
17
+ * optional — a membership with neither is the ordinary shape.
18
+ */
19
+ export const STANDING_KEYS = Object.freeze(["rank", "office"]);
@@ -0,0 +1,455 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **A being's standing, held in the body that confers it.**
16
+ *
17
+ * `data.affiliations` is a map keyed by the affiliation's Address, and each
18
+ * entry carries the standing the being holds *in that body*:
19
+ *
20
+ * ```yaml
21
+ * data:
22
+ * affiliations:
23
+ * vrystwldtrbs: { rank: 5, office: War Chief }
24
+ * greenwardens: { rank: 1 }
25
+ * ```
26
+ *
27
+ * The key is what makes the pair expressible. A being in two bodies holds a
28
+ * standing in each, two bodies may name a rung the same word, and both facts
29
+ * are about one membership rather than about the being at large.
30
+ *
31
+ * **`rank` is a number, not a link.** The body declares `{level, title,
32
+ * description}` for every rung of its `governance.ranks`, so a number indexes
33
+ * into that ladder and the body stays the single source for what its level 5 is
34
+ * called. **`office` is a string**, matched against the keys of the body's
35
+ * `governance.offices` map: there are far more offices in a tree than bodies,
36
+ * most of them held by one body alone, and an Address would make writing the
37
+ * fact wait on authoring a page about the post.
38
+ *
39
+ * Both are resolved against the one affiliation the entry is keyed by, which is
40
+ * what lets each be checked — a rung the body does not confer and an office it
41
+ * does not hold are both findings naming the file, line and column.
42
+ *
43
+ * @module
44
+ */
45
+
46
+ import { acceptsType, isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
47
+ import { AddressEntries } from "./address-values.mjs";
48
+ import { slugify } from "./content-slug.mjs";
49
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
50
+ import { STANDING_BODY_TYPES, STANDING_KEYS } from "./standing-terms.mjs";
51
+
52
+ export { STANDING_BODY_TYPES, STANDING_KEYS };
53
+
54
+ /** Whether a value is a plain mapping. */
55
+ function mapping(value) {
56
+ return value !== null && typeof value === "object" && !Array.isArray(value);
57
+ }
58
+
59
+ /**
60
+ * The standings a being authored, in one shape whichever shape it wrote.
61
+ *
62
+ * The list form names bodies and no standings; the map form names both. Three
63
+ * spellings reach here and every reader downstream takes the same entries, so
64
+ * none of them asks which it was handed:
65
+ *
66
+ * - an **{@link AddressEntries}** — the map after the note boundary decoded its
67
+ * keys into typed Addresses, which is the shape every compile sees;
68
+ * - a **plain map**, which is the shape a checker reading raw YAML sees;
69
+ * - a **list**, which names bodies and says nothing about standing in them.
70
+ *
71
+ * An emptied map arrives from the property editor as `[]` and means what `{}`
72
+ * means — this being authors no membership — so both read as no entries.
73
+ *
74
+ * Each entry carries `body` for a message and `sourceKey` for a position: the
75
+ * authored key is what a reader has to find in the file, and the decoded
76
+ * Address is what a finding should name.
77
+ *
78
+ * @param {unknown} value - The authored `data.affiliations`.
79
+ * @returns {{form: "absent"|"list"|"map"|"malformed",
80
+ * entries: Array<{body: unknown, sourceKey: string|number, standing: unknown}>}}
81
+ * The form it was written in, and its entries.
82
+ */
83
+ export function readStandings(value) {
84
+ if (value === undefined || value === null) return { form: "absent", entries: [] };
85
+ if (value instanceof AddressEntries) {
86
+ return {
87
+ form: "map",
88
+ entries: value.entries.map((entry) => ({
89
+ body: entry.target,
90
+ sourceKey: entry.sourceKey,
91
+ standing: entry.value,
92
+ })),
93
+ };
94
+ }
95
+ if (Array.isArray(value)) {
96
+ // An empty list is an emptied map, not a membership stated as a list.
97
+ if (value.length === 0) return { form: "map", entries: [] };
98
+ return {
99
+ form: "list",
100
+ entries: value.map((body, position) => ({
101
+ body,
102
+ sourceKey: position,
103
+ standing: undefined,
104
+ })),
105
+ };
106
+ }
107
+ if (!mapping(value) || isAddressTuple(value)) return { form: "malformed", entries: [] };
108
+ return {
109
+ form: "map",
110
+ entries: Object.entries(value).map(([body, standing]) => ({
111
+ body,
112
+ sourceKey: body,
113
+ standing,
114
+ })),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * The rungs an affiliation's ladder declares, as level → title.
120
+ *
121
+ * A rung states its own `level`, so the ladder is read by that number rather
122
+ * than by its position in the list: a body may write its rungs in any order,
123
+ * and several start at zero.
124
+ *
125
+ * @param {unknown} ranks - An affiliation's `data.governance.ranks`.
126
+ * @returns {Map<number, string>} Level → the title that level is called.
127
+ */
128
+ export function rankLadder(ranks) {
129
+ const ladder = new Map();
130
+ if (!Array.isArray(ranks)) return ladder;
131
+ for (const rung of ranks) {
132
+ if (!mapping(rung)) continue;
133
+ const level = Number(rung.level);
134
+ if (!Number.isFinite(level)) continue;
135
+ if (!ladder.has(level)) ladder.set(level, typeof rung.title === "string" ? rung.title : "");
136
+ }
137
+ return ladder;
138
+ }
139
+
140
+ /**
141
+ * The offices an affiliation names, as key → description.
142
+ *
143
+ * An office is authored either as a bare description or as a `{description,
144
+ * holders}` map, and a reader of the post's name wants the same string from
145
+ * both.
146
+ *
147
+ * @param {unknown} offices - An affiliation's `data.governance.offices`.
148
+ * @returns {Map<string, string>} Office key → its description.
149
+ */
150
+ export function officeRoster(offices) {
151
+ const roster = new Map();
152
+ if (!mapping(offices)) return roster;
153
+ for (const [office, value] of Object.entries(offices)) {
154
+ if (typeof value === "string") roster.set(office, value);
155
+ else if (mapping(value))
156
+ roster.set(office, typeof value.description === "string" ? value.description : "");
157
+ else roster.set(office, "");
158
+ }
159
+ return roster;
160
+ }
161
+
162
+ /**
163
+ * The anchor an office's row is addressed by.
164
+ *
165
+ * Declared once, because the row that carries the anchor and the link that
166
+ * reaches it are drawn by different passes: two spellings of the same
167
+ * derivation is a link into a page that offers no such destination.
168
+ *
169
+ * An office whose name reduces to nothing URL-safe has no anchor, and the
170
+ * caller is the one that can say what to do without one.
171
+ *
172
+ * @param {string} office - The office's key, as the body wrote it.
173
+ * @returns {string} The anchor, or `""` where the name carries nothing.
174
+ */
175
+ export function officeAnchor(office) {
176
+ const slug = slugify(office);
177
+ return slug ? `office-${slug}` : "";
178
+ }
179
+
180
+ /**
181
+ * What an affiliation lends to a reference to it.
182
+ *
183
+ * A reference's value answers what was found as well as where it is — the same
184
+ * reason a target's `subType` travels with it. A standing is read on the
185
+ * *being*, and what it is called is declared on the *body*, so the ladder and
186
+ * the office roster ride along to the one place that needs them.
187
+ *
188
+ * Nothing rides for a note that declares neither.
189
+ *
190
+ * @param {object} fm - The affiliation note's frontmatter.
191
+ * @returns {{ranks?: Record<string, string>, offices?: Record<string, string>}|undefined}
192
+ * What a reference carries, or nothing.
193
+ */
194
+ export function standingsDigest(fm) {
195
+ const governance = fm?.data?.governance;
196
+ if (!mapping(governance)) return undefined;
197
+ const ladder = rankLadder(governance.ranks);
198
+ const roster = officeRoster(governance.offices);
199
+ if (!ladder.size && !roster.size) return undefined;
200
+ return {
201
+ ...(ladder.size ? { ranks: Object.fromEntries(ladder) } : {}),
202
+ ...(roster.size ? { offices: Object.fromEntries(roster) } : {}),
203
+ };
204
+ }
205
+
206
+ /**
207
+ * How a standing reads beside the body that confers it.
208
+ *
209
+ * The office leads, because it is the more particular of the two: a reader
210
+ * meeting "War Chief" has been told the rung as well. The body closes the
211
+ * phrase, which is the whole point of keying the entry by it — a standing with
212
+ * no body named is the lossy form.
213
+ *
214
+ * No article is supplied. One body's name reads with a "the" in front of it and
215
+ * the next does not, and inventing one is this build editing the corpus.
216
+ *
217
+ * @param {object} standing - `{rank, office}`, either or both absent.
218
+ * @param {string} body - What the body is called.
219
+ * @param {{ranks?: Record<string, string>}} [digest] - The body's ladder.
220
+ * @returns {string} The phrase, or the body's name where no standing is held.
221
+ */
222
+ export function standingPhrase(standing, body, digest) {
223
+ const parts = [];
224
+ if (typeof standing?.office === "string" && standing.office.trim())
225
+ parts.push(standing.office.trim());
226
+ const level = Number(standing?.rank);
227
+ if (Number.isFinite(level)) {
228
+ const title = digest?.ranks?.[String(level)];
229
+ parts.push(title ? title : `Rank ${level}`);
230
+ }
231
+ return parts.length ? `${parts.join(", ")}, of ${body}` : body;
232
+ }
233
+
234
+ /**
235
+ * Validate a being's memberships and the standing it holds in each.
236
+ *
237
+ * Four questions, and the last two are what keying the entry by its body buys:
238
+ *
239
+ * 1. the value is a map keyed by Address (or the list this window still takes);
240
+ * 2. every key names an affiliation that resolves, once;
241
+ * 3. a `rank` is a level the named body's `governance.ranks` declares;
242
+ * 4. an `office` is a key of that body's `governance.offices`.
243
+ *
244
+ * Both of the last two are read from the named body's own frontmatter as the
245
+ * lint runs, so a renamed rung or a renamed post fails on every being pointing
246
+ * at it rather than on a list somebody has to remember to edit.
247
+ *
248
+ * @param {object} note - The note.
249
+ * @param {{index?: object}} [options] - The lint's options.
250
+ * @returns {object[]} Located findings.
251
+ */
252
+ export function checkStandings(note, { index } = {}) {
253
+ const value = note.fm?.data?.affiliations;
254
+ const { form, entries } = readStandings(value);
255
+ if (form === "absent") return [];
256
+ if (form === "malformed")
257
+ return [
258
+ {
259
+ ...position(note, ["data", "affiliations"]),
260
+ message:
261
+ "`data.affiliations` must be a map keyed by an affiliation Address, " +
262
+ "each entry holding that body's standing",
263
+ },
264
+ ];
265
+
266
+ const findings = [];
267
+ const seen = new Map();
268
+ // The list form names bodies and has nowhere to put a standing. Its entries
269
+ // are still held to being affiliations that resolve, so a tree part-way
270
+ // through the sweep keeps the check it had.
271
+ const isKey = form === "map";
272
+ for (const { body, sourceKey, standing } of entries) {
273
+ const path = ["data", "affiliations", sourceKey];
274
+ const resolved = resolveBody(note, index, body);
275
+ findings.push(...bodyFindings(note, body, path, isKey, resolved));
276
+ if (resolved.address) {
277
+ if (seen.has(resolved.address)) {
278
+ findings.push({
279
+ ...position(note, path, isKey),
280
+ message:
281
+ `\`data.affiliations\` names ${resolved.address} twice, as ` +
282
+ `${JSON.stringify(String(seen.get(resolved.address)))} and ` +
283
+ `${JSON.stringify(String(sourceKey))}; one membership per body`,
284
+ });
285
+ continue;
286
+ }
287
+ seen.set(resolved.address, sourceKey);
288
+ }
289
+ if (!isKey) continue;
290
+ if (standing === undefined || standing === null) continue;
291
+ if (Array.isArray(standing) && standing.length === 0) continue;
292
+ const named = resolved.address ?? String(body);
293
+ if (!mapping(standing)) {
294
+ findings.push({
295
+ ...position(note, path),
296
+ message:
297
+ `\`data.affiliations\` entry for ${named} must hold that body's standing ` +
298
+ "as a map of `rank` and `office`, but reads " +
299
+ JSON.stringify(standing),
300
+ });
301
+ continue;
302
+ }
303
+ for (const key of Object.keys(standing)) {
304
+ if (STANDING_KEYS.includes(key)) continue;
305
+ findings.push({
306
+ ...position(note, [...path, key], true),
307
+ message:
308
+ `\`data.affiliations\` entry for ${named} has unknown key \`${key}\`; a ` +
309
+ `standing holds ${STANDING_KEYS.map((name) => `\`${name}\``).join(" and ")}`,
310
+ });
311
+ }
312
+ findings.push(...rankFindings(note, standing, named, path, resolved));
313
+ findings.push(...officeFindings(note, standing, named, path, resolved));
314
+ }
315
+ return findings;
316
+ }
317
+
318
+ /**
319
+ * Where a finding about one authored key sits.
320
+ *
321
+ * @param {object} note - The note.
322
+ * @param {Array<string|number>} path - The frontmatter path.
323
+ * @param {boolean} [key] - Whether the key itself is at fault.
324
+ * @returns {object} The located, error-severity half of a finding.
325
+ */
326
+ function position(note, path, key) {
327
+ return {
328
+ file: note.file,
329
+ ...positionOfFrontmatterPath(note.raw ?? "", path, key ? { key: true } : undefined),
330
+ severity: "error",
331
+ };
332
+ }
333
+
334
+ /**
335
+ * The affiliation one key or entry names, and what that note declares.
336
+ *
337
+ * @param {object} note - The citing note.
338
+ * @param {object|undefined} index - The lint's address index.
339
+ * @param {unknown} body - The authored Address.
340
+ * @returns {{address?: string, type?: string, reason?: string, fm?: object, missing?: boolean}}
341
+ * What it names.
342
+ */
343
+ function resolveBody(note, index, body) {
344
+ const tuple = parseAddress(
345
+ body,
346
+ {
347
+ package: index?.contentPackage ?? note.fm?.package,
348
+ system: "note",
349
+ type: "affiliation",
350
+ types: index?.types,
351
+ packages: index?.packages,
352
+ },
353
+ { declared: true },
354
+ );
355
+ if (tuple.reason) return { reason: tuple.reason };
356
+ if (!acceptsType(tuple, STANDING_BODY_TYPES)) return { type: tuple.type };
357
+ const address = renderAddress(tuple);
358
+ if (!index?.addressHit) return { address };
359
+ const target = index.addressHit(address);
360
+ if (!target) return { address, missing: true };
361
+ return { address, fm: target.fm ?? target };
362
+ }
363
+
364
+ /** Findings about the body an entry names. */
365
+ function bodyFindings(note, body, path, isKey, found) {
366
+ const at = position(note, path, isKey);
367
+ const written = isAddressTuple(body) ? renderAddress(body) : String(body);
368
+ const label = `\`data.affiliations\`${isKey ? " key" : ""} ${JSON.stringify(written)}`;
369
+ if (found.reason)
370
+ return [{ ...at, message: `${label} is not a complete Address (${found.reason})` }];
371
+ if (found.type)
372
+ return [{ ...at, message: `\`data.affiliations\` accepts affiliation, not ${found.type}` }];
373
+ if (found.missing)
374
+ return [
375
+ {
376
+ ...at,
377
+ message:
378
+ `\`data.affiliations\` names ${found.address}, which does not resolve ` +
379
+ "in this package or its declared dependencies",
380
+ },
381
+ ];
382
+ return [];
383
+ }
384
+
385
+ /** Whether a `rank` is a rung the named body confers. */
386
+ function rankFindings(note, standing, body, path, resolved) {
387
+ if (standing.rank === undefined || standing.rank === null) return [];
388
+ const at = position(note, [...path, "rank"]);
389
+ const level = typeof standing.rank === "number" ? standing.rank : Number(standing.rank);
390
+ if (typeof standing.rank !== "number" || !Number.isInteger(level))
391
+ return [
392
+ {
393
+ ...at,
394
+ message:
395
+ `\`data.affiliations\` entry for ${body}: \`rank\` must be the level on that body's own ` +
396
+ `ladder, as a whole number, but reads ${JSON.stringify(standing.rank)}`,
397
+ },
398
+ ];
399
+ if (!resolved.fm) return [];
400
+ const ladder = rankLadder(resolved.fm.data?.governance?.ranks);
401
+ if (ladder.has(level)) return [];
402
+ if (!ladder.size)
403
+ return [
404
+ {
405
+ ...at,
406
+ message:
407
+ `\`data.affiliations\` entry for ${body} states a rung, and that body ` +
408
+ "declares no `data.governance.ranks` ladder to hold one",
409
+ },
410
+ ];
411
+ const levels = [...ladder.keys()].sort((a, b) => a - b);
412
+ return [
413
+ {
414
+ ...at,
415
+ message:
416
+ `\`data.affiliations\` entry for ${body}: rank ${level} is not a level ` +
417
+ `${resolved.address} confers; its ladder runs ${levels.join(", ")}`,
418
+ },
419
+ ];
420
+ }
421
+
422
+ /** Whether an `office` is a post the named body names. */
423
+ function officeFindings(note, standing, body, path, resolved) {
424
+ if (standing.office === undefined || standing.office === null) return [];
425
+ const at = position(note, [...path, "office"]);
426
+ if (typeof standing.office !== "string" || !standing.office.trim())
427
+ return [
428
+ {
429
+ ...at,
430
+ message:
431
+ `\`data.affiliations\` entry for ${body}: \`office\` must name a post from its ` +
432
+ `\`governance.offices\`, but reads ${JSON.stringify(standing.office)}`,
433
+ },
434
+ ];
435
+ if (!resolved.fm) return [];
436
+ const roster = officeRoster(resolved.fm.data?.governance?.offices);
437
+ if (roster.has(standing.office)) return [];
438
+ if (!roster.size)
439
+ return [
440
+ {
441
+ ...at,
442
+ message:
443
+ `\`data.affiliations\` entry for ${body} names an office, and that body ` +
444
+ `${resolved.address} declares no \`data.governance.offices\` to hold one`,
445
+ },
446
+ ];
447
+ return [
448
+ {
449
+ ...at,
450
+ message:
451
+ `\`data.affiliations\` entry for ${body}: office ${JSON.stringify(standing.office)} is not ` +
452
+ `an office ${resolved.address} names; it holds ${[...roster.keys()].join(", ")}`,
453
+ },
454
+ ];
455
+ }
@@ -45,7 +45,7 @@ const INK_LIGHT = "#211d16";
45
45
  const INK_DARK = "#ece3cf";
46
46
 
47
47
  /**
48
- * The shapes a theme rule may repaint.
48
+ * The shapes a fill rule may repaint.
49
49
  *
50
50
  * Explicitly black, or black by default for want of a `fill`. A shape painted
51
51
  * some other colour is deliberate — a white highlight in a two-tone badge — and
@@ -53,7 +53,7 @@ const INK_DARK = "#ece3cf";
53
53
  *
54
54
  * @type {string}
55
55
  */
56
- const SELECTOR = [
56
+ const FILL_SELECTOR = [
57
57
  '[fill="#000"]',
58
58
  '[fill="#000000"]',
59
59
  '[fill="black"]',
@@ -67,10 +67,21 @@ const SELECTOR = [
67
67
  "g:not([fill])",
68
68
  ].join(",");
69
69
 
70
+ /**
71
+ * The shapes a stroke rule may repaint.
72
+ *
73
+ * Explicitly black only — SVG's default stroke is `none`, so there is no
74
+ * want-of-a-`stroke` case to opt in the way a want-of-a-`fill` case is. A
75
+ * broad match would paint an outline onto a shape that has none.
76
+ *
77
+ * @type {string}
78
+ */
79
+ const STROKE_SELECTOR = ['[stroke="#000"]', '[stroke="#000000"]', '[stroke="black"]'].join(",");
80
+
70
81
  /** @type {string} */
71
82
  const STYLE =
72
- `<style>${SELECTOR}{fill:${INK_LIGHT}}` +
73
- `@media(prefers-color-scheme:dark){${SELECTOR}{fill:${INK_DARK}}}</style>`;
83
+ `<style>${FILL_SELECTOR}{fill:${INK_LIGHT}}${STROKE_SELECTOR}{stroke:${INK_LIGHT}}` +
84
+ `@media(prefers-color-scheme:dark){${FILL_SELECTOR}{fill:${INK_DARK}}${STROKE_SELECTOR}{stroke:${INK_DARK}}}</style>`;
74
85
 
75
86
  /**
76
87
  * Every `style="…"` attribute, capturing its declarations.
@@ -583,7 +583,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
583
583
  * @param {object} index - From {@link buildWikilinkIndex}.
584
584
  * @param {unknown} ref - The reference, as authored.
585
585
  * @param {object} [hint] - `{type}`, where the caller knows what it expects.
586
- * @returns {{name?: string, uuid?: string, address?: import("./address.mjs").AddressTuple, subType?: string}|undefined}
586
+ * @returns {{name?: string, uuid?: string, address?: import("./address.mjs").AddressTuple, subType?: string, standings?: object}|undefined}
587
587
  * The target, or `undefined` where nothing answers.
588
588
  */
589
589
  export function resolveReference(index, ref, hint) {
@@ -611,6 +611,7 @@ export function resolveReference(index, ref, hint) {
611
611
  return {
612
612
  name: published.name,
613
613
  subType: published.subType,
614
+ ...(published.standings ? { standings: published.standings } : {}),
614
615
  ...(published.uuid ? { uuid: published.uuid } : {}),
615
616
  address: tuple,
616
617
  };
@@ -638,6 +639,7 @@ export function resolveReference(index, ref, hint) {
638
639
  return {
639
640
  name: local.name,
640
641
  subType: local.subType,
642
+ ...(local.standings ? { standings: local.standings } : {}),
641
643
  ...(uuid ? { uuid } : {}),
642
644
  address: tuple,
643
645
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.19.0",
3
+ "version": "22.21.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
package/sohl/actors.mjs CHANGED
@@ -29,16 +29,19 @@
29
29
  * `ACTOR_VAULT_TYPE = "being"` here and emitted `type: "being"` several hundred
30
30
  * lines below, which made the two vocabularies agree by coincidence.
31
31
  *
32
- * It was two content types — `character` and `creature` — which compiled to the
33
- * same `being` with no branch anywhere between them; they were retired in
34
- * retired, and are reported by `assertTypeNotRetired` in
35
- * `engine/ids.mjs`.
36
- *
37
32
  * Attributes (`sohl.attributes` map) become embedded attribute items with
38
33
  * `scoreBase` set from the map value. Each entry in `sohl.items` is similarly
39
34
  * resolved by `(type, shortcode)` and deep-merged with the entry's other
40
35
  * properties. `sohl.skills` is ignored.
41
36
  *
37
+ * **A membership is derived, never authored here.** `data.affiliations` names
38
+ * the bodies a being belongs to and the standing it holds in each, and each
39
+ * entry becomes one embedded affiliation item: the entry's `rank` is that
40
+ * item's `system.level` and its `office` is `system.office`. A standing is a
41
+ * fact about the setting rather than about one system — it does not become true
42
+ * because SoHL is installed — so the system block states none of it, and a
43
+ * sheet and a page cannot come to disagree.
44
+ *
42
45
  * Not a standalone script — exports the `Actors` compiler class, imported and
43
46
  * driven by `engine/generate.mjs`. Must run after the items passes, since it
44
47
  * reads their generated JSON trees.
@@ -58,6 +61,10 @@ import { SOHL_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
58
61
  // verbatim, and `sohl.img` / `sohl.effects` / `sohl.flags` overriding their
59
62
  // shared top-level forms for this system alone.
60
63
  import { blockProperty, mergeSystemData } from "../engine/system-block.mjs";
64
+ // A being's memberships and the standing it holds in each, read from the one
65
+ // place they are authored. The rank *is* the affiliation item's `level`, so
66
+ // there is nothing to map and nowhere for a second authoring to disagree.
67
+ import { readStandings } from "../engine/standings.mjs";
61
68
 
62
69
  /**
63
70
  * The system this pass compiles for — the block its notes write.
@@ -256,10 +263,59 @@ export class Actors extends SystemActorCompiler {
256
263
  });
257
264
  }
258
265
 
266
+ for (const embedded of this.buildMemberships(itemsMap, actorId, fm, ctx)) {
267
+ items.push(embedded);
268
+ }
269
+
259
270
  this.openUnopenedSkills(items, ctx);
260
271
  return items;
261
272
  }
262
273
 
274
+ /**
275
+ * One embedded affiliation item per body the being belongs to.
276
+ *
277
+ * The body's own compiled item is the model, so a membership carries
278
+ * whatever that body declares — its subtype, its common skills, its
279
+ * standings toward others — and the being adds only what is true of *this*
280
+ * membership: the rung it stands on and the post it holds.
281
+ *
282
+ * A being naming a body that compiles to no item is a finding rather than a
283
+ * silently dropped membership: the note says the being belongs somewhere,
284
+ * and a sheet that shows nothing says it does not.
285
+ *
286
+ * @param {Map<string, object>} itemsMap - The predefined-item catalogue.
287
+ * @param {string} actorId - The owning actor's id.
288
+ * @param {object} fm - The note's frontmatter.
289
+ * @param {string} ctx - Diagnostic context.
290
+ * @returns {object[]} The embedded items, in the order the note names them.
291
+ */
292
+ buildMemberships(itemsMap, actorId, fm, ctx) {
293
+ const { entries } = readStandings(fm?.data?.affiliations);
294
+ const items = [];
295
+ for (const { body, standing } of entries) {
296
+ const shortcode = body?.shortcode;
297
+ if (!shortcode) continue;
298
+ const level = Number(standing?.rank);
299
+ const office = typeof standing?.office === "string" ? standing.office.trim() : "";
300
+ const system = {
301
+ ...(Number.isFinite(level) ? { level } : {}),
302
+ ...(office ? { office } : {}),
303
+ };
304
+ const embedded = this.resolveEmbedded(
305
+ itemsMap,
306
+ actorId,
307
+ "affiliation",
308
+ shortcode,
309
+ Object.keys(system).length ? { system } : {},
310
+ `affiliations:${shortcode}`,
311
+ ctx,
312
+ { fmKey: "affiliations", modelPackage: body.package ?? null },
313
+ );
314
+ if (embedded) items.push(embedded);
315
+ }
316
+ return items;
317
+ }
318
+
263
319
  /**
264
320
  * Bake each unopened skill's opening mastery level into the document.
265
321
  *
@@ -4,3 +4,7 @@ export function authoredNoteKeys(markdown: any): string[];
4
4
  export const NOTE_TOP_LEVEL_KEYS: readonly string[];
5
5
  /** Membership test for the top-level vocabulary. */
6
6
  export const NOTE_TOP_LEVEL_KEY_SET: Set<string>;
7
+ /** The name properties every note accepts, in display order. */
8
+ export const NOTE_NAME_KEYS: readonly string[];
9
+ /** Additional name components accepted by an individual character. */
10
+ export const CHARACTER_NAME_KEYS: readonly string[];