@heroiclands/package-build 22.10.0 → 22.11.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Affiliations:** SoHL common skills link to their exact native Items; authors declare them in the SoHL block, and shared declarations receive an error.
8
+
3
9
  ## 22.10.0
4
10
 
5
11
  ### Minor Changes
package/CONTENT.md CHANGED
@@ -1444,6 +1444,27 @@ segment, and returns a complete tuple or a reason. This mode leaves package
1444
1444
  membership to resolution. The lint receives the builder's package separately
1445
1445
  from target lookup, including when reference resolution is disabled.
1446
1446
 
1447
+ An affiliation authors native SoHL skill references at
1448
+ `sohl.system.commonSkills`:
1449
+
1450
+ ```yaml
1451
+ sohl:
1452
+ system:
1453
+ commonSkills:
1454
+ - herb
1455
+ - sohl-sohl-skill-law
1456
+ ```
1457
+
1458
+ The first Address names this content package's SoHL skill; the second names a
1459
+ skill in the declared SoHL dependency. The content index retains their complete
1460
+ Addresses. Foundry compilation resolves each exact native skill Item in the
1461
+ local or declared dependency index and writes its published Item UUID to the
1462
+ affiliation's `system.commonSkills` array. The producer's module ID and pack
1463
+ come from that index. An unresolved target or a target without a native Item
1464
+ UUID is a located error. The shared `data:` container has no `commonSkills`
1465
+ property. Infoboxes use a skill's published documentation journal when it has
1466
+ one and show its known name as plain text otherwise.
1467
+
1447
1468
  **There is deliberately no third rule** requiring every note to repeat its own
1448
1469
  `type-shortcode` address in `aliases:`. It would serve one reader — Obsidian, so
1449
1470
  `[[type-shortcode]]` resolves in the editor — and no build reads it: both
package/docs/api.md CHANGED
@@ -1369,6 +1369,7 @@ Which of SoHL's facts a note's summary panel carries, and how they group. Read o
1369
1369
  | `SOHL_FIELD_PRESENTATION` | `const SOHL_FIELD_PRESENTATION` | — | looking up what one of SoHL's fields is called, or why it carries no row |
1370
1370
  | `PROTECTION_FIELDS` | `const PROTECTION_FIELDS` | — | reading the declarations of the aspects armour is rated against, in the order shown |
1371
1371
  | `UNSTATED` | `const UNSTATED` | — | naming what a strike mode shows where a value was not stated |
1372
+ | `affiliationSections` | `affiliationSections(fm, ctx)` | `object[]` | building an affiliation's box with skill documentation links |
1372
1373
  | `armorSections` | `armorSections(fm, ctx)` | `object[]` | building armour's box, protection included |
1373
1374
  | `beingSections` | `beingSections(fm, ctx)` | `object[]` | building a being's attributes, skills, mystical abilities and equipment |
1374
1375
  | `decodeItem` | `decodeItem(entry)` | `object \| undefined` | reading what one `sohl.items` entry names, whichever form it was written in |
package/docs/commands.md CHANGED
@@ -1445,7 +1445,7 @@ declaration that compiles it. Otherwise 0.
1445
1445
  $ content-build content-format fields --fields sohl --coverage
1446
1446
  […] 13 type(s) compared against sohl's declarations (56 field pair(s)).
1447
1447
  […] 12 type(s) the format declares are out of reach — no `itemBuilders` entry covers them: being, homepage, vehicle, armorlocation, lore, map, place, scenario, doc, macro, bundle, folder.
