hoi4-agent-tools 2.1.0 → 2.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 +13 -0
- package/README.md +7 -2
- package/dist/bin/stdio.js +6 -1
- package/dist/hoi4_agent_tools/core/engine.js +9 -0
- package/dist/hoi4_agent_tools/core/index.js +292 -1
- package/dist/hoi4_agent_tools/focus/render.js +37 -13
- package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
- package/dist/hoi4_agent_tools/mcp/server/result.js +4 -4
- package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +530 -0
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +39 -2
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +368 -0
- package/dist/hoi4_agent_tools/schemas/technology.js +77 -0
- package/dist/hoi4_agent_tools/technology/compare.js +240 -0
- package/dist/hoi4_agent_tools/technology/graph.js +1196 -0
- package/dist/hoi4_agent_tools/technology/index.js +7 -0
- package/dist/hoi4_agent_tools/technology/model.js +2 -0
- package/dist/hoi4_agent_tools/technology/queries.js +300 -0
- package/dist/hoi4_agent_tools/technology/render.js +641 -0
- package/dist/hoi4_agent_tools/technology/service.js +686 -0
- package/dist/hoi4_agent_tools/technology/source-analysis.js +967 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/development.md +5 -1
- package/docs/setup.md +1 -1
- package/docs/technology.md +90 -0
- package/package.json +12 -3
- package/server.json +3 -3
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
import { publicArtifactLink } from '../../core/artifacts.js';
|
|
3
|
+
import { compareCodeUnits, hashCanonical } from '../../core/canonical.js';
|
|
4
|
+
import { emptyServiceResult } from '../../core/result.js';
|
|
5
|
+
import { workspaceIdSchema } from '../../schemas/common.js';
|
|
6
|
+
import { technologyDefectClassSchema, technologyDirectionSchema, technologyGraphReferenceSchema, technologyIdSchema, technologyImpactSchema, technologyProposedSourceSchema, technologyRenderViewSchema, technologyUnlockKindSchema, } from '../../schemas/technology.js';
|
|
7
|
+
import { TechnologyTreeViewer, technologyDiagnostics, } from '../../technology/service.js';
|
|
8
|
+
import { compactValidatedInputSchema } from '../server/context-schemas.js';
|
|
9
|
+
import { nonNegativeIntegerSchema, sha256Schema } from '../server/output-schemas.js';
|
|
10
|
+
import { progressReporter } from '../server/progress.js';
|
|
11
|
+
import { errorResult, setInlineFilesScanned, strictOperationResultSchema, toolResult, } from '../server/result.js';
|
|
12
|
+
const technologyMode = z.enum([
|
|
13
|
+
'scan',
|
|
14
|
+
'folders',
|
|
15
|
+
'trace',
|
|
16
|
+
'explain',
|
|
17
|
+
'unlocks',
|
|
18
|
+
'bonus_coverage',
|
|
19
|
+
'lint',
|
|
20
|
+
'impact',
|
|
21
|
+
]);
|
|
22
|
+
const inspectInput = z
|
|
23
|
+
.object({
|
|
24
|
+
workspaceId: workspaceIdSchema,
|
|
25
|
+
mode: technologyMode,
|
|
26
|
+
folderId: technologyIdSchema.optional(),
|
|
27
|
+
technologyId: technologyIdSchema.optional(),
|
|
28
|
+
categoryId: technologyIdSchema.optional(),
|
|
29
|
+
targetKind: technologyUnlockKindSchema.optional(),
|
|
30
|
+
targetId: technologyIdSchema.optional(),
|
|
31
|
+
direction: technologyDirectionSchema.optional(),
|
|
32
|
+
maxDepth: z.number().int().min(1).max(256).optional(),
|
|
33
|
+
maxNodes: z.number().int().min(1).max(25_000).optional(),
|
|
34
|
+
includeSubTechnologies: z.boolean().optional(),
|
|
35
|
+
classifications: z.array(technologyDefectClassSchema).max(4).optional(),
|
|
36
|
+
codes: z.array(z.string().min(1).max(256)).max(100).optional(),
|
|
37
|
+
impact: compactValidatedInputSchema(technologyImpactSchema, 'Rename or removal subject.').optional(),
|
|
38
|
+
refresh: z.boolean().optional(),
|
|
39
|
+
})
|
|
40
|
+
.strict()
|
|
41
|
+
.superRefine((value, context) => {
|
|
42
|
+
if ((value.mode === 'trace' || value.mode === 'explain') && value.technologyId === undefined)
|
|
43
|
+
context.addIssue({
|
|
44
|
+
code: 'custom',
|
|
45
|
+
path: ['technologyId'],
|
|
46
|
+
message: `${value.mode} requires technologyId`,
|
|
47
|
+
});
|
|
48
|
+
if (value.mode === 'impact' && value.impact === undefined)
|
|
49
|
+
context.addIssue({
|
|
50
|
+
code: 'custom',
|
|
51
|
+
path: ['impact'],
|
|
52
|
+
message: 'Impact mode requires impact',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
const renderInput = z
|
|
56
|
+
.object({
|
|
57
|
+
workspaceId: workspaceIdSchema,
|
|
58
|
+
view: technologyRenderViewSchema.exclude(['comparison']),
|
|
59
|
+
folderId: technologyIdSchema.optional(),
|
|
60
|
+
technologyId: technologyIdSchema.optional(),
|
|
61
|
+
categoryId: technologyIdSchema.optional(),
|
|
62
|
+
targetId: technologyIdSchema.optional(),
|
|
63
|
+
maxNodes: z.number().int().min(1).max(2_000).optional(),
|
|
64
|
+
includeHtml: z.boolean().optional(),
|
|
65
|
+
refresh: z.boolean().optional(),
|
|
66
|
+
})
|
|
67
|
+
.strict()
|
|
68
|
+
.superRefine((value, context) => {
|
|
69
|
+
if (value.view === 'folder' && value.folderId === undefined)
|
|
70
|
+
context.addIssue({
|
|
71
|
+
code: 'custom',
|
|
72
|
+
path: ['folderId'],
|
|
73
|
+
message: 'Folder view requires folderId',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
const compareInput = z
|
|
77
|
+
.object({
|
|
78
|
+
workspaceId: workspaceIdSchema,
|
|
79
|
+
before: compactValidatedInputSchema(technologyGraphReferenceSchema, 'Revision or graph resource.').optional(),
|
|
80
|
+
after: compactValidatedInputSchema(technologyGraphReferenceSchema, 'Revision or graph resource.').optional(),
|
|
81
|
+
proposedSources: z
|
|
82
|
+
.array(compactValidatedInputSchema(technologyProposedSourceSchema, 'In-memory source overlay.'))
|
|
83
|
+
.min(1)
|
|
84
|
+
.max(128)
|
|
85
|
+
.optional(),
|
|
86
|
+
render: z.boolean().optional(),
|
|
87
|
+
maxRenderNodes: z.number().int().min(1).max(2_000).optional(),
|
|
88
|
+
refresh: z.boolean().optional(),
|
|
89
|
+
})
|
|
90
|
+
.strict()
|
|
91
|
+
.superRefine((value, context) => {
|
|
92
|
+
if (value.after !== undefined && value.proposedSources !== undefined)
|
|
93
|
+
context.addIssue({
|
|
94
|
+
code: 'custom',
|
|
95
|
+
path: ['after'],
|
|
96
|
+
message: 'after conflicts with proposedSources',
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
const countsSchema = z
|
|
100
|
+
.object({
|
|
101
|
+
technologies: nonNegativeIntegerSchema,
|
|
102
|
+
legacyDoctrines: nonNegativeIntegerSchema,
|
|
103
|
+
folders: nonNegativeIntegerSchema,
|
|
104
|
+
placements: nonNegativeIntegerSchema,
|
|
105
|
+
edges: nonNegativeIntegerSchema,
|
|
106
|
+
unlocks: nonNegativeIntegerSchema,
|
|
107
|
+
references: nonNegativeIntegerSchema,
|
|
108
|
+
issues: nonNegativeIntegerSchema,
|
|
109
|
+
unresolved: nonNegativeIntegerSchema,
|
|
110
|
+
artifacts: nonNegativeIntegerSchema,
|
|
111
|
+
})
|
|
112
|
+
.strict();
|
|
113
|
+
const analysisOutput = strictOperationResultSchema(z
|
|
114
|
+
.object({
|
|
115
|
+
mode: technologyMode,
|
|
116
|
+
revision: sha256Schema,
|
|
117
|
+
graphHash: sha256Schema,
|
|
118
|
+
counts: countsSchema,
|
|
119
|
+
})
|
|
120
|
+
.strict());
|
|
121
|
+
const renderOutput = strictOperationResultSchema(z
|
|
122
|
+
.object({
|
|
123
|
+
view: technologyRenderViewSchema,
|
|
124
|
+
revision: sha256Schema,
|
|
125
|
+
graphHash: sha256Schema,
|
|
126
|
+
hashes: z
|
|
127
|
+
.object({
|
|
128
|
+
json: sha256Schema,
|
|
129
|
+
svg: sha256Schema,
|
|
130
|
+
png: sha256Schema,
|
|
131
|
+
html: sha256Schema.optional(),
|
|
132
|
+
})
|
|
133
|
+
.strict(),
|
|
134
|
+
selectedNodes: nonNegativeIntegerSchema,
|
|
135
|
+
omittedNodes: nonNegativeIntegerSchema,
|
|
136
|
+
focusedRenders: nonNegativeIntegerSchema,
|
|
137
|
+
sourceAccurate: z.boolean(),
|
|
138
|
+
})
|
|
139
|
+
.strict());
|
|
140
|
+
const compareOutput = strictOperationResultSchema(z
|
|
141
|
+
.object({
|
|
142
|
+
beforeRevision: sha256Schema,
|
|
143
|
+
afterRevision: sha256Schema,
|
|
144
|
+
added: nonNegativeIntegerSchema,
|
|
145
|
+
removed: nonNegativeIntegerSchema,
|
|
146
|
+
renamed: nonNegativeIntegerSchema,
|
|
147
|
+
moved: nonNegativeIntegerSchema,
|
|
148
|
+
regressions: nonNegativeIntegerSchema,
|
|
149
|
+
artifacts: nonNegativeIntegerSchema,
|
|
150
|
+
renderHashes: z
|
|
151
|
+
.object({ json: sha256Schema, svg: sha256Schema, png: sha256Schema })
|
|
152
|
+
.strict()
|
|
153
|
+
.optional(),
|
|
154
|
+
})
|
|
155
|
+
.strict());
|
|
156
|
+
const readOnly = {
|
|
157
|
+
readOnlyHint: true,
|
|
158
|
+
destructiveHint: false,
|
|
159
|
+
idempotentHint: true,
|
|
160
|
+
openWorldHint: false,
|
|
161
|
+
};
|
|
162
|
+
function graphHash(graph) {
|
|
163
|
+
return hashCanonical({
|
|
164
|
+
schemaVersion: graph.schemaVersion,
|
|
165
|
+
workspaceIdentity: graph.workspaceIdentity,
|
|
166
|
+
revision: graph.revision,
|
|
167
|
+
statistics: graph.statistics,
|
|
168
|
+
sourceHashes: graph.sourceHashes,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
function counts(graph, artifacts) {
|
|
172
|
+
return {
|
|
173
|
+
technologies: graph.statistics.technologyCount,
|
|
174
|
+
legacyDoctrines: graph.statistics.legacyDoctrineCount,
|
|
175
|
+
folders: graph.statistics.folderCount,
|
|
176
|
+
placements: graph.statistics.placementCount,
|
|
177
|
+
edges: graph.edges.length,
|
|
178
|
+
unlocks: graph.statistics.unlockCount,
|
|
179
|
+
references: graph.statistics.externalReferenceCount,
|
|
180
|
+
issues: graph.statistics.issueCount,
|
|
181
|
+
unresolved: graph.statistics.unresolvedCount,
|
|
182
|
+
artifacts,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function validation(graph) {
|
|
186
|
+
const blocking = technologyDiagnostics(graph).filter(({ severity }) => severity === 'error' || severity === 'blocker').length;
|
|
187
|
+
return {
|
|
188
|
+
passed: graph.complete && blocking === 0,
|
|
189
|
+
checks: [
|
|
190
|
+
{
|
|
191
|
+
id: 'technology-analysis',
|
|
192
|
+
passed: graph.complete && blocking === 0,
|
|
193
|
+
message: graph.complete
|
|
194
|
+
? `${blocking} blocking technology diagnostics; full evidence is linked`
|
|
195
|
+
: `${graph.skippedSourceCount} source(s) were skipped; full evidence is linked`,
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function analysisRequest(input, workspaceId, context, signal) {
|
|
201
|
+
const impact = input.impact === undefined
|
|
202
|
+
? undefined
|
|
203
|
+
: {
|
|
204
|
+
kind: input.impact.kind,
|
|
205
|
+
id: input.impact.id,
|
|
206
|
+
operation: input.impact.operation,
|
|
207
|
+
...(input.impact.replacementId === undefined
|
|
208
|
+
? {}
|
|
209
|
+
: { replacementId: input.impact.replacementId }),
|
|
210
|
+
};
|
|
211
|
+
const values = {
|
|
212
|
+
folderId: input.folderId,
|
|
213
|
+
technologyId: input.technologyId,
|
|
214
|
+
categoryId: input.categoryId,
|
|
215
|
+
targetKind: input.targetKind,
|
|
216
|
+
targetId: input.targetId,
|
|
217
|
+
direction: input.direction,
|
|
218
|
+
maxDepth: input.maxDepth,
|
|
219
|
+
maxNodes: input.maxNodes,
|
|
220
|
+
includeSubTechnologies: input.includeSubTechnologies,
|
|
221
|
+
classifications: input.classifications,
|
|
222
|
+
codes: input.codes,
|
|
223
|
+
impact,
|
|
224
|
+
refresh: input.refresh,
|
|
225
|
+
};
|
|
226
|
+
const compact = Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
|
|
227
|
+
return {
|
|
228
|
+
workspaceId,
|
|
229
|
+
mode: input.mode,
|
|
230
|
+
...compact,
|
|
231
|
+
...(context.principal === undefined ? {} : { principal: context.principal }),
|
|
232
|
+
signal,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
export function registerTechnologyTools(server, engine, context) {
|
|
236
|
+
const viewer = new TechnologyTreeViewer(engine);
|
|
237
|
+
server.registerTool('hoi4.tech_inspect', {
|
|
238
|
+
title: 'Inspect technology trees',
|
|
239
|
+
description: 'Scan, discover folders, trace, explain, inspect unlocks or bonuses, lint, and assess impact.',
|
|
240
|
+
inputSchema: inspectInput,
|
|
241
|
+
outputSchema: analysisOutput,
|
|
242
|
+
annotations: readOnly,
|
|
243
|
+
}, async (input, extra) => {
|
|
244
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
|
|
245
|
+
try {
|
|
246
|
+
const progress = progressReporter(extra);
|
|
247
|
+
await progress.report(0, 2, 'Analyzing technologies');
|
|
248
|
+
const output = await viewer.analyze(analysisRequest(input, workspaceId, context, progress.signal));
|
|
249
|
+
const result = emptyServiceResult(workspaceId, {
|
|
250
|
+
mode: input.mode,
|
|
251
|
+
revision: output.graph.revision,
|
|
252
|
+
graphHash: graphHash(output.graph),
|
|
253
|
+
counts: counts(output.graph, output.artifacts.length),
|
|
254
|
+
});
|
|
255
|
+
result.code = output.graph.complete ? 'TECH_INSPECTED' : 'TECH_INSPECTED_PARTIAL';
|
|
256
|
+
setInlineFilesScanned(result, output.graph.filesScanned);
|
|
257
|
+
result.artifacts = output.artifacts.map(publicArtifactLink);
|
|
258
|
+
result.validation = validation(output.graph);
|
|
259
|
+
await progress.report(2, 2, 'Technology analysis complete');
|
|
260
|
+
return toolResult(result);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
return errorResult(error, workspaceId);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
server.registerTool('hoi4.tech_render', {
|
|
267
|
+
title: 'Render technology trees',
|
|
268
|
+
description: 'Render source-linked JSON, SVG, PNG, and optional HTML technology views.',
|
|
269
|
+
inputSchema: renderInput,
|
|
270
|
+
outputSchema: renderOutput,
|
|
271
|
+
annotations: readOnly,
|
|
272
|
+
}, async (input, extra) => {
|
|
273
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
|
|
274
|
+
try {
|
|
275
|
+
const progress = progressReporter(extra);
|
|
276
|
+
const request = {
|
|
277
|
+
workspaceId,
|
|
278
|
+
view: input.view,
|
|
279
|
+
...(input.folderId === undefined ? {} : { folderId: input.folderId }),
|
|
280
|
+
...(input.technologyId === undefined ? {} : { technologyId: input.technologyId }),
|
|
281
|
+
...(input.categoryId === undefined ? {} : { categoryId: input.categoryId }),
|
|
282
|
+
...(input.targetId === undefined ? {} : { targetId: input.targetId }),
|
|
283
|
+
...(input.maxNodes === undefined ? {} : { maxNodes: input.maxNodes }),
|
|
284
|
+
...(input.includeHtml === undefined ? {} : { includeHtml: input.includeHtml }),
|
|
285
|
+
...(input.refresh === undefined ? {} : { refresh: input.refresh }),
|
|
286
|
+
...(context.principal === undefined ? {} : { principal: context.principal }),
|
|
287
|
+
signal: progress.signal,
|
|
288
|
+
};
|
|
289
|
+
const output = await viewer.renderAndStore(request);
|
|
290
|
+
const result = emptyServiceResult(workspaceId, {
|
|
291
|
+
view: output.render.view,
|
|
292
|
+
revision: output.graph.revision,
|
|
293
|
+
graphHash: graphHash(output.graph),
|
|
294
|
+
hashes: output.render.hashes,
|
|
295
|
+
selectedNodes: output.render.selectedIds.length,
|
|
296
|
+
omittedNodes: output.render.omittedNodeCount,
|
|
297
|
+
focusedRenders: output.focused.length,
|
|
298
|
+
sourceAccurate: output.render.sourceAccurate,
|
|
299
|
+
});
|
|
300
|
+
result.code = output.graph.complete ? 'TECH_RENDERED' : 'TECH_RENDERED_PARTIAL';
|
|
301
|
+
setInlineFilesScanned(result, output.graph.filesScanned);
|
|
302
|
+
result.artifacts = output.artifacts.map(publicArtifactLink);
|
|
303
|
+
result.validation = validation(output.graph);
|
|
304
|
+
return toolResult(result);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
return errorResult(error, workspaceId);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
server.registerTool('hoi4.tech_compare', {
|
|
311
|
+
title: 'Compare technology trees',
|
|
312
|
+
description: 'Compare cached, resource-backed, current, or proposed source graphs without writes.',
|
|
313
|
+
inputSchema: compareInput,
|
|
314
|
+
outputSchema: compareOutput,
|
|
315
|
+
annotations: readOnly,
|
|
316
|
+
}, async (input, extra) => {
|
|
317
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
|
|
318
|
+
try {
|
|
319
|
+
const progress = progressReporter(extra);
|
|
320
|
+
const request = {
|
|
321
|
+
workspaceId,
|
|
322
|
+
...(input.before === undefined
|
|
323
|
+
? {}
|
|
324
|
+
: { before: input.before }),
|
|
325
|
+
...(input.after === undefined
|
|
326
|
+
? {}
|
|
327
|
+
: { after: input.after }),
|
|
328
|
+
...(input.proposedSources === undefined
|
|
329
|
+
? {}
|
|
330
|
+
: {
|
|
331
|
+
proposedSources: input.proposedSources,
|
|
332
|
+
}),
|
|
333
|
+
...(input.render === undefined ? {} : { render: input.render }),
|
|
334
|
+
...(input.maxRenderNodes === undefined ? {} : { maxRenderNodes: input.maxRenderNodes }),
|
|
335
|
+
...(input.refresh === undefined ? {} : { refresh: input.refresh }),
|
|
336
|
+
...(context.principal === undefined ? {} : { principal: context.principal }),
|
|
337
|
+
signal: progress.signal,
|
|
338
|
+
};
|
|
339
|
+
const output = await viewer.compareAndStore(request);
|
|
340
|
+
const comparison = output.comparison;
|
|
341
|
+
const result = emptyServiceResult(workspaceId, {
|
|
342
|
+
beforeRevision: comparison.beforeRevision,
|
|
343
|
+
afterRevision: comparison.afterRevision,
|
|
344
|
+
added: comparison.technologies.added.length,
|
|
345
|
+
removed: comparison.technologies.removed.length,
|
|
346
|
+
renamed: comparison.technologies.renamed.length,
|
|
347
|
+
moved: comparison.technologies.moved.length,
|
|
348
|
+
regressions: comparison.regressions.length,
|
|
349
|
+
artifacts: output.artifacts.length,
|
|
350
|
+
...(output.render === undefined ? {} : { renderHashes: output.render.hashes }),
|
|
351
|
+
});
|
|
352
|
+
result.code =
|
|
353
|
+
output.before.complete && output.after.complete
|
|
354
|
+
? 'TECH_COMPARED'
|
|
355
|
+
: 'TECH_COMPARED_PARTIAL';
|
|
356
|
+
setInlineFilesScanned(result, [...new Set([...output.before.filesScanned, ...output.after.filesScanned])].sort(compareCodeUnits));
|
|
357
|
+
result.artifacts = output.artifacts.map(publicArtifactLink);
|
|
358
|
+
result.validation = {
|
|
359
|
+
passed: validation(output.before).passed && validation(output.after).passed,
|
|
360
|
+
checks: [...validation(output.before).checks, ...validation(output.after).checks],
|
|
361
|
+
};
|
|
362
|
+
return toolResult(result);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
return errorResult(error, workspaceId);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
import { SOURCE_MAX_BYTES } from '../core/source/index.js';
|
|
3
|
+
import { workspaceRelativePathSchema } from './common.js';
|
|
4
|
+
export const technologyIdSchema = z.string().min(1).max(512);
|
|
5
|
+
export const technologyDirectionSchema = z.enum(['prerequisites', 'descendants', 'both']);
|
|
6
|
+
export const technologyUnlockKindSchema = z.enum([
|
|
7
|
+
'equipment',
|
|
8
|
+
'equipment_module',
|
|
9
|
+
'sub_unit',
|
|
10
|
+
'building',
|
|
11
|
+
'ability',
|
|
12
|
+
'tactic',
|
|
13
|
+
'other',
|
|
14
|
+
]);
|
|
15
|
+
export const technologyDefectClassSchema = z.enum([
|
|
16
|
+
'confirmed_error',
|
|
17
|
+
'probable_defect',
|
|
18
|
+
'design_warning',
|
|
19
|
+
'unresolved_analysis',
|
|
20
|
+
]);
|
|
21
|
+
export const technologyRenderViewSchema = z.enum([
|
|
22
|
+
'summary',
|
|
23
|
+
'folder',
|
|
24
|
+
'dependencies',
|
|
25
|
+
'technology',
|
|
26
|
+
'doctrine',
|
|
27
|
+
'exclusive',
|
|
28
|
+
'memberships',
|
|
29
|
+
'bonuses',
|
|
30
|
+
'grants',
|
|
31
|
+
'unlocks',
|
|
32
|
+
'metadata',
|
|
33
|
+
'assets',
|
|
34
|
+
'unresolved',
|
|
35
|
+
'comparison',
|
|
36
|
+
]);
|
|
37
|
+
export const technologyImpactSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
kind: z.enum(['technology', 'category', 'folder', 'unlock_target']),
|
|
40
|
+
id: technologyIdSchema,
|
|
41
|
+
operation: z.enum(['remove', 'rename']),
|
|
42
|
+
replacementId: technologyIdSchema.optional(),
|
|
43
|
+
})
|
|
44
|
+
.strict()
|
|
45
|
+
.superRefine((value, context) => {
|
|
46
|
+
if (value.operation === 'rename' && value.replacementId === undefined)
|
|
47
|
+
context.addIssue({
|
|
48
|
+
code: 'custom',
|
|
49
|
+
path: ['replacementId'],
|
|
50
|
+
message: 'Rename requires replacementId',
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
export const technologyGraphReferenceSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
revision: z
|
|
56
|
+
.string()
|
|
57
|
+
.regex(/^[a-f0-9]{64}$/u)
|
|
58
|
+
.optional(),
|
|
59
|
+
artifactUri: z
|
|
60
|
+
.string()
|
|
61
|
+
.min(1)
|
|
62
|
+
.max(8_192)
|
|
63
|
+
.regex(/^hoi4-agent:\/\//u)
|
|
64
|
+
.optional(),
|
|
65
|
+
})
|
|
66
|
+
.strict()
|
|
67
|
+
.refine(({ revision, artifactUri }) => Number(revision !== undefined) + Number(artifactUri !== undefined) === 1, 'Provide exactly one revision or artifact URI');
|
|
68
|
+
export const technologyProposedSourceSchema = z
|
|
69
|
+
.object({
|
|
70
|
+
relativePath: workspaceRelativePathSchema,
|
|
71
|
+
source: z.string().max(SOURCE_MAX_BYTES).nullable(),
|
|
72
|
+
expectedSourceHash: z
|
|
73
|
+
.string()
|
|
74
|
+
.regex(/^[a-f0-9]{64}$/u)
|
|
75
|
+
.optional(),
|
|
76
|
+
})
|
|
77
|
+
.strict();
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
|
|
2
|
+
function byId(values) {
|
|
3
|
+
return new Map(values.map((value) => [value.id, value]));
|
|
4
|
+
}
|
|
5
|
+
function placementSignature(values) {
|
|
6
|
+
return hashCanonical(values
|
|
7
|
+
.map(({ folderId, x, y, xExpression, yExpression }) => ({
|
|
8
|
+
folderId,
|
|
9
|
+
x: x ?? null,
|
|
10
|
+
y: y ?? null,
|
|
11
|
+
xExpression: xExpression ?? null,
|
|
12
|
+
yExpression: yExpression ?? null,
|
|
13
|
+
}))
|
|
14
|
+
.sort((left, right) => compareCodeUnits(left.folderId, right.folderId) ||
|
|
15
|
+
(left.x ?? 0) - (right.x ?? 0) ||
|
|
16
|
+
(left.y ?? 0) - (right.y ?? 0)));
|
|
17
|
+
}
|
|
18
|
+
function edgeKey(edge) {
|
|
19
|
+
return hashCanonical({
|
|
20
|
+
kind: edge.kind,
|
|
21
|
+
from: edge.from,
|
|
22
|
+
to: edge.to,
|
|
23
|
+
coefficient: edge.coefficient ?? null,
|
|
24
|
+
ignoreForLayout: edge.ignoreForLayout ?? null,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function unlockKey(unlock) {
|
|
28
|
+
return hashCanonical({
|
|
29
|
+
technologyId: unlock.technologyId,
|
|
30
|
+
kind: unlock.kind,
|
|
31
|
+
targetId: unlock.targetId,
|
|
32
|
+
level: unlock.level ?? null,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function externalKey(reference) {
|
|
36
|
+
return hashCanonical({
|
|
37
|
+
kind: reference.kind,
|
|
38
|
+
sourceKind: reference.sourceKind,
|
|
39
|
+
sourceId: reference.sourceId,
|
|
40
|
+
technologyId: reference.technologyId ?? null,
|
|
41
|
+
categoryId: reference.categoryId ?? null,
|
|
42
|
+
helperStack: reference.helperStack,
|
|
43
|
+
expression: reference.expression,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function issueKey(issue) {
|
|
47
|
+
return hashCanonical({
|
|
48
|
+
code: issue.code,
|
|
49
|
+
classification: issue.classification,
|
|
50
|
+
details: issue.details,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function difference(before, after, key) {
|
|
54
|
+
const beforeKeys = new Set(before.map(key));
|
|
55
|
+
const afterKeys = new Set(after.map(key));
|
|
56
|
+
return {
|
|
57
|
+
added: after.filter((value) => !beforeKeys.has(key(value))),
|
|
58
|
+
removed: before.filter((value) => !afterKeys.has(key(value))),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function reachable(graph, signal) {
|
|
62
|
+
const incoming = new Map();
|
|
63
|
+
const outgoing = new Map();
|
|
64
|
+
for (const technology of graph.technologies) {
|
|
65
|
+
incoming.set(technology.id, 0);
|
|
66
|
+
outgoing.set(technology.id, []);
|
|
67
|
+
}
|
|
68
|
+
for (const edge of graph.edges.filter(({ kind }) => kind === 'prerequisite')) {
|
|
69
|
+
if (!incoming.has(edge.from) || !incoming.has(edge.to))
|
|
70
|
+
continue;
|
|
71
|
+
incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1);
|
|
72
|
+
outgoing.get(edge.from).push(edge.to);
|
|
73
|
+
}
|
|
74
|
+
const result = new Set();
|
|
75
|
+
const pending = graph.technologies
|
|
76
|
+
.filter(({ id, hidden }) => hidden !== true && (incoming.get(id) ?? 0) === 0)
|
|
77
|
+
.map(({ id }) => id);
|
|
78
|
+
while (pending.length > 0) {
|
|
79
|
+
signal?.throwIfAborted();
|
|
80
|
+
const current = pending.pop();
|
|
81
|
+
if (result.has(current))
|
|
82
|
+
continue;
|
|
83
|
+
result.add(current);
|
|
84
|
+
pending.push(...(outgoing.get(current) ?? []));
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
function technologyMetadata(technology) {
|
|
89
|
+
return {
|
|
90
|
+
kind: technology.kind,
|
|
91
|
+
startYear: technology.startYear ?? null,
|
|
92
|
+
researchCost: technology.researchCost ?? null,
|
|
93
|
+
doctrineName: technology.doctrineName ?? null,
|
|
94
|
+
hidden: technology.hidden,
|
|
95
|
+
folders: technology.folders,
|
|
96
|
+
categories: technology.categories,
|
|
97
|
+
tags: technology.tags,
|
|
98
|
+
subTechnologies: technology.subTechnologies,
|
|
99
|
+
ai: technology.ai,
|
|
100
|
+
effectSignature: technology.effectSignature,
|
|
101
|
+
localisation: technology.localisation,
|
|
102
|
+
icon: technology.icon,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function renameCandidates(removed, added, beforePlacements, afterPlacements, signal) {
|
|
106
|
+
const candidates = [];
|
|
107
|
+
const claimed = new Set();
|
|
108
|
+
for (const before of removed) {
|
|
109
|
+
signal?.throwIfAborted();
|
|
110
|
+
const matches = added
|
|
111
|
+
.filter((after) => !claimed.has(after.id))
|
|
112
|
+
.map((after) => {
|
|
113
|
+
const evidence = [];
|
|
114
|
+
if (before.effectSignature === after.effectSignature)
|
|
115
|
+
evidence.push('identical effect and unlock signature');
|
|
116
|
+
if (before.startYear === after.startYear && before.researchCost === after.researchCost)
|
|
117
|
+
evidence.push('identical year and research cost');
|
|
118
|
+
if (placementSignature(beforePlacements.filter(({ technologyId }) => technologyId === before.id)) ===
|
|
119
|
+
placementSignature(afterPlacements.filter(({ technologyId }) => technologyId === after.id)))
|
|
120
|
+
evidence.push('identical folder placements');
|
|
121
|
+
if (before.categories.join('\0') === after.categories.join('\0'))
|
|
122
|
+
evidence.push('identical category membership');
|
|
123
|
+
if (before.source.path === after.source.path)
|
|
124
|
+
evidence.push('same source file');
|
|
125
|
+
return { after, evidence };
|
|
126
|
+
})
|
|
127
|
+
.filter(({ evidence }) => evidence.length >= 4)
|
|
128
|
+
.sort((left, right) => right.evidence.length - left.evidence.length ||
|
|
129
|
+
compareCodeUnits(left.after.id, right.after.id));
|
|
130
|
+
if (matches.length !== 1)
|
|
131
|
+
continue;
|
|
132
|
+
const match = matches[0];
|
|
133
|
+
claimed.add(match.after.id);
|
|
134
|
+
candidates.push({
|
|
135
|
+
beforeId: before.id,
|
|
136
|
+
afterId: match.after.id,
|
|
137
|
+
confidence: match.evidence.length >= 5 ? 'high' : 'medium',
|
|
138
|
+
evidence: match.evidence,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return candidates.sort((left, right) => compareCodeUnits(left.beforeId, right.beforeId));
|
|
142
|
+
}
|
|
143
|
+
export function compareTechnologyGraphs(before, after, signal) {
|
|
144
|
+
signal?.throwIfAborted();
|
|
145
|
+
const beforeTech = byId(before.technologies);
|
|
146
|
+
const afterTech = byId(after.technologies);
|
|
147
|
+
const addedDefinitions = after.technologies.filter(({ id }) => !beforeTech.has(id));
|
|
148
|
+
const removedDefinitions = before.technologies.filter(({ id }) => !afterTech.has(id));
|
|
149
|
+
const added = addedDefinitions.map(({ id }) => id).sort(compareCodeUnits);
|
|
150
|
+
const removed = removedDefinitions.map(({ id }) => id).sort(compareCodeUnits);
|
|
151
|
+
const moved = [];
|
|
152
|
+
const metadataChanged = [];
|
|
153
|
+
for (const [technologyId, beforeDefinition] of beforeTech) {
|
|
154
|
+
signal?.throwIfAborted();
|
|
155
|
+
const afterDefinition = afterTech.get(technologyId);
|
|
156
|
+
if (afterDefinition === undefined)
|
|
157
|
+
continue;
|
|
158
|
+
const beforePlacement = before.placements.filter(({ technologyId: id }) => id === technologyId);
|
|
159
|
+
const afterPlacement = after.placements.filter(({ technologyId: id }) => id === technologyId);
|
|
160
|
+
if (placementSignature(beforePlacement) !== placementSignature(afterPlacement))
|
|
161
|
+
moved.push({ technologyId, before: beforePlacement, after: afterPlacement });
|
|
162
|
+
const beforeMetadata = technologyMetadata(beforeDefinition);
|
|
163
|
+
const afterMetadata = technologyMetadata(afterDefinition);
|
|
164
|
+
const fields = Object.keys(beforeMetadata)
|
|
165
|
+
.filter((field) => hashCanonical(beforeMetadata[field]) !== hashCanonical(afterMetadata[field]))
|
|
166
|
+
.sort(compareCodeUnits);
|
|
167
|
+
if (fields.length > 0)
|
|
168
|
+
metadataChanged.push({
|
|
169
|
+
technologyId,
|
|
170
|
+
fields,
|
|
171
|
+
before: Object.fromEntries(fields.map((field) => [field, beforeMetadata[field]])),
|
|
172
|
+
after: Object.fromEntries(fields.map((field) => [field, afterMetadata[field]])),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const edges = difference(before.edges, after.edges, edgeKey);
|
|
176
|
+
const unlocks = difference(before.unlocks, after.unlocks, unlockKey);
|
|
177
|
+
const externalReferences = difference(before.externalReferences, after.externalReferences, externalKey);
|
|
178
|
+
const beforeFolders = byId(before.folders);
|
|
179
|
+
const afterFolders = byId(after.folders);
|
|
180
|
+
const folderAdded = after.folders
|
|
181
|
+
.filter(({ id }) => !beforeFolders.has(id))
|
|
182
|
+
.map(({ id }) => id)
|
|
183
|
+
.sort(compareCodeUnits);
|
|
184
|
+
const folderRemoved = before.folders
|
|
185
|
+
.filter(({ id }) => !afterFolders.has(id))
|
|
186
|
+
.map(({ id }) => id)
|
|
187
|
+
.sort(compareCodeUnits);
|
|
188
|
+
const folderChanged = before.folders
|
|
189
|
+
.filter(({ id }) => {
|
|
190
|
+
const afterFolder = afterFolders.get(id);
|
|
191
|
+
return (afterFolder !== undefined &&
|
|
192
|
+
hashCanonical({ ...beforeFolders.get(id), source: undefined }) !==
|
|
193
|
+
hashCanonical({ ...afterFolder, source: undefined }));
|
|
194
|
+
})
|
|
195
|
+
.map(({ id }) => id)
|
|
196
|
+
.sort(compareCodeUnits);
|
|
197
|
+
const issueDifference = difference(before.issues, after.issues, issueKey);
|
|
198
|
+
const retained = before.issues.filter((issue) => new Set(after.issues.map(issueKey)).has(issueKey(issue))).length;
|
|
199
|
+
const beforeReachable = reachable(before, signal);
|
|
200
|
+
const afterReachable = reachable(after, signal);
|
|
201
|
+
const sourceFiles = {
|
|
202
|
+
added: Object.keys(after.sourceHashes)
|
|
203
|
+
.filter((sourcePath) => !(sourcePath in before.sourceHashes))
|
|
204
|
+
.sort(compareCodeUnits),
|
|
205
|
+
removed: Object.keys(before.sourceHashes)
|
|
206
|
+
.filter((sourcePath) => !(sourcePath in after.sourceHashes))
|
|
207
|
+
.sort(compareCodeUnits),
|
|
208
|
+
changed: Object.keys(before.sourceHashes)
|
|
209
|
+
.filter((sourcePath) => after.sourceHashes[sourcePath] !== undefined &&
|
|
210
|
+
after.sourceHashes[sourcePath] !== before.sourceHashes[sourcePath])
|
|
211
|
+
.sort(compareCodeUnits),
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
schemaVersion: 1,
|
|
215
|
+
beforeRevision: before.revision,
|
|
216
|
+
afterRevision: after.revision,
|
|
217
|
+
technologies: {
|
|
218
|
+
added,
|
|
219
|
+
removed,
|
|
220
|
+
renamed: renameCandidates(removedDefinitions, addedDefinitions, before.placements, after.placements, signal),
|
|
221
|
+
moved: moved.sort((left, right) => compareCodeUnits(left.technologyId, right.technologyId)),
|
|
222
|
+
metadataChanged: metadataChanged.sort((left, right) => compareCodeUnits(left.technologyId, right.technologyId)),
|
|
223
|
+
},
|
|
224
|
+
edges,
|
|
225
|
+
unlocks,
|
|
226
|
+
externalReferences,
|
|
227
|
+
folders: { added: folderAdded, removed: folderRemoved, changed: folderChanged },
|
|
228
|
+
issues: { introduced: issueDifference.added, resolved: issueDifference.removed, retained },
|
|
229
|
+
reachability: {
|
|
230
|
+
newlyReachable: [...afterReachable]
|
|
231
|
+
.filter((id) => !beforeReachable.has(id))
|
|
232
|
+
.sort(compareCodeUnits),
|
|
233
|
+
newlyOrphaned: [...beforeReachable]
|
|
234
|
+
.filter((id) => !afterReachable.has(id) && afterTech.has(id))
|
|
235
|
+
.sort(compareCodeUnits),
|
|
236
|
+
},
|
|
237
|
+
sourceFiles,
|
|
238
|
+
regressions: issueDifference.added.filter(({ classification }) => classification === 'confirmed_error' || classification === 'probable_defect'),
|
|
239
|
+
};
|
|
240
|
+
}
|