hoi4-agent-tools 4.0.5 → 4.2.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 (104) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -0
  3. package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
  4. package/dist/hoi4_agent_tools/core/canonical.js +42 -0
  5. package/dist/hoi4_agent_tools/core/condition-evaluator.js +12 -8
  6. package/dist/hoi4_agent_tools/core/dependency-walk.js +21 -2
  7. package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
  8. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  9. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  10. package/dist/hoi4_agent_tools/core/impact-graph.js +6 -2
  11. package/dist/hoi4_agent_tools/core/impact-semantic.js +12 -2
  12. package/dist/hoi4_agent_tools/core/index.js +1 -0
  13. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  14. package/dist/hoi4_agent_tools/core/job-worker-host.js +12 -2
  15. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  16. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  17. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  18. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  19. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  20. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  21. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  22. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  23. package/dist/hoi4_agent_tools/decision/service.js +22 -1
  24. package/dist/hoi4_agent_tools/event/algorithms.js +3 -2
  25. package/dist/hoi4_agent_tools/event/graph.js +52 -35
  26. package/dist/hoi4_agent_tools/event/limits.js +4 -0
  27. package/dist/hoi4_agent_tools/event/operations.js +11 -0
  28. package/dist/hoi4_agent_tools/event/queries.js +13 -34
  29. package/dist/hoi4_agent_tools/event/render.js +4 -2
  30. package/dist/hoi4_agent_tools/event/service.js +93 -33
  31. package/dist/hoi4_agent_tools/event/source-analysis.js +94 -4
  32. package/dist/hoi4_agent_tools/event/state-keys.js +63 -0
  33. package/dist/hoi4_agent_tools/event/summary.js +130 -0
  34. package/dist/hoi4_agent_tools/impact/service.js +2 -1
  35. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  36. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  37. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  38. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  39. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  40. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  41. package/dist/hoi4_agent_tools/map/render.js +9 -1
  42. package/dist/hoi4_agent_tools/map/service.js +222 -18
  43. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  44. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  45. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  46. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  47. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  48. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  49. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  50. package/dist/hoi4_agent_tools/map/world.js +374 -253
  51. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +1 -0
  52. package/dist/hoi4_agent_tools/mcp/tools/event.js +66 -0
  53. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  54. package/dist/hoi4_agent_tools/reference/source-lookup.js +4 -2
  55. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  56. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  57. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  58. package/dist/hoi4_agent_tools/version.js +2 -2
  59. package/docs/analysis.md +4 -0
  60. package/docs/events.md +12 -0
  61. package/docs/images/reference-maps/political.png +0 -0
  62. package/docs/images/reference-maps/sketch.png +0 -0
  63. package/docs/map.md +45 -0
  64. package/docs/third-party-notices.md +6 -0
  65. package/npm-shrinkwrap.json +124 -2
  66. package/package.json +4 -1
  67. package/schemas/configuration.schema.json +1 -1
  68. package/schemas/continuous-focus-palette.schema.json +1 -1
  69. package/schemas/custom-weighted-pool.schema.json +1 -1
  70. package/schemas/decision-inspect-input.schema.json +1 -1
  71. package/schemas/error-log-input.schema.json +1 -1
  72. package/schemas/event-feature-manifest.schema.json +1 -1
  73. package/schemas/focus-plan.schema.json +1 -1
  74. package/schemas/focus-planning-sidecar.schema.json +1 -1
  75. package/schemas/gui-animation-source.schema.json +1 -1
  76. package/schemas/gui-helper.schema.json +1 -1
  77. package/schemas/gui-scenario.schema.json +1 -1
  78. package/schemas/helper-expansion-request.schema.json +1 -1
  79. package/schemas/helper-expansion-summary.schema.json +1 -1
  80. package/schemas/impact-inspect-input.schema.json +1 -1
  81. package/schemas/map-operation.schema.json +373 -70
  82. package/schemas/mechanic-test-input.schema.json +1 -1
  83. package/schemas/mod-index-input.schema.json +1 -1
  84. package/schemas/operation-result.schema.json +1 -1
  85. package/schemas/package-check-input.schema.json +1 -1
  86. package/schemas/package-manifest.schema.json +1 -1
  87. package/schemas/probability-analysis-result.schema.json +1 -1
  88. package/schemas/probability-compare-input.schema.json +1 -1
  89. package/schemas/probability-evaluate-input.schema.json +1 -1
  90. package/schemas/probability-inspect-input.schema.json +1 -1
  91. package/schemas/probability-render-input.schema.json +1 -1
  92. package/schemas/probability-scenario-set.schema.json +1 -1
  93. package/schemas/probability-sequence-input.schema.json +1 -1
  94. package/schemas/probability-simulate-input.schema.json +1 -1
  95. package/schemas/probability-sweep-input.schema.json +1 -1
  96. package/schemas/reference-context-input.schema.json +1 -1
  97. package/schemas/reference-read-input.schema.json +1 -1
  98. package/schemas/reference-search-input.schema.json +1 -1
  99. package/schemas/scenario-suite.schema.json +1 -1
  100. package/schemas/scenario-test-input.schema.json +1 -1
  101. package/schemas/script-validate-input.schema.json +1 -1
  102. package/schemas/shared-scenario.schema.json +1 -1
  103. package/schemas/source-lookup-input.schema.json +1 -1
  104. package/server.json +2 -2
