@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,12 @@
1
+ import { createValidator } from "../schema-loader.js";
2
+ import { validateFiles } from "../validate.js";
3
+ import { formatReport } from "../report.js";
4
+ export async function validateCoreCommand(opts) {
5
+ const ajv = createValidator();
6
+ const result = await validateFiles(ajv, "core/**/*.json");
7
+ const output = formatReport(result, opts.reporter);
8
+ console.log(output);
9
+ if (result.failed > 0) {
10
+ process.exit(1);
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ export declare function validateEnrichmentCommand(opts: {
2
+ reporter: string;
3
+ }): Promise<void>;
@@ -0,0 +1,12 @@
1
+ import { createValidator } from "../schema-loader.js";
2
+ import { validateFiles } from "../validate.js";
3
+ import { formatReport } from "../report.js";
4
+ export async function validateEnrichmentCommand(opts) {
5
+ const ajv = createValidator();
6
+ const result = await validateFiles(ajv, "enrichment/**/*.json");
7
+ const output = formatReport(result, opts.reporter);
8
+ console.log(output);
9
+ if (result.failed > 0) {
10
+ process.exit(1);
11
+ }
12
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Generic faction converter: army-assist → 40kdc-data format.
3
+ *
4
+ * Usage: npx tsx tools/src/convert-faction.ts <faction-id>
5
+ * Example: npx tsx tools/src/convert-faction.ts emperors-children
6
+ *
7
+ * Faction configs are registered via side-effect imports from ./converters/configs/.
8
+ */
9
+ import { type FactionConfig } from "./converters/faction-config.js";
10
+ import "./converters/configs/world-eaters.js";
11
+ import "./converters/configs/emperors-children.js";
12
+ import "./converters/configs/chaos-knights.js";
13
+ import "./converters/configs/imperial-knights.js";
14
+ import "./converters/configs/leagues-of-votann.js";
15
+ import "./converters/configs/drukhari.js";
16
+ import "./converters/configs/genestealer-cults.js";
17
+ import "./converters/configs/grey-knights.js";
18
+ import "./converters/configs/thousand-sons.js";
19
+ import "./converters/configs/death-guard.js";
20
+ import "./converters/configs/adeptus-custodes.js";
21
+ import "./converters/configs/adepta-sororitas.js";
22
+ import "./converters/configs/agents-of-the-imperium.js";
23
+ import "./converters/configs/adeptus-mechanicus.js";
24
+ import "./converters/configs/tau-empire.js";
25
+ import "./converters/configs/tyranids.js";
26
+ import "./converters/configs/necrons.js";
27
+ import "./converters/configs/chaos-daemons.js";
28
+ import "./converters/configs/orks.js";
29
+ import "./converters/configs/aeldari.js";
30
+ import "./converters/configs/chaos-space-marines.js";
31
+ import "./converters/configs/astra-militarum.js";
32
+ import "./converters/configs/adeptus-astartes.js";
33
+ import "./converters/configs/blood-angels.js";
34
+ import "./converters/configs/dark-angels.js";
35
+ import "./converters/configs/space-wolves.js";
36
+ import "./converters/configs/black-templars.js";
37
+ import "./converters/configs/deathwatch.js";
38
+ import "./converters/configs/ultramarines.js";
39
+ import "./converters/configs/imperial-fists.js";
40
+ import "./converters/configs/crimson-fists.js";
41
+ import "./converters/configs/iron-hands.js";
42
+ import "./converters/configs/raven-guard.js";
43
+ import "./converters/configs/salamanders.js";
44
+ import "./converters/configs/white-scars.js";
45
+ export declare function convertFaction(config: FactionConfig): void;
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Generic faction converter: army-assist → 40kdc-data format.
3
+ *
4
+ * Usage: npx tsx tools/src/convert-faction.ts <faction-id>
5
+ * Example: npx tsx tools/src/convert-faction.ts emperors-children
6
+ *
7
+ * Faction configs are registered via side-effect imports from ./converters/configs/.
8
+ */
9
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
+ import { resolve, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { nameToId, parseStratagemType, parsePlayerTurn, mapPhases } from "./converters/id-generator.js";
13
+ import { parseMove, parseTargetNumber, parseIntStat, parseInvuln } from "./converters/stat-parser.js";
14
+ import { findFactionViewIndex, getViewEntries, getPointsForView, splitIntoViews } from "./converters/view-selector.js";
15
+ import { buildWeaponRegistry } from "./converters/weapon-dedup.js";
16
+ import { getKeywordsForFaction } from "./converters/keyword-filter.js";
17
+ import { getFactionConfig, listFactions } from "./converters/faction-config.js";
18
+ // Register all faction configs
19
+ import "./converters/configs/world-eaters.js";
20
+ import "./converters/configs/emperors-children.js";
21
+ import "./converters/configs/chaos-knights.js";
22
+ import "./converters/configs/imperial-knights.js";
23
+ import "./converters/configs/leagues-of-votann.js";
24
+ import "./converters/configs/drukhari.js";
25
+ import "./converters/configs/genestealer-cults.js";
26
+ import "./converters/configs/grey-knights.js";
27
+ import "./converters/configs/thousand-sons.js";
28
+ import "./converters/configs/death-guard.js";
29
+ import "./converters/configs/adeptus-custodes.js";
30
+ import "./converters/configs/adepta-sororitas.js";
31
+ import "./converters/configs/agents-of-the-imperium.js";
32
+ import "./converters/configs/adeptus-mechanicus.js";
33
+ import "./converters/configs/tau-empire.js";
34
+ import "./converters/configs/tyranids.js";
35
+ import "./converters/configs/necrons.js";
36
+ import "./converters/configs/chaos-daemons.js";
37
+ import "./converters/configs/orks.js";
38
+ import "./converters/configs/aeldari.js";
39
+ import "./converters/configs/chaos-space-marines.js";
40
+ import "./converters/configs/astra-militarum.js";
41
+ import "./converters/configs/adeptus-astartes.js";
42
+ import "./converters/configs/blood-angels.js";
43
+ import "./converters/configs/dark-angels.js";
44
+ import "./converters/configs/space-wolves.js";
45
+ import "./converters/configs/black-templars.js";
46
+ import "./converters/configs/deathwatch.js";
47
+ import "./converters/configs/ultramarines.js";
48
+ import "./converters/configs/imperial-fists.js";
49
+ import "./converters/configs/crimson-fists.js";
50
+ import "./converters/configs/iron-hands.js";
51
+ import "./converters/configs/raven-guard.js";
52
+ import "./converters/configs/salamanders.js";
53
+ import "./converters/configs/white-scars.js";
54
+ const __dirname = dirname(fileURLToPath(import.meta.url));
55
+ const ROOT = resolve(__dirname, "../..");
56
+ const SOURCE = resolve(process.env.HOME, "army-assist/src/assets/json");
57
+ const GAME_VERSION = { edition: "10th", dataslate: "2025-q3" };
58
+ // ─── Helpers ─────────────────────────────────────────────────────────
59
+ function readJSON(filename) {
60
+ return JSON.parse(readFileSync(resolve(SOURCE, filename), "utf-8"));
61
+ }
62
+ function writeOutput(relPath, data) {
63
+ const outPath = resolve(ROOT, relPath);
64
+ writeFileSync(outPath, JSON.stringify(data, null, 2) + "\n");
65
+ console.log(` ✓ ${relPath} (${Array.isArray(data) ? data.length : 1} entries)`);
66
+ }
67
+ /** Determine unit role from keywords and abilities. */
68
+ function deriveRole(keywords, abilities, name) {
69
+ const kw = new Set(keywords.map((k) => k.toLowerCase()));
70
+ if (kw.has("epic hero"))
71
+ return "epic-hero";
72
+ if (kw.has("character"))
73
+ return "character";
74
+ const hasLeader = abilities.some((a) => a.type === "Core" && a.name === "Leader");
75
+ if (hasLeader)
76
+ return "character";
77
+ if (kw.has("battleline"))
78
+ return "battleline";
79
+ if (name.toLowerCase().includes("rhino") ||
80
+ kw.has("dedicated transport"))
81
+ return "dedicated-transport";
82
+ return undefined;
83
+ }
84
+ /** Parse base size string. "32mm" → { shape: "round", diameter: 32 } */
85
+ function parseBaseSize(s) {
86
+ if (!s || s.trim() === "")
87
+ return undefined;
88
+ const round = s.match(/(\d+)\s*mm/i);
89
+ if (round)
90
+ return { shape: "round", diameter: parseInt(round[1], 10) };
91
+ const oval = s.match(/(\d+)\s*x\s*(\d+)/i);
92
+ if (oval)
93
+ return {
94
+ shape: "oval",
95
+ width: parseInt(oval[1], 10),
96
+ length: parseInt(oval[2], 10),
97
+ };
98
+ return undefined;
99
+ }
100
+ /** Parse transport capacity from transport text. */
101
+ function parseTransport(s) {
102
+ if (!s || s.trim() === "")
103
+ return undefined;
104
+ // Match "capacity of N" or "N <FACTION> INFANTRY"
105
+ const capMatch = s.match(/capacity\s*(?:of\s*)?(\d+)/i) || s.match(/(\d+)\s+\S[\S\s]*?\s+(?:infantry|model)/i);
106
+ if (!capMatch)
107
+ return undefined;
108
+ const capacity = parseInt(capMatch[1], 10);
109
+ const result = { capacity };
110
+ if (/jump pack/i.test(s) && /cannot/i.test(s)) {
111
+ result.exclusion_keywords = ["Jump Pack"];
112
+ }
113
+ return result;
114
+ }
115
+ // ─── Main conversion ─────────────────────────────────────────────────
116
+ export function convertFaction(config) {
117
+ const { sourceFactionId, factionId, factionName, factionAbilityName } = config;
118
+ console.log(`Converting ${factionName} (${sourceFactionId} → ${factionId})...`);
119
+ console.log("Loading source data from army-assist...");
120
+ const datasheets = readJSON("Datasheets.json");
121
+ const allModels = readJSON("Datasheets_models.json");
122
+ const allWargear = readJSON("Datasheets_wargear.json");
123
+ const allAbilities = readJSON("Datasheets_abilities.json");
124
+ const allKeywords = readJSON("Datasheets_keywords.json");
125
+ const allPoints = readJSON("Datasheets_points.json");
126
+ const allLeaders = readJSON("Datasheets_leader.json");
127
+ const enhancements = readJSON("Enhancements.json");
128
+ const stratagems = readJSON("Stratagems.json");
129
+ const detachmentAbilities = readJSON("Detachment_abilities.json");
130
+ // Filter to target faction, skip datasheets with no model data (metadata entries)
131
+ const modelDatasheetIds = new Set(allModels.map((m) => m.datasheet_id));
132
+ const factionDatasheets = datasheets.filter((d) => d.faction_id === sourceFactionId && modelDatasheetIds.has(d.id));
133
+ const factionIds = new Set(factionDatasheets.map((d) => d.id));
134
+ const idToName = new Map(factionDatasheets.map((d) => [d.id, d.name]));
135
+ console.log(`Found ${factionDatasheets.length} ${factionName} datasheets\n`);
136
+ // ─── Determine view indices for shared units ───
137
+ const viewIndices = new Map();
138
+ for (const ds of factionDatasheets) {
139
+ const dsAbilities = allAbilities.filter((a) => a.datasheet_id === ds.id);
140
+ viewIndices.set(ds.id, findFactionViewIndex(dsAbilities, factionAbilityName));
141
+ }
142
+ // ─── Build units ───
143
+ console.log("Converting units...");
144
+ const units = [];
145
+ const unitWargearMap = new Map();
146
+ const unitAbilityNames = new Map();
147
+ for (const ds of factionDatasheets) {
148
+ const viewIdx = viewIndices.get(ds.id);
149
+ // Models
150
+ const dsModels = allModels.filter((m) => m.datasheet_id === ds.id);
151
+ const viewModels = getViewEntries(dsModels, viewIdx);
152
+ // Abilities (for role derivation and ability name collection)
153
+ const dsAbilities = allAbilities.filter((a) => a.datasheet_id === ds.id);
154
+ const viewAbilities = getViewEntries(dsAbilities, viewIdx);
155
+ const abilityNames = viewAbilities
156
+ .filter((a) => a.type !== "Faction")
157
+ .map((a) => a.name);
158
+ unitAbilityNames.set(ds.id, [...new Set(abilityNames)]);
159
+ // Keywords — use faction-aware filtering for shared units
160
+ const dsKeywords = allKeywords.filter((k) => k.datasheet_id === ds.id);
161
+ const { factionKeywords, regularKeywords } = getKeywordsForFaction(dsKeywords, factionName);
162
+ // Points — select the correct view for shared units
163
+ const allDsPoints = allPoints.filter((p) => p.datasheet_id === ds.id);
164
+ const dsAbilityViews = splitIntoViews(allAbilities.filter((a) => a.datasheet_id === ds.id));
165
+ const numViews = dsAbilityViews.length;
166
+ const viewPoints = getPointsForView(allDsPoints, viewIdx, numViews);
167
+ const dsPoints = viewPoints
168
+ .map((p) => ({
169
+ models: parseInt(p.models, 10),
170
+ cost: parseInt(p.cost, 10),
171
+ }))
172
+ .sort((a, b) => a.models - b.models);
173
+ // Wargear for this unit's view
174
+ const dsWargear = allWargear.filter((w) => w.datasheet_id === ds.id);
175
+ const viewWargear = getViewEntries(dsWargear, viewIdx);
176
+ unitWargearMap.set(ds.id, viewWargear);
177
+ // Build stat profiles
178
+ const profiles = viewModels.map((m) => {
179
+ const profile = {
180
+ name: m.name,
181
+ M: parseMove(m.M),
182
+ T: parseIntStat(m.T),
183
+ W: parseIntStat(m.W),
184
+ Sv: parseTargetNumber(m.Sv),
185
+ invuln_sv: parseInvuln(m.inv_sv),
186
+ Ld: parseTargetNumber(m.Ld),
187
+ OC: parseIntStat(m.OC),
188
+ };
189
+ return profile;
190
+ });
191
+ const role = deriveRole(regularKeywords, viewAbilities, ds.name);
192
+ const baseSize = parseBaseSize(viewModels[0]?.base_size ?? "");
193
+ const transport = parseTransport(ds.transport);
194
+ const modelMin = dsPoints.length > 0 ? dsPoints[0].models : 1;
195
+ const modelMax = dsPoints.length > 0 ? dsPoints[dsPoints.length - 1].models : 1;
196
+ const unitId = nameToId(ds.name);
197
+ const unit = {
198
+ id: unitId,
199
+ name: ds.name,
200
+ faction_id: factionId,
201
+ ...(role ? { role } : {}),
202
+ profiles,
203
+ points: dsPoints,
204
+ keywords: regularKeywords,
205
+ faction_keywords: factionKeywords,
206
+ ...(baseSize ? { base_size_mm: baseSize } : {}),
207
+ model_count: { min: modelMin, max: modelMax },
208
+ weapon_ids: [],
209
+ ability_ids: [],
210
+ ...(transport ? { transport_capacity: transport } : {}),
211
+ game_version: GAME_VERSION,
212
+ is_legend: false,
213
+ };
214
+ units.push(unit);
215
+ }
216
+ // ─── Build weapons ───
217
+ console.log("Converting weapons...");
218
+ const { weapons, unitWeaponIds } = buildWeaponRegistry(unitWargearMap, GAME_VERSION);
219
+ // Wire weapon_ids into units
220
+ for (const unit of units) {
221
+ const dsId = factionDatasheets.find((d) => d.name === unit.name).id;
222
+ const weaponIds = unitWeaponIds.get(dsId);
223
+ if (weaponIds) {
224
+ unit.weapon_ids = [...weaponIds].sort();
225
+ }
226
+ }
227
+ // ─── Build leader attachments ───
228
+ console.log("Converting leader attachments...");
229
+ const leaderMap = new Map();
230
+ for (const l of allLeaders) {
231
+ if (factionIds.has(l.leader_id) && factionIds.has(l.attached_id)) {
232
+ const leaderId = nameToId(idToName.get(l.leader_id));
233
+ const attachedId = nameToId(idToName.get(l.attached_id));
234
+ if (!leaderMap.has(leaderId)) {
235
+ leaderMap.set(leaderId, new Set());
236
+ }
237
+ leaderMap.get(leaderId).add(attachedId);
238
+ }
239
+ }
240
+ const leaderAttachments = [...leaderMap.entries()].map(([leaderId, bodyguards]) => ({
241
+ leader_id: leaderId,
242
+ eligible_bodyguard_ids: [...bodyguards].sort(),
243
+ max_leaders_per_unit: 1,
244
+ game_version: GAME_VERSION,
245
+ }));
246
+ // ─── Build detachments ───
247
+ console.log("Converting detachments...");
248
+ const factionDetAbilities = detachmentAbilities.filter((d) => d.faction_id === sourceFactionId);
249
+ let factionEnhancements = enhancements.filter((e) => e.faction_id === sourceFactionId);
250
+ let factionStratagems = stratagems.filter((s) => s.faction_id === sourceFactionId);
251
+ // Deduplicate detachments (some have multiple ability entries per detachment)
252
+ let detachmentNames = [...new Set(factionDetAbilities.map((da) => da.detachment))];
253
+ // Apply detachment filter for subfactions
254
+ if (config.detachmentFilter) {
255
+ detachmentNames = detachmentNames.filter((d) => config.detachmentFilter.includes(d));
256
+ // Also filter enhancements and stratagems to matching detachments
257
+ const allowedDetachments = new Set(config.detachmentFilter);
258
+ factionEnhancements = factionEnhancements.filter((e) => allowedDetachments.has(e.detachment));
259
+ factionStratagems = factionStratagems.filter((s) => allowedDetachments.has(s.detachment));
260
+ }
261
+ const detachments = detachmentNames.map((detName) => {
262
+ const detId = nameToId(detName);
263
+ const detEnhIds = factionEnhancements
264
+ .filter((e) => e.detachment === detName)
265
+ .map((e) => nameToId(e.name));
266
+ const detStratIds = factionStratagems
267
+ .filter((s) => s.detachment === detName)
268
+ .map((s) => nameToId(s.name));
269
+ return {
270
+ id: detId,
271
+ name: detName,
272
+ faction_id: factionId,
273
+ detachment_rule_id: null,
274
+ enhancement_ids: detEnhIds,
275
+ stratagem_ids: detStratIds,
276
+ game_version: GAME_VERSION,
277
+ };
278
+ });
279
+ // ─── Build enhancements ───
280
+ console.log("Converting enhancements...");
281
+ const enhancementEntities = factionEnhancements.map((e) => ({
282
+ id: nameToId(e.name),
283
+ name: e.name,
284
+ detachment_id: nameToId(e.detachment),
285
+ cost: parseInt(e.cost, 10),
286
+ keyword_restrictions: [factionName],
287
+ ability_id: null,
288
+ is_unique: true,
289
+ game_version: GAME_VERSION,
290
+ }));
291
+ // ─── Build stratagems ───
292
+ console.log("Converting stratagems...");
293
+ const stratagemEntities = factionStratagems.map((s) => {
294
+ const { type } = parseStratagemType(s.type);
295
+ const phases = mapPhases(s.phases);
296
+ const playerTurn = parsePlayerTurn(s.turn);
297
+ return {
298
+ id: nameToId(s.name),
299
+ name: s.name,
300
+ category: "detachment",
301
+ type,
302
+ detachment_id: nameToId(s.detachment),
303
+ cp_cost: parseInt(s.cp_cost, 10),
304
+ phases,
305
+ player_turn: playerTurn,
306
+ timing: "once-per-phase",
307
+ target_restrictions: null,
308
+ ability_id: null,
309
+ game_version: GAME_VERSION,
310
+ };
311
+ });
312
+ // ─── Build phase mappings from source ability phases ───
313
+ console.log("Converting phase mappings...");
314
+ const phaseMappings = [];
315
+ // Unit abilities
316
+ for (const ds of factionDatasheets) {
317
+ const viewIdx = viewIndices.get(ds.id);
318
+ const dsAbilities = allAbilities.filter((a) => a.datasheet_id === ds.id);
319
+ const viewAbilities = getViewEntries(dsAbilities, viewIdx);
320
+ const seen = new Set();
321
+ for (const a of viewAbilities) {
322
+ if (a.type === "Faction")
323
+ continue;
324
+ const sourceId = nameToId(a.name);
325
+ if (seen.has(sourceId))
326
+ continue;
327
+ seen.add(sourceId);
328
+ const sourceType = a.type === "Core" ? "ability" :
329
+ a.type === "Wargear" ? "ability" :
330
+ "ability";
331
+ const phases = mapPhases(a.phases);
332
+ if (phases.length > 0) {
333
+ phaseMappings.push({
334
+ source_id: sourceId,
335
+ source_type: sourceType,
336
+ phases,
337
+ game_version: GAME_VERSION,
338
+ authored_by: "40kdc-community",
339
+ });
340
+ }
341
+ }
342
+ }
343
+ // Stratagem phase mappings
344
+ for (const s of factionStratagems) {
345
+ const phases = mapPhases(s.phases);
346
+ if (phases.length > 0) {
347
+ phaseMappings.push({
348
+ source_id: nameToId(s.name),
349
+ source_type: "stratagem",
350
+ phases,
351
+ game_version: GAME_VERSION,
352
+ authored_by: "40kdc-community",
353
+ });
354
+ }
355
+ }
356
+ // Enhancement phase mappings
357
+ for (const e of factionEnhancements) {
358
+ const phases = mapPhases(e.phases);
359
+ if (phases.length > 0) {
360
+ phaseMappings.push({
361
+ source_id: nameToId(e.name),
362
+ source_type: "enhancement",
363
+ phases,
364
+ game_version: GAME_VERSION,
365
+ authored_by: "40kdc-community",
366
+ });
367
+ }
368
+ }
369
+ // Detachment rule phase mappings
370
+ for (const da of factionDetAbilities) {
371
+ const phases = mapPhases(da.phases);
372
+ if (phases.length > 0) {
373
+ phaseMappings.push({
374
+ source_id: nameToId(da.name),
375
+ source_type: "detachment-rule",
376
+ phases,
377
+ game_version: GAME_VERSION,
378
+ authored_by: "40kdc-community",
379
+ });
380
+ }
381
+ }
382
+ // Deduplicate phase mappings
383
+ const dedupedPhaseMappings = [
384
+ ...new Map(phaseMappings.map((pm) => [
385
+ `${pm.source_id}|${pm.source_type}`,
386
+ pm,
387
+ ])).values(),
388
+ ];
389
+ // ─── Build unit compositions ───
390
+ console.log("Generating unit compositions...");
391
+ const unitCompositions = units.map((u) => {
392
+ const unitId = u.id;
393
+ const modelCount = u.model_count;
394
+ const override = config.compositionOverrides[unitId];
395
+ if (override) {
396
+ return {
397
+ unit_id: unitId,
398
+ models: override,
399
+ game_version: GAME_VERSION,
400
+ };
401
+ }
402
+ // Single-model unit (vehicles, characters, monsters)
403
+ const profileName = u.profiles[0]?.name;
404
+ return {
405
+ unit_id: unitId,
406
+ models: [
407
+ {
408
+ name: profileName || u.name,
409
+ min: modelCount.min,
410
+ max: modelCount.max,
411
+ is_leader_model: false,
412
+ },
413
+ ],
414
+ game_version: GAME_VERSION,
415
+ };
416
+ });
417
+ // ─── Generate factions.json ───
418
+ const factionEntity = [
419
+ {
420
+ id: factionId,
421
+ name: factionName,
422
+ parent_faction_id: config.parentFactionId,
423
+ game_version: GAME_VERSION,
424
+ keywords: config.factionKeywords,
425
+ aliases: config.aliases,
426
+ faction_rule_id: config.factionRuleId,
427
+ },
428
+ ];
429
+ // ─── Write output ──────────────────────────────────────────────────
430
+ const coreDir = `data/core/${factionId}`;
431
+ const enrichDir = `data/enrichment/${factionId}`;
432
+ mkdirSync(resolve(ROOT, coreDir), { recursive: true });
433
+ mkdirSync(resolve(ROOT, enrichDir), { recursive: true });
434
+ console.log("\nWriting output files...");
435
+ writeOutput(`${coreDir}/factions.json`, factionEntity);
436
+ if (!config.skipUnits) {
437
+ writeOutput(`${coreDir}/units.json`, units);
438
+ writeOutput(`${coreDir}/weapons.json`, weapons);
439
+ writeOutput(`${coreDir}/leader-attachments.json`, leaderAttachments);
440
+ writeOutput(`${coreDir}/unit-compositions.json`, unitCompositions);
441
+ }
442
+ writeOutput(`${coreDir}/detachments.json`, detachments);
443
+ writeOutput(`${coreDir}/enhancements.json`, enhancementEntities);
444
+ writeOutput(`${coreDir}/stratagems.json`, stratagemEntities);
445
+ if (!config.skipUnits) {
446
+ writeOutput(`${enrichDir}/phase-mappings.json`, dedupedPhaseMappings);
447
+ }
448
+ // ─── Summary ───
449
+ console.log(`\n── ${factionName} Summary ──`);
450
+ if (!config.skipUnits) {
451
+ console.log(` Units: ${units.length}`);
452
+ console.log(` Weapons: ${weapons.length}`);
453
+ console.log(` Leader attachments: ${leaderAttachments.length}`);
454
+ console.log(` Unit compositions: ${unitCompositions.length}`);
455
+ }
456
+ console.log(` Detachments: ${detachments.length}`);
457
+ console.log(` Enhancements: ${enhancementEntities.length}`);
458
+ console.log(` Stratagems: ${stratagemEntities.length}`);
459
+ if (!config.skipUnits) {
460
+ console.log(` Phase mappings: ${dedupedPhaseMappings.length}`);
461
+ }
462
+ console.log("\nDone. Run 'npm run validate' to check output.");
463
+ }
464
+ // ─── CLI entry point ─────────────────────────────────────────────────
465
+ // Only run CLI when this module is the entry point (not when imported)
466
+ const isMain = process.argv[1] &&
467
+ resolve(process.argv[1]).replace(/\.\w+$/, "") ===
468
+ fileURLToPath(import.meta.url).replace(/\.\w+$/, "");
469
+ if (isMain) {
470
+ const args = process.argv.slice(2);
471
+ if (args.length === 0 || args[0] === "--help") {
472
+ console.log("Usage: npx tsx tools/src/convert-faction.ts <faction-id>");
473
+ console.log(`Available factions: ${listFactions().join(", ")}`);
474
+ process.exit(args[0] === "--help" ? 0 : 1);
475
+ }
476
+ const factionIdArg = args[0];
477
+ const factionConfig = getFactionConfig(factionIdArg);
478
+ convertFaction(factionConfig);
479
+ }
@@ -0,0 +1,3 @@
1
+ import { type FactionConfig } from "../faction-config.js";
2
+ declare const adeptaSororitas: FactionConfig;
3
+ export default adeptaSororitas;
@@ -0,0 +1,70 @@
1
+ import { registerFaction } from "../faction-config.js";
2
+ const adeptaSororitas = {
3
+ sourceFactionId: "AS",
4
+ factionId: "adepta-sororitas",
5
+ factionName: "Adepta Sororitas",
6
+ factionAbilityName: "Acts of Faith",
7
+ factionRuleId: "acts-of-faith",
8
+ factionKeywords: ["Imperium", "Adepta Sororitas"],
9
+ parentFactionId: null,
10
+ aliases: [],
11
+ compositionOverrides: {
12
+ "battle-sisters-squad": [
13
+ { name: "Sister Superior", profile_name: "Battle Sister", min: 1, max: 1, default_weapon_ids: ["bolt-pistol", "power-weapon"], is_leader_model: true },
14
+ { name: "Battle Sister", min: 9, max: 9, default_weapon_ids: ["boltgun", "close-combat-weapon"], is_leader_model: false },
15
+ ],
16
+ "celestian-sacresants": [
17
+ { name: "Sacresant Superior", profile_name: "Celestian Sacresant", min: 1, max: 1, default_weapon_ids: ["bolt-pistol", "hallowed-mace"], is_leader_model: true },
18
+ { name: "Celestian Sacresant", min: 4, max: 9, default_weapon_ids: ["bolt-pistol", "hallowed-mace"], is_leader_model: false },
19
+ ],
20
+ "celestian-insidiants": [
21
+ { name: "Insidiant Superior", profile_name: "Celestian Insidiant", min: 1, max: 1, default_weapon_ids: ["condemnor-bolt-pistol", "blessed-sword"], is_leader_model: true },
22
+ { name: "Celestian Insidiant", min: 9, max: 9, default_weapon_ids: ["condemnor-bolt-pistol", "null-mace"], is_leader_model: false },
23
+ ],
24
+ "dominion-squad": [
25
+ { name: "Dominion Superior", profile_name: "Dominion", min: 1, max: 1, default_weapon_ids: ["bolt-pistol", "power-weapon"], is_leader_model: true },
26
+ { name: "Dominion", min: 9, max: 9, default_weapon_ids: ["boltgun", "close-combat-weapon"], is_leader_model: false },
27
+ ],
28
+ "seraphim-squad": [
29
+ { name: "Seraphim Superior", profile_name: "Seraphim", min: 1, max: 1, default_weapon_ids: ["bolt-pistol", "chainsword"], is_leader_model: true },
30
+ { name: "Seraphim", min: 4, max: 9, default_weapon_ids: ["bolt-pistol", "close-combat-weapon"], is_leader_model: false },
31
+ ],
32
+ "zephyrim-squad": [
33
+ { name: "Zephyrim Superior", profile_name: "Zephyrim", min: 1, max: 1, default_weapon_ids: ["bolt-pistol", "power-weapon"], is_leader_model: true },
34
+ { name: "Zephyrim", min: 4, max: 9, default_weapon_ids: ["bolt-pistol", "power-weapon"], is_leader_model: false },
35
+ ],
36
+ "retributor-squad": [
37
+ { name: "Retributor Superior", profile_name: "Retributor", min: 1, max: 1, default_weapon_ids: ["bolt-pistol", "power-weapon"], is_leader_model: true },
38
+ { name: "Retributor", min: 4, max: 4, default_weapon_ids: ["boltgun", "close-combat-weapon"], is_leader_model: false },
39
+ ],
40
+ "repentia-squad": [
41
+ { name: "Repentia Superior", min: 1, max: 1, default_weapon_ids: ["bolt-pistol", "neural-whips"], is_leader_model: true },
42
+ { name: "Sister Repentia", min: 4, max: 9, default_weapon_ids: ["penitent-eviscerator"], is_leader_model: false },
43
+ ],
44
+ "sisters-novitiate-squad": [
45
+ { name: "Novitiate Superior", min: 1, max: 1, default_weapon_ids: ["bolt-pistol", "power-weapon"], is_leader_model: true },
46
+ { name: "Sister Novitiate", min: 9, max: 9, default_weapon_ids: ["autogun", "close-combat-weapon"], is_leader_model: false },
47
+ ],
48
+ "sanctifiers": [
49
+ { name: "Missionary", profile_name: "Missionary", min: 1, max: 1, default_weapon_ids: ["holy-fire", "close-combat-weapon"], is_leader_model: true },
50
+ { name: "Miraculist", min: 2, max: 2, default_weapon_ids: ["ministorum-hand-flamer", "close-combat-weapon"], is_leader_model: false },
51
+ { name: "Salvationist", min: 2, max: 2, default_weapon_ids: ["ministorum-flamer", "close-combat-weapon"], is_leader_model: false },
52
+ { name: "Death Cult Assassin", min: 2, max: 2, default_weapon_ids: ["death-cult-blades"], is_leader_model: false },
53
+ { name: "Sanctifier", min: 2, max: 2, default_weapon_ids: ["sanctifier-melee-weapon"], is_leader_model: false },
54
+ ],
55
+ "saint-celestine": [
56
+ { name: "Celestine", min: 1, max: 1, default_weapon_ids: ["the-ardent-blade-strike"], is_leader_model: true },
57
+ { name: "Geminae Superia", min: 2, max: 2, default_weapon_ids: ["bolt-pistol", "power-weapon"], is_leader_model: false },
58
+ ],
59
+ "aestred-thurga-and-agathae-dolan": [
60
+ { name: "Aestred Thurga", min: 1, max: 1, default_weapon_ids: ["bolt-pistol", "auto-of-castigation"], is_leader_model: true },
61
+ { name: "Agathae Dolan", min: 1, max: 1, default_weapon_ids: ["close-combat-weapon"], is_leader_model: false },
62
+ ],
63
+ "daemonifuge": [
64
+ { name: "Ephrael Stern", min: 1, max: 1, default_weapon_ids: ["bolt-pistol", "sanctity"], is_leader_model: true },
65
+ { name: "Kyganil of the Bloody Tears", profile_name: "Kyganil of the Bloody Tears", min: 1, max: 1, default_weapon_ids: ["the-blades-of-the-harlequin"], is_leader_model: false },
66
+ ],
67
+ },
68
+ };
69
+ registerFaction(adeptaSororitas);
70
+ export default adeptaSororitas;
@@ -0,0 +1,3 @@
1
+ import { type FactionConfig } from "../faction-config.js";
2
+ declare const adeptusAstartes: FactionConfig;
3
+ export default adeptusAstartes;