hoi4-agent-tools 4.0.0 → 4.0.1

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 (84) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +29 -53
  3. package/dist/hoi4_agent_tools/core/condition-evaluator.js +18 -0
  4. package/dist/hoi4_agent_tools/core/job-worker-host.js +115 -27
  5. package/dist/hoi4_agent_tools/core/job-worker.js +7 -2
  6. package/dist/hoi4_agent_tools/core/temporary-directories.js +293 -0
  7. package/dist/hoi4_agent_tools/core/worker-exit.js +80 -0
  8. package/dist/hoi4_agent_tools/gui/assets.js +3 -0
  9. package/dist/hoi4_agent_tools/gui/dds.js +8 -4
  10. package/dist/hoi4_agent_tools/gui/layout.js +45 -5
  11. package/dist/hoi4_agent_tools/gui/native-state.js +244 -0
  12. package/dist/hoi4_agent_tools/gui/renderer.js +12 -7
  13. package/dist/hoi4_agent_tools/gui/source-graph.js +17 -4
  14. package/dist/hoi4_agent_tools/gui/studio.js +33 -21
  15. package/dist/hoi4_agent_tools/mcp/prompts/probability.js +2 -2
  16. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  17. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +8 -8
  18. package/dist/hoi4_agent_tools/mcp/tools/event.js +3 -3
  19. package/dist/hoi4_agent_tools/mcp/tools/focus.js +5 -5
  20. package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -6
  21. package/dist/hoi4_agent_tools/mcp/tools/job.js +4 -4
  22. package/dist/hoi4_agent_tools/mcp/tools/map.js +4 -4
  23. package/dist/hoi4_agent_tools/mcp/tools/probability.js +4 -4
  24. package/dist/hoi4_agent_tools/mcp/tools/reference.js +12 -12
  25. package/dist/hoi4_agent_tools/mcp/tools/technology.js +3 -3
  26. package/dist/hoi4_agent_tools/runtime.js +14 -1
  27. package/dist/hoi4_agent_tools/schemas/map-requests.js +1 -1
  28. package/dist/hoi4_agent_tools/version.js +1 -1
  29. package/docs/analysis.md +2 -2
  30. package/docs/development.md +2 -0
  31. package/docs/events.md +2 -3
  32. package/docs/examples.md +8 -23
  33. package/docs/focus.md +3 -2
  34. package/docs/gui.md +7 -4
  35. package/docs/helper-expansion.md +1 -1
  36. package/docs/jobs.md +5 -2
  37. package/docs/map.md +5 -4
  38. package/docs/mechanics.md +2 -2
  39. package/docs/probability.md +5 -2
  40. package/docs/reference.md +2 -1
  41. package/docs/setup.md +2 -4
  42. package/docs/technology.md +2 -3
  43. package/docs/tools.md +12 -13
  44. package/docs/troubleshooting.md +1 -1
  45. package/npm-shrinkwrap.json +2 -2
  46. package/package.json +2 -2
  47. package/schemas/configuration.schema.json +1 -1
  48. package/schemas/continuous-focus-palette.schema.json +1 -1
  49. package/schemas/custom-weighted-pool.schema.json +1 -1
  50. package/schemas/decision-inspect-input.schema.json +1 -1
  51. package/schemas/error-log-input.schema.json +1 -1
  52. package/schemas/event-feature-manifest.schema.json +1 -1
  53. package/schemas/focus-plan.schema.json +1 -1
  54. package/schemas/focus-planning-sidecar.schema.json +1 -1
  55. package/schemas/gui-animation-source.schema.json +1 -1
  56. package/schemas/gui-helper.schema.json +1 -1
  57. package/schemas/gui-scenario.schema.json +1 -1
  58. package/schemas/helper-expansion-request.schema.json +1 -1
  59. package/schemas/helper-expansion-summary.schema.json +1 -1
  60. package/schemas/impact-inspect-input.schema.json +1 -1
  61. package/schemas/map-operation.schema.json +1 -1
  62. package/schemas/mechanic-test-input.schema.json +1 -1
  63. package/schemas/mod-index-input.schema.json +1 -1
  64. package/schemas/operation-result.schema.json +1 -1
  65. package/schemas/package-check-input.schema.json +1 -1
  66. package/schemas/package-manifest.schema.json +1 -1
  67. package/schemas/probability-analysis-result.schema.json +1 -1
  68. package/schemas/probability-compare-input.schema.json +1 -1
  69. package/schemas/probability-evaluate-input.schema.json +1 -1
  70. package/schemas/probability-inspect-input.schema.json +1 -1
  71. package/schemas/probability-render-input.schema.json +1 -1
  72. package/schemas/probability-scenario-set.schema.json +1 -1
  73. package/schemas/probability-sequence-input.schema.json +1 -1
  74. package/schemas/probability-simulate-input.schema.json +1 -1
  75. package/schemas/probability-sweep-input.schema.json +1 -1
  76. package/schemas/reference-context-input.schema.json +1 -1
  77. package/schemas/reference-read-input.schema.json +1 -1
  78. package/schemas/reference-search-input.schema.json +1 -1
  79. package/schemas/scenario-suite.schema.json +1 -1
  80. package/schemas/scenario-test-input.schema.json +1 -1
  81. package/schemas/script-validate-input.schema.json +1 -1
  82. package/schemas/shared-scenario.schema.json +1 -1
  83. package/schemas/source-lookup-input.schema.json +1 -1
  84. package/server.json +3 -3
