@alpaca-software/40kdc-data 0.3.2 → 0.4.5
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 +12 -6
- package/dist/bundle-schemas.d.ts.map +1 -1
- package/dist/bundle-schemas.js +17 -0
- package/dist/bundle-schemas.js.map +1 -1
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -1
- package/dist/codegen-data.js +1 -0
- package/dist/codegen-data.js.map +1 -1
- package/dist/commands/populate-base-sizes.d.ts +2 -0
- package/dist/commands/populate-base-sizes.d.ts.map +1 -0
- package/dist/commands/populate-base-sizes.js +158 -0
- package/dist/commands/populate-base-sizes.js.map +1 -0
- package/dist/convert-faction.d.ts +3 -1
- package/dist/convert-faction.d.ts.map +1 -1
- package/dist/convert-faction.js +49 -16
- package/dist/convert-faction.js.map +1 -1
- package/dist/converters/base-size-bridge.d.ts +122 -0
- package/dist/converters/base-size-bridge.d.ts.map +1 -0
- package/dist/converters/base-size-bridge.js +198 -0
- package/dist/converters/base-size-bridge.js.map +1 -0
- package/dist/converters/base-size-guide-extract.d.ts +11 -0
- package/dist/converters/base-size-guide-extract.d.ts.map +1 -0
- package/dist/converters/base-size-guide-extract.js +59 -0
- package/dist/converters/base-size-guide-extract.js.map +1 -0
- package/dist/converters/option-bridge.d.ts +36 -0
- package/dist/converters/option-bridge.d.ts.map +1 -0
- package/dist/converters/option-bridge.js +72 -0
- package/dist/converters/option-bridge.js.map +1 -0
- package/dist/converters/option-parser.d.ts +56 -0
- package/dist/converters/option-parser.d.ts.map +1 -0
- package/dist/converters/option-parser.js +209 -0
- package/dist/converters/option-parser.js.map +1 -0
- package/dist/converters/wargear-options.d.ts +55 -0
- package/dist/converters/wargear-options.d.ts.map +1 -0
- package/dist/converters/wargear-options.js +187 -0
- package/dist/converters/wargear-options.js.map +1 -0
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/dataset.d.ts +9 -1
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +14 -0
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/entities.d.ts +3 -1
- package/dist/data/entities.d.ts.map +1 -1
- package/dist/data/entities.js +4 -0
- package/dist/data/entities.js.map +1 -1
- package/dist/data/index.d.ts +4 -0
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +4 -0
- package/dist/data/index.js.map +1 -1
- package/dist/data/loadout.d.ts +60 -0
- package/dist/data/loadout.d.ts.map +1 -0
- package/dist/data/loadout.js +135 -0
- package/dist/data/loadout.js.map +1 -0
- package/dist/data/types.d.ts +3 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/data/types.js +1 -0
- package/dist/data/types.js.map +1 -1
- package/dist/export/rosterizer.js +1 -1
- package/dist/export/rosterizer.js.map +1 -1
- package/dist/gen-conformance.js +171 -0
- package/dist/gen-conformance.js.map +1 -1
- package/dist/generated.d.ts +135 -55
- package/dist/generated.d.ts.map +1 -1
- package/dist/generated.js.map +1 -1
- package/dist/import/rosterizer.d.ts +1 -1
- package/dist/import/rosterizer.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/runner.d.ts +16 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +216 -0
- package/dist/runner.js.map +1 -1
- package/dist/scoring/index.d.ts +28 -6
- package/dist/scoring/index.d.ts.map +1 -1
- package/dist/scoring/index.js +31 -7
- package/dist/scoring/index.js.map +1 -1
- package/dist/terrain/index.d.ts +2 -2
- package/dist/terrain/index.d.ts.map +1 -1
- package/dist/terrain/index.js +1 -1
- package/dist/terrain/index.js.map +1 -1
- package/dist/terrain/solve.d.ts +41 -0
- package/dist/terrain/solve.d.ts.map +1 -1
- package/dist/terrain/solve.js +100 -0
- package/dist/terrain/solve.js.map +1 -1
- package/dist/translate/condition.d.ts.map +1 -1
- package/dist/translate/condition.js +4 -0
- package/dist/translate/condition.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +13 -5
- package/dist/validate.js.map +1 -1
- package/package.json +5 -5
- package/schemas/$defs/common.schema.json +14 -0
- package/schemas/core/secondary-card.schema.json +10 -0
- package/schemas/core/terrain-layout.schema.json +18 -0
- package/schemas/core/unit-composition.schema.json +5 -1
- package/schemas/core/unit.schema.json +2 -10
- package/schemas/core/wargear-option.schema.json +32 -6
- package/schemas/core/wargear.schema.json +24 -0
- package/schemas/enrichment/ability-dsl/condition.schema.json +3 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The maximum number of models that may take `option` in a unit of `modelCount`
|
|
3
|
+
* models: `any_number` → all models; else `per_n_models` → floor(n / per); else
|
|
4
|
+
* `max_count ?? 1`; then clamped by `max_count` when set. A null constraint is
|
|
5
|
+
* treated as unrestricted (every model). Never negative.
|
|
6
|
+
*/
|
|
7
|
+
export function optionCap(option, modelCount) {
|
|
8
|
+
const c = option.model_constraint;
|
|
9
|
+
if (!c)
|
|
10
|
+
return Math.max(0, modelCount);
|
|
11
|
+
let cap;
|
|
12
|
+
if (c.any_number)
|
|
13
|
+
cap = modelCount;
|
|
14
|
+
else if (c.per_n_models)
|
|
15
|
+
cap = Math.floor(modelCount / c.per_n_models);
|
|
16
|
+
else
|
|
17
|
+
cap = c.max_count ?? 1;
|
|
18
|
+
if (c.max_count != null)
|
|
19
|
+
cap = Math.min(cap, c.max_count);
|
|
20
|
+
return Math.max(0, cap);
|
|
21
|
+
}
|
|
22
|
+
/** The ids a single option can add, given the chosen choice branch (default 0). */
|
|
23
|
+
function addedIds(option, choiceIndex = 0) {
|
|
24
|
+
if (option.replacement)
|
|
25
|
+
return option.replacement;
|
|
26
|
+
return option.replacement_choice?.[choiceIndex] ?? [];
|
|
27
|
+
}
|
|
28
|
+
/** Every id that any option can add — across all choice branches. */
|
|
29
|
+
function allReplacementIds(options) {
|
|
30
|
+
const out = new Set();
|
|
31
|
+
for (const o of options) {
|
|
32
|
+
for (const id of o.replacement ?? [])
|
|
33
|
+
out.add(id);
|
|
34
|
+
for (const group of o.replacement_choice ?? [])
|
|
35
|
+
for (const id of group)
|
|
36
|
+
out.add(id);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** Base (always-carried) weapon ids: in `weapon_ids`, never a replacement. */
|
|
41
|
+
function baseWeaponIds(unit, options) {
|
|
42
|
+
const replacements = allReplacementIds(options);
|
|
43
|
+
return (unit.weapon_ids ?? []).filter((id) => !replacements.has(id));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The maximal loadout: every base weapon on every model, then each option
|
|
47
|
+
* applied at its full {@link optionCap} (choices take their first branch). Swaps
|
|
48
|
+
* move count from the replaced id to the added id; add-ons only add.
|
|
49
|
+
*/
|
|
50
|
+
export function maximalLoadout(unit, modelCount, options) {
|
|
51
|
+
const counts = new Map();
|
|
52
|
+
for (const id of baseWeaponIds(unit, options)) {
|
|
53
|
+
counts.set(id, (counts.get(id) ?? 0) + modelCount);
|
|
54
|
+
}
|
|
55
|
+
for (const option of options) {
|
|
56
|
+
const cap = optionCap(option, modelCount);
|
|
57
|
+
if (cap === 0)
|
|
58
|
+
continue;
|
|
59
|
+
for (const id of option.replaces ?? []) {
|
|
60
|
+
counts.set(id, (counts.get(id) ?? 0) - cap);
|
|
61
|
+
}
|
|
62
|
+
for (const id of addedIds(option)) {
|
|
63
|
+
counts.set(id, (counts.get(id) ?? 0) + cap);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Drop any id that nets to zero so the loadout reads cleanly.
|
|
67
|
+
for (const [id, n] of counts)
|
|
68
|
+
if (n === 0)
|
|
69
|
+
counts.delete(id);
|
|
70
|
+
return { counts };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Inclusive valid count range for each weapon/wargear id, used to clamp a UI's
|
|
74
|
+
* per-weapon inputs so invalid loadouts are unreachable. A base weapon ranges
|
|
75
|
+
* `[modelCount − maxSwapsAway, modelCount]`; an optional (replacement) id ranges
|
|
76
|
+
* `[0, Σ caps that add it]`.
|
|
77
|
+
*/
|
|
78
|
+
export function weaponBounds(unit, modelCount, options) {
|
|
79
|
+
const bounds = new Map();
|
|
80
|
+
for (const id of baseWeaponIds(unit, options)) {
|
|
81
|
+
bounds.set(id, { min: modelCount, max: modelCount });
|
|
82
|
+
}
|
|
83
|
+
for (const option of options) {
|
|
84
|
+
const cap = optionCap(option, modelCount);
|
|
85
|
+
for (const id of option.replaces ?? []) {
|
|
86
|
+
const b = bounds.get(id) ?? { min: 0, max: 0 };
|
|
87
|
+
bounds.set(id, { min: Math.max(0, b.min - cap), max: b.max });
|
|
88
|
+
}
|
|
89
|
+
// A replacement id can appear in multiple options / both choice branches;
|
|
90
|
+
// sum the caps so its ceiling reflects every way to add it.
|
|
91
|
+
const adds = new Set();
|
|
92
|
+
for (const id of option.replacement ?? [])
|
|
93
|
+
adds.add(id);
|
|
94
|
+
for (const group of option.replacement_choice ?? [])
|
|
95
|
+
for (const id of group)
|
|
96
|
+
adds.add(id);
|
|
97
|
+
for (const id of adds) {
|
|
98
|
+
const b = bounds.get(id) ?? { min: 0, max: 0 };
|
|
99
|
+
bounds.set(id, { min: b.min, max: b.max + cap });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return bounds;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Clamp a single weapon's requested count into its valid range. Ids with no
|
|
106
|
+
* bound (not part of this unit's loadout) are returned unchanged but floored at
|
|
107
|
+
* zero.
|
|
108
|
+
*/
|
|
109
|
+
export function clampWeaponCount(bounds, id, requested) {
|
|
110
|
+
const b = bounds.get(id);
|
|
111
|
+
const n = Math.max(0, Math.floor(requested) || 0);
|
|
112
|
+
if (!b)
|
|
113
|
+
return n;
|
|
114
|
+
return Math.min(b.max, Math.max(b.min, n));
|
|
115
|
+
}
|
|
116
|
+
/** Report every weapon/wargear count that falls outside its valid range. */
|
|
117
|
+
export function validateLoadout(unit, modelCount, options, counts) {
|
|
118
|
+
const bounds = weaponBounds(unit, modelCount, options);
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const [id, n] of counts) {
|
|
121
|
+
const b = bounds.get(id);
|
|
122
|
+
if (!b)
|
|
123
|
+
continue;
|
|
124
|
+
if (n > b.max) {
|
|
125
|
+
out.push({ id, code: "exceeds-max", message: `${id}: ${n} exceeds max ${b.max}` });
|
|
126
|
+
}
|
|
127
|
+
else if (n < b.min) {
|
|
128
|
+
out.push({ id, code: "below-min", message: `${id}: ${n} below min ${b.min}` });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Deterministic order so the result is stable for cross-impl comparison.
|
|
132
|
+
out.sort((a, b) => (a.id === b.id ? a.code.localeCompare(b.code) : a.id.localeCompare(b.id)));
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=loadout.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loadout.js","sourceRoot":"","sources":["../../src/data/loadout.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Wargear-loadout maths shared by every consumer of the dataset: how many\n * models may take an option, what the maximal (take-every-swap) loadout looks\n * like, the valid count range for each weapon, and whether an edited loadout is\n * legal.\n *\n * The base loadout is derived, not stored: a weapon in `unit.weapon_ids` that\n * never appears as the *replacement* of any option is a **base** weapon, carried\n * by every model; a weapon that does appear as a replacement is **optional**,\n * carried only by the models that took the swap. This holds for uniform infantry\n * squads (every model shares the base loadout) and is exactly right for the\n * cases the corpus pins. Mirror of `crates/wh40kdc/src/data/loadout.rs`.\n *\n * @packageDocumentation\n */\nimport type { Unit, WargearOption } from \"../generated.js\";\n\n/** Inclusive count range a single weapon/wargear id may take in a loadout. */\nexport interface WeaponBound {\n min: number;\n max: number;\n}\n\n/** A resolved loadout: entity id (weapon or wargear) → count across the unit. */\nexport interface Loadout {\n counts: Map<string, number>;\n}\n\n/** A loadout-rule violation. `id` is the offending weapon/wargear id. */\nexport interface Violation {\n id: string;\n code: \"exceeds-max\" | \"below-min\";\n message: string;\n}\n\n/**\n * The maximum number of models that may take `option` in a unit of `modelCount`\n * models: `any_number` → all models; else `per_n_models` → floor(n / per); else\n * `max_count ?? 1`; then clamped by `max_count` when set. A null constraint is\n * treated as unrestricted (every model). Never negative.\n */\nexport function optionCap(option: WargearOption, modelCount: number): number {\n const c = option.model_constraint;\n if (!c) return Math.max(0, modelCount);\n let cap: number;\n if (c.any_number) cap = modelCount;\n else if (c.per_n_models) cap = Math.floor(modelCount / c.per_n_models);\n else cap = c.max_count ?? 1;\n if (c.max_count != null) cap = Math.min(cap, c.max_count);\n return Math.max(0, cap);\n}\n\n/** The ids a single option can add, given the chosen choice branch (default 0). */\nfunction addedIds(option: WargearOption, choiceIndex = 0): string[] {\n if (option.replacement) return option.replacement;\n return option.replacement_choice?.[choiceIndex] ?? [];\n}\n\n/** Every id that any option can add — across all choice branches. */\nfunction allReplacementIds(options: readonly WargearOption[]): Set<string> {\n const out = new Set<string>();\n for (const o of options) {\n for (const id of o.replacement ?? []) out.add(id);\n for (const group of o.replacement_choice ?? []) for (const id of group) out.add(id);\n }\n return out;\n}\n\n/** Base (always-carried) weapon ids: in `weapon_ids`, never a replacement. */\nfunction baseWeaponIds(unit: Unit, options: readonly WargearOption[]): string[] {\n const replacements = allReplacementIds(options);\n return (unit.weapon_ids ?? []).filter((id) => !replacements.has(id));\n}\n\n/**\n * The maximal loadout: every base weapon on every model, then each option\n * applied at its full {@link optionCap} (choices take their first branch). Swaps\n * move count from the replaced id to the added id; add-ons only add.\n */\nexport function maximalLoadout(\n unit: Unit,\n modelCount: number,\n options: readonly WargearOption[],\n): Loadout {\n const counts = new Map<string, number>();\n for (const id of baseWeaponIds(unit, options)) {\n counts.set(id, (counts.get(id) ?? 0) + modelCount);\n }\n for (const option of options) {\n const cap = optionCap(option, modelCount);\n if (cap === 0) continue;\n for (const id of option.replaces ?? []) {\n counts.set(id, (counts.get(id) ?? 0) - cap);\n }\n for (const id of addedIds(option)) {\n counts.set(id, (counts.get(id) ?? 0) + cap);\n }\n }\n // Drop any id that nets to zero so the loadout reads cleanly.\n for (const [id, n] of counts) if (n === 0) counts.delete(id);\n return { counts };\n}\n\n/**\n * Inclusive valid count range for each weapon/wargear id, used to clamp a UI's\n * per-weapon inputs so invalid loadouts are unreachable. A base weapon ranges\n * `[modelCount − maxSwapsAway, modelCount]`; an optional (replacement) id ranges\n * `[0, Σ caps that add it]`.\n */\nexport function weaponBounds(\n unit: Unit,\n modelCount: number,\n options: readonly WargearOption[],\n): Map<string, WeaponBound> {\n const bounds = new Map<string, WeaponBound>();\n for (const id of baseWeaponIds(unit, options)) {\n bounds.set(id, { min: modelCount, max: modelCount });\n }\n for (const option of options) {\n const cap = optionCap(option, modelCount);\n for (const id of option.replaces ?? []) {\n const b = bounds.get(id) ?? { min: 0, max: 0 };\n bounds.set(id, { min: Math.max(0, b.min - cap), max: b.max });\n }\n // A replacement id can appear in multiple options / both choice branches;\n // sum the caps so its ceiling reflects every way to add it.\n const adds = new Set<string>();\n for (const id of option.replacement ?? []) adds.add(id);\n for (const group of option.replacement_choice ?? []) for (const id of group) adds.add(id);\n for (const id of adds) {\n const b = bounds.get(id) ?? { min: 0, max: 0 };\n bounds.set(id, { min: b.min, max: b.max + cap });\n }\n }\n return bounds;\n}\n\n/**\n * Clamp a single weapon's requested count into its valid range. Ids with no\n * bound (not part of this unit's loadout) are returned unchanged but floored at\n * zero.\n */\nexport function clampWeaponCount(\n bounds: Map<string, WeaponBound>,\n id: string,\n requested: number,\n): number {\n const b = bounds.get(id);\n const n = Math.max(0, Math.floor(requested) || 0);\n if (!b) return n;\n return Math.min(b.max, Math.max(b.min, n));\n}\n\n/** Report every weapon/wargear count that falls outside its valid range. */\nexport function validateLoadout(\n unit: Unit,\n modelCount: number,\n options: readonly WargearOption[],\n counts: Map<string, number>,\n): Violation[] {\n const bounds = weaponBounds(unit, modelCount, options);\n const out: Violation[] = [];\n for (const [id, n] of counts) {\n const b = bounds.get(id);\n if (!b) continue;\n if (n > b.max) {\n out.push({ id, code: \"exceeds-max\", message: `${id}: ${n} exceeds max ${b.max}` });\n } else if (n < b.min) {\n out.push({ id, code: \"below-min\", message: `${id}: ${n} below min ${b.min}` });\n }\n }\n // Deterministic order so the result is stable for cross-impl comparison.\n out.sort((a, b) => (a.id === b.id ? a.code.localeCompare(b.code) : a.id.localeCompare(b.id)));\n return out;\n}\n"]}
|
package/dist/data/types.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @packageDocumentation
|
|
9
9
|
*/
|
|
10
|
-
import type { AbilityDSLEntry, DeploymentPattern, Detachment, Enhancement, Faction, ForceDisposition, GameVersion, InteractionFlag, LeaderAttachment, Mission, MissionMatchup, PhaseMapping, ResourcePool, SecondaryCard, Stratagem, TerrainLayout, TerrainTemplate, TimingFlag, Unit, UnitComposition, WargearOption, Weapon, WeaponKeyword } from "../generated.js";
|
|
10
|
+
import type { AbilityDSLEntry, DeploymentPattern, Detachment, Enhancement, Faction, ForceDisposition, GameVersion, InteractionFlag, LeaderAttachment, Mission, MissionMatchup, PhaseMapping, ResourcePool, SecondaryCard, Stratagem, TerrainLayout, TerrainTemplate, TimingFlag, Unit, UnitComposition, Wargear, WargearOption, Weapon, WeaponKeyword } from "../generated.js";
|
|
11
11
|
/**
|
|
12
12
|
* Every entity collection in the dataset, keyed by camelCase collection name.
|
|
13
13
|
*
|
|
@@ -31,6 +31,8 @@ export interface RawData {
|
|
|
31
31
|
leaderAttachments: LeaderAttachment[];
|
|
32
32
|
unitCompositions: UnitComposition[];
|
|
33
33
|
wargearOptions: WargearOption[];
|
|
34
|
+
/** Non-weapon wargear items (icons, attachments) referenced by wargear options. */
|
|
35
|
+
wargear: Wargear[];
|
|
34
36
|
gameVersions: GameVersion[];
|
|
35
37
|
missions: Mission[];
|
|
36
38
|
missionMatchups: MissionMatchup[];
|
package/dist/data/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/data/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EACV,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,WAAW,EACX,OAAO,EACP,gBAAgB,EAChB,WAAW,EACX,eAAe,EACf,gBAAgB,EAChB,OAAO,EACP,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,SAAS,EACT,aAAa,EACb,eAAe,EACf,UAAU,EACV,IAAI,EACJ,eAAe,EACf,aAAa,EACb,MAAM,EACN,aAAa,EACd,MAAM,iBAAiB,CAAC;AAEzB;;;;;;GAMG;AACH,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,kFAAkF;IAClF,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,4EAA4E;IAC5E,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,8EAA8E;IAC9E,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,iBAAiB,EAAE,gBAAgB,EAAE,CAAC;IACtC,gBAAgB,EAAE,eAAe,EAAE,CAAC;IACpC,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,eAAe,EAAE,cAAc,EAAE,CAAC;IAClC,YAAY,EAAE,aAAa,EAAE,CAAC;IAC9B,kBAAkB,EAAE,iBAAiB,EAAE,CAAC;IACxC,iBAAiB,EAAE,gBAAgB,EAAE,CAAC;IACtC,qEAAqE;IACrE,gBAAgB,EAAE,eAAe,EAAE,CAAC;IACpC,2EAA2E;IAC3E,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,gBAAgB,EAAE,eAAe,EAAE,CAAC;CACrC;AAED,uEAAuE;AACvE,wBAAgB,YAAY,IAAI,OAAO,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/data/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EACV,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,WAAW,EACX,OAAO,EACP,gBAAgB,EAChB,WAAW,EACX,eAAe,EACf,gBAAgB,EAChB,OAAO,EACP,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,SAAS,EACT,aAAa,EACb,eAAe,EACf,UAAU,EACV,IAAI,EACJ,eAAe,EACf,OAAO,EACP,aAAa,EACb,MAAM,EACN,aAAa,EACd,MAAM,iBAAiB,CAAC;AAEzB;;;;;;GAMG;AACH,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,kFAAkF;IAClF,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,4EAA4E;IAC5E,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,8EAA8E;IAC9E,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,iBAAiB,EAAE,gBAAgB,EAAE,CAAC;IACtC,gBAAgB,EAAE,eAAe,EAAE,CAAC;IACpC,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,mFAAmF;IACnF,OAAO,EAAE,OAAO,EAAE,CAAC;IACnB,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,eAAe,EAAE,cAAc,EAAE,CAAC;IAClC,YAAY,EAAE,aAAa,EAAE,CAAC;IAC9B,kBAAkB,EAAE,iBAAiB,EAAE,CAAC;IACxC,iBAAiB,EAAE,gBAAgB,EAAE,CAAC;IACtC,qEAAqE;IACrE,gBAAgB,EAAE,eAAe,EAAE,CAAC;IACpC,2EAA2E;IAC3E,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,gBAAgB,EAAE,eAAe,EAAE,CAAC;CACrC;AAED,uEAAuE;AACvE,wBAAgB,YAAY,IAAI,OAAO,CA2BtC"}
|
package/dist/data/types.js
CHANGED
package/dist/data/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/data/types.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/data/types.ts"],"names":[],"mappings":"AA4EA,uEAAuE;AACvE,MAAM,UAAU,YAAY;IAC1B,OAAO;QACL,KAAK,EAAE,EAAE;QACT,OAAO,EAAE,EAAE;QACX,cAAc,EAAE,EAAE;QAClB,QAAQ,EAAE,EAAE;QACZ,SAAS,EAAE,EAAE;QACb,aAAa,EAAE,EAAE;QACjB,WAAW,EAAE,EAAE;QACf,UAAU,EAAE,EAAE;QACd,YAAY,EAAE,EAAE;QAChB,iBAAiB,EAAE,EAAE;QACrB,gBAAgB,EAAE,EAAE;QACpB,cAAc,EAAE,EAAE;QAClB,OAAO,EAAE,EAAE;QACX,YAAY,EAAE,EAAE;QAChB,QAAQ,EAAE,EAAE;QACZ,eAAe,EAAE,EAAE;QACnB,YAAY,EAAE,EAAE;QAChB,kBAAkB,EAAE,EAAE;QACtB,iBAAiB,EAAE,EAAE;QACrB,gBAAgB,EAAE,EAAE;QACpB,cAAc,EAAE,EAAE;QAClB,aAAa,EAAE,EAAE;QACjB,WAAW,EAAE,EAAE;QACf,gBAAgB,EAAE,EAAE;KACrB,CAAC;AACJ,CAAC","sourcesContent":["/**\n * The shape of the embedded data bundle: one named array per entity collection.\n *\n * `RawData` is the boundary between the generated JSON-Schema types and the\n * linked view layer. The codegen ({@link file://../codegen-data.ts}) emits a\n * `RAW_DATA: RawData` constant; {@link Dataset} wraps it with linked accessors.\n *\n * @packageDocumentation\n */\nimport type {\n AbilityDSLEntry,\n DeploymentPattern,\n Detachment,\n Enhancement,\n Faction,\n ForceDisposition,\n GameVersion,\n InteractionFlag,\n LeaderAttachment,\n Mission,\n MissionMatchup,\n PhaseMapping,\n ResourcePool,\n SecondaryCard,\n Stratagem,\n TerrainLayout,\n TerrainTemplate,\n TimingFlag,\n Unit,\n UnitComposition,\n Wargear,\n WargearOption,\n Weapon,\n WeaponKeyword,\n} from \"../generated.js\";\n\n/**\n * Every entity collection in the dataset, keyed by camelCase collection name.\n *\n * Collections with no authored data yet (e.g. `interactionFlags`) are present\n * as empty arrays so the API surface is stable and new data flows through\n * automatically once authored.\n */\nexport interface RawData {\n units: Unit[];\n weapons: Weapon[];\n /** Catalog of weapon keywords (Lethal Hits, Sustained Hits N, Anti-X N+, ...). */\n weaponKeywords: WeaponKeyword[];\n factions: Faction[];\n /** Community-authored ability mechanics (key is `ability_id`, not `id`). */\n abilities: AbilityDSLEntry[];\n /** Phase assignments, joined to abilities/stratagems/etc. via `source_id`. */\n phaseMappings: PhaseMapping[];\n detachments: Detachment[];\n stratagems: Stratagem[];\n enhancements: Enhancement[];\n leaderAttachments: LeaderAttachment[];\n unitCompositions: UnitComposition[];\n wargearOptions: WargearOption[];\n /** Non-weapon wargear items (icons, attachments) referenced by wargear options. */\n wargear: Wargear[];\n gameVersions: GameVersion[];\n missions: Mission[];\n missionMatchups: MissionMatchup[];\n missionCards: SecondaryCard[];\n deploymentPatterns: DeploymentPattern[];\n forceDispositions: ForceDisposition[];\n /** Reusable terrain catalog: standard areas and scenery features. */\n terrainTemplates: TerrainTemplate[];\n /** Terrain layouts: arrangements of catalog/inline pieces on the board. */\n terrainLayouts: TerrainLayout[];\n resourcePools: ResourcePool[];\n timingFlags: TimingFlag[];\n interactionFlags: InteractionFlag[];\n}\n\n/** A `RawData` with every collection initialised to an empty array. */\nexport function emptyRawData(): RawData {\n return {\n units: [],\n weapons: [],\n weaponKeywords: [],\n factions: [],\n abilities: [],\n phaseMappings: [],\n detachments: [],\n stratagems: [],\n enhancements: [],\n leaderAttachments: [],\n unitCompositions: [],\n wargearOptions: [],\n wargear: [],\n gameVersions: [],\n missions: [],\n missionMatchups: [],\n missionCards: [],\n deploymentPatterns: [],\n forceDispositions: [],\n terrainTemplates: [],\n terrainLayouts: [],\n resourcePools: [],\n timingFlags: [],\n interactionFlags: [],\n };\n}\n"]}
|
|
@@ -12,7 +12,7 @@ const CLS_TRAIT = "Trait";
|
|
|
12
12
|
const DSG_WARLORD = "Warlord";
|
|
13
13
|
const RULEBOOK_NAME = "40kdc";
|
|
14
14
|
const RULEBOOK_GAME = "Warhammer 40,000";
|
|
15
|
-
const RULEBOOK_PUBLISHER = "
|
|
15
|
+
const RULEBOOK_PUBLISHER = "Alpaca Software";
|
|
16
16
|
const RULEBOOK_URL = "https://40kdc.dev";
|
|
17
17
|
const RULEBOOK_GENRE = "wargame";
|
|
18
18
|
function key(classification, designation) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rosterizer.js","sourceRoot":"","sources":["../../src/export/rosterizer.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Rosterizer serializer — emits a Rosterizer-shaped roster JSON skeleton that\n * round-trips through {@link rosterizerAdapter}.\n *\n * The shape carries only fields the importer reads: `rulebook` (envelope),\n * `snapshot` (an `Asset` tree rooted at `Roster§Roster`), and per-unit\n * `item`/`name`/`quantity`/`stats.Points.value`/`assets.included`/`assets.traits`.\n * No `text`, `description`, `rules`, `lineage`, `_layers`, `classIdentity`,\n * `processed`, or `bareResourceKey` ever appear — they aren't stored in the\n * Roster and emitting them could leak prose.\n *\n * Faction and detachment display names come from {@link titleCaseId} — the\n * Roster doesn't carry the source's raw faction name, so we reconstruct it\n * from the kebab-case id. Same lossy hop as the NewRecruit JSON serializer.\n *\n * @packageDocumentation\n */\nimport type { Roster, RosterUnit, RosterWargear } from \"../import/types.js\";\nimport { prettyJson, titleCaseId, totalArmyPoints } from \"./helpers.js\";\nimport type { RosterSerializer } from \"./serializer.js\";\n\n// Mirror the importer's constants (kept inline rather than imported so the\n// exporter stays decoupled — the seams are the `item` keys themselves).\nconst CLS_ROSTER = \"Roster\";\nconst CLS_FACTION = \"Faction\";\nconst CLS_DETACHMENT = \"Detachment\";\nconst CLS_UNIT = \"Unit\";\nconst CLS_WEAPON = \"Weapon\";\nconst CLS_ENHANCEMENT = \"Enhancement\";\nconst CLS_BATTLE_SIZE = \"Battle Size\";\nconst CLS_TRAIT = \"Trait\";\nconst DSG_WARLORD = \"Warlord\";\n\nconst RULEBOOK_NAME = \"40kdc\";\nconst RULEBOOK_GAME = \"Warhammer 40,000\";\nconst RULEBOOK_PUBLISHER = \"Tabletop Developer Consortium\";\nconst RULEBOOK_URL = \"https://40kdc.dev\";\nconst RULEBOOK_GENRE = \"wargame\";\n\ninterface Asset {\n item: string;\n name?: string;\n quantity?: number;\n stats?: Record<string, { value: number }>;\n assets?: {\n included?: Asset[];\n traits?: Asset[];\n };\n}\n\ninterface Envelope {\n slug: string;\n key: string;\n visible: \"hidden\" | \"public\" | \"friends\";\n locked: boolean;\n rulebook: {\n name: string;\n game: string;\n publisher: string;\n url: string;\n genre: string;\n };\n snapshot: Asset;\n}\n\nfunction key(classification: string, designation: string): string {\n return `${classification}§${designation}`; // §\n}\n\nfunction pointsStat(value: number | null | undefined): Record<string, { value: number }> | undefined {\n if (value === null || value === undefined) return undefined;\n return { Points: { value } };\n}\n\nfunction wargearAsset(w: RosterWargear): Asset {\n return {\n item: key(CLS_WEAPON, w.ref.raw_name),\n name: w.ref.raw_name,\n quantity: w.count,\n };\n}\n\nfunction enhancementAsset(u: RosterUnit): Asset | null {\n if (!u.enhancement) return null;\n return {\n item: key(CLS_ENHANCEMENT, u.enhancement.raw_name),\n name: u.enhancement.raw_name,\n quantity: 1,\n ...(u.enhancement_points !== null\n ? { stats: pointsStat(u.enhancement_points) }\n : {}),\n };\n}\n\nfunction warlordTraitAsset(): Asset {\n return {\n item: key(CLS_TRAIT, DSG_WARLORD),\n name: DSG_WARLORD,\n quantity: 1,\n };\n}\n\nfunction unitAsset(u: RosterUnit): Asset {\n const included: Asset[] = [];\n const enh = enhancementAsset(u);\n if (enh !== null) included.push(enh);\n for (const w of u.wargear) included.push(wargearAsset(w));\n\n const traits: Asset[] = [];\n if (u.is_warlord) traits.push(warlordTraitAsset());\n\n const asset: Asset = {\n item: key(CLS_UNIT, u.ref.raw_name),\n name: u.ref.raw_name,\n quantity: u.model_count,\n };\n const stats = pointsStat(u.points);\n if (stats !== undefined) asset.stats = stats;\n if (included.length > 0 || traits.length > 0) {\n asset.assets = {};\n if (included.length > 0) asset.assets.included = included;\n if (traits.length > 0) asset.assets.traits = traits;\n }\n return asset;\n}\n\nfunction factionAsset(roster: Roster): Asset | null {\n const display = titleCaseId(roster.faction_id);\n if (display === null) return null;\n return { item: key(CLS_FACTION, display), name: display, quantity: 1 };\n}\n\nfunction detachmentAsset(roster: Roster): Asset | null {\n const display = titleCaseId(roster.detachment_id);\n if (display === null) return null;\n return { item: key(CLS_DETACHMENT, display), name: display, quantity: 1 };\n}\n\nfunction battleSizeAsset(roster: Roster): Asset | null {\n if (roster.battle_size === \"strike-force\") {\n const limit = roster.points.declared_limit ?? 2000;\n const label = `Strike Force (${limit} Point limit)`;\n return { item: key(CLS_BATTLE_SIZE, label), name: label, quantity: 1 };\n }\n if (roster.battle_size === \"incursion\") {\n const limit = roster.points.declared_limit ?? 1000;\n const label = `Incursion (${limit} Point limit)`;\n return { item: key(CLS_BATTLE_SIZE, label), name: label, quantity: 1 };\n }\n return null;\n}\n\nexport const rosterizerSerializer: RosterSerializer = {\n id: \"rosterizer\",\n\n serialize(roster: Roster): string {\n const included: Asset[] = [];\n const faction = factionAsset(roster);\n if (faction) included.push(faction);\n const detachment = detachmentAsset(roster);\n if (detachment) included.push(detachment);\n const battleSize = battleSizeAsset(roster);\n if (battleSize) included.push(battleSize);\n for (const u of roster.units) included.push(unitAsset(u));\n\n const total = totalArmyPoints(roster);\n const snapshot: Asset = {\n item: key(CLS_ROSTER, CLS_ROSTER),\n name: roster.name,\n quantity: 1,\n ...(total > 0 ? { stats: pointsStat(total) } : {}),\n assets: { included },\n };\n\n const envelope: Envelope = {\n slug: \"\",\n key: \"\",\n visible: \"hidden\",\n locked: false,\n rulebook: {\n name: RULEBOOK_NAME,\n game: RULEBOOK_GAME,\n publisher: RULEBOOK_PUBLISHER,\n url: RULEBOOK_URL,\n genre: RULEBOOK_GENRE,\n },\n snapshot,\n };\n\n return prettyJson(envelope);\n },\n};\n"]}
|
|
1
|
+
{"version":3,"file":"rosterizer.js","sourceRoot":"","sources":["../../src/export/rosterizer.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Rosterizer serializer — emits a Rosterizer-shaped roster JSON skeleton that\n * round-trips through {@link rosterizerAdapter}.\n *\n * The shape carries only fields the importer reads: `rulebook` (envelope),\n * `snapshot` (an `Asset` tree rooted at `Roster§Roster`), and per-unit\n * `item`/`name`/`quantity`/`stats.Points.value`/`assets.included`/`assets.traits`.\n * No `text`, `description`, `rules`, `lineage`, `_layers`, `classIdentity`,\n * `processed`, or `bareResourceKey` ever appear — they aren't stored in the\n * Roster and emitting them could leak prose.\n *\n * Faction and detachment display names come from {@link titleCaseId} — the\n * Roster doesn't carry the source's raw faction name, so we reconstruct it\n * from the kebab-case id. Same lossy hop as the NewRecruit JSON serializer.\n *\n * @packageDocumentation\n */\nimport type { Roster, RosterUnit, RosterWargear } from \"../import/types.js\";\nimport { prettyJson, titleCaseId, totalArmyPoints } from \"./helpers.js\";\nimport type { RosterSerializer } from \"./serializer.js\";\n\n// Mirror the importer's constants (kept inline rather than imported so the\n// exporter stays decoupled — the seams are the `item` keys themselves).\nconst CLS_ROSTER = \"Roster\";\nconst CLS_FACTION = \"Faction\";\nconst CLS_DETACHMENT = \"Detachment\";\nconst CLS_UNIT = \"Unit\";\nconst CLS_WEAPON = \"Weapon\";\nconst CLS_ENHANCEMENT = \"Enhancement\";\nconst CLS_BATTLE_SIZE = \"Battle Size\";\nconst CLS_TRAIT = \"Trait\";\nconst DSG_WARLORD = \"Warlord\";\n\nconst RULEBOOK_NAME = \"40kdc\";\nconst RULEBOOK_GAME = \"Warhammer 40,000\";\nconst RULEBOOK_PUBLISHER = \"Alpaca Software\";\nconst RULEBOOK_URL = \"https://40kdc.dev\";\nconst RULEBOOK_GENRE = \"wargame\";\n\ninterface Asset {\n item: string;\n name?: string;\n quantity?: number;\n stats?: Record<string, { value: number }>;\n assets?: {\n included?: Asset[];\n traits?: Asset[];\n };\n}\n\ninterface Envelope {\n slug: string;\n key: string;\n visible: \"hidden\" | \"public\" | \"friends\";\n locked: boolean;\n rulebook: {\n name: string;\n game: string;\n publisher: string;\n url: string;\n genre: string;\n };\n snapshot: Asset;\n}\n\nfunction key(classification: string, designation: string): string {\n return `${classification}§${designation}`; // §\n}\n\nfunction pointsStat(value: number | null | undefined): Record<string, { value: number }> | undefined {\n if (value === null || value === undefined) return undefined;\n return { Points: { value } };\n}\n\nfunction wargearAsset(w: RosterWargear): Asset {\n return {\n item: key(CLS_WEAPON, w.ref.raw_name),\n name: w.ref.raw_name,\n quantity: w.count,\n };\n}\n\nfunction enhancementAsset(u: RosterUnit): Asset | null {\n if (!u.enhancement) return null;\n return {\n item: key(CLS_ENHANCEMENT, u.enhancement.raw_name),\n name: u.enhancement.raw_name,\n quantity: 1,\n ...(u.enhancement_points !== null\n ? { stats: pointsStat(u.enhancement_points) }\n : {}),\n };\n}\n\nfunction warlordTraitAsset(): Asset {\n return {\n item: key(CLS_TRAIT, DSG_WARLORD),\n name: DSG_WARLORD,\n quantity: 1,\n };\n}\n\nfunction unitAsset(u: RosterUnit): Asset {\n const included: Asset[] = [];\n const enh = enhancementAsset(u);\n if (enh !== null) included.push(enh);\n for (const w of u.wargear) included.push(wargearAsset(w));\n\n const traits: Asset[] = [];\n if (u.is_warlord) traits.push(warlordTraitAsset());\n\n const asset: Asset = {\n item: key(CLS_UNIT, u.ref.raw_name),\n name: u.ref.raw_name,\n quantity: u.model_count,\n };\n const stats = pointsStat(u.points);\n if (stats !== undefined) asset.stats = stats;\n if (included.length > 0 || traits.length > 0) {\n asset.assets = {};\n if (included.length > 0) asset.assets.included = included;\n if (traits.length > 0) asset.assets.traits = traits;\n }\n return asset;\n}\n\nfunction factionAsset(roster: Roster): Asset | null {\n const display = titleCaseId(roster.faction_id);\n if (display === null) return null;\n return { item: key(CLS_FACTION, display), name: display, quantity: 1 };\n}\n\nfunction detachmentAsset(roster: Roster): Asset | null {\n const display = titleCaseId(roster.detachment_id);\n if (display === null) return null;\n return { item: key(CLS_DETACHMENT, display), name: display, quantity: 1 };\n}\n\nfunction battleSizeAsset(roster: Roster): Asset | null {\n if (roster.battle_size === \"strike-force\") {\n const limit = roster.points.declared_limit ?? 2000;\n const label = `Strike Force (${limit} Point limit)`;\n return { item: key(CLS_BATTLE_SIZE, label), name: label, quantity: 1 };\n }\n if (roster.battle_size === \"incursion\") {\n const limit = roster.points.declared_limit ?? 1000;\n const label = `Incursion (${limit} Point limit)`;\n return { item: key(CLS_BATTLE_SIZE, label), name: label, quantity: 1 };\n }\n return null;\n}\n\nexport const rosterizerSerializer: RosterSerializer = {\n id: \"rosterizer\",\n\n serialize(roster: Roster): string {\n const included: Asset[] = [];\n const faction = factionAsset(roster);\n if (faction) included.push(faction);\n const detachment = detachmentAsset(roster);\n if (detachment) included.push(detachment);\n const battleSize = battleSizeAsset(roster);\n if (battleSize) included.push(battleSize);\n for (const u of roster.units) included.push(unitAsset(u));\n\n const total = totalArmyPoints(roster);\n const snapshot: Asset = {\n item: key(CLS_ROSTER, CLS_ROSTER),\n name: roster.name,\n quantity: 1,\n ...(total > 0 ? { stats: pointsStat(total) } : {}),\n assets: { included },\n };\n\n const envelope: Envelope = {\n slug: \"\",\n key: \"\",\n visible: \"hidden\",\n locked: false,\n rulebook: {\n name: RULEBOOK_NAME,\n game: RULEBOOK_GAME,\n publisher: RULEBOOK_PUBLISHER,\n url: RULEBOOK_URL,\n genre: RULEBOOK_GENRE,\n },\n snapshot,\n };\n\n return prettyJson(envelope);\n },\n};\n"]}
|
package/dist/gen-conformance.js
CHANGED
|
@@ -25,9 +25,12 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
import { Dataset } from "./data/dataset.js";
|
|
26
26
|
import { normalizeName } from "./data/normalize.js";
|
|
27
27
|
import { describeScoringCard } from "./translate/index.js";
|
|
28
|
+
import { awardsOf } from "./scoring/index.js";
|
|
29
|
+
import { createRunnerState, dispatch } from "./runner.js";
|
|
28
30
|
import { exportRoster } from "./export/index.js";
|
|
29
31
|
import { importRoster, REGISTERED_ADAPTERS } from "./import/import-roster.js";
|
|
30
32
|
import { selectAdapter } from "./import/adapter.js";
|
|
33
|
+
import { encodeBase } from "./runner.js";
|
|
31
34
|
import { attributeStages } from "./cruncher/attribution.js";
|
|
32
35
|
import { resolveLayout, } from "./terrain/resolve.js";
|
|
33
36
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -206,6 +209,12 @@ const LINKED_API_QUERIES = [
|
|
|
206
209
|
{ name: "abilities_of_faction world-eaters", query: "abilities_of_faction", args: { factionId: "world-eaters" }, comparison: "set" },
|
|
207
210
|
// weapons_of_faction: compared as set.
|
|
208
211
|
{ name: "weapons_of_faction world-eaters", query: "weapons_of_faction", args: { factionId: "world-eaters" }, comparison: "set" },
|
|
212
|
+
// base_size_of(unit): scalar encoded base — round, oval, and a draft flying-base.
|
|
213
|
+
{ name: "base_size_of intercessor-squad", query: "base_size_of", args: { unitId: "intercessor-squad" }, comparison: "scalar" },
|
|
214
|
+
{ name: "base_size_of vertus-praetors", query: "base_size_of", args: { unitId: "vertus-praetors" }, comparison: "scalar" },
|
|
215
|
+
{ name: "base_size_of windriders (draft flying base)", query: "base_size_of", args: { unitId: "windriders" }, comparison: "scalar" },
|
|
216
|
+
// model_bases_of(unit): ordered per-model bases; jakhals mixes 28.5mm bodies with a 40mm Dishonoured.
|
|
217
|
+
{ name: "model_bases_of jakhals (mixed)", query: "model_bases_of", args: { unitId: "jakhals" }, comparison: "ordered" },
|
|
209
218
|
];
|
|
210
219
|
function genLinkedApi() {
|
|
211
220
|
const ds = Dataset.embedded();
|
|
@@ -262,6 +271,19 @@ function runLinkedQuery(ds, q) {
|
|
|
262
271
|
throw new Error(`weapons_of_faction: unknown faction ${q.args.factionId}`);
|
|
263
272
|
return f.weapons.map((w) => w.id).sort();
|
|
264
273
|
}
|
|
274
|
+
case "base_size_of": {
|
|
275
|
+
const u = ds.units.get(q.args.unitId);
|
|
276
|
+
if (!u)
|
|
277
|
+
throw new Error(`base_size_of: unknown unit ${q.args.unitId}`);
|
|
278
|
+
return encodeBase(u.raw.base_size_mm);
|
|
279
|
+
}
|
|
280
|
+
case "model_bases_of": {
|
|
281
|
+
const u = ds.units.get(q.args.unitId);
|
|
282
|
+
if (!u)
|
|
283
|
+
throw new Error(`model_bases_of: unknown unit ${q.args.unitId}`);
|
|
284
|
+
const comp = ds.unitCompositions.find((c) => c.unit_id === q.args.unitId);
|
|
285
|
+
return (comp?.models ?? []).map((m) => `${m.name}=${encodeBase(m.base_size_mm) ?? "none"}`);
|
|
286
|
+
}
|
|
265
287
|
}
|
|
266
288
|
}
|
|
267
289
|
/**
|
|
@@ -349,6 +371,154 @@ function genScoringTranslation() {
|
|
|
349
371
|
writeJson(join(CONFORMANCE, "scoring-translation", "cases.json"), cases);
|
|
350
372
|
console.log(`scoring-translation/cases.json: ${cases.length} cases`);
|
|
351
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Scoring-engine corpus: pin the pure VP arithmetic of the scoring engine
|
|
376
|
+
* (`tools/src/scoring/` — the oracle) so the Rust `wh40kdc::scoring` port
|
|
377
|
+
* reproduces it. Three ops, each case `{ name, op, args, expected }`:
|
|
378
|
+
*
|
|
379
|
+
* - `score_event` — per card and approach, assert every award matching the
|
|
380
|
+
* approach (by its full-`awards`-array index). Pins `scoreAward`, `scoreTurn`
|
|
381
|
+
* (exclusive-group "highest only", `vp_per × count` clamped to `per_max`,
|
|
382
|
+
* cumulative sums), `scoreCap` (tactical 5 vs fixed `vp_max`/uncapped), and
|
|
383
|
+
* `scoreSecondaryEvent`; primary cards also carry a `roundCap` to pin
|
|
384
|
+
* `scorePrimaryEvent`. `cap: null` means uncapped (Infinity has no JSON form).
|
|
385
|
+
* - `score_state` — replay scenarios over a `PlayerGame`, pinning the per-round
|
|
386
|
+
* cap (15), per-game primary cap (45), grand-total cap (100), score+discard,
|
|
387
|
+
* and undo.
|
|
388
|
+
* - `wtc_result` — the 20-point band mapping across its boundaries.
|
|
389
|
+
*
|
|
390
|
+
* Goldens are produced by driving the TS runner's own `dispatch`, so the corpus
|
|
391
|
+
* and the runner agree by construction; the cross-impl contract is the Rust
|
|
392
|
+
* runner reproducing them. Integers are compared exactly (no tolerance).
|
|
393
|
+
*/
|
|
394
|
+
function genScoring() {
|
|
395
|
+
const ds = Dataset.embedded();
|
|
396
|
+
mkdirSync(join(CONFORMANCE, "scoring"), { recursive: true });
|
|
397
|
+
// One initialized runner state, reused across cases (the ops don't mutate it).
|
|
398
|
+
const specVersion = Number.parseInt(readFileSync(join(CONFORMANCE, "SPEC_VERSION"), "utf8").trim(), 10);
|
|
399
|
+
const state = createRunnerState();
|
|
400
|
+
const init = dispatch(state, {
|
|
401
|
+
op: "init",
|
|
402
|
+
args: { spec_version: specVersion, locale: "C", tz: "UTC", seed: 0 },
|
|
403
|
+
});
|
|
404
|
+
if (!init.ok)
|
|
405
|
+
throw new Error(`gen scoring: init failed: ${JSON.stringify(init)}`);
|
|
406
|
+
const run = (op, args) => {
|
|
407
|
+
const r = dispatch(state, { op, args });
|
|
408
|
+
if (!r.ok)
|
|
409
|
+
throw new Error(`gen scoring: ${op} failed: ${JSON.stringify(r)} for ${JSON.stringify(args)}`);
|
|
410
|
+
return r.value;
|
|
411
|
+
};
|
|
412
|
+
const cases = [];
|
|
413
|
+
// score_event: every mission card, both approaches. Assert the approach's
|
|
414
|
+
// awards by their full-array index; count vp_per awards to their per_max
|
|
415
|
+
// (else 2) so the cap logic actually bites.
|
|
416
|
+
const cards = ds.missionCards.all
|
|
417
|
+
.slice()
|
|
418
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
419
|
+
for (const card of cards) {
|
|
420
|
+
for (const approach of ["fixed", "tactical"]) {
|
|
421
|
+
const asserted = awardsOf(card)
|
|
422
|
+
.map((aw, index) => ({ aw, index }))
|
|
423
|
+
.filter(({ aw }) => aw.mode == null || aw.mode === approach)
|
|
424
|
+
.map(({ aw, index }) => aw.vp_per != null ? { index, count: aw.per_max ?? 2 } : { index });
|
|
425
|
+
const args = { cardId: card.id, approach, asserted };
|
|
426
|
+
if (card.card_type === "primary")
|
|
427
|
+
args.roundCap = 15;
|
|
428
|
+
cases.push({
|
|
429
|
+
name: `score_event/${card.id}/${approach}`,
|
|
430
|
+
op: "score_event",
|
|
431
|
+
args,
|
|
432
|
+
expected: run("score_event", args),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// score_state: hand-authored replay scenarios. Card ids are real deck/mission
|
|
437
|
+
// cards; expected state is whatever the engine produces.
|
|
438
|
+
const stateScenarios = [
|
|
439
|
+
{
|
|
440
|
+
name: "primary-round-and-game-caps",
|
|
441
|
+
args: {
|
|
442
|
+
approach: "tactical",
|
|
443
|
+
ops: [
|
|
444
|
+
{ kind: "set-primary", round: 1, vp: 30, roundCap: 15, gameCap: 45 },
|
|
445
|
+
{ kind: "set-primary", round: 2, vp: 30, roundCap: 15, gameCap: 45 },
|
|
446
|
+
{ kind: "set-primary", round: 3, vp: 30, roundCap: 15, gameCap: 45 },
|
|
447
|
+
{ kind: "set-primary", round: 4, vp: 30, roundCap: 15, gameCap: 45 },
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
// The full primary path: a card's raw round total, clamped to the round
|
|
453
|
+
// cap on store, then cleared back to 0 by a set-primary 0.
|
|
454
|
+
name: "score-primary-then-clear",
|
|
455
|
+
args: {
|
|
456
|
+
approach: "tactical",
|
|
457
|
+
ops: [
|
|
458
|
+
{
|
|
459
|
+
kind: "score-primary",
|
|
460
|
+
cardId: "ground-control",
|
|
461
|
+
round: 2,
|
|
462
|
+
asserted: awardsOf(ds.missionCards.get("ground-control")).map((aw, index) => aw.vp_per != null ? { index, count: aw.per_max ?? 3 } : { index }),
|
|
463
|
+
roundCap: 15,
|
|
464
|
+
gameCap: 45,
|
|
465
|
+
},
|
|
466
|
+
{ kind: "set-primary", round: 3, vp: 99, roundCap: 15, gameCap: 45 },
|
|
467
|
+
{ kind: "set-primary", round: 2, vp: 0, roundCap: 15, gameCap: 45 },
|
|
468
|
+
],
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
name: "secondary-score-and-undo",
|
|
473
|
+
args: {
|
|
474
|
+
approach: "tactical",
|
|
475
|
+
ops: [
|
|
476
|
+
{ kind: "draw", cardId: "no-prisoners" },
|
|
477
|
+
{ kind: "score-secondary", cardId: "no-prisoners", round: 2, asserted: [{ index: 0, count: 3 }] },
|
|
478
|
+
{ kind: "remove-score", index: 0 },
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
// Uncapped set-primary (no caps) overshoots so the 100 grand-total cap bites.
|
|
484
|
+
name: "grand-total-cap-at-100",
|
|
485
|
+
args: {
|
|
486
|
+
approach: "tactical",
|
|
487
|
+
ops: [
|
|
488
|
+
{ kind: "set-primary", round: 1, vp: 30 },
|
|
489
|
+
{ kind: "set-primary", round: 2, vp: 30 },
|
|
490
|
+
{ kind: "set-primary", round: 3, vp: 30 },
|
|
491
|
+
{ kind: "set-primary", round: 4, vp: 30 },
|
|
492
|
+
{ kind: "set-primary", round: 5, vp: 30 },
|
|
493
|
+
{ kind: "draw", cardId: "no-prisoners" },
|
|
494
|
+
{ kind: "score-secondary", cardId: "no-prisoners", round: 5, asserted: [{ index: 0, count: 99 }] },
|
|
495
|
+
],
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
];
|
|
499
|
+
for (const s of stateScenarios) {
|
|
500
|
+
cases.push({ name: `score_state/${s.name}`, op: "score_state", args: s.args, expected: run("score_state", s.args) });
|
|
501
|
+
}
|
|
502
|
+
// wtc_result: band boundaries and symmetry.
|
|
503
|
+
const wtcPairs = [
|
|
504
|
+
[50, 50],
|
|
505
|
+
[48, 45],
|
|
506
|
+
[45, 50],
|
|
507
|
+
[56, 50],
|
|
508
|
+
[50, 61],
|
|
509
|
+
[100, 50],
|
|
510
|
+
[100, 49],
|
|
511
|
+
[0, 100],
|
|
512
|
+
[60, 40],
|
|
513
|
+
[55, 50],
|
|
514
|
+
];
|
|
515
|
+
for (const [a, b] of wtcPairs) {
|
|
516
|
+
const args = { a, b };
|
|
517
|
+
cases.push({ name: `wtc_result/${a}-${b}`, op: "wtc_result", args, expected: run("wtc_result", args) });
|
|
518
|
+
}
|
|
519
|
+
writeJson(join(CONFORMANCE, "scoring", "cases.json"), cases);
|
|
520
|
+
console.log(`scoring/cases.json: ${cases.length} cases`);
|
|
521
|
+
}
|
|
352
522
|
/**
|
|
353
523
|
* Terrain-resolver corpus: resolve template-anchored layouts to absolute
|
|
354
524
|
* board-space vertices (y-down inches). The TS resolver is the oracle; the Rust
|
|
@@ -510,5 +680,6 @@ genRosters();
|
|
|
510
680
|
genLinkedApi();
|
|
511
681
|
genAttribution();
|
|
512
682
|
genScoringTranslation();
|
|
683
|
+
genScoring();
|
|
513
684
|
genTerrainResolver();
|
|
514
685
|
//# sourceMappingURL=gen-conformance.js.map
|