hoi4-agent-tools 2.3.0 → 2.3.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/CHANGELOG.md +13 -0
- package/dist/bin/stdio.js +4 -0
- package/dist/hoi4_agent_tools/event/algorithms.js +178 -1
- package/dist/hoi4_agent_tools/event/graph.js +24 -9
- package/dist/hoi4_agent_tools/event/limits.js +4 -1
- package/dist/hoi4_agent_tools/event/service.js +182 -45
- package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +480 -0
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +4 -1
- package/dist/hoi4_agent_tools/runtime.js +109 -7
- package/dist/hoi4_agent_tools/technology/graph.js +30 -3
- package/dist/hoi4_agent_tools/technology/queries.js +70 -3
- package/dist/hoi4_agent_tools/technology/service.js +38 -2
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/events.md +2 -2
- package/docs/setup.md +2 -0
- package/docs/technology.md +4 -2
- package/package.json +3 -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/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/map-operation.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- 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/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.3.3 - 2026-07-26
|
|
4
|
+
|
|
5
|
+
- Regenerated the npm lockfile with the pinned release npm so clean installs include the patched optional native runtime packages.
|
|
6
|
+
|
|
7
|
+
## 2.3.2 - 2026-07-26
|
|
8
|
+
|
|
9
|
+
- Pinned patched transitive `brace-expansion` and `postcss` releases so the CI and release audit remain clean without changing the MCP Inspector surface.
|
|
10
|
+
|
|
11
|
+
## 2.3.1 - 2026-07-26
|
|
12
|
+
|
|
13
|
+
- Fixed large vanilla-plus-mod technology scans that could consume multiple gigabytes and leave the MCP transport open until a client timeout closed it.
|
|
14
|
+
- Added automatic focused technology analysis for game-backed and large workspaces, retaining direct source evidence and helper-call indexing while deferring workspace-wide scripted-effect projections.
|
|
15
|
+
|
|
3
16
|
## 2.3.0 - 2026-07-22
|
|
4
17
|
|
|
5
18
|
- Added a read-only AI and MTTH Scenario Analyzer with inspect, evaluate, sweep, simulate, sequence, compare, and render tools.
|
package/dist/bin/stdio.js
CHANGED
|
@@ -8,6 +8,10 @@ async function main() {
|
|
|
8
8
|
const engine = await createEngine();
|
|
9
9
|
const context = {};
|
|
10
10
|
const server = createMcpServer(engine, context);
|
|
11
|
+
if (process.env.HOI4_AGENT_TOOLS_CHAOSX === '1') {
|
|
12
|
+
const { registerChaosxTools } = await import('../hoi4_agent_tools/mcp/tools/chaosx.js');
|
|
13
|
+
registerChaosxTools(server, engine, context);
|
|
14
|
+
}
|
|
11
15
|
server.server.onerror = (error) => {
|
|
12
16
|
const fatalInputError = error instanceof StdioFrameLimitError || error instanceof StdioInvalidUtf8Error;
|
|
13
17
|
const rejectedMessage = error instanceof StdioInvalidMessageError;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { compareCodeUnits } from '../core/canonical.js';
|
|
1
|
+
import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
|
|
2
2
|
import { EventAnalysisBudget } from './limits.js';
|
|
3
3
|
import { iterativeStronglyConnectedComponents } from './scc.js';
|
|
4
4
|
function sortedUnique(values) {
|
|
@@ -41,6 +41,178 @@ function edgeMaps(edges) {
|
|
|
41
41
|
}
|
|
42
42
|
return { outgoing, incoming };
|
|
43
43
|
}
|
|
44
|
+
function helperName(node) {
|
|
45
|
+
const name = node.metadata.name;
|
|
46
|
+
if (typeof name === 'string')
|
|
47
|
+
return name;
|
|
48
|
+
return node.label || node.id.replace(/^helper:/u, '');
|
|
49
|
+
}
|
|
50
|
+
function collapsedHelperEdge(path, helperStack, from, to) {
|
|
51
|
+
const first = path[0];
|
|
52
|
+
const last = path.at(-1);
|
|
53
|
+
if (helperStack.length === 0)
|
|
54
|
+
return first;
|
|
55
|
+
return {
|
|
56
|
+
id: `edge:lazy-helper-projection:${hashCanonical({
|
|
57
|
+
from,
|
|
58
|
+
to,
|
|
59
|
+
edges: path.map(({ id }) => id),
|
|
60
|
+
}).slice(0, 32)}`,
|
|
61
|
+
from,
|
|
62
|
+
to,
|
|
63
|
+
reason: 'scripted_effect_expansion',
|
|
64
|
+
conditions: path.flatMap(({ conditions }) => conditions),
|
|
65
|
+
helperStack: [...helperStack],
|
|
66
|
+
location: first.location,
|
|
67
|
+
provenance: path.flatMap(({ provenance }) => provenance),
|
|
68
|
+
confidence: last.confidence,
|
|
69
|
+
derived: true,
|
|
70
|
+
...(last.timing === undefined ? {} : { timing: last.timing }),
|
|
71
|
+
...(last.weight === undefined ? {} : { weight: last.weight }),
|
|
72
|
+
...(last.scope === undefined ? {} : { scope: last.scope }),
|
|
73
|
+
metadata: { structuralEdgeIds: path.map(({ id }) => id) },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build only the helper projections reached by one bounded trace. Full-workspace
|
|
78
|
+
* helper materialisation is useful for broad reports, but it is unnecessarily
|
|
79
|
+
* expensive when an agent asks for one route in a large installed game.
|
|
80
|
+
*/
|
|
81
|
+
function lazyCollapsedEdges(graph, nodeId, direction, maps, maximumPaths, signal) {
|
|
82
|
+
const nodes = nodeMap(graph);
|
|
83
|
+
const retained = new Map();
|
|
84
|
+
let pathVisits = 0;
|
|
85
|
+
const add = (edge) => {
|
|
86
|
+
retained.set(edge.id, edge);
|
|
87
|
+
};
|
|
88
|
+
const downstream = (edge, path, stack, visited) => {
|
|
89
|
+
signal?.throwIfAborted();
|
|
90
|
+
if (++pathVisits > maximumPaths)
|
|
91
|
+
return;
|
|
92
|
+
if (path.length > 64)
|
|
93
|
+
return;
|
|
94
|
+
const target = nodes.get(edge.to);
|
|
95
|
+
if (target?.kind !== 'helper') {
|
|
96
|
+
add(collapsedHelperEdge(path, stack, nodeId, edge.to));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (visited.has(target.id))
|
|
100
|
+
return;
|
|
101
|
+
for (const next of maps.outgoing.get(target.id) ?? []) {
|
|
102
|
+
downstream(next, [...path, next], [...stack, helperName(target)], new Set([...visited, target.id]));
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const upstream = (edge, path, stack, visited) => {
|
|
106
|
+
signal?.throwIfAborted();
|
|
107
|
+
if (++pathVisits > maximumPaths)
|
|
108
|
+
return;
|
|
109
|
+
if (path.length > 64)
|
|
110
|
+
return;
|
|
111
|
+
const source = nodes.get(edge.from);
|
|
112
|
+
if (source?.kind !== 'helper') {
|
|
113
|
+
const forwardPath = [...path].reverse();
|
|
114
|
+
add(collapsedHelperEdge(forwardPath, [...stack].reverse(), edge.from, nodeId));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (visited.has(source.id))
|
|
118
|
+
return;
|
|
119
|
+
for (const previous of maps.incoming.get(source.id) ?? []) {
|
|
120
|
+
upstream(previous, [...path, previous], [...stack, helperName(source)], new Set([...visited, source.id]));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
if (direction !== 'upstream') {
|
|
124
|
+
for (const edge of maps.outgoing.get(nodeId) ?? [])
|
|
125
|
+
downstream(edge, [edge], [], new Set());
|
|
126
|
+
}
|
|
127
|
+
if (direction !== 'downstream') {
|
|
128
|
+
for (const edge of maps.incoming.get(nodeId) ?? [])
|
|
129
|
+
upstream(edge, [edge], [], new Set());
|
|
130
|
+
}
|
|
131
|
+
return [...retained.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
132
|
+
}
|
|
133
|
+
function traceCollapsedHelpers(graph, starts, boundary, signal) {
|
|
134
|
+
const byId = nodeMap(graph);
|
|
135
|
+
const maps = edgeMaps(graph.edges.filter(({ derived }) => !derived));
|
|
136
|
+
const edgeCache = new Map();
|
|
137
|
+
const edgesFor = (nodeId) => {
|
|
138
|
+
const key = `${nodeId}\0${boundary.direction}`;
|
|
139
|
+
const cached = edgeCache.get(key);
|
|
140
|
+
if (cached !== undefined)
|
|
141
|
+
return cached;
|
|
142
|
+
const edges = lazyCollapsedEdges(graph, nodeId, boundary.direction, maps, Math.max(256, boundary.maxEdges * 2), signal);
|
|
143
|
+
edgeCache.set(key, edges);
|
|
144
|
+
return edges;
|
|
145
|
+
};
|
|
146
|
+
const depthByNode = new Map(starts.map((id) => [id, 0]));
|
|
147
|
+
const queue = [...starts];
|
|
148
|
+
const retainedEdges = new Map();
|
|
149
|
+
const boundaryNodes = new Set();
|
|
150
|
+
let truncated = false;
|
|
151
|
+
let cursor = 0;
|
|
152
|
+
while (cursor < queue.length) {
|
|
153
|
+
if ((cursor & 255) === 0)
|
|
154
|
+
signal?.throwIfAborted();
|
|
155
|
+
const current = queue[cursor++];
|
|
156
|
+
const depth = depthByNode.get(current) ?? 0;
|
|
157
|
+
const nextEdges = edgesFor(current);
|
|
158
|
+
if (depth >= boundary.maxDepth) {
|
|
159
|
+
if (nextEdges.length > 0) {
|
|
160
|
+
boundaryNodes.add(current);
|
|
161
|
+
truncated = true;
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
for (const edge of nextEdges) {
|
|
166
|
+
if (retainedEdges.size >= boundary.maxEdges) {
|
|
167
|
+
boundaryNodes.add(current);
|
|
168
|
+
truncated = true;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
const next = boundary.direction === 'upstream'
|
|
172
|
+
? edge.from
|
|
173
|
+
: boundary.direction === 'downstream'
|
|
174
|
+
? edge.to
|
|
175
|
+
: edge.from === current
|
|
176
|
+
? edge.to
|
|
177
|
+
: edge.from;
|
|
178
|
+
const known = depthByNode.has(next);
|
|
179
|
+
if (!known && depthByNode.size >= boundary.maxNodes) {
|
|
180
|
+
boundaryNodes.add(current);
|
|
181
|
+
truncated = true;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
retainedEdges.set(edge.id, edge);
|
|
185
|
+
if (!known) {
|
|
186
|
+
depthByNode.set(next, depth + 1);
|
|
187
|
+
queue.push(next);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const retainedNodes = [...depthByNode.keys()]
|
|
192
|
+
.flatMap((id) => {
|
|
193
|
+
const node = byId.get(id);
|
|
194
|
+
return node === undefined ? [] : [node];
|
|
195
|
+
})
|
|
196
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
197
|
+
const layers = [...new Set(depthByNode.values())]
|
|
198
|
+
.sort((left, right) => left - right)
|
|
199
|
+
.map((depth) => ({
|
|
200
|
+
depth,
|
|
201
|
+
nodeIds: [...depthByNode.entries()]
|
|
202
|
+
.filter(([, value]) => value === depth)
|
|
203
|
+
.map(([id]) => id)
|
|
204
|
+
.sort(compareCodeUnits),
|
|
205
|
+
}));
|
|
206
|
+
return {
|
|
207
|
+
startNodeIds: [...starts],
|
|
208
|
+
nodes: retainedNodes,
|
|
209
|
+
edges: [...retainedEdges.values()].sort((left, right) => compareCodeUnits(left.id, right.id)),
|
|
210
|
+
layers,
|
|
211
|
+
boundary,
|
|
212
|
+
boundaryNodeIds: [...boundaryNodes].sort(compareCodeUnits),
|
|
213
|
+
truncated,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
44
216
|
function edgesForDirection(nodeId, direction, maps) {
|
|
45
217
|
const results = [];
|
|
46
218
|
if (direction !== 'upstream') {
|
|
@@ -58,6 +230,11 @@ export function traceEventGraph(graph, startNodeIds, boundary, signal) {
|
|
|
58
230
|
const byId = nodeMap(graph);
|
|
59
231
|
const resolvedStarts = sortedUnique(startNodeIds).filter((id) => byId.has(id));
|
|
60
232
|
const starts = resolvedStarts.slice(0, Math.max(0, boundary.maxNodes));
|
|
233
|
+
if (!boundary.expandHelpers &&
|
|
234
|
+
!graph.edges.some(({ derived }) => derived) &&
|
|
235
|
+
starts.every((id) => byId.get(id)?.kind !== 'helper')) {
|
|
236
|
+
return traceCollapsedHelpers(graph, starts, boundary, signal);
|
|
237
|
+
}
|
|
61
238
|
const edges = eventFlowEdges(graph, boundary.expandHelpers);
|
|
62
239
|
const maps = edgeMaps(edges);
|
|
63
240
|
const depthByNode = new Map(starts.map((id) => [id, 0]));
|
|
@@ -29,6 +29,14 @@ function activeFiles(snapshot) {
|
|
|
29
29
|
!/(?:^|\/)(?:supply_nodes|railways|buildings|unitstacks|weatherpositions)\.txt$/iu.test(relativePath.replaceAll('\\', '/')))
|
|
30
30
|
.sort((left, right) => compareCodeUnits(left.displayPath, right.displayPath));
|
|
31
31
|
}
|
|
32
|
+
const EVENT_SETUP_CALL_PATTERN = /\b(?:country_event|news_event|state_event|unit_leader_event|operative_leader_event|fire_event|event)\s*=/iu;
|
|
33
|
+
function shouldAnalyzeEventFile(file) {
|
|
34
|
+
const sourcePath = file.relativePath.replaceAll('\\', '/').toLowerCase();
|
|
35
|
+
if (!sourcePath.startsWith('history/countries/') && !sourcePath.startsWith('history/states/')) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return EVENT_SETUP_CALL_PATTERN.test(file.bytes.toString('utf8'));
|
|
39
|
+
}
|
|
32
40
|
function catalogFor(snapshot) {
|
|
33
41
|
const unshadowedEventSymbols = snapshot.index
|
|
34
42
|
.findAll('event')
|
|
@@ -1444,6 +1452,8 @@ function analyzeFragments(snapshot, files, catalog, options, budget) {
|
|
|
1444
1452
|
const fragments = [];
|
|
1445
1453
|
for (const file of files) {
|
|
1446
1454
|
budget.spend('source_fragment');
|
|
1455
|
+
if (!shouldAnalyzeEventFile(file))
|
|
1456
|
+
continue;
|
|
1447
1457
|
if (snapshot.index.isSourceSkipped(file.displayPath))
|
|
1448
1458
|
continue;
|
|
1449
1459
|
const cacheKey = eventSemanticFragmentCacheKey(file, catalog.fingerprint);
|
|
@@ -1560,14 +1570,17 @@ export function buildEventGraph(snapshot, options = {}) {
|
|
|
1560
1570
|
graph.edges = stableUnique(graph.edges);
|
|
1561
1571
|
graph.stateAccesses = stableUnique(graph.stateAccesses);
|
|
1562
1572
|
graph.unresolved = stableUnique(graph.unresolved);
|
|
1563
|
-
const
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1573
|
+
const focused = options.analysisMode === 'focused';
|
|
1574
|
+
const stateLinks = focused ? [] : stateLinksFor(graph, budget);
|
|
1575
|
+
if (!focused) {
|
|
1576
|
+
duplicateAndReferenceIssues(graph);
|
|
1577
|
+
unresolvedIssues(graph);
|
|
1578
|
+
structuralCallIssues(graph, budget);
|
|
1579
|
+
localisationIssues(graph);
|
|
1580
|
+
stateLifecycleIssues(graph, stateLinks, budget);
|
|
1581
|
+
unreachableIssues(graph, budget);
|
|
1582
|
+
normalizeIssueSubjects(graph);
|
|
1583
|
+
}
|
|
1571
1584
|
graph.issues = stableIssues(graph.issues);
|
|
1572
1585
|
assertGraphLimit(graph.nodes.length, EVENT_GRAPH_MAX_NODES, 'EVENT_NODE_LIMIT', 'node count');
|
|
1573
1586
|
assertGraphLimit(graph.edges.length, EVENT_GRAPH_MAX_EDGES, 'EVENT_EDGE_LIMIT', 'edge count');
|
|
@@ -1577,7 +1590,8 @@ export function buildEventGraph(snapshot, options = {}) {
|
|
|
1577
1590
|
assertGraphLimit(graph.unresolved.length, EVENT_GRAPH_MAX_UNRESOLVED, 'EVENT_UNRESOLVED_LIMIT', 'unresolved-analysis count');
|
|
1578
1591
|
budget.check();
|
|
1579
1592
|
const diagnostics = sortDiagnostics([...snapshot.diagnostics, ...partial.diagnostics]);
|
|
1580
|
-
const complete =
|
|
1593
|
+
const complete = !focused &&
|
|
1594
|
+
snapshot.complete &&
|
|
1581
1595
|
graph.unresolved.length === 0 &&
|
|
1582
1596
|
!graph.issues.some(({ classification, severity }) => classification === 'unresolved_analysis' || severity === 'blocker');
|
|
1583
1597
|
const nodes = sortNodes(graph.nodes);
|
|
@@ -1588,6 +1602,7 @@ export function buildEventGraph(snapshot, options = {}) {
|
|
|
1588
1602
|
return {
|
|
1589
1603
|
schemaVersion: EVENT_GRAPH_SCHEMA_VERSION,
|
|
1590
1604
|
parserVersion: EVENT_GRAPH_PARSER_VERSION,
|
|
1605
|
+
analysisMode: options.analysisMode ?? 'full',
|
|
1591
1606
|
workspaceId: snapshot.workspaceId,
|
|
1592
1607
|
workspaceIdentity: options.workspaceIdentity ?? hashCanonical({ workspaceId: snapshot.workspaceId }),
|
|
1593
1608
|
revision: snapshot.revision,
|
|
@@ -4,7 +4,10 @@ export const EVENT_GRAPH_MAX_EDGES = 500_000;
|
|
|
4
4
|
export const EVENT_GRAPH_MAX_STATE_ACCESSES = 250_000;
|
|
5
5
|
export const EVENT_GRAPH_MAX_STATE_LINKS = 250_000;
|
|
6
6
|
export const EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES = 500_000;
|
|
7
|
-
|
|
7
|
+
// Installed-game scans can legitimately produce more than twenty thousand source-linked
|
|
8
|
+
// findings before an agent narrows the query. Keep the graph bounded, but do not reject a
|
|
9
|
+
// complete large workspace merely because its diagnostics are numerous.
|
|
10
|
+
export const EVENT_GRAPH_MAX_ISSUES = 100_000;
|
|
8
11
|
export const EVENT_GRAPH_MAX_UNRESOLVED = 50_000;
|
|
9
12
|
export const EVENT_GRAPH_MAX_HELPER_DEPTH = 64;
|
|
10
13
|
export const EVENT_GRAPH_MAX_HELPER_PROJECTIONS = 200_000;
|
|
@@ -42,14 +42,16 @@ class BoundedEventFragmentCache {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
function eventGraphCacheKey(workspaceId, projectHelpers) {
|
|
46
|
-
return `${workspaceId}\0${projectHelpers ? 'expanded' : 'structural'}`;
|
|
45
|
+
function eventGraphCacheKey(workspaceId, projectHelpers, analysisMode = 'full') {
|
|
46
|
+
return `${workspaceId}\0${projectHelpers ? 'expanded' : 'structural'}\0${analysisMode}`;
|
|
47
47
|
}
|
|
48
48
|
const serviceState = new WeakMap();
|
|
49
49
|
const EVENT_GRAPH_ARTIFACT_MAX_BYTES = 67_108_864;
|
|
50
50
|
const EVENT_GRAPH_ARTIFACT_MAX_CHUNKS = 1_024;
|
|
51
51
|
const EVENT_SCAN_FULL_GRAPH_RECORD_LIMIT = 100_000;
|
|
52
52
|
const EVENT_SCAN_SUMMARY_SAMPLE_LIMIT = 100;
|
|
53
|
+
const EVENT_FOCUSED_EVIDENCE_LIMIT = 2_000;
|
|
54
|
+
const EVENT_FOCUSED_GRAPH_FILE_THRESHOLD = 1_000;
|
|
53
55
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
54
56
|
const graphHashCache = new WeakMap();
|
|
55
57
|
function groupedCounts(values, key) {
|
|
@@ -116,7 +118,16 @@ function cachedEventGraphHash(graph, signal) {
|
|
|
116
118
|
const cached = graphHashCache.get(graph);
|
|
117
119
|
if (cached !== undefined)
|
|
118
120
|
return cached;
|
|
119
|
-
const computed =
|
|
121
|
+
const computed = graph.analysisMode === 'focused'
|
|
122
|
+
? hashCanonical({
|
|
123
|
+
schemaVersion: graph.schemaVersion,
|
|
124
|
+
parserVersion: graph.parserVersion,
|
|
125
|
+
workspaceIdentity: graph.workspaceIdentity,
|
|
126
|
+
revision: graph.revision,
|
|
127
|
+
statistics: graph.statistics,
|
|
128
|
+
sourceHashes: graph.sourceHashes,
|
|
129
|
+
})
|
|
130
|
+
: eventGraphHash(graph, signal);
|
|
120
131
|
graphHashCache.set(graph, computed);
|
|
121
132
|
return computed;
|
|
122
133
|
}
|
|
@@ -135,6 +146,55 @@ function sharedState(engine) {
|
|
|
135
146
|
function engineGeneration(engine, workspaceId) {
|
|
136
147
|
return engine.generation(workspaceId);
|
|
137
148
|
}
|
|
149
|
+
function eventScanPatterns(workspace) {
|
|
150
|
+
const roots = workspace.registration.roots;
|
|
151
|
+
const under = (values, glob) => values.map((root) => `${root.replaceAll('\\', '/').replace(/\/$/u, '')}/${glob}`);
|
|
152
|
+
return [
|
|
153
|
+
'events/**/*.txt',
|
|
154
|
+
'history/countries/**/*.txt',
|
|
155
|
+
...under(roots.states, '**/*.txt'),
|
|
156
|
+
'common/continuous_focus/**/*.txt',
|
|
157
|
+
'common/decisions/**/*.txt',
|
|
158
|
+
'common/ideas/**/*.txt',
|
|
159
|
+
'common/characters/**/*.txt',
|
|
160
|
+
'common/operations/**/*.txt',
|
|
161
|
+
'common/raids/**/*.txt',
|
|
162
|
+
'common/bop/**/*.txt',
|
|
163
|
+
'common/resistance_compliance_modifiers/**/*.txt',
|
|
164
|
+
'common/abilities/**/*.txt',
|
|
165
|
+
'common/on_actions/**/*.txt',
|
|
166
|
+
'common/special_projects/**/*.txt',
|
|
167
|
+
'common/scripted_effects/**/*.txt',
|
|
168
|
+
...under(roots.focus, '**/*.txt'),
|
|
169
|
+
...under(roots.scriptedGui, '**/*.txt'),
|
|
170
|
+
...under(roots.localisation, 'english/**/*.{yml,yaml}'),
|
|
171
|
+
...under(roots.localisation, '*.{yml,yaml}'),
|
|
172
|
+
].sort(compareCodeUnits);
|
|
173
|
+
}
|
|
174
|
+
async function scanEventSources(engine, workspaceId, workspace, principal, signal) {
|
|
175
|
+
const patterns = eventScanPatterns(workspace);
|
|
176
|
+
const sourceKinds = ['mod', 'dependency', 'fixture'];
|
|
177
|
+
const scans = await Promise.all([
|
|
178
|
+
engine.scan(workspaceId, { patterns, rootKinds: sourceKinds }, principal, signal),
|
|
179
|
+
...(workspace.roots.some(({ kind }) => kind === 'game')
|
|
180
|
+
? [engine.scan(workspaceId, { patterns, rootKinds: ['game'] }, principal, signal)]
|
|
181
|
+
: []),
|
|
182
|
+
]);
|
|
183
|
+
const files = scans.flatMap(({ files }) => files.map((file) => ({ ...file })));
|
|
184
|
+
recomputeShadowing(files);
|
|
185
|
+
files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
|
|
186
|
+
const index = engine.indexFiles(files);
|
|
187
|
+
return {
|
|
188
|
+
workspaceId,
|
|
189
|
+
revision: snapshotRevision(files),
|
|
190
|
+
files,
|
|
191
|
+
index,
|
|
192
|
+
complete: scans.every(({ complete }) => complete) && index.complete,
|
|
193
|
+
skippedSourceCount: index.skippedSourceCount,
|
|
194
|
+
skippedSources: index.skippedSources,
|
|
195
|
+
diagnostics: [...scans.flatMap(({ diagnostics }) => diagnostics), ...index.diagnostics],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
138
198
|
function rememberGraph(state, workspaceId, graph) {
|
|
139
199
|
const history = state.history.get(workspaceId) ?? new Map();
|
|
140
200
|
history.delete(graph.revision);
|
|
@@ -163,6 +223,20 @@ function eventIssueDiagnostic(issue) {
|
|
|
163
223
|
},
|
|
164
224
|
};
|
|
165
225
|
}
|
|
226
|
+
function boundedEvidence(values, limit = EVENT_FOCUSED_EVIDENCE_LIMIT) {
|
|
227
|
+
if (values.length <= limit)
|
|
228
|
+
return { values: [...values] };
|
|
229
|
+
return {
|
|
230
|
+
values: values.slice(0, limit),
|
|
231
|
+
inventory: { count: values.length, retainedCount: limit, truncated: true },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function boundedUnresolved(graph, selectedNodeIds) {
|
|
235
|
+
const relevant = selectedNodeIds === undefined
|
|
236
|
+
? graph.unresolved
|
|
237
|
+
: graph.unresolved.filter(({ id, ownerId }) => selectedNodeIds.has(id) || (ownerId !== undefined && selectedNodeIds.has(ownerId)));
|
|
238
|
+
return boundedEvidence(relevant);
|
|
239
|
+
}
|
|
166
240
|
export function eventDiagnostics(graph) {
|
|
167
241
|
const existing = new Set(graph.diagnostics.map(({ code, location }) => `${code}:${location?.path ?? ''}:${location?.start.offset ?? -1}`));
|
|
168
242
|
return [
|
|
@@ -618,37 +692,56 @@ export class EventChainViewer {
|
|
|
618
692
|
const workspace = this.engine.resolver.get(workspaceId, options.principal);
|
|
619
693
|
const generation = engineGeneration(this.engine, workspaceId);
|
|
620
694
|
const projectHelpers = options.projectHelpers ?? true;
|
|
621
|
-
const
|
|
695
|
+
const analysisMode = options.analysisMode ?? 'full';
|
|
696
|
+
const cacheKey = eventGraphCacheKey(workspaceId, projectHelpers, analysisMode);
|
|
622
697
|
const cached = this.#state.current.get(cacheKey);
|
|
623
698
|
if (options.refresh !== true && cached?.generation === generation) {
|
|
624
699
|
return cached.graph;
|
|
625
700
|
}
|
|
626
|
-
const sibling = this.#state.current.get(eventGraphCacheKey(workspaceId, !projectHelpers))
|
|
701
|
+
const sibling = this.#state.current.get(eventGraphCacheKey(workspaceId, !projectHelpers, analysisMode)) ??
|
|
702
|
+
this.#state.current.get(eventGraphCacheKey(workspaceId, !projectHelpers)) ??
|
|
703
|
+
this.#state.current.get(eventGraphCacheKey(workspaceId, projectHelpers));
|
|
627
704
|
const snapshot = options.refresh !== true && sibling?.generation === generation
|
|
628
705
|
? sibling.snapshot
|
|
629
|
-
: await this.engine
|
|
706
|
+
: await scanEventSources(this.engine, workspaceId, workspace, options.principal, options.signal);
|
|
630
707
|
options.signal?.throwIfAborted();
|
|
631
708
|
if (cached?.snapshot.revision === snapshot.revision && cached.generation === generation) {
|
|
632
709
|
return cached.graph;
|
|
633
710
|
}
|
|
711
|
+
const buildAnalysisMode = analysisMode === 'focused' && snapshot.files.length > EVENT_FOCUSED_GRAPH_FILE_THRESHOLD
|
|
712
|
+
? 'focused'
|
|
713
|
+
: 'full';
|
|
634
714
|
const graph = buildEventGraph(snapshot, {
|
|
635
715
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
636
716
|
cache: this.#state.fragments,
|
|
637
717
|
workspaceIdentity: workspace.workspaceIdentity,
|
|
638
718
|
projectHelpers,
|
|
719
|
+
analysisMode: buildAnalysisMode,
|
|
639
720
|
});
|
|
640
721
|
this.#state.current.set(cacheKey, { generation, snapshot, graph });
|
|
641
|
-
|
|
722
|
+
if (analysisMode === 'full')
|
|
723
|
+
rememberGraph(this.#state, workspaceId, graph);
|
|
642
724
|
return graph;
|
|
643
725
|
}
|
|
644
726
|
async inspect(input) {
|
|
645
727
|
input.signal?.throwIfAborted();
|
|
646
|
-
const
|
|
728
|
+
const scannedGraph = await this.scan(input.workspaceId, {
|
|
647
729
|
refresh: input.refresh ?? input.mode === 'scan',
|
|
648
|
-
|
|
730
|
+
// Focused routes traverse helper nodes directly and collapse only the
|
|
731
|
+
// selected path. Avoid materialising the full workspace projection first.
|
|
732
|
+
projectHelpers: input.mode !== 'scan' &&
|
|
733
|
+
input.mode !== 'roots' &&
|
|
734
|
+
input.mode !== 'trace' &&
|
|
735
|
+
input.mode !== 'explain_path',
|
|
736
|
+
...(input.mode === 'trace' || input.mode === 'explain_path'
|
|
737
|
+
? { analysisMode: 'focused' }
|
|
738
|
+
: {}),
|
|
649
739
|
...(input.principal === undefined ? {} : { principal: input.principal }),
|
|
650
740
|
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
651
741
|
});
|
|
742
|
+
// Keep the service result's graph identity distinct from a prior scan while
|
|
743
|
+
// reusing its structural cache and avoiding a second large graph build.
|
|
744
|
+
const graph = input.mode === 'trace' || input.mode === 'explain_path' ? { ...scannedGraph } : scannedGraph;
|
|
652
745
|
const maxDepth = input.maxDepth ?? 8;
|
|
653
746
|
const maxNodes = input.maxNodes ?? 500;
|
|
654
747
|
const maxEdges = input.maxEdges ?? 2_000;
|
|
@@ -671,28 +764,41 @@ export class EventChainViewer {
|
|
|
671
764
|
report = eventScanReport(graph);
|
|
672
765
|
break;
|
|
673
766
|
case 'roots':
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
767
|
+
{
|
|
768
|
+
const unresolved = boundedUnresolved(graph);
|
|
769
|
+
report = {
|
|
770
|
+
graphRevision: graph.revision,
|
|
771
|
+
sourceHashes: graph.sourceHashes,
|
|
772
|
+
roots: discoverEventRoots(graph, input.signal),
|
|
773
|
+
unresolved: unresolved.values,
|
|
774
|
+
...(unresolved.inventory === undefined
|
|
775
|
+
? {}
|
|
776
|
+
: { unresolvedInventory: unresolved.inventory }),
|
|
777
|
+
};
|
|
778
|
+
}
|
|
680
779
|
break;
|
|
681
780
|
case 'trace':
|
|
682
781
|
if (input.selector === undefined)
|
|
683
782
|
throw new ServiceError('EVENT_SELECTOR_REQUIRED', 'Trace requires a selector');
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
sourceHashes: graph.sourceHashes,
|
|
687
|
-
trace: traceSelectedEvents(graph, input.selector, {
|
|
783
|
+
{
|
|
784
|
+
const trace = traceSelectedEvents(graph, input.selector, {
|
|
688
785
|
maxDepth,
|
|
689
786
|
maxNodes,
|
|
690
787
|
maxEdges,
|
|
691
788
|
direction: input.direction ?? 'both',
|
|
692
789
|
expandHelpers: input.expandHelpers ?? false,
|
|
693
|
-
}, input.signal)
|
|
694
|
-
unresolved
|
|
695
|
-
|
|
790
|
+
}, input.signal);
|
|
791
|
+
const unresolved = boundedUnresolved(graph, new Set(trace.nodes.map(({ id }) => id)));
|
|
792
|
+
report = {
|
|
793
|
+
graphRevision: graph.revision,
|
|
794
|
+
sourceHashes: graph.sourceHashes,
|
|
795
|
+
trace,
|
|
796
|
+
unresolved: unresolved.values,
|
|
797
|
+
...(unresolved.inventory === undefined
|
|
798
|
+
? {}
|
|
799
|
+
: { unresolvedInventory: unresolved.inventory }),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
696
802
|
break;
|
|
697
803
|
case 'explain_path':
|
|
698
804
|
if (input.from === undefined || input.to === undefined)
|
|
@@ -704,31 +810,54 @@ export class EventChainViewer {
|
|
|
704
810
|
};
|
|
705
811
|
break;
|
|
706
812
|
case 'state_flow':
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
813
|
+
{
|
|
814
|
+
const unresolved = boundedUnresolved(graph);
|
|
815
|
+
const issues = boundedEvidence(lintEventGraph(graph, input.selector, input.signal).filter(({ code }) => /(?:STATE|FLAG|VARIABLE|ARRAY|TARGET|SCOPE)/u.test(code)));
|
|
816
|
+
report = {
|
|
817
|
+
graphRevision: graph.revision,
|
|
818
|
+
sourceHashes: graph.sourceHashes,
|
|
819
|
+
flow: inspectEventStateFlow(graph, input.selector, input.stateSubject, input.signal),
|
|
820
|
+
issues: issues.values,
|
|
821
|
+
unresolved: unresolved.values,
|
|
822
|
+
...(issues.inventory === undefined ? {} : { issueInventory: issues.inventory }),
|
|
823
|
+
...(unresolved.inventory === undefined
|
|
824
|
+
? {}
|
|
825
|
+
: { unresolvedInventory: unresolved.inventory }),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
713
828
|
break;
|
|
714
829
|
case 'lint':
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
830
|
+
{
|
|
831
|
+
const issues = boundedEvidence(lintEventGraph(graph, input.selector, input.signal));
|
|
832
|
+
const unresolved = boundedUnresolved(graph);
|
|
833
|
+
report = {
|
|
834
|
+
graphRevision: graph.revision,
|
|
835
|
+
sourceHashes: graph.sourceHashes,
|
|
836
|
+
issues: issues.values,
|
|
837
|
+
complete: graph.complete,
|
|
838
|
+
unresolved: unresolved.values,
|
|
839
|
+
...(issues.inventory === undefined ? {} : { issueInventory: issues.inventory }),
|
|
840
|
+
...(unresolved.inventory === undefined
|
|
841
|
+
? {}
|
|
842
|
+
: { unresolvedInventory: unresolved.inventory }),
|
|
843
|
+
};
|
|
844
|
+
}
|
|
722
845
|
break;
|
|
723
846
|
case 'impact':
|
|
724
847
|
if (input.impactSubject === undefined)
|
|
725
848
|
throw new ServiceError('EVENT_IMPACT_SUBJECT_REQUIRED', 'Impact analysis requires a subject');
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
849
|
+
{
|
|
850
|
+
const unresolved = boundedUnresolved(graph);
|
|
851
|
+
report = {
|
|
852
|
+
graphRevision: graph.revision,
|
|
853
|
+
sourceHashes: graph.sourceHashes,
|
|
854
|
+
impact: analyzeEventImpact(graph, input.impactSubject, input.signal),
|
|
855
|
+
unresolved: unresolved.values,
|
|
856
|
+
...(unresolved.inventory === undefined
|
|
857
|
+
? {}
|
|
858
|
+
: { unresolvedInventory: unresolved.inventory }),
|
|
859
|
+
};
|
|
860
|
+
}
|
|
732
861
|
break;
|
|
733
862
|
}
|
|
734
863
|
const graphHash = cachedEventGraphHash(graph, input.signal);
|
|
@@ -742,7 +871,7 @@ export class EventChainViewer {
|
|
|
742
871
|
: undefined;
|
|
743
872
|
const artifactUnresolved = projectedScan
|
|
744
873
|
? graph.unresolved.slice(0, EVENT_SCAN_SUMMARY_SAMPLE_LIMIT)
|
|
745
|
-
: graph.
|
|
874
|
+
: boundedUnresolved(graph).values;
|
|
746
875
|
const reportJson = `${canonicalJson({
|
|
747
876
|
schemaVersion: 'event-analysis.v1',
|
|
748
877
|
graphSchemaVersion: graph.schemaVersion,
|
|
@@ -759,12 +888,12 @@ export class EventChainViewer {
|
|
|
759
888
|
complete: graph.complete,
|
|
760
889
|
filters: analysisFilters,
|
|
761
890
|
unresolved: artifactUnresolved,
|
|
762
|
-
...(
|
|
891
|
+
...(artifactUnresolved.length !== graph.unresolved.length
|
|
763
892
|
? {
|
|
764
893
|
unresolvedInventory: {
|
|
765
894
|
count: graph.unresolved.length,
|
|
766
895
|
retainedCount: artifactUnresolved.length,
|
|
767
|
-
truncated:
|
|
896
|
+
truncated: true,
|
|
768
897
|
},
|
|
769
898
|
}
|
|
770
899
|
: {}),
|
|
@@ -950,7 +1079,15 @@ export class EventChainViewer {
|
|
|
950
1079
|
includeHtml: input.includeHtml ?? false,
|
|
951
1080
|
refresh: input.refresh ?? false,
|
|
952
1081
|
},
|
|
953
|
-
|
|
1082
|
+
...(() => {
|
|
1083
|
+
const unresolved = boundedUnresolved(graph, new Set(render.selectedNodeIds));
|
|
1084
|
+
return {
|
|
1085
|
+
unresolved: unresolved.values,
|
|
1086
|
+
...(unresolved.inventory === undefined
|
|
1087
|
+
? {}
|
|
1088
|
+
: { unresolvedInventory: unresolved.inventory }),
|
|
1089
|
+
};
|
|
1090
|
+
})(),
|
|
954
1091
|
resources: { ...resourceNames, artifacts: resourceLinks },
|
|
955
1092
|
overview: {
|
|
956
1093
|
selectedNodeIds: render.selectedNodeIds,
|