hoi4-agent-tools 4.0.5 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -0
  3. package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
  4. package/dist/hoi4_agent_tools/core/canonical.js +42 -0
  5. package/dist/hoi4_agent_tools/core/condition-evaluator.js +12 -8
  6. package/dist/hoi4_agent_tools/core/dependency-walk.js +21 -2
  7. package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
  8. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  9. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  10. package/dist/hoi4_agent_tools/core/impact-graph.js +6 -2
  11. package/dist/hoi4_agent_tools/core/impact-semantic.js +12 -2
  12. package/dist/hoi4_agent_tools/core/index.js +1 -0
  13. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  14. package/dist/hoi4_agent_tools/core/job-worker-host.js +12 -2
  15. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  16. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  17. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  18. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  19. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  20. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  21. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  22. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  23. package/dist/hoi4_agent_tools/decision/service.js +22 -1
  24. package/dist/hoi4_agent_tools/event/algorithms.js +3 -2
  25. package/dist/hoi4_agent_tools/event/graph.js +52 -35
  26. package/dist/hoi4_agent_tools/event/limits.js +4 -0
  27. package/dist/hoi4_agent_tools/event/operations.js +11 -0
  28. package/dist/hoi4_agent_tools/event/queries.js +13 -34
  29. package/dist/hoi4_agent_tools/event/render.js +4 -2
  30. package/dist/hoi4_agent_tools/event/service.js +93 -33
  31. package/dist/hoi4_agent_tools/event/source-analysis.js +94 -4
  32. package/dist/hoi4_agent_tools/event/state-keys.js +63 -0
  33. package/dist/hoi4_agent_tools/event/summary.js +130 -0
  34. package/dist/hoi4_agent_tools/impact/service.js +2 -1
  35. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  36. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  37. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  38. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  39. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  40. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  41. package/dist/hoi4_agent_tools/map/render.js +9 -1
  42. package/dist/hoi4_agent_tools/map/service.js +222 -18
  43. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  44. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  45. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  46. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  47. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  48. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  49. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  50. package/dist/hoi4_agent_tools/map/world.js +374 -253
  51. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +1 -0
  52. package/dist/hoi4_agent_tools/mcp/tools/event.js +66 -0
  53. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  54. package/dist/hoi4_agent_tools/reference/source-lookup.js +4 -2
  55. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  56. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  57. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  58. package/dist/hoi4_agent_tools/version.js +2 -2
  59. package/docs/analysis.md +4 -0
  60. package/docs/events.md +12 -0
  61. package/docs/images/reference-maps/political.png +0 -0
  62. package/docs/images/reference-maps/sketch.png +0 -0
  63. package/docs/map.md +45 -0
  64. package/docs/third-party-notices.md +6 -0
  65. package/npm-shrinkwrap.json +124 -2
  66. package/package.json +4 -1
  67. package/schemas/configuration.schema.json +1 -1
  68. package/schemas/continuous-focus-palette.schema.json +1 -1
  69. package/schemas/custom-weighted-pool.schema.json +1 -1
  70. package/schemas/decision-inspect-input.schema.json +1 -1
  71. package/schemas/error-log-input.schema.json +1 -1
  72. package/schemas/event-feature-manifest.schema.json +1 -1
  73. package/schemas/focus-plan.schema.json +1 -1
  74. package/schemas/focus-planning-sidecar.schema.json +1 -1
  75. package/schemas/gui-animation-source.schema.json +1 -1
  76. package/schemas/gui-helper.schema.json +1 -1
  77. package/schemas/gui-scenario.schema.json +1 -1
  78. package/schemas/helper-expansion-request.schema.json +1 -1
  79. package/schemas/helper-expansion-summary.schema.json +1 -1
  80. package/schemas/impact-inspect-input.schema.json +1 -1
  81. package/schemas/map-operation.schema.json +373 -70
  82. package/schemas/mechanic-test-input.schema.json +1 -1
  83. package/schemas/mod-index-input.schema.json +1 -1
  84. package/schemas/operation-result.schema.json +1 -1
  85. package/schemas/package-check-input.schema.json +1 -1
  86. package/schemas/package-manifest.schema.json +1 -1
  87. package/schemas/probability-analysis-result.schema.json +1 -1
  88. package/schemas/probability-compare-input.schema.json +1 -1
  89. package/schemas/probability-evaluate-input.schema.json +1 -1
  90. package/schemas/probability-inspect-input.schema.json +1 -1
  91. package/schemas/probability-render-input.schema.json +1 -1
  92. package/schemas/probability-scenario-set.schema.json +1 -1
  93. package/schemas/probability-sequence-input.schema.json +1 -1
  94. package/schemas/probability-simulate-input.schema.json +1 -1
  95. package/schemas/probability-sweep-input.schema.json +1 -1
  96. package/schemas/reference-context-input.schema.json +1 -1
  97. package/schemas/reference-read-input.schema.json +1 -1
  98. package/schemas/reference-search-input.schema.json +1 -1
  99. package/schemas/scenario-suite.schema.json +1 -1
  100. package/schemas/scenario-test-input.schema.json +1 -1
  101. package/schemas/script-validate-input.schema.json +1 -1
  102. package/schemas/shared-scenario.schema.json +1 -1
  103. package/schemas/source-lookup-input.schema.json +1 -1
  104. package/server.json +2 -2
