hoi4-agent-tools 3.10.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 (163) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +91 -147
  3. package/dist/bin/http.js +10 -0
  4. package/dist/bin/setup.js +3 -2
  5. package/dist/bin/stdio.js +10 -0
  6. package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
  7. package/dist/hoi4_agent_tools/core/canonical.js +18 -4
  8. package/dist/hoi4_agent_tools/core/condition-evaluator.js +18 -0
  9. package/dist/hoi4_agent_tools/core/configuration.js +7 -0
  10. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
  11. package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
  12. package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
  13. package/dist/hoi4_agent_tools/core/engine.js +29 -2
  14. package/dist/hoi4_agent_tools/core/index.js +2 -2
  15. package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
  16. package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
  17. package/dist/hoi4_agent_tools/core/job-store.js +78 -5
  18. package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
  19. package/dist/hoi4_agent_tools/core/job-worker-host.js +314 -127
  20. package/dist/hoi4_agent_tools/core/job-worker.js +96 -37
  21. package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
  22. package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
  23. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
  24. package/dist/hoi4_agent_tools/core/server-registry.js +22 -5
  25. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
  26. package/dist/hoi4_agent_tools/core/source/cache.js +20 -10
  27. package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
  28. package/dist/hoi4_agent_tools/core/temporary-directories.js +293 -0
  29. package/dist/hoi4_agent_tools/core/transactions.js +114 -23
  30. package/dist/hoi4_agent_tools/core/worker-exit.js +80 -0
  31. package/dist/hoi4_agent_tools/core/workspace.js +84 -8
  32. package/dist/hoi4_agent_tools/decision/ai.js +1 -1
  33. package/dist/hoi4_agent_tools/decision/service.js +28 -0
  34. package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
  35. package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
  36. package/dist/hoi4_agent_tools/event/operations.js +14 -1
  37. package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
  38. package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
  39. package/dist/hoi4_agent_tools/gui/assets.js +3 -0
  40. package/dist/hoi4_agent_tools/gui/dds.js +8 -4
  41. package/dist/hoi4_agent_tools/gui/layout.js +45 -5
  42. package/dist/hoi4_agent_tools/gui/native-state.js +244 -0
  43. package/dist/hoi4_agent_tools/gui/renderer.js +12 -7
  44. package/dist/hoi4_agent_tools/gui/source-graph.js +17 -4
  45. package/dist/hoi4_agent_tools/gui/studio.js +33 -21
  46. package/dist/hoi4_agent_tools/gui/validators.js +8 -6
  47. package/dist/hoi4_agent_tools/map/bmp.js +1 -1
  48. package/dist/hoi4_agent_tools/map/derived.js +748 -0
  49. package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
  50. package/dist/hoi4_agent_tools/map/model.js +103 -0
  51. package/dist/hoi4_agent_tools/map/operations.js +427 -58
  52. package/dist/hoi4_agent_tools/map/references.js +483 -0
  53. package/dist/hoi4_agent_tools/map/service.js +136 -61
  54. package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
  55. package/dist/hoi4_agent_tools/map/validation.js +3 -0
  56. package/dist/hoi4_agent_tools/map/world.js +1280 -0
  57. package/dist/hoi4_agent_tools/mcp/prompts/probability.js +2 -2
  58. package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
  59. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
  60. package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
  61. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  62. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
  63. package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
  64. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +28 -8
  65. package/dist/hoi4_agent_tools/mcp/tools/event.js +19 -30
  66. package/dist/hoi4_agent_tools/mcp/tools/focus.js +5 -13
  67. package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -6
  68. package/dist/hoi4_agent_tools/mcp/tools/job.js +4 -4
  69. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -18
  70. package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
  71. package/dist/hoi4_agent_tools/mcp/tools/reference.js +26 -9
  72. package/dist/hoi4_agent_tools/mcp/tools/technology.js +17 -30
  73. package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
  74. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +31 -8
  75. package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
  76. package/dist/hoi4_agent_tools/probability/operations.js +175 -13
  77. package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
  78. package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
  79. package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
  80. package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
  81. package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
  82. package/dist/hoi4_agent_tools/reference/operations.js +88 -19
  83. package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
  84. package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
  85. package/dist/hoi4_agent_tools/reference/service.js +6 -0
  86. package/dist/hoi4_agent_tools/runtime.js +14 -1
  87. package/dist/hoi4_agent_tools/schemas/event.js +78 -19
  88. package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
  89. package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
  90. package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
  91. package/dist/hoi4_agent_tools/schemas/map.js +122 -3
  92. package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
  93. package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
  94. package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
  95. package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
  96. package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
  97. package/dist/hoi4_agent_tools/schemas/technology.js +79 -21
  98. package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
  99. package/dist/hoi4_agent_tools/technology/operations.js +14 -1
  100. package/dist/hoi4_agent_tools/version.js +1 -1
  101. package/docs/README.md +7 -4
  102. package/docs/analysis.md +15 -2
  103. package/docs/configuration.md +281 -0
  104. package/docs/development.md +26 -2
  105. package/docs/events.md +29 -6
  106. package/docs/examples.md +13 -28
  107. package/docs/focus.md +127 -5
  108. package/docs/gui.md +105 -21
  109. package/docs/helper-expansion.md +9 -9
  110. package/docs/http.md +3 -1
  111. package/docs/jobs.md +23 -14
  112. package/docs/map.md +199 -7
  113. package/docs/mechanics.md +6 -2
  114. package/docs/probability.md +46 -16
  115. package/docs/reference.md +73 -5
  116. package/docs/setup.md +45 -14
  117. package/docs/technology.md +28 -4
  118. package/docs/third-party-notices.md +11 -0
  119. package/docs/tools.md +150 -42
  120. package/docs/troubleshooting.md +61 -0
  121. package/examples/clients/codex-config.toml +5 -2
  122. package/examples/clients/generic.json +2 -2
  123. package/examples/config/config.json +7 -0
  124. package/npm-shrinkwrap.json +151 -156
  125. package/package.json +13 -8
  126. package/schemas/configuration.schema.json +144 -128
  127. package/schemas/continuous-focus-palette.schema.json +1 -1
  128. package/schemas/custom-weighted-pool.schema.json +1 -1
  129. package/schemas/decision-inspect-input.schema.json +1 -1
  130. package/schemas/error-log-input.schema.json +55 -0
  131. package/schemas/event-feature-manifest.schema.json +1 -1
  132. package/schemas/focus-plan.schema.json +1 -1
  133. package/schemas/focus-planning-sidecar.schema.json +1 -1
  134. package/schemas/gui-animation-source.schema.json +1 -1
  135. package/schemas/gui-helper.schema.json +1 -1
  136. package/schemas/gui-scenario.schema.json +1 -1
  137. package/schemas/helper-expansion-request.schema.json +1 -1
  138. package/schemas/helper-expansion-summary.schema.json +1 -1
  139. package/schemas/impact-inspect-input.schema.json +1 -1
  140. package/schemas/map-operation.schema.json +597 -126
  141. package/schemas/mechanic-test-input.schema.json +1 -1
  142. package/schemas/mod-index-input.schema.json +51 -0
  143. package/schemas/operation-result.schema.json +2 -2
  144. package/schemas/package-check-input.schema.json +1 -1
  145. package/schemas/package-manifest.schema.json +1 -1
  146. package/schemas/probability-analysis-result.schema.json +1 -1
  147. package/schemas/probability-compare-input.schema.json +1 -1
  148. package/schemas/probability-evaluate-input.schema.json +1 -1
  149. package/schemas/probability-inspect-input.schema.json +1 -1
  150. package/schemas/probability-render-input.schema.json +1 -1
  151. package/schemas/probability-scenario-set.schema.json +1 -1
  152. package/schemas/probability-sequence-input.schema.json +1 -1
  153. package/schemas/probability-simulate-input.schema.json +1 -1
  154. package/schemas/probability-sweep-input.schema.json +1 -1
  155. package/schemas/reference-context-input.schema.json +1 -1
  156. package/schemas/reference-read-input.schema.json +1 -1
  157. package/schemas/reference-search-input.schema.json +1 -1
  158. package/schemas/scenario-suite.schema.json +3 -5
  159. package/schemas/scenario-test-input.schema.json +3 -5
  160. package/schemas/script-validate-input.schema.json +1 -1
  161. package/schemas/shared-scenario.schema.json +1 -1
  162. package/schemas/source-lookup-input.schema.json +1 -1
  163. package/server.json +3 -3
