hoi4-agent-tools 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
  3. package/dist/hoi4_agent_tools/core/configuration.js +11 -0
  4. package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
  5. package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
  6. package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
  7. package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
  8. package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
  9. package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
  10. package/dist/hoi4_agent_tools/core/transactions.js +23 -4
  11. package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
  12. package/dist/hoi4_agent_tools/core/workspace.js +42 -6
  13. package/dist/hoi4_agent_tools/focus/drift.js +34 -0
  14. package/dist/hoi4_agent_tools/focus/service.js +9 -3
  15. package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
  16. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  17. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  18. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  19. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  20. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  21. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  22. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  23. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  24. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  25. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  26. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  27. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  28. package/dist/hoi4_agent_tools/map/model.js +17 -3
  29. package/dist/hoi4_agent_tools/map/operations.js +284 -14
  30. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  31. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  32. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  33. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  34. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  35. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  36. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  37. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  38. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  39. package/dist/hoi4_agent_tools/map/reference-preparation.js +142 -6
  40. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  41. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  42. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  43. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  44. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  45. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  46. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  47. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  48. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  49. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  50. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  51. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  52. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  53. package/dist/hoi4_agent_tools/map/render.js +36 -0
  54. package/dist/hoi4_agent_tools/map/service.js +206 -16
  55. package/dist/hoi4_agent_tools/map/tool-operations.js +249 -3
  56. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  57. package/dist/hoi4_agent_tools/map/water.js +227 -0
  58. package/dist/hoi4_agent_tools/map/world-context.js +47 -1
  59. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  60. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  61. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  62. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  63. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  64. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  65. package/dist/hoi4_agent_tools/map/world.js +562 -163
  66. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  67. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  68. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  69. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  70. package/dist/hoi4_agent_tools/schemas/map.js +280 -15
  71. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  72. package/dist/hoi4_agent_tools/version.js +1 -1
  73. package/docs/configuration.md +17 -14
  74. package/docs/focus.md +6 -0
  75. package/docs/map.md +292 -15
  76. package/docs/tools.md +4 -4
  77. package/docs/troubleshooting.md +11 -11
  78. package/npm-shrinkwrap.json +109 -91
  79. package/package.json +3 -3
  80. package/schemas/configuration.schema.json +65 -58
  81. package/schemas/continuous-focus-palette.schema.json +1 -1
  82. package/schemas/custom-weighted-pool.schema.json +1 -1
  83. package/schemas/decision-inspect-input.schema.json +1 -1
  84. package/schemas/error-log-input.schema.json +1 -1
  85. package/schemas/event-feature-manifest.schema.json +1 -1
  86. package/schemas/focus-plan.schema.json +1 -1
  87. package/schemas/focus-planning-sidecar.schema.json +1 -1
  88. package/schemas/geography-model.schema.json +1148 -0
  89. package/schemas/gui-animation-source.schema.json +1 -1
  90. package/schemas/gui-helper.schema.json +5 -11
  91. package/schemas/gui-scenario.schema.json +1 -1
  92. package/schemas/helper-expansion-request.schema.json +1 -1
  93. package/schemas/helper-expansion-summary.schema.json +1 -1
  94. package/schemas/impact-inspect-input.schema.json +1 -1
  95. package/schemas/map-operation.schema.json +2325 -292
  96. package/schemas/mechanic-test-input.schema.json +1 -1
  97. package/schemas/mod-index-input.schema.json +1 -1
  98. package/schemas/operation-result.schema.json +1 -1
  99. package/schemas/package-check-input.schema.json +1 -1
  100. package/schemas/package-manifest.schema.json +1 -1
  101. package/schemas/probability-analysis-result.schema.json +25 -7
  102. package/schemas/probability-compare-input.schema.json +1 -1
  103. package/schemas/probability-evaluate-input.schema.json +1 -1
  104. package/schemas/probability-inspect-input.schema.json +1 -1
  105. package/schemas/probability-render-input.schema.json +1 -1
  106. package/schemas/probability-scenario-set.schema.json +1 -1
  107. package/schemas/probability-sequence-input.schema.json +1 -1
  108. package/schemas/probability-simulate-input.schema.json +1 -1
  109. package/schemas/probability-sweep-input.schema.json +1 -1
  110. package/schemas/reference-context-input.schema.json +1 -1
  111. package/schemas/reference-read-input.schema.json +1 -1
  112. package/schemas/reference-search-input.schema.json +1 -1
  113. package/schemas/reference-sheets.schema.json +267 -0
  114. package/schemas/scenario-suite.schema.json +1 -1
  115. package/schemas/scenario-test-input.schema.json +1 -1
  116. package/schemas/script-validate-input.schema.json +1 -1
  117. package/schemas/shared-scenario.schema.json +1 -1
  118. package/schemas/source-lookup-input.schema.json +1 -1
  119. package/server.json +2 -2
