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
@@ -1,6 +1,16 @@
1
1
  import { sha256Bytes } from '../core/canonical.js';
2
2
  import { parsePreviewScenario } from './scenario.js';
3
3
  const countryNames = ['Germany', 'France', 'Poland', 'Italy', 'Brazil', 'India', 'Canada'];
4
+ const countryTags = ['GER', 'FRA', 'POL', 'ITA', 'BRA', 'RAJ', 'CAN'];
5
+ const countryIdeologies = [
6
+ 'fascism',
7
+ 'democratic',
8
+ 'democratic',
9
+ 'fascism',
10
+ 'democratic',
11
+ 'neutrality',
12
+ 'democratic',
13
+ ];
4
14
  const countryAdjectives = [
5
15
  'German',
6
16
  'French',
@@ -447,6 +457,8 @@ function generatedRows(count, random, options) {
447
457
  return Array.from({ length: count }, (_unused, index) => {
448
458
  const countryIndex = (countryOffset + index) % countryNames.length;
449
459
  const name = countryNames[countryIndex];
460
+ const tag = countryTags[countryIndex];
461
+ const ideology = countryIdeologies[countryIndex];
450
462
  const value = generatedNumber(options, random);
451
463
  return {
452
464
  index: index + 1,
@@ -455,6 +467,12 @@ function generatedRows(count, random, options) {
455
467
  name,
456
468
  GetName: name,
457
469
  GetAdjective: countryAdjectives[countryIndex],
470
+ GetTag: tag,
471
+ tag,
472
+ countryTag: tag,
473
+ ideology,
474
+ countryIdeology: ideology,
475
+ countryScope: true,
458
476
  value,
459
477
  amount: value,
460
478
  percentage: Math.max(0, Math.min(100, value)),
@@ -532,6 +550,8 @@ export function generateGuiPreviewScenarios(graph, windowName, base, options) {
532
550
  const visibility = generatedVisibility(graph, scripted, options, random, base.visibility);
533
551
  const elementStates = { ...base.elementStates };
534
552
  const scopedCountries = new Map();
553
+ const generatedCountryIndex = random.integer(0, countryNames.length - 1);
554
+ const generatedCountryTag = countryTags[generatedCountryIndex];
535
555
  for (const key of tokens.numeric)
536
556
  setIfMissing(values, key, generatedNumberForKey(key, options, random, numericConstraints.ranges.get(key)));
537
557
  for (const [elementName, { minimum, maximum }] of progressRanges) {
@@ -580,6 +600,10 @@ export function generateGuiPreviewScenarios(graph, windowName, base, options) {
580
600
  for (const [key, template] of scriptedTextTemplates)
581
601
  values[key] = materializeDynamicText(template, values);
582
602
  for (const key of imageTokens) {
603
+ if (/(?:^|\.)GetFlag$/u.test(key)) {
604
+ setIfMissing(values, key, generatedCountryTag);
605
+ continue;
606
+ }
583
607
  const sprite = scriptedLocalisationValue(graph, key, base.language, random, 'image', values);
584
608
  if (sprite !== undefined)
585
609
  setIfMissing(values, key, sprite);
@@ -603,6 +627,18 @@ export function generateGuiPreviewScenarios(graph, windowName, base, options) {
603
627
  id: `${base.id}-${options.idPrefix}-${scenarioIndex + 1}`,
604
628
  description: `Generated source-aware scenario ${scenarioIndex + 1} (seed ${sourceSeed})`,
605
629
  values,
630
+ country: [...imageTokens].some((key) => /(?:^|\.)GetFlag$/u.test(key))
631
+ ? {
632
+ GetName: countryNames[generatedCountryIndex],
633
+ GetAdjective: countryAdjectives[generatedCountryIndex],
634
+ GetTag: generatedCountryTag,
635
+ tag: generatedCountryTag,
636
+ countryTag: generatedCountryTag,
637
+ ideology: countryIdeologies[generatedCountryIndex],
638
+ countryIdeology: countryIdeologies[generatedCountryIndex],
639
+ ...base.country,
640
+ }
641
+ : base.country,
606
642
  lists,
607
643
  visibility,
608
644
  elementStates,
@@ -241,7 +241,7 @@ function basenameFallbackPatterns(exactPaths, exactFiles) {
241
241
  .map(({ relativePath }) => relativePath.replaceAll('\\', '/').toLowerCase()));
242
242
  return [
243
243
  ...new Set(exactPaths
244
- .filter((candidate) => !resolved.has(candidate.toLowerCase()))
244
+ .filter((candidate) => !/[*?[\]{}!]/u.test(candidate) && !resolved.has(candidate.toLowerCase()))
245
245
  .map((candidate) => `**/${path.posix.basename(candidate)}`)),
246
246
  ].sort((left, right) => compareCodeUnits(left, right));
247
247
  }
@@ -341,6 +341,22 @@ export function referencedAssetPatternsForWindow(graph, windowName, additionalSp
341
341
  if (textValues.has(localisation.key))
342
342
  textValues.add(localisation.value);
343
343
  }
344
+ const dynamicTextNames = new Set();
345
+ for (const value of textValues) {
346
+ for (const match of value.matchAll(/\[(?:\?[^\]]*\.)?([A-Za-z_][A-Za-z0-9_.:-]*)[^\]]*\]/gu)) {
347
+ const token = match[1];
348
+ if (token === undefined)
349
+ continue;
350
+ dynamicTextNames.add(token);
351
+ dynamicTextNames.add(token.slice(token.lastIndexOf('.') + 1));
352
+ }
353
+ }
354
+ const dynamicTextLocalisationKeys = new Set(graph.scriptedLocalisation
355
+ .filter(({ name }) => dynamicTextNames.has(name))
356
+ .flatMap(({ localisationKeys }) => localisationKeys));
357
+ for (const localisation of graph.localisation)
358
+ if (dynamicTextLocalisationKeys.has(localisation.key))
359
+ textValues.add(localisation.value);
344
360
  for (const value of textValues) {
345
361
  for (const token of localisationIconTokens(value)) {
346
362
  for (const candidate of localisationIconSpriteCandidates(token))
@@ -372,6 +388,12 @@ export function referencedAssetPatternsForWindow(graph, windowName, additionalSp
372
388
  const match = /^\[([^\]]+)\]$/u.exec(image);
373
389
  return match?.[1] === undefined ? [] : [match[1]];
374
390
  }));
391
+ const usesCountryFlags = graph.scriptedGuis
392
+ .filter(({ name }) => relatedScriptedGuiNames.has(name))
393
+ .flatMap(({ propertyDefinitions }) => propertyDefinitions)
394
+ .some(({ attributes }) => Object.entries(attributes).some(([key, value]) => key.toLocaleLowerCase('en-US') === 'image' &&
395
+ typeof value === 'string' &&
396
+ /(?:^|\.)GetFlag\]$/u.test(value)));
375
397
  for (const definition of graph.scriptedLocalisation) {
376
398
  if (!dynamicImageTokens.has(definition.name))
377
399
  continue;
@@ -417,17 +439,41 @@ export function referencedAssetPatternsForWindow(graph, windowName, additionalSp
417
439
  ...manifest.sourceFrames.map(({ path: framePath }) => framePath),
418
440
  ]),
419
441
  ];
420
- return [...new Set(references.flatMap(referenceVariants))].sort((left, right) => compareCodeUnits(left, right));
421
- }
422
- function scenarioSpriteNames(scenarios) {
423
442
  return [
424
- ...new Set(scenarios.flatMap(({ values, scriptedGui }) => Object.entries({ ...scriptedGui, ...values })
425
- .filter(([key, value]) => typeof value === 'string' &&
426
- value.startsWith('GFX_') &&
427
- (key.endsWith('.image') || !key.includes('.')))
428
- .map(([, value]) => value))),
443
+ ...new Set([
444
+ ...references.flatMap(referenceVariants),
445
+ ...(usesCountryFlags
446
+ ? [
447
+ 'gfx/flags/*.{bmp,dds,png,tga}',
448
+ 'gfx/flags/medium/*.{bmp,dds,png,tga}',
449
+ 'gfx/flags/small/*.{bmp,dds,png,tga}',
450
+ ]
451
+ : []),
452
+ ]),
429
453
  ].sort((left, right) => compareCodeUnits(left, right));
430
454
  }
455
+ function scenarioSpriteNames(scenarios, additionalText = []) {
456
+ const sprites = new Set();
457
+ const pending = [...scenarios, ...additionalText];
458
+ while (pending.length > 0) {
459
+ const value = pending.pop();
460
+ if (typeof value === 'string') {
461
+ if (/^GFX_[A-Za-z0-9_.:+-]+$/u.test(value))
462
+ sprites.add(value);
463
+ for (const token of localisationIconTokens(value))
464
+ for (const candidate of localisationIconSpriteCandidates(token))
465
+ sprites.add(candidate);
466
+ continue;
467
+ }
468
+ if (value === null || typeof value !== 'object')
469
+ continue;
470
+ if (Array.isArray(value))
471
+ pending.push(...value);
472
+ else
473
+ pending.push(...Object.values(value));
474
+ }
475
+ return [...sprites].sort((left, right) => compareCodeUnits(left, right));
476
+ }
431
477
  function bmFontPagePatterns(files) {
432
478
  const references = files.flatMap((file) => {
433
479
  if (!file.relativePath.toLowerCase().endsWith('.fnt') || file.shadowedBy !== undefined)
@@ -570,6 +616,14 @@ function fullImage(render) {
570
616
  throw new Error('GUI render did not produce an image.');
571
617
  return image;
572
618
  }
619
+ function visualSceneKey(scene) {
620
+ const { id: _id, description: _description, ...scenario } = scene.scenario;
621
+ return hashCanonical({
622
+ windowName: scene.windowName,
623
+ sourceRevision: scene.sourceRevision,
624
+ scenario,
625
+ });
626
+ }
573
627
  export function guiArtifactProvenance(graph, kind, primaryScene, scenarioScenes = [primaryScene], metadata = {}) {
574
628
  const { scenario } = primaryScene;
575
629
  const sourceEvidence = boundedSourceHashEvidence(graph.sourceHashes);
@@ -657,7 +711,7 @@ export class ScriptedGuiStudio {
657
711
  graph: buildGuiSourceGraph(retainedFiles, this.engine.indexFiles(retainedFiles)),
658
712
  };
659
713
  this.#graphCache.set(key, scanned);
660
- while (this.#graphCache.size > 8) {
714
+ while (this.#graphCache.size > 1) {
661
715
  const oldest = this.#graphCache.keys().next().value;
662
716
  if (oldest === undefined)
663
717
  break;
@@ -665,6 +719,10 @@ export class ScriptedGuiStudio {
665
719
  }
666
720
  return scanned;
667
721
  }
722
+ /** Release parsed GUI graphs after a bounded batch job has finished using the studio. */
723
+ clearCaches() {
724
+ this.#graphCache.clear();
725
+ }
668
726
  async scan(workspaceId, principal, signal, languages = ['l_english']) {
669
727
  signal?.throwIfAborted();
670
728
  const workspace = this.resolver.get(workspaceId, principal);
@@ -746,10 +804,10 @@ export class ScriptedGuiStudio {
746
804
  input.signal?.throwIfAborted();
747
805
  const placeholderScenario = parsePreviewScenario(input.scenario);
748
806
  const explicitRelatedScenarios = (input.relatedScenarios ?? []).map(parsePreviewScenario);
749
- const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios]));
750
807
  const generatedOptions = input.generatedScenarios === undefined
751
808
  ? undefined
752
809
  : parseGeneratedScenarioOptions(input.generatedScenarios);
810
+ const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples));
753
811
  const generatedScenarios = generatedOptions === undefined
754
812
  ? []
755
813
  : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions);
@@ -817,6 +875,9 @@ export class ScriptedGuiStudio {
817
875
  const requestedBaselineScenario = input.comparisonScenario === undefined
818
876
  ? undefined
819
877
  : parsePreviewScenario(input.comparisonScenario);
878
+ const generatedOptions = input.generatedScenarios === undefined
879
+ ? undefined
880
+ : parseGeneratedScenarioOptions(input.generatedScenarios);
820
881
  const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [
821
882
  placeholderScenario.language,
822
883
  ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario.language]),
@@ -825,10 +886,7 @@ export class ScriptedGuiStudio {
825
886
  placeholderScenario,
826
887
  ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
827
888
  ...explicitRelatedScenarios,
828
- ]));
829
- const generatedOptions = input.generatedScenarios === undefined
830
- ? undefined
831
- : parseGeneratedScenarioOptions(input.generatedScenarios);
889
+ ], generatedOptions?.textSamples));
832
890
  const generatedScenarios = generatedOptions === undefined
