@alpaca-software/40kdc-data 0.1.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.
Files changed (155) hide show
  1. package/README.md +78 -0
  2. package/dist/bundle-schemas.d.ts +3 -0
  3. package/dist/bundle-schemas.js +137 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +31 -0
  6. package/dist/codegen-data.d.ts +1 -0
  7. package/dist/codegen-data.js +128 -0
  8. package/dist/commands/translate.d.ts +7 -0
  9. package/dist/commands/translate.js +238 -0
  10. package/dist/commands/validate-all.d.ts +3 -0
  11. package/dist/commands/validate-all.js +20 -0
  12. package/dist/commands/validate-core.d.ts +3 -0
  13. package/dist/commands/validate-core.js +12 -0
  14. package/dist/commands/validate-enrichment.d.ts +3 -0
  15. package/dist/commands/validate-enrichment.js +12 -0
  16. package/dist/convert-faction.d.ts +45 -0
  17. package/dist/convert-faction.js +479 -0
  18. package/dist/converters/configs/adepta-sororitas.d.ts +3 -0
  19. package/dist/converters/configs/adepta-sororitas.js +70 -0
  20. package/dist/converters/configs/adeptus-astartes.d.ts +3 -0
  21. package/dist/converters/configs/adeptus-astartes.js +74 -0
  22. package/dist/converters/configs/adeptus-custodes.d.ts +3 -0
  23. package/dist/converters/configs/adeptus-custodes.js +14 -0
  24. package/dist/converters/configs/adeptus-mechanicus.d.ts +3 -0
  25. package/dist/converters/configs/adeptus-mechanicus.js +51 -0
  26. package/dist/converters/configs/aeldari.d.ts +3 -0
  27. package/dist/converters/configs/aeldari.js +79 -0
  28. package/dist/converters/configs/agents-of-the-imperium.d.ts +3 -0
  29. package/dist/converters/configs/agents-of-the-imperium.js +57 -0
  30. package/dist/converters/configs/astra-militarum.d.ts +3 -0
  31. package/dist/converters/configs/astra-militarum.js +80 -0
  32. package/dist/converters/configs/black-templars.d.ts +3 -0
  33. package/dist/converters/configs/black-templars.js +16 -0
  34. package/dist/converters/configs/blood-angels.d.ts +3 -0
  35. package/dist/converters/configs/blood-angels.js +16 -0
  36. package/dist/converters/configs/chaos-daemons.d.ts +3 -0
  37. package/dist/converters/configs/chaos-daemons.js +40 -0
  38. package/dist/converters/configs/chaos-knights.d.ts +3 -0
  39. package/dist/converters/configs/chaos-knights.js +14 -0
  40. package/dist/converters/configs/chaos-space-marines.d.ts +3 -0
  41. package/dist/converters/configs/chaos-space-marines.js +95 -0
  42. package/dist/converters/configs/crimson-fists.d.ts +3 -0
  43. package/dist/converters/configs/crimson-fists.js +16 -0
  44. package/dist/converters/configs/dark-angels.d.ts +3 -0
  45. package/dist/converters/configs/dark-angels.js +16 -0
  46. package/dist/converters/configs/death-guard.d.ts +3 -0
  47. package/dist/converters/configs/death-guard.js +30 -0
  48. package/dist/converters/configs/deathwatch.d.ts +3 -0
  49. package/dist/converters/configs/deathwatch.js +16 -0
  50. package/dist/converters/configs/drukhari.d.ts +3 -0
  51. package/dist/converters/configs/drukhari.js +51 -0
  52. package/dist/converters/configs/emperors-children.d.ts +3 -0
  53. package/dist/converters/configs/emperors-children.js +38 -0
  54. package/dist/converters/configs/genestealer-cults.d.ts +3 -0
  55. package/dist/converters/configs/genestealer-cults.js +36 -0
  56. package/dist/converters/configs/grey-knights.d.ts +3 -0
  57. package/dist/converters/configs/grey-knights.js +39 -0
  58. package/dist/converters/configs/imperial-fists.d.ts +3 -0
  59. package/dist/converters/configs/imperial-fists.js +16 -0
  60. package/dist/converters/configs/imperial-knights.d.ts +3 -0
  61. package/dist/converters/configs/imperial-knights.js +14 -0
  62. package/dist/converters/configs/iron-hands.d.ts +3 -0
  63. package/dist/converters/configs/iron-hands.js +16 -0
  64. package/dist/converters/configs/leagues-of-votann.d.ts +3 -0
  65. package/dist/converters/configs/leagues-of-votann.js +32 -0
  66. package/dist/converters/configs/necrons.d.ts +3 -0
  67. package/dist/converters/configs/necrons.js +19 -0
  68. package/dist/converters/configs/orks.d.ts +3 -0
  69. package/dist/converters/configs/orks.js +71 -0
  70. package/dist/converters/configs/raven-guard.d.ts +3 -0
  71. package/dist/converters/configs/raven-guard.js +16 -0
  72. package/dist/converters/configs/salamanders.d.ts +3 -0
  73. package/dist/converters/configs/salamanders.js +16 -0
  74. package/dist/converters/configs/space-wolves.d.ts +3 -0
  75. package/dist/converters/configs/space-wolves.js +16 -0
  76. package/dist/converters/configs/tau-empire.d.ts +3 -0
  77. package/dist/converters/configs/tau-empire.js +44 -0
  78. package/dist/converters/configs/thousand-sons.d.ts +3 -0
  79. package/dist/converters/configs/thousand-sons.js +30 -0
  80. package/dist/converters/configs/tyranids.d.ts +3 -0
  81. package/dist/converters/configs/tyranids.js +27 -0
  82. package/dist/converters/configs/ultramarines.d.ts +3 -0
  83. package/dist/converters/configs/ultramarines.js +16 -0
  84. package/dist/converters/configs/white-scars.d.ts +3 -0
  85. package/dist/converters/configs/white-scars.js +16 -0
  86. package/dist/converters/configs/world-eaters.d.ts +3 -0
  87. package/dist/converters/configs/world-eaters.js +43 -0
  88. package/dist/converters/faction-config.d.ts +53 -0
  89. package/dist/converters/faction-config.js +22 -0
  90. package/dist/converters/id-generator.d.ts +14 -0
  91. package/dist/converters/id-generator.js +65 -0
  92. package/dist/converters/keyword-filter.d.ts +26 -0
  93. package/dist/converters/keyword-filter.js +78 -0
  94. package/dist/converters/stat-parser.d.ts +22 -0
  95. package/dist/converters/stat-parser.js +84 -0
  96. package/dist/converters/view-selector.d.ts +54 -0
  97. package/dist/converters/view-selector.js +96 -0
  98. package/dist/converters/weapon-dedup.d.ts +60 -0
  99. package/dist/converters/weapon-dedup.js +120 -0
  100. package/dist/data/bundle.generated.d.ts +3 -0
  101. package/dist/data/bundle.generated.js +3 -0
  102. package/dist/data/collection.d.ts +64 -0
  103. package/dist/data/collection.js +118 -0
  104. package/dist/data/dataset.d.ts +50 -0
  105. package/dist/data/dataset.js +134 -0
  106. package/dist/data/entities.d.ts +80 -0
  107. package/dist/data/entities.js +133 -0
  108. package/dist/data/index.d.ts +59 -0
  109. package/dist/data/index.js +57 -0
  110. package/dist/data/normalize.d.ts +29 -0
  111. package/dist/data/normalize.js +37 -0
  112. package/dist/data/types.d.ts +43 -0
  113. package/dist/data/types.js +25 -0
  114. package/dist/generated.d.ts +1084 -0
  115. package/dist/generated.js +2 -0
  116. package/dist/index.d.ts +3 -0
  117. package/dist/index.js +7 -0
  118. package/dist/known-support-10e.d.ts +31 -0
  119. package/dist/known-support-10e.js +113 -0
  120. package/dist/port-10e-faction.d.ts +52 -0
  121. package/dist/port-10e-faction.js +413 -0
  122. package/dist/report.d.ts +3 -0
  123. package/dist/report.js +31 -0
  124. package/dist/schema-loader.d.ts +15 -0
  125. package/dist/schema-loader.js +79 -0
  126. package/dist/validate.d.ts +21 -0
  127. package/dist/validate.js +124 -0
  128. package/package.json +77 -0
  129. package/schemas/$defs/common.schema.json +86 -0
  130. package/schemas/$defs/game-version-ref.schema.json +11 -0
  131. package/schemas/core/deployment-pattern.schema.json +102 -0
  132. package/schemas/core/detachment.schema.json +56 -0
  133. package/schemas/core/enhancement.schema.json +46 -0
  134. package/schemas/core/faction.schema.json +29 -0
  135. package/schemas/core/force-disposition.schema.json +22 -0
  136. package/schemas/core/game-version.schema.json +20 -0
  137. package/schemas/core/leader-attachment.schema.json +18 -0
  138. package/schemas/core/mission-matchup.schema.json +25 -0
  139. package/schemas/core/mission.schema.json +42 -0
  140. package/schemas/core/roster.schema.json +203 -0
  141. package/schemas/core/secondary-card.schema.json +195 -0
  142. package/schemas/core/stratagem.schema.json +58 -0
  143. package/schemas/core/terrain-layout.schema.json +135 -0
  144. package/schemas/core/unit-composition.schema.json +38 -0
  145. package/schemas/core/unit.schema.json +125 -0
  146. package/schemas/core/wargear-option.schema.json +47 -0
  147. package/schemas/core/weapon.schema.json +56 -0
  148. package/schemas/enrichment/ability-dsl/ability.schema.json +60 -0
  149. package/schemas/enrichment/ability-dsl/condition.schema.json +48 -0
  150. package/schemas/enrichment/ability-dsl/effect.schema.json +145 -0
  151. package/schemas/enrichment/ability-dsl/scope.schema.json +12 -0
  152. package/schemas/enrichment/interaction-flag.schema.json +17 -0
  153. package/schemas/enrichment/phase-mapping.schema.json +14 -0
  154. package/schemas/enrichment/resource-pool.schema.json +36 -0
  155. package/schemas/enrichment/timing-flag.schema.json +28 -0
