@alpaca-software/40kdc-data 0.1.2 → 0.1.3
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/README.md +2 -0
- package/dist/abilities-resolver/resolver.d.ts +13 -4
- package/dist/abilities-resolver/resolver.d.ts.map +1 -1
- package/dist/abilities-resolver/resolver.js +22 -15
- package/dist/abilities-resolver/resolver.js.map +1 -1
- package/dist/audit-coverage.d.ts +78 -0
- package/dist/audit-coverage.d.ts.map +1 -0
- package/dist/audit-coverage.js +341 -0
- package/dist/audit-coverage.js.map +1 -0
- package/dist/author-batch.d.ts +147 -0
- package/dist/author-batch.d.ts.map +1 -0
- package/dist/author-batch.js +675 -0
- package/dist/author-batch.js.map +1 -0
- package/dist/author-input.d.ts +37 -0
- package/dist/author-input.d.ts.map +1 -0
- package/dist/author-input.js +162 -0
- package/dist/author-input.js.map +1 -0
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/translate.d.ts.map +1 -1
- package/dist/commands/translate.js +9 -4
- package/dist/commands/translate.js.map +1 -1
- package/dist/cruncher/attribution.d.ts +66 -0
- package/dist/cruncher/attribution.d.ts.map +1 -0
- package/dist/cruncher/attribution.js +88 -0
- package/dist/cruncher/attribution.js.map +1 -0
- package/dist/cruncher/buffs.d.ts +23 -1
- package/dist/cruncher/buffs.d.ts.map +1 -1
- package/dist/cruncher/buffs.js +1 -1
- package/dist/cruncher/buffs.js.map +1 -1
- package/dist/cruncher/from-dsl.d.ts +32 -0
- package/dist/cruncher/from-dsl.d.ts.map +1 -1
- package/dist/cruncher/from-dsl.js +485 -40
- package/dist/cruncher/from-dsl.js.map +1 -1
- package/dist/cruncher/index.d.ts +1 -0
- package/dist/cruncher/index.d.ts.map +1 -1
- package/dist/cruncher/index.js +1 -0
- package/dist/cruncher/index.js.map +1 -1
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/collection.d.ts +9 -0
- package/dist/data/collection.d.ts.map +1 -1
- package/dist/data/collection.js +14 -0
- package/dist/data/collection.js.map +1 -1
- package/dist/data/dataset.d.ts +80 -2
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +143 -6
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/entities.d.ts +2 -5
- package/dist/data/entities.d.ts.map +1 -1
- package/dist/data/entities.js.map +1 -1
- package/dist/data/index.d.ts +3 -2
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +1 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/roster-resolve.d.ts +26 -1
- package/dist/data/roster-resolve.d.ts.map +1 -1
- package/dist/data/roster-resolve.js +46 -0
- package/dist/data/roster-resolve.js.map +1 -1
- package/dist/export/index.d.ts +1 -0
- package/dist/export/index.d.ts.map +1 -1
- package/dist/export/index.js +3 -0
- package/dist/export/index.js.map +1 -1
- package/dist/export/rosterizer.d.ts +3 -0
- package/dist/export/rosterizer.d.ts.map +1 -0
- package/dist/export/rosterizer.js +144 -0
- package/dist/export/rosterizer.js.map +1 -0
- package/dist/export/serializer.d.ts +1 -1
- package/dist/export/serializer.d.ts.map +1 -1
- package/dist/export/serializer.js.map +1 -1
- package/dist/gen-conformance.js +212 -11
- package/dist/gen-conformance.js.map +1 -1
- package/dist/import/gw.d.ts +69 -0
- package/dist/import/gw.d.ts.map +1 -0
- package/dist/import/gw.js +245 -0
- package/dist/import/gw.js.map +1 -0
- package/dist/import/import-roster.d.ts +52 -3
- package/dist/import/import-roster.d.ts.map +1 -1
- package/dist/import/import-roster.js +114 -4
- package/dist/import/import-roster.js.map +1 -1
- package/dist/import/index.d.ts +2 -2
- package/dist/import/index.d.ts.map +1 -1
- package/dist/import/index.js +1 -1
- package/dist/import/index.js.map +1 -1
- package/dist/import/listforge.d.ts.map +1 -1
- package/dist/import/listforge.js +15 -1
- package/dist/import/listforge.js.map +1 -1
- package/dist/import/newrecruit-text.d.ts +3 -0
- package/dist/import/newrecruit-text.d.ts.map +1 -1
- package/dist/import/newrecruit-text.js +6 -0
- package/dist/import/newrecruit-text.js.map +1 -1
- package/dist/import/newrecruit-wtc.d.ts.map +1 -1
- package/dist/import/newrecruit-wtc.js +10 -7
- package/dist/import/newrecruit-wtc.js.map +1 -1
- package/dist/import/rosterizer.d.ts +70 -0
- package/dist/import/rosterizer.d.ts.map +1 -0
- package/dist/import/rosterizer.js +348 -0
- package/dist/import/rosterizer.js.map +1 -0
- package/dist/import/types.d.ts +1 -1
- package/dist/import/types.d.ts.map +1 -1
- package/dist/import/types.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/migrations/2026-weapon-keywords.js +4 -0
- package/dist/migrations/2026-weapon-keywords.js.map +1 -1
- package/dist/runner.d.ts +38 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +492 -0
- package/dist/runner.js.map +1 -0
- package/dist/scrub-ip.d.ts +14 -0
- package/dist/scrub-ip.d.ts.map +1 -0
- package/dist/scrub-ip.js +88 -0
- package/dist/scrub-ip.js.map +1 -0
- package/package.json +9 -2
- package/schemas/core/roster.schema.json +3 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { factionFromKeyword, inferBattleSizeRaw, stripParenthetical, } from "./newrecruit-text.js";
|
|
2
|
+
const FACTION_KEYWORD_PREFIX = "+ FACTION KEYWORD:";
|
|
3
|
+
const HEADER_FIELDS = {
|
|
4
|
+
faction: /^\+\s*FACTION KEYWORD:\s*(.+?)\s*$/i,
|
|
5
|
+
detachment: /^\+\s*DETACHMENT:\s*(.+?)\s*$/i,
|
|
6
|
+
totalPoints: /^\+\s*TOTAL ARMY POINTS:\s*(\d+)\s*pts?\s*$/i,
|
|
7
|
+
};
|
|
8
|
+
const FENCE = /^\++\s*$/;
|
|
9
|
+
const HEADER_LINE = /^\+/;
|
|
10
|
+
const SECTION_HEADER = /^[A-Z][A-Z0-9 \-/&]+$/; // BATTLELINE, ALLIED UNITS, …
|
|
11
|
+
const UNIT_HEADER = /^(.+?)\s*\(\s*(\d+)\s*pts?\s*\)\s*$/i;
|
|
12
|
+
const BULLET_LINE = /^(\s*)•\s*(.+?)\s*$/u;
|
|
13
|
+
const NX_PREFIX = /^(\d+)x\s+(.+)$/;
|
|
14
|
+
const ENHANCEMENT_ANNOT = /^(.+?)\s*\(\+\s*(\d+)\s*pts?\s*\)\s*$/i;
|
|
15
|
+
const WITH_LINE = /^[\t ]*\d+\s+with\b/m;
|
|
16
|
+
const BULLET = /^[\t ]*•/mu;
|
|
17
|
+
const ALLIED_SECTION = "ALLIED UNITS";
|
|
18
|
+
const CHARACTERS_SECTION = "CHARACTERS";
|
|
19
|
+
const CHARACTER_SUFFIX = " Character";
|
|
20
|
+
const WARLORD_MARKER = "Warlord";
|
|
21
|
+
/** Accept the input only when it carries the FACTION KEYWORD summary header,
|
|
22
|
+
* has `•` bullets, and lacks the WTC `N with` body lines. */
|
|
23
|
+
function isGwText(decoded) {
|
|
24
|
+
if (typeof decoded !== "string")
|
|
25
|
+
return null;
|
|
26
|
+
if (!decoded.includes(FACTION_KEYWORD_PREFIX))
|
|
27
|
+
return null;
|
|
28
|
+
if (!BULLET.test(decoded))
|
|
29
|
+
return null;
|
|
30
|
+
if (WITH_LINE.test(decoded))
|
|
31
|
+
return null; // that's wtc-full
|
|
32
|
+
return decoded;
|
|
33
|
+
}
|
|
34
|
+
function parseHeader(lines) {
|
|
35
|
+
let faction_raw_name = null;
|
|
36
|
+
let detachment_raw_name = null;
|
|
37
|
+
let total_reported = null;
|
|
38
|
+
const fenceIndices = [];
|
|
39
|
+
for (let i = 0; i < lines.length && fenceIndices.length < 2; i += 1) {
|
|
40
|
+
if (FENCE.test(lines[i]))
|
|
41
|
+
fenceIndices.push(i);
|
|
42
|
+
}
|
|
43
|
+
let sawFactionKeyword = false;
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (!line.startsWith("+"))
|
|
46
|
+
continue;
|
|
47
|
+
const factionMatch = HEADER_FIELDS.faction.exec(line);
|
|
48
|
+
if (factionMatch) {
|
|
49
|
+
faction_raw_name = factionFromKeyword(factionMatch[1]);
|
|
50
|
+
sawFactionKeyword = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const detMatch = HEADER_FIELDS.detachment.exec(line);
|
|
54
|
+
if (detMatch) {
|
|
55
|
+
detachment_raw_name = stripParenthetical(detMatch[1]);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const ptsMatch = HEADER_FIELDS.totalPoints.exec(line);
|
|
59
|
+
if (ptsMatch) {
|
|
60
|
+
total_reported = Number.parseInt(ptsMatch[1], 10);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!sawFactionKeyword)
|
|
64
|
+
return null;
|
|
65
|
+
const bodyStart = fenceIndices.length >= 2 ? fenceIndices[1] + 1 : 0;
|
|
66
|
+
// The GW export has no POINTS LIMIT line — only TOTAL ARMY POINTS. Use it as
|
|
67
|
+
// the declared limit so the inferred battle size stays round-trippable.
|
|
68
|
+
const declared_limit = total_reported;
|
|
69
|
+
return {
|
|
70
|
+
header: {
|
|
71
|
+
name: "Imported roster",
|
|
72
|
+
faction_raw_name,
|
|
73
|
+
detachment_raw_name,
|
|
74
|
+
total_reported,
|
|
75
|
+
declared_limit,
|
|
76
|
+
battle_size_raw: inferBattleSizeRaw(declared_limit),
|
|
77
|
+
},
|
|
78
|
+
bodyStart,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function finishUnit(acc) {
|
|
82
|
+
const topIndent = acc.bullets.length
|
|
83
|
+
? Math.min(...acc.bullets.map((b) => b.indent))
|
|
84
|
+
: 0;
|
|
85
|
+
const wargear = new Map();
|
|
86
|
+
let model_count = 0;
|
|
87
|
+
let is_warlord = false;
|
|
88
|
+
let is_character = acc.section === CHARACTERS_SECTION;
|
|
89
|
+
let enhancement_raw_name = null;
|
|
90
|
+
let enhancement_points = null;
|
|
91
|
+
const addWargear = (raw_name, count) => {
|
|
92
|
+
wargear.set(raw_name, (wargear.get(raw_name) ?? 0) + count);
|
|
93
|
+
};
|
|
94
|
+
for (let i = 0; i < acc.bullets.length; i += 1) {
|
|
95
|
+
const b = acc.bullets[i];
|
|
96
|
+
// A child bullet (deeper than the unit's top level) is a model group's
|
|
97
|
+
// weapon — its `Nx` count is already the squad-wide total.
|
|
98
|
+
if (b.indent > topIndent) {
|
|
99
|
+
if (b.count !== null)
|
|
100
|
+
addWargear(b.text, b.count);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Top-level annotation (no `Nx` count): enhancement / character / warlord.
|
|
104
|
+
if (b.count === null) {
|
|
105
|
+
const enh = ENHANCEMENT_ANNOT.exec(b.text);
|
|
106
|
+
if (enh) {
|
|
107
|
+
if (enhancement_raw_name === null) {
|
|
108
|
+
enhancement_raw_name = enh[1].trim();
|
|
109
|
+
enhancement_points = Number.parseInt(enh[2], 10);
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (const token of b.text.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
114
|
+
if (token === WARLORD_MARKER)
|
|
115
|
+
is_warlord = true;
|
|
116
|
+
else if (token.endsWith(CHARACTER_SUFFIX))
|
|
117
|
+
is_character = true;
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Top-level `Nx` bullet: a model group when it has child bullets beneath
|
|
122
|
+
// it, otherwise plain wargear.
|
|
123
|
+
const next = acc.bullets[i + 1];
|
|
124
|
+
if (next && next.indent > topIndent) {
|
|
125
|
+
model_count += b.count;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
addWargear(b.text, b.count);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (model_count === 0)
|
|
132
|
+
model_count = 1;
|
|
133
|
+
// The GW unit header points include the enhancement; back it out to the base.
|
|
134
|
+
const displayed = acc.displayed_pts;
|
|
135
|
+
const points = displayed === null
|
|
136
|
+
? null
|
|
137
|
+
: enhancement_points !== null
|
|
138
|
+
? displayed - enhancement_points
|
|
139
|
+
: displayed;
|
|
140
|
+
const wargearList = [];
|
|
141
|
+
for (const [raw_name, count] of wargear)
|
|
142
|
+
wargearList.push({ raw_name, count });
|
|
143
|
+
return {
|
|
144
|
+
raw_name: acc.raw_name,
|
|
145
|
+
is_character,
|
|
146
|
+
model_count,
|
|
147
|
+
points,
|
|
148
|
+
is_warlord,
|
|
149
|
+
enhancement_raw_name,
|
|
150
|
+
enhancement_points,
|
|
151
|
+
wargear: wargearList,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function parseBody(lines, bodyStart) {
|
|
155
|
+
const units = [];
|
|
156
|
+
let current = null;
|
|
157
|
+
let section = null;
|
|
158
|
+
let alliedUnits = 0;
|
|
159
|
+
const finalize = () => {
|
|
160
|
+
if (current) {
|
|
161
|
+
units.push(finishUnit(current));
|
|
162
|
+
current = null;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
for (let i = bodyStart; i < lines.length; i += 1) {
|
|
166
|
+
const raw = lines[i];
|
|
167
|
+
const line = raw.trim();
|
|
168
|
+
if (!line || FENCE.test(line) || HEADER_LINE.test(line))
|
|
169
|
+
continue;
|
|
170
|
+
const bulletMatch = BULLET_LINE.exec(raw);
|
|
171
|
+
if (bulletMatch) {
|
|
172
|
+
if (current) {
|
|
173
|
+
const indent = bulletMatch[1].length;
|
|
174
|
+
const rest = bulletMatch[2];
|
|
175
|
+
const nx = NX_PREFIX.exec(rest);
|
|
176
|
+
current.bullets.push({
|
|
177
|
+
indent,
|
|
178
|
+
count: nx ? Number.parseInt(nx[1], 10) : null,
|
|
179
|
+
text: (nx ? nx[2] : rest).trim(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const unitMatch = UNIT_HEADER.exec(line);
|
|
185
|
+
if (unitMatch) {
|
|
186
|
+
finalize();
|
|
187
|
+
current = {
|
|
188
|
+
raw_name: unitMatch[1].trim(),
|
|
189
|
+
displayed_pts: Number.parseInt(unitMatch[2], 10),
|
|
190
|
+
section,
|
|
191
|
+
bullets: [],
|
|
192
|
+
};
|
|
193
|
+
if (section === ALLIED_SECTION)
|
|
194
|
+
alliedUnits += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (SECTION_HEADER.test(line)) {
|
|
198
|
+
finalize();
|
|
199
|
+
section = line;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
finalize();
|
|
203
|
+
return { units, multi_force: alliedUnits > 0 };
|
|
204
|
+
}
|
|
205
|
+
export const gwAdapter = {
|
|
206
|
+
id: "gw",
|
|
207
|
+
matches(decoded) {
|
|
208
|
+
return isGwText(decoded) !== null;
|
|
209
|
+
},
|
|
210
|
+
parse(decoded) {
|
|
211
|
+
const text = isGwText(decoded);
|
|
212
|
+
if (text === null)
|
|
213
|
+
throw new Error("gw: input is not a GW app text export");
|
|
214
|
+
const lines = text.split(/\r?\n/);
|
|
215
|
+
const parsed = parseHeader(lines);
|
|
216
|
+
if (!parsed)
|
|
217
|
+
throw new Error('gw: missing "+ FACTION KEYWORD:" header');
|
|
218
|
+
const { header, bodyStart } = parsed;
|
|
219
|
+
const { units, multi_force } = parseBody(lines, bodyStart);
|
|
220
|
+
let total_computed = 0;
|
|
221
|
+
for (const u of units) {
|
|
222
|
+
total_computed += u.points ?? 0;
|
|
223
|
+
total_computed += u.enhancement_points ?? 0;
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
name: header.name,
|
|
227
|
+
generated_by: null,
|
|
228
|
+
faction_raw_name: header.faction_raw_name,
|
|
229
|
+
detachment_raw_name: header.detachment_raw_name,
|
|
230
|
+
battle_size_raw: header.battle_size_raw,
|
|
231
|
+
declared_limit: header.declared_limit,
|
|
232
|
+
total_reported: header.total_reported,
|
|
233
|
+
total_computed,
|
|
234
|
+
units,
|
|
235
|
+
multi_force,
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
// Internals re-exported for unit tests.
|
|
240
|
+
export const _internals = {
|
|
241
|
+
isGwText,
|
|
242
|
+
parseHeader,
|
|
243
|
+
parseBody,
|
|
244
|
+
};
|
|
245
|
+
//# sourceMappingURL=gw.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gw.js","sourceRoot":"","sources":["../../src/import/gw.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * GW adapter: lower the Games Workshop 40K app's plain-text army-list export to\n * a {@link ParsedRoster}.\n *\n * The format opens with the same `++++…++++` summary fence as the NewRecruit WTC\n * formats (FACTION KEYWORD / DETACHMENT / TOTAL ARMY POINTS / WARLORD /\n * ENHANCEMENT / NUMBER OF UNITS / SECONDARY), then lists units grouped under\n * ALL-CAPS battlefield-role sections (`BATTLELINE`, `CHARACTERS`,\n * `ALLIED UNITS`, …). Each unit is a header line `Name (N pts)` followed by\n * `•`-bulleted entries:\n *\n * ```\n * War Dog Executioner (130 pts)\n * • 1x Armoured feet\n * • 2x War Dog autocannon\n * • Houndpack Lance Character, Warlord\n *\n * Nurglings (40 pts)\n * • 3x Nurgling Swarm\n * • 3x Diseased claws and teeth\n * ```\n *\n * Bullet classification (the parsing crux):\n * - A top-level `• Nx Thing` *with* further-indented child bullets is a **model\n * group** — `N` adds to the model count and the children are that group's\n * wargear (Nurglings, Beasts of Nurgle).\n * - A top-level `• Nx Thing` *without* children is plain **wargear**.\n * - A bullet *without* an `Nx` count is an **annotation**: `… Character` flags a\n * character, `Warlord` flags the warlord, `Name (+N pts)` is the enhancement.\n *\n * **Disjointness from the WTC matchers**: the GW format always carries `•`\n * bullets and never the WTC `N with` lines. wtc-full always has `N with` (so it\n * never collides), and wtc-compact never has bullets (its matcher now excludes\n * them). This adapter therefore matches on *bullets present* + *no `N with`*.\n *\n * The GW export carries no separate POINTS LIMIT line, so `declared_limit`\n * falls back to TOTAL ARMY POINTS (the round-trippable battle-size signal).\n *\n * @packageDocumentation\n */\nimport type { FormatAdapter } from \"./adapter.js\";\nimport type { ParsedRoster, ParsedUnit, ParsedWargear } from \"./types.js\";\nimport {\n factionFromKeyword,\n inferBattleSizeRaw,\n stripParenthetical,\n} from \"./newrecruit-text.js\";\n\nconst FACTION_KEYWORD_PREFIX = \"+ FACTION KEYWORD:\";\n\nconst HEADER_FIELDS = {\n faction: /^\\+\\s*FACTION KEYWORD:\\s*(.+?)\\s*$/i,\n detachment: /^\\+\\s*DETACHMENT:\\s*(.+?)\\s*$/i,\n totalPoints: /^\\+\\s*TOTAL ARMY POINTS:\\s*(\\d+)\\s*pts?\\s*$/i,\n} as const;\n\nconst FENCE = /^\\++\\s*$/;\nconst HEADER_LINE = /^\\+/;\nconst SECTION_HEADER = /^[A-Z][A-Z0-9 \\-/&]+$/; // BATTLELINE, ALLIED UNITS, …\nconst UNIT_HEADER = /^(.+?)\\s*\\(\\s*(\\d+)\\s*pts?\\s*\\)\\s*$/i;\nconst BULLET_LINE = /^(\\s*)•\\s*(.+?)\\s*$/u;\nconst NX_PREFIX = /^(\\d+)x\\s+(.+)$/;\nconst ENHANCEMENT_ANNOT = /^(.+?)\\s*\\(\\+\\s*(\\d+)\\s*pts?\\s*\\)\\s*$/i;\nconst WITH_LINE = /^[\\t ]*\\d+\\s+with\\b/m;\nconst BULLET = /^[\\t ]*•/mu;\n\nconst ALLIED_SECTION = \"ALLIED UNITS\";\nconst CHARACTERS_SECTION = \"CHARACTERS\";\nconst CHARACTER_SUFFIX = \" Character\";\nconst WARLORD_MARKER = \"Warlord\";\n\n/** Accept the input only when it carries the FACTION KEYWORD summary header,\n * has `•` bullets, and lacks the WTC `N with` body lines. */\nfunction isGwText(decoded: unknown): string | null {\n if (typeof decoded !== \"string\") return null;\n if (!decoded.includes(FACTION_KEYWORD_PREFIX)) return null;\n if (!BULLET.test(decoded)) return null;\n if (WITH_LINE.test(decoded)) return null; // that's wtc-full\n return decoded;\n}\n\ninterface GwHeader {\n name: string;\n faction_raw_name: string | null;\n detachment_raw_name: string | null;\n total_reported: number | null;\n declared_limit: number | null;\n battle_size_raw: string | null;\n}\n\nfunction parseHeader(lines: string[]): { header: GwHeader; bodyStart: number } | null {\n let faction_raw_name: string | null = null;\n let detachment_raw_name: string | null = null;\n let total_reported: number | null = null;\n\n const fenceIndices: number[] = [];\n for (let i = 0; i < lines.length && fenceIndices.length < 2; i += 1) {\n if (FENCE.test(lines[i])) fenceIndices.push(i);\n }\n\n let sawFactionKeyword = false;\n for (const line of lines) {\n if (!line.startsWith(\"+\")) continue;\n const factionMatch = HEADER_FIELDS.faction.exec(line);\n if (factionMatch) {\n faction_raw_name = factionFromKeyword(factionMatch[1]);\n sawFactionKeyword = true;\n continue;\n }\n const detMatch = HEADER_FIELDS.detachment.exec(line);\n if (detMatch) {\n detachment_raw_name = stripParenthetical(detMatch[1]);\n continue;\n }\n const ptsMatch = HEADER_FIELDS.totalPoints.exec(line);\n if (ptsMatch) {\n total_reported = Number.parseInt(ptsMatch[1], 10);\n }\n }\n\n if (!sawFactionKeyword) return null;\n\n const bodyStart = fenceIndices.length >= 2 ? fenceIndices[1] + 1 : 0;\n // The GW export has no POINTS LIMIT line — only TOTAL ARMY POINTS. Use it as\n // the declared limit so the inferred battle size stays round-trippable.\n const declared_limit = total_reported;\n return {\n header: {\n name: \"Imported roster\",\n faction_raw_name,\n detachment_raw_name,\n total_reported,\n declared_limit,\n battle_size_raw: inferBattleSizeRaw(declared_limit),\n },\n bodyStart,\n };\n}\n\ninterface Bullet {\n indent: number;\n count: number | null;\n text: string;\n}\n\ninterface UnitAcc {\n raw_name: string;\n displayed_pts: number | null;\n section: string | null;\n bullets: Bullet[];\n}\n\nfunction finishUnit(acc: UnitAcc): ParsedUnit {\n const topIndent = acc.bullets.length\n ? Math.min(...acc.bullets.map((b) => b.indent))\n : 0;\n\n const wargear = new Map<string, number>();\n let model_count = 0;\n let is_warlord = false;\n let is_character = acc.section === CHARACTERS_SECTION;\n let enhancement_raw_name: string | null = null;\n let enhancement_points: number | null = null;\n\n const addWargear = (raw_name: string, count: number): void => {\n wargear.set(raw_name, (wargear.get(raw_name) ?? 0) + count);\n };\n\n for (let i = 0; i < acc.bullets.length; i += 1) {\n const b = acc.bullets[i];\n\n // A child bullet (deeper than the unit's top level) is a model group's\n // weapon — its `Nx` count is already the squad-wide total.\n if (b.indent > topIndent) {\n if (b.count !== null) addWargear(b.text, b.count);\n continue;\n }\n\n // Top-level annotation (no `Nx` count): enhancement / character / warlord.\n if (b.count === null) {\n const enh = ENHANCEMENT_ANNOT.exec(b.text);\n if (enh) {\n if (enhancement_raw_name === null) {\n enhancement_raw_name = enh[1].trim();\n enhancement_points = Number.parseInt(enh[2], 10);\n }\n continue;\n }\n for (const token of b.text.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n if (token === WARLORD_MARKER) is_warlord = true;\n else if (token.endsWith(CHARACTER_SUFFIX)) is_character = true;\n }\n continue;\n }\n\n // Top-level `Nx` bullet: a model group when it has child bullets beneath\n // it, otherwise plain wargear.\n const next = acc.bullets[i + 1];\n if (next && next.indent > topIndent) {\n model_count += b.count;\n } else {\n addWargear(b.text, b.count);\n }\n }\n\n if (model_count === 0) model_count = 1;\n\n // The GW unit header points include the enhancement; back it out to the base.\n const displayed = acc.displayed_pts;\n const points =\n displayed === null\n ? null\n : enhancement_points !== null\n ? displayed - enhancement_points\n : displayed;\n\n const wargearList: ParsedWargear[] = [];\n for (const [raw_name, count] of wargear) wargearList.push({ raw_name, count });\n\n return {\n raw_name: acc.raw_name,\n is_character,\n model_count,\n points,\n is_warlord,\n enhancement_raw_name,\n enhancement_points,\n wargear: wargearList,\n };\n}\n\nfunction parseBody(lines: string[], bodyStart: number): {\n units: ParsedUnit[];\n multi_force: boolean;\n} {\n const units: ParsedUnit[] = [];\n let current: UnitAcc | null = null;\n let section: string | null = null;\n let alliedUnits = 0;\n\n const finalize = (): void => {\n if (current) {\n units.push(finishUnit(current));\n current = null;\n }\n };\n\n for (let i = bodyStart; i < lines.length; i += 1) {\n const raw = lines[i];\n const line = raw.trim();\n if (!line || FENCE.test(line) || HEADER_LINE.test(line)) continue;\n\n const bulletMatch = BULLET_LINE.exec(raw);\n if (bulletMatch) {\n if (current) {\n const indent = bulletMatch[1].length;\n const rest = bulletMatch[2];\n const nx = NX_PREFIX.exec(rest);\n current.bullets.push({\n indent,\n count: nx ? Number.parseInt(nx[1], 10) : null,\n text: (nx ? nx[2] : rest).trim(),\n });\n }\n continue;\n }\n\n const unitMatch = UNIT_HEADER.exec(line);\n if (unitMatch) {\n finalize();\n current = {\n raw_name: unitMatch[1].trim(),\n displayed_pts: Number.parseInt(unitMatch[2], 10),\n section,\n bullets: [],\n };\n if (section === ALLIED_SECTION) alliedUnits += 1;\n continue;\n }\n\n if (SECTION_HEADER.test(line)) {\n finalize();\n section = line;\n }\n }\n\n finalize();\n return { units, multi_force: alliedUnits > 0 };\n}\n\nexport const gwAdapter: FormatAdapter = {\n id: \"gw\",\n\n matches(decoded: unknown): boolean {\n return isGwText(decoded) !== null;\n },\n\n parse(decoded: unknown): ParsedRoster {\n const text = isGwText(decoded);\n if (text === null) throw new Error(\"gw: input is not a GW app text export\");\n\n const lines = text.split(/\\r?\\n/);\n const parsed = parseHeader(lines);\n if (!parsed) throw new Error('gw: missing \"+ FACTION KEYWORD:\" header');\n const { header, bodyStart } = parsed;\n\n const { units, multi_force } = parseBody(lines, bodyStart);\n\n let total_computed = 0;\n for (const u of units) {\n total_computed += u.points ?? 0;\n total_computed += u.enhancement_points ?? 0;\n }\n\n return {\n name: header.name,\n generated_by: null,\n faction_raw_name: header.faction_raw_name,\n detachment_raw_name: header.detachment_raw_name,\n battle_size_raw: header.battle_size_raw,\n declared_limit: header.declared_limit,\n total_reported: header.total_reported,\n total_computed,\n units,\n multi_force,\n };\n },\n};\n\n// Internals re-exported for unit tests.\nexport const _internals = {\n isGwText,\n parseHeader,\n parseBody,\n};\n"]}
|
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The adapter seam ({@link FormatAdapter}) lets every supported source format
|
|
5
5
|
* plug in here without touching {@link decode} or {@link resolve}. Adapters are
|
|
6
|
-
* registered in priority order
|
|
7
|
-
*
|
|
6
|
+
* registered in priority order, and every adapter's `matches()` predicate is
|
|
7
|
+
* tight enough that **at most one** matches any given decoded payload —
|
|
8
|
+
* {@link tryImportRoster} relies on that disjointness to short-circuit on the
|
|
9
|
+
* first match.
|
|
8
10
|
*
|
|
9
11
|
* @packageDocumentation
|
|
10
12
|
*/
|
|
11
13
|
import { Dataset } from "../data/dataset.js";
|
|
12
|
-
import type {
|
|
14
|
+
import type { FormatAdapter } from "./adapter.js";
|
|
15
|
+
import type { Roster, RosterFormat } from "./types.js";
|
|
13
16
|
export interface ImportOptions {
|
|
14
17
|
/** Dataset to resolve against. Defaults to the package's embedded dataset. */
|
|
15
18
|
dataset?: Dataset;
|
|
@@ -32,4 +35,50 @@ export declare function importListForge(input: string, opts?: ImportOptions): Ro
|
|
|
32
35
|
*/
|
|
33
36
|
export declare function importNewRecruit(input: string, opts?: ImportOptions): Roster;
|
|
34
37
|
export declare function importRoster(decoded: unknown, opts?: ImportOptions): Roster;
|
|
38
|
+
/** Why a {@link tryImportRoster} call did not produce a roster. */
|
|
39
|
+
export type ImportFailureReason = "empty-input" | "decode-failed" | "no-adapter-matched" | "parse-failed";
|
|
40
|
+
/** Per-adapter outcome from a {@link tryImportRoster} dispatch. */
|
|
41
|
+
export interface AdapterTrial {
|
|
42
|
+
id: RosterFormat;
|
|
43
|
+
/** True iff this adapter's `matches()` predicate accepted the decoded input. */
|
|
44
|
+
matched: boolean;
|
|
45
|
+
/** Present when {@link matched} is true and `parse()` then threw — the matcher
|
|
46
|
+
* violated its contract. Absent for clean rejections. */
|
|
47
|
+
reason?: string;
|
|
48
|
+
}
|
|
49
|
+
/** Discriminated result returned by {@link tryImportRoster}. */
|
|
50
|
+
export type ImportResult = {
|
|
51
|
+
ok: true;
|
|
52
|
+
roster: Roster;
|
|
53
|
+
format: RosterFormat;
|
|
54
|
+
} | {
|
|
55
|
+
ok: false;
|
|
56
|
+
reason: ImportFailureReason;
|
|
57
|
+
message: string;
|
|
58
|
+
trials: AdapterTrial[];
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Auto-detect and import any supported roster format from a single string.
|
|
62
|
+
*
|
|
63
|
+
* Pipeline:
|
|
64
|
+
* 1. Empty input → `empty-input`.
|
|
65
|
+
* 2. Looks like a ListForge URL / base64 payload → decode (base64 + gunzip + JSON.parse).
|
|
66
|
+
* 3. Looks like raw JSON (starts with `{`/`[`) → JSON.parse.
|
|
67
|
+
* 4. Otherwise treat as text.
|
|
68
|
+
* 5. Greedy first-match adapter dispatch. The first adapter whose `matches()`
|
|
69
|
+
* accepts the decoded value wins; subsequent adapters are not tried.
|
|
70
|
+
* 6. If the matched adapter's `parse()` throws, that's a matcher contract
|
|
71
|
+
* violation — surfaced as `parse-failed`, not silently retried.
|
|
72
|
+
*
|
|
73
|
+
* Caller never sees an exception; the discriminated {@link ImportResult} carries
|
|
74
|
+
* either the resolved {@link Roster} (with the detected {@link RosterFormat})
|
|
75
|
+
* or a typed failure plus per-adapter trial info for diagnostics.
|
|
76
|
+
*
|
|
77
|
+
* Prefer this over {@link importListForge} / {@link importNewRecruit} when the
|
|
78
|
+
* caller doesn't know which format the user pasted.
|
|
79
|
+
*/
|
|
80
|
+
export declare function tryImportRoster(input: string, opts?: ImportOptions): ImportResult;
|
|
81
|
+
/** The adapter list, exposed for tests that need to walk every matcher (e.g.
|
|
82
|
+
* the disjointness invariant test). */
|
|
83
|
+
export declare const REGISTERED_ADAPTERS: readonly FormatAdapter[];
|
|
35
84
|
//# sourceMappingURL=import-roster.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"import-roster.d.ts","sourceRoot":"","sources":["../../src/import/import-roster.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"import-roster.d.ts","sourceRoot":"","sources":["../../src/import/import-roster.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAC7C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAalD,OAAO,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AA2BvD,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAG/E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAUhF;AAyBD,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAM/E;AAMD,mEAAmE;AACnE,MAAM,MAAM,mBAAmB,GAC3B,aAAa,GACb,eAAe,GACf,oBAAoB,GACpB,cAAc,CAAC;AAEnB,mEAAmE;AACnE,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,YAAY,CAAC;IACjB,gFAAgF;IAChF,OAAO,EAAE,OAAO,CAAC;IACjB;6DACyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,gEAAgE;AAChE,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,YAAY,CAAA;CAAE,GAClD;IACE,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EAAE,mBAAmB,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,YAAY,EAAE,CAAC;CACxB,CAAC;AAWN;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,aAAkB,GACvB,YAAY,CA+Dd;AAED;uCACuC;AACvC,eAAO,MAAM,mBAAmB,EAAE,SAAS,aAAa,EAAa,CAAC"}
|
|
@@ -3,32 +3,42 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The adapter seam ({@link FormatAdapter}) lets every supported source format
|
|
5
5
|
* plug in here without touching {@link decode} or {@link resolve}. Adapters are
|
|
6
|
-
* registered in priority order
|
|
7
|
-
*
|
|
6
|
+
* registered in priority order, and every adapter's `matches()` predicate is
|
|
7
|
+
* tight enough that **at most one** matches any given decoded payload —
|
|
8
|
+
* {@link tryImportRoster} relies on that disjointness to short-circuit on the
|
|
9
|
+
* first match.
|
|
8
10
|
*
|
|
9
11
|
* @packageDocumentation
|
|
10
12
|
*/
|
|
11
13
|
import { Dataset } from "../data/dataset.js";
|
|
12
14
|
import { selectAdapter } from "./adapter.js";
|
|
13
15
|
import { decodeListForge } from "./decode.js";
|
|
16
|
+
import { gwAdapter } from "./gw.js";
|
|
14
17
|
import { listForgeAdapter } from "./listforge.js";
|
|
15
18
|
import { newRecruitJsonAdapter } from "./newrecruit-json.js";
|
|
16
19
|
import { newRecruitSimpleAdapter } from "./newrecruit-simple.js";
|
|
17
20
|
import { newRecruitWtcCompactAdapter, newRecruitWtcFullAdapter, } from "./newrecruit-wtc.js";
|
|
18
21
|
import { resolve } from "./resolve.js";
|
|
22
|
+
import { rosterizerAdapter } from "./rosterizer.js";
|
|
19
23
|
/**
|
|
20
24
|
* Adapters available to {@link importRoster}, in match-priority order.
|
|
21
25
|
*
|
|
22
26
|
* NewRecruit-JSON runs ahead of ListForge because both recognise a
|
|
23
27
|
* `roster.forces` BattleScribe payload, and the NewRecruit signature is more
|
|
24
28
|
* specific (`xmlns: rosterSchema` or `generatedBy: newrecruit.eu`). The text
|
|
25
|
-
* adapters (`wtc-compact` / `wtc-full` / `simple`) only match strings and
|
|
29
|
+
* adapters (`gw` / `wtc-compact` / `wtc-full` / `simple`) only match strings and
|
|
26
30
|
* disambiguate among themselves via structural cues, so their order amongst
|
|
27
31
|
* each other doesn't matter; wtc-full goes before wtc-compact because its
|
|
28
|
-
* matcher is the more specific of the two.
|
|
32
|
+
* matcher is the more specific of the two. GW shares the WTC summary header but
|
|
33
|
+
* carries `•` bullets and no `N with` lines, so it stays disjoint from both wtc
|
|
34
|
+
* matchers. Rosterizer rides at the top of the JSON dispatch — its `rulebook` +
|
|
35
|
+
* `snapshot` signature is structurally distinct from the BattleScribe
|
|
36
|
+
* `roster.forces` shape.
|
|
29
37
|
*/
|
|
30
38
|
const ADAPTERS = [
|
|
39
|
+
rosterizerAdapter,
|
|
31
40
|
newRecruitJsonAdapter,
|
|
41
|
+
gwAdapter,
|
|
32
42
|
newRecruitWtcFullAdapter,
|
|
33
43
|
newRecruitWtcCompactAdapter,
|
|
34
44
|
newRecruitSimpleAdapter,
|
|
@@ -94,4 +104,104 @@ export function importRoster(decoded, opts = {}) {
|
|
|
94
104
|
const parsed = adapter.parse(decoded);
|
|
95
105
|
return resolve(parsed, ds, adapter.id);
|
|
96
106
|
}
|
|
107
|
+
/** Cheap predicate: does the input look like ListForge's URL-or-base64 wrapper? */
|
|
108
|
+
function looksLikeListForgeEncoded(input) {
|
|
109
|
+
if (input.includes("/listforge/"))
|
|
110
|
+
return true;
|
|
111
|
+
if (/^https?:\/\//i.test(input))
|
|
112
|
+
return true;
|
|
113
|
+
// Every gzip-then-base64 payload starts with this prefix.
|
|
114
|
+
if (input.startsWith("H4sIA"))
|
|
115
|
+
return true;
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Auto-detect and import any supported roster format from a single string.
|
|
120
|
+
*
|
|
121
|
+
* Pipeline:
|
|
122
|
+
* 1. Empty input → `empty-input`.
|
|
123
|
+
* 2. Looks like a ListForge URL / base64 payload → decode (base64 + gunzip + JSON.parse).
|
|
124
|
+
* 3. Looks like raw JSON (starts with `{`/`[`) → JSON.parse.
|
|
125
|
+
* 4. Otherwise treat as text.
|
|
126
|
+
* 5. Greedy first-match adapter dispatch. The first adapter whose `matches()`
|
|
127
|
+
* accepts the decoded value wins; subsequent adapters are not tried.
|
|
128
|
+
* 6. If the matched adapter's `parse()` throws, that's a matcher contract
|
|
129
|
+
* violation — surfaced as `parse-failed`, not silently retried.
|
|
130
|
+
*
|
|
131
|
+
* Caller never sees an exception; the discriminated {@link ImportResult} carries
|
|
132
|
+
* either the resolved {@link Roster} (with the detected {@link RosterFormat})
|
|
133
|
+
* or a typed failure plus per-adapter trial info for diagnostics.
|
|
134
|
+
*
|
|
135
|
+
* Prefer this over {@link importListForge} / {@link importNewRecruit} when the
|
|
136
|
+
* caller doesn't know which format the user pasted.
|
|
137
|
+
*/
|
|
138
|
+
export function tryImportRoster(input, opts = {}) {
|
|
139
|
+
const trimmed = input.trim();
|
|
140
|
+
if (trimmed === "") {
|
|
141
|
+
return { ok: false, reason: "empty-input", message: "input is empty", trials: [] };
|
|
142
|
+
}
|
|
143
|
+
let decoded;
|
|
144
|
+
if (looksLikeListForgeEncoded(trimmed)) {
|
|
145
|
+
try {
|
|
146
|
+
decoded = decodeListForge(trimmed);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
const message = err.message;
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
reason: "decode-failed",
|
|
153
|
+
message: `failed to decode ListForge payload: ${message}`,
|
|
154
|
+
trials: [{ id: "listforge", matched: false, reason: message }],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
159
|
+
try {
|
|
160
|
+
decoded = JSON.parse(trimmed);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
reason: "decode-failed",
|
|
166
|
+
message: `input looks like JSON but failed to parse: ${err.message}`,
|
|
167
|
+
trials: [],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
decoded = input;
|
|
173
|
+
}
|
|
174
|
+
const ds = opts.dataset ?? Dataset.embedded();
|
|
175
|
+
const trials = [];
|
|
176
|
+
for (const adapter of ADAPTERS) {
|
|
177
|
+
if (!adapter.matches(decoded)) {
|
|
178
|
+
trials.push({ id: adapter.id, matched: false });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const parsed = adapter.parse(decoded);
|
|
183
|
+
const roster = resolve(parsed, ds, adapter.id);
|
|
184
|
+
return { ok: true, roster, format: adapter.id };
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
const message = err.message;
|
|
188
|
+
trials.push({ id: adapter.id, matched: true, reason: message });
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
reason: "parse-failed",
|
|
192
|
+
message: `${adapter.id}: ${message}`,
|
|
193
|
+
trials,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
reason: "no-adapter-matched",
|
|
200
|
+
message: `tried ${ADAPTERS.length} formats, none recognised the input`,
|
|
201
|
+
trials,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** The adapter list, exposed for tests that need to walk every matcher (e.g.
|
|
205
|
+
* the disjointness invariant test). */
|
|
206
|
+
export const REGISTERED_ADAPTERS = ADAPTERS;
|
|
97
207
|
//# sourceMappingURL=import-roster.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"import-roster.js","sourceRoot":"","sources":["../../src/import/import-roster.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAE7C,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EACL,2BAA2B,EAC3B,wBAAwB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAGvC;;;;;;;;;;GAUG;AACH,MAAM,QAAQ,GAA6B;IACzC,qBAAqB;IACrB,wBAAwB;IACxB,2BAA2B;IAC3B,uBAAuB;IACvB,gBAAgB;CACjB,CAAC;AAOF;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,KAAa,EAAE,OAAsB,EAAE;IACrE,MAAM,OAAO,GAAG,eAAe,CAAC,KAAK,CAAC,CAAC;IACvC,OAAO,YAAY,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;AACrC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa,EAAE,OAAsB,EAAE;IACtE,MAAM,OAAO,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC;IAClC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QACvD,IAAI,CAAC;YACH,OAAO,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC,CAAC;QAC/C,CAAC;QAAC,MAAM,CAAC;YACP,kDAAkD;QACpD,CAAC;IACH,CAAC;IACD,OAAO,YAAY,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;AACnC,CAAC;AAED;;;;GAIG;AACH;;;;GAIG;AACH,SAAS,iBAAiB,CAAC,OAAgB;IACzC,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAClE,MAAM,CAAC,GAAG,OAAkC,CAAC;IAC7C,MAAM,MAAM,GAAG,CAAC,CAAC,MAA6C,CAAC;IAC/D,OAAO,CACL,OAAO,MAAM,KAAK,QAAQ;QAC1B,MAAM,KAAK,IAAI;QACf,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ;QACjC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC;QACtB,aAAa,IAAI,CAAC,CACnB,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,OAAgB,EAAE,OAAsB,EAAE;IACrE,IAAI,iBAAiB,CAAC,OAAO,CAAC;QAAE,OAAO,OAAO,CAAC;IAC/C,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;IAC9C,MAAM,OAAO,GAAG,aAAa,CAAC,OAAO,EAAE,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC;IACtD,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACtC,OAAO,OAAO,CAAC,MAAM,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;AACzC,CAAC","sourcesContent":["/**\n * Orchestrates an army-list import: decode → parse → resolve.\n *\n * The adapter seam ({@link FormatAdapter}) lets every supported source format\n * plug in here without touching {@link decode} or {@link resolve}. Adapters are\n * registered in priority order — NewRecruit's tighter matchers run first so\n * the ListForge fallback only catches generic BattleScribe JSON.\n *\n * @packageDocumentation\n */\nimport { Dataset } from \"../data/dataset.js\";\nimport type { FormatAdapter } from \"./adapter.js\";\nimport { selectAdapter } from \"./adapter.js\";\nimport { decodeListForge } from \"./decode.js\";\nimport { listForgeAdapter } from \"./listforge.js\";\nimport { newRecruitJsonAdapter } from \"./newrecruit-json.js\";\nimport { newRecruitSimpleAdapter } from \"./newrecruit-simple.js\";\nimport {\n newRecruitWtcCompactAdapter,\n newRecruitWtcFullAdapter,\n} from \"./newrecruit-wtc.js\";\nimport { resolve } from \"./resolve.js\";\nimport type { Roster } from \"./types.js\";\n\n/**\n * Adapters available to {@link importRoster}, in match-priority order.\n *\n * NewRecruit-JSON runs ahead of ListForge because both recognise a\n * `roster.forces` BattleScribe payload, and the NewRecruit signature is more\n * specific (`xmlns: rosterSchema` or `generatedBy: newrecruit.eu`). The text\n * adapters (`wtc-compact` / `wtc-full` / `simple`) only match strings and\n * disambiguate among themselves via structural cues, so their order amongst\n * each other doesn't matter; wtc-full goes before wtc-compact because its\n * matcher is the more specific of the two.\n */\nconst ADAPTERS: readonly FormatAdapter[] = [\n newRecruitJsonAdapter,\n newRecruitWtcFullAdapter,\n newRecruitWtcCompactAdapter,\n newRecruitSimpleAdapter,\n listForgeAdapter,\n];\n\nexport interface ImportOptions {\n /** Dataset to resolve against. Defaults to the package's embedded dataset. */\n dataset?: Dataset;\n}\n\n/**\n * Import a ListForge share payload into a resolved 40kdc {@link Roster}.\n *\n * `input` may be a full ListForge URL, a bare base64 segment, or an\n * already-decoded JSON string — all are handled transparently. For NewRecruit\n * sources, use {@link importNewRecruit} (no base64/gzip decode).\n */\nexport function importListForge(input: string, opts: ImportOptions = {}): Roster {\n const decoded = decodeListForge(input);\n return importRoster(decoded, opts);\n}\n\n/**\n * Import a NewRecruit export (any of the four formats — JSON, wtc-compact,\n * wtc-full, simple) into a resolved 40kdc {@link Roster}.\n *\n * The JSON form is parsed when `input` is valid JSON; the text forms are\n * dispatched on string content. No base64/gzip decoding is attempted —\n * NewRecruit exports are not encoded.\n */\nexport function importNewRecruit(input: string, opts: ImportOptions = {}): Roster {\n const trimmed = input.trimStart();\n if (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n try {\n return importRoster(JSON.parse(input), opts);\n } catch {\n // Fall through to treating the input as raw text.\n }\n }\n return importRoster(input, opts);\n}\n\n/**\n * Import an already-decoded payload. Selects the matching format adapter and\n * resolves the result against the dataset. Accepts either a parsed JSON object\n * (NewRecruit JSON / ListForge) or a string (the three NewRecruit text formats).\n */\n/**\n * Detect an already-resolved canonical {@link Roster} (the JSON shape produced\n * by `rosterJsonSerializer`). Lets a downstream consumer round-trip canonical\n * Roster JSON through `importRoster` without going through an adapter.\n */\nfunction isCanonicalRoster(decoded: unknown): decoded is Roster {\n if (typeof decoded !== \"object\" || decoded === null) return false;\n const r = decoded as Record<string, unknown>;\n const source = r.source as Record<string, unknown> | undefined;\n return (\n typeof source === \"object\" &&\n source !== null &&\n typeof source.format === \"string\" &&\n Array.isArray(r.units) &&\n \"diagnostics\" in r\n );\n}\n\nexport function importRoster(decoded: unknown, opts: ImportOptions = {}): Roster {\n if (isCanonicalRoster(decoded)) return decoded;\n const ds = opts.dataset ?? Dataset.embedded();\n const adapter = selectAdapter(decoded, [...ADAPTERS]);\n const parsed = adapter.parse(decoded);\n return resolve(parsed, ds, adapter.id);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"import-roster.js","sourceRoot":"","sources":["../../src/import/import-roster.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Orchestrates an army-list import: decode → parse → resolve.\n *\n * The adapter seam ({@link FormatAdapter}) lets every supported source format\n * plug in here without touching {@link decode} or {@link resolve}. Adapters are\n * registered in priority order, and every adapter's `matches()` predicate is\n * tight enough that **at most one** matches any given decoded payload —\n * {@link tryImportRoster} relies on that disjointness to short-circuit on the\n * first match.\n *\n * @packageDocumentation\n */\nimport { Dataset } from \"../data/dataset.js\";\nimport type { FormatAdapter } from \"./adapter.js\";\nimport { selectAdapter } from \"./adapter.js\";\nimport { decodeListForge } from \"./decode.js\";\nimport { gwAdapter } from \"./gw.js\";\nimport { listForgeAdapter } from \"./listforge.js\";\nimport { newRecruitJsonAdapter } from \"./newrecruit-json.js\";\nimport { newRecruitSimpleAdapter } from \"./newrecruit-simple.js\";\nimport {\n newRecruitWtcCompactAdapter,\n newRecruitWtcFullAdapter,\n} from \"./newrecruit-wtc.js\";\nimport { resolve } from \"./resolve.js\";\nimport { rosterizerAdapter } from \"./rosterizer.js\";\nimport type { Roster, RosterFormat } from \"./types.js\";\n\n/**\n * Adapters available to {@link importRoster}, in match-priority order.\n *\n * NewRecruit-JSON runs ahead of ListForge because both recognise a\n * `roster.forces` BattleScribe payload, and the NewRecruit signature is more\n * specific (`xmlns: rosterSchema` or `generatedBy: newrecruit.eu`). The text\n * adapters (`gw` / `wtc-compact` / `wtc-full` / `simple`) only match strings and\n * disambiguate among themselves via structural cues, so their order amongst\n * each other doesn't matter; wtc-full goes before wtc-compact because its\n * matcher is the more specific of the two. GW shares the WTC summary header but\n * carries `•` bullets and no `N with` lines, so it stays disjoint from both wtc\n * matchers. Rosterizer rides at the top of the JSON dispatch — its `rulebook` +\n * `snapshot` signature is structurally distinct from the BattleScribe\n * `roster.forces` shape.\n */\nconst ADAPTERS: readonly FormatAdapter[] = [\n rosterizerAdapter,\n newRecruitJsonAdapter,\n gwAdapter,\n newRecruitWtcFullAdapter,\n newRecruitWtcCompactAdapter,\n newRecruitSimpleAdapter,\n listForgeAdapter,\n];\n\nexport interface ImportOptions {\n /** Dataset to resolve against. Defaults to the package's embedded dataset. */\n dataset?: Dataset;\n}\n\n/**\n * Import a ListForge share payload into a resolved 40kdc {@link Roster}.\n *\n * `input` may be a full ListForge URL, a bare base64 segment, or an\n * already-decoded JSON string — all are handled transparently. For NewRecruit\n * sources, use {@link importNewRecruit} (no base64/gzip decode).\n */\nexport function importListForge(input: string, opts: ImportOptions = {}): Roster {\n const decoded = decodeListForge(input);\n return importRoster(decoded, opts);\n}\n\n/**\n * Import a NewRecruit export (any of the four formats — JSON, wtc-compact,\n * wtc-full, simple) into a resolved 40kdc {@link Roster}.\n *\n * The JSON form is parsed when `input` is valid JSON; the text forms are\n * dispatched on string content. No base64/gzip decoding is attempted —\n * NewRecruit exports are not encoded.\n */\nexport function importNewRecruit(input: string, opts: ImportOptions = {}): Roster {\n const trimmed = input.trimStart();\n if (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n try {\n return importRoster(JSON.parse(input), opts);\n } catch {\n // Fall through to treating the input as raw text.\n }\n }\n return importRoster(input, opts);\n}\n\n/**\n * Import an already-decoded payload. Selects the matching format adapter and\n * resolves the result against the dataset. Accepts either a parsed JSON object\n * (NewRecruit JSON / ListForge) or a string (the three NewRecruit text formats).\n */\n/**\n * Detect an already-resolved canonical {@link Roster} (the JSON shape produced\n * by `rosterJsonSerializer`). Lets a downstream consumer round-trip canonical\n * Roster JSON through `importRoster` without going through an adapter.\n */\nfunction isCanonicalRoster(decoded: unknown): decoded is Roster {\n if (typeof decoded !== \"object\" || decoded === null) return false;\n const r = decoded as Record<string, unknown>;\n const source = r.source as Record<string, unknown> | undefined;\n return (\n typeof source === \"object\" &&\n source !== null &&\n typeof source.format === \"string\" &&\n Array.isArray(r.units) &&\n \"diagnostics\" in r\n );\n}\n\nexport function importRoster(decoded: unknown, opts: ImportOptions = {}): Roster {\n if (isCanonicalRoster(decoded)) return decoded;\n const ds = opts.dataset ?? Dataset.embedded();\n const adapter = selectAdapter(decoded, [...ADAPTERS]);\n const parsed = adapter.parse(decoded);\n return resolve(parsed, ds, adapter.id);\n}\n\n// ---------------------------------------------------------------------------\n// tryImportRoster — single string-in, structured-result-out entry point.\n// ---------------------------------------------------------------------------\n\n/** Why a {@link tryImportRoster} call did not produce a roster. */\nexport type ImportFailureReason =\n | \"empty-input\"\n | \"decode-failed\"\n | \"no-adapter-matched\"\n | \"parse-failed\";\n\n/** Per-adapter outcome from a {@link tryImportRoster} dispatch. */\nexport interface AdapterTrial {\n id: RosterFormat;\n /** True iff this adapter's `matches()` predicate accepted the decoded input. */\n matched: boolean;\n /** Present when {@link matched} is true and `parse()` then threw — the matcher\n * violated its contract. Absent for clean rejections. */\n reason?: string;\n}\n\n/** Discriminated result returned by {@link tryImportRoster}. */\nexport type ImportResult =\n | { ok: true; roster: Roster; format: RosterFormat }\n | {\n ok: false;\n reason: ImportFailureReason;\n message: string;\n trials: AdapterTrial[];\n };\n\n/** Cheap predicate: does the input look like ListForge's URL-or-base64 wrapper? */\nfunction looksLikeListForgeEncoded(input: string): boolean {\n if (input.includes(\"/listforge/\")) return true;\n if (/^https?:\\/\\//i.test(input)) return true;\n // Every gzip-then-base64 payload starts with this prefix.\n if (input.startsWith(\"H4sIA\")) return true;\n return false;\n}\n\n/**\n * Auto-detect and import any supported roster format from a single string.\n *\n * Pipeline:\n * 1. Empty input → `empty-input`.\n * 2. Looks like a ListForge URL / base64 payload → decode (base64 + gunzip + JSON.parse).\n * 3. Looks like raw JSON (starts with `{`/`[`) → JSON.parse.\n * 4. Otherwise treat as text.\n * 5. Greedy first-match adapter dispatch. The first adapter whose `matches()`\n * accepts the decoded value wins; subsequent adapters are not tried.\n * 6. If the matched adapter's `parse()` throws, that's a matcher contract\n * violation — surfaced as `parse-failed`, not silently retried.\n *\n * Caller never sees an exception; the discriminated {@link ImportResult} carries\n * either the resolved {@link Roster} (with the detected {@link RosterFormat})\n * or a typed failure plus per-adapter trial info for diagnostics.\n *\n * Prefer this over {@link importListForge} / {@link importNewRecruit} when the\n * caller doesn't know which format the user pasted.\n */\nexport function tryImportRoster(\n input: string,\n opts: ImportOptions = {},\n): ImportResult {\n const trimmed = input.trim();\n if (trimmed === \"\") {\n return { ok: false, reason: \"empty-input\", message: \"input is empty\", trials: [] };\n }\n\n let decoded: unknown;\n if (looksLikeListForgeEncoded(trimmed)) {\n try {\n decoded = decodeListForge(trimmed);\n } catch (err) {\n const message = (err as Error).message;\n return {\n ok: false,\n reason: \"decode-failed\",\n message: `failed to decode ListForge payload: ${message}`,\n trials: [{ id: \"listforge\", matched: false, reason: message }],\n };\n }\n } else if (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n try {\n decoded = JSON.parse(trimmed);\n } catch (err) {\n return {\n ok: false,\n reason: \"decode-failed\",\n message: `input looks like JSON but failed to parse: ${(err as Error).message}`,\n trials: [],\n };\n }\n } else {\n decoded = input;\n }\n\n const ds = opts.dataset ?? Dataset.embedded();\n const trials: AdapterTrial[] = [];\n for (const adapter of ADAPTERS) {\n if (!adapter.matches(decoded)) {\n trials.push({ id: adapter.id, matched: false });\n continue;\n }\n try {\n const parsed = adapter.parse(decoded);\n const roster = resolve(parsed, ds, adapter.id);\n return { ok: true, roster, format: adapter.id };\n } catch (err) {\n const message = (err as Error).message;\n trials.push({ id: adapter.id, matched: true, reason: message });\n return {\n ok: false,\n reason: \"parse-failed\",\n message: `${adapter.id}: ${message}`,\n trials,\n };\n }\n }\n\n return {\n ok: false,\n reason: \"no-adapter-matched\",\n message: `tried ${ADAPTERS.length} formats, none recognised the input`,\n trials,\n };\n}\n\n/** The adapter list, exposed for tests that need to walk every matcher (e.g.\n * the disjointness invariant test). */\nexport const REGISTERED_ADAPTERS: readonly FormatAdapter[] = ADAPTERS;\n"]}
|
package/dist/import/index.d.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @packageDocumentation
|
|
11
11
|
*/
|
|
12
|
-
export { importListForge, importNewRecruit, importRoster } from "./import-roster.js";
|
|
13
|
-
export type { ImportOptions } from "./import-roster.js";
|
|
12
|
+
export { importListForge, importNewRecruit, importRoster, tryImportRoster, REGISTERED_ADAPTERS, } from "./import-roster.js";
|
|
13
|
+
export type { ImportOptions, ImportResult, ImportFailureReason, AdapterTrial, } from "./import-roster.js";
|
|
14
14
|
export { decodeListForge } from "./decode.js";
|
|
15
15
|
export { resolve } from "./resolve.js";
|
|
16
16
|
export { listForgeAdapter } from "./listforge.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/import/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/import/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,eAAe,EACf,mBAAmB,GACpB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,aAAa,EACb,YAAY,EACZ,mBAAmB,EACnB,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EACL,2BAA2B,EAC3B,wBAAwB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,YAAY,EACV,MAAM,EACN,UAAU,EACV,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,SAAS,EACT,sBAAsB,EACtB,WAAW,EACX,OAAO,EACP,WAAW,EACX,UAAU,EACV,cAAc,EACd,YAAY,EACZ,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAC"}
|
package/dist/import/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @packageDocumentation
|
|
11
11
|
*/
|
|
12
|
-
export { importListForge, importNewRecruit, importRoster } from "./import-roster.js";
|
|
12
|
+
export { importListForge, importNewRecruit, importRoster, tryImportRoster, REGISTERED_ADAPTERS, } from "./import-roster.js";
|
|
13
13
|
export { decodeListForge } from "./decode.js";
|
|
14
14
|
export { resolve } from "./resolve.js";
|
|
15
15
|
export { listForgeAdapter } from "./listforge.js";
|
package/dist/import/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/import/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/import/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,eAAe,EACf,mBAAmB,GACpB,MAAM,oBAAoB,CAAC;AAO5B,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EACL,2BAA2B,EAC3B,wBAAwB,GACzB,MAAM,qBAAqB,CAAC","sourcesContent":["/**\n * Army-list importer: turn an external list-builder export into a resolved\n * 40kdc roster.\n *\n * v1 supports ListForge's \"share JSON\" payload. The output is a {@link Roster}\n * keyed on 40kdc entity ids and validatable against\n * `schemas/core/roster.schema.json`. Resolution is lenient — unmatched names are\n * retained with candidate suggestions and summarised in diagnostics.\n *\n * @packageDocumentation\n */\nexport {\n importListForge,\n importNewRecruit,\n importRoster,\n tryImportRoster,\n REGISTERED_ADAPTERS,\n} from \"./import-roster.js\";\nexport type {\n ImportOptions,\n ImportResult,\n ImportFailureReason,\n AdapterTrial,\n} from \"./import-roster.js\";\nexport { decodeListForge } from \"./decode.js\";\nexport { resolve } from \"./resolve.js\";\nexport { listForgeAdapter } from \"./listforge.js\";\nexport { newRecruitJsonAdapter } from \"./newrecruit-json.js\";\nexport { newRecruitSimpleAdapter } from \"./newrecruit-simple.js\";\nexport {\n newRecruitWtcCompactAdapter,\n newRecruitWtcFullAdapter,\n} from \"./newrecruit-wtc.js\";\nexport type { FormatAdapter } from \"./adapter.js\";\nexport type {\n Roster,\n RosterUnit,\n RosterWargear,\n RosterSource,\n RosterFormat,\n RosterPoints,\n ResolvedRef,\n Candidate,\n RosterLeaderAttachment,\n Diagnostics,\n Warning,\n WarningCode,\n BattleSize,\n GameVersionRef,\n ParsedRoster,\n ParsedUnit,\n ParsedWargear,\n} from \"./types.js\";\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"listforge.d.ts","sourceRoot":"","sources":["../../src/import/listforge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"listforge.d.ts","sourceRoot":"","sources":["../../src/import/listforge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAiNlD,eAAO,MAAM,gBAAgB,EAAE,aA4D9B,CAAC"}
|
package/dist/import/listforge.js
CHANGED
|
@@ -4,6 +4,8 @@ const POINTS_LIMIT = /(\d[\d,]*)\s*Point/i;
|
|
|
4
4
|
const ENHANCEMENT_GROUP_PREFIX = "Enhancements";
|
|
5
5
|
const CHARACTER_CATEGORIES = new Set(["Character", "Epic Hero"]);
|
|
6
6
|
const WEAPON_CATEGORY_SUFFIX = " Weapon"; // "Ranged Weapon", "Melee Weapon", "Psychic Weapon"
|
|
7
|
+
const NEWRECRUIT_XMLNS = "http://www.battlescribe.net/schema/rosterSchema";
|
|
8
|
+
const NEWRECRUIT_HOST_PREFIX = "https://newrecruit";
|
|
7
9
|
function asArray(value) {
|
|
8
10
|
return Array.isArray(value) ? value : [];
|
|
9
11
|
}
|
|
@@ -144,10 +146,22 @@ function rosterOf(decoded) {
|
|
|
144
146
|
return null;
|
|
145
147
|
return roster;
|
|
146
148
|
}
|
|
149
|
+
/** Detect a NewRecruit-flavoured BattleScribe payload. ListForge's matcher
|
|
150
|
+
* excludes these so the greedy first-match dispatcher routes them to the
|
|
151
|
+
* NewRecruit adapter without falling through to here. */
|
|
152
|
+
function hasNewRecruitSignature(decoded, roster) {
|
|
153
|
+
if (asString(roster.xmlns) === NEWRECRUIT_XMLNS)
|
|
154
|
+
return true;
|
|
155
|
+
const genBy = asString(decoded.generatedBy) ?? asString(roster.generatedBy);
|
|
156
|
+
return genBy !== null && genBy.toLowerCase().startsWith(NEWRECRUIT_HOST_PREFIX);
|
|
157
|
+
}
|
|
147
158
|
export const listForgeAdapter = {
|
|
148
159
|
id: "listforge",
|
|
149
160
|
matches(decoded) {
|
|
150
|
-
|
|
161
|
+
const roster = rosterOf(decoded);
|
|
162
|
+
if (!roster)
|
|
163
|
+
return false;
|
|
164
|
+
return !hasNewRecruitSignature(decoded, roster);
|
|
151
165
|
},
|
|
152
166
|
parse(decoded) {
|
|
153
167
|
const payload = decoded;
|