hoi4-agent-tools 4.4.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 (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
  3. package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
  4. package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
  5. package/dist/hoi4_agent_tools/focus/drift.js +34 -0
  6. package/dist/hoi4_agent_tools/focus/service.js +9 -3
  7. package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
  8. package/dist/hoi4_agent_tools/map/model.js +2 -1
  9. package/dist/hoi4_agent_tools/map/operations.js +173 -6
  10. package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
  11. package/dist/hoi4_agent_tools/map/service.js +15 -0
  12. package/dist/hoi4_agent_tools/map/tool-operations.js +41 -1
  13. package/dist/hoi4_agent_tools/map/world-context.js +23 -0
  14. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  15. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  16. package/dist/hoi4_agent_tools/map/world.js +100 -39
  17. package/dist/hoi4_agent_tools/schemas/map.js +71 -13
  18. package/dist/hoi4_agent_tools/version.js +1 -1
  19. package/docs/focus.md +6 -0
  20. package/docs/map.md +34 -0
  21. package/npm-shrinkwrap.json +2 -2
  22. package/package.json +1 -1
  23. package/schemas/configuration.schema.json +1 -1
  24. package/schemas/continuous-focus-palette.schema.json +1 -1
  25. package/schemas/custom-weighted-pool.schema.json +1 -1
  26. package/schemas/decision-inspect-input.schema.json +1 -1
  27. package/schemas/error-log-input.schema.json +1 -1
  28. package/schemas/event-feature-manifest.schema.json +1 -1
  29. package/schemas/focus-plan.schema.json +1 -1
  30. package/schemas/focus-planning-sidecar.schema.json +1 -1
  31. package/schemas/geography-model.schema.json +1 -1
  32. package/schemas/gui-animation-source.schema.json +1 -1
  33. package/schemas/gui-helper.schema.json +1 -1
  34. package/schemas/gui-scenario.schema.json +1 -1
  35. package/schemas/helper-expansion-request.schema.json +1 -1
  36. package/schemas/helper-expansion-summary.schema.json +1 -1
  37. package/schemas/impact-inspect-input.schema.json +1 -1
  38. package/schemas/map-operation.schema.json +725 -589
  39. package/schemas/mechanic-test-input.schema.json +1 -1
  40. package/schemas/mod-index-input.schema.json +1 -1
  41. package/schemas/operation-result.schema.json +1 -1
  42. package/schemas/package-check-input.schema.json +1 -1
  43. package/schemas/package-manifest.schema.json +1 -1
  44. package/schemas/probability-analysis-result.schema.json +1 -1
  45. package/schemas/probability-compare-input.schema.json +1 -1
  46. package/schemas/probability-evaluate-input.schema.json +1 -1
  47. package/schemas/probability-inspect-input.schema.json +1 -1
  48. package/schemas/probability-render-input.schema.json +1 -1
  49. package/schemas/probability-scenario-set.schema.json +1 -1
  50. package/schemas/probability-sequence-input.schema.json +1 -1
  51. package/schemas/probability-simulate-input.schema.json +1 -1
  52. package/schemas/probability-sweep-input.schema.json +1 -1
  53. package/schemas/reference-context-input.schema.json +1 -1
  54. package/schemas/reference-read-input.schema.json +1 -1
  55. package/schemas/reference-search-input.schema.json +1 -1
  56. package/schemas/reference-sheets.schema.json +1 -1
  57. package/schemas/scenario-suite.schema.json +1 -1
  58. package/schemas/scenario-test-input.schema.json +1 -1
  59. package/schemas/script-validate-input.schema.json +1 -1
  60. package/schemas/shared-scenario.schema.json +1 -1
  61. package/schemas/source-lookup-input.schema.json +1 -1
  62. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.5.0 - 2026-10-25
4
+
5
+ - Refuse `hoi4.focus_rewrite` with `FOCUS_SOURCE_CHANGED_SINCE_PLAN` when the focus source changed in meaning after the plan was inspected, instead of reverting the external change to the plan's values (national trees and continuous palettes). Comment and formatting changes and a source that already equals the plan are still accepted. Reported by the HOI4 Factory S01 EXP-02 probe.
6
+ - Refuse every rewrite of a file that is not valid UTF-8 and contains bytes Windows-1252 cannot represent (0x81, 0x8D, 0x8F, 0x90, 0x9D) with `SOURCE_ENCODING_NOT_ROUND_TRIPPABLE`. Previously the encoder wrote 0x9D for each of them without a diagnostic. Reads and inspection continue and add a warning with the same code. Valid UTF-8 and valid Windows-1252 files are unchanged.
7
+ - Place countries the mod already owns (ADR 0069): `create_world.countries` accepts `{ "source": "workspace", "realms": [...] }` besides the array. Each realm is a declared tag with a capital point and optional seeds, claim and exclusion polygons and a weight; `unowned` polygons and `islands` (`nearest`, `one-owner`, `unowned`) decide land no country claims. The world then writes only the map, states, strategic regions and their names, and none of the countries' files. New refusals `MAP_WORLD_REALM_TAG_UNKNOWN`, `MAP_WORLD_REALM_TAG_DYNAMIC`, `MAP_WORLD_REALM_POINT_OUTSIDE`, `MAP_WORLD_REALM_POINT_WATER`, `MAP_WORLD_REALM_POINT_CONFLICT` and `MAP_WORLD_REALM_CLAIM_OVERLAP`; notes `MAP_WORLD_REALMS_PLACED` and `MAP_WORLD_STATE_UNOWNED`; result field `data.realms` and artifact `map-world-realms.json`. Public schema change: `countries` becomes a union of the existing array and the new object (minor version). Requests with the array are unchanged.
8
+ - Report what a world write does to the previous world's files: `MAP_WORLD_OUTPUTS_RECONCILED`, `data.outputs` (written, retired and released counts with up to 200 paths each) and the artifact `map-world-outputs.json`. With workspace countries an edited country file of the previous world is released to the mod (`MAP_WORLD_OUTPUT_RELEASED`) instead of refusing the write; every other edited output still refuses with `MANAGED_OUTPUT_EDITED`.
9
+ - Report the declared country tags after every world write (`MAP_WORLD_TAG_COUNT`) and warn above 439 (`MAP_WORLD_TAG_COUNT_HIGH`), the count at which one measured game build overflowed its flag sheet.
10
+
3
11
  ## 4.4.0 - 2026-10-24
4
12
 
5
13
  - Collect test fixtures that a killed or crashed test run left in the temporary folder: the test setup now also sweeps `hoi4-*` fixtures that carry an owner record of a process that has ended (`sweepTemporaryDirectories` option `ownedFixturesOnly`). Unmarked folders and folders of a live process are never removed. Interrupted local runs had left 2,441 such folders (640 MB).
@@ -16,6 +16,10 @@ const manifestSchema = z
16
16
  .strict();
17
17
  /** Pure planning only: the shared transaction engine performs every replacement and deletion. */
18
18
  export function reconcileManagedOutputs(input) {
19
+ return planManagedOutputs(input).changes;
20
+ }
21
+ /** Plans the managed-output reconciliation and reports what it writes, retires and releases. */
22
+ export function planManagedOutputs(input) {
19
23
  const sources = new Map(input.sources
20
24
  .filter((f) => f.rootKind === 'mod')
21
25
  .map((f) => [f.relativePath.replaceAll('\\', '/').toLowerCase(), f]));
@@ -32,6 +36,9 @@ export function reconcileManagedOutputs(input) {
32
36
  throw new ServiceError('MANAGED_OUTPUT_MANIFEST_INVALID', 'Generated-output inventory is invalid');
33
37
  }
34
38
  })();
