hoi4-agent-tools 3.2.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +4 -0
- package/dist/hoi4_agent_tools/core/analysis-job-operations.js +11 -1
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +18 -9
- package/dist/hoi4_agent_tools/core/helper-expansion.js +3 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +3 -0
- package/dist/hoi4_agent_tools/core/operation-tasks.js +7 -0
- package/dist/hoi4_agent_tools/core/scenario-model.js +85 -0
- package/dist/hoi4_agent_tools/event/comparison-selection.js +78 -0
- package/dist/hoi4_agent_tools/event/helper-expansion.js +48 -0
- package/dist/hoi4_agent_tools/event/operations.js +16 -0
- package/dist/hoi4_agent_tools/event/service.js +21 -2
- package/dist/hoi4_agent_tools/focus/compact.js +241 -27
- package/dist/hoi4_agent_tools/focus/layout.js +119 -8
- package/dist/hoi4_agent_tools/focus/render.js +62 -0
- package/dist/hoi4_agent_tools/focus/scenario.js +99 -0
- package/dist/hoi4_agent_tools/focus/tool-operations.js +30 -4
- package/dist/hoi4_agent_tools/gui/assets.js +54 -4
- package/dist/hoi4_agent_tools/gui/layout.js +935 -104
- package/dist/hoi4_agent_tools/gui/renderer.js +47 -17
- package/dist/hoi4_agent_tools/gui/scenario-conditions.js +2 -23
- package/dist/hoi4_agent_tools/gui/shader.js +515 -0
- package/dist/hoi4_agent_tools/gui/source-graph.js +166 -6
- package/dist/hoi4_agent_tools/gui/studio.js +245 -19
- package/dist/hoi4_agent_tools/gui/tool-operations.js +9 -0
- package/dist/hoi4_agent_tools/gui/validators.js +38 -12
- package/dist/hoi4_agent_tools/map/operations.js +70 -1
- package/dist/hoi4_agent_tools/map/render.js +70 -1
- package/dist/hoi4_agent_tools/map/service.js +30 -6
- package/dist/hoi4_agent_tools/map/tool-operations.js +67 -1
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +3 -0
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +90 -0
- package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -1
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +14 -4
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +5 -1
- package/dist/hoi4_agent_tools/mcp/tools/map.js +12 -2
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +5 -0
- package/dist/hoi4_agent_tools/mechanic/assertions.js +112 -0
- package/dist/hoi4_agent_tools/mechanic/interpreter.js +619 -0
- package/dist/hoi4_agent_tools/mechanic/service.js +145 -0
- package/dist/hoi4_agent_tools/mechanic/source.js +89 -0
- package/dist/hoi4_agent_tools/package-check/service.js +231 -0
- package/dist/hoi4_agent_tools/probability/evaluation.js +6 -1
- package/dist/hoi4_agent_tools/probability/operations.js +8 -0
- package/dist/hoi4_agent_tools/probability/service.js +33 -12
- package/dist/hoi4_agent_tools/probability/source-analysis.js +1 -0
- package/dist/hoi4_agent_tools/scenario-suite/service.js +341 -0
- package/dist/hoi4_agent_tools/schemas/event.js +8 -0
- package/dist/hoi4_agent_tools/schemas/focus-requests.js +40 -1
- package/dist/hoi4_agent_tools/schemas/gui-requests.js +8 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +22 -1
- package/dist/hoi4_agent_tools/schemas/scenarios.js +264 -0
- package/dist/hoi4_agent_tools/schemas/technology.js +7 -0
- package/dist/hoi4_agent_tools/technology/graph.js +25 -10
- package/dist/hoi4_agent_tools/technology/render.js +286 -31
- package/dist/hoi4_agent_tools/technology/service.js +55 -8
- package/dist/hoi4_agent_tools/technology/source-analysis.js +166 -3
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/events.md +3 -1
- package/docs/focus.md +10 -0
- package/docs/gui.md +49 -3
- package/docs/map.md +8 -0
- package/docs/mechanics.md +70 -0
- package/docs/probability.md +3 -1
- package/docs/technology.md +7 -2
- package/package.json +2 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +650 -0
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +164 -0
- package/schemas/package-manifest.schema.json +142 -0
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1004 -0
- package/schemas/scenario-test-input.schema.json +1057 -0
- package/schemas/shared-scenario.schema.json +237 -0
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 3.4.0 - 2026-09-20
|
|
6
|
+
|
|
7
|
+
- Add branch-local focus cleanup with pinned anchors and symmetry groups, connector measurements, source-linked crops, and scenario-backed structural route diagnostics.
|
|
8
|
+
- Report GUI visibility and enabled branches, measured label and background boxes, and matched source-baseline renders alongside scenario comparisons.
|
|
9
|
+
- Expose complete source-backed probability catalogs, actionable missing scenario inputs, and eligible, excluded, and unresolved inventories.
|
|
10
|
+
- Add selected event-chain comparisons and revision-bound bounded helper expansion while retaining explicit lifecycle coverage limits.
|
|
11
|
+
- Compose technology folders from source pixel placements, native item skins, icon and subtechnology positions, panel and background artwork, year labels, and supplied research scenarios; report unresolved placement and asset coverage honestly.
|
|
12
|
+
- Resolve source `GFX_*_medium` artwork and design-team overlays at native item size for chemical and biological warfare folders.
|
|
13
|
+
- Compose installed GUI backgrounds, focus inlays, container scrolling, native button and progress shaders, and scenario-driven rows from source assets and control states.
|
|
14
|
+
- Bound focus connector and overlap diagnostics on anchored trees with thousands of nodes.
|
|
15
|
+
- Add focused map lookups, reusable render tiles, affected-area output, and typed external script-reference updates for map renumbering.
|
|
16
|
+
|
|
17
|
+
## 3.3.0 - 2026-09-20
|
|
18
|
+
|
|
19
|
+
- Add `hoi4.mechanic_test` with copied versioned scenario state, bounded source effects, explicit time advancement, traces, and typed gameplay assertions.
|
|
20
|
+
- Add `hoi4.package_check` for declarative package definitions, calls, registrations, localisation, assets, and required suite-case references.
|
|
21
|
+
- Add `hoi4.scenario_test` for named inline or workspace-owned suites through fixed read-only operations, case artifacts, and authenticated revision-bound continuation.
|
|
22
|
+
- Share declared condition state across GUI and probability adapters while preserving their public inputs and specialized evaluation semantics.
|
|
23
|
+
|
|
5
24
|
## 3.2.0 - 2026-09-20
|
|
6
25
|
|
|
7
26
|
- Add read-only `hoi4.impact_inspect` with active and overridden definitions, direct and transitive source consumers, state-key access roles, cycle and coverage evidence, and in-memory source comparisons.
|
package/README.md
CHANGED
|
@@ -74,6 +74,9 @@ On non-Windows systems, use `hoi4-agent-tools` as the command. Agentic HOI4 repo
|
|
|
74
74
|
| `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
|
|
75
75
|
| `hoi4.impact_inspect` | Trace symbol and changed-file consumers across source systems and compare proposals. |
|
|
76
76
|
| `hoi4.decision_inspect` | Inventory and evaluate decisions or missions under declared scenarios and source changes. |
|
|
77
|
+
| `hoi4.mechanic_test` | Execute bounded source effects on a copied declared scenario and check assertions. |
|
|
78
|
+
| `hoi4.package_check` | Check declarative package definitions, calls, registrations, assets, and case links. |
|
|
79
|
+
| `hoi4.scenario_test` | Run named source and domain cases in resumable, revision-bound batches. |
|
|
77
80
|
| `hoi4.job_inspect` | Inspect durable background work or retrieve its completed tool result. |
|
|
78
81
|
| `hoi4.job_cancel` | Durably request cancellation of authorized background work. |
|
|
79
82
|
| `hoi4.tech_inspect` | Scan, trace, explain, lint, and assess technology and doctrine systems. |
|
|
@@ -108,6 +111,7 @@ Ask your agent in normal task language. A typical workflow is inspect, render, r
|
|
|
108
111
|
- Maps: "Render the whole map and find this state by name," "Create a state from these provinces," "Create a province inside this exact rectangle," or "Swap these state IDs and update connected references." See [Maps](docs/map.md).
|
|
109
112
|
- Event chains: "Trace every route from this event and explain where its flags and variables change," or "Compare the workspace event graph with its previous revision and render the affected routes." See [Event chains](docs/events.md).
|
|
110
113
|
- Cross-system impact and decisions: "Find every direct and transitive consumer of this idea," or "Compare who can take this decision, what they pay, and its mission end paths for these actor and target scenarios." See [Cross-system impact and decisions](docs/analysis.md).
|
|
114
|
+
- Mechanics and packages: "Test this transfer's conservation and payment," or "Check every declared package link and resume its named scenario suite." See [Mechanic tests and scenario suites](docs/mechanics.md).
|
|
111
115
|
- Technology trees: "Explain everything this technology requires and unlocks," or "Compare this technology patch and render every affected folder and doctrine branch." See [Technology trees](docs/technology.md).
|
|
112
116
|
- AI and MTTH: "Compare these focus weights across peace, defensive-war, and low-stability scenarios," or "Show when this MTTH event becomes likely and which unknown inputs control the result." See [AI and MTTH analysis](docs/probability.md).
|
|
113
117
|
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { z } from 'zod/v4';
|
|
2
2
|
import { DecisionAnalyzer } from '../decision/service.js';
|
|
3
3
|
import { ImpactAnalyzer } from '../impact/service.js';
|
|
4
|
+
import { MechanicAnalyzer } from '../mechanic/service.js';
|
|
5
|
+
import { PackageAnalyzer } from '../package-check/service.js';
|
|
6
|
+
import { ScenarioAnalyzer } from '../scenario-suite/service.js';
|
|
4
7
|
import { decisionInspectRequestSchema, impactInspectRequestSchema } from '../schemas/analysis.js';
|
|
8
|
+
import { mechanicTestRequestSchema, packageCheckRequestSchema, scenarioTestRequestSchema, } from '../schemas/scenarios.js';
|
|
5
9
|
import { errorResult, toolResult } from './operation-result.js';
|
|
6
10
|
import { ServiceError } from './result.js';
|
|
7
11
|
const wireResult = z.record(z.string(), z.json());
|
|
8
|
-
/** Registers
|
|
12
|
+
/** Registers read-only analysis services for durable ordinary and native tasks. */
|
|
9
13
|
export function registerAnalysisJobs(operations, engine) {
|
|
10
14
|
const impact = new ImpactAnalyzer(engine);
|
|
11
15
|
const decision = new DecisionAnalyzer(engine);
|
|
16
|
+
const mechanic = new MechanicAnalyzer(engine);
|
|
17
|
+
const packages = new PackageAnalyzer(engine);
|
|
18
|
+
const scenarios = new ScenarioAnalyzer(engine);
|
|
12
19
|
const register = (name, schema, execute, progressMessage) => {
|
|
13
20
|
operations.registerRead(name, schema, async (input, context) => {
|
|
14
21
|
try {
|
|
@@ -35,4 +42,7 @@ export function registerAnalysisJobs(operations, engine) {
|
|
|
35
42
|
};
|
|
36
43
|
register('hoi4.impact_inspect', impactInspectRequestSchema, (input) => impact.inspect(input), 'Tracing cross-system impact');
|
|
37
44
|
register('hoi4.decision_inspect', decisionInspectRequestSchema, (input) => decision.inspect(input), 'Analyzing decisions and missions');
|
|
45
|
+
register('hoi4.mechanic_test', mechanicTestRequestSchema, (input) => mechanic.test(input), 'Testing declared mechanic steps');
|
|
46
|
+
register('hoi4.package_check', packageCheckRequestSchema, (input) => packages.check(input), 'Checking package connections');
|
|
47
|
+
register('hoi4.scenario_test', scenarioTestRequestSchema, (input) => scenarios.test(input), 'Running a bounded scenario suite');
|
|
38
48
|
}
|
|
@@ -96,7 +96,7 @@ function numberValue(expression, scenario, context, definitions, path, seen = ne
|
|
|
96
96
|
}
|
|
97
97
|
return undefined;
|
|
98
98
|
}
|
|
99
|
-
function unresolved(assignment, candidate, reason) {
|
|
99
|
+
function unresolved(assignment, candidate, reason, scenarioInput) {
|
|
100
100
|
return {
|
|
101
101
|
state: 'unresolved',
|
|
102
102
|
unresolved: [
|
|
@@ -104,6 +104,7 @@ function unresolved(assignment, candidate, reason) {
|
|
|
104
104
|
code: 'TRIGGER_UNRESOLVED',
|
|
105
105
|
message: reason,
|
|
106
106
|
path: assignment.key.value,
|
|
107
|
+
...(scenarioInput === undefined ? {} : { details: { scenarioInput } }),
|
|
107
108
|
candidateId: candidate.id,
|
|
108
109
|
...(candidate.provenance[0] === undefined ? {} : { provenance: candidate.provenance[0] }),
|
|
109
110
|
},
|
|
@@ -136,7 +137,11 @@ function evaluateCheckVariable(block, scenario, candidate, assignment, scopeCont
|
|
|
136
137
|
const leftNumber = numberValue(variable, scenario, scopeContext, definitions, path);
|
|
137
138
|
const rightNumber = numberValue(target, scenario, scopeContext, definitions, path);
|
|
138
139
|
if (leftNumber === undefined || rightNumber === undefined)
|
|
139
|
-
return unresolved(assignment, candidate, `Scenario does not resolve numeric operands ${variable} and ${target}
|
|
140
|
+
return unresolved(assignment, candidate, `Scenario does not resolve numeric operands ${variable} and ${target}`, leftNumber === undefined && !/^(?:@|constant:)/u.test(variable)
|
|
141
|
+
? `variable.${variable}`
|
|
142
|
+
: rightNumber === undefined && !/^(?:@|constant:)/u.test(target)
|
|
143
|
+
? `variable.${target}`
|
|
144
|
+
: undefined);
|
|
140
145
|
const compareName = firstScalar(block, 'compare')?.value ??
|
|
141
146
|
(short?.operator.text === '>'
|
|
142
147
|
? 'greater_than'
|
|
@@ -202,7 +207,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
|
|
|
202
207
|
const array = scopeStateValue(scenario, `array.${arrayId}`, scopeContext) ??
|
|
203
208
|
rootStateValue(scenario, `array.${arrayId}`);
|
|
204
209
|
if (!Array.isArray(array))
|
|
205
|
-
return unresolved(assignment, candidate, `Scenario does not declare array ${arrayId}`);
|
|
210
|
+
return unresolved(assignment, candidate, `Scenario does not declare array ${arrayId}`, `array.${arrayId}`);
|
|
206
211
|
const compared = ['THIS', 'ROOT', 'PREV'].includes(declaredValue.toUpperCase()) ||
|
|
207
212
|
resolveScopeContext(scenario, declaredValue, scopeContext) !== undefined
|
|
208
213
|
? scopeIdentity(resolveScopeContext(scenario, declaredValue, scopeContext) ?? scopeContext)
|
|
@@ -244,7 +249,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
|
|
|
244
249
|
const declaredScope = bool(rootStateValue(scenario, `scope.${key}`));
|
|
245
250
|
if (declaredScope !== undefined)
|
|
246
251
|
return { state: declaredScope ? 'true' : 'false', unresolved: [] };
|
|
247
|
-
return unresolved(assignment, candidate, `Scoped or compound trigger ${key} is not declared by the scenario`);
|
|
252
|
+
return unresolved(assignment, candidate, `Scoped or compound trigger ${key} is not declared by the scenario`, `scope.${key}`);
|
|
248
253
|
}
|
|
249
254
|
const right = assignment.value.value;
|
|
250
255
|
if (key === 'always') {
|
|
@@ -257,9 +262,13 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
|
|
|
257
262
|
const explicit = bool(scopeStateValue(scenario, `flag.${right}`, scopeContext));
|
|
258
263
|
if (explicit !== undefined)
|
|
259
264
|
return { state: explicit ? 'true' : 'false', unresolved: [] };
|
|
260
|
-
const flags =
|
|
265
|
+
const flags = key === 'has_global_flag' && scenario.globalFlags !== undefined
|
|
266
|
+
? scenario.globalFlags
|
|
267
|
+
: scopeContext.expression === 'ROOT'
|
|
268
|
+
? scenario.flags
|
|
269
|
+
: scopeContext.binding.flags;
|
|
261
270
|
if (scenario.closedFlags === false && flags?.includes(right) !== true)
|
|
262
|
-
return unresolved(assignment, candidate, `Scenario does not declare flag ${right}`);
|
|
271
|
+
return unresolved(assignment, candidate, `Scenario does not declare flag ${right}`, `flag.${right}`);
|
|
263
272
|
return { state: flags?.includes(right) === true ? 'true' : 'false', unresolved: [] };
|
|
264
273
|
}
|
|
265
274
|
if (key === 'has_event_target') {
|
|
@@ -274,7 +283,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
|
|
|
274
283
|
? scenario.actor
|
|
275
284
|
: (scopeContext.binding.actor ?? scopeContext.binding.id);
|
|
276
285
|
if (actor === undefined)
|
|
277
|
-
return unresolved(assignment, candidate, `Scenario does not declare actor for ${key}
|
|
286
|
+
return unresolved(assignment, candidate, `Scenario does not declare actor for ${key}`, 'actor');
|
|
278
287
|
return {
|
|
279
288
|
state: compare(actor, right, assignment.operator.text) ? 'true' : 'false',
|
|
280
289
|
unresolved: [],
|
|
@@ -282,7 +291,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
|
|
|
282
291
|
}
|
|
283
292
|
if (key === 'date') {
|
|
284
293
|
if (scenario.date === undefined)
|
|
285
|
-
return unresolved(assignment, candidate, 'Scenario does not declare date');
|
|
294
|
+
return unresolved(assignment, candidate, 'Scenario does not declare date', 'date');
|
|
286
295
|
const dateNumber = (value) => {
|
|
287
296
|
const parts = /^(\d+)\.(\d{1,2})\.(\d{1,2})$/u.exec(value);
|
|
288
297
|
if (parts === null)
|
|
@@ -332,7 +341,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
|
|
|
332
341
|
const declared = scopeStateValue(scenario, key, scopeContext) ??
|
|
333
342
|
numberValue(key, scenario, scopeContext, definitions, candidate.provenance[0]?.path);
|
|
334
343
|
if (declared === undefined)
|
|
335
|
-
return unresolved(assignment, candidate, `Scenario does not declare trigger ${key}
|
|
344
|
+
return unresolved(assignment, candidate, `Scenario does not declare trigger ${key}`, key);
|
|
336
345
|
const expectedBoolean = bool(right);
|
|
337
346
|
const actualBoolean = bool(declared);
|
|
338
347
|
if (expectedBoolean !== undefined && actualBoolean !== undefined)
|
|
@@ -104,6 +104,9 @@ export async function inspectHelperExpansion(engine, workspaceId, inventory, req
|
|
|
104
104
|
kind,
|
|
105
105
|
rootId: input.roots[root],
|
|
106
106
|
path: path.map((index) => inventory.edges[index].id),
|
|
107
|
+
...(inventory.recordDetails === undefined
|
|
108
|
+
? {}
|
|
109
|
+
: inventory.recordDetails(path.map((index) => inventory.edges[index]))),
|
|
107
110
|
}));
|
|
108
111
|
const continuationName = `${inventory.domain}-helper-continuation.json`;
|
|
109
112
|
const report = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod/v4';
|
|
2
2
|
import { decisionInspectRequestSchema, impactInspectRequestSchema } from '../schemas/analysis.js';
|
|
3
|
+
import { mechanicTestRequestSchema, packageCheckRequestSchema, scenarioTestRequestSchema, } from '../schemas/scenarios.js';
|
|
3
4
|
import { eventCompareRequestSchema, eventInspectRequestSchema, eventRenderRequestSchema, } from '../schemas/event.js';
|
|
4
5
|
import { focusInspectRequestSchema, focusRenderRequestSchema, focusRewriteRequestSchema, } from '../schemas/focus-requests.js';
|
|
5
6
|
import { guiInspectRequestSchema, guiRenderRequestSchema, guiRewriteRequestSchema, } from '../schemas/gui-requests.js';
|
|
@@ -28,6 +29,9 @@ export const operationTaskCallSchema = z
|
|
|
28
29
|
name: z.enum([
|
|
29
30
|
'hoi4.impact_inspect',
|
|
30
31
|
'hoi4.decision_inspect',
|
|
32
|
+
'hoi4.mechanic_test',
|
|
33
|
+
'hoi4.package_check',
|
|
34
|
+
'hoi4.scenario_test',
|
|
31
35
|
'hoi4.event_inspect',
|
|
32
36
|
'hoi4.event_render',
|
|
33
37
|
'hoi4.event_compare',
|
|
@@ -58,6 +62,9 @@ export const operationTaskCallSchema = z
|
|
|
58
62
|
const taskArgumentSchemas = {
|
|
59
63
|
'hoi4.impact_inspect': impactInspectRequestSchema,
|
|
60
64
|
'hoi4.decision_inspect': decisionInspectRequestSchema,
|
|
65
|
+
'hoi4.mechanic_test': mechanicTestRequestSchema,
|
|
66
|
+
'hoi4.package_check': packageCheckRequestSchema,
|
|
67
|
+
'hoi4.scenario_test': scenarioTestRequestSchema,
|
|
61
68
|
'hoi4.event_inspect': eventInspectRequestSchema,
|
|
62
69
|
'hoi4.event_render': eventRenderRequestSchema,
|
|
63
70
|
'hoi4.event_compare': eventCompareRequestSchema,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export function copyScenario(scenario) {
|
|
2
|
+
return {
|
|
3
|
+
scenario: structuredClone(scenario),
|
|
4
|
+
unknown: new Set(),
|
|
5
|
+
day: 0,
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function scenarioValue(state, key, scope = 'ROOT') {
|
|
9
|
+
if (state.unknown.has('*') || state.unknown.has(`${scope}.${key}`))
|
|
10
|
+
return undefined;
|
|
11
|
+
if (scope === 'ROOT' || scope === 'THIS')
|
|
12
|
+
return state.scenario.state[key];
|
|
13
|
+
return state.scenario.scopes?.[scope]?.state[key];
|
|
14
|
+
}
|
|
15
|
+
export function setScenarioValue(state, key, value, scope = 'ROOT') {
|
|
16
|
+
const identity = `${scope}.${key}`;
|
|
17
|
+
if (value === undefined)
|
|
18
|
+
state.unknown.add(identity);
|
|
19
|
+
else {
|
|
20
|
+
state.unknown.delete(identity);
|
|
21
|
+
if (scope === 'ROOT' || scope === 'THIS')
|
|
22
|
+
state.scenario.state[key] = value;
|
|
23
|
+
else if (state.scenario.scopes?.[scope] !== undefined)
|
|
24
|
+
state.scenario.scopes[scope].state[key] = value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Preserve the GUI's existing input precedence and exploratory flag semantics. */
|
|
28
|
+
export function scenarioFromGui(base, values = base.values) {
|
|
29
|
+
const state = {
|
|
30
|
+
...base.stateValues,
|
|
31
|
+
...base.variables,
|
|
32
|
+
...values,
|
|
33
|
+
};
|
|
34
|
+
for (const [key, value] of Object.entries(base.flags))
|
|
35
|
+
state[`flag.${key}`] = value;
|
|
36
|
+
const scopes = structuredClone(base.scopes ?? {});
|
|
37
|
+
for (const [key, value] of Object.entries(state)) {
|
|
38
|
+
const match = /^(ROOT|THIS|FROM(?:\.FROM)*|PREV)\.(.+)$/u.exec(key);
|
|
39
|
+
if (match === null)
|
|
40
|
+
continue;
|
|
41
|
+
if (match[1] === 'ROOT' || match[1] === 'THIS')
|
|
42
|
+
state[match[2]] ??= value;
|
|
43
|
+
else {
|
|
44
|
+
const scope = (scopes[match[1]] ??= { id: match[1], state: {} });
|
|
45
|
+
scope.state[match[2]] ??= value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const actor = base.country?.tag ?? base.country?.countryTag ?? base.country?.GetTag;
|
|
49
|
+
return {
|
|
50
|
+
schemaVersion: '1.0',
|
|
51
|
+
id: base.id,
|
|
52
|
+
state,
|
|
53
|
+
scopes,
|
|
54
|
+
closedFlags: false,
|
|
55
|
+
...(typeof actor === 'string' ? { actor } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Probability retains pools, uncertainty, correlations and schedule in its own adapter. */
|
|
59
|
+
export function scenarioFromProbability(base) {
|
|
60
|
+
return {
|
|
61
|
+
schemaVersion: '1.0',
|
|
62
|
+
id: base.id,
|
|
63
|
+
...(base.actor === undefined ? {} : { actor: base.actor }),
|
|
64
|
+
...(base.date === undefined ? {} : { date: base.date }),
|
|
65
|
+
state: structuredClone(base.state),
|
|
66
|
+
...(base.flags === undefined ? {} : { flags: [...base.flags] }),
|
|
67
|
+
...(base.eventTargets === undefined ? {} : { eventTargets: { ...base.eventTargets } }),
|
|
68
|
+
...(base.scopes === undefined ? {} : { scopes: structuredClone(base.scopes) }),
|
|
69
|
+
...(base.candidateOverrides === undefined
|
|
70
|
+
? {}
|
|
71
|
+
: { candidateOverrides: { ...base.candidateOverrides } }),
|
|
72
|
+
closedFlags: true,
|
|
73
|
+
...(base.scopePools === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: {
|
|
76
|
+
scopeCatalogs: Object.fromEntries(base.scopePools.map((pool) => [
|
|
77
|
+
pool.id,
|
|
78
|
+
{
|
|
79
|
+
complete: pool.complete,
|
|
80
|
+
members: pool.candidates.map((candidate) => candidate.id),
|
|
81
|
+
},
|
|
82
|
+
])),
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { compareCodeUnits } from '../core/canonical.js';
|
|
2
|
+
import { traceEventGraph } from './algorithms.js';
|
|
3
|
+
import { selectEventNodes } from './queries.js';
|
|
4
|
+
/** Project a whole-graph comparison onto the downstream chain in each revision. */
|
|
5
|
+
export function selectEventComparison(before, after, comparison, selector, maxChainNodes, signal) {
|
|
6
|
+
const trace = (graph) => {
|
|
7
|
+
const starts = selectEventNodes(graph, selector, signal).map(({ id }) => id);
|
|
8
|
+
return traceEventGraph(graph, starts, {
|
|
9
|
+
maxDepth: 64,
|
|
10
|
+
maxNodes: maxChainNodes,
|
|
11
|
+
maxEdges: Math.max(1, Math.min(20_000, graph.edges.length)),
|
|
12
|
+
direction: 'downstream',
|
|
13
|
+
expandHelpers: true,
|
|
14
|
+
}, signal);
|
|
15
|
+
};
|
|
16
|
+
const beforeTrace = trace(before);
|
|
17
|
+
const afterTrace = trace(after);
|
|
18
|
+
const beforeNodeIds = beforeTrace.nodes.map(({ id }) => id).sort(compareCodeUnits);
|
|
19
|
+
const afterNodeIds = afterTrace.nodes.map(({ id }) => id).sort(compareCodeUnits);
|
|
20
|
+
const selectedNodes = new Set([...beforeNodeIds, ...afterNodeIds]);
|
|
21
|
+
const edges = new Map([...before.edges, ...after.edges].map((edge) => [edge.id, edge]));
|
|
22
|
+
const accesses = new Map([...before.stateAccesses, ...after.stateAccesses].map((access) => [access.id, access]));
|
|
23
|
+
const links = new Map([...before.stateLinks, ...after.stateLinks].map((link) => [link.id, link]));
|
|
24
|
+
const unresolved = new Map([...before.unresolved, ...after.unresolved].map((item) => [item.id, item]));
|
|
25
|
+
const association = (change) => {
|
|
26
|
+
const { kind, subjectId } = change;
|
|
27
|
+
if (kind.startsWith('node_') || kind === 'caller_removed' || kind.endsWith('_disconnected'))
|
|
28
|
+
return selectedNodes.has(subjectId);
|
|
29
|
+
if (kind.startsWith('edge_')) {
|
|
30
|
+
const edge = edges.get(subjectId);
|
|
31
|
+
return edge === undefined
|
|
32
|
+
? undefined
|
|
33
|
+
: selectedNodes.has(edge.from) || selectedNodes.has(edge.to);
|
|
34
|
+
}
|
|
35
|
+
if (kind.startsWith('state_access_')) {
|
|
36
|
+
const access = accesses.get(subjectId);
|
|
37
|
+
return access === undefined ? undefined : selectedNodes.has(access.ownerId);
|
|
38
|
+
}
|
|
39
|
+
if (kind.startsWith('state_link_')) {
|
|
40
|
+
const link = links.get(subjectId);
|
|
41
|
+
if (link === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
const producer = accesses.get(link.producerId);
|
|
44
|
+
const consumer = accesses.get(link.consumerId);
|
|
45
|
+
return producer === undefined && consumer === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: (producer !== undefined && selectedNodes.has(producer.ownerId)) ||
|
|
48
|
+
(consumer !== undefined && selectedNodes.has(consumer.ownerId));
|
|
49
|
+
}
|
|
50
|
+
if (kind.startsWith('unresolved_')) {
|
|
51
|
+
const item = unresolved.get(subjectId);
|
|
52
|
+
return item?.ownerId === undefined ? undefined : selectedNodes.has(item.ownerId);
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
};
|
|
56
|
+
const selectedChangeIds = [];
|
|
57
|
+
const unattributedChangeIds = [];
|
|
58
|
+
for (const [index, change] of comparison.changes.entries()) {
|
|
59
|
+
if ((index & 255) === 0)
|
|
60
|
+
signal?.throwIfAborted();
|
|
61
|
+
const relevant = association(change);
|
|
62
|
+
if (relevant === true)
|
|
63
|
+
selectedChangeIds.push(change.id);
|
|
64
|
+
else if (relevant === undefined)
|
|
65
|
+
unattributedChangeIds.push(change.id);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
selector,
|
|
69
|
+
maxChainNodes,
|
|
70
|
+
beforeNodeIds,
|
|
71
|
+
afterNodeIds,
|
|
72
|
+
selectedChangeIds,
|
|
73
|
+
selectedChangeCount: selectedChangeIds.length,
|
|
74
|
+
omittedChangeCount: comparison.changes.length - selectedChangeIds.length,
|
|
75
|
+
unattributedChangeIds,
|
|
76
|
+
truncated: beforeTrace.truncated || afterTrace.truncated,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -2,6 +2,14 @@ import { compareCodeUnits } from '../core/canonical.js';
|
|
|
2
2
|
export function eventHelperInventory(graph) {
|
|
3
3
|
const helpers = new Set(graph.nodes.filter(({ kind }) => kind === 'helper').map(({ id }) => id));
|
|
4
4
|
const structural = graph.edges.filter(({ derived }) => !derived);
|
|
5
|
+
const structuralById = new Map(structural.map((edge) => [edge.id, edge]));
|
|
6
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
7
|
+
const optionParents = new Map(graph.edges
|
|
8
|
+
.filter(({ reason }) => reason === 'option_branch')
|
|
9
|
+
.map(({ from, to }) => [to, from]));
|
|
10
|
+
const accessById = new Map(graph.stateAccesses
|
|
11
|
+
.filter(({ ownerId }) => helpers.has(ownerId))
|
|
12
|
+
.map((access) => [`state-access:${access.id}`, access]));
|
|
5
13
|
return {
|
|
6
14
|
domain: 'event',
|
|
7
15
|
sourceRevision: graph.revision,
|
|
@@ -29,5 +37,45 @@ export function eventHelperInventory(graph) {
|
|
|
29
37
|
.map(({ id }) => id)
|
|
30
38
|
.sort(compareCodeUnits),
|
|
31
39
|
branches: [...helpers].sort(compareCodeUnits),
|
|
40
|
+
recordDetails: (path) => {
|
|
41
|
+
let current = 'unknown';
|
|
42
|
+
const root = structuralById.get(path[0]?.id ?? '');
|
|
43
|
+
if (root !== undefined) {
|
|
44
|
+
const caller = nodesById.get(optionParents.get(root.from) ?? root.from);
|
|
45
|
+
const candidate = root.scope?.source ?? caller?.metadata.expectedScope ?? caller?.metadata.scope;
|
|
46
|
+
if (candidate === 'country' ||
|
|
47
|
+
candidate === 'state' ||
|
|
48
|
+
candidate === 'province' ||
|
|
49
|
+
candidate === 'unit_leader' ||
|
|
50
|
+
candidate === 'operative' ||
|
|
51
|
+
candidate === 'character' ||
|
|
52
|
+
candidate === 'global')
|
|
53
|
+
current = candidate;
|
|
54
|
+
}
|
|
55
|
+
const transitions = [];
|
|
56
|
+
for (const { id } of path) {
|
|
57
|
+
const edge = structuralById.get(id);
|
|
58
|
+
if (edge?.scope !== undefined) {
|
|
59
|
+
const { source, destination, confidence } = edge.scope;
|
|
60
|
+
current =
|
|
61
|
+
current !== 'unknown' && source !== 'unknown' && current !== source
|
|
62
|
+
? 'unknown'
|
|
63
|
+
: destination;
|
|
64
|
+
transitions.push({ edgeId: id, source, destination: current, confidence });
|
|
65
|
+
}
|
|
66
|
+
const access = accessById.get(id);
|
|
67
|
+
if (access !== undefined) {
|
|
68
|
+
return {
|
|
69
|
+
scopeEvidence: {
|
|
70
|
+
stateAccessId: access.id,
|
|
71
|
+
declaredScope: access.scope,
|
|
72
|
+
resolvedScope: access.scope === 'unknown' ? current : access.scope,
|
|
73
|
+
transitions,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { scopeEvidence: { resolvedScope: current, transitions } };
|
|
79
|
+
},
|
|
32
80
|
};
|
|
33
81
|
}
|
|
@@ -130,6 +130,15 @@ export async function compareEvents(viewer, input) {
|
|
|
130
130
|
...(compared.render === undefined ? {} : { renderHashes: compared.render.hashes }),
|
|
131
131
|
counts: {
|
|
132
132
|
changes: comparison.changes.length,
|
|
133
|
+
...(compared.selection === undefined
|
|
134
|
+
? {}
|
|
135
|
+
: {
|
|
136
|
+
selectedChanges: compared.selection.selectedChangeCount,
|
|
137
|
+
omittedChanges: compared.selection.omittedChangeCount,
|
|
138
|
+
unattributedChanges: compared.selection.unattributedChangeIds.length,
|
|
139
|
+
selectedBeforeNodes: compared.selection.beforeNodeIds.length,
|
|
140
|
+
selectedAfterNodes: compared.selection.afterNodeIds.length,
|
|
141
|
+
}),
|
|
133
142
|
addedNodes: comparison.addedNodeIds.length,
|
|
134
143
|
removedNodes: comparison.removedNodeIds.length,
|
|
135
144
|
changedNodes: comparison.changedNodeIds.length,
|
|
@@ -155,6 +164,13 @@ export async function compareEvents(viewer, input) {
|
|
|
155
164
|
},
|
|
156
165
|
boundary: {
|
|
157
166
|
proposedSources: input.proposedSources?.length ?? 0,
|
|
167
|
+
...(compared.selection === undefined
|
|
168
|
+
? {}
|
|
169
|
+
: {
|
|
170
|
+
selector: compared.selection.selector,
|
|
171
|
+
maxChainNodes: compared.selection.maxChainNodes,
|
|
172
|
+
selectionTruncated: compared.selection.truncated,
|
|
173
|
+
}),
|
|
158
174
|
render: input.render ?? true,
|
|
159
175
|
maxRenderNodes: input.maxRenderNodes ?? 120,
|
|
160
176
|
refresh: compared.refresh,
|
|
@@ -13,6 +13,7 @@ import { buildEventGraph, buildEventGraphAsync } from './graph.js';
|
|
|
13
13
|
import { EVENT_FRAGMENT_CACHE_MAX_ENTRIES, EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES, EVENT_PROPOSED_SOURCE_MAX_BYTES, EVENT_PROPOSED_SOURCE_MAX_FILES, } from './limits.js';
|
|
14
14
|
import { analyzeEventImpact, discoverEventRoots, explainEventPath, inspectEventStateFlow, lintEventGraph, traceSelectedEvents, } from './queries.js';
|
|
15
15
|
import { compareEventGraphs, eventGraphHash } from './compare.js';
|
|
16
|
+
import { selectEventComparison } from './comparison-selection.js';
|
|
16
17
|
import { eventFlowEdges } from './algorithms.js';
|
|
17
18
|
import { validateEventGraphArtifact } from './artifact-validation.js';
|
|
18
19
|
import { renderEventGraph, } from './render.js';
|
|
@@ -1331,16 +1332,29 @@ export class EventChainViewer {
|
|
|
1331
1332
|
after = current;
|
|
1332
1333
|
}
|
|
1333
1334
|
const comparison = compareEventGraphs(before, after, input.signal);
|
|
1335
|
+
const selection = input.selector === undefined
|
|
1336
|
+
? undefined
|
|
1337
|
+
: selectEventComparison(before, after, comparison, input.selector, input.maxChainNodes ?? 1_000, input.signal);
|
|
1338
|
+
if (selection?.beforeNodeIds.length === 0 && selection.afterNodeIds.length === 0)
|
|
1339
|
+
throw new ServiceError('EVENT_COMPARISON_SELECTOR_NOT_FOUND', 'The selector matched no event-chain nodes in either revision');
|
|
1334
1340
|
graphHashCache.set(before, comparison.beforeGraphHash);
|
|
1335
1341
|
graphHashCache.set(after, comparison.afterGraphHash);
|
|
1336
1342
|
let render;
|
|
1337
1343
|
if (input.render !== false) {
|
|
1338
1344
|
const projection = comparisonRenderProjection(before, after, comparison, input.signal);
|
|
1345
|
+
const selectedChainIds = selection === undefined
|
|
1346
|
+
? undefined
|
|
1347
|
+
: new Set([...selection.beforeNodeIds, ...selection.afterNodeIds]);
|
|
1348
|
+
const changedNodeIds = selectedChainIds === undefined
|
|
1349
|
+
? projection.selectedNodeIds
|
|
1350
|
+
: projection.selectedNodeIds.filter((id) => selectedChainIds.has(id));
|
|
1339
1351
|
const selector = {
|
|
1340
1352
|
kind: 'manifest',
|
|
1341
1353
|
manifest: {
|
|
1342
1354
|
id: 'comparison',
|
|
1343
|
-
nodeIds:
|
|
1355
|
+
nodeIds: changedNodeIds.length > 0
|
|
1356
|
+
? changedNodeIds
|
|
1357
|
+
: (selection?.afterNodeIds.slice(0, 1) ?? selection?.beforeNodeIds.slice(0, 1) ?? []),
|
|
1344
1358
|
},
|
|
1345
1359
|
};
|
|
1346
1360
|
render = await renderEventGraph(projection.graph, {
|
|
@@ -1354,7 +1368,7 @@ export class EventChainViewer {
|
|
|
1354
1368
|
});
|
|
1355
1369
|
}
|
|
1356
1370
|
const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
|
|
1357
|
-
const prefix = safeSlug(`event-compare-${before.revision.slice(0, 8)}-${after.revision.slice(0, 8)}`);
|
|
1371
|
+
const prefix = safeSlug(`event-compare-${before.revision.slice(0, 8)}-${after.revision.slice(0, 8)}${selection === undefined ? '' : `-${hashCanonical({ selector: selection.selector, maxChainNodes: selection.maxChainNodes }).slice(0, 8)}`}`);
|
|
1358
1372
|
const resourceNames = {
|
|
1359
1373
|
json: `${prefix}.json`,
|
|
1360
1374
|
...(render === undefined
|
|
@@ -1380,6 +1394,9 @@ export class EventChainViewer {
|
|
|
1380
1394
|
filters: {
|
|
1381
1395
|
...(input.before === undefined ? {} : { before: input.before }),
|
|
1382
1396
|
...(input.after === undefined ? {} : { after: input.after }),
|
|
1397
|
+
...(input.selector === undefined
|
|
1398
|
+
? {}
|
|
1399
|
+
: { selector: input.selector, maxChainNodes: input.maxChainNodes ?? 1_000 }),
|
|
1383
1400
|
proposedSources: input.proposedSources?.map(({ relativePath, source, expectedSourceHash }) => ({
|
|
1384
1401
|
relativePath,
|
|
1385
1402
|
operation: source === null ? 'delete' : 'overlay',
|
|
@@ -1397,6 +1414,7 @@ export class EventChainViewer {
|
|
|
1397
1414
|
evidence: comparisonEntityEvidence(before, after, comparison, input.signal),
|
|
1398
1415
|
resources: resourceNames,
|
|
1399
1416
|
comparison,
|
|
1417
|
+
...(selection === undefined ? {} : { selection }),
|
|
1400
1418
|
})}\n`;
|
|
1401
1419
|
const provenance = eventProvenance(after, 'event-comparison', {
|
|
1402
1420
|
beforeRevision: before.revision,
|
|
@@ -1447,6 +1465,7 @@ export class EventChainViewer {
|
|
|
1447
1465
|
after,
|
|
1448
1466
|
refresh,
|
|
1449
1467
|
comparison,
|
|
1468
|
+
...(selection === undefined ? {} : { selection }),
|
|
1450
1469
|
comparisonJson,
|
|
1451
1470
|
...(render === undefined ? {} : { render }),
|
|
1452
1471
|
artifacts,
|