@alpaca-software/40kdc-data 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/bundle-schemas.d.ts.map +1 -1
- package/dist/bundle-schemas.js +17 -0
- package/dist/bundle-schemas.js.map +1 -1
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -1
- package/dist/codegen-data.js +2 -1
- package/dist/codegen-data.js.map +1 -1
- package/dist/commands/populate-base-sizes.d.ts +2 -0
- package/dist/commands/populate-base-sizes.d.ts.map +1 -0
- package/dist/commands/populate-base-sizes.js +158 -0
- package/dist/commands/populate-base-sizes.js.map +1 -0
- package/dist/convert-faction.d.ts +3 -1
- package/dist/convert-faction.d.ts.map +1 -1
- package/dist/convert-faction.js +49 -16
- package/dist/convert-faction.js.map +1 -1
- package/dist/converters/base-size-bridge.d.ts +122 -0
- package/dist/converters/base-size-bridge.d.ts.map +1 -0
- package/dist/converters/base-size-bridge.js +198 -0
- package/dist/converters/base-size-bridge.js.map +1 -0
- package/dist/converters/base-size-guide-extract.d.ts +11 -0
- package/dist/converters/base-size-guide-extract.d.ts.map +1 -0
- package/dist/converters/base-size-guide-extract.js +59 -0
- package/dist/converters/base-size-guide-extract.js.map +1 -0
- package/dist/converters/option-bridge.d.ts +36 -0
- package/dist/converters/option-bridge.d.ts.map +1 -0
- package/dist/converters/option-bridge.js +72 -0
- package/dist/converters/option-bridge.js.map +1 -0
- package/dist/converters/option-parser.d.ts +56 -0
- package/dist/converters/option-parser.d.ts.map +1 -0
- package/dist/converters/option-parser.js +209 -0
- package/dist/converters/option-parser.js.map +1 -0
- package/dist/converters/wargear-options.d.ts +55 -0
- package/dist/converters/wargear-options.d.ts.map +1 -0
- package/dist/converters/wargear-options.js +187 -0
- package/dist/converters/wargear-options.js.map +1 -0
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/dataset.d.ts +10 -2
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +16 -2
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/entities.d.ts +3 -1
- package/dist/data/entities.d.ts.map +1 -1
- package/dist/data/entities.js +4 -0
- package/dist/data/entities.js.map +1 -1
- package/dist/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +5 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/loadout.d.ts +60 -0
- package/dist/data/loadout.d.ts.map +1 -0
- package/dist/data/loadout.js +135 -0
- package/dist/data/loadout.js.map +1 -0
- package/dist/data/types.d.ts +4 -2
- package/dist/data/types.d.ts.map +1 -1
- package/dist/data/types.js +2 -1
- package/dist/data/types.js.map +1 -1
- package/dist/export/rosterizer.js +1 -1
- package/dist/export/rosterizer.js.map +1 -1
- package/dist/gen-conformance.js +25 -2
- package/dist/gen-conformance.js.map +1 -1
- package/dist/generated.d.ts +112 -55
- package/dist/generated.d.ts.map +1 -1
- package/dist/generated.js.map +1 -1
- package/dist/import/rosterizer.d.ts +1 -1
- package/dist/import/rosterizer.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/runner.d.ts +16 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +55 -1
- package/dist/runner.js.map +1 -1
- package/dist/scoring/index.d.ts +135 -0
- package/dist/scoring/index.d.ts.map +1 -0
- package/dist/scoring/index.js +195 -0
- package/dist/scoring/index.js.map +1 -0
- package/dist/translate/condition.d.ts.map +1 -1
- package/dist/translate/condition.js +4 -0
- package/dist/translate/condition.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.map +1 -1
- package/dist/translate/scoring.d.ts +6 -0
- package/dist/translate/scoring.d.ts.map +1 -1
- package/dist/translate/scoring.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +13 -5
- package/dist/validate.js.map +1 -1
- package/package.json +4 -4
- package/schemas/$defs/common.schema.json +14 -0
- package/schemas/core/secondary-card.schema.json +10 -0
- package/schemas/core/unit-composition.schema.json +5 -1
- package/schemas/core/unit.schema.json +2 -10
- package/schemas/core/wargear-option.schema.json +32 -6
- package/schemas/core/wargear.schema.json +24 -0
- package/schemas/enrichment/ability-dsl/condition.schema.json +3 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract base-size rows from the GW *Chapter Approved Tournament Companion —
|
|
3
|
+
* Base Size Guide* into a committed numerical-facts table.
|
|
4
|
+
*
|
|
5
|
+
* IP: base sizes are numerical facts (same category as stat lines / points, which
|
|
6
|
+
* the project permits) and unit/model names are entity identifiers already present
|
|
7
|
+
* in the dataset. We never commit the PDF or any prose/artwork from it — only the
|
|
8
|
+
* name → size rows. The source document is cited, not reproduced.
|
|
9
|
+
*
|
|
10
|
+
* Usage (one-time, from a locally-downloaded PDF; the PDF stays uncommitted):
|
|
11
|
+
* pdftotext -layout tournament-companion.pdf tc.txt
|
|
12
|
+
* tsx src/converters/base-size-guide-extract.ts tc.txt > src/converters/data/base-size-guide.json
|
|
13
|
+
*
|
|
14
|
+
* `-layout` mode keeps each "<unit name> <base size>" pair on one physical line,
|
|
15
|
+
* so the two-column tables parse line-by-line. Multi-model datasheets appear as
|
|
16
|
+
* "Unit: ModelLabel" rows; shared rows as "UnitA/UnitB: ModelLabel".
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
/** A base-size token at the end of a guide line: round, oval, Hull, Unique, or a flying base. */
|
|
20
|
+
const SIZE_TOKEN = "(?:\\d+(?:\\.\\d+)?\\s*[xX×]\\s*\\d+(?:\\.\\d+)?\\s*mm(?:\\s*Oval Base)?)" + // oval
|
|
21
|
+
"|(?:\\d+(?:\\.\\d+)?\\s*mm)" + // round
|
|
22
|
+
"|Hull|Unique|(?:Large|Small) Flying Base";
|
|
23
|
+
const LINE_RE = new RegExp(`^(.*\\S)\\s{2,}(${SIZE_TOKEN})\\s*$`);
|
|
24
|
+
/** Parse the layout-mode text of the Base Size Guide into structured rows. */
|
|
25
|
+
export function extractGuideRows(layoutText) {
|
|
26
|
+
const lines = layoutText.split(/\r?\n/);
|
|
27
|
+
const start = lines.findIndex((l) => /BASE SIZE GUIDE/.test(l));
|
|
28
|
+
const region = start >= 0 ? lines.slice(start) : lines;
|
|
29
|
+
const rows = [];
|
|
30
|
+
for (const line of region) {
|
|
31
|
+
const m = line.match(LINE_RE);
|
|
32
|
+
if (!m)
|
|
33
|
+
continue;
|
|
34
|
+
const name = m[1].trim();
|
|
35
|
+
const raw = m[2].trim();
|
|
36
|
+
const colon = name.indexOf(":");
|
|
37
|
+
if (colon >= 0) {
|
|
38
|
+
rows.push({ unit: name.slice(0, colon).trim(), model: name.slice(colon + 1).trim(), raw });
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
rows.push({ unit: name, raw });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return rows;
|
|
45
|
+
}
|
|
46
|
+
function main() {
|
|
47
|
+
const path = process.argv[2];
|
|
48
|
+
if (!path) {
|
|
49
|
+
console.error("usage: base-size-guide-extract.ts <pdftotext -layout output>");
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
const rows = extractGuideRows(readFileSync(path, "utf8"));
|
|
53
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
|
|
54
|
+
console.error(`extracted ${rows.length} rows`);
|
|
55
|
+
}
|
|
56
|
+
// Run as a script (not when imported by tests).
|
|
57
|
+
if (import.meta.url === `file://${process.argv[1]}`)
|
|
58
|
+
main();
|
|
59
|
+
//# sourceMappingURL=base-size-guide-extract.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"base-size-guide-extract.js","sourceRoot":"","sources":["../../src/converters/base-size-guide-extract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAWvC,iGAAiG;AACjG,MAAM,UAAU,GACd,2EAA2E,GAAG,OAAO;IACrF,6BAA6B,GAAG,QAAQ;IACxC,0CAA0C,CAAC;AAE7C,MAAM,OAAO,GAAG,IAAI,MAAM,CAAC,mBAAmB,UAAU,QAAQ,CAAC,CAAC;AAElE,8EAA8E;AAC9E,MAAM,UAAU,gBAAgB,CAAC,UAAkB;IACjD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACxC,MAAM,KAAK,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IAChE,MAAM,MAAM,GAAG,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;IAEvD,MAAM,IAAI,GAAe,EAAE,CAAC;IAC5B,KAAK,MAAM,IAAI,IAAI,MAAM,EAAE,CAAC;QAC1B,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QAC9B,IAAI,CAAC,CAAC;YAAE,SAAS;QACjB,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACxB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAChC,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;YACf,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;QAC7F,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;QACjC,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,IAAI;IACX,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC7B,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,CAAC,KAAK,CAAC,8DAA8D,CAAC,CAAC;QAC9E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IACD,MAAM,IAAI,GAAG,gBAAgB,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IAC1D,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAC3D,OAAO,CAAC,KAAK,CAAC,aAAa,IAAI,CAAC,MAAM,OAAO,CAAC,CAAC;AACjD,CAAC;AAED,gDAAgD;AAChD,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,UAAU,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC","sourcesContent":["/**\n * Extract base-size rows from the GW *Chapter Approved Tournament Companion —\n * Base Size Guide* into a committed numerical-facts table.\n *\n * IP: base sizes are numerical facts (same category as stat lines / points, which\n * the project permits) and unit/model names are entity identifiers already present\n * in the dataset. We never commit the PDF or any prose/artwork from it — only the\n * name → size rows. The source document is cited, not reproduced.\n *\n * Usage (one-time, from a locally-downloaded PDF; the PDF stays uncommitted):\n * pdftotext -layout tournament-companion.pdf tc.txt\n * tsx src/converters/base-size-guide-extract.ts tc.txt > src/converters/data/base-size-guide.json\n *\n * `-layout` mode keeps each \"<unit name> <base size>\" pair on one physical line,\n * so the two-column tables parse line-by-line. Multi-model datasheets appear as\n * \"Unit: ModelLabel\" rows; shared rows as \"UnitA/UnitB: ModelLabel\".\n */\nimport { readFileSync } from \"node:fs\";\n\nexport interface GuideRow {\n /** Left-of-colon datasheet name(s). May be \"A/B\" for shared rows. */\n unit: string;\n /** Right-of-colon per-model label, when the row is model-specific. */\n model?: string;\n /** Verbatim base-size string (e.g. \"32mm\", \"60 x 35.5mm Oval Base\", \"Hull\"). */\n raw: string;\n}\n\n/** A base-size token at the end of a guide line: round, oval, Hull, Unique, or a flying base. */\nconst SIZE_TOKEN =\n \"(?:\\\\d+(?:\\\\.\\\\d+)?\\\\s*[xX×]\\\\s*\\\\d+(?:\\\\.\\\\d+)?\\\\s*mm(?:\\\\s*Oval Base)?)\" + // oval\n \"|(?:\\\\d+(?:\\\\.\\\\d+)?\\\\s*mm)\" + // round\n \"|Hull|Unique|(?:Large|Small) Flying Base\";\n\nconst LINE_RE = new RegExp(`^(.*\\\\S)\\\\s{2,}(${SIZE_TOKEN})\\\\s*$`);\n\n/** Parse the layout-mode text of the Base Size Guide into structured rows. */\nexport function extractGuideRows(layoutText: string): GuideRow[] {\n const lines = layoutText.split(/\\r?\\n/);\n const start = lines.findIndex((l) => /BASE SIZE GUIDE/.test(l));\n const region = start >= 0 ? lines.slice(start) : lines;\n\n const rows: GuideRow[] = [];\n for (const line of region) {\n const m = line.match(LINE_RE);\n if (!m) continue;\n const name = m[1].trim();\n const raw = m[2].trim();\n const colon = name.indexOf(\":\");\n if (colon >= 0) {\n rows.push({ unit: name.slice(0, colon).trim(), model: name.slice(colon + 1).trim(), raw });\n } else {\n rows.push({ unit: name, raw });\n }\n }\n return rows;\n}\n\nfunction main(): void {\n const path = process.argv[2];\n if (!path) {\n console.error(\"usage: base-size-guide-extract.ts <pdftotext -layout output>\");\n process.exit(2);\n }\n const rows = extractGuideRows(readFileSync(path, \"utf8\"));\n process.stdout.write(JSON.stringify(rows, null, 2) + \"\\n\");\n console.error(`extracted ${rows.length} rows`);\n}\n\n// Run as a script (not when imported by tests).\nif (import.meta.url === `file://${process.argv[1]}`) main();\n"]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge army-assist's two datasheet id-spaces. The wargear-option table
|
|
3
|
+
* (`Datasheets_options.json`) and the unit-composition table
|
|
4
|
+
* (`Datasheets_unit_composition.json`) key on a *numeric* datasheet id
|
|
5
|
+
* (`000002627`) that never appears in the UUID-keyed `Datasheets.json` /
|
|
6
|
+
* `Datasheets_models.json`. The link is **model names**: composition
|
|
7
|
+
* descriptions name a datasheet's models (numeric side), and `Datasheets_models`
|
|
8
|
+
* names them (UUID side).
|
|
9
|
+
*
|
|
10
|
+
* Globally the match is ambiguous (the same model name recurs across factions),
|
|
11
|
+
* but the converter runs one faction at a time, so candidates are restricted to
|
|
12
|
+
* that faction's ~50–90 datasheets where model-name sets are effectively unique.
|
|
13
|
+
* Ties that survive are returned for the caller to report rather than guessed.
|
|
14
|
+
*/
|
|
15
|
+
/** Normalise a model name for matching: lowercase, keep alphanumerics + spaces. */
|
|
16
|
+
export declare function normModelName(s: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Extract a model name from a unit-composition description by dropping the
|
|
19
|
+
* leading count or range ("1 Khorne Berzerker Champion" → "khorne berzerker
|
|
20
|
+
* champion"; "9-19 Khorne Berzerkers" → "khorne berzerkers").
|
|
21
|
+
*/
|
|
22
|
+
export declare function modelNameFromComposition(desc: string): string;
|
|
23
|
+
export interface BridgeResult {
|
|
24
|
+
/** numeric datasheet id → UUID datasheet id (unique best model-name match). */
|
|
25
|
+
byNumeric: Map<string, string>;
|
|
26
|
+
/** numeric ids that overlap the faction but tie across ≥2 UUIDs. */
|
|
27
|
+
ambiguous: string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve each numeric datasheet id that carries options to a faction UUID by
|
|
31
|
+
* best model-name-set overlap. Numeric ids with no overlap are treated as
|
|
32
|
+
* belonging to another faction and dropped silently; ties (overlap > 0 but no
|
|
33
|
+
* unique winner) are returned in `ambiguous`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function bridgeOptionsToUnits(factionUuids: readonly string[], modelsByUuid: Map<string, Set<string>>, compByNumeric: Map<string, Set<string>>, optionNumericIds: Iterable<string>): BridgeResult;
|
|
36
|
+
//# sourceMappingURL=option-bridge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"option-bridge.d.ts","sourceRoot":"","sources":["../../src/converters/option-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,mFAAmF;AACnF,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAO/C;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7D;AAED,MAAM,WAAW,YAAY;IAC3B,+EAA+E;IAC/E,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,oEAAoE;IACpE,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,YAAY,EAAE,SAAS,MAAM,EAAE,EAC/B,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,EACtC,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,EACvC,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,GACjC,YAAY,CA6Bd"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge army-assist's two datasheet id-spaces. The wargear-option table
|
|
3
|
+
* (`Datasheets_options.json`) and the unit-composition table
|
|
4
|
+
* (`Datasheets_unit_composition.json`) key on a *numeric* datasheet id
|
|
5
|
+
* (`000002627`) that never appears in the UUID-keyed `Datasheets.json` /
|
|
6
|
+
* `Datasheets_models.json`. The link is **model names**: composition
|
|
7
|
+
* descriptions name a datasheet's models (numeric side), and `Datasheets_models`
|
|
8
|
+
* names them (UUID side).
|
|
9
|
+
*
|
|
10
|
+
* Globally the match is ambiguous (the same model name recurs across factions),
|
|
11
|
+
* but the converter runs one faction at a time, so candidates are restricted to
|
|
12
|
+
* that faction's ~50–90 datasheets where model-name sets are effectively unique.
|
|
13
|
+
* Ties that survive are returned for the caller to report rather than guessed.
|
|
14
|
+
*/
|
|
15
|
+
/** Normalise a model name for matching: lowercase, keep alphanumerics + spaces. */
|
|
16
|
+
export function normModelName(s) {
|
|
17
|
+
return s
|
|
18
|
+
.normalize("NFD")
|
|
19
|
+
.replace(/[̀-ͯ]/g, "")
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extract a model name from a unit-composition description by dropping the
|
|
26
|
+
* leading count or range ("1 Khorne Berzerker Champion" → "khorne berzerker
|
|
27
|
+
* champion"; "9-19 Khorne Berzerkers" → "khorne berzerkers").
|
|
28
|
+
*/
|
|
29
|
+
export function modelNameFromComposition(desc) {
|
|
30
|
+
return normModelName(desc.replace(/^\s*\d+\s*[-–]?\s*\d*\s+/, ""));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve each numeric datasheet id that carries options to a faction UUID by
|
|
34
|
+
* best model-name-set overlap. Numeric ids with no overlap are treated as
|
|
35
|
+
* belonging to another faction and dropped silently; ties (overlap > 0 but no
|
|
36
|
+
* unique winner) are returned in `ambiguous`.
|
|
37
|
+
*/
|
|
38
|
+
export function bridgeOptionsToUnits(factionUuids, modelsByUuid, compByNumeric, optionNumericIds) {
|
|
39
|
+
const byNumeric = new Map();
|
|
40
|
+
const ambiguous = [];
|
|
41
|
+
for (const numericId of optionNumericIds) {
|
|
42
|
+
const numModels = compByNumeric.get(numericId);
|
|
43
|
+
if (!numModels || numModels.size === 0)
|
|
44
|
+
continue;
|
|
45
|
+
let bestScore = 0;
|
|
46
|
+
let winners = [];
|
|
47
|
+
for (const uuid of factionUuids) {
|
|
48
|
+
const uModels = modelsByUuid.get(uuid);
|
|
49
|
+
if (!uModels)
|
|
50
|
+
continue;
|
|
51
|
+
let score = 0;
|
|
52
|
+
for (const m of numModels)
|
|
53
|
+
if (uModels.has(m))
|
|
54
|
+
score++;
|
|
55
|
+
if (score > bestScore) {
|
|
56
|
+
bestScore = score;
|
|
57
|
+
winners = [uuid];
|
|
58
|
+
}
|
|
59
|
+
else if (score === bestScore && score > 0) {
|
|
60
|
+
winners.push(uuid);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (bestScore === 0)
|
|
64
|
+
continue; // another faction's datasheet
|
|
65
|
+
if (winners.length === 1)
|
|
66
|
+
byNumeric.set(numericId, winners[0]);
|
|
67
|
+
else
|
|
68
|
+
ambiguous.push(numericId);
|
|
69
|
+
}
|
|
70
|
+
return { byNumeric, ambiguous };
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=option-bridge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"option-bridge.js","sourceRoot":"","sources":["../../src/converters/option-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,mFAAmF;AACnF,MAAM,UAAU,aAAa,CAAC,CAAS;IACrC,OAAO,CAAC;SACL,SAAS,CAAC,KAAK,CAAC;SAChB,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC;SACrB,WAAW,EAAE;SACb,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC;SAC3B,IAAI,EAAE,CAAC;AACZ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,wBAAwB,CAAC,IAAY;IACnD,OAAO,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,0BAA0B,EAAE,EAAE,CAAC,CAAC,CAAC;AACrE,CAAC;AASD;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAClC,YAA+B,EAC/B,YAAsC,EACtC,aAAuC,EACvC,gBAAkC;IAElC,MAAM,SAAS,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC5C,MAAM,SAAS,GAAa,EAAE,CAAC;IAE/B,KAAK,MAAM,SAAS,IAAI,gBAAgB,EAAE,CAAC;QACzC,MAAM,SAAS,GAAG,aAAa,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC/C,IAAI,CAAC,SAAS,IAAI,SAAS,CAAC,IAAI,KAAK,CAAC;YAAE,SAAS;QAEjD,IAAI,SAAS,GAAG,CAAC,CAAC;QAClB,IAAI,OAAO,GAAa,EAAE,CAAC;QAC3B,KAAK,MAAM,IAAI,IAAI,YAAY,EAAE,CAAC;YAChC,MAAM,OAAO,GAAG,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YACvC,IAAI,CAAC,OAAO;gBAAE,SAAS;YACvB,IAAI,KAAK,GAAG,CAAC,CAAC;YACd,KAAK,MAAM,CAAC,IAAI,SAAS;gBAAE,IAAI,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;oBAAE,KAAK,EAAE,CAAC;YACvD,IAAI,KAAK,GAAG,SAAS,EAAE,CAAC;gBACtB,SAAS,GAAG,KAAK,CAAC;gBAClB,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC;YACnB,CAAC;iBAAM,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;gBAC5C,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACrB,CAAC;QACH,CAAC;QAED,IAAI,SAAS,KAAK,CAAC;YAAE,SAAS,CAAC,8BAA8B;QAC7D,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS,CAAC,GAAG,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;;YAC1D,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACjC,CAAC;IAED,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC;AAClC,CAAC","sourcesContent":["/**\n * Bridge army-assist's two datasheet id-spaces. The wargear-option table\n * (`Datasheets_options.json`) and the unit-composition table\n * (`Datasheets_unit_composition.json`) key on a *numeric* datasheet id\n * (`000002627`) that never appears in the UUID-keyed `Datasheets.json` /\n * `Datasheets_models.json`. The link is **model names**: composition\n * descriptions name a datasheet's models (numeric side), and `Datasheets_models`\n * names them (UUID side).\n *\n * Globally the match is ambiguous (the same model name recurs across factions),\n * but the converter runs one faction at a time, so candidates are restricted to\n * that faction's ~50–90 datasheets where model-name sets are effectively unique.\n * Ties that survive are returned for the caller to report rather than guessed.\n */\n\n/** Normalise a model name for matching: lowercase, keep alphanumerics + spaces. */\nexport function normModelName(s: string): string {\n return s\n .normalize(\"NFD\")\n .replace(/[̀-ͯ]/g, \"\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \" \")\n .trim();\n}\n\n/**\n * Extract a model name from a unit-composition description by dropping the\n * leading count or range (\"1 Khorne Berzerker Champion\" → \"khorne berzerker\n * champion\"; \"9-19 Khorne Berzerkers\" → \"khorne berzerkers\").\n */\nexport function modelNameFromComposition(desc: string): string {\n return normModelName(desc.replace(/^\\s*\\d+\\s*[-–]?\\s*\\d*\\s+/, \"\"));\n}\n\nexport interface BridgeResult {\n /** numeric datasheet id → UUID datasheet id (unique best model-name match). */\n byNumeric: Map<string, string>;\n /** numeric ids that overlap the faction but tie across ≥2 UUIDs. */\n ambiguous: string[];\n}\n\n/**\n * Resolve each numeric datasheet id that carries options to a faction UUID by\n * best model-name-set overlap. Numeric ids with no overlap are treated as\n * belonging to another faction and dropped silently; ties (overlap > 0 but no\n * unique winner) are returned in `ambiguous`.\n */\nexport function bridgeOptionsToUnits(\n factionUuids: readonly string[],\n modelsByUuid: Map<string, Set<string>>,\n compByNumeric: Map<string, Set<string>>,\n optionNumericIds: Iterable<string>,\n): BridgeResult {\n const byNumeric = new Map<string, string>();\n const ambiguous: string[] = [];\n\n for (const numericId of optionNumericIds) {\n const numModels = compByNumeric.get(numericId);\n if (!numModels || numModels.size === 0) continue;\n\n let bestScore = 0;\n let winners: string[] = [];\n for (const uuid of factionUuids) {\n const uModels = modelsByUuid.get(uuid);\n if (!uModels) continue;\n let score = 0;\n for (const m of numModels) if (uModels.has(m)) score++;\n if (score > bestScore) {\n bestScore = score;\n winners = [uuid];\n } else if (score === bestScore && score > 0) {\n winners.push(uuid);\n }\n }\n\n if (bestScore === 0) continue; // another faction's datasheet\n if (winners.length === 1) byNumeric.set(numericId, winners[0]);\n else ambiguous.push(numericId);\n }\n\n return { byNumeric, ambiguous };\n}\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a Warhammer wargear-option line (army-assist `Datasheets_options.json`
|
|
3
|
+
* `description` prose) into a structured option, or report why it could not.
|
|
4
|
+
*
|
|
5
|
+
* The prose is regular enough to parse with a small staged pipeline:
|
|
6
|
+
* 1. skip non-options (null / "None" / footnotes / "cannot be replaced");
|
|
7
|
+
* 2. peel the leading *constraint* clause ("For every 5 models,", "The
|
|
8
|
+
* Champion's", "Any number of Boyz", "Up to 2 models", "1 model");
|
|
9
|
+
* 3. split the core on the swap/add verb (passive "… can be replaced with …",
|
|
10
|
+
* active "… can replace its … with …", or add-on "… equipped with / can
|
|
11
|
+
* have …") into the replaced weapon(s) and the replacement clause;
|
|
12
|
+
* 4. parse the replacement clause into a flat group or a "one of the
|
|
13
|
+
* following" choice.
|
|
14
|
+
*
|
|
15
|
+
* Output carries weapon/wargear **display names**, not ids — the converter
|
|
16
|
+
* resolves those against each unit's own wargear (see convert-faction.ts).
|
|
17
|
+
* Anything that does not fit returns `{ ok: false, reason }` so the caller can
|
|
18
|
+
* surface it in the unparsed report rather than guess. Mechanic-only; no
|
|
19
|
+
* copyrighted rules text is reproduced.
|
|
20
|
+
*/
|
|
21
|
+
export interface ParsedConstraint {
|
|
22
|
+
model_name?: string;
|
|
23
|
+
per_n_models?: number;
|
|
24
|
+
max_count?: number;
|
|
25
|
+
any_number?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface ParsedOption {
|
|
28
|
+
kind: "swap" | "addon";
|
|
29
|
+
constraint: ParsedConstraint;
|
|
30
|
+
/** Display names of weapons removed; empty for a pure add-on. */
|
|
31
|
+
replaces: string[];
|
|
32
|
+
/** Display names added (all of them). Set iff not a choice. */
|
|
33
|
+
replacement?: string[];
|
|
34
|
+
/** Choice of groups ("one of the following"); pick one group. Set iff a choice. */
|
|
35
|
+
replacement_choice?: string[][];
|
|
36
|
+
}
|
|
37
|
+
export type ParseResult = {
|
|
38
|
+
ok: true;
|
|
39
|
+
option: ParsedOption;
|
|
40
|
+
}
|
|
41
|
+
/** A real option we failed to parse — goes to the unparsed report. */
|
|
42
|
+
| {
|
|
43
|
+
ok: false;
|
|
44
|
+
reason: string;
|
|
45
|
+
}
|
|
46
|
+
/** Not an option at all (footnote / empty / note) — silently ignored. */
|
|
47
|
+
| {
|
|
48
|
+
ok: "skip";
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Parse one option `description`. Returns `{ ok: "skip" }` for non-options,
|
|
52
|
+
* `{ ok: false, reason }` for a real option we couldn't parse, and
|
|
53
|
+
* `{ ok: true, option }` otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export declare function parseOption(description: string | null | undefined): ParseResult;
|
|
56
|
+
//# sourceMappingURL=option-parser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"option-parser.d.ts","sourceRoot":"","sources":["../../src/converters/option-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IACvB,UAAU,EAAE,gBAAgB,CAAC;IAC7B,iEAAiE;IACjE,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,mFAAmF;IACnF,kBAAkB,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC;CACjC;AAED,MAAM,MAAM,WAAW,GACnB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,YAAY,CAAA;CAAE;AACpC,sEAAsE;GACpE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE;AAC/B,yEAAyE;GACvE;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AA6JnB;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,WAAW,CAkC/E"}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a Warhammer wargear-option line (army-assist `Datasheets_options.json`
|
|
3
|
+
* `description` prose) into a structured option, or report why it could not.
|
|
4
|
+
*
|
|
5
|
+
* The prose is regular enough to parse with a small staged pipeline:
|
|
6
|
+
* 1. skip non-options (null / "None" / footnotes / "cannot be replaced");
|
|
7
|
+
* 2. peel the leading *constraint* clause ("For every 5 models,", "The
|
|
8
|
+
* Champion's", "Any number of Boyz", "Up to 2 models", "1 model");
|
|
9
|
+
* 3. split the core on the swap/add verb (passive "… can be replaced with …",
|
|
10
|
+
* active "… can replace its … with …", or add-on "… equipped with / can
|
|
11
|
+
* have …") into the replaced weapon(s) and the replacement clause;
|
|
12
|
+
* 4. parse the replacement clause into a flat group or a "one of the
|
|
13
|
+
* following" choice.
|
|
14
|
+
*
|
|
15
|
+
* Output carries weapon/wargear **display names**, not ids — the converter
|
|
16
|
+
* resolves those against each unit's own wargear (see convert-faction.ts).
|
|
17
|
+
* Anything that does not fit returns `{ ok: false, reason }` so the caller can
|
|
18
|
+
* surface it in the unparsed report rather than guess. Mechanic-only; no
|
|
19
|
+
* copyrighted rules text is reproduced.
|
|
20
|
+
*/
|
|
21
|
+
/** Tidy a captured weapon/wargear name: drop footnote markers, collapse spaces. */
|
|
22
|
+
function cleanName(raw) {
|
|
23
|
+
return raw
|
|
24
|
+
.replace(/\*+/g, "") // footnote markers ("blastmaster*")
|
|
25
|
+
.replace(/\s+/g, " ")
|
|
26
|
+
.replace(/^[.,;:\s]+|[.,;:\s]+$/g, "")
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
/** Strip a leading per-model quantity ("1 ", "2 ") from a replacement item. */
|
|
30
|
+
function stripLeadingCount(item) {
|
|
31
|
+
return cleanName(item.replace(/^\s*\d+\s+/, ""));
|
|
32
|
+
}
|
|
33
|
+
/** Split a flat replacement/replaces clause on " and " into individual names. */
|
|
34
|
+
function splitAnd(clause) {
|
|
35
|
+
return clause
|
|
36
|
+
.split(/\s+and\s+/i)
|
|
37
|
+
.map(stripLeadingCount)
|
|
38
|
+
.filter((s) => /[a-z0-9]/i.test(s)); // drop empties and stray punctuation
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Split the concatenated "one of the following" list into groups. The source
|
|
42
|
+
* runs items together with no separator ("1 flamer1 grav-gun1 meltagun"), so we
|
|
43
|
+
* break before each leading count. Each item may itself be "A and B".
|
|
44
|
+
*/
|
|
45
|
+
function parseChoiceList(tail) {
|
|
46
|
+
// Break before a digit that begins a new item (preceded by a non-digit).
|
|
47
|
+
const parts = tail
|
|
48
|
+
.split(/(?<=\D)(?=\d+\s)/)
|
|
49
|
+
.map((s) => s.trim())
|
|
50
|
+
.filter((s) => s.length > 0);
|
|
51
|
+
const groups = parts.map(splitAnd).filter((g) => g.length > 0);
|
|
52
|
+
return groups;
|
|
53
|
+
}
|
|
54
|
+
/** Parse the text after the swap/add verb into a flat group or a choice. */
|
|
55
|
+
function parseReplacement(rest) {
|
|
56
|
+
const choiceMatch = rest.match(/one of the following\s*:?\s*(.+)$/is);
|
|
57
|
+
if (choiceMatch) {
|
|
58
|
+
const groups = parseChoiceList(choiceMatch[1]);
|
|
59
|
+
// A "choice" that parsed to a single alternative is just a plain
|
|
60
|
+
// replacement — the schema reserves replacement_choice for ≥2 options.
|
|
61
|
+
if (groups.length >= 2)
|
|
62
|
+
return { replacement_choice: groups };
|
|
63
|
+
if (groups.length === 1)
|
|
64
|
+
return { replacement: groups[0] };
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
return { replacement: splitAnd(rest) };
|
|
68
|
+
}
|
|
69
|
+
/** Normalise known source typos / spacing so the verb regexes match uniformly. */
|
|
70
|
+
function normalize(desc) {
|
|
71
|
+
return desc
|
|
72
|
+
.replace(/–|—/g, "-")
|
|
73
|
+
.replace(/replaced\s+with/gi, "replaced with") // collapse double space
|
|
74
|
+
.replace(/can be replace with/gi, "can be replaced with") // missing 'd'
|
|
75
|
+
.replace(/can be replace\b(?! with)/gi, "can be replaced")
|
|
76
|
+
.replace(/replaced one of the following/gi, "replaced with one of the following")
|
|
77
|
+
.replace(/following(?=\d)/gi, "following:") // "following1 flamer" -> "following:1 flamer"
|
|
78
|
+
.replace(/\s+/g, " ")
|
|
79
|
+
.trim();
|
|
80
|
+
}
|
|
81
|
+
/** Peel the leading constraint clause, returning it plus the remaining core. */
|
|
82
|
+
function extractConstraint(core) {
|
|
83
|
+
const constraint = {};
|
|
84
|
+
let s = core;
|
|
85
|
+
const forEvery = s.match(/^for every (\d+) models?(?: in (?:this unit|the unit))?,?\s*/i);
|
|
86
|
+
if (forEvery) {
|
|
87
|
+
constraint.per_n_models = parseInt(forEvery[1], 10);
|
|
88
|
+
s = s.slice(forEvery[0].length);
|
|
89
|
+
}
|
|
90
|
+
if (/^any number of\b/i.test(s)) {
|
|
91
|
+
constraint.any_number = true;
|
|
92
|
+
const m = s.match(/^any number of\s+(.+?)\s+can\b/i);
|
|
93
|
+
if (m && !/^models?$/i.test(m[1]))
|
|
94
|
+
constraint.model_name = cleanName(m[1]);
|
|
95
|
+
s = s.replace(/^any number of\s+.+?\s+(?=can\b)/i, "");
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const upTo = s.match(/^up to (\d+)\s+(.+?)\s+(?=can\b)/i);
|
|
99
|
+
if (upTo) {
|
|
100
|
+
constraint.max_count = parseInt(upTo[1], 10);
|
|
101
|
+
if (!/^models?$/i.test(upTo[2]))
|
|
102
|
+
constraint.model_name = cleanName(upTo[2]);
|
|
103
|
+
s = s.slice(upTo[0].length);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// "The Champion's …", "The Kill Team Sergeant can …"
|
|
107
|
+
const theRole = s.match(/^the\s+(.+?)(?:'s|’s)\s+/i) || s.match(/^the\s+(.+?)\s+can\b/i);
|
|
108
|
+
if (theRole) {
|
|
109
|
+
constraint.model_name = cleanName(theRole[1]);
|
|
110
|
+
if (constraint.max_count === undefined && constraint.per_n_models === undefined) {
|
|
111
|
+
constraint.max_count = 1;
|
|
112
|
+
}
|
|
113
|
+
// Drop only the "The <role>" lead-in; keep the possessive weapon for the verb stage.
|
|
114
|
+
s = s.replace(/^the\s+/i, "");
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
// "1 model …", "1 Khorne Berzerker's …", "One model …"
|
|
118
|
+
const leadCount = s.match(/^(\d+|one)\s+(model|.+?)(?=(?:'s|’s)\s+|\s+can\b)/i);
|
|
119
|
+
if (leadCount) {
|
|
120
|
+
const n = /^one$/i.test(leadCount[1]) ? 1 : parseInt(leadCount[1], 10);
|
|
121
|
+
if (constraint.per_n_models === undefined && constraint.max_count === undefined) {
|
|
122
|
+
constraint.max_count = n;
|
|
123
|
+
}
|
|
124
|
+
if (!/^models?$/i.test(leadCount[2]))
|
|
125
|
+
constraint.model_name = cleanName(leadCount[2]);
|
|
126
|
+
s = s.replace(/^(?:\d+|one)\s+(?:model\s+)?/i, "");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// A bare "For every N models, 1 <model>'s …" leaves a leading "1 <model>" —
|
|
132
|
+
// and "This model"/"This unit"/"it" lead-ins carry no constraint.
|
|
133
|
+
s = s.replace(/^(\d+)\s+(?=\S+(?:'s|’s)\s)/, "");
|
|
134
|
+
return { constraint, rest: s.trim() };
|
|
135
|
+
}
|
|
136
|
+
/** Pull the replaced weapon(s) and the replacement clause out of the core. */
|
|
137
|
+
function splitOnVerb(core) {
|
|
138
|
+
// Active voice: "… (can )?(each )?replace(s|d) (its|their|one of its/their) WEAPON with REST"
|
|
139
|
+
const active = core.match(/\b(?:can\s+)?(?:each\s+)?replaces?\s+(?:one of\s+)?(?:its|their)\s+(.+?)\s+with\s*:?\s*(.+)$/i);
|
|
140
|
+
if (active) {
|
|
141
|
+
return { replaces: splitAnd(active[1]), rest: active[2].trim() };
|
|
142
|
+
}
|
|
143
|
+
// "have/has (its|their) WEAPON replaced with REST" ("Any number of Boyz can
|
|
144
|
+
// each have their slugga and choppa replaced with …").
|
|
145
|
+
const havePassive = core.match(/\b(?:have|has)\s+(?:its|their)\s+(.+?)\s+replaced\s+with\s*:?\s*(.+)$/i);
|
|
146
|
+
if (havePassive) {
|
|
147
|
+
return { replaces: splitAnd(havePassive[1]), rest: havePassive[2].trim() };
|
|
148
|
+
}
|
|
149
|
+
// Passive voice: "… WEAPON can [each] be replaced with REST" (WEAPON sits after
|
|
150
|
+
// a possessive 's, or is the whole lead if none).
|
|
151
|
+
const passive = core.match(/^(.*?)\s+can (?:each )?be replaced\s+with\s*:?\s*(.+)$/i);
|
|
152
|
+
if (passive) {
|
|
153
|
+
const lead = passive[1];
|
|
154
|
+
const possessive = lead.match(/(?:'s|’s)\s+(.+)$/);
|
|
155
|
+
const weapon = possessive ? possessive[1] : lead;
|
|
156
|
+
return { replaces: splitAnd(weapon), rest: passive[2].trim() };
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
/** Pull the added wargear out of an add-on core ("equipped with …" / "can have …"). */
|
|
161
|
+
function splitOnAddVerb(core) {
|
|
162
|
+
// Allow an immediate colon ("equipped with:1 lobba") as well as a space.
|
|
163
|
+
const m = core.match(/\b(?:equipped with|can have)\s*:?\s*(.+)$/i);
|
|
164
|
+
return m ? { rest: m[1].trim() } : null;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Parse one option `description`. Returns `{ ok: "skip" }` for non-options,
|
|
168
|
+
* `{ ok: false, reason }` for a real option we couldn't parse, and
|
|
169
|
+
* `{ ok: true, option }` otherwise.
|
|
170
|
+
*/
|
|
171
|
+
export function parseOption(description) {
|
|
172
|
+
if (description == null)
|
|
173
|
+
return { ok: "skip" };
|
|
174
|
+
const trimmed = description.trim();
|
|
175
|
+
if (trimmed === "" || trimmed === "None")
|
|
176
|
+
return { ok: "skip" };
|
|
177
|
+
if (trimmed.startsWith("*"))
|
|
178
|
+
return { ok: "skip" };
|
|
179
|
+
if (/cannot be (replaced|taken|equipped)/i.test(trimmed))
|
|
180
|
+
return { ok: "skip" };
|
|
181
|
+
const desc = normalize(trimmed);
|
|
182
|
+
const isSwap = /\breplace/i.test(desc);
|
|
183
|
+
const isAddon = !isSwap && /\b(equipped with|can have)\b/i.test(desc);
|
|
184
|
+
if (!isSwap && !isAddon) {
|
|
185
|
+
return { ok: false, reason: "no swap/add verb recognised" };
|
|
186
|
+
}
|
|
187
|
+
const { constraint, rest: core } = extractConstraint(desc);
|
|
188
|
+
if (isSwap) {
|
|
189
|
+
const split = splitOnVerb(core);
|
|
190
|
+
if (!split)
|
|
191
|
+
return { ok: false, reason: "could not isolate replaced weapon / replacement" };
|
|
192
|
+
if (split.replaces.length === 0)
|
|
193
|
+
return { ok: false, reason: "empty replaced-weapon list" };
|
|
194
|
+
const repl = parseReplacement(split.rest);
|
|
195
|
+
if (!repl.replacement?.length && !repl.replacement_choice?.length) {
|
|
196
|
+
return { ok: false, reason: "empty replacement" };
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, option: { kind: "swap", constraint, replaces: split.replaces, ...repl } };
|
|
199
|
+
}
|
|
200
|
+
const add = splitOnAddVerb(core);
|
|
201
|
+
if (!add)
|
|
202
|
+
return { ok: false, reason: "could not isolate added wargear" };
|
|
203
|
+
const repl = parseReplacement(add.rest);
|
|
204
|
+
if (!repl.replacement?.length && !repl.replacement_choice?.length) {
|
|
205
|
+
return { ok: false, reason: "empty add-on" };
|
|
206
|
+
}
|
|
207
|
+
return { ok: true, option: { kind: "addon", constraint, replaces: [], ...repl } };
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=option-parser.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"option-parser.js","sourceRoot":"","sources":["../../src/converters/option-parser.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Parse a Warhammer wargear-option line (army-assist `Datasheets_options.json`\n * `description` prose) into a structured option, or report why it could not.\n *\n * The prose is regular enough to parse with a small staged pipeline:\n * 1. skip non-options (null / \"None\" / footnotes / \"cannot be replaced\");\n * 2. peel the leading *constraint* clause (\"For every 5 models,\", \"The\n * Champion's\", \"Any number of Boyz\", \"Up to 2 models\", \"1 model\");\n * 3. split the core on the swap/add verb (passive \"… can be replaced with …\",\n * active \"… can replace its … with …\", or add-on \"… equipped with / can\n * have …\") into the replaced weapon(s) and the replacement clause;\n * 4. parse the replacement clause into a flat group or a \"one of the\n * following\" choice.\n *\n * Output carries weapon/wargear **display names**, not ids — the converter\n * resolves those against each unit's own wargear (see convert-faction.ts).\n * Anything that does not fit returns `{ ok: false, reason }` so the caller can\n * surface it in the unparsed report rather than guess. Mechanic-only; no\n * copyrighted rules text is reproduced.\n */\n\nexport interface ParsedConstraint {\n model_name?: string;\n per_n_models?: number;\n max_count?: number;\n any_number?: boolean;\n}\n\nexport interface ParsedOption {\n kind: \"swap\" | \"addon\";\n constraint: ParsedConstraint;\n /** Display names of weapons removed; empty for a pure add-on. */\n replaces: string[];\n /** Display names added (all of them). Set iff not a choice. */\n replacement?: string[];\n /** Choice of groups (\"one of the following\"); pick one group. Set iff a choice. */\n replacement_choice?: string[][];\n}\n\nexport type ParseResult =\n | { ok: true; option: ParsedOption }\n /** A real option we failed to parse — goes to the unparsed report. */\n | { ok: false; reason: string }\n /** Not an option at all (footnote / empty / note) — silently ignored. */\n | { ok: \"skip\" };\n\n/** Tidy a captured weapon/wargear name: drop footnote markers, collapse spaces. */\nfunction cleanName(raw: string): string {\n return raw\n .replace(/\\*+/g, \"\") // footnote markers (\"blastmaster*\")\n .replace(/\\s+/g, \" \")\n .replace(/^[.,;:\\s]+|[.,;:\\s]+$/g, \"\")\n .trim();\n}\n\n/** Strip a leading per-model quantity (\"1 \", \"2 \") from a replacement item. */\nfunction stripLeadingCount(item: string): string {\n return cleanName(item.replace(/^\\s*\\d+\\s+/, \"\"));\n}\n\n/** Split a flat replacement/replaces clause on \" and \" into individual names. */\nfunction splitAnd(clause: string): string[] {\n return clause\n .split(/\\s+and\\s+/i)\n .map(stripLeadingCount)\n .filter((s) => /[a-z0-9]/i.test(s)); // drop empties and stray punctuation\n}\n\n/**\n * Split the concatenated \"one of the following\" list into groups. The source\n * runs items together with no separator (\"1 flamer1 grav-gun1 meltagun\"), so we\n * break before each leading count. Each item may itself be \"A and B\".\n */\nfunction parseChoiceList(tail: string): string[][] {\n // Break before a digit that begins a new item (preceded by a non-digit).\n const parts = tail\n .split(/(?<=\\D)(?=\\d+\\s)/)\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n const groups = parts.map(splitAnd).filter((g) => g.length > 0);\n return groups;\n}\n\n/** Parse the text after the swap/add verb into a flat group or a choice. */\nfunction parseReplacement(rest: string): Pick<ParsedOption, \"replacement\" | \"replacement_choice\"> {\n const choiceMatch = rest.match(/one of the following\\s*:?\\s*(.+)$/is);\n if (choiceMatch) {\n const groups = parseChoiceList(choiceMatch[1]);\n // A \"choice\" that parsed to a single alternative is just a plain\n // replacement — the schema reserves replacement_choice for ≥2 options.\n if (groups.length >= 2) return { replacement_choice: groups };\n if (groups.length === 1) return { replacement: groups[0] };\n return {};\n }\n return { replacement: splitAnd(rest) };\n}\n\n/** Normalise known source typos / spacing so the verb regexes match uniformly. */\nfunction normalize(desc: string): string {\n return desc\n .replace(/–|—/g, \"-\")\n .replace(/replaced\\s+with/gi, \"replaced with\") // collapse double space\n .replace(/can be replace with/gi, \"can be replaced with\") // missing 'd'\n .replace(/can be replace\\b(?! with)/gi, \"can be replaced\")\n .replace(/replaced one of the following/gi, \"replaced with one of the following\")\n .replace(/following(?=\\d)/gi, \"following:\") // \"following1 flamer\" -> \"following:1 flamer\"\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\n/** Peel the leading constraint clause, returning it plus the remaining core. */\nfunction extractConstraint(core: string): { constraint: ParsedConstraint; rest: string } {\n const constraint: ParsedConstraint = {};\n let s = core;\n\n const forEvery = s.match(/^for every (\\d+) models?(?: in (?:this unit|the unit))?,?\\s*/i);\n if (forEvery) {\n constraint.per_n_models = parseInt(forEvery[1], 10);\n s = s.slice(forEvery[0].length);\n }\n\n if (/^any number of\\b/i.test(s)) {\n constraint.any_number = true;\n const m = s.match(/^any number of\\s+(.+?)\\s+can\\b/i);\n if (m && !/^models?$/i.test(m[1])) constraint.model_name = cleanName(m[1]);\n s = s.replace(/^any number of\\s+.+?\\s+(?=can\\b)/i, \"\");\n } else {\n const upTo = s.match(/^up to (\\d+)\\s+(.+?)\\s+(?=can\\b)/i);\n if (upTo) {\n constraint.max_count = parseInt(upTo[1], 10);\n if (!/^models?$/i.test(upTo[2])) constraint.model_name = cleanName(upTo[2]);\n s = s.slice(upTo[0].length);\n } else {\n // \"The Champion's …\", \"The Kill Team Sergeant can …\"\n const theRole = s.match(/^the\\s+(.+?)(?:'s|’s)\\s+/i) || s.match(/^the\\s+(.+?)\\s+can\\b/i);\n if (theRole) {\n constraint.model_name = cleanName(theRole[1]);\n if (constraint.max_count === undefined && constraint.per_n_models === undefined) {\n constraint.max_count = 1;\n }\n // Drop only the \"The <role>\" lead-in; keep the possessive weapon for the verb stage.\n s = s.replace(/^the\\s+/i, \"\");\n } else {\n // \"1 model …\", \"1 Khorne Berzerker's …\", \"One model …\"\n const leadCount = s.match(/^(\\d+|one)\\s+(model|.+?)(?=(?:'s|’s)\\s+|\\s+can\\b)/i);\n if (leadCount) {\n const n = /^one$/i.test(leadCount[1]) ? 1 : parseInt(leadCount[1], 10);\n if (constraint.per_n_models === undefined && constraint.max_count === undefined) {\n constraint.max_count = n;\n }\n if (!/^models?$/i.test(leadCount[2])) constraint.model_name = cleanName(leadCount[2]);\n s = s.replace(/^(?:\\d+|one)\\s+(?:model\\s+)?/i, \"\");\n }\n }\n }\n }\n\n // A bare \"For every N models, 1 <model>'s …\" leaves a leading \"1 <model>\" —\n // and \"This model\"/\"This unit\"/\"it\" lead-ins carry no constraint.\n s = s.replace(/^(\\d+)\\s+(?=\\S+(?:'s|’s)\\s)/, \"\");\n return { constraint, rest: s.trim() };\n}\n\n/** Pull the replaced weapon(s) and the replacement clause out of the core. */\nfunction splitOnVerb(\n core: string,\n): { replaces: string[]; rest: string } | null {\n // Active voice: \"… (can )?(each )?replace(s|d) (its|their|one of its/their) WEAPON with REST\"\n const active = core.match(\n /\\b(?:can\\s+)?(?:each\\s+)?replaces?\\s+(?:one of\\s+)?(?:its|their)\\s+(.+?)\\s+with\\s*:?\\s*(.+)$/i,\n );\n if (active) {\n return { replaces: splitAnd(active[1]), rest: active[2].trim() };\n }\n // \"have/has (its|their) WEAPON replaced with REST\" (\"Any number of Boyz can\n // each have their slugga and choppa replaced with …\").\n const havePassive = core.match(\n /\\b(?:have|has)\\s+(?:its|their)\\s+(.+?)\\s+replaced\\s+with\\s*:?\\s*(.+)$/i,\n );\n if (havePassive) {\n return { replaces: splitAnd(havePassive[1]), rest: havePassive[2].trim() };\n }\n // Passive voice: \"… WEAPON can [each] be replaced with REST\" (WEAPON sits after\n // a possessive 's, or is the whole lead if none).\n const passive = core.match(/^(.*?)\\s+can (?:each )?be replaced\\s+with\\s*:?\\s*(.+)$/i);\n if (passive) {\n const lead = passive[1];\n const possessive = lead.match(/(?:'s|’s)\\s+(.+)$/);\n const weapon = possessive ? possessive[1] : lead;\n return { replaces: splitAnd(weapon), rest: passive[2].trim() };\n }\n return null;\n}\n\n/** Pull the added wargear out of an add-on core (\"equipped with …\" / \"can have …\"). */\nfunction splitOnAddVerb(core: string): { rest: string } | null {\n // Allow an immediate colon (\"equipped with:1 lobba\") as well as a space.\n const m = core.match(/\\b(?:equipped with|can have)\\s*:?\\s*(.+)$/i);\n return m ? { rest: m[1].trim() } : null;\n}\n\n/**\n * Parse one option `description`. Returns `{ ok: \"skip\" }` for non-options,\n * `{ ok: false, reason }` for a real option we couldn't parse, and\n * `{ ok: true, option }` otherwise.\n */\nexport function parseOption(description: string | null | undefined): ParseResult {\n if (description == null) return { ok: \"skip\" };\n const trimmed = description.trim();\n if (trimmed === \"\" || trimmed === \"None\") return { ok: \"skip\" };\n if (trimmed.startsWith(\"*\")) return { ok: \"skip\" };\n if (/cannot be (replaced|taken|equipped)/i.test(trimmed)) return { ok: \"skip\" };\n\n const desc = normalize(trimmed);\n const isSwap = /\\breplace/i.test(desc);\n const isAddon = !isSwap && /\\b(equipped with|can have)\\b/i.test(desc);\n if (!isSwap && !isAddon) {\n return { ok: false, reason: \"no swap/add verb recognised\" };\n }\n\n const { constraint, rest: core } = extractConstraint(desc);\n\n if (isSwap) {\n const split = splitOnVerb(core);\n if (!split) return { ok: false, reason: \"could not isolate replaced weapon / replacement\" };\n if (split.replaces.length === 0) return { ok: false, reason: \"empty replaced-weapon list\" };\n const repl = parseReplacement(split.rest);\n if (!repl.replacement?.length && !repl.replacement_choice?.length) {\n return { ok: false, reason: \"empty replacement\" };\n }\n return { ok: true, option: { kind: \"swap\", constraint, replaces: split.replaces, ...repl } };\n }\n\n const add = splitOnAddVerb(core);\n if (!add) return { ok: false, reason: \"could not isolate added wargear\" };\n const repl = parseReplacement(add.rest);\n if (!repl.replacement?.length && !repl.replacement_choice?.length) {\n return { ok: false, reason: \"empty add-on\" };\n }\n return { ok: true, option: { kind: \"addon\", constraint, replaces: [], ...repl } };\n}\n"]}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type ParsedConstraint } from "./option-parser.js";
|
|
2
|
+
interface SourceOption {
|
|
3
|
+
datasheet_id: string;
|
|
4
|
+
line: string;
|
|
5
|
+
description: string | null;
|
|
6
|
+
}
|
|
7
|
+
interface SourceComposition {
|
|
8
|
+
datasheet_id: string;
|
|
9
|
+
line: string;
|
|
10
|
+
description: string;
|
|
11
|
+
}
|
|
12
|
+
interface SourceModel {
|
|
13
|
+
datasheet_id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
}
|
|
16
|
+
interface GameVersion {
|
|
17
|
+
edition: string;
|
|
18
|
+
dataslate: string;
|
|
19
|
+
}
|
|
20
|
+
export interface WargearEntity {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
category?: string;
|
|
24
|
+
game_version: GameVersion;
|
|
25
|
+
}
|
|
26
|
+
export interface WargearOptionEntity {
|
|
27
|
+
id: string;
|
|
28
|
+
unit_id: string;
|
|
29
|
+
model_constraint?: ParsedConstraint;
|
|
30
|
+
replaces?: string[];
|
|
31
|
+
replacement?: string[];
|
|
32
|
+
replacement_choice?: string[][];
|
|
33
|
+
is_free: boolean;
|
|
34
|
+
game_version: GameVersion;
|
|
35
|
+
}
|
|
36
|
+
export interface UnparsedOption {
|
|
37
|
+
unit_id: string | null;
|
|
38
|
+
datasheet: string;
|
|
39
|
+
line: string;
|
|
40
|
+
description: string | null;
|
|
41
|
+
reason: string;
|
|
42
|
+
}
|
|
43
|
+
export interface BuildWargearResult {
|
|
44
|
+
wargearOptions: WargearOptionEntity[];
|
|
45
|
+
wargear: WargearEntity[];
|
|
46
|
+
unparsed: UnparsedOption[];
|
|
47
|
+
}
|
|
48
|
+
export declare function buildWargearOptions(factionDatasheets: readonly {
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
}[], allModels: readonly SourceModel[], allOptions: readonly SourceOption[], allComposition: readonly SourceComposition[], unitWeaponIds: Map<string, Set<string>>, // UUID → weapon ids on that unit
|
|
52
|
+
globalWeaponIds: Set<string>, // every weapon id in the faction
|
|
53
|
+
gameVersion: GameVersion): BuildWargearResult;
|
|
54
|
+
export {};
|
|
55
|
+
//# sourceMappingURL=wargear-options.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wargear-options.d.ts","sourceRoot":"","sources":["../../src/converters/wargear-options.ts"],"names":[],"mappings":"AAYA,OAAO,EAAe,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAOxE,UAAU,YAAY;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AACD,UAAU,iBAAiB;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;CACrB;AACD,UAAU,WAAW;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,WAAW;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,WAAW,CAAC;CAC3B;AAED,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,kBAAkB,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB,YAAY,EAAE,WAAW,CAAC;CAC3B;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,mBAAmB,EAAE,CAAC;IACtC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,QAAQ,EAAE,cAAc,EAAE,CAAC;CAC5B;AAqBD,wBAAgB,mBAAmB,CACjC,iBAAiB,EAAE,SAAS;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,EAC1D,SAAS,EAAE,SAAS,WAAW,EAAE,EACjC,UAAU,EAAE,SAAS,YAAY,EAAE,EACnC,cAAc,EAAE,SAAS,iBAAiB,EAAE,EAC5C,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,EAAE,iCAAiC;AAC1E,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,iCAAiC;AAC/D,WAAW,EAAE,WAAW,GACvB,kBAAkB,CA0JpB"}
|