hoi4-agent-tools 3.8.0 → 3.9.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 (68) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +1 -1
  3. package/dist/bin/setup.js +5 -2
  4. package/dist/bin/stdio.js +27 -1
  5. package/dist/hoi4_agent_tools/core/configuration.js +8 -0
  6. package/dist/hoi4_agent_tools/core/job-worker-host.js +40 -2
  7. package/dist/hoi4_agent_tools/core/job-worker.js +14 -0
  8. package/dist/hoi4_agent_tools/core/scenario-model.js +1 -1
  9. package/dist/hoi4_agent_tools/core/transactions.js +31 -2
  10. package/dist/hoi4_agent_tools/gui/renderer.js +8 -2
  11. package/dist/hoi4_agent_tools/gui/scenario-generator.js +48 -0
  12. package/dist/hoi4_agent_tools/gui/scenario.js +2 -0
  13. package/dist/hoi4_agent_tools/gui/studio.js +15 -9
  14. package/dist/hoi4_agent_tools/gui/validators.js +22 -0
  15. package/dist/hoi4_agent_tools/mcp/tools/reference.js +1 -1
  16. package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +9 -0
  17. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +83 -0
  18. package/dist/hoi4_agent_tools/reference/event-popups.js +87 -0
  19. package/dist/hoi4_agent_tools/reference/operations.js +151 -1
  20. package/dist/hoi4_agent_tools/reference/player-text.js +158 -0
  21. package/dist/hoi4_agent_tools/reference/script-validation.js +315 -33
  22. package/dist/hoi4_agent_tools/reference/source-locate.js +1 -1
  23. package/dist/hoi4_agent_tools/schemas/gui-requests.js +18 -4
  24. package/dist/hoi4_agent_tools/schemas/script-validation.js +40 -7
  25. package/dist/hoi4_agent_tools/version.js +1 -1
  26. package/docs/gui.md +25 -0
  27. package/docs/jobs.md +8 -0
  28. package/docs/reference.md +38 -0
  29. package/docs/setup.md +10 -0
  30. package/docs/tools.md +1 -1
  31. package/npm-shrinkwrap.json +2 -2
  32. package/package.json +1 -1
  33. package/schemas/configuration.schema.json +163 -128
  34. package/schemas/continuous-focus-palette.schema.json +1 -1
  35. package/schemas/custom-weighted-pool.schema.json +1 -1
  36. package/schemas/decision-inspect-input.schema.json +1 -1
  37. package/schemas/event-feature-manifest.schema.json +1 -1
  38. package/schemas/focus-plan.schema.json +1 -1
  39. package/schemas/focus-planning-sidecar.schema.json +1 -1
  40. package/schemas/gui-animation-source.schema.json +1 -1
  41. package/schemas/gui-helper.schema.json +1 -1
  42. package/schemas/gui-scenario.schema.json +38 -22
  43. package/schemas/helper-expansion-request.schema.json +1 -1
  44. package/schemas/helper-expansion-summary.schema.json +1 -1
  45. package/schemas/impact-inspect-input.schema.json +1 -1
  46. package/schemas/map-operation.schema.json +1 -1
  47. package/schemas/mechanic-test-input.schema.json +1 -1
  48. package/schemas/operation-result.schema.json +1 -1
  49. package/schemas/package-check-input.schema.json +1 -1
  50. package/schemas/package-manifest.schema.json +1 -1
  51. package/schemas/probability-analysis-result.schema.json +1 -1
  52. package/schemas/probability-compare-input.schema.json +1 -1
  53. package/schemas/probability-evaluate-input.schema.json +1 -1
  54. package/schemas/probability-inspect-input.schema.json +1 -1
  55. package/schemas/probability-render-input.schema.json +1 -1
  56. package/schemas/probability-scenario-set.schema.json +1 -1
  57. package/schemas/probability-sequence-input.schema.json +1 -1
  58. package/schemas/probability-simulate-input.schema.json +1 -1
  59. package/schemas/probability-sweep-input.schema.json +1 -1
  60. package/schemas/reference-context-input.schema.json +1 -1
  61. package/schemas/reference-read-input.schema.json +1 -1
  62. package/schemas/reference-search-input.schema.json +1 -1
  63. package/schemas/scenario-suite.schema.json +1 -1
  64. package/schemas/scenario-test-input.schema.json +1 -1
  65. package/schemas/script-validate-input.schema.json +60 -36
  66. package/schemas/shared-scenario.schema.json +1 -1
  67. package/schemas/source-lookup-input.schema.json +1 -1
  68. 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();
@@ -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.0';
3
+ export const PACKAGE_VERSION = '3.9.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;
package/docs/gui.md CHANGED
@@ -184,6 +184,31 @@ Source or scenario `enabled = false` selects disabled appearance and removes the
184
184
 
185
185
  The renderer does not run the game engine. Each render includes a fidelity report that separates fields it models from fields it approximates, ignores, cannot resolve, or does not support. Treat that report as part of the review.
186
186
 
187
+ ### Scenario fields
188
+
189
+ Every `scenario` needs an `id`, a short label such as `"usa_1936"`. The other fields describe the game state the window is drawn under:
190
+
191
+ | Field | Meaning |
192
+ | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
193
+ | `date` | Game date, `"1936.1.1"`. |
194
+ | `controls` | State controllers, `{"64": "GER"}`. |
195
+ | `flags` | Flags on the viewing country, `{"show_settings_page": true}`. |
196
+ | `closedFlags` | `true` treats every flag not listed in `flags` as unset, as on a country that never received it. Without it, an undeclared flag is unknown and every condition that reads it stays unresolved. |
197
+ | `variables` | Variables on the viewing country, `{"settings_page": 1}`. |
198
+ | `values` | Exact runtime values for localisation tokens and scripted GUI properties, keyed as they appear in source. |
199
+ | `visibility` | Forced visibility by element or scripted GUI name, `{"settings_page_events": false}`; it overrides evaluated triggers. |
200
+ | `scopes` | Named scope bindings such as `FROM` with their own state. |
201
+ | `resolution`, `uiScale`, `state`, `language` | Screen size, in-game UI scale, preview element state, and localisation language. |
202
+ | `background` | `"transparent"` renders without the preview backdrop, so pixels the window does not cover are fully transparent in every PNG. |
203
+
204
+ A scripted GUI `visible` or `<element>_visible` trigger that the scenario decides hides or shows its element exactly.
205
+ For compositing, each visible element is its own `<g>` group in the full SVG, with an `id` that matches the element in `layout.json`, which also records every element's box.
206
+ A trigger the scenario cannot decide leaves the element drawn and lists it in the fidelity report as unresolved, so pages that the game shows one at a time can overlap in the preview.
207
+ For tabbed windows, set the flags or variables of the page to show and `closedFlags: true`, then render each page as its own scenario through `relatedScenarios`.
208
+ `GUI_UNRESOLVED_VISIBILITY_OVERLAP` names each overlapping pair drawn together for that reason.
209
+ `GUI_NEAR_CENTRE_OFFSET` names an element whose horizontal centre is between half a pixel and 3 pixels from its parent's centre, which usually means it was meant to be centred.
210
+ Scripted localisation (`defined_text`) used by the window resolves to its first branch whose trigger the scenario decides as true, as long as every earlier branch is decided false; otherwise the token renders as `[dynamic_loc]`. A value in `values` always takes precedence.
211
+
187
212
  The generated primary view replaces discovered numeric and text runtime tokens with plausible values. Set `generatedScenarios.preservePlaceholder` to `true`, or disable generation, when the same run should also show unresolved numeric values such as `[?variable|format]` as `[X]` and unresolved text-returning scripted or scoped localisation such as `[GetStatusText]`, `[FROM.GetName]`, and `[?leader_scope.GetName]` as `[dynamic_loc]`. Put `variable` or `GetStatusText` in the supplied scenario's `values` object when an exact value matters; explicit values always take precedence over generated ones and can drive the related meter or state-dependent controls. Supported HOI4 `§` localisation colour controls use the active font's palette and `§!` restores the face colour.
188
213
 
189
214
  A rewrite stops if malformed or unsupported GUI script makes the requested change ambiguous. Frame-sheet animation, nine-slice tiling, progress composition, masking, and the primary and secondary textures referenced by vanilla GFX definitions render offline. Font selection follows mod/game load order and preview language, accepts Paradox hex and numeric color declarations, and uses the supplied HOI4 bitmap or outline font assets without requiring host font installation. Shader operations outside the supported colour subset, hardcoded controls, and dynamic values that are not supplied by a scenario remain fidelity-report entries; an unexecuted shader never hides its resolved base texture.
package/docs/jobs.md CHANGED
@@ -51,6 +51,14 @@ A running cancellation is observed by the isolated worker; if a rewrite has alre
51
51
  Cancelling an ordinary synchronous call prevents admission when possible and otherwise requests cooperative cancellation of its durable job.
52
52
  Closing the originating connection does not cancel an accepted native task.
53
53
 
54
+ A worker busy in long synchronous analysis cannot observe its own cancellation, so the server that started it also supervises read-only jobs.
55
+ A read-only job still running `jobCancelGraceSeconds` (default 10) after cancellation is stopped and settles as cancelled.
56
+ A read-only job still running `jobDeadlineSeconds` (default 1,800) after it was dispatched is stopped and fails with `JOB_DEADLINE_EXCEEDED`; narrow its selector or limits, or raise the setting in the server configuration.
57
+ Rewrite jobs are never stopped this way, because their transaction journal owns write recovery; they keep cooperative cancellation and reconciliation.
58
+ Supervision belongs to the server process that dispatched the worker. A read-only worker whose dispatching server has exited enforces the same deadline itself and then exits; a rewrite worker keeps running until it finishes.
59
+ `jobWorkerMaxHeapMiB` sets a V8 heap ceiling for each worker; without it, workers use Node's default.
60
+ At most `maxSharedTools` (default 4) workers and expensive calls run at once across every server process that shares a `serverStateRoot`; further work waits for capacity.
61
+
54
62
  Read-only jobs stage a source-revision-bound result checkpoint before terminal publication.
55
63
  If a worker or server stops after that checkpoint, a later authorized process publishes the retained result without rerunning the operation.
56
64
  Event and technology helper expansion also saves an authenticated intermediate frontier with its exact source revision and traversal contract.