hoi4-agent-tools 3.8.1 → 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 (67) hide show
  1. package/CHANGELOG.md +15 -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/gui/renderer.js +8 -2
  10. package/dist/hoi4_agent_tools/gui/scenario-generator.js +48 -0
  11. package/dist/hoi4_agent_tools/gui/scenario.js +2 -0
  12. package/dist/hoi4_agent_tools/gui/studio.js +15 -9
  13. package/dist/hoi4_agent_tools/gui/validators.js +22 -0
  14. package/dist/hoi4_agent_tools/mcp/tools/reference.js +1 -1
  15. package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +9 -0
  16. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +83 -0
  17. package/dist/hoi4_agent_tools/reference/event-popups.js +87 -0
  18. package/dist/hoi4_agent_tools/reference/operations.js +151 -1
  19. package/dist/hoi4_agent_tools/reference/player-text.js +158 -0
  20. package/dist/hoi4_agent_tools/reference/script-validation.js +315 -33
  21. package/dist/hoi4_agent_tools/reference/source-locate.js +1 -1
  22. package/dist/hoi4_agent_tools/schemas/gui-requests.js +18 -4
  23. package/dist/hoi4_agent_tools/schemas/script-validation.js +40 -7
  24. package/dist/hoi4_agent_tools/version.js +1 -1
  25. package/docs/gui.md +25 -0
  26. package/docs/jobs.md +8 -0
  27. package/docs/reference.md +38 -0
  28. package/docs/setup.md +10 -0
  29. package/docs/tools.md +1 -1
  30. package/npm-shrinkwrap.json +2 -2
  31. package/package.json +1 -1
  32. package/schemas/configuration.schema.json +163 -128
  33. package/schemas/continuous-focus-palette.schema.json +1 -1
  34. package/schemas/custom-weighted-pool.schema.json +1 -1
  35. package/schemas/decision-inspect-input.schema.json +1 -1
  36. package/schemas/event-feature-manifest.schema.json +1 -1
  37. package/schemas/focus-plan.schema.json +1 -1
  38. package/schemas/focus-planning-sidecar.schema.json +1 -1
  39. package/schemas/gui-animation-source.schema.json +1 -1
  40. package/schemas/gui-helper.schema.json +1 -1
  41. package/schemas/gui-scenario.schema.json +38 -22
  42. package/schemas/helper-expansion-request.schema.json +1 -1
  43. package/schemas/helper-expansion-summary.schema.json +1 -1
  44. package/schemas/impact-inspect-input.schema.json +1 -1
  45. package/schemas/map-operation.schema.json +1 -1
  46. package/schemas/mechanic-test-input.schema.json +1 -1
  47. package/schemas/operation-result.schema.json +1 -1
  48. package/schemas/package-check-input.schema.json +1 -1
  49. package/schemas/package-manifest.schema.json +1 -1
  50. package/schemas/probability-analysis-result.schema.json +1 -1
  51. package/schemas/probability-compare-input.schema.json +1 -1
  52. package/schemas/probability-evaluate-input.schema.json +1 -1
  53. package/schemas/probability-inspect-input.schema.json +1 -1
  54. package/schemas/probability-render-input.schema.json +1 -1
  55. package/schemas/probability-scenario-set.schema.json +1 -1
  56. package/schemas/probability-sequence-input.schema.json +1 -1
  57. package/schemas/probability-simulate-input.schema.json +1 -1
  58. package/schemas/probability-sweep-input.schema.json +1 -1
  59. package/schemas/reference-context-input.schema.json +1 -1
  60. package/schemas/reference-read-input.schema.json +1 -1
  61. package/schemas/reference-search-input.schema.json +1 -1
  62. package/schemas/scenario-suite.schema.json +1 -1
  63. package/schemas/scenario-test-input.schema.json +1 -1
  64. package/schemas/script-validate-input.schema.json +60 -36
  65. package/schemas/shared-scenario.schema.json +1 -1
  66. package/schemas/source-lookup-input.schema.json +1 -1
  67. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 3.9.0 - 2026-10-06
4
+
5
+ - Exit a stdio server whose launching process has exited, and add `stdioIdleExitMinutes` so clients that keep idle per-subagent connections no longer accumulate servers.
6
+ - Stop a read-only job whose worker outlives `jobDeadlineSeconds` (`JOB_DEADLINE_EXCEEDED`) or ignores cancellation for `jobCancelGraceSeconds`, so a blocked worker can no longer hang a workflow.
7
+ - Stop an orphaned read-only job worker at `jobDeadlineSeconds` even after its dispatching server has exited, and add `jobWorkerMaxHeapMiB` to bound each worker's heap.
8
+ - Check a whole mod file with `hoi4.script_validate` `path`: every event, decision, focus, scripted helper, and on-action body with the scope its structure fixes, the mod's own scripted helpers as known commands, and building, ideology, and resource trigger keys.
9
+ - Report a dynamic variable compared as a trigger (`SCRIPT_DYNAMIC_VARIABLE_AS_TRIGGER`) and a scope link outside its documented source scopes (`SCRIPT_SCOPE_LINK_WRONG_SCOPE`), and enter `owner`, `controller`, `capital_scope`, `overlord`, and `faction_leader` blocks with their target scope.
10
+ - Check player-facing text in `hoi4.script_validate` file mode: colour codes in news and report events, including through scripted localisation, development wording in shown text, and unlocalised flags in decision and focus requirement tooltips.
11
+ - Report visible events fired inside `every_*` loops that reach one player once per iteration, either through a fixed recipient or as a major event (`SCRIPT_EVENT_POPUP_REPEATED`).
12
+ - Publish the GUI scenario shape on `hoi4.gui_inspect`, `hoi4.gui_render`, and `hoi4.gui_rewrite`, with `id` required, so clients can build a valid scenario from discovery.
13
+ - Add `closedFlags` to GUI scenarios: undeclared flags are unset, as in the game, so flag-driven tab pages and panels render one at a time.
14
+ - Resolve a window's scripted localisation exactly under the supplied scenario wherever its flags, variables, and values decide the branch; undecided tokens still render as `[dynamic_loc]`.
15
+ - Warn with `GUI_UNRESOLVED_VISIBILITY_OVERLAP` when overlapping elements are drawn together because the scenario does not decide which one is visible.
16
+ - Warn with `GUI_NEAR_CENTRE_OFFSET` when an element sits a few pixels from its parent's horizontal centre.
17
+ - Add `background: "transparent"` to GUI scenarios for renders without the preview backdrop.
4
18
 