@@ -0,0 +1,244 @@
1
+ import { assignments, parseClausewitz } from '../core/source/index.js';
2
+ /** Windows whose branches the engine selects, keyed by the window's root element name. */
3
+ const NATIVE_BRANCHES = {
4
+ countrypoliticsview: [
5
+ {
6
+ elements: ['no_faction'],
7
+ shown: (state) => state.faction === 'none',
8
+ describe: 'the country is not in a faction',
9
+ },
10
+ {
11
+ elements: ['original_in_faction'],
12
+ shown: (state) => state.faction === 'leader',
13
+ describe: 'the country leads its faction',
14
+ },
15
+ {
16
+ elements: ['in_faction'],
17
+ shown: (state) => state.faction === 'member',
18
+ describe: 'the country is a member of a faction it does not lead',
19
+ },
20
+ {
21
+ elements: ['power_balance_button'],
22
+ shown: (state) => state.balanceOfPower,
23
+ describe: 'the country has an active balance of power',
24
+ },
25
+ ],
26
+ };
27
+ /**
28
+ * Icons whose frame the game picks from the ruling ideology, as the ideology group at each
29
+ * one-based frame. `pol_factions_strip` holds fascism, communism, democratic and neutrality.
30
+ */
31
+ const IDEOLOGY_FRAME_ICONS = {
32
+ pol_faction_icon: ['fascism', 'communism', 'democratic', 'neutrality'],
33
+ };
34
+ const DEFAULT_SCENARIO_DATE = '1936.1.1';
35
+ function scenarioDate(scenario) {
36
+ const parts = (scenario.date ?? DEFAULT_SCENARIO_DATE).split('.').map(Number);
37
+ return dateKey(parts[0] ?? 1936, parts[1] ?? 1, parts[2] ?? 1);
38
+ }
39
+ function dateKey(year, month, day) {
40
+ return year * 10_000 + month * 100 + day;
41
+ }
42
+ function scenarioCountryTag(scenario) {
43
+ const candidate = [
44
+ scenario.country?.tag,
45
+ scenario.country?.countryTag,
46
+ scenario.country?.GetTag,
47
+ ].find((value) => typeof value === 'string' && /^[A-Za-z0-9_]{2,64}$/u.test(value));
48
+ return candidate?.toUpperCase();
49
+ }
50
+ function scenarioString(scenario, ...keys) {
51
+ for (const key of keys) {
52
+ const value = scenario.country?.[key];
53
+ if (typeof value === 'string' && /^[A-Za-z0-9_]{2,64}$/u.test(value))
54
+ return value.toLowerCase();
55
+ }
56
+ return undefined;
57
+ }
58
+ function scenarioBoolean(scenario, ...keys) {
59
+ for (const key of keys) {
60
+ const value = scenario.country?.[key];
61
+ if (typeof value === 'boolean')
62
+ return value;
63
+ if (typeof value === 'string' && /^(?:yes|true)$/iu.test(value))
64
+ return true;
65
+ if (typeof value === 'string' && /^(?:no|false)$/iu.test(value))
66
+ return false;
67
+ }
68
+ return undefined;
69
+ }
70
+ function explicitFaction(scenario) {
71
+ const named = scenarioString(scenario, 'faction', 'factionRole');
72
+ if (named === 'none' || named === 'member' || named === 'leader')
73
+ return named;
74
+ const leader = scenarioBoolean(scenario, 'factionLeader', 'isFactionLeader');
75
+ if (leader === true)
76
+ return 'leader';
77
+ const member = scenarioBoolean(scenario, 'inFaction', 'isInFaction', 'faction');
78
+ if (member === false)
79
+ return 'none';
80
+ if (member === true)
81
+ return 'member';
82
+ return undefined;
83
+ }
84
+ const FACTION_CREATION = new Set(['create_faction', 'create_faction_from_template']);
85
+ function visitHistory(entries, cutoff, facts, members, owner) {
86
+ for (const entry of entries) {
87
+ if (entry.type !== 'assignment')
88
+ continue;
89
+ const key = entry.key.value;
90
+ const dated = /^(\d{1,4})\.(\d{1,2})\.(\d{1,2})$/u.exec(key);
91
+ if (dated !== null) {
92
+ // A dated block applies from its date; later blocks have not happened yet.
93
+ if (entry.value.type === 'block' && dateKey(+dated[1], +dated[2], +dated[3]) <= cutoff)
94
+ visitHistory(entry.value.entries, cutoff, facts, members, owner);
95
+ continue;
96
+ }
97
+ if (key === 'set_politics' && entry.value.type === 'block') {
98
+ const party = assignments(entry.value, 'ruling_party')[0]?.value;
99
+ if (party?.type === 'scalar')
100
+ facts.ideology = party.value.toLowerCase();
101
+ }
102
+ else if (FACTION_CREATION.has(key)) {
103
+ facts.createsFaction = true;
104
+ }
105
+ else if (key === 'set_power_balance') {
106
+ facts.powerBalance = true;
107
+ }
108
+ else if (key === 'remove_power_balance') {
109
+ facts.powerBalance = false;
110
+ }
111
+ else if (key === 'add_to_faction' &&
112
+ entry.value.type === 'scalar' &&
113
+ entry.value.value.toUpperCase() !== owner) {
114
+ const tag = entry.value.value.toUpperCase();
115
+ const joined = members.get(tag) ?? new Set();
116
+ joined.add(owner);
117
+ members.set(tag, joined);
118
+ }
119
+ else if ((key === 'if' || key === 'else' || key === 'else_if') &&
120
+ entry.value.type === 'block') {
121
+ // Conditions such as DLC checks cannot be evaluated here; their content is treated as
122
+ // part of the start, which matches a fully owned game.
123
+ visitHistory(entry.value.entries, cutoff, facts, members, owner);
124
+ }
125
+ }
126
+ }
127
+ function historyTag(file) {
128
+ const match = /^history\/countries\/([A-Za-z0-9_]{2,64})(?:\s*-[^/]*)?\.txt$/u.exec(file.relativePath.replaceAll('\\', '/'));
129
+ return match?.[1]?.toUpperCase();
130
+ }
131
+ function historyState(tag, files, cutoff) {
132
+ const facts = {
133
+ createsFaction: false,
134
+ powerBalance: false,
135
+ };
136
+ // `add_to_faction = X` in a leader's history adds X to that leader's faction, so a country
137
+ // is a member when another country's history adds it.
138
+ const additions = new Map();
139
+ let found = false;
140
+ for (const file of files) {
141
+ if (file.shadowedBy !== undefined)
142
+ continue;
143
+ const fileTag = historyTag(file);
144
+ if (fileTag === undefined)
145
+ continue;
146
+ const owned = fileTag === tag;
147
+ if (owned)
148
+ found = true;
149
+ // Only the country's own history carries its other facts; every other file can matter
150
+ // solely through the members it adds to a faction.
151
+ else if (!file.bytes.includes('add_to_faction'))
152
+ continue;
153
+ const document = parseClausewitz(file.bytes, file.displayPath);
154
+ const own = {
155
+ createsFaction: false,
156
+ powerBalance: false,
157
+ };
158
+ visitHistory(document.root.entries, cutoff, owned ? facts : own, additions, fileTag);
159
+ }
160
+ const memberOfFaction = (additions.get(tag)?.size ?? 0) > 0;
161
+ return { facts, found, memberOfFaction };
162
+ }
163
+ /** Resolve the country facts the engine would use, explicit scenario values first. */
164
+ export function resolveNativeCountryState(scenario, files) {
165
+ const tag = scenarioCountryTag(scenario);
166
+ const history = tag === undefined
167
+ ? undefined
168
+ : historyState(tag, files.filter(({ relativePath }) => relativePath.startsWith('history/countries/')), scenarioDate(scenario));
169
+ const knownHistory = history?.found === true ? history : undefined;
170
+ const ideologyFromScenario = scenarioString(scenario, 'ideology', 'rulingIdeology', 'countryIdeology');
171
+ const factionFromScenario = explicitFaction(scenario);
172
+ const bopFromScenario = scenarioBoolean(scenario, 'balanceOfPower', 'hasBalanceOfPower', 'powerBalance');
173
+ const ideology = ideologyFromScenario ?? knownHistory?.facts.ideology;
174
+ const faction = factionFromScenario ??
175
+ (knownHistory === undefined
176
+ ? 'none'
177
+ : knownHistory.facts.createsFaction
178
+ ? 'leader'
179
+ : knownHistory.memberOfFaction
180
+ ? 'member'
181
+ : 'none');
182
+ return {
183
+ tag,
184
+ ideology,
185
+ faction,
186
+ balanceOfPower: bopFromScenario ?? knownHistory?.facts.powerBalance ?? false,
187
+ sources: {
188
+ ideology: ideologyFromScenario !== undefined
189
+ ? 'scenario'
190
+ : knownHistory?.facts.ideology !== undefined
191
+ ? 'history'
192
+ : 'unknown',
193
+ faction: factionFromScenario !== undefined
194
+ ? 'scenario'
195
+ : knownHistory !== undefined
196
+ ? 'history'
197
+ : 'default',
198
+ balanceOfPower: bopFromScenario !== undefined
199
+ ? 'scenario'
200
+ : knownHistory !== undefined
201
+ ? 'history'
202
+ : 'default',
203
+ },
204
+ };
205
+ }
206
+ /** Whether any native decision can apply to this window, so the country state is worth resolving. */
207
+ export function windowHasNativeState(windowName, elementNames) {
208
+ return (NATIVE_BRANCHES[windowName] !== undefined ||
209
+ Object.keys(IDEOLOGY_FRAME_ICONS).some((name) => elementNames.has(name)));
210
+ }
211
+ /** The engine-selected branches and frames for a scene, below any explicit scenario input. */
212
+ export function nativeWindowState(windowName, elementNames, scenario, files) {
213
+ if (!windowHasNativeState(windowName, elementNames))
214
+ return undefined;
215
+ const state = resolveNativeCountryState(scenario, files);
216
+ const visibility = {};
217
+ const values = {};
218
+ const notes = [];
219
+ const subject = state.tag ?? 'the scenario country';
220
+ for (const rule of NATIVE_BRANCHES[windowName] ?? []) {
221
+ const shown = rule.shown(state);
222
+ for (const name of rule.elements)
223
+ visibility[name] = shown;
224
+ notes.push(`${rule.elements.join(', ')} ${shown ? 'is shown' : 'is hidden'} for ${subject}; the game shows it only when ${rule.describe}.`);
225
+ }
226
+ for (const [name, order] of Object.entries(IDEOLOGY_FRAME_ICONS)) {
227
+ if (!elementNames.has(name) || state.ideology === undefined)
228
+ continue;
229
+ const index = order.indexOf(state.ideology);
230
+ if (index < 0)
231
+ continue;
232
+ values[`${name}.frame`] = index + 1;
233
+ notes.push(`${name} shows the ${state.ideology} frame for ${subject}.`);
234
+ }
235
+ return { visibility, values, notes, state };
236
+ }
237
+ /** Whether the scenario needs history to resolve its native state, and so to be scanned. */
238
+ export function scenarioNeedsCountryHistory(scenario) {
239
+ if (scenarioCountryTag(scenario) === undefined)
240
+ return false;
241
+ return (scenarioString(scenario, 'ideology', 'rulingIdeology', 'countryIdeology') === undefined ||
242
+ explicitFaction(scenario) === undefined ||
243
+ scenarioBoolean(scenario, 'balanceOfPower', 'hasBalanceOfPower', 'powerBalance') === undefined);
244
+ }
@@ -658,15 +658,20 @@ export async function renderGuiScene(scene, variants = defaultVariants, signal,
658
658
  const basePng = await sharp(Buffer.from(full.svg), { limitInputPixels: RENDER_MAX_PIXELS })
659
659
  .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
660
660
  .toBuffer();
661
- const rendered = await Promise.all(prepared.map(async (item) => {
662
- if (item.variant === 'full')
663
- return {
661
+ // Derived variants are rasterized one at a time: each decodes the full-size base raster, so
662
+ // running them together multiplies the peak native memory of one render.
663
+ const rendered = [];
664
+ for (const item of prepared) {
665
+ if (item.variant === 'full') {
666
+ rendered.push({
664
667
  variant: item.variant,
665
668
  svg: item.svg,
666
669
  png: basePng,
667
670
  width: item.view.width,
668
671
  height: item.view.height,
669
- };
672
+ });
673
+ continue;
674
+ }
670
675
  signal?.throwIfAborted();
671
676
  budget.reserveRasterOperation(`gui-derived-variant:${sha256Bytes(item.svg)}`, `GUI ${item.variant} derived rasterization`);
672
677
  const png = item.variant === 'cropped'
@@ -684,14 +689,14 @@ export async function renderGuiScene(scene, variants = defaultVariants, signal,
684
689
  .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
685
690
  .toBuffer();
686
691
  signal?.throwIfAborted();
687
- return {
692
+ rendered.push({
688
693
  variant: item.variant,
689
694
  svg: item.svg,
690
695
  png,
691
696
  width: item.view.width,
692
697
  height: item.view.height,
693
- };
694
- }));
698
+ });
699
+ }
695
700
  images = rendered;
696
701
  }
697
702
  return {
@@ -76,6 +76,7 @@ const modelledElementAttributes = new Set([
76
76
  'hide',
77
77
  'hidden',
78
78
  'visible',
79
+ 'show',
79
80
  'enabled',
80
81
  'context_aware_text',
81
82
  ]);
@@ -1063,9 +1064,13 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
1063
1064
  for (const font of fonts) {
1064
1065
  const key = font.name.toLocaleLowerCase('en-US');
1065
1066
  const existing = fontByName.get(key);
1067
+ const declared = font.kind === 'bitmapfont';
1068
+ const existingDeclared = existing?.kind === 'bitmapfont';
1066
1069
  if (existing === undefined ||
1067
- (existing.override && !font.override) ||
1068
- (existing.languages.length > 0 && font.languages.length === 0))
1070
+ (!existingDeclared && declared) ||
1071
+ (existingDeclared === declared &&
1072
+ ((existing.override && !font.override) ||
1073
+ (existing.languages.length > 0 && font.languages.length === 0))))
1069
1074
  fontByName.set(key, font);
1070
1075
  }
1071
1076
  const elementByName = new Map();
@@ -1126,7 +1131,7 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
1126
1131
  addEdge(edges, 'uses_font', element.id, font?.id ?? `font:${fontName}`, font !== undefined, { fontName }, element.location);
1127
1132
  }
