hoi4-agent-tools 3.4.1 → 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 (96) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +26 -18
  3. package/dist/hoi4_agent_tools/core/condition-evaluator.js +41 -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/scanner.js +1 -1
  9. package/dist/hoi4_agent_tools/core/scenario-model.js +6 -0
  10. package/dist/hoi4_agent_tools/core/scenario-state.js +3 -0
  11. package/dist/hoi4_agent_tools/core/workspace.js +13 -1
  12. package/dist/hoi4_agent_tools/event/graph.js +8 -1
  13. package/dist/hoi4_agent_tools/event/service.js +1 -0
  14. package/dist/hoi4_agent_tools/event/source-analysis.js +1 -1
  15. package/dist/hoi4_agent_tools/focus/render.js +10 -2
  16. package/dist/hoi4_agent_tools/gui/layout.js +58 -11
  17. package/dist/hoi4_agent_tools/gui/scenario.js +3 -1
  18. package/dist/hoi4_agent_tools/gui/source-graph.js +4 -1
  19. package/dist/hoi4_agent_tools/gui/studio.js +32 -10
  20. package/dist/hoi4_agent_tools/map/model.js +121 -12
  21. package/dist/hoi4_agent_tools/map/operations.js +80 -7
  22. package/dist/hoi4_agent_tools/map/service.js +1 -0
  23. package/dist/hoi4_agent_tools/map/validation.js +38 -1
  24. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +18 -1
  25. package/dist/hoi4_agent_tools/mcp/server/create.js +2 -0
  26. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  27. package/dist/hoi4_agent_tools/mcp/tools/reference.js +60 -0
  28. package/dist/hoi4_agent_tools/probability/service.js +2 -2
  29. package/dist/hoi4_agent_tools/probability/source-analysis.js +16 -0
  30. package/dist/hoi4_agent_tools/probability/value-evaluator.js +2 -1
  31. package/dist/hoi4_agent_tools/reference/lines.js +52 -0
  32. package/dist/hoi4_agent_tools/reference/operations.js +41 -0
  33. package/dist/hoi4_agent_tools/reference/service.js +427 -0
  34. package/dist/hoi4_agent_tools/reference/source-lookup.js +90 -0
  35. package/dist/hoi4_agent_tools/schemas/analysis.js +2 -0
  36. package/dist/hoi4_agent_tools/schemas/gui-requests.js +1 -1
  37. package/dist/hoi4_agent_tools/schemas/probability-requests.js +1 -1
  38. package/dist/hoi4_agent_tools/schemas/probability.js +26 -2
  39. package/dist/hoi4_agent_tools/schemas/reference.js +160 -0
  40. package/dist/hoi4_agent_tools/technology/render.js +9 -4
  41. package/dist/hoi4_agent_tools/version.js +1 -1
  42. package/docs/README.md +3 -0
  43. package/docs/gui.md +6 -0
  44. package/docs/map.md +8 -0
  45. package/docs/probability.md +7 -0
  46. package/docs/reference.md +32 -0
  47. package/docs/research/reference-benchmark.md +28 -0
  48. package/docs/setup.md +6 -0
  49. package/examples/clients/codex-config.toml +2 -0
  50. package/package.json +4 -2
  51. package/schemas/configuration.schema.json +72 -58
  52. package/schemas/continuous-focus-palette.schema.json +1 -1
  53. package/schemas/custom-weighted-pool.schema.json +1 -1
  54. package/schemas/decision-inspect-input.schema.json +43 -26
  55. package/schemas/event-feature-manifest.schema.json +1 -1
  56. package/schemas/focus-plan.schema.json +1 -1
  57. package/schemas/focus-planning-sidecar.schema.json +1 -1
  58. package/schemas/gui-animation-source.schema.json +1 -1
  59. package/schemas/gui-helper.schema.json +1 -1
  60. package/schemas/gui-scenario.schema.json +102 -75
  61. package/schemas/helper-expansion-request.schema.json +1 -1
  62. package/schemas/helper-expansion-summary.schema.json +1 -1
  63. package/schemas/impact-inspect-input.schema.json +1 -1
  64. package/schemas/map-operation.schema.json +1 -1
  65. package/schemas/mechanic-test-input.schema.json +49 -32
  66. package/schemas/operation-result.schema.json +1 -1
  67. package/schemas/package-check-input.schema.json +1 -1
  68. package/schemas/package-manifest.schema.json +1 -1
  69. package/schemas/probability-analysis-result.schema.json +1 -1
  70. package/schemas/probability-compare-input.schema.json +160 -131
  71. package/schemas/probability-evaluate-input.schema.json +161 -132
  72. package/schemas/probability-inspect-input.schema.json +20 -11
  73. package/schemas/probability-render-input.schema.json +1 -1
  74. package/schemas/probability-scenario-set.schema.json +110 -90
  75. package/schemas/probability-sequence-input.schema.json +154 -134
  76. package/schemas/probability-simulate-input.schema.json +161 -132
  77. package/schemas/probability-sweep-input.schema.json +163 -134
  78. package/schemas/reference-context-input.schema.json +42 -0
  79. package/schemas/reference-read-input.schema.json +41 -0
  80. package/schemas/reference-search-input.schema.json +45 -0
  81. package/schemas/scenario-suite.schema.json +92 -75
  82. package/schemas/scenario-test-input.schema.json +103 -86
  83. package/schemas/shared-scenario.schema.json +39 -22
  84. package/schemas/source-lookup-input.schema.json +61 -0
  85. package/server.json +2 -2
  86. package/docs/images/readme/chaos-meter-ingame.png +0 -0
  87. package/docs/images/readme/chaos-meter-mcp.png +0 -0
  88. package/docs/images/readme/communist-insurgency-ingame.png +0 -0
  89. package/docs/images/readme/communist-insurgency-mcp.png +0 -0
  90. package/docs/images/readme/event-chain-mcp.png +0 -0
  91. package/docs/images/readme/focus-tree-mcp.png +0 -0
  92. package/docs/images/readme/map-mcp.png +0 -0
  93. package/docs/images/readme/probability-mcp.png +0 -0
  94. package/docs/images/readme/settings-advanced-mcp.png +0 -0
  95. package/docs/images/readme/settings-events-mcp.png +0 -0
  96. package/docs/images/readme/technology-tree-mcp.png +0 -0