833
891
  ? []
834
892
  : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions);
@@ -846,6 +904,18 @@ export class ScriptedGuiStudio {
846
904
  const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, scenario.language);
847
905
  const scene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, scenario, catalog);
848
906
  const render = await renderGuiScene(scene, undefined, input.signal, budget);
907
+ const fullRenderCache = new Map([
908
+ [visualSceneKey(scene), fullImage(render)],
909
+ ]);
910
+ const renderFull = async (target) => {
911
+ const key = visualSceneKey(target);
912
+ const cached = fullRenderCache.get(key);
913
+ if (cached !== undefined)
914
+ return cached;
915
+ const image = fullImage(await renderGuiScene(target, ['full'], input.signal, budget));
916
+ fullRenderCache.set(key, image);
917
+ return image;
918
+ };
849
919
  const scenarioScenes = [scene];
850
920
  const scenarioItems = [];
851
921
  for (const variantScenario of [scenario, ...relatedScenarios]) {
@@ -855,10 +925,7 @@ export class ScriptedGuiStudio {
855
925
  : await buildGuiScene(scanned.graph, scanned.files, input.windowName, variantScenario, catalog);
856
926
  if (variantScene !== scene)
857
927
  scenarioScenes.push(variantScene);
858
- const variantRender = variantScene === scene
859
- ? render
860
- : await renderGuiScene(variantScene, ['full'], input.signal, new RenderBudget());
861
- const image = fullImage(variantRender);
928
+ const image = variantScene === scene ? fullImage(render) : await renderFull(variantScene);
862
929
  scenarioItems.push({
863
930
  label: variantScenario.description ?? variantScenario.id,
864
931
  png: image.png,
@@ -873,8 +940,7 @@ export class ScriptedGuiStudio {
873
940
  const stateScenario = scenarioWith(scenario, { id: `${scenario.id}-${state}`, state });
874
941
  const stateScene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, stateScenario, catalog);
875
942
  stateScenes.push(stateScene);
876
- const stateRender = await renderGuiScene(stateScene, ['full'], input.signal, budget);
877
- const image = fullImage(stateRender);
943
+ const image = await renderFull(stateScene);
878
944
  stateItems.push({ label: state, png: image.png, width: image.width, height: image.height });
879
945
  }
880
946
  const resolutionScenes = [];
@@ -895,8 +961,7 @@ export class ScriptedGuiStudio {
895
961
  });
896
962
  const resolutionScene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, resolutionScenario, catalog);
897
963
  resolutionScenes.push(resolutionScene);
898
- const resolutionRender = await renderGuiScene(resolutionScene, ['full'], input.signal, budget);
899
- const image = fullImage(resolutionRender);
964
+ const image = await renderFull(resolutionScene);
900
965
  resolutionItems.push({
901
966
  label: `${resolution.width}×${resolution.height} · UI ${uiScale}`,
902
967
  png: image.png,
@@ -905,8 +970,8 @@ export class ScriptedGuiStudio {
905
970
  });
906
971
  }
907
972
  const baselineScene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, baselineScenario, catalog);