1128
1133
  for (const key of localisationAttributeNames) {
1129
- const value = element.attributes[key];
1134
+ const value = localisationAttributeValue(element.attributes, key);
1130
1135
  if (typeof value !== 'string' ||
1131
1136
  value.trim().length === 0 ||
1132
1137
  !isStaticGuiLocalisationReference(value))
@@ -1206,6 +1211,14 @@ const localisationAttributeNames = [
1206
1211
  'pdx_tooltip_delayed',
1207
1212
  'hint_tag',
1208
1213
  ];
1214
+ /** Clausewitz keys are case-insensitive: vanilla spells the button label `buttontext`. */
1215
+ function localisationAttributeValue(attributes, name) {
1216
+ const lowered = name.toLowerCase();
1217
+ for (const [key, value] of Object.entries(attributes))
1218
+ if (key.toLowerCase() === lowered)
1219
+ return value;
1220
+ return undefined;
1221
+ }
1209
1222
  function isStaticGuiLocalisationReference(value) {
1210
1223
  const trimmed = value.trim();
1211
1224
  return !(trimmed.startsWith('[') && trimmed.endsWith(']'));
@@ -1214,7 +1227,7 @@ function referencedGuiLocalisationKeys(elements, scriptedLocalisation) {
1214
1227
  const keys = new Set(scriptedLocalisation.flatMap(({ localisationKeys }) => localisationKeys));
1215
1228
  for (const element of elements) {
1216
1229
  for (const attribute of localisationAttributeNames) {
1217
- const value = element.attributes[attribute];
1230
+ const value = localisationAttributeValue(element.attributes, attribute);
1218
1231
  if (typeof value === 'string' &&
1219
1232
  value.trim().length > 0 &&
1220
1233
  isStaticGuiLocalisationReference(value))
@@ -17,6 +17,7 @@ import { TRANSACTION_MAX_DIAGNOSTICS } from '../core/transaction-limits.js';
17
17
  import { PACKAGE_VERSION } from '../version.js';
18
18
  import { isSafeAnimationSourcePath } from './animation-manifest.js';
19
19
  import { GuiAssetCatalog, parseBmFont } from './assets.js';
20
+ import { scenarioNeedsCountryHistory } from './native-state.js';
20
21
  import { projectGuiGraphForArtifact } from './inspection-artifact.js';
21
22
  import { buildGuiScene } from './layout.js';
22
23
  import { GUI_VALIDATION_MAX_DIAGNOSTICS } from './limits.js';
@@ -333,7 +334,13 @@ function assertTargetWindowAvailable(graph, windowName) {
333
334
  })),
334
335
  });
335
336
  }
