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.
@@ -0,0 +1,7 @@
1
+ export * from './model.js';
2
+ export * from './compare.js';
3
+ export * from './graph.js';
4
+ export * from './queries.js';
5
+ export * from './render.js';
6
+ export * from './service.js';
7
+ export * from './source-analysis.js';
@@ -0,0 +1,2 @@
1
+ export const TECHNOLOGY_GRAPH_SCHEMA_VERSION = 1;
2
+ export const TECHNOLOGY_PARSER_VERSION = 'clausewitz-cst.v1';
@@ -0,0 +1,300 @@
1
+ import { compareCodeUnits, deterministicId } from '../core/canonical.js';
2
+ import { ServiceError } from '../core/result.js';
3
+ const MAX_TRACE_DEPTH = 256;
4
+ const MAX_TRACE_NODES = 25_000;
5
+ function requireTechnology(graph, technologyId) {
6
+ if (!graph.technologies.some(({ id }) => id === technologyId))
7
+ throw new ServiceError('TECHNOLOGY_NOT_FOUND', `Technology ${technologyId} was not found`, {
8
+ technologyId,
9
+ });
10
+ }
11
+ export function technologyScanReport(graph) {
12
+ const classifications = Object.fromEntries(['confirmed_error', 'probable_defect', 'design_warning', 'unresolved_analysis'].map((classification) => [
13
+ classification,
14
+ graph.issues.filter((issue) => issue.classification === classification).length,
15
+ ]));
16
+ return {
17
+ schemaVersion: graph.schemaVersion,
18
+ parserVersion: graph.parserVersion,
19
+ workspaceId: graph.workspaceId,
20
+ workspaceIdentity: graph.workspaceIdentity,
21
+ revision: graph.revision,
22
+ complete: graph.complete,
23
+ statistics: graph.statistics,
24
+ issueClassifications: classifications,
25
+ folderIds: graph.folders.map(({ id }) => id),
26
+ modernDoctrineFolders: graph.doctrineDefinitions
27
+ .filter(({ kind }) => kind === 'folder')
28
+ .map(({ id }) => id),
29
+ sourceHashCount: Object.keys(graph.sourceHashes).length,
30
+ skippedSourceCount: graph.skippedSourceCount,
31
+ analysisBoundary: graph.analysisBoundary,
32
+ issueSamples: graph.issues.slice(0, 100),
33
+ unresolvedSamples: graph.unresolved.slice(0, 100),
34
+ authoritativeGraphIncluded: true,
35
+ };
36
+ }
37
+ export function discoverTechnologyFolders(graph, folderId) {
38
+ const incoming = new Set(graph.edges.filter(({ kind }) => kind === 'prerequisite').map(({ to }) => to));
39
+ const selected = graph.folders.filter((folder) => folderId === undefined || folder.id === folderId);
40
+ if (folderId !== undefined && selected.length === 0)
41
+ throw new ServiceError('TECH_FOLDER_NOT_FOUND', `Technology folder ${folderId} was not found`, {
42
+ folderId,
43
+ });
44
+ return {
45
+ folders: selected.map((folder) => {
46
+ const placements = graph.placements.filter(({ folderId: candidate }) => candidate === folder.id);
47
+ const technologyIds = [...new Set(placements.map(({ technologyId }) => technologyId))].sort(compareCodeUnits);
48
+ const roots = technologyIds.filter((technologyId) => !incoming.has(technologyId));
49
+ return {
50
+ folder,
51
+ placementCount: placements.length,
52
+ technologyCount: technologyIds.length,
53
+ roots,
54
+ placements,
55
+ issues: graph.issues.filter(({ details }) => details.folderId === folder.id),
56
+ };
57
+ }),
58
+ modernDoctrines: graph.doctrineDefinitions
59
+ .filter(({ kind, id }) => kind === 'folder' && (folderId === undefined || id === folderId))
60
+ .map((folder) => ({
61
+ folder,
62
+ grandDoctrines: graph.doctrineDefinitions.filter(({ kind, folderId: candidate }) => kind === 'grand_doctrine' && candidate === folder.id),
63
+ })),
64
+ };
65
+ }
66
+ export function traceTechnology(graph, input) {
67
+ requireTechnology(graph, input.technologyId);
68
+ const direction = input.direction ?? 'both';
69
+ const maxDepth = input.maxDepth ?? 32;
70
+ const maxNodes = input.maxNodes ?? 2_000;
71
+ if (maxDepth < 0 || maxDepth > MAX_TRACE_DEPTH || maxNodes < 1 || maxNodes > MAX_TRACE_NODES)
72
+ throw new ServiceError('TECH_TRACE_LIMIT_INVALID', 'Technology trace limits are outside supported bounds', {
73
+ maxDepth,
74
+ maxNodes,
75
+ maximumDepth: MAX_TRACE_DEPTH,
76
+ maximumNodes: MAX_TRACE_NODES,
77
+ });
78
+ const allowedKinds = new Set([
79
+ 'prerequisite',
80
+ ...(input.includeSubTechnologies === true ? ['sub_technology'] : []),
81
+ ]);
82
+ const relevant = graph.edges.filter(({ kind }) => allowedKinds.has(kind));
83
+ const incoming = new Map();
84
+ const outgoing = new Map();
85
+ for (const edge of relevant) {
86
+ const upstream = incoming.get(edge.to) ?? [];
87
+ upstream.push(edge);
88
+ incoming.set(edge.to, upstream);
89
+ const downstream = outgoing.get(edge.from) ?? [];
90
+ downstream.push(edge);
91
+ outgoing.set(edge.from, downstream);
92
+ }
93
+ const selected = new Set([input.technologyId]);
94
+ const selectedEdges = new Map();
95
+ const depthById = new Map([[input.technologyId, 0]]);
96
+ const queue = [input.technologyId];
97
+ const boundary = new Set();
98
+ let truncated = false;
99
+ while (queue.length > 0) {
100
+ input.signal?.throwIfAborted();
101
+ const current = queue.shift();
102
+ const depth = depthById.get(current) ?? 0;
103
+ const candidates = [
104
+ ...(direction === 'descendants'
105
+ ? []
106
+ : (incoming.get(current) ?? []).map((edge) => ({ edge, next: edge.from }))),
107
+ ...(direction === 'prerequisites'
108
+ ? []
109
+ : (outgoing.get(current) ?? []).map((edge) => ({ edge, next: edge.to }))),
110
+ ].sort((left, right) => compareCodeUnits(left.edge.id, right.edge.id));
111
+ for (const { edge, next } of candidates) {
112
+ selectedEdges.set(edge.id, edge);
113
+ if (selected.has(next))
114
+ continue;
115
+ if (depth >= maxDepth || selected.size >= maxNodes) {
116
+ truncated = true;
117
+ boundary.add(next);
118
+ continue;
119
+ }
120
+ selected.add(next);
121
+ depthById.set(next, depth + 1);
122
+ queue.push(next);
123
+ }
124
+ }
125
+ const layers = [...new Set(depthById.values())]
126
+ .sort((left, right) => left - right)
127
+ .map((depth) => ({
128
+ depth,
129
+ technologyIds: [...depthById]
130
+ .filter(([, candidate]) => candidate === depth)
131
+ .map(([id]) => id)
132
+ .sort(compareCodeUnits),
133
+ }));
134
+ return {
135
+ technologyId: input.technologyId,
136
+ direction,
137
+ nodes: [...selected].sort(compareCodeUnits),
138
+ edges: [...selectedEdges.values()].sort((left, right) => compareCodeUnits(left.id, right.id)),
139
+ layers,
140
+ truncated,
141
+ boundary: [...boundary].sort(compareCodeUnits),
142
+ };
143
+ }
144
+ export function explainTechnology(graph, technologyId, signal) {
145
+ requireTechnology(graph, technologyId);
146
+ const technology = graph.technologies.find(({ id }) => id === technologyId);
147
+ const prerequisites = traceTechnology(graph, {
148
+ technologyId,
149
+ direction: 'prerequisites',
150
+ maxDepth: 256,
151
+ maxNodes: 25_000,
152
+ ...(signal === undefined ? {} : { signal }),
153
+ });
154
+ const descendants = traceTechnology(graph, {
155
+ technologyId,
156
+ direction: 'descendants',
157
+ maxDepth: 256,
158
+ maxNodes: 25_000,
159
+ ...(signal === undefined ? {} : { signal }),
160
+ });
161
+ return {
162
+ technology,
163
+ placements: graph.placements.filter(({ technologyId: candidate }) => candidate === technologyId),
164
+ directPrerequisites: graph.edges.filter(({ kind, to }) => kind === 'prerequisite' && to === technologyId),
165
+ transitivePrerequisites: prerequisites,
166
+ directDescendants: graph.edges.filter(({ kind, from }) => kind === 'prerequisite' && from === technologyId),
167
+ descendants,
168
+ exclusiveChoices: graph.edges.filter(({ kind, from, to }) => kind === 'exclusive' && (from === technologyId || to === technologyId)),
169
+ unlocks: graph.unlocks.filter(({ technologyId: candidate }) => candidate === technologyId),
170
+ externalGrants: graph.externalReferences.filter(({ technologyId: candidate, kind }) => candidate === technologyId && (kind === 'grant' || kind === 'starting_technology')),
171
+ matchingBonuses: graph.externalReferences.filter(({ technologyId: candidate, categoryId, kind }) => kind === 'research_bonus' &&
172
+ (candidate === technologyId ||
173
+ (categoryId !== undefined && technology.categories.includes(categoryId)))),
174
+ issues: graph.issues.filter(({ details }) => details.technologyId === technologyId ||
175
+ (Array.isArray(details.technologyIds) && details.technologyIds.includes(technologyId))),
176
+ unresolved: graph.unresolved.filter(({ ownerId }) => ownerId === technologyId),
177
+ confidenceLimits: graph.analysisBoundary,
178
+ };
179
+ }
180
+ export function inspectTechnologyUnlocks(graph, selector) {
181
+ if (selector.technologyId !== undefined)
182
+ requireTechnology(graph, selector.technologyId);
183
+ const unlocks = graph.unlocks.filter(({ technologyId, kind, targetId }) => (selector.technologyId === undefined || technologyId === selector.technologyId) &&
184
+ (selector.targetKind === undefined || kind === selector.targetKind) &&
185
+ (selector.targetId === undefined || targetId === selector.targetId));
186
+ return {
187
+ selector,
188
+ unlocks,
189
+ targets: graph.unlockTargets.filter(({ kind, targetId }) => unlocks.some((unlock) => unlock.kind === kind && unlock.targetId === targetId)),
190
+ externalGrants: graph.externalReferences.filter(({ technologyId, kind }) => technologyId !== undefined &&
191
+ unlocks.some((unlock) => unlock.technologyId === technologyId) &&
192
+ (kind === 'grant' || kind === 'starting_technology')),
193
+ issues: graph.issues.filter(({ code, details }) => code === 'TECH_UNLOCK_TARGET_MISSING' &&
194
+ unlocks.some(({ technologyId, kind, targetId }) => details.technologyId === technologyId &&
195
+ details.targetKind === kind &&
196
+ details.targetId === targetId)),
197
+ };
198
+ }
199
+ export function technologyBonusCoverage(graph, selector = {}) {
200
+ if (selector.technologyId !== undefined)
201
+ requireTechnology(graph, selector.technologyId);
202
+ const categories = graph.categories.filter(({ id }) => selector.categoryId === undefined || id === selector.categoryId);
203
+ if (selector.categoryId !== undefined && categories.length === 0)
204
+ throw new ServiceError('TECH_CATEGORY_NOT_FOUND', `Technology category ${selector.categoryId} was not found`, { categoryId: selector.categoryId });
205
+ const rows = categories.map((category) => {
206
+ const technologyIds = graph.technologies
207
+ .filter(({ id, categories: memberships }) => memberships.includes(category.id) &&
208
+ (selector.technologyId === undefined || id === selector.technologyId))
209
+ .map(({ id }) => id);
210
+ const bonusSources = graph.externalReferences.filter(({ kind, categoryId, technologyId }) => kind === 'research_bonus' &&
211
+ (categoryId === category.id ||
212
+ (technologyId !== undefined && technologyIds.includes(technologyId))));
213
+ const sharingSources = graph.externalReferences.filter(({ kind, categoryId }) => kind === 'technology_sharing' && categoryId === category.id);
214
+ return {
215
+ category,
216
+ technologyIds,
217
+ bonusSources,
218
+ sharingSources,
219
+ covered: bonusSources.length > 0 || sharingSources.length > 0,
220
+ };
221
+ });
222
+ return {
223
+ selector,
224
+ rows,
225
+ uncoveredCategoryIds: rows.filter(({ covered }) => !covered).map(({ category }) => category.id),
226
+ technologySpecificBonuses: graph.externalReferences.filter(({ kind, technologyId }) => kind === 'research_bonus' &&
227
+ technologyId !== undefined &&
228
+ (selector.technologyId === undefined || technologyId === selector.technologyId)),
229
+ invalidBonusSources: graph.issues.filter(({ code }) => code === 'TECH_EXTERNAL_CATEGORY_MISSING' || code === 'TECH_BONUS_TARGET_MISSING'),
230
+ };
231
+ }
232
+ export function lintTechnologyGraph(graph, filter = {}) {
233
+ const issues = graph.issues.filter((issue) => (filter.classifications === undefined ||
234
+ filter.classifications.includes(issue.classification)) &&
235
+ (filter.codes === undefined || filter.codes.includes(issue.code)) &&
236
+ (filter.folderId === undefined || issue.details.folderId === filter.folderId) &&
237
+ (filter.technologyId === undefined ||
238
+ issue.details.technologyId === filter.technologyId ||
239
+ (Array.isArray(issue.details.technologyIds) &&
240
+ issue.details.technologyIds.includes(filter.technologyId))));
241
+ const grouped = (key) => {
242
+ const counts = new Map();
243
+ for (const issue of issues)
244
+ counts.set(key(issue), (counts.get(key(issue)) ?? 0) + 1);
245
+ return Object.fromEntries([...counts].sort(([left], [right]) => compareCodeUnits(left, right)));
246
+ };
247
+ return {
248
+ filter,
249
+ issueCount: issues.length,
250
+ byClassification: grouped(({ classification }) => classification),
251
+ byCode: grouped(({ code }) => code),
252
+ issues,
253
+ unresolved: graph.unresolved.filter((item) => filter.technologyId === undefined ? true : item.ownerId === filter.technologyId),
254
+ };
255
+ }
256
+ export function analyzeTechnologyImpact(graph, input) {
257
+ if (input.operation === 'rename' && input.replacementId === undefined)
258
+ throw new ServiceError('TECH_IMPACT_REPLACEMENT_REQUIRED', 'Rename impact requires replacementId');
259
+ const references = [];
260
+ if (input.kind === 'technology') {
261
+ requireTechnology(graph, input.id);
262
+ references.push(...graph.edges.filter(({ from, to }) => from === input.id || to === input.id), ...graph.placements.filter(({ technologyId }) => technologyId === input.id), ...graph.unlocks.filter(({ technologyId }) => technologyId === input.id), ...graph.externalReferences.filter(({ technologyId }) => technologyId === input.id));
263
+ }
264
+ else if (input.kind === 'category') {
265
+ references.push(...graph.technologies.filter(({ categories }) => categories.includes(input.id)), ...graph.externalReferences.filter(({ categoryId }) => categoryId === input.id));
266
+ }
267
+ else if (input.kind === 'folder') {
268
+ references.push(...graph.placements.filter(({ folderId }) => folderId === input.id), ...graph.technologies.filter(({ folders }) => folders.includes(input.id)));
269
+ }
270
+ else {
271
+ references.push(...graph.unlocks.filter(({ targetId }) => targetId === input.id));
272
+ }
273
+ const locations = references.flatMap((reference) => {
274
+ if (reference !== null && typeof reference === 'object') {
275
+ if ('location' in reference && reference.location !== undefined)
276
+ return [reference.location];
277
+ if ('source' in reference &&
278
+ reference.source !== undefined &&
279
+ reference.source !== null &&
280
+ typeof reference.source === 'object' &&
281
+ 'location' in reference.source)
282
+ return [reference.source.location];
283
+ }
284
+ return [];
285
+ });
286
+ return {
287
+ id: deterministicId('tech-impact', input),
288
+ subject: input,
289
+ referenceCount: references.length,
290
+ references,
291
+ sourceLocations: locations,
292
+ wouldBreakReferences: references.length > 0,
293
+ replacementCollision: input.replacementId === undefined
294
+ ? false
295
+ : graph.technologies.some(({ id }) => id === input.replacementId) ||
296
+ graph.categories.some(({ id }) => id === input.replacementId) ||
297
+ graph.folders.some(({ id }) => id === input.replacementId),
298
+ limitations: graph.analysisBoundary.unsupportedConstructs,
299
+ };
300
+ }