5
19
  ## 3.8.1 - 2026-10-05
6
20
 
package/README.md CHANGED
@@ -119,7 +119,7 @@ Your agent can select them from ordinary language; use the live tool schema for
119
119
  | `hoi4.reference_search` | Search local documentation in plain words or script names; each result names its answering line. |
120
120
  | `hoi4.reference_read` | Read one revision-bound section, with line continuation. |
121
121
  | `hoi4.source_lookup` | Find exact definitions, overrides and usages across game, DLC and mod, or the definition containing a file line. |
122
- | `hoi4.script_validate` | Check snippet syntax, native command kinds, and declared scopes against local documentation. |
122
+ | `hoi4.script_validate` | Check a snippet or a whole mod file for syntax, command kinds, and scopes against local documentation. |
123
123
  | `hoi4.event_inspect` | Scan, trace, explain, lint, or assess event chains and their state flow. |
124
124
  | `hoi4.event_render` | Render source-linked event routes, options, timing, state, scope, and unresolved edges. |
125
125
  | `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
package/dist/bin/setup.js CHANGED
@@ -225,8 +225,11 @@ function printClientConfig(configPath) {
225
225
  args: [],
226
226
  ...(useDefaultConfiguration ? {} : { env: { HOI4_AGENT_CONFIG: absolute } }),
227
227
  };
228
- const npxToml = `[mcp_servers.hoi4_agent_tools]\ncommand = "${command}"\nargs = ["-y", "hoi4-agent-tools@${PACKAGE_VERSION}"]${useDefaultConfiguration ? '' : `\nenv = { HOI4_AGENT_CONFIG = ${JSON.stringify(absolute)} }`}`;
229
- const globalToml = `[mcp_servers.hoi4_agent_tools]\ncommand = "${installedCommand}"${useDefaultConfiguration ? '' : `\nenv = { HOI4_AGENT_CONFIG = ${JSON.stringify(absolute)} }`}`;
228
+ // Codex keeps a server per subagent connected after the subagent finishes, so its servers
229
+ // exit after half an hour without traffic instead of accumulating.
230
+ const codexEnv = `\nenv = { ${useDefaultConfiguration ? '' : `HOI4_AGENT_CONFIG = ${JSON.stringify(absolute)}, `}HOI4_AGENT_STDIO_IDLE_EXIT_MINUTES = "30" }`;
231
+ const npxToml = `[mcp_servers.hoi4_agent_tools]\ncommand = "${command}"\nargs = ["-y", "hoi4-agent-tools@${PACKAGE_VERSION}"]${codexEnv}`;
232
+ const globalToml = `[mcp_servers.hoi4_agent_tools]\ncommand = "${installedCommand}"${codexEnv}`;
230
233
  process.stdout.write(`${JSON.stringify({
231
234
  note: 'Review and paste one example into your MCP client; this utility does not edit client settings.',
232
235
  generic: { mcpServers: { hoi4_agent_tools: common } },
package/dist/bin/stdio.js CHANGED
@@ -2,7 +2,22 @@
2
2
  import { canonicalJson } from '../hoi4_agent_tools/core/canonical.js';
3
3
  import { StdioFrameLimitError, StdioInvalidMessageError, StdioInvalidUtf8Error, } from '../hoi4_agent_tools/mcp/transports/bounded-stdio.js';
4
4
  import { serveNegotiatedStdio } from '../hoi4_agent_tools/mcp/transports/negotiated-stdio.js';
5
+ import { StdioLifetime } from '../hoi4_agent_tools/mcp/transports/stdio-lifetime.js';
5
6
  import { createEngine } from '../hoi4_agent_tools/runtime.js';
7
+ /**
8
+ * A client registration can set its own idle limit, because the server configuration is shared
9
+ * by every client: clients that keep idle per-subagent connections opt in without affecting a
10
+ * long-lived interactive session.
11
+ */
12
+ function idleExitMinutes(configured) {
13
+ const override = process.env.HOI4_AGENT_STDIO_IDLE_EXIT_MINUTES;
14
+ if (override === undefined || override.trim() === '')
15
+ return configured;
16
+ const minutes = Number(override);
17
+ if (!Number.isInteger(minutes) || minutes < 0 || minutes > 10_080)
18
+ throw new Error('HOI4_AGENT_STDIO_IDLE_EXIT_MINUTES must be a whole number from 0 to 10080');
19
+ return minutes;
20
+ }
6
21
  async function main() {
7
22
  const engine = await createEngine();
8
23
  const chaosxTools = process.env.HOI4_AGENT_TOOLS_CHAOSX === '1'
@@ -20,8 +35,17 @@ async function main() {
20
35
  message: error.message,
21
36
  })}\n`);
22
37
  };
23
- await serveNegotiatedStdio(engine, {
38
+ let closeTransport = () => Promise.resolve();
39
+ const lifetime = new StdioLifetime({
40
+ idleExitMinutes: idleExitMinutes(engine.resolver.config().stdioIdleExitMinutes),
41
+ onExit: (reason) => {
42
+ process.stderr.write(`${canonicalJson({ level: 'info', event: 'stdio_exit', reason })}\n`);
43
+ void closeTransport().finally(() => setTimeout(() => process.exit(0), 250).unref());
44
+ },
45
+ });
46
+ const transport = await serveNegotiatedStdio(engine, {
24
47
  onerror,
48
+ observer: lifetime,
25
49
  onFailure: (error) => {
26
50
  process.exitCode = 1;
27
51
  process.stderr.write(`${canonicalJson({
@@ -37,6 +61,8 @@ async function main() {
37
61
  registerLegacy: (server) => chaosxTools.registerChaosxTools(server, engine, {}),
38
62
  }),
39
63
  });
64
+ closeTransport = () => transport.close();
65
+ lifetime.start();
40
66
  }
41
67
  main().catch((error) => {
42
68
  process.stderr.write(`${canonicalJson({
@@ -147,6 +147,14 @@ export const serverConfigurationSchema = z
147
147
  scanMaxFiles: z.number().int().min(1).max(1_000_000).default(DEFAULT_SCAN_MAX_FILES),
148
148
  maxConcurrentTools: z.number().int().min(1).max(32).default(2),
149
149
  maxSharedTools: z.number().int().min(1).max(128).default(4),
150
+ /** A read-only job still running after this many seconds is stopped and fails. */
151
+ jobDeadlineSeconds: z.number().int().min(1).max(86_400).default(1_800),
152
+ /** A read-only job that has not stopped this long after cancellation is stopped. */
153
+ jobCancelGraceSeconds: z.number().int().min(1).max(600).default(10),
154
+ /** A stdio server with no client traffic or open request for this long exits; 0 never. */
155
+ stdioIdleExitMinutes: z.number().int().min(0).max(10_080).default(0),
156
+ /** V8 heap ceiling for each job worker in MiB; unset keeps Node's default. */
157
+ jobWorkerMaxHeapMiB: z.number().int().min(256).max(65_536).optional(),
150
158
  scanMaxBytes: z
151
159
  .number()
152
160
  .int()
@@ -57,7 +57,18 @@ export function workerFailure(error, stage) {
57
57
  message: `Worker coordination could not complete (${nativeCode}, stage ${stage}); inspect the retained job outcome`,
58
58
  };
59
59
  }
60
- /** Bounded, fixed-entry child execution; client cancellation/disconnection never kills a worker. */
60
+ /** Stop a supervised worker and wait, bounded, until the operating system reports its exit. */
61
+ async function stopChild(child) {
62
+ if (child.exitCode !== null || child.signalCode !== null)
63
+ return;
64
+ const exited = new Promise((resolve) => child.once('exit', () => resolve()));
65
+ child.kill('SIGKILL');
66
+ await Promise.race([exited, delay(10_000)]);
67
+ }
68
+ /**
69
+ * Bounded, fixed-entry child execution. Client disconnection never kills a worker; an
70
+ * overdue or unresponsive cancelled read-only worker is stopped by its supervising host.
71
+ */
61
72
  export class JobWorkerHost {
62
73
  engine;
63
74
  jobs;
@@ -189,7 +200,13 @@ export class JobWorkerHost {
189
200
  }
190
201
  : grant;
191
202
  progress.stage = 'startup';
192
- const child = spawn(process.execPath, [...(sourceMode ? ['--import', import.meta.resolve('tsx')] : []), entry], {
203
+ const child = spawn(process.execPath, [
204
+ ...(configuration.jobWorkerMaxHeapMiB === undefined
205
+ ? []
206
+ : [`--max-old-space-size=${configuration.jobWorkerMaxHeapMiB}`]),
207
+ ...(sourceMode ? ['--import', import.meta.resolve('tsx')] : []),
208
+ entry,
209
+ ], {
193
210
  detached: true,
194
211
  cwd: process.cwd(),
195
212
  stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
@@ -265,6 +282,9 @@ export class JobWorkerHost {
265
282
  clearTimeout(acknowledgmentTimer);
266
283
  }
267
284
  progress.stage = 'supervision';
285
+ const { jobDeadlineSeconds, jobCancelGraceSeconds } = this.engine.resolver.config();
286
+ const supervisedSince = Date.now();
287
+ let cancellationSeenAt;
268
288
  for (;;) {
269
289
  const record = await this.jobs.get(workspaceId, id, principal);
270
290
  if (['completed', 'failed', 'cancelled'].includes(record.status))
@@ -273,6 +293,24 @@ export class JobWorkerHost {
273
293
  if (jobOwnerLiveness(record.owner) === 'alive')
274
294
  return;
275
295
  }
296
+ // A worker blocked in synchronous analysis never reaches its own cancellation check,
297
+ // so this process enforces the deadline and cancellation through the child handle.
298
+ // Rewrites are never stopped here: their transaction journal owns write recovery.
299
+ if (!record.request.mutation) {
300
+ if (record.cancelRequested)
301
+ cancellationSeenAt ??= Date.now();
302
+ const overdue = Date.now() - supervisedSince > jobDeadlineSeconds * 1000;
303
+ const unresponsive = cancellationSeenAt !== undefined &&
304
+ Date.now() - cancellationSeenAt > jobCancelGraceSeconds * 1000;
305
+ if (overdue || unresponsive) {
306
+ await stopChild(child);
307
+ await this.jobs.failInterrupted(workspaceId, id, {
308
+ code: 'JOB_DEADLINE_EXCEEDED',
309
+ message: `The job ran longer than ${jobDeadlineSeconds} seconds and was stopped; narrow its selector or limits, or raise jobDeadlineSeconds`,
310
+ }, principal);
311
+ return;
312
+ }
313
+ }
276
314
  if (child.pid !== undefined) {
277
315
  try {
278
316
  process.kill(child.pid, 0);
@@ -42,6 +42,20 @@ process.once('message', (value) => {
42
42
  // unrelated live rewrite journals when a worker starts.
43
43
  const jobs = await JobService.create(resolver);
44
44
  const record = await jobs.get(input.workspaceId, input.jobId, input.principal);
45
+ if (!record.request.mutation) {
46
+ // The host enforces the deadline while it lives; a worker whose host has gone stops
47
+ // itself, so an orphaned read-only analysis never holds memory indefinitely.
48
+ const { jobDeadlineSeconds } = resolver.config();
49
+ setTimeout(() => {
50
+ void jobs
51
+ .failInterrupted(input.workspaceId, input.jobId, {
52
+ code: 'JOB_DEADLINE_EXCEEDED',
53
+ message: `The job ran longer than ${jobDeadlineSeconds} seconds and was stopped; narrow its selector or limits, or raise jobDeadlineSeconds`,
54
+ }, input.principal)
55
+ .catch(() => undefined)
56
+ .finally(() => process.exit(1));
57
+ }, jobDeadlineSeconds * 1000).unref();
58
+ }
45
59
  const operations = await registerWorkerOperations(engine, record.request.toolName);
46
60
  await new JobExecutor(engine, jobs, operations).run(input.workspaceId, input.jobId, input.principal);
47
61
  })()
@@ -56,7 +56,7 @@ export function scenarioFromGui(base, values = base.values) {
56
56
  ...(base.controls === undefined ? {} : { controls: { ...base.controls } }),
57
57
  state,
58
58
  scopes,
59
- closedFlags: false,
59
+ closedFlags: base.closedFlags === true,
60
60
  ...(typeof actor === 'string' ? { actor } : {}),
61
61
  };
62
62
  }
@@ -456,6 +456,12 @@ function overlayToSvg(scene, variant) {
456
456
  .join('');
457
457
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${scene.resolution.width}" height="${scene.resolution.height}" viewBox="0 0 ${scene.resolution.width} ${scene.resolution.height}"><defs>${toolText.definitions()}</defs>${overlays}</svg>`;
458
458
  }
459
+ /** The preview backdrop; a transparent scenario leaves uncovered pixels fully transparent. */
460
+ function backdrop(scene, viewBox) {
461
+ return scene.scenario.background === 'transparent'
462
+ ? ''
463
+ : `<rect ${rectAttributes(viewBox)} fill="#17202a"/>`;
464
+ }
459
465
  export function sceneToSvg(scene, variant) {
460
466
  const view = viewFor(scene, variant);
461
467
  assertRenderDimensions(view.width, view.height, `GUI ${variant} SVG`);
@@ -470,7 +476,7 @@ export function sceneToSvg(scene, variant) {
470
476
  .map((element) => renderOverlay(element, variant, toolText))
471
477
  .join('');
472
478
  const fidelity = escapeXml(canonicalJson(scene.fidelity));
473
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions.join('')}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs><rect ${rectAttributes(view.viewBox)} fill="#17202a"/>${body}${overlays}</svg>`;
479
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions.join('')}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs>${backdrop(scene, view.viewBox)}${body}${overlays}</svg>`;
474
480
  }
475
481
  async function cooperativeParts(values, render, signal) {
476
482
  const output = [];
@@ -494,7 +500,7 @@ async function sceneToSvgCooperative(scene, variant, signal) {
494
500
  const body = (await cooperativeParts(scene.elements, (element, index) => renderBaseElement(element, element.clipRect === undefined ? undefined : `clip-${index}`, toolText), signal)) + renderTooltipPreview(scene, toolText);
495
501
  const overlays = await cooperativeParts(scene.elements, (element) => renderOverlay(element, variant, toolText), signal);
496
502
  const fidelity = escapeXml(canonicalJson(scene.fidelity));
497
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs><rect ${rectAttributes(view.viewBox)} fill="#17202a"/>${body}${overlays}</svg>`;
503
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs>${backdrop(scene, view.viewBox)}${body}${overlays}</svg>`;
498
504
  }
499
505
  export function hierarchyToSvg(scene) {
500
506
  const rows = scene.elements.toSorted((left, right) => left.depth - right.depth || left.zIndex - right.zIndex || compareCodeUnits(left.id, right.id));
@@ -459,6 +459,54 @@ function generatedRows(count, random, options) {
459
459
  };
460
460
  });
461
461
  }
462
+ /** Sprite tokens read by scripted GUI `image` properties of the window. */
463
+ function scriptedImageTokens(scripted) {
464
+ const tokens = new Set();
465
+ for (const definition of scripted)
466
+ for (const property of definition.propertyDefinitions)
467
+ for (const [attribute, expression] of Object.entries(property.attributes)) {
468
+ const token = expressionToken(expression);
469
+ if (token !== undefined && attribute.toLocaleLowerCase('en-US') === 'image')
470
+ tokens.add(token);
471
+ }
472
+ return tokens;
473
+ }
474
+ /**
475
+ * Resolve the window's scripted localisation exactly under a supplied scenario: a text or
476
+ * sprite token whose `defined_text` branch the scenario's flags, variables and values decide
477
+ * takes that branch. A token the scenario cannot decide is left out, so it still renders as
478
+ * `[dynamic_loc]`, and caller-supplied values always win.
479
+ */
480
+ export function resolveScenarioScriptedLocalisation(graph, windowName, base, definitions = new ClausewitzEvaluationDefinitions()) {
481
+ if (graph.scriptedLocalisation.length === 0)
482
+ return base;
483
+ const scripted = relatedScriptedGuis(graph, windowName);
484
+ const elements = relevantElements(graph, windowName, scripted);
485
+ const textual = tokensIn(sourceTexts(graph, elements, base.language, scripted)).textual;
486
+ const explicitValues = guiExplicitConditionValues(base);
487
+ const values = { ...base.values };
488
+ const templates = new Map();
489
+ const evaluate = (key, kind) => explicitValues[key] !== undefined
490
+ ? undefined
491
+ : scriptedLocalisationValue(graph, key, base.language, kind, explicitValues, base, definitions, []);
492
+ for (const key of textual) {
493
+ const template = evaluate(key, 'text');
494
+ if (template !== undefined)
495
+ templates.set(key, template);
496
+ }
497
+ for (const key of scriptedImageTokens(scripted)) {
498
+ const sprite = evaluate(key, 'image');
499
+ if (sprite !== undefined)
500
+ values[key] = sprite;
501
+ }
502
+ // Branch texts can embed further tokens, including other resolved branches.
503
+ for (let pass = 0; pass < 3; pass += 1)
504
+ for (const [key, template] of templates)
505
+ values[key] = materializeDynamicText(template, { ...explicitValues, ...values });
506
+ return Object.keys(values).length === Object.keys(base.values).length
507
+ ? base
508
+ : { ...base, values };
509
+ }
462
510
  export function generateGuiPreviewScenarios(graph, windowName, base, options, definitions = new ClausewitzEvaluationDefinitions()) {
463
511
  if (!options.enabled)
464
512
  return [];
@@ -145,6 +145,8 @@ const GuiPreviewScenarioBodySchema = z
145
145
  conditionResults: z.array(ConditionResultSchema).max(10_000).optional(),
146
146
  variables: z.record(z.string(), z.number()).default({}),
147
147
  flags: z.record(z.string(), z.boolean()).default({}),
148
+ closedFlags: z.boolean().optional(),
149
+ background: z.enum(['scene', 'transparent']).optional(),
148
150
  lists: z.record(z.string(), z.array(objectSchema).max(10_000)).default({}),
149
151
  localisation: z.record(z.string(), z.string()).default({}),
150
152
  values: objectSchema.default({}),
@@ -21,7 +21,7 @@ import { projectGuiGraphForArtifact } from './inspection-artifact.js';
21
21
  import { buildGuiScene } from './layout.js';
22
22
  import { GUI_VALIDATION_MAX_DIAGNOSTICS } from './limits.js';
23
23
  import { compareGuiImages, galleryDimensions, renderGallerySvg, renderGuiScene, } from './renderer.js';
24
- import { generateGuiPreviewScenarios } from './scenario-generator.js';
24
+ import { generateGuiPreviewScenarios, resolveScenarioScriptedLocalisation, } from './scenario-generator.js';
25
25
  import { parseGeneratedScenarioOptions, parsePreviewScenario } from './scenario.js';
26
26
  import { buildGuiSourceGraph } from './source-graph.js';
27
27
  import { assertGuiSourcePatchesSafe } from './source-patch.js';
@@ -1062,16 +1062,19 @@ export class ScriptedGuiStudio {
1062
1062
  ? undefined
1063
1063
  : parseGeneratedScenarioOptions(input.generatedScenarios);
1064
1064
  const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryFlagPatterns([placeholderScenario, ...explicitRelatedScenarios]), scenarioLocalisationKeys([placeholderScenario, ...explicitRelatedScenarios]), [placeholderScenario, ...explicitRelatedScenarios].some(({ tooltip }) => tooltip !== undefined));
1065
+ const evaluationDefinitions = ClausewitzEvaluationDefinitions.build(scanned);
1066
+ const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
1067
+ const suppliedScenario = resolveLocalisation(placeholderScenario);
1065
1068
  const generatedScenarios = generatedOptions === undefined
1066
1069
  ? []
1067
- : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, ClausewitzEvaluationDefinitions.build(scanned));
1068
- const scenario = generatedScenarios[0] ?? placeholderScenario;
1070
+ : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, evaluationDefinitions);
1071
+ const scenario = generatedScenarios[0] ?? suppliedScenario;
1069
1072
  const relatedScenarios = [
1070
1073
  ...(generatedOptions?.preservePlaceholder === true && generatedScenarios.length > 0
1071
- ? [placeholderScenario]
1074
+ ? [suppliedScenario]
1072
1075
  : []),
1073
1076
  ...generatedScenarios.slice(1),
1074
- ...explicitRelatedScenarios,
1077
+ ...explicitRelatedScenarios.map(resolveLocalisation),
1075
1078
  ];
1076
1079
  assertUniqueScenarioIds([scenario, ...relatedScenarios]);
1077
1080
  const budget = new RenderBudget();
@@ -1157,16 +1160,19 @@ export class ScriptedGuiStudio {
1157
1160
  ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
1158
1161
  ...explicitRelatedScenarios,
1159
1162
  ].some(({ tooltip }) => tooltip !== undefined));
