@alpaca-software/40kdc-data 0.1.0 → 0.1.1
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/dist/bundle-schemas.js +11 -0
- package/dist/cli.js +8 -0
- package/dist/commands/import.d.ts +6 -0
- package/dist/commands/import.js +102 -0
- package/dist/gen-conformance.d.ts +1 -0
- package/dist/gen-conformance.js +70 -0
- package/dist/import/adapter.d.ts +26 -0
- package/dist/import/adapter.js +9 -0
- package/dist/import/decode.d.ts +6 -0
- package/dist/import/decode.js +72 -0
- package/dist/import/import-listforge.d.ts +23 -0
- package/dist/import/import-listforge.js +32 -0
- package/dist/import/index.d.ts +18 -0
- package/dist/import/index.js +15 -0
- package/dist/import/listforge.d.ts +23 -0
- package/dist/import/listforge.js +195 -0
- package/dist/import/resolve.d.ts +19 -0
- package/dist/import/resolve.js +187 -0
- package/dist/import/types.d.ts +143 -0
- package/dist/import/types.js +19 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/package.json +2 -1
package/dist/bundle-schemas.js
CHANGED
|
@@ -23,6 +23,15 @@ import { findSchemaFiles, SCHEMAS_ROOT } from "./schema-loader.js";
|
|
|
23
23
|
* targets the `$id` `.../schemas/defs/...`, while the file lives in `$defs/`).
|
|
24
24
|
*/
|
|
25
25
|
const COMMON_ID = "https://40kdc.dev/schemas/defs/common.schema.json";
|
|
26
|
+
/**
|
|
27
|
+
* Schemas excluded from the codegen bundle (still loaded for AJV validation).
|
|
28
|
+
* The roster schema describes importer *output* — a tool-side artifact, not a
|
|
29
|
+
* dataset entity the Rust crate serves — so it is intentionally kept out of the
|
|
30
|
+
* generated types. Its TS types are hand-authored in `src/import/types.ts`.
|
|
31
|
+
*/
|
|
32
|
+
const CODEGEN_EXCLUDED_IDS = new Set([
|
|
33
|
+
"https://40kdc.dev/schemas/core/roster.schema.json",
|
|
34
|
+
]);
|
|
26
35
|
const OUTPUT_PATH = resolve(SCHEMAS_ROOT, "../crates/wh40kdc/schemas/bundled.schema.json");
|
|
27
36
|
const BUNDLE_ID = "https://40kdc.dev/schemas/bundled.schema.json";
|
|
28
37
|
function stemOfId(id) {
|
|
@@ -106,6 +115,8 @@ export function bundle() {
|
|
|
106
115
|
const id = raw.$id;
|
|
107
116
|
if (!id)
|
|
108
117
|
throw new Error(`schema missing $id: ${file}`);
|
|
118
|
+
if (CODEGEN_EXCLUDED_IDS.has(id))
|
|
119
|
+
continue;
|
|
109
120
|
const { $id: _id, $schema: _schema, $defs: localDefs, ...body } = raw;
|
|
110
121
|
// Hoist this file's local $defs flat to the top level (names are globally
|
|
111
122
|
// unique across the schema set; collisions throw above).
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { validateCoreCommand } from "./commands/validate-core.js";
|
|
|
3
3
|
import { validateEnrichmentCommand } from "./commands/validate-enrichment.js";
|
|
4
4
|
import { validateAllCommand } from "./commands/validate-all.js";
|
|
5
5
|
import { translateCommand } from "./commands/translate.js";
|
|
6
|
+
import { importCommand } from "./commands/import.js";
|
|
6
7
|
const program = new Command();
|
|
7
8
|
program
|
|
8
9
|
.name("40kdc-validate")
|
|
@@ -28,4 +29,11 @@ program
|
|
|
28
29
|
.description("Translate ability DSL to plain English")
|
|
29
30
|
.argument("[path]", "Path to abilities.json file")
|
|
30
31
|
.action(translateCommand);
|
|
32
|
+
program
|
|
33
|
+
.command("import")
|
|
34
|
+
.description("Import a ListForge army-list export into a 40kdc roster")
|
|
35
|
+
.argument("[input]", "ListForge URL, base64 segment, JSON, or file path (omit/'-' for stdin)")
|
|
36
|
+
.option("--reporter <mode>", "Output format: json or pretty", "json")
|
|
37
|
+
.option("--out <file>", "Write roster JSON to a file instead of stdout")
|
|
38
|
+
.action(importCommand);
|
|
31
39
|
program.parse();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `import` command: turn a ListForge army-list export into a 40kdc roster.
|
|
3
|
+
*
|
|
4
|
+
* Input may be a ListForge URL, a bare base64 segment, an already-decoded JSON
|
|
5
|
+
* string, or a path to a file containing any of those (or `-`/omitted for stdin).
|
|
6
|
+
* The resolved roster is validated against `roster.schema.json` before output —
|
|
7
|
+
* a guard that the importer only ever emits schema-valid rosters.
|
|
8
|
+
*
|
|
9
|
+
* The import is lenient: unresolved entries do not fail the command (exit 0).
|
|
10
|
+
* Only a decode failure or schema-invalid output is fatal (exit 1).
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { importListForge } from "../import/index.js";
|
|
14
|
+
import { createValidator } from "../schema-loader.js";
|
|
15
|
+
const ROSTER_SCHEMA_ID = "https://40kdc.dev/schemas/core/roster.schema.json";
|
|
16
|
+
function readStdin() {
|
|
17
|
+
try {
|
|
18
|
+
return readFileSync(0, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Resolve the command's input source to a raw payload string. */
|
|
25
|
+
function resolveInput(input) {
|
|
26
|
+
if (!input || input === "-")
|
|
27
|
+
return readStdin().trim();
|
|
28
|
+
if (existsSync(input))
|
|
29
|
+
return readFileSync(input, "utf8").trim();
|
|
30
|
+
return input;
|
|
31
|
+
}
|
|
32
|
+
function formatPretty(roster) {
|
|
33
|
+
const d = roster.diagnostics;
|
|
34
|
+
const lines = [];
|
|
35
|
+
lines.push(`Roster: ${roster.name}`);
|
|
36
|
+
lines.push(` Faction: ${roster.faction_id ?? "(unresolved)"}`);
|
|
37
|
+
lines.push(` Detachment: ${roster.detachment_id ?? "(none/unresolved)"}`);
|
|
38
|
+
lines.push(` Battle size: ${roster.battle_size ?? "(unmapped)"}`);
|
|
39
|
+
lines.push(` Points: computed ${roster.points.total_computed}` +
|
|
40
|
+
(roster.points.total_reported !== null ? `, reported ${roster.points.total_reported}` : "") +
|
|
41
|
+
(roster.points.declared_limit !== null ? `, limit ${roster.points.declared_limit}` : ""));
|
|
42
|
+
lines.push(` Units (${roster.units.length}):`);
|
|
43
|
+
for (const u of roster.units) {
|
|
44
|
+
const mark = u.ref.resolved ? "✓" : "✗";
|
|
45
|
+
const id = u.ref.resolved ? u.ref.id : `${u.ref.raw_name} → unresolved`;
|
|
46
|
+
const extras = [];
|
|
47
|
+
if (u.is_warlord)
|
|
48
|
+
extras.push("warlord");
|
|
49
|
+
if (u.enhancement)
|
|
50
|
+
extras.push(`enh:${u.enhancement.resolved ? u.enhancement.id : "?"}`);
|
|
51
|
+
if (u.leader_attachment)
|
|
52
|
+
extras.push(`leads:${u.leader_attachment.bodyguard_ref.id}?`);
|
|
53
|
+
const suffix = extras.length ? ` [${extras.join(", ")}]` : "";
|
|
54
|
+
lines.push(` ${mark} ${id} ×${u.model_count}${u.points !== null ? ` (${u.points}pts)` : ""}${suffix}`);
|
|
55
|
+
}
|
|
56
|
+
lines.push(` Resolved: ${d.resolved_units} units / ${d.resolved_weapons} weapons; ` +
|
|
57
|
+
`unresolved: ${d.unresolved_units} units / ${d.unresolved_weapons} weapons`);
|
|
58
|
+
if (d.warnings.length) {
|
|
59
|
+
lines.push(` Warnings (${d.warnings.length}):`);
|
|
60
|
+
for (const w of d.warnings) {
|
|
61
|
+
lines.push(` - [${w.code}]${w.raw_name ? ` "${w.raw_name}":` : ""} ${w.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
}
|
|
66
|
+
export async function importCommand(input, opts) {
|
|
67
|
+
const payload = resolveInput(input);
|
|
68
|
+
if (!payload) {
|
|
69
|
+
console.error("import: no input (provide a URL/base64/JSON argument, a file path, or pipe via stdin)");
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
let roster;
|
|
73
|
+
try {
|
|
74
|
+
roster = importListForge(payload);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
console.error(`import: failed to decode/parse payload: ${err.message}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
// Guard: our own output must be schema-valid.
|
|
81
|
+
const validate = createValidator().getSchema(ROSTER_SCHEMA_ID);
|
|
82
|
+
if (!validate) {
|
|
83
|
+
console.error(`import: roster schema not found (${ROSTER_SCHEMA_ID})`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
if (!validate(roster)) {
|
|
87
|
+
console.error("import: produced roster failed schema validation:");
|
|
88
|
+
console.error(JSON.stringify(validate.errors, null, 2));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
const json = JSON.stringify(roster, null, 2);
|
|
92
|
+
if (opts.out) {
|
|
93
|
+
writeFileSync(opts.out, json + "\n", "utf8");
|
|
94
|
+
console.error(`Wrote roster → ${opts.out}`);
|
|
95
|
+
}
|
|
96
|
+
if (opts.reporter === "pretty") {
|
|
97
|
+
console.log(formatPretty(roster));
|
|
98
|
+
}
|
|
99
|
+
else if (!opts.out) {
|
|
100
|
+
console.log(json);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate the cross-implementation conformance corpus under repo-root
|
|
3
|
+
* `conformance/`. The TypeScript package is the reference implementation, so the
|
|
4
|
+
* goldens it emits are what the Rust crate must reproduce byte-for-byte
|
|
5
|
+
* (structurally). Run via `npm run gen:conformance`; CI regenerates and asserts
|
|
6
|
+
* `git diff --exit-code conformance/` is clean.
|
|
7
|
+
*
|
|
8
|
+
* Outputs:
|
|
9
|
+
* - `conformance/normalize.json` — `[{ input, expected }]` for normalizeName.
|
|
10
|
+
* - `conformance/roster/<case>/expected.roster.json` — the resolved Roster for
|
|
11
|
+
* the sibling `input.json` (the ListForge payload).
|
|
12
|
+
*/
|
|
13
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { Dataset } from "./data/dataset.js";
|
|
17
|
+
import { normalizeName } from "./data/normalize.js";
|
|
18
|
+
import { importRoster } from "./import/import-listforge.js";
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const REPO_ROOT = join(__dirname, "../..");
|
|
21
|
+
const CONFORMANCE = join(REPO_ROOT, "conformance");
|
|
22
|
+
/** Inputs for the normalize table — every rule, plus real accented/quoted names. */
|
|
23
|
+
const NORMALIZE_INPUTS = [
|
|
24
|
+
// NFD diacritic strip
|
|
25
|
+
"Khârn the Betrayer",
|
|
26
|
+
"Brôkhyr",
|
|
27
|
+
"Ûthar",
|
|
28
|
+
"Magnús",
|
|
29
|
+
// apostrophe / quote variants
|
|
30
|
+
"T'au",
|
|
31
|
+
"Be’lakor",
|
|
32
|
+
"Kor’sarro Khan",
|
|
33
|
+
"Aetaos'rau'keres",
|
|
34
|
+
"‘quoted’",
|
|
35
|
+
// whitespace / hyphen collapse + trim
|
|
36
|
+
"Brôkhyr Iron-master",
|
|
37
|
+
" the betrayer ",
|
|
38
|
+
"space--marines",
|
|
39
|
+
// casefold
|
|
40
|
+
"KHÂRN THE BETRAYER",
|
|
41
|
+
// already-normalized (idempotence)
|
|
42
|
+
"kharn the betrayer",
|
|
43
|
+
// distinctness anchors (must NOT collapse together)
|
|
44
|
+
"Khorne",
|
|
45
|
+
"Khârn",
|
|
46
|
+
];
|
|
47
|
+
/** Pretty JSON with a trailing newline (matches the repo's 2-space convention). */
|
|
48
|
+
function writeJson(path, value) {
|
|
49
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
50
|
+
}
|
|
51
|
+
function genNormalize() {
|
|
52
|
+
const table = NORMALIZE_INPUTS.map((input) => ({ input, expected: normalizeName(input) }));
|
|
53
|
+
writeJson(join(CONFORMANCE, "normalize.json"), table);
|
|
54
|
+
console.log(`normalize.json: ${table.length} cases`);
|
|
55
|
+
}
|
|
56
|
+
function genRosters() {
|
|
57
|
+
const ds = Dataset.embedded();
|
|
58
|
+
const rosterDir = join(CONFORMANCE, "roster");
|
|
59
|
+
for (const entry of readdirSync(rosterDir, { withFileTypes: true })) {
|
|
60
|
+
if (!entry.isDirectory())
|
|
61
|
+
continue;
|
|
62
|
+
const caseDir = join(rosterDir, entry.name);
|
|
63
|
+
const input = JSON.parse(readFileSync(join(caseDir, "input.json"), "utf8"));
|
|
64
|
+
const roster = importRoster(input, { dataset: ds });
|
|
65
|
+
writeJson(join(caseDir, "expected.roster.json"), roster);
|
|
66
|
+
console.log(`roster/${entry.name}: ${roster.units.length} units, ${roster.diagnostics.warnings.length} warnings`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
genNormalize();
|
|
70
|
+
genRosters();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The format-adapter seam.
|
|
3
|
+
*
|
|
4
|
+
* Each supported source format implements {@link FormatAdapter}: it recognises a
|
|
5
|
+
* decoded payload ({@link FormatAdapter.matches}) and lowers it to the
|
|
6
|
+
* format-agnostic {@link ParsedRoster} ({@link FormatAdapter.parse}). Resolution
|
|
7
|
+
* onto 40kdc entity ids happens once, downstream, against any `ParsedRoster` —
|
|
8
|
+
* so adding a new source format (New Recruit, WTC, …) means writing one adapter,
|
|
9
|
+
* not touching the resolver.
|
|
10
|
+
*
|
|
11
|
+
* v1 registers only {@link listForgeAdapter}.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
import type { ParsedRoster } from "./types.js";
|
|
16
|
+
/** Recognises and parses one source list-export format. */
|
|
17
|
+
export interface FormatAdapter {
|
|
18
|
+
/** Stable identifier for the format (e.g. "listforge"). */
|
|
19
|
+
id: string;
|
|
20
|
+
/** True when this adapter can parse the given decoded payload. */
|
|
21
|
+
matches(decoded: unknown): boolean;
|
|
22
|
+
/** Lower a recognised payload to the format-agnostic intermediate. */
|
|
23
|
+
parse(decoded: unknown): ParsedRoster;
|
|
24
|
+
}
|
|
25
|
+
/** Pick the first adapter that recognises the payload. */
|
|
26
|
+
export declare function selectAdapter(decoded: unknown, adapters: FormatAdapter[]): FormatAdapter;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Pick the first adapter that recognises the payload. */
|
|
2
|
+
export function selectAdapter(decoded, adapters) {
|
|
3
|
+
const adapter = adapters.find((a) => a.matches(decoded));
|
|
4
|
+
if (!adapter) {
|
|
5
|
+
throw new Error("no registered import adapter recognises this payload " +
|
|
6
|
+
`(tried: ${adapters.map((a) => a.id).join(", ") || "none"})`);
|
|
7
|
+
}
|
|
8
|
+
return adapter;
|
|
9
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode a ListForge share payload into a JSON object.
|
|
3
|
+
*
|
|
4
|
+
* ListForge packs a roster as `base64( gzip( utf8(json) ) )` and embeds it in a
|
|
5
|
+
* URL hash fragment: `https://app/#/listforge/<BASE64>`. The fragment is used
|
|
6
|
+
* deliberately so browsers never send it to a server, preserving the payload
|
|
7
|
+
* verbatim. A valid gzipped payload always base64-encodes to a string starting
|
|
8
|
+
* with `H4sIAAAAAAAAA`.
|
|
9
|
+
*
|
|
10
|
+
* {@link decodeListForge} accepts any of three forms and returns the parsed JSON:
|
|
11
|
+
* - a full URL (the segment after the last `/` is taken),
|
|
12
|
+
* - a bare base64 segment,
|
|
13
|
+
* - an already-decoded JSON string (passed straight to `JSON.parse`).
|
|
14
|
+
*
|
|
15
|
+
* Only `node:zlib` is used — no third-party dependency.
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
import { gunzipSync } from "node:zlib";
|
|
20
|
+
/** The base64 prefix every ListForge gzip payload begins with. */
|
|
21
|
+
const GZIP_BASE64_PREFIX = "H4sIA";
|
|
22
|
+
/** The path marker ListForge uses ahead of the payload. */
|
|
23
|
+
const LISTFORGE_MARKER = "/listforge/";
|
|
24
|
+
/**
|
|
25
|
+
* Extract the payload segment from an input that may be a URL.
|
|
26
|
+
*
|
|
27
|
+
* The base64 alphabet includes `/`, so a bare base64 segment cannot be split on
|
|
28
|
+
* `/`. We only treat the input as a URL when it carries the `/listforge/` marker
|
|
29
|
+
* or an `http(s)://` scheme; otherwise it is returned unchanged.
|
|
30
|
+
*/
|
|
31
|
+
function extractSegment(input) {
|
|
32
|
+
const markerIndex = input.indexOf(LISTFORGE_MARKER);
|
|
33
|
+
if (markerIndex !== -1) {
|
|
34
|
+
return input.slice(markerIndex + LISTFORGE_MARKER.length);
|
|
35
|
+
}
|
|
36
|
+
if (/^https?:\/\//i.test(input)) {
|
|
37
|
+
const lastSlash = input.lastIndexOf("/");
|
|
38
|
+
return lastSlash === -1 ? input : input.slice(lastSlash + 1);
|
|
39
|
+
}
|
|
40
|
+
return input;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Decode a ListForge payload (URL, bare base64, or raw JSON) into a JSON value.
|
|
44
|
+
*
|
|
45
|
+
* @throws if the input is neither valid JSON nor a decodable gzip payload.
|
|
46
|
+
*/
|
|
47
|
+
export function decodeListForge(input) {
|
|
48
|
+
const trimmed = input.trim();
|
|
49
|
+
if (trimmed === "") {
|
|
50
|
+
throw new Error("decodeListForge: empty input");
|
|
51
|
+
}
|
|
52
|
+
// Raw JSON object passed directly.
|
|
53
|
+
if (trimmed.startsWith("{")) {
|
|
54
|
+
return JSON.parse(trimmed);
|
|
55
|
+
}
|
|
56
|
+
const segment = extractSegment(trimmed);
|
|
57
|
+
if (!segment.startsWith(GZIP_BASE64_PREFIX)) {
|
|
58
|
+
throw new Error("decodeListForge: input is not a ListForge payload (expected raw JSON, " +
|
|
59
|
+
`or a gzip+base64 segment beginning with "${GZIP_BASE64_PREFIX}…")`);
|
|
60
|
+
}
|
|
61
|
+
let json;
|
|
62
|
+
try {
|
|
63
|
+
const bytes = Buffer.from(segment, "base64");
|
|
64
|
+
json = gunzipSync(bytes).toString("utf8");
|
|
65
|
+
}
|
|
66
|
+
catch (cause) {
|
|
67
|
+
throw new Error("decodeListForge: failed to gunzip base64 payload", {
|
|
68
|
+
cause,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return JSON.parse(json);
|
|
72
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrates a ListForge import: decode → parse → resolve.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
import { Dataset } from "../data/dataset.js";
|
|
7
|
+
import type { Roster } from "./types.js";
|
|
8
|
+
export interface ImportOptions {
|
|
9
|
+
/** Dataset to resolve against. Defaults to the package's embedded dataset. */
|
|
10
|
+
dataset?: Dataset;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Import a ListForge army-list export into a resolved 40kdc {@link Roster}.
|
|
14
|
+
*
|
|
15
|
+
* `input` may be a full ListForge URL, a bare base64 segment, or an
|
|
16
|
+
* already-decoded JSON string — all are handled transparently.
|
|
17
|
+
*/
|
|
18
|
+
export declare function importListForge(input: string, opts?: ImportOptions): Roster;
|
|
19
|
+
/**
|
|
20
|
+
* Import an already-decoded payload. Selects the matching format adapter and
|
|
21
|
+
* resolves the result against the dataset.
|
|
22
|
+
*/
|
|
23
|
+
export declare function importRoster(decoded: unknown, opts?: ImportOptions): Roster;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrates a ListForge import: decode → parse → resolve.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
import { Dataset } from "../data/dataset.js";
|
|
7
|
+
import { selectAdapter } from "./adapter.js";
|
|
8
|
+
import { decodeListForge } from "./decode.js";
|
|
9
|
+
import { listForgeAdapter } from "./listforge.js";
|
|
10
|
+
import { resolve } from "./resolve.js";
|
|
11
|
+
/** Adapters available to {@link importRoster}, in match-priority order. */
|
|
12
|
+
const ADAPTERS = [listForgeAdapter];
|
|
13
|
+
/**
|
|
14
|
+
* Import a ListForge army-list export into a resolved 40kdc {@link Roster}.
|
|
15
|
+
*
|
|
16
|
+
* `input` may be a full ListForge URL, a bare base64 segment, or an
|
|
17
|
+
* already-decoded JSON string — all are handled transparently.
|
|
18
|
+
*/
|
|
19
|
+
export function importListForge(input, opts = {}) {
|
|
20
|
+
const decoded = decodeListForge(input);
|
|
21
|
+
return importRoster(decoded, opts);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Import an already-decoded payload. Selects the matching format adapter and
|
|
25
|
+
* resolves the result against the dataset.
|
|
26
|
+
*/
|
|
27
|
+
export function importRoster(decoded, opts = {}) {
|
|
28
|
+
const ds = opts.dataset ?? Dataset.embedded();
|
|
29
|
+
const adapter = selectAdapter(decoded, ADAPTERS);
|
|
30
|
+
const parsed = adapter.parse(decoded);
|
|
31
|
+
return resolve(parsed, ds);
|
|
32
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Army-list importer: turn an external list-builder export into a resolved
|
|
3
|
+
* 40kdc roster.
|
|
4
|
+
*
|
|
5
|
+
* v1 supports ListForge's "share JSON" payload. The output is a {@link Roster}
|
|
6
|
+
* keyed on 40kdc entity ids and validatable against
|
|
7
|
+
* `schemas/core/roster.schema.json`. Resolution is lenient — unmatched names are
|
|
8
|
+
* retained with candidate suggestions and summarised in diagnostics.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
export { importListForge, importRoster } from "./import-listforge.js";
|
|
13
|
+
export type { ImportOptions } from "./import-listforge.js";
|
|
14
|
+
export { decodeListForge } from "./decode.js";
|
|
15
|
+
export { resolve } from "./resolve.js";
|
|
16
|
+
export { listForgeAdapter } from "./listforge.js";
|
|
17
|
+
export type { FormatAdapter } from "./adapter.js";
|
|
18
|
+
export type { Roster, RosterUnit, RosterWargear, RosterSource, RosterPoints, ResolvedRef, Candidate, RosterLeaderAttachment, Diagnostics, Warning, WarningCode, BattleSize, GameVersionRef, ParsedRoster, ParsedUnit, ParsedWargear, } from "./types.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Army-list importer: turn an external list-builder export into a resolved
|
|
3
|
+
* 40kdc roster.
|
|
4
|
+
*
|
|
5
|
+
* v1 supports ListForge's "share JSON" payload. The output is a {@link Roster}
|
|
6
|
+
* keyed on 40kdc entity ids and validatable against
|
|
7
|
+
* `schemas/core/roster.schema.json`. Resolution is lenient — unmatched names are
|
|
8
|
+
* retained with candidate suggestions and summarised in diagnostics.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
export { importListForge, importRoster } from "./import-listforge.js";
|
|
13
|
+
export { decodeListForge } from "./decode.js";
|
|
14
|
+
export { resolve } from "./resolve.js";
|
|
15
|
+
export { listForgeAdapter } from "./listforge.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ListForge adapter: lower a decoded ListForge "share JSON" payload (a
|
|
3
|
+
* BattleScribe-derived roster tree) to a {@link ParsedRoster}.
|
|
4
|
+
*
|
|
5
|
+
* The walk reads an ALLOWLIST of fields only — `name`, `number`, `type`,
|
|
6
|
+
* `categories[].name`, `group`, and `costs` point values — and never touches
|
|
7
|
+
* `rules[].description` or ability `profiles[].characteristics[].$text`, which
|
|
8
|
+
* carry reproduced rules text. This keeps the importer's output free of
|
|
9
|
+
* copyrighted prose by construction.
|
|
10
|
+
*
|
|
11
|
+
* Selection-tree shape (recursive `selections`):
|
|
12
|
+
* - Configuration nodes (`type: "upgrade"`) named "Detachment" / "Battle Size"
|
|
13
|
+
* carry the chosen value as their first child selection.
|
|
14
|
+
* - Unit nodes (`type: "model" | "unit"`) carry role categories, a points cost,
|
|
15
|
+
* and — nested anywhere beneath them — their wargear (weapon-category
|
|
16
|
+
* selections), enhancement (a selection whose `group` starts "Enhancements"),
|
|
17
|
+
* the "Warlord" marker, and model sub-selections.
|
|
18
|
+
* - Every unit carries a `"Faction: <Name>"` category.
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
import type { FormatAdapter } from "./adapter.js";
|
|
23
|
+
export declare const listForgeAdapter: FormatAdapter;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
const PTS_COST_NAME = "pts";
|
|
2
|
+
const FACTION_CATEGORY = /^Faction:\s*(.+)$/;
|
|
3
|
+
const POINTS_LIMIT = /(\d[\d,]*)\s*Point/i;
|
|
4
|
+
const ENHANCEMENT_GROUP_PREFIX = "Enhancements";
|
|
5
|
+
const CHARACTER_CATEGORIES = new Set(["Character", "Epic Hero"]);
|
|
6
|
+
const WEAPON_CATEGORY_SUFFIX = " Weapon"; // "Ranged Weapon", "Melee Weapon", "Psychic Weapon"
|
|
7
|
+
function asArray(value) {
|
|
8
|
+
return Array.isArray(value) ? value : [];
|
|
9
|
+
}
|
|
10
|
+
function asString(value) {
|
|
11
|
+
return typeof value === "string" ? value : null;
|
|
12
|
+
}
|
|
13
|
+
function selectionName(sel) {
|
|
14
|
+
return asString(sel.name) ?? "";
|
|
15
|
+
}
|
|
16
|
+
function selectionType(sel) {
|
|
17
|
+
return asString(sel.type) ?? "";
|
|
18
|
+
}
|
|
19
|
+
/** A selection's multiplicity (`number`), defaulting to 1. */
|
|
20
|
+
function selectionCount(sel) {
|
|
21
|
+
return typeof sel.number === "number" && sel.number > 0 ? sel.number : 1;
|
|
22
|
+
}
|
|
23
|
+
/** Point value from a selection's cost block, or null when absent. */
|
|
24
|
+
function pointsOf(sel) {
|
|
25
|
+
for (const raw of asArray(sel.costs)) {
|
|
26
|
+
const cost = raw;
|
|
27
|
+
if (asString(cost.name) === PTS_COST_NAME && typeof cost.value === "number") {
|
|
28
|
+
return cost.value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function categoryNames(sel) {
|
|
34
|
+
return asArray(sel.categories)
|
|
35
|
+
.map((c) => asString(c.name))
|
|
36
|
+
.filter((n) => n !== null);
|
|
37
|
+
}
|
|
38
|
+
function childSelections(sel) {
|
|
39
|
+
return asArray(sel.selections);
|
|
40
|
+
}
|
|
41
|
+
/** Depth-first visit of a selection and everything beneath it. */
|
|
42
|
+
function walk(sel, visit) {
|
|
43
|
+
visit(sel);
|
|
44
|
+
for (const child of childSelections(sel))
|
|
45
|
+
walk(child, visit);
|
|
46
|
+
}
|
|
47
|
+
function isUnitSelection(sel) {
|
|
48
|
+
const type = selectionType(sel);
|
|
49
|
+
return type === "model" || type === "unit";
|
|
50
|
+
}
|
|
51
|
+
function isCharacter(sel) {
|
|
52
|
+
return categoryNames(sel).some((n) => CHARACTER_CATEGORIES.has(n));
|
|
53
|
+
}
|
|
54
|
+
function isWeaponSelection(sel) {
|
|
55
|
+
return categoryNames(sel).some((n) => n.endsWith(WEAPON_CATEGORY_SUFFIX));
|
|
56
|
+
}
|
|
57
|
+
function isEnhancementSelection(sel) {
|
|
58
|
+
const group = asString(sel.group);
|
|
59
|
+
return group !== null && group.startsWith(ENHANCEMENT_GROUP_PREFIX);
|
|
60
|
+
}
|
|
61
|
+
/** Sum the model count of a unit from its nested model selections. */
|
|
62
|
+
function modelCount(unit) {
|
|
63
|
+
let total = 0;
|
|
64
|
+
walk(unit, (s) => {
|
|
65
|
+
if (selectionType(s) === "model")
|
|
66
|
+
total += selectionCount(s);
|
|
67
|
+
});
|
|
68
|
+
return total > 0 ? total : selectionCount(unit);
|
|
69
|
+
}
|
|
70
|
+
/** Build a parsed unit from a top-level unit selection. */
|
|
71
|
+
function parseUnit(unit) {
|
|
72
|
+
const wargear = [];
|
|
73
|
+
let enhancement_raw_name = null;
|
|
74
|
+
let is_warlord = false;
|
|
75
|
+
for (const node of childSelections(unit)) {
|
|
76
|
+
walk(node, (s) => {
|
|
77
|
+
if (isEnhancementSelection(s)) {
|
|
78
|
+
enhancement_raw_name ??= selectionName(s);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (selectionName(s) === "Warlord") {
|
|
82
|
+
is_warlord = true;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (isWeaponSelection(s)) {
|
|
86
|
+
wargear.push({ raw_name: selectionName(s), count: selectionCount(s) });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
raw_name: selectionName(unit),
|
|
92
|
+
is_character: isCharacter(unit),
|
|
93
|
+
model_count: modelCount(unit),
|
|
94
|
+
points: pointsOf(unit),
|
|
95
|
+
is_warlord,
|
|
96
|
+
enhancement_raw_name,
|
|
97
|
+
wargear,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Value carried as the first child of a named configuration selection. */
|
|
101
|
+
function configValue(selections, configName) {
|
|
102
|
+
const node = selections.find((s) => selectionName(s) === configName);
|
|
103
|
+
if (!node)
|
|
104
|
+
return null;
|
|
105
|
+
const child = childSelections(node)[0];
|
|
106
|
+
return child ? selectionName(child) : null;
|
|
107
|
+
}
|
|
108
|
+
function parseLimit(label) {
|
|
109
|
+
if (!label)
|
|
110
|
+
return null;
|
|
111
|
+
const match = POINTS_LIMIT.exec(label);
|
|
112
|
+
if (!match)
|
|
113
|
+
return null;
|
|
114
|
+
return Number.parseInt(match[1].replace(/,/g, ""), 10);
|
|
115
|
+
}
|
|
116
|
+
/** First `"Faction: X"` category found anywhere; reports all distinct names. */
|
|
117
|
+
function collectFactions(forces) {
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
for (const force of forces) {
|
|
120
|
+
for (const sel of childSelections(force)) {
|
|
121
|
+
walk(sel, (s) => {
|
|
122
|
+
for (const name of categoryNames(s)) {
|
|
123
|
+
const match = FACTION_CATEGORY.exec(name);
|
|
124
|
+
if (match)
|
|
125
|
+
seen.add(match[1].trim());
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...seen];
|
|
131
|
+
}
|
|
132
|
+
function rosterOf(decoded) {
|
|
133
|
+
if (!decoded || typeof decoded !== "object")
|
|
134
|
+
return null;
|
|
135
|
+
const roster = decoded.roster;
|
|
136
|
+
if (!roster || typeof roster !== "object")
|
|
137
|
+
return null;
|
|
138
|
+
if (!Array.isArray(roster.forces))
|
|
139
|
+
return null;
|
|
140
|
+
return roster;
|
|
141
|
+
}
|
|
142
|
+
export const listForgeAdapter = {
|
|
143
|
+
id: "listforge",
|
|
144
|
+
matches(decoded) {
|
|
145
|
+
return rosterOf(decoded) !== null;
|
|
146
|
+
},
|
|
147
|
+
parse(decoded) {
|
|
148
|
+
const payload = decoded;
|
|
149
|
+
const roster = rosterOf(decoded);
|
|
150
|
+
if (!roster) {
|
|
151
|
+
throw new Error("listforge: payload has no roster.forces array");
|
|
152
|
+
}
|
|
153
|
+
const forces = asArray(roster.forces);
|
|
154
|
+
// Configuration lives among each force's top-level selections.
|
|
155
|
+
let detachment_raw_name = null;
|
|
156
|
+
let battle_size_raw = null;
|
|
157
|
+
const units = [];
|
|
158
|
+
for (const force of forces) {
|
|
159
|
+
const top = childSelections(force);
|
|
160
|
+
detachment_raw_name ??= configValue(top, "Detachment");
|
|
161
|
+
battle_size_raw ??= configValue(top, "Battle Size");
|
|
162
|
+
for (const sel of top) {
|
|
163
|
+
if (isUnitSelection(sel))
|
|
164
|
+
units.push(parseUnit(sel));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const factions = collectFactions(forces);
|
|
168
|
+
const total_reported = pointsOf(roster);
|
|
169
|
+
// Honest computed total: sum every cost line in the tree. A unit's own cost
|
|
170
|
+
// and its nested enhancement's cost are distinct lines that together make up
|
|
171
|
+
// the unit's army contribution, so a full walk reproduces the army total.
|
|
172
|
+
let total_computed = 0;
|
|
173
|
+
for (const force of forces) {
|
|
174
|
+
for (const sel of childSelections(force)) {
|
|
175
|
+
walk(sel, (s) => {
|
|
176
|
+
const pts = pointsOf(s);
|
|
177
|
+
if (pts)
|
|
178
|
+
total_computed += pts;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
name: asString(payload.name) ?? asString(roster.name) ?? "Imported roster",
|
|
184
|
+
generated_by: asString(payload.generatedBy),
|
|
185
|
+
faction_raw_name: factions[0] ?? null,
|
|
186
|
+
detachment_raw_name,
|
|
187
|
+
battle_size_raw,
|
|
188
|
+
declared_limit: parseLimit(battle_size_raw),
|
|
189
|
+
total_reported,
|
|
190
|
+
total_computed,
|
|
191
|
+
units,
|
|
192
|
+
multi_force: factions.length > 1,
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a {@link ParsedRoster} onto 40kdc entity ids, producing a {@link Roster}.
|
|
3
|
+
*
|
|
4
|
+
* Resolution is lenient: a name that doesn't match a 40kdc entity yields a
|
|
5
|
+
* {@link ResolvedRef} with `id: null`, `resolved: false`, and up to five
|
|
6
|
+
* candidate suggestions — the roster is never dropped or rejected. Everything
|
|
7
|
+
* that didn't resolve cleanly is summarised in the {@link Diagnostics} block.
|
|
8
|
+
*
|
|
9
|
+
* Matching reuses the dataset's own lookups ({@link Collection.find},
|
|
10
|
+
* {@link Collection.findAll}, {@link Collection.byFaction}) and
|
|
11
|
+
* {@link normalizeName}; there is no bespoke fuzzy matcher. Faction is resolved
|
|
12
|
+
* first so unit/detachment/enhancement lookups can be scoped to it — the same
|
|
13
|
+
* unit id can appear under several factions, so scoping disambiguates.
|
|
14
|
+
*
|
|
15
|
+
* @packageDocumentation
|
|
16
|
+
*/
|
|
17
|
+
import type { Dataset } from "../data/dataset.js";
|
|
18
|
+
import type { ParsedRoster, Roster } from "./types.js";
|
|
19
|
+
export declare function resolve(parsed: ParsedRoster, ds: Dataset): Roster;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { normalizeName } from "../data/normalize.js";
|
|
2
|
+
/** The dataset edition/dataslate stamped onto an imported roster. */
|
|
3
|
+
const ROSTER_GAME_VERSION = { edition: "11th", dataslate: "pre-launch-provisional" };
|
|
4
|
+
const MAX_CANDIDATES = 5;
|
|
5
|
+
/** Accumulates warnings and resolved/unresolved tallies during an import. */
|
|
6
|
+
class DiagnosticsBuilder {
|
|
7
|
+
resolved_units = 0;
|
|
8
|
+
unresolved_units = 0;
|
|
9
|
+
resolved_weapons = 0;
|
|
10
|
+
unresolved_weapons = 0;
|
|
11
|
+
warnings = [];
|
|
12
|
+
warn(code, message, raw_name = null) {
|
|
13
|
+
this.warnings.push({ code, message, raw_name });
|
|
14
|
+
}
|
|
15
|
+
build() {
|
|
16
|
+
return {
|
|
17
|
+
resolved_units: this.resolved_units,
|
|
18
|
+
unresolved_units: this.unresolved_units,
|
|
19
|
+
resolved_weapons: this.resolved_weapons,
|
|
20
|
+
unresolved_weapons: this.unresolved_weapons,
|
|
21
|
+
warnings: this.warnings,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function unresolved(raw_name, candidates = []) {
|
|
26
|
+
return { id: null, raw_name, resolved: false, candidates };
|
|
27
|
+
}
|
|
28
|
+
function resolved(id, raw_name) {
|
|
29
|
+
return { id, raw_name, resolved: true, candidates: [] };
|
|
30
|
+
}
|
|
31
|
+
function toCandidates(records) {
|
|
32
|
+
return records.slice(0, MAX_CANDIDATES).map((r) => ({ id: r.id, name: r.name }));
|
|
33
|
+
}
|
|
34
|
+
/** Map a source battle-size label to the 40kdc enum, if recognisable. */
|
|
35
|
+
function mapBattleSize(raw) {
|
|
36
|
+
if (!raw)
|
|
37
|
+
return null;
|
|
38
|
+
const key = normalizeName(raw);
|
|
39
|
+
if (key.includes("strike force"))
|
|
40
|
+
return "strike-force";
|
|
41
|
+
if (key.includes("incursion"))
|
|
42
|
+
return "incursion";
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
export function resolve(parsed, ds) {
|
|
46
|
+
const diag = new DiagnosticsBuilder();
|
|
47
|
+
if (parsed.multi_force) {
|
|
48
|
+
diag.warn("multi-force", "Source list contains more than one faction; the primary faction was used for scoping.");
|
|
49
|
+
}
|
|
50
|
+
// --- Faction (resolved first so other lookups can scope to it). -----------
|
|
51
|
+
let faction_id = null;
|
|
52
|
+
if (parsed.faction_raw_name) {
|
|
53
|
+
const hit = ds.factions.find(parsed.faction_raw_name);
|
|
54
|
+
if (hit) {
|
|
55
|
+
faction_id = hit.id;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
diag.warn("faction-unresolved", "Faction name did not match any 40kdc faction.", parsed.faction_raw_name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// --- Detachment (scoped to faction, then global fallback). ----------------
|
|
62
|
+
let detachment_id = null;
|
|
63
|
+
if (parsed.detachment_raw_name) {
|
|
64
|
+
const key = normalizeName(parsed.detachment_raw_name);
|
|
65
|
+
const scoped = faction_id
|
|
66
|
+
? ds.detachments.byFaction(faction_id).find((d) => normalizeName(d.name ?? "") === key)
|
|
67
|
+
: undefined;
|
|
68
|
+
const hit = scoped ?? ds.detachments.find(parsed.detachment_raw_name);
|
|
69
|
+
if (hit) {
|
|
70
|
+
detachment_id = hit.id;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
diag.warn("detachment-unresolved", "Detachment name did not match any 40kdc detachment.", parsed.detachment_raw_name);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// --- Battle size. ---------------------------------------------------------
|
|
77
|
+
const battle_size = mapBattleSize(parsed.battle_size_raw);
|
|
78
|
+
if (parsed.battle_size_raw && battle_size === null) {
|
|
79
|
+
diag.warn("battle-size-unmapped", "Battle size label could not be mapped.", parsed.battle_size_raw);
|
|
80
|
+
}
|
|
81
|
+
// --- Units (and their enhancements / wargear). ----------------------------
|
|
82
|
+
const units = parsed.units.map((u) => resolveUnit(u, faction_id, detachment_id, ds, diag));
|
|
83
|
+
// --- Leader attachments (second pass: needs all resolved unit ids). -------
|
|
84
|
+
inferLeaderAttachments(parsed.units, units, ds, diag);
|
|
85
|
+
// --- Points reconciliation (reported vs computed kept distinct). ----------
|
|
86
|
+
if (parsed.total_reported !== null && parsed.total_reported !== parsed.total_computed) {
|
|
87
|
+
diag.warn("points-mismatch", `Source-reported total (${parsed.total_reported}) differs from the sum of cost lines (${parsed.total_computed}).`);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
name: parsed.name,
|
|
91
|
+
source: { format: "listforge", generated_by: parsed.generated_by },
|
|
92
|
+
faction_id,
|
|
93
|
+
detachment_id,
|
|
94
|
+
battle_size,
|
|
95
|
+
points: {
|
|
96
|
+
declared_limit: parsed.declared_limit,
|
|
97
|
+
total_reported: parsed.total_reported,
|
|
98
|
+
total_computed: parsed.total_computed,
|
|
99
|
+
},
|
|
100
|
+
units,
|
|
101
|
+
game_version: { ...ROSTER_GAME_VERSION },
|
|
102
|
+
diagnostics: diag.build(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function resolveUnit(parsed, faction_id, detachment_id, ds, diag) {
|
|
106
|
+
// Prefer a faction-scoped match (the same unit id recurs across factions),
|
|
107
|
+
// then fall back to a global name lookup.
|
|
108
|
+
const key = normalizeName(parsed.raw_name);
|
|
109
|
+
const scoped = faction_id
|
|
110
|
+
? ds.units.byFaction(faction_id).find((u) => normalizeName(u.name) === key)
|
|
111
|
+
: undefined;
|
|
112
|
+
const all = ds.units.findAll(parsed.raw_name);
|
|
113
|
+
const hit = scoped ?? all[0];
|
|
114
|
+
let ref;
|
|
115
|
+
if (hit) {
|
|
116
|
+
ref = resolved(hit.id, parsed.raw_name);
|
|
117
|
+
diag.resolved_units += 1;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
ref = unresolved(parsed.raw_name, toCandidates(all));
|
|
121
|
+
diag.unresolved_units += 1;
|
|
122
|
+
diag.warn("unit-unresolved", "Unit name did not match any 40kdc unit.", parsed.raw_name);
|
|
123
|
+
}
|
|
124
|
+
const enhancement = parsed.enhancement_raw_name
|
|
125
|
+
? resolveEnhancement(parsed.enhancement_raw_name, detachment_id, ds, diag)
|
|
126
|
+
: null;
|
|
127
|
+
const wargear = parsed.wargear.map((w) => {
|
|
128
|
+
const hits = ds.weapons.findAll(w.raw_name);
|
|
129
|
+
if (hits[0]) {
|
|
130
|
+
diag.resolved_weapons += 1;
|
|
131
|
+
return { ref: resolved(hits[0].id, w.raw_name), count: w.count };
|
|
132
|
+
}
|
|
133
|
+
diag.unresolved_weapons += 1;
|
|
134
|
+
diag.warn("weapon-unresolved", "Weapon name did not match any 40kdc weapon.", w.raw_name);
|
|
135
|
+
return { ref: unresolved(w.raw_name, toCandidates(hits)), count: w.count };
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
ref,
|
|
139
|
+
model_count: parsed.model_count,
|
|
140
|
+
points: parsed.points,
|
|
141
|
+
is_warlord: parsed.is_warlord,
|
|
142
|
+
enhancement,
|
|
143
|
+
wargear,
|
|
144
|
+
leader_attachment: null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function resolveEnhancement(raw_name, detachment_id, ds, diag) {
|
|
148
|
+
const key = normalizeName(raw_name);
|
|
149
|
+
// Enhancements belong to a detachment, not a faction — scope by detachment_id.
|
|
150
|
+
const scoped = detachment_id
|
|
151
|
+
? ds.enhancements.all.find((e) => e.detachment_id === detachment_id && normalizeName(e.name ?? "") === key)
|
|
152
|
+
: undefined;
|
|
153
|
+
const hit = scoped ?? ds.enhancements.find(raw_name);
|
|
154
|
+
if (hit) {
|
|
155
|
+
return resolved(hit.id, raw_name);
|
|
156
|
+
}
|
|
157
|
+
diag.warn("enhancement-unresolved", "Enhancement name did not match any 40kdc enhancement.", raw_name);
|
|
158
|
+
return unresolved(raw_name, toCandidates(ds.enhancements.findAll(raw_name)));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Infer leader→bodyguard attachments. The source format does not encode an
|
|
162
|
+
* unambiguous attachment, so each inferred link is marked provisional: we match
|
|
163
|
+
* a resolved character unit against a resolved non-character unit in the same
|
|
164
|
+
* roster using the dataset's leader-attachment data.
|
|
165
|
+
*/
|
|
166
|
+
function inferLeaderAttachments(parsedUnits, units, ds, diag) {
|
|
167
|
+
const bodyguardIds = new Set(units.filter((u, i) => u.ref.id && !parsedUnits[i].is_character).map((u) => u.ref.id));
|
|
168
|
+
units.forEach((unit, i) => {
|
|
169
|
+
if (!unit.ref.id || !parsedUnits[i].is_character)
|
|
170
|
+
return;
|
|
171
|
+
const leaderId = unit.ref.id;
|
|
172
|
+
const attachment = ds.leaderAttachments.find((la) => la.leader_id === leaderId);
|
|
173
|
+
if (!attachment)
|
|
174
|
+
return;
|
|
175
|
+
const bodyguardId = attachment.eligible_bodyguard_ids.find((id) => bodyguardIds.has(id));
|
|
176
|
+
if (!bodyguardId)
|
|
177
|
+
return;
|
|
178
|
+
const bodyguard = units.find((u) => u.ref.id === bodyguardId);
|
|
179
|
+
if (!bodyguard)
|
|
180
|
+
return;
|
|
181
|
+
unit.leader_attachment = {
|
|
182
|
+
bodyguard_ref: resolved(bodyguardId, bodyguard.ref.raw_name),
|
|
183
|
+
provisional: true,
|
|
184
|
+
};
|
|
185
|
+
diag.warn("leader-attachment-inferred", "Leader attachment was inferred from leader-attachment data and is provisional.", unit.ref.raw_name);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the army-list importer.
|
|
3
|
+
*
|
|
4
|
+
* Two layers live here:
|
|
5
|
+
* - The **output** types ({@link Roster} and friends) mirror
|
|
6
|
+
* `schemas/core/roster.schema.json` field-for-field. They are hand-authored
|
|
7
|
+
* rather than generated so importer work isn't gated on the Rust→typify codegen
|
|
8
|
+
* round-trip; the AJV validator (against the real schema) is the source of truth
|
|
9
|
+
* for conformance.
|
|
10
|
+
* - The **intermediate** type ({@link ParsedRoster}) is format-agnostic: a parser
|
|
11
|
+
* adapter lowers a source payload to this shape (raw names + counts only, no
|
|
12
|
+
* resolved ids), and {@link resolve} turns it into a {@link Roster}.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here ever carries reproduced rules or ability text — only permitted
|
|
15
|
+
* facts (names, counts, points, keywords, entity ids).
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
/** A 40kdc battle size (mirrors the shared `battle-size` def). */
|
|
20
|
+
export type BattleSize = "incursion" | "strike-force";
|
|
21
|
+
/** Diagnostic warning codes emitted during an import. */
|
|
22
|
+
export type WarningCode = "faction-unresolved" | "unit-unresolved" | "weapon-unresolved" | "enhancement-unresolved" | "detachment-unresolved" | "battle-size-unmapped" | "points-mismatch" | "leader-attachment-inferred" | "multi-force" | "unknown-field";
|
|
23
|
+
/** A near-match suggestion offered when resolution fails. */
|
|
24
|
+
export interface Candidate {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A reference to a 40kdc entity that may or may not have resolved. Retains the
|
|
30
|
+
* source's raw name so the import is lossless even on a miss.
|
|
31
|
+
*/
|
|
32
|
+
export interface ResolvedRef {
|
|
33
|
+
/** Resolved entity id, or null when no match was found. */
|
|
34
|
+
id: string | null;
|
|
35
|
+
/** The display name exactly as it appeared in the source payload. */
|
|
36
|
+
raw_name: string;
|
|
37
|
+
/** True iff {@link id} is non-null. */
|
|
38
|
+
resolved: boolean;
|
|
39
|
+
/** Up to 5 best-guess alternatives when resolution failed. */
|
|
40
|
+
candidates: Candidate[];
|
|
41
|
+
}
|
|
42
|
+
/** A weapon/wargear selection on a unit. */
|
|
43
|
+
export interface RosterWargear {
|
|
44
|
+
ref: ResolvedRef;
|
|
45
|
+
count: number;
|
|
46
|
+
}
|
|
47
|
+
/** An inferred, always-provisional leader→bodyguard attachment. */
|
|
48
|
+
export interface RosterLeaderAttachment {
|
|
49
|
+
bodyguard_ref: ResolvedRef;
|
|
50
|
+
provisional: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** One unit entry in a roster. */
|
|
53
|
+
export interface RosterUnit {
|
|
54
|
+
ref: ResolvedRef;
|
|
55
|
+
model_count: number;
|
|
56
|
+
points: number | null;
|
|
57
|
+
is_warlord: boolean;
|
|
58
|
+
enhancement: ResolvedRef | null;
|
|
59
|
+
wargear: RosterWargear[];
|
|
60
|
+
leader_attachment: RosterLeaderAttachment | null;
|
|
61
|
+
}
|
|
62
|
+
/** Provenance of the imported list. */
|
|
63
|
+
export interface RosterSource {
|
|
64
|
+
format: "listforge";
|
|
65
|
+
generated_by: string | null;
|
|
66
|
+
}
|
|
67
|
+
/** Point totals; reported and computed are kept distinct, never reconciled. */
|
|
68
|
+
export interface RosterPoints {
|
|
69
|
+
declared_limit: number | null;
|
|
70
|
+
total_reported: number | null;
|
|
71
|
+
total_computed: number;
|
|
72
|
+
}
|
|
73
|
+
/** A single diagnostic warning. */
|
|
74
|
+
export interface Warning {
|
|
75
|
+
code: WarningCode;
|
|
76
|
+
message: string;
|
|
77
|
+
raw_name: string | null;
|
|
78
|
+
}
|
|
79
|
+
/** A summary of what resolved and what did not during the import. */
|
|
80
|
+
export interface Diagnostics {
|
|
81
|
+
resolved_units: number;
|
|
82
|
+
unresolved_units: number;
|
|
83
|
+
resolved_weapons: number;
|
|
84
|
+
unresolved_weapons: number;
|
|
85
|
+
warnings: Warning[];
|
|
86
|
+
}
|
|
87
|
+
/** Reference to the game edition + dataslate (mirrors game-version-ref). */
|
|
88
|
+
export interface GameVersionRef {
|
|
89
|
+
edition: string;
|
|
90
|
+
dataslate: string;
|
|
91
|
+
}
|
|
92
|
+
/** A fully-resolved army list. Validates against `roster.schema.json`. */
|
|
93
|
+
export interface Roster {
|
|
94
|
+
name: string;
|
|
95
|
+
source: RosterSource;
|
|
96
|
+
faction_id: string | null;
|
|
97
|
+
detachment_id: string | null;
|
|
98
|
+
battle_size: BattleSize | null;
|
|
99
|
+
points: RosterPoints;
|
|
100
|
+
units: RosterUnit[];
|
|
101
|
+
game_version: GameVersionRef;
|
|
102
|
+
diagnostics: Diagnostics;
|
|
103
|
+
}
|
|
104
|
+
/** A weapon/wargear selection before id resolution. */
|
|
105
|
+
export interface ParsedWargear {
|
|
106
|
+
raw_name: string;
|
|
107
|
+
count: number;
|
|
108
|
+
}
|
|
109
|
+
/** A unit selection before id resolution. */
|
|
110
|
+
export interface ParsedUnit {
|
|
111
|
+
raw_name: string;
|
|
112
|
+
/** True when the source classifies this as a character/leader-capable model. */
|
|
113
|
+
is_character: boolean;
|
|
114
|
+
model_count: number;
|
|
115
|
+
points: number | null;
|
|
116
|
+
is_warlord: boolean;
|
|
117
|
+
enhancement_raw_name: string | null;
|
|
118
|
+
wargear: ParsedWargear[];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The format-agnostic intermediate. A {@link FormatAdapter} produces this from a
|
|
122
|
+
* decoded source payload; {@link resolve} consumes it. Contains only raw display
|
|
123
|
+
* names and counts — never reproduced rules text.
|
|
124
|
+
*/
|
|
125
|
+
export interface ParsedRoster {
|
|
126
|
+
name: string;
|
|
127
|
+
generated_by: string | null;
|
|
128
|
+
/** Raw faction name from the source (e.g. "Grey Knights"). */
|
|
129
|
+
faction_raw_name: string | null;
|
|
130
|
+
/** Raw detachment name (e.g. "Banishers"). */
|
|
131
|
+
detachment_raw_name: string | null;
|
|
132
|
+
/** Raw battle-size label (e.g. "2. Strike Force (2000 Point limit)"). */
|
|
133
|
+
battle_size_raw: string | null;
|
|
134
|
+
/** Points limit parsed from the battle-size label, if any. */
|
|
135
|
+
declared_limit: number | null;
|
|
136
|
+
/** Total points reported by the source cost block. */
|
|
137
|
+
total_reported: number | null;
|
|
138
|
+
/** Points summed from every cost line in the source tree. */
|
|
139
|
+
total_computed: number;
|
|
140
|
+
units: ParsedUnit[];
|
|
141
|
+
/** True when the source contained more than one distinct faction. */
|
|
142
|
+
multi_force: boolean;
|
|
143
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the army-list importer.
|
|
3
|
+
*
|
|
4
|
+
* Two layers live here:
|
|
5
|
+
* - The **output** types ({@link Roster} and friends) mirror
|
|
6
|
+
* `schemas/core/roster.schema.json` field-for-field. They are hand-authored
|
|
7
|
+
* rather than generated so importer work isn't gated on the Rust→typify codegen
|
|
8
|
+
* round-trip; the AJV validator (against the real schema) is the source of truth
|
|
9
|
+
* for conformance.
|
|
10
|
+
* - The **intermediate** type ({@link ParsedRoster}) is format-agnostic: a parser
|
|
11
|
+
* adapter lowers a source payload to this shape (raw names + counts only, no
|
|
12
|
+
* resolved ids), and {@link resolve} turns it into a {@link Roster}.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here ever carries reproduced rules or ability text — only permitted
|
|
15
|
+
* facts (names, counts, points, keywords, entity ids).
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export * from "./data/index.js";
|
|
2
2
|
export * from "./generated.js";
|
|
3
3
|
export { createValidator, findSchemaFiles, listSchemaIds, SCHEMAS_ROOT, } from "./schema-loader.js";
|
|
4
|
+
export { importListForge, importRoster, decodeListForge } from "./import/index.js";
|
|
5
|
+
export type { FormatAdapter } from "./import/index.js";
|
|
6
|
+
export type { ImportOptions, Roster, RosterUnit, RosterWargear, RosterSource, RosterPoints, RosterLeaderAttachment, ResolvedRef, Candidate, Diagnostics, Warning, WarningCode, ParsedRoster, ParsedUnit, ParsedWargear, } from "./import/index.js";
|
package/dist/index.js
CHANGED
|
@@ -5,3 +5,7 @@ export * from "./generated.js";
|
|
|
5
5
|
// Schema access + AJV validation (secondary: this package also validates data
|
|
6
6
|
// against the canonical JSON Schemas).
|
|
7
7
|
export { createValidator, findSchemaFiles, listSchemaIds, SCHEMAS_ROOT, } from "./schema-loader.js";
|
|
8
|
+
// Army-list importer (ListForge → resolved 40kdc roster). Types are curated
|
|
9
|
+
// rather than re-exported wholesale to avoid name clashes with generated types
|
|
10
|
+
// (e.g. BattleSize, LeaderAttachment).
|
|
11
|
+
export { importListForge, importRoster, decodeListForge } from "./import/index.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alpaca-software/40kdc-data",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The 40kdc Warhammer 40K dataset behind a linked, typed API — find units, follow them to their weapons, abilities, phases, and factions. Also validates data against the canonical JSON Schemas.",
|
|
6
6
|
"keywords": [
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "npm run codegen:data && tsc",
|
|
48
48
|
"codegen:data": "tsx src/codegen-data.ts",
|
|
49
|
+
"gen:conformance": "tsx src/gen-conformance.ts",
|
|
49
50
|
"docs:api": "typedoc",
|
|
50
51
|
"validate": "tsx src/cli.ts validate-all",
|
|
51
52
|
"validate:core": "tsx src/cli.ts validate-core",
|