1448
- […] affiliation: format only [commonSkills, demonym, economy, epithet, governance, lore, population, symbol, templatePriority], declaration only [level, office, society, subType, title]
1448
+ […] affiliation: format only [demonym, economy, epithet, governance, lore, population, symbol, templatePriority], declaration only [commonSkills, level, office, society, subType, title]
1449
1449
  ```
1450
1450
 
1451
1451
  **SEE ALSO**
@@ -495,6 +495,12 @@ reduction happens at build time, and two things are errors naming the note: an
495
495
  Address whose type the field does not accept, and two Addresses that reduce to one
496
496
  shortcode, which the field cannot hold both of.
497
497
 
498
+ **A SoHL skill Address becomes a native Item UUID.** An affiliation's
499
+ `sohl.system.commonSkills` names skills in its own package or a declared
500
+ dependency. Its `system.commonSkills` contains the exact published Item UUIDs,
501
+ including each foreign module's own pack name. Native skill lookup uses those
502
+ UUIDs; the journal, website and book use documentation links for presentation.
503
+
498
504
  **A `Code` suffix marks a different thing.** `system.assocSkillCode`,
499
505
  `system.assocAffiliationCode` and `system.parentSkillCode` hold a `Shortcode` at
500
506
  both ends — each is authored as one, under `<system>.system`, and emitted verbatim
@@ -2844,7 +2850,6 @@ rank names the standing, and the standing says.
2844
2850
  | `governance.summary` | `string` | summary of the governance situation |
2845
2851
  | `governance.ranks` | `Rank[]` | The ranks available to members of the affiliation |
2846
2852
  | `governance.offices` | `Map<name, description>` | Official offices in the affiliation |
2847
- | `commonSkills` | `Address[]` | Common skills among members (languages, etc.) |
2848
2853
  | `seat` | `Address` | Where the affiliation's authority sits |
2849
2854
  | `domains` | `Address[]` | Places over which this affiliation holds sway |
2850
2855
  | `population` | `number` | Number of people in the affiliation (precision 2 significant digits). |
@@ -2926,6 +2931,27 @@ If `sohl` is present, this becomes an `affiliation` item.
2926
2931
  | `data.parents` | `system.parents` | NA |
2927
2932
  | `data.relations` | `system.relations` | NA |
2928
2933
 
2934
+ An affiliation writes SoHL common skills under `sohl.system.commonSkills` as a
2935
+ list of skill Addresses. An abbreviated value names a skill in the authoring
2936
+ package; a skill in the SoHL system package uses its complete Address:
2937
+
2938
+ ```yaml
2939
+ sohl:
2940
+ system:
2941
+ commonSkills:
2942
+ - herb
2943
+ - sohl-sohl-skill-law
2944
+ ```
2945
+
2946
+ The compiled SoHL Item stores the exact published Item UUIDs in
2947
+ `system.commonSkills`. An empty or absent list emits `[]`. A foreign target must
2948
+ come from a declared `relationships.systems` or `relationships.requires` content
2949
+ index. Each UUID uses the module and pack named by that target's published index;
2950
+ a missing skill Item or Item UUID is an error at the authored Address. The
2951
+ content index retains complete Addresses. Journal, web and book infoboxes link
2952
+ to a skill's published documentation journal when one exists and otherwise show
2953
+ its name as plain text.
2954
+
2929
2955
  `governance` reaches no system field. Ranks and offices are the note's and the
2930
2956
  web page's — SoHL's affiliation item has nowhere to put them, and inventing a
2931
2957
  mapping for a field no schema declares is the drift these tables exist to catch.
@@ -3903,7 +3929,7 @@ because they were true of something else, and each removal has a home to go to.
3903
3929
 
3904
3930
  **`languages` is a fact about a polity.** A place's languages change when its
3905
3931
  ruler changes, which is what makes them the ruler's property — and
3906
- `affiliation.commonSkills` already holds them. The authored corpus agrees: of 206
3932
+ `sohl.system.commonSkills` holds them. The authored corpus agrees: of 206
3907
3933
  places carrying `languages`, 190 were settlements and 16 were regions, and not one
3908
3934
  was a site, a structure or a feature. A ruin has no language.
3909
3935
 
@@ -12,16 +12,18 @@
12
12
  */
13
13
 
14
14
  /**
15
- * **An authored Address, an emitted Shortcode.**
15
+ * **An authored Address, an emitted native reference.**
16
16
  *
17
17
  * A declared field may hold an Address where the document it compiles into
18
- * holds a **Shortcode**. An affiliation's `seat` is the worked case: the note
18
+ * holds a **Shortcode** or a published Item UUID. An affiliation's `seat` is a Shortcode case: the note
19
19
  * names a place at whatever length says what it means — `tashal`,
20
20
  * `place-tashal`, `none-place-tashal`, `kethira-none-place-tashal` — and the
21
21
  * compiled Item carries `tashal`, which is what the runtime resolves among the
22
22
  * items one actor holds, where packages do not exist.
23
23
  *
24
- * So the builder resolves the one into the other, and this module is that step.
24
+ * The `emit: "uuid"` declaration resolves an exact native target from the
25
+ * local or declared dependency index. The target supplies its published UUID.
26
+ * This module performs both conversions at the Foundry output boundary.
25
27
  * Storing the written form instead would store a value plus a context that does
26
28
  * not travel with it: a four-segment string reaches a runtime that looks up one
27
29
  * segment and returns nothing, silently.
@@ -70,10 +72,8 @@
70
72
  *
71
73
  * ## What this module does not do
72
74
  *
73
- * It resolves nothing against the index: the shortcode is a segment of the
74
- * Address, so reducing one needs the grammar and the position's declarations
75
- * and nothing else. Whether the target exists is a separate question, asked
76
- * where the index is.
75
+ * Shortcode output needs only the Address grammar. UUID output requires the
76
+ * exact target in the published index and accepts only Item UUIDs.
77
77
  *
78
78
  * A failure is a **finding**, never a diagnostic: the caller owns the file and
79
79
  * the severity, and locates the finding at the value inside the note.
@@ -107,6 +107,7 @@ export const ADDRESS_FIELD_SHAPES = Object.freeze(["value", "items", "keys"]);
107
107
  * @property {readonly string[]} accepts - Every type the position accepts.
108
108
  * @property {"value"|"items"|"keys"} [holds] - Where the Addresses are.
109
109
  * Defaults to `value`.
110
+ * @property {"uuid"} [emit] - Resolve each exact target to its published Item UUID.
110
111
  */
111
112
 
112
113
  /**
@@ -171,7 +172,7 @@ function reduceOne(value, field, vocabulary, findings) {
171
172
  // Two keys, or two list entries, reducing to one emitted shortcode: the
172
173
  // Address each of them named, by the shortcode all of them land on.
173
174
  const seen = new Map();
174
- const read = (written) => readOne(written, field, defaults, seen, findings, shape);
175
+ const read = (written) => readOne(written, field, defaults, seen, findings, shape, vocabulary);
175
176
 
176
177
  if (shape === "keys") {
177
178
  if (!isMapping(value)) return undefined;
@@ -212,8 +213,15 @@ function reduceOne(value, field, vocabulary, findings) {
212
213
  * @returns {string|undefined} The shortcode, or `undefined` when nothing is
213
214
  * emitted for this value.
214
215
  */
215
- function readOne(written, field, defaults, seen, findings, shape) {
216
+ function readOne(written, field, defaults, seen, findings, shape, vocabulary) {
216
217
  const key = findingKey(field);
218
+ // An embedded Item may inherit an already compiled reference from its model.
219
+ if (
220
+ field.address.emit === "uuid" &&
221
+ typeof written === "string" &&
222
+ /^Compendium\.[^.]+\.[^.]+\.Item\.[^.]+$/.test(written)
223
+ )
224
+ return written;
217
225
  const value =
218
226
  isAddressTuple(written) ? renderAddress(written)
219
227
  : typeof written === "string" ? written
@@ -231,6 +239,20 @@ function readOne(written, field, defaults, seen, findings, shape) {
231
239
  }
232
240
 
233
241
  const address = renderAddress(tuple);
242
+ if (field.address.emit === "uuid") {
243
+ const target =
244
+ tuple.package === vocabulary.package ?
245
+ vocabulary.referenceTargets?.get(address)
246
+ : vocabulary.foreignReferences?.get(address);
247
+ const uuid = target?.uuid;
248
+ if (!uuid || !/^Compendium\.[^.]+\.[^.]+\.Item\.[^.]+$/.test(uuid)) {
249
+ report(
250
+ `\`${key}\` names \`${address}\`, but its exact native Item has no published Item UUID`,
251
+ );
252
+ return undefined;
253
+ }
254
+ return uuid;
255
+ }
234
256
  const code = tuple.shortcode;
