hoi4-agent-tools 3.8.1 → 3.10.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 (79) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +1 -1
  3. package/dist/bin/http.js +2 -0
  4. package/dist/bin/setup.js +5 -2
  5. package/dist/bin/stdio.js +29 -1
  6. package/dist/hoi4_agent_tools/core/configuration.js +8 -0
  7. package/dist/hoi4_agent_tools/core/engine.js +27 -8
  8. package/dist/hoi4_agent_tools/core/index.js +45 -20
  9. package/dist/hoi4_agent_tools/core/job-worker-host.js +40 -2
  10. package/dist/hoi4_agent_tools/core/job-worker.js +14 -0
  11. package/dist/hoi4_agent_tools/core/scenario-model.js +1 -1
  12. package/dist/hoi4_agent_tools/core/server-registry.js +138 -0
  13. package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
  14. package/dist/hoi4_agent_tools/core/source/cache.js +89 -20
  15. package/dist/hoi4_agent_tools/event/service.js +7 -0
  16. package/dist/hoi4_agent_tools/gui/renderer.js +8 -2
  17. package/dist/hoi4_agent_tools/gui/scenario-generator.js +56 -0
  18. package/dist/hoi4_agent_tools/gui/scenario.js +2 -0
  19. package/dist/hoi4_agent_tools/gui/studio.js +31 -12
  20. package/dist/hoi4_agent_tools/gui/validators.js +22 -0
  21. package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
  22. package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
  23. package/dist/hoi4_agent_tools/mcp/tools/reference.js +1 -1
  24. package/dist/hoi4_agent_tools/mcp/tools/technology.js +5 -2
  25. package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +9 -0
  26. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +79 -0
  27. package/dist/hoi4_agent_tools/reference/event-popups.js +87 -0
  28. package/dist/hoi4_agent_tools/reference/operations.js +151 -1
  29. package/dist/hoi4_agent_tools/reference/player-text.js +158 -0
  30. package/dist/hoi4_agent_tools/reference/script-validation.js +315 -33
  31. package/dist/hoi4_agent_tools/reference/source-locate.js +1 -1
  32. package/dist/hoi4_agent_tools/schemas/gui-requests.js +18 -4
  33. package/dist/hoi4_agent_tools/schemas/script-validation.js +40 -7
  34. package/dist/hoi4_agent_tools/schemas/technology.js +4 -2
  35. package/dist/hoi4_agent_tools/technology/service.js +7 -0
  36. package/dist/hoi4_agent_tools/version.js +1 -1
  37. package/docs/gui.md +25 -0
  38. package/docs/jobs.md +8 -0
  39. package/docs/reference.md +38 -0
  40. package/docs/setup.md +11 -0
  41. package/docs/tools.md +1 -1
  42. package/npm-shrinkwrap.json +12 -8
  43. package/package.json +1 -1
  44. package/schemas/configuration.schema.json +163 -128
  45. package/schemas/continuous-focus-palette.schema.json +1 -1
  46. package/schemas/custom-weighted-pool.schema.json +1 -1
  47. package/schemas/decision-inspect-input.schema.json +1 -1
  48. package/schemas/event-feature-manifest.schema.json +1 -1
  49. package/schemas/focus-plan.schema.json +1 -1
  50. package/schemas/focus-planning-sidecar.schema.json +1 -1
  51. package/schemas/gui-animation-source.schema.json +1 -1
  52. package/schemas/gui-helper.schema.json +1 -1
  53. package/schemas/gui-scenario.schema.json +38 -22
  54. package/schemas/helper-expansion-request.schema.json +1 -1
  55. package/schemas/helper-expansion-summary.schema.json +1 -1
  56. package/schemas/impact-inspect-input.schema.json +1 -1
  57. package/schemas/map-operation.schema.json +1 -1
  58. package/schemas/mechanic-test-input.schema.json +1 -1
  59. package/schemas/operation-result.schema.json +1 -1
  60. package/schemas/package-check-input.schema.json +1 -1
  61. package/schemas/package-manifest.schema.json +1 -1
  62. package/schemas/probability-analysis-result.schema.json +1 -1
  63. package/schemas/probability-compare-input.schema.json +1 -1
  64. package/schemas/probability-evaluate-input.schema.json +1 -1
  65. package/schemas/probability-inspect-input.schema.json +1 -1
  66. package/schemas/probability-render-input.schema.json +1 -1
  67. package/schemas/probability-scenario-set.schema.json +1 -1
  68. package/schemas/probability-sequence-input.schema.json +1 -1
  69. package/schemas/probability-simulate-input.schema.json +1 -1
  70. package/schemas/probability-sweep-input.schema.json +1 -1
  71. package/schemas/reference-context-input.schema.json +1 -1
  72. package/schemas/reference-read-input.schema.json +1 -1
  73. package/schemas/reference-search-input.schema.json +1 -1
  74. package/schemas/scenario-suite.schema.json +1 -1
  75. package/schemas/scenario-test-input.schema.json +1 -1
  76. package/schemas/script-validate-input.schema.json +60 -36
  77. package/schemas/shared-scenario.schema.json +1 -1
  78. package/schemas/source-lookup-input.schema.json +1 -1
  79. package/server.json +2 -2
