@alpaca-software/40kdc-data 0.5.11 → 0.5.14
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 +10 -0
- package/dist/author-input-pack.d.ts +27 -0
- package/dist/author-input-pack.d.ts.map +1 -0
- package/dist/author-input-pack.js +198 -0
- package/dist/author-input-pack.js.map +1 -0
- package/dist/codegen-data.js +1 -0
- package/dist/codegen-data.js.map +1 -1
- package/dist/commands/import.d.ts.map +1 -1
- package/dist/commands/import.js +3 -1
- package/dist/commands/import.js.map +1 -1
- package/dist/convert-faction.d.ts.map +1 -1
- package/dist/convert-faction.js +22 -2
- package/dist/convert-faction.js.map +1 -1
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/dataset.d.ts +2 -1
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +2 -0
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/entities.d.ts +22 -0
- package/dist/data/entities.d.ts.map +1 -1
- package/dist/data/entities.js +28 -0
- package/dist/data/entities.js.map +1 -1
- package/dist/data/index.d.ts +3 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +3 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/roster-resolve.d.ts +16 -1
- package/dist/data/roster-resolve.d.ts.map +1 -1
- package/dist/data/roster-resolve.js +19 -0
- package/dist/data/roster-resolve.js.map +1 -1
- package/dist/data/types.d.ts +3 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/data/types.js +1 -0
- package/dist/data/types.js.map +1 -1
- package/dist/export/newrecruit-json.d.ts.map +1 -1
- package/dist/export/newrecruit-json.js +4 -3
- package/dist/export/newrecruit-json.js.map +1 -1
- package/dist/export/newrecruit-simple.js +2 -2
- package/dist/export/newrecruit-simple.js.map +1 -1
- package/dist/export/newrecruit-wtc.d.ts.map +1 -1
- package/dist/export/newrecruit-wtc.js +3 -1
- package/dist/export/newrecruit-wtc.js.map +1 -1
- package/dist/export/rosterizer.d.ts.map +1 -1
- package/dist/export/rosterizer.js +6 -7
- package/dist/export/rosterizer.js.map +1 -1
- package/dist/extract-faction-pack.d.ts +61 -0
- package/dist/extract-faction-pack.d.ts.map +1 -0
- package/dist/extract-faction-pack.js +318 -0
- package/dist/extract-faction-pack.js.map +1 -0
- package/dist/gen-conformance.js +37 -4
- package/dist/gen-conformance.js.map +1 -1
- package/dist/generated.d.ts +46 -2
- package/dist/generated.d.ts.map +1 -1
- package/dist/generated.js.map +1 -1
- package/dist/import/gw-headerless.d.ts +70 -0
- package/dist/import/gw-headerless.d.ts.map +1 -0
- package/dist/import/gw-headerless.js +343 -0
- package/dist/import/gw-headerless.js.map +1 -0
- package/dist/import/gw.js +1 -1
- package/dist/import/gw.js.map +1 -1
- package/dist/import/import-roster.d.ts.map +1 -1
- package/dist/import/import-roster.js +8 -0
- package/dist/import/import-roster.js.map +1 -1
- package/dist/import/listforge-text.js +1 -1
- package/dist/import/listforge-text.js.map +1 -1
- package/dist/import/listforge.d.ts.map +1 -1
- package/dist/import/listforge.js +18 -3
- package/dist/import/listforge.js.map +1 -1
- package/dist/import/newrecruit-json.d.ts.map +1 -1
- package/dist/import/newrecruit-json.js +21 -3
- package/dist/import/newrecruit-json.js.map +1 -1
- package/dist/import/newrecruit-simple.js +1 -1
- package/dist/import/newrecruit-simple.js.map +1 -1
- package/dist/import/newrecruit-wtc.js +1 -1
- package/dist/import/newrecruit-wtc.js.map +1 -1
- package/dist/import/resolve.d.ts.map +1 -1
- package/dist/import/resolve.js +46 -18
- package/dist/import/resolve.js.map +1 -1
- package/dist/import/roster-json.d.ts +5 -3
- package/dist/import/roster-json.d.ts.map +1 -1
- package/dist/import/roster-json.js +8 -4
- package/dist/import/roster-json.js.map +1 -1
- package/dist/import/rosterizer.js +3 -3
- package/dist/import/rosterizer.js.map +1 -1
- package/dist/import/types.d.ts +15 -4
- package/dist/import/types.d.ts.map +1 -1
- package/dist/import/types.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/merge-faction-pack.d.ts +2 -0
- package/dist/merge-faction-pack.d.ts.map +1 -0
- package/dist/merge-faction-pack.js +157 -0
- package/dist/merge-faction-pack.js.map +1 -0
- package/dist/pack-blocks.d.ts +32 -0
- package/dist/pack-blocks.d.ts.map +1 -0
- package/dist/pack-blocks.js +99 -0
- package/dist/pack-blocks.js.map +1 -0
- package/dist/rube-goldberg.js +1 -1
- package/dist/rube-goldberg.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +33 -1
- package/dist/runner.js.map +1 -1
- package/dist/scope.d.ts +31 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +34 -0
- package/dist/scope.js.map +1 -0
- package/dist/seed-detachment-abilities.d.ts +2 -0
- package/dist/seed-detachment-abilities.d.ts.map +1 -0
- package/dist/seed-detachment-abilities.js +137 -0
- package/dist/seed-detachment-abilities.js.map +1 -0
- package/dist/translate/effect.d.ts +17 -3
- package/dist/translate/effect.d.ts.map +1 -1
- package/dist/translate/effect.js +23 -4
- package/dist/translate/effect.js.map +1 -1
- package/dist/translate/index.d.ts +1 -1
- package/dist/translate/index.d.ts.map +1 -1
- package/dist/translate/index.js +1 -1
- package/dist/translate/index.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +1 -0
- package/dist/validate.js.map +1 -1
- package/package.json +3 -1
- package/schemas/core/detachment.schema.json +6 -0
- package/schemas/core/hull-shape.schema.json +30 -0
- package/schemas/core/roster.schema.json +24 -8
- package/schemas/core/stratagem.schema.json +2 -2
- package/schemas/core/unit-composition.schema.json +7 -0
- package/schemas/enrichment/ability-dsl/ability.schema.json +14 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { inferBattleSizeRaw } from "./newrecruit-text.js";
|
|
2
|
+
const CHARACTERS_SECTION = "CHARACTERS";
|
|
3
|
+
const ALLIED_SECTION = "ALLIED UNITS";
|
|
4
|
+
const CHARACTER_SUFFIX = " Character";
|
|
5
|
+
const WARLORD_MARKER = "Warlord";
|
|
6
|
+
/** Title / unit header: `Name (N pts|Points)` with an optional trailing comment
|
|
7
|
+
* (the GW export sometimes appends TO notes). Points may carry thousands
|
|
8
|
+
* commas. Case-insensitive `pts`/`points`. */
|
|
9
|
+
const RE_PTS_LINE = /^(.+?)\s*\(\s*([\d,]+)\s*(?:pts?|points?)\s*\).*$/i;
|
|
10
|
+
/** `## Section [ (N pts) ]` markdown header. */
|
|
11
|
+
const RE_MD_SECTION = /^#{1,6}\s*(.+?)\s*$/;
|
|
12
|
+
/** ALL-CAPS role section (`CHARACTERS`, `OTHER DATASHEETS`, …). */
|
|
13
|
+
const RE_CAPS_SECTION = /^[A-Z][A-Z0-9 \-/&]+$/;
|
|
14
|
+
/** `Title:` colon section (`Epic Hero:`, `Battleline:`). */
|
|
15
|
+
const RE_COLON_SECTION = /^([A-Za-z][\w /&-]*):\s*$/;
|
|
16
|
+
/** Bullet line: leading indent, a `•` or `◦` marker, then the body. */
|
|
17
|
+
const RE_BULLET = /^([\t ]*)[•◦]\s*(.+?)\s*$/u;
|
|
18
|
+
const RE_NX_PREFIX = /^(\d+)x\s+(.+)$/i;
|
|
19
|
+
/** Inline enhancement annotation: `Name (+N pts)`. */
|
|
20
|
+
const RE_ENHANCEMENT_ANNOT = /^(.+?)\s*\(\+\s*(\d+)\s*pts?\s*\)\s*$/i;
|
|
21
|
+
/** `Enhancements: X` / `E: X` enhancement bullet. */
|
|
22
|
+
const RE_ENHANCEMENT_LABEL = /^(?:e|enh|enhancement|enhancements)\s*:\s*(.+)$/i;
|
|
23
|
+
const RE_WITH_LINE = /^[\t ]*\d+\s+with\b/m;
|
|
24
|
+
const RE_BULLET_ANYWHERE = /^[\t ]*[•◦]/mu;
|
|
25
|
+
/** ListForge-text first line: `<name> - <faction> - <detachment> (N Points)`.
|
|
26
|
+
* Used only to *decline* — that framed header belongs to `listForgeTextAdapter`,
|
|
27
|
+
* which runs ahead of us; declining keeps the matchers mutually exclusive. */
|
|
28
|
+
const RE_LISTFORGE_FIRST_LINE = /^(.+)\s\(\s*\d+\s*Points?\s*\)\s*$/i;
|
|
29
|
+
/** Battle-size labels that look like unit headers (`Strike Force (2,000 Points)`)
|
|
30
|
+
* but are army metadata, not datasheets. */
|
|
31
|
+
const BATTLE_SIZE_NAMES = new Set([
|
|
32
|
+
"combat patrol",
|
|
33
|
+
"incursion",
|
|
34
|
+
"strike force",
|
|
35
|
+
"onslaught",
|
|
36
|
+
]);
|
|
37
|
+
function parsePts(raw) {
|
|
38
|
+
const n = Number.parseInt(raw.replace(/,/g, ""), 10);
|
|
39
|
+
return Number.isNaN(n) ? null : n;
|
|
40
|
+
}
|
|
41
|
+
/** Accept bullet-bearing plain text that no framed adapter claims. */
|
|
42
|
+
function headerlessText(decoded) {
|
|
43
|
+
if (typeof decoded !== "string")
|
|
44
|
+
return null;
|
|
45
|
+
if (!RE_BULLET_ANYWHERE.test(decoded))
|
|
46
|
+
return null; // need a bullet
|
|
47
|
+
if (decoded.includes("+ FACTION KEYWORD:"))
|
|
48
|
+
return null; // framed GW → gwAdapter
|
|
49
|
+
if (RE_WITH_LINE.test(decoded))
|
|
50
|
+
return null; // WTC-full
|
|
51
|
+
const lines = decoded.split(/\r?\n/);
|
|
52
|
+
// ListForge-text's `name - faction - detachment (N Points)` header → defer to
|
|
53
|
+
// listForgeTextAdapter (registered ahead of us). Mirrors its own matcher so
|
|
54
|
+
// the two stay disjoint, per the importer's single-match invariant.
|
|
55
|
+
const firstNonBlank = lines.find((l) => l.trim().length > 0);
|
|
56
|
+
const lf = firstNonBlank && RE_LISTFORGE_FIRST_LINE.exec(firstNonBlank.trim());
|
|
57
|
+
if (lf && lf[1].split(" - ").length >= 3)
|
|
58
|
+
return null;
|
|
59
|
+
// NewRecruit `# ++ Army Roster ++` → newrecruit-simple.
|
|
60
|
+
if (lines.some((l) => {
|
|
61
|
+
const t = l.trim();
|
|
62
|
+
return t.startsWith("# ++") && t.includes("Army Roster");
|
|
63
|
+
})) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
// Require a `Name (N pts|Points)` line somewhere — the unit/title signature.
|
|
67
|
+
return lines.some((l) => RE_PTS_LINE.test(l.trim())) ? decoded : null;
|
|
68
|
+
}
|
|
69
|
+
function parseBullet(indent, body) {
|
|
70
|
+
// Enhancement label first — `Enhancements: X` must not read as a model.
|
|
71
|
+
const label = RE_ENHANCEMENT_LABEL.exec(body);
|
|
72
|
+
if (label) {
|
|
73
|
+
return {
|
|
74
|
+
indent,
|
|
75
|
+
count: null,
|
|
76
|
+
name: "",
|
|
77
|
+
colon_wargear: null,
|
|
78
|
+
is_annotation: true,
|
|
79
|
+
enhancement: [label[1].trim(), null],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const nx = RE_NX_PREFIX.exec(body);
|
|
83
|
+
const count = nx ? Number.parseInt(nx[1], 10) : null;
|
|
84
|
+
const rest = (nx ? nx[2] : body).trim();
|
|
85
|
+
// `Name (+N pts)` enhancement annotation.
|
|
86
|
+
const annot = RE_ENHANCEMENT_ANNOT.exec(rest);
|
|
87
|
+
if (annot) {
|
|
88
|
+
const pts = Number.parseInt(annot[2], 10);
|
|
89
|
+
return {
|
|
90
|
+
indent,
|
|
91
|
+
count,
|
|
92
|
+
name: rest,
|
|
93
|
+
colon_wargear: null,
|
|
94
|
+
is_annotation: true,
|
|
95
|
+
enhancement: [annot[1].trim(), Number.isNaN(pts) ? null : pts],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// `ModelType: w1, w2` — a model bullet with inline wargear.
|
|
99
|
+
const idx = rest.indexOf(":");
|
|
100
|
+
if (idx >= 0) {
|
|
101
|
+
const wargear = rest.slice(idx + 1).trim();
|
|
102
|
+
return {
|
|
103
|
+
indent,
|
|
104
|
+
count,
|
|
105
|
+
name: rest.slice(0, idx).trim(),
|
|
106
|
+
colon_wargear: wargear.length > 0 ? wargear : null,
|
|
107
|
+
is_annotation: false,
|
|
108
|
+
enhancement: null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// Bare token: annotation iff it has no count (Warlord / Character / wargear).
|
|
112
|
+
return {
|
|
113
|
+
indent,
|
|
114
|
+
count,
|
|
115
|
+
name: rest,
|
|
116
|
+
colon_wargear: null,
|
|
117
|
+
is_annotation: count === null,
|
|
118
|
+
enhancement: null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function finishUnit(acc) {
|
|
122
|
+
const topIndent = acc.bullets.length
|
|
123
|
+
? Math.min(...acc.bullets.map((b) => b.indent))
|
|
124
|
+
: 0;
|
|
125
|
+
const wargear = new Map();
|
|
126
|
+
const addWargear = (raw_name, count) => {
|
|
127
|
+
const name = raw_name.trim();
|
|
128
|
+
if (!name)
|
|
129
|
+
return;
|
|
130
|
+
wargear.set(name, (wargear.get(name) ?? 0) + count);
|
|
131
|
+
};
|
|
132
|
+
let model_count = 0;
|
|
133
|
+
let is_warlord = false;
|
|
134
|
+
let is_character = acc.is_character_section;
|
|
135
|
+
let enhancement_raw_name = null;
|
|
136
|
+
let enhancement_points = null;
|
|
137
|
+
for (let i = 0; i < acc.bullets.length; i += 1) {
|
|
138
|
+
const b = acc.bullets[i];
|
|
139
|
+
// Child bullet: a model group's squad-wide wargear (count already total).
|
|
140
|
+
if (b.indent > topIndent) {
|
|
141
|
+
addWargear(b.name, b.count ?? 1);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Enhancement annotation (`Enhancements: X` or `X (+N pts)`).
|
|
145
|
+
if (b.enhancement) {
|
|
146
|
+
if (enhancement_raw_name === null) {
|
|
147
|
+
enhancement_raw_name = b.enhancement[0];
|
|
148
|
+
enhancement_points = b.enhancement[1];
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Model with inline `: wargear` (the `##`/fixture dialect).
|
|
153
|
+
if (b.colon_wargear !== null) {
|
|
154
|
+
const n = b.count ?? 1;
|
|
155
|
+
model_count += n;
|
|
156
|
+
for (const item of b.colon_wargear.split(",").map((s) => s.trim())) {
|
|
157
|
+
if (item)
|
|
158
|
+
addWargear(item, n);
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// Model group: top-level bullet followed by deeper child bullets.
|
|
163
|
+
const next = acc.bullets[i + 1];
|
|
164
|
+
if (next && next.indent > topIndent) {
|
|
165
|
+
model_count += b.count ?? 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Annotation (no count): Warlord / Character flags, else bare wargear.
|
|
169
|
+
if (b.is_annotation) {
|
|
170
|
+
const leftover = [];
|
|
171
|
+
for (const token of b.name.split(",").map((t) => t.trim())) {
|
|
172
|
+
if (!token)
|
|
173
|
+
continue;
|
|
174
|
+
if (token === WARLORD_MARKER) {
|
|
175
|
+
is_warlord = true;
|
|
176
|
+
}
|
|
177
|
+
else if (token.endsWith(CHARACTER_SUFFIX)) {
|
|
178
|
+
is_character = true;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
leftover.push(token);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const token of leftover)
|
|
185
|
+
addWargear(token, 1);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// Plain `Nx` wargear on a single-model unit.
|
|
189
|
+
addWargear(b.name, b.count ?? 1);
|
|
190
|
+
}
|
|
191
|
+
if (model_count === 0)
|
|
192
|
+
model_count = 1;
|
|
193
|
+
let points = acc.displayed_pts;
|
|
194
|
+
if (acc.displayed_pts !== null && enhancement_points !== null) {
|
|
195
|
+
points = Math.max(0, acc.displayed_pts - enhancement_points);
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
raw_name: acc.raw_name,
|
|
199
|
+
is_character,
|
|
200
|
+
model_count,
|
|
201
|
+
points,
|
|
202
|
+
is_warlord,
|
|
203
|
+
enhancement_raw_name,
|
|
204
|
+
enhancement_points,
|
|
205
|
+
wargear: [...wargear].map(([raw_name, count]) => ({ raw_name, count })),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function isBattleSize(name) {
|
|
209
|
+
return BATTLE_SIZE_NAMES.has(name.trim().toLowerCase());
|
|
210
|
+
}
|
|
211
|
+
export const gwHeaderlessAdapter = {
|
|
212
|
+
// Provenance: a GW-family plain-text export. Reuses the `gw` id so no schema
|
|
213
|
+
// churn is needed for a new label (mirrors the Rust adapter).
|
|
214
|
+
id: "gw",
|
|
215
|
+
matches(decoded) {
|
|
216
|
+
return headerlessText(decoded) !== null;
|
|
217
|
+
},
|
|
218
|
+
parse(decoded) {
|
|
219
|
+
const text = headerlessText(decoded);
|
|
220
|
+
if (text === null) {
|
|
221
|
+
throw new Error("gw-headerless: not a headerless plain-text list");
|
|
222
|
+
}
|
|
223
|
+
let name = "Imported roster";
|
|
224
|
+
let declared_limit = null;
|
|
225
|
+
let battle_size_raw = null;
|
|
226
|
+
const units = [];
|
|
227
|
+
let current = null;
|
|
228
|
+
let section = null;
|
|
229
|
+
let allied = 0;
|
|
230
|
+
let consumed_title = false;
|
|
231
|
+
// The GW app export lists faction then detachment as bare lines between the
|
|
232
|
+
// title and the first section. Capture the first two so `resolve` can scope
|
|
233
|
+
// to them; later bare lines (stray notes) are ignored.
|
|
234
|
+
let faction_raw_name = null;
|
|
235
|
+
const detachment_raw_names = [];
|
|
236
|
+
const flush = () => {
|
|
237
|
+
if (current) {
|
|
238
|
+
units.push(finishUnit(current));
|
|
239
|
+
current = null;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
for (const rawLine of text.split("\n")) {
|
|
243
|
+
const raw = rawLine.replace(/\r+$/, "");
|
|
244
|
+
const line = raw.trim();
|
|
245
|
+
if (!line)
|
|
246
|
+
continue;
|
|
247
|
+
// Bullets attach to the open unit.
|
|
248
|
+
const bullet = RE_BULLET.exec(raw);
|
|
249
|
+
if (bullet) {
|
|
250
|
+
if (current)
|
|
251
|
+
current.bullets.push(parseBullet(bullet[1].length, bullet[2]));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
// GW export footer.
|
|
255
|
+
if (line.startsWith("Exported with"))
|
|
256
|
+
continue;
|
|
257
|
+
// `## Section` markdown header (strip an optional `(N pts)` tail).
|
|
258
|
+
const md = RE_MD_SECTION.exec(line);
|
|
259
|
+
if (md) {
|
|
260
|
+
flush();
|
|
261
|
+
const pts = RE_PTS_LINE.exec(md[1]);
|
|
262
|
+
section = pts ? pts[1].trim() : md[1].trim();
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// First `Name (N pts|Points)` line is the roster title, not a unit.
|
|
266
|
+
const pts = RE_PTS_LINE.exec(line);
|
|
267
|
+
if (pts) {
|
|
268
|
+
const header_name = pts[1].trim();
|
|
269
|
+
const points = parsePts(pts[2]);
|
|
270
|
+
if (!consumed_title && current === null && units.length === 0) {
|
|
271
|
+
consumed_title = true;
|
|
272
|
+
name = header_name;
|
|
273
|
+
declared_limit = points;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// Battle-size metadata (`Strike Force (2,000 Points)`).
|
|
277
|
+
if (isBattleSize(header_name)) {
|
|
278
|
+
battle_size_raw = line;
|
|
279
|
+
if (declared_limit === null)
|
|
280
|
+
declared_limit = points;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
// A real unit header.
|
|
284
|
+
flush();
|
|
285
|
+
const inChars = section?.toLowerCase() === CHARACTERS_SECTION.toLowerCase();
|
|
286
|
+
if (section === ALLIED_SECTION)
|
|
287
|
+
allied += 1;
|
|
288
|
+
current = {
|
|
289
|
+
raw_name: header_name,
|
|
290
|
+
displayed_pts: points,
|
|
291
|
+
is_character_section: inChars,
|
|
292
|
+
bullets: [],
|
|
293
|
+
};
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
// Section headers without points (ALL-CAPS role, `Title:` colon).
|
|
297
|
+
if (RE_CAPS_SECTION.test(line) || RE_COLON_SECTION.test(line)) {
|
|
298
|
+
flush();
|
|
299
|
+
section = line.replace(/:\s*$/, "").trim();
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// Anything else (faction/detachment preamble, stray notes).
|
|
303
|
+
if (!consumed_title && current === null && units.length === 0) {
|
|
304
|
+
// Very first content line with no `(N pts)` title → use as name.
|
|
305
|
+
consumed_title = true;
|
|
306
|
+
name = line;
|
|
307
|
+
}
|
|
308
|
+
else if (current === null && units.length === 0) {
|
|
309
|
+
// Preamble after the title, before the first unit: faction then
|
|
310
|
+
// detachment. Names are resolved (and warned on miss) downstream.
|
|
311
|
+
if (faction_raw_name === null) {
|
|
312
|
+
faction_raw_name = line;
|
|
313
|
+
}
|
|
314
|
+
else if (detachment_raw_names.length === 0) {
|
|
315
|
+
detachment_raw_names.push(line);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
flush();
|
|
320
|
+
let total_computed = 0;
|
|
321
|
+
for (const u of units) {
|
|
322
|
+
total_computed += (u.points ?? 0) + (u.enhancement_points ?? 0);
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
name,
|
|
326
|
+
generated_by: null,
|
|
327
|
+
faction_raw_name,
|
|
328
|
+
detachment_raw_names,
|
|
329
|
+
battle_size_raw: battle_size_raw ?? inferBattleSizeRaw(declared_limit),
|
|
330
|
+
declared_limit,
|
|
331
|
+
total_reported: null,
|
|
332
|
+
total_computed,
|
|
333
|
+
units,
|
|
334
|
+
multi_force: allied > 0,
|
|
335
|
+
};
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
// Internals re-exported for unit tests.
|
|
339
|
+
export const _internals = {
|
|
340
|
+
headerlessText,
|
|
341
|
+
parseBullet,
|
|
342
|
+
};
|
|
343
|
+
//# sourceMappingURL=gw-headerless.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gw-headerless.js","sourceRoot":"","sources":["../../src/import/gw-headerless.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Headerless plain-text adapter: the GW 40K app's *exported* list (no\n * `++…++` / `+ FACTION KEYWORD:` summary fence), the NewRecruit \"copy as text\"\n * dialect, and the markdown-ish `## Section (N pts)` shape hand-authored lists\n * use. All three share one body grammar; they differ only in cosmetic framing,\n * so a single lenient parser covers them.\n *\n * Shape (any of):\n *\n * ```\n * <list name> (1995 Points) ← title line (consumed, not a unit)\n * World Eaters ← faction (bare preamble line)\n * Berzerker Warband ← detachment (bare preamble line)\n * Strike Force (2,000 Points) ← battle-size metadata\n *\n * CHARACTERS ← ALL-CAPS role section …\n * ## Battleline (200 pts) ← … or `##` markdown section …\n * Epic Hero: ← … or `Title:` colon section\n *\n * Khârn the Betrayer (100 Points) ← unit header: Name (N pts|Points)\n * • Warlord ← annotation\n * • 1x Gorechild ← Nx wargear (single-model unit)\n * • Enhancements: Berzerker Glaive ← enhancement\n * Khorne Berzerkers (180 Points)\n * • 9x Khorne Berzerker ← model group (has ◦ children) …\n * ◦ 8x Bolt pistol ← … children are squad-wide wargear\n * • 4x Intercessor: Bolt rifle ← model group (colon wargear, no children)\n * ```\n *\n * **Model vs wargear** (the crux), unified across dialects: a top-level bullet\n * is a *model group* when it carries a `: wargear` colon **or** is followed by\n * deeper-indented child bullets; its `Nx` count (default 1) adds to the model\n * count. Otherwise it is plain wargear (an `Nx`/bare item) or an annotation\n * (`Warlord`, `… Character`, `Enhancements: …`).\n *\n * **Faction / detachment**: the GW export lists them as bare lines between the\n * title and the first section (`World Eaters` / `Berzerker Warband`). The first\n * two such preamble lines are captured so `resolve` can scope to them.\n *\n * **Disjointness**: this adapter is the fallback for bullet-bearing text that\n * the framed adapters reject — it declines input carrying the GW\n * `+ FACTION KEYWORD:` fence (→ {@link gwAdapter}), the NewRecruit\n * `# ++ Army Roster ++` header (→ newrecruit-simple), or WTC `N with` body\n * lines, and requires at least one `•`/`◦` bullet.\n *\n * @packageDocumentation\n */\nimport type { FormatAdapter } from \"./adapter.js\";\nimport type { ParsedRoster, ParsedUnit, ParsedWargear } from \"./types.js\";\nimport { inferBattleSizeRaw } from \"./newrecruit-text.js\";\n\nconst CHARACTERS_SECTION = \"CHARACTERS\";\nconst ALLIED_SECTION = \"ALLIED UNITS\";\nconst CHARACTER_SUFFIX = \" Character\";\nconst WARLORD_MARKER = \"Warlord\";\n\n/** Title / unit header: `Name (N pts|Points)` with an optional trailing comment\n * (the GW export sometimes appends TO notes). Points may carry thousands\n * commas. Case-insensitive `pts`/`points`. */\nconst RE_PTS_LINE = /^(.+?)\\s*\\(\\s*([\\d,]+)\\s*(?:pts?|points?)\\s*\\).*$/i;\n/** `## Section [ (N pts) ]` markdown header. */\nconst RE_MD_SECTION = /^#{1,6}\\s*(.+?)\\s*$/;\n/** ALL-CAPS role section (`CHARACTERS`, `OTHER DATASHEETS`, …). */\nconst RE_CAPS_SECTION = /^[A-Z][A-Z0-9 \\-/&]+$/;\n/** `Title:` colon section (`Epic Hero:`, `Battleline:`). */\nconst RE_COLON_SECTION = /^([A-Za-z][\\w /&-]*):\\s*$/;\n/** Bullet line: leading indent, a `•` or `◦` marker, then the body. */\nconst RE_BULLET = /^([\\t ]*)[•◦]\\s*(.+?)\\s*$/u;\nconst RE_NX_PREFIX = /^(\\d+)x\\s+(.+)$/i;\n/** Inline enhancement annotation: `Name (+N pts)`. */\nconst RE_ENHANCEMENT_ANNOT = /^(.+?)\\s*\\(\\+\\s*(\\d+)\\s*pts?\\s*\\)\\s*$/i;\n/** `Enhancements: X` / `E: X` enhancement bullet. */\nconst RE_ENHANCEMENT_LABEL = /^(?:e|enh|enhancement|enhancements)\\s*:\\s*(.+)$/i;\nconst RE_WITH_LINE = /^[\\t ]*\\d+\\s+with\\b/m;\nconst RE_BULLET_ANYWHERE = /^[\\t ]*[•◦]/mu;\n/** ListForge-text first line: `<name> - <faction> - <detachment> (N Points)`.\n * Used only to *decline* — that framed header belongs to `listForgeTextAdapter`,\n * which runs ahead of us; declining keeps the matchers mutually exclusive. */\nconst RE_LISTFORGE_FIRST_LINE = /^(.+)\\s\\(\\s*\\d+\\s*Points?\\s*\\)\\s*$/i;\n\n/** Battle-size labels that look like unit headers (`Strike Force (2,000 Points)`)\n * but are army metadata, not datasheets. */\nconst BATTLE_SIZE_NAMES = new Set([\n \"combat patrol\",\n \"incursion\",\n \"strike force\",\n \"onslaught\",\n]);\n\nfunction parsePts(raw: string): number | null {\n const n = Number.parseInt(raw.replace(/,/g, \"\"), 10);\n return Number.isNaN(n) ? null : n;\n}\n\n/** Accept bullet-bearing plain text that no framed adapter claims. */\nfunction headerlessText(decoded: unknown): string | null {\n if (typeof decoded !== \"string\") return null;\n if (!RE_BULLET_ANYWHERE.test(decoded)) return null; // need a bullet\n if (decoded.includes(\"+ FACTION KEYWORD:\")) return null; // framed GW → gwAdapter\n if (RE_WITH_LINE.test(decoded)) return null; // WTC-full\n const lines = decoded.split(/\\r?\\n/);\n // ListForge-text's `name - faction - detachment (N Points)` header → defer to\n // listForgeTextAdapter (registered ahead of us). Mirrors its own matcher so\n // the two stay disjoint, per the importer's single-match invariant.\n const firstNonBlank = lines.find((l) => l.trim().length > 0);\n const lf = firstNonBlank && RE_LISTFORGE_FIRST_LINE.exec(firstNonBlank.trim());\n if (lf && lf[1].split(\" - \").length >= 3) return null;\n // NewRecruit `# ++ Army Roster ++` → newrecruit-simple.\n if (\n lines.some((l) => {\n const t = l.trim();\n return t.startsWith(\"# ++\") && t.includes(\"Army Roster\");\n })\n ) {\n return null;\n }\n // Require a `Name (N pts|Points)` line somewhere — the unit/title signature.\n return lines.some((l) => RE_PTS_LINE.test(l.trim())) ? decoded : null;\n}\n\ninterface Bullet {\n indent: number;\n count: number | null;\n /** Model/wargear name (after any `Nx` and before any `: wargear`). */\n name: string;\n /** Comma-separated wargear listed after a `:` on a model bullet. */\n colon_wargear: string | null;\n /** True for `Warlord` / `… Character` / `Enhancements:` annotations. */\n is_annotation: boolean;\n /** `[name, points]` when this bullet declares an enhancement. */\n enhancement: [string, number | null] | null;\n}\n\ninterface UnitAcc {\n raw_name: string;\n displayed_pts: number | null;\n is_character_section: boolean;\n bullets: Bullet[];\n}\n\nfunction parseBullet(indent: number, body: string): Bullet {\n // Enhancement label first — `Enhancements: X` must not read as a model.\n const label = RE_ENHANCEMENT_LABEL.exec(body);\n if (label) {\n return {\n indent,\n count: null,\n name: \"\",\n colon_wargear: null,\n is_annotation: true,\n enhancement: [label[1].trim(), null],\n };\n }\n\n const nx = RE_NX_PREFIX.exec(body);\n const count = nx ? Number.parseInt(nx[1], 10) : null;\n const rest = (nx ? nx[2] : body).trim();\n\n // `Name (+N pts)` enhancement annotation.\n const annot = RE_ENHANCEMENT_ANNOT.exec(rest);\n if (annot) {\n const pts = Number.parseInt(annot[2], 10);\n return {\n indent,\n count,\n name: rest,\n colon_wargear: null,\n is_annotation: true,\n enhancement: [annot[1].trim(), Number.isNaN(pts) ? null : pts],\n };\n }\n\n // `ModelType: w1, w2` — a model bullet with inline wargear.\n const idx = rest.indexOf(\":\");\n if (idx >= 0) {\n const wargear = rest.slice(idx + 1).trim();\n return {\n indent,\n count,\n name: rest.slice(0, idx).trim(),\n colon_wargear: wargear.length > 0 ? wargear : null,\n is_annotation: false,\n enhancement: null,\n };\n }\n\n // Bare token: annotation iff it has no count (Warlord / Character / wargear).\n return {\n indent,\n count,\n name: rest,\n colon_wargear: null,\n is_annotation: count === null,\n enhancement: null,\n };\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 const addWargear = (raw_name: string, count: number): void => {\n const name = raw_name.trim();\n if (!name) return;\n wargear.set(name, (wargear.get(name) ?? 0) + count);\n };\n\n let model_count = 0;\n let is_warlord = false;\n let is_character = acc.is_character_section;\n let enhancement_raw_name: string | null = null;\n let enhancement_points: number | null = null;\n\n for (let i = 0; i < acc.bullets.length; i += 1) {\n const b = acc.bullets[i];\n\n // Child bullet: a model group's squad-wide wargear (count already total).\n if (b.indent > topIndent) {\n addWargear(b.name, b.count ?? 1);\n continue;\n }\n\n // Enhancement annotation (`Enhancements: X` or `X (+N pts)`).\n if (b.enhancement) {\n if (enhancement_raw_name === null) {\n enhancement_raw_name = b.enhancement[0];\n enhancement_points = b.enhancement[1];\n }\n continue;\n }\n\n // Model with inline `: wargear` (the `##`/fixture dialect).\n if (b.colon_wargear !== null) {\n const n = b.count ?? 1;\n model_count += n;\n for (const item of b.colon_wargear.split(\",\").map((s) => s.trim())) {\n if (item) addWargear(item, n);\n }\n continue;\n }\n\n // Model group: top-level bullet followed by deeper child bullets.\n const next = acc.bullets[i + 1];\n if (next && next.indent > topIndent) {\n model_count += b.count ?? 1;\n continue;\n }\n\n // Annotation (no count): Warlord / Character flags, else bare wargear.\n if (b.is_annotation) {\n const leftover: string[] = [];\n for (const token of b.name.split(\",\").map((t) => t.trim())) {\n if (!token) continue;\n if (token === WARLORD_MARKER) {\n is_warlord = true;\n } else if (token.endsWith(CHARACTER_SUFFIX)) {\n is_character = true;\n } else {\n leftover.push(token);\n }\n }\n for (const token of leftover) addWargear(token, 1);\n continue;\n }\n\n // Plain `Nx` wargear on a single-model unit.\n addWargear(b.name, b.count ?? 1);\n }\n\n if (model_count === 0) model_count = 1;\n\n let points = acc.displayed_pts;\n if (acc.displayed_pts !== null && enhancement_points !== null) {\n points = Math.max(0, acc.displayed_pts - enhancement_points);\n }\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: [...wargear].map(\n ([raw_name, count]): ParsedWargear => ({ raw_name, count }),\n ),\n };\n}\n\nfunction isBattleSize(name: string): boolean {\n return BATTLE_SIZE_NAMES.has(name.trim().toLowerCase());\n}\n\nexport const gwHeaderlessAdapter: FormatAdapter = {\n // Provenance: a GW-family plain-text export. Reuses the `gw` id so no schema\n // churn is needed for a new label (mirrors the Rust adapter).\n id: \"gw\",\n\n matches(decoded: unknown): boolean {\n return headerlessText(decoded) !== null;\n },\n\n parse(decoded: unknown): ParsedRoster {\n const text = headerlessText(decoded);\n if (text === null) {\n throw new Error(\"gw-headerless: not a headerless plain-text list\");\n }\n\n let name = \"Imported roster\";\n let declared_limit: number | null = null;\n let battle_size_raw: string | null = null;\n const units: ParsedUnit[] = [];\n let current: UnitAcc | null = null;\n let section: string | null = null;\n let allied = 0;\n let consumed_title = false;\n // The GW app export lists faction then detachment as bare lines between the\n // title and the first section. Capture the first two so `resolve` can scope\n // to them; later bare lines (stray notes) are ignored.\n let faction_raw_name: string | null = null;\n const detachment_raw_names: string[] = [];\n\n const flush = (): void => {\n if (current) {\n units.push(finishUnit(current));\n current = null;\n }\n };\n\n for (const rawLine of text.split(\"\\n\")) {\n const raw = rawLine.replace(/\\r+$/, \"\");\n const line = raw.trim();\n if (!line) continue;\n\n // Bullets attach to the open unit.\n const bullet = RE_BULLET.exec(raw);\n if (bullet) {\n if (current) current.bullets.push(parseBullet(bullet[1].length, bullet[2]));\n continue;\n }\n\n // GW export footer.\n if (line.startsWith(\"Exported with\")) continue;\n\n // `## Section` markdown header (strip an optional `(N pts)` tail).\n const md = RE_MD_SECTION.exec(line);\n if (md) {\n flush();\n const pts = RE_PTS_LINE.exec(md[1]);\n section = pts ? pts[1].trim() : md[1].trim();\n continue;\n }\n\n // First `Name (N pts|Points)` line is the roster title, not a unit.\n const pts = RE_PTS_LINE.exec(line);\n if (pts) {\n const header_name = pts[1].trim();\n const points = parsePts(pts[2]);\n if (!consumed_title && current === null && units.length === 0) {\n consumed_title = true;\n name = header_name;\n declared_limit = points;\n continue;\n }\n // Battle-size metadata (`Strike Force (2,000 Points)`).\n if (isBattleSize(header_name)) {\n battle_size_raw = line;\n if (declared_limit === null) declared_limit = points;\n continue;\n }\n // A real unit header.\n flush();\n const inChars = section?.toLowerCase() === CHARACTERS_SECTION.toLowerCase();\n if (section === ALLIED_SECTION) allied += 1;\n current = {\n raw_name: header_name,\n displayed_pts: points,\n is_character_section: inChars,\n bullets: [],\n };\n continue;\n }\n\n // Section headers without points (ALL-CAPS role, `Title:` colon).\n if (RE_CAPS_SECTION.test(line) || RE_COLON_SECTION.test(line)) {\n flush();\n section = line.replace(/:\\s*$/, \"\").trim();\n continue;\n }\n\n // Anything else (faction/detachment preamble, stray notes).\n if (!consumed_title && current === null && units.length === 0) {\n // Very first content line with no `(N pts)` title → use as name.\n consumed_title = true;\n name = line;\n } else if (current === null && units.length === 0) {\n // Preamble after the title, before the first unit: faction then\n // detachment. Names are resolved (and warned on miss) downstream.\n if (faction_raw_name === null) {\n faction_raw_name = line;\n } else if (detachment_raw_names.length === 0) {\n detachment_raw_names.push(line);\n }\n }\n }\n flush();\n\n let total_computed = 0;\n for (const u of units) {\n total_computed += (u.points ?? 0) + (u.enhancement_points ?? 0);\n }\n\n return {\n name,\n generated_by: null,\n faction_raw_name,\n detachment_raw_names,\n battle_size_raw: battle_size_raw ?? inferBattleSizeRaw(declared_limit),\n declared_limit,\n total_reported: null,\n total_computed,\n units,\n multi_force: allied > 0,\n };\n },\n};\n\n// Internals re-exported for unit tests.\nexport const _internals = {\n headerlessText,\n parseBullet,\n};\n"]}
|
package/dist/import/gw.js
CHANGED
|
@@ -226,7 +226,7 @@ export const gwAdapter = {
|
|
|
226
226
|
name: header.name,
|
|
227
227
|
generated_by: null,
|
|
228
228
|
faction_raw_name: header.faction_raw_name,
|
|
229
|
-
|
|
229
|
+
detachment_raw_names: header.detachment_raw_name ? [header.detachment_raw_name] : [],
|
|
230
230
|
battle_size_raw: header.battle_size_raw,
|
|
231
231
|
declared_limit: header.declared_limit,
|
|
232
232
|
total_reported: header.total_reported,
|
package/dist/import/gw.js.map
CHANGED
|
@@ -1 +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"]}
|
|
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_names: header.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"]}
|
|
@@ -1 +1 @@
|
|
|
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;
|
|
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;AAgBlD,OAAO,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAyCvD,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"}
|
|
@@ -14,6 +14,7 @@ import { Dataset } from "../data/dataset.js";
|
|
|
14
14
|
import { selectAdapter } from "./adapter.js";
|
|
15
15
|
import { decodeListForge } from "./decode.js";
|
|
16
16
|
import { gwAdapter } from "./gw.js";
|
|
17
|
+
import { gwHeaderlessAdapter } from "./gw-headerless.js";
|
|
17
18
|
import { listForgeAdapter } from "./listforge.js";
|
|
18
19
|
import { listForgeTextAdapter } from "./listforge-text.js";
|
|
19
20
|
import { newRecruitJsonAdapter } from "./newrecruit-json.js";
|
|
@@ -50,7 +51,14 @@ const ADAPTERS = [
|
|
|
50
51
|
newRecruitWtcFullAdapter,
|
|
51
52
|
newRecruitWtcCompactAdapter,
|
|
52
53
|
newRecruitSimpleAdapter,
|
|
54
|
+
// listforge-text requires the `name - faction - detachment (N Points)` first
|
|
55
|
+
// line none of the others accept; it runs right before the headerless
|
|
56
|
+
// fallback so its framed header wins when present.
|
|
53
57
|
listForgeTextAdapter,
|
|
58
|
+
// Fallback for bullet-bearing plain text without a summary fence (GW app
|
|
59
|
+
// export, NewRecruit copy-text, `##` markdown lists). Placed after the framed
|
|
60
|
+
// text adapters so they win when their headers are present.
|
|
61
|
+
gwHeaderlessAdapter,
|
|
54
62
|
listForgeAdapter,
|
|
55
63
|
];
|
|
56
64
|
/**
|
|
@@ -1 +1 @@
|
|
|
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 { listForgeTextAdapter } from \"./listforge-text.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 { rosterJsonAdapter } from \"./roster-json.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` / `listforge-text`)\n * only match strings and disambiguate among themselves via structural cues, so\n * their order amongst each other doesn't matter; wtc-full goes before\n * wtc-compact because its matcher is the more specific of the two. GW shares\n * the WTC summary header but carries `•` bullets and no `N with` lines, so it\n * stays disjoint from both wtc matchers; listforge-text requires the\n * `name - faction - detachment (N Points)` first line none of the others\n * accept. 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 // roster-json runs first: its matcher is the most specific (the canonical\n // source.format + game_version + diagnostics envelope), so a 40kdc-native\n // export is never mis-detected by a looser matcher.\n rosterJsonAdapter,\n rosterizerAdapter,\n newRecruitJsonAdapter,\n gwAdapter,\n newRecruitWtcFullAdapter,\n newRecruitWtcCompactAdapter,\n newRecruitSimpleAdapter,\n listForgeTextAdapter,\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"]}
|
|
1
|
+
{"version":3,"file":"import-roster.js","sourceRoot":"","sources":["../../src/import/import-roster.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;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,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,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;AACvC,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAGpD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,QAAQ,GAA6B;IACzC,0EAA0E;IAC1E,0EAA0E;IAC1E,oDAAoD;IACpD,iBAAiB;IACjB,iBAAiB;IACjB,qBAAqB;IACrB,SAAS;IACT,wBAAwB;IACxB,2BAA2B;IAC3B,uBAAuB;IACvB,6EAA6E;IAC7E,sEAAsE;IACtE,mDAAmD;IACnD,oBAAoB;IACpB,yEAAyE;IACzE,8EAA8E;IAC9E,4DAA4D;IAC5D,mBAAmB;IACnB,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;AAiCD,mFAAmF;AACnF,SAAS,yBAAyB,CAAC,KAAa;IAC9C,IAAI,KAAK,CAAC,QAAQ,CAAC,aAAa,CAAC;QAAE,OAAO,IAAI,CAAC;IAC/C,IAAI,eAAe,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAC7C,0DAA0D;IAC1D,IAAI,KAAK,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAC3C,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,eAAe,CAC7B,KAAa,EACb,OAAsB,EAAE;IAExB,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC7B,IAAI,OAAO,KAAK,EAAE,EAAE,CAAC;QACnB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;IACrF,CAAC;IAED,IAAI,OAAgB,CAAC;IACrB,IAAI,yBAAyB,CAAC,OAAO,CAAC,EAAE,CAAC;QACvC,IAAI,CAAC;YACH,OAAO,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC;QACrC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,OAAO,GAAI,GAAa,CAAC,OAAO,CAAC;YACvC,OAAO;gBACL,EAAE,EAAE,KAAK;gBACT,MAAM,EAAE,eAAe;gBACvB,OAAO,EAAE,uCAAuC,OAAO,EAAE;gBACzD,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;aAC/D,CAAC;QACJ,CAAC;IACH,CAAC;SAAM,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QAC9D,IAAI,CAAC;YACH,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QAChC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO;gBACL,EAAE,EAAE,KAAK;gBACT,MAAM,EAAE,eAAe;gBACvB,OAAO,EAAE,8CAA+C,GAAa,CAAC,OAAO,EAAE;gBAC/E,MAAM,EAAE,EAAE;aACX,CAAC;QACJ,CAAC;IACH,CAAC;SAAM,CAAC;QACN,OAAO,GAAG,KAAK,CAAC;IAClB,CAAC;IAED,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;IAC9C,MAAM,MAAM,GAAmB,EAAE,CAAC;IAClC,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;YAC9B,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;YAChD,SAAS;QACX,CAAC;QACD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YACtC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;YAC/C,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC;QAClD,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,OAAO,GAAI,GAAa,CAAC,OAAO,CAAC;YACvC,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;YAChE,OAAO;gBACL,EAAE,EAAE,KAAK;gBACT,MAAM,EAAE,cAAc;gBACtB,OAAO,EAAE,GAAG,OAAO,CAAC,EAAE,KAAK,OAAO,EAAE;gBACpC,MAAM;aACP,CAAC;QACJ,CAAC;IACH,CAAC;IAED,OAAO;QACL,EAAE,EAAE,KAAK;QACT,MAAM,EAAE,oBAAoB;QAC5B,OAAO,EAAE,SAAS,QAAQ,CAAC,MAAM,qCAAqC;QACtE,MAAM;KACP,CAAC;AACJ,CAAC;AAED;uCACuC;AACvC,MAAM,CAAC,MAAM,mBAAmB,GAA6B,QAAQ,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, 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 { gwHeaderlessAdapter } from \"./gw-headerless.js\";\nimport { listForgeAdapter } from \"./listforge.js\";\nimport { listForgeTextAdapter } from \"./listforge-text.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 { rosterJsonAdapter } from \"./roster-json.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` / `listforge-text`)\n * only match strings and disambiguate among themselves via structural cues, so\n * their order amongst each other doesn't matter; wtc-full goes before\n * wtc-compact because its matcher is the more specific of the two. GW shares\n * the WTC summary header but carries `•` bullets and no `N with` lines, so it\n * stays disjoint from both wtc matchers; listforge-text requires the\n * `name - faction - detachment (N Points)` first line none of the others\n * accept. 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 // roster-json runs first: its matcher is the most specific (the canonical\n // source.format + game_version + diagnostics envelope), so a 40kdc-native\n // export is never mis-detected by a looser matcher.\n rosterJsonAdapter,\n rosterizerAdapter,\n newRecruitJsonAdapter,\n gwAdapter,\n newRecruitWtcFullAdapter,\n newRecruitWtcCompactAdapter,\n newRecruitSimpleAdapter,\n // listforge-text requires the `name - faction - detachment (N Points)` first\n // line none of the others accept; it runs right before the headerless\n // fallback so its framed header wins when present.\n listForgeTextAdapter,\n // Fallback for bullet-bearing plain text without a summary fence (GW app\n // export, NewRecruit copy-text, `##` markdown lists). Placed after the framed\n // text adapters so they win when their headers are present.\n gwHeaderlessAdapter,\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"]}
|
|
@@ -176,7 +176,7 @@ export const listForgeTextAdapter = {
|
|
|
176
176
|
name: header.name,
|
|
177
177
|
generated_by: "List Forge",
|
|
178
178
|
faction_raw_name: header.faction_raw_name,
|
|
179
|
-
|
|
179
|
+
detachment_raw_names: header.detachment_raw_name ? [header.detachment_raw_name] : [],
|
|
180
180
|
battle_size_raw: inferBattleSizeRaw(declared_limit),
|
|
181
181
|
declared_limit,
|
|
182
182
|
total_reported: header.total_reported,
|