hoi4-agent-tools 3.9.0 → 4.0.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 (157) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +115 -147
  3. package/dist/bin/http.js +12 -0
  4. package/dist/bin/setup.js +3 -2
  5. package/dist/bin/stdio.js +12 -0
  6. package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
  7. package/dist/hoi4_agent_tools/core/canonical.js +18 -4
  8. package/dist/hoi4_agent_tools/core/configuration.js +7 -0
  9. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
  10. package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
  11. package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
  12. package/dist/hoi4_agent_tools/core/engine.js +56 -10
  13. package/dist/hoi4_agent_tools/core/index.js +45 -20
  14. package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
  15. package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
  16. package/dist/hoi4_agent_tools/core/job-store.js +78 -5
  17. package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
  18. package/dist/hoi4_agent_tools/core/job-worker-host.js +215 -116
  19. package/dist/hoi4_agent_tools/core/job-worker.js +90 -36
  20. package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
  21. package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
  22. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
  23. package/dist/hoi4_agent_tools/core/server-registry.js +155 -0
  24. package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
  25. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
  26. package/dist/hoi4_agent_tools/core/source/cache.js +108 -29
  27. package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
  28. package/dist/hoi4_agent_tools/core/transactions.js +114 -23
  29. package/dist/hoi4_agent_tools/core/workspace.js +84 -8
  30. package/dist/hoi4_agent_tools/decision/ai.js +1 -1
  31. package/dist/hoi4_agent_tools/decision/service.js +28 -0
  32. package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
  33. package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
  34. package/dist/hoi4_agent_tools/event/operations.js +14 -1
  35. package/dist/hoi4_agent_tools/event/service.js +7 -0
  36. package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
  37. package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
  38. package/dist/hoi4_agent_tools/gui/scenario-generator.js +11 -3
  39. package/dist/hoi4_agent_tools/gui/studio.js +20 -7
  40. package/dist/hoi4_agent_tools/gui/validators.js +8 -6
  41. package/dist/hoi4_agent_tools/map/bmp.js +1 -1
  42. package/dist/hoi4_agent_tools/map/derived.js +748 -0
  43. package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
  44. package/dist/hoi4_agent_tools/map/model.js +103 -0
  45. package/dist/hoi4_agent_tools/map/operations.js +427 -58
  46. package/dist/hoi4_agent_tools/map/references.js +483 -0
  47. package/dist/hoi4_agent_tools/map/service.js +136 -61
  48. package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
  49. package/dist/hoi4_agent_tools/map/validation.js +3 -0
  50. package/dist/hoi4_agent_tools/map/world.js +1280 -0
  51. package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
  52. package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
  53. package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
  54. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
  55. package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
  56. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
  57. package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
  58. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
  59. package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
  60. package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
  61. package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
  62. package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
  63. package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
  64. package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
  65. package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
  66. package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -26
  67. package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
  68. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +37 -18
  69. package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
  70. package/dist/hoi4_agent_tools/probability/operations.js +175 -13
  71. package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
  72. package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
  73. package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
  74. package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
  75. package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
  76. package/dist/hoi4_agent_tools/reference/operations.js +88 -19
  77. package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
  78. package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
  79. package/dist/hoi4_agent_tools/reference/service.js +6 -0
  80. package/dist/hoi4_agent_tools/schemas/event.js +78 -19
  81. package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
  82. package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
  83. package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
  84. package/dist/hoi4_agent_tools/schemas/map.js +122 -3
  85. package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
  86. package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
  87. package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
  88. package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
  89. package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
  90. package/dist/hoi4_agent_tools/schemas/technology.js +83 -23
  91. package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
  92. package/dist/hoi4_agent_tools/technology/operations.js +14 -1
  93. package/dist/hoi4_agent_tools/technology/service.js +7 -0
  94. package/dist/hoi4_agent_tools/version.js +1 -1
  95. package/docs/README.md +7 -4
  96. package/docs/analysis.md +13 -0
  97. package/docs/configuration.md +281 -0
  98. package/docs/development.md +24 -2
  99. package/docs/events.md +27 -3
  100. package/docs/examples.md +6 -6
  101. package/docs/focus.md +125 -4
  102. package/docs/gui.md +98 -17
  103. package/docs/helper-expansion.md +8 -8
  104. package/docs/http.md +3 -1
  105. package/docs/jobs.md +19 -13
  106. package/docs/map.md +197 -6
  107. package/docs/mechanics.md +4 -0
  108. package/docs/probability.md +42 -15
  109. package/docs/reference.md +71 -4
  110. package/docs/setup.md +45 -11
  111. package/docs/technology.md +26 -1
  112. package/docs/third-party-notices.md +11 -0
  113. package/docs/tools.md +151 -42
  114. package/docs/troubleshooting.md +61 -0
  115. package/examples/clients/codex-config.toml +5 -2
  116. package/examples/clients/generic.json +2 -2
  117. package/examples/config/config.json +7 -0
  118. package/npm-shrinkwrap.json +161 -162
  119. package/package.json +13 -8
  120. package/schemas/configuration.schema.json +144 -128
  121. package/schemas/continuous-focus-palette.schema.json +1 -1
  122. package/schemas/custom-weighted-pool.schema.json +1 -1
  123. package/schemas/decision-inspect-input.schema.json +1 -1
  124. package/schemas/error-log-input.schema.json +55 -0
  125. package/schemas/event-feature-manifest.schema.json +1 -1
  126. package/schemas/focus-plan.schema.json +1 -1
  127. package/schemas/focus-planning-sidecar.schema.json +1 -1
  128. package/schemas/gui-animation-source.schema.json +1 -1
  129. package/schemas/gui-helper.schema.json +1 -1
  130. package/schemas/gui-scenario.schema.json +1 -1
  131. package/schemas/helper-expansion-request.schema.json +1 -1
  132. package/schemas/helper-expansion-summary.schema.json +1 -1
  133. package/schemas/impact-inspect-input.schema.json +1 -1
  134. package/schemas/map-operation.schema.json +597 -126
  135. package/schemas/mechanic-test-input.schema.json +1 -1
  136. package/schemas/mod-index-input.schema.json +51 -0
  137. package/schemas/operation-result.schema.json +2 -2
  138. package/schemas/package-check-input.schema.json +1 -1
  139. package/schemas/package-manifest.schema.json +1 -1
  140. package/schemas/probability-analysis-result.schema.json +1 -1
  141. package/schemas/probability-compare-input.schema.json +1 -1
  142. package/schemas/probability-evaluate-input.schema.json +1 -1
  143. package/schemas/probability-inspect-input.schema.json +1 -1
  144. package/schemas/probability-render-input.schema.json +1 -1
  145. package/schemas/probability-scenario-set.schema.json +1 -1
  146. package/schemas/probability-sequence-input.schema.json +1 -1
  147. package/schemas/probability-simulate-input.schema.json +1 -1
  148. package/schemas/probability-sweep-input.schema.json +1 -1
  149. package/schemas/reference-context-input.schema.json +1 -1
  150. package/schemas/reference-read-input.schema.json +1 -1
  151. package/schemas/reference-search-input.schema.json +1 -1
  152. package/schemas/scenario-suite.schema.json +3 -5
  153. package/schemas/scenario-test-input.schema.json +3 -5
  154. package/schemas/script-validate-input.schema.json +1 -1
  155. package/schemas/shared-scenario.schema.json +1 -1
  156. package/schemas/source-lookup-input.schema.json +1 -1
  157. package/server.json +3 -3