1163
+ const evaluationDefinitions = ClausewitzEvaluationDefinitions.build(scanned);
1164
+ const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
1165
+ const suppliedScenario = resolveLocalisation(placeholderScenario);
1160
1166
  const generatedScenarios = generatedOptions === undefined
1161
1167
  ? []
1162
- : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, ClausewitzEvaluationDefinitions.build(scanned));
1163
- const scenario = generatedScenarios[0] ?? placeholderScenario;
1168
+ : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, evaluationDefinitions);
1169
+ const scenario = generatedScenarios[0] ?? suppliedScenario;
1164
1170
  const relatedScenarios = [
1165
1171
  ...(generatedOptions?.preservePlaceholder === true && generatedScenarios.length > 0
1166
- ? [placeholderScenario]
1172
+ ? [suppliedScenario]
1167
1173
  : []),
1168
1174
  ...generatedScenarios.slice(1),
1169
- ...explicitRelatedScenarios,
1175
+ ...explicitRelatedScenarios.map(resolveLocalisation),
1170
1176
  ];
1171
1177
  assertUniqueScenarioIds([scenario, ...relatedScenarios]);
1172
1178
  const baselineScenario = requestedBaselineScenario ??
@@ -4,6 +4,9 @@ import { sha256Bytes } from '../core/canonical.js';
4
4
  import { assertRenderDimensions } from '../core/render-budget.js';
