@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
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSL stub authoring engine (#21) — batched, subscription-billed, two-phase.
|
|
3
|
+
*
|
|
4
|
+
* Empty-modifier ability stubs (`{type:"stat-modifier", modifier:{}}` and kin)
|
|
5
|
+
* are authored into real DSL from their 10e-archive source rule. The work is a
|
|
6
|
+
* pile of discrete, stateless, structured LLM calls, so we run them as batched
|
|
7
|
+
* `claude -p --json-schema` invocations on the Claude subscription rather than
|
|
8
|
+
* spawning a full agent per ability (the agent fan-out's per-call system-prompt
|
|
9
|
+
* + tool-loop overhead is ~50-100x the useful work). Assembly and schema
|
|
10
|
+
* validation are pure TS — the model only classifies and judges.
|
|
11
|
+
*
|
|
12
|
+
* author-input/<faction>.json (datasheet-resolved rules, from author-input.ts)
|
|
13
|
+
* ── classify ──▶ flat slot-forms (batched claude -p)
|
|
14
|
+
* ── assemble ──▶ full ability entries (TS: effect + scope, no LLM)
|
|
15
|
+
* ── validate ──▶ AJV against the schema (TS — rejects invented enums)
|
|
16
|
+
* ── verify ──▶ fidelity verdict (batched claude -p, scope-aware)
|
|
17
|
+
* ─────────────▶ data/_audit/proposed/<faction>.json
|
|
18
|
+
*
|
|
19
|
+
* Two modes:
|
|
20
|
+
* propose (default) — write proposals; never touch live data.
|
|
21
|
+
* apply — splice gated proposals into live abilities.json. Only
|
|
22
|
+
* rewrites entries that are STILL empty-modifier stubs,
|
|
23
|
+
* so re-running is safe and authored work is never
|
|
24
|
+
* clobbered. Gate defaults: schema-valid + verifier-
|
|
25
|
+
* faithful + confidence≠low + not complex-flagged.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* npx tsx tools/src/author-batch.ts propose <faction|--all> [--batch N] [--model M]
|
|
29
|
+
* npx tsx tools/src/author-batch.ts apply <faction|--all> [--min-confidence high|medium]
|
|
30
|
+
* [--include-complex] [--dry-run]
|
|
31
|
+
*/
|
|
32
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
|
|
33
|
+
import { resolve } from "node:path";
|
|
34
|
+
import { execFile } from "node:child_process";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import { createValidator } from "./schema-loader.js";
|
|
37
|
+
import { hasEmptyModifier } from "./audit-coverage.js";
|
|
38
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
39
|
+
const DATA_ROOT = resolve(__dirname, "../../data");
|
|
40
|
+
const INPUT_DIR = resolve(DATA_ROOT, "_audit", "author-input");
|
|
41
|
+
const ENRICHMENT_ROOT = resolve(DATA_ROOT, "enrichment");
|
|
42
|
+
const OUT_DIR = resolve(DATA_ROOT, "_audit", "proposed");
|
|
43
|
+
const ABILITY_SCHEMA_ID = "https://40kdc.dev/schemas/enrichment/ability-dsl/ability.schema.json";
|
|
44
|
+
const readJSON = (p) => JSON.parse(readFileSync(p, "utf-8"));
|
|
45
|
+
const writeJSON = (p, v) => writeFileSync(p, JSON.stringify(v, null, 2) + "\n");
|
|
46
|
+
const PARAMETERLESS = new Set(["deep-strike", "fallback-and-act", "fight-first", "fight-last", "shoot-on-death", "fight-on-death"]);
|
|
47
|
+
// ─── claude CLI bridge (subscription, structured output) ─────────────
|
|
48
|
+
/** One batched, structured `claude -p` call. Resolves to the validated object. */
|
|
49
|
+
export function callClaude(system, user, schema, model) {
|
|
50
|
+
return new Promise((res, rej) => {
|
|
51
|
+
execFile("claude", ["-p", user, "--system-prompt", system, "--exclude-dynamic-system-prompt-sections",
|
|
52
|
+
"--json-schema", JSON.stringify(schema), "--output-format", "json", "--model", model], { maxBuffer: 64 * 1024 * 1024, timeout: 300_000 }, (err, stdout) => {
|
|
53
|
+
if (err && !stdout)
|
|
54
|
+
return rej(err);
|
|
55
|
+
try {
|
|
56
|
+
const env = JSON.parse(stdout);
|
|
57
|
+
if (env.is_error)
|
|
58
|
+
return rej(new Error(env.result ?? "claude error"));
|
|
59
|
+
if (!env.structured_output)
|
|
60
|
+
return rej(new Error("no structured_output in response"));
|
|
61
|
+
res(env.structured_output);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
rej(new Error(`parse failed: ${e.message}; head=${String(stdout).slice(0, 200)}`));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// ─── prompts + schemas ───────────────────────────────────────────────
|
|
70
|
+
const CLASSIFY_SYSTEM = `You translate Warhammer 40k ability rules into a structured DSL. For each ability return one slot-form.\n\n` +
|
|
71
|
+
`effect_type — pick the SINGLE best of:\n` +
|
|
72
|
+
` stat-modifier {operation:"add"|"subtract"|"set", stat:"A"|"S"|"T"|"Sv"|"AP"|"OC"|"Ld", value:int}\n` +
|
|
73
|
+
` roll-modifier {operation:"add"|"subtract", roll:"hit"|"wound"|"save"|"charge", value:int}\n` +
|
|
74
|
+
` re-roll {roll:"hit"|"wound"|"save"|"damage"|"charge", subset:"ones"|"all-failures"} — ONLY combat dice, NOT Battle-shock/Leadership\n` +
|
|
75
|
+
` leadership-modifier {test:"battle-shock", operation:"re-roll"} or {operation:"add"|"subtract", value:int} — USE for Battle-shock/Leadership rerolls or Ld changes\n` +
|
|
76
|
+
` mortal-wounds {count:int|"D3"|"D6"} ; feel-no-pain {threshold:int} ; invulnerable-save {invuln_sv:int}\n` +
|
|
77
|
+
` keyword-grant {keywords:[ "lethal-hits"|"sustained-hits"|"devastating-wounds"|"twin-linked"|... ]} (ARRAY)\n` +
|
|
78
|
+
` damage-reduction {reduction:int} ; objective-control-modifier {operation,value}|{sticky:true}\n` +
|
|
79
|
+
` ability-grant {ability_id:"kebab"}|{grant_type:"..."} ; attack-restriction {restriction:"..."}\n` +
|
|
80
|
+
` cp-gain|cp-refund {amount:int} ; resurrection {count:int|"D3"} ; model-destruction {count:int}\n` +
|
|
81
|
+
` resource-gain|resource-spend {pool_id:"...", amount:int|"D3"} — faction resources: Miracle Dice→"miracle-dice-pool", Khorne Blessings→"blessings-of-khorne-pool", Pain tokens→"pain-token-pool"\n` +
|
|
82
|
+
` movement-modifier {move_type,value} ; deep-strike/fallback-and-act/fight-first/fight-last/shoot-on-death/fight-on-death → modifier {}\n\n` +
|
|
83
|
+
`attack_type — "melee"|"ranged" if the rule limits to that attack kind, else "any". (Do NOT encode this as a condition.)\n` +
|
|
84
|
+
`condition_kind — DEFAULT "none". Only set if the rule EXPLICITLY restricts: "phase" (+condition_param = phase name), "vs-keyword" (+param=keyword), ` +
|
|
85
|
+
`"charged", "stationary", "below-half", "below-starting", "attached", "leading". Do NOT add a phase condition just because the ability operates in a phase. ` +
|
|
86
|
+
`If the rule needs a compound/event trigger (e.g. a dice roll, an either/or choice, or "when a friendly VEHICLE is destroyed within 12\\"") set complex=true.\n` +
|
|
87
|
+
`scope_range — "self"|"unit"|"attached-unit"|"aura-6"|"aura-9"|"aura-12"|... . scope_duration — "phase"|"turn"|"battle-round"|"battle"|"permanent".\n` +
|
|
88
|
+
`target — "self"|"unit"|"friendly-within-aura"|"enemy-within-aura"|"attacker"|"defender"|... (only values from the schema enum).\n` +
|
|
89
|
+
`Never copy rule text into any field. Give confidence and a one-sentence reasoning.`;
|
|
90
|
+
export const VERIFY_SYSTEM = `You judge whether authored DSL faithfully captures a 40k rule. The DSL includes scope {range,duration} — credit the aura/range/duration when it is in scope (do NOT flag "missing 6\\" aura" if scope.range is "aura-6"). ` +
|
|
91
|
+
`Be strict about the core mechanic: wrong effect type, wrong stat/roll, wrong value, a condition the rule does NOT state (phantom), a stated condition that is missing, or modeling a Leadership/Battle-shock re-roll as a combat re-roll. ` +
|
|
92
|
+
`severity "ok" = core mechanic + conditions + scope correct; "minor" = core correct but a secondary detail imperfect; "wrong" = core mechanic wrong. Return one verdict per ability, echoing its ability_id.`;
|
|
93
|
+
const CLASSIFY_SCHEMA = {
|
|
94
|
+
type: "object", additionalProperties: false,
|
|
95
|
+
properties: { results: { type: "array", items: {
|
|
96
|
+
type: "object", additionalProperties: false,
|
|
97
|
+
properties: {
|
|
98
|
+
ability_id: { type: "string" }, effect_type: { type: "string" }, target: { type: "string" },
|
|
99
|
+
modifier: { type: "object", additionalProperties: true }, attack_type: { enum: ["any", "melee", "ranged"] },
|
|
100
|
+
condition_kind: { enum: ["none", "phase", "vs-keyword", "charged", "stationary", "below-half", "below-starting", "attached", "leading"] },
|
|
101
|
+
condition_param: { type: ["string", "null"] }, scope_range: { type: "string" }, scope_duration: { type: "string" },
|
|
102
|
+
complex: { type: "boolean" }, confidence: { enum: ["high", "medium", "low"] }, reasoning: { type: "string" },
|
|
103
|
+
},
|
|
104
|
+
required: ["ability_id", "effect_type", "target", "modifier", "attack_type", "condition_kind", "scope_range", "scope_duration", "complex", "confidence", "reasoning"],
|
|
105
|
+
} } },
|
|
106
|
+
required: ["results"],
|
|
107
|
+
};
|
|
108
|
+
export const VERIFY_SCHEMA = {
|
|
109
|
+
type: "object", additionalProperties: false,
|
|
110
|
+
properties: { results: { type: "array", items: {
|
|
111
|
+
type: "object", additionalProperties: false,
|
|
112
|
+
properties: { ability_id: { type: "string" }, severity: { enum: ["ok", "minor", "wrong"] }, faithful: { type: "boolean" }, issue: { type: "string" } },
|
|
113
|
+
required: ["ability_id", "severity", "faithful", "issue"],
|
|
114
|
+
} } },
|
|
115
|
+
required: ["results"],
|
|
116
|
+
};
|
|
117
|
+
// ─── full-tree repair prompts + schema ───────────────────────────────
|
|
118
|
+
//
|
|
119
|
+
// The flat-form classifier (above) emits a single condition + flat leaf, so it
|
|
120
|
+
// structurally CANNOT express compound conditions, event triggers, or nested
|
|
121
|
+
// effect kinds — every such rule lands in the proposed/ residue with a verifier
|
|
122
|
+
// `issue` naming the gap. The repair pass hands the model the FULL DSL grammar
|
|
123
|
+
// and asks it to emit the complete nested effect tree, seeded with the existing
|
|
124
|
+
// draft + that exact gap. The envelope schema below is intentionally loose (just
|
|
125
|
+
// `effect`/`scope` objects); the real gate is AJV against ability.schema, exactly
|
|
126
|
+
// as the flat-form path validates `buildEntry` output.
|
|
127
|
+
export const REPAIR_SYSTEM = `You repair Warhammer 40k ability DSL. You are given a rule, a DRAFT effect that an earlier flat-form pass produced, and the EXACT gap a verifier found (usually a missing trigger or compound condition). Emit the COMPLETE nested effect tree that fixes the gap. Never copy rule text into any field.\n\n` +
|
|
128
|
+
`An effect node is ONE of:\n` +
|
|
129
|
+
` • a leaf: {type, target, modifier} — type ∈ [stat-modifier, roll-modifier, re-roll, mortal-wounds, feel-no-pain, invulnerable-save, ward, keyword-grant, movement-modifier, deep-strike, fallback-and-act, fight-first, fight-last, shoot-on-death, fight-on-death, objective-control-modifier, leadership-modifier, damage-reduction, attack-restriction, ability-grant, cp-gain, cp-refund, model-destruction, resurrection, resource-gain, resource-spend, charge-roll-modifier, terrain-area-tag, bs-modifier, engagement-passthrough]; target ∈ [self, bearer, unit, attached-unit, attacker, defender, friendly-within-aura, enemy-within-aura, all-friendly, all-enemy]\n` +
|
|
130
|
+
` • conditional: {type:"conditional", condition, effect}\n` +
|
|
131
|
+
` • sequence: {type:"sequence", steps:[effect, ...]} — multiple effects that all apply\n` +
|
|
132
|
+
` • choice: {type:"choice", options:[effect, ...], choice_label?} — pick exactly one\n` +
|
|
133
|
+
` • dice-gated: {type:"dice-gated", dice:"D6"|..., threshold:int, comparison?:"greater-or-equal"|..., on_success:effect, on_fail?:effect}\n` +
|
|
134
|
+
` • dice-pool-allocation: {type:"dice-pool-allocation", pool:{count,die}, max_activations:int, options:[{name, requirement, effect}, ...]}\n\n` +
|
|
135
|
+
`A condition is ONE of:\n` +
|
|
136
|
+
` • simple: {type, parameters:{...}} — ALL params go UNDER "parameters", never as top-level keys (e.g. {"type":"unit-has-keyword","parameters":{"keyword":"VEHICLE"}}, NOT {"type":"unit-has-keyword","keyword":"VEHICLE"}). type ∈ [phase-is{phase}, timing-is{timing}, player-turn-is{turn}, unit-below-starting-strength, unit-below-half-strength, unit-has-keyword{keyword}, unit-within-range-of{target_type}, model-is-leader, target-has-keyword{keyword}, charged-this-turn, advanced-this-turn, remained-stationary, is-battle-shocked, has-lost-wounds, opponent-unit-within-range, within-range-of-objective, attack-is-type{attack_type}, has-fought-this-phase, destroyed-by-attack-type{attack_type}, controls-objective, is-attached, terrain-area-control, engagement-state, territory-control, fights-first, disposition-matches, units-destroyed{side,window,count_min}, units-destroyed-comparison, objective-majority]\n` +
|
|
137
|
+
` • compound: {operator:"and"|"or"|"not", operands:[condition, ...]} — use "not" with ONE operand to negate (e.g. "while not Battle-shocked" → {operator:"not", operands:[{type:"is-battle-shocked"}]}). Nest compounds freely.\n\n` +
|
|
138
|
+
`Encode reactive/event triggers as a conditional whose condition is the trigger (e.g. an enemy destroyed a model nearby → destroyed-by-attack-type / opponent-unit-within-range). Encode "first time per turn"/"once per game" by choosing the correct timing condition; do not invent fields.\n` +
|
|
139
|
+
`scope = {range, duration}: range ∈ [self, unit, attached-unit, aura-6, aura-9, aura-12, ...]; duration ∈ [phase, turn, battle-round, battle, permanent]. Credit the aura in scope.range, NOT as a condition.\n` +
|
|
140
|
+
`behavior ∈ [passive, activated, reactive, aura].\n\n` +
|
|
141
|
+
`CANONICAL MODIFIER KEYS — use ONLY the keys listed per type; never invent a key (an unknown key is silently ignored by consumers and corrupts the data):\n` +
|
|
142
|
+
` stat-modifier.modifier: {stat, operation:"add"|"subtract"|"set", value:int}. stat ∈ [A,S,T,Sv,AP,OC,Ld,M,W,D] ONLY (use "M" for Move, never "Move"/"range"; weapon range is NOT a unit stat). operation:"set" IS allowed for "characteristic of N" rules (e.g. OC of 9). Optional narrowing: attack_type:"melee"|"ranged", weapon_type:"melee"|"ranged", or weapon_name:"<weapon>" for a single named weapon. Do NOT use weapon_keyword/weapon_filter/model_filter.\n` +
|
|
143
|
+
` roll-modifier.modifier: {roll:"hit"|"wound"|"save"|"charge"|"damage", operation, value}. re-roll.modifier: {roll, subset:"ones"|"all-failures"}. Optional attack_type/weapon_type/weapon_name as above.\n` +
|
|
144
|
+
` keyword-grant.modifier: {keywords:[...]} (array) — combat keywords as written ("Lethal Hits","Sustained Hits 1","Twin-linked"). Optional weapon_type:"melee"|"ranged", weapon_name.\n` +
|
|
145
|
+
` feel-no-pain.modifier:{threshold:int}; damage-reduction.modifier:{reduction:int}; bs-modifier.modifier:{operation,value}; ability-grant.modifier:{grant_type:"kebab-label"}; objective-control-modifier.modifier:{operation:"add"|"set",value} or {sticky:true}; movement-modifier.modifier:{move_type:"kebab",value}; deep-strike.modifier:{} (parameterless).\n\n` +
|
|
146
|
+
`dice-gated.comparison ∈ ["gte","lte","gt","lt","eq"] (use "gte" for "on a 2+"). dice e.g. "D6","2D6"; threshold int. on_success/on_fail are effect nodes.\n` +
|
|
147
|
+
`ENCODING THE RESIDUE — these ARE expressible, do not punt on them:\n` +
|
|
148
|
+
` • "roll a D6, on 2+ <effect>" → dice-gated {dice:"D6", threshold:2, comparison:"gte", on_success:<effect>}.\n` +
|
|
149
|
+
` • "select one of N abilities/effects" → choice {options:[<effect>,...]}.\n` +
|
|
150
|
+
` • "re-roll Battle-shock/Leadership tests" → leadership-modifier {test:"battle-shock", operation:"re-roll"} (NOT a combat re-roll).\n` +
|
|
151
|
+
` • deployment/redeploy ("set up in Strategic Reserves", "set up anywhere >9\\"", "redeploy after deployment") → deep-strike, or ability-grant {grant_type:"<descriptive-kebab>"} for a named deployment rule.\n` +
|
|
152
|
+
` • "move through terrain" → movement-modifier {move_type:"through-terrain"}.\n` +
|
|
153
|
+
` • "characteristic of N" → the matching stat/OC modifier with operation:"set".\n\n` +
|
|
154
|
+
`Set unencodable:true ONLY when the rule is NOT an in-battle ability effect at all — army-construction ("you can include one X per Y", "cannot include A with B"), roster selection ("cannot be your Warlord"), model geometry/transport-capacity declarations, or roll-off/meta procedures. For those, return your best partial effect and explain. Everything that IS an in-battle effect must be encoded with the grammar above. Give confidence + one-sentence reasoning.`;
|
|
155
|
+
export const REPAIR_SCHEMA = {
|
|
156
|
+
type: "object", additionalProperties: false,
|
|
157
|
+
properties: { results: { type: "array", items: {
|
|
158
|
+
type: "object", additionalProperties: false,
|
|
159
|
+
properties: {
|
|
160
|
+
ability_id: { type: "string" },
|
|
161
|
+
effect: { type: "object", additionalProperties: true },
|
|
162
|
+
scope: { type: "object", additionalProperties: true },
|
|
163
|
+
behavior: { type: "string" },
|
|
164
|
+
unencodable: { type: "boolean" },
|
|
165
|
+
confidence: { enum: ["high", "medium", "low"] },
|
|
166
|
+
reasoning: { type: "string" },
|
|
167
|
+
},
|
|
168
|
+
required: ["ability_id", "effect", "scope", "behavior", "unencodable", "confidence", "reasoning"],
|
|
169
|
+
} } },
|
|
170
|
+
required: ["results"],
|
|
171
|
+
};
|
|
172
|
+
// ─── assembly (pure TS, no LLM) ──────────────────────────────────────
|
|
173
|
+
export function conditionNode(kind, param) {
|
|
174
|
+
switch (kind) {
|
|
175
|
+
case "phase": return { type: "phase-is", parameters: { phase: param } };
|
|
176
|
+
case "vs-keyword": return { type: "target-has-keyword", parameters: { keyword: param } };
|
|
177
|
+
case "charged": return { type: "charged-this-turn" };
|
|
178
|
+
case "stationary": return { type: "remained-stationary" };
|
|
179
|
+
case "below-half": return { type: "unit-below-half-strength" };
|
|
180
|
+
case "below-starting": return { type: "unit-below-starting-strength" };
|
|
181
|
+
case "attached": return { type: "is-attached" };
|
|
182
|
+
case "leading": return { type: "model-is-leader" };
|
|
183
|
+
default: return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Build the effect node + scope from a flat-form. */
|
|
187
|
+
export function assembleEffect(form) {
|
|
188
|
+
const modifier = PARAMETERLESS.has(form.effect_type) ? {} : { ...(form.modifier ?? {}) };
|
|
189
|
+
if (form.attack_type && form.attack_type !== "any" && ["stat-modifier", "roll-modifier", "re-roll"].includes(form.effect_type)) {
|
|
190
|
+
modifier.attack_type = form.attack_type;
|
|
191
|
+
}
|
|
192
|
+
let effect = { type: form.effect_type, target: form.target, modifier };
|
|
193
|
+
const cond = conditionNode(form.condition_kind, form.condition_param);
|
|
194
|
+
if (cond)
|
|
195
|
+
effect = { type: "conditional", condition: cond, effect };
|
|
196
|
+
return { effect, scope: { range: form.scope_range, duration: form.scope_duration } };
|
|
197
|
+
}
|
|
198
|
+
/** Splice the authored effect+scope onto the original entry, preserving metadata. */
|
|
199
|
+
export function buildEntry(original, form) {
|
|
200
|
+
const { effect, scope } = assembleEffect(form);
|
|
201
|
+
return { ...original, effect, scope, community_notes: "community-authored from 10e source (provisional 11e); see #21" };
|
|
202
|
+
}
|
|
203
|
+
const BEHAVIOR_VALUES = new Set(["passive", "activated", "reactive", "aura"]);
|
|
204
|
+
// ─── canonical-key lint ──────────────────────────────────────────────
|
|
205
|
+
//
|
|
206
|
+
// The full-tree repair model emits the whole effect node, including the open
|
|
207
|
+
// `modifier` object. AJV permits any modifier key (additionalProperties:true),
|
|
208
|
+
// so an invented key (`weapon_keyword`, `model_filter`, `critical_threshold`)
|
|
209
|
+
// passes schema validation — but the cruncher reads ONLY the canonical keys, so
|
|
210
|
+
// an ignored filter on an `add` operation silently OVER-APPLIES the buff. The
|
|
211
|
+
// verifier can't catch this: it judges the JSON against the rule as a reader,
|
|
212
|
+
// not against what the engine honors. This lint is the deterministic gate.
|
|
213
|
+
//
|
|
214
|
+
// Vocabulary is calibrated to what EXISTING enrichment data actually uses (not
|
|
215
|
+
// world-eaters alone): `keywords` array is the dominant keyword-grant form,
|
|
216
|
+
// `damage-reduction` uses `reduction`, and `stat` spans the full statline. The
|
|
217
|
+
// lint only runs on NEW repair proposals, so strictness can't regress shipped
|
|
218
|
+
// data — a rejected proposal just stays residue for hand-authoring.
|
|
219
|
+
/** Modifier keys the cruncher / canonical conventions recognise, per leaf type. */
|
|
220
|
+
const CANONICAL_MODIFIER_KEYS = {
|
|
221
|
+
// weapon_type/weapon_name are valid narrowing keys (gold uses weapon_name): the
|
|
222
|
+
// cruncher honors weapon_type as a phase gate and fail-safes (unsupported) on
|
|
223
|
+
// weapon_name, so the data can carry them without risking a silent over-apply.
|
|
224
|
+
"stat-modifier": new Set(["stat", "operation", "value", "attack_type", "weapon_type", "weapon_name"]),
|
|
225
|
+
"roll-modifier": new Set(["roll", "operation", "value", "attack_type", "weapon_type", "weapon_name", "critical_on", "uses", "context"]),
|
|
226
|
+
"re-roll": new Set(["roll", "subset", "attack_type", "weapon_type", "weapon_name", "max_rerolls", "uses", "context"]),
|
|
227
|
+
"keyword-grant": new Set(["keyword", "keywords", "weapon_type", "weapon_name"]),
|
|
228
|
+
"bs-modifier": new Set(["operation", "value", "attack_type"]),
|
|
229
|
+
"feel-no-pain": new Set(["threshold"]),
|
|
230
|
+
"damage-reduction": new Set(["reduction", "amount"]),
|
|
231
|
+
};
|
|
232
|
+
const CANONICAL_STATS = new Set(["A", "S", "T", "Sv", "AP", "OC", "Ld", "M", "W", "D", "Damage", "BS", "WS"]);
|
|
233
|
+
const CANONICAL_ROLLS = new Set(["hit", "wound", "save", "charge", "damage", "advance", "any", "all"]);
|
|
234
|
+
const CANONICAL_SUBSETS = new Set(["ones", "all-failures"]);
|
|
235
|
+
const CANONICAL_ATTACK_TYPES = new Set(["melee", "ranged"]);
|
|
236
|
+
/**
|
|
237
|
+
* Walk an effect tree and flag any cruncher-interpreted leaf whose modifier
|
|
238
|
+
* carries an unknown key or an out-of-vocabulary stat/roll/subset/attack_type.
|
|
239
|
+
* Non-interpreted leaf types (ability-grant, movement-modifier, …) are left
|
|
240
|
+
* permissive — they don't reach the damage path, so an unknown key there is a
|
|
241
|
+
* consistency nit, not a silent-corruption risk.
|
|
242
|
+
*/
|
|
243
|
+
export function lintCanonical(effect) {
|
|
244
|
+
const issues = [];
|
|
245
|
+
// A simple condition is {type, parameters?, negated?}; every param lives UNDER
|
|
246
|
+
// `parameters`. The cruncher reads condition.parameters.* only, and AJV doesn't
|
|
247
|
+
// forbid stray top-level keys, so a param placed top-level (e.g.
|
|
248
|
+
// {type:"unit-has-keyword", keyword:"X"}) silently makes the condition
|
|
249
|
+
// unevaluatable — the buff never fires. Existing data is 680 nested / 0 top-level.
|
|
250
|
+
const visitCondition = (c) => {
|
|
251
|
+
if (!c || typeof c !== "object")
|
|
252
|
+
return;
|
|
253
|
+
if (Array.isArray(c.operands))
|
|
254
|
+
return c.operands.forEach(visitCondition); // compound {operator, operands}
|
|
255
|
+
if (typeof c.type === "string") {
|
|
256
|
+
for (const k of Object.keys(c))
|
|
257
|
+
if (k !== "type" && k !== "parameters" && k !== "negated")
|
|
258
|
+
issues.push(`condition ${c.type}: param "${k}" must live under "parameters"`);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const visit = (node) => {
|
|
262
|
+
if (Array.isArray(node))
|
|
263
|
+
return node.forEach(visit);
|
|
264
|
+
if (!node || typeof node !== "object")
|
|
265
|
+
return;
|
|
266
|
+
const type = node.type;
|
|
267
|
+
const allow = type ? CANONICAL_MODIFIER_KEYS[type] : undefined;
|
|
268
|
+
if (allow && node.modifier && typeof node.modifier === "object") {
|
|
269
|
+
const m = node.modifier;
|
|
270
|
+
for (const k of Object.keys(m))
|
|
271
|
+
if (!allow.has(k))
|
|
272
|
+
issues.push(`${type}: non-canonical modifier key "${k}"`);
|
|
273
|
+
if (type === "stat-modifier" && m.stat != null && !CANONICAL_STATS.has(String(m.stat)))
|
|
274
|
+
issues.push(`stat-modifier: unknown stat "${String(m.stat)}"`);
|
|
275
|
+
if ((type === "roll-modifier" || type === "re-roll") && m.roll != null && !CANONICAL_ROLLS.has(String(m.roll)))
|
|
276
|
+
issues.push(`${type}: unknown roll "${String(m.roll)}"`);
|
|
277
|
+
if (m.subset != null && !CANONICAL_SUBSETS.has(String(m.subset)))
|
|
278
|
+
issues.push(`${type}: unknown subset "${String(m.subset)}"`);
|
|
279
|
+
if (m.attack_type != null && !CANONICAL_ATTACK_TYPES.has(String(m.attack_type)))
|
|
280
|
+
issues.push(`${type}: unknown attack_type "${String(m.attack_type)}"`);
|
|
281
|
+
}
|
|
282
|
+
if (node.condition)
|
|
283
|
+
visitCondition(node.condition);
|
|
284
|
+
// Recurse through the wrapper kinds (conditional/sequence/choice/dice-*).
|
|
285
|
+
for (const key of ["effect", "steps", "options", "on_success", "on_fail"])
|
|
286
|
+
if (node[key])
|
|
287
|
+
visit(node[key]);
|
|
288
|
+
};
|
|
289
|
+
visit(effect);
|
|
290
|
+
return { canonical: issues.length === 0, issues };
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Splice a pre-formed nested effect tree (from the repair pass) onto the original
|
|
294
|
+
* entry. Unlike {@link buildEntry} the LLM owns the whole tree, so we only graft
|
|
295
|
+
* `effect`/`scope`/`behavior` and the citation — never the flat-form assembly.
|
|
296
|
+
* `behavior` is an ability-level field; only set it when the model returned a
|
|
297
|
+
* valid enum value (an invalid one would just fail AJV and lose the whole entry).
|
|
298
|
+
*/
|
|
299
|
+
export function buildRepairedEntry(original, effect, scope, behavior) {
|
|
300
|
+
const entry = { ...original, effect, scope, community_notes: "community-authored from 10e source (provisional 11e); see #21" };
|
|
301
|
+
if (behavior && BEHAVIOR_VALUES.has(behavior))
|
|
302
|
+
entry.behavior = behavior;
|
|
303
|
+
return entry;
|
|
304
|
+
}
|
|
305
|
+
/** Whether a proposal is safe to apply automatically. */
|
|
306
|
+
export function passesGate(p, opts) {
|
|
307
|
+
if (!p.schema_valid || !p.final_faithful)
|
|
308
|
+
return false;
|
|
309
|
+
if (p.confidence === "low")
|
|
310
|
+
return false;
|
|
311
|
+
if (opts.minConfidence === "high" && p.confidence !== "high")
|
|
312
|
+
return false;
|
|
313
|
+
// A repaired proposal IS the full nested tree, so `complex` no longer means
|
|
314
|
+
// "couldn't express it" — the AJV + verifier gate already proved it expresses
|
|
315
|
+
// the rule faithfully. It must also clear the canonical-key lint so an invented
|
|
316
|
+
// modifier key can't silently over-apply. The complex-exclusion only applies to
|
|
317
|
+
// flat-form output.
|
|
318
|
+
if (p.repaired)
|
|
319
|
+
return p.canonical !== false;
|
|
320
|
+
if (p.complex && !opts.includeComplex)
|
|
321
|
+
return false;
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
// ─── batching helpers ────────────────────────────────────────────────
|
|
325
|
+
const chunk = (arr, n) => {
|
|
326
|
+
const out = [];
|
|
327
|
+
for (let i = 0; i < arr.length; i += n)
|
|
328
|
+
out.push(arr.slice(i, i + n));
|
|
329
|
+
return out;
|
|
330
|
+
};
|
|
331
|
+
const classifyUserPrompt = (items) => `Classify each ability below. Return results[] (one per ability, echo its ability_id):\n\n` +
|
|
332
|
+
items.map((it) => `- ability_id: ${it.ability_id}\n name: ${it.name}\n rule: ${it.src?.description ?? "(none)"}`).join("\n");
|
|
333
|
+
export const verifyUserPrompt = (entries) => `Judge each authored DSL against its rule. Return results[] (one per ability, echo its ability_id):\n\n` +
|
|
334
|
+
entries.map((e) => `- ability_id: ${e.ability_id}\n rule: ${e.rule}\n authored: ${JSON.stringify({ effect: e.effect, scope: e.scope })}`).join("\n");
|
|
335
|
+
export const repairUserPrompt = (items) => `Repair each ability's DSL. Emit the full nested effect tree + scope + behavior that fixes the stated gap. Return results[] (one per ability, echo its ability_id):\n\n` +
|
|
336
|
+
items.map((it) => `- ability_id: ${it.ability_id}\n rule: ${it.rule || "(none)"}\n draft_effect: ${JSON.stringify(it.draft ?? null)}\n gap_to_fix: ${it.issue || "(verifier produced no issue — re-author faithfully from the rule)"}`).join("\n\n");
|
|
337
|
+
async function proposeFaction(faction, opts, validate) {
|
|
338
|
+
const inputPath = resolve(INPUT_DIR, `${faction}.json`);
|
|
339
|
+
if (!existsSync(inputPath))
|
|
340
|
+
return { faction, skipped: "no author-input" };
|
|
341
|
+
const input = readJSON(inputPath).filter((e) => e.resolved);
|
|
342
|
+
if (input.length === 0)
|
|
343
|
+
return { faction, skipped: "no resolved stubs" };
|
|
344
|
+
const original = new Map();
|
|
345
|
+
for (const a of readJSON(resolve(ENRICHMENT_ROOT, faction, "abilities.json")))
|
|
346
|
+
original.set(a.ability_id, a);
|
|
347
|
+
const proposals = [];
|
|
348
|
+
for (const batch of chunk(input, opts.batch)) {
|
|
349
|
+
let forms;
|
|
350
|
+
try {
|
|
351
|
+
({ results: forms } = await callClaude(CLASSIFY_SYSTEM, classifyUserPrompt(batch), CLASSIFY_SCHEMA, opts.model));
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
// One flaky call shouldn't sink the run — record the batch as errored and move on.
|
|
355
|
+
process.stderr.write(` ${faction}: classify batch failed (${e.message.slice(0, 80)}) — skipping ${batch.length}\n`);
|
|
356
|
+
for (const it of batch)
|
|
357
|
+
proposals.push({ ability_id: it.ability_id, name: it.name, faction, schema_valid: false, final_faithful: false, error: "classify call failed" });
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const byId = new Map(forms.map((f) => [f.ability_id, f]));
|
|
361
|
+
const built = [];
|
|
362
|
+
for (const it of batch) {
|
|
363
|
+
const form = byId.get(it.ability_id);
|
|
364
|
+
const orig = original.get(it.ability_id);
|
|
365
|
+
if (!form || !orig) {
|
|
366
|
+
proposals.push({ ability_id: it.ability_id, name: it.name, faction, schema_valid: false, final_faithful: false, error: !form ? "no classification" : "no original entry" });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const entry = buildEntry(orig, form);
|
|
370
|
+
built.push({ it, form, entry, schemaValid: validate(entry) });
|
|
371
|
+
}
|
|
372
|
+
const toVerify = built.filter((b) => b.schemaValid);
|
|
373
|
+
const verdicts = new Map();
|
|
374
|
+
if (toVerify.length > 0) {
|
|
375
|
+
try {
|
|
376
|
+
const { results } = await callClaude(VERIFY_SYSTEM, verifyUserPrompt(toVerify.map((b) => ({ ability_id: b.it.ability_id, rule: b.it.src?.description ?? "", effect: b.entry.effect, scope: b.entry.scope }))), VERIFY_SCHEMA, opts.model);
|
|
377
|
+
for (const v of results)
|
|
378
|
+
verdicts.set(v.ability_id, v);
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
// Verify failure → leave verdicts null (proposal kept, just not auto-gateable).
|
|
382
|
+
process.stderr.write(` ${faction}: verify batch failed (${e.message.slice(0, 80)})\n`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
for (const b of built) {
|
|
386
|
+
const verdict = verdicts.get(b.it.ability_id) ?? null;
|
|
387
|
+
proposals.push({
|
|
388
|
+
ability_id: b.it.ability_id, name: b.it.name, faction,
|
|
389
|
+
effect_type: b.form.effect_type, complex: b.form.complex, confidence: b.form.confidence,
|
|
390
|
+
schema_valid: b.schemaValid, proposed_effect: b.entry.effect, proposed_scope: b.entry.scope,
|
|
391
|
+
verdict, final_faithful: !!verdict?.faithful && b.schemaValid,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
process.stderr.write(` ${faction}: ${proposals.length}/${input.length}\n`);
|
|
395
|
+
}
|
|
396
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
397
|
+
writeJSON(resolve(OUT_DIR, `${faction}.json`), proposals);
|
|
398
|
+
return {
|
|
399
|
+
faction, total: proposals.length,
|
|
400
|
+
schema_valid: proposals.filter((p) => p.schema_valid).length,
|
|
401
|
+
faithful: proposals.filter((p) => p.final_faithful).length,
|
|
402
|
+
gateable: proposals.filter((p) => passesGate(p, { minConfidence: "medium", includeComplex: false })).length,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Re-author the complex residue in proposed/<faction>.json as full nested DSL.
|
|
407
|
+
* Reuses the propose pipeline shape — classify(→repair)/assemble/AJV/verify —
|
|
408
|
+
* but the model emits the whole effect tree and we seed it with the existing
|
|
409
|
+
* draft + the verifier's gap. Updates the proposals in place (matched by
|
|
410
|
+
* ability_id), tagging each `repaired:true`; the already-gateable and the errored
|
|
411
|
+
* entries are left untouched.
|
|
412
|
+
*/
|
|
413
|
+
async function repairFaction(faction, opts, validate) {
|
|
414
|
+
const proposalsPath = resolve(OUT_DIR, `${faction}.json`);
|
|
415
|
+
if (!existsSync(proposalsPath))
|
|
416
|
+
return { faction, skipped: "no proposals — run propose first" };
|
|
417
|
+
const proposals = readJSON(proposalsPath);
|
|
418
|
+
const gate = { minConfidence: "medium", includeComplex: false };
|
|
419
|
+
// Residue = not already auto-appliable, not a hard error. Phase A narrows to
|
|
420
|
+
// the cruncher-relevant leaf types via --types.
|
|
421
|
+
const targets = proposals
|
|
422
|
+
.map((p, idx) => ({ p, idx }))
|
|
423
|
+
.filter(({ p }) => !passesGate(p, gate) && !p.error)
|
|
424
|
+
.filter(({ p }) => !opts.types || (p.effect_type != null && opts.types.has(p.effect_type)));
|
|
425
|
+
if (targets.length === 0)
|
|
426
|
+
return { faction, skipped: "no matching residue" };
|
|
427
|
+
// Source rules (the gap the draft must close) + originals (metadata + AJV).
|
|
428
|
+
const inputPath = resolve(INPUT_DIR, `${faction}.json`);
|
|
429
|
+
const srcById = new Map();
|
|
430
|
+
if (existsSync(inputPath))
|
|
431
|
+
for (const e of readJSON(inputPath))
|
|
432
|
+
if (e.src?.description)
|
|
433
|
+
srcById.set(e.ability_id, e.src.description);
|
|
434
|
+
const original = new Map();
|
|
435
|
+
for (const a of readJSON(resolve(ENRICHMENT_ROOT, faction, "abilities.json")))
|
|
436
|
+
original.set(a.ability_id, a);
|
|
437
|
+
let done = 0;
|
|
438
|
+
for (const batch of chunk(targets, opts.batch)) {
|
|
439
|
+
let results;
|
|
440
|
+
try {
|
|
441
|
+
({ results } = await callClaude(REPAIR_SYSTEM, repairUserPrompt(batch.map(({ p }) => ({ ability_id: p.ability_id, rule: srcById.get(p.ability_id) ?? "", draft: p.proposed_effect, issue: p.verdict?.issue ?? "" }))), REPAIR_SCHEMA, opts.model));
|
|
442
|
+
}
|
|
443
|
+
catch (e) {
|
|
444
|
+
process.stderr.write(` ${faction}: repair batch failed (${e.message.slice(0, 80)}) — skipping ${batch.length}\n`);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const byId = new Map(results.map((r) => [r.ability_id, r]));
|
|
448
|
+
const built = [];
|
|
449
|
+
for (const { p, idx } of batch) {
|
|
450
|
+
const r = byId.get(p.ability_id);
|
|
451
|
+
const orig = original.get(p.ability_id);
|
|
452
|
+
if (!r || !orig)
|
|
453
|
+
continue; // model dropped it / live entry gone — leave the proposal as-is
|
|
454
|
+
const entry = buildRepairedEntry(orig, r.effect, r.scope, r.behavior);
|
|
455
|
+
built.push({ idx, p, r, entry, schemaValid: validate(entry), canonical: lintCanonical(r.effect).canonical });
|
|
456
|
+
}
|
|
457
|
+
// Verify only what can still pass the gate — a non-canonical entry can't, so
|
|
458
|
+
// don't spend a verify call on it.
|
|
459
|
+
const toVerify = built.filter((b) => b.schemaValid && b.canonical);
|
|
460
|
+
const verdicts = new Map();
|
|
461
|
+
if (toVerify.length > 0) {
|
|
462
|
+
try {
|
|
463
|
+
const { results: vs } = await callClaude(VERIFY_SYSTEM, verifyUserPrompt(toVerify.map((b) => ({ ability_id: b.p.ability_id, rule: srcById.get(b.p.ability_id) ?? "", effect: b.entry.effect, scope: b.entry.scope }))), VERIFY_SCHEMA, opts.model);
|
|
464
|
+
for (const v of vs)
|
|
465
|
+
verdicts.set(v.ability_id, v);
|
|
466
|
+
}
|
|
467
|
+
catch (e) {
|
|
468
|
+
process.stderr.write(` ${faction}: repair-verify batch failed (${e.message.slice(0, 80)})\n`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const b of built) {
|
|
472
|
+
const verdict = verdicts.get(b.p.ability_id) ?? null;
|
|
473
|
+
const behavior = b.r.behavior && BEHAVIOR_VALUES.has(b.r.behavior) ? b.r.behavior : undefined;
|
|
474
|
+
proposals[b.idx] = {
|
|
475
|
+
...b.p,
|
|
476
|
+
confidence: b.r.confidence ?? b.p.confidence,
|
|
477
|
+
schema_valid: b.schemaValid,
|
|
478
|
+
canonical: b.canonical,
|
|
479
|
+
proposed_effect: b.entry.effect,
|
|
480
|
+
proposed_scope: b.entry.scope,
|
|
481
|
+
proposed_behavior: behavior,
|
|
482
|
+
verdict,
|
|
483
|
+
final_faithful: !!verdict?.faithful && b.schemaValid && b.canonical,
|
|
484
|
+
repaired: true,
|
|
485
|
+
unencodable: !!b.r.unencodable,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
done += batch.length;
|
|
489
|
+
process.stderr.write(` ${faction}: repaired ${done}/${targets.length}\n`);
|
|
490
|
+
}
|
|
491
|
+
writeJSON(proposalsPath, proposals);
|
|
492
|
+
const repaired = proposals.filter((p) => p.repaired);
|
|
493
|
+
return {
|
|
494
|
+
faction, attempted: targets.length,
|
|
495
|
+
now_faithful: repaired.filter((p) => p.final_faithful).length,
|
|
496
|
+
non_canonical: repaired.filter((p) => p.canonical === false).length,
|
|
497
|
+
unencodable: repaired.filter((p) => p.unencodable).length,
|
|
498
|
+
gateable: proposals.filter((p) => passesGate(p, gate)).length,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/** Splice gated proposals into the live abilities.json — only over surviving stubs. */
|
|
502
|
+
function applyFaction(faction, opts) {
|
|
503
|
+
const proposalsPath = resolve(OUT_DIR, `${faction}.json`);
|
|
504
|
+
if (!existsSync(proposalsPath))
|
|
505
|
+
return { faction, skipped: "no proposals — run propose first" };
|
|
506
|
+
const proposals = readJSON(proposalsPath);
|
|
507
|
+
const abilitiesPath = resolve(ENRICHMENT_ROOT, faction, "abilities.json");
|
|
508
|
+
if (!existsSync(abilitiesPath))
|
|
509
|
+
return { faction, skipped: "no live abilities.json" };
|
|
510
|
+
const abilities = readJSON(abilitiesPath);
|
|
511
|
+
const byId = new Map(abilities.map((a) => [a.ability_id, a]));
|
|
512
|
+
let applied = 0;
|
|
513
|
+
const skipped = [];
|
|
514
|
+
for (const p of proposals) {
|
|
515
|
+
if (!passesGate(p, opts)) {
|
|
516
|
+
skipped.push({ id: p.ability_id, why: "gate" });
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const entry = byId.get(p.ability_id);
|
|
520
|
+
if (!entry) {
|
|
521
|
+
skipped.push({ id: p.ability_id, why: "gone" });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// Never clobber work that's no longer a stub (idempotent + safe to re-run).
|
|
525
|
+
if (!hasEmptyModifier(entry.effect)) {
|
|
526
|
+
skipped.push({ id: p.ability_id, why: "not-a-stub" });
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
entry.effect = p.proposed_effect;
|
|
530
|
+
entry.scope = p.proposed_scope;
|
|
531
|
+
if (p.proposed_behavior && BEHAVIOR_VALUES.has(p.proposed_behavior))
|
|
532
|
+
entry.behavior = p.proposed_behavior;
|
|
533
|
+
entry.community_notes = "community-authored from 10e source (provisional 11e); see #21";
|
|
534
|
+
applied++;
|
|
535
|
+
}
|
|
536
|
+
if (!opts.dryRun && applied > 0)
|
|
537
|
+
writeJSON(abilitiesPath, abilities);
|
|
538
|
+
return { faction, applied, skipped_gate: skipped.filter((s) => s.why === "gate").length, skipped_other: skipped.filter((s) => s.why !== "gate").length, dry_run: opts.dryRun };
|
|
539
|
+
}
|
|
540
|
+
// ─── review (cluster proposals into shape-families) ──────────────────
|
|
541
|
+
/** Load every faction's proposals (skips the ad-hoc damage-batch scratch file). */
|
|
542
|
+
function loadAllProposals() {
|
|
543
|
+
if (!existsSync(OUT_DIR))
|
|
544
|
+
return [];
|
|
545
|
+
return readdirSync(OUT_DIR)
|
|
546
|
+
.filter((f) => f.endsWith(".json") && f !== "damage-batch.json")
|
|
547
|
+
.flatMap((f) => readJSON(resolve(OUT_DIR, f)));
|
|
548
|
+
}
|
|
549
|
+
/** Write a shape-family clustered REVIEW.md — gateable vs the complex residue, grouped for templating. */
|
|
550
|
+
function review() {
|
|
551
|
+
const all = loadAllProposals();
|
|
552
|
+
const gate = { minConfidence: "medium", includeComplex: false };
|
|
553
|
+
const gateable = all.filter((p) => passesGate(p, gate));
|
|
554
|
+
const residue = all.filter((p) => !passesGate(p, gate) && !p.error);
|
|
555
|
+
const byType = (ps) => Object.entries(ps.reduce((m, p) => ((m[p.effect_type ?? "?"] = (m[p.effect_type ?? "?"] ?? 0) + 1), m), {})).sort((a, b) => b[1] - a[1]);
|
|
556
|
+
// Cross-faction name dupes among the residue — author once, fan to all members.
|
|
557
|
+
const byName = new Map();
|
|
558
|
+
for (const p of residue)
|
|
559
|
+
(byName.get(p.name) ?? byName.set(p.name, []).get(p.name)).push(p);
|
|
560
|
+
const shared = [...byName.entries()].filter(([, ps]) => new Set(ps.map((p) => p.faction)).size > 1)
|
|
561
|
+
.map(([name, ps]) => ({ name, type: ps[0].effect_type, factions: [...new Set(ps.map((p) => p.faction))] }))
|
|
562
|
+
.sort((a, b) => b.factions.length - a.factions.length);
|
|
563
|
+
const L = [
|
|
564
|
+
"# DSL stub authoring — review",
|
|
565
|
+
"",
|
|
566
|
+
`Generated by \`author-batch review\`. ${all.length} proposals across ${new Set(all.map((p) => p.faction)).size} factions.`,
|
|
567
|
+
"",
|
|
568
|
+
`- **schema-valid:** ${all.filter((p) => p.schema_valid).length}`,
|
|
569
|
+
`- **verifier-faithful:** ${all.filter((p) => p.final_faithful).length}`,
|
|
570
|
+
`- **complex-flagged:** ${all.filter((p) => p.complex).length}`,
|
|
571
|
+
`- **auto-appliable (gate: valid+faithful+conf≠low+not-complex):** ${gateable.length}`,
|
|
572
|
+
"",
|
|
573
|
+
"## Auto-appliable now — by faction",
|
|
574
|
+
"",
|
|
575
|
+
...Object.entries(gateable.reduce((m, p) => ((m[p.faction] = (m[p.faction] ?? 0) + 1), m), {})).sort().map(([f, n]) => `- \`${f}\`: ${n}`),
|
|
576
|
+
"",
|
|
577
|
+
"Apply with: `npm run author:apply -- <faction|--all> --dry-run` then drop `--dry-run`.",
|
|
578
|
+
"",
|
|
579
|
+
"## Complex residue — by shape family (author a template per family)",
|
|
580
|
+
"",
|
|
581
|
+
...byType(residue).map(([t, n]) => `- **${t}** — ${n}`),
|
|
582
|
+
"",
|
|
583
|
+
"## Cross-faction shared shapes (author once, fan to all members)",
|
|
584
|
+
"",
|
|
585
|
+
...(shared.length ? shared.map((s) => `- **${s.name}** (\`${s.type}\`) → ${s.factions.join(", ")}`) : ["_(none found)_"]),
|
|
586
|
+
"",
|
|
587
|
+
];
|
|
588
|
+
writeFileSync(resolve(OUT_DIR, "REVIEW.md"), L.join("\n") + "\n");
|
|
589
|
+
return { total: all.length, gateable: gateable.length, residue: residue.length, shared_shapes: shared.length };
|
|
590
|
+
}
|
|
591
|
+
// ─── main ────────────────────────────────────────────────────────────
|
|
592
|
+
function factionList(arg, dir) {
|
|
593
|
+
return arg === "--all"
|
|
594
|
+
? readdirSync(dir).filter((f) => f.endsWith(".json") && f !== "damage-batch.json").map((f) => f.replace(/\.json$/, "")).sort()
|
|
595
|
+
: [arg];
|
|
596
|
+
}
|
|
597
|
+
const flag = (argv, name) => (argv.includes(name) ? argv[argv.indexOf(name) + 1] : undefined);
|
|
598
|
+
async function main() {
|
|
599
|
+
const argv = process.argv.slice(2);
|
|
600
|
+
const mode = argv[0];
|
|
601
|
+
const target = argv[1];
|
|
602
|
+
if (mode === "review") {
|
|
603
|
+
console.log(JSON.stringify(review(), null, 2));
|
|
604
|
+
console.error(`\nWrote ${resolve(OUT_DIR, "REVIEW.md")}`);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (!["propose", "repair", "apply"].includes(mode) || !target) {
|
|
608
|
+
console.error("Usage:\n author-batch propose <faction|--all> [--batch N] [--model M]\n author-batch repair <faction|--all> [--types t1,t2] [--batch N] [--model M]\n author-batch apply <faction|--all> [--min-confidence high|medium] [--include-complex] [--dry-run]\n author-batch review");
|
|
609
|
+
process.exit(1);
|
|
610
|
+
}
|
|
611
|
+
if (mode === "propose") {
|
|
612
|
+
const ajv = createValidator();
|
|
613
|
+
const validateFn = ajv.getSchema(ABILITY_SCHEMA_ID);
|
|
614
|
+
if (!validateFn)
|
|
615
|
+
throw new Error(`ability schema not loaded: ${ABILITY_SCHEMA_ID}`);
|
|
616
|
+
const validate = (x) => !!validateFn(x);
|
|
617
|
+
const opts = { batch: Number(flag(argv, "--batch")) || 15, model: flag(argv, "--model") ?? "claude-haiku-4-5" };
|
|
618
|
+
const summary = [];
|
|
619
|
+
for (const f of factionList(target, INPUT_DIR)) {
|
|
620
|
+
try {
|
|
621
|
+
summary.push(await proposeFaction(f, opts, validate));
|
|
622
|
+
}
|
|
623
|
+
catch (e) {
|
|
624
|
+
process.stderr.write(` ${f}: FAILED (${e.message.slice(0, 100)})\n`);
|
|
625
|
+
summary.push({ faction: f, error: e.message });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (mode === "repair") {
|
|
632
|
+
const ajv = createValidator();
|
|
633
|
+
const validateFn = ajv.getSchema(ABILITY_SCHEMA_ID);
|
|
634
|
+
if (!validateFn)
|
|
635
|
+
throw new Error(`ability schema not loaded: ${ABILITY_SCHEMA_ID}`);
|
|
636
|
+
const validate = (x) => !!validateFn(x);
|
|
637
|
+
const typesArg = flag(argv, "--types");
|
|
638
|
+
const opts = {
|
|
639
|
+
batch: Number(flag(argv, "--batch")) || 8,
|
|
640
|
+
model: flag(argv, "--model") ?? "claude-sonnet-4-6",
|
|
641
|
+
types: typesArg ? new Set(typesArg.split(",").map((t) => t.trim()).filter(Boolean)) : undefined,
|
|
642
|
+
};
|
|
643
|
+
const summary = [];
|
|
644
|
+
for (const f of factionList(target, OUT_DIR)) {
|
|
645
|
+
try {
|
|
646
|
+
summary.push(await repairFaction(f, opts, validate));
|
|
647
|
+
}
|
|
648
|
+
catch (e) {
|
|
649
|
+
process.stderr.write(` ${f}: FAILED (${e.message.slice(0, 100)})\n`);
|
|
650
|
+
summary.push({ faction: f, error: e.message });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
654
|
+
const unenc = summary.reduce((n, s) => n + (s.unencodable ?? 0), 0);
|
|
655
|
+
const faithful = summary.reduce((n, s) => n + (s.now_faithful ?? 0), 0);
|
|
656
|
+
const noncanon = summary.reduce((n, s) => n + (s.non_canonical ?? 0), 0);
|
|
657
|
+
console.error(`\nrepair: ${faithful} now faithful, ${noncanon} non-canonical (gate-blocked), ${unenc} flagged unencodable (need hand-authoring).`);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// apply
|
|
661
|
+
const opts = {
|
|
662
|
+
minConfidence: flag(argv, "--min-confidence") === "high" ? "high" : "medium",
|
|
663
|
+
includeComplex: argv.includes("--include-complex"),
|
|
664
|
+
dryRun: argv.includes("--dry-run"),
|
|
665
|
+
};
|
|
666
|
+
const summary = factionList(target, OUT_DIR).map((f) => applyFaction(f, opts));
|
|
667
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
668
|
+
const total = summary.reduce((n, s) => n + (s.applied ?? 0), 0);
|
|
669
|
+
console.error(`\n${opts.dryRun ? "[dry-run] would apply" : "applied"} ${total} entr${total === 1 ? "y" : "ies"}.`);
|
|
670
|
+
}
|
|
671
|
+
const isMain = process.argv[1] &&
|
|
672
|
+
resolve(process.argv[1]).replace(/\.\w+$/, "") === fileURLToPath(import.meta.url).replace(/\.\w+$/, "");
|
|
673
|
+
if (isMain)
|
|
674
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|
|
675
|
+
//# sourceMappingURL=author-batch.js.map
|