@heroiclands/package-build 22.10.0 → 22.12.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,17 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **References:** An Address in note data that names no local or declared dependency target now reports an error; missing artwork keeps its fallback warning.
8
+
9
+ ## 22.11.0
10
+
11
+ ### Minor Changes
12
+
13
+ - **Affiliations:** SoHL common skills link to their exact native Items; authors declare them in the SoHL block, and shared declarations receive an error.
14
+
3
15
  ## 22.10.0
4
16
 
5
17
  ### Minor Changes
package/CONTENT.md CHANGED
@@ -1249,6 +1249,12 @@ somewhere other than where they were made, or not at all:
1249
1249
  reference answered by a vendored manifest lands exactly as it would in a
1250
1250
  wikilink. `--no-references` turns this one off for a tree whose cross-package
1251
1251
  references it cannot see.
1252
+ - **Dead Address reference** — an Address-valued `data:` field naming no local
1253
+ note, asset or entry in a declared dependency index. The finding names the
1254
+ field and the complete Address it names. A short form means this package;
1255
+ another package requires the full Address. `--no-references` also turns off
1256
+ this target check. Art slots report unresolved art through their own fallback
1257
+ warning.
1252
1258
 
1253
1259
  **A schema says what a note may _write_, not what the compiler emits.** Those
1254
1260
  are different, and the difference is the whole calibration of the check: a note
@@ -1444,6 +1450,27 @@ segment, and returns a complete tuple or a reason. This mode leaves package
1444
1450
  membership to resolution. The lint receives the builder's package separately
1445
1451
  from target lookup, including when reference resolution is disabled.
1446
1452
 
1453
+ An affiliation authors native SoHL skill references at
1454
+ `sohl.system.commonSkills`:
1455
+
1456
+ ```yaml
1457
+ sohl:
1458
+ system:
1459
+ commonSkills:
1460
+ - herb
1461
+ - sohl-sohl-skill-law
1462
+ ```
1463
+
1464
+ The first Address names this content package's SoHL skill; the second names a
1465
+ skill in the declared SoHL dependency. The content index retains their complete
1466
+ Addresses. Foundry compilation resolves each exact native skill Item in the
1467
+ local or declared dependency index and writes its published Item UUID to the
1468
+ affiliation's `system.commonSkills` array. The producer's module ID and pack
1469
+ come from that index. An unresolved target or a target without a native Item
1470
+ UUID is a located error. The shared `data:` container has no `commonSkills`
1471
+ property. Infoboxes use a skill's published documentation journal when it has
1472
+ one and show its known name as plain text otherwise.
1473
+
1447
1474
  **There is deliberately no third rule** requiring every note to repeat its own
1448
1475
  `type-shortcode` address in `aliases:`. It would serve one reader — Obsidian, so
