hoi4-agent-tools 2.8.9 → 2.8.11

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 (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/hoi4_agent_tools/core/artifacts.js +4 -4
  3. package/dist/hoi4_agent_tools/core/source/limits.js +6 -7
  4. package/dist/hoi4_agent_tools/event/graph.js +3 -4
  5. package/dist/hoi4_agent_tools/event/limits.js +4 -4
  6. package/dist/hoi4_agent_tools/event/service.js +4 -16
  7. package/dist/hoi4_agent_tools/gui/assets.js +65 -26
  8. package/dist/hoi4_agent_tools/gui/renderer.js +1 -1
  9. package/dist/hoi4_agent_tools/mcp/tools/gui.js +2 -2
  10. package/dist/hoi4_agent_tools/mcp/tools/probability.js +72 -14
  11. package/dist/hoi4_agent_tools/probability/sequence.js +43 -18
  12. package/dist/hoi4_agent_tools/probability/service.js +156 -18
  13. package/dist/hoi4_agent_tools/technology/service.js +41 -8
  14. package/dist/hoi4_agent_tools/version.js +1 -1
  15. package/package.json +1 -1
  16. package/schemas/configuration.schema.json +1 -1
  17. package/schemas/continuous-focus-palette.schema.json +1 -1
  18. package/schemas/custom-weighted-pool.schema.json +1 -1
  19. package/schemas/event-feature-manifest.schema.json +1 -1
  20. package/schemas/focus-plan.schema.json +1 -1
  21. package/schemas/focus-planning-sidecar.schema.json +1 -1
  22. package/schemas/gui-animation-source.schema.json +1 -1
  23. package/schemas/gui-helper.schema.json +1 -1
  24. package/schemas/gui-scenario.schema.json +1 -1
  25. package/schemas/map-operation.schema.json +1 -1
  26. package/schemas/operation-result.schema.json +1 -1
  27. package/schemas/probability-analysis-result.schema.json +1 -1
  28. package/schemas/probability-compare-input.schema.json +1 -1
  29. package/schemas/probability-evaluate-input.schema.json +1 -1
  30. package/schemas/probability-inspect-input.schema.json +1 -1
  31. package/schemas/probability-render-input.schema.json +1 -1
  32. package/schemas/probability-scenario-set.schema.json +1 -1
  33. package/schemas/probability-sequence-input.schema.json +1 -1
  34. package/schemas/probability-simulate-input.schema.json +1 -1
  35. package/schemas/probability-sweep-input.schema.json +1 -1
  36. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@
4
4
 
5
5
  No unreleased changes.
6
6
 
7
+ ## 2.8.11 - 2026-08-30
8
+
9
+ - Render vanilla HOI4 bitmap fonts with their native atlas masks, legacy page fallbacks, inverted-mask handling, declared glyph channels, crisp scaled sampling, borders, and localisation colours; qualify every shipped descriptor and base font family.
10
+ - Keep large event and technology workspaces complete by separating game and mod scans, accepting large legitimate Clausewitz sources, retaining large state-access inventories, and removing automatic focused-analysis fallbacks.
11
+ - Inspect, evaluate, and compare complete declared dynamic weighted pools with scenario-resolved candidate weights, eligibility, exact probabilities, and complete candidate inventories.
12
+ - Authenticate GitHub release-state API checks so release publication does not fail on anonymous API limits.
13
+
14
+ ## 2.8.10 - 2026-08-30
15
+
16
+ - Fix vanilla BMFont corruption by following declared face and outline channel coverage directly instead of guessing polarity independently for each glyph.
17
+ - Add full-word mixed-colour raster coverage and pixel-level face/outline mask checks for glyphs with very different filled areas.
18
+ - Keep cross-process artifact admission stable when Windows temporarily locks an in-progress artifact file, and retain tool-discovery headroom for multi-digit patch versions.
19
+
7
20
  ## 2.8.9 - 2026-08-29
8
21
 
9
22
  - Decode BMFont channel roles so installed HOI4 atlases render their face and outline masks separately instead of losing localisation colours.
@@ -380,16 +380,16 @@ async function artifactUsage(root, signal) {
380
380
  throw new ServiceError('ARTIFACT_STORAGE_UNSAFE', 'Artifact storage contains a symbolic link or junction');
381
381
  }
382
382
  if (child.isFile() && child.name.startsWith(artifactTemporaryPrefix)) {
383
- const temporary = await resolveListedPath(directory, child.name);
384
- if (temporary === undefined)
385
- continue;
383
+ const temporary = path.join(directory, child.name);
386
384
  try {
387
385
  const metadata = await lstat(temporary);
386
+ if (metadata.isSymbolicLink())
387
+ throw new ServiceError('ARTIFACT_STORAGE_UNSAFE', 'Artifact storage contains a symbolic link or junction');
388
388
  if (Date.now() - metadata.mtimeMs >= artifactDebrisMinimumAgeMs)
389
389
  await unlink(temporary);
390
390
  }
391
391
  catch (error) {
392
- if (error.code !== 'ENOENT')
392
+ if (!['ENOENT', 'EPERM'].includes(error.code ?? ''))
393
393
  throw error;
394
394
  }
395
395
  continue;
@@ -1,12 +1,11 @@
1
1
  export const SOURCE_DIAGNOSTIC_LIMIT = 100;
2
2
  export const SOURCE_MAX_NESTING = 256;
3
- export const SOURCE_MAX_BYTES = 8_388_608;
4
- // Large authored HOI4 focus and scripted-effect files can exceed 250k tokens while remaining well
5
- // below the byte and nesting ceilings. Keep enough room for those files without allowing an
6
- // unbounded token inventory.
7
- export const SOURCE_TOKEN_LIMIT = 1_000_000;
8
- export const SOURCE_ENTRY_LIMIT = 100_000;
9
- export const SOURCE_LINE_LIMIT = 250_000;
3
+ // This is a parser ceiling, not the aggregate workspace-scan or remote request ceiling. Large
4
+ // generated focus, event, and scripted-effect sources routinely exceed 8 MiB in real agentic mods.
5
+ export const SOURCE_MAX_BYTES = 33_554_432;
6
+ export const SOURCE_TOKEN_LIMIT = 4_000_000;
7
+ export const SOURCE_ENTRY_LIMIT = 1_000_000;
8
+ export const SOURCE_LINE_LIMIT = 2_000_000;
10
9
  /**
11
10
  * Parser ceilings that intentionally return no document or a structurally partial document.
12
11
  * Broad inventories may skip these sources, but targeted reads and rewrites must continue to
@@ -1604,10 +1604,9 @@ export function buildEventGraph(snapshot, options = {}) {
1604
1604
  assertGraphLimit(graph.unresolved.length, EVENT_GRAPH_MAX_UNRESOLVED, 'EVENT_UNRESOLVED_LIMIT', 'unresolved-analysis count');
1605
1605
  budget.check();
1606
1606
  const diagnostics = sortDiagnostics([...snapshot.diagnostics, ...partial.diagnostics]);
1607
- const complete = !focused &&
1608
- snapshot.complete &&
1609
- graph.unresolved.length === 0 &&
1610
- !graph.issues.some(({ classification, severity }) => classification === 'unresolved_analysis' || severity === 'blocker');
1607
+ // Coverage completeness is independent from defects discovered in the source. A full,
1608
+ // untruncated analysis remains complete when it correctly reports missing or dynamic links.
1609
+ const complete = !focused && snapshot.complete;
1611
1610
  const nodes = sortNodes(graph.nodes);
1612
1611
  const edges = stableUnique(graph.edges);
1613
1612
  const stateAccesses = stableUnique(graph.stateAccesses);
@@ -1,9 +1,9 @@
1
1
  import { ServiceError } from '../core/result.js';
2
2
  export const EVENT_GRAPH_MAX_NODES = 250_000;
3
3
  export const EVENT_GRAPH_MAX_EDGES = 500_000;
4
- export const EVENT_GRAPH_MAX_STATE_ACCESSES = 250_000;
5
- export const EVENT_GRAPH_MAX_STATE_LINKS = 250_000;
6
- export const EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES = 500_000;
4
+ export const EVENT_GRAPH_MAX_STATE_ACCESSES = 1_000_000;
5
+ export const EVENT_GRAPH_MAX_STATE_LINKS = 1_000_000;
6
+ export const EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES = 2_000_000;
7
7
  // Installed-game scans can legitimately produce more than twenty thousand source-linked
8
8
  // findings before an agent narrows the query. Keep the graph bounded, but do not reject a
9
9
  // complete large workspace merely because its diagnostics are numerous.
@@ -13,7 +13,7 @@ export const EVENT_GRAPH_MAX_HELPER_DEPTH = 64;
13
13
  export const EVENT_GRAPH_MAX_HELPER_PROJECTIONS = 200_000;
14
14
  export const EVENT_GRAPH_MAX_HELPER_STATE_PROJECTIONS = 50_000;
15
15
  export const EVENT_GRAPH_MAX_CONDITION_TEXT = 16_384;
16
- export const EVENT_GRAPH_WORK_LIMIT = 5_000_000;
16
+ export const EVENT_GRAPH_WORK_LIMIT = 10_000_000;
17
17
  export const EVENT_FRAGMENT_CACHE_MAX_ENTRIES = 10_000;
18
18
  export const EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES = 134_217_728;
19
19
  export const EVENT_PROPOSED_SOURCE_MAX_FILES = 64;
@@ -45,16 +45,12 @@ class BoundedEventFragmentCache {
45
45
  function eventGraphCacheKey(workspaceId, projectHelpers, analysisMode = 'full') {
46
46
  return `${workspaceId}\0${projectHelpers ? 'expanded' : 'structural'}\0${analysisMode}`;
47
47
  }
48
- function isGameBackedWorkspace(workspace) {
49
- return workspace.roots.some(({ kind }) => kind === 'game');
50
- }
51
48
  const serviceState = new WeakMap();
52
49
  const EVENT_GRAPH_ARTIFACT_MAX_BYTES = 67_108_864;
53
50
  const EVENT_GRAPH_ARTIFACT_MAX_CHUNKS = 1_024;
54
51
  const EVENT_SCAN_FULL_GRAPH_RECORD_LIMIT = 100_000;
55
52
  const EVENT_SCAN_SUMMARY_SAMPLE_LIMIT = 100;
56
53
  const EVENT_FOCUSED_EVIDENCE_LIMIT = 2_000;
57
- const EVENT_FOCUSED_GRAPH_FILE_THRESHOLD = 1_000;
58
54
  const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
59
55
  const graphHashCache = new WeakMap();
60
56
  function groupedCounts(values, key) {
@@ -704,8 +700,7 @@ export class EventChainViewer {
704
700
  const generation = engineGeneration(this.engine, workspaceId);
705
701
  const requestedProjectHelpers = options.projectHelpers ?? true;
706
702
  const requestedAnalysisMode = options.analysisMode ?? 'full';
707
- const autoFocused = isGameBackedWorkspace(workspace) && requestedAnalysisMode === 'full';
708
- const cacheAnalysisMode = autoFocused ? 'focused' : requestedAnalysisMode;
703
+ const cacheAnalysisMode = requestedAnalysisMode;
709
704
  const cacheProjectHelpers = cacheAnalysisMode === 'focused' ? false : requestedProjectHelpers;
710
705
  const cacheKey = eventGraphCacheKey(workspaceId, cacheProjectHelpers, cacheAnalysisMode);
711
706
  const cached = this.#state.current.get(cacheKey);
@@ -716,7 +711,7 @@ export class EventChainViewer {
716
711
  this.#state.current.get(eventGraphCacheKey(workspaceId, requestedProjectHelpers, requestedAnalysisMode)) ??
717
712
  this.#state.current.get(eventGraphCacheKey(workspaceId, !requestedProjectHelpers)) ??
718
713
  this.#state.current.get(eventGraphCacheKey(workspaceId, requestedProjectHelpers));
719
- const scanFocused = autoFocused || requestedAnalysisMode === 'focused';
714
+ const scanFocused = requestedAnalysisMode === 'focused';
720
715
  const snapshot = options.refresh !== true && sibling?.generation === generation
721
716
  ? sibling.snapshot
722
717
  : await scanEventSources(this.engine, workspaceId, workspace, scanFocused, options.principal, options.signal);
@@ -724,9 +719,7 @@ export class EventChainViewer {
724
719
  if (cached?.snapshot.revision === snapshot.revision && cached.generation === generation) {
725
720
  return cached.graph;
726
721
  }
727
- const buildAnalysisMode = autoFocused || snapshot.files.length > EVENT_FOCUSED_GRAPH_FILE_THRESHOLD
728
- ? 'focused'
729
- : 'full';
722
+ const buildAnalysisMode = requestedAnalysisMode;
730
723
  const projectHelpers = buildAnalysisMode === 'focused' ? false : requestedProjectHelpers;
731
724
  const graph = buildEventGraph(snapshot, {
732
725
  ...(options.signal === undefined ? {} : { signal: options.signal }),
@@ -750,15 +743,10 @@ export class EventChainViewer {
750
743
  input.mode !== 'roots' &&
751
744
  input.mode !== 'trace' &&
752
745
  input.mode !== 'explain_path',
753
- ...(input.mode === 'trace' || input.mode === 'explain_path'
754
- ? { analysisMode: 'focused' }
755
- : {}),
756
746
  ...(input.principal === undefined ? {} : { principal: input.principal }),
757
747
  ...(input.signal === undefined ? {} : { signal: input.signal }),
758
748
  });
759
- // Keep the service result's graph identity distinct from a prior scan while
760
- // reusing its structural cache and avoiding a second large graph build.
761
- const graph = input.mode === 'trace' || input.mode === 'explain_path' ? { ...scannedGraph } : scannedGraph;
749
+ const graph = scannedGraph;
762
750
  const maxDepth = input.maxDepth ?? 8;
763
751
  const maxNodes = input.maxNodes ?? 500;
764
752
  const maxEdges = input.maxEdges ?? 2_000;
@@ -110,6 +110,7 @@ export function parseBmFont(text) {
110
110
  ? fields.width
111
111
  : 0,
112
112
  page: typeof fields.page === 'number' ? fields.page : 0,
113
+ channelMask: typeof fields.chnl === 'number' ? fields.chnl : 15,
113
114
  });
114
115
  }
115
116
  recognised = true;
@@ -471,7 +472,9 @@ export class GuiAssetCatalog {
471
472
  if (previous !== undefined)
472
473
  penX += (entry.bmfont.kerning.get(`${previous}:${codePoint}`) ?? 0) * scale;
473
474
  const pagePath = entry.bmfont.pages[metric.page];
474
- const pageFile = pagePath === undefined ? undefined : this.resolveBmFontPage(entry, pagePath, metric.page);
475
+ // Older shipped HOI4 descriptors omit the page record entirely. Page zero still uses the
476
+ // descriptor stem (for example garamond_12.fnt -> garamond_12.dds/tga).
477
+ const pageFile = this.resolveBmFontPage(entry, pagePath ?? '', metric.page);
475
478
  const raster = pageFile === undefined
476
479
  ? undefined
477
480
  : await this.loadBmFontGlyph(entry, pageFile, metric, codePoint);
@@ -619,37 +622,73 @@ export class GuiAssetCatalog {
619
622
  .raw()
620
623
  .toBuffer({ resolveWithObject: true });
621
624
  const channelRoles = entry.bmfont?.channelRoles;
622
- let facePixels = extracted.data;
623
- let borderPixels;
624
- if (channelRoles?.some((role) => role === 0) === true) {
625
- facePixels = Buffer.alloc(extracted.data.length);
626
- borderPixels = channelRoles.some((role) => role === 1)
627
- ? Buffer.alloc(extracted.data.length)
628
- : undefined;
629
- let backgroundSamples = 0;
630
- let backgroundTotal = 0;
631
- for (let offset = 0; offset < extracted.data.length; offset += 4) {
632
- const borderCoverage = channelRoles.reduce((maximum, role, channel) => role === 1 ? Math.max(maximum, extracted.data[offset + channel] ?? 0) : maximum, 0);
633
- if (borderCoverage !== 0)
634
- continue;
635
- for (let channel = 0; channel < 4; channel += 1) {
636
- if (channelRoles[channel] !== 0)
637
- continue;
638
- backgroundTotal += extracted.data[offset + channel] ?? 0;
639
- backgroundSamples += 1;
640
- }
625
+ const channelRanges = [0, 1, 2, 3].map((channel) => {
626
+ let minimum = 255;
627
+ let maximum = 0;
628
+ for (let offset = channel; offset < extracted.data.length; offset += 4) {
629
+ const sample = extracted.data[offset] ?? 0;
630
+ minimum = Math.min(minimum, sample);
631
+ maximum = Math.max(maximum, sample);
641
632
  }
642
- const invertedFace = backgroundSamples > 0 && backgroundTotal / backgroundSamples > 127;
633
+ return maximum - minimum;
634
+ });
635
+ const channelSelected = (channel) => {
636
+ if (metric.channelMask === 15)
637
+ return true;
638
+ const maskForChannel = [4, 2, 1, 8][channel] ?? 0;
639
+ return (metric.channelMask & maskForChannel) !== 0;
640
+ };
641
+ const informativeChannels = (role) => channelRoles === undefined
642
+ ? []
643
+ : channelRoles
644
+ .map((candidate, channel) => ({ candidate, channel }))
645
+ .filter(({ candidate, channel }) => candidate === role &&
646
+ channelSelected(channel) &&
647
+ (channelRanges[channel] ?? 0) > 0)
648
+ .map(({ channel }) => channel);
649
+ const alphaIsInformative = (channelRanges[3] ?? 0) > 0;
650
+ let faceChannels = informativeChannels(0);
651
+ let borderChannels = informativeChannels(1);
652
+ if (faceChannels.length === 0) {
653
+ if (borderChannels.length > 0) {
654
+ // Some vanilla fonts declare their only useful alpha bitmap as the outline
655
+ // channel even though the atlas contains no separate face channel. In that case
656
+ // the alpha bitmap is the face; treating constant-white RGB as coverage produces
657
+ // an opaque rectangle for every glyph.
658
+ faceChannels = borderChannels;
659
+ borderChannels = [];
660
+ }
661
+ else {
662
+ faceChannels = alphaIsInformative
663
+ ? [3]
664
+ : channelRanges
665
+ .map((range, channel) => ({ range, channel }))
666
+ .filter(({ range }) => range > 0)
667
+ .map(({ channel }) => channel);
668
+ }
669
+ }
670
+ const facePixels = Buffer.alloc(extracted.data.length);
671
+ let borderPixels;
672
+ if (faceChannels.length > 0) {
673
+ borderPixels =
674
+ borderChannels.length > 0 ? Buffer.alloc(extracted.data.length) : undefined;
675
+ // BMFont role 0 is coverage, not a per-glyph light/dark bitmap. Vanilla atlases may
676
+ // contain glyphs with either more filled or more empty pixels, so polarity inference
677
+ // from an individual crop corrupts neighboring letters inconsistently.
643
678
  for (let offset = 0; offset < extracted.data.length; offset += 4) {
644
679
  let faceCoverage = 0;
645
680
  let borderCoverage = 0;
646
- for (let channel = 0; channel < 4; channel += 1) {
681
+ for (const channel of faceChannels) {
647
682
  const sample = extracted.data[offset + channel] ?? 0;
648
- if (channelRoles[channel] === 0)
649
- faceCoverage = Math.max(faceCoverage, invertedFace ? 255 - sample : sample);
650
- if (channelRoles[channel] === 1)
651
- borderCoverage = Math.max(borderCoverage, sample);
683
+ faceCoverage = Math.max(faceCoverage, sample);
652
684
  }
685
+ for (const channel of borderChannels)
686
+ borderCoverage = Math.max(borderCoverage, extracted.data[offset + channel] ?? 0);
687
+ // Vanilla's compressed multi-channel atlases can bleed a neighboring glyph into
688
+ // an RGB face crop while the alpha silhouette remains correctly bounded. The
689
+ // face is the intersection, not the union, of those declared layers.
690
+ if (borderChannels.length > 0)
691
+ faceCoverage = Math.min(faceCoverage, borderCoverage);
653
692
  facePixels[offset] = 255;
654
693
  facePixels[offset + 1] = 255;
655
694
  facePixels[offset + 2] = 255;
@@ -60,7 +60,7 @@ function sceneGlyphDefinitions(scene) {
60
60
  .map(([key, glyphPath]) => `<path id="${outlineDefinitionId(key)}" d="${glyphPath}"/>`),
61
61
  ...[...bitmaps.entries()]
62
62
  .sort(([left], [right]) => compareCodeUnits(left, right))
63
- .map(([key, glyph]) => `<image id="${bitmapDefinitionId(key)}" width="${finite(glyph.width)}" height="${finite(glyph.height)}" href="${glyph.dataUri}" preserveAspectRatio="none"/>${glyph.borderDataUri === undefined ? '' : `<image id="${bitmapDefinitionId(key, 'border')}" width="${finite(glyph.width)}" height="${finite(glyph.height)}" href="${glyph.borderDataUri}" preserveAspectRatio="none"/>`}`),
63
+ .map(([key, glyph]) => `<image id="${bitmapDefinitionId(key)}" width="${finite(glyph.width)}" height="${finite(glyph.height)}" href="${glyph.dataUri}" preserveAspectRatio="none" image-rendering="optimizeSpeed"/>${glyph.borderDataUri === undefined ? '' : `<image id="${bitmapDefinitionId(key, 'border')}" width="${finite(glyph.width)}" height="${finite(glyph.height)}" href="${glyph.borderDataUri}" preserveAspectRatio="none" image-rendering="optimizeSpeed"/>`}`),
64
64
  ].join('');
65
65
  }
66
66
  function bitmapTintMarkup(id, colour, glyphs, rect) {
@@ -91,9 +91,9 @@ const previewStateSchema = z.enum([
91
91
  'long-text',
92
92
  'missing-localisation',
93
93
  ]);
94
- const compactGuiScenarioSchema = compactValidatedInputSchema(GuiPreviewScenarioSchema, `Complete GUI preview scenario: https://github.com/klimPaskov/hoi4-agent-tools/blob/v${PACKAGE_VERSION}/docs/gui.md`);
94
+ const compactGuiScenarioSchema = compactValidatedInputSchema(GuiPreviewScenarioSchema, `GUI preview scenario: https://github.com/klimPaskov/hoi4-agent-tools/blob/v${PACKAGE_VERSION}/docs/gui.md`);
95
95
  const compactGeneratedScenarioOptionsSchema = compactValidatedInputSchema(GuiGeneratedScenarioOptionsSchema, `Options: https://github.com/klimPaskov/hoi4-agent-tools/blob/v${PACKAGE_VERSION}/docs/gui.md`);
96
- const compactGuiHelperSchema = compactValidatedInputSchema(GuiHelperDocumentSchema, `Complete GUI helper document: https://github.com/klimPaskov/hoi4-agent-tools/blob/v${PACKAGE_VERSION}/docs/gui.md`);
96
+ const compactGuiHelperSchema = compactValidatedInputSchema(GuiHelperDocumentSchema, `GUI helper document: https://github.com/klimPaskov/hoi4-agent-tools/blob/v${PACKAGE_VERSION}/docs/gui.md`);
97
97
  const guiBaseInput = z
98
98
  .object({
99
99
  workspaceId: workspaceIdSchema,
@@ -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) {
@@ -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;
@@ -775,13 +833,61 @@ export class ProbabilityAnalyzer {
775
833
  }
776
834
  return complete;
777
835
  }
778
- async inspect(context, adapter, source, candidatePool = []) {
779
- if (source === undefined)
836
+ async inspect(context, adapter, source, candidatePool = [], customPoolManifest) {
837
+ if (source === undefined && customPoolManifest === undefined)
780
838
  return { adapters: probabilityAdapters(), artifacts: [], filesScanned: [] };
781
- const snapshot = await this.scan(context, [source.path]);
839
+ if (customPoolManifest !== undefined && source !== undefined)
840
+ throw new ServiceError('PROBABILITY_INPUT_AMBIGUOUS', 'Provide either source or customPoolManifest for inspection');
841
+ if (customPoolManifest !== undefined && adapter !== 'custom_weighted_pool')
842
+ throw new ServiceError('PROBABILITY_ADAPTER_MISMATCH', 'customPoolManifest inspection requires adapter custom_weighted_pool');
843
+ const snapshot = await this.scan(context, [source?.path]);
844
+ if (customPoolManifest !== undefined) {
845
+ const surface = customSurface(customPoolManifest, snapshot.revision);
846
+ const gameVersionVerification = await this.verifyGameVersion(context, 'custom_weighted_pool');
847
+ const report = {
848
+ schemaVersion: 'probability-inspection.v2',
849
+ workspaceId: context.workspaceId,
850
+ surface: {
851
+ id: surface.id,
852
+ adapter: surface.adapter,
853
+ poolComplete: true,
854
+ sourceRevision: surface.sourceRevision,
855
+ sourceHash: surface.sourceHash,
856
+ gameVersionVerification,
857
+ candidateCount: surface.candidates.length,
858
+ candidates: surface.candidates.map((candidate) => ({
859
+ id: candidate.id,
860
+ sourceKind: candidate.sourceKind,
861
+ provenance: candidate.provenance,
862
+ hasEligibility: customPoolManifest.candidates.find(({ id }) => id === candidate.id)?.eligibleWhen !==
863
+ undefined,
864
+ hasWeightBlock: true,
865
+ requiredInputs: [],
866
+ referencedProvenance: [],
867
+ analysisSupport: 'exact',
868
+ })),
869
+ requiredInputs: [],
870
+ unsupported: [],
871
+ },
872
+ };
873
+ const workspace = this.engine.resolver.get(context.workspaceId, context.principal);
874
+ const artifact = await this.engine.artifacts.putChunked(workspace, `probability-inspect-${surface.sourceHash.slice(0, 12)}.json`, 'application/json', `${canonicalJson(report)}\n`, {
875
+ kind: 'probability-inspection',
876
+ toolVersion: PACKAGE_VERSION,
877
+ schemaVersion: 'probability-inspection.v2',
878
+ sourceHashes: { aggregate: surface.sourceHash },
879
+ }, 'Declared custom weighted-pool inventory', context.signal);
880
+ return {
881
+ adapters: probabilityAdapters(),
882
+ surface: report.surface,
883
+ artifacts: [publicArtifactLink(artifact)],
884
+ filesScanned: [],
885
+ };
886
+ }
887
+ const selectedSource = source;
782
888
  const storeDiscovery = async (inventory, reason) => {
783
889
  const availableAdapters = inventory.adapters.filter(({ candidateCount }) => candidateCount > 0);
784
- const matchingIdentifier = availableAdapters.filter(({ identifierMatchCount }) => source.identifier !== undefined && identifierMatchCount > 0);
890
+ const matchingIdentifier = availableAdapters.filter(({ identifierMatchCount }) => selectedSource.identifier !== undefined && identifierMatchCount > 0);
785
891
  const matchingPool = availableAdapters.filter(({ candidatePoolMatchCount }) => candidatePool.length > 0 && candidatePoolMatchCount > 0);
786
892
  const requested = availableAdapters.find(({ adapterId }) => adapterId === adapter);
787
893
  const ranked = [
@@ -826,7 +932,7 @@ export class ProbabilityAnalyzer {
826
932
  };
827
933
  };
828
934
  if (adapter === undefined) {
829
- const inventory = inventoryWeightedSurfaces(snapshot, source, candidatePool);
935
+ const inventory = inventoryWeightedSurfaces(snapshot, selectedSource, candidatePool);
830
936
  return storeDiscovery(inventory, inventory.adapters.some(({ candidateCount }) => candidateCount > 0)
831
937
  ? 'source_inventory'
832
938
  : 'no_weighted_surfaces');
@@ -834,14 +940,14 @@ export class ProbabilityAnalyzer {
834
940
  const gameVersionVerification = await this.verifyGameVersion(context, adapter);
835
941
  let surface;
836
942
  try {
837
- surface = discoverWeightedSurface(snapshot, adapter, source, candidatePool);
943
+ surface = discoverWeightedSurface(snapshot, adapter, selectedSource, candidatePool);
838
944
  }
839
945
  catch (error) {
840
946
  if (!(error instanceof ServiceError) ||
841
947
  (error.code !== 'PROBABILITY_SURFACE_EMPTY' &&
842
948
  error.code !== 'PROBABILITY_IDENTIFIER_NOT_FOUND'))
843
949
  throw error;
844
- const inventory = inventoryWeightedSurfaces(snapshot, source, candidatePool);
950
+ const inventory = inventoryWeightedSurfaces(snapshot, selectedSource, candidatePool);
845
951
  const requested = inventory.adapters.find(({ adapterId }) => adapterId === adapter);
846
952
  const reason = error.code === 'PROBABILITY_IDENTIFIER_NOT_FOUND'
847
953
  ? 'identifier_not_found'
@@ -916,11 +1022,22 @@ export class ProbabilityAnalyzer {
916
1022
  if (request.scenarioSet.workspaceId !== undefined &&
917
1023
  request.scenarioSet.workspaceId !== request.workspaceId)
918
1024
  throw new ServiceError('PROBABILITY_SCENARIO_WORKSPACE_MISMATCH', 'Scenario set belongs to a different workspace');
1025
+ const usingManifest = request.customPoolManifest !== undefined;
1026
+ if (usingManifest !== (request.source === undefined))
1027
+ throw new ServiceError('PROBABILITY_INPUT_AMBIGUOUS', 'Provide exactly one source or customPoolManifest');
1028
+ if (usingManifest && request.adapter !== 'custom_weighted_pool')
1029
+ throw new ServiceError('PROBABILITY_ADAPTER_MISMATCH', 'customPoolManifest requires the custom_weighted_pool adapter');
1030
+ if (!usingManifest && request.adapter === 'custom_weighted_pool')
1031
+ throw new ServiceError('PROBABILITY_MANIFEST_REQUIRED', 'custom_weighted_pool requires customPoolManifest');
919
1032
  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 ?? []);
1033
+ const snapshot = await this.scan(request, [request.source?.path]);
1034
+ const surface = usingManifest
1035
+ ? customSurface(request.customPoolManifest, snapshot.revision)
1036
+ : discoverWeightedSurface(snapshot, request.adapter, request.source, request.candidatePool ?? []);
922
1037
  const definitions = ClausewitzEvaluationDefinitions.build(snapshot);
923
- const metadata = this.metadata(request, snapshot, surface, request.scenarioSet, request.candidatePool ?? [], gameVersionVerification, {
1038
+ const metadata = this.metadata(request, snapshot, surface, request.scenarioSet, usingManifest
1039
+ ? request.customPoolManifest.candidates.map(({ id }) => id)
1040
+ : (request.candidatePool ?? []), gameVersionVerification, {
924
1041
  operation,
925
1042
  horizonDays: request.horizonDays,
926
1043
  requestedMetrics: request.metrics,
@@ -931,7 +1048,9 @@ export class ProbabilityAnalyzer {
931
1048
  const cached = this.state.byCacheKey.get(metadata.cacheKey);
932
1049
  if (cached !== undefined)
933
1050
  return { result: cached, surface, definitions, snapshot };
934
- const scenarios = evaluateSurfaceScenarios(surface, request.scenarioSet.scenarios, definitions, request.horizonDays);
1051
+ const scenarios = usingManifest
1052
+ ? customPoolScenarios(surface, request.customPoolManifest, request.scenarioSet)
1053
+ : evaluateSurfaceScenarios(surface, request.scenarioSet.scenarios, definitions, request.horizonDays);
935
1054
  return {
936
1055
  result: this.buildResult(operation, metadata, surface, scenarios, {
937
1056
  ...(request.acceptanceBands === undefined
@@ -1137,12 +1256,31 @@ export class ProbabilityAnalyzer {
1137
1256
  return this.store(result, request.outputs ?? ['json', 'sequence'], false, request.signal, request.principal);
1138
1257
  }
1139
1258
  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
- });
1259
+ const manifestMode = request.beforeManifest !== undefined || request.afterManifest !== undefined;
1260
+ if (manifestMode !== (request.before === undefined && request.after === undefined))
1261
+ throw new ServiceError('PROBABILITY_COMPARE_INPUT_AMBIGUOUS', 'Compare either before/after sources or beforeManifest/afterManifest');
1262
+ if (manifestMode &&
1263
+ (request.beforeManifest === undefined || request.afterManifest === undefined))
1264
+ throw new ServiceError('PROBABILITY_COMPARE_MANIFEST_REQUIRED', 'Custom-pool comparison requires both beforeManifest and afterManifest');
1265
+ if (!manifestMode && (request.before === undefined || request.after === undefined))
1266
+ throw new ServiceError('PROBABILITY_COMPARE_SOURCE_REQUIRED', 'Source comparison requires both before and after');
1267
+ const before = manifestMode
1268
+ ? await this.analyzeUnstored('evaluate', {
1269
+ ...request,
1270
+ customPoolManifest: request.beforeManifest,
1271
+ })
1272
+ : await this.analyzeUnstored('evaluate', { ...request, source: request.before });
1273
+ const after = manifestMode
1274
+ ? await this.analyzeUnstored('evaluate', {
1275
+ ...request,
1276
+ customPoolManifest: request.afterManifest,
1277
+ refresh: false,
1278
+ })
1279
+ : await this.analyzeUnstored('evaluate', {
1280
+ ...request,
1281
+ source: request.after,
1282
+ refresh: false,
1283
+ });
1146
1284
  const metadata = {
1147
1285
  ...after.result.metadata,
1148
1286
  cacheKey: hashCanonical({
@@ -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.9';
3
+ export const PACKAGE_VERSION = '2.8.11';
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/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.9",
4
+ "version": "2.8.11",
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.8.9/schemas/configuration.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.8.9/schemas/continuous-focus-palette.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.9/schemas/custom-weighted-pool.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.9/schemas/event-feature-manifest.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.9/schemas/focus-plan.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.9/schemas/focus-planning-sidecar.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.9/schemas/gui-animation-source.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.9/schemas/gui-helper.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.9/schemas/gui-scenario.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.9/schemas/map-operation.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.9/schemas/operation-result.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.8.9/schemas/probability-analysis-result.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.8.9/schemas/probability-compare-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.8.9/schemas/probability-evaluate-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.8.9/schemas/probability-inspect-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.8.9/schemas/probability-render-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.8.9/schemas/probability-scenario-set.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.8.9/schemas/probability-sequence-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.8.9/schemas/probability-simulate-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.8.9/schemas/probability-sweep-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.11/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.8.9",
6
+ "version": "2.8.11",
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.8.9",
16
+ "version": "2.8.11",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }