hoi4-agent-tools 3.4.0 → 3.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 (109) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +26 -18
  3. package/dist/hoi4_agent_tools/core/condition-evaluator.js +48 -2
  4. package/dist/hoi4_agent_tools/core/condition-schema.js +18 -0
  5. package/dist/hoi4_agent_tools/core/configuration.js +10 -0
  6. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +9 -1
  7. package/dist/hoi4_agent_tools/core/impact-semantic.js +11 -4
  8. package/dist/hoi4_agent_tools/core/index.js +1 -0
  9. package/dist/hoi4_agent_tools/core/job-worker-host.js +19 -1
  10. package/dist/hoi4_agent_tools/core/operation-result.js +4 -1
  11. package/dist/hoi4_agent_tools/core/scanner.js +1 -1
  12. package/dist/hoi4_agent_tools/core/scenario-model.js +6 -0
  13. package/dist/hoi4_agent_tools/core/scenario-state.js +3 -0
  14. package/dist/hoi4_agent_tools/core/workspace.js +13 -1
  15. package/dist/hoi4_agent_tools/event/graph.js +8 -1
  16. package/dist/hoi4_agent_tools/event/operations.js +16 -10
  17. package/dist/hoi4_agent_tools/event/service.js +1 -0
  18. package/dist/hoi4_agent_tools/event/source-analysis.js +1 -1
  19. package/dist/hoi4_agent_tools/focus/render.js +10 -2
  20. package/dist/hoi4_agent_tools/gui/layout.js +58 -11
  21. package/dist/hoi4_agent_tools/gui/scenario.js +3 -1
  22. package/dist/hoi4_agent_tools/gui/source-graph.js +4 -1
  23. package/dist/hoi4_agent_tools/gui/studio.js +32 -10
  24. package/dist/hoi4_agent_tools/gui/validators.js +3 -0
  25. package/dist/hoi4_agent_tools/map/model.js +121 -12
  26. package/dist/hoi4_agent_tools/map/operations.js +80 -7
  27. package/dist/hoi4_agent_tools/map/service.js +1 -0
  28. package/dist/hoi4_agent_tools/map/validation.js +38 -1
  29. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +18 -1
  30. package/dist/hoi4_agent_tools/mcp/server/create.js +2 -0
  31. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  32. package/dist/hoi4_agent_tools/mcp/tools/reference.js +60 -0
  33. package/dist/hoi4_agent_tools/mechanic/interpreter.js +91 -30
  34. package/dist/hoi4_agent_tools/mechanic/source.js +19 -4
  35. package/dist/hoi4_agent_tools/probability/service.js +2 -2
  36. package/dist/hoi4_agent_tools/probability/source-analysis.js +16 -0
  37. package/dist/hoi4_agent_tools/probability/value-evaluator.js +2 -1
  38. package/dist/hoi4_agent_tools/reference/lines.js +52 -0
  39. package/dist/hoi4_agent_tools/reference/operations.js +41 -0
  40. package/dist/hoi4_agent_tools/reference/service.js +427 -0
  41. package/dist/hoi4_agent_tools/reference/source-lookup.js +90 -0
  42. package/dist/hoi4_agent_tools/schemas/analysis.js +2 -0
  43. package/dist/hoi4_agent_tools/schemas/gui-requests.js +1 -1
  44. package/dist/hoi4_agent_tools/schemas/probability-requests.js +1 -1
  45. package/dist/hoi4_agent_tools/schemas/probability.js +26 -2
  46. package/dist/hoi4_agent_tools/schemas/reference.js +160 -0
  47. package/dist/hoi4_agent_tools/technology/operations.js +29 -1
  48. package/dist/hoi4_agent_tools/technology/render.js +11 -5
  49. package/dist/hoi4_agent_tools/technology/service.js +1 -0
  50. package/dist/hoi4_agent_tools/version.js +1 -1
  51. package/docs/README.md +3 -0
  52. package/docs/events.md +1 -1
  53. package/docs/gui.md +7 -0
  54. package/docs/jobs.md +1 -0
  55. package/docs/map.md +8 -0
  56. package/docs/mechanics.md +2 -1
  57. package/docs/probability.md +7 -0
  58. package/docs/reference.md +32 -0
  59. package/docs/research/reference-benchmark.md +28 -0
  60. package/docs/setup.md +6 -0
  61. package/docs/technology.md +5 -1
  62. package/examples/clients/codex-config.toml +2 -0
  63. package/package.json +4 -2
  64. package/schemas/configuration.schema.json +72 -58
  65. package/schemas/continuous-focus-palette.schema.json +1 -1
  66. package/schemas/custom-weighted-pool.schema.json +1 -1
  67. package/schemas/decision-inspect-input.schema.json +43 -26
  68. package/schemas/event-feature-manifest.schema.json +1 -1
  69. package/schemas/focus-plan.schema.json +1 -1
  70. package/schemas/focus-planning-sidecar.schema.json +1 -1
  71. package/schemas/gui-animation-source.schema.json +1 -1
  72. package/schemas/gui-helper.schema.json +1 -1
  73. package/schemas/gui-scenario.schema.json +102 -75
  74. package/schemas/helper-expansion-request.schema.json +1 -1
  75. package/schemas/helper-expansion-summary.schema.json +1 -1
  76. package/schemas/impact-inspect-input.schema.json +1 -1
  77. package/schemas/map-operation.schema.json +1 -1
  78. package/schemas/mechanic-test-input.schema.json +49 -32
  79. package/schemas/operation-result.schema.json +1 -1
  80. package/schemas/package-check-input.schema.json +1 -1
  81. package/schemas/package-manifest.schema.json +1 -1
  82. package/schemas/probability-analysis-result.schema.json +1 -1
  83. package/schemas/probability-compare-input.schema.json +160 -131
  84. package/schemas/probability-evaluate-input.schema.json +161 -132
  85. package/schemas/probability-inspect-input.schema.json +20 -11
  86. package/schemas/probability-render-input.schema.json +1 -1
  87. package/schemas/probability-scenario-set.schema.json +110 -90
  88. package/schemas/probability-sequence-input.schema.json +154 -134
  89. package/schemas/probability-simulate-input.schema.json +161 -132
  90. package/schemas/probability-sweep-input.schema.json +163 -134
  91. package/schemas/reference-context-input.schema.json +42 -0
  92. package/schemas/reference-read-input.schema.json +41 -0
  93. package/schemas/reference-search-input.schema.json +45 -0
  94. package/schemas/scenario-suite.schema.json +92 -75
  95. package/schemas/scenario-test-input.schema.json +103 -86
  96. package/schemas/shared-scenario.schema.json +39 -22
  97. package/schemas/source-lookup-input.schema.json +61 -0
  98. package/server.json +2 -2
  99. package/docs/images/readme/chaos-meter-ingame.png +0 -0
  100. package/docs/images/readme/chaos-meter-mcp.png +0 -0
  101. package/docs/images/readme/communist-insurgency-ingame.png +0 -0
  102. package/docs/images/readme/communist-insurgency-mcp.png +0 -0
  103. package/docs/images/readme/event-chain-mcp.png +0 -0
  104. package/docs/images/readme/focus-tree-mcp.png +0 -0
  105. package/docs/images/readme/map-mcp.png +0 -0
  106. package/docs/images/readme/probability-mcp.png +0 -0
  107. package/docs/images/readme/settings-advanced-mcp.png +0 -0
  108. package/docs/images/readme/settings-events-mcp.png +0 -0
  109. package/docs/images/readme/technology-tree-mcp.png +0 -0
