hoi4-agent-tools 2.8.10 → 3.0.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +35 -10
  3. package/dist/bin/stdio.js +0 -4
  4. package/dist/hoi4_agent_tools/core/configuration.js +16 -3
  5. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +10 -13
  6. package/dist/hoi4_agent_tools/core/engine.js +3 -0
  7. package/dist/hoi4_agent_tools/core/scanner.js +63 -7
  8. package/dist/hoi4_agent_tools/core/source/limits.js +6 -7
  9. package/dist/hoi4_agent_tools/event/graph.js +3 -4
  10. package/dist/hoi4_agent_tools/event/limits.js +4 -4
  11. package/dist/hoi4_agent_tools/event/service.js +34 -39
  12. package/dist/hoi4_agent_tools/gui/assets.js +280 -12
  13. package/dist/hoi4_agent_tools/gui/layout.js +70 -9
  14. package/dist/hoi4_agent_tools/gui/renderer.js +81 -12
  15. package/dist/hoi4_agent_tools/gui/scenario-generator.js +36 -0
  16. package/dist/hoi4_agent_tools/gui/studio.js +90 -25
  17. package/dist/hoi4_agent_tools/mcp/tools/gui.js +1 -1
  18. package/dist/hoi4_agent_tools/mcp/tools/probability.js +72 -14
  19. package/dist/hoi4_agent_tools/probability/sequence.js +43 -18
  20. package/dist/hoi4_agent_tools/probability/service.js +187 -24
  21. package/dist/hoi4_agent_tools/schemas/probability.js +2 -0
  22. package/dist/hoi4_agent_tools/technology/service.js +41 -8
  23. package/dist/hoi4_agent_tools/version.js +1 -1
  24. package/docs/development.md +2 -0
  25. package/docs/gui.md +14 -3
  26. package/docs/images/readme/chaos-meter-ingame.png +0 -0
  27. package/docs/images/readme/chaos-meter-mcp.png +0 -0
  28. package/docs/images/readme/communist-insurgency-ingame.png +0 -0
  29. package/docs/images/readme/communist-insurgency-mcp.png +0 -0
  30. package/docs/images/readme/event-chain-mcp.png +0 -0
  31. package/docs/images/readme/focus-tree-mcp.png +0 -0
  32. package/docs/images/readme/map-mcp.png +0 -0
  33. package/docs/images/readme/probability-mcp.png +0 -0
  34. package/docs/images/readme/settings-advanced-mcp.png +0 -0
  35. package/docs/images/readme/settings-events-mcp.png +0 -0
  36. package/docs/images/readme/technology-tree-mcp.png +0 -0
  37. package/package.json +3 -1
  38. package/schemas/configuration.schema.json +3 -3
  39. package/schemas/continuous-focus-palette.schema.json +1 -1
  40. package/schemas/custom-weighted-pool.schema.json +1 -1
  41. package/schemas/event-feature-manifest.schema.json +1 -1
  42. package/schemas/focus-plan.schema.json +1 -1
  43. package/schemas/focus-planning-sidecar.schema.json +1 -1
  44. package/schemas/gui-animation-source.schema.json +1 -1
  45. package/schemas/gui-helper.schema.json +1 -1
  46. package/schemas/gui-scenario.schema.json +1 -1
  47. package/schemas/map-operation.schema.json +1 -1
  48. package/schemas/operation-result.schema.json +1 -1
  49. package/schemas/probability-analysis-result.schema.json +58 -39
  50. package/schemas/probability-compare-input.schema.json +1 -1
  51. package/schemas/probability-evaluate-input.schema.json +1 -1
  52. package/schemas/probability-inspect-input.schema.json +1 -1
  53. package/schemas/probability-render-input.schema.json +1 -1
  54. package/schemas/probability-scenario-set.schema.json +1 -1
  55. package/schemas/probability-sequence-input.schema.json +1 -1
  56. package/schemas/probability-simulate-input.schema.json +1 -1
  57. package/schemas/probability-sweep-input.schema.json +1 -1
  58. package/server.json +2 -2
  59. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +0 -483
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { canonicalJson, compareCodeUnits, hashCanonical } from '../core/canonical.js';
4
4
  import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
5
- import { probabilityDomainScanPatterns } from '../core/domain-scan-patterns.js';
5
+ import { probabilityDomainScanPatterns, probabilitySharedDefinitionPatterns, } from '../core/domain-scan-patterns.js';
6
6
  import { publicArtifactLink } from '../core/artifacts.js';
7
7
  import { ServiceError } from '../core/result.js';
8
8
  import { PACKAGE_VERSION } from '../version.js';
@@ -11,8 +11,9 @@ import { compareProbabilityResults } from './compare.js';
11
11
  import { evaluateExactCandidates, evaluateSurfaceScenarios } from './evaluation.js';
