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 +21 -0
- package/README.md +205 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +74 -0
- package/dist/curated/extract-catalogue.d.ts +2 -0
- package/dist/curated/extract-catalogue.js +320 -0
- package/dist/curated/extract-game-system.d.ts +2 -0
- package/dist/curated/extract-game-system.js +45 -0
- package/dist/curated/types.d.ts +89 -0
- package/dist/curated/types.js +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +95 -0
- package/dist/raw-converter.d.ts +1 -0
- package/dist/raw-converter.js +43 -0
- package/dist/utils.d.ts +13 -0
- package/dist/utils.js +61 -0
- package/package.json +45 -0
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
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,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,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 {};
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -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
|
+
}
|