337
+ const lowerCaseKeySets = new WeakMap();
336
338
  function collectNamedAttributes(value, keys, output) {
339
+ let lowered = lowerCaseKeySets.get(keys);
340
+ if (lowered === undefined) {
341
+ lowered = new Set([...keys].map((key) => key.toLowerCase()));
342
+ lowerCaseKeySets.set(keys, lowered);
343
+ }
337
344
  if (Array.isArray(value)) {
338
345
  for (const entry of value)
339
346
  collectNamedAttributes(entry, keys, output);
@@ -342,7 +349,7 @@ function collectNamedAttributes(value, keys, output) {
342
349
  if (typeof value !== 'object' || value === null)
343
350
  return;
344
351
  for (const [key, entry] of Object.entries(value)) {
345
- if (keys.has(key) && typeof entry === 'string')
352
+ if (lowered.has(key.toLowerCase()) && typeof entry === 'string')
346
353
  output.add(entry);
347
354
  collectNamedAttributes(entry, keys, output);
348
355
  }
@@ -559,6 +566,16 @@ export function scenarioCountryFlagPatterns(scenarios) {
559
566
  `gfx/flags/${directory}${tag}_*.{bmp,dds,png,tga}`,
560
567
  ]));
561
568
  }
569
+ /**
570
+ * Engine-controlled windows read the scenario country's faction, balance of power and ruling
571
+ * party from country history; the faction facts also live in other countries' histories.
572
+ */
573
+ function scenarioCountryHistoryPatterns(scenarios) {
574
+ return scenarios.some(scenarioNeedsCountryHistory) ? ['history/countries/*.txt'] : [];
575
+ }
576
+ function scenarioCountryAssetPatterns(scenarios) {
577
+ return [...scenarioCountryFlagPatterns(scenarios), ...scenarioCountryHistoryPatterns(scenarios)];
578
+ }
562
579
  function bmFontPagePatterns(files) {
563
580
  const references = files.flatMap((file) => {
564
581
  if (!file.relativePath.toLowerCase().endsWith('.fnt') || file.shadowedBy !== undefined)
@@ -1072,7 +1089,7 @@ export class ScriptedGuiStudio {
1072
1089
  const generatedOptions = input.generatedScenarios === undefined
1073
1090
  ? undefined
1074
1091
  : parseGeneratedScenarioOptions(input.generatedScenarios);
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));
1092
+ 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]), scenarioCountryAssetPatterns([placeholderScenario, ...explicitRelatedScenarios]), scenarioLocalisationKeys([placeholderScenario, ...explicitRelatedScenarios]), [placeholderScenario, ...explicitRelatedScenarios].some(({ tooltip }) => tooltip !== undefined));
1076
1093
  let builtDefinitions;
