hoi4-agent-tools 4.0.4 → 4.1.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 (85) hide show
  1. package/CHANGELOG.md +20 -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/diagnostic-rollup.js +91 -0
  5. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  6. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  7. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  8. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  9. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  10. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  11. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  12. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  13. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  14. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  15. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  16. package/dist/hoi4_agent_tools/gui/layout.js +61 -25
  17. package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
  18. package/dist/hoi4_agent_tools/gui/source-graph.js +9 -6
  19. package/dist/hoi4_agent_tools/gui/studio.js +4 -1
  20. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  21. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  22. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  23. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  24. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  25. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  26. package/dist/hoi4_agent_tools/map/render.js +9 -1
  27. package/dist/hoi4_agent_tools/map/service.js +222 -18
  28. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  29. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  30. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  31. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  32. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  33. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  34. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  35. package/dist/hoi4_agent_tools/map/world.js +374 -253
  36. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  37. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  38. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  39. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  40. package/dist/hoi4_agent_tools/version.js +2 -2
  41. package/docs/gui.md +6 -2
  42. package/docs/images/reference-maps/political.png +0 -0
  43. package/docs/images/reference-maps/sketch.png +0 -0
  44. package/docs/map.md +45 -0
  45. package/docs/third-party-notices.md +6 -0
  46. package/npm-shrinkwrap.json +124 -2
  47. package/package.json +4 -1
  48. package/schemas/configuration.schema.json +1 -1
  49. package/schemas/continuous-focus-palette.schema.json +1 -1
  50. package/schemas/custom-weighted-pool.schema.json +1 -1
  51. package/schemas/decision-inspect-input.schema.json +1 -1
  52. package/schemas/error-log-input.schema.json +1 -1
  53. package/schemas/event-feature-manifest.schema.json +1 -1
  54. package/schemas/focus-plan.schema.json +1 -1
  55. package/schemas/focus-planning-sidecar.schema.json +1 -1
  56. package/schemas/gui-animation-source.schema.json +1 -1
  57. package/schemas/gui-helper.schema.json +1 -1
  58. package/schemas/gui-scenario.schema.json +1 -1
  59. package/schemas/helper-expansion-request.schema.json +1 -1
  60. package/schemas/helper-expansion-summary.schema.json +1 -1
  61. package/schemas/impact-inspect-input.schema.json +1 -1
  62. package/schemas/map-operation.schema.json +373 -70
  63. package/schemas/mechanic-test-input.schema.json +1 -1
  64. package/schemas/mod-index-input.schema.json +1 -1
  65. package/schemas/operation-result.schema.json +1 -1
  66. package/schemas/package-check-input.schema.json +1 -1
  67. package/schemas/package-manifest.schema.json +1 -1
  68. package/schemas/probability-analysis-result.schema.json +1 -1
  69. package/schemas/probability-compare-input.schema.json +1 -1
  70. package/schemas/probability-evaluate-input.schema.json +1 -1
  71. package/schemas/probability-inspect-input.schema.json +1 -1
  72. package/schemas/probability-render-input.schema.json +1 -1
  73. package/schemas/probability-scenario-set.schema.json +1 -1
  74. package/schemas/probability-sequence-input.schema.json +1 -1
  75. package/schemas/probability-simulate-input.schema.json +1 -1
  76. package/schemas/probability-sweep-input.schema.json +1 -1
  77. package/schemas/reference-context-input.schema.json +1 -1
  78. package/schemas/reference-read-input.schema.json +1 -1
  79. package/schemas/reference-search-input.schema.json +1 -1
  80. package/schemas/scenario-suite.schema.json +1 -1
  81. package/schemas/scenario-test-input.schema.json +1 -1
  82. package/schemas/script-validate-input.schema.json +1 -1
  83. package/schemas/shared-scenario.schema.json +1 -1
  84. package/schemas/source-lookup-input.schema.json +1 -1
  85. package/server.json +2 -2
@@ -629,11 +629,12 @@ function explicitElementVisibility(definition, scenario, rowValues) {
629
629
  visibilityValue(scripted));
630
630
  }
631
631
  /** Source visibility: `visible` and `show` mean shown when true, `hide` and `hidden` when false. */
