@optolith/entity-descriptions 0.4.7 → 0.4.10

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/lib/entities/activatable.d.ts +8 -3
  3. package/lib/entities/activatable.js +187 -15
  4. package/lib/entities/curriculum.js +14 -21
  5. package/lib/entities/derivedCharacteristic.js +5 -7
  6. package/lib/entities/equipmentPackage.js +2 -1
  7. package/lib/entities/liturgicalChant.js +7 -3
  8. package/lib/entities/partial/commonnessRatedAdvantagesAndDisadvantages.js +8 -12
  9. package/lib/entities/partial/herbary.js +5 -8
  10. package/lib/entities/partial/markdown.d.ts +55 -0
  11. package/lib/entities/partial/markdown.js +59 -0
  12. package/lib/entities/partial/prerequisites/index.d.ts +1 -1
  13. package/lib/entities/partial/prerequisites/index.js +26 -13
  14. package/lib/entities/partial/prerequisites/part.js +8 -3
  15. package/lib/entities/partial/prerequisites/single/culture.js +2 -1
  16. package/lib/entities/partial/prerequisites/single/personalityTrait.js +2 -1
  17. package/lib/entities/partial/prerequisites/single/profession.js +4 -1
  18. package/lib/entities/partial/prerequisites/single/race.js +2 -1
  19. package/lib/entities/partial/prerequisites/single/rated.js +2 -27
  20. package/lib/entities/partial/prerequisites/single/ratedMinimumNumber.js +2 -1
  21. package/lib/entities/partial/prerequisites/single/ratedSum.js +2 -1
  22. package/lib/entities/partial/prerequisites/single/state.js +2 -1
  23. package/lib/entities/partial/reader.d.ts +33 -0
  24. package/lib/entities/partial/reader.js +13 -0
  25. package/lib/entities/partial/unknown.d.ts +1 -1
  26. package/lib/entities/partial/unknown.js +1 -1
  27. package/lib/entities/personalityTrait.js +9 -2
  28. package/lib/entities/profession.js +25 -25
  29. package/lib/entities/race.js +4 -6
  30. package/lib/entities/spell.js +13 -25
  31. package/lib/index.d.ts +53 -53
  32. package/lib/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
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.10](https://github.com/Optolith/entity-descriptions/compare/v0.4.9...v0.4.10) (2026-03-17)
6
+
7
+ ## [0.4.9](https://github.com/Optolith/entity-descriptions/compare/v0.4.8...v0.4.9) (2026-03-17)
8
+
9
+
10
+ ### Features
11
+
12
+ * mark generated instances with attributed strings ([85cce91](https://github.com/Optolith/entity-descriptions/commit/85cce917d16ac10833d4dccf7031c2dca4e75d26))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * always append level to activatable name ([10ca82a](https://github.com/Optolith/entity-descriptions/commit/10ca82a1e57afd2d11ba7c32b55a49172e676398))
18
+ * require previous level and fix function typo ([2b12a01](https://github.com/Optolith/entity-descriptions/commit/2b12a01beaaf786843edbbcf42def3d1b1ecb673))
19
+
20
+ ## [0.4.8](https://github.com/Optolith/entity-descriptions/compare/v0.4.7...v0.4.8) (2026-03-15)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * render style and advanced special ability values ([d7a2a99](https://github.com/Optolith/entity-descriptions/commit/d7a2a99188976ea626e917da0085726cb02f7e26))
26
+
5
27
  ## [0.4.7](https://github.com/Optolith/entity-descriptions/compare/v0.4.6...v0.4.7) (2026-03-15)
6
28
 
7
29
 
@@ -1,7 +1,10 @@
1
- import type { ActivatableIdentifier, AdvantageDisadvantagePrerequisites, AdventurePointsValue, ApplicableCombatTechniques, Aspect_ID, CombatSpecialAbilityUsageType, DaggerRitualCost, EnchantmentCost, Errata, GeneralPrerequisites, MagicalSignCost, Penalty, PropertyDeclaration, PublicationRefs, RatedIdentifier, SelectOptions, SpecialRule, Volume } from "optolith-database-schema/gen";
1
+ import type { ActivatableIdentifier, AdvancedSpecialAbility, AdvantageDisadvantagePrerequisites, AdventurePointsValue, ApplicableCombatTechniques, Aspect_ID, CombatSpecialAbilityUsageType, DaggerRitualCost, EnchantmentCost, Errata, GeneralPrerequisites, MagicalSignCost, Penalty, PropertyDeclaration, PublicationRefs, RatedIdentifier, SelectOptions, SpecialRule, Volume } from "optolith-database-schema/gen";
2
+ import { Case } from "tsondb/schema/gen";
2
3
  import type { GetAllInstances, GetInstanceById } from "../helpers/getTypes.js";
3
4
  import type { GetAllResolvedNewSkillApplications, GetAllResolvedSelectOptions, GetAllResolvedSkillUses } from "../index.js";
4
- import type { GetResolvedSelectOptionById } from "./partial/prerequisites/single/activatable.js";
5
+ import { type GetResolvedSelectOptionById } from "./partial/prerequisites/single/activatable.js";
6
+ type AdvancedSpecialAbilityKind = "AdvancedSkillSpecialAbility" | "AdvancedCombatSpecialAbility" | "AdvancedMagicalSpecialAbility" | "AdvancedKarmaSpecialAbility";
7
+ type AdvancedIdentifierSpecialAbility = string | Case<AdvancedSpecialAbilityKind, string>;
5
8
  /**
6
9
  * The base fields for a special ability entity in the database.
7
10
  */
@@ -10,6 +13,7 @@ export type BaseActivatable = {
10
13
  maximum?: number;
11
14
  select_options?: SelectOptions;
12
15
  usage_type?: CombatSpecialAbilityUsageType;
16
+ advanced?: AdvancedSpecialAbility<AdvancedIdentifierSpecialAbility>[];
13
17
  penalty?: Penalty;
14
18
  combat_techniques?: ApplicableCombatTechniques;
15
19
  volume?: Volume;
@@ -43,10 +47,11 @@ export type BaseActivatableTranslation = {
43
47
  * Get a JSON representation of the rules text for a special ability.
44
48
  */
45
49
  export declare const getActivatableEntityDescription: import("../creator.js").EntityDescriptionCreator<"Advantage" | "Disadvantage" | "AdvancedCombatSpecialAbility" | "AdvancedKarmaSpecialAbility" | "AdvancedMagicalSpecialAbility" | "AdvancedSkillSpecialAbility" | "AncestorGlyph" | "ArcaneOrbEnchantment" | "AttireEnchantment" | "Beutelzauber" | "BlessedTradition" | "BowlEnchantment" | "BrawlingSpecialAbility" | "CauldronEnchantment" | "CeremonialItemSpecialAbility" | "ChronicleEnchantment" | "CombatSpecialAbility" | "CombatStyleSpecialAbility" | "CommandSpecialAbility" | "DaggerRitual" | "FamiliarSpecialAbility" | "FatePointSexSpecialAbility" | "FatePointSpecialAbility" | "FoolsHatEnchantment" | "GeneralSpecialAbility" | "Haubenzauber" | "InstrumentEnchantment" | "KarmaSpecialAbility" | "Krallenkettenzauber" | "Kristallkugelzauber" | "LiturgicalStyleSpecialAbility" | "LycantropicGift" | "MagicalSign" | "MagicalSpecialAbility" | "MagicalTradition" | "MagicStyleSpecialAbility" | "OrbEnchantment" | "PactGift" | "ProtectiveWardingCircleSpecialAbility" | "RingEnchantment" | "Sermon" | "SexSpecialAbility" | "SickleRitual" | "SikaryanDrainSpecialAbility" | "SkillStyleSpecialAbility" | "SpellSwordEnchantment" | "StaffEnchantment" | "ToyEnchantment" | "Trinkhornzauber" | "VampiricGift" | "Vision" | "WandEnchantment" | "WeaponEnchantment", {
46
- getInstanceById: GetInstanceById<"Publication" | "Subject" | ActivatableIdentifier["kind"] | RatedIdentifier["kind"] | "TradeSecret" | "Aspect" | "Property" | "Race" | "Culture" | "State" | "Enhancement" | "PactCategory" | "PactDomain" | "SocialStatus" | "Weapon">;
50
+ getInstanceById: GetInstanceById<"Publication" | "Subject" | ActivatableIdentifier["kind"] | RatedIdentifier["kind"] | "TradeSecret" | "Aspect" | "Property" | "Race" | "Culture" | "State" | "Enhancement" | "PactCategory" | "PactDomain" | "SocialStatus" | "Weapon" | "Patron">;
47
51
  getAllInstances: GetAllInstances<"Script">;
48
52
  getResolvedSelectOptionById: GetResolvedSelectOptionById;
49
53
  getAllResolvedSelectOptions: GetAllResolvedSelectOptions;
50
54
  getAllResolvedNewSkillApplications: GetAllResolvedNewSkillApplications;
51
55
  getAllResolvedSkillUses: GetAllResolvedSkillUses;
52
56
  }, import("../index.js").EntityDescription>;
57
+ export {};
@@ -1,3 +1,6 @@
1
+ import { isNotEmpty } from "@elyukai/utils/array/nonEmpty";
2
+ import { count } from "@elyukai/utils/array/reductions";
3
+ import { deepEqual } from "@elyukai/utils/equality";
1
4
  import { on } from "@elyukai/utils/function";
2
5
  import { isNotNullish } from "@elyukai/utils/nullable";
3
6
  import { numAsc } from "@optolith/helpers/compare";
@@ -7,14 +10,25 @@ import { romanize } from "@optolith/helpers/roman";
7
10
  import { assertExhaustive } from "@optolith/helpers/typeSafety";
8
11
  import { Case, fromUniformCase } from "tsondb/schema/gen";
9
12
  import { createEntityDescriptionCreator } from "../creator.js";
13
+ import { renderActivatableNameComponents } from "./partial/activatableNameChunks.js";
10
14
  import { renderAdventurePointsValue } from "./partial/adventurePointsValue.js";
11
15
  import { renderResponsiveMap } from "./partial/map.js";
16
+ import { attributedName } from "./partial/markdown.js";
12
17
  import { additionFormatter } from "./partial/mathOperation.js";
13
18
  import { printAdvantageDisadvantagePrerequisites, printGeneralPrerequisites, } from "./partial/prerequisites/index.js";
19
+ import { printActivatableName, } from "./partial/prerequisites/single/activatable.js";
14
20
  import { parensIf } from "./partial/rated/activatable/parensIf.js";
15
21
  import { getResponsiveText, getResponsiveTextOptional, ResponsiveTextSize, } from "./partial/responsiveText.js";
16
22
  import { formatTimeSpan } from "./partial/units/timeSpan.js";
17
23
  import { MISSING_VALUE } from "./partial/unknown.js";
24
+ const isStyleSpecialAbilityKind = (value) => value === "SkillStyleSpecialAbility" ||
25
+ value === "CombatStyleSpecialAbility" ||
26
+ value === "MagicStyleSpecialAbility" ||
27
+ value === "LiturgicalStyleSpecialAbility";
28
+ const isAdvancedSpecialAbilityKind = (value) => value === "AdvancedSkillSpecialAbility" ||
29
+ value === "AdvancedCombatSpecialAbility" ||
30
+ value === "AdvancedMagicalSpecialAbility" ||
31
+ value === "AdvancedKarmaSpecialAbility";
18
32
  const renderPropertyValue = (getInstanceById, translate, translateMap, propertyDecl) => {
19
33
  switch (propertyDecl.kind) {
20
34
  case "DependingOnSelection":
@@ -129,7 +143,7 @@ const wrapInParens = (args, append = "") => {
129
143
  return ` (${filteredArgs.join(", ") + append})`;
130
144
  };
131
145
  const addSpaceIfNoCommaAtStart = (str) => (str.startsWith(",") ? str : ` ${str}`);
132
- const renderApplicableCombatTechniquesRestriction = (getInstanceById, locale, main, restriction, translation, weapons, getExcludedInstance) => {
146
+ const renderApplicableCombatTechniquesRestriction = (getInstanceById, locale, main, restriction, translation, weapons, normalizeExcludedId) => {
133
147
  switch (restriction.kind) {
134
148
  case "Improvised":
135
149
  return main + wrapInParens([locale.translate("only improvised weapons"), weapons]);
@@ -143,13 +157,12 @@ const renderApplicableCombatTechniquesRestriction = (getInstanceById, locale, ma
143
157
  return main + wrapInParens([weapons], locale.translate("while mounted"));
144
158
  }
145
159
  case "Race": {
146
- const race = getInstanceById("Race", restriction.Race);
147
- const raceName = locale.translateMap(race?.translations)?.name ?? MISSING_VALUE;
160
+ const raceName = attributedName(locale.translateMap, getInstanceById, "applicable-combat-technique", "Race", restriction.Race);
148
161
  return (main +
149
162
  wrapInParens([
150
163
  // originally "while {$racial} weapon", but different to parameterize without inflection support
151
164
  locale.translate("while weapon of race {$race}", {
152
- race: raceName,
165
+ race: raceName ?? MISSING_VALUE,
153
166
  }),
154
167
  weapons,
155
168
  ]));
@@ -159,7 +172,12 @@ const renderApplicableCombatTechniquesRestriction = (getInstanceById, locale, ma
159
172
  wrapInParens([
160
173
  locale.translate("except {$list}", {
161
174
  list: locale.join(restriction.ExcludeCombatTechniques.list
162
- .map(id => locale.translateMap(getExcludedInstance?.(id)?.translations)?.name ?? MISSING_VALUE)
175
+ .map(id => {
176
+ const normalizedId = normalizeExcludedId?.(id);
177
+ return normalizedId !== undefined
178
+ ? (attributedName(locale.translateMap, getInstanceById, "applicable-combat-technique", normalizedId) ?? MISSING_VALUE)
179
+ : MISSING_VALUE;
180
+ })
163
181
  .toSorted(locale.compare), "conjunction"),
164
182
  }),
165
183
  weapons,
@@ -194,28 +212,27 @@ const renderApplicableCombatTechniquesValue = (getInstanceById, locale, translat
194
212
  const main = locale.translate("All");
195
213
  const mainWithRestriction = applicableCombatTechniques.All.restriction === undefined
196
214
  ? main
197
- : renderApplicableCombatTechniquesRestriction(getInstanceById, locale, main, applicableCombatTechniques.All.restriction, translation, undefined, id => getInstanceById(id.kind, fromUniformCase(id)));
215
+ : renderApplicableCombatTechniquesRestriction(getInstanceById, locale, main, applicableCombatTechniques.All.restriction, translation, undefined, id => id);
198
216
  return mainWithRestriction;
199
217
  }
200
218
  case "AllClose": {
201
219
  const main = locale.translate("All Close Combat Techniques");
202
220
  const mainWithRestriction = applicableCombatTechniques.AllClose.restriction === undefined
203
221
  ? main
204
- : renderApplicableCombatTechniquesRestriction(getInstanceById, locale, main, applicableCombatTechniques.AllClose.restriction, translation, undefined, id => getInstanceById("CloseCombatTechnique", id));
222
+ : renderApplicableCombatTechniquesRestriction(getInstanceById, locale, main, applicableCombatTechniques.AllClose.restriction, translation, undefined, id => Case("CloseCombatTechnique", id));
205
223
  return mainWithRestriction;
206
224
  }
207
225
  case "AllRanged": {
208
226
  const main = locale.translate("All Ranged Combat Techniques");
209
227
  const mainWithRestriction = applicableCombatTechniques.AllRanged.restriction === undefined
210
228
  ? main
211
- : renderApplicableCombatTechniquesRestriction(getInstanceById, locale, main, applicableCombatTechniques.AllRanged.restriction, translation, undefined, id => getInstanceById("RangedCombatTechnique", id));
229
+ : renderApplicableCombatTechniquesRestriction(getInstanceById, locale, main, applicableCombatTechniques.AllRanged.restriction, translation, undefined, id => Case("RangedCombatTechnique", id));
212
230
  return mainWithRestriction;
213
231
  }
214
232
  case "Specific": {
215
233
  return applicableCombatTechniques.Specific.list
216
234
  .map(specific => {
217
- const entry = getInstanceById(specific.id.kind, fromUniformCase(specific.id));
218
- const main = locale.translateMap(entry?.translations)?.name ?? MISSING_VALUE;
235
+ const main = attributedName(locale.translateMap, getInstanceById, "applicable-combat-technique", specific.id) ?? MISSING_VALUE;
219
236
  const mainWithRestriction = specific.restriction === undefined
220
237
  ? main
221
238
  : renderApplicableCombatTechniquesRestriction(getInstanceById, locale, main, specific.restriction, translation, specific.weapons === undefined
@@ -460,6 +477,155 @@ const renderCost = (translate, translateMap, localeJoin, localeCompare, getAllRe
460
477
  return assertExhaustive(cost);
461
478
  }
462
479
  };
480
+ const renderAdvancedLabel = (translate, entityName) => {
481
+ switch (entityName) {
482
+ case "SkillStyleSpecialAbility":
483
+ return translate("Advanced Skill Special Abilities");
484
+ case "CombatStyleSpecialAbility":
485
+ return translate("Advanced Combat Special Abilities");
486
+ case "MagicStyleSpecialAbility":
487
+ return translate("Advanced Magical Special Abilities");
488
+ case "LiturgicalStyleSpecialAbility":
489
+ return translate("Advanced Karma Special Abilities");
490
+ default:
491
+ return assertExhaustive(entityName);
492
+ }
493
+ };
494
+ const normalizeId = (entityName, id) => {
495
+ if (typeof id === "string") {
496
+ switch (entityName) {
497
+ case "SkillStyleSpecialAbility":
498
+ return Case("AdvancedSkillSpecialAbility", id);
499
+ case "CombatStyleSpecialAbility":
500
+ return Case("AdvancedCombatSpecialAbility", id);
501
+ case "MagicStyleSpecialAbility":
502
+ return Case("AdvancedMagicalSpecialAbility", id);
503
+ case "LiturgicalStyleSpecialAbility":
504
+ return Case("AdvancedKarmaSpecialAbility", id);
505
+ default:
506
+ return assertExhaustive(entityName);
507
+ }
508
+ }
509
+ else {
510
+ return id;
511
+ }
512
+ };
513
+ const renderAdvancedSpecialAbilityName = (translate, translateMap, getInstanceById, getResolvedSelectOptionById, entityName, id, option) => {
514
+ const normalizedId = normalizeId(entityName, id);
515
+ return `^[${mapNullable(printActivatableName(getInstanceById, translate, normalizedId, option, undefined, getResolvedSelectOptionById, false), name => renderActivatableNameComponents(translateMap, name, false)) ?? MISSING_VALUE}](entity: "${normalizedId.kind}", instance: "${fromUniformCase(normalizedId)}", style: "normal")`;
516
+ };
517
+ const renderAdvancedValue = (translate, translateMap, localeJoin, getInstanceById, getResolvedSelectOptionById, entityName, advanced) => {
518
+ if (advanced.every(entry => entry.kind === "OneOf") &&
519
+ isNotEmpty(advanced) &&
520
+ advanced.every(entry => entry.OneOf.options.length === 3)) {
521
+ const optionsInAllEntries = advanced[0].OneOf.options.filter(option => advanced.every(otherEntry => otherEntry.OneOf.options.some(otherOption => deepEqual(option, otherOption))));
522
+ if (optionsInAllEntries.length === 2) {
523
+ const uniqueOptions = advanced
524
+ .map(entry => entry.OneOf.options.find(option => optionsInAllEntries.every(commonOption => !deepEqual(option, commonOption))))
525
+ .filter(isNotNullish); // should not do anything, just for type narrowing
526
+ if (uniqueOptions.length === advanced.length) {
527
+ const [first, second] = optionsInAllEntries.map(option => renderAdvancedSpecialAbilityName(translate, translateMap, getInstanceById, getResolvedSelectOptionById, entityName, option));
528
+ return [
529
+ ...uniqueOptions.map(option => renderAdvancedSpecialAbilityName(translate, translateMap, getInstanceById, getResolvedSelectOptionById, entityName, option)),
530
+ translate("one or two of these special abilities can alternatively be replaced by advanced special abilities {$first} and/or {$second}", { first, second }),
531
+ ].join(", ");
532
+ }
533
+ }
534
+ }
535
+ const derivedFromExternalOptionEntriesToGenerate = count(advanced, entry => entry.kind === "DeriveFromExternalOption" &&
536
+ entry.DeriveFromExternalOption.display_option === undefined);
537
+ return [
538
+ ...advanced.map((entry) => {
539
+ switch (entry.kind) {
540
+ case "General":
541
+ return renderAdvancedSpecialAbilityName(translate, translateMap, getInstanceById, getResolvedSelectOptionById, entityName, entry.General);
542
+ case "RestrictOptions":
543
+ return renderAdvancedSpecialAbilityName(translate, translateMap, getInstanceById, getResolvedSelectOptionById, entityName, entry.RestrictOptions.id, entry.RestrictOptions.option);
544
+ case "OneOf": {
545
+ if (entry.OneOf.display_option !== undefined) {
546
+ switch (entry.OneOf.display_option.kind) {
547
+ case "Hide":
548
+ return undefined;
549
+ case "ReplaceWith":
550
+ return (translateMap(entry.OneOf.display_option.ReplaceWith.translations)?.replacement ??
551
+ MISSING_VALUE);
552
+ default:
553
+ return assertExhaustive(entry.OneOf.display_option);
554
+ }
555
+ }
556
+ return localeJoin(entry.OneOf.options.map(option => renderAdvancedSpecialAbilityName(translate, translateMap, getInstanceById, getResolvedSelectOptionById, entityName, option)), "disjunction");
557
+ }
558
+ case "DeriveFromExternalOption":
559
+ if (entry.DeriveFromExternalOption.display_option !== undefined) {
560
+ switch (entry.DeriveFromExternalOption.display_option.kind) {
561
+ case "Hide":
562
+ return undefined;
563
+ case "ReplaceWith":
564
+ return (translateMap(entry.DeriveFromExternalOption.display_option.ReplaceWith.translations)?.replacement ?? MISSING_VALUE);
565
+ default:
566
+ return assertExhaustive(entry.DeriveFromExternalOption.display_option);
567
+ }
568
+ }
569
+ return undefined;
570
+ default:
571
+ return assertExhaustive(entry);
572
+ }
573
+ }),
574
+ derivedFromExternalOptionEntriesToGenerate === 0
575
+ ? undefined
576
+ : translate(".input {$count :number} {{{$count} more by primary patron}}", {
577
+ count: derivedFromExternalOptionEntriesToGenerate,
578
+ }),
579
+ ]
580
+ .filter(isNotNullish)
581
+ .join(", ");
582
+ };
583
+ const renderDeriveFromExternalOptionTable = (translate, translateMap, localeCompare, getInstanceById, getResolvedSelectOptionById, entityName, advanced) => {
584
+ if (!isStyleSpecialAbilityKind(entityName)) {
585
+ return undefined;
586
+ }
587
+ const derivedFromExternalOptionEntriesToGenerate = advanced.filter((entry) => entry.kind === "DeriveFromExternalOption" &&
588
+ entry.DeriveFromExternalOption.display_option === undefined);
589
+ if (derivedFromExternalOptionEntriesToGenerate.length === 0) {
590
+ return undefined;
591
+ }
592
+ const rows = Map.groupBy(derivedFromExternalOptionEntriesToGenerate.flatMap(entry => entry.DeriveFromExternalOption.map.map((option) => [
593
+ option.from_option,
594
+ option.to_advanced,
595
+ ])), row => row[0])
596
+ .entries()
597
+ .map(([option, list]) => [
598
+ translateMap(getInstanceById("Patron", option)?.translations)?.name ?? MISSING_VALUE,
599
+ list
600
+ .map(([, toAdvanced]) => renderAdvancedSpecialAbilityName(translate, translateMap, getInstanceById, getResolvedSelectOptionById, entityName, toAdvanced))
601
+ .toSorted(localeCompare)
602
+ .join(", "),
603
+ ])
604
+ .toArray()
605
+ .toSorted(on(row => row[0], localeCompare));
606
+ return {
607
+ type: "table",
608
+ header: [translate("Patron"), renderAdvancedLabel(translate, entityName)],
609
+ rows,
610
+ };
611
+ };
612
+ const renderTrailingAdvancedPrerequisitesNote = (translate, entityName) => {
613
+ if (isAdvancedSpecialAbilityKind(entityName)) {
614
+ switch (entityName) {
615
+ case "AdvancedCombatSpecialAbility":
616
+ return translate("corresponding combat style special ability");
617
+ case "AdvancedKarmaSpecialAbility":
618
+ return translate("corresponding liturgical style special ability");
619
+ case "AdvancedMagicalSpecialAbility":
620
+ return translate("corresponding magic style special ability");
621
+ case "AdvancedSkillSpecialAbility":
622
+ return translate("corresponding skill style special ability");
623
+ default:
624
+ return assertExhaustive(entityName);
625
+ }
626
+ }
627
+ return undefined;
628
+ };
463
629
  /**
464
630
  * Get a JSON representation of the rules text for a special ability.
465
631
  */
@@ -473,9 +639,8 @@ export const getActivatableEntityDescription = createEntityDescriptionCreator(({
473
639
  const baseEntry = entry;
474
640
  const wrappedId = Case(entityName, id);
475
641
  return {
476
- title: translation.name_in_library ??
477
- translation.name +
478
- (baseEntry.levels !== undefined ? ` I–${romanize(baseEntry.levels)}` : ""),
642
+ title: (translation.name_in_library ?? translation.name) +
643
+ (baseEntry.levels !== undefined ? ` I–${romanize(baseEntry.levels)}` : ""),
479
644
  subtitle: mapNullable(baseEntry.usage_type, usageType => {
480
645
  switch (usageType.kind) {
481
646
  case "Passive":
@@ -526,18 +691,24 @@ export const getActivatableEntityDescription = createEntityDescriptionCreator(({
526
691
  label: translate("Range"),
527
692
  value: range,
528
693
  })),
694
+ mapNullable(baseEntry.advanced, advanced => isStyleSpecialAbilityKind(entityName)
695
+ ? {
696
+ label: renderAdvancedLabel(translate, entityName),
697
+ value: renderAdvancedValue(translate, translateMap, locale.join, getInstanceById, getResolvedSelectOptionById, entityName, advanced),
698
+ }
699
+ : undefined),
529
700
  mapNullable(baseEntry.penalty, penalty => ({
530
701
  label: translate("Penalty"),
531
702
  value: renderPenaltyValue(getInstanceById, translate, translateMap, translation.name, penalty),
532
703
  })),
533
- mapNullable(entry.prerequisites, prerequisites => ({
704
+ mapNullable(entry.prerequisites ?? (isAdvancedSpecialAbilityKind(entityName) ? [] : undefined), prerequisites => ({
534
705
  label: translate("Prerequisites"),
535
706
  value: wrappedId.kind === "Advantage" || wrappedId.kind === "Disadvantage"
536
707
  ? printAdvantageDisadvantagePrerequisites(getInstanceById, getResolvedSelectOptionById, locale, prerequisites, translation.name, wrappedId.kind)
537
708
  : printGeneralPrerequisites(getInstanceById, getResolvedSelectOptionById, locale, prerequisites, mapNullable(baseEntry.levels, levels => ({
538
709
  id: wrappedId,
539
710
  levels,
540
- }))),
711
+ })), renderTrailingAdvancedPrerequisitesNote(translate, entityName)),
541
712
  })),
542
713
  mapNullable(baseEntry.combat_techniques, combatTechniques => ({
543
714
  label: translate("Combat Techniques"),
@@ -573,6 +744,7 @@ export const getActivatableEntityDescription = createEntityDescriptionCreator(({
573
744
  }),
574
745
  ],
575
746
  },
747
+ renderDeriveFromExternalOptionTable(translate, translateMap, locale.compare, getInstanceById, getResolvedSelectOptionById, entityName, baseEntry.advanced ?? []),
576
748
  ],
577
749
  errata: translation.errata,
578
750
  references: entry.src,
@@ -5,6 +5,7 @@ import { sign } from "@elyukai/utils/string/number";
5
5
  import { assertExhaustive } from "@elyukai/utils/typeSafety";
6
6
  import { isNotNullish, mapNullable } from "@optolith/helpers/nullable";
7
7
  import { createEntityDescriptionCreator } from "../creator.js";
8
+ import { attributedName } from "./partial/markdown.js";
8
9
  import { getBaseProfessionPackageForCurriculum } from "./partial/professions.js";
9
10
  import { parensIf } from "./partial/rated/activatable/parensIf.js";
10
11
  import { MISSING_VALUE } from "./partial/unknown.js";
@@ -14,13 +15,12 @@ const renderElectiveSpellworks = (translate, translateMap, localeCompare, getIns
14
15
  return translate("All, at the GM’s discretion");
15
16
  case "Specific":
16
17
  return electiveSpellworks.Specific.list
17
- .map(item => mapNullable(translateMap(getInstanceById(item.id)?.translations)?.name, name => {
18
+ .map(item => mapNullable(attributedName(translateMap, getInstanceById, "curriculum", item.id), name => {
18
19
  if (item.restriction === undefined) {
19
20
  return name;
20
21
  }
21
22
  return (name +
22
- parensIf(translateMap(getInstanceById("Element", item.restriction.Element)?.translations)
23
- ?.name ?? MISSING_VALUE));
23
+ parensIf(attributedName(translateMap, getInstanceById, "curriculum", "Element", item.restriction.Element)));
24
24
  }))
25
25
  .filter(isNotNullish)
26
26
  .toSorted(localeCompare)
@@ -42,7 +42,7 @@ const renderRestrictedSpellworks = (translate, translateMap, localeCompare, loca
42
42
  return "";
43
43
  }
44
44
  const translatedSpellworks = excludedSpellworks
45
- .map(excludedSpellwork => translateMap(getInstanceById(excludedSpellwork)?.translations)?.name)
45
+ .map(excludedSpellwork => attributedName(translateMap, getInstanceById, "curriculum", excludedSpellwork))
46
46
  .filter(isNotNullish);
47
47
  if (translatedSpellworks.length === 0) {
48
48
  return "";
@@ -53,16 +53,14 @@ const renderRestrictedSpellworks = (translate, translateMap, localeCompare, loca
53
53
  };
54
54
  const restrictedProperties = restrictedGroups.get("restrictedProperty")?.map(restriction => translate(".input {$count :number} {{only {$count} additional spellworks with the Property {$property}}}", {
55
55
  count: restriction.Property.maximum,
56
- property: translateMap(getInstanceById("Property", restriction.Property.id)?.translations)
57
- ?.name ?? MISSING_VALUE,
56
+ property: attributedName(translateMap, getInstanceById, "curriculum", "Property", restriction.Property.id) ?? MISSING_VALUE,
58
57
  }) + addExclusion(restriction.Property.exclude)) ?? [];
59
58
  const propertyGroup = restrictedGroups.get("property");
60
59
  const translatedProperties = propertyGroup
61
60
  ?.map(restriction => {
62
61
  switch (restriction.kind) {
63
62
  case "Property":
64
- return translateMap(getInstanceById("Property", restriction.Property.id)?.translations)
65
- ?.name;
63
+ return attributedName(translateMap, getInstanceById, "curriculum", "Property", restriction.Property.id);
66
64
  case "DemonSummoning":
67
65
  return translate("Demon Summoning");
68
66
  default:
@@ -80,7 +78,7 @@ const renderRestrictedSpellworks = (translate, translateMap, localeCompare, loca
80
78
  : [];
81
79
  const spellworkGroup = restrictedGroups.get("spellwork");
82
80
  const translatedSpellworks = spellworkGroup
83
- ?.map(restriction => translateMap(getInstanceById(restriction.Spellwork)?.translations)?.name)
81
+ ?.map(restriction => attributedName(translateMap, getInstanceById, "curriculum", restriction.Spellwork))
84
82
  .filter(isNotNullish)
85
83
  .toSorted(localeCompare) ?? [];
86
84
  const spellworks = translatedSpellworks.length > 0
@@ -103,24 +101,20 @@ const renderRestrictedSpellworks = (translate, translateMap, localeCompare, loca
103
101
  }) ?? [];
104
102
  return [...restrictedProperties, ...properties, ...others, ...spellworks].join("; ");
105
103
  };
106
- const renderSpellworkAdjustment = (translateMap, getInstanceById, adjustment) => `${translateMap(getInstanceById(adjustment.id)?.translations)?.name ?? MISSING_VALUE} ${adjustment.points.toFixed()}${parensIf(adjustment.tradition === undefined
104
+ const renderSpellworkAdjustment = (translateMap, getInstanceById, adjustment) => `${attributedName(translateMap, getInstanceById, "curriculum", adjustment.id) ?? MISSING_VALUE} ${adjustment.points.toFixed()}${parensIf(adjustment.tradition === undefined
107
105
  ? undefined
108
- : translateMap(getInstanceById("MagicalTradition", adjustment.tradition)?.translations)?.name)}`;
106
+ : attributedName(translateMap, getInstanceById, "curriculum", "MagicalTradition", adjustment.tradition))}`;
109
107
  const renderAbilityAdjustmentName = (translateMap, getInstanceById, abilityAdjustment) => {
110
108
  switch (abilityAdjustment.kind) {
111
109
  case "Skill":
112
- return (translateMap(getInstanceById("Skill", abilityAdjustment.Skill.id)?.translations)?.name ??
113
- MISSING_VALUE);
110
+ return (attributedName(translateMap, getInstanceById, "curriculum", "Skill", abilityAdjustment.Skill.id) ?? MISSING_VALUE);
114
111
  case "CombatTechnique":
115
- return (translateMap(getInstanceById(abilityAdjustment.CombatTechnique.id)?.translations)?.name ??
116
- MISSING_VALUE);
112
+ return (attributedName(translateMap, getInstanceById, "curriculum", abilityAdjustment.CombatTechnique.id) ?? MISSING_VALUE);
117
113
  case "Spellwork":
118
- return ((translateMap(getInstanceById(abilityAdjustment.Spellwork.id)?.translations)?.name ??
119
- MISSING_VALUE) +
114
+ return ((attributedName(translateMap, getInstanceById, "curriculum", abilityAdjustment.Spellwork.id) ?? MISSING_VALUE) +
120
115
  parensIf(abilityAdjustment.Spellwork.tradition === undefined
121
116
  ? undefined
122
- : translateMap(getInstanceById("MagicalTradition", abilityAdjustment.Spellwork.tradition)
123
- ?.translations)?.name));
117
+ : attributedName(translateMap, getInstanceById, "curriculum", "MagicalTradition", abilityAdjustment.Spellwork.tradition)));
124
118
  default:
125
119
  return assertExhaustive(abilityAdjustment);
126
120
  }
@@ -195,8 +189,7 @@ export const getCurriculumEntityDescription = createEntityDescriptionCreator(({
195
189
  items: [
196
190
  {
197
191
  label: translate("Guideline"),
198
- value: translateMap(getInstanceById("Guideline", entry.guideline)?.translations)?.name ??
199
- MISSING_VALUE,
192
+ value: attributedName(translateMap, getInstanceById, "curriculum", "Guideline", entry.guideline) ?? MISSING_VALUE,
200
193
  },
201
194
  {
202
195
  label: translate("Elective Spellworks Package"),
@@ -1,21 +1,19 @@
1
1
  import { assertExhaustive } from "@optolith/helpers/typeSafety";
2
2
  import { createEntityDescriptionCreator } from "../creator.js";
3
+ import { attributedCustomName } from "./partial/markdown.js";
3
4
  import { renderMathOperation } from "./partial/mathOperation.js";
4
5
  import { MISSING_VALUE } from "./partial/unknown.js";
5
- const getAttribute = (getInstanceById, translateMap, attributeId, style = "full") => {
6
- const attribute = getInstanceById("Attribute", attributeId);
7
- if (attribute === undefined) {
8
- return MISSING_VALUE;
9
- }
6
+ const renderAttributeNameForStyle = (style, translation) => {
10
7
  switch (style) {
11
8
  case "full":
12
- return translateMap(attribute.translations)?.name ?? MISSING_VALUE;
9
+ return translation.name;
13
10
  case "compact":
14
- return translateMap(attribute.translations)?.abbreviation ?? MISSING_VALUE;
11
+ return translation.abbreviation;
15
12
  default:
16
13
  return assertExhaustive(style);
17
14
  }
18
15
  };
16
+ const getAttribute = (getInstanceById, translateMap, attributeId, style = "full") => attributedCustomName(translateMap, getInstanceById, "derived-characteristic-calculation", translation => renderAttributeNameForStyle(style, translation), "Attribute", attributeId) ?? MISSING_VALUE;
19
17
  const getRaceBaseValue = (translate, translateMap, getInstanceById, idMap, raceBaseValue, style = "full") => {
20
18
  const instance = getInstanceById("DerivedCharacteristic", idMap.DerivedCharacteristic[raceBaseValue.kind]);
21
19
  const translation = translateMap(instance?.translations);
@@ -2,6 +2,7 @@ import { on } from "@elyukai/utils/function";
2
2
  import { assertExhaustive } from "@elyukai/utils/typeSafety";
3
3
  import { createEntityDescriptionCreator } from "../creator.js";
4
4
  import { getEquipmentName } from "./equipment.js";
5
+ import { attributedInstance } from "./partial/markdown.js";
5
6
  const sumAtomicEquipmentCost = (acc, current) => {
6
7
  if (typeof acc === "string") {
7
8
  return acc;
@@ -280,7 +281,7 @@ export const getEquipmentPackageEntityDescription = createEntityDescriptionCreat
280
281
  header: [translation.name, translate("Weight"), translate("Cost")],
281
282
  rows: actualItems
282
283
  .map((item) => [
283
- getEquipmentName(translate, translateMap, getInstanceById, item.content),
284
+ attributedInstance(getEquipmentName(translate, translateMap, getInstanceById, item.content), item.content.entity, item.content.id, { context: '"equipment-package"' }),
284
285
  renderAtomicWeight(translate, locale.measurementAdjustments, getAtomicEquipmentWeight(item.content)),
285
286
  renderAtomicCost(translate, getAtomicEquipmentCost(item.content)),
286
287
  ])
@@ -3,6 +3,7 @@ import { assertExhaustive } from "@optolith/helpers/typeSafety";
3
3
  import { Case } from "tsondb/schema/gen";
4
4
  import { createEntityDescriptionCreator } from "../creator.js";
5
5
  import { renderEnhancements } from "./partial/enhancements.js";
6
+ import { attributedInstance, attributedName } from "./partial/markdown.js";
6
7
  import { renderOneTimeDuration } from "./partial/rated/activatable/duration.js";
7
8
  import { renderEffect } from "./partial/rated/activatable/effect.js";
8
9
  import { combineGeneratedTextWithStaticTranslation, renderFastPerformanceParameters, renderSlowPerformanceParameters, } from "./partial/rated/activatable/index.js";
@@ -13,7 +14,7 @@ import { renderImprovementCost } from "./partial/rated/improvementCost.js";
13
14
  import { renderSkillCheckWithPenalty } from "./partial/rated/skillCheck.js";
14
15
  import { ResponsiveTextSize } from "./partial/responsiveText.js";
15
16
  const getTextForTraditions = (deps, values) => {
16
- const getAspectName = (aspectId) => deps.translateMap(deps.getInstanceById("Aspect", aspectId)?.translations)?.name;
17
+ const getAspectName = (aspectId) => attributedName(deps.translateMap, deps.getInstanceById, "traditions", "Aspect", aspectId);
17
18
  const text = values
18
19
  .map(trad => {
19
20
  switch (trad.kind) {
@@ -25,14 +26,17 @@ const getTextForTraditions = (deps, values) => {
25
26
  if (name === undefined) {
26
27
  return undefined;
27
28
  }
29
+ const attributedStringName = attributedInstance(name, "BlessedTradition", trad.Tradition.tradition, {
30
+ context: '"traditions"',
31
+ });
28
32
  const aspects = trad.Tradition.aspects
29
33
  ?.map(getAspectName)
30
34
  .filter(isNotNullish)
31
35
  .sort(deps.localeCompare) ?? [];
32
36
  if (aspects.length === 0) {
33
- return name;
37
+ return attributedStringName;
34
38
  }
35
- return `${name} (${aspects.join(" and ")})`;
39
+ return `${attributedStringName} (${aspects.join(" and ")})`;
36
40
  }
37
41
  default:
38
42
  return assertExhaustive(trad);
@@ -2,18 +2,17 @@ import { isNotEmpty } from "@elyukai/utils/array/nonEmpty";
2
2
  import { mapNullable } from "@elyukai/utils/nullable";
3
3
  import { Reader } from "@elyukai/utils/reader";
4
4
  import { ensureNonEmpty } from "@optolith/helpers/array";
5
+ import { attributedCustomName } from "./markdown.js";
5
6
  import { parensIf } from "./rated/activatable/parensIf.js";
6
7
  import { MISSING_VALUE } from "./unknown.js";
7
8
  const renderCommonnessRatedAdvantageOrDisadvantageName = (translateMap, getInstanceById, entity, item) => {
8
- const instance = getInstanceById(entity, item.id);
9
- const instanceTranslation = translateMap(instance?.translations);
10
9
  const customTranslation = translateMap(item.translations);
11
- const name = instanceTranslation?.name_in_library ?? instanceTranslation?.name;
12
- return name !== undefined &&
13
- customTranslation?.options !== undefined &&
14
- name.endsWith(")")
15
- ? `${name.slice(0, -1)}; ${customTranslation.options})`
16
- : (name ?? MISSING_VALUE) + parensIf(customTranslation?.options);
10
+ return (attributedCustomName(translateMap, getInstanceById, entity, translation => {
11
+ const name = translation.name_in_library ?? translation.name;
12
+ return customTranslation?.options !== undefined && name.endsWith(")")
13
+ ? `${name.slice(0, -1)}; ${customTranslation.options})`
14
+ : name + parensIf(customTranslation?.options);
15
+ }, entity, item.id) ?? MISSING_VALUE);
17
16
  };
18
17
  /**
19
18
  * Render the names of either commonness-rated advantages or commonness-rated disadvantages.
@@ -27,10 +26,7 @@ export const renderCommonnessRatedAdvantagesOrDisadvantages = (entity, items) =>
27
26
  /**
28
27
  * Render the names of commonness-rated advantages and disadvantages together.
29
28
  */
30
- export const renderCommonnessRatedAdvantagesAndDisadvantages = (advantages, disadvantages) => Reader.asks(({ translate, translateMap, getInstanceById, localeCompare }) => mapNullable(ensureNonEmpty([
31
- ["Advantage", advantages],
32
- ["Disadvantage", disadvantages],
33
- ].flatMap(([entity, items]) => items?.map(item => renderCommonnessRatedAdvantageOrDisadvantageName(translateMap, getInstanceById, entity, item)) ?? [])), renderedItems => renderedItems.toSorted(localeCompare).join(", ")) ?? translate("none"));
29
+ export const renderCommonnessRatedAdvantagesAndDisadvantages = (advantages, disadvantages) => Reader.asks(({ translate, translateMap, getInstanceById, localeCompare }) => mapNullable(ensureNonEmpty([["Advantage", advantages], ["Disadvantage", disadvantages]].flatMap(([entity, items]) => items?.map(item => renderCommonnessRatedAdvantageOrDisadvantageName(translateMap, getInstanceById, entity, item)) ?? [])), renderedItems => renderedItems.toSorted(localeCompare).join(", ")) ?? translate("none"));
34
30
  /**
35
31
  * Render a value with a possible translation, falling back to explicity rendering the value if no translation is available.
36
32
  */
@@ -1,6 +1,7 @@
1
1
  import { isNotEmpty } from "@elyukai/utils/array/nonEmpty";
2
2
  import { assertExhaustive } from "@elyukai/utils/typeSafety";
3
3
  import { renderDice } from "./dice.js";
4
+ import { attributedName } from "./markdown.js";
4
5
  import { parensIf } from "./rated/activatable/parensIf.js";
5
6
  import { MISSING_VALUE } from "./unknown.js";
6
7
  /**
@@ -24,16 +25,12 @@ export const renderLaboratoryLevel = (translate, level) => {
24
25
  export const renderResistance = (translate, translateMap, getInstanceById, idMap, resistance) => {
25
26
  switch (resistance.kind) {
26
27
  case "Spirit":
27
- return (translateMap(getInstanceById("DerivedCharacteristic", idMap.DerivedCharacteristic.Spirit)
28
- ?.translations)?.name ?? MISSING_VALUE);
28
+ return (attributedName(translateMap, getInstanceById, "resistance", "DerivedCharacteristic", idMap.DerivedCharacteristic.Spirit) ?? MISSING_VALUE);
29
29
  case "Toughness":
30
- return (translateMap(getInstanceById("DerivedCharacteristic", idMap.DerivedCharacteristic.Toughness)
31
- ?.translations)?.name ?? MISSING_VALUE);
30
+ return (attributedName(translateMap, getInstanceById, "resistance", "DerivedCharacteristic", idMap.DerivedCharacteristic.Toughness) ?? MISSING_VALUE);
32
31
  case "LowerOfSpiritAndToughness": {
33
- const spiritTranslation = translateMap(getInstanceById("DerivedCharacteristic", idMap.DerivedCharacteristic.Spirit)
34
- ?.translations)?.name ?? MISSING_VALUE;
35
- const toughnessTranslation = translateMap(getInstanceById("DerivedCharacteristic", idMap.DerivedCharacteristic.Toughness)
36
- ?.translations)?.name ?? MISSING_VALUE;
32
+ const spiritTranslation = attributedName(translateMap, getInstanceById, "resistance", "DerivedCharacteristic", idMap.DerivedCharacteristic.Spirit) ?? MISSING_VALUE;
33
+ const toughnessTranslation = attributedName(translateMap, getInstanceById, "resistance", "DerivedCharacteristic", idMap.DerivedCharacteristic.Toughness) ?? MISSING_VALUE;
37
34
  return translate("{$first} or {$second}, depending on which value is lower", {
38
35
  first: spiritTranslation,
39
36
  second: toughnessTranslation,