@heroiclands/package-build 23.2.2 → 23.3.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.
@@ -53,33 +53,33 @@ These fields are accepted by every note type. A field's value can still be irrel
53
53
 
54
54
  **Subtypes:** `npc`, `character`, `creature`.
55
55
 
56
- | Field | Shape | Meaning |
57
- | -------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
- | `data.icon` | an Address | The document's profile art — an `icon` address, resolved into `img`. |
59
- | `data.tokenIcon` | an Address | What a token on the canvas wears — an `icon` address; unset, it follows `icon`. |
60
- | `data.templatePriority` | number | Template priority; unset means the note is not a template. |
61
- | `data.archetypes` | list of strings | Archetypal behaviours the being fits. |
62
- | `data.occupation` | string | What the being does for a living. |
63
- | `data.lore` | list of Addresses | Lore concerning this being — the law it lives under, the customs it is subject to, the traditions it was raised in. |
64
- | `data.culture` | an Address | The being's primary culture, as a culture lore note. |
65
- | `data.homes` | list of Addresses | Places the being calls home. |
66
- | `data.affiliations` | a standing map keyed by Address | Bodies the being belongs to, keyed by Address, each entry holding the standing it holds there — `rank`, required, a level on that body's own ladder, and `office`, optional, a post that body names. |
67
- | `data.socialTies` | a map keyed by Address | Defining ties directed from this being to others: `patron` (Supports the subject from greater power, wealth, or status.); `friend` (Supports the subject out of goodwill.); `dependent` (Relies on the subject's support or protection.); `acquaintance` (Knows the subject without a strong disposition.); `rival` (Opposes the subject without implacable hostility.); `nemesis` (Opposes the subject personally and implacably.) |
68
- | `data.gender` | string | One of `male`, `female`, `nonbinary`, `none` or `other`. `none` says the being has no gender; an absent field says its gender is unrecorded. |
69
- | `data.species` | an Address | The being's species, as a lore note. |
70
- | `data.born` | string | When the being was born — a date, or `unknown` where the birth is unrecorded. Absent, the being was never born. |
71
- | `data.calendar` | an Address | Calendar that displays this being's dates. |
72
- | `data.died` | string | When the being died — a date, or `unknown` where the death is unrecorded. Absent, the being is alive. |
73
- | `data.age` | string | Age in years, stated only to override what `born` says — `34`, or `~34` for an estimate. Unstated beside a dated `born` it is computed; unstated beside an unknown or absent `born` the age is unknown. |
74
- | `data.ageYears` | number | Written by the compiler beside an `age` estimate — the `~` stripped, the magnitude alone. A note never authors this. |
75
- | `data.height` | metres or feet and inches | Height in metres or feet and inches. |
76
- | `data.weight` | kilograms or pounds | Body weight in kilograms or pounds. |
77
- | `data.frame` | string | Relative frame — one of `scant`, `light`, `medium`, `heavy` or `massive`. |
78
- | `data.appearance.eye_color` | string | Eye colour. |
79
- | `data.appearance.hair_color` | string | Hair colour. |
80
- | `data.appearance.skin_color` | string | Skin colour. |
81
- | `data.appearance.complexion` | string or list | The skin's condition — one value, or several, because a face carries more than one at once. A single value means a list of one. |
82
- | `data.appearance.extra_features` | list of strings | Anything else a stranger would notice. |
56
+ | Field | Shape | Meaning |
57
+ | -------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
58
+ | `data.icon` | an Address | The document's profile art — an `icon` address, resolved into `img`. |
59
+ | `data.tokenIcon` | an Address | What a token on the canvas wears — an `icon` address; unset, it follows `icon`. |
60
+ | `data.templatePriority` | number | Template priority; unset means the note is not a template. |
61
+ | `data.archetypes` | list of strings | Archetypal behaviours the being fits. |
62
+ | `data.occupation` | string | What the being does for a living. |
63
+ | `data.lore` | list of Addresses | Lore concerning this being — the law it lives under, the customs it is subject to, the traditions it was raised in. |
64
+ | `data.culture` | an Address | The being's primary culture, as a culture lore note. |
65
+ | `data.homes` | list of Addresses | Places the being calls home. |
66
+ | `data.affiliations` | a standing map keyed by Address | Bodies the being belongs to, keyed by Address, each entry holding the standing it holds there — `rank`, required, a level on that body's own ladder, and `office`, optional, a post that body names. |
67
+ | `data.socialTies` | a map keyed by Address | Defining ties directed from this being to others: `patron` (Supports the subject from greater power, wealth, or status.); `friend` (Supports the subject out of goodwill.); `dependent` (Relies on the subject's support or protection.); `acquaintance` (Knows the subject without a strong disposition.); `rival` (Opposes the subject without implacable hostility.); `nemesis` (Opposes the subject personally and implacably.) |
68
+ | `data.gender` | string | One of `female`, `male`, `nonbinary`, `none` or `other`. `none` says the being has no gender; an absent field says its gender is unrecorded. See [a being's look](format-details.md#a-beings-look) for what each word means. |
69
+ | `data.species` | an Address | The being's species, as a lore note. |
70
+ | `data.born` | string | When the being was born — a date, or `unknown` where the birth is unrecorded. Absent, the being was never born. |
71
+ | `data.calendar` | an Address | Calendar that displays this being's dates. |
72
+ | `data.died` | string | When the being died — a date, or `unknown` where the death is unrecorded. Absent, the being is alive. |
73
+ | `data.age` | string | Age in years, stated only to override what `born` says — `34`, or `~34` for an estimate. Unstated beside a dated `born` it is computed; unstated beside an unknown or absent `born` the age is unknown. |
74
+ | `data.ageYears` | number | Written by the compiler beside an `age` estimate — the `~` stripped, the magnitude alone. A note never authors this. |
75
+ | `data.height` | metres or feet and inches | Height in metres or feet and inches. |
76
+ | `data.weight` | kilograms or pounds | Body weight in kilograms or pounds. |
77
+ | `data.frame` | string | Relative frame — one of `scant`, `light`, `medium`, `heavy` or `massive`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
78
+ | `data.appearance.eye_color` | string | Eye colour: one of `amber`, `black`, `blue`, `brown`, `dark_brown`, `grey`, `green`, `hazel` or `violet`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
79
+ | `data.appearance.hair_color` | string | Hair colour: one of `auburn`, `black`, `blonde`, `brown`, `chestnut`, `dark_blonde`, `dark_brown`, `grey`, `greying_black`, `greying_brown`, `red`, `silver`, `strawberry_blond` or `white`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
80
+ | `data.appearance.skin_color` | string | Skin colour: one of `pale`, `fair`, `light`, `medium`, `olive`, `tawny`, `golden`, `tanned`, `swarthy`, `brown`, `dark`, `dark_brown` or `ebony`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
81
+ | `data.appearance.complexion` | string or list | The skin's condition: `ashen`, `blotchy`, `chapped`, `clear`, `freckled`, `jaundiced`, `leathery`, `oily`, `pasty`, `pimpled`, `pockmarked`, `rough`, `ruddy`, `sallow`, `scabrous`, `scarred`, `smooth`, `sun_kissed`, `sunburnt`, `translucent`, `vitiligo`, `wan`, `weathered` or `wrinkled`, one word or a list of several, because a face carries more than one at once. A single word means a list of one. See [a being's look](format-details.md#a-beings-look) for what each word means. |
82
+ | `data.appearance.extra_features` | list of strings | Anything else a stranger would notice, in free text. |
83
83
 
84
84
  **Inner keys** — a key a field's value does not declare is an error at its own line and column.
85
85
 
package/e2e.mjs CHANGED
@@ -636,26 +636,48 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
636
636
  * @param {string} opts.url - The container's base URL.
637
637
  * @param {string} opts.container - The container name, for its log.
638
638
  * @param {string} opts.stage - The stage, named in a licence failure.
639
+ * @param {string} [opts.dataRoot] - The stage's data root, named when its licence is unsigned.
640
+ * @param {(container: string) => string} [opts.readLog] - Reads the container's log since it started.
639
641
  * @param {number} [opts.timeoutMs] - How long to wait.
640
642
  * @param {(message: string) => void} [opts.log] - Progress reporting.
641
643
  * @returns {Promise<void>} Resolves once the world is active.
642
644
  * @throws {Error} On a licence failure or a timeout.
643
645
  */
644
- export async function waitForWorld({ url, container, stage, timeoutMs = 180_000, log = () => {} }) {
646
+ export async function waitForWorld({
647
+ url,
648
+ container,
649
+ stage,
650
+ dataRoot,
651
+ readLog = captureContainerLog,
652
+ timeoutMs = 180_000,
653
+ log = () => {},
654
+ }) {
645
655
  const join = `${url}/join`;
646
656
  const deadline = Date.now() + timeoutMs;
647
657
  log(`Waiting for the world at ${join} …`);
648
658
  while (Date.now() < deadline) {
649
- const logs = captureContainerLog(container);
659
+ const logs = readLog(container);
660
+ if (/software license requires signature/i.test(logs)) {
661
+ throw new Error(
662
+ "The Foundry licence is not signed, so Foundry serves its licence " +
663
+ "page and no world activates. Each installation fetches its own " +
664
+ "signature with the owner's Foundry account, once per data root: " +
665
+ `run \`package-build container ${stage} start\` with no world ` +
666
+ `(FOUNDRYVTT_${stage.toUpperCase()}_WORLD unset), open the stage in a ` +
667
+ "browser and accept the licence agreement. The signature then " +
668
+ `persists in Config/license.json${dataRoot ? ` under ${dataRoot}` : " in the data root"}. ` +
669
+ "The image applies the key but never signs it.",
670
+ );
671
+ }
650
672
  if (/license verification failed/i.test(logs)) {
651
673
  throw new Error(
652
674
  "Foundry licence verification failed. The end-to-end container " +
653
- "needs its own SIGNED licence: dedicate one with " +
654
- `FOUNDRYVTT_${stage.toUpperCase()}_LICENSE_KEY ` +
655
- "plus FOUNDRY_USERNAME/FOUNDRY_PASSWORD so the image signs " +
656
- "it — a bare key stays unsigned, and another installation's " +
657
- "license.json does not verify. A licence shared with a " +
658
- "running container is single-seat and will fail too.",
675
+ "needs its own signed licence, set with " +
676
+ `FOUNDRYVTT_${stage.toUpperCase()}_LICENSE_KEY and signed by ` +
677
+ "accepting the licence agreement in Foundry's own page once " +
678
+ "per data root. Another installation's license.json does not " +
679
+ "verify, and a licence shared with a running container is " +
680
+ "single-seat and will fail too.",
659
681
  );
660
682
  }
661
683
  try {
@@ -677,12 +699,15 @@ export async function waitForWorld({ url, container, stage, timeoutMs = 180_000,
677
699
 
678
700
  /**
679
701
  * @param {string} container - Container name.
680
- * @returns {string} The tail of its log, or `""`.
702
+ * @returns {string} Its log since it last started, or `""`.
681
703
  */
682
704
  function captureContainerLog(container) {
683
- const result = spawnSync("docker", ["logs", "--tail", "40", container], {
705
+ const started = spawnSync("docker", ["inspect", "-f", "{{.State.StartedAt}}", container], {
684
706
  encoding: "utf8",
685
707
  });
708
+ const since = (started.stdout ?? "").trim();
709
+ const args = since ? ["logs", "--since", since, container] : ["logs", container];
710
+ const result = spawnSync("docker", args, { encoding: "utf8" });
686
711
  return `${result.stdout ?? ""}${result.stderr ?? ""}`;
687
712
  }
688
713
 
@@ -1098,6 +1123,7 @@ export async function e2eRun({
1098
1123
  url: container.url,
1099
1124
  container: container.name,
1100
1125
  stage,
1126
+ dataRoot: resolveDataRoot(stage, { env }),
1101
1127
  log,
1102
1128
  });
1103
1129
  return runSuite({
@@ -1175,6 +1201,7 @@ export async function e2eFast({ config, argv = [], env = process.env, log = () =
1175
1201
  url: container.url,
1176
1202
  container: container.name,
1177
1203
  stage,
1204
+ dataRoot: resolveDataRoot(stage, { env }),
1178
1205
  log,
1179
1206
  });
1180
1207
 
@@ -703,6 +703,12 @@ vocabularies:
703
703
  - "4"
704
704
  - "5"
705
705
  - "6"
706
+ holdingModel:
707
+ - held
708
+ - endowed
709
+ - registered
710
+ - kin
711
+ - polis
706
712
  bearing:
707
713
  - N
708
714
  - NE
@@ -289,9 +289,13 @@ export function checkDataKeys(note, fields) {
289
289
  walk(spec, value, path);
290
290
  };
291
291
 
292
- /** Whether a present value is in the spec's closed set, if it has one; else a finding. */
292
+ /**
293
+ * Whether a present value is in the spec's closed set, if it has one; else a
294
+ * finding. A list is not itself a word: its entries are checked against
295
+ * their own declaration as the walk reaches them.
296
+ */
293
297
  const inAllowed = (spec, value, path) => {
294
- if (!spec.allowed || spec.allowed.includes(value)) return true;
298
+ if (!spec.allowed || Array.isArray(value) || spec.allowed.includes(value)) return true;
295
299
  const guess = typeof value === "string" ? nearest(value, spec.allowed) : undefined;
296
300
  findings.push(
297
301
  finding(
package/engine/index.mjs CHANGED
@@ -103,6 +103,9 @@ export * as populations from "./populations.mjs";
103
103
  /** What trade a settlement supports: the six-step market scale, and the check that holds a value to it. */
104
104
  export * as marketClass from "./market-class.mjs";
105
105
 
106
+ /** A region's settlements table: the holding models, the columns, and the check that holds a table to them. */
107
+ export * as settlementTables from "./settlement-tables.mjs";
108
+
106
109
  /** The places a map is drawn from, read from the content index, and what `parents` says about them. */
107
110
  export * as mapPlaces from "./map-places.mjs";
108
111
 
@@ -491,7 +491,7 @@ export function humanizeFieldName(name) {
491
491
  }
492
492
 
493
493
  /**
494
- * An enumerated value as a reader sees it: `graying_brown` → `graying brown`.
494
+ * An enumerated value as a reader sees it: `greying_brown` → `greying brown`.
495
495
  *
496
496
  * @param {unknown} value - The authored value.
497
497
  * @returns {string} The text.
@@ -127,6 +127,7 @@ import { checkCitedPopulations, checkPopulation } from "./populations.mjs";
127
127
  // finding an author meets state one list.
128
128
  import { MARKET_CLASSES, checkMarket } from "./market-class.mjs";
129
129
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
130
+ import { checkSettlementTables } from "./settlement-tables.mjs";
130
131
 
131
132
  /**
132
133
  * One `data:` key a note type may carry.
@@ -241,11 +242,166 @@ const NUM = Object.freeze({ shape: "number", kind: "number" });
241
242
  /** A string, including the enumerated-value fields the format spells as one. */
242
243
  const TEXT = Object.freeze({ shape: "string", kind: "string" });
243
244
 
245
+ /**
246
+ * One word of a closed vocabulary: the value a note writes, the label a reader
247
+ * sees it by, and what it means.
248
+ *
249
+ * @typedef {object} LookWord
250
+ * @property {string} value - The word as a note writes it.
251
+ * @property {string} label - The word as a reader sees it.
252
+ * @property {string} describe - What it means, in one line.
253
+ */
254
+
255
+ /** A frozen word list from `[value, label, describe]` rows. */
256
+ function lookWords(rows) {
257
+ return Object.freeze(
258
+ rows.map(([value, label, describe]) => Object.freeze({ value, label, describe })),
259
+ );
260
+ }
261
+
262
+ /**
263
+ * The words a being's look is stated in, keyed by the `data:` field that takes
264
+ * them.
265
+ *
266
+ * Each field is closed to its list: any other value, an empty one included, is
267
+ * a finding at the value naming the words it takes. `appearance.complexion`
268
+ * takes one word or a list of them, and each entry is checked on its own.
269
+ * `appearance.extra_features` is not here: it is free text, a list of the marks
270
+ * no word covers.
271
+ *
272
+ * @type {Readonly<Record<string, readonly LookWord[]>>}
273
+ */
274
+ export const BEING_LOOK_VOCABULARY = Object.freeze({
275
+ gender: lookWords([
276
+ ["female", "Female", "Female."],
277
+ ["male", "Male", "Male."],
278
+ ["nonbinary", "Nonbinary", "Neither male nor female."],
279
+ ["none", "None", "The being has no gender, as against one that is simply unrecorded."],
280
+ ["other", "Other", "A gender none of these words fits."],
281
+ ]),
282
+ frame: lookWords([
283
+ ["scant", "Scant", "Very thin, with little flesh over the bone."],
284
+ ["light", "Light", "Slender, narrow through shoulder and hip."],
285
+ ["medium", "Medium", "The ordinary build, neither slight nor heavy."],
286
+ ["heavy", "Heavy", "Broad and solid, in muscle or in flesh."],
287
+ ["massive", "Massive", "Exceptionally large in frame."],
288
+ ]),
289
+ "appearance.eye_color": lookWords([
290
+ ["amber", "Amber", "Golden or coppery, with no grey or green in it; the honey-toned eye."],
291
+ [
292
+ "black",
293
+ "Black",
294
+ "An iris that cannot be told from the pupil in any light, darker than `dark_brown`.",
295
+ ],
296
+ ["blue", "Blue", "Unmixed blue, from pale ice to deep sea."],
297
+ ["brown", "Brown", "Mid-brown, the iris pattern still legible in ordinary light."],
298
+ ["dark_brown", "Dark Brown", "Brown so deep the iris barely separates from the pupil."],
299
+ ["grey", "Grey", "Grey or grey-blue, cool and without green."],
300
+ ["green", "Green", "Green dominant, with no brown ring worth naming."],
301
+ [
302
+ "hazel",
303
+ "Hazel",
304
+ "Brown and green together, often shifting with the light; the commonest mixed eye.",
305
+ ],
306
+ ["violet", "Violet", "The pink-violet of an iris carrying no pigment."],
307
+ ]),
308
+ "appearance.hair_color": lookWords([
309
+ ["auburn", "Auburn", "Red-brown with the red dominant."],
310
+ ["black", "Black", "True black, with no brown cast in sunlight."],
311
+ ["blonde", "Blonde", "Light yellow, from near-white to wheat."],
312
+ ["brown", "Brown", "Mid-brown, neither dark nor reddish."],
313
+ [
314
+ "chestnut",
315
+ "Chestnut",
316
+ "Mid-brown with a red cast, the brown dominant — between brown and auburn.",
317
+ ],
318
+ ["dark_blonde", "Dark Blonde", "Blonde darkened towards light brown."],
319
+ ["dark_brown", "Dark Brown", "Brown approaching black, still brown in sunlight."],
320
+ ["grey", "Grey", "Grey throughout, with nothing of the original colour left to read."],
321
+ ["greying_black", "Greying Black", "Black still dominant, grey coming in."],
322
+ ["greying_brown", "Greying Brown", "Brown still dominant, grey coming in."],
323
+ ["red", "Red", "Ginger or copper, the red unmixed with brown."],
324
+ ["silver", "Silver", "Grey with a bright metallic sheen rather than a dull cast."],
325
+ [
326
+ "strawberry_blond",
327
+ "Strawberry Blond",
328
+ "Pale red-gold: blonde with a red cast, lighter than red.",
329
+ ],
330
+ ["white", "White", "Wholly white, whether from age or from carrying no pigment."],
331
+ ]),
332
+ "appearance.skin_color": lookWords([
333
+ [
334
+ "pale",
335
+ "Pale",
336
+ "The lightest skin, which burns rather than browns; also skin carrying no pigment.",
337
+ ],
338
+ ["fair", "Fair", "Light with a pink cast, burning before it browns."],
339
+ ["light", "Light", "Light and neutral, browning without burning readily."],
340
+ ["medium", "Medium", "Neither light nor dark, with no strong cast either way."],
341
+ ["olive", "Olive", "Medium with a green-grey cast."],
342
+ ["tawny", "Tawny", "Light brown, warm rather than olive."],
343
+ ["golden", "Golden", "Light to medium with a yellow cast."],
344
+ ["tanned", "Tanned", "Medium, darkened by sun rather than by birth."],
345
+ ["swarthy", "Swarthy", "Dark with an olive or sallow cast, between olive and brown."],
346
+ ["brown", "Brown", "Mid-brown."],
347
+ [
348
+ "dark",
349
+ "Dark",
350
+ "Darker than brown with no particular cast; the general word where no finer shade is stated.",
351
+ ],
352
+ ["dark_brown", "Dark Brown", "Deep brown."],
353
+ ["ebony", "Ebony", "The darkest skin, near-black."],
354
+ ]),
355
+ "appearance.complexion": lookWords([
356
+ ["ashen", "Ashen", "Grey and drained; the skin of serious illness, shock or terror."],
357
+ ["blotchy", "Blotchy", "Uneven in colour, patched red or pale."],
358
+ ["chapped", "Chapped", "Reddened and cracked by cold and wind."],
359
+ ["clear", "Clear", "Free of blemish, mark or broken vessel."],
360
+ ["freckled", "Freckled", "Freckled, in patches or across the whole face."],
361
+ ["jaundiced", "Jaundiced", "Yellowed, the colour a failing liver gives."],
362
+ ["leathery", "Leathery", "Thickened and toughened by years of exposure."],
363
+ ["oily", "Oily", "Carrying a greasy sheen."],
364
+ ["pasty", "Pasty", "Pale and doughy; the skin of indoor work and poor feeding."],
365
+ ["pimpled", "Pimpled", "Presently broken out, with pimples at brow, cheek or jaw."],
366
+ ["pockmarked", "Pockmarked", "Pitted by pox, or by deep pimples long since healed."],
367
+ ["rough", "Rough", "Coarse in texture, without the wear of outdoor work."],
368
+ ["ruddy", "Ruddy", "High colour at cheek and nose, from weather, drink or constitution."],
369
+ ["sallow", "Sallow", "Yellowish and bloodless."],
370
+ [
371
+ "scabrous",
372
+ "Scabrous",
373
+ "Rough and scaling, the skin of a lasting complaint such as eczema.",
374
+ ],
375
+ [
376
+ "scarred",
377
+ "Scarred",
378
+ "Marked by healed wounds. `extra_features` says where and from what.",
379
+ ],
380
+ ["smooth", "Smooth", "Even in texture, whatever marks it carries."],
381
+ ["sun_kissed", "Sun-Kissed", "Browned by sun on skin not naturally that dark."],
382
+ ["sunburnt", "Sunburnt", "Presently burned, red and peeling."],
383
+ ["translucent", "Translucent", "So pale the vessels show through."],
384
+ [
385
+ "vitiligo",
386
+ "Vitiligo",
387
+ "Patched with white where the skin has lost its colour, most often at face, hands and mouth. The tone it has lost is still what `skin_color` states.",
388
+ ],
389
+ ["wan", "Wan", "Pale and drained; the skin of exhaustion or convalescence."],
390
+ ["weathered", "Weathered", "Worn and lined by sun and wind; the skin of outdoor work."],
391
+ ["wrinkled", "Wrinkled", "Deeply lined by age."],
392
+ ]),
393
+ });
394
+
395
+ /** The values of one field of {@link BEING_LOOK_VOCABULARY}. */
396
+ function lookValues(field) {
397
+ return Object.freeze(BEING_LOOK_VOCABULARY[field].map(({ value }) => value));
398
+ }
399
+
244
400
  /** The words a being's `gender` takes. */
245
- const BEING_GENDERS = Object.freeze(["male", "female", "nonbinary", "none", "other"]);
401
+ const BEING_GENDERS = lookValues("gender");
246
402
 
247
403
  /** The words a being's `frame` takes, lightest to heaviest. */
248
- const BEING_FRAMES = Object.freeze(["scant", "light", "medium", "heavy", "massive"]);
404
+ const BEING_FRAMES = lookValues("frame");
249
405
 
250
406
  /** Words as code spans, joined by commas and a final "or". */
251
407
  function wordList(words) {
@@ -253,6 +409,26 @@ function wordList(words) {
253
409
  return `${spans.slice(0, -1).join(", ")} or ${spans.at(-1)}`;
254
410
  }
255
411
 
412
+ /**
413
+ * A string field closed to one list of {@link BEING_LOOK_VOCABULARY}, its words
414
+ * named in the reference and their meanings linked.
415
+ *
416
+ * @param {string} name - The field, as declared.
417
+ * @param {string} what - What the field states, without a closing stop.
418
+ * @returns {object} The declaration.
419
+ */
420
+ function lookField(name, what) {
421
+ const allowed = lookValues(name);
422
+ return {
423
+ name,
424
+ ...TEXT,
425
+ allowed,
426
+ describe:
427
+ `${what}: one of ${wordList(allowed)}. ` +
428
+ "See [a being's look](format-details.md#a-beings-look) for what each word means.",
429
+ };
430
+ }
431
+
256
432
  /** A list. */
257
433
  const LIST = Object.freeze({ shape: "list", kind: "list" });
258
434
 
@@ -643,17 +819,23 @@ const CHARGES = Object.freeze([
643
819
  /**
644
820
  * Everything `place` asks of a whole note.
645
821
  *
646
- * Two questions about one subject, and they are independent: who governs this
647
- * inhabited place, and — where the note is a body rather than somewhere within one — what
648
- * the body states about itself. A type declares one whole-note check, so the
649
- * two are composed here rather than either being folded into the other.
822
+ * Three questions about one subject, and they are independent: who governs
823
+ * this inhabited place, what the body states about itself where the note is a
824
+ * body rather than somewhere within one, and whether a region's settlements
825
+ * table agrees with its holding model and the notes it links. A type declares
826
+ * one whole-note check, so the three are composed here rather than any being
827
+ * folded into another.
650
828
  *
651
829
  * @param {object} note - The note.
652
830
  * @param {object} [opts] - The lint's options, passed through unchanged.
653
- * @returns {object[]} Findings from both.
831
+ * @returns {object[]} Findings from all three.
654
832
  */
655
833
  function checkPlace(note, opts) {
656
- return [...checkGovernment(note), ...checkWorldFacts(note, opts)];
834
+ return [
835
+ ...checkGovernment(note),
836
+ ...checkWorldFacts(note, opts),
837
+ ...checkSettlementTables(note, opts),
838
+ ];
657
839
  }
658
840
 
659
841
  /** Validate a being's authored date using the shared note-date grammar. */
@@ -1036,7 +1218,8 @@ export const NOTE_VOCABULARY = Object.freeze({
1036
1218
  describe:
1037
1219
  `One of ${wordList(BEING_GENDERS)}. ` +
1038
1220
  "`none` says the being has no gender; an absent field says its " +
1039
- "gender is unrecorded.",
1221
+ "gender is unrecorded. See [a being's look](format-details.md#a-beings-look) " +
1222
+ "for what each word means.",
1040
1223
  },
1041
1224
  {
1042
1225
  name: "species",
@@ -1106,22 +1289,31 @@ export const NOTE_VOCABULARY = Object.freeze({
1106
1289
  name: "frame",
1107
1290
  ...TEXT,
1108
1291
  allowed: BEING_FRAMES,
1109
- describe: `Relative frame — one of ${wordList(BEING_FRAMES)}.`,
1292
+ describe:
1293
+ `Relative frame — one of ${wordList(BEING_FRAMES)}. ` +
1294
+ "See [a being's look](format-details.md#a-beings-look) for what each word means.",
1110
1295
  },
1111
- { name: "appearance.eye_color", ...TEXT, describe: "Eye colour." },
1112
- { name: "appearance.hair_color", ...TEXT, describe: "Hair colour." },
1113
- { name: "appearance.skin_color", ...TEXT, describe: "Skin colour." },
1296
+ lookField("appearance.eye_color", "Eye colour"),
1297
+ lookField("appearance.hair_color", "Hair colour"),
1298
+ lookField("appearance.skin_color", "Skin colour"),
1114
1299
  {
1115
- name: "appearance.complexion",
1300
+ ...lookField("appearance.complexion", "The skin's condition"),
1116
1301
  ...TEXT_OR_LIST,
1117
1302
  describe:
1118
- "The skin's condition — one value, or several, because a face " +
1119
- "carries more than one at once. A single value means a list of one.",
1303
+ `The skin's condition: ${wordList(lookValues("appearance.complexion"))}, ` +
1304
+ "one word or a list of several, because a face carries more than one at " +
1305
+ "once. A single word means a list of one. See " +
1306
+ "[a being's look](format-details.md#a-beings-look) for what each word means.",
1307
+ entries: Object.freeze({
1308
+ kind: "string",
1309
+ shape: "a string",
1310
+ allowed: lookValues("appearance.complexion"),
1311
+ }),
1120
1312
  },
1121
1313
  {
1122
1314
  name: "appearance.extra_features",
1123
1315
  ...TEXT_LIST,
1124
- describe: "Anything else a stranger would notice.",
1316
+ describe: "Anything else a stranger would notice, in free text.",
1125
1317
  },
1126
1318
  ]),
1127
1319
  }),