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
@@ -27,29 +27,50 @@ function decodedDocument(encoded, expected) {
27
27
  throw new Error('Persistent source document does not match its source identity');
28
28
  return value;
29
29
  }
30
- /** Process-local parsed facts; serialized entries cannot be mutated by their consumers. */
30
+ /** Puts between halvings of every entry's hit count, so entries no longer read become evictable. */
31
+ const HIT_AGING_INTERVAL = 4096;
32
+ /** Keys of documents turned away from a full cache, remembered so a repeat can be admitted. */
33
+ const DOORKEEPER_KEYS = 16_384;
34
+ /**
35
+ * Process-local parsed facts; serialized entries cannot be mutated by their consumers.
36
+ *
37
+ * A workspace scan often parses more source than the cache holds. Plain LRU then evicts
38
+ * every document before its next use and pays serialization for nothing. A full cache
39
+ * therefore turns a document away the first time, remembering only its key, and admits it
40
+ * on a later miss by displacing entries that were never read; a rejected document is not
41
+ * serialized. Repeated scans keep what fits and hit it. Hit counts age, so documents that
42
+ * stop being read become evictable again.
43
+ */
31
44
  export class SourceDocumentCache {
32
45
  maxBytes;
33
46
  #entries = new Map();
34
47
  #bytes = 0;
48
+ /** Bytes held by entries without hits, which a new document may displace. */
49
+ #coldBytes = 0;
50
+ #puts = 0;
35
51
  #hits = 0;
36
52
  #misses = 0;
37
53
  #evictions = 0;
54
+ #rejections = 0;
55
+ #doorkeeper = new Set();
38
56
  constructor(maxBytes = 64 * 1024 * 1024) {
39
57
  this.maxBytes = maxBytes;
40
58
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
41
59
  throw new RangeError('Source cache size must be a non-negative integer');
42
60
  }
43
61
  get(key) {
44
- const encoded = this.#entries.get(key);
45
- if (encoded === undefined) {
62
+ const entry = this.#entries.get(key);
63
+ if (entry === undefined) {
46
64
  this.#misses += 1;
47
65
  return undefined;
48
66
  }
49
67
  this.#hits += 1;
68
+ if (entry.hits === 0)
69
+ this.#coldBytes -= entrySize(key, entry.encoded);
70
+ entry.hits += 1;
50
71
  this.#entries.delete(key);
51
- this.#entries.set(key, encoded);
52
- return decodedDocument(encoded);
72
+ this.#entries.set(key, entry);
73
+ return decodedDocument(entry.encoded);
53
74
  }
54
75
  /** Availability check without deserialization or cache-hit accounting. */
55
76
  has(key) {
@@ -61,8 +82,19 @@ export class SourceDocumentCache {
61
82
  const estimatedBytes = document.bytes.length * 12 + document.tokens.length * 512 + key.length * 2 + 128;
62
83
  if (this.maxBytes === 0 || estimatedBytes > this.maxBytes)
63
84
  return;
64
- const encoded = serialize(document);
65
- this.retain(key, encoded);
85
+ this.#age();
86
+ const previous = this.#entries.get(key);
87
+ const reusable = previous === undefined ? 0 : entrySize(key, previous.encoded);
88
+ const free = this.maxBytes - this.#bytes + reusable;
89
+ if (estimatedBytes > free) {
90
+ // Only a document missed before may displace entries, and only entries never read.
91
+ if (!this.#doorkeeper.delete(key) || estimatedBytes > free + this.#coldBytes) {
92
+ this.#remember(key);
93
+ this.#rejections += 1;
94
+ return;
95
+ }
96
+ }
97
+ this.retain(key, serialize(document));
66
98
  }
67
99
  /** Import one authenticated same-runtime entry without counting it as a process-local hit. */
68
100
  importEncoded(key, encoded, expected) {
@@ -70,42 +102,79 @@ export class SourceDocumentCache {
70
102
  this.retain(key, Buffer.from(encoded));
71
103
  }
72
104
  exportEncoded(key) {
73
- const encoded = this.#entries.get(key);
74
- return encoded === undefined ? undefined : Buffer.from(encoded);
105
+ const entry = this.#entries.get(key);
106
+ return entry === undefined ? undefined : Buffer.from(entry.encoded);
75
107
  }
76
108
  retain(key, encoded) {
77
- const size = encoded.length + key.length * 2 + 128;
109
+ const size = entrySize(key, encoded);
78
110
  if (size > this.maxBytes)
79
111
  return;
80
- const previous = this.#entries.get(key);
81
- if (previous !== undefined)
82
- this.#bytes -= previous.length + key.length * 2 + 128;
83
- this.#entries.delete(key);
84
- while (this.#bytes + size > this.maxBytes) {
85
- const oldest = this.#entries.entries().next().value;
86
- if (oldest === undefined)
112
+ this.#remove(key);
113
+ // Displace the oldest entries that were never read again; read entries stay.
114
+ for (const [oldKey, entry] of this.#entries) {
115
+ if (this.#bytes + size <= this.maxBytes)
87
116
  break;
88
- this.#entries.delete(oldest[0]);
89
- this.#bytes -= oldest[1].length + oldest[0].length * 2 + 128;
117
+ if (entry.hits > 0)
118
+ continue;
119
+ this.#remove(oldKey);
90
120
  this.#evictions += 1;
91
121
  }
92
- this.#entries.set(key, encoded);
122
+ if (this.#bytes + size > this.maxBytes) {
123
+ this.#rejections += 1;
124
+ return;
125
+ }
126
+ this.#entries.set(key, { encoded, hits: 0 });
93
127
  this.#bytes += size;
128
+ this.#coldBytes += size;
129
+ }
130
+ #remember(key) {
131
+ this.#doorkeeper.delete(key);
132
+ this.#doorkeeper.add(key);
133
+ if (this.#doorkeeper.size > DOORKEEPER_KEYS)
134
+ this.#doorkeeper.delete(this.#doorkeeper.values().next().value);
135
+ }
136
+ #remove(key) {
137
+ const entry = this.#entries.get(key);
138
+ if (entry === undefined)
139
+ return;
140
+ const size = entrySize(key, entry.encoded);
141
+ this.#entries.delete(key);
142
+ this.#bytes -= size;
143
+ if (entry.hits === 0)
144
+ this.#coldBytes -= size;
145
+ }
146
+ #age() {
147
+ this.#puts += 1;
148
+ if (this.#puts % HIT_AGING_INTERVAL !== 0)
149
+ return;
150
+ for (const [key, entry] of this.#entries) {
151
+ if (entry.hits === 0)
152
+ continue;
153
+ entry.hits = Math.floor(entry.hits / 2);
154
+ if (entry.hits === 0)
155
+ this.#coldBytes += entrySize(key, entry.encoded);
156
+ }
94
157
  }
95
158
  clear() {
96
159
  this.#entries.clear();
160
+ this.#doorkeeper.clear();
97
161
  this.#bytes = 0;
162
+ this.#coldBytes = 0;
98
163
  }
99
164
  statistics() {
100
165
  return {
101
166
  hits: this.#hits,
102
167
  misses: this.#misses,
103
168
  evictions: this.#evictions,
169
+ rejections: this.#rejections,
104
170
  retainedBytes: this.#bytes,
105
171
  retainedDocuments: this.#entries.size,
106
172
  };
107
173
  }
108
174
  }
175
+ function entrySize(key, encoded) {
176
+ return encoded.length + key.length * 2 + 128;
177
+ }
109
178
  // All domains call the same parser; cache keys include both bytes and source location.
110
179
  // Authorization remains in workspace reads, before any document reaches this cache.
111
180
  export const sourceDocuments = new SourceDocumentCache();
@@ -1308,6 +1308,13 @@ export class EventChainViewer {
1308
1308
  input.signal?.throwIfAborted();
1309
1309
  if (input.proposedSources !== undefined)
1310
1310
  assertProposedSourceBounds(input.proposedSources);
1311
+ // Without references the comparison uses the previous cached revision; with none cached,
1312
+ // say so before scanning the workspace.
1313
+ if (input.before === undefined &&
1314
+ input.after === undefined &&
1315
+ input.proposedSources === undefined &&
1316
+ (this.#state.history.get(input.workspaceId)?.size ?? 0) === 0)
1317
+ throw new ServiceError('EVENT_COMPARISON_BASELINE_REQUIRED', 'Provide a cached revision, graph artifact, or proposed source overlay');
1311
1318
  const refresh = input.refresh ??
1312
1319
  (input.proposedSources !== undefined ||
1313
1320
  input.before === undefined ||
@@ -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,62 @@ 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,
481
+ /** Built only when the window shows scripted localisation; building scans every helper. */
482
+ definitions = () => new ClausewitzEvaluationDefinitions()) {
483
+ if (graph.scriptedLocalisation.length === 0)
484
+ return base;
485
+ const scripted = relatedScriptedGuis(graph, windowName);
486
+ const elements = relevantElements(graph, windowName, scripted);
487
+ const textual = tokensIn(sourceTexts(graph, elements, base.language, scripted)).textual;
488
+ const names = new Set(graph.scriptedLocalisation.map(({ name }) => name));
489
+ const shown = (token) => names.has(token.slice(token.lastIndexOf('.') + 1));
490
+ const imageTokens = scriptedImageTokens(scripted);
491
+ if (![...textual, ...imageTokens].some(shown))
492
+ return base;
493
+ const evaluation = definitions();
494
+ const explicitValues = guiExplicitConditionValues(base);
495
+ const values = { ...base.values };
496
+ const templates = new Map();
497
+ const evaluate = (key, kind) => explicitValues[key] !== undefined
498
+ ? undefined
499
+ : scriptedLocalisationValue(graph, key, base.language, kind, explicitValues, base, evaluation, []);
500
+ for (const key of textual) {
501
+ const template = evaluate(key, 'text');
502
+ if (template !== undefined)
503
+ templates.set(key, template);
504
+ }
505
+ for (const key of imageTokens) {
506
+ const sprite = evaluate(key, 'image');
507
+ if (sprite !== undefined)
508
+ values[key] = sprite;
509
+ }
510
+ // Branch texts can embed further tokens, including other resolved branches.
511
+ for (let pass = 0; pass < 3; pass += 1)
512
+ for (const [key, template] of templates)
513
+ values[key] = materializeDynamicText(template, { ...explicitValues, ...values });
514
+ return Object.keys(values).length === Object.keys(base.values).length
515
+ ? base
516
+ : { ...base, values };
517
+ }
462
518
  export function generateGuiPreviewScenarios(graph, windowName, base, options, definitions = new ClausewitzEvaluationDefinitions()) {
463
519
  if (!options.enabled)
464
520
  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';
@@ -917,6 +917,8 @@ export function guiArtifactProvenance(graph, kind, primaryScene, scenarioScenes
917
917
  };
918
918
  }
919
919
  const sharedGuiGraphs = new WeakMap();
920
+ /** Graphs kept per engine: one for each scope a preview builds, across a few workspaces. */
921
+ const GUI_GRAPH_CACHE_SCOPES = 6;
920
922
  export class ScriptedGuiStudio {
921
923
  engine;
922
924
  resolver;
@@ -953,12 +955,21 @@ export class ScriptedGuiStudio {
953
955
  return cached;
954
956
  }
955
957
  const retainedFiles = [...files];
958
+ // The previous graph of this scope still indexes every unchanged file.
959
+ const previous = [...this.#graphCache].find(([existing]) => existing.startsWith(`${scope}:`))?.[1].index;
960
+ const index = this.engine.indexFiles(retainedFiles, previous);
956
961
  const scanned = {
957
962
  files: retainedFiles,
958
- graph: buildGuiSourceGraph(retainedFiles, this.engine.indexFiles(retainedFiles), additionalLocalisationKeys),
963
+ graph: buildGuiSourceGraph(retainedFiles, index, additionalLocalisationKeys),
964
+ index,
959
965
  };
966
+ // One graph per scope: a preview builds layout, game-layout and definition graphs, and a
967
+ // single shared slot made each repeat call rebuild all of them.
968
+ for (const existing of this.#graphCache.keys())
969
+ if (existing.startsWith(`${scope}:`))
970
+ this.#graphCache.delete(existing);
960
971
  this.#graphCache.set(key, scanned);
961
- while (this.#graphCache.size > 1) {
972
+ while (this.#graphCache.size > GUI_GRAPH_CACHE_SCOPES) {
962
973
  const oldest = this.#graphCache.keys().next().value;
963
974
  if (oldest === undefined)
964
975
  break;
@@ -1004,7 +1015,7 @@ export class ScriptedGuiStudio {
1004
1015
  const gameGraph = this.graphForFiles(gameLayouts.files, `game-layout:${workspaceId}`).graph;
1005
1016
  const selectedPaths = new Set(selectedElements(gameGraph, windowName, [...requiredNames]).map(({ sourcePath }) => sourcePath));
1006
1017
  layoutFiles = mergeScannedFiles(layoutFiles, gameLayouts.files.filter(({ displayPath }) => selectedPaths.has(displayPath)));
1007
- layoutGraph = this.graphForFiles(layoutFiles, `layout:${workspaceId}`).graph;
1018
+ layoutGraph = this.graphForFiles(layoutFiles, `merged-layout:${workspaceId}`).graph;
1008
1019
  }
1009
1020
  if (!layoutGraph.elements.some(({ name }) => name === windowName))
1010
1021
  assertTargetWindowAvailable(layoutGraph, windowName);
@@ -1062,16 +1073,20 @@ export class ScriptedGuiStudio {
1062
1073
  ? undefined
1063
1074
  : parseGeneratedScenarioOptions(input.generatedScenarios);
1064
1075
  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));
1076
+ let builtDefinitions;
1077
+ const evaluationDefinitions = () => (builtDefinitions ??= ClausewitzEvaluationDefinitions.build(scanned));
1078
+ const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
1079
+ const suppliedScenario = resolveLocalisation(placeholderScenario);
1065
1080
  const generatedScenarios = generatedOptions === undefined
1066
1081
  ? []
1067
- : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, ClausewitzEvaluationDefinitions.build(scanned));
1068
- const scenario = generatedScenarios[0] ?? placeholderScenario;
1082
+ : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, evaluationDefinitions());
1083
+ const scenario = generatedScenarios[0] ?? suppliedScenario;
1069
1084
  const relatedScenarios = [
1070
1085
  ...(generatedOptions?.preservePlaceholder === true && generatedScenarios.length > 0
1071
- ? [placeholderScenario]
1086
+ ? [suppliedScenario]
1072
1087
  : []),
1073
1088
  ...generatedScenarios.slice(1),
1074
- ...explicitRelatedScenarios,
1089
+ ...explicitRelatedScenarios.map(resolveLocalisation),
1075
1090
  ];
1076
1091
  assertUniqueScenarioIds([scenario, ...relatedScenarios]);
1077
1092
  const budget = new RenderBudget();
@@ -1157,16 +1172,20 @@ export class ScriptedGuiStudio {
1157
1172
  ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
1158
1173
  ...explicitRelatedScenarios,
1159
1174
  ].some(({ tooltip }) => tooltip !== undefined));
1175
+ let builtDefinitions;
1176
+ const evaluationDefinitions = () => (builtDefinitions ??= ClausewitzEvaluationDefinitions.build(scanned));
1177
+ const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
1178
+ const suppliedScenario = resolveLocalisation(placeholderScenario);
1160
1179
  const generatedScenarios = generatedOptions === undefined
1161
1180
  ? []
1162
- : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, ClausewitzEvaluationDefinitions.build(scanned));
1163
- const scenario = generatedScenarios[0] ?? placeholderScenario;
1181
+ : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, evaluationDefinitions());
1182
+ const scenario = generatedScenarios[0] ?? suppliedScenario;
1164
1183
  const relatedScenarios = [
1165
1184
  ...(generatedOptions?.preservePlaceholder === true && generatedScenarios.length > 0
1166
- ? [placeholderScenario]
1185
+ ? [suppliedScenario]
1167
1186
  : []),
1168
1187
  ...generatedScenarios.slice(1),
1169
- ...explicitRelatedScenarios,
1188
+ ...explicitRelatedScenarios.map(resolveLocalisation),
1170
1189
  ];
1171
1190
  assertUniqueScenarioIds([scenario, ...relatedScenarios]);
1172
1191
  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
  }
@@ -1,14 +1,26 @@
1
1
  import { ReadResourceResultSchema } from '@modelcontextprotocol/core';
2
2
  import { ProtocolError, ProtocolErrorCode, ResourceNotFoundError, } from '@modelcontextprotocol/server';
3
3
  import { artifactResourceDefinition, readArtifactResource } from '../../core/artifact-resource.js';
4
+ import { readServerStatus, serverStatusResource } from '../../core/server-status-resource.js';
4
5
  import { ServiceError } from '../../core/result.js';
5
6
  /** Modern wire projection of the same non-enumerating, authorization-bound resource reader. */
6
7
  export function registerModernResources(server, engine, context) {
7
- server.setRequestHandler('resources/list', () => ({ resources: [] }));
8
+ // Process details are offered to the local client only, never to authenticated remote ones.
9
+ const local = context.principal === undefined;
10
+ server.setRequestHandler('resources/list', () => ({
11
+ resources: local ? [serverStatusResource] : [],
12
+ }));
8
13
  server.setRequestHandler('resources/templates/list', () => ({
9
14
  resourceTemplates: [artifactResourceDefinition],
10
15
  }));
11
16
  server.setRequestHandler('resources/read', async (request, extra) => {
17
+ if (local && request.params.uri === serverStatusResource.uri)
18
+ return ReadResourceResultSchema.parse({
19
+ ...(await readServerStatus(engine)),
20
+ resultType: 'complete',
21
+ ttlMs: 0,
22
+ cacheScope: 'private',
23
+ });
12
24
  try {
13
25
  return ReadResourceResultSchema.parse({
14
26
  ...(await readArtifactResource(engine, request.params.uri, context, extra.mcpReq.signal)),
@@ -1,7 +1,13 @@
1
1
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { artifactResourceDefinition, readArtifactResource } from '../../core/artifact-resource.js';
3
+ import { readServerStatus, serverStatusResource } from '../../core/server-status-resource.js';
3
4
  /** SDK-v1 registration only; ownership and byte-range behavior live in the shared core. */
4
5
  export function registerMcpResources(server, engine, context) {
5
6
  const { name, uriTemplate, ...metadata } = artifactResourceDefinition;
6
7
  server.registerResource(name, new ResourceTemplate(uriTemplate, { list: undefined }), metadata, (uri, _variables, extra) => readArtifactResource(engine, uri.href, context, extra.signal));
8
+ // Process details are offered to the local client only, never to authenticated remote ones.
9
+ if (context.principal !== undefined)
10
+ return;
11
+ const { name: statusName, uri: statusUri, ...statusMetadata } = serverStatusResource;
12
+ server.registerResource(statusName, statusUri, statusMetadata, () => readServerStatus(engine));
7
13
  }
@@ -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,
@@ -1,7 +1,7 @@
1
1
  import { registerLegacyTaskTools } from '../server/legacy-task-tools.js';
2
2
  import { z } from 'zod/v4';
3
3
  import { helperExpansionRequestSchema, helperExpansionSummarySchema, } from '../../schemas/helper-expansion.js';
4
- import { technologyAnalysisModeSchema, technologyCompareRequestSchema, technologyGraphReferenceSchema, technologyImpactSchema, technologyInspectRequestSchema, technologyProposedSourceSchema, technologyRenderRequestSchema, technologyRenderViewSchema, validateTechnologyCompareRequest, validateTechnologyInspectRequest, } from '../../schemas/technology.js';
4
+ import { technologyAnalysisModeSchema, technologyCompareRequestSchema, technologyGraphReferenceSchema, technologyImpactSchema, technologyInspectRequestSchema, technologyProposedSourceSchema, technologyRenderRequestSchema, validateTechnologyRenderRequest, technologyRenderViewSchema, validateTechnologyCompareRequest, validateTechnologyInspectRequest, } from '../../schemas/technology.js';
5
5
  import { compactValidatedInputSchema } from '../server/context-schemas.js';
6
6
  import { nonNegativeIntegerSchema, sha256Schema } from '../server/output-schemas.js';
7
7
  import { strictOperationResultSchema } from '../server/result.js';
@@ -13,7 +13,10 @@ const inspectInputSchema = z
13
13
  })
14
14
  .strict()
15
15
  .superRefine(validateTechnologyInspectRequest);
16
- const renderInputSchema = z.object({ ...technologyRenderRequestSchema.shape }).strict();
16
+ const renderInputSchema = z
17
+ .object({ ...technologyRenderRequestSchema.shape })
18
+ .strict()
19
+ .superRefine(validateTechnologyRenderRequest);
17
20
  const compareInputSchema = z
18
21
  .object({
19
22
  ...technologyCompareRequestSchema.shape,
@@ -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,79 @@
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
+ import { processAlive } from '../../core/server-registry.js';
9
+ const PARENT_POLL_MS = 5_000;
10
+ export { processAlive };
11
+ export class StdioLifetime {
12
+ #options;
13
+ #open = new Set();
14
+ #lastActivity;
15
+ #timer;
16
+ #stopped = false;
17
+ constructor(options) {
18
+ this.#options = {
19
+ parentPid: options.parentPid ?? process.ppid,
20
+ isAlive: options.isAlive ?? processAlive,
21
+ now: options.now ?? Date.now,
22
+ pollMs: options.pollMs ?? PARENT_POLL_MS,
23
+ idleExitMinutes: options.idleExitMinutes,
24
+ onExit: options.onExit,
25
+ };
26
+ this.#lastActivity = this.#options.now();
27
+ }
28
+ start() {
29
+ this.#timer = setInterval(() => this.check(), this.#options.pollMs);
30
+ // The poll never keeps an otherwise finished server alive.
31
+ this.#timer.unref();
32
+ }
33
+ stop() {
34
+ this.#stopped = true;
35
+ if (this.#timer !== undefined)
36
+ clearInterval(this.#timer);
37
+ }
38
+ /** A client message: a request opens until its response is sent. */
39
+ received(message) {
40
+ this.#lastActivity = this.#options.now();
41
+ const id = requestId(message, 'method');
42
+ if (id !== undefined)
43
+ this.#open.add(id);
44
+ }
45
+ /** A server message: a response closes its request. */
46
+ sent(message) {
47
+ this.#lastActivity = this.#options.now();
48
+ const id = requestId(message, 'result') ?? requestId(message, 'error');
49
+ if (id !== undefined)
50
+ this.#open.delete(id);
51
+ }
52
+ /** When the client last sent or received a message, in epoch milliseconds. */
53
+ lastActivity() {
54
+ return this.#lastActivity;
55
+ }
56
+ check() {
57
+ if (this.#stopped)
58
+ return;
59
+ const { parentPid, isAlive, idleExitMinutes, now, onExit } = this.#options;
60
+ // A parent pid of 0 or 1 means the server was already reparented at start.
61
+ if (parentPid > 1 && !isAlive(parentPid)) {
62
+ this.stop();
63
+ onExit('parent_exited');
64
+ return;
65
+ }
66
+ if (idleExitMinutes > 0 &&
67
+ this.#open.size === 0 &&
68
+ now() - this.#lastActivity > idleExitMinutes * 60_000) {
69
+ this.stop();
70
+ onExit('idle');
71
+ }
72
+ }
73
+ }
74
+ function requestId(message, field) {
75
+ if (typeof message !== 'object' || message === null || !(field in message))
76
+ return undefined;
77
+ const id = message.id;
78
+ return typeof id === 'string' || typeof id === 'number' ? id : undefined;
79
+ }