@@ -157,7 +157,11 @@ function decodeRgb(bytes, dataOffset, width, height, bitCount, pitch, masks) {
157
157
  const source = dataOffset + y * rowPitch + x * bytesPerPixel;
158
158
  const pixel = bitCount === 32
159
159
  ? bytes.readUInt32LE(source)
160
- : byte(bytes, source) | (byte(bytes, source + 1) << 8) | (byte(bytes, source + 2) << 16);
160
+ : bitCount === 16
161
+ ? bytes.readUInt16LE(source)
162
+ : byte(bytes, source) |
163
+ (byte(bytes, source + 1) << 8) |
164
+ (byte(bytes, source + 2) << 16);
161
165
  const target = (y * width + x) * 4;
162
166
  output[target] = extractChannel(pixel, masks[0], 0);
163
167
  output[target + 1] = extractChannel(pixel, masks[1], 0);
@@ -231,8 +235,8 @@ export function decodeDds(bytes, budget = new RenderBudget(), operationKey = `dd
231
235
  if ((flags & DDPF_RGB) === 0)
232
236
  return unsupported('non-rgb', 'The DDS is neither RGB nor a recognised compressed format.');
233
237
  const bitCount = bytes.readUInt32LE(88);
234
- if (bitCount !== 24 && bitCount !== 32)
235
- return unsupported(`rgb${bitCount}`, `RGB DDS bit depth ${bitCount} is unsupported; expected 24 or 32.`);
238
+ if (bitCount !== 16 && bitCount !== 24 && bitCount !== 32)
239
+ return unsupported(`rgb${bitCount}`, `RGB DDS bit depth ${bitCount} is unsupported; expected 16, 24 or 32.`);
236
240
  const masks = [
237
241
  bytes.readUInt32LE(92),
238
242
  bytes.readUInt32LE(96),
@@ -240,7 +244,7 @@ export function decodeDds(bytes, budget = new RenderBudget(), operationKey = `dd
240
244
  bytes.readUInt32LE(104),
241
245
  ];
242
246
  const output = decodeRgb(bytes, 128, width, height, bitCount, rowPitch, masks);
243
- const format = bitCount === 24 ? 'rgb24' : 'rgba32';
247
+ const format = bitCount === 16 ? 'rgb16' : bitCount === 24 ? 'rgb24' : 'rgba32';
244
248
  return output === undefined
245
249
  ? unsupported(format, 'The DDS RGB pixel data is truncated.')
246
250
  : { width, height, data: output, format };
@@ -2,6 +2,7 @@ import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
2
2
  import { assertRenderDimensions } from '../core/render-budget.js';
3
3
  import { sortDiagnostics } from '../core/diagnostics.js';
4
4
  import { ServiceError } from '../core/result.js';
5
+ import { nativeWindowState } from './native-state.js';
5
6
  import { GuiAssetCatalog } from './assets.js';
6
7
  import { GUI_GRAPH_MAX_EDGES, GUI_GRAPH_MAX_ELEMENTS, GUI_GRAPH_MAX_NODES, GUI_SCENE_MAX_DEPTH, GUI_SCENE_MAX_ELEMENTS, GUI_SCENE_MAX_TEXT_CHARACTERS, GUI_SCENE_MAX_TEXT_LAYOUT_OPERATIONS, GUI_SCENE_MAX_WORK, GUI_TEXT_MAX_CHARACTERS, GUI_TEXT_MAX_MISSING_GLYPH_SAMPLES, } from './limits.js';
7
8
  import { guiElementAttributeFidelity } from './source-graph.js';
@@ -593,6 +594,24 @@ function anchoredCoordinate(start, span, offset, anchor) {
593
594
  function legacyRowSuffix(instanceSuffix) {
594
595
  return instanceSuffix.replace(/#list-[^#]+/gu, '');
595
596
  }
597
+ /**
598
+ * Read a visibility value the way the game reads one: booleans, the script words yes/no and
599
+ * true/false, visible/hidden/shown, and the numbers 0 and 1. Anything else is not a decision.
600
+ */
601
+ function visibilityValue(value) {
602
+ if (typeof value === 'boolean')
603
+ return value;
604
+ if (typeof value === 'number')
605
+ return value === 0 ? false : value === 1 ? true : undefined;
606
+ if (typeof value !== 'string')
607
+ return undefined;
608
+ const word = value.trim().toLowerCase();
609
+ if (['yes', 'true', '1', 'visible', 'shown', 'show'].includes(word))
610
+ return true;
611
+ if (['no', 'false', '0', 'hidden', 'hide'].includes(word))
612
+ return false;
613
+ return undefined;
614
+ }
596
615
  function explicitElementVisibility(definition, scenario, rowValues) {
597
616
  const scripted = rowValues?.[`${definition.name}.visible`] ??
598
617
  scenario.values[`${definition.name}.visible`] ??
@@ -602,7 +621,14 @@ function explicitElementVisibility(definition, scenario, rowValues) {
602
621
  (definition.sourceOwnerId === undefined
603
622
  ? undefined
604
623
  : scenario.visibility[definition.sourceOwnerId]) ??
605
- (typeof scripted === 'boolean' ? scripted : undefined));
624
+ visibilityValue(scripted));
625
+ }
626
+ /** Source visibility: `visible` and `show` mean shown when true, `hide` and `hidden` when false. */
627
+ function declaredVisibility(attributes) {
628
+ const visible = scalarBoolean(property(attributes, 'visible', 'show'));
629
+ if (visible !== undefined)
630
+ return visible;
631
+ return !(scalarBoolean(property(attributes, 'hide', 'hidden')) ?? false);
606
632
  }
607
633
  function clippedByChain(chain) {
608
634
  let result;
@@ -1124,8 +1150,7 @@ async function layoutAttachedScrollbars(definition, scrollbars, owner, childOutp
1124
1150
  ...(maximum === 0 ? { enabled: false } : {}),
1125
1151
  },
1126
1152
  };
1127
- const sourceShown = scalarBoolean(property(bar.attributes, 'visible')) ??
1128
- !(scalarBoolean(property(bar.attributes, 'hide', 'hidden')) ?? false);
1153
+ const sourceShown = declaredVisibility(bar.attributes);
1129
1154
  barLayouts.push({
1130
1155
  definition: bar,
1131
1156
  rect: { x: coordinate(true), y: coordinate(false), width: barWidth, height: barHeight },
@@ -1253,8 +1278,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1253
1278
  const parentUnresolvedVisibility = parentSceneElement?.visibilityStatus === 'unresolved'
1254
1279
  ? parentSceneElement.visibilityStatusReason
1255
1280
  : undefined;
1256
- const declaredHidden = scalarBoolean(property(definition.attributes, 'hide', 'hidden')) ?? false;
1257
- const sourceVisible = scalarBoolean(property(definition.attributes, 'visible')) ?? !declaredHidden;
1281
+ const sourceVisible = declaredVisibility(definition.attributes);
1258
1282
  const resolveVisibleAppearance = inheritedVisible && (explicitlyVisible ?? sourceVisible);
1259
1283
  const scriptedEnabled = rowValues?.[`${definition.name}.enabled`] ??
1260
1284
  scenario.values[`${definition.name}.enabled`] ??
@@ -2158,13 +2182,29 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2158
2182
  addFidelity(fidelity, 'unresolved', 'scripted_gui_visibility', `${scripted.name}.visible requires more scenario facts; its source visibility remains unresolved.`);
2159
2183
  }
2160
2184
  }
2185
+ // Engine-controlled branches and icon frames come from the scenario country; scripted state,
2186
+ // then every explicit scenario value, outranks them.
2187
+ const native = nativeWindowState(windowName, new Set(graph.elements.map(({ name }) => name)), scenario, scannedFiles);
2188
+ if (native !== undefined) {
2189
+ for (const note of native.notes)
2190
+ addFidelity(fidelity, 'modelled', 'native_window_state', note);
2191
+ const { sources } = native.state;
2192
+ if (native.state.tag === undefined ||
2193
+ sources.faction === 'default' ||
2194
+ sources.balanceOfPower === 'default')
2195
+ addFidelity(fidelity, 'approximated', 'native_window_state', native.state.tag === undefined
2196
+ ? 'The scenario names no country, so the window shows the state of a country in no faction and without a balance of power; set country.tag or the country faction, balanceOfPower and ideology facts.'
2197
+ : `No history for ${native.state.tag} was available, so its faction and balance of power default to none; set country.faction, country.balanceOfPower and country.ideology to override.`);
2198
+ }
2161
2199
  const layoutScenario = {
2162
2200
  ...scenario,
2163
2201
  values: {
2164
2202
  ...resolvedScriptedProperties,
2203
+ ...(native?.values ?? {}),
2165
2204
  ...scenario.values,
2166
2205
  },
2167
2206
  visibility: {
2207
+ ...(native?.visibility ?? {}),
2168
2208
  ...scriptedWindowVisibility,
2169
2209
  ...constantElementVisibility,
2170
2210
  ...scenario.visibility,
@@ -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
  ? []
@@ -490,15 +490,17 @@ function validateOverlapAndGeometry(scene, diagnostics, work) {
490
490
  if (overlap === undefined)
491
491
  continue;
492
492
  const ratio = area(overlap) / Math.max(1, Math.min(area(left.rect), area(right.rect)));
493
+ // Tab pages and alternative panels overlap by design; the game shows one at a time.
494
+ // When the scenario cannot decide which, the preview draws them stacked, and the pair
495
+ // is reported only as missing scenario facts, not as a layout or click conflict.
496
+ if (left.visibilityStatus === 'unresolved' || right.visibilityStatus === 'unresolved') {
497
+ if (left.depth === right.depth && ratio > 0.2)
498
+ 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 }));
499
+ continue;
500
+ }
493
501
  if (left.clickable && right.clickable && ratio > 0.02) {
494
502
  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 }));
495
503
  }
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 }));
502
504
  if (left.depth === right.depth && ratio > 0.2) {
503
505
  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 }));
504
506
  }
@@ -314,7 +314,7 @@ export function createBmp(options) {
314
314
  bytes.writeUInt32LE(0, 30);
315
315
  bytes.writeUInt32LE(stride * options.height, 34);
316
316
  if (options.bitsPerPixel === 8)
317
- bytes.writeUInt32LE(palette.length === 256 ? 256 : palette.length, 46);
317
+ bytes.writeUInt32LE(options.colorsUsed ?? palette.length, 46);
318
318
  for (const [index, color] of palette.entries()) {
319
319
  const offset = 14 + dibSize + index * 4;
320
320
  bytes[offset] = color.b;