@@ -2,9 +2,158 @@ import path from 'node:path';
2
2
  import { hashCanonical } from '../core/canonical.js';
3
3
  import { ServiceError } from '../core/result.js';
4
4
  import { locationFor, parseClausewitz, } from '../core/source/index.js';
5
+ import { repeatedPopupFindings } from './event-popups.js';
6
+ import { playerTextFindings, playerTextPosition } from './player-text.js';
5
7
  import { editDistance } from './suggestions.js';
6
8
  import { publicReferenceSection } from './service.js';
7
9
  const MAX_COMMANDS = 256;
10
+ const MAX_FILE_COMMANDS = 50_000;
11
+ /** A block whose scope the file's structure does not determine; scope checks are skipped. */
12
+ const UNKNOWN_SCOPE = 'unknown';
13
+ /**
14
+ * Scope links from the wiki Scopes page: the scopes each link is usable in and the scope it
15
+ * enters. The engine accepts these keys in any letter case.
16
+ */
17
+ const scopeLinks = {
18
+ owner: { from: ['state', 'character', 'combatant'], to: 'country' },
19
+ controller: { from: ['state'], to: 'country' },
20
+ capital_scope: { from: ['country'], to: 'state' },
21
+ overlord: { from: ['country'], to: 'country' },
22
+ faction_leader: { from: ['country'], to: 'country' },
23
+ };
24
+ const eventScopes = {
25
+ country_event: 'country',
26
+ news_event: 'country',
27
+ state_event: 'state',
28
+ unit_leader_event: 'character',
29
+ operative_leader_event: 'character',
30
+ };
31
+ const optionMetadata = new Set([
32
+ 'name',
33
+ 'ai_chance',
34
+ 'original_recipient_only',
35
+ 'highlight_states',
36
+ 'is_triggered_only',
37
+ ]);
38
+ const decisionTriggers = new Set([
39
+ 'allowed',
40
+ 'visible',
41
+ 'available',
42
+ 'cancel_trigger',
43
+ 'remove_trigger',
44
+ 'activation',
45
+ ]);
46
+ const decisionEffects = new Set([
47
+ 'complete_effect',
48
+ 'remove_effect',
49
+ 'timeout_effect',
50
+ 'cancel_effect',
51
+ ]);
52
+ const focusTriggers = new Set(['available', 'bypass', 'cancel', 'allow_branch']);
53
+ const focusEffects = new Set([
54
+ 'completion_reward',
55
+ 'select_effect',
56
+ 'cancel_effect',
57
+ 'bypass_effect',
58
+ ]);
59
+ function blockOf(entry) {
60
+ return entry.type === 'assignment' && entry.value.type === 'block' ? entry.value : undefined;
61
+ }
62
+ /**
63
+ * The effect and trigger bodies of a mod file, with the scope its structure fixes. Families
64
+ * whose scope depends on the caller, such as scripted helpers and on actions, are checked
65
+ * without scope rules. An unrecognized family yields no roots.
66
+ */
67
+ export function scriptRoots(relativePath, document) {
68
+ const file = relativePath.replaceAll('\\', '/').toLowerCase();
69
+ const roots = [];
70
+ const add = (block, kind, scope) => {
71
+ if (block !== undefined)
72
+ roots.push({ block, kind, scope });
73
+ };
74
+ const top = document.root.entries.filter((entry) => entry.type === 'assignment');
75
+ if (file.startsWith('events/')) {
76
+ for (const event of top) {
77
+ const scope = eventScopes[event.key.value];
78
+ const body = blockOf(event);
79
+ if (scope === undefined || body === undefined)
80
+ continue;
81
+ for (const entry of body.entries) {
82
+ if (entry.type !== 'assignment')
83
+ continue;
84
+ const key = entry.key.value;
85
+ if (key === 'trigger' || key === 'show_major')
86
+ add(blockOf(entry), 'trigger', scope);
87
+ else if (key === 'immediate' || key === 'after')
88
+ add(blockOf(entry), 'effect', scope);
89
+ else if (key === 'option') {
90
+ const option = blockOf(entry);
91
+ if (option === undefined)
92
+ continue;
93
+ const effects = option.entries.filter((child) => child.type !== 'assignment' ||
94
+ (child.key.value !== 'trigger' && !optionMetadata.has(child.key.value)));
95
+ for (const child of option.entries)
96
+ if (child.type === 'assignment' && child.key.value === 'trigger')
97
+ add(blockOf(child), 'trigger', scope);
98
+ if (effects.length > 0)
99
+ add({ ...option, entries: effects }, 'effect', scope);
100
+ }
101
+ }
102
+ }
103
+ return { family: 'events', roots };
104
+ }
105
+ if (file.startsWith('common/decisions/') && !file.startsWith('common/decisions/categories/')) {
106
+ for (const category of top)
107
+ for (const decision of blockOf(category)?.entries ?? []) {
108
+ const body = blockOf(decision);
109
+ if (body === undefined)
110
+ continue;
111
+ for (const entry of body.entries) {
112
+ if (entry.type !== 'assignment')
113
+ continue;
114
+ if (decisionTriggers.has(entry.key.value))
115
+ add(blockOf(entry), 'trigger', 'country');
116
+ else if (entry.key.value === 'target_trigger')
117
+ add(blockOf(entry), 'trigger', UNKNOWN_SCOPE);
118
+ else if (decisionEffects.has(entry.key.value))
119
+ add(blockOf(entry), 'effect', 'country');
120
+ }
121
+ }
122
+ return { family: 'decisions', roots };
123
+ }
124
+ if (file.startsWith('common/national_focus/')) {
125
+ const focuses = top.flatMap((entry) => entry.key.value === 'shared_focus'
126
+ ? [entry]
127
+ : entry.key.value === 'focus_tree'
128
+ ? (blockOf(entry)?.entries ?? []).filter((child) => child.type === 'assignment' && child.key.value === 'focus')
129
+ : []);
130
+ for (const focus of focuses)
131
+ for (const entry of blockOf(focus)?.entries ?? []) {
132
+ if (entry.type !== 'assignment')
133
+ continue;
134
+ if (focusTriggers.has(entry.key.value))
135
+ add(blockOf(entry), 'trigger', 'country');
136
+ else if (focusEffects.has(entry.key.value))
137
+ add(blockOf(entry), 'effect', 'country');
138
+ }
139
+ return { family: 'national_focus', roots };
140
+ }
141
+ if (file.startsWith('common/scripted_effects/') || file.startsWith('common/scripted_triggers/')) {
142
+ const kind = file.startsWith('common/scripted_effects/') ? 'effect' : 'trigger';
143
+ for (const helper of top)
144
+ add(blockOf(helper), kind, UNKNOWN_SCOPE);
145
+ return { family: kind === 'effect' ? 'scripted_effects' : 'scripted_triggers', roots };
146
+ }
147
+ if (file.startsWith('common/on_actions/')) {
148
+ for (const container of top)
149
+ for (const action of blockOf(container)?.entries ?? [])
150
+ for (const entry of blockOf(action)?.entries ?? [])
151
+ if (entry.type === 'assignment' && entry.key.value === 'effect')
152
+ add(blockOf(entry), 'effect', UNKNOWN_SCOPE);
153
+ return { family: 'on_actions', roots };
154
+ }
155
+ return { family: 'unsupported', roots };
156
+ }
8
157
  const booleanCommands = new Set(['AND', 'OR', 'NOT', 'XOR', 'NAND', 'NOR']);