5
5
  import { GuiAssetCatalog } from './assets.js';
6
6
  import { GUI_GRAPH_MAX_EDGES, GUI_GRAPH_MAX_NODES, GUI_SCENE_MAX_ELEMENTS, GUI_VALIDATION_MAX_ANCESTOR_HOPS, GUI_VALIDATION_MAX_DIAGNOSTICS, GUI_VALIDATION_MAX_PAIR_COMPARISONS, } from './limits.js';
7
+ /** Horizontal offsets from a parent's centre above rounding and up to a few pixels look accidental. */
8
+ const NEAR_CENTRE_TOLERANCE_PX = 0.5;
9
+ const NEAR_CENTRE_MAX_PX = 3;
7
10
  const allStates = [
8
11
  'normal',
9
12
  'hover',
@@ -435,6 +438,19 @@ function validateOverlapAndGeometry(scene, diagnostics, work) {
435
438
  const visible = scene.elements.filter((element) => element.visible && area(element.rect) > 0);
436
439
  const byId = new Map(scene.elements.map((element) => [element.id, element]));
437
440
  for (const element of scene.elements) {
441
+ // An element a few pixels from its parent's horizontal centre was almost always meant
442
+ // to be centred; larger offsets are deliberate placement.
443
+ const parent = element.parentId === undefined ? undefined : byId.get(element.parentId);
444
+ if (element.visible &&
445
+ parent?.visible === true &&
446
+ element.unclippedRect.width > 0 &&
447
+ element.unclippedRect.width < parent.unclippedRect.width - 2) {
448
+ const offset = element.unclippedRect.x +
449
+ element.unclippedRect.width / 2 -
450
+ (parent.unclippedRect.x + parent.unclippedRect.width / 2);
451
+ if (Math.abs(offset) > NEAR_CENTRE_TOLERANCE_PX && Math.abs(offset) <= NEAR_CENTRE_MAX_PX)
452
+ diagnostics.push(issue('GUI_NEAR_CENTRE_OFFSET', 'warning', 'layout', `${element.name} sits ${Math.abs(offset).toFixed(1)} px ${offset > 0 ? 'right' : 'left'} of the centre of ${parent.name}; centre it exactly or offset it clearly.`, element, { parent: parent.id, offset }));
453
+ }
438
454
  if (element.unclippedRect.width <= 0 || element.unclippedRect.height <= 0) {
439
455
  diagnostics.push(issue('GUI_INVALID_SIZE', 'warning', 'layout', `${element.name} has a non-positive ${element.unclippedRect.width}x${element.unclippedRect.height} size.`, element));
440
456
  }
@@ -477,6 +493,12 @@ function validateOverlapAndGeometry(scene, diagnostics, work) {
477
493
  if (left.clickable && right.clickable && ratio > 0.02) {
478
494
  diagnostics.push(issue('GUI_CONFLICTING_CLICK_REGIONS', 'error', 'design', `${left.name} and ${right.name} have overlapping click regions (${(ratio * 100).toFixed(1)}%).`, right, { left: left.id, right: right.id, ratio }));
479
495
  }
496
+ // Tab pages and alternative panels overlap by design; the game shows one at a time.
497
+ // When the scenario cannot decide which, the preview draws them stacked.
498
+ if (left.depth === right.depth &&
499
+ ratio > 0.2 &&
500
+ (left.visibilityStatus === 'unresolved' || right.visibilityStatus === 'unresolved'))
501
+ diagnostics.push(issue('GUI_UNRESOLVED_VISIBILITY_OVERLAP', 'warning', 'layout', `${left.name} and ${right.name} overlap and the scenario does not decide which is visible, so the preview draws both. Declare the flags or variables their visible triggers read, with closedFlags, to render one of them.`, right, { left: left.id, right: right.id, ratio }));
480
502
  if (left.depth === right.depth && ratio > 0.2) {
481
503
  diagnostics.push(issue('GUI_VISIBLE_OVERLAP', 'warning', 'layout', `${left.name} and ${right.name} overlap by ${(ratio * 100).toFixed(1)}% of the smaller element.`, right, { left: left.id, right: right.id, ratio }));
482
504
  }
@@ -44,7 +44,7 @@ export const referenceTools = [
44
44
  {
45
45
  name: 'hoi4.script_validate',
46
46
  title: 'Check HOI4 script commands and scopes',
47
- description: 'Check effect or trigger snippet syntax, native command kinds and declared scopes against cited installed documentation. Unknown helpers and native argument blocks remain explicit.',
47
+ description: 'Check an effect or trigger snippet (source, kind, scope) or a whole mod file (path) for syntax, native command kinds and scopes against cited installed documentation. Unknown helpers and native argument blocks remain explicit.',
48
48
  inputSchema: scriptValidateRequestSchema,
49
49
  outputSchema: strictOperationResultSchema(scriptValidateDataSchema),
50
50
  annotations: readOnly,
@@ -80,12 +80,21 @@ export async function serveNegotiatedStdio(engine, options) {
80
80
  reportedErrors.add(error);
81
81
  options.onerror(error);
82
82
  };
83
+ const observer = options.observer;
84
+ if (observer !== undefined) {
85
+ const send = physical.send.bind(physical);
86
+ physical.send = (message) => {
87
+ observer.sent(message);
88
+ return send(message);
89
+ };
90
+ }
83
91
  physical.onclose = () => pinned?.onclose();
84
92
  physical.onerror = (error) => {
85
93
  reportError(error);
86
94
  pinned?.onerror(error);
87
95
  };
88
96
  physical.onmessage = (message) => {
97
+ observer?.received(message);
89
98
  if (pinned === undefined) {
90
99
  pinned = new PinnedStdioTransport(physical, reportError);
91
100
  try {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Ends a stdio server that no client can reach. A client normally closes stdin, which the
3
+ * transport turns into a clean exit. Two cases leave stdin open: the launching process
4
+ * (for example a `cmd.exe` shim that a client terminated) dies while the client keeps the
5
+ * pipe, and a client keeps an idle connection it will never use again. The first is detected
6
+ * by polling the parent process; the second by an optional idle limit.
7
+ */
8
+ const PARENT_POLL_MS = 5_000;
9
+ /** Whether a process exists; a permission refusal still proves it exists. */
10
+ export function processAlive(pid) {
11
+ try {
12
+ process.kill(pid, 0);
13
+ return true;
14
+ }
15
+ catch (error) {
16
+ return error.code === 'EPERM';
17
+ }
18
+ }
19
+ export class StdioLifetime {
20
+ #options;
21
+ #open = new Set();
22
+ #lastActivity;
23
+ #timer;
24
+ #stopped = false;
25
+ constructor(options) {
26
+ this.#options = {
27
+ parentPid: options.parentPid ?? process.ppid,
28
+ isAlive: options.isAlive ?? processAlive,
29
+ now: options.now ?? Date.now,
30
+ pollMs: options.pollMs ?? PARENT_POLL_MS,
31
+ idleExitMinutes: options.idleExitMinutes,
32
+ onExit: options.onExit,
33
+ };
34
+ this.#lastActivity = this.#options.now();
35
+ }
36
+ start() {
37
+ this.#timer = setInterval(() => this.check(), this.#options.pollMs);
38
+ // The poll never keeps an otherwise finished server alive.
39
+ this.#timer.unref();
40
+ }
41
+ stop() {
42
+ this.#stopped = true;
43
+ if (this.#timer !== undefined)
44
+ clearInterval(this.#timer);
45
+ }
46
+ /** A client message: a request opens until its response is sent. */
47
+ received(message) {
48
+ this.#lastActivity = this.#options.now();
49
+ const id = requestId(message, 'method');
50
+ if (id !== undefined)
51
+ this.#open.add(id);
52
+ }
53
+ /** A server message: a response closes its request. */
54
+ sent(message) {
55
+ this.#lastActivity = this.#options.now();
56
+ const id = requestId(message, 'result') ?? requestId(message, 'error');
57
+ if (id !== undefined)
58
+ this.#open.delete(id);
59
+ }
60
+ check() {
61
+ if (this.#stopped)
62
+ return;
63
+ const { parentPid, isAlive, idleExitMinutes, now, onExit } = this.#options;
64
+ // A parent pid of 0 or 1 means the server was already reparented at start.
65
+ if (parentPid > 1 && !isAlive(parentPid)) {
66
+ this.stop();
67
+ onExit('parent_exited');
68
+ return;
69
+ }
70
+ if (idleExitMinutes > 0 &&
71
+ this.#open.size === 0 &&
72
+ now() - this.#lastActivity > idleExitMinutes * 60_000) {
73
+ this.stop();
74
+ onExit('idle');
75
+ }
76
+ }
77
+ }
78
+ function requestId(message, field) {
79
+ if (typeof message !== 'object' || message === null || !(field in message))
80
+ return undefined;
81
+ const id = message.id;
82
+ return typeof id === 'string' || typeof id === 'number' ? id : undefined;
83
+ }
@@ -0,0 +1,87 @@
1
+ import { parseClausewitz } from '../core/source/index.js';
2
+ const eventEffects = new Set(['country_event', 'news_event']);
3
+ const eventTypes = new Set([
4
+ 'country_event',
5
+ 'news_event',
6
+ 'state_event',
7
+ 'unit_leader_event',
8
+ 'operative_leader_event',
9
+ ]);
10
+ /** Scopes that name the same target on every iteration of an enclosing loop. */
11
+ const fixedScope = /^(?:root|from(?:\.from)*|prev(?:\.prev)*|global|event_target:.+|var:.+|[A-Z][A-Z0-9]{2})$/u;
12
+ const notTags = new Set(['AND', 'NOT', 'NOR', 'XOR']);
13
+ function assignmentsOf(block) {
14
+ return block.entries.filter((entry) => entry.type === 'assignment');
15
+ }
16
+ function scalar(block, key) {
17
+ const entry = assignmentsOf(block).find((child) => child.key.value === key);
18
+ return entry?.value.type === 'scalar' ? entry.value.value : undefined;
19
+ }
20
+ /** Event definitions of one events file, keyed by id. */
21
+ export function eventPopupDefinitions(bytes, path) {
22
+ const definitions = new Map();
23
+ for (const event of assignmentsOf(parseClausewitz(bytes, path).root)) {
24
+ if (!eventTypes.has(event.key.value) || event.value.type !== 'block')
25
+ continue;
26
+ const id = scalar(event.value, 'id');
27
+ if (id === undefined)
28
+ continue;
29
+ definitions.set(id, {
30
+ hidden: scalar(event.value, 'hidden') === 'yes',
31
+ major: scalar(event.value, 'major') === 'yes',
32
+ fireOnlyOnce: scalar(event.value, 'fire_only_once') === 'yes',
33
+ });
34
+ }
35
+ return definitions;
36
+ }
37
+ /**
38
+ * Visible events fired inside an `every_*` loop that reach the same player once per
39
+ * iteration: a fixed recipient (ROOT, FROM, PREV, a tag, a target) receives one window per
40
+ * loop member, and a major event is shown to every country once per loop member.
41
+ */
42
+ export function repeatedPopupFindings(document, events) {
43
+ const findings = [];
44
+ const visit = (block, loops, fixed) => {
45
+ for (const entry of assignmentsOf(block)) {
46
+ const key = entry.key.value;
47
+ if (key === 'limit')
48
+ continue;
49
+ if (eventEffects.has(key) && loops.length > 0) {
50
+ const id = entry.value.type === 'block' ? scalar(entry.value, 'id') : entry.value.value;
51
+ const definition = id === undefined ? undefined : events.get(id);
52
+ if (definition === undefined || definition.hidden)
53
+ continue;
54
+ const loop = loops.join(' inside ');
55
+ if (fixed !== undefined)
56
+ findings.push({
57
+ code: 'SCRIPT_EVENT_POPUP_REPEATED',
58
+ command: key,
59
+ offset: entry.start,
60
+ end: entry.end,
61
+ message: `${id} is sent to ${fixed} once per member of ${loop}, so that player receives one window per iteration; fire it once outside the loop or mark it hidden`,
62
+ });
63
+ else if (definition.major && !definition.fireOnlyOnce)
64
+ findings.push({
65
+ code: 'SCRIPT_EVENT_POPUP_REPEATED',
66
+ command: key,
67
+ offset: entry.start,
68
+ end: entry.end,
69
+ message: `${id} is a major event fired once per member of ${loop}, so every player receives one window per iteration; fire it once outside the loop`,
70
+ });
71
+ continue;
72
+ }
73
+ if (entry.value.type !== 'block')
74
+ continue;
75
+ if (key.startsWith('every_'))
76
+ visit(entry.value, [...loops, key], undefined);
77
+ else if (loops.length > 0 &&
78
+ !notTags.has(key) &&
79
+ (fixedScope.test(key) || fixedScope.test(key.toLowerCase())))
80
+ visit(entry.value, loops, key);
81
+ else
82
+ visit(entry.value, loops, fixed);
83
+ }
84
+ };
85
+ visit(document.root, [], undefined);
86
+ return findings;
87
+ }