@@ -402,10 +402,18 @@ export async function renderMap(index, options = {}) {
402
402
  throw new ServiceError('MAP_TILE_OUT_OF_BOUNDS', 'Map tile extends beyond the province raster', { tile, rasterWidth: raster.width, rasterHeight: raster.height });
403
403
  const outputDimensions = budget.reserve((tile?.width ?? raster.width) * resolved.scale, (tile?.height ?? raster.height) * resolved.scale, 'map PNG output');
404
404
  const raw = Buffer.alloc(raster.width * raster.height * 4);
405
+ // A base layer assigns one color to each province. Resolve that color once for this
406
+ // immutable render, rather than repeating state lookups and color hashes for every pixel.
407
+ const provinceColors = new Map();
405
408
  for (let offset = 0; offset < raster.provinceIds.length; offset += 1) {
406
409
  if (offset % Math.max(raster.width * 32, 1) === 0)
407
410
  await renderCheckpoint(options.signal);
408
- const color = baseColor(index, raster.provinceIds[offset] ?? -1, resolved.layer);
411
+ const provinceId = raster.provinceIds[offset] ?? -1;
412
+ let color = provinceColors.get(provinceId);
413
+ if (color === undefined) {
414
+ color = baseColor(index, provinceId, resolved.layer);
415
+ provinceColors.set(provinceId, color);
416
+ }
409
417
  const target = offset * 4;
410
418
  raw[target] = color.r;
411
419
  raw[target + 1] = color.g;
@@ -4,6 +4,7 @@ import { escapeFileGlob } from '../core/file-glob.js';
4
4
  import { ArtifactStore, boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
5
5
  import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
6
6
  import { sortDiagnostics } from '../core/diagnostics.js';
7
+ import { diagnosticSeverityCounts, rollupRepeatedSoftDiagnostics, } from '../core/diagnostic-rollup.js';
7
8
  import { CoreEngine } from '../core/engine.js';
8
9
  import { SymbolIndex } from '../core/index.js';
9
10
  import { RenderBudget } from '../core/render-budget.js';
@@ -13,9 +14,12 @@ import { readDependenciesFromScannedFiles, } from '../core/transactions.js';
13
14
  import { PACKAGE_VERSION } from '../version.js';
14
15
  import { MAP_PROVINCE_GEOMETRY_SELECTOR_LIMIT, MAP_SELECTED_OFFSET_BYTES, MAP_SELECTED_PIXEL_LIMIT, } from './limits.js';
15
16
  import { defaultMapSelectorValue, MapWorkspaceIndex, } from './model.js';
16
- import { planMapOperationsAsync } from './operations.js';
17
+ import { planMapOperationsAsync, } from './operations.js';
18
+ import { prepareReferenceWorld } from './reference-preparation.js';
19
+ import { WORLD_OUTPUT_MANIFEST } from './world-outputs.js';
17
20
  import { renderMap, renderMapDiff, } from './render.js';
18
21
  import { validateMapAsync } from './validation.js';
22
+ import { buildWorldPreviewArtifacts, generatedWorldOperationIds } from './world-preview.js';
19
23
  function compareNumbers(left, right) {
20
24
  return left - right;
21
25
  }
@@ -384,6 +388,7 @@ function sourceTextPatterns(roots) {
384
388
  'common/terrain/*.txt',
385
389
  'common/buildings/*.txt',
386
390
  'descriptor.mod',
391
+ WORLD_OUTPUT_MANIFEST,
387
392
  ...roots.map.flatMap((root) => {
388
393
  const normalized = normalizeSourceRoot(root);
389
394
  return ['map', 'csv', 'txt'].map((extension) => `${normalized}/**/*.${extension}`);
@@ -391,7 +396,11 @@ function sourceTextPatterns(roots) {
391
396
  ...roots.states.map((root) => `${normalizeSourceRoot(root)}/**/*.txt`),
392
397
  ...roots.localisation.flatMap((root) => {
393
398
  const normalized = normalizeSourceRoot(root);
394
- return [`${normalized}/english/**/*.{yml,yaml}`, `${normalized}/*.{yml,yaml}`];
399
+ return [
400
+ `${normalized}/english/**/*.{yml,yaml}`,
401
+ `${normalized}/replace/**/*.{yml,yaml}`,
402
+ `${normalized}/*.{yml,yaml}`,
403
+ ];
395
404
  }),
396
405
  ];
397
406
  }
@@ -441,8 +450,42 @@ function provinceBitmapPatterns(defaultMaps, mapRoots) {
441
450
  }
442
451
  return [...patterns].sort((left, right) => compareCodeUnits(left, right));
443
452
  }
453
+ function mapSourceEvidence(snapshot) {
454
+ if (snapshot.referenceSources === undefined)
455
+ return snapshot.files;
456
+ return [
457
+ ...new Map([...snapshot.files, ...(snapshot.referenceSources ?? [])].map((source) => [
458
+ source.displayPath,
459
+ source,
460
+ ])).values(),
461
+ ].sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
462
+ }
444
463
  function sourceHashes(snapshot) {
445
- return Object.fromEntries(snapshot.files.map(({ displayPath, sha256 }) => [displayPath, sha256]));
464
+ return Object.fromEntries(mapSourceEvidence(snapshot).map(({ displayPath, sha256 }) => [displayPath, sha256]));
465
+ }
466
+ function mapValidationEvidence(snapshot, validation) {
467
+ const evidence = boundedSourceHashEvidence(sourceHashes(snapshot));
468
+ const counts = diagnosticSeverityCounts(validation.diagnostics);
469
+ const omissions = validation.diagnostics
470
+ .filter((diagnostic) => diagnostic.code === 'MAP_DIAGNOSTICS_TRUNCATED')
471
+ .map((diagnostic) => diagnostic.details ?? {});
472
+ return {
473
+ name: 'map-validation.json',
474
+ mimeType: 'application/json',
475
+ content: `${canonicalJson({ schemaVersion: 1, passed: validation.passed, checks: validation.checks, diagnosticCount: validation.diagnostics.length, counts, diagnostics: validation.diagnostics, omissionEvidence: omissions })}\n`,
476
+ description: 'Complete bounded map validation diagnostics, counts and omission evidence',
477
+ provenance: {
478
+ kind: 'map-validation',
479
+ toolVersion: PACKAGE_VERSION,
480
+ schemaVersion: 'map-validation.v1',
481
+ sourceHashes: evidence.sourceHashes,
482
+ metadata: {
483
+ sourceRevision: snapshot.revision,
484
+ sourceHashInventory: evidence.inventory,
485
+ diagnosticCount: validation.diagnostics.length,
486
+ },
487
+ },
488
+ };
446
489
  }
447
490
  function mapBundleWithSourceHashes(bundle, completeSourceHashes) {
448
491
  const parsed = JSON.parse(bundle.json);
@@ -529,7 +572,7 @@ export class AgentNudger {
529
572
  this.scanner = this.engine.scanner;
530
573
  this.transactions = this.engine.transactions;
531
574
  }
532
- async scan(workspaceId, principal, signal, includeExternalScriptReferences = false) {
575
+ async scan(workspaceId, principal, signal, includeExternalScriptReferences = false, includeWorldContext = false) {
533
576
  signal?.throwIfAborted();
534
577
  const workspace = this.resolver.get(workspaceId, principal);
535
578
  const sourceRoots = {
@@ -546,11 +589,38 @@ export class AgentNudger {
546
589
  patterns: [...MAP_REFERENCE_SCRIPT_PATTERNS],
547
590
  }, principal, signal)
548
591
  : undefined;
592
+ const worldContextSnapshot = includeWorldContext
593
+ ? await this.engine.scan(workspaceId, {
594
+ patterns: [
595
+ 'common/state_category/**/*.txt',
596
+ 'common/country_tags/*.txt',
597
+ ...sourceRoots.map.flatMap((root) => ['terrain', 'rivers', 'trees', 'cities'].map((name) => `${root}/${name}.bmp`)),
598
+ ],
599
+ }, principal, signal)
600
+ : undefined;
601
+ const generatedOutputsSnapshot = includeWorldContext
602
+ ? await this.engine.scan(workspaceId, {
603
+ rootKinds: ['mod'],
604
+ patterns: [
605
+ 'common/countries/*.txt',
606
+ 'common/country_tags/*.txt',
607
+ 'history/countries/*.txt',
608
+ 'history/units/*.txt',
609
+ 'common/on_actions/world.txt',
610
+ 'common/ai_areas/world.txt',
611
+ 'common/ai_strategy_plans/world.txt',
612
+ 'gfx/flags/**/*.tga',
613
+ ...sourceRoots.map.flatMap((root) => [`${root}/**/*.bmp`, `${root}/terrain/*.dds`]),
614
+ ],
615
+ }, principal, signal)
616
+ : undefined;
549
617
  signal?.throwIfAborted();
550
618
  const files = [
551
619
  ...contentSnapshot.files,
552
620
  ...locatorSnapshot.files,
553
621
  ...(externalScriptSnapshot?.files ?? []),
622
+ ...(worldContextSnapshot?.files ?? []).filter((file) => !contentSnapshot.files.some((existing) => existing.displayPath === file.displayPath)),
623
+ ...(generatedOutputsSnapshot?.files ?? []).filter((file) => ![...contentSnapshot.files, ...(worldContextSnapshot?.files ?? [])].some((existing) => existing.displayPath === file.displayPath)),
554
624
  ].sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
555
625
  const revision = hashCanonical(files.map(({ displayPath, loadOrder, sha256 }) => ({ displayPath, loadOrder, sha256 })));
556
626
  return {
@@ -590,11 +660,43 @@ export class AgentNudger {
590
660
  signal?.throwIfAborted();
591
661
  const snapshot = await this.scan(workspaceId, principal, signal, operations.some((operation) => operation.kind === 'renumber_map_entity' ||
592
662
  operation.kind === 'remap_map_references' ||
593
- 'remapReferences' in operation));
594
- const plan = await planMapOperationsAsync(snapshot.index, operations, signal);
595
- const baselineValidation = await validateMapAsync(snapshot.index, signal === undefined ? {} : { signal });
596
- // A new world has nothing to compare its bitmap format and pixels with.
663
+ 'remapReferences' in operation), operations.some((operation) => operation.kind === 'create_world'));
664
+ const referenceWorlds = new Map();
665
+ const referenceImages = [];
666
+ for (const operation of operations) {
667
+ if (operation.kind !== 'create_world' || operation.reference === undefined)
668
+ continue;
669
+ const loaded = await prepareReferenceWorld(this.resolver, workspaceId, operation, principal, signal);
670
+ referenceWorlds.set(operation.id, loaded.prepared);
671
+ referenceImages.push({
672
+ operationId: operation.id,
673
+ bytes: loaded.images[0].bytes,
674
+ source: loaded.images[0].source,
675
+ });
676
+ for (const image of loaded.images) {
677
+ snapshot.referenceSources ??= [];
678
+ const previous = snapshot.referenceSources.findIndex((file) => file.displayPath === image.source.displayPath);
679
+ if (previous < 0)
680
+ snapshot.referenceSources.push(image.source);
681
+ else
682
+ snapshot.referenceSources[previous] = image.source;
683
+ }
684
+ }
685
+ snapshot.files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
686
+ snapshot.revision = hashCanonical(mapSourceEvidence(snapshot).map(({ displayPath, loadOrder, sha256 }) => ({
687
+ displayPath,
688
+ loadOrder,
689
+ sha256,
690
+ })));
691
+ const plan = await planMapOperationsAsync(snapshot.index, operations, signal, {
692
+ referenceWorlds,
693
+ });
597
694
  const newWorld = operations.some(({ kind }) => kind === 'create_world');
695
+ const freshWorld = newWorld && snapshot.index.provinceBitmapFile?.rootKind !== 'mod';
696
+ const baselineValidation = freshWorld
697
+ ? { passed: true, diagnostics: [], checks: [] }
698
+ : await validateMapAsync(snapshot.index, signal === undefined ? {} : { signal });
699
+ // A new world has nothing to compare its bitmap format and pixels with.
598
700
  const rawValidation = await validateMapAsync(plan.finalIndex, {
599
701
  ...(newWorld ? {} : { baseline: snapshot.index }),
600
702
  ...(signal === undefined ? {} : { signal }),
@@ -615,6 +717,7 @@ export class AgentNudger {
615
717
  snapshot,
616
718
  plan,
617
719
  validation: validationWithPlan(attributedValidation, plan),
720
+ referenceImages,
618
721
  };
619
722
  }
620
723
  async plan(input) {
@@ -627,15 +730,24 @@ export class AgentNudger {
627
730
  if (snapshot.workspaceId !== input.workspaceId) {
628
731
  throw new ServiceError('MAP_PREPARED_WORKSPACE_MISMATCH', 'Prepared map operations belong to another workspace');
629
732
  }
733
+ const reportWrite = mapValidationEvidence(snapshot, validation);
734
+ const existingReport = input.artifacts?.find(({ name }) => name === reportWrite.name ||
735
+ (name.startsWith(`${reportWrite.name}.`) && name.endsWith('.chunks.json')));
736
+ const report = existingReport ??
737
+ publicArtifactLink(await this.artifacts.putChunked(this.resolver.get(input.workspaceId, input.principal), reportWrite.name, reportWrite.mimeType, reportWrite.content, reportWrite.provenance, reportWrite.description, input.signal));
738
+ const journalDiagnostics = rollupRepeatedSoftDiagnostics(validation.diagnostics, report);
739
+ const artifacts = [
740
+ ...(input.artifacts ?? []),
741
+ ...(existingReport === undefined ? [report] : []),
742
+ ];
630
743
  const transaction = await this.transactions.plan({
631
744
  workspaceId: input.workspaceId,
632
745
  ...(input.principal === undefined ? {} : { principal: input.principal }),
633
746
  operationKind: 'agent-nudger-map-changes',
634
747
  operations: plan.operations,
635
748
  changes: plan.changes,
636
- readDependencies: readDependenciesFromScannedFiles(snapshot.files),
637
- ...(input.artifacts === undefined ? {} : { artifacts: input.artifacts }),
638
- diagnostics: plan.diagnostics,
749
+ readDependencies: readDependenciesFromScannedFiles(mapSourceEvidence(snapshot)),
750
+ artifacts,
639
751
  validate: (proposed, signal) => {
640
752
  signal?.throwIfAborted();
641
753
  const expected = new Map(plan.changes.map((change) => [change.relativePath, change.content]));
@@ -655,9 +767,9 @@ export class AgentNudger {
655
767
  }) &&
656
768
  [...proposed.keys()].every((relativePath) => expected.has(relativePath));
657
769
  const diagnostics = exactProposedBytes
658
- ? validation.diagnostics
770
+ ? journalDiagnostics
659
771
  : [
660
- ...validation.diagnostics,
772
+ ...journalDiagnostics,
661
773
  {
662
774
  code: 'MAP_TRANSACTION_BYTES_MISMATCH',
663
775
  severity: 'blocker',
@@ -737,7 +849,7 @@ export class AgentNudger {
737
849
  bundle,
738
850
  artifacts,
739
851
  revision: snapshot.revision,
740
- filesScanned: snapshot.files.map(({ displayPath }) => displayPath),
852
+ filesScanned: mapSourceEvidence(snapshot).map(({ displayPath }) => displayPath),
741
853
  };
742
854
  }
743
855
  async renderDiffAndStore(workspaceId, operations, options = {}) {
@@ -764,9 +876,30 @@ export class AgentNudger {
764
876
  }, prepared);
765
877
  return { ...diff, ...planned };
766
878
  }
879
+ /** Build validated offline evidence without planning or applying a write transaction. */
880
+ async previewRewriteWithDiff(input) {
881
+ const prepared = await this.prepareOperations(input.workspaceId, input.operations, input.principal, input.signal);
882
+ const diff = await this.renderPreparedDiffAndStore(prepared, input.operations, {
883
+ ...(input.diffScale === undefined ? {} : { scale: input.diffScale }),
884
+ ...(input.principal === undefined ? {} : { principal: input.principal }),
885
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
886
+ });
887
+ return {
888
+ ...diff,
889
+ validation: prepared.validation,
890
+ diagnostics: rollupRepeatedSoftDiagnostics(prepared.validation.diagnostics, publicArtifactLink(diff.artifacts.find((artifact) => artifact.name === 'map-validation.json' ||
891
+ (artifact.name.startsWith('map-validation.json.') &&
892
+ artifact.name.endsWith('.chunks.json'))))),
893
+ };
894
+ }
767
895
  async renderPreparedDiffAndStore(prepared, operations, options) {
768
896
  const budget = new RenderBudget();
769
897
  const { snapshot, plan, validation } = prepared;
898
+ const failedWorld = operations.some(({ kind }) => kind === 'create_world') &&
899
+ generatedWorldOperationIds(plan).size === 0 &&
900
+ plan.changes.length === 0;
901
+ // A rolled-back generation has no proposed world. Never present inherited game geography
902
+ // or all its province IDs as the result of a failed operation.
770
903
  const review = {
771
904
  operationIds: plan.operations.map(({ id }) => id),
772
905
  affectedFiles: plan.changes.map(({ relativePath, operationIds, mediaType, content }) => ({
@@ -784,6 +917,63 @@ export class AgentNudger {
784
917
  allocations: plan.allocations,
785
918
  validation,
786
919
  };
920
+ if (failedWorld) {
921
+ const report = mapValidationEvidence(snapshot, validation);
922
+ const json = canonicalJson({ generated: false, blockers: plan.blockers });
923
+ const emptyHash = sha256Bytes('');
924
+ // No raster exists after a failed first generation. This empty internal bundle is never
925
+ // published as an image; it carries only zero changed IDs to the normal result adapter.
926
+ const bundle = {
927
+ width: 0,
928
+ height: 0,
929
+ png: Buffer.alloc(0),
930
+ json,
931
+ html: '',
932
+ hashes: { png: emptyHash, json: sha256Bytes(json), html: emptyHash },
933
+ changedProvinceIds: [],
934
+ review,
935
+ semantic: {
936
+ definitions: [],
937
+ stateMembership: [],
938
+ regionMembership: [],
939
+ states: [],
940
+ ports: [],
941
+ buildingPositions: [],
942
+ unitPositions: [],
943
+ weatherPositions: [],
944
+ entityLocators: [],
945
+ supplyNodes: [],
946
+ railways: [],
947
+ adjacencies: [],
948
+ normalAdjacencies: [],
949
+ supplyNodesChanged: false,
950
+ railwaysChanged: false,
951
+ adjacenciesChanged: false,
952
+ normalAdjacenciesChanged: false,
953
+ },
954
+ };
955
+ const artifacts = await this.artifacts.withAtomicChunkedWrites(this.resolver.get(snapshot.workspaceId, options.principal), [
956
+ report,
957
+ {
958
+ name: 'map-generation-failure.json',
959
+ mimeType: 'application/json',
960
+ content: json,
961
+ provenance: {
962
+ ...report.provenance,
963
+ kind: 'map-generation-failure',
964
+ schemaVersion: 'map-generation-failure.v1',
965
+ },
966
+ },
967
+ ], (stored) => Promise.resolve([...stored]), options.signal);
968
+ return {
969
+ bundle,
970
+ proposedBundle: bundle,
971
+ plan,
972
+ artifacts,
973
+ revision: snapshot.revision,
974
+ filesScanned: mapSourceEvidence(snapshot).map(({ displayPath }) => displayPath),
975
+ };
976
+ }
787
977
  const comparisonOverlays = [
788
978
  'adjacencies',
789
979
  'building-positions',
@@ -798,13 +988,16 @@ export class AgentNudger {
798
988
  'victory-points',
799
989
  'weather-positions',
800
990
  ];
801
- const hasBeforeMap = snapshot.index.raster !== undefined ||
802
- snapshot.index.diagnostics.some(({ code }) => code.startsWith('RENDER_'));
991
+ const freshWorld = operations.some(({ kind }) => kind === 'create_world') &&
992
+ snapshot.index.provinceBitmapFile?.rootKind !== 'mod';
993
+ const hasBeforeMap = !freshWorld &&
994
+ (snapshot.index.raster !== undefined ||
995
+ snapshot.index.diagnostics.some(({ code }) => code.startsWith('RENDER_')));
803
996
  const sameSize = snapshot.index.provinceBitmap !== undefined &&
804
997
  snapshot.index.provinceBitmap.width === plan.finalIndex.provinceBitmap?.width &&
805
998
  snapshot.index.provinceBitmap.height === plan.finalIndex.provinceBitmap.height;
806
999
  const [rawBundle, rawBeforeBundle, rawProposedBundle] = await Promise.all([
807
- sameSize
1000
+ sameSize && !freshWorld
808
1001
  ? renderMapDiff(snapshot.index, plan.finalIndex, {
809
1002
  ...(options.scale === undefined ? {} : { scale: options.scale }),
810
1003
  ...(options.signal === undefined ? {} : { signal: options.signal }),
@@ -874,6 +1067,7 @@ export class AgentNudger {
874
1067
  },
875
1068
  });
876
1069
  const writes = [
1070
+ mapValidationEvidence(snapshot, validation),
877
1071
  {
878
1072
  name: 'map-diff.png',
879
1073
  mimeType: 'image/png',
@@ -928,6 +1122,16 @@ export class AgentNudger {
928
1122
  content: beforeBundle.html,
929
1123
  provenance: comparisonProvenance('before'),
930
1124
  });
1125
+ const generatedWorlds = generatedWorldOperationIds(plan);
1126
+ if (generatedWorlds.size > 0)
1127
+ writes.push(...(await buildWorldPreviewArtifacts({
1128
+ index: plan.finalIndex,
1129
+ references: prepared.referenceImages.filter(({ operationId }) => generatedWorlds.has(operationId)),
1130
+ diagnostics: validation.diagnostics,
1131
+ provenance,
1132
+ budget,
1133
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
1134
+ })));
931
1135
  const artifacts = await this.artifacts.withAtomicChunkedWrites(workspace, writes, (stored) => Promise.resolve([...stored]), options.signal);
932
1136
  return {
933
1137
  bundle,
@@ -936,7 +1140,7 @@ export class AgentNudger {
936
1140
  plan,
937
1141
  artifacts,
938
1142
  revision: snapshot.revision,
939
- filesScanned: snapshot.files.map(({ displayPath }) => displayPath),
1143
+ filesScanned: mapSourceEvidence(snapshot).map(({ displayPath }) => displayPath),
940
1144
  };
941
1145
  }
942
1146
  }
@@ -1,5 +1,6 @@
1
1
  import { boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
2
2
  import { canonicalJson, hashCanonical } from '../core/canonical.js';
3
+ import { diagnosticSeverityCounts } from '../core/diagnostic-rollup.js';
3
4
  import { autonomousResultArtifacts, } from '../core/transaction-execution.js';
4
5
  import { emptyServiceResult, ServiceError } from '../core/result.js';
5
6
  import { setInlineFilesScanned } from '../core/operation-result.js';
@@ -8,6 +9,7 @@ import { allocateMapIdentifiersAsync, } from './operations.js';
8
9
  import { buildMapCatalog, lookupMapCoordinate, searchMapCatalog } from './catalog.js';
9
10
  import { renderMap } from './render.js';
10
11
  import { exportProvinceGeometryRowRuns } from './service.js';
12
+ import { generatedWorldOperationIds } from './world-preview.js';
11
13
  const jsonObject = z.record(z.string(), z.json());
12
14
  const mapRewriteRecipeSchema = z
13
15
  .object({
@@ -44,7 +46,7 @@ function compactRecordDiffs(entries) {
44
46
  after: compactNullableText(after),
45
47
  }));
46
48
  }
47
- function compactMapSemanticDiff(semantic) {
49
+ export function compactMapSemanticDiff(semantic) {
48
50
  const counts = {
49
51
  definitions: semantic.definitions.length,
50
52
  stateMembership: semantic.stateMembership.length,
@@ -60,7 +62,7 @@ function compactMapSemanticDiff(semantic) {
60
62
  adjacencies: semantic.adjacencies.length,
61
63
  normalAdjacencies: semantic.normalAdjacencies.length,
62
64
  };
63
- return {
65
+ const result = {
64
66
  semantic: {
65
67
  definitions: semantic.definitions
66
68
  .slice(0, mapInlineDiffLimit)
@@ -113,6 +115,15 @@ function compactMapSemanticDiff(semantic) {
113
115
  (before?.length ?? 0) > mapInlineTextLimit ||
114
116
  (after?.length ?? 0) > mapInlineTextLimit),
115
117
  };
118
+ // Bound the aggregate, not just each collection: a complete world replaces every category.
119
+ // Full diffs remain in map-diff.json; leave enough wire space for preview image links.
120
+ while (Buffer.byteLength(canonicalJson(result.semantic), 'utf8') > 8_192) {
121
+ for (const value of Object.values(result.semantic))
122
+ if (Array.isArray(value))
123
+ value.splice(Math.floor(value.length / 2));
124
+ result.truncated = true;
125
+ }
126
+ return result;
116
127
  }
117
128
  function validationSummary(validation) {
118
129
  return { passed: validation.passed, checks: validation.checks };
@@ -297,6 +308,64 @@ export function completeMapRewrite(workspaceId, execution, recipe) {
297
308
  ].slice(0, 100);
298
309
  return result;
299
310
  }
311
+ /** Generate and validate the same proposed map without creating a write transaction. */
312
+ export async function previewMapRewrite(nudger, input, context) {
313
+ await context.progress(0, 5, 'Generating and validating an offline map preview');
314
+ const planned = await nudger.previewRewriteWithDiff({
315
+ workspaceId: context.workspaceId,
316
+ operations: input.operations,
317
+ ...(input.diffScale === undefined ? {} : { diffScale: input.diffScale }),
318
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
319
+ ...(context.signal === undefined ? {} : { signal: context.signal }),
320
+ });
321
+ const compactSemantic = compactMapSemanticDiff(planned.bundle.semantic);
322
+ const index = planned.plan.finalIndex;
323
+ const result = emptyServiceResult(context.workspaceId, {
324
+ execution: 'preview',
325
+ allocations: planned.plan.allocations.slice(0, 100).map(compactAllocationEvidence),
326
+ operationBlockers: planned.plan.blockers
327
+ .slice(0, 100)
328
+ .map(({ code, message, operationId }) => ({ code, message, operationId })),
329
+ expectedChangedBounds: planned.plan.expectedChangedBounds ?? null,
330
+ changedProvinceIds: planned.bundle.changedProvinceIds.slice(0, 256),
331
+ changedProvinceCount: planned.bundle.changedProvinceIds.length,
332
+ semanticDiff: compactSemantic.semantic,
333
+ semanticDiffCounts: compactSemantic.counts,
334
+ semanticDiffTruncated: compactSemantic.truncated,
335
+ fileCount: planned.plan.changes.length,
336
+ artifactCount: planned.artifacts.length,
337
+ ...(input.operations.some(({ kind }) => kind === 'create_world') &&
338
+ generatedWorldOperationIds(planned.plan).size === 0
339
+ ? {}
340
+ : {
341
+ preview: {
342
+ offlineRepresentation: true,
343
+ width: index.raster?.width ?? 0,
344
+ height: index.raster?.height ?? 0,
345
+ provinces: index.definitions.filter(({ id }) => id !== 0).length,
346
+ states: index.states.length,
347
+ regions: index.regions.length,
348
+ countries: new Set(index.states.flatMap(({ owner }) => (owner === undefined ? [] : [owner]))).size,
349
+ warningCount: diagnosticSeverityCounts(planned.diagnostics).warning,
350
+ },
351
+ }),
352
+ });
353
+ result.status = planned.validation.passed ? 'ok' : 'blocked';
354
+ result.code = planned.validation.passed ? 'MAP_PREVIEW_READY' : 'MAP_PREVIEW_BLOCKED';
355
+ setInlineFilesScanned(result, planned.filesScanned);
356
+ result.proposedFiles = planned.plan.changes.slice(0, 100).map(({ relativePath }) => relativePath);
357
+ result.diagnostics = planned.diagnostics.slice(0, 100);
358
+ result.artifacts = planned.artifacts.map(publicArtifactLink);
359
+ result.validation = validationSummary(planned.validation);
360
+ result.blockers = [
361
+ ...planned.plan.blockers.map(({ code, message }) => ({ code, message })),
362
+ ...planned.diagnostics
363
+ .filter(({ severity }) => severity === 'error' || severity === 'blocker')
364
+ .map(({ code, message }) => ({ code, message })),
365
+ ].slice(0, 100);
366
+ await context.progress(5, 5, 'Offline map preview complete; workspace files were not changed');
367
+ return result;
368
+ }
300
369
  export async function inspectMap(engine, nudger, input, context) {
301
370
  await context.progress(0, 3, 'Inspecting and validating the map');
302
371
  if (input.lookupOnly) {
@@ -0,0 +1,148 @@
1
+ import { compareCodeUnits } from '../core/canonical.js';
2
+ import { hasBlockingDiagnostics } from '../core/diagnostics.js';
3
+ import { ServiceError } from '../core/result.js';
4
+ import { assignments, childBlocks, firstScalar, parseClausewitz } from '../core/source/index.js';
5
+ import { BmpImage } from './bmp.js';
6
+ function normalized(value) {
7
+ return value.replaceAll('\\', '/').replace(/^\.\//u, '').toLowerCase();
8
+ }
9
+ function invalid(file, message) {
10
+ throw new ServiceError('MAP_WORLD_CONTEXT_INVALID', message, { file: file.displayPath });
11
+ }
12
+ function documentFor(file) {
13
+ const document = parseClausewitz(file.bytes, file.displayPath);
14
+ if (hasBlockingDiagnostics(document.diagnostics))
15
+ invalid(file, 'Installed world conventions contain malformed Clausewitz syntax.');
16
+ return document;
17
+ }
18
+ /**
19
+ * Build conventions exclusively from authorized, already-scanned active sources. Missing
20
+ * conventions retain the generator's defaults; present but invalid conventions are refused.
21
+ * Source bitmap bytes and script files are never modified or copied into generated mod files.
22
+ */
23
+ export function buildWorldContext(base) {
24
+ const context = {
25
+ palettes: {},
26
+ mapRoot: base.sourceRoots.map[0] ?? 'map',
27
+ statesRoot: base.sourceRoots.states[0] ?? 'history/states',
28
+ localisationRoot: base.sourceRoots.localisation[0] ?? 'localisation',
29
+ };
30
+ const continentKeys = new Set();
31
+ for (const file of base.sourceFiles.filter((file) => base.sourceRoots.map.some((root) => normalized(file.relativePath) === normalized(`${root}/continent.txt`)))) {
32
+ for (const block of childBlocks(documentFor(file).root, 'continents')) {
33
+ for (const entry of block.entries) {
34
+ if (entry.type !== 'scalar' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/u.test(entry.value))
35
+ invalid(file, 'Continent identifiers must be simple scalar keys.');
36
+ continentKeys.add(entry.value);
37
+ }
38
+ }
39
+ }
40
+ if (continentKeys.size)
41
+ context.continentKeys = [...continentKeys].sort(compareCodeUnits);
42
+ const inheritedCountryTags = new Set();
43
+ for (const file of base.activeFiles.all.filter((file) => /^common\/country_tags\/[^/]+\.txt$/u.test(normalized(file.relativePath))))
44
+ for (const entry of assignments(documentFor(file).root))
45
+ if (/^[A-Z][A-Z0-9]{2}$/u.test(entry.key.value) && entry.value.type === 'scalar')
46
+ inheritedCountryTags.add(entry.key.value);
47
+ if (inheritedCountryTags.size)
48
+ context.inheritedCountryTags = [...inheritedCountryTags].sort(compareCodeUnits);
49
+ for (const name of ['terrain', 'rivers', 'trees', 'cities']) {
50
+ const file = base.sourceRoots.map
51
+ .map((root) => base.activeFiles.byRelativePath.get(normalized(`${root}/${name}.bmp`)))
52
+ .find((entry) => entry !== undefined);
53
+ if (file === undefined)
54
+ continue;
55
+ let bitmap;
56
+ try {
57
+ bitmap = BmpImage.decode(file.bytes);
58
+ }
59
+ catch {
60
+ invalid(file, `The installed ${name} bitmap is malformed or unsupported.`);
61
+ }
62
+ const minimumEntries = { terrain: 22, rivers: 256, trees: 30, cities: 16 }[name];
63
+ if (bitmap.bitsPerPixel !== 8 || bitmap.palette.length < minimumEntries)
64
+ invalid(file, `The installed ${name} bitmap requires an 8-bit palette with at least ${minimumEntries} entries.`);
65
+ context.palettes[name] = bitmap.palette.map((color) => ({ ...color }));
66
+ }
67
+ const terrainFiles = base.activeFiles.all
68
+ .filter((file) => /^common\/terrain\/[^/]+\.txt$/u.test(normalized(file.relativePath)))
69
+ .sort((a, b) => compareCodeUnits(normalized(a.relativePath), normalized(b.relativePath)));
70
+ const terrainIndex = new Map();
71
+ for (const file of terrainFiles) {
72
+ const candidates = new Map();
73
+ const terrain = assignments(documentFor(file).root, 'terrain');
74
+ for (const section of terrain)
75
+ if (section.value.type !== 'block')
76
+ invalid(file, 'Graphical terrain sections must be blocks.');
77
+ for (const { value: block } of terrain) {
78
+ if (block.type !== 'block')
79
+ continue;
80
+ if (block.entries.some((entry) => entry.type !== 'assignment'))
81
+ invalid(file, 'Graphical terrain sections require named entries.');
82
+ for (const entry of assignments(block)) {
83
+ if (entry.value.type !== 'block')
84
+ invalid(file, 'Graphical terrain entries must be blocks.');
85
+ const type = firstScalar(entry.value, 'type')?.value;
86
+ const colors = childBlocks(entry.value, 'color');
87
+ if (type === undefined ||
88
+ type.length === 0 ||
89
+ assignments(entry.value, 'type').length !== 1 ||
90
+ assignments(entry.value, 'color').length !== 1 ||
91
+ colors.length !== 1 ||
92
+ colors[0].entries.length === 0)
93
+ invalid(file, 'Graphical terrain requires a type and a nonempty color-index block.');
94
+ let index = 256;
95
+ for (const color of colors[0].entries) {
96
+ if (color.type !== 'scalar' || !/^\d+$/u.test(color.value))
97
+ invalid(file, 'Graphical terrain colors must be integer palette indices.');
98
+ const value = Number(color.value);
99
+ if (!Number.isSafeInteger(value) || value > 255)
100
+ invalid(file, 'Graphical terrain palette indices must be between 0 and 255.');
101
+ if (context.palettes.terrain !== undefined && value >= context.palettes.terrain.length)
102
+ invalid(file, 'Graphical terrain refers to a missing terrain palette entry.');
103
+ index = Math.min(index, value);
104
+ }
105
+ // Prefer ordinary category variants over cross-category names or permanent snow.
106
+ // Within an equal rank, the lowest declared index is the stable representative.
107
+ const name = entry.key.value;
108
+ let rank = name === type ? 0 : name.startsWith(`${type}_`) || /^terrain_\d+$/u.test(name) ? 1 : 2;
109
+ if (firstScalar(entry.value, 'perm_snow')?.value === 'yes')
110
+ rank += 4;
111
+ const previous = candidates.get(type);
112
+ if (previous === undefined ||
113
+ rank < previous.rank ||
114
+ (rank === previous.rank && index < previous.index))
115
+ candidates.set(type, { index, rank });
116
+ }
117
+ }
118
+ // Later active filenames replace an earlier file's representative for that category.
119
+ for (const [type, candidate] of candidates)
120
+ terrainIndex.set(type, candidate.index);
121
+ }
122
+ if (terrainIndex.size > 0)
123
+ context.terrainIndex = new Map([...terrainIndex].sort(([a], [b]) => compareCodeUnits(a, b)));
124
+ const categoryFiles = base.activeFiles.all
125
+ .filter((file) => /^common\/state_category\/[^/]+\.txt$/u.test(normalized(file.relativePath)))
126
+ .sort((a, b) => compareCodeUnits(normalized(a.relativePath), normalized(b.relativePath)));
127
+ if (categoryFiles.length > 0) {
128
+ const categories = new Set();
129
+ for (const file of categoryFiles) {
130
+ const blocks = childBlocks(documentFor(file).root, 'state_categories');
131
+ if (blocks.length === 0)
132
+ invalid(file, 'State category conventions require a state_categories block.');
133
+ for (const block of blocks) {
134
+ if (block.entries.some((entry) => entry.type !== 'assignment'))
135
+ invalid(file, 'State category conventions require named entries.');
136
+ for (const entry of assignments(block)) {
137
+ if (entry.value.type !== 'block')
138
+ invalid(file, 'State category definitions must be blocks.');
139
+ categories.add(entry.key.value);
140
+ }
141
+ }
142
+ }
143
+ if (categories.size === 0)
144
+ invalid(categoryFiles[0], 'Installed state category conventions define no categories.');
145
+ context.stateCategories = new Set([...categories].sort(compareCodeUnits));
146
+ }
147
+ return context;
148
+ }