@@ -16,10 +16,49 @@ import { MAP_PROVINCE_GEOMETRY_SELECTOR_LIMIT, MAP_SELECTED_OFFSET_BYTES, MAP_SE
16
16
  import { defaultMapSelectorValue, MapWorkspaceIndex, } from './model.js';
17
17
  import { planMapOperationsAsync, } from './operations.js';
18
18
  import { prepareReferenceWorld } from './reference-preparation.js';
19
+ import { buildImportArtifacts, buildImportReport, } from './reference-import.js';
20
+ import { buildGeographyArtifacts } from './geography/preview.js';
21
+ import { operationHasGeography, prepareGeography, } from './geography/preparation.js';
19
22
  import { WORLD_OUTPUT_MANIFEST } from './world-outputs.js';
20
23
  import { renderMap, renderMapDiff, } from './render.js';
21
24
  import { validateMapAsync } from './validation.js';
22
25
  import { buildWorldPreviewArtifacts, generatedWorldOperationIds } from './world-preview.js';
26
+ /**
27
+ * Evidence of an imported reference: registered sheets, an exact or frame-fitted picture, or a
28
+ * geography model beside the picture. Plain pictures keep their previous output.
29
+ */
30
+ function importEvidence(operation, loaded, geography) {
31
+ const reference = operation.reference;
32
+ if (reference === undefined ||
33
+ (loaded.registration === undefined &&
34
+ geography === undefined &&
35
+ reference.fit !== 'frame' &&
36
+ reference.paletteMatch !== 'exact'))
37
+ return undefined;
38
+ const seed = operation.seed ?? 1;
39
+ const built = buildImportReport({
40
+ operationId: operation.id,
41
+ surface: loaded.prepared.surface,
42
+ seed,
43
+ ...(loaded.registration === undefined ? {} : { registration: loaded.registration }),
44
+ ...(geography === undefined ? {} : { geography: geography.geography }),
45
+ ...(reference.agreement === undefined ? {} : { thresholds: reference.agreement }),
46
+ });
47
+ return {
48
+ diagnostics: built.diagnostics,
49
+ prepared: {
50
+ report: built.report,
51
+ surface: loaded.prepared.surface,
52
+ ...(loaded.previewBytes === undefined ? {} : { registered: loaded.previewBytes }),
53
+ ...(geography === undefined ? {} : { geography: geography.geography }),
54
+ seed,
55
+ sourceHashes: Object.fromEntries([...loaded.images.map(({ source }) => source), ...loaded.documents].map((evidence) => [
56
+ evidence.displayPath,
57
+ evidence.sha256,
58
+ ])),
59
+ },
60
+ };
61
+ }
23
62
  function compareNumbers(left, right) {
24
63
  return left - right;
25
64
  }
@@ -404,16 +443,30 @@ function sourceTextPatterns(roots) {
404
443
  }),
405
444
  ];
406
445
  }