908
- const baselineRender = await renderGuiScene(baselineScene, ['full'], input.signal, budget);
909
- const comparison = await compareGuiImages(fullImage(baselineRender).png, fullImage(render).png, budget, input.signal);
973
+ const baselineImage = await renderFull(baselineScene);
974
+ const comparison = await compareGuiImages(baselineImage.png, fullImage(render).png, budget, input.signal);
910
975
  const renderedSourceIds = [
911
976
  ...scenarioScenes,
912
977
  ...stateScenes,
@@ -391,7 +391,7 @@ export function registerGuiTools(server, engine, context) {
391
391
  });
392
392
  server.registerTool('hoi4.gui_render', {
393
393
  title: 'Render scripted GUI artifacts',
394
- description: 'Render GUI states, resolutions, generated or explicit scenarios, hierarchy, comparisons, and diagnostics.',
394
+ description: 'Render GUI states, resolutions, generated or explicit scenarios, dynamic flags and text icons, hierarchy, comparisons, and diagnostics.',
395
395
  inputSchema: renderInput,
396
396
  outputSchema: guiRenderOutputSchema,
397
397
  annotations: artifactProducing,
@@ -19,7 +19,6 @@ const outputs = z.array(probabilityOutputSchema).max(10).optional();
19
19
  const commonShape = {
20
20
  workspaceId: workspaceIdSchema,
21
21
  adapter: probabilityAdapterIdSchema,
22
- source: nestedSource,
23
22
  scenarioSet: nestedScenarios,
24
23
  candidatePool,
25
24
  horizonDays: z.number().positive().max(1_000_000).optional(),
@@ -34,21 +33,55 @@ const inspectInput = z
34
33
  workspaceId: workspaceIdSchema,
35
34
  adapter: probabilityAdapterIdSchema.optional(),
36
35
  source: nestedSource.optional(),
36
+ customPoolManifest: nestedManifest.optional(),
37
37
  candidatePool,
38
38
  refresh: z.boolean().optional(),
39
39
  })
40
40
  .strict()
41
41
  .superRefine((value, context) => {
42
- if (value.adapter !== undefined && value.source === undefined)
42
+ if (value.source !== undefined && value.customPoolManifest !== undefined)
43
+ context.addIssue({
44
+ code: 'custom',
45
+ message: 'Provide either source or customPoolManifest',
46
+ });
47
+ if (value.adapter !== undefined &&
48
+ value.source === undefined &&
49
+ value.customPoolManifest === undefined)
43
50
  context.addIssue({
44
51
  code: 'custom',
45
52
  message: 'An adapter requires a source; provide a source alone to discover compatible adapters',
46
53
  });
54
+ if (value.customPoolManifest !== undefined && value.adapter !== 'custom_weighted_pool')
55
+ context.addIssue({
56
+ code: 'custom',
57
+ path: ['adapter'],
58
+ message: 'customPoolManifest requires adapter custom_weighted_pool',
59
+ });
60
+ });
61
+ const evaluateInput = z
62
+ .object({
63
+ ...commonShape,
64
+ source: nestedSource.optional(),
65
+ customPoolManifest: nestedManifest.optional(),
66
+ })
67
+ .strict()
68
+ .superRefine((value, context) => {
69
+ if ((value.source === undefined) === (value.customPoolManifest === undefined))
70
+ context.addIssue({
71
+ code: 'custom',
72
+ message: 'Provide exactly one source or customPoolManifest',
73
+ });
74
+ if (value.customPoolManifest !== undefined && value.adapter !== 'custom_weighted_pool')
75
+ context.addIssue({
76
+ code: 'custom',
77
+ path: ['adapter'],
78
+ message: 'customPoolManifest requires adapter custom_weighted_pool',
79
+ });
47
80
  });
48
- const evaluateInput = z.object(commonShape).strict();
49
81
  const sweepInput = z
50
82
  .object({
51
83
  ...commonShape,
84
+ source: nestedSource,
52
85
  sweep: z
53
86
  .object({
54
87
  paths: z.array(z.string().min(1).max(1024)).min(1).max(32),
@@ -62,6 +95,7 @@ const sweepInput = z
62
95
  const simulateInput = z
63
96
  .object({
64
97
  ...commonShape,
98
+ source: nestedSource,
65
99
  samples: z.number().int().min(100).max(10_000_000).default(100_000),
66
100
  seed: z.number().int().min(-2_147_483_648).max(2_147_483_647).default(1),
67
101
  confidenceLevel: z.number().min(0.5).max(0.9999).default(0.95),
@@ -87,8 +121,10 @@ const compareInput = z
87
121
  .object({
88
122
  workspaceId: workspaceIdSchema,
89
123
  adapter: probabilityAdapterIdSchema,
90
- before: nestedSource,
91
- after: nestedSource,
124
+ before: nestedSource.optional(),
125
+ after: nestedSource.optional(),
126
+ beforeManifest: nestedManifest.optional(),
127
+ afterManifest: nestedManifest.optional(),
92
128
  scenarioSet: nestedScenarios,
93
129
  candidatePool,
94
130
  horizonDays: z.number().positive().max(1_000_000).optional(),
@@ -97,7 +133,29 @@ const compareInput = z
97
133
  outputs,
98
134
  refresh: z.boolean().optional(),
99
135
  })
100
- .strict();
136
+ .strict()
137
+ .superRefine((value, context) => {
138
+ const sourceMode = value.before !== undefined || value.after !== undefined;
139
+ const manifestMode = value.beforeManifest !== undefined || value.afterManifest !== undefined;
140
+ if (sourceMode === manifestMode)
141
+ context.addIssue({
142
+ code: 'custom',
143
+ message: 'Compare either before/after sources or beforeManifest/afterManifest',
144
+ });
145
+ if (sourceMode && (value.before === undefined || value.after === undefined))
146
+ context.addIssue({ code: 'custom', message: 'Source comparison requires before and after' });
147
+ if (manifestMode && (value.beforeManifest === undefined || value.afterManifest === undefined))
148
+ context.addIssue({
149
+ code: 'custom',
150
+ message: 'Custom-pool comparison requires beforeManifest and afterManifest',
151
+ });
152
+ if (manifestMode && value.adapter !== 'custom_weighted_pool')
153
+ context.addIssue({
154
+ code: 'custom',
155
+ path: ['adapter'],
156
+ message: 'Manifest comparison requires adapter custom_weighted_pool',
157
+ });
158
+ });
101
159
  const renderInput = z
102
160
  .object({
103
161
  workspaceId: workspaceIdSchema,
@@ -243,7 +301,7 @@ export function registerProbabilityTools(server, engine, context) {
243
301
  const analyzer = new ProbabilityAnalyzer(engine);
244
302
  server.registerTool('hoi4.probability_inspect', {
245
303
  title: 'Inspect AI and MTTH weighted logic',
246
- description: 'List versioned adapters, or provide a source alone to discover compatible weighted adapters, candidate pools, capabilities, provenance, and unsupported constructs before narrowing the request.',
304
+ description: 'Discover weighted adapters, candidates, provenance, and required inputs.',
247
305
  inputSchema: inspectInput,
248
306
  outputSchema: inspectOutput,
249
307
  annotations: readOnly,
@@ -252,7 +310,7 @@ export function registerProbabilityTools(server, engine, context) {
252
310
  const base = await requestContext(engine, context, input.workspaceId, input.refresh, progress.signal);
253
311
  try {
254
312
  await progress.report(0, 2, 'Inspecting weighted source');
255
- const inspected = await analyzer.inspect(base, input.adapter, input.source, input.candidatePool);
313
+ const inspected = await analyzer.inspect(base, input.adapter, input.source, input.candidatePool, input.customPoolManifest);
256
314
  const result = emptyServiceResult(base.workspaceId, {
257
315
  adapters: inspected.adapters.length,
258
316
  ...(inspected.surface === undefined
@@ -305,7 +363,7 @@ export function registerProbabilityTools(server, engine, context) {
305
363
  });
306
364
  server.registerTool('hoi4.probability_evaluate', {
307
365
  title: 'Evaluate AI and MTTH scenarios',
308
- description: 'Evaluate eligibility, modifier traces, exact values, proven probabilities, MTTH timing, bounds, and unresolved inputs.',
366
+ description: 'Evaluate weights and probabilities under explicit scenarios.',
309
367
  inputSchema: evaluateInput,
310
368
  outputSchema: analysisOutput,
311
369
  annotations: readOnly,
@@ -329,7 +387,7 @@ export function registerProbabilityTools(server, engine, context) {
329
387
  });
330
388
  server.registerTool('hoi4.probability_sweep', {
331
389
  title: 'Sweep AI and MTTH parameters',
332
- description: 'Evaluate declared ranges, sensitivity, breakpoints, and rank reversals without inventing world state.',
390
+ description: 'Evaluate declared ranges, breakpoints, and rank reversals.',
333
391
  inputSchema: sweepInput,
334
392
  outputSchema: analysisOutput,
335
393
  annotations: readOnly,
@@ -353,7 +411,7 @@ export function registerProbabilityTools(server, engine, context) {
353
411
  });
354
412
  server.registerTool('hoi4.probability_simulate', {
355
413
  title: 'Simulate uncertain weighted scenarios',
356
- description: 'Run reproducible seeded Monte Carlo analysis with distributions, supported correlations, and confidence intervals.',
414
+ description: 'Run seeded simulations with distributions and confidence intervals.',
357
415
  inputSchema: simulateInput,
358
416
  outputSchema: analysisOutput,
359
417
  annotations: readOnly,
@@ -377,7 +435,7 @@ export function registerProbabilityTools(server, engine, context) {
377
435
  });
378
436
  server.registerTool('hoi4.probability_sequence', {
379
437
  title: 'Analyze a declared weighted sequence',
380
- description: 'Analyze only manifest-declared recovery, caps, cooldowns, removals, resets, timers, and terminal states.',
438
+ description: 'Analyze a manifest-declared dynamic weighted sequence.',
381
439
  inputSchema: sequenceInput,
382
440
  outputSchema: analysisOutput,
383
441
  annotations: readOnly,
@@ -401,7 +459,7 @@ export function registerProbabilityTools(server, engine, context) {
401
459
  });
402
460
  server.registerTool('hoi4.probability_compare', {
403
461
  title: 'Compare weighted source patches',
404
- description: 'Compare real or proposed source under identical scenarios with modifier, score, probability, timing, and rank attribution.',
462
+ description: 'Compare sources or declared pools under identical scenarios.',
405
463
  inputSchema: compareInput,
406
464
  outputSchema: analysisOutput,
407
465
  annotations: readOnly,
@@ -425,7 +483,7 @@ export function registerProbabilityTools(server, engine, context) {
425
483
  });
426
484
  server.registerTool('hoi4.probability_render', {
427
485
  title: 'Render AI and MTTH analysis',
428
- description: 'Render cached analysis data as deterministic ranking, matrix, waterfall, timing, sensitivity, sequence, comparison, or unresolved resources.',
486
+ description: 'Render cached probability analysis as deterministic resources.',
429
487
  inputSchema: renderInput,
430
488
  outputSchema: analysisOutput,
431
489
  annotations: readOnly,
@@ -465,22 +465,32 @@ function advance(state, manifest, selectedCandidates, unresolved) {
465
465
  }
466
466
  function initialState(manifest, scenario) {
467
467
  const candidateIds = new Set();
468
+ const state = {
469
+ day: 0,
470
+ step: 0,
471
+ values: { ...(manifest.state ?? {}), ...scenario.state },
472
+ candidates: [],
473
+ timerMinDays: manifest.selection.timerMinDays ?? 0,
474
+ timerMaxDays: manifest.selection.timerMaxDays ?? manifest.selection.timerMinDays ?? 0,
475
+ terminal: false,
476
+ selectedIds: [],
477
+ selectionDays: {},
478
+ };
468
479
  const candidates = manifest.candidates.map((candidate) => {
469
480
  if (candidateIds.has(candidate.id))
470
481
  throw new ServiceError('SEQUENCE_CANDIDATE_DUPLICATE', 'Custom pool candidate IDs must be unique', { id: candidate.id });
471
482
  candidateIds.add(candidate.id);
472
- const weight = typeof candidate.weight === 'number' ? candidate.weight : Number(candidate.weight);
473
- const cap = candidate.cap === undefined
474
- ? undefined
475
- : typeof candidate.cap === 'number'
476
- ? candidate.cap
477
- : Number(candidate.cap);
478
- if (!Number.isFinite(weight) || (cap !== undefined && !Number.isFinite(cap)))
479
- throw new ServiceError('SEQUENCE_INITIAL_VALUE_INVALID', 'Initial custom pool weights and caps must resolve numerically', { id: candidate.id });
483
+ const weight = numberExpression(candidate.weight, state);
484
+ const cap = candidate.cap === undefined ? undefined : numberExpression(candidate.cap, state);
485
+ if (weight === undefined ||
486
+ !Number.isFinite(weight) ||
487
+ (cap !== undefined && !Number.isFinite(cap)))
488
+ throw new ServiceError('SEQUENCE_INITIAL_VALUE_INVALID', 'Initial custom pool weights and caps must resolve from manifest or scenario state', { id: candidate.id });
489
+ const boundedWeight = cap === undefined ? weight : Math.min(weight, cap);
480
490
  return {
481
491
  id: candidate.id,
482
492
  ...(candidate.category === undefined ? {} : { category: candidate.category }),
483
- weight: rounded(weight, manifest.selection.rounding),
493
+ weight: rounded(boundedWeight, manifest.selection.rounding),
484
494
  ...(cap === undefined ? {} : { cap: rounded(cap, manifest.selection.rounding) }),
485
495
  active: true,
486
496
  oneTime: candidate.oneTime ?? false,
@@ -488,16 +498,31 @@ function initialState(manifest, scenario) {
488
498
  cooldownUntil: 0,
489
499
  };
490
500
  });
501
+ state.candidates = candidates;
502
+ return state;
503
+ }
504
+ /** Evaluate one declared dynamic pool at the supplied scenario state before transitions run. */
505
+ export function evaluateCustomPoolPoint(manifest, scenario) {
506
+ const state = initialState(manifest, scenario);
507
+ const unresolved = [];
508
+ const eligible = eligibleCandidates(state, manifest, unresolved);
509
+ const eligibleIds = new Set(eligible.map(({ id }) => id));
510
+ const poolTotal = eligible.reduce((sum, candidate) => sum + candidate.weight, 0);
491
511
  return {
492
- day: 0,
493
- step: 0,
494
- values: { ...(manifest.state ?? {}), ...scenario.state },
495
- candidates,
496
- timerMinDays: manifest.selection.timerMinDays ?? 0,
497
- timerMaxDays: manifest.selection.timerMaxDays ?? manifest.selection.timerMinDays ?? 0,
498
- terminal: false,
499
- selectedIds: [],
500
- selectionDays: {},
512
+ candidates: state.candidates.map((candidate) => ({
513
+ id: candidate.id,
514
+ weight: candidate.weight,
515
+ eligible: eligibleIds.has(candidate.id),
516
+ probability: !eligibleIds.has(candidate.id)
517
+ ? 0
518
+ : manifest.selection.mode === 'categorical_weighted'
519
+ ? poolTotal <= 0
520
+ ? 0
521
+ : candidate.weight / poolTotal
522
+ : Math.max(0, Math.min(1, candidate.weight / 100)),
523
+ })),
524
+ poolTotal,
525
+ unresolved,
501
526
  };
502
527
  }
503
528
  function stateKey(state) {