235
257
  const first = seen.get(code);
236
258
  if (first && first.address !== address) {
@@ -717,6 +717,8 @@ export class BasePackCompiler {
717
717
  types: this.linkIndex?.types,
718
718
  packages: this.linkIndex?.packages,
719
719
  noIndexPackages: this.linkIndex?.noIndexPackages,
720
+ referenceTargets: this.linkIndex?.referenceTargets,
721
+ foreignReferences: this.linkIndex?.foreignReferences,
720
722
  });
721
723
  for (const finding of findings) {
722
724
  this.errorCount++;
@@ -148,8 +148,9 @@ export { legacyKeyOf, retiredTopLevelKey, setPath };
148
148
  * being's own body structure, not a note, so it declares none. Distinct
149
149
  * from a `DataFieldSpec`'s `ref` ({@link module:engine/note-vocabulary}),
150
150
  * which names an **Address** instead.
151
- * @property {import("./address-fields.mjs").AddressPosition} [address] - **The
152
- * authored value is an Address and the emitted one is its Shortcode.** The
151
+ * @property {import("./address-fields.mjs").AddressPosition} [address] - The
152
+ * authored value is an Address. The emitted value is its Shortcode, or the
153
+ * exact published Item UUID when `emit: "uuid"` is declared. The
153
154
  * statement carries the default type an omitted `<type>` segment takes, the
154
155
  * set of types the position accepts — two separate things, which coincide at
155
156
  * most positions — and where the Addresses sit: the value itself, every
@@ -476,10 +476,12 @@ function checkDataContainer(note, { type, fields, packs, addressContext }) {
476
476
  ...positionOfFrontmatterPath(raw, ["data", key], { key: true }),
477
477
  severity: "error",
478
478
  message:
479
- `"${key}" is not a \`data:\` property declared by ${type}; ` +
480
- `the container is closed, so unlike a top-level key it is ` +
481
- `not passed through to the page` +
482
- (guess ? `. Did you mean "${guess}"?` : ""),
479
+ type === "affiliation" && key === "commonSkills" ?
480
+ "`data.commonSkills` is not an affiliation field; write skill Addresses at `sohl.system.commonSkills`"
481
+ : `"${key}" is not a \`data:\` property declared by ${type}; ` +
482
+ `the container is closed, so unlike a top-level key it is ` +
483
+ `not passed through to the page` +
484
+ (guess ? `. Did you mean "${guess}"?` : ""),
483
485
  });