12
12
  import { VERIFIED_GAME_CHECKSUM, VERIFIED_GAME_RAW_VERSION, VERIFIED_GAME_VERSION, } from './model.js';
13
13
  import { renderProbabilityResult } from './render.js';
14
- import { analyzeSequence } from './sequence.js';
14
+ import { analyzeSequence, evaluateCustomPoolPoint } from './sequence.js';
15
15
  import { simulateSurface } from './simulation.js';
16
+ import { Rational } from './rational.js';
16
17
  import { discoverWeightedSurface, inventoryWeightedSurfaces, } from './source-analysis.js';
17
18
  import { sweepSurface } from './sweep.js';
18
19
  const states = new WeakMap();
@@ -584,6 +585,63 @@ function customSurface(manifest, sourceRevision) {
584
585
  unsupported: [],
585
586
  };
586
587
  }
588
+ function customPoolScenarios(surface, manifest, scenarioSet) {
589
+ return scenarioSet.scenarios.map((scenario) => {
590
+ const point = evaluateCustomPoolPoint(manifest, scenario);
591
+ const ordered = [...point.candidates].sort((left, right) => right.probability - left.probability || compareCodeUnits(left.id, right.id));
592
+ const rank = new Map(ordered.map((candidate, index) => [candidate.id, index + 1]));
593
+ const candidates = point.candidates.map((candidate) => {
594
+ const exactWeight = Rational.parse(candidate.weight);
595
+ const exactPoolTotal = Rational.parse(point.poolTotal);
596
+ const exactProbability = !candidate.eligible
597
+ ? Rational.zero
598
+ : manifest.selection.mode === 'categorical_weighted'
599
+ ? exactWeight !== undefined && exactPoolTotal !== undefined && !exactPoolTotal.isZero()
600
+ ? exactWeight.divide(exactPoolTotal)
601
+ : undefined
602
+ : exactWeight?.divide(new Rational(100n)).max(Rational.zero).min(Rational.one);
603
+ const unresolved = point.unresolved.filter(({ candidateId }) => candidateId === undefined || candidateId === candidate.id);
604
+ return {
605
+ id: candidate.id,
606
+ eligibility: candidate.eligible ? 'true' : 'false',
607
+ supportLevel: unresolved.length === 0 ? 'exact' : 'unsupported',
608
+ rawValue: exactWeight?.toJSON() ?? null,
609
+ conditionalProbability: candidate.probability,
610
+ ...(exactProbability === undefined
611
+ ? {}
612
+ : { exactConditionalProbability: exactProbability.toJSON() }),
613
+ rank: candidate.eligible ? (rank.get(candidate.id) ?? null) : null,
614
+ trace: [],
615
+ provenance: surface.candidates.find(({ id }) => id === candidate.id)?.provenance ?? [],
616
+ unresolved,
617
+ };
618
+ });
619
+ const eligible = ordered.filter(({ eligible }) => eligible);
620
+ return {
621
+ id: scenario.id,
622
+ ...(scenario.label === undefined ? {} : { label: scenario.label }),
623
+ ...(scenario.prevalence === undefined ? {} : { prevalence: scenario.prevalence }),
624
+ poolComplete: true,
625
+ supportLevel: point.unresolved.length === 0 ? 'exact' : 'unsupported',
626
+ candidates,
627
+ ...(Rational.parse(point.poolTotal) === undefined
628
+ ? {}
629
+ : { poolTotal: Rational.parse(point.poolTotal).toJSON() }),
630
+ summary: {
631
+ topOutcomes: eligible.slice(0, 3).map(({ id }) => id),
632
+ bottomEligibleOutcomes: eligible.slice(-3).map(({ id }) => id),
633
+ impossibleOutcomes: ordered
634
+ .filter(({ probability }) => probability === 0)
635
+ .map(({ id }) => id),
636
+ unresolvedOutcomes: candidates
637
+ .filter(({ unresolved }) => unresolved.length > 0)
638
+ .map(({ id }) => id),
639
+ dominantFactors: [],
640
+ },
641
+ unresolved: point.unresolved,
642
+ };
643
+ });
644
+ }
587
645
  export class ProbabilityAnalyzer {
588
646
  engine;
589
647
  state;
@@ -591,12 +649,19 @@ export class ProbabilityAnalyzer {
591
649
  this.engine = engine;
592
650
  this.state = analyzerState(engine);
593
651
  }
594
- async scan(context, sourcePaths = []) {
652
+ async scan(context, sourcePaths = [], emptyScope = 'domain') {
595
653
  if (context.refresh === true)
596
654
  this.engine.invalidate(context.workspaceId);
597
655
  const workspace = this.engine.resolver.get(context.workspaceId, context.principal);
656
+ const selectedSourcePaths = sourcePaths.filter((sourcePath) => sourcePath !== undefined);
598
657
  return this.engine.scan(context.workspaceId, {
599
- patterns: probabilityDomainScanPatterns(workspace, sourcePaths.filter((sourcePath) => sourcePath !== undefined)),
658
+ patterns: selectedSourcePaths.length > 0
659
+ ? probabilityDomainScanPatterns(workspace, selectedSourcePaths)
660
+ : emptyScope === 'domain'
661
+ ? probabilityDomainScanPatterns(workspace)
662
+ : emptyScope === 'shared'
663
+ ? probabilitySharedDefinitionPatterns()
664
+ : [],
600
665
  }, context.principal, context.signal);
601
666
  }
602
667
  async verifyGameVersion(context, adapter) {
@@ -656,6 +721,14 @@ export class ProbabilityAnalyzer {
656
721
  return {
657
722
  workspaceId: context.workspaceId,
658
723
  workspaceIdentity: workspace.workspaceIdentity,
724
+ ...(typeof extra.sourceScope === 'string'
725
+ ? {
726
+ sourceScope: extra.sourceScope,
727
+ }
728
+ : {}),
729
+ ...(Array.isArray(extra.sourcePaths) && extra.sourcePaths.length > 0
730
+ ? { sourcePaths: extra.sourcePaths }
731
+ : {}),
659
732
  sourceRevision: snapshot.revision,
660
733
  sourceHash: surface.sourceHash,
661
734
  scenarioHash,
@@ -775,13 +848,61 @@ export class ProbabilityAnalyzer {
775
848
  }
776
849
  return complete;
777
850
  }
778
- async inspect(context, adapter, source, candidatePool = []) {
779
- if (source === undefined)
851
+ async inspect(context, adapter, source, candidatePool = [], customPoolManifest) {
852
+ if (source === undefined && customPoolManifest === undefined)
780
853
  return { adapters: probabilityAdapters(), artifacts: [], filesScanned: [] };
781
- const snapshot = await this.scan(context, [source.path]);
854
+ if (customPoolManifest !== undefined && source !== undefined)
855
+ throw new ServiceError('PROBABILITY_INPUT_AMBIGUOUS', 'Provide either source or customPoolManifest for inspection');
856
+ if (customPoolManifest !== undefined && adapter !== 'custom_weighted_pool')
857
+ throw new ServiceError('PROBABILITY_ADAPTER_MISMATCH', 'customPoolManifest inspection requires adapter custom_weighted_pool');
858
+ const snapshot = await this.scan(context, [source?.path], source === undefined ? 'none' : source.inlineClausewitz === undefined ? 'domain' : 'shared');
859
+ if (customPoolManifest !== undefined) {
860
+ const surface = customSurface(customPoolManifest, snapshot.revision);
861
+ const gameVersionVerification = await this.verifyGameVersion(context, 'custom_weighted_pool');
862
+ const report = {
863
+ schemaVersion: 'probability-inspection.v2',
864
+ workspaceId: context.workspaceId,
865
+ surface: {
866
+ id: surface.id,
867
+ adapter: surface.adapter,
868
+ poolComplete: true,
869
+ sourceRevision: surface.sourceRevision,
870
+ sourceHash: surface.sourceHash,
871
+ gameVersionVerification,
872
+ candidateCount: surface.candidates.length,
873
+ candidates: surface.candidates.map((candidate) => ({
874
+ id: candidate.id,
875
+ sourceKind: candidate.sourceKind,
876
+ provenance: candidate.provenance,
877
+ hasEligibility: customPoolManifest.candidates.find(({ id }) => id === candidate.id)?.eligibleWhen !==
878
+ undefined,
879
+ hasWeightBlock: true,
880
+ requiredInputs: [],
881
+ referencedProvenance: [],
882
+ analysisSupport: 'exact',
883
+ })),
884
+ requiredInputs: [],
885
+ unsupported: [],
886
+ },
887
+ };
888
+ const workspace = this.engine.resolver.get(context.workspaceId, context.principal);
889
+ const artifact = await this.engine.artifacts.putChunked(workspace, `probability-inspect-${surface.sourceHash.slice(0, 12)}.json`, 'application/json', `${canonicalJson(report)}\n`, {
890
+ kind: 'probability-inspection',
891
+ toolVersion: PACKAGE_VERSION,
892
+ schemaVersion: 'probability-inspection.v2',
893
+ sourceHashes: { aggregate: surface.sourceHash },
894
+ }, 'Declared custom weighted-pool inventory', context.signal);
895
+ return {
896
+ adapters: probabilityAdapters(),
897
+ surface: report.surface,
898
+ artifacts: [publicArtifactLink(artifact)],
899
+ filesScanned: [],
900
+ };
901
+ }
902
+ const selectedSource = source;
782
903
  const storeDiscovery = async (inventory, reason) => {
783
904
  const availableAdapters = inventory.adapters.filter(({ candidateCount }) => candidateCount > 0);
784
- const matchingIdentifier = availableAdapters.filter(({ identifierMatchCount }) => source.identifier !== undefined && identifierMatchCount > 0);
905
+ const matchingIdentifier = availableAdapters.filter(({ identifierMatchCount }) => selectedSource.identifier !== undefined && identifierMatchCount > 0);
785
906
  const matchingPool = availableAdapters.filter(({ candidatePoolMatchCount }) => candidatePool.length > 0 && candidatePoolMatchCount > 0);
786
907
  const requested = availableAdapters.find(({ adapterId }) => adapterId === adapter);
787
908
  const ranked = [
@@ -826,7 +947,7 @@ export class ProbabilityAnalyzer {
826
947
  };
827
948
  };
828
949
  if (adapter === undefined) {
829
- const inventory = inventoryWeightedSurfaces(snapshot, source, candidatePool);
950
+ const inventory = inventoryWeightedSurfaces(snapshot, selectedSource, candidatePool);
830
951
  return storeDiscovery(inventory, inventory.adapters.some(({ candidateCount }) => candidateCount > 0)
831
952
  ? 'source_inventory'
832
953
  : 'no_weighted_surfaces');
@@ -834,14 +955,14 @@ export class ProbabilityAnalyzer {
834
955
  const gameVersionVerification = await this.verifyGameVersion(context, adapter);
835
956
  let surface;
836
957
  try {
837
- surface = discoverWeightedSurface(snapshot, adapter, source, candidatePool);
958
+ surface = discoverWeightedSurface(snapshot, adapter, selectedSource, candidatePool);
838
959
  }
839
960
  catch (error) {
840
961
  if (!(error instanceof ServiceError) ||
841
962
  (error.code !== 'PROBABILITY_SURFACE_EMPTY' &&
842
963
  error.code !== 'PROBABILITY_IDENTIFIER_NOT_FOUND'))
843
964
  throw error;
844
- const inventory = inventoryWeightedSurfaces(snapshot, source, candidatePool);
965
+ const inventory = inventoryWeightedSurfaces(snapshot, selectedSource, candidatePool);
845
966
  const requested = inventory.adapters.find(({ adapterId }) => adapterId === adapter);
846
967
  const reason = error.code === 'PROBABILITY_IDENTIFIER_NOT_FOUND'
847
968
  ? 'identifier_not_found'
@@ -916,12 +1037,31 @@ export class ProbabilityAnalyzer {
916
1037
  if (request.scenarioSet.workspaceId !== undefined &&
917
1038
  request.scenarioSet.workspaceId !== request.workspaceId)
918
1039
  throw new ServiceError('PROBABILITY_SCENARIO_WORKSPACE_MISMATCH', 'Scenario set belongs to a different workspace');
1040
+ const usingManifest = request.customPoolManifest !== undefined;
1041
+ if (usingManifest !== (request.source === undefined))
1042
+ throw new ServiceError('PROBABILITY_INPUT_AMBIGUOUS', 'Provide exactly one source or customPoolManifest');
1043
+ if (usingManifest && request.adapter !== 'custom_weighted_pool')
1044
+ throw new ServiceError('PROBABILITY_ADAPTER_MISMATCH', 'customPoolManifest requires the custom_weighted_pool adapter');
1045
+ if (!usingManifest && request.adapter === 'custom_weighted_pool')
1046
+ throw new ServiceError('PROBABILITY_MANIFEST_REQUIRED', 'custom_weighted_pool requires customPoolManifest');
919
1047
  const gameVersionVerification = await this.verifyGameVersion(request, request.adapter);
920
- const snapshot = await this.scan(request, [request.source.path]);
921
- const surface = discoverWeightedSurface(snapshot, request.adapter, request.source, request.candidatePool ?? []);
1048
+ const snapshot = await this.scan(request, [request.source?.path], usingManifest ? 'none' : request.source?.inlineClausewitz === undefined ? 'domain' : 'shared');
1049
+ const surface = usingManifest
1050
+ ? customSurface(request.customPoolManifest, snapshot.revision)
1051
+ : discoverWeightedSurface(snapshot, request.adapter, request.source, request.candidatePool ?? []);
922
1052
  const definitions = ClausewitzEvaluationDefinitions.build(snapshot);
923
- const metadata = this.metadata(request, snapshot, surface, request.scenarioSet, request.candidatePool ?? [], gameVersionVerification, {
1053
+ const metadata = this.metadata(request, snapshot, surface, request.scenarioSet, usingManifest
1054
+ ? request.customPoolManifest.candidates.map(({ id }) => id)
1055
+ : (request.candidatePool ?? []), gameVersionVerification, {
924
1056
  operation,
1057
+ sourceScope: request.source?.path !== undefined
1058
+ ? 'file'
1059
+ : request.source?.inlineClausewitz !== undefined
1060
+ ? 'shared'
1061
+ : request.source !== undefined
1062
+ ? 'domain'
1063
+ : 'none',
1064
+ sourcePaths: request.source?.path === undefined ? [] : [request.source.path],
925
1065
  horizonDays: request.horizonDays,
926
1066
  requestedMetrics: request.metrics,
927
1067
  acceptanceBands: request.acceptanceBands,
@@ -931,7 +1071,9 @@ export class ProbabilityAnalyzer {
931
1071
  const cached = this.state.byCacheKey.get(metadata.cacheKey);
932
1072
  if (cached !== undefined)
933
1073
  return { result: cached, surface, definitions, snapshot };
934
- const scenarios = evaluateSurfaceScenarios(surface, request.scenarioSet.scenarios, definitions, request.horizonDays);
1074
+ const scenarios = usingManifest
1075
+ ? customPoolScenarios(surface, request.customPoolManifest, request.scenarioSet)
1076
+ : evaluateSurfaceScenarios(surface, request.scenarioSet.scenarios, definitions, request.horizonDays);
935
1077
  return {
936
1078
  result: this.buildResult(operation, metadata, surface, scenarios, {
937
1079
  ...(request.acceptanceBands === undefined
@@ -1048,7 +1190,7 @@ export class ProbabilityAnalyzer {
1048
1190
  }
1049
1191
  async sequence(request) {
1050
1192
  request.signal?.throwIfAborted();
1051
- const snapshot = await this.scan(request);
1193
+ const snapshot = await this.scan(request, [], 'none');
1052
1194
  const surface = customSurface(request.customPoolManifest, snapshot.revision);
1053
1195
  const gameVersionVerification = await this.verifyGameVersion(request, 'custom_weighted_pool');
1054
1196
  const metadata = this.metadata(request, snapshot, surface, request.scenarioSet, request.customPoolManifest.candidates.map(({ id }) => id), gameVersionVerification, {
@@ -1137,12 +1279,31 @@ export class ProbabilityAnalyzer {
1137
1279
  return this.store(result, request.outputs ?? ['json', 'sequence'], false, request.signal, request.principal);
1138
1280
  }
1139
1281
  async compare(request) {
1140
- const before = await this.analyzeUnstored('evaluate', { ...request, source: request.before });
1141
- const after = await this.analyzeUnstored('evaluate', {
1142
- ...request,
1143
- source: request.after,
1144
- refresh: false,
1145
- });
1282
+ const manifestMode = request.beforeManifest !== undefined || request.afterManifest !== undefined;
1283
+ if (manifestMode !== (request.before === undefined && request.after === undefined))
1284
+ throw new ServiceError('PROBABILITY_COMPARE_INPUT_AMBIGUOUS', 'Compare either before/after sources or beforeManifest/afterManifest');
1285
+ if (manifestMode &&
1286
+ (request.beforeManifest === undefined || request.afterManifest === undefined))
1287
+ throw new ServiceError('PROBABILITY_COMPARE_MANIFEST_REQUIRED', 'Custom-pool comparison requires both beforeManifest and afterManifest');
1288
+ if (!manifestMode && (request.before === undefined || request.after === undefined))
1289
+ throw new ServiceError('PROBABILITY_COMPARE_SOURCE_REQUIRED', 'Source comparison requires both before and after');
1290
+ const before = manifestMode
1291
+ ? await this.analyzeUnstored('evaluate', {
1292
+ ...request,
1293
+ customPoolManifest: request.beforeManifest,
1294
+ })
1295
+ : await this.analyzeUnstored('evaluate', { ...request, source: request.before });
1296
+ const after = manifestMode
1297
+ ? await this.analyzeUnstored('evaluate', {
1298
+ ...request,
1299
+ customPoolManifest: request.afterManifest,
1300
+ refresh: false,
1301
+ })
1302
+ : await this.analyzeUnstored('evaluate', {
1303
+ ...request,
1304
+ source: request.after,
1305
+ refresh: false,
1306
+ });
1146
1307
  const metadata = {
1147
1308
  ...after.result.metadata,
1148
1309
  cacheKey: hashCanonical({
@@ -1196,8 +1357,10 @@ export class ProbabilityAnalyzer {
1196
1357
  return this.store(stale, ['json'], false, request.signal, request.principal);
1197
1358
  }
1198
1359
  await this.verifyGameVersion(request, result.adapter.id);
1199
- const snapshot = await this.scan(request);
1200
- if (snapshot.revision !== result.metadata.sourceRevision) {
1360
+ const snapshot = result.metadata.sourceScope === undefined || result.metadata.sourceScope === 'none'
1361
+ ? undefined
1362
+ : await this.scan(request, result.metadata.sourcePaths ?? [], result.metadata.sourceScope === 'shared' ? 'shared' : 'domain');
1363
+ if (snapshot !== undefined && snapshot.revision !== result.metadata.sourceRevision) {
1201
1364
  const stale = {
1202
1365
  ...result,
1203
1366
  status: 'stale',
@@ -737,6 +737,8 @@ const probabilityMetadataSchema = z
737
737
  .object({
738
738
  workspaceId: z.string(),
739
739
  workspaceIdentity: z.string(),
740
+ sourceScope: z.enum(['file', 'shared', 'domain', 'none']).optional(),
741
+ sourcePaths: z.array(z.string()).max(100_000).optional(),
740
742
  sourceRevision: z.string(),
741
743
  sourceHash: z.string(),
742
744
  scenarioHash: z.string(),
@@ -20,7 +20,6 @@ const GRAPH_ARTIFACT_BYTES = 134_217_728;
20
20
  const GRAPH_ARTIFACT_CHUNKS = 2_048;
21
21
  const HISTORY_ENTRIES = 12;
22
22
  const FOCUSED_RENDER_LIMIT = 32;
23
- const FOCUSED_GRAPH_FILE_THRESHOLD = 1_000;
24
23
  class BoundedTechnologyFragmentCache {
25
24
  #values = new Map();
26
25
  #bytes = 0;
@@ -88,6 +87,45 @@ function technologyScanPatterns(workspace) {
88
87
  ...under(roots.localisation, '*.{yml,yaml}'),
89
88
  ].sort(compareCodeUnits);
90
89
  }
90
+ function recomputeTechnologyShadowing(files) {
91
+ const groups = new Map();
92
+ for (const file of files) {
93
+ delete file.shadowedBy;
94
+ const key = file.relativePath.replaceAll('\\', '/').toLowerCase();
95
+ const group = groups.get(key) ?? [];
96
+ group.push(file);
97
+ groups.set(key, group);
98
+ }
99
+ for (const group of groups.values()) {
100
+ group.sort((left, right) => right.loadOrder - left.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
101
+ const active = group[0];
102
+ for (const shadowed of group.slice(1))
103
+ shadowed.shadowedBy = active.displayPath;
104
+ }
105
+ }
106
+ async function scanTechnologySources(engine, workspaceId, workspace, principal, signal) {
107
+ const patterns = technologyScanPatterns(workspace);
108
+ const scans = await Promise.all([
109
+ engine.scan(workspaceId, { patterns, rootKinds: ['mod', 'dependency', 'fixture'] }, principal, signal),
110
+ ...(workspace.roots.some(({ kind }) => kind === 'game')
111
+ ? [engine.scan(workspaceId, { patterns, rootKinds: ['game'] }, principal, signal)]
112
+ : []),
113
+ ]);
114
+ const files = scans.flatMap(({ files }) => files.map((file) => ({ ...file })));
115
+ recomputeTechnologyShadowing(files);
116
+ files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
117
+ const index = engine.indexFiles(files);
118
+ return {
119
+ workspaceId,
120
+ revision: hashCanonical(files.map(({ displayPath, loadOrder, sha256 }) => ({ displayPath, loadOrder, sha256 }))),
121
+ files,
122
+ index,
123
+ complete: scans.every(({ complete }) => complete) && index.complete,
124
+ skippedSourceCount: index.skippedSourceCount,
125
+ skippedSources: index.skippedSources,
126
+ diagnostics: [...scans.flatMap(({ diagnostics }) => diagnostics), ...index.diagnostics],
127
+ };
128
+ }
91
129
  function activeTechnologySprite(spriteName, spritePath, sprites) {
92
130
  return sprites.find((sprite) => sprite.name === spriteName && (spritePath === undefined || sprite.sourcePath === spritePath));
93
131
  }
@@ -130,11 +168,6 @@ async function resolveTechnologyIconDataUris(cached, requestedSprites, budget, s
130
168
  }));
131
169
  return Object.fromEntries(Object.entries(dataUris).sort(([left], [right]) => compareCodeUnits(left, right)));
132
170
  }
133
- function technologyAnalysisMode(workspace, snapshot) {
134
- return workspace.gameRoot !== undefined || snapshot.files.length > FOCUSED_GRAPH_FILE_THRESHOLD
135
- ? 'focused'
136
- : 'full';
137
- }
138
171
  function rememberGraph(state, graph) {
139
172
  const history = state.history.get(graph.workspaceId) ?? new Map();
140
173
  history.delete(graph.revision);
@@ -400,8 +433,8 @@ export class TechnologyTreeViewer {
400
433
  const cached = this.#state.current.get(workspaceId);
401
434
  if (options.refresh !== true && cached?.generation === generation)
402
435
  return cached.graph;
403
- const snapshot = await this.engine.scan(workspaceId, { patterns: technologyScanPatterns(workspace) }, options.principal, options.signal);
404
- const analysisMode = technologyAnalysisMode(workspace, snapshot);
436
+ const snapshot = await scanTechnologySources(this.engine, workspaceId, workspace, options.principal, options.signal);
437
+ const analysisMode = 'full';
405
438
  const preliminary = buildTechnologyGraph(snapshot, {
406
439
  workspaceIdentity: workspace.workspaceIdentity,
407
440
  cache: this.#state.fragments,
@@ -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.8.10';
3
+ export const PACKAGE_VERSION = '3.0.0';
4
4
  export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
5
5
  export const CONFIG_VERSION = 1;
6
6
  export const TRANSACTION_VERSION = 1;
@@ -35,4 +35,6 @@ npm run test:local
35
35
 
36
36
  Add `HOI4_DEPENDENCY_ROOTS` as the platform path-delimited list when the external mod has dependencies. Local tests remain read-only.
37
37
 
38
+ The installed-game qualification also parses every shipped bitmap-font descriptor, resolves every atlas page, shapes native and enlarged glyphs, verifies deterministic raster output, and checks HOI4 localisation colours across base fonts and language overrides.
39
+
38
40
  See the package [Security Policy](../SECURITY.md) for private vulnerability reporting.
package/docs/gui.md CHANGED
@@ -31,13 +31,13 @@ Rewrite once, then inspect and render the result.
31
31
 
32
32
  Inspection checks missing assets and localisation, invalid sizes, overlap, clipping, overflow, button-label centering, content crossing a background boundary, conflicting click regions, invisible blockers, broken parents or contexts, list-row cuts, state conflicts, trigger/effect gaps, and resolution drift. `hoi4.gui_render` returns a scripted-scenario gallery and JSON delta report alongside the generic state and resolution galleries.
33
33
 
34
- The renderer resolves sprites and fonts through the complete mod, dependency, and installed-game load order. Mod windows therefore render ordinary vanilla sprites, tiled vanilla panels, progress bars, masked shields, button frames, and localisation icons such as `£command_power` without copying game assets into the mod. Case differences in GFX and font identifiers do not prevent a valid asset from resolving.
34
+ The renderer resolves sprites and fonts through the complete mod, dependency, and installed-game load order. Mod windows therefore render ordinary vanilla sprites, tiled vanilla panels, progress bars, masked shields, button frames, country flags, and localisation icons such as `£command_power` without copying game assets into the mod. Case differences in GFX and font identifiers do not prevent a valid asset from resolving.
35
35
 
36
36
  Bitmap text uses the language-appropriate HOI4 font definition and its real glyph atlas. Header, serif, compact, typewriter, black, inverted, and other declared faces keep their own glyph shapes, native metrics, face colour, border colour, language override, and font-specific `textcolors` palette. The renderer reads BMFont channel roles, separates face and outline coverage, and recognizes both vanilla black-on-white masks and conventional white-on-transparent custom masks. HOI4 localisation colour runs such as `§Y` and `§R` therefore colour the glyph face while retaining its sharp antialiasing and declared border instead of flattening the glyph into a blurred monochrome mask or switching to the tool font. The default language uses the base font rather than an unrelated language override, and the renderer recognizes installed-game atlases whose filename omits a page suffix declared by the matching `.fnt` file.
37
37
 
38
38
  Layout follows Clausewitz orientation and element-origin anchors, including `CENTER_LEFT`, `CENTER_RIGHT`, `CENTER_UP`, `CENTER_DOWN`, `origo`, `centerposition`, inherited container coordinate origins, local scale, and both `%` and `%%` relative dimensions. Text boxes use native font metrics together with `maxWidth`, `maxHeight`, alignment, wrapping, and `fixedsize` clipping. Cornered tile sprites use fixed nine-slice borders and optional center tiling; progress bars composite their filled and background textures; masked shields composite their background and mask textures.
39
39
 
40
- Scripted-GUI composition follows `parent_scripted_gui` chains even when the linked child window is a top-level sibling in the `.gui` file. Literal `always = yes` and `always = no` visibility and click-enabled blocks are applied automatically. A scenario's `values` object supplies concrete runtime values in one place: variable names resolve numeric text, scripted-localisation names resolve dynamic labels, a progress-bar element name sets its fill value, and element keys ending in `.image`, `.frame`, `.x`, `.y`, `.visible`, or `.enabled` update that visual property. Scripted-GUI property expressions such as `image = "[GetMeterSprite]"` or `x = meter_fill_width` consume matching scenario values automatically. Dynamic lists instantiate their declared `entry_container` or `country_scope_entry_container`; each scenario row can select the country template with `countryScope`, choose an explicit `entryContainer`, and provide row-scoped text, image, frame, position, visibility, and enabled values.
40
+ Scripted-GUI composition follows `parent_scripted_gui` chains even when the linked child window is a top-level sibling in the `.gui` file. Literal `always = yes` and `always = no` visibility and click-enabled blocks are applied automatically. A scenario's `values` object supplies concrete runtime values in one place: variable names resolve numeric text, scripted-localisation names resolve dynamic labels, a progress-bar element name sets its fill value, and element keys ending in `.image`, `.frame`, `.x`, `.y`, `.visible`, or `.enabled` update that visual property. Scripted-GUI property expressions such as `image = "[GetMeterSprite]"` or `x = meter_fill_width` consume matching scenario values automatically. Dynamic text is parsed after scenario and scripted-localisation substitution, so a runtime string containing `£command_power` or `£[GetResourceIcon]` renders the resolved inline HOI4 text icon instead of literal marker text. Dynamic lists instantiate their declared `entry_container` or `country_scope_entry_container`; each scenario row can select the country template with `countryScope`, choose an explicit `entryContainer`, provide `countryTag` and `ideology` for `GetFlag`, and supply row-scoped text, image, frame, position, visibility, and enabled values. Masked country sprites use the actual mask and overlay dimensions, so custom flag sizes are discovered from GFX assets rather than assumed by the renderer.
41
41
 
42
42
  `hoi4.gui_inspect` and `hoi4.gui_render` generate a plausible seeded runtime scenario by default. The generator follows the complete `parent_scripted_gui` surface, discovers numeric and text tokens in GUI localisation and scripted-GUI properties, chooses real scripted-localisation text and sprite candidates, applies simple source thresholds, fills meters within their declared ranges, populates dynamic lists with coherent rows, selects one overlapping tab pane, keeps active and idle controls mutually exclusive, and leaves values explicitly supplied by the caller unchanged. Normal controls and animation frames remain stable unless variation is explicitly requested. Every explicit `relatedScenarios` entry is rendered beside the generated view.
43
43
 
@@ -66,11 +66,22 @@ Preview scenarios can include exact layout expectations:
66
66
  "values": {
67
67
  "threat": 73,
68
68
  "GetThreatLabel": "High",
69
+ "GetCostLine": "Cost £command_power 20",
69
70
  "threat_meter": 73,
70
71
  "confirm_button.enabled": true
71
72
  },
72
73
  "flags": { "panel_open": true },
73
- "lists": { "target_list": [{ "id": 1, "label": "First target" }] },
74
+ "lists": {
75
+ "target_list": [
76
+ {
77
+ "id": 1,
78
+ "label": "France",
79
+ "countryScope": true,
80
+ "countryTag": "FRA",
81
+ "ideology": "democratic"
82
+ }
83
+ ]
84
+ },
74
85
  "visibility": { "target_list": true, "confirm_button": true },
75
86
  "expectations": {
76
87
  "visible": ["target_list", "confirm_button"],
Binary file
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.8.10",
4
+ "version": "3.0.0",
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",
@@ -47,6 +47,7 @@
47
47
  "docs/map.md",
48
48
  "docs/http.md",
49
49
  "docs/development.md",
50
+ "docs/images/readme/",
50
51
  "examples/clients/",
51
52
  "examples/probability/",
52
53
  "schemas/",
@@ -75,6 +76,7 @@
75
76
  "test:local": "tsx scripts/run-local-tests.ts",
76
77
  "test:coverage": "vitest run --coverage",
77
78
  "benchmark": "node --expose-gc --import tsx scripts/benchmark.ts",
79
+ "docs:examples": "tsx scripts/generate-readme-examples.ts",
78
80
  "fixtures:generate": "npm run fixtures:generate:focus && npm run fixtures:generate:gui && npm run fixtures:generate:map && npm run fixtures:generate:event && npm run fixtures:generate:technology && npm run fixtures:generate:probability",
79
81
  "fixtures:generate:focus": "tsx scripts/generate-fixtures.ts",
80
82
  "fixtures:generate:gui": "tsx scripts/generate-gui-fixture.ts",
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.10/schemas/configuration.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/schemas/configuration.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -60,13 +60,13 @@
60
60
  "minLength": 1
61
61
  },
62
62
  "__schema2": {
63
- "default": 20000,
63
+ "default": 100000,
64
64
  "type": "integer",
65
65
  "minimum": 1,
66
66
  "maximum": 1000000
67
67
  },
68
68
  "__schema3": {
69
- "default": 134217728,
69
+ "default": 268435456,
70
70
  "type": "integer",
71
71
  "minimum": 1048576,
72
72
  "maximum": 9007199254740991
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.10/schemas/continuous-focus-palette.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/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.8.10/schemas/custom-weighted-pool.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/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.8.10/schemas/event-feature-manifest.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/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.8.10/schemas/focus-plan.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/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.8.10/schemas/focus-planning-sidecar.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/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.8.10/schemas/gui-animation-source.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/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.8.10/schemas/gui-helper.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/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.8.10/schemas/gui-scenario.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/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.8.10/schemas/map-operation.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/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.8.10/schemas/operation-result.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/schemas/operation-result.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {