hoi4-agent-tools 4.3.0 → 4.4.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 (110) hide show
  1. package/CHANGELOG.md +25 -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/operation-context.js +17 -1
  5. package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
  6. package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
  7. package/dist/hoi4_agent_tools/core/transactions.js +23 -4
  8. package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
  9. package/dist/hoi4_agent_tools/core/workspace.js +42 -6
  10. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  11. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  12. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  13. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  14. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  15. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  16. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  17. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  18. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  19. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  20. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  21. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  22. package/dist/hoi4_agent_tools/map/model.js +15 -2
  23. package/dist/hoi4_agent_tools/map/operations.js +111 -8
  24. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  25. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  26. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  27. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  28. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  29. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  30. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  31. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  32. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  33. package/dist/hoi4_agent_tools/map/reference-preparation.js +138 -5
  34. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  35. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  36. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  37. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  38. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  39. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  40. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  41. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  42. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  43. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  44. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  45. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  46. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  47. package/dist/hoi4_agent_tools/map/render.js +36 -0
  48. package/dist/hoi4_agent_tools/map/service.js +191 -16
  49. package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
  50. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  51. package/dist/hoi4_agent_tools/map/water.js +227 -0
  52. package/dist/hoi4_agent_tools/map/world-context.js +24 -1
  53. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  54. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  55. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  56. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  57. package/dist/hoi4_agent_tools/map/world.js +462 -124
  58. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  59. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  60. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  61. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  62. package/dist/hoi4_agent_tools/schemas/map.js +209 -2
  63. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  64. package/dist/hoi4_agent_tools/version.js +1 -1
  65. package/docs/configuration.md +17 -14
  66. package/docs/map.md +258 -15
  67. package/docs/tools.md +4 -4
  68. package/docs/troubleshooting.md +11 -11
  69. package/npm-shrinkwrap.json +109 -91
  70. package/package.json +3 -3
  71. package/schemas/configuration.schema.json +65 -58
  72. package/schemas/continuous-focus-palette.schema.json +1 -1
  73. package/schemas/custom-weighted-pool.schema.json +1 -1
  74. package/schemas/decision-inspect-input.schema.json +1 -1
  75. package/schemas/error-log-input.schema.json +1 -1
  76. package/schemas/event-feature-manifest.schema.json +1 -1
  77. package/schemas/focus-plan.schema.json +1 -1
  78. package/schemas/focus-planning-sidecar.schema.json +1 -1
  79. package/schemas/geography-model.schema.json +1148 -0
  80. package/schemas/gui-animation-source.schema.json +1 -1
  81. package/schemas/gui-helper.schema.json +5 -11
  82. package/schemas/gui-scenario.schema.json +1 -1
  83. package/schemas/helper-expansion-request.schema.json +1 -1
  84. package/schemas/helper-expansion-summary.schema.json +1 -1
  85. package/schemas/impact-inspect-input.schema.json +1 -1
  86. package/schemas/map-operation.schema.json +2156 -259
  87. package/schemas/mechanic-test-input.schema.json +1 -1
  88. package/schemas/mod-index-input.schema.json +1 -1
  89. package/schemas/operation-result.schema.json +1 -1
  90. package/schemas/package-check-input.schema.json +1 -1
  91. package/schemas/package-manifest.schema.json +1 -1
  92. package/schemas/probability-analysis-result.schema.json +25 -7
  93. package/schemas/probability-compare-input.schema.json +1 -1
  94. package/schemas/probability-evaluate-input.schema.json +1 -1
  95. package/schemas/probability-inspect-input.schema.json +1 -1
  96. package/schemas/probability-render-input.schema.json +1 -1
  97. package/schemas/probability-scenario-set.schema.json +1 -1
  98. package/schemas/probability-sequence-input.schema.json +1 -1
  99. package/schemas/probability-simulate-input.schema.json +1 -1
  100. package/schemas/probability-sweep-input.schema.json +1 -1
  101. package/schemas/reference-context-input.schema.json +1 -1
  102. package/schemas/reference-read-input.schema.json +1 -1
  103. package/schemas/reference-search-input.schema.json +1 -1
  104. package/schemas/reference-sheets.schema.json +267 -0
  105. package/schemas/scenario-suite.schema.json +1 -1
  106. package/schemas/scenario-test-input.schema.json +1 -1
  107. package/schemas/script-validate-input.schema.json +1 -1
  108. package/schemas/shared-scenario.schema.json +1 -1
  109. package/schemas/source-lookup-input.schema.json +1 -1
  110. 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]) {
751
+ snapshot.referenceSources ??= [];
752
+ const previous = snapshot.referenceSources.findIndex((file) => file.displayPath === evidence.displayPath);
753
+ if (previous < 0)
754
+ snapshot.referenceSources.push(evidence);
755
+ else
756
+ snapshot.referenceSources[previous] = evidence;
757
+ }
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;
677
767
  snapshot.referenceSources ??= [];