@@ -0,0 +1,30 @@
1
+ import { registerFaction } from "../faction-config.js";
2
+ const thousandSons = {
3
+ sourceFactionId: "TS",
4
+ factionId: "thousand-sons",
5
+ factionName: "Thousand Sons",
6
+ factionAbilityName: "Cabal of Sorcerers",
7
+ factionRuleId: "cabal-of-sorcerers",
8
+ factionKeywords: ["Chaos", "Tzeentch", "Thousand Sons"],
9
+ parentFactionId: null,
10
+ aliases: [],
11
+ compositionOverrides: {
12
+ "rubric-marines": [
13
+ { name: "Aspiring Sorcerer", min: 1, max: 1, default_weapon_ids: ["inferno-bolt-pistol", "force-weapon"], is_leader_model: true },
14
+ { name: "Rubric Marine", min: 4, max: 9, default_weapon_ids: ["inferno-boltgun", "close-combat-weapon"], is_leader_model: false },
15
+ ],
16
+ "scarab-occult-terminators": [
17
+ { name: "Scarab Occult Sorcerer", min: 1, max: 1, default_weapon_ids: ["inferno-combi-bolter", "force-weapon"], is_leader_model: true },
18
+ { name: "Scarab Occult Terminator", min: 4, max: 9, default_weapon_ids: ["inferno-combi-bolter", "prosperine-khopesh"], is_leader_model: false },
19
+ ],
20
+ "tzaangors": [
21
+ { name: "Twistbray", profile_name: "Tzaangor", min: 1, max: 1, default_weapon_ids: ["autopistol", "tzaangor-blades"], is_leader_model: true },
22
+ { name: "Tzaangor", min: 9, max: 19, default_weapon_ids: ["autopistol", "tzaangor-blades"], is_leader_model: false },
23
+ ],
24
+ "chaos-spawn": [
25
+ { name: "Chaos Spawn", min: 2, max: 2, default_weapon_ids: ["hideous-mutations"], is_leader_model: false },
26
+ ],
27
+ },
28
+ };
29
+ registerFaction(thousandSons);
30
+ export default thousandSons;
@@ -0,0 +1,3 @@
1
+ import { type FactionConfig } from "../faction-config.js";
2
+ declare const tyranids: FactionConfig;
3
+ export default tyranids;
@@ -0,0 +1,27 @@
1
+ import { registerFaction } from "../faction-config.js";
2
+ const tyranids = {
3
+ sourceFactionId: "TYR",
4
+ factionId: "tyranids",
5
+ factionName: "Tyranids",
6
+ factionAbilityName: "Shadow in the Warp",
7
+ factionRuleId: "shadow-in-the-warp",
8
+ factionKeywords: ["Tyranids"],
9
+ parentFactionId: null,
10
+ aliases: [],
11
+ compositionOverrides: {
12
+ "hyperadapted-raveners": [
13
+ { name: "Ravener Prime", min: 1, max: 1, default_weapon_ids: ["ravener-claws-and-talons"], is_leader_model: true },
14
+ { name: "Ravener", profile_name: "Raveners", min: 4, max: 4, default_weapon_ids: ["ravener-claws-and-talons"], is_leader_model: false },
15
+ ],
16
+ "tyranid-warriors-with-melee-bio-weapons": [
17
+ { name: "Tyranid Prime", profile_name: "Tyranid Warrior", min: 1, max: 1, default_weapon_ids: ["prime-talons"], is_leader_model: true },
18
+ { name: "Tyranid Warrior", min: 2, max: 5, default_weapon_ids: ["dual-boneswords"], is_leader_model: false },
19
+ ],
20
+ "tyranid-warriors-with-ranged-bio-weapons": [
21
+ { name: "Tyranid Prime", profile_name: "Tyranid Warrior", min: 1, max: 1, default_weapon_ids: ["devourer", "scything-talons"], is_leader_model: true },
22
+ { name: "Tyranid Warrior", min: 2, max: 5, default_weapon_ids: ["devourer", "scything-talons"], is_leader_model: false },
23
+ ],
24
+ },
25
+ };
26
+ registerFaction(tyranids);
27
+ export default tyranids;
@@ -0,0 +1,3 @@
1
+ import { type FactionConfig } from "../faction-config.js";
2
+ declare const ultramarines: FactionConfig;
3
+ export default ultramarines;
@@ -0,0 +1,16 @@
1
+ import { registerFaction } from "../faction-config.js";
2
+ const ultramarines = {
3
+ sourceFactionId: "SM",
4
+ factionId: "ultramarines",
5
+ factionName: "Ultramarines",
6
+ factionAbilityName: "Oath of Moment",
7
+ factionRuleId: "oath-of-moment",
8
+ factionKeywords: ["Imperium", "Adeptus Astartes", "Ultramarines"],
9
+ parentFactionId: "adeptus-astartes",
10
+ aliases: [],
11
+ compositionOverrides: {},
12
+ skipUnits: true,
13
+ detachmentFilter: ["Blade of Ultramar"],
14
+ };
15
+ registerFaction(ultramarines);
16
+ export default ultramarines;
@@ -0,0 +1,3 @@
1
+ import { type FactionConfig } from "../faction-config.js";
2
+ declare const whiteScars: FactionConfig;
3
+ export default whiteScars;
@@ -0,0 +1,16 @@
1
+ import { registerFaction } from "../faction-config.js";
2
+ const whiteScars = {
3
+ sourceFactionId: "SM",
4
+ factionId: "white-scars",
5
+ factionName: "White Scars",
6
+ factionAbilityName: "Oath of Moment",
7
+ factionRuleId: "oath-of-moment",
8
+ factionKeywords: ["Imperium", "Adeptus Astartes", "White Scars"],
9
+ parentFactionId: "adeptus-astartes",
10
+ aliases: [],
11
+ compositionOverrides: {},
12
+ skipUnits: true,
13
+ detachmentFilter: ["Reclamation Force"],
14
+ };
15
+ registerFaction(whiteScars);
16
+ export default whiteScars;
@@ -0,0 +1,3 @@
1
+ import { type FactionConfig } from "../faction-config.js";
2
+ declare const worldEaters: FactionConfig;
3
+ export default worldEaters;
@@ -0,0 +1,43 @@
1
+ import { registerFaction } from "../faction-config.js";
2
+ const worldEaters = {
3
+ sourceFactionId: "WE",
4
+ factionId: "world-eaters",
5
+ factionName: "World Eaters",
6
+ factionAbilityName: "Blessings of Khorne",
7
+ factionRuleId: "blessings-of-khorne",
8
+ factionKeywords: ["Chaos", "Khorne", "World Eaters"],
9
+ parentFactionId: null,
10
+ aliases: [],
11
+ compositionOverrides: {
12
+ "khorne-berzerkers": [
13
+ { name: "Berzerker Champion", profile_name: "Khorne Berzerker", min: 1, max: 1, default_weapon_ids: ["bolt-pistol", "chainblade"], is_leader_model: true },
14
+ { name: "Khorne Berzerker", min: 9, max: 19, default_weapon_ids: ["bolt-pistol", "chainblade"], is_leader_model: false },
15
+ ],
16
+ "jakhals": [
17
+ { name: "Jakhal Pack Leader", profile_name: "Jakhal", min: 1, max: 1, default_weapon_ids: ["autopistol", "jakhal-chainblades"], is_leader_model: true },
18
+ { name: "Dishonoured", profile_name: "Jakhal", min: 1, max: 2, default_weapon_ids: ["mauler-chainblade"], is_leader_model: false },
19
+ { name: "Jakhal", min: 8, max: 17, default_weapon_ids: ["autopistol", "jakhal-chainblades"], is_leader_model: false },
20
+ ],
21
+ "eightbound": [
22
+ { name: "Eightbound Champion", profile_name: "Eightbound", min: 1, max: 1, default_weapon_ids: ["chainblades"], is_leader_model: true },
23
+ { name: "Eightbound", min: 2, max: 5, default_weapon_ids: ["chainblades"], is_leader_model: false },
24
+ ],
25
+ "exalted-eightbound": [
26
+ { name: "Exalted Eightbound Champion", profile_name: "Exalted Eightbound", min: 1, max: 1, default_weapon_ids: ["chainblades"], is_leader_model: true },
27
+ { name: "Exalted Eightbound", min: 2, max: 5, default_weapon_ids: ["chainblades"], is_leader_model: false },
28
+ ],
29
+ "chaos-terminators": [
30
+ { name: "Terminator Champion", profile_name: "World Eaters Terminator", min: 1, max: 1, default_weapon_ids: ["combi-bolter", "accursed-weapon"], is_leader_model: true },
31
+ { name: "Chaos Terminator", profile_name: "World Eaters Terminator", min: 4, max: 4, default_weapon_ids: ["combi-bolter", "accursed-weapon"], is_leader_model: false },
32
+ ],
33
+ "goremongers": [
34
+ { name: "Goremonger Pack Leader", profile_name: "Goremongers", min: 1, max: 1, default_weapon_ids: ["autopistol", "chainblade", "close-combat-weapon"], is_leader_model: true },
35
+ { name: "Goremonger", profile_name: "Goremongers", min: 7, max: 7, default_weapon_ids: ["autopistol", "chainblade", "close-combat-weapon"], is_leader_model: false },
36
+ ],
37
+ "chaos-spawn": [
38
+ { name: "Chaos Spawn", min: 2, max: 2, default_weapon_ids: ["hideous-mutations"], is_leader_model: false },
39
+ ],
40
+ },
41
+ };
42
+ registerFaction(worldEaters);
43
+ export default worldEaters;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Faction-specific configuration for the generic converter.
3
+ *
4
+ * Each faction provides a config that parameterizes the conversion pipeline.
5
+ * Configs are stored in ./configs/ and imported by convert-faction.ts.
6
+ */
7
+ export interface ModelEntry {
8
+ name: string;
9
+ profile_name?: string | null;
10
+ min: number;
11
+ max: number;
12
+ default_weapon_ids?: string[];
13
+ is_leader_model: boolean;
14
+ }
15
+ export interface FactionConfig {
16
+ /** Source faction ID in army-assist data (e.g., "WE", "EC"). */
17
+ sourceFactionId: string;
18
+ /** 40kdc entity ID (e.g., "world-eaters", "emperors-children"). */
19
+ factionId: string;
20
+ /** Display name (e.g., "World Eaters", "Emperor's Children"). */
21
+ factionName: string;
22
+ /** Faction ability name used for view selection on shared units. */
23
+ factionAbilityName: string;
24
+ /** 40kdc ID for the faction rule ability (e.g., "blessings-of-khorne"). */
25
+ factionRuleId: string;
26
+ /** Top-level faction keywords (e.g., ["Chaos", "Khorne", "World Eaters"]). */
27
+ factionKeywords: string[];
28
+ /** Parent faction ID if this is a subfaction, null otherwise. */
29
+ parentFactionId: string | null;
30
+ /** Aliases for the faction name. */
31
+ aliases: string[];
32
+ /**
33
+ * Manual composition overrides for multi-model units.
34
+ * Keyed by 40kdc unit ID (e.g., "khorne-berzerkers").
35
+ * Single-model units (vehicles, characters) get auto-generated compositions.
36
+ */
37
+ compositionOverrides: Record<string, ModelEntry[]>;
38
+ /**
39
+ * For subfactions that share a source faction (e.g., SM chapters):
40
+ * only include detachments whose names match this list.
41
+ * Stratagems and enhancements are filtered to matching detachments.
42
+ * If undefined, all detachments are included.
43
+ */
44
+ detachmentFilter?: string[];
45
+ /**
46
+ * If true, skip unit/weapon/leader-attachment/unit-composition generation.
47
+ * Used by subfactions whose units are inherited from the parent faction.
48
+ */
49
+ skipUnits?: boolean;
50
+ }
51
+ export declare function registerFaction(config: FactionConfig): void;
52
+ export declare function getFactionConfig(factionId: string): FactionConfig;
53
+ export declare function listFactions(): string[];
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Faction-specific configuration for the generic converter.
3
+ *
4
+ * Each faction provides a config that parameterizes the conversion pipeline.
5
+ * Configs are stored in ./configs/ and imported by convert-faction.ts.
6
+ */
7
+ /** Registry of all known faction configs, keyed by 40kdc faction ID. */
8
+ const registry = new Map();
9
+ export function registerFaction(config) {
10
+ registry.set(config.factionId, config);
11
+ }
12
+ export function getFactionConfig(factionId) {
13
+ const config = registry.get(factionId);
14
+ if (!config) {
15
+ const available = [...registry.keys()].join(", ");
16
+ throw new Error(`Unknown faction "${factionId}". Available: ${available}`);
17
+ }
18
+ return config;
19
+ }
20
+ export function listFactions() {
21
+ return [...registry.keys()].sort();
22
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Generates kebab-case entity IDs from display names.
3
+ */
4
+ /** Convert a display name to a kebab-case entity ID. */
5
+ export declare function nameToId(name: string): string;
6
+ /** Convert a stratagem type string to the schema enum value and detachment ID. */
7
+ export declare function parseStratagemType(typeStr: string): {
8
+ type: string;
9
+ detachmentName: string;
10
+ };
11
+ /** Convert a player turn string to schema enum. */
12
+ export declare function parsePlayerTurn(turn: string): string;
13
+ /** Map source phase names to schema phase enum values. Filters out invalid phases. */
14
+ export declare function mapPhases(phases: string[]): string[];
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Generates kebab-case entity IDs from display names.
3
+ */
4
+ const ENTITY_ID_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
5
+ /** Convert a display name to a kebab-case entity ID. */
6
+ export function nameToId(name) {
7
+ const id = name
8
+ .normalize("NFD")
9
+ .replace(/[\u0300-\u036f]/g, "") // strip diacritics
10
+ .replace(/[''\u2019]/g, "") // strip apostrophes/right-quotes
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, "-") // non-alphanumeric → hyphens
13
+ .replace(/^-+|-+$/g, ""); // trim leading/trailing hyphens
14
+ if (!ENTITY_ID_PATTERN.test(id)) {
15
+ throw new Error(`Generated ID "${id}" from name "${name}" does not match entity-id pattern`);
16
+ }
17
+ return id;
18
+ }
19
+ /** Convert a stratagem type string to the schema enum value and detachment ID. */
20
+ export function parseStratagemType(typeStr) {
21
+ // Format: "Berzerker Warband - Battle Tactic Stratagem"
22
+ // Require spaces around the separator to avoid splitting on internal hyphens
23
+ // (e.g., "Spearhead-at-Arms" has hyphens but the delimiter is " - ")
24
+ const match = typeStr.match(/^(.+)\s+-\s+(.+?)\s*Stratagem$/i);
25
+ if (!match) {
26
+ throw new Error(`Cannot parse stratagem type: "${typeStr}"`);
27
+ }
28
+ const detachmentName = match[1].trim();
29
+ const rawType = match[2].trim().toLowerCase();
30
+ const typeMap = {
31
+ "battle tactic": "battle-tactic",
32
+ "strategic ploy": "strategic-ploy",
33
+ "epic deed": "epic-deed",
34
+ wargear: "wargear",
35
+ };
36
+ const type = typeMap[rawType];
37
+ if (!type) {
38
+ throw new Error(`Unknown stratagem type: "${rawType}" from "${typeStr}"`);
39
+ }
40
+ return { type, detachmentName };
41
+ }
42
+ /** Convert a player turn string to schema enum. */
43
+ export function parsePlayerTurn(turn) {
44
+ const lower = turn.toLowerCase().trim();
45
+ if (lower.includes("either"))
46
+ return "either";
47
+ if (lower.includes("your"))
48
+ return "your-turn";
49
+ if (lower.includes("opponent"))
50
+ return "opponent-turn";
51
+ throw new Error(`Cannot parse player turn: "${turn}"`);
52
+ }
53
+ /** Map source phase names to schema phase enum values. Filters out invalid phases. */
54
+ export function mapPhases(phases) {
55
+ const phaseMap = {
56
+ command: "command",
57
+ movement: "movement",
58
+ shooting: "shooting",
59
+ charge: "charge",
60
+ fight: "fight",
61
+ };
62
+ return phases
63
+ .map((p) => phaseMap[p.toLowerCase()])
64
+ .filter((p) => p !== undefined);
65
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Filters keywords for shared units to return only the target faction's view.
3
+ *
4
+ * Source data stores all views' keywords in a flat list, grouped by faction
5
+ * keyword markers (is_faction_keyword === "true"). Each group starts with one
6
+ * or more faction keyword entries, followed by regular keywords for that view.
7
+ */
8
+ export interface SourceKeyword {
9
+ datasheet_id: string;
10
+ keyword: string;
11
+ model: string;
12
+ is_faction_keyword: string;
13
+ }
14
+ interface KeywordResult {
15
+ factionKeywords: string[];
16
+ regularKeywords: string[];
17
+ }
18
+ /**
19
+ * Get keywords for a specific faction from a shared unit's keyword list.
20
+ * Finds the group whose faction keywords include `factionName` and returns
21
+ * deduplicated faction + regular keywords for that view.
22
+ *
23
+ * For single-view units (one group), returns all keywords as-is.
24
+ */
25
+ export declare function getKeywordsForFaction(keywords: SourceKeyword[], factionName: string): KeywordResult;
26
+ export {};
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Filters keywords for shared units to return only the target faction's view.
3
+ *
4
+ * Source data stores all views' keywords in a flat list, grouped by faction
5
+ * keyword markers (is_faction_keyword === "true"). Each group starts with one
6
+ * or more faction keyword entries, followed by regular keywords for that view.
7
+ */
8
+ /**
9
+ * Split keywords into view groups delimited by faction keyword entries.
10
+ * Each group starts when a faction keyword is encountered after regular keywords
11
+ * (or at the start).
12
+ */
13
+ function splitKeywordGroups(keywords) {
14
+ const groups = [];
15
+ let current = null;
16
+ for (const kw of keywords) {
17
+ if (kw.is_faction_keyword === "true") {
18
+ if (current === null || current.regularKeywords.length > 0) {
19
+ // Start a new group when we see a faction keyword after regular ones
20
+ // (or at the very start)
21
+ if (current !== null)
22
+ groups.push(current);
23
+ current = { factionKeywords: [], regularKeywords: [] };
24
+ }
25
+ current.factionKeywords.push(kw.keyword);
26
+ }
27
+ else {
28
+ if (current === null) {
29
+ current = { factionKeywords: [], regularKeywords: [] };
30
+ }
31
+ current.regularKeywords.push(kw.keyword);
32
+ }
33
+ }
34
+ if (current !== null)
35
+ groups.push(current);
36
+ return groups;
37
+ }
38
+ /**
39
+ * Get keywords for a specific faction from a shared unit's keyword list.
40
+ * Finds the group whose faction keywords include `factionName` and returns
41
+ * deduplicated faction + regular keywords for that view.
42
+ *
43
+ * For single-view units (one group), returns all keywords as-is.
44
+ */
45
+ export function getKeywordsForFaction(keywords, factionName) {
46
+ const groups = splitKeywordGroups(keywords);
47
+ if (groups.length <= 1) {
48
+ // Single-view unit — return everything
49
+ const group = groups[0] ?? { factionKeywords: [], regularKeywords: [] };
50
+ return {
51
+ factionKeywords: [...new Set(group.factionKeywords)],
52
+ regularKeywords: [...new Set(group.regularKeywords)],
53
+ };
54
+ }
55
+ // Multi-view unit — find the group matching the target faction
56
+ for (const group of groups) {
57
+ if (group.factionKeywords.includes(factionName)) {
58
+ return {
59
+ factionKeywords: [...new Set(group.factionKeywords)],
60
+ regularKeywords: [...new Set(group.regularKeywords)],
61
+ };
62
+ }
63
+ }
64
+ // Fallback: faction name not found in any group. Return all keywords
65
+ // deduplicated — better than throwing for edge cases.
66
+ const allFaction = new Set();
67
+ const allRegular = new Set();
68
+ for (const group of groups) {
69
+ group.factionKeywords.forEach((k) => allFaction.add(k));
70
+ group.regularKeywords.forEach((k) => allRegular.add(k));
71
+ }
72
+ console.warn(`Warning: faction "${factionName}" not found in keyword groups for ` +
73
+ `datasheet ${keywords[0]?.datasheet_id}. Returning all keywords.`);
74
+ return {
75
+ factionKeywords: [...allFaction],
76
+ regularKeywords: [...allRegular],
77
+ };
78
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Parses army-assist display-format stat strings into typed values
3
+ * for the 40kdc-data schemas.
4
+ */
5
+ /** Strip inch marks and parse to integer. "8\"" → 8, "14\"" → 14, "*" → 0 */
6
+ export declare function parseMove(s: string): number;
7
+ /** Parse target-number stat. "3+" → 3, "6+" → 6, "" → null */
8
+ export declare function parseTargetNumber(s: string): number | null;
9
+ /** Parse a stat-value field. Returns integer for fixed values, string for dice expressions. */
10
+ export declare function parseStatValue(s: string): number | string;
11
+ /** Parse weapon range. "36\"" → 36, "Melee" → "Melee", "" → "Melee", "N/A" → "Melee" */
12
+ export declare function parseRange(s: string): number | "Melee";
13
+ /** Parse BS/WS. "3+" → 3, "N/A" or "" → null */
14
+ export declare function parseBSWS(s: string): number | null;
15
+ /** Parse invulnerable save. "4+" → 4, "" → null */
16
+ export declare function parseInvuln(s: string): number | null;
17
+ /** Parse toughness, wounds, OC — always integers. */
18
+ export declare function parseIntStat(s: string): number;
19
+ /** Parse weapon keywords from the description field. "Pistol, Hazardous" → ["Pistol", "Hazardous"] */
20
+ export declare function parseWeaponKeywords(description: string): string[];
21
+ /** Parse AP value. "0" → 0, "-2" → -2 */
22
+ export declare function parseAP(s: string): number;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Parses army-assist display-format stat strings into typed values
3
+ * for the 40kdc-data schemas.
4
+ */
5
+ /** Strip inch marks and parse to integer. "8\"" → 8, "14\"" → 14, "*" → 0 */
6
+ export function parseMove(s) {
7
+ const cleaned = s.replace(/["″]/g, "").trim();
8
+ if (cleaned === "" || cleaned === "-" || cleaned === "*")
9
+ return 0;
10
+ const n = parseInt(cleaned, 10);
11
+ if (isNaN(n))
12
+ throw new Error(`Cannot parse movement: "${s}"`);
13
+ return n;
14
+ }
15
+ /** Parse target-number stat. "3+" → 3, "6+" → 6, "" → null */
16
+ export function parseTargetNumber(s) {
17
+ const cleaned = s.replace("+", "").trim();
18
+ if (cleaned === "" || cleaned === "-" || cleaned === "N/A")
19
+ return null;
20
+ const n = parseInt(cleaned, 10);
21
+ if (isNaN(n))
22
+ throw new Error(`Cannot parse target number: "${s}"`);
23
+ return n;
24
+ }
25
+ /** Parse a stat-value field. Returns integer for fixed values, string for dice expressions. */
26
+ export function parseStatValue(s) {
27
+ const cleaned = s.trim();
28
+ if (cleaned === "" || cleaned === "-")
29
+ return 0;
30
+ // Dice expressions: D6, 2D6, D6+2, D3+1, etc.
31
+ if (/^\d*[dD]\d/i.test(cleaned))
32
+ return cleaned;
33
+ const n = parseInt(cleaned, 10);
34
+ if (isNaN(n))
35
+ throw new Error(`Cannot parse stat value: "${s}"`);
36
+ return n;
37
+ }
38
+ /** Parse weapon range. "36\"" → 36, "Melee" → "Melee", "" → "Melee", "N/A" → "Melee" */
39
+ export function parseRange(s) {
40
+ const cleaned = s.replace(/["″]/g, "").trim();
41
+ if (cleaned === "" || cleaned.toLowerCase() === "melee" || cleaned === "N/A")
42
+ return "Melee";
43
+ const n = parseInt(cleaned, 10);
44
+ if (isNaN(n))
45
+ throw new Error(`Cannot parse range: "${s}"`);
46
+ return n;
47
+ }
48
+ /** Parse BS/WS. "3+" → 3, "N/A" or "" → null */
49
+ export function parseBSWS(s) {
50
+ return parseTargetNumber(s);
51
+ }
52
+ /** Parse invulnerable save. "4+" → 4, "" → null */
53
+ export function parseInvuln(s) {
54
+ return parseTargetNumber(s);
55
+ }
56
+ /** Parse toughness, wounds, OC — always integers. */
57
+ export function parseIntStat(s) {
58
+ const cleaned = s.trim();
59
+ if (cleaned === "" || cleaned === "-")
60
+ return 0;
61
+ const n = parseInt(cleaned, 10);
62
+ if (isNaN(n))
63
+ throw new Error(`Cannot parse int stat: "${s}"`);
64
+ return n;
65
+ }
66
+ /** Parse weapon keywords from the description field. "Pistol, Hazardous" → ["Pistol", "Hazardous"] */
67
+ export function parseWeaponKeywords(description) {
68
+ if (!description || description.trim() === "")
69
+ return [];
70
+ return description
71
+ .split(",")
72
+ .map((k) => k.trim())
73
+ .filter((k) => k.length > 0);
74
+ }
75
+ /** Parse AP value. "0" → 0, "-2" → -2 */
76
+ export function parseAP(s) {
77
+ const cleaned = s.trim();
78
+ if (cleaned === "" || cleaned === "-" || cleaned === "0")
79
+ return 0;
80
+ const n = parseInt(cleaned, 10);
81
+ if (isNaN(n))
82
+ throw new Error(`Cannot parse AP: "${s}"`);
83
+ return n;
84
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Handles multi-view data for shared units.
3
+ *
4
+ * Shared units (e.g., Chaos Land Raider) appear in multiple factions with the
5
+ * same UUID. Their abilities, models, and weapons tables contain repeated entry
6
+ * groups — one per faction "view". Views are delimited by the line number
7
+ * resetting to 1.
8
+ *
9
+ * For World Eaters, the correct view is identified by having
10
+ * "Blessings of Khorne" as a Faction-type ability.
11
+ */
12
+ export interface SourceAbility {
13
+ datasheet_id: string;
14
+ line: string;
15
+ ability_id: string;
16
+ model: string;
17
+ name: string;
18
+ description: string;
19
+ type: string;
20
+ parameter: string;
21
+ phases: string[];
22
+ }
23
+ interface ViewGroup<T extends {
24
+ line: string;
25
+ }> {
26
+ index: number;
27
+ entries: T[];
28
+ }
29
+ /** Split an array of line-numbered entries into view groups by line-number resets. */
30
+ export declare function splitIntoViews<T extends {
31
+ line: string;
32
+ }>(entries: T[]): ViewGroup<T>[];
33
+ /**
34
+ * Find the view index for a faction's shared unit.
35
+ * Identifies the correct view by matching the faction's primary ability name
36
+ * among the Faction-type abilities in each view.
37
+ * Returns 0 for faction-exclusive units (single view).
38
+ */
39
+ export declare function findFactionViewIndex(abilities: SourceAbility[], factionAbilityName: string): number;
40
+ /** Extract entries for a specific view index from a line-numbered array. */
41
+ export declare function getViewEntries<T extends {
42
+ line: string;
43
+ }>(entries: T[], viewIndex: number): T[];
44
+ /**
45
+ * Split points entries (no line field) into views for a shared unit.
46
+ *
47
+ * Points entries are ordered by view. For simple cases (1 entry per view),
48
+ * indexing by viewIndex works. For multi-squad-size units, views are
49
+ * delimited by the model count resetting (decreasing from prev entry).
50
+ */
51
+ export declare function getPointsForView<T extends {
52
+ models: string;
53
+ }>(entries: T[], viewIndex: number, numViews: number): T[];
54
+ export {};