407
- function provinceBitmapPatterns(defaultMaps, mapRoots) {
446
+ const PROVINCE_BITMAP_SELECTORS = [
447
+ ['provinces', 'provinces.bmp'],
448
+ ['heightmap', 'heightmap.bmp'],
449
+ ];
450
+ // Border quality (opt-in) also reads the river and terrain bitmaps in the main snapshot.
451
+ const BORDER_BITMAP_SELECTORS = [
452
+ ['provinces', 'provinces.bmp'],
453
+ ['heightmap', 'heightmap.bmp'],
454
+ ['rivers', 'rivers.bmp'],
455
+ ['terrain', 'terrain.bmp'],
456
+ ];
457
+ const APPEARANCE_BITMAP_SELECTORS = [
458
+ ['terrain', 'terrain.bmp'],
459
+ ['rivers', 'rivers.bmp'],
460
+ ['tree_definition', 'trees.bmp'],
461
+ ];
462
+ function provinceBitmapPatterns(defaultMaps, mapRoots, selectors = PROVINCE_BITMAP_SELECTORS) {
408
463
  const activeByPath = new Map(defaultMaps
409
464
  .filter(({ shadowedBy }) => shadowedBy === undefined)
410
465
  .map((file) => [file.relativePath.replaceAll('\\', '/').toLowerCase(), file]));
411
- // The province bitmap, and the heightmap that gives generated positions their height.
466
+ // The province bitmap, and the heightmap that gives generated positions their height. Border
467
+ // naturalness (opt-in) also reads the river and terrain bitmaps.
412
468
  const names = new Set();
413
- for (const [selector, fallback] of [
414
- ['provinces', 'provinces.bmp'],
415
- ['heightmap', 'heightmap.bmp'],
416
- ]) {
469
+ for (const [selector, fallback] of selectors) {
417
470
  let activeName = fallback;
418
471
  for (const root of mapRoots) {
419
472
  const relativePath = `${normalizeSourceRoot(root)}/default.map`.toLowerCase();
@@ -572,7 +625,7 @@ export class AgentNudger {
572
625
  this.scanner = this.engine.scanner;
573
626
  this.transactions = this.engine.transactions;
574
627
  }
575
- async scan(workspaceId, principal, signal, includeExternalScriptReferences = false, includeWorldContext = false) {
628
+ async scan(workspaceId, principal, signal, includeExternalScriptReferences = false, includeWorldContext = false, includeAppearance = false) {
576
629
  signal?.throwIfAborted();
577
630
  const workspace = this.resolver.get(workspaceId, principal);
578
631
  const sourceRoots = {
@@ -581,7 +634,7 @@ export class AgentNudger {
581
634
  localisation: workspace.registration.roots.localisation,
582
635
  };
583
636
  const selectorSnapshot = await this.engine.scan(workspaceId, { patterns: defaultMapPatterns(sourceRoots) }, principal, signal);
584
- const bitmapPatterns = provinceBitmapPatterns(selectorSnapshot.files, sourceRoots.map);
637
+ const bitmapPatterns = provinceBitmapPatterns(selectorSnapshot.files, sourceRoots.map, includeAppearance ? BORDER_BITMAP_SELECTORS : PROVINCE_BITMAP_SELECTORS);
585
638
  const contentSnapshot = await this.engine.scan(workspaceId, { patterns: [...sourceTextPatterns(sourceRoots), ...bitmapPatterns] }, principal, signal);
586
639
  const locatorSnapshot = await this.engine.scan(workspaceId, { patterns: ['**/*.asset'], rootKinds: ['mod', 'fixture'] }, principal, signal);
587
640
  const externalScriptSnapshot = includeExternalScriptReferences
@@ -614,12 +667,30 @@ export class AgentNudger {
614
667
  ],
615
668
  }, principal, signal)
616
669
  : undefined;
670
+ // Appearance validation reads the bitmaps and colour maps the mod itself ships.
671
+ const appearanceSnapshot = includeAppearance
672
+ ? await this.engine.scan(workspaceId, {
673
+ rootKinds: ['mod', 'fixture'],
674
+ patterns: [
675
+ ...provinceBitmapPatterns(selectorSnapshot.files, sourceRoots.map, APPEARANCE_BITMAP_SELECTORS),
676
+ ...sourceRoots.map.flatMap((root) => {
677
+ const normalized = normalizeSourceRoot(root);
678
+ return [
679
+ `${normalized}/cities.bmp`,
680
+ `${normalized}/world_normal.bmp`,
681
+ `${normalized}/terrain/colormap_*.dds`,
682
+ ];
683
+ }),
684
+ ],
685
+ }, principal, signal)
686
+ : undefined;
617
687
  signal?.throwIfAborted();
618
688
  const files = [
619
689
  ...contentSnapshot.files,
620
690
  ...locatorSnapshot.files,
621
691
  ...(externalScriptSnapshot?.files ?? []),
622
692
  ...(worldContextSnapshot?.files ?? []).filter((file) => !contentSnapshot.files.some((existing) => existing.displayPath === file.displayPath)),
693
+ ...(appearanceSnapshot?.files ?? []).filter((file) => ![...contentSnapshot.files, ...(worldContextSnapshot?.files ?? [])].some((existing) => existing.displayPath === file.displayPath)),
623
694
  ...(generatedOutputsSnapshot?.files ?? []).filter((file) => ![...contentSnapshot.files, ...(worldContextSnapshot?.files ?? [])].some((existing) => existing.displayPath === file.displayPath)),
624
695
  ].sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
625
696
  const revision = hashCanonical(files.map(({ displayPath, loadOrder, sha256 }) => ({ displayPath, loadOrder, sha256 })));
@@ -630,8 +701,8 @@ export class AgentNudger {
630
701
  index: MapWorkspaceIndex.build(files, sourceRoots, SymbolIndex.build(files)),
631
702
  };
632
703
  }
633
- async validate(workspaceId, principal, signal, scripts = false) {
634
- const snapshot = await this.scan(workspaceId, principal, signal, scripts);
704
+ async validate(workspaceId, principal, signal, scripts = false, includeAppearance = false) {
705
+ const snapshot = await this.scan(workspaceId, principal, signal, scripts, false, includeAppearance);
635
706
  const validation = await validateMapAsync(snapshot.index, signal === undefined ? {} : { signal });
636
707
  if (!scripts)
637
708
  return { snapshot, validation };
@@ -663,6 +734,7 @@ export class AgentNudger {
663
734
  'remapReferences' in operation), operations.some((operation) => operation.kind === 'create_world'));
664
735
  const referenceWorlds = new Map();
665
736
  const referenceImages = [];
737
+ const referenceLoads = new Map();
666
738
  for (const operation of operations) {
667
739
  if (operation.kind !== 'create_world' || operation.reference === undefined)
668
740
  continue;
@@ -670,18 +742,51 @@ export class AgentNudger {
670
742
  referenceWorlds.set(operation.id, loaded.prepared);
671
743
  referenceImages.push({
672
744
  operationId: operation.id,
673
- bytes: loaded.images[0].bytes,
745
+ // A registered drawing is shown as it lies in the world frame, not as drawn.
746
+ bytes: loaded.previewBytes ?? loaded.images[0].bytes,
674
747
  source: loaded.images[0].source,
675
748
  });
676
- for (const image of loaded.images) {
749
+ referenceLoads.set(operation.id, loaded);
750
+ for (const evidence of [...loaded.images.map(({ source }) => source), ...loaded.documents]) {
677
751
  snapshot.referenceSources ??= [];
678
- const previous = snapshot.referenceSources.findIndex((file) => file.displayPath === image.source.displayPath);
752
+ const previous = snapshot.referenceSources.findIndex((file) => file.displayPath === evidence.displayPath);
679
753
  if (previous < 0)
680
- snapshot.referenceSources.push(image.source);
754
+ snapshot.referenceSources.push(evidence);
681
755
  else
682
- snapshot.referenceSources[previous] = image.source;
756
+ snapshot.referenceSources[previous] = evidence;
683
757
  }
684
758
  }
759
+ const geographyModels = new Map();
760
+ for (const operation of operations) {
761
+ if (operation.kind !== 'create_world' || !operationHasGeography(operation))
762
+ continue;
763
+ const geography = await prepareGeography(this.resolver, workspaceId, operation, principal, signal);
764
+ geographyModels.set(operation.id, geography);
765
+ if (geography.source.kind === 'file') {
766
+ const evidence = geography.source.evidence;
767
+ snapshot.referenceSources ??= [];
768
+ const previous = snapshot.referenceSources.findIndex((file) => file.displayPath === evidence.displayPath);
769
+ if (previous < 0)
770
+ snapshot.referenceSources.push(evidence);
771
+ else
772
+ snapshot.referenceSources[previous] = evidence;
773
+ }
774
+ }
775
+ // An imported reference reports what it supplied and how far the model agrees with it.
776
+ const imports = [];
777
+ const importDiagnostics = [];
778
+ for (const operation of operations) {
779
+ if (operation.kind !== 'create_world')
780
+ continue;
781
+ const loaded = referenceLoads.get(operation.id);
782
+ if (loaded === undefined)
783
+ continue;
784
+ const built = importEvidence(operation, loaded, geographyModels.get(operation.id));
785
+ if (built === undefined)
786
+ continue;
787
+ imports.push(built.prepared);
788
+ importDiagnostics.push(...built.diagnostics);
789
+ }
685
790
  snapshot.files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
686
791
  snapshot.revision = hashCanonical(mapSourceEvidence(snapshot).map(({ displayPath, loadOrder, sha256 }) => ({
687
792
  displayPath,
@@ -690,15 +795,24 @@ export class AgentNudger {
690
795
  })));
691
796
  const plan = await planMapOperationsAsync(snapshot.index, operations, signal, {
692
797
  referenceWorlds,
798
+ geographyModels,
693
799
  });
800
+ // Model diagnostics (including warnings of a model that is otherwise usable) travel with the plan.
801
+ for (const geography of geographyModels.values())
802
+ plan.diagnostics.push(...geography.diagnostics);
803
+ plan.diagnostics.push(...importDiagnostics);
694
804
  const newWorld = operations.some(({ kind }) => kind === 'create_world');
695
805
  const freshWorld = newWorld && snapshot.index.provinceBitmapFile?.rootKind !== 'mod';
696
806
  const baselineValidation = freshWorld
697
807
  ? { passed: true, diagnostics: [], checks: [] }
698
808
  : await validateMapAsync(snapshot.index, signal === undefined ? {} : { signal });
699
809
  // A new world has nothing to compare its bitmap format and pixels with.
810
+ const worldOperation = operations.find((operation) => operation.kind === 'create_world');
700
811
  const rawValidation = await validateMapAsync(plan.finalIndex, {
701
812
  ...(newWorld ? {} : { baseline: snapshot.index }),
813
+ ...(worldOperation?.kind === 'create_world' && worldOperation.edge !== undefined
814
+ ? { edgeMode: worldOperation.edge.mode ?? 'sea-band' }
815
+ : {}),
702
816
  ...(signal === undefined ? {} : { signal }),
703
817
  ...(plan.expectedChangedBounds === undefined
704
818
  ? {}
@@ -718,6 +832,8 @@ export class AgentNudger {
718
832
  plan,
719
833
  validation: validationWithPlan(attributedValidation, plan),
720
834
  referenceImages,
835
+ geography: [...geographyModels.values()],
836
+ imports,
721
837
  };
722
838
  }
723
839
  async plan(input) {
@@ -796,7 +912,7 @@ export class AgentNudger {
796
912
  return { transaction, plan, validation };
797
913
  }
798
914
  async renderAndStore(workspaceId, options = {}) {
799
- const snapshot = await this.scan(workspaceId, options.principal, options.signal);
915
+ const snapshot = await this.scan(workspaceId, options.principal, options.signal, false, false, options.overlays?.includes('border-quality') === true);
800
916
  const rawBundle = await renderMap(snapshot.index, {
801
917
  ...options,
802
918
  budget: options.budget ?? new RenderBudget(),
@@ -892,6 +1008,61 @@ export class AgentNudger {
892
1008
  artifact.name.endsWith('.chunks.json'))))),
893
1009
  };
894
1010
  }
1011
+ async geographyArtifacts(prepared, signal, budget) {
1012
+ const writes = [];
1013
+ for (const [ordinal, geography] of prepared.geography.entries())
1014
+ writes.push(...(await buildGeographyArtifacts({
1015
+ prepared: geography,
1016
+ ordinal,
1017
+ sourceHashes: geography.source.kind === 'file'
1018
+ ? { [geography.source.evidence.displayPath]: geography.source.sha256 }
1019
+ : { 'inline:geography': geography.source.sha256 },
1020
+ ...(budget === undefined ? {} : { budget }),
1021
+ ...(signal === undefined ? {} : { signal }),
1022
+ })));
1023
+ return writes;
1024
+ }
1025
+ /**
1026
+ * Validate and draw the geography model of every `create_world` operation without scanning or
1027
+ * planning the map: the review step before any synthesis. Nothing in the mod changes.
1028
+ */
1029
+ async previewGeographyModels(input) {
1030
+ input.signal?.throwIfAborted();
1031
+ const geography = [];
1032
+ const imports = [];
1033
+ const importDiagnostics = [];
1034
+ const referenceFiles = [];
1035
+ for (const operation of input.operations) {
1036
+ if (operation.kind !== 'create_world' || !operationHasGeography(operation))
1037
+ continue;
1038
+ const prepared = await prepareGeography(this.resolver, input.workspaceId, operation, input.principal, input.signal);
1039
+ geography.push(prepared);
1040
+ // The drawing beside the model is registered and compared too, without planning a map.
1041
+ if (operation.reference === undefined)
1042
+ continue;
1043
+ const loaded = await prepareReferenceWorld(this.resolver, input.workspaceId, operation, input.principal, input.signal);
1044
+ referenceFiles.push(...[...loaded.images.map(({ source }) => source), ...loaded.documents].map(({ displayPath }) => displayPath));
1045
+ const built = importEvidence(operation, loaded, prepared);
1046
+ if (built === undefined)
1047
+ continue;
1048
+ imports.push(built.prepared);
1049
+ importDiagnostics.push(...built.diagnostics);
1050
+ }
1051
+ const writes = await this.geographyArtifacts({ geography }, input.signal, new RenderBudget());
1052
+ for (const [ordinal, imported] of imports.entries())
1053
+ writes.push(...(await buildImportArtifacts(imported, ordinal)));
1054
+ const artifacts = await this.artifacts.withAtomicChunkedWrites(this.resolver.get(input.workspaceId, input.principal), writes, (stored) => Promise.resolve([...stored]), input.signal);
1055
+ return {
1056
+ geography,
1057
+ imports: imports.map(({ report }) => report),
1058
+ importDiagnostics,
1059
+ artifacts,
1060
+ filesScanned: [
1061
+ ...geography.flatMap(({ source }) => source.kind === 'file' ? [source.evidence.displayPath] : []),
1062
+ ...referenceFiles,
1063
+ ],
1064
+ };
1065
+ }
895
1066
  async renderPreparedDiffAndStore(prepared, operations, options) {
896
1067
  const budget = new RenderBudget();
897
1068
  const { snapshot, plan, validation } = prepared;
@@ -954,6 +1125,7 @@ export class AgentNudger {
954
1125
  };
955
1126
  const artifacts = await this.artifacts.withAtomicChunkedWrites(this.resolver.get(snapshot.workspaceId, options.principal), [
956
1127
  report,
1128
+ ...(await this.geographyArtifacts(prepared, options.signal, budget)),
957
1129
  {
958
1130
  name: 'map-generation-failure.json',
959
1131
  mimeType: 'application/json',
@@ -1122,6 +1294,24 @@ export class AgentNudger {
1122
1294
  content: beforeBundle.html,
1123
1295
  provenance: comparisonProvenance('before'),
1124
1296
  });
1297
+ writes.push(...(await this.geographyArtifacts(prepared, options.signal, budget)));
1298
+ for (const [ordinal, imported] of prepared.imports.entries())
1299
+ writes.push(...(await buildImportArtifacts(imported, ordinal)));
1300
+ if (plan.worldOutputs !== undefined)
1301
+ writes.push({
1302
+ name: 'map-world-outputs.json',
1303
+ mimeType: 'application/json',
1304
+ content: `${canonicalJson({ schemaVersion: 1, worlds: plan.worldOutputs.map(({ realms: _realms, ...outputs }) => outputs) })}\n`,
1305
+ provenance,
1306
+ });
1307
+ const realmWorlds = (plan.worldOutputs ?? []).flatMap(({ operationId, realms }) => realms === undefined ? [] : [{ operationId, ...realms }]);
1308
+ if (realmWorlds.length > 0)
1309
+ writes.push({
1310
+ name: 'map-world-realms.json',
1311
+ mimeType: 'application/json',
1312
+ content: `${canonicalJson({ schemaVersion: 1, worlds: realmWorlds })}\n`,
1313
+ provenance,
1314
+ });
1125
1315
  const generatedWorlds = generatedWorldOperationIds(plan);
1126
1316
  if (generatedWorlds.size > 0)
1127
1317
  writes.push(...(await buildWorldPreviewArtifacts({
@@ -1,12 +1,18 @@
1
1
  import { boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
2
2
  import { canonicalJson, hashCanonical } from '../core/canonical.js';
3
3
  import { diagnosticSeverityCounts } from '../core/diagnostic-rollup.js';
4
+ import { sortDiagnostics } from '../core/diagnostics.js';
4
5
  import { autonomousResultArtifacts, } from '../core/transaction-execution.js';
5
6
  import { emptyServiceResult, ServiceError } from '../core/result.js';
6
7
  import { setInlineFilesScanned } from '../core/operation-result.js';
7
8
  import { PACKAGE_VERSION } from '../version.js';
8
- import { allocateMapIdentifiersAsync, } from './operations.js';
9
+ import { allocateMapIdentifiersAsync, WORLD_OUTPUT_PATH_SAMPLE, } from './operations.js';
10
+ import { borderQualityDiagnostics } from './border-quality.js';
11
+ import { measureIndexBorderNaturalness, summarizeBorderQuality, } from './border-quality-input.js';
9
12
  import { buildMapCatalog, lookupMapCoordinate, searchMapCatalog } from './catalog.js';
13
+ import { GEOGRAPHY_FEATURE_KINDS } from './geography/model.js';
14
+ import { operationHasGeography } from './geography/preparation.js';
15
+ import { unsupportedGeographyParts } from './relief/index.js';
10
16
  import { renderMap } from './render.js';
11
17
  import { exportProvinceGeometryRowRuns } from './service.js';
12
18
  import { generatedWorldOperationIds } from './world-preview.js';
@@ -241,6 +247,35 @@ export function normalizeMapRenderRequest(input) {
241
247
  export function normalizeMapRewriteRequest(input) {
242
248
  return input;
243
249
  }
250
+ /** The figures of an import report an agent needs inline; the full report is an artifact. */
251
+ function importSummary(report) {
252
+ return {
253
+ operationId: report.operationId,
254
+ width: report.width,
255
+ height: report.height,
256
+ landPx: report.classes.landPx,
257
+ seaPx: report.classes.seaPx,
258
+ lakePx: report.classes.lakePx,
259
+ ...(report.registration === undefined
260
+ ? {}
261
+ : {
262
+ sheets: report.registration.sheets.length,
263
+ hiddenSheets: report.registration.sheets
264
+ .filter(({ pixels }) => pixels === 0)
265
+ .map(({ id }) => id)
266
+ .slice(0, 16),
267
+ uncoveredPx: report.registration.uncoveredPx,
268
+ }),
269
+ ...(report.agreement === undefined
270
+ ? {}
271
+ : {
272
+ landIou: report.agreement.landIou,
273
+ drawingOnlyLandPx: report.agreement.drawingOnlyLandPx,
274
+ modelOnlyLandPx: report.agreement.modelOnlyLandPx,
275
+ misplacedFeatures: report.agreement.featureCount,
276
+ }),
277
+ };
278
+ }
244
279
  export async function prepareMapRewrite(nudger, input, context) {
245
280
  await context.progress(0, 5, 'Planning map operations and visual diff');
246
281
  const planned = await nudger.planRewriteWithDiff({
@@ -266,6 +301,7 @@ export async function prepareMapRewrite(nudger, input, context) {
266
301
  semanticDiff: compactSemantic.semantic,
267
302
  semanticDiffCounts: compactSemantic.counts,
268
303
  semanticDiffTruncated: compactSemantic.truncated,
304
+ ...worldOutputsData(planned.plan),
269
305
  }),
270
306
  filesScanned: planned.filesScanned,
271
307
  planBlockers: planned.plan.blockers
@@ -274,6 +310,44 @@ export async function prepareMapRewrite(nudger, input, context) {
274
310
  }),
275
311
  };
276
312
  }
313
+ /** Counts and sorted samples of the managed outputs each generated world writes, retires and releases. */
314
+ function worldOutputsData(plan) {
315
+ if (plan.worldOutputs === undefined)
316
+ return {};
317
+ const sample = (paths) => paths.slice(0, WORLD_OUTPUT_PATH_SAMPLE);
318
+ const realms = plan.worldOutputs.flatMap(({ operationId, realms: placed }) => placed === undefined
319
+ ? []
320
+ : [
321
+ {
322
+ operationId,
323
+ countries: placed.countries.slice(0, 100).map((country) => ({
324
+ tag: country.tag,
325
+ capitalState: country.capitalState,
326
+ capitalProvince: country.capitalProvince,
327
+ states: country.states.length,
328
+ provinces: country.provinces,
329
+ landShare: country.landShare,
330
+ targetShare: country.targetShare,
331
+ })),
332
+ countryCount: placed.countries.length,
333
+ unownedStates: placed.unownedStates.slice(0, WORLD_OUTPUT_PATH_SAMPLE),
334
+ unownedStateCount: placed.unownedStates.length,
335
+ },
336
+ ]);
337
+ return {
338
+ ...(realms.length === 0 ? {} : { realms: realms.slice(0, 16) }),
339
+ outputs: plan.worldOutputs.slice(0, 16).map((world) => ({
340
+ operationId: world.operationId,
341
+ written: world.written.length,
342
+ retired: world.retired.length,
343
+ released: world.released.length,
344
+ retiredFiles: sample(world.retired),
345
+ releasedFiles: sample(world.released),
346
+ truncated: world.retired.length > WORLD_OUTPUT_PATH_SAMPLE ||
347
+ world.released.length > WORLD_OUTPUT_PATH_SAMPLE,
348
+ })),
349
+ };
350
+ }
277
351
  export function completeMapRewrite(workspaceId, execution, recipe) {
278
352
  const parsed = mapRewriteRecipeSchema.parse(recipe);
279
353
  const transaction = execution.transaction;
@@ -308,8 +382,131 @@ export function completeMapRewrite(workspaceId, execution, recipe) {
308
382
  ].slice(0, 100);
309
383
  return result;
310
384
  }
385
+ const EMPTY_SEMANTIC_DIFF = {
386
+ definitions: [],
387
+ stateMembership: [],
388
+ regionMembership: [],
389
+ states: [],
390
+ ports: [],
391
+ buildingPositions: [],
392
+ unitPositions: [],
393
+ weatherPositions: [],
394
+ entityLocators: [],
395
+ supplyNodes: [],
396
+ railways: [],
397
+ adjacencies: [],
398
+ normalAdjacencies: [],
399
+ supplyNodesChanged: false,
400
+ railwaysChanged: false,
401
+ adjacenciesChanged: false,
402
+ normalAdjacenciesChanged: false,
403
+ };
404
+ function geographySummary(prepared) {
405
+ const counts = Object.fromEntries(GEOGRAPHY_FEATURE_KINDS.map((kind) => [kind, prepared.geography.list(kind).length]));
406
+ const severities = diagnosticSeverityCounts(prepared.diagnostics);
407
+ const unsupported = unsupportedGeographyParts(prepared.geography);
408
+ return {
409
+ operationId: prepared.operationId,
410
+ modelHash: prepared.geography.hash,
411
+ valid: prepared.valid,
412
+ source: prepared.source.kind,
413
+ sourceSha256: prepared.source.sha256,
414
+ width: prepared.frame.width,
415
+ height: prepared.frame.height,
416
+ featureCount: prepared.geography.featureCount,
417
+ pointCount: prepared.geography.pointCount,
418
+ counts,
419
+ errorCount: severities.error,
420
+ warningCount: severities.warning,
421
+ synthesis: unsupported.length === 0 ? 'available' : 'unavailable',
422
+ unsupported,
423
+ };
424
+ }
425
+ /**
426
+ * Review the geography model of a `create_world` operation: validate it, resolve its ids and areas
427
+ * and return an overlay and a report. No map is planned or generated and no file changes.
428
+ */
429
+ async function previewGeographyRewrite(nudger, input, context) {
430
+ await context.progress(0, 3, 'Validating the geography model and drawing its overlay');
431
+ const planned = await nudger.previewGeographyModels({
432
+ workspaceId: context.workspaceId,
433
+ operations: input.operations,
434
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
435
+ ...(context.signal === undefined ? {} : { signal: context.signal }),
436
+ });
437
+ const compactSemantic = compactMapSemanticDiff(EMPTY_SEMANTIC_DIFF);
438
+ const diagnostics = [
439
+ ...planned.geography.flatMap(({ diagnostics: found }) => found),
440
+ ...planned.importDiagnostics,
441
+ ];
442
+ const ignored = input.operations.filter((operation) => operation.kind !== 'create_world' || !operationHasGeography(operation)).length;
443
+ const notes = [
444
+ {
445
+ code: 'MAP_GEOGRAPHY_PREVIEW_MODEL_ONLY',
446
+ severity: 'info',
447
+ category: 'map',
448
+ message: 'This preview checks the geography model, and the registration and agreement of a reference picture beside it, only: no map was planned and nothing was synthesized. A request without preview builds relief, rivers and lakes from the model, and is blocked while the model declares biomes, climate, fertility or sea ice, which the climate stage does not synthesize yet.',
449
+ },
450
+ ...(ignored === 0
451
+ ? []
452
+ : [
453
+ {
454
+ code: 'MAP_GEOGRAPHY_PREVIEW_OTHER_OPERATIONS_SKIPPED',
455
+ severity: 'info',
456
+ category: 'map',
457
+ message: `${ignored} other operation(s) in the request were not evaluated by this model preview`,
458
+ },
459
+ ]),
460
+ ];
461
+ const all = sortDiagnostics([...diagnostics, ...notes]);
462
+ const valid = planned.geography.every(({ valid: ok }) => ok) &&
463
+ !planned.importDiagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker');
464
+ const result = emptyServiceResult(context.workspaceId, {
465
+ execution: 'preview',
466
+ allocations: [],
467
+ operationBlockers: [],
468
+ expectedChangedBounds: null,
469
+ changedProvinceIds: [],
470
+ changedProvinceCount: 0,
471
+ semanticDiff: compactSemantic.semantic,
472
+ semanticDiffCounts: compactSemantic.counts,
473
+ semanticDiffTruncated: compactSemantic.truncated,
474
+ fileCount: 0,
475
+ artifactCount: planned.artifacts.length,
476
+ geography: planned.geography.slice(0, 16).map(geographySummary),
477
+ ...(planned.imports.length === 0
478
+ ? {}
479
+ : { import: planned.imports.slice(0, 16).map(importSummary) }),
480
+ });
481
+ result.status = valid ? 'ok' : 'blocked';
482
+ result.code = valid ? 'MAP_GEOGRAPHY_PREVIEW_READY' : 'MAP_GEOGRAPHY_PREVIEW_BLOCKED';
483
+ setInlineFilesScanned(result, planned.filesScanned);
484
+ result.proposedFiles = [];
485
+ result.diagnostics = all.slice(0, 100);
486
+ result.artifacts = planned.artifacts.map(publicArtifactLink);
487
+ result.validation = {
488
+ passed: valid,
489
+ checks: [
490
+ {
491
+ id: 'geography-model',
492
+ passed: valid,
493
+ message: valid
494
+ ? 'The geography model resolves without errors'
495
+ : 'The geography model has errors; see the MAP_GEOGRAPHY_* diagnostics and map-geography-report.json',
496
+ },
497
+ ],
498
+ };
499
+ result.blockers = all
500
+ .filter(({ severity }) => severity === 'error' || severity === 'blocker')
501
+ .slice(0, 100)
502
+ .map(({ code, message }) => ({ code, message }));
503
+ await context.progress(3, 3, 'Geography model preview complete; workspace files were not changed');
504
+ return result;
505
+ }
311
506
  /** Generate and validate the same proposed map without creating a write transaction. */
312
507
  export async function previewMapRewrite(nudger, input, context) {
508
+ if (input.operations.some((operation) => operation.kind === 'create_world' && operationHasGeography(operation)))
509
+ return previewGeographyRewrite(nudger, input, context);
313
510
  await context.progress(0, 5, 'Generating and validating an offline map preview');
314
511
  const planned = await nudger.previewRewriteWithDiff({
315
512
  workspaceId: context.workspaceId,
@@ -334,6 +531,7 @@ export async function previewMapRewrite(nudger, input, context) {
334
531
  semanticDiffTruncated: compactSemantic.truncated,
335
532
  fileCount: planned.plan.changes.length,
336
533
  artifactCount: planned.artifacts.length,
534
+ ...worldOutputsData(planned.plan),
337
535
  ...(input.operations.some(({ kind }) => kind === 'create_world') &&
338
536
  generatedWorldOperationIds(planned.plan).size === 0
339
537
  ? {}
@@ -431,8 +629,32 @@ export async function inspectMap(engine, nudger, input, context) {
431
629
  await context.progress(3, 3, 'Map lookup complete');
432
630
  return result;
433
631
  }
434
- const { snapshot, validation, scriptReferences } = await nudger.validate(context.workspaceId, context.principal, context.signal, input.references);
632
+ const { snapshot, validation, scriptReferences } = await nudger.validate(context.workspaceId, context.principal, context.signal, input.references, input.quality);
435
633
  const sharedRevision = snapshot.revision;
634
+ let borderQuality;
635
+ const qualityDiagnostics = [];
636
+ if (input.quality) {
637
+ await context.progress(1, 3, 'Measuring how natural the borders are');
638
+ try {
639
+ const measured = await measureIndexBorderNaturalness(snapshot.index, context.signal === undefined ? {} : { signal: context.signal });
640
+ borderQuality = {
641
+ report: measured.report,
642
+ summary: summarizeBorderQuality(measured.report, measured.skipped),
643
+ };
644
+ qualityDiagnostics.push(...borderQualityDiagnostics(measured.report));
645
+ }
646
+ catch (error) {
647
+ if (!(error instanceof ServiceError) || !error.code.startsWith('MAP_BORDER_QUALITY_'))
648
+ throw error;
649
+ qualityDiagnostics.push({
650
+ code: 'MAP_BORDER_QUALITY_UNAVAILABLE',
651
+ severity: 'warning',
652
+ category: 'map',
653
+ message: `Border naturalness could not be measured: ${error.message}`,
654
+ details: { reason: error.code, ...error.details },
655
+ });
656
+ }
657
+ }
436
658
  const catalog = buildMapCatalog(snapshot.index);
437
659
  const queryMatches = input.query === undefined ? [] : searchMapCatalog(catalog, input.query, input.queryLimit);
438
660
  const coordinateMatches = input.coordinates.map((coordinate) => lookupMapCoordinate(snapshot.index, coordinate));
@@ -493,12 +715,33 @@ export async function inspectMap(engine, nudger, input, context) {
493
715
  selected,
494
716
  allocationPreviews,
495
717
  validation,
718
+ ...(borderQuality === undefined ? {} : { borderQuality: borderQuality.report }),
496
719
  sourceHashes: sourceEvidence.complete,
497
720
  })}\n`,
498
721
  provenance: inspectionProvenance,
499
722
  description: 'Complete searchable map catalog, inspection, validation, selected records, and allocation previews',
500
723
  },
501
724
  ];
725
+ if (borderQuality !== undefined)
726
+ artifactWrites.push({
727
+ name: `map-border-quality.${snapshot.revision.slice(0, 16)}.json`,
728
+ mimeType: 'application/json',
729
+ content: `${canonicalJson({
730
+ schemaVersion: 1,
731
+ revision: snapshot.revision,
732
+ report: borderQuality.report,
733
+ skippedInputs: borderQuality.summary.skippedInputs,
734
+ sourceHashes: sourceEvidence.complete,
735
+ })}\n`,
736
+ provenance: {
737
+ kind: 'map-border-quality',
738
+ toolVersion: PACKAGE_VERSION,
739
+ schemaVersion: 'map-border-quality.v1',
740
+ sourceHashes: sourceEvidence.bounded.sourceHashes,
741
+ metadata: { sharedRevision, sourceHashInventory: sourceEvidence.bounded.inventory },
742
+ },
743
+ description: 'Border naturalness: alignment with rivers, slopes, lake shores and terrain changes, enrichment over chance, straightness and the worst artificial runs for each border layer',
744
+ });
502
745
  if (overviewRendered) {
503
746
  const overviewLayer = 'state';
504
747
  const overviewOverlays = [
@@ -592,10 +835,13 @@ export async function inspectMap(engine, nudger, input, context) {
592
835
  unknownProvinceIds: provinceGeometry?.unknownProvinceIds ?? [],
593
836
  missingGeometryProvinceIds: provinceGeometry?.missingGeometryProvinceIds ?? [],
594
837
  ...(scriptReferences === undefined ? {} : { scriptReferences }),
838
+ ...(borderQuality === undefined ? {} : { quality: borderQuality.summary }),
595
839
  });
596
840
  result.code = 'MAP_INSPECTED';
597
841
  setInlineFilesScanned(result, snapshot.files.map(({ displayPath }) => displayPath));
598
- result.diagnostics = validation.diagnostics.slice(0, 100);
842
+ result.diagnostics = (qualityDiagnostics.length === 0
843
+ ? validation.diagnostics
844
+ : sortDiagnostics([...validation.diagnostics, ...qualityDiagnostics])).slice(0, 100);
599
845
  result.artifacts = artifacts.map(publicArtifactLink);
600
846
  result.validation = validationSummary(validation);
601
847
  await context.progress(3, 3, 'Map inspection complete');