678
- const previous = snapshot.referenceSources.findIndex((file) => file.displayPath === image.source.displayPath);
768
+ const previous = snapshot.referenceSources.findIndex((file) => file.displayPath === evidence.displayPath);
679
769
  if (previous < 0)
680
- snapshot.referenceSources.push(image.source);
770
+ snapshot.referenceSources.push(evidence);
681
771
  else
682
- snapshot.referenceSources[previous] = image.source;
772
+ snapshot.referenceSources[previous] = evidence;
683
773
  }
684
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,9 @@ 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)));
1125
1300
  const generatedWorlds = generatedWorldOperationIds(plan);
1126
1301
  if (generatedWorlds.size > 0)
1127
1302
  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
9
  import { allocateMapIdentifiersAsync, } 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({
@@ -308,8 +343,131 @@ export function completeMapRewrite(workspaceId, execution, recipe) {
308
343
  ].slice(0, 100);
309
344
  return result;
310
345
  }
346
+ const EMPTY_SEMANTIC_DIFF = {
347
+ definitions: [],
348
+ stateMembership: [],
349
+ regionMembership: [],
350
+ states: [],
351
+ ports: [],
352
+ buildingPositions: [],
353
+ unitPositions: [],
354
+ weatherPositions: [],
355
+ entityLocators: [],
356
+ supplyNodes: [],
357
+ railways: [],
358
+ adjacencies: [],
359
+ normalAdjacencies: [],
360
+ supplyNodesChanged: false,
361
+ railwaysChanged: false,
362
+ adjacenciesChanged: false,
363
+ normalAdjacenciesChanged: false,
364
+ };
365
+ function geographySummary(prepared) {
366
+ const counts = Object.fromEntries(GEOGRAPHY_FEATURE_KINDS.map((kind) => [kind, prepared.geography.list(kind).length]));
367
+ const severities = diagnosticSeverityCounts(prepared.diagnostics);
368
+ const unsupported = unsupportedGeographyParts(prepared.geography);
369
+ return {
370
+ operationId: prepared.operationId,
371
+ modelHash: prepared.geography.hash,
372
+ valid: prepared.valid,
373
+ source: prepared.source.kind,
374
+ sourceSha256: prepared.source.sha256,
375
+ width: prepared.frame.width,
376
+ height: prepared.frame.height,
377
+ featureCount: prepared.geography.featureCount,
378
+ pointCount: prepared.geography.pointCount,
379
+ counts,
380
+ errorCount: severities.error,
381
+ warningCount: severities.warning,
382
+ synthesis: unsupported.length === 0 ? 'available' : 'unavailable',
383
+ unsupported,
384
+ };
385
+ }
386
+ /**
387
+ * Review the geography model of a `create_world` operation: validate it, resolve its ids and areas
388
+ * and return an overlay and a report. No map is planned or generated and no file changes.
389
+ */
390
+ async function previewGeographyRewrite(nudger, input, context) {
391
+ await context.progress(0, 3, 'Validating the geography model and drawing its overlay');
392
+ const planned = await nudger.previewGeographyModels({
393
+ workspaceId: context.workspaceId,
394
+ operations: input.operations,
395
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
396
+ ...(context.signal === undefined ? {} : { signal: context.signal }),
397
+ });
398
+ const compactSemantic = compactMapSemanticDiff(EMPTY_SEMANTIC_DIFF);
399
+ const diagnostics = [
400
+ ...planned.geography.flatMap(({ diagnostics: found }) => found),
401
+ ...planned.importDiagnostics,
402
+ ];
403
+ const ignored = input.operations.filter((operation) => operation.kind !== 'create_world' || !operationHasGeography(operation)).length;
404
+ const notes = [
405
+ {
406
+ code: 'MAP_GEOGRAPHY_PREVIEW_MODEL_ONLY',
407
+ severity: 'info',
408
+ category: 'map',
409
+ 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.',
410
+ },
411
+ ...(ignored === 0
412
+ ? []
413
+ : [
414
+ {
415
+ code: 'MAP_GEOGRAPHY_PREVIEW_OTHER_OPERATIONS_SKIPPED',
416
+ severity: 'info',
417
+ category: 'map',
418
+ message: `${ignored} other operation(s) in the request were not evaluated by this model preview`,
419
+ },
420
+ ]),
421
+ ];
422
+ const all = sortDiagnostics([...diagnostics, ...notes]);
423
+ const valid = planned.geography.every(({ valid: ok }) => ok) &&
424
+ !planned.importDiagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker');
425
+ const result = emptyServiceResult(context.workspaceId, {
426
+ execution: 'preview',
427
+ allocations: [],
428
+ operationBlockers: [],
429
+ expectedChangedBounds: null,
430
+ changedProvinceIds: [],
431
+ changedProvinceCount: 0,
432
+ semanticDiff: compactSemantic.semantic,
433
+ semanticDiffCounts: compactSemantic.counts,
434
+ semanticDiffTruncated: compactSemantic.truncated,
435
+ fileCount: 0,
436
+ artifactCount: planned.artifacts.length,
437
+ geography: planned.geography.slice(0, 16).map(geographySummary),
438
+ ...(planned.imports.length === 0
439
+ ? {}
440
+ : { import: planned.imports.slice(0, 16).map(importSummary) }),
441
+ });
442
+ result.status = valid ? 'ok' : 'blocked';
443
+ result.code = valid ? 'MAP_GEOGRAPHY_PREVIEW_READY' : 'MAP_GEOGRAPHY_PREVIEW_BLOCKED';
444
+ setInlineFilesScanned(result, planned.filesScanned);
445
+ result.proposedFiles = [];
446
+ result.diagnostics = all.slice(0, 100);
447
+ result.artifacts = planned.artifacts.map(publicArtifactLink);
448
+ result.validation = {
449
+ passed: valid,
450
+ checks: [
451
+ {
452
+ id: 'geography-model',
453
+ passed: valid,
454
+ message: valid
455
+ ? 'The geography model resolves without errors'
456
+ : 'The geography model has errors; see the MAP_GEOGRAPHY_* diagnostics and map-geography-report.json',
457
+ },
458
+ ],
459
+ };
460
+ result.blockers = all
461
+ .filter(({ severity }) => severity === 'error' || severity === 'blocker')
462
+ .slice(0, 100)
463
+ .map(({ code, message }) => ({ code, message }));
464
+ await context.progress(3, 3, 'Geography model preview complete; workspace files were not changed');
465
+ return result;
466
+ }
311
467
  /** Generate and validate the same proposed map without creating a write transaction. */
312
468
  export async function previewMapRewrite(nudger, input, context) {
469
+ if (input.operations.some((operation) => operation.kind === 'create_world' && operationHasGeography(operation)))
470
+ return previewGeographyRewrite(nudger, input, context);
313
471
  await context.progress(0, 5, 'Generating and validating an offline map preview');
314
472
  const planned = await nudger.previewRewriteWithDiff({
315
473
  workspaceId: context.workspaceId,
@@ -431,8 +589,32 @@ export async function inspectMap(engine, nudger, input, context) {
431
589
  await context.progress(3, 3, 'Map lookup complete');
432
590
  return result;
433
591
  }
434
- const { snapshot, validation, scriptReferences } = await nudger.validate(context.workspaceId, context.principal, context.signal, input.references);
592
+ const { snapshot, validation, scriptReferences } = await nudger.validate(context.workspaceId, context.principal, context.signal, input.references, input.quality);
435
593
  const sharedRevision = snapshot.revision;
594
+ let borderQuality;
595
+ const qualityDiagnostics = [];
596
+ if (input.quality) {
597
+ await context.progress(1, 3, 'Measuring how natural the borders are');
598
+ try {
599
+ const measured = await measureIndexBorderNaturalness(snapshot.index, context.signal === undefined ? {} : { signal: context.signal });
600
+ borderQuality = {
601
+ report: measured.report,
602
+ summary: summarizeBorderQuality(measured.report, measured.skipped),
603
+ };
604
+ qualityDiagnostics.push(...borderQualityDiagnostics(measured.report));
605
+ }
606
+ catch (error) {
607
+ if (!(error instanceof ServiceError) || !error.code.startsWith('MAP_BORDER_QUALITY_'))
608
+ throw error;
609
+ qualityDiagnostics.push({
610
+ code: 'MAP_BORDER_QUALITY_UNAVAILABLE',
611
+ severity: 'warning',
612
+ category: 'map',
613
+ message: `Border naturalness could not be measured: ${error.message}`,
614
+ details: { reason: error.code, ...error.details },
615
+ });
616
+ }
617
+ }
436
618
  const catalog = buildMapCatalog(snapshot.index);
437
619
  const queryMatches = input.query === undefined ? [] : searchMapCatalog(catalog, input.query, input.queryLimit);
438
620
  const coordinateMatches = input.coordinates.map((coordinate) => lookupMapCoordinate(snapshot.index, coordinate));
@@ -493,12 +675,33 @@ export async function inspectMap(engine, nudger, input, context) {
493
675
  selected,
494
676
  allocationPreviews,
495
677
  validation,
678
+ ...(borderQuality === undefined ? {} : { borderQuality: borderQuality.report }),
496
679
  sourceHashes: sourceEvidence.complete,
497
680
  })}\n`,
498
681
  provenance: inspectionProvenance,
499
682
  description: 'Complete searchable map catalog, inspection, validation, selected records, and allocation previews',
500
683
  },
501
684
  ];
685
+ if (borderQuality !== undefined)
686
+ artifactWrites.push({
687
+ name: `map-border-quality.${snapshot.revision.slice(0, 16)}.json`,
688
+ mimeType: 'application/json',
689
+ content: `${canonicalJson({
690
+ schemaVersion: 1,
691
+ revision: snapshot.revision,
692
+ report: borderQuality.report,
693
+ skippedInputs: borderQuality.summary.skippedInputs,
694
+ sourceHashes: sourceEvidence.complete,
695
+ })}\n`,
696
+ provenance: {
697
+ kind: 'map-border-quality',
698
+ toolVersion: PACKAGE_VERSION,
699
+ schemaVersion: 'map-border-quality.v1',
700
+ sourceHashes: sourceEvidence.bounded.sourceHashes,
701
+ metadata: { sharedRevision, sourceHashInventory: sourceEvidence.bounded.inventory },
702
+ },
703
+ 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',
704
+ });
502
705
  if (overviewRendered) {
503
706
  const overviewLayer = 'state';
504
707
  const overviewOverlays = [
@@ -592,10 +795,13 @@ export async function inspectMap(engine, nudger, input, context) {
592
795
  unknownProvinceIds: provinceGeometry?.unknownProvinceIds ?? [],
593
796
  missingGeometryProvinceIds: provinceGeometry?.missingGeometryProvinceIds ?? [],
594
797
  ...(scriptReferences === undefined ? {} : { scriptReferences }),
798
+ ...(borderQuality === undefined ? {} : { quality: borderQuality.summary }),
595
799
  });
596
800
  result.code = 'MAP_INSPECTED';
597
801
  setInlineFilesScanned(result, snapshot.files.map(({ displayPath }) => displayPath));
598
- result.diagnostics = validation.diagnostics.slice(0, 100);
802
+ result.diagnostics = (qualityDiagnostics.length === 0
803
+ ? validation.diagnostics
804
+ : sortDiagnostics([...validation.diagnostics, ...qualityDiagnostics])).slice(0, 100);
599
805
  result.artifacts = artifacts.map(publicArtifactLink);
600
806
  result.validation = validationSummary(validation);
601
807
  await context.progress(3, 3, 'Map inspection complete');
@@ -2,6 +2,7 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
2
2
  import { sortDiagnostics } from '../core/diagnostics.js';
3
3
  import { assertRenderDimensions } from '../core/render-budget.js';
4
4
  import { addActiveMapDiagnostic, addMapDiagnostic } from './diagnostic-limit.js';
5
+ import { validateAppearance } from './appearance-validation.js';
5
6
  import { rgbKey } from './bmp.js';
6
7
  import { MAP_PROVINCE_ENGINE_MAX_PIXELS } from './model.js';
7
8
  const MAP_DIAGNOSTIC_DETAIL_SAMPLE_LIMIT = 100;
@@ -1207,6 +1208,8 @@ function* validateMapSteps(index, options = {}) {
1207
1208
  yield* cancellationCheckpoint(options.signal);
1208
1209
  yield* validatePositions(index, diagnostics, options);
1209
1210
  yield* cancellationCheckpoint(options.signal);
1211
+ yield* validateAppearance(index, (diagnostic) => addDiagnostic(diagnostics, options, { ...diagnostic, category: 'map' }), { signal: options.signal, edgeMode: options.edgeMode });
1212
+ yield* cancellationCheckpoint(options.signal);
1210
1213
  const sorted = sortDiagnostics(diagnostics);
1211
1214
  const checks = [
1212
1215
  {
@@ -1259,6 +1262,12 @@ function* validateMapSteps(index, options = {}) {
1259
1262
  code.startsWith('MAP_ENTITY_LOCATOR'))),
1260
1263
  message: 'Map positions, ports, and entity locators are valid',
1261
1264
  },
1265
+ {
1266
+ id: 'map-appearance-files',
1267
+ passed: !sorted.some(({ code, severity }) => (severity === 'error' || severity === 'blocker') &&
1268
+ /^MAP_(RIVER|TERRAIN_BITMAP|HEIGHT|TREES|COLORMAP|WATER|NORMAL|CITIES|LAKE|EDGE|SEAM|POLAR)_/u.test(code)),
1269
+ message: 'Heightmap, terrain, river, tree, colour, normal and city bitmaps, lakes and the world edge are valid',
1270
+ },
1262
1271
  ];
1263
1272
  const passed = checks.every((check) => check.passed) &&
1264
1273
  !sorted.some(({ severity }) => severity === 'error' || severity === 'blocker');