hoi4-agent-tools 2.8.11 → 3.0.1

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 (61) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +38 -11
  3. package/dist/hoi4_agent_tools/core/configuration.js +16 -3
  4. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +10 -1
  5. package/dist/hoi4_agent_tools/core/engine.js +3 -0
  6. package/dist/hoi4_agent_tools/core/scanner.js +63 -7
  7. package/dist/hoi4_agent_tools/event/service.js +30 -23
  8. package/dist/hoi4_agent_tools/gui/assets.js +218 -0
  9. package/dist/hoi4_agent_tools/gui/layout.js +70 -9
  10. package/dist/hoi4_agent_tools/gui/renderer.js +80 -11
  11. package/dist/hoi4_agent_tools/gui/scenario-generator.js +36 -0
  12. package/dist/hoi4_agent_tools/gui/studio.js +90 -25
  13. package/dist/hoi4_agent_tools/mcp/tools/gui.js +1 -1
  14. package/dist/hoi4_agent_tools/mcp/tools/probability.js +9 -5
  15. package/dist/hoi4_agent_tools/probability/service.js +81 -14
  16. package/dist/hoi4_agent_tools/schemas/probability.js +2 -0
  17. package/dist/hoi4_agent_tools/technology/compare.js +6 -1
  18. package/dist/hoi4_agent_tools/technology/graph.js +67 -2
  19. package/dist/hoi4_agent_tools/technology/model.js +1 -1
  20. package/dist/hoi4_agent_tools/technology/queries.js +4 -0
  21. package/dist/hoi4_agent_tools/technology/render.js +73 -28
  22. package/dist/hoi4_agent_tools/technology/service.js +4 -1
  23. package/dist/hoi4_agent_tools/technology/source-analysis.js +102 -19
  24. package/dist/hoi4_agent_tools/version.js +1 -1
  25. package/docs/development.md +2 -0
  26. package/docs/gui.md +14 -3
  27. package/docs/images/readme/chaos-meter-ingame.png +0 -0
  28. package/docs/images/readme/chaos-meter-mcp.png +0 -0
  29. package/docs/images/readme/communist-insurgency-ingame.png +0 -0
  30. package/docs/images/readme/communist-insurgency-mcp.png +0 -0
  31. package/docs/images/readme/event-chain-mcp.png +0 -0
  32. package/docs/images/readme/focus-tree-mcp.png +0 -0
  33. package/docs/images/readme/map-mcp.png +0 -0
  34. package/docs/images/readme/probability-mcp.png +0 -0
  35. package/docs/images/readme/settings-advanced-mcp.png +0 -0
  36. package/docs/images/readme/settings-events-mcp.png +0 -0
  37. package/docs/images/readme/technology-tree-mcp.png +0 -0
  38. package/docs/probability.md +3 -1
  39. package/docs/technology.md +1 -1
  40. package/package.json +3 -1
  41. package/schemas/configuration.schema.json +3 -3
  42. package/schemas/continuous-focus-palette.schema.json +1 -1
  43. package/schemas/custom-weighted-pool.schema.json +1 -1
  44. package/schemas/event-feature-manifest.schema.json +1 -1
  45. package/schemas/focus-plan.schema.json +1 -1
  46. package/schemas/focus-planning-sidecar.schema.json +1 -1
  47. package/schemas/gui-animation-source.schema.json +1 -1
  48. package/schemas/gui-helper.schema.json +1 -1
  49. package/schemas/gui-scenario.schema.json +1 -1
  50. package/schemas/map-operation.schema.json +1 -1
  51. package/schemas/operation-result.schema.json +1 -1
  52. package/schemas/probability-analysis-result.schema.json +58 -39
  53. package/schemas/probability-compare-input.schema.json +1 -1
  54. package/schemas/probability-evaluate-input.schema.json +1 -1
  55. package/schemas/probability-inspect-input.schema.json +1 -1
  56. package/schemas/probability-render-input.schema.json +1 -1
  57. package/schemas/probability-scenario-set.schema.json +1 -1
  58. package/schemas/probability-sequence-input.schema.json +1 -1
  59. package/schemas/probability-simulate-input.schema.json +1 -1
  60. package/schemas/probability-sweep-input.schema.json +1 -1
  61. package/server.json +2 -2
