@optolith/entity-descriptions 0.4.3 → 0.4.5

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
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
4
4
 
5
+ ## [0.4.5](https://github.com/Optolith/entity-descriptions/compare/v0.4.4...v0.4.5) (2026-03-14)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * do not display the twelve blessings for shamanistic professions ([e95cc0d](https://github.com/Optolith/entity-descriptions/commit/e95cc0df5312dfcd6fd8ae571e728eccf08601e2))
11
+ * join prerequisites of the same entry more reliably ([c317c62](https://github.com/Optolith/entity-descriptions/commit/c317c629a5f091cc23485717fad795538aabf0c2))
12
+ * rendering of blessings in packages and variants ([d04e247](https://github.com/Optolith/entity-descriptions/commit/d04e24761204303211157e1154446bc8f1828c43))
13
+
14
+ ## [0.4.4](https://github.com/Optolith/entity-descriptions/compare/v0.4.3...v0.4.4) (2026-03-14)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * book display ([dfc07ee](https://github.com/Optolith/entity-descriptions/commit/dfc07eed999c02890a3516aaacd64313c226c9cb))
20
+ * working creature optional value guards ([11a283a](https://github.com/Optolith/entity-descriptions/commit/11a283af9082b64eb48823cb90107394fa8baa5b))
21
+
5
22
  ## [0.4.3](https://github.com/Optolith/entity-descriptions/compare/v0.4.2...v0.4.3) (2026-03-14)
6
23
 
7
24
 
@@ -19,6 +19,6 @@ export declare const renderRangedWeapon: <Damage>(translate: Translate, translat
19
19
  * Get a JSON representation of the rules text for equipment.
20
20
  */
21
21
  export declare const getEquipmentEntityDescription: import("../creator.js").EntityDescriptionCreator<"Weapon" | "Armor" | "Ammunition" | "Animal" | "AnimalCare" | "BandageOrRemedy" | "Book" | "CeremonialItem" | "Clothes" | "ClothingPackage" | "Container" | "EquipmentOfBlessedOnes" | "GemOrPreciousStone" | "IlluminationLightSource" | "IlluminationRefillOrSupply" | "Jewelry" | "Laboratory" | "Liebesspielzeug" | "LuxuryGood" | "MagicalArtifact" | "MusicalInstrument" | "Newspaper" | "OrienteeringAid" | "RopeOrChain" | "Stationery" | "ThievesTool" | "ToolOfTheTrade" | "TravelGearOrTool" | "Vehicle" | "WeaponAccessory" | "WorkingSupernaturalCreature", {
22
- getInstanceById: GetInstanceById<"Publication" | "Attribute" | "Reach" | "SocialStatus" | "CloseCombatTechnique" | "RangedCombatTechnique" | "MagicalTradition" | "BlessedTradition" | "DerivedCharacteristic" | "Ammunition" | "Race" | "Culture" | "Profession">;
22
+ getInstanceById: GetInstanceById<"Publication" | "Attribute" | "Reach" | "SocialStatus" | "CloseCombatTechnique" | "RangedCombatTechnique" | "MagicalTradition" | "BlessedTradition" | "DerivedCharacteristic" | "Ammunition" | "Race" | "Culture" | "Profession" | "Skill">;
23
23
  idMap: IdMap;
24
24
  }, import("../index.js").EntityDescription>;
@@ -1,5 +1,6 @@
1
1
  import { ensureNonEmpty, isNotEmpty } from "@elyukai/utils/array/nonEmpty";
2
2
  import { isNotNullish } from "@elyukai/utils/nullable";
3
+ import { romanize } from "@elyukai/utils/roman";
3
4
  import { sign } from "@elyukai/utils/string/number";
4
5
  import { assertExhaustive } from "@elyukai/utils/typeSafety";
5
6
  import { mapNullable } from "@optolith/helpers/nullable";
@@ -22,12 +23,13 @@ export const getEquipmentName = (translate, translateMap, getInstanceById, entry
22
23
  socialStatus: socialStatusName,
23
24
  });
24
25
  }
26
+ case "Book":
27
+ return translateMap(entry.content.translations)?.name ?? MISSING_VALUE;
25
28
  case "Ammunition":
26
29
  case "Animal":
27
30
  case "AnimalCare":
28
31
  case "Armor":
29
32
  case "BandageOrRemedy":
30
- case "Book":
31
33
  case "CeremonialItem":
32
34
  case "Clothes":
33
35
  case "Container":
@@ -433,7 +435,6 @@ const normalizeCombatValues = (entity) => {
433
435
  case "Animal":
434
436
  case "AnimalCare":
435
437
  case "BandageOrRemedy":
436
- case "Book":
437
438
  case "CeremonialItem":
438
439
  case "Clothes":
439
440
  case "ClothingPackage":
@@ -481,11 +482,223 @@ const normalizeCombatValues = (entity) => {
481
482
  return assertExhaustive(entity);
482
483
  }
483
484
  };
485
+ const renderBookTypes = (translate, translateMap, localeJoin, localeCompare, getInstanceById, types) => types
486
+ .map((type) => {
487
+ switch (type.kind) {
488
+ case "Mundane":
489
+ switch (type.Mundane.kind) {
490
+ case "RomanceNovel":
491
+ return [translate("Romance Novel")];
492
+ case "Poetry":
493
+ return [translate("Poetry")];
494
+ case "PoliticalPamphlet":
495
+ return [translate("Political Pamphlet")];
496
+ case "CrimeStory":
497
+ return [translate("Crime Story")];
498
+ case "FairyTale":
499
+ return [translate("Fairy Tale")];
500
+ case "Novel":
501
+ return [translate("Novel")];
502
+ case "ProfessionalPublication":
503
+ return [
504
+ translate("Professional Publication"),
505
+ translateMap(getInstanceById("Skill", type.Mundane.ProfessionalPublication)?.translations)?.name ?? MISSING_VALUE,
506
+ ];
507
+ default:
508
+ return assertExhaustive(type.Mundane);
509
+ }
510
+ case "Magical":
511
+ return [translate("Magical Book")];
512
+ case "Religious":
513
+ return [translate("Religious Works")];
514
+ default:
515
+ return assertExhaustive(type);
516
+ }
517
+ })
518
+ .reduce((accTypes, [main, sub]) => {
519
+ const last = accTypes.at(-1);
520
+ return last?.[1] === undefined || sub === undefined
521
+ ? [...accTypes, [main, sub === undefined ? undefined : [sub]]]
522
+ : [...accTypes.slice(0, -1), [last[0], [...last[1], sub]]];
523
+ }, [])
524
+ .map(([main, sub]) => sub === undefined
525
+ ? main
526
+ : `${main} (${localeJoin(sub.toSorted(localeCompare), "conjunction")})`)
527
+ .join(", ");
528
+ const renderBookRules = (translate, rules) => {
529
+ switch (rules.kind) {
530
+ case "Plain":
531
+ if (rules.Plain.reconstruction === undefined &&
532
+ rules.Plain.references === undefined &&
533
+ rules.Plain.textAfter === undefined) {
534
+ return rules.Plain.text;
535
+ }
536
+ return [
537
+ {
538
+ type: "plain",
539
+ text: rules.Plain.text,
540
+ },
541
+ {
542
+ type: "definitionList",
543
+ style: "nested",
544
+ items: [
545
+ mapNullable(rules.Plain.reconstruction, reconstruction => ({
546
+ label: translate("Reconstruction"),
547
+ value: reconstruction,
548
+ })),
549
+ mapNullable(rules.Plain.references, references => ({
550
+ label: translate("References"),
551
+ value: references,
552
+ })),
553
+ ],
554
+ },
555
+ mapNullable(rules.Plain.textAfter, textAfter => ({
556
+ type: "plain",
557
+ text: textAfter,
558
+ })),
559
+ ];
560
+ case "Entertainment":
561
+ return translate("Entertainment");
562
+ case "ByEdition":
563
+ return [
564
+ {
565
+ type: "definitionList",
566
+ style: "nested",
567
+ items: [
568
+ ...rules.ByEdition.editions.map((edition) => {
569
+ if (edition.reconstruction === undefined && edition.references === undefined) {
570
+ return {
571
+ label: edition.label,
572
+ value: edition.text,
573
+ };
574
+ }
575
+ return {
576
+ label: edition.label,
577
+ value: [
578
+ {
579
+ type: "plain",
580
+ text: edition.text,
581
+ },
582
+ {
583
+ type: "definitionList",
584
+ style: "nested",
585
+ items: [
586
+ mapNullable(edition.reconstruction, reconstruction => ({
587
+ label: translate("Reconstruction"),
588
+ value: reconstruction,
589
+ })),
590
+ mapNullable(edition.references, references => ({
591
+ label: translate("References"),
592
+ value: references,
593
+ })),
594
+ ],
595
+ },
596
+ ],
597
+ };
598
+ }),
599
+ ],
600
+ },
601
+ mapNullable(rules.ByEdition.textAfter, textAfter => ({
602
+ type: "plain",
603
+ text: textAfter,
604
+ })),
605
+ ];
606
+ default:
607
+ return assertExhaustive(rules);
608
+ }
609
+ };
484
610
  /**
485
611
  * Get a JSON representation of the rules text for equipment.
486
612
  */
487
613
  export const getEquipmentEntityDescription = createEntityDescriptionCreator(({ getInstanceById, idMap }, locale, entry) => {
488
614
  const { translate, translateMap } = locale;
615
+ if (entry.entity === "Book") {
616
+ const translation = translateMap(entry.content.translations);
617
+ if (translation === undefined) {
618
+ return undefined;
619
+ }
620
+ const name = getEquipmentName(translate, translateMap, getInstanceById, entry);
621
+ return {
622
+ title: name,
623
+ className: "equipment",
624
+ body: [
625
+ {
626
+ type: "definitionList",
627
+ items: [
628
+ {
629
+ label: translate("Name"),
630
+ value: name,
631
+ },
632
+ {
633
+ label: translate("Type"),
634
+ value: renderBookTypes(translate, translateMap, locale.join, locale.compare, getInstanceById, entry.content.types),
635
+ },
636
+ translation.language !== undefined || translation.script !== undefined
637
+ ? {
638
+ label: translate("Language/Script"),
639
+ value: [translation.language, translation.script]
640
+ .map(value => value ?? "—")
641
+ .join(" / "),
642
+ }
643
+ : undefined,
644
+ entry.content.contentQuality === undefined
645
+ ? undefined
646
+ : {
647
+ label: translate("Content Quality"),
648
+ value: (() => {
649
+ switch (entry.content.contentQuality.kind) {
650
+ case "Modest":
651
+ return translate("Modest");
652
+ case "Average":
653
+ return translate("Average");
654
+ case "Demanding":
655
+ return (translate("Demanding") +
656
+ parensIf(translate("CL {$level}", {
657
+ level: romanize(entry.content.contentQuality.Demanding),
658
+ })));
659
+ default:
660
+ return assertExhaustive(entry.content.contentQuality);
661
+ }
662
+ })(),
663
+ },
664
+ mapNullable(entry.content.cost, cost => renderCost(translate, translateMap, locale.formatNumber, cost)),
665
+ translation.note === undefined
666
+ ? undefined
667
+ : {
668
+ label: translate("Note"),
669
+ value: translation.note,
670
+ },
671
+ translation.rules === undefined
672
+ ? undefined
673
+ : {
674
+ label: translate("Rules"),
675
+ value: renderBookRules(translate, translation.rules),
676
+ },
677
+ translation.legality === undefined
678
+ ? undefined
679
+ : {
680
+ label: translate("Legality"),
681
+ value: translation.legality,
682
+ },
683
+ translation.availability === undefined
684
+ ? undefined
685
+ : {
686
+ label: translate("Availability"),
687
+ value: translation.availability,
688
+ },
689
+ translation.special === undefined
690
+ ? undefined
691
+ : {
692
+ label: translate("Special"),
693
+ value: translation.special,
694
+ },
695
+ ],
696
+ },
697
+ ],
698
+ errata: translation.errata,
699
+ references: entry.content.src,
700
+ };
701
+ }
489
702
  const translation = entry.entity === "ClothingPackage" ? undefined : translateMap(entry.content.translations);
490
703
  if (entry.entity !== "ClothingPackage" && translation === undefined) {
491
704
  return undefined;
@@ -522,14 +735,6 @@ export const getEquipmentEntityDescription = createEntityDescriptionCreator(({ g
522
735
  label: translate("Color"),
523
736
  value: color,
524
737
  })),
525
- baseItemTranslation?.language !== undefined || baseItemTranslation?.script !== undefined
526
- ? {
527
- label: translate("Language/Script"),
528
- value: [baseItemTranslation.language, baseItemTranslation.script]
529
- .map(value => value ?? "—")
530
- .join(" / "),
531
- }
532
- : undefined,
533
738
  baseItem.structure_points !== undefined && isNotEmpty(baseItem.structure_points)
534
739
  ? {
535
740
  label: translate("Structure Points"),
@@ -584,13 +789,13 @@ export const getEquipmentEntityDescription = createEntityDescriptionCreator(({ g
584
789
  value: baseItemTranslation.appearance,
585
790
  }
586
791
  : undefined,
587
- baseItemTranslation?.appearance !== undefined
792
+ baseItemTranslation?.components !== undefined
588
793
  ? {
589
794
  label: translate("Components"),
590
795
  value: baseItemTranslation.components,
591
796
  }
592
797
  : undefined,
593
- baseItemTranslation?.appearance !== undefined
798
+ baseItemTranslation?.use !== undefined
594
799
  ? {
595
800
  label: translate("Use"),
596
801
  value: baseItemTranslation.use,
@@ -57,13 +57,10 @@ const normalizeChunks = (chunk) => (Array.isArray(chunk) ? combinePair(chunk) :
57
57
  */
58
58
  const zipChunks = (chunks) => chunks
59
59
  .map(normalizeChunks)
60
- .reduce((acc, chunk) => acc === "" ? chunk : combineChunks(acc, chunk, (a, b) => `${a}, ${b}`), "");
60
+ .reduce((acc, chunk) => (acc === "" ? chunk : combineChunks(acc, chunk, (a, b) => `${a}, ${b}`)), "");
61
61
  const renderLevel = (formatAsPrerequisite, id, level) => level === undefined
62
62
  ? undefined
63
- : formatAsPrerequisite ||
64
- id.kind === "Advantage" ||
65
- id.kind === "Disadvantage" ||
66
- level === 1
63
+ : formatAsPrerequisite || id.kind === "Advantage" || id.kind === "Disadvantage" || level === 1
67
64
  ? romanize(level)
68
65
  : `I–${romanize(level)}`;
69
66
  /**
@@ -86,9 +83,7 @@ const renderActivatableNameChunk = (translateMap, chunk) => {
86
83
  */
87
84
  export const renderActivatableNameComponentsWithoutLevel = (translateMap, components) => {
88
85
  const { levelPlacement, useParenthesis } = components.nameBuilderRules;
89
- const wrapParens = useParenthesis
90
- ? str => `(${str})`
91
- : identity;
86
+ const wrapParens = useParenthesis ? str => `(${str})` : identity;
92
87
  const base = renderActivatableNameChunk(translateMap, components.base);
93
88
  const options = components.options === undefined
94
89
  ? undefined
@@ -313,13 +308,10 @@ export const renderActivatableNameComponentsCombinedIfPossible = (translateMap,
313
308
  // }
314
309
  const renderOptions = (displayedInProfession, getResolvedSelectOptionById, id, options) => {
315
310
  const arr = options?.map(optionId => {
316
- const optTranslations = getResolvedSelectOptionById(id, optionId)?.content
317
- .translations;
311
+ const optTranslations = getResolvedSelectOptionById(id, optionId)?.content.translations;
318
312
  return optTranslations === undefined
319
313
  ? MISSING_VALUE
320
- : mapObject(optTranslations, t10n => displayedInProfession
321
- ? (t10n.name_in_profession ?? t10n.name)
322
- : t10n.name);
314
+ : mapObject(optTranslations, t10n => displayedInProfession ? (t10n.name_in_profession ?? t10n.name) : t10n.name);
323
315
  }) ?? [];
324
316
  if (isNotEmpty(arr) && arr.length > 1) {
325
317
  const [first, ...rest] = arr;
@@ -3,6 +3,7 @@ import { deepEqual, equal } from "@elyukai/utils/equality";
3
3
  import { on } from "@elyukai/utils/function";
4
4
  import { compareNumber, reduceCompare } from "@elyukai/utils/ordering";
5
5
  import { assertExhaustive } from "@optolith/helpers/typeSafety";
6
+ import { fromUniformCase } from "tsondb/schema/gen";
6
7
  import { renderActivatableNameComponents, renderActivatableNameComponentsCombinedIfPossible, } from "../activatableNameChunks.js";
7
8
  /**
8
9
  * Type guard for prerequisite parts with a value object.
@@ -53,6 +54,7 @@ const appendPrerequisitePartGroup = (previous, translateMap, localeCompare, part
53
54
  }
54
55
  else if (parts.every(hasPartValueObject)) {
55
56
  return appendBySentenceType(previous, parts
57
+ .toSorted(on(part => fromUniformCase(part.value.id), localeCompare))
56
58
  .reduce((acc, current, i, arr) => {
57
59
  if (acc[1] > 0) {
58
60
  return [acc[0], acc[1] - 1];
@@ -368,22 +368,28 @@ const renderRestrictedBlessings = (restrictedBlessings) => {
368
368
  return assertExhaustive(restrictedBlessings);
369
369
  }
370
370
  };
371
- const renderSingleBlessings = (traditions) => Reader.traverse(traditions, traditionId => getInstanceByIdR("BlessedTradition", traditionId).map(tradition => tradition?.restricted_blessings)).thenW(restrictedBlessingsList => !isNotEmpty(restrictedBlessingsList)
371
+ const renderSingleBlessings = (traditions) => Reader.traverse(traditions, traditionId => getInstanceByIdR("BlessedTradition", traditionId).map(tradition => tradition
372
+ ? { restrictedBlessings: tradition.restricted_blessings, type: tradition.type }
373
+ : undefined)).thenW(restrictedBlessingsList => !isNotEmpty(restrictedBlessingsList)
372
374
  ? Reader.of(undefined)
373
375
  : restrictedBlessingsList.length === 1
374
376
  ? (() => {
375
- const [restrictedBlessings] = restrictedBlessingsList;
376
- return restrictedBlessings === undefined
377
- ? translateR("The Twelve Blessings")
378
- : translateR("The Twelve Blessings").thenW(base => renderRestrictedBlessings(restrictedBlessings).thenW(list => translateR("except for {$list :list type=conjunction}", {
379
- list,
380
- }).map(note => base + parensIf(note))));
377
+ const [first] = restrictedBlessingsList;
378
+ const { restrictedBlessings, type } = first ?? {};
379
+ return type?.kind === "Shamanistic"
380
+ ? Reader.of(undefined)
381
+ : restrictedBlessings === undefined
382
+ ? translateR("The Twelve Blessings")
383
+ : translateR("The Twelve Blessings").thenW(base => renderRestrictedBlessings(restrictedBlessings).thenW(list => translateR("except for {$list :list type=conjunction}", {
384
+ list,
385
+ }).map(note => base + parensIf(note))));
381
386
  })()
382
- : restrictedBlessingsList.some(isNotNullish)
383
- ? translateR("The Twelve Blessings").map2(translateR("depends on selected tradition"), (base, note) => base + parensIf(note))
384
- : translateR("The Twelve Blessings"));
385
- const renderBlessings = (professionPackages) => {
386
- const traditions = professionPackages.map(pkg => retrieveBlessedTraditionIdentifierFromPrerequisites(pkg.content.prerequisites));
387
+ : restrictedBlessingsList.every(trad => trad?.type.kind === "Shamanistic")
388
+ ? Reader.of(undefined)
389
+ : restrictedBlessingsList.some(trad => trad?.restrictedBlessings !== undefined || trad?.type.kind === "Shamanistic")
390
+ ? translateR("The Twelve Blessings").map2(translateR("depends on selected tradition"), (base, note) => base + parensIf(note))
391
+ : translateR("The Twelve Blessings"));
392
+ const renderBlessings = (traditions) => {
387
393
  if (traditions.every(isEmpty)) {
388
394
  return Reader.of(undefined);
389
395
  }
@@ -395,13 +401,21 @@ const renderBlessings = (professionPackages) => {
395
401
  return Reader.traverse(traditions, option => option.length === 0 ? Reader.of("—") : renderSingleBlessings(option)).map(list => list.join(" / "));
396
402
  }
397
403
  };
404
+ const renderBlessingsForPackages = (professionPackages) => renderBlessings(professionPackages.map(pkg => retrieveBlessedTraditionIdentifierFromPrerequisites(pkg.content.prerequisites)));
405
+ const renderBlessingsForVariant = (professionVariant) => renderBlessings([
406
+ retrieveBlessedTraditionIdentifierFromPrerequisites(professionVariant.prerequisites),
407
+ ]).map(blessingsText => (blessingsText !== undefined ? [blessingsText] : []));
398
408
  const renderLiturgicalChantName = (liturgyIds) => Reader.traverse(liturgyIds, strictNameR).thenW(list => localeJoinR(list, "disjunction"));
399
409
  const renderLiturgicalChants = (professionPackages) => Reader.sequence([
400
- renderBlessings(professionPackages),
410
+ renderBlessingsForPackages(professionPackages),
401
411
  renderNumericListAcrossPackages(professionPackages, (pkg) => Reader.of(pkg.content.liturgical_chants?.map(ct => [ct.id, ct.rating_modifier]) ?? []), deepEqual, renderLiturgicalChantName, 0),
402
412
  renderLiturgiesOption(professionPackages),
403
413
  ]).map(list => ensureNonEmpty(list.filter(isNotNullish))?.join(", "));
414
+ const baseHasNoTradition = (base) => isEmpty(retrieveBlessedTraditionIdentifierFromPrerequisites(base.prerequisites));
404
415
  const renderRatedVariantChanges = (baseList, variantList, renderInstance) => Reader.traverse(variantList ?? [], ({ id, rating_modifier }) => {
416
+ if (baseList === "ignore") {
417
+ return renderInstance(id).map(name => `${name} ${rating_modifier.toFixed()}`);
418
+ }
405
419
  const baseValue = baseList?.find(item => deepEqual(item.id, id))?.rating_modifier ?? 0;
406
420
  return renderInstance(id).thenW(name => translateR("{$replacement} instead of {$base}", {
407
421
  replacement: `${name} ${(baseValue + rating_modifier).toFixed()}`,
@@ -420,11 +434,11 @@ const renderProfessionVariantText = (base, variant, translation) => translation.
420
434
  Reader.asks(env => variant.prerequisites === undefined
421
435
  ? []
422
436
  : [
423
- printProfessionPrerequisites(env.getInstanceById, env.getResolvedSelectOptionById, {
437
+ `${env.translate("Prerequisites")}: ${printProfessionPrerequisites(env.getInstanceById, env.getResolvedSelectOptionById, {
424
438
  ...env,
425
439
  join: env.localeJoin,
426
440
  compare: env.localeCompare,
427
- }, variant.prerequisites),
441
+ }, variant.prerequisites)}`,
428
442
  ]),
429
443
  renderVariantOption(base, variant, "skill_specialization", renderVariantSkillSpecializationOption),
430
444
  renderVariantOption(base, variant, "languages_scripts", renderVariantLanguagesScriptsOption),
@@ -448,7 +462,8 @@ const renderProfessionVariantText = (base, variant, translation) => translation.
448
462
  renderRatedVariantChanges(base.combat_techniques, variant.combat_techniques, id => strictNameR(id)),
449
463
  renderRatedVariantChanges(base.skills, variant.skills, id => strictNameR("Skill", id)),
450
464
  renderRatedVariantChanges(base.spells, variant.spells, renderSpellworkName),
451
- renderRatedVariantChanges(base.liturgical_chants, variant.liturgical_chants, renderLiturgicalChantName),
465
+ renderBlessingsForVariant(variant),
466
+ renderRatedVariantChanges(baseHasNoTradition(base) ? "ignore" : base.liturgical_chants, variant.liturgical_chants, renderLiturgicalChantName),
452
467
  ]).map(lists => lists
453
468
  .filter(isNotEmpty)
454
469
  .map(list => list.join(", "))