@@ -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 }),
@@ -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}`);
@@ -960,6 +960,43 @@ function* validatePositions(index, diagnostics, options) {
960
960
  const location = lineLocation(position.document, position.line);
961
961
  const state = index.statesById.get(position.stateId);
962
962
  const province = index.provinceAtMapCoordinate(position.x, position.z);
963
+ if (position.building === 'floating_harbor') {
964
+ const sea = province === undefined ? undefined : index.definitionsById.get(province);
965
+ const land = index.definitionsById.get(position.adjacentSeaProvince);
966
+ if (sea?.type !== 'sea') {
967
+ addDiagnostic(diagnostics, options, {
968
+ code: 'MAP_FLOATING_HARBOR_POSITION_REVIEW',
969
+ severity: 'warning',
970
+ category: 'map',
971
+ message: 'Floating-harbor position resolves outside a sea province; review the installed-map precedent',
972
+ ...(location === undefined ? {} : { location }),
973
+ details: { resolvedProvince: province },
974
+ });
975
+ }
976
+ if (land?.type !== 'land') {
977
+ addDiagnostic(diagnostics, options, {
978
+ code: 'MAP_FLOATING_HARBOR_LAND_INVALID',
979
+ severity: 'error',
980
+ category: 'map',
981
+ message: 'Floating-harbor final field must name an existing land province',
982
+ ...(location === undefined ? {} : { location }),
983
+ details: { landingProvince: position.adjacentSeaProvince, stateId: position.stateId },
984
+ });
985
+ }
986
+ else if (state === undefined ||
987
+ !state.provinces.includes(position.adjacentSeaProvince) ||
988
+ !index.coastalProvinceIds.has(position.adjacentSeaProvince)) {
989
+ addDiagnostic(diagnostics, options, {
990
+ code: 'MAP_FLOATING_HARBOR_ASSOCIATION_REVIEW',
991
+ severity: 'warning',
992
+ category: 'map',
993
+ message: 'Floating-harbor target differs from its state or computed coastal membership',
994
+ ...(location === undefined ? {} : { location }),
995
+ details: { landingProvince: position.adjacentSeaProvince, stateId: position.stateId },
996
+ });
997
+ }
998
+ continue;
999
+ }
963
1000
  if (state === undefined || province === undefined || !state.provinces.includes(province)) {
964
1001
  addDiagnostic(diagnostics, options, {
965
1002
  code: 'MAP_BUILDING_POSITION_INVALID',
@@ -970,7 +1007,7 @@ function* validatePositions(index, diagnostics, options) {
970
1007
  details: { resolvedProvince: province },
971
1008
  });
972
1009
  }
973
- if (position.building === 'naval_base_spawn' || position.building === 'floating_harbor') {
1010
+ if (position.building === 'naval_base_spawn') {
974
1011
  if (province !== undefined)
975
1012
  navalLocatorProvinces.add(province);
976
1013
  const adjacent = index.definitionsById.get(position.adjacentSeaProvince);
@@ -10,6 +10,8 @@ import { MODERN_TASK_ROUTES, ModernTaskRoutingServer } from '../transports/moder
10
10
  import { registerModernResources } from '../resources/register-modern.js';
11
11
  import { registerModernPrompts } from '../prompts/register-modern.js';
12
12
  import { jobControlTools } from '../tools/job.js';
13
+ import { referenceTools } from '../tools/reference.js';
14
+ import { isReferenceTool, ReferenceToolService } from '../../reference/operations.js';
13
15
  import { ModernTaskAdapter, TASKS_EXTENSION } from './modern-tasks.js';
14
16
  import { SERVER_INSTRUCTIONS } from './instructions.js';
15
17
  import { modernProgressReporter, withProgressHeartbeat } from './progress.js';
@@ -39,9 +41,13 @@ function clientCapabilities(envelope) {
39
41
  export function createModernOperationServer(engine, context = {}, tasks = new OperationTaskService(engine), options = {}) {
40
42
  const adapter = new ModernTaskAdapter(tasks);
41
43
  const controls = new JobControlService(engine);
44
+ const references = new ReferenceToolService(engine);
42
45
  const privateOperations = options.createPrivateTools?.(engine, context);
46
+ const mapEnd = taskToolCatalog.findIndex(({ name }) => name === 'hoi4.map_rewrite') + 1;
43
47
  const definitions = [
44
- ...taskToolCatalog,
48
+ ...taskToolCatalog.slice(0, mapEnd),
49
+ ...referenceTools,
50
+ ...taskToolCatalog.slice(mapEnd),
45
51
  ...jobControlTools,
46
52
  ...(privateOperations === undefined
47
53
  ? []
@@ -109,6 +115,17 @@ export function createModernOperationServer(engine, context = {}, tasks = new Op
109
115
  };
110
116
  }
111
117
  try {
118
+ if (isReferenceTool(definition.name)) {
119
+ const name = definition.name;
120
+ const result = await withProgressHeartbeat(() => engine.requests.run(references, Buffer.byteLength(JSON.stringify(arguments_)), extra.mcpReq.signal, () => engine.sharedRequests.run(extra.mcpReq.signal, () => references.call(name, arguments_, operationContext, extra.mcpReq.signal))), modernProgressReporter(extra));
121
+ definition.outputSchema?.parse(result.structuredContent);
122
+ return CallToolResultSchema.parse({
123
+ ...result,
124
+ resultType: 'complete',
125
+ ttlMs: 0,
126
+ cacheScope: 'private',
127
+ });
128
+ }
112
129
  if (privateOperations !== undefined) {
113
130
  const operations = operationContext === context || options.createPrivateTools === undefined
114
131
  ? privateOperations
@@ -4,6 +4,7 @@ import { PACKAGE_NAME, PACKAGE_VERSION } from '../../version.js';
4
4
  import { registerFocusTools } from '../tools/focus.js';
5
5
  import { registerGuiTools } from '../tools/gui.js';
6
6
  import { registerMapTools } from '../tools/map.js';
7
+ import { registerReferenceTools } from '../tools/reference.js';
7
8
  import { registerEventTools } from '../tools/event.js';
8
9
  import { registerAnalysisTools } from '../tools/analysis.js';
9
10
  import { registerTechnologyTools } from '../tools/technology.js';
@@ -53,6 +54,7 @@ export function createMcpServer(engine, context = {}) {
53
54
  registerFocusTools(server, engine, serverContext);
54
55
  registerGuiTools(server, engine, serverContext);
55
56
  registerMapTools(server, engine, serverContext);
57
+ registerReferenceTools(server, engine, serverContext);
56
58
  registerEventTools(server);
57
59
  registerAnalysisTools(server);
58
60
  registerTechnologyTools(server);
@@ -1,2 +1,2 @@
1
1
  /** Shared agent-facing guidance for both MCP protocol generations. */
2
- export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start cross-system source review with hoi4.impact_inspect and decision or mission scenarios with hoi4.decision_inspect. Test declared mechanic steps with hoi4.mechanic_test, package connections with hoi4.package_check, and named suites with hoi4.scenario_test. These analysis tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Concurrent calls queue automatically and emit periodic progress when the client requests it. Long operations support negotiated MCP tasks; native background rewrites require a stable requestKey. Large evidence is linked as resources.';
2
+ export const SERVER_INSTRUCTIONS = 'Use hoi4.reference_context for local wiki and installed documentation citations, hoi4.reference_search and hoi4.reference_read for exact sections, and hoi4.source_lookup for indexed source examples. Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start cross-system source review with hoi4.impact_inspect and decision or mission scenarios with hoi4.decision_inspect. Test declared mechanic steps with hoi4.mechanic_test, package connections with hoi4.package_check, and named suites with hoi4.scenario_test. These analysis tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Concurrent calls queue automatically and emit periodic progress when the client requests it. Long operations support negotiated MCP tasks; native background rewrites require a stable requestKey. Large evidence is linked as resources.';
@@ -0,0 +1,60 @@
1
+ import { errorResult, strictOperationResultSchema } from '../../core/operation-result.js';
2
+ import { ReferenceToolService } from '../../reference/operations.js';
3
+ import { referenceContextRequestSchema, referenceReadRequestSchema, referenceSearchRequestSchema, sourceLookupRequestSchema, referenceContextDataSchema, referenceReadDataSchema, referenceSearchDataSchema, sourceLookupDataSchema, } from '../../schemas/reference.js';
4
+ const readOnly = {
5
+ readOnlyHint: true,
6
+ destructiveHint: false,
7
+ idempotentHint: true,
8
+ openWorldHint: false,
9
+ };
10
+ export const referenceTools = [
11
+ {
12
+ name: 'hoi4.reference_search',
13
+ title: 'Search local HOI4 references',
14
+ description: 'Search bounded, cited sections of the configured offline wiki and installed game documentation.',
15
+ inputSchema: referenceSearchRequestSchema,
16
+ outputSchema: strictOperationResultSchema(referenceSearchDataSchema),
17
+ annotations: readOnly,
18
+ },
19
+ {
20
+ name: 'hoi4.reference_read',
21
+ title: 'Read cited HOI4 reference section',
22
+ description: 'Read at most 80 lines from one revision-bound local documentation section.',
23
+ inputSchema: referenceReadRequestSchema,
24
+ outputSchema: strictOperationResultSchema(referenceReadDataSchema),
25
+ annotations: readOnly,
26
+ },
27
+ {
28
+ name: 'hoi4.reference_context',
29
+ title: 'Get HOI4 task references',
30
+ description: 'Return a compact bundle of offline wiki and installed game documentation citations for a modding surface.',
31
+ inputSchema: referenceContextRequestSchema,
32
+ outputSchema: strictOperationResultSchema(referenceContextDataSchema),
33
+ annotations: readOnly,
34
+ },
35
+ {
36
+ name: 'hoi4.source_lookup',
37
+ title: 'Look up HOI4 source symbol',
38
+ description: 'Find exact Clausewitz definitions, override status, a bounded source block, and indexed usages in the configured game and mod.',
39
+ inputSchema: sourceLookupRequestSchema,
40
+ outputSchema: strictOperationResultSchema(sourceLookupDataSchema),
41
+ annotations: readOnly,
42
+ },
43
+ ];
44
+ export function registerReferenceTools(server, engine, context) {
45
+ const service = new ReferenceToolService(engine);
46
+ for (const entry of referenceTools) {
47
+ const { name: _name, ...definition } = entry;
48
+ const name = entry.name;
49
+ server.registerTool(name, definition, async (input, extra) => {
50
+ try {
51
+ return await service.call(name, input, context, extra.signal);
52
+ }
53
+ catch (error) {
54
+ if (extra.signal.aborted)
55
+ throw error;
56
+ return errorResult(error);
57
+ }
58
+ });
59
+ }
60
+ }
@@ -962,7 +962,7 @@ export class ProbabilityAnalyzer {
962
962
  adapter !== undefined &&
963
963
  adapter !== 'custom_weighted_pool')
964
964
  throw new ServiceError('PROBABILITY_ADAPTER_MISMATCH', 'customPoolManifest inspection requires adapter custom_weighted_pool');
965
- const snapshot = await this.scan(context, [source?.path], source === undefined ? 'none' : source.inlineClausewitz === undefined ? 'domain' : 'shared');
965
+ const snapshot = await this.scan(context, [source?.path, source?.snapshotPath], source === undefined ? 'none' : source.inlineClausewitz === undefined ? 'domain' : 'shared');
966
966
  if (customPoolManifest !== undefined) {
967
967
  const surface = customSurface(customPoolManifest, snapshot.revision);
968
968
  const gameVersionVerification = await this.verifyGameVersion(context, 'custom_weighted_pool');
@@ -1150,7 +1150,7 @@ export class ProbabilityAnalyzer {
1150
1150
  request.adapter !== undefined &&
1151
1151
  request.adapter !== 'custom_weighted_pool')
1152
1152
  throw new ServiceError('PROBABILITY_ADAPTER_MISMATCH', 'customPoolManifest requires the custom_weighted_pool adapter');
1153
- const snapshot = await this.scan(request, [request.source?.path], usingManifest ? 'none' : request.source?.inlineClausewitz === undefined ? 'domain' : 'shared');
1153
+ const snapshot = await this.scan(request, [request.source?.path, request.source?.snapshotPath], usingManifest ? 'none' : request.source?.inlineClausewitz === undefined ? 'domain' : 'shared');
1154
1154
  const resolvedAdapter = usingManifest
1155
1155
  ? 'custom_weighted_pool'
1156
1156
  : resolveSourceBackedAdapter(snapshot, request.source, request.candidatePool ?? [], request.adapter);