@@ -1172,7 +1172,11 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1172
1172
  const clipChain = inheritedClipChain ?? (inheritedClip === undefined ? [] : [inheritedClip]);
1173
1173
  const localScale = scalarNumber(property(definition.attributes, 'scale'), 1) ?? 1;
1174
1174
  const scale = parentScale * localScale;
1175
- const position = objectProperty(property(definition.attributes, 'position'));
1175
+ const shownPosition = depth === 0 ? objectProperty(property(definition.attributes, 'show_position')) : undefined;
1176
+ const position = shownPosition ?? objectProperty(property(definition.attributes, 'position'));
1177
+ if (shownPosition !== undefined) {
1178
+ addFidelity(fidelity, 'modelled', 'window_show_position', 'The requested root window uses its declared settled show position.', definition);
1179
+ }
1176
1180
  const size = objectProperty(property(definition.attributes, 'size'));
1177
1181
  const scriptedX = rowValues?.[`${definition.name}.x`] ??
1178
1182
  scenario.values[`${definition.name}.x`] ??
@@ -1196,8 +1200,22 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1196
1200
  const orientationAxes = anchorAxes(orientation);
1197
1201
  const anchorX = anchoredCoordinate(parentRect.x, parentRect.width, localX, orientationAxes.horizontal);
1198
1202
  const anchorY = anchoredCoordinate(parentRect.y, parentRect.height, localY, orientationAxes.vertical);
1199
- const widthValue = size === undefined ? undefined : property(size, 'width', 'x');
1200
- const heightValue = size === undefined ? undefined : property(size, 'height', 'y');
1203
+ const scriptedWidth = rowValues?.[`${definition.name}.width`] ??
1204
+ scenario.values[`${definition.name}.width`] ??
1205
+ scenario.scriptedGui[`${definition.name}.width`];
1206
+ const scriptedHeight = rowValues?.[`${definition.name}.height`] ??
1207
+ scenario.values[`${definition.name}.height`] ??
1208
+ scenario.scriptedGui[`${definition.name}.height`];
1209
+ const widthValue = typeof scriptedWidth === 'number' && Number.isFinite(scriptedWidth)
1210
+ ? scriptedWidth
1211
+ : size === undefined
1212
+ ? undefined
1213
+ : property(size, 'width', 'x');
1214
+ const heightValue = typeof scriptedHeight === 'number' && Number.isFinite(scriptedHeight)
1215
+ ? scriptedHeight
1216
+ : size === undefined
1217
+ ? undefined
1218
+ : property(size, 'height', 'y');
1201
1219
  let width = scaledNumber(widthValue, parentRect.width, scale) ?? 0;
1202
1220
  let height = scaledNumber(heightValue, parentRect.height, scale) ?? 0;
1203
1221
  // A doubled percent names a far-edge coordinate in the parent, whereas a single
@@ -1455,10 +1473,17 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1455
1473
  width = declaredMaxWidth;
1456
1474
  if (width === 0 && inferWidth && /textbox/iu.test(definition.elementType))
1457
1475
  width = nativeTextBoxWidth * scale;
1458
- if (height === 0 && inferHeight && declaredMaxHeight !== undefined)
1476
+ const inferredTextHeight = height === 0 && inferHeight;
1477
+ if (inferredTextHeight && declaredMaxHeight !== undefined)
1459
1478
  height = declaredMaxHeight;
1460
1479
  const maxWidth = declaredMaxWidth ?? width;
1461
- const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, maxWidth, context.work, resolvedInlineIcons);
1480
+ const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, scalarBoolean(property(definition.attributes, 'multiline')) === false ? 0 : maxWidth, context.work, resolvedInlineIcons);
1481
+ // Native maxHeight text boxes retain at least one font line. Explicit size
1482
+ // rectangles and inherited parent clipping still constrain their pixels.
1483
+ if (inferredTextHeight && declaredMaxHeight !== undefined && height < wrapped.lineHeight) {
1484
+ height = wrapped.lineHeight;
1485
+ addFidelity(fidelity, 'approximated', 'text_minimum_line_height', 'An inferred maxHeight smaller than one font line retains the first line; this native layout convention has no engine capture in this scenario.', definition);
1486
+ }
1462
1487
  const glyphLines = [];
1463
1488
  const inlineIcons = [];