9
158
  const containers = new Set([
10
159
  'and',
@@ -24,14 +173,21 @@ const iteratorParameters = new Set([
24
173
  'count',
25
174
  ]);
26
175
  /** Check only rules explicitly available from one selected documentation authority. */
27
- export async function validateScript(references, workspace, input, signal) {
28
- if (Buffer.byteLength(input.source, 'utf8') > 64_000)
176
+ export async function validateScript(references, workspace, input, signal, file) {
177
+ if (file === undefined && Buffer.byteLength(input.source ?? '', 'utf8') > 64_000)
29
178
  throw new ServiceError('SCRIPT_SOURCE_TOO_LARGE', 'Snippet exceeds 64000 UTF-8 bytes');
30
179
  const inventory = await references.inventory(workspace, signal, [input.documentation]);
31
180
  const catalogs = { effect: new Map(), trigger: new Map() };
32
181
  const sources = new Map();
182
+ const dynamicVariables = new Set();
183
+ // Generic triggers such as `building_count_trigger` are written with a listed name as the
184
+ // key (`arms_factory > 2`); each listed name checks as that documented trigger.
185
+ const implicitNames = new Map();
33
186
  for (const section of inventory.sections) {
34
187
  const filename = path.basename(section.path).toLowerCase();
188
+ if (filename === 'dynamic_variables_documentation.md' &&
189
+ /^[a-z_][a-z0-9_]{0,255}$/u.test(section.heading))
190
+ dynamicVariables.add(section.heading);
35
191
  const match = /^(effects?|triggers?)_(?:documentation|docs)\.(?:md|log)$/u.exec(filename);
36
192
  if (match === null)
37
193
  continue;
@@ -48,6 +204,12 @@ export async function validateScript(references, workspace, input, signal) {
48
204
  .filter(Boolean)),
49
205
  ].sort();
50
206
  const kind = match[1].startsWith('effect') ? 'effect' : 'trigger';
207
+ const body = section.lines.join('\n');
208
+ const listed = /^Supported (?!Scopes|Targets)[A-Za-z ]+:\s*([a-z0-9_,\s]+?)\.?\s*$/mu.exec(body);
209
+ if (kind === 'trigger' && listed !== null && /^Usage:\s*<[A-Za-z]+>/mu.test(body))
210
+ for (const name of listed[1].split(/[,\s]+/u).filter(Boolean))
211
+ if (!implicitNames.has(name))
212
+ implicitNames.set(name, section.heading);
51
213
  const previous = catalogs[kind].get(section.heading);
52
214
  const reference = publicReferenceSection(section);
53
215
  if (previous !== undefined) {
@@ -56,7 +218,15 @@ export async function validateScript(references, workspace, input, signal) {
56
218
  else
57
219
  catalogs[kind].set(section.heading, { reference, scopes, conflict: false });
58
220
  }
59
- const document = parseClausewitz(Buffer.from(input.source, 'utf8'), 'snippet');
221
+ for (const [name, heading] of [...implicitNames, ...(file?.implicitTriggers ?? [])]) {
222
+ const signature = catalogs.trigger.get(heading);
223
+ if (signature !== undefined && !catalogs.trigger.has(name))
224
+ catalogs.trigger.set(name, signature);
225
+ }
226
+ const documentPath = file?.path ?? 'snippet';
227
+ const document = parseClausewitz(file?.bytes ?? Buffer.from(input.source ?? '', 'utf8'), documentPath);
228
+ const commandLimit = file === undefined ? MAX_COMMANDS : MAX_FILE_COMMANDS;
229
+ let rootScope = input.scope ?? UNKNOWN_SCOPE;
60
230
  const findings = [];
61
231
  let visited = 0;
62
232
  let checkedCommands = 0;
@@ -71,7 +241,11 @@ export async function validateScript(references, workspace, input, signal) {
71
241
  bindings.set(binding.name, binding.scope);
72
242
  }
73
243
  const report = (entry, kind, scope, details) => {
74
- const position = locationFor('snippet', document.lineIndex, entry.start, entry.end).start;
244
+ const position = locationFor(documentPath, document.lineIndex, entry.start, entry.end).start;
245
+ // A file check lists only problems; matching commands are counted, not itemized.
246
+ if (file !== undefined &&
247
+ (details.status === 'supported' || details.code === 'SCRIPT_ARGUMENT_BLOCK_UNCHECKED'))
248
+ return;
75
249
  findings.push({
76
250
  ...details,
77
251
  command: entry.key.value.slice(0, 256),
@@ -86,8 +260,8 @@ export async function validateScript(references, workspace, input, signal) {
86
260
  code: diagnostic.code,
87
261
  status: 'error',
88
262
  command: '',
89
- kind: input.kind,
90
- scope: input.scope,
263
+ kind: input.kind ?? 'effect',
264
+ scope: input.scope ?? UNKNOWN_SCOPE,
91
265
  line: diagnostic.location?.start.line ?? 1,
92
266
  column: diagnostic.location?.start.column ?? 1,
93
267
  message: diagnostic.message.slice(0, 500),
@@ -96,11 +270,11 @@ export async function validateScript(references, workspace, input, signal) {
96
270
  const walk = (block, kind, scope, previous) => {
97
271
  for (const entry of block.entries) {
98
272
  signal?.throwIfAborted();
99
- if (++visited > MAX_COMMANDS) {
273
+ if (++visited > commandLimit) {
100
274
  return;
101
275
  }
102
276
  if (entry.type !== 'assignment') {
103
- const position = locationFor('snippet', document.lineIndex, entry.start, entry.end).start;
277
+ const position = locationFor(documentPath, document.lineIndex, entry.start, entry.end).start;
104
278
  findings.push({
105
279
  code: 'SCRIPT_EXPECTED_COMMAND',
106
280
  status: 'error',
@@ -119,7 +293,7 @@ export async function validateScript(references, workspace, input, signal) {
119
293
  continue;
120
294
  const name = booleanCommands.has(raw) ? raw.toLowerCase() : raw;
121
295
  const explicitScope = raw === 'ROOT'
122
- ? input.scope
296
+ ? rootScope
123
297
  : raw === 'THIS'
124
298
  ? scope
125
299
  : raw === 'PREV'
@@ -136,8 +310,57 @@ export async function validateScript(references, workspace, input, signal) {
136
310
  walk(entry.value, kind, explicitScope ?? fixedScope, scope);
137
311
  continue;
138
312
  }
313
+ const link = entry.value.type === 'block' ? scopeLinks[raw.toLowerCase()] : undefined;
314
+ if (link !== undefined && entry.value.type === 'block') {
315
+ checkedCommands++;
316
+ if (scope !== UNKNOWN_SCOPE && !link.from.includes(scope))
317
+ report(entry, kind, scope, {
318
+ code: 'SCRIPT_SCOPE_LINK_WRONG_SCOPE',
319
+ status: 'error',
320
+ message: `${raw} is usable only within ${link.from.join(', ')} scope`,
321
+ suggestions: [],
322
+ });
323
+ walk(entry.value, kind, link.to, scope);
324
+ continue;
325
+ }
326
+ // A file has no caller to declare FROM, chained links or targets; their blocks are
327
+ // still checked for command kinds, without scope rules.
328
+ if (file !== undefined &&
329
+ entry.value.type === 'block' &&
330
+ (/^(?:root|this|prev|from)(?:\.(?:root|this|prev|from))*$/iu.test(raw) ||
331
+ /^(?:event_target|var|mio|sp):/u.test(raw))) {
332
+ const keyword = raw.toUpperCase();
333
+ walk(entry.value, kind, keyword === 'ROOT'
334
+ ? rootScope
335
+ : keyword === 'THIS'
336
+ ? scope
337
+ : keyword === 'PREV'
338
+ ? (previous ?? UNKNOWN_SCOPE)
339
+ : UNKNOWN_SCOPE, scope);
340
+ continue;
341
+ }
139
342
  const signature = catalogs[kind].get(name);
140
- if (signature === undefined) {
343
+ if (signature === undefined && file?.helpers[kind].has(name) === true) {
344
+ // A scripted helper of the mod; its own file is checked separately.
345
+ checkedCommands++;
346
+ continue;
347
+ }
348
+ // Only a scalar comparison (`num_owned_states > 1`) misuses a dynamic variable; a block
349
+ // with that key is a scope or helper the catalog does not describe.
350
+ if (signature === undefined &&
351
+ kind === 'trigger' &&
352
+ entry.value.type !== 'block' &&
353
+ dynamicVariables.has(name)) {
354
+ report(entry, kind, scope, {
355
+ code: 'SCRIPT_DYNAMIC_VARIABLE_AS_TRIGGER',
356
+ status: 'error',
357
+ message: `${name} is a documented dynamic variable, not a trigger; compare it with check_variable`,
358
+ suggestions: ['check_variable'],
359
+ });
360
+ continue;
361
+ }
362
+ // else_if and else are control flow even where the documentation lists only `if`.
363
+ if (signature === undefined && !conditionals.has(name) && !containers.has(name)) {
141
364
  const other = catalogs[kind === 'effect' ? 'trigger' : 'effect'].get(name);
142
365
  const suggestions = name.length > 128
143
366
  ? []
@@ -161,22 +384,26 @@ export async function validateScript(references, workspace, input, signal) {
161
384
  continue;
162
385
  }
163
386
  checkedCommands++;
164
- const wrongScope = !signature.scopes.includes('any') && !signature.scopes.includes(scope);
165
- report(entry, kind, scope, {
166
- code: signature.conflict
167
- ? 'SCRIPT_DOCUMENTATION_CONFLICT'
168
- : wrongScope
169
- ? 'SCRIPT_COMMAND_WRONG_SCOPE'
170
- : 'SCRIPT_COMMAND_SUPPORTED',
171
- status: signature.conflict ? 'unresolved' : wrongScope ? 'error' : 'supported',
172
- suggestions: [],
173
- reference: signature.reference,
174
- message: signature.conflict
175
- ? 'Conflicting supported scopes in the selected documentation'
176
- : wrongScope
177
- ? `Supported scopes: ${signature.scopes.join(', ')}`
178
- : 'Command kind and declared scope match the selected documentation',
179
- });
387
+ if (signature !== undefined) {
388
+ const wrongScope = scope !== UNKNOWN_SCOPE &&
389
+ !signature.scopes.includes('any') &&
390
+ !signature.scopes.includes(scope);
391
+ report(entry, kind, scope, {
392
+ code: signature.conflict
393
+ ? 'SCRIPT_DOCUMENTATION_CONFLICT'
394
+ : wrongScope
395
+ ? 'SCRIPT_COMMAND_WRONG_SCOPE'
396
+ : 'SCRIPT_COMMAND_SUPPORTED',
397
+ status: signature.conflict ? 'unresolved' : wrongScope ? 'error' : 'supported',
398
+ suggestions: [],
399
+ reference: signature.reference,
400
+ message: signature.conflict
401
+ ? 'Conflicting supported scopes in the selected documentation'
402
+ : wrongScope
403
+ ? `Supported scopes: ${signature.scopes.join(', ')}`
404
+ : 'Command kind and declared scope match the selected documentation',
405
+ });
406
+ }
180
407
  const iterator = /^(?:every|random|any|all)_(?:(?:owned|controlled|neighbor)_)?(state|country)$/u.exec(name);
181
408
  if (entry.value.type !== 'block') {
182
409
  if (containers.has(name) || conditionals.has(name) || iterator !== null)
@@ -185,7 +412,7 @@ export async function validateScript(references, workspace, input, signal) {
185
412
  status: 'error',
186
413
  message: 'This control command requires a block',
187
414
  suggestions: [],
188
- reference: signature.reference,
415
+ ...(signature === undefined ? {} : { reference: signature.reference }),
189
416
  });
190
417
  continue;
191
418
  }
@@ -223,25 +450,70 @@ export async function validateScript(references, workspace, input, signal) {
223
450
  status: 'unresolved',
224
451
  message: 'Native argument block is not validated from prose documentation; read the cited syntax before use',
225
452
  suggestions: [],
226
- reference: signature.reference,
453
+ ...(signature === undefined ? {} : { reference: signature.reference }),
227
454
  });
228
455
  }
229
456
  }
230
457
  };
458
+ const plan = file === undefined
459
+ ? {
460
+ family: 'snippet',
461
+ roots: [{ block: document.root, kind: input.kind, scope: input.scope }],
462
+ }
463
+ : scriptRoots(file.relativePath, document);
231
464
  if (document.diagnostics.every(({ severity }) => severity !== 'error' && severity !== 'blocker'))
232
- walk(document.root, input.kind, input.scope);
465
+ for (const root of plan.roots) {
466
+ rootScope = root.scope;
467
+ walk(root.block, root.kind, root.scope);
468
+ }
469
+ const playerText = plan.family === 'snippet' ? undefined : file?.playerText;
470
+ const playerTextChecked = playerText !== undefined;
471
+ const eventPopups = plan.family === 'snippet' ? undefined : file?.eventPopups;
472
+ const warnings = [
473
+ ...(playerText === undefined ? [] : playerTextFindings(plan.family, document, playerText)),
474
+ ...(eventPopups === undefined ? [] : repeatedPopupFindings(document, eventPopups)),
475
+ ];
476
+ for (const finding of warnings) {
477
+ const position = playerTextPosition(documentPath, document.lineIndex, finding);
478
+ findings.push({
479
+ code: finding.code,
480
+ status: 'warning',
481
+ command: finding.command.slice(0, 256),
482
+ kind: finding.code === 'SCRIPT_FLAG_TOOLTIP_UNLOCALISED'
483
+ ? 'trigger'
484
+ : finding.code === 'SCRIPT_EVENT_POPUP_REPEATED'
485
+ ? 'effect'
486
+ : 'text',
487
+ scope: UNKNOWN_SCOPE,
488
+ line: position.line,
489
+ column: position.column,
490
+ message: finding.message,
491
+ suggestions: [],
492
+ });
493
+ }
233
494
  const unresolvedCount = findings.filter(({ status }) => status === 'unresolved').length;
234
495
  const invalid = findings.some(({ status }) => status === 'error');
235
- const truncated = visited > MAX_COMMANDS;
236
- const incomplete = unresolvedCount > 0 || truncated || inventory.skipped > 0 || catalogs[input.kind].size === 0;
237
- const order = { error: 0, unresolved: 1, supported: 2 };
496
+ const truncated = visited > commandLimit;
497
+ const unknownScopeRoots = plan.roots.filter(({ scope }) => scope === UNKNOWN_SCOPE).length;
498
+ const incomplete = unresolvedCount > 0 ||
499
+ truncated ||
500
+ inventory.skipped > 0 ||
501
+ (file === undefined ? catalogs[input.kind].size === 0 : plan.roots.length === 0) ||
502
+ unknownScopeRoots > 0;
503
+ const order = { error: 0, unresolved: 1, warning: 2, supported: 3 };
238
504
  findings.sort((a, b) => order[a.status] - order[b.status] ||
239
505
  a.line - b.line ||
240
506
  a.column - b.column ||
241
507
  a.code.localeCompare(b.code, 'en'));
242
508
  const result = {
243
509
  valid: invalid ? false : incomplete ? null : true,
244
- checksPerformed: ['syntax', 'command_kind', 'declared_scope'],
510
+ checksPerformed: [
511
+ 'syntax',
512
+ 'command_kind',
513
+ 'declared_scope',
514
+ ...(playerTextChecked ? ['player_text'] : []),
515
+ ...(eventPopups === undefined ? [] : ['event_popups']),
516
+ ],
245
517
  parametersChecked: false,
246
518
  documentation: {
247
519
  source: input.documentation,
@@ -257,6 +529,16 @@ export async function validateScript(references, workspace, input, signal) {
257
529
  unresolvedCount,
258
530
  truncated,
259
531
  argumentBlocksUnchecked,
532
+ ...(file === undefined
533
+ ? {}
534
+ : {
535
+ file: {
536
+ path: file.path,
537
+ family: plan.family,
538
+ roots: plan.roots.length,
539
+ unknownScopeRoots,
540
+ },
541
+ }),
260
542
  };
261
543
  while (Buffer.byteLength(JSON.stringify(result), 'utf8') > 16_000 && result.findings.length > 1)
262
544
  result.findings.pop();
@@ -19,7 +19,7 @@ function samePath(left, right) {
19
19
  return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
20
20
  }
21
21
  /** Resolve a display, relative, or absolute path to one scanned file, preferring the active layer. */
22
- function selectFile(snapshot, requested) {
22
+ export function selectFile(snapshot, requested) {
23
23
  const normalized = requested.replaceAll('\\', '/').replace(/^\.\//u, '');
24
24
  const absolute = path.isAbsolute(requested) ? path.resolve(requested) : undefined;
25
25
  const candidates = [...snapshot.index.files.values()].filter((file) => file.displayPath === normalized ||
@@ -8,7 +8,21 @@ import { backgroundRequestKeySchema, workspaceIdSchema, workspaceRelativePathSch
8
8
  function compact(schema, description) {
9
9
  return z.unknown().describe(description).pipe(schema);
10
10
  }
11
- const compactGuiScenarioSchema = compact(GuiPreviewScenarioSchema, 'GUI preview scenario; optional date and controls {stateId: controllerTag}.');
11
+ /**
12
+ * Published scenario shape: the required `id` and the fields that decide visibility, with the
13
+ * rest named in the description. Every field is validated by the full scenario schema.
14
+ */
15
+ const compactGuiScenarioSchema = z
16
+ .looseObject({
17
+ id: z.string(),
18
+ date: z.string().optional(),
19
+ closedFlags: z.boolean().optional(),
20
+ })
21
+ .describe('Also flags {name: bool}, variables {name: number}, controls {stateId: tag}, values, visibility (docs/gui.md). closedFlags: undeclared flags are unset.')
22
+ .transform((value) => value)
23
+ .pipe(GuiPreviewScenarioSchema);
24
+ /** Related and comparison scenarios repeat the primary scenario's published shape. */
25
+ const compactRelatedScenarioSchema = compact(GuiPreviewScenarioSchema, 'Same shape as scenario.');
12
26
  const compactGeneratedScenarioOptionsSchema = compact(GuiGeneratedScenarioOptionsSchema, 'GUI generated-scenario options.');
13
27
  const compactGuiHelperSchema = compact(GuiHelperDocumentSchema, 'GUI helper document.');
14
28
  export const guiPreviewStateSchema = z.enum([
@@ -32,7 +46,7 @@ export const guiInspectRequestSchema = z
32
46
  workspaceId: workspaceIdSchema,
33
47
  windowName: z.string().min(1).max(256).optional(),
34
48
  scenario: compactGuiScenarioSchema.optional(),
35
- relatedScenarios: z.array(compactGuiScenarioSchema).max(32).optional(),
49
+ relatedScenarios: z.array(compactRelatedScenarioSchema).max(32).optional(),
36
50
  generatedScenarios: compactGeneratedScenarioOptionsSchema.optional(),
37
51
  })
38
52
  .strict()
@@ -79,9 +93,9 @@ export const guiRenderRequestSchema = z
79
93
  scenario: compactGuiScenarioSchema,
80
94
  states: z.array(guiPreviewStateSchema).max(14).optional(),
81
95
  resolutions: z.array(guiResolutionSchema).min(1).max(16).optional(),
82
- relatedScenarios: z.array(compactGuiScenarioSchema).max(32).optional(),
96
+ relatedScenarios: z.array(compactRelatedScenarioSchema).max(32).optional(),
83
97
  generatedScenarios: compactGeneratedScenarioOptionsSchema.optional(),
84
- comparisonScenario: compactGuiScenarioSchema.optional(),
98
+ comparisonScenario: compactRelatedScenarioSchema.optional(),
85
99
  sourceBaseline: z
86
100
  .object({
87
101
  relativePath: workspaceRelativePathSchema,
@@ -18,9 +18,12 @@ const scope = z.enum([
18
18
  export const scriptValidateRequestSchema = z
19
19
  .object({
20
20
  workspaceId: workspaceIdSchema,
21
- source: z.string().min(1).max(64_000),
22
- kind: z.enum(['effect', 'trigger']),
23
- scope,
21
+ /** Snippet mode: an effect or trigger body checked under `kind` and `scope`. */
22
+ source: z.string().min(1).max(64_000).optional(),
23
+ /** File mode: a scanned mod file whose blocks are checked under the scope its structure fixes. */
24
+ path: z.string().trim().min(1).max(4096).optional(),
25
+ kind: z.enum(['effect', 'trigger']).optional(),
26
+ scope: scope.optional(),
24
27
  documentation: z.enum(['game_doc', 'script_doc']).default('game_doc'),
25
28
  bindings: z
26
29
  .array(z.object({ name: z.string().min(1).max(256), scope }).strict())
@@ -28,11 +31,32 @@ export const scriptValidateRequestSchema = z
28
31
  .default([]),
29
32
  limit: z.number().int().min(1).max(32).default(12),
30
33
  })
31
- .strict();
34
+ .strict()
35
+ .superRefine((value, context) => {
36
+ if ((value.source === undefined) === (value.path === undefined))
37
+ context.addIssue({
38
+ code: 'custom',
39
+ path: ['source'],
40
+ message: 'Provide either source with kind and scope, or a mod file path',
41
+ });
42
+ if (value.source !== undefined && (value.kind === undefined || value.scope === undefined))
43
+ context.addIssue({
44
+ code: 'custom',
45
+ path: ['kind'],
46
+ message: 'A snippet check requires kind and scope',
47
+ });
48
+ if (value.path !== undefined &&
49
+ (value.kind !== undefined || value.scope !== undefined || value.bindings.length > 0))
50
+ context.addIssue({
51
+ code: 'custom',
52
+ path: ['path'],
53
+ message: 'A file check infers kinds and scopes from the file structure',
54
+ });
55
+ });
32
56
  export const scriptValidateDataSchema = z
33
57
  .object({
34
58
  valid: z.boolean().nullable(),
35
- checksPerformed: z.array(z.enum(['syntax', 'command_kind', 'declared_scope'])),
59
+ checksPerformed: z.array(z.enum(['syntax', 'command_kind', 'declared_scope', 'player_text', 'event_popups'])),
36
60
  parametersChecked: z.literal(false),
37
61
  documentation: z
38
62
  .object({
@@ -47,9 +71,9 @@ export const scriptValidateDataSchema = z
47
71
  .array(z
48
72
  .object({
49
73
  code: z.string().max(80),
50
- status: z.enum(['supported', 'error', 'unresolved']),
74
+ status: z.enum(['supported', 'error', 'unresolved', 'warning']),
51
75
  command: z.string().max(256),
52
- kind: z.enum(['effect', 'trigger']),
76
+ kind: z.enum(['effect', 'trigger', 'text']),
53
77
  scope: z.string().max(64),
54
78
  line: z.number().int().min(1),
55
79
  column: z.number().int().min(1),
@@ -65,5 +89,14 @@ export const scriptValidateDataSchema = z
65
89
  unresolvedCount: z.number().int().min(0),
66
90
  truncated: z.boolean(),
67
91
  argumentBlocksUnchecked: z.number().int().min(0),
92
+ file: z
93
+ .object({
94
+ path: z.string().max(4096),
95
+ family: z.string().max(64),
96
+ roots: z.number().int().min(0),
97
+ unknownScopeRoots: z.number().int().min(0),
98
+ })
99
+ .strict()
100
+ .optional(),
68
101
  })
69
102
  .strict();
@@ -143,14 +143,16 @@ export const technologyRenderRequestSchema = z
143
143
  refresh: z.boolean().optional(),
144
144
  })
145
145
  .strict()
146
- .superRefine((value, context) => {
146
+ .superRefine(validateTechnologyRenderRequest);
147
+ /** Shared by the request schema and the published tool input, so clients see the same rule. */
148
+ export function validateTechnologyRenderRequest(value, context) {
147
149
  if (value.view === 'folder' && value.folderId === undefined)
148
150
  context.addIssue({
149
151
  code: 'custom',
150
152
  path: ['folderId'],
151
153
  message: 'Folder view requires folderId',
152
154
  });
153
- });
155
+ }
154
156
  export function validateTechnologyCompareRequest(value, context) {
155
157
  if (value.after !== undefined && value.proposedSources !== undefined)
156
158
  context.addIssue({
@@ -880,6 +880,13 @@ export class TechnologyTreeViewer {
880
880
  input.signal?.throwIfAborted();
881
881
  if (input.proposedSources !== undefined)
882
882
  assertProposedBounds(input.proposedSources);
883
+ // Without references the comparison uses the previous cached revision; with none cached,
884
+ // say so before scanning the workspace.
885
+ if (input.before === undefined &&
886
+ input.after === undefined &&
887
+ input.proposedSources === undefined &&
888
+ (this.#state.history.get(input.workspaceId)?.size ?? 0) === 0)
889
+ throw new ServiceError('TECH_COMPARISON_BASELINE_REQUIRED', 'Provide a cached revision, graph artifact, or proposed source overlay');
883
890
  const current = await this.scan(input.workspaceId, {
884
891
  refresh: input.refresh ?? input.proposedSources !== undefined,
885
892
  ...(input.principal === undefined ? {} : { principal: input.principal }),
@@ -1,6 +1,6 @@
1
1
  import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
2
2
  export const PACKAGE_NAME = 'hoi4-agent-tools';
3
- export const PACKAGE_VERSION = '3.8.1';
3
+ export const PACKAGE_VERSION = '3.10.0';
4
4
  export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
5
5
  export const CONFIG_VERSION = 1;
6
6
  export const TRANSACTION_VERSION = 1;