@@ -86,6 +86,21 @@ const STATE_RULES = {
86
86
  multiply_variable: { kind: 'variable', access: 'read_write' },
87
87
  divide_variable: { kind: 'variable', access: 'read_write' },
88
88
  clamp_variable: { kind: 'variable', access: 'read_write' },
89
+ round_variable: { kind: 'variable', access: 'read_write' },
90
+ modulo_variable: { kind: 'variable', access: 'read_write' },
91
+ randomize_variable: { kind: 'variable', access: 'read_write' },
92
+ set_variable_to_random: { kind: 'variable', access: 'write' },
93
+ // `check_variable` and `has_variable` read a temporary variable of the same name as well.
94
+ set_temp_variable: { kind: 'variable', access: 'write', temporary: true },
95
+ set_temp_variable_to_random: { kind: 'variable', access: 'write', temporary: true },
96
+ add_to_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
97
+ subtract_from_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
98
+ multiply_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
99
+ divide_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
100
+ clamp_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
101
+ round_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
102
+ modulo_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
103
+ randomize_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
89
104
  check_variable: { kind: 'variable', access: 'read' },
90
105
  has_variable: { kind: 'variable', access: 'read' },
91
106
  is_variable_equal: { kind: 'variable', access: 'read' },
@@ -101,6 +116,16 @@ const STATE_RULES = {
101
116
  remove_from_array: { kind: 'array', access: 'read_write', nameField: 'array' },
102
117
  is_in_array: { kind: 'array', access: 'read', nameField: 'array' },
103
118
  clear_array: { kind: 'array', access: 'clear' },
119
+ resize_array: { kind: 'array', access: 'write', nameField: 'array' },
120
+ add_to_temp_array: { kind: 'array', access: 'write', nameField: 'array', temporary: true },
121
+ remove_from_temp_array: {
122
+ kind: 'array',
123
+ access: 'read_write',
124
+ nameField: 'array',
125
+ temporary: true,
126
+ },
127
+ resize_temp_array: { kind: 'array', access: 'write', nameField: 'array', temporary: true },
128
+ clear_temp_array: { kind: 'array', access: 'clear', temporary: true },
104
129
  save_event_target_as: { kind: 'event_target', access: 'replace' },
105
130
  has_event_target: { kind: 'event_target', access: 'read' },
106
131
  save_global_event_target_as: { kind: 'global_event_target', access: 'replace' },
@@ -153,7 +178,59 @@ const NON_EXECUTING_BLOCKS = new Set([
153
178
  'mean_time_to_happen',
154
179
  'effect_tooltip',
155
180
  'custom_effect_tooltip',
181
+ // Condition blocks of decisions, missions, ideas, focuses, raids and characters.
182
+ 'visible',
183
+ 'available',
184
+ 'allowed',
185
+ 'allowed_civil_war',
186
+ 'allowed_to_remove',
187
+ 'cancel_trigger',
188
+ 'remove_trigger',
189
+ 'target_trigger',
190
+ 'target_root_trigger',
191
+ 'activation',
192
+ 'ai_will_do',
193
+ 'bypass',
194
+ 'allow_branch',
195
+ 'hidden_trigger',
196
+ 'custom_trigger_tooltip',
197
+ 'and',
198
+ 'or',
199
+ 'not',
200
+ 'nor',
201
+ 'nand',
156
202
  ]);
203
+ /** Native effects whose body holds further effects rather than named arguments. */
204
+ const NATIVE_EFFECT_CONTAINERS = new Set([
205
+ 'create_dynamic_country',
206
+ 'custom_override_tooltip',
207
+ 'effect_tooltip',
208
+ 'for_each_loop',
209
+ 'for_each_scope_loop',
210
+ 'for_loop_effect',
211
+ 'hidden_effect',
212
+ 'if',
213
+ 'random',
214
+ 'random_list',
215
+ 'random_scope_in_array',
216
+ 'while_loop_effect',
217
+ ]);
218
+ /** Inside a native effect such as `add_building_construction`, `key = yes` is an argument. */
219
+ function isNativeArgumentBlock(normalizedKey) {
220
+ return (nativeEffectKeys.has(normalizedKey) &&
221
+ !NATIVE_EFFECT_CONTAINERS.has(normalizedKey) &&
222
+ !CONTROL_BLOCKS.has(normalizedKey) &&
223
+ SCOPE_BLOCKS[normalizedKey] === undefined &&
224
+ !/^(?:every|random|global_every)_/u.test(normalizedKey));
225
+ }
226
+ /** Effect blocks of definition files by naming convention: `on_add`, `complete_effect`, `effects`. */
227
+ function isConventionalEffectBlock(normalizedKey) {
228
+ return /^on_[a-z0-9_]+$|(?:^|_)effects?$/u.test(normalizedKey);
229
+ }
230
+ /** Trigger iterators such as `any_owned_state` hold conditions, never effects. */
231
+ function isTriggerIterator(normalizedKey) {
232
+ return /^(?:any|all)_/u.test(normalizedKey);
233
+ }
157
234
  const TOOLTIP_ONLY_BLOCKS = new Set(['effect_tooltip', 'custom_effect_tooltip']);
158
235
  const COUNTRY_ON_ACTIONS = new Set([
159
236
  'on_startup',
@@ -836,6 +913,7 @@ class FragmentAnalyzer {
836
913
  helperStack: [],
837
914
  semanticPath: ['other'],
838
915
  effectContext: true,
916
+ inferMissingHelpers: false,
839
917
  });
840
918
  if (this.edges.length + this.stateAccesses.length > before) {
841
919
  this.addNode({
@@ -913,6 +991,8 @@ class FragmentAnalyzer {
913
991
  return;
914
992
  }
915
993
  if (this.context.inventoryComplete !== false &&
994
+ context.nativeArguments !== true &&
995
+ context.inferMissingHelpers !== false &&
916
996
  missingHelperCandidate(key, entry.value, effectContext)) {
917
997
  const target = this.unresolvedNode('missing_helper', key, entry, context.ownerId, semanticPath);
918
998
  this.addEdge(context.ownerId, target, 'scripted_effect_call', entry, context.conditions, {
@@ -1039,7 +1119,13 @@ class FragmentAnalyzer {
1039
1119
  conditions,
1040
1120
  sourceScope: scope,
1041
1121
  phase,
1042
- effectContext: NON_EXECUTING_BLOCKS.has(normalizedKey) ? false : effectContext,
1122
+ effectContext: NON_EXECUTING_BLOCKS.has(normalizedKey) || isTriggerIterator(normalizedKey)
1123
+ ? false
1124
+ : effectContext,
1125
+ nativeArguments: context.nativeArguments === true || isNativeArgumentBlock(normalizedKey),
1126
+ ...(context.inferMissingHelpers === false && isConventionalEffectBlock(normalizedKey)
1127
+ ? { inferMissingHelpers: true }
1128
+ : {}),
1043
1129
  suppressSemantics: context.suppressSemantics === true || TOOLTIP_ONLY_BLOCKS.has(normalizedKey),
1044
1130
  semanticPath,
1045
1131
  ...(scopeExpression === undefined ? {} : { scopeExpression }),
@@ -1296,7 +1382,8 @@ class FragmentAnalyzer {
1296
1382
  return id;
1297
1383
  }
1298
1384
  recordState(assignment, context, semanticPath) {
1299
- const keyedTarget = /^(event_target|global_event_target|scope):([A-Za-z0-9_.-]+)$/u.exec(assignment.key.value);
1385
+ // `event_target:founder.districts` names the target `founder`; the rest is a value in its scope.
1386
+ const keyedTarget = /^(event_target|global_event_target|scope):([A-Za-z0-9_-]+)(?:\.[A-Za-z0-9_.-]+)?$/u.exec(assignment.key.value);
1300
1387
  if (keyedTarget !== null) {
1301
1388
  this.addState(context, keyedTarget[1] === 'global_event_target'
1302
1389
  ? 'global_event_target'
@@ -1307,7 +1394,7 @@ class FragmentAnalyzer {
1307
1394
  const rule = STATE_RULES[assignment.key.value];
1308
1395
  if (rule === undefined) {
1309
1396
  const raw = valueText(this.document, assignment.value);
1310
- for (const match of raw.matchAll(/\b(event_target|global_event_target):([A-Za-z0-9_.-]+)/gu)) {
1397
+ for (const match of raw.matchAll(/\b(event_target|global_event_target):([A-Za-z0-9_-]+)/gu)) {
1311
1398
  this.addState(context, match[1] === 'global_event_target' ? 'global_event_target' : 'event_target', match[2], 'read', assignment, semanticPath, false);
1312
1399
  }
1313
1400
  return;
@@ -1336,7 +1423,10 @@ class FragmentAnalyzer {
1336
1423
  else if (access === 'write') {
1337
1424
  this.#seenStateWriters.add(writerKey);
1338
1425
  }
1339
- this.addState(context, kind, name, access, assignment, semanticPath, isDynamic(name), rule.storage === undefined ? {} : { storage: rule.storage });
1426
+ this.addState(context, kind, name, access, assignment, semanticPath, isDynamic(name), {
1427
+ ...(rule.storage === undefined ? {} : { storage: rule.storage }),
1428
+ ...(rule.temporary === true ? { temporary: true } : {}),
1429
+ });
1340
1430
  }
1341
1431
  addState(context, kind, name, access, node, _semanticPath, dynamic, metadata = {}) {
1342
1432
  const semantic = {
@@ -0,0 +1,63 @@
1
+ const SCOPE_KINDS = [
2
+ 'country',
3
+ 'state',
4
+ 'province',
5
+ 'unit_leader',
6
+ 'operative',
7
+ 'character',
8
+ 'global',
9
+ 'unknown',
10
+ ];
11
+ /** Identity of one stored value: variables and arrays live per scope, saved scopes per storage. */
12
+ export function stateKey(value) {
13
+ const scope = value.kind === 'variable' || value.kind === 'array' ? `:${value.scope}` : '';
14
+ const storage = value.kind === 'saved_scope'
15
+ ? `:${value.metadata.storage === 'global' || value.metadata.storage === 'local'
16
+ ? value.metadata.storage
17
+ : 'unknown'}`
18
+ : '';
19
+ return `${value.kind}:${value.name}${scope}${storage}`;
20
+ }
21
+ /** Keys of every write that a read of this value can observe. */
22
+ export function compatibleProducerKeys(value) {
23
+ // `event_target:name` reads local and global targets alike, whereas
24
+ // `global_event_target:name` reads only global ones.
25
+ if (value.kind === 'event_target') {
26
+ return [stateKey(value), `global_event_target:${value.name}`];
27
+ }
28
+ // A helper body runs in whatever scope calls it, so its scope is unknown. A write there can
29
+ // reach a read in any scope, and a read there can observe a write in any scope.
30
+ if (value.kind === 'variable' || value.kind === 'array') {
31
+ const scopes = value.scope === 'unknown' ? SCOPE_KINDS : [value.scope, 'unknown'];
32
+ const keys = scopes.map((scope) => `${value.kind}:${value.name}:${scope}`);
33
+ // `entries^num` and `entries^3` read the array `entries`; `GER.value` and `ROOT.value` read
34
+ // `value` in another scope, which may be written under its bare name in any scope.
35
+ const element = /^([^^]+)\^/u.exec(value.name)?.[1];
36
+ if (element !== undefined)
37
+ keys.push(...SCOPE_KINDS.map((scope) => `array:${unscopedName(element)}:${scope}`));
38
+ const bare = unscopedName(value.name);
39
+ if (bare !== value.name)
40
+ keys.push(...SCOPE_KINDS.map((scope) => `${value.kind}:${bare}:${scope}`));
41
+ return [...new Set(keys)];
42
+ }
43
+ if (value.kind !== 'saved_scope' ||
44
+ value.metadata.storage === 'global' ||
45
+ value.metadata.storage === 'local') {
46
+ return [stateKey(value)];
47
+ }
48
+ return [
49
+ `saved_scope:${value.name}:global`,
50
+ `saved_scope:${value.name}:local`,
51
+ `saved_scope:${value.name}:unknown`,
52
+ ];
53
+ }
54
+ /** `GER.value`, `ROOT.FROM.value` or `var:target.value` without its scope prefix. */
55
+ function unscopedName(name) {
56
+ const scoped = /^(?:(?:[A-Z][A-Z0-9]{2}|ROOT|FROM|PREV|THIS|OWNER|CONTROLLER|CAPITAL)\.|(?:var|event_target):[A-Za-z0-9_]+\.)+(.+)$/u.exec(name);
57
+ return scoped?.[1] ?? name;
58
+ }
59
+ export function hasUnknownSavedScopeStorage(value) {
60
+ return (value.kind === 'saved_scope' &&
61
+ value.metadata.storage !== 'global' &&
62
+ value.metadata.storage !== 'local');
63
+ }
@@ -0,0 +1,130 @@
1
+ import { compareCodeUnits } from '../core/canonical.js';
2
+ /** Findings returned inline, before an agent opens the linked report. */
3
+ export const EVENT_INLINE_FINDINGS = 10;
4
+ const EVENT_INLINE_CODES = 10;
5
+ const EVENT_INLINE_EVENT_IDS = 20;
6
+ const EVENT_INLINE_MESSAGE_LENGTH = 300;
7
+ const CLASSIFICATION_ORDER = {
8
+ confirmed_error: 0,
9
+ probable_defect: 1,
10
+ design_warning: 2,
11
+ unresolved_analysis: 3,
12
+ };
13
+ const CONFIDENCE_ORDER = {
14
+ confirmed: 0,
15
+ high: 1,
16
+ medium: 2,
17
+ low: 3,
18
+ unresolved: 4,
19
+ };
20
+ function countBy(values, key) {
21
+ const counts = new Map();
22
+ for (const value of values)
23
+ counts.set(key(value), (counts.get(key(value)) ?? 0) + 1);
24
+ return Object.fromEntries([...counts].sort(([left], [right]) => compareCodeUnits(left, right)));
25
+ }
26
+ function layer(issue) {
27
+ const path = issue.location?.path;
28
+ return path === undefined ? 'unknown' : (/^([a-z]+):/u.exec(path)?.[1] ?? 'unknown');
29
+ }
30
+ /** The mod's own files first, then most severe: classification, confidence, source order. */
31
+ function issueOrder(left, right) {
32
+ const leftMod = layer(left) === 'mod' ? 0 : 1;
33
+ const rightMod = layer(right) === 'mod' ? 0 : 1;
34
+ return (leftMod - rightMod ||
35
+ CLASSIFICATION_ORDER[left.classification] - CLASSIFICATION_ORDER[right.classification] ||
36
+ CONFIDENCE_ORDER[left.confidence] - CONFIDENCE_ORDER[right.confidence] ||
37
+ compareCodeUnits(left.location?.path ?? '', right.location?.path ?? '') ||
38
+ (left.location?.start.line ?? 0) - (right.location?.start.line ?? 0) ||
39
+ compareCodeUnits(left.code, right.code) ||
40
+ compareCodeUnits(left.message, right.message));
41
+ }
42
+ /** Counts and the most severe findings of a selection, small enough to return inline. */
43
+ export function summarizeEventIssues(issues) {
44
+ const byCode = Object.entries(countBy(issues, ({ code }) => code))
45
+ .sort(([leftCode, left], [rightCode, right]) => right - left || compareCodeUnits(leftCode, rightCode))
46
+ .slice(0, EVENT_INLINE_CODES);
47
+ return {
48
+ issues: issues.length,
49
+ byClassification: countBy(issues, ({ classification }) => classification),
50
+ // `mod` findings are the workspace's own; `game` and `dependency` ones are context.
51
+ byLayer: countBy(issues, layer),
52
+ topCodes: Object.fromEntries(byCode),
53
+ findings: [...issues]
54
+ .sort(issueOrder)
55
+ .slice(0, EVENT_INLINE_FINDINGS)
56
+ .map((issue) => ({
57
+ code: issue.code,
58
+ classification: issue.classification,
59
+ confidence: issue.confidence,
60
+ message: issue.message.length > EVENT_INLINE_MESSAGE_LENGTH
61
+ ? `${issue.message.slice(0, EVENT_INLINE_MESSAGE_LENGTH - 1)}…`
62
+ : issue.message,
63
+ ...(issue.location === undefined
64
+ ? {}
65
+ : { path: issue.location.path, line: issue.location.start.line }),
66
+ ...(typeof issue.details.name === 'string' ? { subject: issue.details.name } : {}),
67
+ })),
68
+ };
69
+ }
70
+ /** Size of a traced chain and the events it reaches. */
71
+ export function summarizeEventTrace(trace) {
72
+ const events = trace.nodes
73
+ .flatMap(({ kind, eventId }) => (kind === 'event' && eventId !== undefined ? [eventId] : []))
74
+ .sort(compareCodeUnits);
75
+ return {
76
+ nodes: trace.nodes.length,
77
+ edges: trace.edges.length,
78
+ events: events.length,
79
+ eventIds: events.slice(0, EVENT_INLINE_EVENT_IDS),
80
+ boundaryNodes: trace.boundaryNodeIds.length,
81
+ truncated: trace.truncated,
82
+ };
83
+ }
84
+ /** How many chains start where, in a form an agent can read without the report. */
85
+ export function summarizeEventRoots(roots) {
86
+ const nodeLayer = ({ sourcePath }) => sourcePath === undefined ? 'unknown' : (/^([a-z]+):/u.exec(sourcePath)?.[1] ?? 'unknown');
87
+ // The mod's own events first: vanilla callerless events are context, not the mod's defects.
88
+ const ids = (nodes) => [...nodes]
89
+ .sort((left, right) => (nodeLayer(left) === 'mod' ? 0 : 1) - (nodeLayer(right) === 'mod' ? 0 : 1) ||
90
+ compareCodeUnits(left.eventId ?? '', right.eventId ?? ''))
91
+ .flatMap(({ eventId }) => (eventId === undefined ? [] : [eventId]))
92
+ .slice(0, EVENT_INLINE_EVENT_IDS);
93
+ return {
94
+ entryPoints: roots.entryPoints.length,
95
+ entryKinds: countBy(roots.entryPoints, ({ metadata }) => typeof metadata.entryKind === 'string' ? metadata.entryKind : 'unknown'),
96
+ automaticEvents: roots.automaticEvents.length,
97
+ callerlessTriggeredEvents: roots.callerlessTriggeredEvents.length,
98
+ callerlessTriggeredByLayer: countBy(roots.callerlessTriggeredEvents, nodeLayer),
99
+ callerlessTriggeredEventIds: ids(roots.callerlessTriggeredEvents),
100
+ cycles: roots.stronglyConnectedComponents.filter(({ cyclic }) => cyclic).length,
101
+ };
102
+ }
103
+ /** Whether a route exists and, if so, the events it passes through in order. */
104
+ export function summarizeEventPath(path) {
105
+ return {
106
+ found: path.found,
107
+ steps: path.steps.length,
108
+ eventIds: path.steps
109
+ .flatMap(({ node }) => node.kind === 'event' && node.eventId !== undefined ? [node.eventId] : [])
110
+ .slice(0, EVENT_INLINE_EVENT_IDS),
111
+ ...(path.failureReason === undefined ? {} : { failureReason: path.failureReason }),
112
+ ...(path.failureMessage === undefined ? {} : { failureMessage: path.failureMessage }),
113
+ unresolvedAssumptions: path.unresolvedAssumptions.length,
114
+ };
115
+ }
116
+ /** How far a change to the subject reaches, by count. */
117
+ export function summarizeEventImpact(impact) {
118
+ return {
119
+ direct: impact.directNodeIds.length,
120
+ upstream: impact.upstreamNodeIds.length,
121
+ downstream: impact.downstreamNodeIds.length,
122
+ stateAccesses: impact.stateAccessIds.length,
123
+ affectedRoots: impact.affectedRootIds.length,
124
+ affectedTerminals: impact.affectedTerminalIds.length,
125
+ wouldDisconnect: impact.wouldDisconnectNodeIds.length,
126
+ wouldDisconnectTerminals: impact.wouldDisconnectTerminalIds.length,
127
+ unresolved: impact.unresolvedNodeIds.length,
128
+ sampleDownstream: impact.downstreamNodeIds.slice(0, EVENT_INLINE_EVENT_IDS),
129
+ };
130
+ }
@@ -94,6 +94,8 @@ export class ImpactAnalyzer {
94
94
  directConsumers: graph.directConsumers.length,
95
95
  transitiveConsumers: graph.transitiveConsumers.length,
96
96
  affectedFiles: graph.affectedFiles.length,
97
+ // Affected files are evidence, not writes: this read-only analysis never changes a file.
98
+ affectedFilePaths: graph.affectedFiles.slice(0, 64),
97
99
  cycles: graph.cycles.length,
98
100
  scenarioSuites: scenarioSuites.scannedSuites,
99
101
  affectedCases: scenarioSuites.matches.length,
@@ -108,7 +110,6 @@ export class ImpactAnalyzer {
108
110
  result.code = complete ? 'IMPACT_ANALYZED' : 'IMPACT_ANALYZED_PARTIAL';
109
111
  result.artifacts = [publicArtifactLink(artifact)];
110
112
  result.proposedFiles = overlay?.changes.map(({ path }) => path).slice(0, 64) ?? [];
111
- result.changedFiles = graph.affectedFiles.slice(0, 64);
112
113
  setInlineFilesScanned(result, before.files.map(({ displayPath }) => displayPath));
113
114
  result.validation = {
114
115
  passed: complete,
@@ -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);