1449
1476
  `[[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
@@ -1309,6 +1315,12 @@ Address-keyed relation maps hold typed targets and standing values. Only generat
1309
1315
  output serializes these properties to Address strings. Runtime Shortcodes,
1310
1316
  Foundry UUIDs, file paths and URLs keep their own representations.
1311
1317
 
1318
+ An Address-valued `data:` field must name a target in this package or a declared
1319
+ dependency index. `content-build lint` reports a missing target as an error at
1320
+ the value or map key that names it. A stub counts as a target for data even
1321
+ though it publishes no page. Art slots use their own missing-art warning and
1322
+ document fallback.
1323
+
1312
1324
  #### An omitted segment defaults from where the link is written
1313
1325
 
1314
1326
  It is **not** a wildcard and resolution is not a search. Every short form expands
@@ -2844,7 +2856,6 @@ rank names the standing, and the standing says.
2844
2856
  | `governance.summary` | `string` | summary of the governance situation |
2845
2857
  | `governance.ranks` | `Rank[]` | The ranks available to members of the affiliation |
2846
2858
  | `governance.offices` | `Map<name, description>` | Official offices in the affiliation |
2847
- | `commonSkills` | `Address[]` | Common skills among members (languages, etc.) |
2848
2859
  | `seat` | `Address` | Where the affiliation's authority sits |
2849
2860
  | `domains` | `Address[]` | Places over which this affiliation holds sway |
2850
2861
  | `population` | `number` | Number of people in the affiliation (precision 2 significant digits). |
@@ -2926,6 +2937,27 @@ If `sohl` is present, this becomes an `affiliation` item.
2926
2937
  | `data.parents` | `system.parents` | NA |
2927
2938
  | `data.relations` | `system.relations` | NA |
2928
2939
 
2940
+ An affiliation writes SoHL common skills under `sohl.system.commonSkills` as a
2941
+ list of skill Addresses. An abbreviated value names a skill in the authoring
2942
+ package; a skill in the SoHL system package uses its complete Address:
2943
+
2944
+ ```yaml
2945
+ sohl:
2946
+ system:
2947
+ commonSkills:
2948
+ - herb
2949
+ - sohl-sohl-skill-law
2950
+ ```
2951
+
2952
+ The compiled SoHL Item stores the exact published Item UUIDs in
2953
+ `system.commonSkills`. An empty or absent list emits `[]`. A foreign target must
2954
+ come from a declared `relationships.systems` or `relationships.requires` content
2955
+ index. Each UUID uses the module and pack named by that target's published index;
2956
+ a missing skill Item or Item UUID is an error at the authored Address. The
2957
+ content index retains complete Addresses. Journal, web and book infoboxes link
2958
+ to a skill's published documentation journal when one exists and otherwise show
2959
+ its name as plain text.
2960
+
2929
2961
  `governance` reaches no system field. Ranks and offices are the note's and the
2930
2962
  web page's — SoHL's affiliation item has nowhere to put them, and inventing a
2931
2963
  mapping for a field no schema declares is the drift these tables exist to catch.
@@ -2989,9 +3021,7 @@ If `sohl` is present, this becomes an `affliction` item.
2989
3021
  | `subType` | `system.subType` | NA |
2990
3022
  | `data.transmission` | `system.transmission` | NA |
2991
3023
  | `data.outcome` | `system.outcome` | NA |
2992
- | `data.healingRate` | `system.healingRateBase` | NA |
2993
3024
  | `data.contagionIndex` | `system.contagionIndexBase` | NA |
2994
- | `data.outcomeTraumas` | `system.outcomeTraumas` | NA |
2995
3025
  | `data.onsetDurationFormula` | `system.onsetDurationFormula` | NA |
2996
3026
  | `data.onsetDurationBase` | `system.onsetDurationBase` | NA |
2997
3027
  | `data.healingCheckDurationFormula` | `system.healingCheckDurationFormula` | NA |
@@ -3029,6 +3059,10 @@ that writes one fails the build. They are world times, and `0` is a valid one,
3029
3059
  so there is no blank a note could write either; leave them out and the data
3030
3060
  model's `null` stands.
3031
3061
 
3062
+ `data.healingRate` and `data.outcomeTraumas` are page data. SoHL reads an
3063
+ affliction’s base healing rate from `sohl.healingRateBase`. Its item declaration
3064
+ does not read `data.outcomeTraumas`.
3065
+
3032
3066
  ### type: armorgear
3033
3067
 
3034
3068
  Note: `data.quantity` may not be specified. Quantity is always 1.
@@ -3103,7 +3137,9 @@ if a `sohl` property is present, a SoHL item of type "concoctiongear" will be cr
3103
3137
  | `data.value` | `system.valueBase` | NA |
3104
3138
  | `data.quality` | `system.qualityBase` | NA |
3105
3139
  | `data.durability` | `system.durabilityBase` | NA |
3106
- | `data.quantity` | `system.quantity` | NA |
3140
+
3141
+ `data.quantity` is page data for a concoction. SoHL sets `system.quantity` to
3142
+ `1` for each compendium item.
3107
3143
 
3108
3144
  ### type: containergear
3109
3145
 
@@ -3116,19 +3152,21 @@ Note: `data.quantity` may not be specified; quantity is always set to 1.
3116
3152
  | `value` | `number` | Gear value |
3117
3153
  | `quality` | `number` | Gear quality |
3118
3154
  | `durability` | `number` | Gear durability |
3119
- | `capacity` | `number` | Container capacity (in lbs) |
3155
+ | `capacity` | `number` | HM3 container capacity (in lbs) |
3120
3156
 
3121
3157
  if a `sohl` property is present, a SoHL item of type "containergear" will be created.
3122
3158
 
3123
3159
  if a `hm3` property is present, an HM3 item of type "containergear" will be created.
3124
3160
 
3125
- | shared source | → sohl | → hm3 |
3126
- | ----------------- | ------------------------ | --------------------- |
3127
- | `data.weight` | `system.weightBase` | `system.weight` |
3128
- | `data.value` | `system.valueBase` | `system.value` |
3129
- | `data.quality` | `system.qualityBase` | NA |
3130
- | `data.durability` | `system.durabilityBase` | NA |
3131
- | `data.capacity` | `system.maxCapacityBase` | `system.capacity.max` |
3161
+ | shared source | → sohl | → hm3 |
3162
+ | ----------------- | ----------------------- | --------------------- |
3163
+ | `data.weight` | `system.weightBase` | `system.weight` |
3164
+ | `data.value` | `system.valueBase` | `system.value` |
3165
+ | `data.quality` | `system.qualityBase` | NA |
3166
+ | `data.durability` | `system.durabilityBase` | NA |
3167
+ | `data.capacity` | NA | `system.capacity.max` |
3168
+
3169
+ SoHL reads a container’s capacity in pounds from `sohl.maxCapacity`.
3132
3170
 
3133
3171
  ### type: miscgear
3134
3172
 
@@ -3139,7 +3177,7 @@ if a `hm3` property is present, an HM3 item of type "containergear" will be crea
3139
3177
  | `value` | `number` | Gear value |
3140
3178
  | `quality` | `number` | Gear quality |
3141
3179
  | `durability` | `number` | Gear durability |
3142
- | `quantity` | `number` | Gear quantity (default: 1) |
3180
+ | `quantity` | `number` | HM3 gear quantity (default: 1) |
3143
3181
 
3144
3182
  if a `sohl` property is present, a SoHL item of type "miscgear" will be created.
3145
3183
 
@@ -3151,7 +3189,9 @@ if a `hm3` property is present, an HM3 item of type "miscgear" will be created.
3151
3189
  | `data.value` | `system.valueBase` | `system.value` |
3152
3190
  | `data.quality` | `system.qualityBase` | NA |
3153
3191
  | `data.durability` | `system.durabilityBase` | NA |
3154
- | `data.quantity` | `system.quantity` | `system.quantity` |
3192
+ | `data.quantity` | NA | `system.quantity` |
3193
+
3194
+ SoHL sets `system.quantity` to `1` for each compendium item.
3155
3195
 
3156
3196
  ### type: mystery
3157
3197
 
@@ -3187,10 +3227,12 @@ if a `sohl` property is present, a SoHL item of type "mystery" will be created.
3187
3227
  | --------------------- | ----------------------- | ----- |
3188
3228
  | `subType` | `system.subType` | NA |
3189
3229
  | `data.skillAptitudes` | `system.skillAptitudes` | NA |
3190
- | `data.level` | `system.levelBase` | NA |
3191
3230
  | `data.charges.value` | `system.charges.value` | NA |
3192
3231
  | `data.charges.max` | `system.charges.max` | NA |
3193
3232
 
3233
+ `data.level` is page data for a mystery. SoHL reads the item’s level from
3234
+ `sohl.levelBase`.
3235
+
3194
3236
  ### type: mysticalability
3195
3237
 
3196
3238
  **subType**:
@@ -3224,13 +3266,15 @@ If an `hm3` property is present, an HM3 item is created, and `hm3.type` states w
3224
3266
  `sohl.system` as shortcodes and have no shared source; `data.assocSkill` and
3225
3267
  `data.assocAffiliation` name the same two notes as Addresses, for the page.
3226
3268
 
3227
- | shared source | → sohl | → hm3 |
3228
- | -------------------- | ------------------------- | ------------- |
3229
- | `subType` | `system.subType` | **see above** |
3230
- | `data.masteryLevel` | `system.masteryLevelBase` | NA |
3231
- | `data.level` | `system.levelBase` | NA |
3232
- | `data.charges.value` | `system.charges.value` | NA |
3233
- | `data.charges.max` | `system.charges.max` | NA |
3269
+ | shared source | → sohl | → hm3 |
3270
+ | -------------------- | ---------------------- | ------------- |
3271
+ | `subType` | `system.subType` | **see above** |
3272
+ | `data.charges.value` | `system.charges.value` | NA |
3273
+ | `data.charges.max` | `system.charges.max` | NA |
3274
+
3275
+ `data.masteryLevel` and `data.level` are page data for an ability. SoHL reads
3276
+ the item’s base mastery and level from `sohl.masteryLevelBase` and
3277
+ `sohl.levelBase`.
3234
3278
 
3235
3279
  ### type: projectilegear
3236
3280
 
@@ -3250,7 +3294,7 @@ If an `hm3` property is present, an HM3 item is created, and `hm3.type` states w
3250
3294
  | `value` | `number` | Gear value |
3251
3295
  | `quality` | `number` | Gear quality |
3252
3296
  | `durability` | `number` | Gear durability |
3253
- | `quantity` | `number` | Gear quantity (default: 1) |
3297
+ | `quantity` | `number` | HM3 gear quantity (default: 1) |
3254
3298
 
3255
3299
  if a `sohl` property is present, a SoHL item of type "projectilegear" will be created.
3256
3300
 
@@ -3267,7 +3311,9 @@ HM3 side while remaining distinct on the SoHL side.
3267
3311
  | `data.value` | `system.valueBase` | `system.value` |
3268
3312
  | `data.quality` | `system.qualityBase` | NA |
3269
3313
  | `data.durability` | `system.durabilityBase` | NA |
3270
- | `data.quantity` | `system.quantity` | `system.quantity` |
3314
+ | `data.quantity` | NA | `system.quantity` |
3315
+
3316
+ SoHL sets `system.quantity` to `1` for each compendium item.
3271
3317
 
3272
3318
  ### type: skill
3273
3319
 
@@ -3287,7 +3333,7 @@ HM3 side while remaining distinct on the SoHL side.
3287
3333
  | `data` property | Values | Description |
3288
3334
  | ------------------ | --------- | ------------------------------------------ |
3289
3335
  | `templatePriority` | `number` | Template priority, _null_ = not a template |
3290
- | `masteryLevel` | `number` | Mastery Level |
3336
+ | `masteryLevel` | `number` | HM3 mastery level |
3291
3337
  | `parentSkill` | `Address` | Parent skill this skill specializes |
3292
3338
 
3293
3339
  if a `sohl` property is present, a SoHL item of type "skill" will be created.
@@ -3300,10 +3346,12 @@ Note: `hm3.system.type` (skill types) use the values "Craft", "Physical", "Commu
3300
3346
  skill this one specializes, and has no shared source; `data.parentSkill` names that
3301
3347
  skill as an Address, for the page.
3302
3348
 
3303
- | shared source | → sohl | → hm3 |
3304
- | ------------------- | ------------------------- | --------------------- |
3305
- | `subType` | `system.subType` | See notes above |
3306
- | `data.masteryLevel` | `system.masteryLevelBase` | `system.masteryLevel` |
3349
+ | shared source | → sohl | → hm3 |
3350
+ | ------------------- | ---------------- | --------------------- |
3351
+ | `subType` | `system.subType` | See notes above |
3352
+ | `data.masteryLevel` | NA | `system.masteryLevel` |
3353
+
3354
+ SoHL reads a skill’s base mastery from `sohl.masteryLevelBase`.
3307
3355
 
3308
3356
  ### type: trauma
3309
3357
 
@@ -3903,7 +3951,7 @@ because they were true of something else, and each removal has a home to go to.
3903
3951
 
3904
3952
  **`languages` is a fact about a polity.** A place's languages change when its
3905
3953
  ruler changes, which is what makes them the ruler's property — and
3906
- `affiliation.commonSkills` already holds them. The authored corpus agrees: of 206
3954
+ `sohl.system.commonSkills` holds them. The authored corpus agrees: of 206
3907
3955
  places carrying `languages`, 190 were settlements and 16 were regions, and not one
3908
3956
  was a site, a structure or a feature. A ruin has no language.
3909
3957
 
@@ -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
@@ -68,7 +68,7 @@ import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.
68
68
  import { pathnameProblem } from "./pathnames.mjs";
69
69
  import { checkHomepageAddressFields } from "./homepage.mjs";
70
70
  import { RETIRED_TYPES, RENAMED_TYPES, currentType, renamedTypeMessage } from "./ids.mjs";
71
- import { parseAddress, acceptsType } from "./address.mjs";
71
+ import { parseAddress, acceptsType, renderAddress } from "./address.mjs";
72
72
  import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
73
73
  import { isAddressSegment } from "./address-charset.mjs";
74
74
  // The one place the "every pack not named" key is spelled. Imported rather
@@ -421,7 +421,7 @@ function dataBlock(fm) {
421
421
  * was loaded to recognise it would be worse than not checking.
422
422
  * @returns {object[]} Findings.
423
423
  */
424
- function checkDataContainer(note, { type, fields, packs, addressContext }) {
424
+ function checkDataContainer(note, { type, fields, packs, addressContext, index }) {
425
425
  const findings = [];
426
426
  const { present, entries, malformed } = dataBlock(note.fm ?? {});
427
427
  if (!present) return findings;
@@ -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
 
@@ -502,7 +504,7 @@ function checkDataContainer(note, { type, fields, packs, addressContext }) {
502
504
  });
503
505
  continue;
504
506
  }
505
- findings.push(...checkDataReferences(note, field, value, segments, addressContext));
507
+ findings.push(...checkDataReferences(note, field, value, segments, addressContext, index));
506
508
  // A `scalar-or-map` written in its map form is checked entry by entry,
507
509
  // because that is the correction an author has to make: one key's
508
510
  // value, not the whole map. Quoting the map back would name every
@@ -519,15 +521,16 @@ function checkDataContainer(note, { type, fields, packs, addressContext }) {
519
521
  }
520
522
 
521
523
  /**
522
- * Validate typed entries without resolving their targets.
524
+ * Validate typed entries and resolve declared non-art Address targets.
523
525
  * @param {object} note - The authored note.
524
526
  * @param {object} field - Its data field declaration.
525
527
  * @param {unknown} value - The field value.
526
528
  * @param {string[]} segments - The field's path under `data`.
527
529
  * @param {object} context - The builder's Address context.
530
+ * @param {object} [index] - The local and declared dependency Address index.
528
531
  * @returns {object[]} Located findings.
529
532
  */
530
- function checkDataReferences(note, field, value, segments, context) {
533
+ function checkDataReferences(note, field, value, segments, context, index) {
531
534
  const checks = [];
532
535
  if (field.kind === "address") checks.push({ value, path: [], kind: "address" });
533
536
  if (field.entryKind) {
@@ -571,10 +574,16 @@ function checkDataReferences(note, field, value, segments, context) {
571
574
  let reason;
572
575
  if (!matchesKind(check.value, check.kind, defaults))
573
576
  reason = `should be ${check.kind === "address" ? "an Address" : "a Shortcode"}`;
574
- else if (check.kind === "address" && accepted) {
577
+ else if (check.kind === "address") {
575
578
  const tuple = parseAddress(check.value, defaults, { declared: true });
576
- if (!acceptsType(tuple, accepted))
579
+ if (accepted && !acceptsType(tuple, accepted))
577
580
  reason = `accepts ${accepted.join(" or ")}, not ${tuple.type}`;
581
+ else if (
582
+ index?.addressHit &&
583
+ !ART_SLOTS.some((slot) => slot.key === field.name) &&
584
+ !index.addressHit(renderAddress(tuple))
585
+ )
586
+ reason = `names ${renderAddress(tuple)}, which does not resolve in this package or its declared dependencies`;
578
587
  }
579
588
  if (!reason) continue;
580
589
  const path = ["data", ...segments, ...check.path];
@@ -1517,6 +1526,7 @@ export function lintNote(
1517
1526
  fields,
1518
1527
  packs,
1519
1528
  addressContext: referenceContext,
1529
+ index,
1520
1530
  }),
1521
1531
  );
1522
1532
  findings.push(...checkSubType(note, { type, entry }));
@@ -124,15 +124,12 @@ export const HELD_SUBTYPES = Object.freeze(["settlement", "site", "structure"]);
124
124
  * system, only the shortcode {@link module:engine/address} would resolve it
125
125
  * to anyway.
126
126
  *
127
- * **The match this shortcode feeds is deliberately package-blind.** `places`
128
- * and `affiliations` merge this package's own notes with every fetched
129
- * dependency's into one shortcode space, first declaration wins — the way a
130
- * dependency's generic geography attaches to whatever a consumer calls the
131
- * same shortcode, which is the point of _A dependency's places and
132
- * affiliations take part_ above. Comparing the full Address instead would
133
- * ask an entry's package to agree with the citing note's, and a fetched
134
- * entry's own `parents` or `domains` is written in *its* package while the
135
- * blend it takes part in is *this* one's — see
127
+ * **The holdings graph has package-blind Shortcode identity.** `places` and
128
+ * `affiliations` merge local and fetched notes into one shortcode space; the
129
+ * first declaration wins. A fetched place attaches to a local parent with
130
+ * the same shortcode, and a fetched affiliation can name both local and
131
+ * fetched domains. The authored entries remain Addresses; this graph uses
132
+ * their shortcode projection for containment and tenure. See
136
133
  * `tests/site-holdings.test.ts`'s `empire.domains: ["mill", "abroad"]`, where
137
134
  * `mill` is local and `abroad` is the dependency's own, both bare, both
138
135
  * correct only because neither package is asked.
@@ -321,6 +318,7 @@ export function holdingsPages(nodes) {
321
318
  // Read through the one reader, so a caller handing in what a note
322
319
  // wrote — an address, a wikilink, a repeat — is read as `holdingsNode`
323
320
  // reads it.
321
+ // Shortcode identity: the first node of each type owns this graph key.
324
322
  by.set(shortcode, {
325
323
  ...node,
326
324
  shortcode,
@@ -108,15 +108,14 @@ const PLACE_TYPES = Object.freeze(new Set(["place"]));
108
108
  * since this position asks neither its package nor its system, only the
109
109
  * shortcode {@link module:engine/address} would resolve it to anyway.
110
110
  *
111
- * **The match this shortcode feeds is deliberately package-blind.**
112
- * `world.places` merges this package's own places with every fetched
113
- * dependency's into one shortcode space, the way a dependency's place
114
- * attaches to the local containment tree it is drawn beside — see
111
+ * **The map graph has package-blind Shortcode identity.** `world.places`
112
+ * merges local and fetched places by shortcode, with local declarations
113
+ * taking precedence. A fetched place attaches to the local containment tree
114
+ * by its parent's shortcode. The authored parent remains an Address; the map
115
+ * uses its shortcode projection for containment, layout, and DOT nodes. See
115
116
  * `tests/map.test.ts`'s _loads a dependency's places beside the package's
116
117
  * own_, where a fetched place's bare `parents: ["world"]` reaches this
117
- * package's own root. Comparing the full Address instead would ask the
118
- * dependency's own package to agree with this one's, which the feature is
119
- * built to cross.
118
+ * package's own root.
120
119
  *
121
120
  * @param {unknown} parent - One `parents` entry.
122
121
  * @returns {string} Its shortcode, or `""` for an entry that names nothing.
@@ -193,6 +192,7 @@ export function placesFromRecords(records, { contentBase, base }) {
193
192
  // lint's finding.
194
193
  if (places.has(shortcode)) continue;
195
194
  const data = record.data && typeof record.data === "object" ? record.data : {};
195
+ // Shortcode identity: local places claim the map node first.
196
196
  places.set(shortcode, {
197
197
  shortcode,
198
198
  name,
@@ -243,6 +243,7 @@ function addForeignPlaces(world, foreignIndex) {
243
243
  if (entry?.type !== "place") continue;
244
244
  const shortcode = readCanonicalKey(canonical)?.shortcode?.toLowerCase() ?? "";
245
245
  if (!shortcode || world.places.has(shortcode)) continue;
246
+ // Shortcode identity: fetched places fill only unclaimed map nodes.
246
247
  world.places.set(shortcode, {
247
248
  shortcode,
248
249
  name: String(entry.name ?? shortcode),
@@ -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,
@@ -242,8 +242,10 @@ export function foreignNode(canonical, entry) {
242
242
  /**
243
243
  * The corpus a link index describes.
244
244
  *
245
- * A shortcode is declared once per type and the first declaration wins, so a
246
- * local note shadows a dependency's the way the link resolver answers.
245
+ * Population containment and tenure use the same package-blind Shortcode
246
+ * identity as holdings and maps. Each type has one node per shortcode, with
247
+ * local notes taking precedence over fetched entries. A fetched place can
248
+ * attach to local geography, while its authored `parents` remains an Address.
247
249
  *
248
250
  * @param {object} index - The link index.
249
251
  * @returns {Corpus} The corpus.
@@ -263,6 +265,7 @@ function corpusOf(index) {
263
265
  if (!node) return;
264
266
  const by = node.type === "place" ? places : affiliations;
265
267
  if (by.has(node.shortcode)) return;
268
+ // Shortcode identity: the first node of each type owns this graph key.
266
269
  by.set(node.shortcode, node);
267
270
  // Not a canonical Address: package-blind by design, so a local place
268
271
  // shadows a foreign one of the same type and shortcode. `/` keeps that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.10.0",
3
+ "version": "22.12.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
  /**