@@ -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,
@@ -18,7 +18,7 @@ const candidatePool = z.array(z.string().min(1).max(512)).max(100_000).optional(
18
18
  const outputs = z.array(probabilityOutputSchema).max(10).optional();
19
19
  const commonShape = {
20
20
  workspaceId: workspaceIdSchema,
21
- adapter: probabilityAdapterIdSchema,
21
+ adapter: probabilityAdapterIdSchema.optional(),
22
22
  scenarioSet: nestedScenarios,
23
23
  candidatePool,
24
24
  horizonDays: z.number().positive().max(1_000_000).optional(),
@@ -51,7 +51,9 @@ const inspectInput = z
51
51
  code: 'custom',
52
52
  message: 'An adapter requires a source; provide a source alone to discover compatible adapters',
53
53
  });
54
- if (value.customPoolManifest !== undefined && value.adapter !== 'custom_weighted_pool')
54
+ if (value.customPoolManifest !== undefined &&
55
+ value.adapter !== undefined &&
56
+ value.adapter !== 'custom_weighted_pool')
55
57
  context.addIssue({
56
58
  code: 'custom',
57
59
  path: ['adapter'],
@@ -71,7 +73,9 @@ const evaluateInput = z
71
73
  code: 'custom',
72
74
  message: 'Provide exactly one source or customPoolManifest',
73
75
  });