1464
1489
  for (const [lineIndex, line] of wrapped.lines.entries()) {
@@ -1747,7 +1772,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1747
1772
  if (state === 'full-list' && rows === undefined)
1748
1773
  rows = Array.from({ length: 12 }, (_unused, index) => ({ index }));
1749
1774
  const dynamicList = context.dynamicListsByName.get(definition.name);
1750
- const listElement = /(?:grid|listbox|overlappingelements)/iu.test(definition.elementType);
1775
+ const listElement = /(?:grid|listbox|overlappingelements|positiontype)/iu.test(definition.elementType);
1751
1776
  const slotSize = objectProperty(property(definition.attributes, 'slotsize'));
1752
1777
  const slotHeight = scaledNumber(slotSize === undefined ? undefined : property(slotSize, 'height', 'y'), height, scale);
1753
1778
  const slotWidth = scaledNumber(slotSize === undefined ? undefined : property(slotSize, 'width', 'x'), width, scale);
@@ -1761,8 +1786,13 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1761
1786
  const maxHorizontal = scalarNumber(property(definition.attributes, 'max_slots_horizontal'), 1);
1762
1787
  const maxVertical = scalarNumber(property(definition.attributes, 'max_slots_vertical'), 1);
1763
1788
  const grid = /grid/iu.test(definition.elementType);
1789
+ const singleColumnFlow = grid &&
1790
+ maxHorizontal === 1 &&
1791
+ slotHeight !== undefined &&
1792
+ slotHeight <= scale &&
1793
+ (maxVertical === undefined || maxVertical <= 0);
1764
1794
  const rowPosition = (index, fallbackY) => {
1765
- if (!grid || slotWidth === undefined || slotHeight === undefined)
1795
+ if (!grid || singleColumnFlow || slotWidth === undefined || slotHeight === undefined)
1766
1796
  return { x: 0, y: fallbackY };
1767
1797
  if (maxVertical !== undefined && maxVertical > 0) {
1768
1798
  const rowsPerColumn = Math.max(1, Math.trunc(maxVertical));
@@ -1822,11 +1852,22 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1822
1852
  (row.countryScope === true
1823
1853
  ? (dynamicList?.countryScopeEntryContainer ?? dynamicList?.entryContainer)
1824
1854
  : (dynamicList?.entryContainer ?? dynamicList?.countryScopeEntryContainer));
1825
- const template = templateName === undefined ? undefined : context.elementsByName.get(templateName);
1855
+ const candidates = templateName === undefined
1856
+ ? []
1857
+ : (context.elementsByNameCandidates.get(templateName) ?? []);
1858
+ const localCandidates = candidates.filter(({ sourcePath }) => sourcePath === definition.sourcePath);
1859
+ const localRoots = localCandidates.filter(({ parentId }) => parentId === undefined);
1860
+ const template = localRoots.length === 1
1861
+ ? localRoots[0]
1862
+ : localCandidates.length === 1
1863
+ ? localCandidates[0]
1864
+ : candidates.length === 1
1865
+ ? candidates[0]
1866
+ : undefined;
1826
1867
  if (template === undefined) {
1827
1868
  diagnostics.push(diagnostic('GUI_DYNAMIC_LIST_TEMPLATE_MISSING', 'error', templateName === undefined
1828
1869
  ? `Dynamic list ${definition.name} has no entry container for scenario row ${index}.`
1829
- : `Dynamic list ${definition.name} cannot resolve entry container ${templateName}.`, definition));
1870
+ : `Dynamic list ${definition.name} cannot unambiguously resolve entry container ${templateName}.`, definition));
1830
1871
  continue;
1831
1872
  }
1832
1873
  const templatePosition = objectProperty(property(template.attributes, 'position'));
@@ -1842,7 +1883,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1842
1883
  };
1843
1884
  await layoutElement(template, shiftedParent, childClip, scale, depth + 1, context, rowSuffix(index), index, row, instanceId, origo, resolveVisibleAppearance, childClipChain);
1844
1885
  const rendered = context.instancesById.get(`${template.id}${rowSuffix(index)}`);
1845
- rowY += (slotHeight ?? rendered?.unclippedRect.height ?? 0) + spacingY;
1886
+ rowY += Math.max(slotHeight ?? 0, rendered?.unclippedRect.height ?? 0) + spacingY;
1846
1887
  renderedRows += 1;
1847
1888
  }
1848
1889
  addFidelity(fidelity, renderedRows === rows.length ? 'modelled' : 'missing', dynamicList === undefined ? 'native_dynamic_list' : 'scripted_gui_dynamic_list', `Expanded ${renderedRows}/${rows.length} scenario rows for ${definition.name} through ${dynamicList === undefined ? 'explicit native' : 'scripted-GUI'} entry containers.`, definition);
@@ -2055,10 +2096,15 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2055
2096
  };
2056
2097
  const output = [];
2057
2098
  const elementsByName = new Map();
2099
+ const elementsByNameCandidates = new Map();
2058
2100
  for (const element of [...graph.elements].sort((left, right) => compareCodeUnits(right.sourcePath, left.sourcePath) ||
2059
- right.definitionOrder - left.definitionOrder))
2101
+ right.definitionOrder - left.definitionOrder)) {
2060
2102
  if (!elementsByName.has(element.name))
2061
2103
  elementsByName.set(element.name, element);
2104
+ const candidates = elementsByNameCandidates.get(element.name) ?? [];
2105
+ candidates.push(element);
2106
+ elementsByNameCandidates.set(element.name, candidates);
2107
+ }
2062
2108
  const context = {
2063
2109
  graph,
2064
2110
  scenario: layoutScenario,
@@ -2067,6 +2113,7 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2067
2113
  diagnostics,
2068
2114
  elementsById,
2069
2115
  elementsByName,
2116
+ elementsByNameCandidates,
2070
2117
  dynamicListsByName,
2071
2118
  countryFlagProperties,
2072
2119
  constantElementEnabled,
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { ConditionScopeBindingSchema, ConditionResultSchema } from '../core/condition-schema.js';
2
+ import { ConditionScopeBindingSchema, ConditionResultSchema, ConditionControlMapSchema, GuiScenarioDateSchema, } from '../core/condition-schema.js';
3
3
  import { renderDimensionViolation, RENDER_MAX_DIMENSION } from '../core/render-budget.js';
4
4
  import { ServiceError } from '../core/result.js';
5
5
  import { GUI_SCENARIO_MAX_KEYS, GUI_SCENARIO_MAX_LIST_KEYS, GUI_SCENARIO_MAX_ROWS, GUI_SCENARIO_MAX_STRING_CHARACTERS, } from './limits.js';
@@ -104,6 +104,8 @@ const GuiPreviewScenarioBodySchema = z
104
104
  .max(128)
105
105
  .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u),
