bsdata40k-to-json 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Antone
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # bsdata40k-to-json
2
+
3
+ Convert [BSData](https://github.com/BSData) BattleScribe XML for **Warhammer 40,000** into JSON. Turn army list data (factions, units, weapons, abilities, points) from the community-maintained BSData repos into two output formats:
4
+
5
+ - **Raw** — Faithful 1:1 XML-to-JSON conversion preserving all BattleScribe elements, attributes, and list-builder logic
6
+ - **Curated** — Clean, game-ready JSON: datasheet stats (M, T, SV, W, LD, OC), ranged/melee weapon profiles, abilities, keywords, transport capacity, leader attachments, enhancements, and points per faction
7
+
8
+ Built for **Warhammer 40,000** (40k) 10th edition data. Use the output for list-building apps, points calculators, roster viewers, or any tool that needs structured 40k game data.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install bsdata40k-to-json
14
+ ```
15
+
16
+ Or install globally for CLI access:
17
+
18
+ ```bash
19
+ npm install -g bsdata40k-to-json
20
+ ```
21
+
22
+ ## CLI Usage
23
+
24
+ Pass the input directory (BSData repo) and output directory as two arguments:
25
+
26
+ ```bash
27
+ bsdata40k-to-json ./wh40k-10e ./output
28
+ ```
29
+
30
+ ### Options
31
+
32
+ | Argument / Flag | Description |
33
+ |---|---|
34
+ | `<input-dir>` | Directory containing `.cat` and `.gst` files (required) |
35
+ | `<output-dir>` | Directory to write JSON output (required) |
36
+ | `--raw-only` | Only produce raw (1:1 XML-to-JSON) output |
37
+ | `--curated-only` | Only produce curated game-data output |
38
+ | `--help`, `-h` | Show help message |
39
+
40
+ ### Development scripts
41
+
42
+ | Command | Description |
43
+ |---|---|
44
+ | `npm run convert -- <input-dir> <output-dir>` | Run both pipelines via `tsx` |
45
+ | `npm run convert:raw -- <input-dir> <output-dir>` | Raw XML-to-JSON only |
46
+ | `npm run convert:curated -- <input-dir> <output-dir>` | Curated extraction only |
47
+ | `npm run build` | Compile TypeScript to `dist/` |
48
+
49
+ ## Library Usage
50
+
51
+ ```typescript
52
+ import { convert } from "bsdata40k-to-json";
53
+
54
+ const result = await convert({
55
+ inputDir: "./wh40k-10e",
56
+ outputDir: "./output",
57
+ curatedOnly: true,
58
+ });
59
+
60
+ console.log(`Converted ${result.factionCount} factions`);
61
+ ```
62
+
63
+ Individual extractors are also available:
64
+
65
+ ```typescript
66
+ import {
67
+ convertFileToRawJson,
68
+ extractGameSystem,
69
+ extractCatalogue,
70
+ discoverFiles,
71
+ } from "bsdata40k-to-json";
72
+
73
+ const { gstFiles, catFiles } = await discoverFiles("./wh40k-10e");
74
+ const rawJson = await convertFileToRawJson(gstFiles[0]);
75
+ const gameSystem = extractGameSystem(rawJson);
76
+ ```
77
+
78
+ All TypeScript types are exported:
79
+
80
+ ```typescript
81
+ import type {
82
+ CuratedFaction,
83
+ CuratedUnit,
84
+ WeaponProfile,
85
+ Enhancement,
86
+ CuratedGameSystem,
87
+ ConvertOptions,
88
+ ConvertResult,
89
+ } from "bsdata40k-to-json";
90
+ ```
91
+
92
+ ## Output Structure
93
+
94
+ ```
95
+ <output-dir>/
96
+ raw/ # 1:1 XML-to-JSON
97
+ game-system.json # Warhammer 40,000.gst (core rules, profile types)
98
+ necrons.json # One file per faction catalogue (.cat)
99
+ imperium-space-marines.json
100
+ ...
101
+
102
+ curated/ # Clean game data
103
+ game-system.json # Shared rules (e.g. Blast, Devastating Wounds), profile types, categories
104
+ factions/
105
+ necrons.json
106
+ imperium-space-marines.json
107
+ ...
108
+ index.json # Faction manifest with unit counts
109
+ ```
110
+
111
+ ### What’s in the curated output (40k terms)
112
+
113
+ - **Factions** — One JSON file per 40k faction (e.g. Necrons, Space Marines, Tyranids), with `name`, `catalogueName`, and all units/enhancements for that army.
114
+ - **Units** — Full datasheet-style data: Movement (M), Toughness (T), Save (SV), Wounds (W), Leadership (LD), Objective Control (OC), plus keywords (Infantry, Character, etc.) and faction keywords.
115
+ - **Weapons** — Ranged and melee weapon profiles with Range, Attacks (A), Ballistic Skill (BS) or Weapon Skill (WS), Strength (S), Armour Penetration (AP), Damage (D), and weapon keywords (e.g. Devastating Wounds, Blast).
116
+ - **Abilities** — Named abilities and their rules text (e.g. *Master Chronomancer*, *Oath of Moment*).
117
+ - **Enhancements** — Detachment enhancements with name, points cost, and description; optional `detachment` when applicable.
118
+ - **Leader attachments** — Which units a character can lead (e.g. Orikan the Diviner → Immortals, Necron Warriors).
119
+
120
+ ## Curated JSON Format (example: Necrons)
121
+
122
+ Each faction file contains units with full 40k-style datasheets:
123
+
124
+ ```json
125
+ {
126
+ "id": "b654-a18a-ea1-3bf2",
127
+ "name": "Necrons",
128
+ "catalogueName": "Xenos - Necrons",
129
+ "units": [
130
+ {
131
+ "id": "ba86-eaad-5396-fc07",
132
+ "name": "Orikan the Diviner",
133
+ "points": 80,
134
+ "keywords": ["Infantry", "Character", "Epic Hero", "Cryptek"],
135
+ "factionKeywords": ["Necrons"],
136
+ "stats": { "M": "5\"", "T": "4", "SV": "4+", "W": "4", "LD": "6+", "OC": "1" },
137
+ "rangedWeapons": [],
138
+ "meleeWeapons": [
139
+ {
140
+ "name": "Staff of Tomorrow",
141
+ "range": "Melee",
142
+ "A": "2",
143
+ "WS": "3+",
144
+ "S": "4",
145
+ "AP": "-3",
146
+ "D": "D3",
147
+ "keywords": ["Devastating Wounds"]
148
+ }
149
+ ],
150
+ "abilities": [
151
+ { "name": "Master Chronomancer", "description": "While this model is leading a unit, models in that unit have a 4+ invulnerable save." }
152
+ ],
153
+ "leader": ["Immortals", "Necron Warriors"]
154
+ }
155
+ ],
156
+ "enhancements": [
157
+ {
158
+ "name": "Veil of Darkness",
159
+ "points": 20,
160
+ "description": "NECRONS model only. Once per battle...",
161
+ "detachment": "Awakened Dynasty"
162
+ }
163
+ ]
164
+ }
165
+ ```
166
+
167
+ ## Project Structure
168
+
169
+ ```
170
+ src/
171
+ cli.ts # CLI entry point (arg parsing)
172
+ index.ts # Library entry point (programmatic API + re-exports)
173
+ raw-converter.ts # Faithful XML-to-JSON using fast-xml-parser
174
+ utils.ts # File discovery, slugification, helpers
175
+ curated/
176
+ types.ts # TypeScript interfaces for output shapes
177
+ extract-game-system.ts # .gst extractor (rules, profile types, categories)
178
+ extract-catalogue.ts # .cat extractor (units, weapons, abilities, enhancements)
179
+ ```
180
+
181
+ ## How It Works
182
+
183
+ 1. **Discovery** — Scans the input directory for the 40k game system (`.gst`) and faction catalogues (`.cat`).
184
+ 2. **Raw conversion** — Parses each XML file with `fast-xml-parser`, preserving attributes (prefixed with `@_`) and forcing arrays for repeatable elements.
185
+ 3. **Curated extraction** — Walks the parsed XML tree to extract 40k game data:
186
+ - Resolves `entryLink` cross-references (shared units, wargear, weapons) by building an ID lookup map
187
+ - Recursively collects profiles from nested `selectionEntry`, `selectionEntryGroup`, and `entryLink` elements
188
+ - Deduplicates weapons by name
189
+ - Parses leader attachment targets from ability descriptions (e.g. “This model can be attached to…”)
190
+ - Separates faction keywords (e.g. `Faction: Necrons`) from unit keywords (Infantry, Character, etc.)
191
+
192
+ ## Data Source (Warhammer 40,000)
193
+
194
+ Input files come from the [BSData](https://github.com/BSData) community-maintained **Warhammer 40,000** data. For 10th edition use the [BSData/wh40k-10e](https://github.com/BSData/wh40k-10e) repository:
195
+
196
+ ```bash
197
+ git clone https://github.com/BSData/wh40k-10e.git
198
+ bsdata40k-to-json ./wh40k-10e ./output
199
+ ```
200
+
201
+ The repo contains the core game system (`Warhammer 40,000.gst`) and one catalogue (`.cat`) per faction (Space Marines, Necrons, Tyranids, etc.). Download or clone the repo and pass its path as the first argument.
202
+
203
+ ## License
204
+
205
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import { convert } from "./index.js";
3
+ function printUsage() {
4
+ console.log(`
5
+ bsdata40k-to-json — Convert BSData XML files (.cat/.gst) to JSON
6
+
7
+ Usage:
8
+ bsdata40k-to-json <input-dir> <output-dir> [options]
9
+
10
+ input-dir Directory containing .cat and .gst files
11
+ output-dir Directory to write JSON output
12
+
13
+ Options:
14
+ --raw-only Only produce raw (1:1 XML-to-JSON) output
15
+ --curated-only Only produce curated game-data output
16
+ --help, -h Show this help message
17
+ `.trim());
18
+ }
19
+ function parseArgs(argv) {
20
+ const result = {
21
+ input: undefined,
22
+ output: undefined,
23
+ rawOnly: false,
24
+ curatedOnly: false,
25
+ help: false,
26
+ };
27
+ const positionals = [];
28
+ for (let i = 2; i < argv.length; i++) {
29
+ const arg = argv[i];
30
+ switch (arg) {
31
+ case "--raw-only":
32
+ result.rawOnly = true;
33
+ break;
34
+ case "--curated-only":
35
+ result.curatedOnly = true;
36
+ break;
37
+ case "--help":
38
+ case "-h":
39
+ result.help = true;
40
+ break;
41
+ default:
42
+ if (arg.startsWith("-")) {
43
+ console.error(`Unknown option: ${arg}`);
44
+ printUsage();
45
+ process.exit(1);
46
+ }
47
+ positionals.push(arg);
48
+ }
49
+ }
50
+ if (positionals.length >= 1)
51
+ result.input = positionals[0];
52
+ if (positionals.length >= 2)
53
+ result.output = positionals[1];
54
+ return result;
55
+ }
56
+ const args = parseArgs(process.argv);
57
+ if (args.help) {
58
+ printUsage();
59
+ process.exit(0);
60
+ }
61
+ if (!args.input || !args.output) {
62
+ console.error("Error: input and output directories are required.\n");
63
+ printUsage();
64
+ process.exit(1);
65
+ }
66
+ convert({
67
+ inputDir: args.input,
68
+ outputDir: args.output,
69
+ rawOnly: args.rawOnly,
70
+ curatedOnly: args.curatedOnly,
71
+ }).catch((err) => {
72
+ console.error(err);
73
+ process.exit(1);
74
+ });
@@ -0,0 +1,2 @@
1
+ import type { CuratedFaction } from "./types.js";
2
+ export declare function extractCatalogue(rawJson: any): CuratedFaction;
@@ -0,0 +1,320 @@
1
+ import { ensureArray, cleanFactionName } from "../utils.js";
2
+ export function extractCatalogue(rawJson) {
3
+ const cat = rawJson.catalogue;
4
+ const catalogueName = cat["@_name"];
5
+ // Build a lookup map of all shared entries by ID so we can resolve entryLinks
6
+ const entryMap = buildEntryMap(cat);
7
+ const units = [];
8
+ const enhancements = [];
9
+ // Units live in sharedSelectionEntries
10
+ const sharedEntries = ensureArray(cat?.sharedSelectionEntries?.selectionEntry);
11
+ for (const entry of sharedEntries) {
12
+ const type = entry["@_type"];
13
+ if (type === "unit" || type === "model") {
14
+ const unit = extractUnit(entry, entryMap);
15
+ if (unit)
16
+ units.push(unit);
17
+ }
18
+ }
19
+ // Also check top-level selectionEntries (some catalogues use these)
20
+ const topEntries = ensureArray(cat?.selectionEntries?.selectionEntry);
21
+ for (const entry of topEntries) {
22
+ const type = entry["@_type"];
23
+ if (type === "unit" || type === "model") {
24
+ const unit = extractUnit(entry, entryMap);
25
+ if (unit)
26
+ units.push(unit);
27
+ }
28
+ }
29
+ // Extract enhancements from sharedSelectionEntryGroups
30
+ const sharedGroups = ensureArray(cat?.sharedSelectionEntryGroups?.selectionEntryGroup);
31
+ for (const group of sharedGroups) {
32
+ if (group["@_name"] === "Enhancements") {
33
+ enhancements.push(...extractEnhancementsFromGroup(group));
34
+ }
35
+ }
36
+ return {
37
+ id: cat["@_id"],
38
+ name: cleanFactionName(catalogueName),
39
+ catalogueName,
40
+ units,
41
+ enhancements,
42
+ };
43
+ }
44
+ function buildEntryMap(cat) {
45
+ const map = new Map();
46
+ function indexEntry(entry) {
47
+ if (entry["@_id"]) {
48
+ map.set(entry["@_id"], entry);
49
+ }
50
+ for (const sub of ensureArray(entry?.selectionEntries?.selectionEntry)) {
51
+ indexEntry(sub);
52
+ }
53
+ for (const group of ensureArray(entry?.selectionEntryGroups?.selectionEntryGroup)) {
54
+ if (group["@_id"])
55
+ map.set(group["@_id"], group);
56
+ for (const ge of ensureArray(group?.selectionEntries?.selectionEntry)) {
57
+ indexEntry(ge);
58
+ }
59
+ }
60
+ }
61
+ for (const entry of ensureArray(cat?.sharedSelectionEntries?.selectionEntry)) {
62
+ indexEntry(entry);
63
+ }
64
+ for (const entry of ensureArray(cat?.selectionEntries?.selectionEntry)) {
65
+ indexEntry(entry);
66
+ }
67
+ for (const group of ensureArray(cat?.sharedSelectionEntryGroups?.selectionEntryGroup)) {
68
+ if (group["@_id"])
69
+ map.set(group["@_id"], group);
70
+ for (const ge of ensureArray(group?.selectionEntries?.selectionEntry)) {
71
+ indexEntry(ge);
72
+ }
73
+ }
74
+ return map;
75
+ }
76
+ function extractUnit(entry, entryMap) {
77
+ const name = entry["@_name"];
78
+ const id = entry["@_id"];
79
+ const profiles = collectAllProfiles(entry, entryMap);
80
+ const stats = extractUnitStats(profiles);
81
+ if (!stats)
82
+ return null;
83
+ const rangedWeapons = extractWeapons(entry, "Ranged Weapons", entryMap);
84
+ const meleeWeapons = extractWeapons(entry, "Melee Weapons", entryMap);
85
+ const abilities = extractAbilities(profiles);
86
+ const points = extractPoints(entry);
87
+ const { keywords, factionKeywords } = extractKeywords(entry);
88
+ const leader = extractLeaderTargets(profiles);
89
+ const transportCapacity = extractTransportCapacity(profiles);
90
+ const unit = {
91
+ id,
92
+ name,
93
+ points,
94
+ keywords,
95
+ factionKeywords,
96
+ stats,
97
+ rangedWeapons,
98
+ meleeWeapons,
99
+ abilities,
100
+ };
101
+ if (transportCapacity)
102
+ unit.transportCapacity = transportCapacity;
103
+ if (leader.length > 0)
104
+ unit.leader = leader;
105
+ return unit;
106
+ }
107
+ /**
108
+ * Recursively collects all <profile> elements from an entry and its nested children,
109
+ * including those reachable via entryLink references.
110
+ */
111
+ function collectAllProfiles(entry, entryMap, visited = new Set()) {
112
+ const entryId = entry["@_id"];
113
+ if (entryId && visited.has(entryId))
114
+ return [];
115
+ if (entryId)
116
+ visited.add(entryId);
117
+ const profiles = [];
118
+ // Direct profiles on this entry
119
+ profiles.push(...ensureArray(entry?.profiles?.profile));
120
+ // Profiles inside nested selectionEntries
121
+ for (const sub of ensureArray(entry?.selectionEntries?.selectionEntry)) {
122
+ profiles.push(...collectAllProfiles(sub, entryMap, visited));
123
+ }
124
+ // Profiles inside selectionEntryGroups
125
+ for (const group of ensureArray(entry?.selectionEntryGroups?.selectionEntryGroup)) {
126
+ for (const ge of ensureArray(group?.selectionEntries?.selectionEntry)) {
127
+ profiles.push(...collectAllProfiles(ge, entryMap, visited));
128
+ }
129
+ // entryLinks inside groups
130
+ for (const link of ensureArray(group?.entryLinks?.entryLink)) {
131
+ const target = entryMap.get(link["@_targetId"]);
132
+ if (target) {
133
+ profiles.push(...collectAllProfiles(target, entryMap, visited));
134
+ }
135
+ }
136
+ }
137
+ // Resolve entryLink references
138
+ for (const link of ensureArray(entry?.entryLinks?.entryLink)) {
139
+ const target = entryMap.get(link["@_targetId"]);
140
+ if (target) {
141
+ profiles.push(...collectAllProfiles(target, entryMap, visited));
142
+ }
143
+ }
144
+ return profiles;
145
+ }
146
+ /**
147
+ * Recursively collect weapon profiles from nested selectionEntries,
148
+ * including those reachable via entryLink references.
149
+ */
150
+ function collectWeaponProfiles(entry, typeName, entryMap, visited = new Set()) {
151
+ const entryId = entry["@_id"];
152
+ if (entryId && visited.has(entryId))
153
+ return [];
154
+ if (entryId)
155
+ visited.add(entryId);
156
+ const weapons = [];
157
+ // Direct profiles on this entry
158
+ for (const profile of ensureArray(entry?.profiles?.profile)) {
159
+ if (profile["@_typeName"] === typeName) {
160
+ const wp = parseWeaponProfile(profile, typeName);
161
+ if (wp)
162
+ weapons.push(wp);
163
+ }
164
+ }
165
+ // Nested selectionEntries
166
+ for (const sub of ensureArray(entry?.selectionEntries?.selectionEntry)) {
167
+ weapons.push(...collectWeaponProfiles(sub, typeName, entryMap, visited));
168
+ }
169
+ // Nested selectionEntryGroups -> selectionEntries + entryLinks
170
+ for (const group of ensureArray(entry?.selectionEntryGroups?.selectionEntryGroup)) {
171
+ for (const ge of ensureArray(group?.selectionEntries?.selectionEntry)) {
172
+ weapons.push(...collectWeaponProfiles(ge, typeName, entryMap, visited));
173
+ }
174
+ for (const link of ensureArray(group?.entryLinks?.entryLink)) {
175
+ const target = entryMap.get(link["@_targetId"]);
176
+ if (target) {
177
+ weapons.push(...collectWeaponProfiles(target, typeName, entryMap, visited));
178
+ }
179
+ }
180
+ }
181
+ // Resolve entryLink references
182
+ for (const link of ensureArray(entry?.entryLinks?.entryLink)) {
183
+ const target = entryMap.get(link["@_targetId"]);
184
+ if (target) {
185
+ weapons.push(...collectWeaponProfiles(target, typeName, entryMap, visited));
186
+ }
187
+ }
188
+ return weapons;
189
+ }
190
+ function extractWeapons(entry, typeName, entryMap) {
191
+ const all = collectWeaponProfiles(entry, typeName, entryMap);
192
+ // Deduplicate by name
193
+ const seen = new Set();
194
+ const unique = [];
195
+ for (const w of all) {
196
+ if (!seen.has(w.name)) {
197
+ seen.add(w.name);
198
+ unique.push(w);
199
+ }
200
+ }
201
+ return unique;
202
+ }
203
+ function parseWeaponProfile(profile, typeName) {
204
+ const chars = characteristicsToMap(profile);
205
+ const isRanged = typeName === "Ranged Weapons";
206
+ const keywordsRaw = chars["Keywords"] || "-";
207
+ const keywords = keywordsRaw === "-"
208
+ ? []
209
+ : keywordsRaw
210
+ .split(",")
211
+ .map((k) => k.trim())
212
+ .filter(Boolean);
213
+ return {
214
+ name: profile["@_name"],
215
+ range: chars["Range"] || "",
216
+ A: chars["A"] || "",
217
+ ...(isRanged ? { BS: chars["BS"] || "" } : { WS: chars["WS"] || "" }),
218
+ S: chars["S"] || "",
219
+ AP: chars["AP"] || "",
220
+ D: chars["D"] || "",
221
+ keywords,
222
+ };
223
+ }
224
+ function extractUnitStats(profiles) {
225
+ const unitProfile = profiles.find((p) => p["@_typeName"] === "Unit");
226
+ if (!unitProfile)
227
+ return null;
228
+ const chars = characteristicsToMap(unitProfile);
229
+ return {
230
+ M: chars["M"] || "",
231
+ T: chars["T"] || "",
232
+ SV: chars["SV"] || "",
233
+ W: chars["W"] || "",
234
+ LD: chars["LD"] || "",
235
+ OC: chars["OC"] || "",
236
+ };
237
+ }
238
+ function extractAbilities(profiles) {
239
+ return profiles
240
+ .filter((p) => p["@_typeName"] === "Abilities")
241
+ .map((p) => {
242
+ const chars = characteristicsToMap(p);
243
+ return {
244
+ name: p["@_name"],
245
+ description: chars["Description"] || "",
246
+ };
247
+ });
248
+ }
249
+ function extractTransportCapacity(profiles) {
250
+ const transport = profiles.find((p) => p["@_typeName"] === "Transport");
251
+ if (!transport)
252
+ return undefined;
253
+ const chars = characteristicsToMap(transport);
254
+ return chars["Capacity"] || undefined;
255
+ }
256
+ function extractPoints(entry) {
257
+ const costs = ensureArray(entry?.costs?.cost);
258
+ const ptsCost = costs.find((c) => c["@_name"] === "pts" || c["@_typeId"] === "51b2-306e-1021-d207");
259
+ return ptsCost ? parseInt(ptsCost["@_value"], 10) || 0 : 0;
260
+ }
261
+ function extractKeywords(entry) {
262
+ const links = ensureArray(entry?.categoryLinks?.categoryLink);
263
+ const keywords = [];
264
+ const factionKeywords = [];
265
+ for (const link of links) {
266
+ const name = link["@_name"] || "";
267
+ if (name.startsWith("Faction: ")) {
268
+ factionKeywords.push(name.replace("Faction: ", ""));
269
+ }
270
+ else if (name !== "Configuration" &&
271
+ name !== "Unit" &&
272
+ name !== entry["@_name"]) {
273
+ keywords.push(name);
274
+ }
275
+ }
276
+ return { keywords, factionKeywords };
277
+ }
278
+ function extractLeaderTargets(profiles) {
279
+ const leaderProfile = profiles.find((p) => p["@_typeName"] === "Abilities" && p["@_name"] === "Leader");
280
+ if (!leaderProfile)
281
+ return [];
282
+ const chars = characteristicsToMap(leaderProfile);
283
+ const desc = chars["Description"] || "";
284
+ // Parse lines like "■ IMMORTALS" or "■ NECRON WARRIORS"
285
+ const matches = desc.match(/■[ \t]*([A-Z][A-Z ''-]+)/g);
286
+ if (!matches)
287
+ return [];
288
+ return matches.map((m) => m
289
+ .replace(/^■[ \t]*/, "")
290
+ .trim()
291
+ .split(/\s+/)
292
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
293
+ .join(" "));
294
+ }
295
+ function characteristicsToMap(profile) {
296
+ const chars = ensureArray(profile?.characteristics?.characteristic);
297
+ const map = {};
298
+ for (const c of chars) {
299
+ const name = c["@_name"];
300
+ const value = typeof c === "object" ? c["#text"] ?? "" : String(c);
301
+ map[name] = String(value);
302
+ }
303
+ return map;
304
+ }
305
+ function extractEnhancementsFromGroup(group) {
306
+ const entries = ensureArray(group?.selectionEntries?.selectionEntry);
307
+ return entries.map((entry) => {
308
+ const profiles = ensureArray(entry?.profiles?.profile);
309
+ const abilityProfile = profiles.find((p) => p["@_typeName"] === "Abilities");
310
+ const chars = abilityProfile
311
+ ? characteristicsToMap(abilityProfile)
312
+ : {};
313
+ return {
314
+ name: entry["@_name"],
315
+ points: extractPoints(entry),
316
+ description: chars["Description"] || "",
317
+ ...(entry.comment ? { detachment: String(entry.comment) } : {}),
318
+ };
319
+ });
320
+ }
@@ -0,0 +1,2 @@
1
+ import type { CuratedGameSystem } from "./types.js";
2
+ export declare function extractGameSystem(rawJson: any): CuratedGameSystem;
@@ -0,0 +1,45 @@
1
+ import { ensureArray } from "../utils.js";
2
+ /* eslint-disable @typescript-eslint/no-explicit-any */
3
+ export function extractGameSystem(rawJson) {
4
+ const gs = rawJson.gameSystem;
5
+ return {
6
+ id: gs["@_id"],
7
+ name: gs["@_name"],
8
+ revision: gs["@_revision"],
9
+ profileTypes: extractProfileTypes(gs),
10
+ categories: extractCategories(gs),
11
+ sharedRules: extractSharedRules(gs),
12
+ };
13
+ }
14
+ function extractProfileTypes(gs) {
15
+ const profileTypes = ensureArray(gs?.profileTypes?.profileType);
16
+ return profileTypes.map((pt) => ({
17
+ id: pt["@_id"],
18
+ name: pt["@_name"],
19
+ characteristics: ensureArray(pt?.characteristicTypes?.characteristicType).map((ct) => ({
20
+ id: ct["@_id"],
21
+ name: ct["@_name"],
22
+ })),
23
+ }));
24
+ }
25
+ function extractCategories(gs) {
26
+ const categories = ensureArray(gs?.categoryEntries?.categoryEntry);
27
+ return categories.map((cat) => ({
28
+ id: cat["@_id"],
29
+ name: cat["@_name"],
30
+ }));
31
+ }
32
+ function extractSharedRules(gs) {
33
+ const rules = ensureArray(gs?.sharedRules?.rule);
34
+ return rules.map((rule) => {
35
+ const result = {
36
+ id: rule["@_id"],
37
+ name: rule["@_name"],
38
+ description: rule.description || "",
39
+ };
40
+ if (rule.alias) {
41
+ result.alias = rule.alias;
42
+ }
43
+ return result;
44
+ });
45
+ }
@@ -0,0 +1,89 @@
1
+ export interface WeaponProfile {
2
+ name: string;
3
+ range: string;
4
+ A: string;
5
+ BS?: string;
6
+ WS?: string;
7
+ S: string;
8
+ AP: string;
9
+ D: string;
10
+ keywords: string[];
11
+ }
12
+ export interface UnitStats {
13
+ M: string;
14
+ T: string;
15
+ SV: string;
16
+ W: string;
17
+ LD: string;
18
+ OC: string;
19
+ }
20
+ export interface Ability {
21
+ name: string;
22
+ description: string;
23
+ }
24
+ export interface TransportCapacity {
25
+ capacity: string;
26
+ }
27
+ export interface CuratedUnit {
28
+ id: string;
29
+ name: string;
30
+ points: number;
31
+ keywords: string[];
32
+ factionKeywords: string[];
33
+ stats: UnitStats;
34
+ rangedWeapons: WeaponProfile[];
35
+ meleeWeapons: WeaponProfile[];
36
+ abilities: Ability[];
37
+ transportCapacity?: string;
38
+ leader?: string[];
39
+ }
40
+ export interface Enhancement {
41
+ name: string;
42
+ points: number;
43
+ description: string;
44
+ detachment?: string;
45
+ }
46
+ export interface SharedRule {
47
+ id: string;
48
+ name: string;
49
+ description: string;
50
+ alias?: string;
51
+ }
52
+ export interface ProfileTypeDefinition {
53
+ id: string;
54
+ name: string;
55
+ characteristics: {
56
+ id: string;
57
+ name: string;
58
+ }[];
59
+ }
60
+ export interface CategoryDefinition {
61
+ id: string;
62
+ name: string;
63
+ }
64
+ export interface CuratedGameSystem {
65
+ id: string;
66
+ name: string;
67
+ revision: string;
68
+ profileTypes: ProfileTypeDefinition[];
69
+ categories: CategoryDefinition[];
70
+ sharedRules: SharedRule[];
71
+ }
72
+ export interface CuratedFaction {
73
+ id: string;
74
+ name: string;
75
+ catalogueName: string;
76
+ units: CuratedUnit[];
77
+ enhancements: Enhancement[];
78
+ }
79
+ export interface FactionManifestEntry {
80
+ id: string;
81
+ name: string;
82
+ catalogueName: string;
83
+ filename: string;
84
+ unitCount: number;
85
+ }
86
+ export interface FactionManifest {
87
+ gameSystem: string;
88
+ factions: FactionManifestEntry[];
89
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ export { convertFileToRawJson } from "./raw-converter.js";
2
+ export { extractGameSystem } from "./curated/extract-game-system.js";
3
+ export { extractCatalogue } from "./curated/extract-catalogue.js";
4
+ export * from "./curated/types.js";
5
+ export { slugify, discoverFiles, ensureArray, cleanFactionName } from "./utils.js";
6
+ export interface ConvertOptions {
7
+ inputDir: string;
8
+ outputDir: string;
9
+ rawOnly?: boolean;
10
+ curatedOnly?: boolean;
11
+ }
12
+ export interface ConvertResult {
13
+ rawFiles: string[];
14
+ curatedFiles: string[];
15
+ factionCount: number;
16
+ }
17
+ export declare function convert(options: ConvertOptions): Promise<ConvertResult>;
package/dist/index.js ADDED
@@ -0,0 +1,95 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { discoverFiles, slugify } from "./utils.js";
4
+ import { convertFileToRawJson } from "./raw-converter.js";
5
+ import { extractGameSystem } from "./curated/extract-game-system.js";
6
+ import { extractCatalogue } from "./curated/extract-catalogue.js";
7
+ // --- Re-exports for programmatic consumers ---
8
+ export { convertFileToRawJson } from "./raw-converter.js";
9
+ export { extractGameSystem } from "./curated/extract-game-system.js";
10
+ export { extractCatalogue } from "./curated/extract-catalogue.js";
11
+ export * from "./curated/types.js";
12
+ export { slugify, discoverFiles, ensureArray, cleanFactionName } from "./utils.js";
13
+ async function writeJson(filePath, data) {
14
+ await mkdir(path.dirname(filePath), { recursive: true });
15
+ await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
16
+ }
17
+ export async function convert(options) {
18
+ const { inputDir, outputDir, rawOnly = false, curatedOnly = false } = options;
19
+ const rawDir = path.join(outputDir, "raw");
20
+ const curatedDir = path.join(outputDir, "curated");
21
+ const factionsDir = path.join(curatedDir, "factions");
22
+ const doRaw = !curatedOnly;
23
+ const doCurated = !rawOnly;
24
+ const rawFiles = [];
25
+ const curatedFiles = [];
26
+ console.log(`Input: ${inputDir}`);
27
+ console.log(`Output: ${outputDir}`);
28
+ console.log();
29
+ const { gstFiles, catFiles } = await discoverFiles(inputDir);
30
+ console.log(`Found ${gstFiles.length} game system file(s)`);
31
+ console.log(`Found ${catFiles.length} catalogue file(s)`);
32
+ console.log();
33
+ if (gstFiles.length === 0) {
34
+ throw new Error("No .gst file found in input directory.");
35
+ }
36
+ const gstPath = gstFiles[0];
37
+ const gstRaw = await convertFileToRawJson(gstPath);
38
+ if (doRaw) {
39
+ const rawPath = path.join(rawDir, "game-system.json");
40
+ await writeJson(rawPath, gstRaw);
41
+ rawFiles.push(rawPath);
42
+ console.log(`[raw] game-system.json`);
43
+ }
44
+ if (doCurated) {
45
+ const gameSystem = extractGameSystem(gstRaw);
46
+ const curatedPath = path.join(curatedDir, "game-system.json");
47
+ await writeJson(curatedPath, gameSystem);
48
+ curatedFiles.push(curatedPath);
49
+ console.log(`[curated] game-system.json (${gameSystem.sharedRules.length} shared rules, ${gameSystem.categories.length} categories)`);
50
+ }
51
+ const manifest = [];
52
+ for (const catPath of catFiles) {
53
+ const baseName = path.basename(catPath, ".cat");
54
+ const slug = slugify(baseName);
55
+ const catRaw = await convertFileToRawJson(catPath);
56
+ if (doRaw) {
57
+ const rawPath = path.join(rawDir, `${slug}.json`);
58
+ await writeJson(rawPath, catRaw);
59
+ rawFiles.push(rawPath);
60
+ console.log(`[raw] ${slug}.json`);
61
+ }
62
+ if (doCurated) {
63
+ const faction = extractCatalogue(catRaw);
64
+ const factionPath = path.join(factionsDir, `${slug}.json`);
65
+ await writeJson(factionPath, faction);
66
+ curatedFiles.push(factionPath);
67
+ console.log(`[curated] ${slug}.json — ${faction.name} (${faction.units.length} units, ${faction.enhancements.length} enhancements)`);
68
+ manifest.push({
69
+ id: faction.id,
70
+ name: faction.name,
71
+ catalogueName: faction.catalogueName,
72
+ filename: `${slug}.json`,
73
+ unitCount: faction.units.length,
74
+ });
75
+ }
76
+ }
77
+ if (doCurated) {
78
+ const index = {
79
+ gameSystem: "Warhammer 40,000 10th Edition",
80
+ factions: manifest.sort((a, b) => a.name.localeCompare(b.name)),
81
+ };
82
+ const indexPath = path.join(curatedDir, "index.json");
83
+ await writeJson(indexPath, index);
84
+ curatedFiles.push(indexPath);
85
+ console.log();
86
+ console.log(`[curated] index.json (${manifest.length} factions)`);
87
+ }
88
+ console.log();
89
+ console.log("Done.");
90
+ return {
91
+ rawFiles,
92
+ curatedFiles,
93
+ factionCount: manifest.length,
94
+ };
95
+ }
@@ -0,0 +1 @@
1
+ export declare function convertFileToRawJson(filePath: string): Promise<Record<string, unknown>>;
@@ -0,0 +1,43 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { XMLParser } from "fast-xml-parser";
3
+ const ARRAY_TAGS = new Set([
4
+ "publication",
5
+ "costType",
6
+ "profileType",
7
+ "characteristicType",
8
+ "categoryEntry",
9
+ "forceEntry",
10
+ "selectionEntry",
11
+ "selectionEntryGroup",
12
+ "entryLink",
13
+ "categoryLink",
14
+ "infoLink",
15
+ "infoGroup",
16
+ "profile",
17
+ "characteristic",
18
+ "rule",
19
+ "constraint",
20
+ "modifier",
21
+ "modifierGroup",
22
+ "condition",
23
+ "conditionGroup",
24
+ "repeat",
25
+ "cost",
26
+ "association",
27
+ "comment",
28
+ ]);
29
+ function createRawParser() {
30
+ return new XMLParser({
31
+ ignoreAttributes: false,
32
+ attributeNamePrefix: "@_",
33
+ allowBooleanAttributes: true,
34
+ parseAttributeValue: false,
35
+ trimValues: true,
36
+ isArray: (name) => ARRAY_TAGS.has(name),
37
+ });
38
+ }
39
+ export async function convertFileToRawJson(filePath) {
40
+ const xml = await readFile(filePath, "utf-8");
41
+ const parser = createRawParser();
42
+ return parser.parse(xml);
43
+ }
@@ -0,0 +1,13 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ export declare function createXmlParser(): XMLParser;
3
+ export declare function slugify(name: string): string;
4
+ export declare function cleanFactionName(catalogueName: string): string;
5
+ export declare function discoverFiles(inputDir: string): Promise<{
6
+ gstFiles: string[];
7
+ catFiles: string[];
8
+ }>;
9
+ /**
10
+ * Normalizes a value that may be a single item or array into an array.
11
+ * BSData XML elements sometimes appear once (parsed as object) or multiple times (parsed as array).
12
+ */
13
+ export declare function ensureArray<T>(value: T | T[] | undefined | null): T[];
package/dist/utils.js ADDED
@@ -0,0 +1,61 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { XMLParser } from "fast-xml-parser";
4
+ export function createXmlParser() {
5
+ return new XMLParser({
6
+ ignoreAttributes: false,
7
+ attributeNamePrefix: "@_",
8
+ allowBooleanAttributes: true,
9
+ parseAttributeValue: false,
10
+ trimValues: true,
11
+ isArray: (_name, _jpath, isLeafNode) => {
12
+ // Force arrays for elements that can repeat
13
+ if (!isLeafNode)
14
+ return false;
15
+ return false;
16
+ },
17
+ });
18
+ }
19
+ export function slugify(name) {
20
+ return name
21
+ .toLowerCase()
22
+ .replace(/['']/g, "")
23
+ .replace(/[^a-z0-9]+/g, "-")
24
+ .replace(/^-+|-+$/g, "");
25
+ }
26
+ export function cleanFactionName(catalogueName) {
27
+ // "Xenos - Necrons" -> "Necrons"
28
+ // "Imperium - Space Marines" -> "Space Marines"
29
+ // "Chaos - Chaos Space Marines" -> "Chaos Space Marines"
30
+ const parts = catalogueName.split(" - ");
31
+ if (parts.length > 1) {
32
+ return parts.slice(1).join(" - ").trim();
33
+ }
34
+ return catalogueName.trim();
35
+ }
36
+ export async function discoverFiles(inputDir) {
37
+ const entries = await readdir(inputDir);
38
+ const gstFiles = [];
39
+ const catFiles = [];
40
+ for (const entry of entries) {
41
+ const ext = path.extname(entry).toLowerCase();
42
+ if (ext === ".gst") {
43
+ gstFiles.push(path.join(inputDir, entry));
44
+ }
45
+ else if (ext === ".cat") {
46
+ catFiles.push(path.join(inputDir, entry));
47
+ }
48
+ }
49
+ return { gstFiles, catFiles };
50
+ }
51
+ /**
52
+ * Normalizes a value that may be a single item or array into an array.
53
+ * BSData XML elements sometimes appear once (parsed as object) or multiple times (parsed as array).
54
+ */
55
+ export function ensureArray(value) {
56
+ if (value === undefined || value === null)
57
+ return [];
58
+ if (Array.isArray(value))
59
+ return value;
60
+ return [value];
61
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "bsdata40k-to-json",
3
+ "version": "1.0.0",
4
+ "description": "Convert BSData BattleScribe XML files (.cat/.gst) to JSON — raw or curated",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "bsdata40k-to-json": "dist/cli.js"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "convert": "tsx src/cli.ts",
23
+ "convert:raw": "tsx src/cli.ts --raw-only",
24
+ "convert:curated": "tsx src/cli.ts --curated-only",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "keywords": [
28
+ "bsdata",
29
+ "battlescribe",
30
+ "warhammer",
31
+ "40k",
32
+ "xml",
33
+ "json",
34
+ "converter"
35
+ ],
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "fast-xml-parser": "^4.5.1"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.13.4",
42
+ "tsx": "^4.19.3",
43
+ "typescript": "^5.7.3"
44
+ }
45
+ }