@@ -1,6 +1,6 @@
1
1
  import { emptyServiceResult } from '../core/result.js';
2
2
  import { setInlineFilesScanned } from '../core/operation-result.js';
3
- import { probabilityCompareRequestSchema, probabilityEvaluateRequestSchema, probabilityInspectRequestSchema, probabilityRenderRequestSchema, probabilitySequenceRequestSchema, probabilitySimulateRequestSchema, probabilitySweepRequestSchema, } from '../schemas/probability-requests.js';
3
+ import { probabilityCompareRequestSchema, probabilityEvaluateRequestSchema, probabilityInspectRequestSchema, probabilityRenderRequestSchema, probabilitySequenceRequestSchema, probabilitySimulateRequestSchema, probabilitySweepRequestSchema, probabilityAnalyzeRequestSchema, splitProbabilityAnalyzeRequest, } from '../schemas/probability-requests.js';
4
4
  function runtime(context, refresh) {
5
5
  return {
6
6
  workspaceId: context.workspaceId,
@@ -9,6 +9,83 @@ function runtime(context, refresh) {
9
9
  ...(refresh === undefined ? {} : { refresh }),
10
10
  };
11
11
  }
12
+ const INLINE_RANKING_SCENARIOS = 4;
13
+ const INLINE_RANKING_CANDIDATES = 12;
14
+ const INLINE_MISSING_INPUTS = 16;
15
+ const INLINE_COMPARISON_CHANGES = 12;
16
+ function rounded(value, digits = 6) {
17
+ if (value === null || value === undefined || !Number.isFinite(value))
18
+ return null;
19
+ const scale = 10 ** digits;
20
+ return Math.round(value * scale) / scale;
21
+ }
22
+ function compareIds(left, right) {
23
+ return left < right ? -1 : left > right ? 1 : 0;
24
+ }
25
+ /** The answer itself, inline: the strongest candidates of the first scenarios. */
26
+ function inlineRanking(result) {
27
+ return result.scenarios.slice(0, INLINE_RANKING_SCENARIOS).map((scenario) => {
28
+ const ranked = [...scenario.candidates].sort((left, right) => (right.conditionalProbability ?? -1) - (left.conditionalProbability ?? -1) ||
29
+ (right.rawValue?.value ?? Number.NEGATIVE_INFINITY) -
30
+ (left.rawValue?.value ?? Number.NEGATIVE_INFINITY) ||
31
+ compareIds(left.id, right.id));
32
+ return {
33
+ scenarioId: scenario.id,
34
+ poolComplete: scenario.poolComplete,
35
+ candidates: ranked.slice(0, INLINE_RANKING_CANDIDATES).map((candidate) => ({
36
+ id: candidate.id,
37
+ eligible: candidate.eligibility,
38
+ weight: rounded(candidate.rawValue?.value),
39
+ ...((candidate.rawValue === undefined || candidate.rawValue === null) &&
40
+ candidate.rawInterval !== undefined
41
+ ? {
42
+ weightRange: [rounded(candidate.rawInterval.min), rounded(candidate.rawInterval.max)],
43
+ }
44
+ : {}),
45
+ probability: rounded(candidate.conditionalProbability),
46
+ ...(candidate.effectiveMtthDays === undefined
47
+ ? {}
48
+ : { mtthDays: rounded(candidate.effectiveMtthDays, 2) }),
49
+ })),
50
+ ...(ranked.length > INLINE_RANKING_CANDIDATES
51
+ ? { omittedCandidates: ranked.length - INLINE_RANKING_CANDIDATES }
52
+ : {}),
53
+ };
54
+ });
55
+ }
56
+ /** Facts the scenarios did not declare but a condition or value needs. */
57
+ function missingInputs(result) {
58
+ const inputs = new Set();
59
+ for (const { code, path, details } of result.unresolved) {
60
+ if (typeof details?.scenarioInput === 'string')
61
+ inputs.add(details.scenarioInput);
62
+ else if (code === 'VALUE_UNRESOLVED' && path !== undefined && !/^(?:@|constant:)/u.test(path))
63
+ inputs.add(path);
64
+ }
65
+ return [...inputs].sort(compareIds).slice(0, INLINE_MISSING_INPUTS);
66
+ }
67
+ function inlineComparison(result) {
68
+ if (result.comparison === undefined)
69
+ return [];
70
+ return [...result.comparison.scenarioChanges]
71
+ .sort((left, right) => Math.abs(right.probabilityDelta ?? 0) - Math.abs(left.probabilityDelta ?? 0) ||
72
+ Math.abs(right.rawDelta ?? 0) - Math.abs(left.rawDelta ?? 0) ||
73
+ compareIds(left.candidateId, right.candidateId))
74
+ .slice(0, INLINE_COMPARISON_CHANGES)
75
+ .map((change) => ({
76
+ scenarioId: change.scenarioId,
77
+ candidateId: change.candidateId,
78
+ ...(change.rawDelta === undefined ? {} : { weightDelta: rounded(change.rawDelta) }),
79
+ ...(change.probabilityDelta === undefined
80
+ ? {}
81
+ : { probabilityDelta: rounded(change.probabilityDelta) }),
82
+ ...(change.eligibilityChange === undefined
83
+ ? {}
84
+ : {
85
+ eligibility: `${change.eligibilityChange.before}->${change.eligibilityChange.after}`,
86
+ }),
87
+ }));
88
+ }
12
89
  export function probabilityAnalysisData(result) {
13
90
  return {
14
91
  operation: result.operation,
@@ -41,6 +118,9 @@ export function probabilityAnalysisData(result) {
41
118
  : { comparisonChanges: result.comparison.scenarioChanges.length }),
42
119
  visualResources: result.resources.filter(({ mimeType }) => mimeType.startsWith('image/'))
43
120
  .length,
121
+ ranking: inlineRanking(result),
122
+ missingInputs: missingInputs(result),
123
+ ...(result.comparison === undefined ? {} : { changes: inlineComparison(result) }),
44
124
  };
45
125
  }
46
126
  export function probabilityAnalysisServiceResult(workspaceId, result) {
@@ -88,19 +168,82 @@ export function normalizeProbabilityCompareRequest(input) {
88
168
  export function normalizeProbabilityRenderRequest(input) {
89
169
  return probabilityRenderRequestSchema.parse(input);
90
170
  }
171
+ const DEFAULT_SCENARIO_SET = {
172
+ schemaVersion: '1.0',
173
+ id: 'source-defaults',
174
+ description: 'One scenario with no declared facts; conditions that need facts stay unresolved.',
175
+ scenarios: [{ id: 'no-facts', state: {} }],
176
+ };
177
+ /** Discover the weighted surface and, unless evaluate is false, evaluate it in the same call. */
91
178
  export async function inspectProbabilities(analyzer, input, context) {
92
- const inspected = await analyzer.inspect(runtime(context, input.refresh), input.adapter, input.source, input.candidatePool, input.customPoolManifest);
93
- const sourceRequiredInputPaths = (inspected.surface?.requiredInputs ?? []).filter((path) => path !== 'focus.external_factors_complete' && path !== 'technology.external_factors_complete');
94
- const adapterRequiredInputPaths = (inspected.surface?.requiredInputs ?? []).filter((path) => path === 'focus.external_factors_complete' || path === 'technology.external_factors_complete');
179
+ let source = input.source;
180
+ const candidatePool = input.candidatePool ?? [];
181
+ // A pool alone names what to inspect: its first ID finds the defining file.
182
+ if (source === undefined && input.customPoolManifest === undefined && candidatePool.length > 0)
183
+ source = { identifier: candidatePool[0] };
184
+ const customPoolManifest = input.customPoolManifest;
185
+ let inspected = await analyzer.inspect(runtime(context, input.refresh), input.adapter, source, candidatePool, customPoolManifest);
186
+ // A wrong adapter guess, such as a mission named as a decision, should not cost another call.
187
+ const suggested = inspected.discovery?.suggestedAdapter;
188
+ if (input.evaluate !== false &&
189
+ inspected.surface === undefined &&
190
+ suggested !== undefined &&
191
+ suggested !== input.adapter &&
192
+ (inspected.discovery?.reason === 'candidate_pool_not_found' ||
193
+ inspected.discovery?.reason === 'requested_adapter_empty' ||
194
+ inspected.discovery?.reason === 'identifier_not_found'))
195
+ inspected = await analyzer.inspect(runtime(context), suggested, source, candidatePool, customPoolManifest);
196
+ const surface = inspected.surface;
197
+ if (input.evaluate !== false &&
198
+ surface !== undefined &&
199
+ (source !== undefined || customPoolManifest !== undefined)) {
200
+ const analyzed = await analyzer.evaluate({
201
+ ...runtime(context),
202
+ adapter: surface.adapter.id,
203
+ ...(source === undefined ? {} : { source }),
204
+ ...(customPoolManifest === undefined ? {} : { customPoolManifest }),
205
+ ...(candidatePool.length === 0 ? {} : { candidatePool }),
206
+ scenarioSet: (input.scenarioSet ??
207
+ DEFAULT_SCENARIO_SET),
208
+ ...(input.horizonDays === undefined ? {} : { horizonDays: input.horizonDays }),
209
+ ...(input.metrics === undefined ? {} : { metrics: input.metrics }),
210
+ ...(input.acceptanceBands === undefined
211
+ ? {}
212
+ : {
213
+ acceptanceBands: input.acceptanceBands,
214
+ }),
215
+ ...(input.diagnosticThresholds === undefined
216
+ ? {}
217
+ : {
218
+ diagnosticThresholds: input.diagnosticThresholds,
219
+ }),
220
+ ...(input.outputs === undefined ? {} : { outputs: input.outputs }),
221
+ });
222
+ const result = probabilityAnalysisServiceResult(context.workspaceId, analyzed);
223
+ result.artifacts = [...result.artifacts, ...inspected.artifacts];
224
+ if (input.scenarioSet === undefined)
225
+ result.diagnostics = [
226
+ {
227
+ code: 'PROBABILITY_DEFAULT_SCENARIO',
228
+ severity: 'info',
229
+ category: 'configuration',
230
+ message: 'Evaluated without declared facts; pass scenarioSet with the facts in missingInputs to resolve the remaining conditions',
231
+ },
232
+ ...result.diagnostics,
233
+ ];
234
+ return result;
235
+ }
236
+ const sourceRequiredInputPaths = (surface?.requiredInputs ?? []).filter((path) => path !== 'focus.external_factors_complete' && path !== 'technology.external_factors_complete');
237
+ const adapterRequiredInputPaths = (surface?.requiredInputs ?? []).filter((path) => path === 'focus.external_factors_complete' || path === 'technology.external_factors_complete');
95
238
  const result = emptyServiceResult(context.workspaceId, {
96
239
  adapters: inspected.adapters.length,
97
- ...(inspected.surface === undefined
240
+ ...(surface === undefined
98
241
  ? {}
99
242
  : {
100
- adapterId: inspected.surface.adapter.id,
101
- sourceRevision: inspected.surface.sourceRevision,
102
- sourceHash: inspected.surface.sourceHash,
103
- poolComplete: inspected.surface.poolComplete,
243
+ adapterId: surface.adapter.id,
244
+ sourceRevision: surface.sourceRevision,
245
+ sourceHash: surface.sourceHash,
246
+ poolComplete: surface.poolComplete,
104
247
  }),
105
248
  ...(inspected.discovery === undefined
106
249
  ? {}
@@ -115,7 +258,7 @@ export async function inspectProbabilities(analyzer, input, context) {
115
258
  sourceRevision: inspected.discovery.sourceRevision,
116
259
  sourceHash: inspected.discovery.sourceHash,
117
260
  }),
118
- candidates: inspected.surface?.candidateCount ?? 0,
261
+ candidates: surface?.candidateCount ?? 0,
119
262
  availableCandidates: inspected.discovery?.availableAdapters.reduce((sum, { candidateCount }) => sum + candidateCount, 0) ?? 0,
120
263
  availableAdapters: inspected.discovery?.availableAdapters.map((available) => ({
121
264
  adapterId: available.adapterId,
@@ -124,14 +267,14 @@ export async function inspectProbabilities(analyzer, input, context) {
124
267
  candidatePoolMatches: available.candidatePoolMatchCount,
125
268
  })) ?? [],
126
269
  candidateExamples: inspected.discovery?.exampleCandidateIds ?? [],
127
- requiredInputs: inspected.surface?.requiredInputs.length ?? 0,
270
+ requiredInputs: surface?.requiredInputs.length ?? 0,
128
271
  requiredInputPaths: sourceRequiredInputPaths.slice(0, 32),
129
272
  requiredInputPathsTruncated: sourceRequiredInputPaths.length > 32,
130
273
  adapterRequiredInputPaths,
131
- unresolved: inspected.surface?.unsupported.length ?? 0,
274
+ unresolved: surface?.unsupported.length ?? 0,
132
275
  });
133
276
  result.code =
134
- inspected.surface !== undefined
277
+ surface !== undefined
135
278
  ? 'PROBABILITY_SOURCE_INSPECTED'
136
279
  : inspected.discovery !== undefined
137
280
  ? 'PROBABILITY_SOURCE_DISCOVERED'
@@ -140,6 +283,25 @@ export async function inspectProbabilities(analyzer, input, context) {
140
283
  setInlineFilesScanned(result, inspected.filesScanned);
141
284
  return result;
142
285
  }
286
+ /** Compare, sweep, simulate, sequence, or render through one entry point. */
287
+ export async function analyzeProbabilities(analyzer, input, context) {
288
+ const split = splitProbabilityAnalyzeRequest(input);
289
+ switch (split.analysis) {
290
+ case 'compare':
291
+ return compareProbabilities(analyzer, split.request, context);
292
+ case 'sweep':
293
+ return sweepProbabilities(analyzer, split.request, context);
294
+ case 'simulate':
295
+ return simulateProbabilities(analyzer, split.request, context);
296
+ case 'sequence':
297
+ return sequenceProbabilities(analyzer, split.request, context);
298
+ case 'render':
299
+ return renderProbabilities(analyzer, split.request, context);
300
+ }
301
+ }
302
+ export function normalizeProbabilityAnalyzeRequest(input) {
303
+ return probabilityAnalyzeRequestSchema.parse(input);
304
+ }
143
305
  export async function evaluateProbabilities(analyzer, input, context) {
144
306
  const analyzed = await analyzer.evaluate({
145
307
  ...runtime(context, input.refresh),
@@ -388,6 +388,28 @@ function samePool(candidate, selected) {
388
388
  return candidate.metadata.randomListId === selected.metadata.randomListId;
389
389
  return candidate.id === selected.id;
390
390
  }
391
+ /**
392
+ * A pool entry that names no candidate but names a categorical pool, such as an event ID for
393
+ * its options or a random_list ID, stands for every candidate of that pool.
394
+ */
395
+ export function expandCandidatePool(candidates, candidatePool) {
396
+ if (candidatePool.length === 0)
397
+ return [];
398
+ const ids = new Set(candidates.map(({ id }) => id));
399
+ const expanded = new Set();
400
+ for (const entry of candidatePool) {
401
+ if (ids.has(entry)) {
402
+ expanded.add(entry);
403
+ continue;
404
+ }
405
+ const members = candidates.filter(({ metadata }) => metadata.eventId === entry || metadata.randomListId === entry);
406
+ if (members.length === 0)
407
+ expanded.add(entry);
408
+ for (const { id } of members)
409
+ expanded.add(id);
410
+ }
411
+ return [...expanded];
412
+ }
391
413
  function candidatesAtSourceLine(candidates, adapterId, line) {
392
414
  if (line === undefined)
393
415
  return candidates;
@@ -421,11 +443,11 @@ function candidatesAtSourceLine(candidates, adapterId, line) {
421
443
  }
422
444
  export function inventoryWeightedSurfaces(snapshot, source, candidatePool = [], adapterIds = sourceBackedAdapterIds) {
423
445
  const contexts = sourceContexts(snapshot, source);
424
- const wanted = new Set(candidatePool);
425
446
  const adapters = adapterIds
426
447
  .filter((adapterId) => adapterId !== 'custom_weighted_pool')
427
448
  .map((adapterId) => {
428
449
  const candidates = candidatesAtSourceLine(contexts.flatMap((context) => candidatesFor(context, adapterId)), adapterId, source.line).sort((left, right) => compareCodeUnits(left.id, right.id));
450
+ const wanted = new Set(expandCandidatePool(candidates, candidatePool));
429
451
  return {
430
452
  adapterId,
431
453
  candidateCount: candidates.length,
@@ -457,6 +479,7 @@ export function discoverWeightedSurface(snapshot, adapterId, source, candidatePo
457
479
  let candidates = contexts.flatMap((context) => candidatesFor(context, adapterId));
458
480
  candidates.sort((left, right) => compareCodeUnits(left.id, right.id));
459
481
  candidates = candidatesAtSourceLine(candidates, adapterId, source.line);
482
+ candidatePool = expandCandidatePool(candidates, candidatePool);
460
483
  const localCategoricalPool = adapterId === 'event_option_ai_chance' || adapterId === 'random_list';
461
484
  let completeLocalPoolIds;
462
485
  if (source.identifier !== undefined) {
@@ -0,0 +1,359 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, stat, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { ServiceError } from '../core/result.js';
6
+ import { knownMistakeForLogMessage } from './known-mistakes.js';
7
+ /** The newest part of a very large log is read; the game writes it anew on every launch. */
8
+ const MAX_LOG_BYTES = 16 * 1024 * 1024;
9
+ const MAX_MESSAGE_CHARACTERS = 600;
10
+ const SOURCE_EXTENSIONS = 'txt|gui|gfx|yml|csv|asset|lua|mod|shader|fxh|json|map|bmp|dds|tga|png';
11
+ const headerPattern = /^\[(\d{2}:\d{2}:\d{2})\]\[([^\]:]+?)(?:\.cpp)?(?::\d+)?\]:\s?(.*)$/u;
12
+ const quotedFilePattern = new RegExp(`(?:in |)file:?\\s*"([^"]+\\.(?:${SOURCE_EXTENSIONS}))"`, 'giu');
13
+ const barePathPattern = new RegExp(`((?:[A-Za-z]:)?[\\w .\\-/\\\\]*?(?:common|events|history|interface|gfx|localisation|localisation_synced|map|music|sound|portraits|tutorial)[/\\\\][^\\s"',;:]+?\\.(?:${SOURCE_EXTENSIONS}))\\b`, 'gu');
14
+ const linePattern = /(?:near\s+)?line:?\s*(\d{1,7})/giu;
15
+ /** Rules in order; the first that matches classifies an entry. */
16
+ const categoryRules = [
17
+ [
18
+ 'syntax',
19
+ /unexpected (?:token|end)|missing (?:closing |opening )?(?:brace|bracket)|parse error|malformed|unbalanced/iu,
20
+ ],
21
+ [
22
+ 'duplicate',
23
+ /duplicate|already (?:exists|defined|registered)|defined (?:twice|multiple times)/iu,
24
+ ],
25
+ ['localisation', /locali[sz]/iu],
26
+ ['effect', /\beffect\b/iu],
27
+ ['trigger', /\btrigger\b|\bcondition\b/iu],
28
+ ['scope', /\bscope\b/iu],
29
+ ['graphics', /texture|sprite|\.dds|\.tga|\.png|gfx|\bfont\b|\bmesh\b|\bentity\b|animation/iu],
30
+ ['interface', /\bgui\b|window|\bwidget\b|scripted ?gui/iu],
31
+ ['map', /province|strategic ?region|\bstate\b|adjacenc|supply|railway|\bmap\b/iu],
32
+ [
33
+ 'missing_reference',
34
+ /not found|unknown|doesn'?t exist|does not exist|invalid|missing|could not find|failed to (?:find|load)/iu,
35
+ ],
36
+ ];
37
+ const moduleCategories = [
38
+ [/locali[sz]/iu, 'localisation'],
39
+ [/^(?:effect|effectbase|effects)/iu, 'effect'],
40
+ [/^(?:trigger|triggerbase|triggers)/iu, 'trigger'],
41
+ [/gfx|texture|sprite|font|graphic/iu, 'graphics'],
42
+ [/gui|window|widget/iu, 'interface'],
43
+ [/province|map|state|region|supply|railway|adjacenc/iu, 'map'],
44
+ [/persistent|parse|pdx_?data|script/iu, 'syntax'],
45
+ ];
46
+ /** What to do about each kind of entry; names the tool that narrows it down. */
47
+ const categoryHints = {
48
+ syntax: 'Check braces, quotes and assignments near the named line; hoi4.script_validate with the file path pinpoints it.',
49
+ duplicate: 'Two active definitions share a name: rename one, or remove the copy that overrides the other.',
50
+ localisation: 'Add the key to a UTF-8 with BOM .yml under localisation/<language>/ (key: "Text", no :0), or correct the key name.',
51
+ effect: 'Check that the effect exists and takes these arguments in this scope: hoi4.script_validate on the file, hoi4.reference_search for its documentation.',
52
+ trigger: 'Check that the trigger exists and takes these arguments in this scope: hoi4.script_validate on the file, hoi4.reference_search for its documentation.',
53
+ scope: 'A command runs in the wrong scope: check the enclosing scope changes with hoi4.script_validate.',
54
+ graphics: 'Register the sprite in a .gfx file, and check the texture path, file name case and DDS format.',
55
+ interface: 'Check the .gui element and sprite names; hoi4.gui_inspect shows the window and its missing assets.',
56
+ map: 'Check the named province, state or region with hoi4.map_inspect; map errors can stop the game loading without debug mode.',
57
+ missing_reference: 'The named object is not defined in any active layer: hoi4.source_lookup confirms, then define it or fix the name.',
58
+ other: 'Read the message and the named file; hoi4.source_lookup locates the definition.',
59
+ };
60
+ /** Reduce a message to its shape: names, numbers, quoted values and paths become placeholders. */
61
+ export function errorLogPattern(message) {
62
+ return (message
63
+ .toLowerCase()
64
+ .replace(/"[^"]*"/gu, '<v>')
65
+ .replace(new RegExp(`[\\w./\\\\:-]+\\.(?:${SOURCE_EXTENSIONS})\\b`, 'gu'), '<file>')
66
+ .replace(/\b[a-z_][\w]*\.\d+\b/gu, '<id>')
67
+ // snake_case script names (keys, effects, sprites) differ between otherwise equal messages.
68
+ .replace(/\b[a-z0-9]+(?:_[a-z0-9]+)+\b/gu, '<name>')
69
+ .replace(/-?\b\d+(?:\.\d+)?\b/gu, '<n>')
70
+ .replace(/\s+/gu, ' ')
71
+ .trim()
72
+ .slice(0, 300));
73
+ }
74
+ const HISTORY_FILE = 'error-log-history.json';
75
+ const MAX_HISTORY_ENTRIES = 5_000;
76
+ function entryKey(module, message) {
77
+ return createHash('sha256').update(`${module}\0${message}`).digest('hex').slice(0, 24);
78
+ }
79
+ async function readHistory(file) {
80
+ try {
81
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
82
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
83
+ }
84
+ catch {
85
+ return {};
86
+ }
87
+ }
88
+ /**
89
+ * Remember the entries of each log this server reads, keyed by the log's write time, so the
90
+ * next launch's log can say which errors are new and which a fix removed. The history lives
91
+ * in the server's own cache folder, never in the mod.
92
+ */
93
+ async function compareWithPreviousLaunch(workspace, modifiedAt, entries) {
94
+ const file = path.join(workspace.cacheRoot, HISTORY_FILE);
95
+ const history = await readHistory(file);
96
+ const current = { modifiedAt, entries: {} };
97
+ for (const entry of entries.slice(0, MAX_HISTORY_ENTRIES))
98
+ current.entries[entryKey(entry.module, entry.message)] = {
99
+ category: entry.category,
100
+ message: entry.message.slice(0, 300),
101
+ ...(entry.relativePath === undefined ? {} : { relativePath: entry.relativePath }),
102
+ };
103
+ if (history.current?.modifiedAt === modifiedAt)
104
+ return { ...(history.previous === undefined ? {} : { previous: history.previous }), current };
105
+ const next = {
106
+ ...(history.current === undefined ? {} : { previous: history.current }),
107
+ current,
108
+ };
109
+ try {
110
+ await mkdir(workspace.cacheRoot, { recursive: true });
111
+ const temporary = `${file}.${process.pid}.tmp`;
112
+ await writeFile(temporary, JSON.stringify(next));
113
+ await rename(temporary, file);
114
+ }
115
+ catch {
116
+ // The comparison is a convenience; an unwritable cache leaves the read itself intact.
117
+ }
118
+ return { ...(next.previous === undefined ? {} : { previous: next.previous }), current };
119
+ }
120
+ function classify(module, message) {
121
+ for (const [category, pattern] of categoryRules)
122
+ if (category !== 'missing_reference' && pattern.test(message))
123
+ return category;
124
+ for (const [pattern, category] of moduleCategories)
125
+ if (pattern.test(module))
126
+ return category;
127
+ return categoryRules.at(-1)[1].test(message) ? 'missing_reference' : 'other';
128
+ }
129
+ /** The reviewed known mistake a logged message reports, when there is one. */
130
+ function knownMistakeOf(message) {
131
+ const rule = knownMistakeForLogMessage(message);
132
+ return rule === undefined ? {} : { knownMistake: { id: rule.id, fix: rule.fix } };
133
+ }
134
+ /** The user-data folder that holds `logs/`, in order of preference. */
135
+ function userDataCandidates(workspace, configured) {
136
+ const candidates = [];
137
+ if (configured !== undefined)
138
+ candidates.push(path.resolve(configured));
139
+ const modParent = path.dirname(workspace.modRoot);
140
+ if (path.basename(modParent).toLowerCase() === 'mod')
141
+ candidates.push(path.dirname(modParent));
142
+ const home = os.homedir();
143
+ const userFolder = path.join('Paradox Interactive', 'Hearts of Iron IV');
144
+ if (process.platform === 'win32') {
145
+ if (process.env.OneDrive !== undefined)
146
+ candidates.push(path.join(process.env.OneDrive, 'Documents', userFolder));
147
+ candidates.push(path.join(home, 'Documents', userFolder));
148
+ }
149
+ else if (process.platform === 'darwin') {
150
+ candidates.push(path.join(home, 'Documents', userFolder));
151
+ }
152
+ else {
153
+ candidates.push(path.join(home, '.local', 'share', userFolder));
154
+ }
155
+ return [...new Set(candidates)];
156
+ }
157
+ async function readTail(file, size) {
158
+ const start = Math.max(0, size - MAX_LOG_BYTES);
159
+ const handle = await open(file, 'r');
160
+ try {
161
+ const buffer = Buffer.alloc(size - start);
162
+ await handle.read(buffer, 0, buffer.length, start);
163
+ let text = buffer.toString('utf8');
164
+ // Drop the partial first line of a truncated read.
165
+ if (start > 0)
166
+ text = text.slice(text.indexOf('\n') + 1);
167
+ return { text, truncated: start > 0 };
168
+ }
169
+ finally {
170
+ await handle.close();
171
+ }
172
+ }
173
+ /** The last source file a message names, relative to its source root, and its line. */
174
+ function locate(message, roots) {
175
+ const files = [
176
+ ...[...message.matchAll(quotedFilePattern)].map((match) => match[1]),
177
+ ...[...message.matchAll(barePathPattern)].map((match) => match[1].trim()),
178
+ ];
179
+ const raw = files.at(-1);
180
+ if (raw === undefined)
181
+ return undefined;
182
+ let normalized = raw.replaceAll('\\', '/').replace(/^\.\//u, '');
183
+ for (const root of roots) {
184
+ const prefix = `${root.replaceAll('\\', '/').replace(/\/$/u, '')}/`;
185
+ if (normalized.toLowerCase().startsWith(prefix.toLowerCase())) {
186
+ normalized = normalized.slice(prefix.length);
187
+ break;
188
+ }
189
+ }
190
+ // Paths under any mod folder are made relative to that mod.
191
+ const modMatch = /\/mod\/[^/]+\/(.+)$/u.exec(normalized);
192
+ if (modMatch !== null && path.isAbsolute(raw))
193
+ normalized = modMatch[1];
194
+ const lines = [...message.matchAll(linePattern)].map((match) => Number(match[1]));
195
+ return { relativePath: normalized, ...(lines.length === 0 ? {} : { line: lines.at(-1) }) };
196
+ }
197
+ async function layerOf(workspace, relativePath, logModified) {
198
+ const layers = [
199
+ ['mod', workspace.modRoot],
200
+ ...workspace.dependencyRoots.map((root) => [
201
+ 'dependency',
202
+ root,
203
+ ]),
204
+ ...(workspace.gameRoot === undefined
205
+ ? []
206
+ : [['game', workspace.gameRoot]]),
207
+ ];
208
+ for (const [layer, root] of layers) {
209
+ const candidate = path.resolve(root, relativePath);
210
+ if (!candidate.startsWith(path.resolve(root) + path.sep))
211
+ continue;
212
+ const info = await stat(candidate).catch(() => undefined);
213
+ if (info?.isFile() === true)
214
+ return { layer, changedSinceLog: info.mtimeMs > logModified };
215
+ }
216
+ return { layer: 'unknown', changedSinceLog: false };
217
+ }
218
+ export async function readErrorLog(workspace, input, configuredUserRoot, signal) {
219
+ let found;
220
+ for (const root of userDataCandidates(workspace, configuredUserRoot)) {
221
+ const file = path.join(root, 'logs', 'error.log');
222
+ const info = await stat(file).catch(() => undefined);
223
+ if (info?.isFile() === true) {
224
+ found = { file, size: info.size, modified: info.mtimeMs };
225
+ break;
226
+ }
227
+ }
228
+ if (found === undefined)
229
+ throw new ServiceError('ERROR_LOG_NOT_FOUND', 'No logs/error.log was found in the game user-data folder; launch the game once, or set gameUserRoot in the server configuration');
230
+ signal?.throwIfAborted();
231
+ const { text, truncated } = await readTail(found.file, found.size);
232
+ const roots = [workspace.modRoot, ...workspace.dependencyRoots, workspace.gameRoot ?? ''].filter(Boolean);
233
+ // Join continuation lines to the entry they belong to.
234
+ const raw = [];
235
+ text.split(/\r?\n/u).forEach((line, index) => {
236
+ const header = headerPattern.exec(line);
237
+ if (header !== null)
238
+ raw.push({ time: header[1], module: header[2], message: header[3], logLine: index + 1 });
239
+ else if (line.trim() !== '' && raw.length > 0)
240
+ raw.at(-1).message += ` ${line.trim()}`;
241
+ else if (line.trim() !== '')
242
+ raw.push({ module: '', message: line.trim(), logLine: index + 1 });
243
+ });
244
+ const grouped = new Map();
245
+ for (const [order, item] of raw.entries()) {
246
+ signal?.throwIfAborted();
247
+ const message = item.message.slice(0, MAX_MESSAGE_CHARACTERS);
248
+ const key = `${item.module}\0${message}`;
249
+ const existing = grouped.get(key);
250
+ if (existing !== undefined) {
251
+ existing.count += 1;
252
+ continue;
253
+ }
254
+ const located = locate(message, roots);
255
+ const source = located === undefined
256
+ ? undefined
257
+ : {
258
+ relativePath: located.relativePath.slice(0, 1024),
259
+ ...(located.line === undefined ? {} : { line: located.line }),
260
+ ...(await layerOf(workspace, located.relativePath, found.modified)),
261
+ };
262
+ grouped.set(key, {
263
+ category: classify(item.module, message),
264
+ module: item.module.slice(0, 128),
265
+ message,
266
+ count: 1,
267
+ firstLogLine: item.logLine,
268
+ ...(item.time === undefined ? {} : { time: item.time }),
269
+ ...(source === undefined ? {} : { source }),
270
+ order,
271
+ });
272
+ }
273
+ const layerRank = { mod: 0, dependency: 1, unknown: 2, game: 3 };
274
+ const all = [...grouped.values()].sort((left, right) => layerRank[left.source?.layer ?? 'unknown'] - layerRank[right.source?.layer ?? 'unknown'] ||
275
+ left.order - right.order);
276
+ const selected = all.filter((entry) => (input.scope === 'all' ||
277
+ (input.scope === 'mod' && entry.source?.layer === 'mod') ||
278
+ (input.scope === 'unlocated' && entry.source === undefined)) &&
279
+ (input.category === undefined || entry.category === input.category) &&
280
+ (input.query === undefined ||
281
+ entry.message.toLowerCase().includes(input.query.toLowerCase()) ||
282
+ entry.source?.relativePath.toLowerCase().includes(input.query.toLowerCase()) === true));
283
+ const launches = await compareWithPreviousLaunch(workspace, new Date(found.modified).toISOString(), all.map((entry) => ({
284
+ module: entry.module,
285
+ message: entry.message,
286
+ category: entry.category,
287
+ ...(entry.source === undefined ? {} : { relativePath: entry.source.relativePath }),
288
+ })));
289
+ const previousKeys = launches.previous === undefined ? undefined : new Set(Object.keys(launches.previous.entries));
290
+ const currentKeys = new Set(Object.keys(launches.current.entries));
291
+ const patterns = new Map();
292
+ for (const entry of selected) {
293
+ const pattern = errorLogPattern(entry.message);
294
+ const totals = patterns.get(pattern) ?? {
295
+ category: entry.category,
296
+ distinct: 0,
297
+ entries: 0,
298
+ example: entry.message,
299
+ };
300
+ totals.distinct += 1;
301
+ totals.entries += entry.count;
302
+ patterns.set(pattern, totals);
303
+ }
304
+ const categories = new Map();
305
+ for (const entry of selected) {
306
+ const totals = categories.get(entry.category) ?? { entries: 0, distinct: 0 };
307
+ totals.entries += entry.count;
308
+ totals.distinct += 1;
309
+ categories.set(entry.category, totals);
310
+ }
311
+ const page = selected.slice(input.offset, input.offset + input.limit);
312
+ return {
313
+ log: {
314
+ path: 'user:logs/error.log',
315
+ modifiedAt: new Date(found.modified).toISOString(),
316
+ bytes: found.size,
317
+ truncated,
318
+ entries: raw.length,
319
+ },
320
+ scope: input.scope,
321
+ totalEntries: selected.reduce((sum, entry) => sum + entry.count, 0),
322
+ distinctEntries: selected.length,
323
+ modEntries: all.filter((entry) => entry.source?.layer === 'mod').length,
324
+ changedSinceLog: all.filter((entry) => entry.source?.changedSinceLog === true).length,
325
+ categories: [...categories]
326
+ .map(([category, totals]) => ({ category, ...totals, hint: categoryHints[category] }))
327
+ .sort((left, right) => right.entries - left.entries),
328
+ patterns: [...patterns]
329
+ .filter(([, totals]) => totals.distinct > 1)
330
+ .sort(([, left], [, right]) => right.distinct - left.distinct || right.entries - left.entries)
331
+ .slice(0, 8)
332
+ .map(([pattern, totals]) => ({ pattern, ...totals })),
333
+ ...(launches.previous === undefined || previousKeys === undefined
334
+ ? {}
335
+ : {
336
+ sinceLastLaunch: {
337
+ previousLogAt: launches.previous.modifiedAt,
338
+ newEntries: [...currentKeys].filter((key) => !previousKeys.has(key)).length,
339
+ persistingEntries: [...currentKeys].filter((key) => previousKeys.has(key)).length,
340
+ resolvedEntries: [...previousKeys].filter((key) => !currentKeys.has(key)).length,
341
+ resolvedExamples: Object.entries(launches.previous.entries)
342
+ .filter(([key]) => !currentKeys.has(key))
343
+ .slice(0, 5)
344
+ .map(([, entry]) => entry),
345
+ },
346
+ }),
347
+ entries: page.map(({ order: _order, ...entry }) => ({
348
+ ...entry,
349
+ ...knownMistakeOf(entry.message),
350
+ ...(previousKeys === undefined
351
+ ? {}
352
+ : { new: !previousKeys.has(entryKey(entry.module, entry.message)) }),
353
+ })),
354
+ offset: input.offset,
355
+ ...(input.offset + page.length < selected.length
356
+ ? { nextOffset: input.offset + page.length }
357
+ : {}),
358
+ };
359
+ }