hoi4-agent-tools 1.0.0 → 1.2.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 +21 -0
- package/README.md +27 -16
- package/dist/hoi4_agent_tools/core/engine.js +12 -0
- package/dist/hoi4_agent_tools/event/algorithms.js +208 -0
- package/dist/hoi4_agent_tools/event/artifact-validation.js +572 -0
- package/dist/hoi4_agent_tools/event/compare.js +413 -0
- package/dist/hoi4_agent_tools/event/graph.js +1586 -0
- package/dist/hoi4_agent_tools/event/index.js +10 -0
- package/dist/hoi4_agent_tools/event/layout.js +218 -0
- package/dist/hoi4_agent_tools/event/limits.js +41 -0
- package/dist/hoi4_agent_tools/event/model.js +2 -0
- package/dist/hoi4_agent_tools/event/queries.js +643 -0
- package/dist/hoi4_agent_tools/event/render.js +443 -0
- package/dist/hoi4_agent_tools/event/scc.js +72 -0
- package/dist/hoi4_agent_tools/event/service.js +1147 -0
- package/dist/hoi4_agent_tools/event/source-analysis.js +1458 -0
- package/dist/hoi4_agent_tools/focus/compact.js +432 -0
- package/dist/hoi4_agent_tools/focus/compiler.js +1 -7
- package/dist/hoi4_agent_tools/focus/geometry.js +203 -16
- package/dist/hoi4_agent_tools/focus/index.js +1 -0
- package/dist/hoi4_agent_tools/focus/layout.js +1256 -92
- package/dist/hoi4_agent_tools/focus/limits.js +1 -1
- package/dist/hoi4_agent_tools/focus/lint.js +11 -3
- package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
- package/dist/hoi4_agent_tools/focus/planning.js +6 -1
- package/dist/hoi4_agent_tools/focus/render.js +14 -9
- package/dist/hoi4_agent_tools/mcp/resources/register.js +1 -1
- package/dist/hoi4_agent_tools/mcp/server/context-schemas.js +8 -0
- package/dist/hoi4_agent_tools/mcp/server/create.js +4 -1
- package/dist/hoi4_agent_tools/mcp/server/result.js +117 -52
- package/dist/hoi4_agent_tools/mcp/tools/event.js +459 -0
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +93 -7
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +12 -9
- package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -4
- package/dist/hoi4_agent_tools/mcp/tools/mods.js +1 -1
- package/dist/hoi4_agent_tools/schemas/event.js +95 -0
- package/dist/hoi4_agent_tools/schemas/focus.js +64 -8
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/development.md +10 -0
- package/docs/events.md +113 -0
- package/docs/focus.md +17 -7
- package/docs/setup.md +3 -3
- package/package.json +9 -5
- package/server.json +3 -3
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { canonicalJson, compareCodeUnits, sha256Bytes } from '../core/canonical.js';
|
|
3
|
+
import { RenderBudget, RENDER_MAX_PIXELS } from '../core/render-budget.js';
|
|
4
|
+
import { DeterministicSvgTextRenderer } from '../core/svg-text.js';
|
|
5
|
+
import { eventFlowEdges, traceEventGraph } from './algorithms.js';
|
|
6
|
+
import { layoutEventGraph } from './layout.js';
|
|
7
|
+
import { discoverEventRoots, issueSubjectIds, selectEventNodes, } from './queries.js';
|
|
8
|
+
const colours = {
|
|
9
|
+
event: { fill: '#17324d', stroke: '#62b0e8' },
|
|
10
|
+
option: { fill: '#3b2d4f', stroke: '#c693ef' },
|
|
11
|
+
entry: { fill: '#204638', stroke: '#66d2a2' },
|
|
12
|
+
helper: { fill: '#40391f', stroke: '#d8bd54' },
|
|
13
|
+
unresolved: { fill: '#522a2a', stroke: '#ef7f7f' },
|
|
14
|
+
terminal: { fill: '#32363e', stroke: '#aeb7c4' },
|
|
15
|
+
};
|
|
16
|
+
function escapeXml(value) {
|
|
17
|
+
return value
|
|
18
|
+
.replaceAll('&', '&')
|
|
19
|
+
.replaceAll('<', '<')
|
|
20
|
+
.replaceAll('>', '>')
|
|
21
|
+
.replaceAll('"', '"')
|
|
22
|
+
.replaceAll("'", ''');
|
|
23
|
+
}
|
|
24
|
+
function kindPriority(kind) {
|
|
25
|
+
return { entry: 0, event: 1, option: 2, helper: 3, unresolved: 4, terminal: 5 }[kind];
|
|
26
|
+
}
|
|
27
|
+
function selectedByEdge(graph, predicate) {
|
|
28
|
+
const result = new Set();
|
|
29
|
+
for (const edge of eventFlowEdges(graph, false).filter(predicate)) {
|
|
30
|
+
result.add(edge.from);
|
|
31
|
+
result.add(edge.to);
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
function boundedSelection(graph, options) {
|
|
36
|
+
const maximum = Math.max(1, Math.min(options.maxNodes ?? 120, 240));
|
|
37
|
+
const selected = new Set();
|
|
38
|
+
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
39
|
+
const flowEdges = eventFlowEdges(graph, options.expandHelpers ?? false);
|
|
40
|
+
const add = (ids) => {
|
|
41
|
+
for (const id of ids) {
|
|
42
|
+
if (selected.size >= maximum)
|
|
43
|
+
break;
|
|
44
|
+
if (byId.has(id))
|
|
45
|
+
selected.add(id);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const selectorNodes = options.selector === undefined
|
|
49
|
+
? []
|
|
50
|
+
: selectEventNodes(graph, options.selector, options.signal).map(({ id }) => id);
|
|
51
|
+
let selectorTrace = new Set();
|
|
52
|
+
if (selectorNodes.length > 0) {
|
|
53
|
+
const trace = traceEventGraph(graph, selectorNodes, {
|
|
54
|
+
maxDepth: options.maxDepth ?? 4,
|
|
55
|
+
maxNodes: maximum,
|
|
56
|
+
maxEdges: maximum * 4,
|
|
57
|
+
direction: options.direction ?? 'both',
|
|
58
|
+
expandHelpers: options.expandHelpers ?? false,
|
|
59
|
+
}, options.signal);
|
|
60
|
+
selectorTrace = new Set(trace.nodes.map(({ id }) => id));
|
|
61
|
+
}
|
|
62
|
+
const withinSelector = (ids) => {
|
|
63
|
+
const values = [...new Set(ids)].sort(compareCodeUnits);
|
|
64
|
+
return selectorTrace.size === 0 ? values : values.filter((id) => selectorTrace.has(id));
|
|
65
|
+
};
|
|
66
|
+
if (options.view === 'entries') {
|
|
67
|
+
const roots = discoverEventRoots(graph, options.signal);
|
|
68
|
+
const rootIds = withinSelector([
|
|
69
|
+
...roots.entryPoints.map(({ id }) => id),
|
|
70
|
+
...roots.automaticEvents.map(({ id }) => id),
|
|
71
|
+
]);
|
|
72
|
+
add(rootIds);
|
|
73
|
+
const retainedRoots = new Set(rootIds);
|
|
74
|
+
add(flowEdges.filter(({ from }) => retainedRoots.has(from)).flatMap(({ from, to }) => [from, to]));
|
|
75
|
+
}
|
|
76
|
+
else if (options.view === 'options') {
|
|
77
|
+
const events = new Set(withinSelector(selectorNodes.length > 0
|
|
78
|
+
? selectorNodes
|
|
79
|
+
: graph.nodes.filter(({ kind }) => kind === 'event').map(({ id }) => id)));
|
|
80
|
+
const optionIds = graph.edges
|
|
81
|
+
.filter(({ from, reason }) => events.has(from) && reason === 'option_branch')
|
|
82
|
+
.map(({ to }) => to);
|
|
83
|
+
add(graph.edges
|
|
84
|
+
.filter(({ from, reason }) => events.has(from) && reason === 'option_branch')
|
|
85
|
+
.flatMap(({ from, to }) => [from, to]));
|
|
86
|
+
const options = new Set(optionIds);
|
|
87
|
+
add(flowEdges.filter(({ from }) => options.has(from)).flatMap(({ from, to }) => [from, to]));
|
|
88
|
+
}
|
|
89
|
+
else if (options.view === 'timing') {
|
|
90
|
+
add(withinSelector(selectedByEdge(graph, ({ timing, weight }) => (timing !== undefined && timing.mode !== 'immediate') || weight !== undefined)));
|
|
91
|
+
}
|
|
92
|
+
else if (options.view === 'state') {
|
|
93
|
+
add(withinSelector(graph.stateAccesses.map(({ ownerId }) => ownerId)));
|
|
94
|
+
}
|
|
95
|
+
else if (options.view === 'targets') {
|
|
96
|
+
add(withinSelector(graph.stateAccesses
|
|
97
|
+
.filter(({ kind }) => kind.endsWith('event_target') || kind === 'saved_scope')
|
|
98
|
+
.map(({ ownerId }) => ownerId)));
|
|
99
|
+
}
|
|
100
|
+
else if (options.view === 'scope') {
|
|
101
|
+
add(withinSelector(selectedByEdge(graph, ({ scope }) => scope !== undefined)));
|
|
102
|
+
}
|
|
103
|
+
else if (options.view === 'terminals') {
|
|
104
|
+
const terminals = withinSelector(graph.nodes.filter(({ kind }) => kind === 'terminal').map(({ id }) => id));
|
|
105
|
+
add(terminals);
|
|
106
|
+
add(graph.edges.filter(({ to }) => terminals.includes(to)).flatMap(({ from, to }) => [from, to]));
|
|
107
|
+
}
|
|
108
|
+
else if (options.view === 'unresolved') {
|
|
109
|
+
const unresolved = withinSelector(graph.nodes.filter(({ kind }) => kind === 'unresolved').map(({ id }) => id));
|
|
110
|
+
add(unresolved);
|
|
111
|
+
add(graph.edges.filter(({ to }) => unresolved.includes(to)).flatMap(({ from, to }) => [from, to]));
|
|
112
|
+
}
|
|
113
|
+
else if (options.view === 'neighborhood' || options.view === 'reachability') {
|
|
114
|
+
add(selectorTrace);
|
|
115
|
+
if (options.view === 'reachability' && selectorTrace.size === 0) {
|
|
116
|
+
const roots = discoverEventRoots(graph, options.signal);
|
|
117
|
+
const rootIds = [
|
|
118
|
+
...roots.entryPoints.map(({ id }) => id),
|
|
119
|
+
...roots.automaticEvents.map(({ id }) => id),
|
|
120
|
+
];
|
|
121
|
+
const trace = traceEventGraph(graph, rootIds, {
|
|
122
|
+
maxDepth: options.maxDepth ?? 4,
|
|
123
|
+
maxNodes: maximum,
|
|
124
|
+
maxEdges: maximum * 4,
|
|
125
|
+
direction: options.direction ?? 'downstream',
|
|
126
|
+
expandHelpers: options.expandHelpers ?? false,
|
|
127
|
+
}, options.signal);
|
|
128
|
+
add(trace.nodes.map(({ id }) => id));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Context nodes make specialized projections readable without filling the
|
|
132
|
+
// remaining budget with unrelated globally sorted nodes.
|
|
133
|
+
let contextChanged = true;
|
|
134
|
+
while (contextChanged && selected.size < maximum) {
|
|
135
|
+
const before = selected.size;
|
|
136
|
+
for (const edge of graph.edges) {
|
|
137
|
+
if (edge.reason === 'option_branch' && selected.has(edge.to))
|
|
138
|
+
add([edge.from]);
|
|
139
|
+
if (byId.get(edge.to)?.kind === 'helper' && selected.has(edge.to))
|
|
140
|
+
add([edge.from]);
|
|
141
|
+
if ((byId.get(edge.to)?.kind === 'unresolved' || byId.get(edge.to)?.kind === 'terminal') &&
|
|
142
|
+
selected.has(edge.to)) {
|
|
143
|
+
add([edge.from]);
|
|
144
|
+
}
|
|
145
|
+
if (byId.get(edge.from)?.kind === 'entry' && selected.has(edge.from))
|
|
146
|
+
add([edge.to]);
|
|
147
|
+
}
|
|
148
|
+
contextChanged = selected.size !== before;
|
|
149
|
+
}
|
|
150
|
+
if (options.view === 'overview') {
|
|
151
|
+
add(selectorTrace);
|
|
152
|
+
if (selected.size < maximum && options.selector === undefined) {
|
|
153
|
+
const roots = discoverEventRoots(graph, options.signal);
|
|
154
|
+
add(roots.entryPoints.map(({ id }) => id));
|
|
155
|
+
add(roots.automaticEvents.map(({ id }) => id));
|
|
156
|
+
}
|
|
157
|
+
if (selected.size < maximum)
|
|
158
|
+
add(withinSelector(graph.issues.flatMap(issueSubjectIds)));
|
|
159
|
+
if (selected.size < maximum) {
|
|
160
|
+
add(withinSelector(graph.nodes.filter(({ kind }) => kind === 'unresolved').map(({ id }) => id)));
|
|
161
|
+
}
|
|
162
|
+
if (selected.size < maximum) {
|
|
163
|
+
add(withinSelector(graph.nodes.filter(({ kind }) => kind === 'terminal').map(({ id }) => id)));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if ((options.view === 'overview' && options.selector === undefined) ||
|
|
167
|
+
(options.view === 'neighborhood' && selectorNodes.length === 0)) {
|
|
168
|
+
add([...graph.nodes]
|
|
169
|
+
.sort((left, right) => kindPriority(left.kind) - kindPriority(right.kind) ||
|
|
170
|
+
compareCodeUnits(left.id, right.id))
|
|
171
|
+
.map(({ id }) => id));
|
|
172
|
+
}
|
|
173
|
+
return { selected, omitted: Math.max(0, graph.nodes.length - selected.size) };
|
|
174
|
+
}
|
|
175
|
+
function edgePath(points) {
|
|
176
|
+
const [first, ...remaining] = points;
|
|
177
|
+
if (first === undefined)
|
|
178
|
+
return '';
|
|
179
|
+
return `M ${first.x} ${first.y} ${remaining.map(({ x, y }) => `L ${x} ${y}`).join(' ')}`;
|
|
180
|
+
}
|
|
181
|
+
function timingSummary(edge) {
|
|
182
|
+
const timing = edge.timing;
|
|
183
|
+
const parts = [];
|
|
184
|
+
if (timing !== undefined) {
|
|
185
|
+
const values = [
|
|
186
|
+
['y', timing.years],
|
|
187
|
+
['h', timing.hours],
|
|
188
|
+
['d', timing.days],
|
|
189
|
+
['mo', timing.months],
|
|
190
|
+
['random h', timing.randomHours],
|
|
191
|
+
['random d', timing.randomDays],
|
|
192
|
+
['random mo', timing.randomMonths],
|
|
193
|
+
['date', timing.date],
|
|
194
|
+
];
|
|
195
|
+
const fields = values.flatMap(([label, value]) => value === undefined ? [] : [`${label} ${value}`]);
|
|
196
|
+
if (timing.mode === 'mean_time_to_happen') {
|
|
197
|
+
parts.push(fields.length === 0
|
|
198
|
+
? `MTTH ${truncateLabel(timing.expression ?? 'unresolved', 32)}`
|
|
199
|
+
: `MTTH ${fields.join(' + ')}`);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
parts.push(fields.length === 0 ? timing.mode : fields.join(' + '));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (edge.weight !== undefined)
|
|
206
|
+
parts.push(`weight ${edge.weight.value}`);
|
|
207
|
+
if (edge.conditions.length > 0) {
|
|
208
|
+
const condition = edge.conditions[0];
|
|
209
|
+
parts.push(`${condition.kind.replaceAll('_', ' ')}: ${truncateLabel(condition.expression, 32)}`);
|
|
210
|
+
if (edge.conditions.length > 1)
|
|
211
|
+
parts.push(`+${edge.conditions.length - 1} guards`);
|
|
212
|
+
}
|
|
213
|
+
return parts.length === 0 ? undefined : parts.join(' · ');
|
|
214
|
+
}
|
|
215
|
+
function scopeSummary(edge) {
|
|
216
|
+
if (edge.scope === undefined)
|
|
217
|
+
return undefined;
|
|
218
|
+
const expression = edge.scope.expression === undefined ? '' : ` via ${truncateLabel(edge.scope.expression, 28)}`;
|
|
219
|
+
const guard = edge.conditions.length === 0 ? '' : ` · ${edge.conditions[0].kind.replaceAll('_', ' ')}`;
|
|
220
|
+
return `${edge.scope.source} → ${edge.scope.destination}${expression}${guard}`;
|
|
221
|
+
}
|
|
222
|
+
function stateAccessSummary(graph, nodeId, view) {
|
|
223
|
+
if (view !== 'state' && view !== 'targets')
|
|
224
|
+
return undefined;
|
|
225
|
+
const accesses = graph.stateAccesses.filter(({ ownerId, kind }) => ownerId === nodeId &&
|
|
226
|
+
(view !== 'targets' || kind.endsWith('event_target') || kind === 'saved_scope'));
|
|
227
|
+
const first = accesses[0];
|
|
228
|
+
if (first === undefined)
|
|
229
|
+
return undefined;
|
|
230
|
+
const suffix = accesses.length > 1 ? ` +${accesses.length - 1}` : '';
|
|
231
|
+
return `${first.access} ${first.kind.replaceAll('_', ' ')}:${first.name}${suffix}`;
|
|
232
|
+
}
|
|
233
|
+
function truncateLabel(value, maximum = 28) {
|
|
234
|
+
return value.length <= maximum ? value : `${value.slice(0, maximum - 1)}…`;
|
|
235
|
+
}
|
|
236
|
+
function renderSvg(graph, layout, selectedNodeIds, view, omittedNodeCount) {
|
|
237
|
+
const text = new DeterministicSvgTextRenderer();
|
|
238
|
+
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
239
|
+
const edgeById = new Map(graph.edges.map((edge) => [edge.id, edge]));
|
|
240
|
+
const placedById = new Map(layout.nodes.map((node) => [node.id, node]));
|
|
241
|
+
const accessById = new Map(graph.stateAccesses.map((access) => [access.id, access]));
|
|
242
|
+
const edges = layout.edges.map((placed) => {
|
|
243
|
+
const edge = edgeById.get(placed.id);
|
|
244
|
+
const removed = edge?.metadata.comparisonStatus === 'removed';
|
|
245
|
+
const colour = removed ? '#ff6b78' : edge?.confidence === 'confirmed' ? '#8298ad' : '#d4a85e';
|
|
246
|
+
const dashed = removed || edge?.confidence !== 'confirmed' ? ' stroke-dasharray="7 5"' : '';
|
|
247
|
+
const sourceAttributes = edge === undefined
|
|
248
|
+
? ''
|
|
249
|
+
: ` data-event-edge-reason="${edge.reason}" data-source-path="${escapeXml(edge.location.path)}" data-source-line="${edge.location.start.line}" data-source-column="${edge.location.start.column}"${removed ? ' data-comparison-status="removed"' : ''}`;
|
|
250
|
+
const semantic = edge === undefined
|
|
251
|
+
? undefined
|
|
252
|
+
: view === 'timing'
|
|
253
|
+
? timingSummary(edge)
|
|
254
|
+
: view === 'scope'
|
|
255
|
+
? scopeSummary(edge)
|
|
256
|
+
: undefined;
|
|
257
|
+
const anchor = placed.points[Math.floor(placed.points.length / 2)];
|
|
258
|
+
const label = semantic === undefined || anchor === undefined
|
|
259
|
+
? ''
|
|
260
|
+
: text.render(truncateLabel(semantic, 72), {
|
|
261
|
+
x: anchor.x + 5,
|
|
262
|
+
y: anchor.y - 6,
|
|
263
|
+
fontSize: 10,
|
|
264
|
+
fill: '#f4d58d',
|
|
265
|
+
weight: 600,
|
|
266
|
+
});
|
|
267
|
+
return `<g><path data-event-edge-id="${escapeXml(placed.id)}"${sourceAttributes} d="${edgePath(placed.points)}" fill="none" stroke="${colour}" stroke-width="2"${dashed} marker-end="url(#event-arrow)"/>${label}</g>`;
|
|
268
|
+
});
|
|
269
|
+
const stateLinks = view !== 'state' && view !== 'targets'
|
|
270
|
+
? []
|
|
271
|
+
: graph.stateLinks.flatMap((link) => {
|
|
272
|
+
const producer = accessById.get(link.producerId);
|
|
273
|
+
const consumer = accessById.get(link.consumerId);
|
|
274
|
+
if (producer === undefined || consumer === undefined)
|
|
275
|
+
return [];
|
|
276
|
+
if (view === 'targets' &&
|
|
277
|
+
!link.stateKind.endsWith('event_target') &&
|
|
278
|
+
link.stateKind !== 'saved_scope')
|
|
279
|
+
return [];
|
|
280
|
+
const from = placedById.get(producer.ownerId);
|
|
281
|
+
const to = placedById.get(consumer.ownerId);
|
|
282
|
+
if (from === undefined || to === undefined)
|
|
283
|
+
return [];
|
|
284
|
+
const start = { x: from.x + from.width / 2, y: from.y + from.height };
|
|
285
|
+
const end = { x: to.x + to.width / 2, y: to.y };
|
|
286
|
+
const bend = Math.max(start.y, end.y) + 18;
|
|
287
|
+
const points = producer.ownerId === consumer.ownerId
|
|
288
|
+
? [
|
|
289
|
+
start,
|
|
290
|
+
{ x: from.x + from.width + 22, y: start.y + 18 },
|
|
291
|
+
{ x: from.x + from.width + 22, y: from.y - 18 },
|
|
292
|
+
end,
|
|
293
|
+
]
|
|
294
|
+
: [start, { x: start.x, y: bend }, { x: end.x, y: bend }, end];
|
|
295
|
+
const anchor = points[Math.floor(points.length / 2)];
|
|
296
|
+
const label = text.render(truncateLabel(`${link.stateKind.replaceAll('_', ' ')}:${link.name}`, 44), {
|
|
297
|
+
x: anchor.x + 4,
|
|
298
|
+
y: anchor.y - 4,
|
|
299
|
+
fontSize: 9,
|
|
300
|
+
fill: '#64e6bd',
|
|
301
|
+
weight: 600,
|
|
302
|
+
});
|
|
303
|
+
return [
|
|
304
|
+
`<g data-event-state-link-id="${escapeXml(link.id)}"><path d="${edgePath(points)}" fill="none" stroke="#49cfa6" stroke-width="2" stroke-dasharray="4 3" marker-end="url(#state-arrow)"/>${label}</g>`,
|
|
305
|
+
];
|
|
306
|
+
});
|
|
307
|
+
const nodes = layout.nodes.flatMap((placed) => {
|
|
308
|
+
const node = byId.get(placed.id);
|
|
309
|
+
if (node === undefined || !selectedNodeIds.has(node.id))
|
|
310
|
+
return [];
|
|
311
|
+
const removed = node.metadata.comparisonStatus === 'removed';
|
|
312
|
+
const colour = removed ? { fill: '#4b2027', stroke: '#ff6b78' } : colours[node.kind];
|
|
313
|
+
const source = node.location;
|
|
314
|
+
const attrs = [
|
|
315
|
+
`data-event-node-id="${escapeXml(node.id)}"`,
|
|
316
|
+
`data-event-node-kind="${node.kind}"`,
|
|
317
|
+
...(source === undefined
|
|
318
|
+
? []
|
|
319
|
+
: [
|
|
320
|
+
`data-source-path="${escapeXml(source.path)}"`,
|
|
321
|
+
`data-source-line="${source.start.line}"`,
|
|
322
|
+
`data-source-column="${source.start.column}"`,
|
|
323
|
+
]),
|
|
324
|
+
...(removed ? ['data-comparison-status="removed"'] : []),
|
|
325
|
+
].join(' ');
|
|
326
|
+
const label = text.render(truncateLabel(node.label), {
|
|
327
|
+
x: placed.x + 12,
|
|
328
|
+
y: placed.y + 31,
|
|
329
|
+
fontSize: 15,
|
|
330
|
+
fill: '#f3f7fb',
|
|
331
|
+
weight: 600,
|
|
332
|
+
targetWidth: placed.width - 24,
|
|
333
|
+
});
|
|
334
|
+
const kind = text.render(node.kind, {
|
|
335
|
+
x: placed.x + 12,
|
|
336
|
+
y: placed.y + 55,
|
|
337
|
+
fontSize: 11,
|
|
338
|
+
fill: colour.stroke,
|
|
339
|
+
weight: 500,
|
|
340
|
+
});
|
|
341
|
+
const stateSummary = stateAccessSummary(graph, node.id, view);
|
|
342
|
+
const stateLabel = stateSummary === undefined
|
|
343
|
+
? ''
|
|
344
|
+
: text.render(truncateLabel(stateSummary, 42), {
|
|
345
|
+
x: placed.x + 12,
|
|
346
|
+
y: placed.y + 68,
|
|
347
|
+
fontSize: 8,
|
|
348
|
+
fill: '#86e8c8',
|
|
349
|
+
weight: 500,
|
|
350
|
+
});
|
|
351
|
+
return [
|
|
352
|
+
`<g ${attrs}><rect x="${placed.x}" y="${placed.y}" width="${placed.width}" height="${placed.height}" rx="10" fill="${colour.fill}" stroke="${colour.stroke}" stroke-width="2"/>${label}${kind}${stateLabel}</g>`,
|
|
353
|
+
];
|
|
354
|
+
});
|
|
355
|
+
const title = text.render(`Event chain · ${view}`, {
|
|
356
|
+
x: 24,
|
|
357
|
+
y: 28,
|
|
358
|
+
fontSize: 17,
|
|
359
|
+
fill: '#e9f2fb',
|
|
360
|
+
weight: 700,
|
|
361
|
+
});
|
|
362
|
+
const summary = text.render(`${layout.nodes.length} shown · ${omittedNodeCount} omitted · ${layout.cyclicComponentCount} cyclic components`, { x: 24, y: layout.height - 14, fontSize: 11, fill: '#9eb0c2' });
|
|
363
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${layout.width}" height="${layout.height}" viewBox="0 0 ${layout.width} ${layout.height}" role="img" aria-label="HOI4 event chain ${escapeXml(view)}"><defs><marker id="event-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#8298ad"/></marker><marker id="state-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#49cfa6"/></marker>${text.definitions()}</defs><rect width="100%" height="100%" fill="#0d1721"/>${title}<g>${edges.join('')}</g><g>${stateLinks.join('')}</g><g>${nodes.join('')}</g>${summary}</svg>`;
|
|
364
|
+
}
|
|
365
|
+
function htmlDocument(svg, json, view) {
|
|
366
|
+
const escapedJson = json.replaceAll('&', '&').replaceAll('<', '<');
|
|
367
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>HOI4 event chain ${escapeXml(view)}</title><style>html,body{margin:0;background:#0d1721;color:#e9f2fb;font-family:system-ui,sans-serif}main{padding:16px;overflow:auto}svg{max-width:none}details{margin-top:12px}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style></head><body><main>${svg}<details><summary>Authoritative JSON projection</summary><pre>${escapedJson}</pre></details></main></body></html>`;
|
|
368
|
+
}
|
|
369
|
+
export async function renderEventGraph(graph, options) {
|
|
370
|
+
options.signal?.throwIfAborted();
|
|
371
|
+
const selection = boundedSelection(graph, options);
|
|
372
|
+
const layout = layoutEventGraph(graph, selection.selected, {
|
|
373
|
+
...(options.expandHelpers === undefined ? {} : { expandHelpers: options.expandHelpers }),
|
|
374
|
+
...(options.compactLayout === undefined ? {} : { compact: options.compactLayout }),
|
|
375
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
376
|
+
});
|
|
377
|
+
const selectedNodes = graph.nodes.filter(({ id }) => selection.selected.has(id));
|
|
378
|
+
const selectedEdges = eventFlowEdges(graph, options.expandHelpers ?? false).filter(({ from, to }) => selection.selected.has(from) && selection.selected.has(to));
|
|
379
|
+
const selectedState = graph.stateAccesses.filter(({ ownerId }) => selection.selected.has(ownerId));
|
|
380
|
+
const selectedStateIds = new Set(selectedState.map(({ id }) => id));
|
|
381
|
+
const selectedStateLinks = graph.stateLinks.filter(({ producerId, consumerId }) => selectedStateIds.has(producerId) && selectedStateIds.has(consumerId));
|
|
382
|
+
const selectedSourcePaths = new Set(selectedNodes.flatMap(({ sourcePath, location }) => [sourcePath, location?.path].filter((value) => value !== undefined)));
|
|
383
|
+
const selectedUnresolved = graph.unresolved.filter(({ id, ownerId, kind, location }) => selection.selected.has(id) ||
|
|
384
|
+
(ownerId !== undefined && selection.selected.has(ownerId)) ||
|
|
385
|
+
(ownerId === undefined &&
|
|
386
|
+
kind === 'partial_source' &&
|
|
387
|
+
location !== undefined &&
|
|
388
|
+
selectedSourcePaths.has(location.path)));
|
|
389
|
+
const json = `${canonicalJson({
|
|
390
|
+
schemaVersion: 'event-render.v1',
|
|
391
|
+
graphSchemaVersion: graph.schemaVersion,
|
|
392
|
+
parserVersion: graph.parserVersion,
|
|
393
|
+
workspaceId: graph.workspaceId,
|
|
394
|
+
workspaceIdentity: graph.workspaceIdentity,
|
|
395
|
+
view: options.view,
|
|
396
|
+
graphRevision: graph.revision,
|
|
397
|
+
sourceHashes: graph.sourceHashes,
|
|
398
|
+
complete: graph.complete,
|
|
399
|
+
filters: {
|
|
400
|
+
...(options.selector === undefined ? {} : { selector: options.selector }),
|
|
401
|
+
direction: options.direction ?? 'both',
|
|
402
|
+
maxDepth: options.maxDepth ?? 4,
|
|
403
|
+
maxNodes: options.maxNodes ?? 120,
|
|
404
|
+
expandHelpers: options.expandHelpers ?? false,
|
|
405
|
+
includeHtml: options.includeHtml ?? true,
|
|
406
|
+
compactLayout: options.compactLayout ?? false,
|
|
407
|
+
},
|
|
408
|
+
selectedNodeIds: [...selection.selected].sort(compareCodeUnits),
|
|
409
|
+
omittedNodeCount: selection.omitted,
|
|
410
|
+
nodes: selectedNodes,
|
|
411
|
+
edges: selectedEdges,
|
|
412
|
+
stateAccesses: selectedState,
|
|
413
|
+
stateLinks: selectedStateLinks,
|
|
414
|
+
layout,
|
|
415
|
+
unresolved: selectedUnresolved,
|
|
416
|
+
})}\n`;
|
|
417
|
+
const svg = renderSvg(graph, layout, selection.selected, options.view, selection.omitted);
|
|
418
|
+
const budget = options.budget ?? new RenderBudget();
|
|
419
|
+
budget.reserve(layout.width, layout.height, 'event chain render');
|
|
420
|
+
budget.reserveRasterOperation(`event-chain:${sha256Bytes(svg)}`, 'event chain SVG rasterization');
|
|
421
|
+
const png = await sharp(Buffer.from(svg, 'utf8'), { limitInputPixels: RENDER_MAX_PIXELS })
|
|
422
|
+
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
423
|
+
.toBuffer();
|
|
424
|
+
options.signal?.throwIfAborted();
|
|
425
|
+
const html = options.includeHtml === false ? undefined : htmlDocument(svg, json, options.view);
|
|
426
|
+
return {
|
|
427
|
+
view: options.view,
|
|
428
|
+
graphRevision: graph.revision,
|
|
429
|
+
selectedNodeIds: [...selection.selected].sort(compareCodeUnits),
|
|
430
|
+
omittedNodeCount: selection.omitted,
|
|
431
|
+
layout,
|
|
432
|
+
json,
|
|
433
|
+
svg,
|
|
434
|
+
png,
|
|
435
|
+
...(html === undefined ? {} : { html }),
|
|
436
|
+
hashes: {
|
|
437
|
+
json: sha256Bytes(json),
|
|
438
|
+
svg: sha256Bytes(svg),
|
|
439
|
+
png: sha256Bytes(png),
|
|
440
|
+
...(html === undefined ? {} : { html: sha256Bytes(html) }),
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { compareCodeUnits } from '../core/canonical.js';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic, non-recursive Tarjan traversal.
|
|
4
|
+
*
|
|
5
|
+
* The caller owns the work policy: `spend` is invoked for every discovered
|
|
6
|
+
* node, inspected edge, and component member popped from the Tarjan stack.
|
|
7
|
+
*/
|
|
8
|
+
export function iterativeStronglyConnectedComponents(nodeIds, outgoing, spend) {
|
|
9
|
+
const orderedNodeIds = [...new Set(nodeIds)].sort(compareCodeUnits);
|
|
10
|
+
const retained = new Set(orderedNodeIds);
|
|
11
|
+
const neighboursFor = (nodeId) => [...new Set(outgoing.get(nodeId) ?? [])]
|
|
12
|
+
.filter((candidate) => retained.has(candidate))
|
|
13
|
+
.sort(compareCodeUnits);
|
|
14
|
+
let nextIndex = 0;
|
|
15
|
+
const indexes = new Map();
|
|
16
|
+
const lowLinks = new Map();
|
|
17
|
+
const tarjanStack = [];
|
|
18
|
+
const onTarjanStack = new Set();
|
|
19
|
+
const components = [];
|
|
20
|
+
const enter = (nodeId, frames) => {
|
|
21
|
+
spend();
|
|
22
|
+
indexes.set(nodeId, nextIndex);
|
|
23
|
+
lowLinks.set(nodeId, nextIndex);
|
|
24
|
+
nextIndex += 1;
|
|
25
|
+
tarjanStack.push(nodeId);
|
|
26
|
+
onTarjanStack.add(nodeId);
|
|
27
|
+
frames.push({ nodeId, neighbours: neighboursFor(nodeId), cursor: 0 });
|
|
28
|
+
};
|
|
29
|
+
for (const rootId of orderedNodeIds) {
|
|
30
|
+
if (indexes.has(rootId))
|
|
31
|
+
continue;
|
|
32
|
+
const frames = [];
|
|
33
|
+
enter(rootId, frames);
|
|
34
|
+
while (frames.length > 0) {
|
|
35
|
+
const frame = frames.at(-1);
|
|
36
|
+
const target = frame.neighbours[frame.cursor];
|
|
37
|
+
if (target !== undefined) {
|
|
38
|
+
frame.cursor += 1;
|
|
39
|
+
spend();
|
|
40
|
+
if (!indexes.has(target)) {
|
|
41
|
+
enter(target, frames);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (onTarjanStack.has(target)) {
|
|
45
|
+
lowLinks.set(frame.nodeId, Math.min(lowLinks.get(frame.nodeId), indexes.get(target)));
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
frames.pop();
|
|
50
|
+
const parent = frames.at(-1);
|
|
51
|
+
if (parent !== undefined) {
|
|
52
|
+
lowLinks.set(parent.nodeId, Math.min(lowLinks.get(parent.nodeId), lowLinks.get(frame.nodeId)));
|
|
53
|
+
}
|
|
54
|
+
if (lowLinks.get(frame.nodeId) !== indexes.get(frame.nodeId))
|
|
55
|
+
continue;
|
|
56
|
+
const component = [];
|
|
57
|
+
for (;;) {
|
|
58
|
+
spend();
|
|
59
|
+
const member = tarjanStack.pop();
|
|
60
|
+
if (member === undefined)
|
|
61
|
+
break;
|
|
62
|
+
onTarjanStack.delete(member);
|
|
63
|
+
component.push(member);
|
|
64
|
+
if (member === frame.nodeId)
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
component.sort(compareCodeUnits);
|
|
68
|
+
components.push(component);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return components;
|
|
72
|
+
}
|