@alpaca-software/40kdc-data 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/abilities-resolver/resolver.d.ts +13 -4
- package/dist/abilities-resolver/resolver.d.ts.map +1 -1
- package/dist/abilities-resolver/resolver.js +22 -15
- package/dist/abilities-resolver/resolver.js.map +1 -1
- package/dist/audit-coverage.d.ts +78 -0
- package/dist/audit-coverage.d.ts.map +1 -0
- package/dist/audit-coverage.js +341 -0
- package/dist/audit-coverage.js.map +1 -0
- package/dist/author-batch.d.ts +147 -0
- package/dist/author-batch.d.ts.map +1 -0
- package/dist/author-batch.js +675 -0
- package/dist/author-batch.js.map +1 -0
- package/dist/author-input.d.ts +37 -0
- package/dist/author-input.d.ts.map +1 -0
- package/dist/author-input.js +162 -0
- package/dist/author-input.js.map +1 -0
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/translate.d.ts.map +1 -1
- package/dist/commands/translate.js +9 -4
- package/dist/commands/translate.js.map +1 -1
- package/dist/cruncher/attribution.d.ts +66 -0
- package/dist/cruncher/attribution.d.ts.map +1 -0
- package/dist/cruncher/attribution.js +88 -0
- package/dist/cruncher/attribution.js.map +1 -0
- package/dist/cruncher/buffs.d.ts +23 -1
- package/dist/cruncher/buffs.d.ts.map +1 -1
- package/dist/cruncher/buffs.js +1 -1
- package/dist/cruncher/buffs.js.map +1 -1
- package/dist/cruncher/from-dsl.d.ts +32 -0
- package/dist/cruncher/from-dsl.d.ts.map +1 -1
- package/dist/cruncher/from-dsl.js +485 -40
- package/dist/cruncher/from-dsl.js.map +1 -1
- package/dist/cruncher/index.d.ts +1 -0
- package/dist/cruncher/index.d.ts.map +1 -1
- package/dist/cruncher/index.js +1 -0
- package/dist/cruncher/index.js.map +1 -1
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/collection.d.ts +9 -0
- package/dist/data/collection.d.ts.map +1 -1
- package/dist/data/collection.js +14 -0
- package/dist/data/collection.js.map +1 -1
- package/dist/data/dataset.d.ts +80 -2
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +143 -6
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/entities.d.ts +2 -5
- package/dist/data/entities.d.ts.map +1 -1
- package/dist/data/entities.js.map +1 -1
- package/dist/data/index.d.ts +3 -2
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +1 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/roster-resolve.d.ts +26 -1
- package/dist/data/roster-resolve.d.ts.map +1 -1
- package/dist/data/roster-resolve.js +46 -0
- package/dist/data/roster-resolve.js.map +1 -1
- package/dist/export/index.d.ts +1 -0
- package/dist/export/index.d.ts.map +1 -1
- package/dist/export/index.js +3 -0
- package/dist/export/index.js.map +1 -1
- package/dist/export/rosterizer.d.ts +3 -0
- package/dist/export/rosterizer.d.ts.map +1 -0
- package/dist/export/rosterizer.js +144 -0
- package/dist/export/rosterizer.js.map +1 -0
- package/dist/export/serializer.d.ts +1 -1
- package/dist/export/serializer.d.ts.map +1 -1
- package/dist/export/serializer.js.map +1 -1
- package/dist/gen-conformance.js +212 -11
- package/dist/gen-conformance.js.map +1 -1
- package/dist/import/gw.d.ts +69 -0
- package/dist/import/gw.d.ts.map +1 -0
- package/dist/import/gw.js +245 -0
- package/dist/import/gw.js.map +1 -0
- package/dist/import/import-roster.d.ts +52 -3
- package/dist/import/import-roster.d.ts.map +1 -1
- package/dist/import/import-roster.js +114 -4
- package/dist/import/import-roster.js.map +1 -1
- package/dist/import/index.d.ts +2 -2
- package/dist/import/index.d.ts.map +1 -1
- package/dist/import/index.js +1 -1
- package/dist/import/index.js.map +1 -1
- package/dist/import/listforge.d.ts.map +1 -1
- package/dist/import/listforge.js +15 -1
- package/dist/import/listforge.js.map +1 -1
- package/dist/import/newrecruit-text.d.ts +3 -0
- package/dist/import/newrecruit-text.d.ts.map +1 -1
- package/dist/import/newrecruit-text.js +6 -0
- package/dist/import/newrecruit-text.js.map +1 -1
- package/dist/import/newrecruit-wtc.d.ts.map +1 -1
- package/dist/import/newrecruit-wtc.js +10 -7
- package/dist/import/newrecruit-wtc.js.map +1 -1
- package/dist/import/rosterizer.d.ts +70 -0
- package/dist/import/rosterizer.d.ts.map +1 -0
- package/dist/import/rosterizer.js +348 -0
- package/dist/import/rosterizer.js.map +1 -0
- package/dist/import/types.d.ts +1 -1
- package/dist/import/types.d.ts.map +1 -1
- package/dist/import/types.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/migrations/2026-weapon-keywords.js +4 -0
- package/dist/migrations/2026-weapon-keywords.js.map +1 -1
- package/dist/runner.d.ts +38 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +492 -0
- package/dist/runner.js.map +1 -0
- package/dist/scrub-ip.d.ts +14 -0
- package/dist/scrub-ip.d.ts.map +1 -0
- package/dist/scrub-ip.js +88 -0
- package/dist/scrub-ip.js.map +1 -0
- package/package.json +9 -2
- package/schemas/core/roster.schema.json +3 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @alpaca-software/40kdc-data
|
|
2
2
|
|
|
3
|
+
Published by [Alpaca Software](https://alpacasoft.dev).
|
|
4
|
+
|
|
3
5
|
The [40kdc](https://tabletop-developer-consortium.github.io) Warhammer 40,000
|
|
4
6
|
dataset behind a **linked, typed API**. Find a unit, then walk straight to its
|
|
5
7
|
weapons, abilities, the game phases those abilities act in, and its faction —
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* 3. **detachment-stratagem** — stratagems on the detachment, each yielding
|
|
13
13
|
* the ability referenced by `stratagem.ability_id` (if any).
|
|
14
14
|
* 4. **unit** — abilities listed in `unit.ability_ids`.
|
|
15
|
-
* 5. **
|
|
15
|
+
* 5. **attached** — abilities of each attached member. A leader + bodyguard
|
|
16
|
+
* (and, in 11th, support) form one combined unit, so every member's
|
|
17
|
+
* abilities apply to the whole unit — pulled in full (not aura-filtered),
|
|
18
|
+
* whichever member is the selected/firing unit.
|
|
16
19
|
* 6. **support** — abilities on supporting units whose scope range is an
|
|
17
20
|
* aura (not `self` / `unit`).
|
|
18
21
|
*
|
|
@@ -38,8 +41,8 @@ export type EligibleAbilitySource = {
|
|
|
38
41
|
kind: "unit";
|
|
39
42
|
unitId: string;
|
|
40
43
|
} | {
|
|
41
|
-
kind: "
|
|
42
|
-
|
|
44
|
+
kind: "attached";
|
|
45
|
+
unitId: string;
|
|
43
46
|
} | {
|
|
44
47
|
kind: "support";
|
|
45
48
|
sourceUnitId: string;
|
|
@@ -49,7 +52,13 @@ export type EligibilityInput = {
|
|
|
49
52
|
/** Overrides the unit's own `faction_id` when given (for inheritance cases). */
|
|
50
53
|
factionId?: string;
|
|
51
54
|
detachmentId?: string;
|
|
52
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Other members of the combined ("attached") unit — the attached leader, its
|
|
57
|
+
* bodyguard, or (11th) support attachments — whichever is *not* the selected
|
|
58
|
+
* `unitId`. Their abilities are pooled onto the combined unit. A list so
|
|
59
|
+
* multi-member attachments need no shape change; order is preserved.
|
|
60
|
+
*/
|
|
61
|
+
attachedUnitIds?: string[];
|
|
53
62
|
/** Friendly units whose auras could apply (M2 walks only their aura-ranged abilities). */
|
|
54
63
|
supportingUnitIds?: string[];
|
|
55
64
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolver.d.ts","sourceRoot":"","sources":["../../src/abilities-resolver/resolver.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"resolver.d.ts","sourceRoot":"","sources":["../../src/abilities-resolver/resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,KAAK,EAAE,KAAK,EAAa,MAAM,iBAAiB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,MAAM,MAAM,qBAAqB,GAC7B;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAChB;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,sBAAsB,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACrE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9C,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,0FAA0F;IAC1F,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,WAAW,CAAC;IACrB,MAAM,EAAE,qBAAqB,CAAC;IAC9B,yEAAyE;IACzE,MAAM,EAAE,KAAK,EAAE,CAAC;CACjB,CAAC;AAEF,gFAAgF;AAChF,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,gBAAgB,EACvB,KAAK,EAAE,KAAK,GACX,eAAe,EAAE,CAsGnB"}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/** Compute the sorted-by-source eligible-ability list for one (unit, phase). */
|
|
2
2
|
export function resolveEligibleAbilities(dataset, input, phase) {
|
|
3
|
-
|
|
3
|
+
// Resolve units within the faction when one is known. Unit ids are shared
|
|
4
|
+
// across factions (a shared chassis like `chaos-land-raider` lives under
|
|
5
|
+
// several Chaos factions), so a faction-blind `get` can return the wrong
|
|
6
|
+
// faction's copy — and with it the wrong intrinsic abilities/keywords.
|
|
7
|
+
const resolveUnit = (id, fid) => (fid ? dataset.units.getInFaction(id, fid) : undefined) ?? dataset.units.get(id);
|
|
8
|
+
const unit = resolveUnit(input.unitId, input.factionId);
|
|
4
9
|
if (!unit)
|
|
5
10
|
return [];
|
|
6
11
|
const factionId = input.factionId ?? unit.raw.faction_id;
|
|
@@ -65,25 +70,27 @@ export function resolveEligibleAbilities(dataset, input, phase) {
|
|
|
65
70
|
phases: intersect(ability.phases, phase),
|
|
66
71
|
});
|
|
67
72
|
}
|
|
68
|
-
// 5. Attached
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
// 5. Attached members — the combined unit pools every member's abilities,
|
|
74
|
+
// pulled in full (not aura-filtered like step 6), regardless of which member
|
|
75
|
+
// is the selected/firing unit.
|
|
76
|
+
for (const memberId of input.attachedUnitIds ?? []) {
|
|
77
|
+
const member = resolveUnit(memberId, factionId);
|
|
78
|
+
if (!member)
|
|
79
|
+
continue;
|
|
80
|
+
for (const ability of member.abilities) {
|
|
81
|
+
if (!phaseMatches(ability, phase))
|
|
82
|
+
continue;
|
|
83
|
+
pushUnique(out, seen, {
|
|
84
|
+
ability,
|
|
85
|
+
source: { kind: "attached", unitId: memberId },
|
|
86
|
+
phases: intersect(ability.phases, phase),
|
|
87
|
+
});
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
90
|
// 6. Supporting units — only aura-scoped abilities (otherwise the buff
|
|
84
91
|
// would describe a self-target effect that doesn't reach the input unit).
|
|
85
92
|
for (const supportId of input.supportingUnitIds ?? []) {
|
|
86
|
-
const supporter =
|
|
93
|
+
const supporter = resolveUnit(supportId, factionId);
|
|
87
94
|
if (!supporter)
|
|
88
95
|
continue;
|
|
89
96
|
for (const ability of supporter.abilities) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolver.js","sourceRoot":"","sources":["../../src/abilities-resolver/resolver.ts"],"names":[],"mappings":"AAqDA,gFAAgF;AAChF,MAAM,UAAU,wBAAwB,CACtC,OAAgB,EAChB,KAAuB,EACvB,KAAY;IAEZ,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IAC7C,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAC;IACrB,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,IAAI,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC;IACzD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,GAAG,GAAsB,EAAE,CAAC;IAElC,+EAA+E;IAC/E,KAAK,MAAM,OAAO,IAAI,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,SAAS,CAAC,EAAE,CAAC;QAC7D,IAAI,OAAO,CAAC,GAAG,CAAC,YAAY,KAAK,SAAS;YAAE,SAAS;QACrD,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,KAAK,CAAC;YAAE,SAAS;QAC5C,UAAU,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,SAAS,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC;IACzG,CAAC;IAED,mEAAmE;IACnE,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC;QACvB,KAAK,MAAM,OAAO,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;YACxC,IAAI,OAAO,CAAC,GAAG,CAAC,YAAY,KAAK,YAAY;gBAAE,SAAS;YACxD,IAAI,OAAO,CAAC,GAAG,CAAC,aAAa,KAAK,KAAK,CAAC,YAAY;gBAAE,SAAS;YAC/D,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,KAAK,CAAC;gBAAE,SAAS;YAC5C,UAAU,CAAC,GAAG,EAAE,IAAI,EAAE;gBACpB,OAAO;gBACP,MAAM,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,YAAY,EAAE,KAAK,CAAC,YAAY,EAAE;gBAChE,MAAM,EAAE,SAAS,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC;aACzC,CAAC,CAAC;QACL,CAAC;QAED,4BAA4B;QAC5B,MAAM,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,GAAG,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;QAC/D,IAAI,UAAU,EAAE,CAAC;YACf,KAAK,MAAM,OAAO,IAAI,UAAU,CAAC,aAAa,IAAI,EAAE,EAAE,CAAC;gBACrD,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;gBAClD,IAAI,CAAC,SAAS;oBAAE,SAAS;gBACzB,IAAI,CAAC,qBAAqB,CAAC,SAAS,EAAE,KAAK,CAAC;oBAAE,SAAS;gBACvD,MAAM,OAAO,GACX,SAAS,CAAC,UAAU,KAAK,IAAI,IAAI,SAAS,CAAC,UAAU,KAAK,SAAS;oBACjE,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,SAAS,CAAC,UAAU,CAAC;oBAC7C,CAAC,CAAC,SAAS,CAAC;gBAChB,IAAI,CAAC,OAAO;oBAAE,SAAS;gBACvB,UAAU,CAAC,GAAG,EAAE,IAAI,EAAE;oBACpB,OAAO;oBACP,MAAM,EAAE;wBACN,IAAI,EAAE,sBAAsB;wBAC5B,WAAW,EAAE,SAAS,CAAC,EAAE;wBACzB,MAAM,EAAE,SAAS,CAAC,OAAO;qBAC1B;oBACD,MAAM,EAAE,CAAC,KAAK,CAAC,EAAE,qDAAqD;iBACvE,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC;IAED,2BAA2B;IAC3B,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;QACrC,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,KAAK,CAAC;YAAE,SAAS;QAC5C,UAAU,CAAC,GAAG,EAAE,IAAI,EAAE;YACpB,OAAO;YACP,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE;YAC9C,MAAM,EAAE,SAAS,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC;SACzC,CAAC,CAAC;IACL,CAAC;IAED,sBAAsB;IACtB,IAAI,KAAK,CAAC,gBAAgB,EAAE,CAAC;QAC3B,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC;QACzD,IAAI,MAAM,EAAE,CAAC;YACX,KAAK,MAAM,OAAO,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;gBACvC,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,KAAK,CAAC;oBAAE,SAAS;gBAC5C,UAAU,CAAC,GAAG,EAAE,IAAI,EAAE;oBACpB,OAAO;oBACP,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,gBAAgB,EAAE;oBAC5D,MAAM,EAAE,SAAS,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC;iBACzC,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC;IAED,uEAAuE;IACvE,0EAA0E;IAC1E,KAAK,MAAM,SAAS,IAAI,KAAK,CAAC,iBAAiB,IAAI,EAAE,EAAE,CAAC;QACtD,MAAM,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC/C,IAAI,CAAC,SAAS;YAAE,SAAS;QACzB,KAAK,MAAM,OAAO,IAAI,SAAS,CAAC,SAAS,EAAE,CAAC;YAC1C,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,KAAK,CAAC;gBAAE,SAAS;YAC5C,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC;gBAAE,SAAS;YACrD,UAAU,CAAC,GAAG,EAAE,IAAI,EAAE;gBACpB,OAAO;gBACP,MAAM,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE;gBACpD,MAAM,EAAE,SAAS,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC;aACzC,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,OAAO,GAAG,CAAC;AACb,CAAC;AAED,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E,SAAS,YAAY,CAAC,OAAoB,EAAE,KAAY;IACtD,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAC9B,4EAA4E;IAC5E,yEAAyE;IACzE,yDAAyD;IACzD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrC,OAAO,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AAChC,CAAC;AAED,SAAS,qBAAqB,CAAC,SAAoB,EAAE,KAAY;IAC/D,IAAI,CAAC,SAAS,CAAC,MAAM,IAAI,SAAS,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACrE,OAAQ,SAAS,CAAC,MAAkB,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AACvD,CAAC;AAED,SAAS,SAAS,CAAC,MAAe,EAAE,KAAY;IAC9C,OAAO,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;AACnD,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IACjC,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5C,OAAO,KAAK,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,KAAK,KAAK,oBAAoB,IAAI,KAAK,KAAK,aAAa,CAAC;AAChG,CAAC;AAED,SAAS,UAAU,CACjB,GAAsB,EACtB,IAAiB,EACjB,KAAsB;IAEtB,MAAM,GAAG,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,KAAK,KAAK,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC;IACxD,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO;IAC1B,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACd,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAClB,CAAC","sourcesContent":["/**\n * Walks the dataset for every ability that could apply to a chosen unit in a\n * chosen phase. The SPA passes the result through to the buff layer (each\n * {@link EligibleAbility} carries `.getBuffs()`, the source is pre-tagged).\n *\n * Resolution order — stable for snapshot tests:\n *\n * 1. **army** — faction-scoped abilities whose `ability_type` is `\"faction\"`\n * and whose `faction_id` matches the input.\n * 2. **detachment** — abilities authored against the detachment\n * (`ability_type` is `\"detachment\"`, `detachment_id` matches).\n * 3. **detachment-stratagem** — stratagems on the detachment, each yielding\n * the ability referenced by `stratagem.ability_id` (if any).\n * 4. **unit** — abilities listed in `unit.ability_ids`.\n * 5. **leader** — abilities listed in the attached leader's `ability_ids`.\n * 6. **support** — abilities on supporting units whose scope range is an\n * aura (not `self` / `unit`).\n *\n * Each step phase-filters via the existing `Dataset.phasesFor` index. The\n * resolver collects abilities first, *then* filters by phase, so the SPA can\n * also ask \"what abilities are eligible across all phases?\" by passing every\n * phase (today the API requires a single phase; if the SPA wants the wide\n * view it can call the resolver once per phase).\n */\nimport type { Phase, Stratagem } from \"../generated.js\";\nimport type { Dataset } from \"../data/dataset.js\";\nimport type { AbilityView } from \"../data/entities.js\";\n\nexport type EligibleAbilitySource =\n | { kind: \"army\" }\n | { kind: \"detachment\"; detachmentId: string }\n | { kind: \"detachment-stratagem\"; stratagemId: string; cpCost: number }\n | { kind: \"unit\"; unitId: string }\n | { kind: \"leader\"; leaderId: string }\n | { kind: \"support\"; sourceUnitId: string };\n\nexport type EligibilityInput = {\n unitId: string;\n /** Overrides the unit's own `faction_id` when given (for inheritance cases). */\n factionId?: string;\n detachmentId?: string;\n attachedLeaderId?: string;\n /** Friendly units whose auras could apply (M2 walks only their aura-ranged abilities). */\n supportingUnitIds?: string[];\n};\n\nexport type EligibleAbility = {\n ability: AbilityView;\n source: EligibleAbilitySource;\n /** The subset of `ability.phases` that intersect the requested phase. */\n phases: Phase[];\n};\n\n/** Compute the sorted-by-source eligible-ability list for one (unit, phase). */\nexport function resolveEligibleAbilities(\n dataset: Dataset,\n input: EligibilityInput,\n phase: Phase,\n): EligibleAbility[] {\n const unit = dataset.units.get(input.unitId);\n if (!unit) return [];\n const factionId = input.factionId ?? unit.raw.faction_id;\n const seen = new Set<string>();\n const out: EligibleAbility[] = [];\n\n // 1. Army — faction-scoped abilities (faction rule + any other faction-typed).\n for (const ability of dataset.abilities.byFaction(factionId)) {\n if (ability.raw.ability_type !== \"faction\") continue;\n if (!phaseMatches(ability, phase)) continue;\n pushUnique(out, seen, { ability, source: { kind: \"army\" }, phases: intersect(ability.phases, phase) });\n }\n\n // 2. Detachment abilities — abilities whose detachment_id matches.\n if (input.detachmentId) {\n for (const ability of dataset.abilities) {\n if (ability.raw.ability_type !== \"detachment\") continue;\n if (ability.raw.detachment_id !== input.detachmentId) continue;\n if (!phaseMatches(ability, phase)) continue;\n pushUnique(out, seen, {\n ability,\n source: { kind: \"detachment\", detachmentId: input.detachmentId },\n phases: intersect(ability.phases, phase),\n });\n }\n\n // 3. Detachment stratagems.\n const detachment = dataset.detachments.get(input.detachmentId);\n if (detachment) {\n for (const stratId of detachment.stratagem_ids ?? []) {\n const stratagem = dataset.stratagems.get(stratId);\n if (!stratagem) continue;\n if (!stratagemPhaseMatches(stratagem, phase)) continue;\n const ability =\n stratagem.ability_id !== null && stratagem.ability_id !== undefined\n ? dataset.abilities.get(stratagem.ability_id)\n : undefined;\n if (!ability) continue;\n pushUnique(out, seen, {\n ability,\n source: {\n kind: \"detachment-stratagem\",\n stratagemId: stratagem.id,\n cpCost: stratagem.cp_cost,\n },\n phases: [phase], // the stratagem's printed phase governs eligibility.\n });\n }\n }\n }\n\n // 4. Unit's own abilities.\n for (const ability of unit.abilities) {\n if (!phaseMatches(ability, phase)) continue;\n pushUnique(out, seen, {\n ability,\n source: { kind: \"unit\", unitId: input.unitId },\n phases: intersect(ability.phases, phase),\n });\n }\n\n // 5. Attached leader.\n if (input.attachedLeaderId) {\n const leader = dataset.units.get(input.attachedLeaderId);\n if (leader) {\n for (const ability of leader.abilities) {\n if (!phaseMatches(ability, phase)) continue;\n pushUnique(out, seen, {\n ability,\n source: { kind: \"leader\", leaderId: input.attachedLeaderId },\n phases: intersect(ability.phases, phase),\n });\n }\n }\n }\n\n // 6. Supporting units — only aura-scoped abilities (otherwise the buff\n // would describe a self-target effect that doesn't reach the input unit).\n for (const supportId of input.supportingUnitIds ?? []) {\n const supporter = dataset.units.get(supportId);\n if (!supporter) continue;\n for (const ability of supporter.abilities) {\n if (!phaseMatches(ability, phase)) continue;\n if (!isAuraScope(ability.raw.scope?.range)) continue;\n pushUnique(out, seen, {\n ability,\n source: { kind: \"support\", sourceUnitId: supportId },\n phases: intersect(ability.phases, phase),\n });\n }\n }\n\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction phaseMatches(ability: AbilityView, phase: Phase): boolean {\n const phases = ability.phases;\n // An ability with no phase-mapping is permissive — surface it everywhere so\n // the SPA can decide. M2's translator already gates conditional-on-phase\n // effects internally, so this stays generous on purpose.\n if (phases.length === 0) return true;\n return phases.includes(phase);\n}\n\nfunction stratagemPhaseMatches(stratagem: Stratagem, phase: Phase): boolean {\n if (!stratagem.phases || stratagem.phases.length === 0) return false;\n return (stratagem.phases as Phase[]).includes(phase);\n}\n\nfunction intersect(phases: Phase[], phase: Phase): Phase[] {\n return phases.includes(phase) ? [phase] : phases;\n}\n\nfunction isAuraScope(range: unknown): boolean {\n if (typeof range !== \"string\") return false;\n return range.startsWith(\"aura-\") || range === \"any-on-battlefield\" || range === \"any-visible\";\n}\n\nfunction pushUnique(\n out: EligibleAbility[],\n seen: Set<string>,\n entry: EligibleAbility,\n): void {\n const key = `${entry.source.kind}::${entry.ability.id}`;\n if (seen.has(key)) return;\n seen.add(key);\n out.push(entry);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"resolver.js","sourceRoot":"","sources":["../../src/abilities-resolver/resolver.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Walks the dataset for every ability that could apply to a chosen unit in a\n * chosen phase. The SPA passes the result through to the buff layer (each\n * {@link EligibleAbility} carries `.getBuffs()`, the source is pre-tagged).\n *\n * Resolution order — stable for snapshot tests:\n *\n * 1. **army** — faction-scoped abilities whose `ability_type` is `\"faction\"`\n * and whose `faction_id` matches the input.\n * 2. **detachment** — abilities authored against the detachment\n * (`ability_type` is `\"detachment\"`, `detachment_id` matches).\n * 3. **detachment-stratagem** — stratagems on the detachment, each yielding\n * the ability referenced by `stratagem.ability_id` (if any).\n * 4. **unit** — abilities listed in `unit.ability_ids`.\n * 5. **attached** — abilities of each attached member. A leader + bodyguard\n * (and, in 11th, support) form one combined unit, so every member's\n * abilities apply to the whole unit — pulled in full (not aura-filtered),\n * whichever member is the selected/firing unit.\n * 6. **support** — abilities on supporting units whose scope range is an\n * aura (not `self` / `unit`).\n *\n * Each step phase-filters via the existing `Dataset.phasesFor` index. The\n * resolver collects abilities first, *then* filters by phase, so the SPA can\n * also ask \"what abilities are eligible across all phases?\" by passing every\n * phase (today the API requires a single phase; if the SPA wants the wide\n * view it can call the resolver once per phase).\n */\nimport type { Phase, Stratagem } from \"../generated.js\";\nimport type { Dataset } from \"../data/dataset.js\";\nimport type { AbilityView } from \"../data/entities.js\";\n\nexport type EligibleAbilitySource =\n | { kind: \"army\" }\n | { kind: \"detachment\"; detachmentId: string }\n | { kind: \"detachment-stratagem\"; stratagemId: string; cpCost: number }\n | { kind: \"unit\"; unitId: string }\n | { kind: \"attached\"; unitId: string }\n | { kind: \"support\"; sourceUnitId: string };\n\nexport type EligibilityInput = {\n unitId: string;\n /** Overrides the unit's own `faction_id` when given (for inheritance cases). */\n factionId?: string;\n detachmentId?: string;\n /**\n * Other members of the combined (\"attached\") unit — the attached leader, its\n * bodyguard, or (11th) support attachments — whichever is *not* the selected\n * `unitId`. Their abilities are pooled onto the combined unit. A list so\n * multi-member attachments need no shape change; order is preserved.\n */\n attachedUnitIds?: string[];\n /** Friendly units whose auras could apply (M2 walks only their aura-ranged abilities). */\n supportingUnitIds?: string[];\n};\n\nexport type EligibleAbility = {\n ability: AbilityView;\n source: EligibleAbilitySource;\n /** The subset of `ability.phases` that intersect the requested phase. */\n phases: Phase[];\n};\n\n/** Compute the sorted-by-source eligible-ability list for one (unit, phase). */\nexport function resolveEligibleAbilities(\n dataset: Dataset,\n input: EligibilityInput,\n phase: Phase,\n): EligibleAbility[] {\n // Resolve units within the faction when one is known. Unit ids are shared\n // across factions (a shared chassis like `chaos-land-raider` lives under\n // several Chaos factions), so a faction-blind `get` can return the wrong\n // faction's copy — and with it the wrong intrinsic abilities/keywords.\n const resolveUnit = (id: string, fid: string | undefined) =>\n (fid ? dataset.units.getInFaction(id, fid) : undefined) ?? dataset.units.get(id);\n\n const unit = resolveUnit(input.unitId, input.factionId);\n if (!unit) return [];\n const factionId = input.factionId ?? unit.raw.faction_id;\n const seen = new Set<string>();\n const out: EligibleAbility[] = [];\n\n // 1. Army — faction-scoped abilities (faction rule + any other faction-typed).\n for (const ability of dataset.abilities.byFaction(factionId)) {\n if (ability.raw.ability_type !== \"faction\") continue;\n if (!phaseMatches(ability, phase)) continue;\n pushUnique(out, seen, { ability, source: { kind: \"army\" }, phases: intersect(ability.phases, phase) });\n }\n\n // 2. Detachment abilities — abilities whose detachment_id matches.\n if (input.detachmentId) {\n for (const ability of dataset.abilities) {\n if (ability.raw.ability_type !== \"detachment\") continue;\n if (ability.raw.detachment_id !== input.detachmentId) continue;\n if (!phaseMatches(ability, phase)) continue;\n pushUnique(out, seen, {\n ability,\n source: { kind: \"detachment\", detachmentId: input.detachmentId },\n phases: intersect(ability.phases, phase),\n });\n }\n\n // 3. Detachment stratagems.\n const detachment = dataset.detachments.get(input.detachmentId);\n if (detachment) {\n for (const stratId of detachment.stratagem_ids ?? []) {\n const stratagem = dataset.stratagems.get(stratId);\n if (!stratagem) continue;\n if (!stratagemPhaseMatches(stratagem, phase)) continue;\n const ability =\n stratagem.ability_id !== null && stratagem.ability_id !== undefined\n ? dataset.abilities.get(stratagem.ability_id)\n : undefined;\n if (!ability) continue;\n pushUnique(out, seen, {\n ability,\n source: {\n kind: \"detachment-stratagem\",\n stratagemId: stratagem.id,\n cpCost: stratagem.cp_cost,\n },\n phases: [phase], // the stratagem's printed phase governs eligibility.\n });\n }\n }\n }\n\n // 4. Unit's own abilities.\n for (const ability of unit.abilities) {\n if (!phaseMatches(ability, phase)) continue;\n pushUnique(out, seen, {\n ability,\n source: { kind: \"unit\", unitId: input.unitId },\n phases: intersect(ability.phases, phase),\n });\n }\n\n // 5. Attached members — the combined unit pools every member's abilities,\n // pulled in full (not aura-filtered like step 6), regardless of which member\n // is the selected/firing unit.\n for (const memberId of input.attachedUnitIds ?? []) {\n const member = resolveUnit(memberId, factionId);\n if (!member) continue;\n for (const ability of member.abilities) {\n if (!phaseMatches(ability, phase)) continue;\n pushUnique(out, seen, {\n ability,\n source: { kind: \"attached\", unitId: memberId },\n phases: intersect(ability.phases, phase),\n });\n }\n }\n\n // 6. Supporting units — only aura-scoped abilities (otherwise the buff\n // would describe a self-target effect that doesn't reach the input unit).\n for (const supportId of input.supportingUnitIds ?? []) {\n const supporter = resolveUnit(supportId, factionId);\n if (!supporter) continue;\n for (const ability of supporter.abilities) {\n if (!phaseMatches(ability, phase)) continue;\n if (!isAuraScope(ability.raw.scope?.range)) continue;\n pushUnique(out, seen, {\n ability,\n source: { kind: \"support\", sourceUnitId: supportId },\n phases: intersect(ability.phases, phase),\n });\n }\n }\n\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction phaseMatches(ability: AbilityView, phase: Phase): boolean {\n const phases = ability.phases;\n // An ability with no phase-mapping is permissive — surface it everywhere so\n // the SPA can decide. M2's translator already gates conditional-on-phase\n // effects internally, so this stays generous on purpose.\n if (phases.length === 0) return true;\n return phases.includes(phase);\n}\n\nfunction stratagemPhaseMatches(stratagem: Stratagem, phase: Phase): boolean {\n if (!stratagem.phases || stratagem.phases.length === 0) return false;\n return (stratagem.phases as Phase[]).includes(phase);\n}\n\nfunction intersect(phases: Phase[], phase: Phase): Phase[] {\n return phases.includes(phase) ? [phase] : phases;\n}\n\nfunction isAuraScope(range: unknown): boolean {\n if (typeof range !== \"string\") return false;\n return range.startsWith(\"aura-\") || range === \"any-on-battlefield\" || range === \"any-visible\";\n}\n\nfunction pushUnique(\n out: EligibleAbility[],\n seen: Set<string>,\n entry: EligibleAbility,\n): void {\n const key = `${entry.source.kind}::${entry.ability.id}`;\n if (seen.has(key)) return;\n seen.add(key);\n out.push(entry);\n}\n"]}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Shape of an authored ability entry (only the fields this audit reads). */
|
|
2
|
+
interface AbilityEntry {
|
|
3
|
+
ability_id: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
ability_type?: string;
|
|
6
|
+
community_notes?: string;
|
|
7
|
+
effect?: unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface FactionCoverage {
|
|
10
|
+
faction: string;
|
|
11
|
+
total: number;
|
|
12
|
+
offensive: number;
|
|
13
|
+
defensive: number;
|
|
14
|
+
/** Produces neither an offensive nor a defensive buff. */
|
|
15
|
+
inert: number;
|
|
16
|
+
/** `community_notes` flags it an auto-generated stub / partial. */
|
|
17
|
+
stub: number;
|
|
18
|
+
/**
|
|
19
|
+
* Structurally a placeholder: the effect tree contains a modifier-bearing
|
|
20
|
+
* node with an empty `modifier: {}` (the original pass's untyped stub, e.g.
|
|
21
|
+
* `stat-modifier {}`). This — not `inert` — is the authoring worklist: an
|
|
22
|
+
* inert-but-correctly-typed ability (movement, objective control) is *done*;
|
|
23
|
+
* an empty-modifier node is a gap regardless of who consumes it.
|
|
24
|
+
*/
|
|
25
|
+
stubStructural: number;
|
|
26
|
+
/** `community_notes` carries a verbatim GW `"Original:"` text dump (IP leak). */
|
|
27
|
+
gwTextLeak: number;
|
|
28
|
+
/** Explicitly tagged `"defensive ability (skipped for damage calc)"`. */
|
|
29
|
+
defensiveSkipped: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One named gap. Carries the ability's identity and current shape so the gap is
|
|
33
|
+
* self-describing on the consumer end (the fan-out joins the source rule; the
|
|
34
|
+
* editor / downstream tools can list "what's unauthored" without re-deriving).
|
|
35
|
+
*/
|
|
36
|
+
export interface WorklistEntry {
|
|
37
|
+
faction: string;
|
|
38
|
+
ability_id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
/** Top-level effect type as authored today (the "shape"), or `null` if absent. */
|
|
41
|
+
shape: string | null;
|
|
42
|
+
/** Has an empty-modifier placeholder node somewhere in its effect tree. */
|
|
43
|
+
stub: boolean;
|
|
44
|
+
offensive: boolean;
|
|
45
|
+
defensive: boolean;
|
|
46
|
+
/** Most-informative unsupported reason from the attacker walk, if any. */
|
|
47
|
+
gap: string | null;
|
|
48
|
+
}
|
|
49
|
+
export interface CoverageReport {
|
|
50
|
+
factions: FactionCoverage[];
|
|
51
|
+
totals: Omit<FactionCoverage, "faction">;
|
|
52
|
+
/** `unsupported.reason` (normalized) → count, descending. */
|
|
53
|
+
unsupportedReasons: {
|
|
54
|
+
reason: string;
|
|
55
|
+
count: number;
|
|
56
|
+
}[];
|
|
57
|
+
/** Per-ability named gaps — the authoring worklist. */
|
|
58
|
+
worklist: WorklistEntry[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* True if any node in the effect tree is a *parameter-requiring* leaf left with
|
|
62
|
+
* an empty `modifier: {}` — the original pass's untyped placeholder. Pure-flag
|
|
63
|
+
* effects (see {@link PARAMETERLESS_EFFECTS}) are exempt.
|
|
64
|
+
*/
|
|
65
|
+
export declare function hasEmptyModifier(node: unknown): boolean;
|
|
66
|
+
/** Compute coverage for a set of factions. Pure — IO lives in the command. */
|
|
67
|
+
export declare function computeCoverage(input: {
|
|
68
|
+
faction: string;
|
|
69
|
+
abilities: AbilityEntry[];
|
|
70
|
+
}[]): CoverageReport;
|
|
71
|
+
export interface AuditCoverageOptions {
|
|
72
|
+
reporter?: "pretty" | "json";
|
|
73
|
+
write?: boolean;
|
|
74
|
+
}
|
|
75
|
+
/** Commander action: run the audit over the repo's enrichment data. */
|
|
76
|
+
export declare function auditCoverageCommand(opts?: AuditCoverageOptions): void;
|
|
77
|
+
export {};
|
|
78
|
+
//# sourceMappingURL=audit-coverage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-coverage.d.ts","sourceRoot":"","sources":["../src/audit-coverage.ts"],"names":[],"mappings":"AA0CA,6EAA6E;AAC7E,UAAU,YAAY;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB,iFAAiF;IACjF,UAAU,EAAE,MAAM,CAAC;IACnB,yEAAyE;IACzE,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,kFAAkF;IAClF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,2EAA2E;IAC3E,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,OAAO,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;IACnB,0EAA0E;IAC1E,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,MAAM,EAAE,IAAI,CAAC,eAAe,EAAE,SAAS,CAAC,CAAC;IACzC,6DAA6D;IAC7D,kBAAkB,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACxD,uDAAuD;IACvD,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC3B;AAuFD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAiBvD;AAWD,8EAA8E;AAC9E,wBAAgB,eAAe,CAC7B,KAAK,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,YAAY,EAAE,CAAA;CAAE,EAAE,GACtD,cAAc,CA2EhB;AA+GD,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC7B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,uEAAuE;AACvE,wBAAgB,oBAAoB,CAAC,IAAI,GAAE,oBAAyB,GAAG,IAAI,CAkB1E"}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ability-coverage audit: measure how much of the community-authored ability
|
|
3
|
+
* data actually translates into cruncher buffs, per faction.
|
|
4
|
+
*
|
|
5
|
+
* The buff layer only interprets a subset of the ability DSL (see
|
|
6
|
+
* `cruncher/from-dsl.ts`). An ability that doesn't translate is inert in Salvo —
|
|
7
|
+
* the projection silently falls back to raw-statline math. This tool runs the
|
|
8
|
+
* *real* translator (`effectToBuffs`) over every authored ability, under both
|
|
9
|
+
* the attacker and target perspectives and across every phase, and classifies
|
|
10
|
+
* each entry as:
|
|
11
|
+
*
|
|
12
|
+
* - **offensive** — yields ≥1 attacker-side buff (auto-applied or activatable)
|
|
13
|
+
* - **defensive** — yields ≥1 target-side buff
|
|
14
|
+
* - **inert** — neither; the effect is `unsupported` and/or a stub
|
|
15
|
+
*
|
|
16
|
+
* It also tallies the verbatim-GW-text leak (`community_notes` carrying an
|
|
17
|
+
* `"Original:"` dump) and the explicit `"skipped for damage calc"` defensive
|
|
18
|
+
* entries, and histograms the `unsupported.reason` strings so the output is a
|
|
19
|
+
* directly-actionable authoring worklist.
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* npx tsx tools/src/audit-coverage.ts (pretty report to stdout)
|
|
23
|
+
* npx tsx tools/src/audit-coverage.ts --json (machine-readable)
|
|
24
|
+
* npx tsx tools/src/audit-coverage.ts --write (also emit data/_audit/*)
|
|
25
|
+
*
|
|
26
|
+
* Wired as `40kdc-validate audit-coverage` and `npm run audit:coverage`.
|
|
27
|
+
*/
|
|
28
|
+
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
29
|
+
import { resolve, basename } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import chalk from "chalk";
|
|
32
|
+
import { effectToBuffs } from "./cruncher/from-dsl.js";
|
|
33
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
34
|
+
const DATA_ROOT = resolve(__dirname, "../../data");
|
|
35
|
+
const ENRICHMENT_ROOT = resolve(DATA_ROOT, "enrichment");
|
|
36
|
+
const AUDIT_DIR = resolve(DATA_ROOT, "_audit");
|
|
37
|
+
const PHASES = ["command", "movement", "shooting", "charge", "fight"];
|
|
38
|
+
/**
|
|
39
|
+
* Build a permissive context for `phase` — every situational flag set so that
|
|
40
|
+
* conditionals gated on stationary/charged/half-range/attachment can fire. We
|
|
41
|
+
* want "could this ability *ever* produce a buff", not "does it fire right now".
|
|
42
|
+
* Keyword-specific conditions (`target-has-keyword`) still evaluate `"unknown"`
|
|
43
|
+
* since no concrete target exists; those surface in the reason histogram.
|
|
44
|
+
*/
|
|
45
|
+
function permissiveContext(phase) {
|
|
46
|
+
return {
|
|
47
|
+
phase,
|
|
48
|
+
attackerStationary: true,
|
|
49
|
+
attackerCharged: true,
|
|
50
|
+
withinHalfRange: true,
|
|
51
|
+
attackerInCover: true,
|
|
52
|
+
targetInCover: true,
|
|
53
|
+
attackerAttached: true,
|
|
54
|
+
attackerKeywords: [],
|
|
55
|
+
targetKeywords: [],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Map an `ability_type` to the buff-source kind (only labels; not load-bearing here). */
|
|
59
|
+
function sourceKind(abilityType) {
|
|
60
|
+
const kind = abilityType === "faction"
|
|
61
|
+
? "army"
|
|
62
|
+
: abilityType === "detachment"
|
|
63
|
+
? "detachment"
|
|
64
|
+
: abilityType === "stratagem"
|
|
65
|
+
? "detachment-stratagem"
|
|
66
|
+
: "unit";
|
|
67
|
+
return { kind: "ability", abilityId: "audit", abilityKind: kind };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Keep reasons verbatim — the quoted specifics (which effect type, which
|
|
71
|
+
* operation) ARE the worklist signal. We only collapse the `subset "…"` half of
|
|
72
|
+
* the re-roll reason, which is high-cardinality noise, while keeping the roll.
|
|
73
|
+
*/
|
|
74
|
+
function normalizeReason(reason) {
|
|
75
|
+
return reason.replace(/\(subset "[^"]*"\)/g, "(subset …)");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Translate one ability under one perspective across all phases; return whether
|
|
79
|
+
* it produced any buff, accumulating unsupported reasons into `reasonCounts`.
|
|
80
|
+
*/
|
|
81
|
+
function producesBuff(effect, source, perspective, reasonCounts) {
|
|
82
|
+
let produced = false;
|
|
83
|
+
for (const phase of PHASES) {
|
|
84
|
+
const t = effectToBuffs(effect, source, permissiveContext(phase), perspective);
|
|
85
|
+
if (t.applied.length > 0 || t.activatable.length > 0)
|
|
86
|
+
produced = true;
|
|
87
|
+
// Only attacker-perspective reasons feed the histogram — the target pass
|
|
88
|
+
// re-walks the same tree and would double-count every offensive-only branch
|
|
89
|
+
// as "unsupported on defense", which is noise for the worklist.
|
|
90
|
+
if (perspective === "attacker") {
|
|
91
|
+
for (const u of t.unsupported) {
|
|
92
|
+
const key = normalizeReason(u.reason);
|
|
93
|
+
reasonCounts.set(key, (reasonCounts.get(key) ?? 0) + 1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return produced;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Effect types that are pure flags — they carry no parameters, so an empty
|
|
101
|
+
* `modifier: {}` is *correct*, not a stub (per `translate.ts`, these read no
|
|
102
|
+
* modifier fields). Excluding them keeps the worklist from crying wolf on
|
|
103
|
+
* legitimately-authored abilities like Deep Strike.
|
|
104
|
+
*/
|
|
105
|
+
const PARAMETERLESS_EFFECTS = new Set([
|
|
106
|
+
"deep-strike",
|
|
107
|
+
"fallback-and-act",
|
|
108
|
+
"fight-first",
|
|
109
|
+
"fight-last",
|
|
110
|
+
"fight-on-death",
|
|
111
|
+
"shoot-on-death",
|
|
112
|
+
]);
|
|
113
|
+
/**
|
|
114
|
+
* True if any node in the effect tree is a *parameter-requiring* leaf left with
|
|
115
|
+
* an empty `modifier: {}` — the original pass's untyped placeholder. Pure-flag
|
|
116
|
+
* effects (see {@link PARAMETERLESS_EFFECTS}) are exempt.
|
|
117
|
+
*/
|
|
118
|
+
export function hasEmptyModifier(node) {
|
|
119
|
+
if (Array.isArray(node))
|
|
120
|
+
return node.some(hasEmptyModifier);
|
|
121
|
+
if (typeof node !== "object" || node === null)
|
|
122
|
+
return false;
|
|
123
|
+
const rec = node;
|
|
124
|
+
const mod = rec.modifier;
|
|
125
|
+
if (typeof rec.type === "string" &&
|
|
126
|
+
!PARAMETERLESS_EFFECTS.has(rec.type) &&
|
|
127
|
+
mod !== undefined &&
|
|
128
|
+
typeof mod === "object" &&
|
|
129
|
+
mod !== null &&
|
|
130
|
+
!Array.isArray(mod) &&
|
|
131
|
+
Object.keys(mod).length === 0) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
return Object.values(rec).some(hasEmptyModifier);
|
|
135
|
+
}
|
|
136
|
+
/** Top-level effect type (the "shape") as authored today. */
|
|
137
|
+
function shapeOf(effect) {
|
|
138
|
+
if (typeof effect === "object" && effect !== null && !Array.isArray(effect)) {
|
|
139
|
+
const t = effect.type;
|
|
140
|
+
if (typeof t === "string")
|
|
141
|
+
return t;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
/** Compute coverage for a set of factions. Pure — IO lives in the command. */
|
|
146
|
+
export function computeCoverage(input) {
|
|
147
|
+
const reasonCounts = new Map();
|
|
148
|
+
const factions = [];
|
|
149
|
+
const worklist = [];
|
|
150
|
+
for (const { faction, abilities } of input) {
|
|
151
|
+
const fc = {
|
|
152
|
+
faction,
|
|
153
|
+
total: abilities.length,
|
|
154
|
+
offensive: 0,
|
|
155
|
+
defensive: 0,
|
|
156
|
+
inert: 0,
|
|
157
|
+
stub: 0,
|
|
158
|
+
stubStructural: 0,
|
|
159
|
+
gwTextLeak: 0,
|
|
160
|
+
defensiveSkipped: 0,
|
|
161
|
+
};
|
|
162
|
+
for (const a of abilities) {
|
|
163
|
+
const notes = a.community_notes ?? "";
|
|
164
|
+
if (/Original:/.test(notes))
|
|
165
|
+
fc.gwTextLeak++;
|
|
166
|
+
if (/stub|partial/i.test(notes))
|
|
167
|
+
fc.stub++;
|
|
168
|
+
if (/skipped for damage calc/i.test(notes))
|
|
169
|
+
fc.defensiveSkipped++;
|
|
170
|
+
const source = sourceKind(a.ability_type);
|
|
171
|
+
// De-dupe reasons within a single ability so a 5-phase walk doesn't count
|
|
172
|
+
// the same unsupported branch five times.
|
|
173
|
+
const perAbility = new Map();
|
|
174
|
+
const off = producesBuff(a.effect, source, "attacker", perAbility);
|
|
175
|
+
const def = producesBuff(a.effect, source, "target", new Map());
|
|
176
|
+
for (const [reason] of perAbility) {
|
|
177
|
+
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
|
|
178
|
+
}
|
|
179
|
+
if (off)
|
|
180
|
+
fc.offensive++;
|
|
181
|
+
if (def)
|
|
182
|
+
fc.defensive++;
|
|
183
|
+
if (!off && !def)
|
|
184
|
+
fc.inert++;
|
|
185
|
+
const isStub = hasEmptyModifier(a.effect);
|
|
186
|
+
if (isStub)
|
|
187
|
+
fc.stubStructural++;
|
|
188
|
+
worklist.push({
|
|
189
|
+
faction,
|
|
190
|
+
ability_id: a.ability_id,
|
|
191
|
+
name: a.name ?? a.ability_id,
|
|
192
|
+
shape: shapeOf(a.effect),
|
|
193
|
+
stub: isStub,
|
|
194
|
+
offensive: off,
|
|
195
|
+
defensive: def,
|
|
196
|
+
gap: perAbility.size > 0 ? [...perAbility.keys()][0] : null,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
factions.push(fc);
|
|
200
|
+
}
|
|
201
|
+
factions.sort((a, b) => a.faction.localeCompare(b.faction));
|
|
202
|
+
const totals = factions.reduce((acc, f) => {
|
|
203
|
+
acc.total += f.total;
|
|
204
|
+
acc.offensive += f.offensive;
|
|
205
|
+
acc.defensive += f.defensive;
|
|
206
|
+
acc.inert += f.inert;
|
|
207
|
+
acc.stub += f.stub;
|
|
208
|
+
acc.stubStructural += f.stubStructural;
|
|
209
|
+
acc.gwTextLeak += f.gwTextLeak;
|
|
210
|
+
acc.defensiveSkipped += f.defensiveSkipped;
|
|
211
|
+
return acc;
|
|
212
|
+
}, { total: 0, offensive: 0, defensive: 0, inert: 0, stub: 0, stubStructural: 0, gwTextLeak: 0, defensiveSkipped: 0 });
|
|
213
|
+
const unsupportedReasons = [...reasonCounts.entries()]
|
|
214
|
+
.map(([reason, count]) => ({ reason, count }))
|
|
215
|
+
.sort((a, b) => b.count - a.count);
|
|
216
|
+
return { factions, totals, unsupportedReasons, worklist };
|
|
217
|
+
}
|
|
218
|
+
/** Read every `data/enrichment/<faction>/abilities.json` (skips `_example`). */
|
|
219
|
+
function loadFactions() {
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const entry of readdirSync(ENRICHMENT_ROOT, { withFileTypes: true })) {
|
|
222
|
+
if (!entry.isDirectory() || entry.name === "_example")
|
|
223
|
+
continue;
|
|
224
|
+
const file = resolve(ENRICHMENT_ROOT, entry.name, "abilities.json");
|
|
225
|
+
let abilities;
|
|
226
|
+
try {
|
|
227
|
+
abilities = JSON.parse(readFileSync(file, "utf-8"));
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
continue; // no abilities.json in this folder
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(abilities))
|
|
233
|
+
continue;
|
|
234
|
+
out.push({ faction: entry.name, abilities });
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
const pct = (n, d) => (d === 0 ? "—" : `${Math.round((100 * n) / d)}%`);
|
|
239
|
+
function prettyReport(r) {
|
|
240
|
+
const lines = [];
|
|
241
|
+
lines.push("");
|
|
242
|
+
lines.push(chalk.bold("40kdc Ability Coverage Audit"));
|
|
243
|
+
lines.push(chalk.gray("─".repeat(78)));
|
|
244
|
+
lines.push(chalk.gray("faction".padEnd(24) +
|
|
245
|
+
"total".padStart(7) +
|
|
246
|
+
"off".padStart(7) +
|
|
247
|
+
"def".padStart(7) +
|
|
248
|
+
"inert".padStart(7) +
|
|
249
|
+
"stub".padStart(7) +
|
|
250
|
+
"leak".padStart(7)));
|
|
251
|
+
for (const f of r.factions) {
|
|
252
|
+
const stubWarn = f.stubStructural > 0;
|
|
253
|
+
const row = f.faction.padEnd(24) +
|
|
254
|
+
String(f.total).padStart(7) +
|
|
255
|
+
`${f.offensive}`.padStart(7) +
|
|
256
|
+
`${f.defensive}`.padStart(7) +
|
|
257
|
+
`${f.inert}`.padStart(7) +
|
|
258
|
+
`${f.stubStructural}`.padStart(7) +
|
|
259
|
+
`${f.gwTextLeak}`.padStart(7);
|
|
260
|
+
lines.push(stubWarn ? chalk.yellow(row) : row);
|
|
261
|
+
}
|
|
262
|
+
lines.push(chalk.gray("─".repeat(78)));
|
|
263
|
+
const t = r.totals;
|
|
264
|
+
lines.push(chalk.bold("TOTAL".padEnd(24) +
|
|
265
|
+
String(t.total).padStart(7) +
|
|
266
|
+
`${t.offensive}`.padStart(7) +
|
|
267
|
+
`${t.defensive}`.padStart(7) +
|
|
268
|
+
`${t.inert}`.padStart(7) +
|
|
269
|
+
`${t.stubStructural}`.padStart(7) +
|
|
270
|
+
`${t.gwTextLeak}`.padStart(7)));
|
|
271
|
+
lines.push("");
|
|
272
|
+
lines.push(`Offensive coverage: ${chalk.cyan(pct(t.offensive, t.total))} ` +
|
|
273
|
+
`Defensive coverage: ${chalk.cyan(pct(t.defensive, t.total))} ` +
|
|
274
|
+
`Inert: ${chalk.yellow(pct(t.inert, t.total))}`);
|
|
275
|
+
if (t.gwTextLeak > 0) {
|
|
276
|
+
lines.push(chalk.red(`⚠ ${t.gwTextLeak} entries leak verbatim GW text (community_notes "Original:") — IP scrub needed (#20).`));
|
|
277
|
+
}
|
|
278
|
+
lines.push(chalk.gray(`${t.defensiveSkipped} entries tagged "skipped for damage calc" (defensive worklist, #23).`));
|
|
279
|
+
lines.push("");
|
|
280
|
+
lines.push(chalk.bold("Top unsupported-effect reasons (offensive walk):"));
|
|
281
|
+
for (const { reason, count } of r.unsupportedReasons.slice(0, 15)) {
|
|
282
|
+
lines.push(` ${String(count).padStart(5)} ${chalk.gray(reason)}`);
|
|
283
|
+
}
|
|
284
|
+
lines.push("");
|
|
285
|
+
return lines.join("\n");
|
|
286
|
+
}
|
|
287
|
+
function markdownReport(r) {
|
|
288
|
+
const lines = [
|
|
289
|
+
"# Ability coverage audit",
|
|
290
|
+
"",
|
|
291
|
+
"Generated by `tools/src/audit-coverage.ts` (`npm run audit:coverage`). Counts",
|
|
292
|
+
"abilities that translate into cruncher buffs via the real `effectToBuffs`",
|
|
293
|
+
"(attacker + target perspective, all phases). `inert` = produces neither.",
|
|
294
|
+
"",
|
|
295
|
+
"| faction | total | offensive | defensive | inert | stub* | notes-stub | gw-leak | def-skipped |",
|
|
296
|
+
"|---|--:|--:|--:|--:|--:|--:|--:|--:|",
|
|
297
|
+
];
|
|
298
|
+
for (const f of r.factions) {
|
|
299
|
+
lines.push(`| ${f.faction} | ${f.total} | ${f.offensive} | ${f.defensive} | ${f.inert} | ${f.stubStructural} | ${f.stub} | ${f.gwTextLeak} | ${f.defensiveSkipped} |`);
|
|
300
|
+
}
|
|
301
|
+
const t = r.totals;
|
|
302
|
+
lines.push(`| **TOTAL** | **${t.total}** | **${t.offensive}** | **${t.defensive}** | **${t.inert}** | **${t.stubStructural}** | **${t.stub}** | **${t.gwTextLeak}** | **${t.defensiveSkipped}** |`);
|
|
303
|
+
lines.push("", "`stub*` = structural (empty-modifier placeholder node) — the authoring worklist. `notes-stub` = flagged in community_notes.");
|
|
304
|
+
lines.push("", "## Unsupported-effect reasons (offensive walk)", "");
|
|
305
|
+
for (const { reason, count } of r.unsupportedReasons) {
|
|
306
|
+
lines.push(`- \`${count}\` — ${reason}`);
|
|
307
|
+
}
|
|
308
|
+
lines.push("");
|
|
309
|
+
return lines.join("\n");
|
|
310
|
+
}
|
|
311
|
+
/** Commander action: run the audit over the repo's enrichment data. */
|
|
312
|
+
export function auditCoverageCommand(opts = {}) {
|
|
313
|
+
const report = computeCoverage(loadFactions());
|
|
314
|
+
if (opts.write) {
|
|
315
|
+
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
316
|
+
writeFileSync(resolve(AUDIT_DIR, "coverage.json"), JSON.stringify(report, null, 2) + "\n");
|
|
317
|
+
writeFileSync(resolve(AUDIT_DIR, "summary.md"), markdownReport(report));
|
|
318
|
+
// The named-gap worklist is the authoring artifact — split out so it's
|
|
319
|
+
// diffable on its own and consumable without the histograms.
|
|
320
|
+
writeFileSync(resolve(AUDIT_DIR, "worklist.json"), JSON.stringify(report.worklist, null, 2) + "\n");
|
|
321
|
+
}
|
|
322
|
+
if (opts.reporter === "json") {
|
|
323
|
+
console.log(JSON.stringify(report, null, 2));
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
console.log(prettyReport(report));
|
|
327
|
+
if (opts.write)
|
|
328
|
+
console.log(chalk.gray(`Wrote ${basename(AUDIT_DIR)}/coverage.json + summary.md`));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Direct-invocation entry point (`npx tsx tools/src/audit-coverage.ts [--json] [--write]`).
|
|
332
|
+
const isMain = process.argv[1] &&
|
|
333
|
+
resolve(process.argv[1]).replace(/\.\w+$/, "") === fileURLToPath(import.meta.url).replace(/\.\w+$/, "");
|
|
334
|
+
if (isMain) {
|
|
335
|
+
const argv = process.argv.slice(2);
|
|
336
|
+
auditCoverageCommand({
|
|
337
|
+
reporter: argv.includes("--json") ? "json" : "pretty",
|
|
338
|
+
write: argv.includes("--write"),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
//# sourceMappingURL=audit-coverage.js.map
|