1077
1094
  const evaluationDefinitions = () => (builtDefinitions ??= ClausewitzEvaluationDefinitions.build(scanned));
1078
1095
  const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
@@ -1106,15 +1123,14 @@ export class ScriptedGuiStudio {
1106
1123
  const budget = new RenderBudget();
1107
1124
  const beforeScenario = parsePreviewScenario(input.before);
1108
1125
  const afterScenario = parsePreviewScenario(input.after);
1109
- const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]), scenarioTemplateNames([beforeScenario, afterScenario]), scenarioCountryFlagPatterns([beforeScenario, afterScenario]), scenarioLocalisationKeys([beforeScenario, afterScenario]), [beforeScenario, afterScenario].some(({ tooltip }) => tooltip !== undefined));
1126
+ const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]), scenarioTemplateNames([beforeScenario, afterScenario]), scenarioCountryAssetPatterns([beforeScenario, afterScenario]), scenarioLocalisationKeys([beforeScenario, afterScenario]), [beforeScenario, afterScenario].some(({ tooltip }) => tooltip !== undefined));
1110
1127
  const beforeCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, beforeScenario.language);
1111
1128
  const afterCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, afterScenario.language);
1112
1129
  const before = await buildGuiScene(scanned.graph, scanned.files, input.windowName, beforeScenario, beforeCatalog);
1113
1130
  const after = await buildGuiScene(scanned.graph, scanned.files, input.windowName, afterScenario, afterCatalog);
1114
- const [beforeRender, afterRender] = await Promise.all([
1115
- renderGuiScene(before, ['full'], input.signal, budget),
1116
- renderGuiScene(after, ['full'], input.signal, budget),
1117
- ]);
1131
+ // One full raster at a time keeps the peak native memory of a comparison to a single render.
1132
+ const beforeRender = await renderGuiScene(before, ['full'], input.signal, budget);
1133
+ const afterRender = await renderGuiScene(after, ['full'], input.signal, budget);
1118
1134
  const comparison = await compareGuiImages(fullImage(beforeRender).png, fullImage(afterRender).png, budget, input.signal);
