@alpaca-software/40kdc-data 0.1.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 +78 -0
- package/dist/bundle-schemas.d.ts +3 -0
- package/dist/bundle-schemas.js +137 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +31 -0
- package/dist/codegen-data.d.ts +1 -0
- package/dist/codegen-data.js +128 -0
- package/dist/commands/translate.d.ts +7 -0
- package/dist/commands/translate.js +238 -0
- package/dist/commands/validate-all.d.ts +3 -0
- package/dist/commands/validate-all.js +20 -0
- package/dist/commands/validate-core.d.ts +3 -0
- package/dist/commands/validate-core.js +12 -0
- package/dist/commands/validate-enrichment.d.ts +3 -0
- package/dist/commands/validate-enrichment.js +12 -0
- package/dist/convert-faction.d.ts +45 -0
- package/dist/convert-faction.js +479 -0
- package/dist/converters/configs/adepta-sororitas.d.ts +3 -0
- package/dist/converters/configs/adepta-sororitas.js +70 -0
- package/dist/converters/configs/adeptus-astartes.d.ts +3 -0
- package/dist/converters/configs/adeptus-astartes.js +74 -0
- package/dist/converters/configs/adeptus-custodes.d.ts +3 -0
- package/dist/converters/configs/adeptus-custodes.js +14 -0
- package/dist/converters/configs/adeptus-mechanicus.d.ts +3 -0
- package/dist/converters/configs/adeptus-mechanicus.js +51 -0
- package/dist/converters/configs/aeldari.d.ts +3 -0
- package/dist/converters/configs/aeldari.js +79 -0
- package/dist/converters/configs/agents-of-the-imperium.d.ts +3 -0
- package/dist/converters/configs/agents-of-the-imperium.js +57 -0
- package/dist/converters/configs/astra-militarum.d.ts +3 -0
- package/dist/converters/configs/astra-militarum.js +80 -0
- package/dist/converters/configs/black-templars.d.ts +3 -0
- package/dist/converters/configs/black-templars.js +16 -0
- package/dist/converters/configs/blood-angels.d.ts +3 -0
- package/dist/converters/configs/blood-angels.js +16 -0
- package/dist/converters/configs/chaos-daemons.d.ts +3 -0
- package/dist/converters/configs/chaos-daemons.js +40 -0
- package/dist/converters/configs/chaos-knights.d.ts +3 -0
- package/dist/converters/configs/chaos-knights.js +14 -0
- package/dist/converters/configs/chaos-space-marines.d.ts +3 -0
- package/dist/converters/configs/chaos-space-marines.js +95 -0
- package/dist/converters/configs/crimson-fists.d.ts +3 -0
- package/dist/converters/configs/crimson-fists.js +16 -0
- package/dist/converters/configs/dark-angels.d.ts +3 -0
- package/dist/converters/configs/dark-angels.js +16 -0
- package/dist/converters/configs/death-guard.d.ts +3 -0
- package/dist/converters/configs/death-guard.js +30 -0
- package/dist/converters/configs/deathwatch.d.ts +3 -0
- package/dist/converters/configs/deathwatch.js +16 -0
- package/dist/converters/configs/drukhari.d.ts +3 -0
- package/dist/converters/configs/drukhari.js +51 -0
- package/dist/converters/configs/emperors-children.d.ts +3 -0
- package/dist/converters/configs/emperors-children.js +38 -0
- package/dist/converters/configs/genestealer-cults.d.ts +3 -0
- package/dist/converters/configs/genestealer-cults.js +36 -0
- package/dist/converters/configs/grey-knights.d.ts +3 -0
- package/dist/converters/configs/grey-knights.js +39 -0
- package/dist/converters/configs/imperial-fists.d.ts +3 -0
- package/dist/converters/configs/imperial-fists.js +16 -0
- package/dist/converters/configs/imperial-knights.d.ts +3 -0
- package/dist/converters/configs/imperial-knights.js +14 -0
- package/dist/converters/configs/iron-hands.d.ts +3 -0
- package/dist/converters/configs/iron-hands.js +16 -0
- package/dist/converters/configs/leagues-of-votann.d.ts +3 -0
- package/dist/converters/configs/leagues-of-votann.js +32 -0
- package/dist/converters/configs/necrons.d.ts +3 -0
- package/dist/converters/configs/necrons.js +19 -0
- package/dist/converters/configs/orks.d.ts +3 -0
- package/dist/converters/configs/orks.js +71 -0
- package/dist/converters/configs/raven-guard.d.ts +3 -0
- package/dist/converters/configs/raven-guard.js +16 -0
- package/dist/converters/configs/salamanders.d.ts +3 -0
- package/dist/converters/configs/salamanders.js +16 -0
- package/dist/converters/configs/space-wolves.d.ts +3 -0
- package/dist/converters/configs/space-wolves.js +16 -0
- package/dist/converters/configs/tau-empire.d.ts +3 -0
- package/dist/converters/configs/tau-empire.js +44 -0
- package/dist/converters/configs/thousand-sons.d.ts +3 -0
- package/dist/converters/configs/thousand-sons.js +30 -0
- package/dist/converters/configs/tyranids.d.ts +3 -0
- package/dist/converters/configs/tyranids.js +27 -0
- package/dist/converters/configs/ultramarines.d.ts +3 -0
- package/dist/converters/configs/ultramarines.js +16 -0
- package/dist/converters/configs/white-scars.d.ts +3 -0
- package/dist/converters/configs/white-scars.js +16 -0
- package/dist/converters/configs/world-eaters.d.ts +3 -0
- package/dist/converters/configs/world-eaters.js +43 -0
- package/dist/converters/faction-config.d.ts +53 -0
- package/dist/converters/faction-config.js +22 -0
- package/dist/converters/id-generator.d.ts +14 -0
- package/dist/converters/id-generator.js +65 -0
- package/dist/converters/keyword-filter.d.ts +26 -0
- package/dist/converters/keyword-filter.js +78 -0
- package/dist/converters/stat-parser.d.ts +22 -0
- package/dist/converters/stat-parser.js +84 -0
- package/dist/converters/view-selector.d.ts +54 -0
- package/dist/converters/view-selector.js +96 -0
- package/dist/converters/weapon-dedup.d.ts +60 -0
- package/dist/converters/weapon-dedup.js +120 -0
- package/dist/data/bundle.generated.d.ts +3 -0
- package/dist/data/bundle.generated.js +3 -0
- package/dist/data/collection.d.ts +64 -0
- package/dist/data/collection.js +118 -0
- package/dist/data/dataset.d.ts +50 -0
- package/dist/data/dataset.js +134 -0
- package/dist/data/entities.d.ts +80 -0
- package/dist/data/entities.js +133 -0
- package/dist/data/index.d.ts +59 -0
- package/dist/data/index.js +57 -0
- package/dist/data/normalize.d.ts +29 -0
- package/dist/data/normalize.js +37 -0
- package/dist/data/types.d.ts +43 -0
- package/dist/data/types.js +25 -0
- package/dist/generated.d.ts +1084 -0
- package/dist/generated.js +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/known-support-10e.d.ts +31 -0
- package/dist/known-support-10e.js +113 -0
- package/dist/port-10e-faction.d.ts +52 -0
- package/dist/port-10e-faction.js +413 -0
- package/dist/report.d.ts +3 -0
- package/dist/report.js +31 -0
- package/dist/schema-loader.d.ts +15 -0
- package/dist/schema-loader.js +79 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.js +124 -0
- package/package.json +77 -0
- package/schemas/$defs/common.schema.json +86 -0
- package/schemas/$defs/game-version-ref.schema.json +11 -0
- package/schemas/core/deployment-pattern.schema.json +102 -0
- package/schemas/core/detachment.schema.json +56 -0
- package/schemas/core/enhancement.schema.json +46 -0
- package/schemas/core/faction.schema.json +29 -0
- package/schemas/core/force-disposition.schema.json +22 -0
- package/schemas/core/game-version.schema.json +20 -0
- package/schemas/core/leader-attachment.schema.json +18 -0
- package/schemas/core/mission-matchup.schema.json +25 -0
- package/schemas/core/mission.schema.json +42 -0
- package/schemas/core/roster.schema.json +203 -0
- package/schemas/core/secondary-card.schema.json +195 -0
- package/schemas/core/stratagem.schema.json +58 -0
- package/schemas/core/terrain-layout.schema.json +135 -0
- package/schemas/core/unit-composition.schema.json +38 -0
- package/schemas/core/unit.schema.json +125 -0
- package/schemas/core/wargear-option.schema.json +47 -0
- package/schemas/core/weapon.schema.json +56 -0
- package/schemas/enrichment/ability-dsl/ability.schema.json +60 -0
- package/schemas/enrichment/ability-dsl/condition.schema.json +48 -0
- package/schemas/enrichment/ability-dsl/effect.schema.json +145 -0
- package/schemas/enrichment/ability-dsl/scope.schema.json +12 -0
- package/schemas/enrichment/interaction-flag.schema.json +17 -0
- package/schemas/enrichment/phase-mapping.schema.json +14 -0
- package/schemas/enrichment/resource-pool.schema.json +36 -0
- package/schemas/enrichment/timing-flag.schema.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @alpaca-software/40kdc-data
|
|
2
|
+
|
|
3
|
+
The [40kdc](https://tabletop-developer-consortium.github.io) Warhammer 40,000
|
|
4
|
+
dataset behind a **linked, typed API**. Find a unit, then walk straight to its
|
|
5
|
+
weapons, abilities, the game phases those abilities act in, and its faction —
|
|
6
|
+
all strongly typed, all resolved for you.
|
|
7
|
+
|
|
8
|
+
The full dataset is embedded in the package, so there is no network call, no
|
|
9
|
+
database, and no filesystem access at runtime. It works the same in Node,
|
|
10
|
+
bundlers, and the browser.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { units } from "@alpaca-software/40kdc-data";
|
|
14
|
+
|
|
15
|
+
units.find("Kharn")!.abilities
|
|
16
|
+
.filter(a => a.phases.includes("shooting"))
|
|
17
|
+
.map(a => a.id); // ["berzerker-frenzy"]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @alpaca-software/40kdc-data
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## The shape
|
|
27
|
+
|
|
28
|
+
Top-level collections (`units`, `weapons`, `factions`, `abilities`,
|
|
29
|
+
`detachments`, `stratagems`, …) are accessors over a single embedded
|
|
30
|
+
[`Dataset`](docs/api/data/classes/Dataset.md). Each collection is iterable and
|
|
31
|
+
offers:
|
|
32
|
+
|
|
33
|
+
| Method | Returns |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `.all` | every record (deduplicated) |
|
|
36
|
+
| `.get(id)` | one record by exact id |
|
|
37
|
+
| `.find(nameOrId)` | first match by id or name |
|
|
38
|
+
| `.findAll(nameOrId)` | every match (surfaces names shared across factions) |
|
|
39
|
+
| `.byFaction(id)` | records belonging to a faction |
|
|
40
|
+
|
|
41
|
+
Records resolve their links lazily:
|
|
42
|
+
|
|
43
|
+
- `unit.faction`, `unit.weapons`, `unit.abilities`
|
|
44
|
+
- `ability.phases` (joined from `phase-mappings`), `ability.units`
|
|
45
|
+
- `weapon.units`
|
|
46
|
+
- `faction.units`, `faction.abilities`, `faction.weapons`
|
|
47
|
+
|
|
48
|
+
The full underlying record is always available via `.raw`.
|
|
49
|
+
|
|
50
|
+
### Name matching is built for a global player base
|
|
51
|
+
|
|
52
|
+
Warhammer 40,000 is played worldwide, and many names carry diacritics or
|
|
53
|
+
punctuation — "Khârn the Betrayer", "T'au", "Be'lakor". `find`/`findAll` are
|
|
54
|
+
diacritic- and punctuation-insensitive, so `find("Kharn")` resolves "Khârn the
|
|
55
|
+
Betrayer" and `find("Belakor")` resolves "Be'lakor". The exact rule is exported
|
|
56
|
+
as `normalizeName` so you can reproduce it in your own search UI.
|
|
57
|
+
|
|
58
|
+
## API reference
|
|
59
|
+
|
|
60
|
+
Auto-generated from the source: [`docs/api/`](docs/api/README.md).
|
|
61
|
+
|
|
62
|
+
## Also: schema validation
|
|
63
|
+
|
|
64
|
+
This package also ships the canonical JSON Schemas and an AJV-based validator
|
|
65
|
+
(`createValidator`, `listSchemaIds`, and the `40kdc-validate` CLI) for checking
|
|
66
|
+
data against them. See the repository root for schema details.
|
|
67
|
+
|
|
68
|
+
## Licensing & attribution
|
|
69
|
+
|
|
70
|
+
- Code (`tools/`): **MIT**.
|
|
71
|
+
- Embedded enrichment data (`data/enrichment/`): **CC BY 4.0** —
|
|
72
|
+
attribution: *40kdc community contributors*
|
|
73
|
+
(<https://github.com/Tabletop-Developer-Consortium/40kdc-data>).
|
|
74
|
+
- JSON Schemas: **CC0**.
|
|
75
|
+
|
|
76
|
+
Stat lines and points are numerical facts. Ability and rules text are never
|
|
77
|
+
stored — abilities are community-authored structured mechanics (the Ability
|
|
78
|
+
DSL), not reproductions of copyrighted text.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { findSchemaFiles, SCHEMAS_ROOT } from "./schema-loader.js";
|
|
5
|
+
/**
|
|
6
|
+
* Flattens the multi-file schema set into a single self-contained
|
|
7
|
+
* draft-2020-12 document, written to crates/wh40kdc/schemas/bundled.schema.json.
|
|
8
|
+
*
|
|
9
|
+
* Why a bespoke flattener rather than a ref-parser bundle: the Rust codegen
|
|
10
|
+
* (typify) wants one document where every type lives in a single flat `$defs`
|
|
11
|
+
* map — it resolves `$ref`s as `#/$defs/<name>` and does NOT traverse nested
|
|
12
|
+
* `$defs` paths. Generic bundlers also anchor a reused subschema to its first-use
|
|
13
|
+
* location (e.g. `#/$defs/faction/properties/id`), yielding junk Rust type names.
|
|
14
|
+
*
|
|
15
|
+
* So this pass hoists EVERY definition — `common.schema.json`'s shared defs, each
|
|
16
|
+
* entity schema (keyed by its filename stem), and every entity's local `$defs` —
|
|
17
|
+
* flat into one top-level `$defs`. All such names are globally unique across the
|
|
18
|
+
* schema set (asserted at build time), so no prefixing is needed and type names
|
|
19
|
+
* track the names authors actually chose.
|
|
20
|
+
*
|
|
21
|
+
* Refs are resolved against each schema's `$id` URL — not its filesystem path —
|
|
22
|
+
* because that is how the refs are authored (e.g. `../defs/common.schema.json`
|
|
23
|
+
* targets the `$id` `.../schemas/defs/...`, while the file lives in `$defs/`).
|
|
24
|
+
*/
|
|
25
|
+
const COMMON_ID = "https://40kdc.dev/schemas/defs/common.schema.json";
|
|
26
|
+
const OUTPUT_PATH = resolve(SCHEMAS_ROOT, "../crates/wh40kdc/schemas/bundled.schema.json");
|
|
27
|
+
const BUNDLE_ID = "https://40kdc.dev/schemas/bundled.schema.json";
|
|
28
|
+
function stemOfId(id) {
|
|
29
|
+
return basename(new URL(id).pathname).replace(/\.schema\.json$/, "");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Rewrite a single `$ref` (resolved against the `$id` of the file it appears in)
|
|
33
|
+
* to a flat pointer into the bundle's top-level `$defs`.
|
|
34
|
+
*
|
|
35
|
+
* - any `#/$defs/<name>` pointer (file-local, common, or cross-file into a hoisted
|
|
36
|
+
* local def) stays `#/$defs/<name>` — every such name is now top-level.
|
|
37
|
+
* - a whole-file ref (`effect.schema.json`) maps to that file's stem: `#/$defs/effect`.
|
|
38
|
+
*/
|
|
39
|
+
function rewriteRef(ref, sourceId) {
|
|
40
|
+
const hashIndex = ref.indexOf("#");
|
|
41
|
+
const filePart = hashIndex === -1 ? ref : ref.slice(0, hashIndex);
|
|
42
|
+
const pointer = hashIndex === -1 ? "" : ref.slice(hashIndex + 1); // e.g. "/$defs/entity-id"
|
|
43
|
+
if (pointer) {
|
|
44
|
+
if (!pointer.startsWith("/$defs/")) {
|
|
45
|
+
throw new Error(`unexpected non-$defs JSON pointer in $ref ${JSON.stringify(ref)} (source ${sourceId})`);
|
|
46
|
+
}
|
|
47
|
+
return `#${pointer}`;
|
|
48
|
+
}
|
|
49
|
+
// Whole-file ref: resolve to the target's $id and key by its stem.
|
|
50
|
+
const targetId = filePart ? new URL(filePart, sourceId).href : sourceId;
|
|
51
|
+
return `#/$defs/${stemOfId(targetId)}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Strip JSON Schema conditional applicators (`if`/`then`/`else`) from the codegen
|
|
55
|
+
* bundle. typify cannot model them — they express "field X is required when field
|
|
56
|
+
* Y has value Z", which has no Rust-type representation. The constraints are still
|
|
57
|
+
* enforced at data-validation time by ajv against the real (un-stripped) schemas;
|
|
58
|
+
* dropping them here only makes the affected fields optional in the generated Rust
|
|
59
|
+
* types, which is correct for deserializing any valid document.
|
|
60
|
+
*/
|
|
61
|
+
function stripConditionals(node) {
|
|
62
|
+
if (Array.isArray(node)) {
|
|
63
|
+
return node.map(stripConditionals);
|
|
64
|
+
}
|
|
65
|
+
if (node && typeof node === "object") {
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const [key, value] of Object.entries(node)) {
|
|
68
|
+
if (key === "if" || key === "then" || key === "else")
|
|
69
|
+
continue;
|
|
70
|
+
out[key] = stripConditionals(value);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
return node;
|
|
75
|
+
}
|
|
76
|
+
/** Recursively rewrite every `$ref` in `node`, knowing the source schema's `$id`. */
|
|
77
|
+
function rewriteRefs(node, sourceId) {
|
|
78
|
+
if (Array.isArray(node)) {
|
|
79
|
+
return node.map((item) => rewriteRefs(item, sourceId));
|
|
80
|
+
}
|
|
81
|
+
if (node && typeof node === "object") {
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const [key, value] of Object.entries(node)) {
|
|
84
|
+
if (key === "$ref" && typeof value === "string") {
|
|
85
|
+
out[key] = rewriteRef(value, sourceId);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
out[key] = rewriteRefs(value, sourceId);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
return node;
|
|
94
|
+
}
|
|
95
|
+
export function bundle() {
|
|
96
|
+
const files = findSchemaFiles(SCHEMAS_ROOT).sort();
|
|
97
|
+
const defs = {};
|
|
98
|
+
const place = (name, def, sourceId) => {
|
|
99
|
+
if (name in defs) {
|
|
100
|
+
throw new Error(`definition name collision: ${name} (from ${sourceId})`);
|
|
101
|
+
}
|
|
102
|
+
defs[name] = stripConditionals(rewriteRefs(def, sourceId));
|
|
103
|
+
};
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const raw = JSON.parse(readFileSync(file, "utf-8"));
|
|
106
|
+
const id = raw.$id;
|
|
107
|
+
if (!id)
|
|
108
|
+
throw new Error(`schema missing $id: ${file}`);
|
|
109
|
+
const { $id: _id, $schema: _schema, $defs: localDefs, ...body } = raw;
|
|
110
|
+
// Hoist this file's local $defs flat to the top level (names are globally
|
|
111
|
+
// unique across the schema set; collisions throw above).
|
|
112
|
+
for (const [name, def] of Object.entries((localDefs ?? {}))) {
|
|
113
|
+
place(name, def, id);
|
|
114
|
+
}
|
|
115
|
+
// common is purely a $defs bag — it contributes no stem-keyed entity.
|
|
116
|
+
if (id !== COMMON_ID) {
|
|
117
|
+
place(stemOfId(id), body, id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
122
|
+
$id: BUNDLE_ID,
|
|
123
|
+
title: "40kdc Bundled Schemas",
|
|
124
|
+
description: "Auto-generated by tools/src/bundle-schemas.ts. Single self-contained schema for Rust codegen — do not edit by hand.",
|
|
125
|
+
$defs: defs,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function main() {
|
|
129
|
+
const result = bundle();
|
|
130
|
+
mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
|
|
131
|
+
writeFileSync(OUTPUT_PATH, JSON.stringify(result, null, 2) + "\n", "utf-8");
|
|
132
|
+
const count = Object.keys(result.$defs).length;
|
|
133
|
+
console.log(`Bundled ${count} definitions → ${OUTPUT_PATH}`);
|
|
134
|
+
}
|
|
135
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
136
|
+
main();
|
|
137
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { validateCoreCommand } from "./commands/validate-core.js";
|
|
3
|
+
import { validateEnrichmentCommand } from "./commands/validate-enrichment.js";
|
|
4
|
+
import { validateAllCommand } from "./commands/validate-all.js";
|
|
5
|
+
import { translateCommand } from "./commands/translate.js";
|
|
6
|
+
const program = new Command();
|
|
7
|
+
program
|
|
8
|
+
.name("40kdc-validate")
|
|
9
|
+
.description("Validate 40kdc data files against schemas")
|
|
10
|
+
.version("0.1.0");
|
|
11
|
+
program
|
|
12
|
+
.command("validate-core")
|
|
13
|
+
.description("Validate core data files")
|
|
14
|
+
.option("--reporter <mode>", "Output format: pretty or json", "pretty")
|
|
15
|
+
.action(validateCoreCommand);
|
|
16
|
+
program
|
|
17
|
+
.command("validate-enrichment")
|
|
18
|
+
.description("Validate enrichment data files")
|
|
19
|
+
.option("--reporter <mode>", "Output format: pretty or json", "pretty")
|
|
20
|
+
.action(validateEnrichmentCommand);
|
|
21
|
+
program
|
|
22
|
+
.command("validate-all")
|
|
23
|
+
.description("Validate all data files")
|
|
24
|
+
.option("--reporter <mode>", "Output format: pretty or json", "pretty")
|
|
25
|
+
.action(validateAllCommand);
|
|
26
|
+
program
|
|
27
|
+
.command("translate")
|
|
28
|
+
.description("Translate ability DSL to plain English")
|
|
29
|
+
.argument("[path]", "Path to abilities.json file")
|
|
30
|
+
.action(translateCommand);
|
|
31
|
+
program.parse();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundles every authored data file under `data/` into a single embedded module,
|
|
3
|
+
* `src/data/bundle.generated.ts`.
|
|
4
|
+
*
|
|
5
|
+
* The bundle is inlined as an escaped JSON *string* that is `JSON.parse`d at load
|
|
6
|
+
* time (mirroring the Rust crate's `include_str!`): tsc typechecks it instantly
|
|
7
|
+
* (it is just a string), it parses once at import, and it compiles into `dist`
|
|
8
|
+
* with no runtime filesystem access — so the published package works in Node,
|
|
9
|
+
* bundlers, and browsers alike, where `data/` is not shipped.
|
|
10
|
+
*
|
|
11
|
+
* Run via `npm run codegen:data`. The output is gitignored and regenerated on
|
|
12
|
+
* build/test/pack.
|
|
13
|
+
*/
|
|
14
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { emptyRawData } from "./data/types.js";
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const REPO_ROOT = resolve(__dirname, "../..");
|
|
20
|
+
const DATA_ROOTS = [join(REPO_ROOT, "data", "core"), join(REPO_ROOT, "data", "enrichment")];
|
|
21
|
+
const OUT_FILE = join(__dirname, "data", "bundle.generated.ts");
|
|
22
|
+
/** Directory names that hold examples/scratch data and must never be bundled. */
|
|
23
|
+
const EXCLUDED_DIRS = new Set(["_example", "_port-audit"]);
|
|
24
|
+
/** Map a data file's base name (sans `.json`) to its `RawData` collection key. */
|
|
25
|
+
const FILE_TO_COLLECTION = {
|
|
26
|
+
units: "units",
|
|
27
|
+
weapons: "weapons",
|
|
28
|
+
factions: "factions",
|
|
29
|
+
abilities: "abilities",
|
|
30
|
+
"phase-mappings": "phaseMappings",
|
|
31
|
+
detachments: "detachments",
|
|
32
|
+
stratagems: "stratagems",
|
|
33
|
+
enhancements: "enhancements",
|
|
34
|
+
"leader-attachments": "leaderAttachments",
|
|
35
|
+
"unit-compositions": "unitCompositions",
|
|
36
|
+
"wargear-options": "wargearOptions",
|
|
37
|
+
"game-versions": "gameVersions",
|
|
38
|
+
missions: "missions",
|
|
39
|
+
"mission-matchups": "missionMatchups",
|
|
40
|
+
"secondary-cards": "secondaryCards",
|
|
41
|
+
"deployment-patterns": "deploymentPatterns",
|
|
42
|
+
"force-dispositions": "forceDispositions",
|
|
43
|
+
"resource-pools": "resourcePools",
|
|
44
|
+
"timing-flags": "timingFlags",
|
|
45
|
+
"interaction-flags": "interactionFlags",
|
|
46
|
+
};
|
|
47
|
+
/** The id-bearing key for a collection, used only for duplicate-id reporting. */
|
|
48
|
+
const ID_KEY = {
|
|
49
|
+
abilities: "ability_id",
|
|
50
|
+
};
|
|
51
|
+
/** Recursively collect bundleable `.json` files, skipping excluded dirs/examples. */
|
|
52
|
+
function collectFiles(dir) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const entry of readdirSync(dir)) {
|
|
55
|
+
if (EXCLUDED_DIRS.has(entry))
|
|
56
|
+
continue;
|
|
57
|
+
const full = join(dir, entry);
|
|
58
|
+
if (statSync(full).isDirectory()) {
|
|
59
|
+
out.push(...collectFiles(full));
|
|
60
|
+
}
|
|
61
|
+
else if (entry.endsWith(".json") && !entry.endsWith(".example.json")) {
|
|
62
|
+
out.push(full);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function baseName(file) {
|
|
68
|
+
return file.slice(file.lastIndexOf("/") + 1, -".json".length);
|
|
69
|
+
}
|
|
70
|
+
function build() {
|
|
71
|
+
const data = emptyRawData();
|
|
72
|
+
for (const root of DATA_ROOTS) {
|
|
73
|
+
for (const file of collectFiles(root)) {
|
|
74
|
+
const collection = FILE_TO_COLLECTION[baseName(file)];
|
|
75
|
+
if (!collection)
|
|
76
|
+
continue; // schema/scratch json we don't bundle
|
|
77
|
+
const parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
78
|
+
if (!Array.isArray(parsed)) {
|
|
79
|
+
throw new Error(`expected a JSON array in ${file}`);
|
|
80
|
+
}
|
|
81
|
+
data[collection].push(...parsed);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return data;
|
|
85
|
+
}
|
|
86
|
+
/** Warn (do not fail) on duplicate primary ids — a data-hygiene signal. */
|
|
87
|
+
function reportDuplicateIds(data) {
|
|
88
|
+
for (const [collection, key] of Object.entries(ID_KEY)) {
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
const dupes = new Set();
|
|
91
|
+
for (const item of data[collection]) {
|
|
92
|
+
const id = item[key];
|
|
93
|
+
if (id === undefined)
|
|
94
|
+
continue;
|
|
95
|
+
if (seen.has(id))
|
|
96
|
+
dupes.add(id);
|
|
97
|
+
else
|
|
98
|
+
seen.add(id);
|
|
99
|
+
}
|
|
100
|
+
if (dupes.size > 0) {
|
|
101
|
+
console.warn(` ⚠ ${collection}: ${dupes.size} duplicate ${key}(s), e.g. ${[...dupes].slice(0, 3).join(", ")}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function emit(data) {
|
|
106
|
+
// JSON.stringify of the JSON text yields a valid, fully-escaped JS string
|
|
107
|
+
// literal — safe to drop straight into the generated source.
|
|
108
|
+
const jsonText = JSON.stringify(data);
|
|
109
|
+
const literal = JSON.stringify(jsonText);
|
|
110
|
+
return `/* GENERATED by 'npm run codegen:data' from the repository's data/ tree. DO NOT EDIT BY HAND. */
|
|
111
|
+
import type { RawData } from "./types.js";
|
|
112
|
+
|
|
113
|
+
const JSON_TEXT = ${literal};
|
|
114
|
+
|
|
115
|
+
/** The full 40kdc dataset, embedded at build time and parsed once at load. */
|
|
116
|
+
export const RAW_DATA: RawData = JSON.parse(JSON_TEXT) as RawData;
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
function main() {
|
|
120
|
+
const data = build();
|
|
121
|
+
reportDuplicateIds(data);
|
|
122
|
+
writeFileSync(OUT_FILE, emit(data));
|
|
123
|
+
const counts = Object.keys(data)
|
|
124
|
+
.map((k) => `${k}=${data[k].length}`)
|
|
125
|
+
.join(", ");
|
|
126
|
+
console.log(`Wrote ${OUT_FILE}\n ${counts}`);
|
|
127
|
+
}
|
|
128
|
+
main();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translates ability DSL entries into plain English descriptions.
|
|
3
|
+
*
|
|
4
|
+
* Recursively walks the effect/condition tree and produces human-readable
|
|
5
|
+
* text purely from the structured data — no external text sources needed.
|
|
6
|
+
*/
|
|
7
|
+
export declare function translateCommand(path?: string): Promise<void>;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translates ability DSL entries into plain English descriptions.
|
|
3
|
+
*
|
|
4
|
+
* Recursively walks the effect/condition tree and produces human-readable
|
|
5
|
+
* text purely from the structured data — no external text sources needed.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
// ─── Condition translator ────────────────────────────────────────────
|
|
10
|
+
function translateCondition(c) {
|
|
11
|
+
const negate = c.negated ? "not " : "";
|
|
12
|
+
// Compound conditions
|
|
13
|
+
if (c.operator === "and" && c.operands) {
|
|
14
|
+
const parts = c.operands.map(translateCondition);
|
|
15
|
+
return parts.join(" AND ");
|
|
16
|
+
}
|
|
17
|
+
if (c.operator === "or" && c.operands) {
|
|
18
|
+
const parts = c.operands.map(translateCondition);
|
|
19
|
+
return parts.join(" OR ");
|
|
20
|
+
}
|
|
21
|
+
if (c.operator === "not" && c.operands) {
|
|
22
|
+
return `NOT (${c.operands.map(translateCondition).join(", ")})`;
|
|
23
|
+
}
|
|
24
|
+
const p = c.parameters ?? {};
|
|
25
|
+
switch (c.type) {
|
|
26
|
+
case "phase-is":
|
|
27
|
+
return `${negate}during the ${p.phase} phase`;
|
|
28
|
+
case "timing-is":
|
|
29
|
+
return `${negate}at ${formatTiming(p.timing)}`;
|
|
30
|
+
case "player-turn-is":
|
|
31
|
+
return `${negate}in ${p.turn === "your-turn" ? "your" : p.turn === "opponent-turn" ? "opponent's" : "either player's"} turn`;
|
|
32
|
+
case "charged-this-turn":
|
|
33
|
+
return `${negate}unit charged this turn`;
|
|
34
|
+
case "advanced-this-turn":
|
|
35
|
+
return `${negate}unit advanced this turn`;
|
|
36
|
+
case "remained-stationary":
|
|
37
|
+
return `${negate}unit remained stationary`;
|
|
38
|
+
case "unit-below-starting-strength":
|
|
39
|
+
return `${negate}target is below starting strength`;
|
|
40
|
+
case "unit-below-half-strength":
|
|
41
|
+
return `${negate}target is below half strength`;
|
|
42
|
+
case "unit-has-keyword":
|
|
43
|
+
return `${negate}unit has "${p.keyword}"`;
|
|
44
|
+
case "target-has-keyword":
|
|
45
|
+
return `${negate}target has "${p.keyword}"`;
|
|
46
|
+
case "model-is-leader":
|
|
47
|
+
return `${negate}model is leading a unit`;
|
|
48
|
+
case "is-attached":
|
|
49
|
+
return `${negate}attached to a ${p.keyword ?? ""} unit`;
|
|
50
|
+
case "attack-is-type":
|
|
51
|
+
return `${negate}for ${p.attack_type} attacks`;
|
|
52
|
+
case "is-battle-shocked":
|
|
53
|
+
return `${negate}unit is battle-shocked`;
|
|
54
|
+
case "has-lost-wounds":
|
|
55
|
+
return `${negate}model has lost wounds`;
|
|
56
|
+
case "opponent-unit-within-range":
|
|
57
|
+
return `${negate}enemy unit within ${p.range === "engagement" ? "engagement range" : p.range + '"'}`;
|
|
58
|
+
case "unit-within-range-of":
|
|
59
|
+
return `${negate}within ${p.range}" of ${p.target_type ?? "target"}${p.keyword ? ` (${p.keyword})` : ""}`;
|
|
60
|
+
case "within-range-of-objective":
|
|
61
|
+
return `${negate}within range of an objective`;
|
|
62
|
+
case "controls-objective":
|
|
63
|
+
return `${negate}controlling an objective`;
|
|
64
|
+
case "has-fought-this-phase":
|
|
65
|
+
return `${negate}has fought this phase`;
|
|
66
|
+
case "destroyed-by-attack-type":
|
|
67
|
+
return `${negate}destroyed by ${p.attack_type} attack`;
|
|
68
|
+
default:
|
|
69
|
+
return `${negate}[${c.type ?? "unknown"}]`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function formatTiming(t) {
|
|
73
|
+
return t.replace(/-/g, " ");
|
|
74
|
+
}
|
|
75
|
+
// ─── Effect translator ───────────────────────────────────────────────
|
|
76
|
+
function translateEffect(e, depth = 0) {
|
|
77
|
+
const indent = " ".repeat(depth);
|
|
78
|
+
const arrow = depth > 0 ? "→ " : "";
|
|
79
|
+
switch (e.type) {
|
|
80
|
+
case "conditional":
|
|
81
|
+
return (`${indent}If ${translateCondition(e.condition)}:\n` +
|
|
82
|
+
translateEffect(e.effect, depth + 1));
|
|
83
|
+
case "sequence":
|
|
84
|
+
return e
|
|
85
|
+
.steps.map((s) => translateEffect(s, depth))
|
|
86
|
+
.join("\n");
|
|
87
|
+
case "choice":
|
|
88
|
+
return (`${indent}${arrow}Choose one${e.choice_label ? ` (${e.choice_label})` : ""}:\n` +
|
|
89
|
+
e
|
|
90
|
+
.options.map((o, i) => `${indent} ${i + 1}. ${translateEffectInline(o)}`)
|
|
91
|
+
.join("\n"));
|
|
92
|
+
case "dice-gated": {
|
|
93
|
+
const comp = formatComparison(e.comparison ?? "gte", e.threshold);
|
|
94
|
+
const success = e.on_success
|
|
95
|
+
? translateEffectInline(e.on_success)
|
|
96
|
+
: "nothing";
|
|
97
|
+
const fail = e.on_fail
|
|
98
|
+
? `, otherwise ${translateEffectInline(e.on_fail)}`
|
|
99
|
+
: "";
|
|
100
|
+
return `${indent}${arrow}Roll ${e.dice}: on ${comp}, ${success}${fail}`;
|
|
101
|
+
}
|
|
102
|
+
case "dice-pool-allocation": {
|
|
103
|
+
const poolStr = `${e.pool.count}${e.pool.die}`;
|
|
104
|
+
const lines = [`${indent}${arrow}Roll ${poolStr} (max ${e.max_activations} activations):`];
|
|
105
|
+
for (const opt of e.options) {
|
|
106
|
+
const req = opt.requirement;
|
|
107
|
+
lines.push(`${indent} - ${opt.name}: need ${req.type} of ${req.min_value}+ → ${translateEffectInline(opt.effect)}`);
|
|
108
|
+
}
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
default:
|
|
112
|
+
return `${indent}${arrow}${translateEffectInline(e)}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Single-line translation for leaf effects. */
|
|
116
|
+
function translateEffectInline(e) {
|
|
117
|
+
const m = e.modifier ?? {};
|
|
118
|
+
const target = formatTarget(e.target);
|
|
119
|
+
switch (e.type) {
|
|
120
|
+
case "stat-modifier": {
|
|
121
|
+
const op = m.operation === "add" ? "+" : m.operation === "subtract" ? "-" : `${m.operation} `;
|
|
122
|
+
const scope = m.attack_type ? ` (${m.attack_type})` : "";
|
|
123
|
+
return `${op}${m.value} ${m.stat}${scope} for ${target}`;
|
|
124
|
+
}
|
|
125
|
+
case "roll-modifier": {
|
|
126
|
+
const op = m.operation === "add" ? "+" : "-";
|
|
127
|
+
return `${op}${m.value} to ${m.roll} rolls for ${target}`;
|
|
128
|
+
}
|
|
129
|
+
case "re-roll":
|
|
130
|
+
return `re-roll ${m.roll}${m.value ? ` (${m.value}s)` : ""} for ${target}`;
|
|
131
|
+
case "mortal-wounds":
|
|
132
|
+
return `deal ${m.amount ?? m.amount_table ? "variable" : "?"} mortal wounds to ${target}`;
|
|
133
|
+
case "feel-no-pain":
|
|
134
|
+
return `${target} gains Feel No Pain ${m.threshold}+`;
|
|
135
|
+
case "keyword-grant":
|
|
136
|
+
return `${target}'s ${m.weapon_type ?? "all"} weapons gain ${m.keyword}`;
|
|
137
|
+
case "ability-grant":
|
|
138
|
+
return `${target} gains ${formatGrantType(m.grant_type)}`;
|
|
139
|
+
case "movement-modifier":
|
|
140
|
+
return `${target} gains ${m.move_type}${m.value ? ` ${m.value}"` : ""}`;
|
|
141
|
+
case "damage-reduction":
|
|
142
|
+
return `reduce incoming damage to ${target} by ${m.amount}`;
|
|
143
|
+
case "resurrection":
|
|
144
|
+
return `return ${m.count ?? 1} model(s) to ${target} with ${m.wounds_remaining ?? "full"} wounds`;
|
|
145
|
+
case "model-destruction":
|
|
146
|
+
return `destroy ${m.count} non-leader model(s) from ${target}`;
|
|
147
|
+
case "cp-gain":
|
|
148
|
+
return `gain ${m.amount} CP`;
|
|
149
|
+
case "cp-refund":
|
|
150
|
+
return `refund ${m.amount} CP`;
|
|
151
|
+
case "resource-gain":
|
|
152
|
+
return `gain ${m.amount} to ${m.pool_id}`;
|
|
153
|
+
case "resource-spend":
|
|
154
|
+
return `spend ${m.amount} from ${m.pool_id}`;
|
|
155
|
+
case "invulnerable-save":
|
|
156
|
+
return `${target} gains ${m.value}+ invulnerable save`;
|
|
157
|
+
case "leadership-modifier":
|
|
158
|
+
return `force battle-shock test on ${target}`;
|
|
159
|
+
case "fight-on-death":
|
|
160
|
+
return `${target} fights on death`;
|
|
161
|
+
case "shoot-on-death":
|
|
162
|
+
return `${target} shoots on death`;
|
|
163
|
+
case "fight-first":
|
|
164
|
+
return `${target} fights first`;
|
|
165
|
+
case "fight-last":
|
|
166
|
+
return `${target} fights last`;
|
|
167
|
+
case "deep-strike":
|
|
168
|
+
return `${target} can deep strike`;
|
|
169
|
+
case "fallback-and-act":
|
|
170
|
+
return `${target} can fall back and act`;
|
|
171
|
+
case "attack-restriction":
|
|
172
|
+
return `${target}: ${m.restriction_type ?? "restriction"} (max ${m.max_models ?? "?"} models)`;
|
|
173
|
+
case "objective-control-modifier":
|
|
174
|
+
return `modify OC of ${target} by ${m.value}`;
|
|
175
|
+
// Container types — recurse
|
|
176
|
+
case "conditional":
|
|
177
|
+
return `if ${translateCondition(e.condition)}: ${translateEffectInline(e.effect)}`;
|
|
178
|
+
case "sequence":
|
|
179
|
+
return e.steps.map(translateEffectInline).join("; ");
|
|
180
|
+
case "dice-gated": {
|
|
181
|
+
const comp = formatComparison(e.comparison ?? "gte", e.threshold);
|
|
182
|
+
return `roll ${e.dice} (${comp}): ${e.on_success ? translateEffectInline(e.on_success) : "nothing"}`;
|
|
183
|
+
}
|
|
184
|
+
default:
|
|
185
|
+
return `[${e.type}]`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function formatTarget(t) {
|
|
189
|
+
if (!t)
|
|
190
|
+
return "target";
|
|
191
|
+
return t.replace(/-/g, " ");
|
|
192
|
+
}
|
|
193
|
+
function formatGrantType(g) {
|
|
194
|
+
return g.replace(/-/g, " ");
|
|
195
|
+
}
|
|
196
|
+
function formatComparison(comp, threshold) {
|
|
197
|
+
const thStr = typeof threshold === "string" ? threshold : `${threshold}`;
|
|
198
|
+
switch (comp) {
|
|
199
|
+
case "gte": return `${thStr}+`;
|
|
200
|
+
case "lte": return `${thStr} or less`;
|
|
201
|
+
case "gt": return `greater than ${thStr}`;
|
|
202
|
+
case "lt": return `less than ${thStr}`;
|
|
203
|
+
case "eq": return `exactly ${thStr}`;
|
|
204
|
+
default: return `${thStr}+`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// ─── Scope translator ────────────────────────────────────────────────
|
|
208
|
+
function translateScope(s) {
|
|
209
|
+
if (!s)
|
|
210
|
+
return "";
|
|
211
|
+
const range = s.range.replace(/-/g, " ");
|
|
212
|
+
const duration = s.duration.replace(/-/g, " ");
|
|
213
|
+
return `Scope: ${range}${s.range_inches ? ` (${s.range_inches}")` : ""}. Duration: ${duration}.`;
|
|
214
|
+
}
|
|
215
|
+
// ─── Main command ────────────────────────────────────────────────────
|
|
216
|
+
export async function translateCommand(path) {
|
|
217
|
+
const filePath = resolve(process.cwd(), path ?? "../data/enrichment/world-eaters/abilities.json");
|
|
218
|
+
const abilities = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
219
|
+
for (const a of abilities) {
|
|
220
|
+
const meta = [];
|
|
221
|
+
if (a.ability_type)
|
|
222
|
+
meta.push(a.ability_type);
|
|
223
|
+
if (a.behavior)
|
|
224
|
+
meta.push(a.behavior);
|
|
225
|
+
if (a.detachment_id)
|
|
226
|
+
meta.push(`detachment: ${a.detachment_id}`);
|
|
227
|
+
if (a.unit_ids?.length)
|
|
228
|
+
meta.push(`units: ${a.unit_ids.join(", ")}`);
|
|
229
|
+
console.log(`\n═══ ${a.name} [${a.ability_id}] ═══`);
|
|
230
|
+
if (meta.length)
|
|
231
|
+
console.log(` ${meta.join(" | ")}`);
|
|
232
|
+
console.log(translateEffect(a.effect));
|
|
233
|
+
const scope = translateScope(a.scope);
|
|
234
|
+
if (scope)
|
|
235
|
+
console.log(scope);
|
|
236
|
+
}
|
|
237
|
+
console.log(`\n── ${abilities.length} abilities translated ──`);
|
|
238
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createValidator } from "../schema-loader.js";
|
|
2
|
+
import { validateFiles } from "../validate.js";
|
|
3
|
+
import { formatReport } from "../report.js";
|
|
4
|
+
export async function validateAllCommand(opts) {
|
|
5
|
+
const ajv = createValidator();
|
|
6
|
+
const coreResult = await validateFiles(ajv, "core/**/*.json");
|
|
7
|
+
const enrichmentResult = await validateFiles(ajv, "enrichment/**/*.json");
|
|
8
|
+
const combined = {
|
|
9
|
+
totalFiles: coreResult.totalFiles + enrichmentResult.totalFiles,
|
|
10
|
+
totalItems: coreResult.totalItems + enrichmentResult.totalItems,
|
|
11
|
+
passed: coreResult.passed + enrichmentResult.passed,
|
|
12
|
+
failed: coreResult.failed + enrichmentResult.failed,
|
|
13
|
+
errors: [...coreResult.errors, ...enrichmentResult.errors],
|
|
14
|
+
};
|
|
15
|
+
const output = formatReport(combined, opts.reporter);
|
|
16
|
+
console.log(output);
|
|
17
|
+
if (combined.failed > 0) {
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|