39
+ const outputs = input.proposed.filter((f) => f.content !== null && input.permitsPath(f.relativePath));
40
+ const next = new Set(outputs.map((f) => f.relativePath.toLowerCase()));
41
+ const released = new Set();
35
42
  const seen = new Set();
36
43
  for (const file of previous) {
37
44
  const key = file.relativePath.toLowerCase();
@@ -43,19 +50,26 @@ export function reconcileManagedOutputs(input) {
43
50
  throw new ServiceError('MANAGED_OUTPUT_MANIFEST_INVALID', 'Generated-output inventory contains an unsupported or duplicate path');
44
51
  seen.add(key);
45
52
  const current = sources.get(key);
46
- if (current !== undefined && sha256Bytes(current.bytes) !== file.sha256)
47
- throw new ServiceError('MANAGED_OUTPUT_EDITED', 'A generated file has been edited; regeneration would discard those changes', { relativePath: file.relativePath });
53
+ if (current === undefined || sha256Bytes(current.bytes) === file.sha256)
54
+ continue;
55
+ if (!next.has(key) && input.releasable?.(file.relativePath) === true) {
56
+ released.add(key);
57
+ continue;
58
+ }
59
+ throw new ServiceError('MANAGED_OUTPUT_EDITED', 'A generated file has been edited; regeneration would discard those changes', { relativePath: file.relativePath });
48
60
  }
49
- const outputs = input.proposed.filter((f) => f.content !== null && input.permitsPath(f.relativePath));
50
- const next = new Set(outputs.map((f) => f.relativePath.toLowerCase()));
51
61
  const deletions = previous
52
- .filter((f) => !next.has(f.relativePath.toLowerCase()) && sources.has(f.relativePath.toLowerCase()))
62
+ .filter((f) => {
63
+ const key = f.relativePath.toLowerCase();
64
+ return !next.has(key) && !released.has(key) && sources.has(key);
65
+ })
53
66
  .map((f) => ({
54
67
  relativePath: f.relativePath,
55
68
  content: null,
56
69
  operationIds: [input.operationId],
57
70
  }));
58
- return [
71
+ const sorted = (paths) => [...paths].sort(compareCodeUnits);
72
+ const changes = [
59
73
  ...deletions,
60
74
  {
61
75
  relativePath: input.manifestPath,
@@ -69,4 +83,10 @@ export function reconcileManagedOutputs(input) {
69
83
  mediaType: 'application/json',
70
84
  },
71
85
  ];
86
+ return {
87
+ changes,
88
+ written: sorted(outputs.map((f) => f.relativePath)),
89
+ retired: sorted(deletions.map((f) => f.relativePath)),
90
+ released: sorted(previous.filter((f) => released.has(f.relativePath.toLowerCase())).map((f) => f.relativePath)),
91
+ };
72
92
  }
@@ -1,6 +1,7 @@
1
1
  import iconv from 'iconv-lite';
2
2
  import { ServiceError } from '../result.js';
3
3
  const utf8Bom = Buffer.from([0xef, 0xbb, 0xbf]);
4
+ const REPLACEMENT_CHARACTER = String.fromCharCode(0xfffd);
4
5
  function detectNewline(text) {
5
6
  const crlf = text.indexOf('\r\n');
6
7
  const lf = text.indexOf('\n');
@@ -22,10 +23,55 @@ function isUtf8(bytes) {
22
23
  return false;
23
24
  }
24
25
  }
26
+ let windows1252RoundTrip;
27
+ function windows1252Table() {
28
+ windows1252RoundTrip ??= Array.from({ length: 256 }, (_, value) => {
29
+ const original = Buffer.from([value]);
30
+ const decoded = iconv.decode(original, 'windows-1252');
31
+ return (decoded !== REPLACEMENT_CHARACTER && iconv.encode(decoded, 'windows-1252').equals(original));
32
+ });
33
+ return windows1252RoundTrip;
34
+ }
35
+ function findWindows1252Loss(bytes) {
36
+ const table = windows1252Table();
37
+ let count = 0;
38
+ let firstOffset = -1;
39
+ const seen = new Set();
40
+ for (let offset = 0; offset < bytes.length; offset += 1) {
41
+ const value = bytes[offset];
42
+ if (table[value])
43
+ continue;
44
+ if (firstOffset < 0)
45
+ firstOffset = offset;
46
+ count += 1;
47
+ seen.add(value);
48
+ }
49
+ if (count === 0)
50
+ return undefined;
51
+ return { count, firstOffset, byteValues: [...seen].sort((left, right) => left - right) };
52
+ }
53
+ /**
54
+ * Refuses a mutation of a decoded source that cannot be re-encoded to its original bytes.
55
+ * Reads of such a source continue to work; only writes are blocked.
56
+ */
57
+ export function assertSourceEncodingPreserved(decoded, sourcePath) {
58
+ const loss = decoded.encodingLoss;
59
+ if (loss === undefined)
60
+ return;
61
+ const values = loss.byteValues.map((value) => `0x${value.toString(16).toUpperCase()}`);
62
+ throw new ServiceError('SOURCE_ENCODING_NOT_ROUND_TRIPPABLE', `Source is not valid UTF-8 and contains ${loss.count} byte(s) (${values.join(', ')}) that Windows-1252 cannot represent; editing it would silently rewrite them. Convert the file to UTF-8 or replace those bytes, then retry`, {
63
+ path: sourcePath,
64
+ encoding: decoded.encoding,
65
+ byteCount: loss.count,
66
+ byteValues: values,
67
+ firstByteOffset: loss.firstOffset,
68
+ });
69
+ }
25
70
  export function decodeSource(input) {
26
71
  const bytes = Buffer.from(input);
27
72
  let encoding;
28
73
  let text;
74
+ let encodingLoss;
29
75
  if (bytes.subarray(0, 3).equals(utf8Bom)) {
30
76
  encoding = 'utf8-bom';
31
77
  text = bytes.subarray(3).toString('utf8');
@@ -37,16 +83,24 @@ export function decodeSource(input) {
37
83
  else {
38
84
  encoding = 'windows-1252';
39
85
  text = iconv.decode(bytes, 'windows-1252');
86
+ encodingLoss = findWindows1252Loss(bytes);
40
87
  }
41
- return { encoding, text, bytes, newline: detectNewline(text) };
88
+ return {
89
+ encoding,
90
+ text,
91
+ bytes,
92
+ newline: detectNewline(text),
93
+ ...(encodingLoss === undefined ? {} : { encodingLoss }),
94
+ };
42
95
  }
43
96
  export function encodeSource(text, encoding) {
44
97
  if (encoding === 'utf8')
45
98
  return Buffer.from(text, 'utf8');
46
99
  if (encoding === 'utf8-bom')
47
100
  return Buffer.concat([utf8Bom, Buffer.from(text, 'utf8')]);
101
+ // U+FFFD is what undefined Windows-1252 bytes decode to; iconv-lite would write it back as 0x9D.
48
102
  const encoded = iconv.encode(text, 'windows-1252');
49
- if (iconv.decode(encoded, 'windows-1252') !== text) {
103
+ if (text.includes(REPLACEMENT_CHARACTER) || iconv.decode(encoded, 'windows-1252') !== text) {
50
104
  throw new ServiceError('SOURCE_ENCODING_UNREPRESENTABLE', 'The edit introduces characters that cannot be represented in the source encoding', { encoding });
51
105
  }
52
106
  return encoded;
@@ -2,7 +2,7 @@ import { hasBlockingDiagnostics } from '../diagnostics.js';
2
2
  import { ServiceError } from '../result.js';
3
3
  import { sha256Bytes } from '../canonical.js';
4
4
  import { sourceDocumentCacheKey, sourceDocuments } from './cache.js';
5
- import { decodeSource, encodeSource } from './encoding.js';
5
+ import { assertSourceEncodingPreserved, decodeSource, encodeSource, } from './encoding.js';
6
6
  import { createSourceLineIndex, isTrivia, lexClausewitz, locationFor, } from './lexer.js';
7
7
  import { SOURCE_LINE_LIMIT, SOURCE_MAX_BYTES, SOURCE_MAX_NESTING, SourceDiagnosticCollector, } from './limits.js';
8
8
  function scalarValue(token) {
@@ -298,6 +298,20 @@ function parseClausewitzUncached(bytes, sourcePath) {
298
298
  sourceRevision: sha256Bytes(decoded.bytes),
299
299
  };
300
300
  }
301
+ const encodingLoss = decoded.encodingLoss;
302
+ if (encodingLoss !== undefined) {
303
+ diagnostics.add(() => ({
304
+ code: 'SOURCE_ENCODING_NOT_ROUND_TRIPPABLE',
305
+ severity: 'warning',
306
+ category: 'syntax',
307
+ message: 'Source is not valid UTF-8 and contains bytes that Windows-1252 cannot represent; it can be read but any rewrite is refused',
308
+ location: locationFor(sourcePath, lineIndex, encodingLoss.firstOffset, encodingLoss.firstOffset + 1),
309
+ details: {
310
+ byteCount: encodingLoss.count,
311
+ byteValues: encodingLoss.byteValues.map((value) => `0x${value.toString(16).toUpperCase()}`),
312
+ },
313
+ }));
314
+ }
301
315
  const lexed = lexClausewitz(decoded.text, sourcePath, { lineIndex, diagnostics });
302
316
  const parser = new Parser(lexed.tokens, decoded.text, sourcePath, lineIndex, diagnostics);
303
317
  const root = parser.parseRoot();
@@ -323,6 +337,7 @@ export function assertRewriteSafe(document) {
323
337
  }
324
338
  }
325
339
  export function encodeDocumentText(document, text) {
340
+ assertSourceEncodingPreserved(document, document.path);
326
341
  return encodeSource(text, document.encoding);
327
342
  }
328
343
  export function assignments(block, key) {
@@ -72,6 +72,40 @@ export function assertFocusPlanAuthority(drift, authority) {
72
72
  currentPlanHash: drift.currentPlanHash,
73
73
  });
74
74
  }
75
+ /**
76
+ * True when the source changed in meaning after the plan was imported, so regenerating from the
77
+ * plan would overwrite those changes. A formatting-only change (the source still imports to the
78
+ * plan's original baseline), a source that already equals the plan, and an unchanged source are not
79
+ * semantic drift. This is deliberately independent of whether the plan itself was edited: a plan
80
+ * edit plus an external formatting change reports `conflict`, which is still safe to apply.
81
+ */
82
+ export function hasSemanticSourceDrift(drift) {
83
+ if (!drift.sourceChanged)
84
+ return false;
85
+ if (drift.status === 'tree_removed')
86
+ return true;
87
+ return (drift.currentPlanHash !== drift.importedPlanHash &&
88
+ drift.currentPlanHash !== drift.savedPlanHash);
89
+ }
90
+ /**
91
+ * Refuses to regenerate a tree from a plan when the source changed in meaning after the plan was
92
+ * imported. Applying the plan would overwrite those external changes with stale plan values.
93
+ * Re-import the source with the inspect tool, reapply the intended change to the fresh plan, and
94
+ * retry.
95
+ */
96
+ export function assertNoSemanticSourceDrift(drift, sourcePath) {
97
+ if (!hasSemanticSourceDrift(drift))
98
+ return;
99
+ throw new ServiceError('FOCUS_SOURCE_CHANGED_SINCE_PLAN', 'The focus source changed in meaning after the plan was imported; rewriting from the plan would overwrite those changes. Import the current source with hoi4.focus_inspect, reapply the intended edit to the fresh plan, and retry', {
100
+ relativePath: sourcePath,
101
+ status: drift.status,
102
+ savedSourceHash: drift.savedSourceHash,
103
+ currentSourceHash: drift.currentSourceHash,
104
+ savedPlanHash: drift.savedPlanHash,
105
+ currentPlanHash: drift.currentPlanHash,
106
+ importedPlanHash: drift.importedPlanHash,
107
+ });
108
+ }
75
109
  export function detectContinuousFocusDrift(savedPlan, currentDocument) {
76
110
  const currentSourceHash = sha256Bytes(currentDocument.bytes);
77
111
  const savedPlanHash = focusPlanHash(savedPlan);
@@ -5,7 +5,7 @@ import { ServiceError } from '../core/result.js';
5
5
  import { parseClausewitz } from '../core/source/index.js';
6
6
  import { TRANSACTION_MAX_DIAGNOSTICS } from '../core/transaction-limits.js';
7
7
  import { PACKAGE_VERSION } from '../version.js';
8
- import { assertFocusPlanAuthority, detectContinuousFocusDrift, detectFocusDrift, } from './drift.js';
8
+ import { assertFocusPlanAuthority, assertNoSemanticSourceDrift, detectContinuousFocusDrift, detectFocusDrift, } from './drift.js';
9
9
  import { importContinuousFocusPalettes, importFocusTrees } from './importer.js';
10
10
  import { layoutFocusTree, layoutFocusTreeAsync } from './layout.js';
11
11
  import { lintContinuousFocusPalette, lintFocusTree } from './lint.js';
@@ -191,8 +191,11 @@ export class FocusWorkbench {
191
191
  const drift = targetCreated
192
192
  ? missingTargetDrift(input.plan, document)
193
193
  : detectFocusDrift(input.plan, document, input.references);
194
- if (!targetCreated)
194
+ if (!targetCreated) {
195
+ if (input.onSemanticSourceDrift === 'refuse')
196
+ assertNoSemanticSourceDrift(drift, input.relativePath);
195
197
  assertFocusPlanAuthority(drift, input.authority ?? 'plan');
198
+ }
196
199
  const layout = input.layout ??
197
200
  (await layoutFocusTreeAsync(input.plan, input.signal === undefined ? {} : { signal: input.signal }));
198
201
  const after = updateFocusTreeSource(document, targetCreated ? undefined : drift.currentSourcePlan, input.plan, layout);
@@ -414,8 +417,11 @@ export class FocusWorkbench {
414
417
  const drift = targetCreated
415
418
  ? missingTargetDrift(input.plan, document)
416
419
  : detectContinuousFocusDrift(input.plan, document);
417
- if (!targetCreated)
420
+ if (!targetCreated) {
421
+ if (input.onSemanticSourceDrift === 'refuse')
422
+ assertNoSemanticSourceDrift(drift, input.relativePath);
418
423
  assertFocusPlanAuthority(drift, input.authority ?? 'plan');
424
+ }
419
425
  const after = updateContinuousFocusPaletteSource(document, targetCreated ? undefined : drift.currentSourcePlan, input.plan);
420
426
  const afterHash = sha256Bytes(after);
421
427
  const operationId = deterministicId('continuous_focus_change', {
@@ -147,6 +147,7 @@ export async function prepareFocusRewrite(engine, workbench, input, context) {
147
147
  plan,
148
148
  createIfMissing,
149
149
  authority: 'plan',
150
+ onSemanticSourceDrift: 'refuse',
150
151
  ...(context.principal === undefined ? {} : { principal: context.principal }),
151
152
  artifacts: reviewArtifacts,
152
153
  readDependencies: readDependenciesFromScannedFiles(snapshot.files),
@@ -301,6 +302,7 @@ export async function prepareFocusRewrite(engine, workbench, input, context) {
301
302
  plan,
302
303
  createIfMissing,
303
304
  authority: 'plan',
305
+ onSemanticSourceDrift: 'refuse',
304
306
  layout: proposed.layout,
305
307
  ...(context.principal === undefined ? {} : { principal: context.principal }),
306
308
  index: snapshot.index,
@@ -4,7 +4,7 @@ import { SymbolIndex } from '../core/index.js';
4
4
  import { assertRenderDimensions } from '../core/render-budget.js';
5
5
  import { addMapDiagnostic, addMapDiagnostics } from './diagnostic-limit.js';
6
6
  import { ServiceError } from '../core/result.js';
7
- import { assignments, childBlocks, decodeSource, encodeSource, firstScalar, nodeLocation, parseClausewitz, parseLocalisation, } from '../core/source/index.js';
7
+ import { assignments, assertSourceEncodingPreserved, childBlocks, decodeSource, encodeSource, firstScalar, nodeLocation, parseClausewitz, parseLocalisation, } from '../core/source/index.js';
8
8
  import { BmpImage, rgbKey } from './bmp.js';
9
9
  const defaultMapIndexSourceRoots = {
10
10
  map: ['map'],
@@ -119,6 +119,7 @@ export function parseTextDocument(file) {
119
119
  return { ...decoded, file, lines };
120
120
  }
121
121
  export function encodeTextDocument(document, text) {
122
+ assertSourceEncodingPreserved(document, document.file.displayPath);
122
123
  return encodeSource(text, document.encoding);
123
124
  }
124
125
  function lineLocation(document, line) {
@@ -10,8 +10,8 @@ import { planReferenceRemap, ReferenceRemapError } from './references.js';
10
10
  import { generateWorld } from './world.js';
11
11
  import { resolveEdge } from './world-edge.js';
12
12
  import { planMapPositions, planSupplyRebuild } from './derived.js';
13
- import { buildWorldContext } from './world-context.js';
14
- import { reconcileWorldOutputs } from './world-outputs.js';
13
+ import { buildWorldContext, countryTagCatalog, WORLD_COUNTRY_TAG_FILE, } from './world-context.js';
14
+ import { planWorldOutputs } from './world-outputs.js';
15
15
  import { resolvePartitionMode } from './partition.js';
16
16
  import { unsupportedGeographyParts } from './relief/index.js';
17
17
  function* cancellationCheckpoint(signal, iteration = 0, stride = 1) {
@@ -41,6 +41,8 @@ async function completeCooperatively(steps, signal) {
41
41
  yieldDeadline = performance.now() + 8;
42
42
  }
43
43
  }
44
+ /** Paths listed per kind in diagnostics and tool results; the artifact lists them all. */
45
+ export const WORLD_OUTPUT_PATH_SAMPLE = 200;
44
46
  function normalizeRelative(value) {
45
47
  return value.replaceAll('\\', '/').replace(/^\.\//u, '').toLowerCase();
46
48
  }
@@ -774,6 +776,7 @@ function proposedChanges(changes) {
774
776
  /** Diagnostics an operation raises while applying, drained into the plan after it succeeds. */
775
777
  const diagnosticsByPlan = new WeakMap();
776
778
  const replacedRootsByPlan = new WeakMap();
779
+ const worldOutputsByPlan = new WeakMap();
777
780
  function currentIndex(base, changes) {
778
781
  return indexWithProposedChanges(base, proposedChanges(changes), replacedRootsByPlan.get(changes) ?? []);
779
782
  }
@@ -3314,6 +3317,20 @@ function applyCreateWorld(base, changes, operation, context, signal) {
3314
3317
  throw new ServiceError('MAP_LOOK_CUSTOM_PALETTE_REQUIRED', 'look "custom" needs colormap.palette with at least one class colour');
3315
3318
  if (colormapProfile === 'legacy' && look !== undefined)
3316
3319
  throw new ServiceError('MAP_LOOK_REQUIRES_CALIBRATED_COLORMAP', 'look selects a calibrated palette; remove colormap.profile "legacy" or look');
3320
+ const workspaceCountries = operation.countries !== undefined && !Array.isArray(operation.countries)
3321
+ ? operation.countries
3322
+ : undefined;
3323
+ const tagCatalog = countryTagCatalog(base);
3324
+ if (workspaceCountries !== undefined) {
3325
+ const declared = new Set(tagCatalog.static);
3326
+ const dynamicTags = new Set(tagCatalog.dynamic);
3327
+ for (const realm of workspaceCountries.realms) {
3328
+ if (dynamicTags.has(realm.tag))
3329
+ throw new ServiceError('MAP_WORLD_REALM_TAG_DYNAMIC', `${realm.tag} is a dynamic tag; a world can only place countries with static tags`, { tag: realm.tag });
3330
+ if (!declared.has(realm.tag))
3331
+ throw new ServiceError('MAP_WORLD_REALM_TAG_UNKNOWN', `No active common/country_tags file other than the generated world's declares ${realm.tag}`, { tag: realm.tag });
3332
+ }
3333
+ }
3317
3334
  const partitionMode = resolvePartitionMode(operation.partition, hasGeography);
3318
3335
  if (partitionMode === 'legacy' && operation.partitionTuning !== undefined)
3319
3336
  throw new ServiceError('MAP_PARTITION_TUNING_REQUIRES_FEATURE_AWARE', 'partitionTuning applies to the feature-aware partition; set partition to "feature-aware" or remove it');
@@ -3332,13 +3349,29 @@ function applyCreateWorld(base, changes, operation, context, signal) {
3332
3349
  provincesPerState: operation.provincesPerState ?? (reference === undefined ? 5 : 12),
3333
3350
  landProvincesPerRegion: operation.landProvincesPerRegion ?? (reference === undefined ? 25 : 60),
3334
3351
  seaProvincesPerRegion: operation.seaProvincesPerRegion ?? (reference === undefined ? 10 : 30),
3335
- countries: operation.countries ??
3352
+ countries: workspaceCountries?.realms.map((realm) => ({
3353
+ tag: realm.tag,
3354
+ name: realm.tag,
3355
+ ...(realm.weight === undefined ? {} : { share: realm.weight }),
3356
+ })) ??
3357
+ operation.countries ??
3336
3358
  reference?.countries ?? [
3337
3359
  { tag: 'X01', name: 'Aster' },
3338
3360
  { tag: 'X02', name: 'Brum' },
3339
3361
  { tag: 'X03', name: 'Corvel' },
3340
3362
  { tag: 'X04', name: 'Doran' },
3341
3363
  ],
3364
+ ...(workspaceCountries === undefined
3365
+ ? {}
3366
+ : {
3367
+ realms: {
3368
+ list: workspaceCountries.realms,
3369
+ ...(workspaceCountries.unowned === undefined
3370
+ ? {}
3371
+ : { unowned: workspaceCountries.unowned }),
3372
+ islands: workspaceCountries.islands ?? 'nearest',
3373
+ },
3374
+ }),
3342
3375
  ...(reference === undefined
3343
3376
  ? {}
3344
3377
  : {
@@ -3398,7 +3431,7 @@ function applyCreateWorld(base, changes, operation, context, signal) {
3398
3431
  for (const file of world.files)
3399
3432
  addChange(changes, file.relativePath, file.content, operation.id, file.mediaType);
3400
3433
  // Remove only unchanged outputs recorded by a previous generation, before indexing new IDs.
3401
- for (const file of reconcileWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation))
3434
+ for (const file of planWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation, { releaseCountryOutputs: workspaceCountries !== undefined }).changes)
3402
3435
  if (file.content === null)
3403
3436
  addChange(changes, file.relativePath, null, operation.id, 'text/plain');
3404
3437
  // Start from empty derived files so no base-game position or supply line survives.
@@ -3418,8 +3451,16 @@ function applyCreateWorld(base, changes, operation, context, signal) {
3418
3451
  const positioned = currentIndex(base, changes);
3419
3452
  for (const file of planSupplyRebuild(positioned, { mode: 'replace' }))
3420
3453
  addChange(changes, file.relativePath, file.content, operation.id, 'text/plain');
3421
- for (const file of reconcileWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation))
3454
+ const outputs = planWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation, { releaseCountryOutputs: workspaceCountries !== undefined });
3455
+ for (const file of outputs.changes)
3422
3456
  addChange(changes, file.relativePath, file.content, operation.id, file.mediaType ?? 'text/plain');
3457
+ worldOutputsByPlan.set(changes, {
3458
+ operationId: operation.id,
3459
+ written: outputs.written,
3460
+ retired: outputs.retired,
3461
+ released: outputs.released,
3462
+ ...(world.realms === undefined ? {} : { realms: world.realms }),
3463
+ });
3423
3464
  return [
3424
3465
  ...(reference?.diagnostics ?? []).map((message) => ({
3425
3466
  code: 'MAP_REFERENCE_INTERPRETATION',
@@ -3448,6 +3489,24 @@ function applyCreateWorld(base, changes, operation, context, signal) {
3448
3489
  details: { warningCount: world.warnings.length, samples: world.warnings.slice(0, 5) },
3449
3490
  },
3450
3491
  ]),
3492
+ worldOutputsDiagnostic(operation.id, outputs),
3493
+ ...worldTagDiagnostics(operation.id, tagCatalog, world, workspaceCountries !== undefined),
3494
+ ...worldRealmDiagnostics(operation.id, world),
3495
+ ...(outputs.released.length === 0
3496
+ ? []
3497
+ : [
3498
+ {
3499
+ code: 'MAP_WORLD_OUTPUT_RELEASED',
3500
+ severity: 'info',
3501
+ category: 'map',
3502
+ operationId: operation.id,
3503
+ message: `${outputs.released.length} edited country file(s) of the previous world are left to the workspace and no longer recorded as generated`,
3504
+ details: {
3505
+ count: outputs.released.length,
3506
+ files: outputs.released.slice(0, WORLD_OUTPUT_PATH_SAMPLE),
3507
+ },
3508
+ },
3509
+ ]),
3451
3510
  {
3452
3511
  code: 'MAP_WORLD_CREATED',
3453
3512
  severity: 'info',
@@ -3470,6 +3529,107 @@ function applyCreateWorld(base, changes, operation, context, signal) {
3470
3529
  ]),
3471
3530
  ];
3472
3531
  }
3532
+ /** Tag count observed to overflow the flag sheet of one 1.19 Windows build; never a block. */
3533
+ export const WORLD_TAG_COUNT_OBSERVED_LIMIT = 439;
3534
+ function worldTagDiagnostics(operationId, catalog, world, workspaceOwned) {
3535
+ const declared = new Set(catalog.static);
3536
+ const generated = workspaceOwned
3537
+ ? 0
3538
+ : worldGeneratedTags(world).filter((tag) => !declared.has(tag)).length;
3539
+ const total = declared.size + generated + catalog.dynamic.length;
3540
+ const details = {
3541
+ static: declared.size + generated,
3542
+ generated,
3543
+ dynamic: catalog.dynamic.length,
3544
+ total,
3545
+ observedLimit: WORLD_TAG_COUNT_OBSERVED_LIMIT,
3546
+ };
3547
+ return [
3548
+ {
3549
+ code: 'MAP_WORLD_TAG_COUNT',
3550
+ severity: 'info',
3551
+ category: 'map',
3552
+ operationId,
3553
+ message: `After this write ${total} country tags are declared: ${details.static} static (${generated} written by the world) and ${details.dynamic} dynamic`,
3554
+ details,
3555
+ },
3556
+ ...(total <= WORLD_TAG_COUNT_OBSERVED_LIMIT
3557
+ ? []
3558
+ : [
3559
+ {
3560
+ code: 'MAP_WORLD_TAG_COUNT_HIGH',
3561
+ severity: 'warning',
3562
+ category: 'map',
3563
+ operationId,
3564
+ message: `${total} country tags exceed ${WORLD_TAG_COUNT_OBSERVED_LIMIT}, the count at which one measured game build overflowed its flag sheet`,
3565
+ details,
3566
+ },
3567
+ ]),
3568
+ ];
3569
+ }
3570
+ /** Tags of the country files a generated world writes. */
3571
+ function worldGeneratedTags(world) {
3572
+ const file = world.files.find(({ relativePath }) => relativePath === WORLD_COUNTRY_TAG_FILE);
3573
+ if (file === undefined)
3574
+ return [];
3575
+ return [
3576
+ ...new Set([...file.content.toString('utf8').matchAll(/^([A-Z][A-Z0-9]{2}) =/gmu)].map((match) => match[1])),
3577
+ ];
3578
+ }
3579
+ function worldRealmDiagnostics(operationId, world) {
3580
+ if (world.realms === undefined)
3581
+ return [];
3582
+ const { countries, unownedStates } = world.realms;
3583
+ return [
3584
+ {
3585
+ code: 'MAP_WORLD_REALMS_PLACED',
3586
+ severity: 'info',
3587
+ category: 'map',
3588
+ operationId,
3589
+ message: `Placed ${countries.length} workspace countries; their country files, histories, flags and names stay the workspace's`,
3590
+ details: {
3591
+ countries: countries
3592
+ .slice(0, 100)
3593
+ .map(({ tag, capitalState, states, landShare, targetShare }) => ({
3594
+ tag,
3595
+ capitalState,
3596
+ states: states.length,
3597
+ landShare,
3598
+ targetShare,
3599
+ })),
3600
+ truncated: countries.length > 100,
3601
+ },
3602
+ },
3603
+ ...(unownedStates.length === 0
3604
+ ? []
3605
+ : [
3606
+ {
3607
+ code: 'MAP_WORLD_STATE_UNOWNED',
3608
+ severity: 'info',
3609
+ category: 'map',
3610
+ operationId,
3611
+ message: `${unownedStates.length} state(s) have no owner, as the request asks; whether the game build accepts unowned land is for the mod to verify`,
3612
+ details: { count: unownedStates.length, states: unownedStates.slice(0, 200) },
3613
+ },
3614
+ ]),
3615
+ ];
3616
+ }
3617
+ function worldOutputsDiagnostic(operationId, outputs) {
3618
+ return {
3619
+ code: 'MAP_WORLD_OUTPUTS_RECONCILED',
3620
+ severity: 'info',
3621
+ category: 'map',
3622
+ operationId,
3623
+ message: `The world records ${outputs.written.length} generated file(s), retires ${outputs.retired.length} earlier output(s) it no longer writes and releases ${outputs.released.length} to the workspace`,
3624
+ details: {
3625
+ written: outputs.written.length,
3626
+ retired: outputs.retired.length,
3627
+ released: outputs.released.length,
3628
+ retiredFiles: outputs.retired.slice(0, WORLD_OUTPUT_PATH_SAMPLE),
3629
+ truncated: outputs.retired.length > WORLD_OUTPUT_PATH_SAMPLE,
3630
+ },
3631
+ };
3632
+ }
3473
3633
  function applyRegenerateMapPositions(base, changes, operation) {
3474
3634
  const index = currentIndex(base, changes);
3475
3635
  for (const file of planMapPositions(index, operation))
@@ -3567,6 +3727,7 @@ function* planMapOperationSteps(base, operations, signal, context = {}) {
3567
3727
  const allocations = [];
3568
3728
  let expectedChangedBounds;
3569
3729
  const manifestOperations = [];
3730
+ const worldOutputs = [];
3570
3731
  const ids = new Set();
3571
3732
  for (const [operationIndex, operation] of operations.entries()) {
3572
3733
  yield* cancellationCheckpoint(signal, operationIndex);
@@ -3596,8 +3757,13 @@ function* planMapOperationSteps(base, operations, signal, context = {}) {
3596
3757
  const allocationsCheckpoint = allocations.length;
3597
3758
  const boundsCheckpoint = expectedChangedBounds;
3598
3759
  try {
3599
- if (operation.kind === 'create_world')
3760
+ if (operation.kind === 'create_world') {
3600
3761
  diagnostics.push(...applyCreateWorld(base, changes, operation, context, signal));
3762
+ const outputs = worldOutputsByPlan.get(changes);
3763
+ worldOutputsByPlan.delete(changes);
3764
+ if (outputs !== undefined)
3765
+ worldOutputs.push(outputs);
3766
+ }
3601
3767
  else if (operation.kind === 'move_state_provinces')
3602
3768
  applyMoveStateProvinces(base, changes, operation);
3603
3769
  else if (operation.kind === 'update_state')
@@ -3674,6 +3840,7 @@ function* planMapOperationSteps(base, operations, signal, context = {}) {
3674
3840
  ...(expectedChangedBounds === undefined ? {} : { expectedChangedBounds }),
3675
3841
  finalIndex,
3676
3842
  operations: manifestOperations,
3843
+ ...(worldOutputs.length === 0 ? {} : { worldOutputs }),
3677
3844
  };
3678
3845
  }
3679
3846
  export function planMapOperations(base, operations, signal, context = {}) {
@@ -248,8 +248,11 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
248
248
  return id === 0 ? undefined : id;
249
249
  };
250
250
  let countries;
251
- if (operation.countries !== undefined)
251
+ if (Array.isArray(operation.countries))
252
252
  countries = operation.countries;
253
+ // Workspace-owned countries are placed from their own points; the picture's colours are unused.
254
+ else if (operation.countries !== undefined)
255
+ countries = [];
253
256
  else if (surface.inferredCountries.length > 1) {
254
257
  countries = surface.inferredCountries.slice(0, 200).map((country, index) => ({
255
258
  tag: `X${(index + 1).toString(36).toUpperCase().padStart(2, '0')}`,
@@ -1297,6 +1297,21 @@ export class AgentNudger {
1297
1297
  writes.push(...(await this.geographyArtifacts(prepared, options.signal, budget)));
1298
1298
  for (const [ordinal, imported] of prepared.imports.entries())
1299
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
+ });
1300
1315
  const generatedWorlds = generatedWorldOperationIds(plan);
1301
1316
  if (generatedWorlds.size > 0)
1302
1317
  writes.push(...(await buildWorldPreviewArtifacts({