hoi4-agent-tools 2.5.0 → 2.5.2
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 +18 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +167 -30
- package/dist/hoi4_agent_tools/technology/compare.js +1 -0
- package/dist/hoi4_agent_tools/technology/render.js +41 -25
- package/dist/hoi4_agent_tools/technology/source-analysis.js +19 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/technology.md +1 -1
- package/package.json +1 -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,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
No unreleased changes.
|
|
6
|
+
|
|
7
|
+
## 2.5.2 - 2026-08-15
|
|
8
|
+
|
|
9
|
+
- Distinguished HOI4 small and large technology layouts in graph data and source-positioned folder renders, including exact 72×72 small items selected by `force_use_small_tech_layout`.
|
|
10
|
+
- Reverified the public Technology Tree Viewer MCP workflow across
|
|
11
|
+
`hoi4.tech_inspect`, `hoi4.tech_render`, and `hoi4.tech_compare` before
|
|
12
|
+
publication.
|
|
13
|
+
|
|
14
|
+
## 2.5.1 - 2026-08-13
|
|
15
|
+
|
|
16
|
+
- Published artifact contents and provenance manifests atomically across independent MCP processes, preventing event, probability, GUI, and other concurrent inspections from observing partially written manifests.
|
|
17
|
+
- Recovered clearly interrupted zero-filled or truncated provenance manifests during matching writes and storage pruning while continuing to reject arbitrary malformed or tampered manifests.
|
|
18
|
+
- Tolerated temporary-file disappearance during concurrent storage scans without weakening artifact integrity validation.
|
|
19
|
+
- Added cross-process publication, interrupted-write recovery, and fail-closed integrity regressions, then verified event inspection, probability inspection, and artifact-resource workflows against the repaired store.
|
|
20
|
+
|
|
3
21
|
## 2.5.0 - 2026-08-09
|
|
4
22
|
|
|
5
23
|
- Added exact continuous-focus coordinates and linked palette IDs to compact national focus inspection results.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { lstat, mkdir, open, readFile, readdir, stat, unlink
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { link, lstat, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { z } from 'zod/v4';
|
|
5
5
|
import { canonicalJson, compareCodeUnits, sha256Bytes } from './canonical.js';
|
|
@@ -159,6 +159,8 @@ const artifactManifestMaxBytes = 1_048_576;
|
|
|
159
159
|
const artifactLogicalBatchMaxBytes = 536_870_912;
|
|
160
160
|
const artifactHashYieldBytes = 16_777_216;
|
|
161
161
|
const artifactPreflightYieldEntries = 256;
|
|
162
|
+
const interruptedManifestMinimumAgeMs = 1_000;
|
|
163
|
+
const artifactTemporaryPrefix = '.artifact-tmp-';
|
|
162
164
|
const ownedArtifactContent = Symbol('ownedArtifactContent');
|
|
163
165
|
const precomputedArtifactSha256 = Symbol('precomputedArtifactSha256');
|
|
164
166
|
export const chunkedArtifactIndexMimeType = 'application/json';
|
|
@@ -320,11 +322,22 @@ async function artifactUsage(root, signal) {
|
|
|
320
322
|
if (child.isSymbolicLink()) {
|
|
321
323
|
throw new ServiceError('ARTIFACT_STORAGE_UNSAFE', 'Artifact storage contains a symbolic link or junction');
|
|
322
324
|
}
|
|
325
|
+
if (child.isFile() && child.name.startsWith(artifactTemporaryPrefix))
|
|
326
|
+
continue;
|
|
323
327
|
const candidate = await containedGeneratedPath(root, path.relative(root, path.join(directory, child.name)));
|
|
324
328
|
if (child.isDirectory())
|
|
325
329
|
await walk(candidate);
|
|
326
330
|
else if (child.isFile()) {
|
|
327
|
-
|
|
331
|
+
let size;
|
|
332
|
+
try {
|
|
333
|
+
size = (await stat(candidate)).size;
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
if (error.code === 'ENOENT')
|
|
337
|
+
continue;
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
bytes += size;
|
|
328
341
|
if (child.name.endsWith('.manifest.json'))
|
|
329
342
|
entries += 1;
|
|
330
343
|
if (!Number.isSafeInteger(bytes)) {
|
|
@@ -375,6 +388,101 @@ async function readArtifactManifest(filePath, signal) {
|
|
|
375
388
|
throw new ServiceError('ARTIFACT_MANIFEST_INVALID', 'Artifact provenance manifest is invalid');
|
|
376
389
|
}
|
|
377
390
|
}
|
|
391
|
+
async function readBoundedManifestBytes(filePath, signal) {
|
|
392
|
+
signal?.throwIfAborted();
|
|
393
|
+
let metadata;
|
|
394
|
+
try {
|
|
395
|
+
metadata = await lstat(filePath);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
throw new ServiceError('ARTIFACT_NOT_FOUND', 'Artifact provenance manifest is unavailable');
|
|
399
|
+
}
|
|
400
|
+
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
|
401
|
+
throw new ServiceError('ARTIFACT_MANIFEST_INVALID', 'Artifact provenance manifest is invalid');
|
|
402
|
+
}
|
|
403
|
+
if (metadata.size > artifactManifestMaxBytes) {
|
|
404
|
+
throw new ServiceError('ARTIFACT_MANIFEST_LIMIT', 'Artifact provenance manifest exceeds its fixed byte limit');
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const bytes = signal === undefined ? await readFile(filePath) : await readFile(filePath, { signal });
|
|
408
|
+
return { bytes, metadata };
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (error.name === 'AbortError')
|
|
412
|
+
throw error;
|
|
413
|
+
throw new ServiceError('ARTIFACT_NOT_FOUND', 'Artifact provenance manifest is unavailable');
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function zeroFilled(bytes) {
|
|
417
|
+
return bytes.length === 0 || bytes.every((value) => value === 0);
|
|
418
|
+
}
|
|
419
|
+
async function interruptedManifestState(filePath, expectedBytes, signal) {
|
|
420
|
+
const { bytes, metadata } = await readBoundedManifestBytes(filePath, signal);
|
|
421
|
+
const expected = expectedBytes === undefined ? undefined : Buffer.from(expectedBytes, 'utf8');
|
|
422
|
+
const expectedPrefix = expected !== undefined &&
|
|
423
|
+
bytes.length < expected.length &&
|
|
424
|
+
expected.subarray(0, bytes.length).equals(bytes);
|
|
425
|
+
const staleZeroFill = zeroFilled(bytes) && Date.now() - Number(metadata.mtimeMs) >= interruptedManifestMinimumAgeMs;
|
|
426
|
+
return { interrupted: expectedPrefix || staleZeroFill, size: Number(metadata.size) };
|
|
427
|
+
}
|
|
428
|
+
async function existingManifestMatches(filePath, expectedBytes, signal) {
|
|
429
|
+
if (!(await fileExists(filePath)))
|
|
430
|
+
return false;
|
|
431
|
+
try {
|
|
432
|
+
const manifest = await readArtifactManifest(filePath, signal);
|
|
433
|
+
if (`${canonicalJson(manifest)}\n` !== expectedBytes) {
|
|
434
|
+
throw new ServiceError('ARTIFACT_MANIFEST_INTEGRITY_FAILED', 'Immutable artifact provenance manifest contains different bytes');
|
|
435
|
+
}
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
if (error instanceof ServiceError && error.code === 'ARTIFACT_NOT_FOUND')
|
|
440
|
+
return false;
|
|
441
|
+
if (!(error instanceof ServiceError) || error.code !== 'ARTIFACT_MANIFEST_INVALID')
|
|
442
|
+
throw error;
|
|
443
|
+
const state = await interruptedManifestState(filePath, expectedBytes, signal);
|
|
444
|
+
if (!state.interrupted)
|
|
445
|
+
throw error;
|
|
446
|
+
try {
|
|
447
|
+
await unlink(filePath);
|
|
448
|
+
}
|
|
449
|
+
catch (unlinkError) {
|
|
450
|
+
if (unlinkError.code !== 'ENOENT')
|
|
451
|
+
throw unlinkError;
|
|
452
|
+
}
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async function publishExclusiveFile(target, bytes, signal) {
|
|
457
|
+
signal?.throwIfAborted();
|
|
458
|
+
const temporary = path.join(path.dirname(target), `${artifactTemporaryPrefix}${process.pid}-${randomUUID().replaceAll('-', '')}`);
|
|
459
|
+
let handle;
|
|
460
|
+
try {
|
|
461
|
+
handle = await open(temporary, 'wx');
|
|
462
|
+
await handle.writeFile(bytes, signal === undefined ? undefined : { signal });
|
|
463
|
+
await handle.sync();
|
|
464
|
+
await handle.close();
|
|
465
|
+
handle = undefined;
|
|
466
|
+
signal?.throwIfAborted();
|
|
467
|
+
try {
|
|
468
|
+
await link(temporary, target);
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (error.code === 'EEXIST')
|
|
473
|
+
return false;
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
if (handle !== undefined)
|
|
479
|
+
await handle.close().catch(() => undefined);
|
|
480
|
+
await unlink(temporary).catch((error) => {
|
|
481
|
+
if (error.code !== 'ENOENT')
|
|
482
|
+
throw error;
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
378
486
|
async function readVerifiedArtifactRange(filePath, expectedSha256, range, verifiedIdentity, signal) {
|
|
379
487
|
signal?.throwIfAborted();
|
|
380
488
|
if (range !== undefined &&
|
|
@@ -495,8 +603,47 @@ export class ArtifactStore {
|
|
|
495
603
|
}
|
|
496
604
|
if (!child.isFile() || !child.name.endsWith('.manifest.json'))
|
|
497
605
|
continue;
|
|
498
|
-
|
|
499
|
-
|
|
606
|
+
let metadata;
|
|
607
|
+
try {
|
|
608
|
+
metadata = await lstat(candidate);
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
if (error.code === 'ENOENT')
|
|
612
|
+
continue;
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
let manifest;
|
|
616
|
+
try {
|
|
617
|
+
manifest = await readArtifactManifest(candidate, signal);
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
if (error instanceof ServiceError && error.code === 'ARTIFACT_NOT_FOUND')
|
|
621
|
+
continue;
|
|
622
|
+
if (!(error instanceof ServiceError) || error.code !== 'ARTIFACT_MANIFEST_INVALID')
|
|
623
|
+
throw error;
|
|
624
|
+
let interrupted;
|
|
625
|
+
try {
|
|
626
|
+
interrupted = await interruptedManifestState(candidate, undefined, signal);
|
|
627
|
+
}
|
|
628
|
+
catch (interruptedError) {
|
|
629
|
+
if (interruptedError instanceof ServiceError &&
|
|
630
|
+
interruptedError.code === 'ARTIFACT_NOT_FOUND')
|
|
631
|
+
continue;
|
|
632
|
+
throw interruptedError;
|
|
633
|
+
}
|
|
634
|
+
if (!interrupted.interrupted)
|
|
635
|
+
throw error;
|
|
636
|
+
try {
|
|
637
|
+
await unlink(candidate);
|
|
638
|
+
}
|
|
639
|
+
catch (unlinkError) {
|
|
640
|
+
if (unlinkError.code !== 'ENOENT')
|
|
641
|
+
throw unlinkError;
|
|
642
|
+
}
|
|
643
|
+
usage.entries = Math.max(0, usage.entries - 1);
|
|
644
|
+
usage.bytes = Math.max(0, usage.bytes - interrupted.size);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
500
647
|
if (child.name !== `${manifest.name}.${manifest.provenanceHash}.manifest.json`) {
|
|
501
648
|
throw new ServiceError('ARTIFACT_MANIFEST_INTEGRITY_FAILED', 'Artifact provenance manifest filename does not match its contents');
|
|
502
649
|
}
|
|
@@ -993,13 +1140,8 @@ export class ArtifactStore {
|
|
|
993
1140
|
throw new ServiceError('ARTIFACT_MANIFEST_INTEGRITY_FAILED', 'Artifact batch maps different provenance to the same immutable manifest');
|
|
994
1141
|
}
|
|
995
1142
|
if (duplicateManifest === undefined) {
|
|
996
|
-
const exists = await
|
|
1143
|
+
const exists = await existingManifestMatches(artifact.manifestPath, artifact.manifestBytes, signal);
|
|
997
1144
|
signal?.throwIfAborted();
|
|
998
|
-
if (exists &&
|
|
999
|
-
`${canonicalJson(await readArtifactManifest(artifact.manifestPath, signal))}\n` !==
|
|
1000
|
-
artifact.manifestBytes) {
|
|
1001
|
-
throw new ServiceError('ARTIFACT_MANIFEST_INTEGRITY_FAILED', 'Immutable artifact provenance manifest contains different bytes');
|
|
1002
|
-
}
|
|
1003
1145
|
uniqueManifests.set(artifact.manifestPath, { ...artifact, exists });
|
|
1004
1146
|
}
|
|
1005
1147
|
}
|
|
@@ -1020,18 +1162,19 @@ export class ArtifactStore {
|
|
|
1020
1162
|
continue;
|
|
1021
1163
|
await mkdir(artifact.directory, { recursive: true });
|
|
1022
1164
|
signal?.throwIfAborted();
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
flag: 'wx',
|
|
1026
|
-
...(signal === undefined ? {} : { signal }),
|
|
1027
|
-
});
|
|
1165
|
+
const created = await publishExclusiveFile(artifact.target, artifact.bytes, signal);
|
|
1166
|
+
if (created) {
|
|
1028
1167
|
createdTargets.push(artifact.target);
|
|
1029
1168
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1169
|
+
else {
|
|
1170
|
+
try {
|
|
1171
|
+
await readVerifiedArtifactRange(artifact.target, artifact.artifact.sha256, { offset: 0, length: 1 }, undefined, signal);
|
|
1172
|
+
}
|
|
1173
|
+
catch (error) {
|
|
1174
|
+
if (error.name === 'AbortError')
|
|
1175
|
+
throw error;
|
|
1176
|
+
throw new ServiceError('ARTIFACT_HASH_COLLISION', 'Artifact path contains different bytes');
|
|
1033
1177
|
}
|
|
1034
|
-
throw error;
|
|
1035
1178
|
}
|
|
1036
1179
|
}
|
|
1037
1180
|
for (const artifact of uniqueManifests.values()) {
|
|
@@ -1040,18 +1183,12 @@ export class ArtifactStore {
|
|
|
1040
1183
|
continue;
|
|
1041
1184
|
await mkdir(artifact.directory, { recursive: true });
|
|
1042
1185
|
signal?.throwIfAborted();
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
flag: 'wx',
|
|
1046
|
-
...(signal === undefined ? {} : { signal }),
|
|
1047
|
-
});
|
|
1186
|
+
const created = await publishExclusiveFile(artifact.manifestPath, artifact.manifestBytes, signal);
|
|
1187
|
+
if (created) {
|
|
1048
1188
|
createdManifests.push(artifact.manifestPath);
|
|
1049
1189
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
createdManifests.push(artifact.manifestPath);
|
|
1053
|
-
}
|
|
1054
|
-
throw error;
|
|
1190
|
+
else if (!(await existingManifestMatches(artifact.manifestPath, artifact.manifestBytes, signal))) {
|
|
1191
|
+
throw new ServiceError('ARTIFACT_MANIFEST_INVALID', 'Concurrent artifact provenance manifest publication did not complete');
|
|
1055
1192
|
}
|
|
1056
1193
|
}
|
|
1057
1194
|
signal?.throwIfAborted();
|
|
@@ -92,6 +92,7 @@ function technologyMetadata(technology) {
|
|
|
92
92
|
researchCost: technology.researchCost ?? null,
|
|
93
93
|
doctrineName: technology.doctrineName ?? null,
|
|
94
94
|
hidden: technology.hidden,
|
|
95
|
+
layoutSize: technology.layoutSize,
|
|
95
96
|
folders: technology.folders,
|
|
96
97
|
categories: technology.categories,
|
|
97
98
|
tags: technology.tags,
|
|
@@ -5,8 +5,10 @@ import { ServiceError } from '../core/result.js';
|
|
|
5
5
|
import { DeterministicSvgTextRenderer } from '../core/svg-text.js';
|
|
6
6
|
import { traceTechnology } from './queries.js';
|
|
7
7
|
const MAX_RENDER_NODES = 2_000;
|
|
8
|
-
const
|
|
9
|
-
const
|
|
8
|
+
const LARGE_NODE_WIDTH = 183;
|
|
9
|
+
const LARGE_NODE_HEIGHT = 84;
|
|
10
|
+
const SMALL_NODE_WIDTH = 72;
|
|
11
|
+
const SMALL_NODE_HEIGHT = 72;
|
|
10
12
|
const GAP_X = 42;
|
|
11
13
|
const GAP_Y = 42;
|
|
12
14
|
const GENERATED_NODE_WIDTH = 180;
|
|
@@ -44,6 +46,7 @@ function technologyNode(graph, technologyId, placement) {
|
|
|
44
46
|
kind: technology.kind,
|
|
45
47
|
sourcePath: technology.source.path,
|
|
46
48
|
sourceLine: technology.source.location.start.line,
|
|
49
|
+
layoutSize: technology.layoutSize,
|
|
47
50
|
...(placement === undefined ? {} : { placement }),
|
|
48
51
|
};
|
|
49
52
|
}
|
|
@@ -415,17 +418,22 @@ function issueKey(issue) {
|
|
|
415
418
|
return sha256Bytes(canonicalJson({ code: issue.code, details: issue.details })).slice(0, 20);
|
|
416
419
|
}
|
|
417
420
|
function layoutFolder(nodes) {
|
|
421
|
+
const dimensions = (node) => node.layoutSize === 'small'
|
|
422
|
+
? { width: SMALL_NODE_WIDTH, height: SMALL_NODE_HEIGHT }
|
|
423
|
+
: { width: LARGE_NODE_WIDTH, height: LARGE_NODE_HEIGHT };
|
|
418
424
|
const sourcePixel = nodes.filter(({ placement }) => placement?.pixelX !== undefined && placement.pixelY !== undefined);
|
|
419
425
|
if (sourcePixel.length === nodes.length && sourcePixel.length > 0) {
|
|
420
426
|
const minimumX = Math.min(...sourcePixel.map(({ placement }) => placement.pixelX));
|
|
421
427
|
const minimumY = Math.min(...sourcePixel.map(({ placement }) => placement.pixelY));
|
|
422
|
-
return sourcePixel.map((node) =>
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
428
|
+
return sourcePixel.map((node) => {
|
|
429
|
+
const size = dimensions(node);
|
|
430
|
+
return {
|
|
431
|
+
...node,
|
|
432
|
+
x: PADDING + node.placement.pixelX - minimumX,
|
|
433
|
+
y: HEADER_HEIGHT + PADDING + node.placement.pixelY - minimumY,
|
|
434
|
+
...size,
|
|
435
|
+
};
|
|
436
|
+
});
|
|
429
437
|
}
|
|
430
438
|
const branchRoots = [
|
|
431
439
|
...new Set(nodes.map(({ placement }) => placement?.branchRootId ?? '<unresolved>')),
|
|
@@ -434,9 +442,12 @@ function layoutFolder(nodes) {
|
|
|
434
442
|
return nodes.map((node, index) => {
|
|
435
443
|
const placement = node.placement;
|
|
436
444
|
const lane = lanes.get(placement?.branchRootId ?? '<unresolved>') ?? 0;
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
|
|
445
|
+
const size = dimensions(node);
|
|
446
|
+
const x = PADDING + lane * 680 + (placement?.x ?? index % 3) * (LARGE_NODE_WIDTH + GAP_X);
|
|
447
|
+
const y = HEADER_HEIGHT +
|
|
448
|
+
PADDING +
|
|
449
|
+
(placement?.y ?? Math.floor(index / 3)) * (LARGE_NODE_HEIGHT + GAP_Y);
|
|
450
|
+
return { ...node, x, y, ...size };
|
|
440
451
|
});
|
|
441
452
|
}
|
|
442
453
|
function layoutGenerated(nodes, edges) {
|
|
@@ -527,8 +538,8 @@ function colours(kind) {
|
|
|
527
538
|
}[kind];
|
|
528
539
|
}
|
|
529
540
|
function svgFor(titleText, positioned, edges, sourceAccurate) {
|
|
530
|
-
const maximumX = Math.max(PADDING +
|
|
531
|
-
const maximumY = Math.max(HEADER_HEIGHT +
|
|
541
|
+
const maximumX = Math.max(PADDING + LARGE_NODE_WIDTH, ...positioned.map(({ x, width }) => x + width));
|
|
542
|
+
const maximumY = Math.max(HEADER_HEIGHT + LARGE_NODE_HEIGHT, ...positioned.map(({ y, height }) => y + height));
|
|
532
543
|
const width = Math.ceil(maximumX + PADDING);
|
|
533
544
|
const height = Math.ceil(maximumY + PADDING);
|
|
534
545
|
if (width > 16_384 || height > 16_384 || width * height > RENDER_MAX_PIXELS)
|
|
@@ -554,24 +565,29 @@ function svgFor(titleText, positioned, edges, sourceAccurate) {
|
|
|
554
565
|
});
|
|
555
566
|
const nodeSvg = positioned.map((node) => {
|
|
556
567
|
const colour = colours(node.kind);
|
|
557
|
-
const
|
|
568
|
+
const small = node.layoutSize === 'small';
|
|
569
|
+
const labelLimit = small ? 12 : 34;
|
|
570
|
+
const label = node.label.length > labelLimit
|
|
571
|
+
? `${node.label.slice(0, Math.max(1, labelLimit - 1))}…`
|
|
572
|
+
: node.label;
|
|
558
573
|
const subtitle = node.subtitle.length > 48 ? `${node.subtitle.slice(0, 45)}…` : node.subtitle;
|
|
559
574
|
const labelSvg = text.render(label, {
|
|
560
|
-
x: node.x + 12,
|
|
561
|
-
y: node.y + 30,
|
|
562
|
-
fontSize: 15,
|
|
575
|
+
x: node.x + (small ? 6 : 12),
|
|
576
|
+
y: node.y + (small ? 27 : 30),
|
|
577
|
+
fontSize: small ? 9 : 15,
|
|
563
578
|
fill: '#f1f5f8',
|
|
564
579
|
weight: 600,
|
|
565
|
-
targetWidth: Math.min(node.width - 24, Math.max(1, text.measure(label, 15))),
|
|
580
|
+
targetWidth: Math.min(node.width - (small ? 12 : 24), Math.max(1, text.measure(label, small ? 9 : 15))),
|
|
566
581
|
});
|
|
567
|
-
const subtitleSvg = text.render(subtitle, {
|
|
568
|
-
x: node.x + 12,
|
|
569
|
-
y: node.y +
|
|
570
|
-
fontSize: 11,
|
|
582
|
+
const subtitleSvg = text.render(small ? 'SMALL' : subtitle, {
|
|
583
|
+
x: node.x + (small ? 6 : 12),
|
|
584
|
+
y: node.y + (small ? 51 : 58),
|
|
585
|
+
fontSize: small ? 8 : 11,
|
|
571
586
|
fill: '#b8c4ce',
|
|
572
|
-
targetWidth: Math.min(node.width - 24, Math.max(1, text.measure(subtitle, 11))),
|
|
587
|
+
targetWidth: Math.min(node.width - (small ? 12 : 24), Math.max(1, text.measure(small ? 'SMALL' : subtitle, small ? 8 : 11))),
|
|
573
588
|
});
|
|
574
|
-
|
|
589
|
+
const layoutAttribute = node.layoutSize === undefined ? '' : ` data-layout-size="${node.layoutSize}"`;
|
|
590
|
+
return `<g data-node-id="${escapeXml(node.id)}"${layoutAttribute}${node.sourcePath === undefined ? '' : ` data-source-path="${escapeXml(node.sourcePath)}" data-source-line="${node.sourceLine ?? 1}"`}><rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="8" fill="${colour.fill}" stroke="${colour.stroke}" stroke-width="2"/><title>${escapeXml(`${node.id}${node.layoutSize === undefined ? '' : ` · ${node.layoutSize} layout`}${node.sourcePath === undefined ? '' : ` · ${node.sourcePath}:${node.sourceLine ?? 1}`}`)}</title>${labelSvg}${subtitleSvg}</g>`;
|
|
575
591
|
});
|
|
576
592
|
const title = text.render(titleText, {
|
|
577
593
|
x: PADDING,
|
|
@@ -348,6 +348,13 @@ function parseTechnologyDefinitions(file, document, fragment) {
|
|
|
348
348
|
.map(({ key }) => key.value)
|
|
349
349
|
.filter((key) => !technologyStructuralFields.has(key) && !key.startsWith('@'))),
|
|
350
350
|
].sort(compareCodeUnits);
|
|
351
|
+
const layoutExpression = firstValue(block, 'force_use_small_tech_layout');
|
|
352
|
+
const smallLayout = booleanValue(layoutExpression);
|
|
353
|
+
const layoutSize = layoutExpression === undefined || smallLayout === false
|
|
354
|
+
? 'large'
|
|
355
|
+
: smallLayout === true
|
|
356
|
+
? 'small'
|
|
357
|
+
: 'unknown';
|
|
351
358
|
const unsupportedFields = fragment.placements
|
|
352
359
|
.filter(({ technologyId, xExpression, yExpression }) => technologyId === id && (xExpression !== undefined || yExpression !== undefined))
|
|
353
360
|
.map((placement) => ({
|
|
@@ -355,7 +362,17 @@ function parseTechnologyDefinitions(file, document, fragment) {
|
|
|
355
362
|
expression: `${placement.xExpression ?? placement.x ?? '?'}:${placement.yExpression ?? placement.y ?? '?'}`,
|
|
356
363
|
location: placement.location,
|
|
357
364
|
reason: 'The source coordinate expression is not a resolvable file-local numeric constant',
|
|
358
|
-
}))
|
|
365
|
+
}))
|
|
366
|
+
.concat(layoutSize === 'unknown'
|
|
367
|
+
? [
|
|
368
|
+
{
|
|
369
|
+
field: 'force_use_small_tech_layout',
|
|
370
|
+
expression: layoutExpression,
|
|
371
|
+
location: nodeLocation(document, block, id),
|
|
372
|
+
reason: 'The technology layout-size expression is not a literal yes or no',
|
|
373
|
+
},
|
|
374
|
+
]
|
|
375
|
+
: []);
|
|
359
376
|
const doctrine = booleanValue(firstValue(block, 'doctrine')) === true;
|
|
360
377
|
fragment.technologies.push({
|
|
361
378
|
id,
|
|
@@ -372,6 +389,7 @@ function parseTechnologyDefinitions(file, document, fragment) {
|
|
|
372
389
|
? {}
|
|
373
390
|
: { doctrineName: firstValue(block, 'doctrine_name') }),
|
|
374
391
|
hidden: hiddenStatus(block),
|
|
392
|
+
layoutSize,
|
|
375
393
|
folders: [...new Set(folders)].sort(compareCodeUnits),
|
|
376
394
|
categories: [...new Set(categories)].sort(compareCodeUnits),
|
|
377
395
|
tags: [...new Set(tags)].sort(compareCodeUnits),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
export const PACKAGE_NAME = 'hoi4-agent-tools';
|
|
3
|
-
export const PACKAGE_VERSION = '2.5.
|
|
3
|
+
export const PACKAGE_VERSION = '2.5.2';
|
|
4
4
|
export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
|
|
5
5
|
export const CONFIG_VERSION = 1;
|
|
6
6
|
export const TRANSACTION_VERSION = 1;
|
package/docs/technology.md
CHANGED
|
@@ -51,7 +51,7 @@ Game-backed and otherwise large workspaces automatically use focused analysis mo
|
|
|
51
51
|
|
|
52
52
|
`hoi4.tech_render` returns authoritative JSON with deterministic SVG and PNG resources; set `includeHtml` when a bundled static report is useful. Available views are `summary`, `folder`, `dependencies`, `technology`, `doctrine`, `exclusive`, `memberships`, `bonuses`, `grants`, `unlocks`, `metadata`, `assets`, and `unresolved`.
|
|
53
53
|
|
|
54
|
-
Folder renders use the actual folder assignments, gridbox geometry,
|
|
54
|
+
Folder renders use the actual folder assignments, gridbox geometry, technology coordinates, and HOI4 item size found in source. Technologies with `force_use_small_tech_layout = yes` render as 72×72 small items; normal technologies render as large items. The authoritative JSON and SVG expose `layoutSize` for every technology. Dependency and other semantic views are explicitly labelled as generated analysis layouts. Missing sprites, textures, or localisation remain identifiable from JSON and vector output even when no icon raster is decoded.
|
|
55
55
|
|
|
56
56
|
Example folder render:
|
|
57
57
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hoi4-agent-tools",
|
|
3
3
|
"mcpName": "io.github.klimPaskov/hoi4-agent-tools",
|
|
4
|
-
"version": "2.5.
|
|
4
|
+
"version": "2.5.2",
|
|
5
5
|
"description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/configuration.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/continuous-focus-palette.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/custom-weighted-pool.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/event-feature-manifest.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/focus-plan.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/focus-planning-sidecar.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/gui-animation-source.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/gui-helper.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/gui-scenario.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/map-operation.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"oneOf": [
|
|
5
5
|
{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/operation-result.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-analysis-result.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-compare-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-evaluate-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-inspect-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-render-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-scenario-set.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-sequence-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-simulate-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-sweep-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.klimPaskov/hoi4-agent-tools",
|
|
4
4
|
"title": "HOI4 Agent Tools",
|
|
5
5
|
"description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
|
|
6
|
-
"version": "2.5.
|
|
6
|
+
"version": "2.5.2",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/klimPaskov/hoi4-agent-tools",
|
|
9
9
|
"source": "github"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "hoi4-agent-tools",
|
|
16
|
-
"version": "2.5.
|
|
16
|
+
"version": "2.5.2",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|