632
- function declaredVisibility(attributes) {
632
+ function declaredVisibility(attributes, fallback = true) {
633
633
  const visible = scalarBoolean(property(attributes, 'visible', 'show'));
634
634
  if (visible !== undefined)
635
635
  return visible;
636
- return !(scalarBoolean(property(attributes, 'hide', 'hidden')) ?? false);
636
+ const hidden = scalarBoolean(property(attributes, 'hide', 'hidden'));
637
+ return hidden === undefined ? fallback : !hidden;
637
638
  }
638
639
  function clippedByChain(chain) {
639
640
  let result;
@@ -694,7 +695,7 @@ function refreshSceneClips(context) {
694
695
  }
695
696
  }
696
697
  function attachedScrollbars(definition, context) {
697
- if (!/^(?:containerwindowtype|windowtype)$/iu.test(definition.elementType))
698
+ if (!/^(?:containerwindowtype|windowtype|expandedwindow)$/iu.test(definition.elementType))
698
699
  return [];
699
700
  const result = [];
700
701
  const add = (candidate, horizontal, reference) => {
@@ -718,8 +719,10 @@ function attachedScrollbars(definition, context) {
718
719
  for (const horizontal of [false, true]) {
719
720
  const name = scalarString(property(definition.attributes, horizontal ? 'horizontalScrollbar' : 'verticalScrollbar'));
720
721
  if (name !== undefined) {
721
- const candidate = context.elementsByName.get(name);
722
- add(candidate?.parentId === undefined ? candidate : undefined, horizontal, name);
722
+ const candidate = context.elementsByNameCandidates
723
+ .get(name)
724
+ ?.find((element) => element.parentId === undefined);
725
+ add(candidate, horizontal, name);
723
726
  }
724
727
  }
725
728
  for (const id of definition.childIds) {
@@ -1218,10 +1221,13 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1218
1221
  const clipChain = inheritedClipChain ?? (inheritedClip === undefined ? [] : [inheritedClip]);
1219
1222
  const localScale = scalarNumber(property(definition.attributes, 'scale'), 1) ?? 1;
1220
1223
  const scale = parentScale * localScale;
1221
- const shownPosition = depth === 0 ? objectProperty(property(definition.attributes, 'show_position')) : undefined;
1224
+ const expandedWindow = definition.elementType.toLowerCase() === 'expandedwindow';
1225
+ const shownPosition = depth === 0 || expandedWindow
1226
+ ? objectProperty(property(definition.attributes, 'show_position'))
1227
+ : undefined;
1222
1228
  const position = shownPosition ?? objectProperty(property(definition.attributes, 'position'));
1223
1229
  if (shownPosition !== undefined) {
1224
- addFidelity(fidelity, 'modelled', 'window_show_position', 'The requested root window uses its declared settled show position.', definition);
1230
+ addFidelity(fidelity, 'modelled', 'window_show_position', 'The requested window uses its declared settled show position.', definition);
1225
1231
  }
1226
1232
  const size = objectProperty(property(definition.attributes, 'size'));
1227
1233
  const scriptedX = rowValues?.[`${definition.name}.x`] ??
@@ -1277,13 +1283,19 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1277
1283
  width = Math.max(0, parentRect.width - localX + width);
1278
1284
  if (height < 0)
1279
1285
  height = Math.max(0, parentRect.height - localY + height);
1280
- const explicitlyVisible = explicitElementVisibility(definition, scenario, rowValues);
1286
+ const explicitlyVisible = explicitElementVisibility(definition, scenario, rowValues) ??
1287
+ context.nativeVisibility.get(definition.id);
1281
1288
  const unresolvedVisibilityReason = context.unresolvedElementVisibility.get(definition.name);
1282
1289
  const parentSceneElement = parentInstanceId === undefined ? undefined : context.instancesById.get(parentInstanceId);
1283
1290
  const parentUnresolvedVisibility = parentSceneElement?.visibilityStatus === 'unresolved'
1284
1291
  ? parentSceneElement.visibilityStatusReason
1285
1292
  : undefined;
1286
- const sourceVisible = declaredVisibility(definition.attributes);
1293
+ const dropdown = definition.parentId === undefined ? undefined : context.elementsById.get(definition.parentId);
1294
+ const sourceVisible = declaredVisibility(definition.attributes, !expandedWindow ||
1295
+ depth === 0 ||
1296
+ visibilityValue(rowValues?.[`${dropdown?.name}.expanded`] ?? scenario.values[`${dropdown?.name}.expanded`]) === true);
1297
+ if (expandedWindow)
1298
+ addFidelity(fidelity, 'modelled', 'native_dropdown', 'Dropdown expansion is closed by default; bind <dropdown>.expanded or explicit panel visibility to show its settled position.', definition);
1287
1299
  const resolveVisibleAppearance = inheritedVisible && (explicitlyVisible ?? sourceVisible);
1288
1300
  const scriptedEnabled = rowValues?.[`${definition.name}.enabled`] ??
1289
1301
  scenario.values[`${definition.name}.enabled`] ??
@@ -1619,12 +1631,13 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1619
1631
  else if (origoAxes.vertical === 'bottom')
1620
1632
  y -= height;
1621
1633
  const unclippedRect = { x, y, width: Math.max(0, width), height: Math.max(0, height) };
1622
- const ownClipping = scalarBoolean(property(definition.attributes, 'clipping')) ??
1623
- (backgrounds.length > 0 && /(?:containerwindow|windowtype)/iu.test(definition.elementType));
1624
1634
  let scrollbars = attachedScrollbars(definition, context);
1625
- if (scrollbars.length > 0 && (!ownClipping || backgrounds.length === 0)) {
1626
- diagnostics.push(diagnostic('GUI_SCROLLBAR_CONTAINER_BOUNDARY_MISSING', 'warning', `Container ${definition.name} needs a background and clipping for attached scrolling.`, definition));
1627
- addFidelity(fidelity, 'unsupported', 'attached_scrollbar', 'The container has no active clipped background boundary.', definition);
1635
+ const ownClipping = scalarBoolean(property(definition.attributes, 'clipping')) ??
1636
+ ((backgrounds.length > 0 || scrollbars.length > 0) &&
1637
+ /(?:containerwindow|windowtype|expandedwindow)/iu.test(definition.elementType));
1638
+ if (scrollbars.length > 0 && (!ownClipping || width <= 0 || height <= 0)) {
1639
+ diagnostics.push(diagnostic('GUI_SCROLLBAR_CONTAINER_BOUNDARY_MISSING', 'warning', `Container ${definition.name} needs a positive-sized clipping boundary for attached scrolling.`, definition));
1640
+ addFidelity(fidelity, 'unsupported', 'attached_scrollbar', 'The container has no active clipping boundary.', definition);
1628
1641
  scrollbars = [];
1629
1642
  }
1630
1643
  const margin = scrollbars.length === 0 ? undefined : objectProperty(property(definition.attributes, 'margin'));
@@ -1835,10 +1848,12 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1835
1848
  const childDefinitions = await scrollbarChildren(definition, definition.childIds
1836
1849
  .map((id) => context.elementsById.get(id))
1837
1850
  .filter((element) => element !== undefined)
1838
- .filter((element) => !/^(?:containerwindowtype|windowtype)$/iu.test(definition.elementType) ||
1851
+ .filter((element) => !/^(?:containerwindowtype|windowtype|expandedwindow)$/iu.test(definition.elementType) ||
1839
1852
  element.elementType.toLowerCase() !== 'extendedscrollbartype')
1840
- .sort((left, right) => (scalarNumber(property(left.attributes, 'priority'), 0) ?? 0) -
1841
- (scalarNumber(property(right.attributes, 'priority'), 0) ?? 0) ||
1853
+ .sort((left, right) => Number(context.nativeForeground.has(left.id)) -
1854
+ Number(context.nativeForeground.has(right.id)) ||
1855
+ (scalarNumber(property(left.attributes, 'priority'), 0) ?? 0) -
1856
+ (scalarNumber(property(right.attributes, 'priority'), 0) ?? 0) ||
1842
1857
  left.definitionOrder - right.definitionOrder), unclippedRect, scale, context, rowValues);
1843
1858
  const rowList = rowValues?.[`${definition.name}.list`];
1844
1859
  let rows = (typeof rowList === 'string' ? scenario.lists[rowList] : undefined) ??
@@ -1908,7 +1923,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1908
1923
  ...(slotWidth === undefined ? {} : { width: slotWidth }),
1909
1924
  ...(slotHeight === undefined ? {} : { height: slotHeight }),
1910
1925
  };
1911
- await layoutElement(child, shiftedParent, childClip, scale, depth + 1, context, rowSuffix(index), index, row, instanceId, origo, resolveVisibleAppearance, childClipChain);
1926
+ await layoutElement(child, shiftedParent, childClip, scale, depth + 1, context, rowSuffix(index), index, row, instanceId, 'upper_left', resolveVisibleAppearance, childClipChain);
1912
1927
  const rendered = context.instancesById.get(`${child.id}${rowSuffix(index)}`);
1913
1928
  rowHeight = Math.max(rowHeight, rendered?.unclippedRect.height ?? 0);
1914
1929
  }
@@ -1960,7 +1975,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1960
1975
  ...(slotWidth === undefined ? {} : { width: slotWidth }),
1961
1976
  ...(slotHeight === undefined ? {} : { height: slotHeight }),
1962
1977
  };
1963
- await layoutElement(template, shiftedParent, childClip, scale, depth + 1, context, rowSuffix(index), index, row, instanceId, origo, resolveVisibleAppearance, childClipChain);
1978
+ await layoutElement(template, shiftedParent, childClip, scale, depth + 1, context, rowSuffix(index), index, row, instanceId, 'upper_left', resolveVisibleAppearance, childClipChain);
1964
1979
  const rendered = context.instancesById.get(`${template.id}${rowSuffix(index)}`);
1965
1980
  rowY += Math.max(slotHeight ?? 0, rendered?.unclippedRect.height ?? 0) + spacingY;
1966
1981
  renderedRows += 1;
@@ -1970,7 +1985,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1970
1985
  else {
1971
1986
  for (const child of childDefinitions) {
1972
1987
  context.work.spend('nested child expansion');
1973
- await layoutElement(child, childParentRect, childClip, scale, depth + 1, context, instanceSuffix, rowIndex, rowValues, instanceId, origo, resolveVisibleAppearance, childClipChain);
1988
+ await layoutElement(child, childParentRect, childClip, scale, depth + 1, context, instanceSuffix, rowIndex, rowValues, instanceId, 'upper_left', resolveVisibleAppearance, childClipChain);
1974
1989
  }
1975
1990
  }
1976
1991
  const childOutput = context.output.slice(firstChildOutput);
@@ -2195,10 +2210,14 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2195
2210
  if (native !== undefined) {
2196
2211
  for (const note of native.notes)
2197
2212
  addFidelity(fidelity, 'modelled', 'native_window_state', note);
2198
- const { sources } = native.state;
2199
- if (native.state.tag === undefined ||
2200
- sources.faction === 'default' ||
2201
- sources.balanceOfPower === 'default')
2213
+ for (const note of native.approximations ?? [])
2214
+ addFidelity(fidelity, 'approximated', 'native_window_state', note);
2215
+ const sources = native.state?.sources;
2216
+ if (native.state !== undefined &&
2217
+ sources !== undefined &&
2218
+ (native.state.tag === undefined ||
2219
+ sources.faction === 'default' ||
2220
+ sources.balanceOfPower === 'default'))
2202
2221
  addFidelity(fidelity, 'approximated', 'native_window_state', native.state.tag === undefined
2203
2222
  ? 'The scenario names no country, so the window shows the state of a country in no faction and without a balance of power; set country.tag or the country faction, balanceOfPower and ideology facts.'
2204
2223
  : `No history for ${native.state.tag} was available, so its faction and balance of power default to none; set country.faction, country.balanceOfPower and country.ideology to override.`);
@@ -2211,7 +2230,6 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2211
2230
  ...scenario.values,
2212
2231
  },
2213
2232
  visibility: {
2214
- ...(native?.visibility ?? {}),
2215
2233
  ...scriptedWindowVisibility,
2216
2234
  ...constantElementVisibility,
2217
2235
  ...scenario.visibility,
@@ -2229,6 +2247,24 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2229
2247
  elementsByNameCandidates.set(element.name, candidates);
2230
2248
  }
2231
2249
  const context = {
2250
+ nativeForeground: new Set(graph.elements
2251
+ .filter((element) => element.parentId === root.id && native?.foreground?.includes(element.name))
2252
+ .map(({ id }) => id)),
2253
+ nativeVisibility: new Map(graph.elements.flatMap((element) => {
2254
+ const visible = native?.visibility[element.name];
2255
+ if (visible === undefined)
2256
+ return [];
2257
+ // The designer reuses names (including division_title) inside selector panels.
2258
+ // Its branch decisions apply to root controls, not identically named descendants.
2259
+ if (windowName === 'countrydivisiondesignerview' && element.parentId !== root.id) {
2260
+ const parent = element.parentId === undefined ? undefined : elementsById.get(element.parentId);
2261
+ if (element.name !== 'hq_badge' ||
2262
+ parent?.name !== 'template_emblem_slot' ||
2263
+ parent.parentId !== root.id)
2264
+ return [];
2265
+ }
2266
+ return [[element.id, visible]];
2267
+ })),
2232
2268
  graph,
2233
2269
  scenario: layoutScenario,
2234
2270
  catalog,
@@ -205,13 +205,52 @@ export function resolveNativeCountryState(scenario, files) {
205
205
  }
206
206
  /** Whether any native decision can apply to this window, so the country state is worth resolving. */
207
207
  export function windowHasNativeState(windowName, elementNames) {
208
- return (NATIVE_BRANCHES[windowName] !== undefined ||
208
+ return (windowName === 'countrydivisiondesignerview' ||
209
+ NATIVE_BRANCHES[windowName] !== undefined ||
209
210
  Object.keys(IDEOLOGY_FRAME_ICONS).some((name) => elementNames.has(name)));
210
211
  }
211
212
  /** The engine-selected branches and frames for a scene, below any explicit scenario input. */
212
213
  export function nativeWindowState(windowName, elementNames, scenario, files) {
213
214
  if (!windowHasNativeState(windowName, elementNames))
214
215
  return undefined;
216
+ if (windowName === 'countrydivisiondesignerview') {
217
+ const hq = scenario.values['countrydivisiondesignerview.hq'] === true;
218
+ const panel = scenario.values['countrydivisiondesignerview.panel'];
219
+ const visibility = {};
220
+ for (const name of ['hq_overlay_images', 'subunits_hq', 'hq_view', 'hq_title', 'hq_badge'])
221
+ if (elementNames.has(name))
222
+ visibility[name] = hq;
223
+ for (const name of [
224
+ 'subunits',
225
+ 'non_hq_view',
226
+ 'division_title',
227
+ 'division_composition_label',
228
+ 'combat_width_view',
229
+ ])
230
+ if (elementNames.has(name))
231
+ visibility[name] = !hq;
232
+ const selectors = [
233
+ 'designerregimentswindow',
234
+ 'division_equipment_view',
235
+ 'div_templ_custom_icon_view',
236
+ 'div_template_select_model',
237
+ 'niche_icon_selector',
238
+ ];
239
+ for (const name of selectors)
240
+ if (elementNames.has(name))
241
+ visibility[name] = panel === name;
242
+ return {
243
+ visibility,
244
+ values: {},
245
+ foreground: selectors,
246
+ notes: [
247
+ `Division designer uses the ${hq ? 'HQ' : 'division'} branch and only the explicitly selected native selector panel.`,
248
+ ],
249
+ approximations: [
250
+ 'Native division state is a preview convention: defaults to a division with selectors closed. Set values countrydivisiondesignerview.hq and countrydivisiondesignerview.panel, or explicit element visibility. Engine-populated rows and statistics require scenario bindings.',
251
+ ],
252
+ };
253
+ }
215
254
  const state = resolveNativeCountryState(scenario, files);
216
255
  const visibility = {};
217
256
  const values = {};
@@ -119,7 +119,7 @@ export function guiElementAttributeFidelity(key, elementType) {
119
119
  if (elementType?.toLowerCase() === 'extendedscrollbartype' &&
120
120
  ['horizontal', 'slider', 'track', 'decreaseButton', 'increaseButton'].some((candidate) => candidate.toLowerCase() === key.toLowerCase()))
121
121
  return 'modelled';
122
- if (/^(?:containerwindowtype|windowtype)$/iu.test(elementType ?? '') &&
122
+ if (/^(?:containerwindowtype|windowtype|expandedwindow)$/iu.test(elementType ?? '') &&
123
123
  ['verticalScrollbar', 'horizontalScrollbar', 'autohide_scrollbars', 'margin'].some((candidate) => candidate.toLowerCase() === key.toLowerCase()))
124
124
  return 'structural';
125
125
  if (elementType?.toLowerCase() === 'scrollbartype' &&
@@ -298,9 +298,12 @@ function nestedSizeFrom(block, key) {
298
298
  function raw(document, node) {
299
299
  return document.text.slice(node.start, node.end);
300
300
  }
301
- function isGuiElement(assignment) {
301
+ function isGuiElement(assignment, parentType) {
302
302
  if (assignment.value.type !== 'block')
303
303
  return false;
304
+ if (parentType?.toLowerCase() === 'dropdownboxtype' &&
305
+ /^(?:expandedwindow|expandbutton)$/iu.test(assignment.key.value))
306
+ return true;
304
307
  if (explicitGuiElementTypes.has(assignment.key.value.toLowerCase()))
305
308
  return true;
306
309
  return (/type$/iu.test(assignment.key.value) && firstScalar(assignment.value, 'name') !== undefined);
@@ -363,11 +366,11 @@ function indexGuiElements(document, file, fileNodeId, nodes, edges, elements) {
363
366
  const constants = constantsFor(document);
364
367
  const firstFileElement = elements.length;
365
368
  let order = 0;
366
- const walk = (block, parentId) => {
369
+ const walk = (block, parentId, parentType) => {
367
370
  for (const assignment of assignments(block)) {
368
371
  if (assignment.value.type !== 'block')
369
372
  continue;
370
- if (isGuiElement(assignment)) {
373
+ if (isGuiElement(assignment, parentType)) {
371
374
  const name = firstScalar(assignment.value, 'name')?.value;
372
375
  if (name === undefined)
373
376
  continue;
@@ -386,7 +389,7 @@ function indexGuiElements(document, file, fileNodeId, nodes, edges, elements) {
386
389
  childIds: [],
387
390
  attributes: attributes,
388
391
  unsupportedAttributes: assignments(assignment.value)
389
- .filter((child) => !isGuiElement(child))
392
+ .filter((child) => !isGuiElement(child, assignment.key.value))
390
393
  .map(({ key }) => key.value)
391
394
  .filter((key) => guiElementAttributeFidelity(key, assignment.key.value) === 'unsupported')
392
395
  .sort((a, b) => compareCodeUnits(a, b)),
@@ -409,7 +412,7 @@ function indexGuiElements(document, file, fileNodeId, nodes, edges, elements) {
409
412
  addEdge(edges, 'contains', parentId ?? fileNodeId, id, true, {}, element.location);
410
413
  if (parentId !== undefined)
411
414
  addEdge(edges, 'parent', id, parentId, true, {}, element.location);
412
- walk(assignment.value, id);
415
+ walk(assignment.value, id, assignment.key.value);
413
416
  }
414
417
  else {
415
418
  walk(assignment.value, parentId);
@@ -1048,7 +1048,10 @@ export class ScriptedGuiStudio {
1048
1048
  const requiredNames = new Set([windowName, ...additionalTemplateNames]);
1049
1049
  for (const element of selectedLayoutElements)
1050
1050
  collectNamedAttributes(element.attributes, new Set(['verticalScrollbar', 'horizontalScrollbar', 'scrollbarType', 'scrollbartype']), requiredNames);
1051
- const availableNames = new Set(layoutGraph.elements.map(({ name }) => name));
1051
+ // Scrollbar and row references name public templates, not private controls with the same name.
1052
+ const availableNames = new Set(layoutGraph.elements.filter(({ parentId }) => parentId === undefined).map(({ name }) => name));
1053
+ if (layoutGraph.elements.some(({ name }) => name === windowName))
1054
+ availableNames.add(windowName);
1052
1055
  if ([...requiredNames].some((name) => !availableNames.has(name))) {
1053
1056
  const gameLayouts = await this.scanner.scan(workspace, {
1054
1057
  patterns: layoutPatterns,
@@ -136,9 +136,9 @@ function coastPoint(raster, shape, isSea) {
136
136
  [0, 1],
137
137
  [0, -1],
138
138
  ]) {
139
- const nx = x + dx;
139
+ const nx = (x + dx + raster.width) % raster.width;
140
140
  const ny = y + dy;
141
- if (nx < 0 || ny < 0 || nx >= raster.width || ny >= raster.height)
141
+ if (ny < 0 || ny >= raster.height)
142
142
  continue;
143
143
  const other = raster.provinceIds[ny * raster.width + nx];
144
144
  if (other < 0 || !isSea(other))
@@ -154,6 +154,19 @@ function coastPoint(raster, shape, isSea) {
154
154
  const middle = points[Math.floor(points.length / 2)];
155
155
  return { ...middle, sea };
156
156
  }
157
+ /** A sea pixel next to the landing province, staying inside narrow inlets and map edges. */
158
+ function floatingHarborPoint(raster, coast) {
159
+ for (const distance of [2, 1]) {
160
+ const x = (coast.x + coast.dx * distance + raster.width) % raster.width;
161
+ const y = coast.y + coast.dy * distance;
162
+ if (y < 0 || y >= raster.height)
163
+ continue;
164
+ // Keep the same sea association selected from the real coastal contact.
165
+ if (raster.provinceIds[y * raster.width + x] === coast.sea)
166
+ return { x, y };
167
+ }
168
+ throw new ServiceError('MAP_FLOATING_HARBOR_WATER_UNAVAILABLE', 'The selected coastal contact has no adjacent sea pixel for a floating harbor');
169
+ }
157
170
  function rotationFor(dx, dy) {
158
171
  // Bitmap y grows downward; map z grows upward.
159
172
  return Math.atan2(-dy, dx);
@@ -315,9 +328,8 @@ function planBuildingPositions(index, request) {
315
328
  continue;
316
329
  for (let copy = 0; copy < perOwner && produced < wanted - have; copy += 1, produced += 1) {
317
330
  const floating = slot.name === 'floating_harbor';
318
- // A floating harbour sits on the water two pixels out and names its land province.
319
- const x = floating ? coast.x + coast.dx * 2 : coast.x;
320
- const y = floating ? coast.y + coast.dy * 2 : coast.y;
331
+ // Prefer two pixels offshore, with a verified adjacent-water fallback.
332
+ const { x, y } = floating ? floatingHarborPoint(raster, coast) : coast;
321
333
  added.push([
322
334
  state.id,
323
335
  slot.name,
@@ -350,7 +362,10 @@ function planBuildingPositions(index, request) {
350
362
  }
351
363
  return {
352
364
  relativePath: document?.file.relativePath ?? `${mapRoot(index)}/buildings.txt`,
353
- content: rewriteLines(document, drop, added),
365
+ // The native building-position reader treats a trailing newline as an empty CSV record.
366
+ content: Buffer.from(rewriteLines(document, drop, added)
367
+ .toString('utf8')
368
+ .replace(/\r?\n$/u, ''), 'utf8'),
354
369
  };
355
370
  }
356
371
  function planUnitPositions(index, request) {
@@ -4,7 +4,7 @@ import { errorResult, toolResult } from '../core/operation-result.js';
4
4
  import { ServiceError } from '../core/result.js';
5
5
  import { MAP_VIEW_FIELDS, mapInspectRequestSchema, mapRewriteRequestSchema, } from '../schemas/map-requests.js';
6
6
  import { AgentNudger } from './service.js';
7
- import { inspectMap, completeMapRewrite, normalizeMapInspectRequest, normalizeMapRenderRequest, normalizeMapRewriteRequest, prepareMapRewrite, renderMapView, } from './tool-operations.js';
7
+ import { inspectMap, completeMapRewrite, normalizeMapInspectRequest, normalizeMapRenderRequest, normalizeMapRewriteRequest, prepareMapRewrite, previewMapRewrite, renderMapView, } from './tool-operations.js';
8
8
  const wireResult = z.record(z.string(), z.json());
9
9
  /** Registers read-only map operations without invoking transport handlers. */
10
10
  export function registerMapJobs(operations, engine) {
@@ -54,12 +54,18 @@ export function registerMapJobs(operations, engine) {
54
54
  throw new ServiceError('JOB_ARGUMENT_SCOPE_MISMATCH', 'The request workspace does not match the authorized job workspace');
55
55
  const release = lifetime.begin();
56
56
  try {
57
- return await prepareMapRewrite(nudger, normalizeMapRewriteRequest(input), {
57
+ const operationContext = {
58
58
  workspaceId: context.workspaceId,
59
59
  ...(context.principal === undefined ? {} : { principal: context.principal }),
60
60
  signal: context.signal,
61
61
  progress: (completed, total, message) => context.progress({ completed, total, message }),
62
- });
62
+ };
63
+ const normalized = normalizeMapRewriteRequest(input);
64
+ if (normalized.preview)
65
+ return {
66
+ readOnlyPreview: wireResult.parse(JSON.parse(JSON.stringify(toolResult(await previewMapRewrite(nudger, normalized, operationContext))))),
67
+ };
68
+ return await prepareMapRewrite(nudger, normalized, operationContext);
63
69
  }
64
70
  finally {
65
71
  release();
@@ -9,6 +9,8 @@ import { derivedStateCapital, encodeTextDocument, LEVEL_STATE_BUILDINGS, MapWork
9
9
  import { planReferenceRemap, ReferenceRemapError } from './references.js';
10
10
  import { generateWorld } from './world.js';
11
11
  import { planMapPositions, planSupplyRebuild } from './derived.js';
12
+ import { buildWorldContext } from './world-context.js';
13
+ import { reconcileWorldOutputs } from './world-outputs.js';
12
14
  function* cancellationCheckpoint(signal, iteration = 0, stride = 1) {
13
15
  if (iteration % stride !== 0)
14
16
  return;
@@ -3250,6 +3252,14 @@ export const WORLD_REPLACED_ROOTS = [
3250
3252
  'map/supplyareas',
3251
3253
  'common/bookmarks',
3252
3254
  ];
3255
+ const STARTING_SCENARIO_REPLACED_ROOTS = [
3256
+ 'common/on_actions',
3257
+ 'common/ai_areas',
3258
+ 'common/ai_strategy_plans',
3259
+ 'common/ai_strategy',
3260
+ 'common/decisions',
3261
+ 'events',
3262
+ ];
3253
3263
  /** Add replace_path lines to descriptor.mod, creating a minimal descriptor when none exists. */
3254
3264
  function descriptorWithReplacePaths(index, roots) {
3255
3265
  const existing = index.sourceFiles.find((file) => file.rootKind === 'mod' && normalizeRelative(file.relativePath) === 'descriptor.mod');
@@ -3262,36 +3272,56 @@ function descriptorWithReplacePaths(index, roots) {
3262
3272
  const body = textValue.endsWith('\n') ? textValue : `${textValue}${newline}`;
3263
3273
  return Buffer.from(`${body}${missing.map((root) => `replace_path="${root}"`).join(newline)}${missing.length > 0 ? newline : ''}`, 'utf8');
3264
3274
  }
3265
- function applyCreateWorld(base, changes, operation) {
3275
+ function applyCreateWorld(base, changes, operation, context) {
3276
+ const reference = context.referenceWorlds?.get(operation.id);
3277
+ if (operation.reference !== undefined && reference === undefined)
3278
+ throw new ServiceError('MAP_REFERENCE_NOT_PREPARED', 'Reference worlds must be prepared by the authorized workspace service');
3266
3279
  const mapRoot = base.sourceRoots.map[0] ?? 'map';
3280
+ const startingScenario = operation.startingScenario ?? reference !== undefined;
3267
3281
  const world = generateWorld({
3268
- width: operation.width ?? 1_024,
3269
- height: operation.height ?? 512,
3282
+ startingScenario,
3283
+ ...(operation.worldName === undefined ? {} : { worldName: operation.worldName }),
3284
+ width: operation.width ?? reference?.surface.width ?? 1_024,
3285
+ height: operation.height ?? reference?.surface.height ?? 512,
3270
3286
  seed: operation.seed ?? 1,
3271
3287
  landCoverage: operation.landCoverage ?? 0.35,
3272
3288
  continents: operation.continents ?? 3,
3273
3289
  ...(operation.landPolygons === undefined ? {} : { landPolygons: operation.landPolygons }),
3274
3290
  landProvincePixels: operation.landProvincePixels ?? 400,
3275
3291
  seaProvincePixels: operation.seaProvincePixels ?? 2_500,
3276
- provincesPerState: operation.provincesPerState ?? 5,
3277
- landProvincesPerRegion: operation.landProvincesPerRegion ?? 25,
3278
- seaProvincesPerRegion: operation.seaProvincesPerRegion ?? 10,
3279
- countries: operation.countries,
3292
+ provincesPerState: operation.provincesPerState ?? (reference === undefined ? 5 : 12),
3293
+ landProvincesPerRegion: operation.landProvincesPerRegion ?? (reference === undefined ? 25 : 60),
3294
+ seaProvincesPerRegion: operation.seaProvincesPerRegion ?? (reference === undefined ? 10 : 30),
3295
+ countries: operation.countries ??
3296
+ reference?.countries ?? [
3297
+ { tag: 'X01', name: 'Aster' },
3298
+ { tag: 'X02', name: 'Brum' },
3299
+ { tag: 'X03', name: 'Corvel' },
3300
+ { tag: 'X04', name: 'Doran' },
3301
+ ],
3302
+ ...(reference === undefined
3303
+ ? {}
3304
+ : {
3305
+ referenceSurface: reference.surface,
3306
+ referenceCities: reference.cities,
3307
+ referenceLabels: reference.labels,
3308
+ }),
3280
3309
  ...(operation.continentNames === undefined
3281
3310
  ? {}
3282
3311
  : { continentNames: operation.continentNames }),
3283
3312
  startDate: operation.startDate ?? '1936.1.1',
3284
- rivers: operation.rivers ?? 8,
3285
- }, {
3286
- palettes: {},
3287
- mapRoot,
3288
- statesRoot: base.sourceRoots.states[0] ?? 'history/states',
3289
- localisationRoot: base.sourceRoots.localisation[0] ?? 'localisation',
3290
- });
3291
- const roots = operation.replaceBaseGame === false ? [] : [...WORLD_REPLACED_ROOTS];
3313
+ rivers: operation.rivers ?? (reference === undefined ? 8 : 80),
3314
+ }, buildWorldContext(base));
3315
+ const roots = operation.replaceBaseGame === false
3316
+ ? []
3317
+ : [...WORLD_REPLACED_ROOTS, ...(startingScenario ? STARTING_SCENARIO_REPLACED_ROOTS : [])];
3292
3318
  replacedRootsByPlan.set(changes, roots);
3293
3319
  for (const file of world.files)
3294
3320
  addChange(changes, file.relativePath, file.content, operation.id, file.mediaType);
3321
+ // Remove only unchanged outputs recorded by a previous generation, before indexing new IDs.
3322
+ for (const file of reconcileWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation))
3323
+ if (file.content === null)
3324
+ addChange(changes, file.relativePath, null, operation.id, 'text/plain');
3295
3325
  // Start from empty derived files so no base-game position or supply line survives.
3296
3326
  for (const name of [
3297
3327
  'buildings.txt',
@@ -3309,7 +3339,28 @@ function applyCreateWorld(base, changes, operation) {
3309
3339
  const positioned = currentIndex(base, changes);
3310
3340
  for (const file of planSupplyRebuild(positioned, { mode: 'replace' }))
3311
3341
  addChange(changes, file.relativePath, file.content, operation.id, 'text/plain');
3342
+ for (const file of reconcileWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation))
3343
+ addChange(changes, file.relativePath, file.content, operation.id, file.mediaType ?? 'text/plain');
3312
3344
  return [
3345
+ ...(reference?.diagnostics ?? []).map((message) => ({
3346
+ code: 'MAP_REFERENCE_INTERPRETATION',
3347
+ severity: 'warning',
3348
+ category: 'map',
3349
+ operationId: operation.id,
3350
+ message,
3351
+ })),
3352
+ ...(world.warnings.length === 0
3353
+ ? []
3354
+ : [
3355
+ {
3356
+ code: 'MAP_WORLD_GENERATION_REVIEW',
3357
+ severity: 'warning',
3358
+ category: 'map',
3359
+ operationId: operation.id,
3360
+ message: `Drainage generation reported ${world.warnings.length} review notes`,
3361
+ details: { warningCount: world.warnings.length, samples: world.warnings.slice(0, 5) },
3362
+ },
3363
+ ]),
3313
3364
  {
3314
3365
  code: 'MAP_WORLD_CREATED',
3315
3366
  severity: 'info',
@@ -3405,7 +3456,7 @@ function applyEntityLocator(base, changes, operation) {
3405
3456
  ]);
3406
3457
  addChange(changes, locator.file.relativePath, output, operation.id, 'text/plain');
3407
3458
  }
3408
- function* planMapOperationSteps(base, operations, signal) {
3459
+ function* planMapOperationSteps(base, operations, signal, context = {}) {
3409
3460
  yield* cancellationCheckpoint(signal);
3410
3461
  const changes = new Map();
3411
3462
  const diagnostics = [];
@@ -3443,7 +3494,7 @@ function* planMapOperationSteps(base, operations, signal) {
3443
3494
  const boundsCheckpoint = expectedChangedBounds;
3444
3495
  try {
3445
3496
  if (operation.kind === 'create_world')
3446
- diagnostics.push(...applyCreateWorld(base, changes, operation));
3497
+ diagnostics.push(...applyCreateWorld(base, changes, operation, context));
3447
3498
  else if (operation.kind === 'move_state_provinces')
3448
3499
  applyMoveStateProvinces(base, changes, operation);
3449
3500
  else if (operation.kind === 'update_state')
@@ -3522,12 +3573,12 @@ function* planMapOperationSteps(base, operations, signal) {
3522
3573
  operations: manifestOperations,
3523
3574
  };
3524
3575
  }
3525
- export function planMapOperations(base, operations, signal) {
3526
- return completeSynchronously(planMapOperationSteps(base, operations, signal));
3576
+ export function planMapOperations(base, operations, signal, context = {}) {
3577
+ return completeSynchronously(planMapOperationSteps(base, operations, signal, context));
3527
3578
  }
3528
3579
  /** Plans map mutations while periodically yielding so protocol cancellation can be delivered. */
3529
- export async function planMapOperationsAsync(base, operations, signal) {
3530
- return completeCooperatively(planMapOperationSteps(base, operations, signal), signal);
3580
+ export async function planMapOperationsAsync(base, operations, signal, context = {}) {
3581
+ return completeCooperatively(planMapOperationSteps(base, operations, signal, context), signal);
3531
3582
  }
3532
3583
  export function mapOperationId(operation) {
3533
3584
  return deterministicId('map_operation', operation);