1119
1135
  const evidenceJson = `${canonicalJson({
1120
1136
  offline: true,
@@ -1159,7 +1175,7 @@ export class ScriptedGuiStudio {
1159
1175
  placeholderScenario,
1160
1176
  ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
1161
1177
  ...explicitRelatedScenarios,
1162
- ]), scenarioCountryFlagPatterns([
1178
+ ]), scenarioCountryAssetPatterns([
1163
1179
  placeholderScenario,
1164
1180
  ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
1165
1181
  ...explicitRelatedScenarios,
@@ -1316,17 +1332,13 @@ export class ScriptedGuiStudio {
1316
1332
  galleryBudget.reserveRasterOperation(`gui-gallery:${sha256Bytes(scenarioGallerySvg)}`, 'GUI scripted scenario gallery SVG rasterization');
1317
1333
  galleryBudget.reserveRasterOperation(`gui-gallery:${sha256Bytes(stateGallerySvg)}`, 'GUI state gallery SVG rasterization');
1318
1334
  galleryBudget.reserveRasterOperation(`gui-gallery:${sha256Bytes(resolutionGallerySvg)}`, 'GUI resolution gallery SVG rasterization');
1319
- const [scenarioGalleryPng, stateGalleryPng, resolutionGalleryPng] = await Promise.all([
1320
- sharp(Buffer.from(scenarioGallerySvg), { limitInputPixels: RENDER_MAX_PIXELS })
1321
- .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
1322
- .toBuffer(),
1323
- sharp(Buffer.from(stateGallerySvg), { limitInputPixels: RENDER_MAX_PIXELS })
1324
- .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
1325
- .toBuffer(),
1326
- sharp(Buffer.from(resolutionGallerySvg), { limitInputPixels: RENDER_MAX_PIXELS })
1327
- .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
1328
- .toBuffer(),
1329
- ]);
1335
+ const rasterizeGallery = (svg) => sharp(Buffer.from(svg), { limitInputPixels: RENDER_MAX_PIXELS })
1336
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
1337
+ .toBuffer();
1338
+ // Gallery canvases are large and independent; rasterize them one at a time.
1339
+ const scenarioGalleryPng = await rasterizeGallery(scenarioGallerySvg);
1340
+ const stateGalleryPng = await rasterizeGallery(stateGallerySvg);
1341
+ const resolutionGalleryPng = await rasterizeGallery(resolutionGallerySvg);
1330
1342
  const slug = safeSlug(input.windowName);
1331
1343
  const projectedArtifactGraph = projectGuiGraphForArtifact(scanned.graph, [
1332
1344
  ...new Set([...scenarioScenes, ...stateScenes, ...resolutionScenes, baselineScene].flatMap(({ elements }) => elements.map(({ sourceId }) => sourceId))),
@@ -1522,7 +1534,7 @@ export class ScriptedGuiStudio {
1522
1534
  const previewScenario = input.scenario === undefined ? undefined : parsePreviewScenario(input.scenario);
1523
1535
  const initiallyScanned = input.windowName === undefined
1524
1536
  ? await this.scan(input.workspaceId, input.principal, input.signal)
1525
- : await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]), scenarioTemplateNames([previewScenario]), scenarioCountryFlagPatterns([previewScenario]), scenarioLocalisationKeys([previewScenario]), previewScenario.tooltip !== undefined);
1537
+ : await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]), scenarioTemplateNames([previewScenario]), scenarioCountryAssetPatterns([previewScenario]), scenarioLocalisationKeys([previewScenario]), previewScenario.tooltip !== undefined);
1526
1538
  const exactTargets = await this.scanner.scan(workspace, {
1527
1539
  patterns: prepared.map(({ relativePath }) => relativePath),
1528
1540
  ...(input.signal === undefined ? {} : { signal: input.signal }),
@@ -1544,7 +1556,7 @@ export class ScriptedGuiStudio {
1544
1556
  let proposedGraph = buildGuiSourceGraph(proposedFiles, this.engine.indexFiles(proposedFiles));
1545
1557
  const proposedAssetPatterns = [
1546
1558
  ...referencedAssetPatternsForWindow(proposedGraph, input.windowName, [], [scenario.language], scenarioTemplateNames([scenario])),
1547
- ...scenarioCountryFlagPatterns([scenario]),
1559
+ ...scenarioCountryAssetPatterns([scenario]),
1548
1560
  ];
1549
1561
  const exactProposedAssets = proposedAssetPatterns.length === 0
1550
1562
  ? []
@@ -5,8 +5,8 @@ export const probabilityPromptArguments = z.object({
5
5
  });
6
6
  export const probabilityPromptDefinition = {
7
7
  name: 'hoi4.probability_analysis',
8
- title: 'Analyze HOI4 weighted logic',
9
- description: 'Plan one source-linked AI, MTTH, random, or declared-pool analysis and return to the owning modding workflow.',
8
+ title: 'Analyze AI choices and event timing',
9
+ description: 'Outline an analysis of AI choices, event timing, random chances or a custom weighted pool.',
10
10
  };
11
11
  /** One prompt body for both protocol generations; it does not run an analysis. */
12
12
  export function probabilityPrompt(input) {
@@ -1,2 +1,2 @@
1
1
  /** Shared agent-facing guidance for both MCP protocol generations. */
2
- export const SERVER_INSTRUCTIONS = 'Start with the matching inspect tool; use hoi4.probability_inspect for weights and timing. Find local documentation with hoi4.reference_context, hoi4.reference_search and hoi4.reference_read, and source definitions with hoi4.source_lookup. Inspection and rendering are read-only; rewrite tools edit mod source. Results describe offline source and scenario models, not observed game behavior. Unresolved states are not confirmed coverage. Read linked artifacts when summaries omit detail. Long operations support negotiated MCP tasks and hoi4.job_inspect/hoi4.job_cancel; background rewrites need a stable requestKey.';
2
+ export const SERVER_INSTRUCTIONS = 'Start with the matching inspect tool; use hoi4.probability_inspect for weights and timing. Find local documentation with hoi4.reference_context, hoi4.reference_search and hoi4.reference_read, and source definitions with hoi4.source_lookup. Rewrite tools edit mod source. Results describe offline source and scenario models, not observed game behavior. Unresolved states are not confirmed coverage. Read linked artifacts when summaries omit detail. Long operations support negotiated MCP tasks and hoi4.job_inspect/hoi4.job_cancel; background rewrites need a stable requestKey.';
@@ -112,7 +112,7 @@ const packageInputSchema = z
112
112
  const scenarioInputSchema = z
113
113
  .object({
114
114
  ...scenarioTestRequestSchema.shape,
115
- suite: compactValidatedInputSchema(scenarioSuiteSchema, 'Named mechanic, package, or fixed read-only tool cases. See docs/mechanics.md.').optional(),
115
+ suite: compactValidatedInputSchema(scenarioSuiteSchema, 'Named mechanic, package or inspection cases. See docs/mechanics.md.').optional(),
116
116
  })
117
117
  .strict()
118
118
  .superRefine((input, context) => {
@@ -131,8 +131,8 @@ const readOnlyAnalysisTool = {
131
131
  export const analysisTaskTools = [
132
132
  {
133
133
  name: 'hoi4.impact_inspect',
134
- title: 'Inspect cross-system impact',
135
- description: 'Trace where symbols are defined and consumed across focuses, events, decisions, ideas, GUI and helpers, or what a changed file affects; proposed sources are analyzed in memory. Example: {"symbols":[{"kind":"scripted_effect","id":"my_effect"}]}. Returns consumer counts inline; the revision-bound graph is a linked resource.',
134
+ title: 'Find what a script change affects',
135
+ description: 'Find what a script change affects across focuses, events, decisions, ideas, windows and helpers. Trace definitions and their uses, including proposed source. Example: {"symbols":[{"kind":"scripted_effect","id":"my_effect"}]}. Returns consumer counts inline; the revision-bound graph is a linked resource.',
136
136
  inputSchema: impactInspectRequestSchema,
137
137
  outputSchema: impactOutputSchema,
138
138
  annotations: readOnlyAnalysisTool,
@@ -140,15 +140,15 @@ export const analysisTaskTools = [
140
140
  {
141
141
  name: 'hoi4.decision_inspect',
142
142
  title: 'Inspect decisions and missions',
143
- description: 'Inventory decisions and missions, or evaluate one under declared actor and target scenarios: visibility, availability, payment, lifecycle and proposed-source differences. Example: {"mode":"inspect","id":"my_decision","scenarios":[{"id":"base","actor":"GER","state":{}}]}. Returns each scenario outcome inline; full evidence is linked.',
143
+ description: 'Explain why a decision or mission is hidden, unavailable or unaffordable. Inspect conditions, costs and lifecycle for declared actors and targets, or compare proposed source. Example: {"mode":"inspect","id":"my_decision","scenarios":[{"id":"base","actor":"GER","state":{}}]}. Returns each scenario outcome inline; full evidence is linked.',
144
144
  inputSchema: decisionInspectRequestSchema,
145
145
  outputSchema: decisionOutputSchema,
146
146
  annotations: readOnlyAnalysisTool,
147
147
  },
148
148
  {
149
149
  name: 'hoi4.mechanic_test',
150
- title: 'Test declared mechanic steps',
151
- description: 'Interpret source-backed effect steps (scripted effect, event option, decision, focus reward) on a declared state and check typed assertions; unsupported effects stay unresolved and no source changes. Example: {"test":{"id":"t","scenario":{"schemaVersion":"1.0","id":"t","state":{"treasury":10}},"steps":[...],"assertions":[...]}}. See docs/mechanics.md.',
150
+ title: 'Test scripted effects',
151
+ description: 'Test what an effect, event option, decision or focus reward does to a declared state. Run steps and check assertions; unsupported effects stay unresolved. See docs/mechanics.md. Example: {"test":{"id":"t","scenario":{"schemaVersion":"1.0","id":"t","state":{"treasury":10}},"steps":[...],"assertions":[...]}}.',
152
152
  inputSchema: mechanicInputSchema,
153
153
  outputSchema: mechanicOutputSchema,
154
154
  annotations: readOnlyAnalysisTool,
@@ -156,7 +156,7 @@ export const analysisTaskTools = [
156
156
  {
157
157
  name: 'hoi4.package_check',
158
158
  title: 'Check package connections',
159
- description: 'Check a declarative package manifest against active definitions, calls, registrations, localisation, assets and required suite cases. Example: {"manifest":{"schemaVersion":"1.0","id":"my_package","definitions":[{"kind":"scripted_effect","id":"my_effect"}]}}. Returns present, absent, shadowed and unresolved counts inline.',
159
+ description: 'Find missing pieces of a feature by checking its package manifest against definitions, calls, registrations, localisation, assets and required test cases. Example: {"manifest":{"schemaVersion":"1.0","id":"my_package","definitions":[{"kind":"scripted_effect","id":"my_effect"}]}}. Returns present, absent, shadowed and unresolved counts inline.',
160
160
  inputSchema: packageInputSchema,
161
161
  outputSchema: packageOutputSchema,
162
162
  annotations: readOnlyAnalysisTool,
@@ -164,7 +164,7 @@ export const analysisTaskTools = [
164
164
  {
165
165
  name: 'hoi4.scenario_test',
166
166
  title: 'Run scenario suite',
167
- description: 'Run a suite of named mechanic, package or read-only tool cases, inline or from a workspace file, in resumable revision-bound batches. Example: {"suitePath":"tests/my_suite.json","maxCases":20}. Returns completed, failed, unresolved and pending counts, and a continuation while cases remain.',
167
+ description: 'Run named mechanic, package or inspection cases from a suite or workspace file. Continue unfinished batches and report failures and unresolved cases. Example: {"suitePath":"tests/my_suite.json","maxCases":20}. Returns completed, failed, unresolved and pending counts, and a continuation while cases remain.',
168
168
  inputSchema: scenarioInputSchema,
169
169
  outputSchema: scenarioOutputSchema,
170
170
  annotations: readOnlyAnalysisTool,
@@ -169,15 +169,15 @@ export const eventTaskTools = [
169
169
  {
170
170
  name: 'hoi4.event_inspect',
171
171
  title: 'Inspect event chains',
172
- description: 'Scan, find roots, trace, explain paths, inspect state flow, lint, assess impact or compare (mode compare) event graphs. Example: {"mode":"trace","selector":"my_mod.1","direction":"downstream"}; a selector is an event ID, a namespace, my_mod.* or a file. Returns counts and the main findings inline; the full source-linked report is a resource.',
172
+ description: 'Follow event chains and explain their options, conditions, timing and state changes. Find broken calls, inspect affected events or compare revisions with mode compare. Example: {"mode":"trace","selector":"my_mod.1","direction":"downstream"}; a selector is an event ID, a namespace, my_mod.* or a file. Returns counts and the main findings inline; the full source-linked report is a resource.',
173
173
  inputSchema: eventInspectInputSchema,
174
174
  outputSchema: eventInspectOutputSchema,
175
175
  annotations: readOnlyEventTool,
176
176
  },
177
177
  {
178
178
  name: 'hoi4.event_render',
179
- title: 'Render event chains',
180
- description: 'Render a source-linked event view (overview, neighborhood, options, timing, state, scope, unresolved and more) as JSON, SVG and PNG. Example: {"view":"overview","selector":"my_mod"}. Images are linked artifacts; calls that cannot be resolved statically stay explicit unresolved edges.',
179
+ title: 'Preview event chains',
180
+ description: 'Preview event routes, options, timing, state and scope as JSON, SVG and PNG. Example: {"view":"overview","selector":"my_mod"}. Images are linked artifacts; calls that cannot be resolved statically stay explicit unresolved edges.',
181
181
  inputSchema: eventRenderInputSchema,
182
182
  outputSchema: eventRenderOutputSchema,
183
183
  annotations: readOnlyEventTool,
@@ -135,23 +135,23 @@ export const focusTaskTools = [
135
135
  {
136
136
  name: 'hoi4.focus_inspect',
137
137
  title: 'Inspect focus trees',
138
- description: 'Read national focus trees and continuous-focus palettes: structure, layout, source references and diagnostics. Example: {"treeId":"my_focus_tree"}. Returns counts and the main diagnostics inline; the full report is a linked resource.',
138
+ description: 'Find focus branches, prerequisites, layout problems and missing references in national trees and continuous-focus palettes. Example: {"treeId":"my_focus_tree"}. Returns counts and the main diagnostics inline; the full report is a linked resource.',
139
139
  inputSchema: focusInspectRequestSchema,
140
140
  outputSchema: focusInspectOutputSchema,
141
141
  annotations: artifactProducing,
142
142
  },
143
143
  {
144
144
  name: 'hoi4.focus_render',
145
- title: 'Render a focus tree or continuous palette',
146
- description: 'Render a focus tree or palette as a PNG with decoded icons plus HTML, SVG and JSON source maps (png:false for vector only; cropFocusIds adds close-ups). Example: {"relativePath":"common/national_focus/my_tree.txt","treeId":"my_focus_tree"}. Images are linked artifacts, not inline.',
145
+ title: 'Preview a focus tree or continuous palette',
146
+ description: 'Preview a focus tree or palette with its icons. Export PNG, HTML, SVG and JSON source maps; png:false skips the PNG and cropFocusIds adds close-ups. Example: {"relativePath":"common/national_focus/my_tree.txt","treeId":"my_focus_tree"}. Images are linked artifacts, not inline.',
147
147
  inputSchema: focusRenderRequestSchema,
148
148
  outputSchema: focusRenderOutputSchema,
149
149
  annotations: artifactProducing,
150
150
  },
151
151
  {
152
152
  name: 'hoi4.focus_rewrite',
153
- title: 'Create or clean up focus content',
154
- description: 'Create a focus tree or palette from a complete plan (createIfMissing), or re-lay out an existing tree without a plan. Example: {"relativePath":"common/national_focus/my_tree.txt","treeId":"my_focus_tree","layoutMode":"compact"}. Validates, writes atomically and returns the changed files with a rollback-able transaction.',
153
+ title: 'Build or arrange a focus tree',
154
+ description: 'Create focus trees and continuous-focus palettes, or rearrange existing branches. Use createIfMissing with a complete plan to create content, or layoutMode to arrange an existing tree. Example: {"relativePath":"common/national_focus/my_tree.txt","treeId":"my_focus_tree","layoutMode":"compact"}. Returns the changed files.',
155
155
  inputSchema: focusRewriteRequestSchema,
156
156
  outputSchema: focusPlanOutputSchema,
157
157
  annotations: {