@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,96 @@
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
+ /** Split an array of line-numbered entries into view groups by line-number resets. */
13
+ export function splitIntoViews(entries) {
14
+ const views = [];
15
+ let current = [];
16
+ let lastLine = Infinity;
17
+ for (const entry of entries) {
18
+ const line = parseInt(entry.line, 10);
19
+ if (line <= lastLine && current.length > 0) {
20
+ views.push({ index: views.length, entries: current });
21
+ current = [];
22
+ }
23
+ current.push(entry);
24
+ lastLine = line;
25
+ }
26
+ if (current.length > 0) {
27
+ views.push({ index: views.length, entries: current });
28
+ }
29
+ return views;
30
+ }
31
+ /**
32
+ * Find the view index for a faction's shared unit.
33
+ * Identifies the correct view by matching the faction's primary ability name
34
+ * among the Faction-type abilities in each view.
35
+ * Returns 0 for faction-exclusive units (single view).
36
+ */
37
+ export function findFactionViewIndex(abilities, factionAbilityName) {
38
+ if (abilities.length === 0)
39
+ return 0;
40
+ const views = splitIntoViews(abilities);
41
+ if (views.length === 1)
42
+ return 0;
43
+ for (const view of views) {
44
+ const hasAbility = view.entries.some((a) => a.type === "Faction" &&
45
+ a.name === factionAbilityName);
46
+ if (hasAbility)
47
+ return view.index;
48
+ }
49
+ // Some shared units (e.g., SM vehicles used by Grey Knights, or units shared
50
+ // with Agents of the Imperium) have views with no Faction-type ability.
51
+ // Use the first such view when no direct match is found.
52
+ const emptyFactionViews = views.filter((v) => !v.entries.some((a) => a.type === "Faction"));
53
+ if (emptyFactionViews.length > 0) {
54
+ return emptyFactionViews[0].index;
55
+ }
56
+ throw new Error(`No "${factionAbilityName}" faction ability found in ${views.length} views ` +
57
+ `for datasheet ${abilities[0]?.datasheet_id}`);
58
+ }
59
+ /** Extract entries for a specific view index from a line-numbered array. */
60
+ export function getViewEntries(entries, viewIndex) {
61
+ if (entries.length === 0)
62
+ return [];
63
+ const views = splitIntoViews(entries);
64
+ if (viewIndex >= views.length) {
65
+ throw new Error(`View index ${viewIndex} out of range (${views.length} views available)`);
66
+ }
67
+ return views[viewIndex].entries;
68
+ }
69
+ /**
70
+ * Split points entries (no line field) into views for a shared unit.
71
+ *
72
+ * Points entries are ordered by view. For simple cases (1 entry per view),
73
+ * indexing by viewIndex works. For multi-squad-size units, views are
74
+ * delimited by the model count resetting (decreasing from prev entry).
75
+ */
76
+ export function getPointsForView(entries, viewIndex, numViews) {
77
+ if (numViews <= 1)
78
+ return entries;
79
+ // Try splitting by model count resets (decrease signals new view)
80
+ const views = [[]];
81
+ for (let i = 0; i < entries.length; i++) {
82
+ const cur = parseInt(entries[i].models, 10);
83
+ const prev = i > 0 ? parseInt(entries[i - 1].models, 10) : 0;
84
+ if (i > 0 && cur <= prev) {
85
+ views.push([]);
86
+ }
87
+ views[views.length - 1].push(entries[i]);
88
+ }
89
+ if (viewIndex < views.length) {
90
+ return views[viewIndex];
91
+ }
92
+ // Fallback: evenly divide
93
+ const perView = Math.ceil(entries.length / numViews);
94
+ const start = viewIndex * perView;
95
+ return entries.slice(start, start + perView);
96
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Weapon profile merging and cross-unit deduplication.
3
+ *
4
+ * Source data splits multi-profile weapons into separate rows using an
5
+ * en-dash delimiter: "Plasma pistol – Standard" / "Plasma pistol – Supercharge".
6
+ * These become a single weapon entity with a profiles array.
7
+ *
8
+ * Cross-unit dedup: weapons with identical (name, type, all profile stats)
9
+ * across different units are the same entity, referenced by a shared ID.
10
+ */
11
+ export interface SourceWargear {
12
+ datasheet_id: string;
13
+ line: string;
14
+ line_in_wargear: string;
15
+ dice: string;
16
+ name: string;
17
+ description: string;
18
+ range: string;
19
+ type: string;
20
+ A: string;
21
+ BS_WS: string;
22
+ S: string;
23
+ AP: string;
24
+ D: string;
25
+ }
26
+ export interface WeaponProfile {
27
+ name: string;
28
+ range: number | "Melee";
29
+ stats: {
30
+ A: number | string;
31
+ BS?: number | null;
32
+ WS?: number | null;
33
+ S: number | string;
34
+ AP: number;
35
+ D: number | string;
36
+ };
37
+ keywords: string[];
38
+ }
39
+ export interface WeaponEntity {
40
+ id: string;
41
+ name: string;
42
+ type: "ranged" | "melee";
43
+ profiles: WeaponProfile[];
44
+ game_version: {
45
+ edition: string;
46
+ dataslate: string;
47
+ };
48
+ }
49
+ /**
50
+ * Merge wargear rows for a single unit into weapon entities,
51
+ * then deduplicate across all units.
52
+ */
53
+ export declare function buildWeaponRegistry(allUnitWargear: Map<string, SourceWargear[]>, gameVersion: {
54
+ edition: string;
55
+ dataslate: string;
56
+ }): {
57
+ weapons: WeaponEntity[];
58
+ /** Maps (datasheetId) → Set of weapon entity IDs for that unit */
59
+ unitWeaponIds: Map<string, Set<string>>;
60
+ };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Weapon profile merging and cross-unit deduplication.
3
+ *
4
+ * Source data splits multi-profile weapons into separate rows using an
5
+ * en-dash delimiter: "Plasma pistol – Standard" / "Plasma pistol – Supercharge".
6
+ * These become a single weapon entity with a profiles array.
7
+ *
8
+ * Cross-unit dedup: weapons with identical (name, type, all profile stats)
9
+ * across different units are the same entity, referenced by a shared ID.
10
+ */
11
+ import { nameToId } from "./id-generator.js";
12
+ import { parseRange, parseBSWS, parseStatValue, parseAP, parseWeaponKeywords, } from "./stat-parser.js";
13
+ // EN-DASH (U+2013) and regular hyphen used as profile separators
14
+ const PROFILE_SEPARATOR = /\s*[\u2013\u2014-]\s+/;
15
+ /** Split "Plasma pistol – Standard" into { baseName: "Plasma pistol", profileName: "Standard" } */
16
+ function splitProfileName(fullName) {
17
+ const parts = fullName.split(PROFILE_SEPARATOR);
18
+ if (parts.length >= 2) {
19
+ return {
20
+ baseName: parts[0].trim(),
21
+ profileName: parts.slice(1).join(" – ").trim(),
22
+ };
23
+ }
24
+ return { baseName: fullName.trim(), profileName: fullName.trim() };
25
+ }
26
+ /** Build a single weapon profile from a source wargear row. */
27
+ function buildProfile(row, profileName) {
28
+ const range = parseRange(row.range);
29
+ const isMelee = row.type === "Melee" || range === "Melee";
30
+ const stats = {
31
+ A: parseStatValue(row.A),
32
+ S: parseStatValue(row.S),
33
+ AP: parseAP(row.AP),
34
+ D: parseStatValue(row.D),
35
+ };
36
+ if (isMelee) {
37
+ stats.WS = parseBSWS(row.BS_WS);
38
+ }
39
+ else {
40
+ stats.BS = parseBSWS(row.BS_WS);
41
+ }
42
+ return {
43
+ name: profileName,
44
+ range,
45
+ stats,
46
+ keywords: parseWeaponKeywords(row.description),
47
+ };
48
+ }
49
+ /** Hash a weapon entity for cross-unit deduplication. */
50
+ function weaponHash(name, type, profiles) {
51
+ const profileParts = profiles
52
+ .map((p) => `${p.name}|${p.range}|${JSON.stringify(p.stats)}|${p.keywords.sort().join(",")}`)
53
+ .sort();
54
+ return `${name.toLowerCase()}|${type}|${profileParts.join(";")}`;
55
+ }
56
+ /**
57
+ * Merge wargear rows for a single unit into weapon entities,
58
+ * then deduplicate across all units.
59
+ */
60
+ export function buildWeaponRegistry(allUnitWargear, gameVersion) {
61
+ // Global dedup registry: hash → WeaponEntity
62
+ const registry = new Map();
63
+ const unitWeaponIds = new Map();
64
+ for (const [datasheetId, rows] of allUnitWargear) {
65
+ const weaponIds = new Set();
66
+ unitWeaponIds.set(datasheetId, weaponIds);
67
+ // Group rows by base weapon name for profile merging
68
+ const groups = new Map();
69
+ for (const row of rows) {
70
+ const { baseName, profileName } = splitProfileName(row.name);
71
+ const key = baseName.toLowerCase();
72
+ if (!groups.has(key)) {
73
+ groups.set(key, { baseName, rows: [], profileNames: [] });
74
+ }
75
+ const group = groups.get(key);
76
+ group.rows.push(row);
77
+ group.profileNames.push(profileName);
78
+ }
79
+ for (const [, group] of groups) {
80
+ const { baseName, rows: groupRows, profileNames } = group;
81
+ const type = groupRows[0].type === "Melee" ? "melee" : "ranged";
82
+ // Skip rows with obviously corrupt source data (shifted columns)
83
+ const validRows = groupRows.filter((row, i) => {
84
+ const s = parseStatValue(row.S);
85
+ if (typeof s === "number" && s < 0)
86
+ return false;
87
+ const d = parseStatValue(row.D);
88
+ if (type === "ranged" && d === 0 && row.D === "")
89
+ return false;
90
+ return true;
91
+ });
92
+ if (validRows.length === 0)
93
+ continue;
94
+ const validProfileNames = groupRows.map((row, i) => ({ row, name: profileNames[i] }))
95
+ .filter(({ row }) => validRows.includes(row))
96
+ .map(({ name }) => name);
97
+ // If there's only one profile and its name matches the base name, use base name as profile name
98
+ const profiles = validRows.map((row, i) => {
99
+ const pName = validRows.length === 1 ? baseName : validProfileNames[i];
100
+ return buildProfile(row, pName);
101
+ });
102
+ const hash = weaponHash(baseName, type, profiles);
103
+ if (!registry.has(hash)) {
104
+ const id = nameToId(baseName);
105
+ registry.set(hash, {
106
+ id,
107
+ name: baseName,
108
+ type: type,
109
+ profiles,
110
+ game_version: gameVersion,
111
+ });
112
+ }
113
+ weaponIds.add(registry.get(hash).id);
114
+ }
115
+ }
116
+ return {
117
+ weapons: [...registry.values()],
118
+ unitWeaponIds,
119
+ };
120
+ }
@@ -0,0 +1,3 @@
1
+ import type { RawData } from "./types.js";
2
+ /** The full 40kdc dataset, embedded at build time and parsed once at load. */
3
+ export declare const RAW_DATA: RawData;