106
106
  description: z.string().max(500).optional(),
107
+ date: GuiScenarioDateSchema.optional(),
108
+ controls: ConditionControlMapSchema.optional(),
107
109
  resolution: GuiPreviewResolutionSchema.default({ width: 1920, height: 1080 }),
108
110
  uiScale: z
109
111
  .number()
@@ -56,6 +56,7 @@ const modelledElementAttributes = new Set([
56
56
  'maxWidth',
57
57
  'maxHeight',
58
58
  'fixedsize',
59
+ 'multiline',
59
60
  'format',
60
61
  'buttonText',
61
62
  'buttonFont',
@@ -1292,7 +1293,7 @@ function referenceLabel(edge) {
1292
1293
  return String(value);
1293
1294
  return value === undefined ? edge.to : JSON.stringify(value);
1294
1295
  }
1295
- export function buildGuiSourceGraph(files, sharedIndex) {
1296
+ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKeys = []) {
1296
1297
  const activeFiles = files.filter(({ shadowedBy }) => shadowedBy === undefined);
1297
1298
  const nodes = [];
1298
1299
  const edges = [];
@@ -1435,6 +1436,8 @@ export function buildGuiSourceGraph(files, sharedIndex) {
1435
1436
  indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
1436
1437
  }
1437
1438
  const referencedLocalisationKeys = referencedGuiLocalisationKeys(elements, scriptedLocalisation);
1439
+ for (const key of additionalLocalisationKeys)
1440
+ referencedLocalisationKeys.add(key);
1438
1441
  for (const { file, fileNodeId } of localisationFiles.sort((left, right) => right.file.loadOrder - left.file.loadOrder ||
1439
1442
  compareCodeUnits(left.file.displayPath, right.file.displayPath))) {
1440
1443
  const document = parseLocalisation(file.bytes, file.displayPath);
@@ -498,6 +498,24 @@ function scenarioTemplateNames(scenarios) {
498
498
  ...new Set(scenarios.flatMap(({ lists }) => Object.values(lists).flatMap((rows) => rows.flatMap(({ entryContainer }) => typeof entryContainer === 'string' && entryContainer.length > 0 ? [entryContainer] : [])))),
499
499
  ].sort(compareCodeUnits);
500
500
  }
501
+ function scenarioLocalisationKeys(scenarios) {
502
+ const keys = new Set();
503
+ const pending = [...scenarios];
504
+ while (pending.length > 0) {
505
+ const value = pending.pop();
506
+ if (typeof value === 'string') {
507
+ if (/^[A-Za-z0-9_.-]{1,512}$/u.test(value))
508
+ keys.add(value);
509
+ for (const match of value.matchAll(/\$([A-Za-z0-9_.-]+)\$/gu))
510
+ keys.add(match[1]);
511
+ }
512
+ else if (Array.isArray(value))
513
+ pending.push(...value);
514
+ else if (value !== null && typeof value === 'object')
515
+ pending.push(...Object.values(value));
516
+ }
517
+ return [...keys].sort(compareCodeUnits);
518
+ }
501
519
  /** Native row bindings admit only bounded country tokens, never caller-supplied paths or globs. */
502
520
  export function scenarioCountryFlagPatterns(scenarios) {
503
521
  const tags = new Set();
@@ -861,8 +879,8 @@ export class ScriptedGuiStudio {
861
879
  this.scanner = this.engine.scanner;
862
880
  this.artifacts = this.engine.artifacts;
863
881
  }
864
- graphForFiles(files, scope) {
865
- const key = `${scope}:${hashCanonical(files.map(({ displayPath, sha256, shadowedBy }) => ({
882
+ graphForFiles(files, scope, additionalLocalisationKeys = []) {
883
+ const key = `${scope}:${hashCanonical(additionalLocalisationKeys)}:${hashCanonical(files.map(({ displayPath, sha256, shadowedBy }) => ({
866
884
  displayPath,
867
885
  sha256,
868
886
  ...(shadowedBy === undefined ? {} : { shadowedBy }),
@@ -876,7 +894,7 @@ export class ScriptedGuiStudio {
876
894
  const retainedFiles = [...files];
877
895
  const scanned = {
878
896
  files: retainedFiles,
879
- graph: buildGuiSourceGraph(retainedFiles, this.engine.indexFiles(retainedFiles)),
897
+ graph: buildGuiSourceGraph(retainedFiles, this.engine.indexFiles(retainedFiles), additionalLocalisationKeys),
880
898
  };
881
899
  this.#graphCache.set(key, scanned);
882
900
  while (this.#graphCache.size > 1) {
@@ -906,7 +924,7 @@ export class ScriptedGuiStudio {
906
924
  const files = mergeScannedFiles(snapshot.files, localisationFiles);
907
925
  return this.graphForFiles(files, `workspace:${workspaceId}`);
908
926
  }
909
- async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = [], additionalTemplateNames = [], additionalAssetPatterns = []) {
927
+ async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = [], additionalTemplateNames = [], additionalAssetPatterns = [], additionalLocalisationKeys = []) {
910
928
  const workspace = this.resolver.get(workspaceId, principal);
911
929
  const layoutPatterns = guiLayoutPatterns(workspace);
912
930
  const overlayLayouts = await this.engine.scan(workspaceId, {
@@ -934,7 +952,7 @@ export class ScriptedGuiStudio {
934
952
  this.engine.scan(workspaceId, { patterns: guiLocalisationPatterns(workspace, languages) }, principal, signal),
935
953
  ]);
936
954
  const definitionFiles = mergeScannedFiles(layoutFiles, linkedDefinitions.files, localisation.files);
937
- const definitions = this.graphForFiles(definitionFiles, `definitions:${workspaceId}`);
955
+ const definitions = this.graphForFiles(definitionFiles, `definitions:${workspaceId}`, additionalLocalisationKeys);
938
956
  assertTargetWindowAvailable(definitions.graph, windowName);
939
957
  const referencedPatterns = [
940
958
  ...referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames, languages, additionalTemplateNames),
@@ -973,7 +991,7 @@ export class ScriptedGuiStudio {
973
991
  const fontPages = mergeScannedFiles(exactFontPages, fallbackFontPages);
974
992
  signal?.throwIfAborted();
975
993
  const files = mergeScannedFiles(definitions.files, referenced, fontPages);
976
- return this.graphForFiles(files, `window:${workspaceId}:${windowName}`);
994
+ return this.graphForFiles(files, `window:${workspaceId}:${windowName}`, additionalLocalisationKeys);
977
995
  }
978
996
  async lint(input) {
979
997
  input.signal?.throwIfAborted();
@@ -982,7 +1000,7 @@ export class ScriptedGuiStudio {
982
1000
  const generatedOptions = input.generatedScenarios === undefined
983
1001
  ? undefined
984
1002
  : parseGeneratedScenarioOptions(input.generatedScenarios);
985
- const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryFlagPatterns([placeholderScenario, ...explicitRelatedScenarios]));
1003
+ const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryFlagPatterns([placeholderScenario, ...explicitRelatedScenarios]), scenarioLocalisationKeys([placeholderScenario, ...explicitRelatedScenarios]));
986
1004
  const generatedScenarios = generatedOptions === undefined
987
1005
  ? []
988
1006
  : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, ClausewitzEvaluationDefinitions.build(scanned));
@@ -1012,7 +1030,7 @@ export class ScriptedGuiStudio {
1012
1030
  const budget = new RenderBudget();
1013
1031
  const beforeScenario = parsePreviewScenario(input.before);
1014
1032
  const afterScenario = parsePreviewScenario(input.after);
1015
- const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]), scenarioTemplateNames([beforeScenario, afterScenario]), scenarioCountryFlagPatterns([beforeScenario, afterScenario]));
1033
+ const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]), scenarioTemplateNames([beforeScenario, afterScenario]), scenarioCountryFlagPatterns([beforeScenario, afterScenario]), scenarioLocalisationKeys([beforeScenario, afterScenario]));
1016
1034
  const beforeCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, beforeScenario.language);
1017
1035
  const afterCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, afterScenario.language);
1018
1036
  const before = await buildGuiScene(scanned.graph, scanned.files, input.windowName, beforeScenario, beforeCatalog);
@@ -1069,6 +1087,10 @@ export class ScriptedGuiStudio {
1069
1087
  placeholderScenario,
1070
1088
  ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
1071
1089
  ...explicitRelatedScenarios,
1090
+ ]), scenarioLocalisationKeys([
1091
+ placeholderScenario,
1092
+ ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
1093
+ ...explicitRelatedScenarios,
1072
1094
  ]));
1073
1095
  const generatedScenarios = generatedOptions === undefined
1074
1096
  ? []
@@ -1177,7 +1199,7 @@ export class ScriptedGuiStudio {
1177
1199
  loadOrder: original.loadOrder,
1178
1200
  },
1179
1201
  ]);
1180
- const baselineGraph = this.graphForFiles(baselineFiles, `source-baseline:${input.workspaceId}`).graph;
1202
+ const baselineGraph = this.graphForFiles(baselineFiles, `source-baseline:${input.workspaceId}`, scenarioLocalisationKeys([scenario])).graph;
1181
1203
  if (!baselineGraph.elements.some(({ name }) => name === input.windowName))
1182
1204
  throw new ServiceError('GUI_SOURCE_BASELINE_WINDOW_MISSING', 'Source baseline does not define the requested GUI window', { relativePath, windowName: input.windowName });
1183
1205
  sourceBaselineScene = await buildGuiScene(baselineGraph, baselineFiles, input.windowName, scenario, new GuiAssetCatalog(baselineGraph, baselineFiles, budget, scenario.language));
@@ -1416,7 +1438,7 @@ export class ScriptedGuiStudio {
1416
1438
  const previewScenario = input.scenario === undefined ? undefined : parsePreviewScenario(input.scenario);
1417
1439
  const initiallyScanned = input.windowName === undefined
1418
1440
  ? await this.scan(input.workspaceId, input.principal, input.signal)
1419
- : await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]), scenarioTemplateNames([previewScenario]), scenarioCountryFlagPatterns([previewScenario]));
1441
+ : await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]), scenarioTemplateNames([previewScenario]), scenarioCountryFlagPatterns([previewScenario]), scenarioLocalisationKeys([previewScenario]));
1420
1442
  const exactTargets = await this.scanner.scan(workspace, {
1421
1443
  patterns: prepared.map(({ relativePath }) => relativePath),
1422
1444
  ...(input.signal === undefined ? {} : { signal: input.signal }),
@@ -655,6 +655,9 @@ function validateReferencesAndScript(graph, scene, diagnostics) {
655
655
  const elementsById = new Map(graph.elements.map((element) => [element.id, element]));
656
656
  for (const button of scene.elements.filter(({ elementType }) => /button/iu.test(elementType))) {
657
657
  const source = elementsById.get(button.sourceId);
658
+ const referencedByScript = effects.has(button.name) || triggers.has(button.name);
659
+ if ((source === undefined || !/button/iu.test(source.elementType)) && !referencedByScript)
660
+ continue;
658
661
  const tooltipOnly = !effects.has(button.name) &&
659
662
  !triggers.has(button.name) &&
660
663
  (typeof source?.attributes.pdx_tooltip === 'string' ||
@@ -169,6 +169,39 @@ function parseNumber(value) {
169
169
  const parsed = Number(value);
170
170
  return Number.isFinite(parsed) ? parsed : undefined;
171
171
  }
172
+ export function parseMapDate(value) {
173
+ const match = /^(\d{1,4})\.(\d{1,2})\.(\d{1,2})(?:\.(\d{1,2}))?$/u.exec(value);
174
+ if (match === null)
175
+ return undefined;
176
+ const year = Number(match[1]);
177
+ const month = Number(match[2]);
178
+ const day = Number(match[3]);
179
+ const hour = match[4] === undefined ? 0 : Number(match[4]);
180
+ if (year < 1 || month < 1 || month > 12 || day < 1 || day > 31 || hour < 0 || hour > 23)
181
+ return undefined;
182
+ return year * 1_000_000 + month * 10_000 + day * 100 + hour;
183
+ }
184
+ function earliestBookmarkDate(files) {
185
+ let earliest;
186
+ for (const file of files) {
187
+ if (!/^common\/bookmarks\/.*\.txt$/iu.test(normalizedPath(file.relativePath)))
188
+ continue;
189
+ const document = parseClausewitz(file.bytes, file.displayPath);
190
+ for (const root of childBlocks(document.root, 'bookmarks')) {
191
+ for (const bookmark of childBlocks(root, 'bookmark')) {
192
+ const value = firstScalar(bookmark, 'date')?.value;
193
+ if (value === undefined)
194
+ continue;
195
+ const ordinal = parseMapDate(value);
196
+ if (ordinal === undefined)
197
+ continue;
198
+ if (earliest === undefined || ordinal < earliest.ordinal)
199
+ earliest = { value, ordinal, sourcePath: file.displayPath };
200
+ }
201
+ }
202
+ }
203
+ return earliest;
204
+ }
172
205
  function requiredNumber(values, index, label) {
173
206
  const value = values[index];
174
207
  if (value === undefined) {
@@ -260,7 +293,7 @@ export function parseDefinitions(file, diagnostics) {
260
293
  }
261
294
  return definitions;
262
295
  }
263
- export function parseState(file, diagnostics) {
296
+ export function parseState(file, diagnostics, earliestBookmark) {
264
297
  const document = parseClausewitz(file.bytes, file.displayPath);
265
298
  addMapDiagnostics(diagnostics, document.diagnostics);
266
299
  const assignment = firstTopLevelAssignment(document, 'state');
@@ -318,8 +351,85 @@ export function parseState(file, diagnostics) {
318
351
  }
319
352
  }
320
353
  }
321
- const owner = firstScalar(historyBlock ?? block, 'owner')?.value;
322
- const controller = firstScalar(historyBlock ?? block, 'controller')?.value;
354
+ let owner = firstScalar(historyBlock ?? block, 'owner')?.value;
355
+ let controller = firstScalar(historyBlock ?? block, 'controller')?.value;
356
+ let cores = historyBlock === undefined
357
+ ? []
358
+ : assignments(historyBlock, 'add_core_of').flatMap(({ value }) => value.type === 'scalar' ? [value.value] : []);
359
+ let claims = historyBlock === undefined
360
+ ? []
361
+ : assignments(historyBlock, 'add_claim_by').flatMap(({ value }) => value.type === 'scalar' ? [value.value] : []);
362
+ if (historyBlock !== undefined) {
363
+ const dated = assignments(historyBlock)
364
+ .flatMap((entry) => {
365
+ if (entry.value.type !== 'block')
366
+ return [];
367
+ const date = parseMapDate(entry.key.value);
368
+ return date === undefined ? [] : [{ entry, date }];
369
+ })
370
+ .sort((left, right) => left.date - right.date || left.entry.start - right.entry.start);
371
+ if (dated.length > 0 && earliestBookmark === undefined) {
372
+ addMapDiagnostic(diagnostics, {
373
+ code: 'MAP_STATE_DATED_HISTORY_UNRESOLVED',
374
+ severity: 'warning',
375
+ category: 'map',
376
+ message: `State ${id} has dated history but no active bookmark date was found`,
377
+ location: nodeLocation(document, dated[0].entry),
378
+ });
379
+ }
380
+ for (const { entry, date } of dated) {
381
+ if (earliestBookmark === undefined || date >= earliestBookmark.ordinal)
382
+ continue;
383
+ if (entry.value.type !== 'block')
384
+ continue;
385
+ for (const command of assignments(entry.value)) {
386
+ const value = command.value;
387
+ if (value.type === 'scalar') {
388
+ if (command.key.value === 'owner')
389
+ owner = value.value;
390
+ else if (command.key.value === 'controller')
391
+ controller = value.value;
392
+ else if (command.key.value === 'add_core_of' && !cores.includes(value.value))
393
+ cores.push(value.value);
394
+ else if (command.key.value === 'remove_core_of')
395
+ cores = cores.filter((tag) => tag !== value.value);
396
+ else if (command.key.value === 'add_claim_by' && !claims.includes(value.value))
397
+ claims.push(value.value);
398
+ else if (command.key.value === 'remove_claim_by')
399
+ claims = claims.filter((tag) => tag !== value.value);
400
+ }
401
+ else if (command.key.value === 'victory_points') {
402
+ const values = scalarList(value);
403
+ const provinceId = parseInteger(values[0]);
404
+ const amount = parseNumber(values[1]);
405
+ if (provinceId === undefined || amount === undefined)
406
+ continue;
407
+ const existing = victoryPoints.findIndex((point) => point.provinceId === provinceId);
408
+ if (existing >= 0)
409
+ victoryPoints.splice(existing, 1);
410
+ victoryPoints.push({ provinceId, value: amount, assignment: command });
411
+ }
412
+ else if (command.key.value === 'buildings') {
413
+ for (const building of assignments(value)) {
414
+ if (building.value.type === 'block') {
415
+ const provinceId = parseInteger(building.key.value);
416
+ if (provinceId === undefined)
417
+ continue;
418
+ const merged = new Map(provinceBuildings.get(provinceId) ?? []);
419
+ for (const [key, level] of numericMap(building.value))
420
+ merged.set(key, level);
421
+ provinceBuildings.set(provinceId, merged);
422
+ }
423
+ else {
424
+ const level = parseNumber(building.value.value);
425
+ if (level !== undefined)
426
+ stateBuildings.set(building.key.value, level);
427
+ }
428
+ }
429
+ }
430
+ }
431
+ }
432
+ }
323
433
  const capital = derivedStateCapital(victoryPoints);
324
434
  return {
325
435
  id,
@@ -331,12 +441,8 @@ export function parseState(file, diagnostics) {
331
441
  resources: numericMap(resourcesBlock),
332
442
  ...(owner === undefined ? {} : { owner }),
333
443
  ...(controller === undefined ? {} : { controller }),
334
- cores: historyBlock === undefined
335
- ? []
336
- : assignments(historyBlock, 'add_core_of').flatMap(({ value }) => value.type === 'scalar' ? [value.value] : []),
337
- claims: historyBlock === undefined
338
- ? []
339
- : assignments(historyBlock, 'add_claim_by').flatMap(({ value }) => value.type === 'scalar' ? [value.value] : []),
444
+ cores,
445
+ claims,
340
446
  victoryPoints,
341
447
  provinceBuildings,
342
448
  stateBuildings,
@@ -825,6 +931,7 @@ export class MapWorkspaceIndex {
825
931
  /** Shared cross-domain symbol/reference authority for these exact source bytes. */
826
932
  sharedIndex;
827
933
  activeFiles;
934
+ earliestBookmark;
828
935
  diagnostics = [];
829
936
  definitions;
830
937
  definitionsAcrossRoots;
@@ -875,6 +982,7 @@ export class MapWorkspaceIndex {
875
982
  localisation: sourceRoots.localisation.map(normalizeConfiguredRoot),
876
983
  };
877
984
  this.activeFiles = selectActiveFiles(files);
985
+ this.earliestBookmark = earliestBookmarkDate(this.activeFiles.all);
878
986
  const modelBudget = new MapModelBudget();
879
987
  modelBudget.assertScriptFiles(this.sourceFiles.filter((file) => inConfiguredRoot(file.relativePath, this.sourceRoots.states) ||
880
988
  this.sourceRoots.map.some((root) => normalizedPath(file.relativePath).startsWith(`${root}/strategicregions/`)) ||
@@ -935,7 +1043,9 @@ export class MapWorkspaceIndex {
935
1043
  const cached = parsedStates.get(file.displayPath);
936
1044
  if (cached !== undefined)
937
1045
  return cached ?? undefined;
938
- const state = parseState(file, activeStatePaths.has(file.displayPath) ? this.diagnostics : []);
1046
+ const state = parseState(file, activeStatePaths.has(file.displayPath) ? this.diagnostics : [], this.earliestBookmark);
1047
+ if (state !== undefined && this.earliestBookmark !== undefined)
1048
+ state.earliestBookmark = this.earliestBookmark;
939
1049
  parsedStates.set(file.displayPath, state ?? null);
940
1050
  if (state !== undefined) {
941
1051
  modelBudget.addDocument(state.document);
@@ -1075,9 +1185,8 @@ export class MapWorkspaceIndex {
1075
1185
  this.coastalProvinceIds = this.raster?.coastalProvinceIds ?? new Set();
1076
1186
  const portPositionsByStateProvince = new Map();
1077
1187
  for (const position of this.buildingPositions) {
1078
- if (position.building !== 'naval_base_spawn' && position.building !== 'floating_harbor') {
1188
+ if (position.building !== 'naval_base_spawn')
1079
1189
  continue;
1080
- }
1081
1190
  const provinceId = this.provinceAtMapCoordinate(position.x, position.z);
1082
1191
  if (provinceId === undefined)
1083
1192
  continue;
@@ -5,7 +5,7 @@ import { ServiceError } from '../core/result.js';
5
5
  import { applyReplacements, assignments, encodeSource, parseLocalisation, parseClausewitz, } from '../core/source/index.js';
6
6
  import { rgbKey } from './bmp.js';
7
7
  import { MAP_MASK_CELL_LIMIT, MAP_POLYGON_WORK_LIMIT, MAP_SELECTED_OFFSET_BYTES, MAP_SELECTED_PIXEL_LIMIT, } from './limits.js';
8
- import { derivedStateCapital, encodeTextDocument, MapWorkspaceIndex, parseTextDocument, } from './model.js';
8
+ import { derivedStateCapital, encodeTextDocument, MapWorkspaceIndex, parseMapDate, parseTextDocument, } from './model.js';
9
9
  function* cancellationCheckpoint(signal, iteration = 0, stride = 1) {
10
10
  if (iteration % stride !== 0)
11
11
  return;
@@ -1034,8 +1034,74 @@ function stateMembershipId(index, provinceId) {
1034
1034
  return states[0]?.id;
1035
1035
  }
1036
1036
  function commitState(changes, state, data, fields, operationId) {
1037
+ assertDatedStateHistorySafe(state, fields, operationId);
1037
1038
  addChange(changes, state.file.relativePath, patchState(state, data, fields), operationId, 'text/plain');
1038
1039
  }
1040
+ function assertDatedStateHistorySafe(state, fields, operationId) {
1041
+ if (state.historyBlock === undefined)
1042
+ return;
1043
+ for (const entry of assignments(state.historyBlock)) {
1044
+ if (entry.value.type !== 'block')
1045
+ continue;
1046
+ const datedAt = parseMapDate(entry.key.value);
1047
+ if (datedAt === undefined)
1048
+ continue;
1049
+ const keys = new Set(assignments(entry.value).map(({ key }) => key.value));
1050
+ const applicable = state.earliestBookmark === undefined || datedAt <= state.earliestBookmark.ordinal;
1051
+ const affected = [
1052
+ ['owner', fields.owner === true && keys.has('owner') && applicable],
1053
+ ['controller', fields.controller === true && keys.has('controller') && applicable],
1054
+ [
1055
+ 'cores',
1056
+ fields.cores === true &&
1057
+ (keys.has('add_core_of') || keys.has('remove_core_of')) &&
1058
+ applicable,
1059
+ ],
1060
+ [
1061
+ 'claims',
1062
+ fields.claims === true &&
1063
+ (keys.has('add_claim_by') || keys.has('remove_claim_by')) &&
1064
+ applicable,
1065
+ ],
1066
+ [
1067
+ 'victoryPoints',
1068
+ ((fields.victoryPoints === true && applicable) || fields.provinces === true) &&
1069
+ keys.has('victory_points'),
1070
+ ],
1071
+ [
1072
+ 'buildings',
1073
+ ((fields.buildings === true && applicable) || fields.provinces === true) &&
1074
+ keys.has('buildings'),
1075
+ ],
1076
+ ];
1077
+ const conflict = affected.find(([, selected]) => selected);
1078
+ if (conflict === undefined)
1079
+ continue;
1080
+ throw new ServiceError('MAP_DATED_STATE_HISTORY_CONFLICT', `State ${state.id} has a dated ${conflict[0]} change that this map operation cannot safely rewrite`, {
1081
+ operationId,
1082
+ stateId: state.id,
1083
+ field: conflict[0],
1084
+ datedAt: entry.key.value,
1085
+ earliestBookmark: state.earliestBookmark?.value ?? null,
1086
+ sourcePath: state.file.displayPath,
1087
+ sourceOffset: entry.start,
1088
+ });
1089
+ }
1090
+ }
1091
+ function assertNoDatedStateHistory(state, operationId) {
1092
+ if (state.historyBlock === undefined)
1093
+ return;
1094
+ const dated = assignments(state.historyBlock).find((entry) => entry.value.type === 'block' && parseMapDate(entry.key.value) !== undefined);
1095
+ if (dated === undefined)
1096
+ return;
1097
+ throw new ServiceError('MAP_DATED_STATE_DISTRIBUTION_UNSUPPORTED', `State ${state.id} has dated history that cannot be distributed to a new or merged state`, {
1098
+ operationId,
1099
+ stateId: state.id,
1100
+ datedAt: dated.key.value,
1101
+ sourcePath: state.file.displayPath,
1102
+ sourceOffset: dated.start,
1103
+ });
1104
+ }
1039
1105
  function commitRegion(changes, region, provinces, operationId) {
1040
1106
  addChange(changes, region.file.relativePath, patchRegion(region, provinces), operationId, 'text/plain');
1041
1107
  }
@@ -1219,6 +1285,7 @@ function* applySplitState(base, changes, allocations, operation, signal) {
1219
1285
  const source = index.statesById.get(sourceStateId);
1220
1286
  if (source === undefined)
1221
1287
  throw new ServiceError('MAP_STATE_NOT_FOUND', `State ${sourceStateId} does not exist`);
1288
+ assertNoDatedStateHistory(source, operation.id);
1222
1289
  const selectedSet = new Set(selected);
1223
1290
  if (selected.length === 0 || selected.some((id) => !source.provinces.includes(id))) {
1224
1291
  throw new ServiceError('MAP_STATE_PROVINCE_SELECTION_INVALID', 'Every new-state province must belong to the source state');
@@ -1336,6 +1403,8 @@ function applyMergeStates(base, changes, operation) {
1336
1403
  if (target === undefined || sourceIds.length === 0 || sources.length !== sourceIds.length) {
1337
1404
  throw new ServiceError('MAP_STATE_SELECTION_INVALID', 'Merge target and source states must exist');
1338
1405
  }
1406
+ for (const source of sources)
1407
+ assertNoDatedStateHistory(source, operation.id);
1339
1408
  const regionIds = new Set([...target.provinces, ...sources.flatMap((state) => state.provinces)].map((id) => regionMembershipId(index, id)));
1340
1409
  if (regionIds.size !== 1)
1341
1410
  throw new ServiceError('MAP_STATE_REGION_DISTRIBUTION_CONFLICT', 'Merged states must share one strategic region');
@@ -1964,12 +2033,16 @@ function* applyUpdateProvinceDefinition(base, changes, operation, signal) {
1964
2033
  if (policy.ports === 'retain-if-valid') {
1965
2034
  for (const position of dependentPortPositions) {
1966
2035
  const locatedProvince = index.provinceAtMapCoordinate(position.x, position.z);
1967
- if ((locatedProvince === source.id &&
1968
- (targetType !== 'land' ||
1969
- position.stateId !== projectedStateId ||
1970
- !updated.coastal ||
1971
- index.definitionsById.get(position.adjacentSeaProvince)?.type !== 'sea')) ||
1972
- (position.adjacentSeaProvince === source.id && targetType !== 'sea'))
2036
+ const incompatible = position.building === 'floating_harbor'
2037
+ ? (locatedProvince === source.id && targetType !== 'sea') ||
2038
+ (position.adjacentSeaProvince === source.id && targetType !== 'land')
2039
+ : (locatedProvince === source.id &&
2040
+ (targetType !== 'land' ||
2041
+ position.stateId !== projectedStateId ||
2042
+ !updated.coastal ||
2043
+ index.definitionsById.get(position.adjacentSeaProvince)?.type !== 'sea')) ||
2044
+ (position.adjacentSeaProvince === source.id && targetType !== 'sea');
2045
+ if (incompatible)
1973
2046
  provinceTypeDistributionConflict(source, targetType, 'ports', 'A retained port placement would no longer identify compatible land and sea provinces', {
1974
2047
  stateId: position.stateId,
1975
2048
  locatedProvince: locatedProvince ?? null,
@@ -379,6 +379,7 @@ function defaultMapPatterns(roots) {
379
379
  }
380
380
  function sourceTextPatterns(roots) {
381
381
  return [
382
+ 'common/bookmarks/**/*.txt',
382
383
  ...roots.map.flatMap((root) => {
383
384
  const normalized = normalizeSourceRoot(root);
384
385
  return ['map', 'csv', 'txt'].map((extension) => `${normalized}/**/*.${extension}`);