74
- if (value.customPoolManifest !== undefined && value.adapter !== 'custom_weighted_pool')
76
+ if (value.customPoolManifest !== undefined &&
77
+ value.adapter !== undefined &&
78
+ value.adapter !== 'custom_weighted_pool')
75
79
  context.addIssue({
76
80
  code: 'custom',
77
81
  path: ['adapter'],
@@ -120,7 +124,7 @@ const sequenceInput = z
120
124
  const compareInput = z
121
125
  .object({
122
126
  workspaceId: workspaceIdSchema,
123
- adapter: probabilityAdapterIdSchema,
127
+ adapter: probabilityAdapterIdSchema.optional(),
124
128
  before: nestedSource.optional(),
125
129
  after: nestedSource.optional(),
126
130
  beforeManifest: nestedManifest.optional(),
@@ -149,7 +153,7 @@ const compareInput = z
149
153
  code: 'custom',
150
154
  message: 'Custom-pool comparison requires beforeManifest and afterManifest',
151
155
  });
152
- if (manifestMode && value.adapter !== 'custom_weighted_pool')
156
+ if (manifestMode && value.adapter !== undefined && value.adapter !== 'custom_weighted_pool')
153
157
  context.addIssue({
154
158
  code: 'custom',
155
159
  path: ['adapter'],
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { canonicalJson, compareCodeUnits, hashCanonical } from '../core/canonical.js';
4
4
  import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
5
- import { probabilityDomainScanPatterns } from '../core/domain-scan-patterns.js';
5
+ import { probabilityDomainScanPatterns, probabilitySharedDefinitionPatterns, } from '../core/domain-scan-patterns.js';
6
6
  import { publicArtifactLink } from '../core/artifacts.js';
7
7
  import { ServiceError } from '../core/result.js';
8
8
  import { PACKAGE_VERSION } from '../version.js';
@@ -17,6 +17,43 @@ import { Rational } from './rational.js';
17
17
  import { discoverWeightedSurface, inventoryWeightedSurfaces, } from './source-analysis.js';
18
18
  import { sweepSurface } from './sweep.js';
19
19
  const states = new WeakMap();
20
+ function resolveSourceBackedAdapter(snapshot, source, candidatePool, requested) {
21
+ if (requested !== undefined && requested !== 'custom_weighted_pool') {
22
+ try {
23
+ discoverWeightedSurface(snapshot, requested, source, candidatePool);
24
+ return requested;
25
+ }
26
+ catch (error) {
27
+ if (!(error instanceof ServiceError) ||
28
+ (error.code !== 'PROBABILITY_SURFACE_EMPTY' &&
29
+ error.code !== 'PROBABILITY_IDENTIFIER_NOT_FOUND'))
30
+ throw error;
31
+ }
32
+ }
33
+ const inventory = inventoryWeightedSurfaces(snapshot, source, candidatePool);
34
+ const viable = inventory.adapters
35
+ .filter(({ candidateCount, identifierMatchCount, candidatePoolMatchCount }) => candidateCount > 0 &&
36
+ (source.identifier === undefined || identifierMatchCount > 0) &&
37
+ (candidatePool.length === 0 || candidatePoolMatchCount > 0))
38
+ .sort((left, right) => right.identifierMatchCount - left.identifierMatchCount ||
39
+ right.candidatePoolMatchCount - left.candidatePoolMatchCount ||
40
+ right.candidateCount - left.candidateCount ||
41
+ compareCodeUnits(left.adapterId, right.adapterId));
42
+ if (viable.length === 1)
43
+ return viable[0].adapterId;
44
+ if (viable.length === 0)
45
+ throw new ServiceError('PROBABILITY_SURFACE_EMPTY', 'No weighted blocks matched this request', {
46
+ requestedAdapter: requested,
47
+ source,
48
+ candidatePool,
49
+ availableAdapters: inventory.adapters.filter(({ candidateCount }) => candidateCount > 0),
50
+ });
51
+ throw new ServiceError('PROBABILITY_ADAPTER_AMBIGUOUS', 'Several weighted adapters match this source; select one adapter or narrow the source by identifier or line', {
52
+ requestedAdapter: requested,
53
+ source,
54
+ matchingAdapters: viable,
55
+ });
56
+ }
20
57
  function analyzerState(engine) {
21
58
  let state = states.get(engine);
22
59
  if (state === undefined) {
@@ -649,12 +686,19 @@ export class ProbabilityAnalyzer {
649
686
  this.engine = engine;
650
687
  this.state = analyzerState(engine);
651
688
  }
652
- async scan(context, sourcePaths = []) {
689
+ async scan(context, sourcePaths = [], emptyScope = 'domain') {
653
690
  if (context.refresh === true)
654
691
  this.engine.invalidate(context.workspaceId);
655
692
  const workspace = this.engine.resolver.get(context.workspaceId, context.principal);
693
+ const selectedSourcePaths = sourcePaths.filter((sourcePath) => sourcePath !== undefined);
656
694
  return this.engine.scan(context.workspaceId, {
657
- patterns: probabilityDomainScanPatterns(workspace, sourcePaths.filter((sourcePath) => sourcePath !== undefined)),
695
+ patterns: selectedSourcePaths.length > 0
696
+ ? probabilityDomainScanPatterns(workspace, selectedSourcePaths)
697
+ : emptyScope === 'domain'
698
+ ? probabilityDomainScanPatterns(workspace)
699
+ : emptyScope === 'shared'
700
+ ? probabilitySharedDefinitionPatterns()
701
+ : [],
658
702
  }, context.principal, context.signal);
659
703
  }
660
704
  async verifyGameVersion(context, adapter) {
@@ -714,6 +758,14 @@ export class ProbabilityAnalyzer {
714
758
  return {
715
759
  workspaceId: context.workspaceId,
716
760
  workspaceIdentity: workspace.workspaceIdentity,
761
+ ...(typeof extra.sourceScope === 'string'
762
+ ? {
763
+ sourceScope: extra.sourceScope,
764
+ }
765
+ : {}),
766
+ ...(Array.isArray(extra.sourcePaths) && extra.sourcePaths.length > 0
767
+ ? { sourcePaths: extra.sourcePaths }
768
+ : {}),
717
769
  sourceRevision: snapshot.revision,
718
770
  sourceHash: surface.sourceHash,
719
771
  scenarioHash,
@@ -838,9 +890,11 @@ export class ProbabilityAnalyzer {
838
890
  return { adapters: probabilityAdapters(), artifacts: [], filesScanned: [] };
839
891
  if (customPoolManifest !== undefined && source !== undefined)
840
892
  throw new ServiceError('PROBABILITY_INPUT_AMBIGUOUS', 'Provide either source or customPoolManifest for inspection');
841
- if (customPoolManifest !== undefined && adapter !== 'custom_weighted_pool')
893
+ if (customPoolManifest !== undefined &&
894
+ adapter !== undefined &&
895
+ adapter !== 'custom_weighted_pool')
842
896
  throw new ServiceError('PROBABILITY_ADAPTER_MISMATCH', 'customPoolManifest inspection requires adapter custom_weighted_pool');
843
- const snapshot = await this.scan(context, [source?.path]);
897
+ const snapshot = await this.scan(context, [source?.path], source === undefined ? 'none' : source.inlineClausewitz === undefined ? 'domain' : 'shared');
844
898
  if (customPoolManifest !== undefined) {
845
899
  const surface = customSurface(customPoolManifest, snapshot.revision);
846
900
  const gameVersionVerification = await this.verifyGameVersion(context, 'custom_weighted_pool');
@@ -1025,20 +1079,31 @@ export class ProbabilityAnalyzer {
1025
1079
  const usingManifest = request.customPoolManifest !== undefined;
1026
1080
  if (usingManifest !== (request.source === undefined))
1027
1081
  throw new ServiceError('PROBABILITY_INPUT_AMBIGUOUS', 'Provide exactly one source or customPoolManifest');
1028
- if (usingManifest && request.adapter !== 'custom_weighted_pool')
1082
+ if (usingManifest &&
1083
+ request.adapter !== undefined &&
1084
+ request.adapter !== 'custom_weighted_pool')
1029
1085
  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');
1032
- const gameVersionVerification = await this.verifyGameVersion(request, request.adapter);
1033
- const snapshot = await this.scan(request, [request.source?.path]);
1086
+ const snapshot = await this.scan(request, [request.source?.path], usingManifest ? 'none' : request.source?.inlineClausewitz === undefined ? 'domain' : 'shared');
1087
+ const resolvedAdapter = usingManifest
1088
+ ? 'custom_weighted_pool'
1089
+ : resolveSourceBackedAdapter(snapshot, request.source, request.candidatePool ?? [], request.adapter);
1090
+ const gameVersionVerification = await this.verifyGameVersion(request, resolvedAdapter);
1034
1091
  const surface = usingManifest
1035
1092
  ? customSurface(request.customPoolManifest, snapshot.revision)
1036
- : discoverWeightedSurface(snapshot, request.adapter, request.source, request.candidatePool ?? []);
1093
+ : discoverWeightedSurface(snapshot, resolvedAdapter, request.source, request.candidatePool ?? []);
1037
1094
  const definitions = ClausewitzEvaluationDefinitions.build(snapshot);
1038
1095
  const metadata = this.metadata(request, snapshot, surface, request.scenarioSet, usingManifest
1039
1096
  ? request.customPoolManifest.candidates.map(({ id }) => id)
1040
1097
  : (request.candidatePool ?? []), gameVersionVerification, {
1041
1098
  operation,
1099
+ sourceScope: request.source?.path !== undefined
1100
+ ? 'file'
1101
+ : request.source?.inlineClausewitz !== undefined
1102
+ ? 'shared'
1103
+ : request.source !== undefined
1104
+ ? 'domain'
1105
+ : 'none',
1106
+ sourcePaths: request.source?.path === undefined ? [] : [request.source.path],
1042
1107
  horizonDays: request.horizonDays,
1043
1108
  requestedMetrics: request.metrics,
1044
1109
  acceptanceBands: request.acceptanceBands,
@@ -1167,7 +1232,7 @@ export class ProbabilityAnalyzer {
1167
1232
  }
1168
1233
  async sequence(request) {
1169
1234
  request.signal?.throwIfAborted();
1170
- const snapshot = await this.scan(request);
1235
+ const snapshot = await this.scan(request, [], 'none');
1171
1236
  const surface = customSurface(request.customPoolManifest, snapshot.revision);
1172
1237
  const gameVersionVerification = await this.verifyGameVersion(request, 'custom_weighted_pool');
1173
1238
  const metadata = this.metadata(request, snapshot, surface, request.scenarioSet, request.customPoolManifest.candidates.map(({ id }) => id), gameVersionVerification, {
@@ -1334,8 +1399,10 @@ export class ProbabilityAnalyzer {
1334
1399
  return this.store(stale, ['json'], false, request.signal, request.principal);
1335
1400
  }
1336
1401
  await this.verifyGameVersion(request, result.adapter.id);
1337
- const snapshot = await this.scan(request);
1338
- if (snapshot.revision !== result.metadata.sourceRevision) {
1402
+ const snapshot = result.metadata.sourceScope === undefined || result.metadata.sourceScope === 'none'
1403
+ ? undefined
1404
+ : await this.scan(request, result.metadata.sourcePaths ?? [], result.metadata.sourceScope === 'shared' ? 'shared' : 'domain');
1405
+ if (snapshot !== undefined && snapshot.revision !== result.metadata.sourceRevision) {
1339
1406
  const stale = {
1340
1407
  ...result,
1341
1408
  status: 'stale',
@@ -737,6 +737,8 @@ const probabilityMetadataSchema = z
737
737
  .object({
738
738
  workspaceId: z.string(),
739
739
  workspaceIdentity: z.string(),
740
+ sourceScope: z.enum(['file', 'shared', 'domain', 'none']).optional(),
741
+ sourcePaths: z.array(z.string()).max(100_000).optional(),
740
742
  sourceRevision: z.string(),
741
743
  sourceHash: z.string(),
742
744
  scenarioHash: z.string(),
@@ -4,12 +4,17 @@ function byId(values) {
4
4
  }
5
5
  function placementSignature(values) {
6
6
  return hashCanonical(values
7
- .map(({ folderId, x, y, xExpression, yExpression }) => ({
7
+ .map(({ folderId, x, y, xExpression, yExpression, layoutSize, layoutWidth, layoutHeight, layoutOffsetX, layoutOffsetY, }) => ({
8
8
  folderId,
9
9
  x: x ?? null,
10
10
  y: y ?? null,
11
11
  xExpression: xExpression ?? null,
12
12
  yExpression: yExpression ?? null,
13
+ layoutSize: layoutSize ?? null,
14
+ layoutWidth: layoutWidth ?? null,
15
+ layoutHeight: layoutHeight ?? null,
16
+ layoutOffsetX: layoutOffsetX ?? null,
17
+ layoutOffsetY: layoutOffsetY ?? null,
13
18
  }))
14
19
  .sort((left, right) => compareCodeUnits(left.folderId, right.folderId) ||
15
20
  (left.x ?? 0) - (right.x ?? 0) ||
@@ -155,7 +155,51 @@ function activeGridboxes(values) {
155
155
  .sort((left, right) => compareCodeUnits(left.folderId ?? '', right.folderId ?? '') ||
156
156
  compareCodeUnits(left.name, right.name));
157
157
  }
158
- function enrichPlacements(placements, edges, gridboxes, signal) {
158
+ function activeItemLayouts(values) {
159
+ const groups = new Map();
160
+ for (const layout of values) {
161
+ const key = `${layout.folderId}\0${layout.layoutSize}`;
162
+ const group = groups.get(key) ?? [];
163
+ group.push(layout);
164
+ groups.set(key, group);
165
+ }
166
+ return [...groups.values()]
167
+ .map((group) => group.sort((left, right) => right.loadOrder - left.loadOrder ||
168
+ compareCodeUnits(left.sourcePath, right.sourcePath) ||
169
+ left.location.start.offset - right.location.start.offset)[0])
170
+ .sort((left, right) => compareCodeUnits(left.folderId, right.folderId) ||
171
+ compareCodeUnits(left.layoutSize, right.layoutSize));
172
+ }
173
+ function activeYearMarkers(values) {
174
+ const groups = new Map();
175
+ for (const marker of values) {
176
+ const key = `${marker.folderId}\0${marker.name}`;
177
+ const group = groups.get(key) ?? [];
178
+ group.push(marker);
179
+ groups.set(key, group);
180
+ }
181
+ return [...groups.values()]
182
+ .map((group) => group.sort((left, right) => right.loadOrder - left.loadOrder ||
183
+ compareCodeUnits(left.sourcePath, right.sourcePath) ||
184
+ left.location.start.offset - right.location.start.offset)[0])
185
+ .sort((left, right) => compareCodeUnits(left.folderId, right.folderId) ||
186
+ left.year - right.year ||
187
+ compareCodeUnits(left.name, right.name));
188
+ }
189
+ function enrichPlacements(placements, edges, gridboxes, technologies, unlocks, itemLayouts, signal) {
190
+ const technologyById = new Map(technologies.map((technology) => [technology.id, technology]));
191
+ const unlockKindsByTechnology = new Map();
192
+ for (const unlock of unlocks) {
193
+ const kinds = unlockKindsByTechnology.get(unlock.technologyId) ?? new Set();
194
+ kinds.add(unlock.kind);
195
+ unlockKindsByTechnology.set(unlock.technologyId, kinds);
196
+ }
197
+ const layoutsByFolder = new Map();
198
+ for (const layout of itemLayouts) {
199
+ const layouts = layoutsByFolder.get(layout.folderId) ?? new Map();
200
+ layouts.set(layout.layoutSize, layout);
201
+ layoutsByFolder.set(layout.folderId, layouts);
202
+ }
159
203
  const placementsByTechnology = new Map();
160
204
  for (const placement of placements) {
161
205
  const group = placementsByTechnology.get(placement.technologyId) ?? [];
@@ -198,6 +242,15 @@ function enrichPlacements(placements, edges, gridboxes, signal) {
198
242
  : gridboxes.filter(({ name, folderId }) => name === expectedGridbox &&
199
243
  (folderId === undefined || folderId === placement.folderId));
200
244
  const gridbox = candidates[0];
245
+ const technology = technologyById.get(placement.technologyId);
246
+ const folderLayouts = layoutsByFolder.get(placement.folderId);
247
+ const unlockKinds = unlockKindsByTechnology.get(placement.technologyId) ?? new Set();
248
+ const unlocksEquipment = unlockKinds.has('equipment') || unlockKinds.has('equipment_module');
249
+ const layoutSize = technology?.layoutSize === 'small' ||
250
+ (folderLayouts?.has('small') === true && !unlocksEquipment)
251
+ ? 'small'
252
+ : 'large';
253
+ const itemLayout = folderLayouts?.get(layoutSize) ?? folderLayouts?.get('large');
201
254
  let pixelX;
202
255
  let pixelY;
203
256
  if (gridbox !== undefined &&
@@ -223,6 +276,12 @@ function enrichPlacements(placements, edges, gridboxes, signal) {
223
276
  ...(gridbox === undefined ? {} : { gridboxId: gridbox.id }),
224
277
  ...(pixelX === undefined ? {} : { pixelX }),
225
278
  ...(pixelY === undefined ? {} : { pixelY }),
279
+ layoutSize,
280
+ ...(itemLayout === undefined ? {} : { itemLayoutId: itemLayout.id }),
281
+ ...(itemLayout?.size.width === undefined ? {} : { layoutWidth: itemLayout.size.width }),
282
+ ...(itemLayout?.size.height === undefined ? {} : { layoutHeight: itemLayout.size.height }),
283
+ ...(itemLayout?.position.x === undefined ? {} : { layoutOffsetX: itemLayout.position.x }),
284
+ ...(itemLayout?.position.y === undefined ? {} : { layoutOffsetY: itemLayout.position.y }),
226
285
  geometryStatus: pixelX !== undefined && pixelY !== undefined
227
286
  ? 'source_pixel'
228
287
  : placement.x !== undefined && placement.y !== undefined
@@ -1093,6 +1152,8 @@ export function buildTechnologyGraph(snapshot, options) {
1093
1152
  const selectedFolders = activeDefinitions(fragments.flatMap(({ folders }) => folders));
1094
1153
  const folders = localiseFolders(selectedFolders.active, snapshot.index);
1095
1154
  const gridboxes = activeGridboxes(fragments.flatMap(({ gridboxes }) => gridboxes));
1155
+ const itemLayouts = activeItemLayouts(fragments.flatMap(({ itemLayouts }) => itemLayouts));
1156
+ const yearMarkers = activeYearMarkers(fragments.flatMap(({ yearMarkers }) => yearMarkers));
1096
1157
  const categories = activeCategories(fragments.flatMap(({ categories }) => categories));
1097
1158
  const selectedDoctrines = activeDefinitions(fragments.flatMap(({ doctrineDefinitions }) => doctrineDefinitions));
1098
1159
  const doctrineDefinitions = localiseDoctrines(selectedDoctrines.active, snapshot.index, assets);
@@ -1102,8 +1163,8 @@ export function buildTechnologyGraph(snapshot, options) {
1102
1163
  const unlockTargets = selectedTargets.active;
1103
1164
  const basePlacements = selectOwnedRecords(fragments.flatMap(({ placements }) => placements), ({ technologyId }) => technologyId, ({ location }) => location, technologyById).sort((left, right) => compareCodeUnits(left.id, right.id));
1104
1165
  const edges = selectOwnedRecords(fragments.flatMap(({ edges }) => edges), ({ from }) => from, ({ location }) => location, technologyById).sort((left, right) => compareCodeUnits(left.id, right.id));
1105
- const placements = enrichPlacements(basePlacements, edges, gridboxes, options.signal);
1106
1166
  const unlocks = resolveUnlocks(selectOwnedRecords(fragments.flatMap(({ unlocks }) => unlocks), ({ technologyId }) => technologyId, ({ location }) => location, technologyById).sort((left, right) => compareCodeUnits(left.id, right.id)), unlockTargets, snapshot.complete);
1167
+ const placements = enrichPlacements(basePlacements, edges, gridboxes, technologies, unlocks, itemLayouts, options.signal);
1107
1168
  const unresolved = fragments.flatMap(({ unresolved }) => unresolved);
1108
1169
  const helperCalls = fragments.flatMap(({ helperCalls }) => helperCalls);
1109
1170
  const externalReferences = expandHelperReferences(fragments.flatMap(({ externalReferences }) => externalReferences), helperCalls, unresolved, options.analysisMode ?? 'full', options.signal);
@@ -1167,6 +1228,8 @@ export function buildTechnologyGraph(snapshot, options) {
1167
1228
  folders,
1168
1229
  placements,
1169
1230
  gridboxes,
1231
+ itemLayouts,
1232
+ yearMarkers,
1170
1233
  edges,
1171
1234
  categories,
1172
1235
  doctrineDefinitions,
@@ -1183,6 +1246,8 @@ export function buildTechnologyGraph(snapshot, options) {
1183
1246
  folderCount: folders.length,
1184
1247
  placementCount: placements.length,
1185
1248
  gridboxCount: gridboxes.length,
1249
+ itemLayoutCount: itemLayouts.length,
1250
+ yearMarkerCount: yearMarkers.length,
1186
1251
  prerequisiteCount: edges.filter(({ kind }) => kind === 'prerequisite').length,
1187
1252
  exclusiveCount: edges.filter(({ kind }) => kind === 'exclusive').length,
1188
1253
  categoryCount: categories.length,
@@ -1,2 +1,2 @@
1
- export const TECHNOLOGY_GRAPH_SCHEMA_VERSION = 1;
1
+ export const TECHNOLOGY_GRAPH_SCHEMA_VERSION = 2;
2
2
  export const TECHNOLOGY_PARSER_VERSION = 'clausewitz-cst.v1';
@@ -15,6 +15,8 @@ function technologyRecordCount(graph) {
15
15
  graph.categories.length +
16
16
  graph.placements.length +
17
17
  graph.gridboxes.length +
18
+ graph.itemLayouts.length +
19
+ graph.yearMarkers.length +
18
20
  graph.edges.length +
19
21
  graph.unlocks.length +
20
22
  graph.unlockTargets.length +
@@ -62,6 +64,8 @@ export function technologyScanReport(graph, fullGraphRecordLimit = TECHNOLOGY_SC
62
64
  categories: graph.categories.length,
63
65
  placements: graph.placements.length,
64
66
  gridboxes: graph.gridboxes.length,
67
+ itemLayouts: graph.itemLayouts.length,
68
+ yearMarkers: graph.yearMarkers.length,
65
69
  edges: graph.edges.length,
66
70
  unlocks: graph.unlocks.length,
67
71
  unlockTargets: graph.unlockTargets.length,