484
486
  }
485
487
 
@@ -5,7 +5,7 @@
5
5
  * SPDX-License-Identifier: GPL-3.0-or-later
6
6
  */
7
7
 
8
- import { systemBlocksFor } from "./system-vocabulary.mjs";
8
+ import { declaredSystems, systemBlocksFor } from "./system-vocabulary.mjs";
9
9
  import { RETIRED_FIELD_ALIASES } from "./retired-fields.mjs";
10
10
  import {
11
11
  isAddressTuple,
@@ -260,10 +260,25 @@ export function decodeNoteAddresses(fm, context) {
260
260
  * @returns {object} Builder defaults and system declarations.
261
261
  */
262
262
  export function noteAddressContext(config) {
263
+ const systemBlocks = { ...systemBlocksFor(config) };
264
+ // A module with one declared system uses its flat Item registry for that
265
+ // system's Address positions, including fields stored in its content index.
266
+ const systems = new Set([
267
+ ...declaredSystems(config),
268
+ ...(config.relationships?.systems ?? []).map((relationship) => relationship.id),
269
+ ]);
270
+ if (
271
+ systems.size === 1 &&
272
+ config.itemFields &&
273
+ Object.keys(config.itemFieldsBySystem ?? {}).length === 0
274
+ ) {
275
+ const [system] = systems;
276
+ systemBlocks[system] = { ...systemBlocks[system], fields: config.itemFields };
277
+ }
263
278
  return {
264
279
  package: config.contentPackage,
265
280
  system: "none",
266
- systemBlocks: systemBlocksFor(config),
281
+ systemBlocks,
267
282
  };
268
283
  }
269
284
 
@@ -761,13 +761,6 @@ export const NOTE_VOCABULARY = Object.freeze({
761
761
  ...ANY,
762
762
  describe: "The named offices it appoints, each with what the office does.",
763
763
  },
764
- {
765
- name: "commonSkills",
766
- ...LINKS,
767
- ref: "skill",
768
- accepts: ["skill"],
769
- describe: "Skills common among members — languages first among them.",
770
- },
771
764
  {
772
765
  name: "seat",
773
766
  ...LINK,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.10.0",
3
+ "version": "22.11.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/infobox.mjs CHANGED
@@ -543,6 +543,36 @@ function genericSections(fm, type, ctx) {
543
543
  return systemRowsSection(fm, NOTE_SCHEMAS[type], ctx);
544
544
  }
545
545
 
546
+ /** An affiliation presents skill documentation while retaining native Item identity. */
547
+ export function affiliationSections(fm, ctx) {
548
+ const field = NOTE_SCHEMAS.affiliation.find((entry) => entry.to === "commonSkills");
549
+ const sections = systemRowsSection(
550
+ fm,
551
+ NOTE_SCHEMAS.affiliation.filter((entry) => entry !== field),
552
+ ctx,
553
+ );
554
+ const { value } = ctx.resolveField(field, fm, { block: ctx.block });
555
+ if (!Array.isArray(value) || value.length === 0) return sections;
556
+ const entries = value.map((skill) => {
557
+ const native = ctx.resolve?.(skill, { type: "skill", system: "sohl" });
558
+ const docAddress =
559
+ isAddressTuple(skill) ? completeAddress({ ...skill, system: "none" }) : undefined;
560
+ const doc = docAddress ? ctx.resolve?.(docAddress, { type: docAddress.type }) : undefined;
561
+ return {
562
+ text: doc?.name || native?.name || humanizeValue(skill),
563
+ ...(doc?.url ? { url: doc.url } : {}),
564
+ ...(doc?.uuid ? { uuid: doc.uuid } : {}),
565
+ ...(docAddress && (doc?.url || doc?.uuid) ? { address: docAddress } : {}),
566
+ };
567
+ });
568
+ sections.push({
569
+ id: "commonskills",
570
+ layout: "rows",
571
+ rows: [{ label: "Common skills", kind: "links", value: entries }],
572
+ });
573
+ return sections;
574
+ }
575
+
546
576
  /**
547
577
  * SoHL's infobox declaration.
548
578
  *
@@ -555,6 +585,7 @@ export const SOHL_INFOBOX = defineInfobox({
555
585
  presentation: SOHL_FIELD_PRESENTATION,
556
586
  sections: {
557
587
  being: beingSections,
588
+ affiliation: affiliationSections,
558
589
  armorgear: armorSections,
559
590
  weapongear: weaponSections,
560
591
  projectilegear: projectileSections,
@@ -178,6 +178,14 @@ const AFFILIATION_ITEMS = Object.freeze({
178
178
  /** A map keyed by affiliation: this one's standing toward each. */
179
179
  const AFFILIATION_KEYS = Object.freeze({ ...AFFILIATION_ITEMS, holds: "keys" });
180
180
 
181
+ /** Native skill references retain the published Item UUID. */
182
+ const SKILL_UUID_ITEMS = Object.freeze({
183
+ type: "skill",
184
+ accepts: Object.freeze(["skill"]),
185
+ holds: "items",
186
+ emit: "uuid",
187
+ });
188
+
181
189
  /** Standings toward other affiliations, validated against the closed list. */
182
190
  const RELATION = Object.freeze({
183
191
  shape: "map of affiliation address → standing",
@@ -385,6 +393,15 @@ export const ITEM_FIELDS = Object.freeze({
385
393
  default: 0,
386
394
  describe: "Standing within the society.",
387
395
  },
396
+ {
397
+ name: "commonSkills",
398
+ to: "commonSkills",
399
+ ...ADDRESS_LIST,
400
+ address: SKILL_UUID_ITEMS,
401
+ default: [],
402
+ topLevelMeans: "Common skills belong to the SoHL affiliation item.",
403
+ describe: "Native SoHL skill Items common among members, by address.",
404
+ },
388
405
  {
389
406
  // Plural because the field holds a map of many standings, one per
390
407
  // affiliation — the singular reads as a misnomer.
@@ -6,6 +6,7 @@
6
6
  * @property {readonly string[]} accepts - Every type the position accepts.
7
7
  * @property {"value"|"items"|"keys"} [holds] - Where the Addresses are.
8
8
  * Defaults to `value`.
9
+ * @property {"uuid"} [emit] - Resolve each exact target to its published Item UUID.
9
10
  */
10
11
  /**
11
12
  * What a reduction could not do, for a caller to report.
@@ -69,6 +70,10 @@ export type AddressPosition = {
69
70
  * Defaults to `value`.
70
71
  */
71
72
  holds?: "keys" | "value" | "items" | undefined;
73
+ /**
74
+ * - Resolve each exact target to its published Item UUID.
75
+ */
76
+ emit?: "uuid" | undefined;
72
77
  };
73
78
  /**
74
79
  * What a reduction could not do, for a caller to report.
@@ -277,8 +277,9 @@ export function runtimeOnlyFields(fields: readonly FieldSpec[]): FieldSpec[];
277
277
  * being's own body structure, not a note, so it declares none. Distinct
278
278
  * from a `DataFieldSpec`'s `ref` ({@link module:engine/note-vocabulary}),
279
279
  * which names an **Address** instead.
280
- * @property {import("./address-fields.mjs").AddressPosition} [address] - **The
281
- * authored value is an Address and the emitted one is its Shortcode.** The
280
+ * @property {import("./address-fields.mjs").AddressPosition} [address] - The
281
+ * authored value is an Address. The emitted value is its Shortcode, or the
282
+ * exact published Item UUID when `emit: "uuid"` is declared. The
282
283
  * statement carries the default type an omitted `<type>` segment takes, the
283
284
  * set of types the position accepts — two separate things, which coincide at
284
285
  * most positions — and where the Addresses sit: the value itself, every
@@ -553,8 +554,9 @@ export type FieldSpec = {
553
554
  */
554
555
  code?: string | undefined;
555
556
  /**
556
- * - **The
557
- * authored value is an Address and the emitted one is its Shortcode.** The
557
+ * - The
558
+ * authored value is an Address. The emitted value is its Shortcode, or the
559
+ * exact published Item UUID when `emit: "uuid"` is declared. The
558
560
  * statement carries the default type an omitted `<type>` segment takes, the
559
561
  * set of types the position accepts — two separate things, which coincide at
560
562
  * most positions — and where the Addresses sit: the value itself, every
@@ -82,6 +82,8 @@ export function projectileSections(fm: object, ctx: object): object[];
82
82
  * @returns {[string, object][]} Mode name → the mode.
83
83
  */
84
84
  export function strikeModes(declared: unknown): [string, object][];
85
+ /** An affiliation presents skill documentation while retaining native Item identity. */
86
+ export function affiliationSections(fm: any, ctx: any): object[];
85
87
  /** What this system's box is called. @type {string} */
86
88
  export const SOHL_INFOBOX_TITLE: string;
87
89
  /**