hoi4-agent-tools 2.8.1 → 2.8.3

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 (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/hoi4_agent_tools/core/artifacts.js +126 -4
  3. package/dist/hoi4_agent_tools/core/index.js +3 -1
  4. package/dist/hoi4_agent_tools/core/scanner.js +43 -2
  5. package/dist/hoi4_agent_tools/gui/assets.js +63 -14
  6. package/dist/hoi4_agent_tools/gui/inspection-artifact.js +85 -14
  7. package/dist/hoi4_agent_tools/gui/layout.js +234 -27
  8. package/dist/hoi4_agent_tools/gui/scenario.js +1 -0
  9. package/dist/hoi4_agent_tools/gui/source-graph.js +116 -7
  10. package/dist/hoi4_agent_tools/gui/studio.js +166 -34
  11. package/dist/hoi4_agent_tools/gui/validators.js +47 -27
  12. package/dist/hoi4_agent_tools/mcp/server/progress.js +3 -0
  13. package/dist/hoi4_agent_tools/version.js +1 -1
  14. package/docs/gui.md +11 -1
  15. package/package.json +1 -1
  16. package/schemas/configuration.schema.json +1 -1
  17. package/schemas/continuous-focus-palette.schema.json +1 -1
  18. package/schemas/custom-weighted-pool.schema.json +1 -1
  19. package/schemas/event-feature-manifest.schema.json +1 -1
  20. package/schemas/focus-plan.schema.json +1 -1
  21. package/schemas/focus-planning-sidecar.schema.json +1 -1
  22. package/schemas/gui-animation-source.schema.json +1 -1
  23. package/schemas/gui-helper.schema.json +1 -1
  24. package/schemas/gui-scenario.schema.json +29 -22
  25. package/schemas/map-operation.schema.json +1 -1
  26. package/schemas/operation-result.schema.json +1 -1
  27. package/schemas/probability-analysis-result.schema.json +1 -1
  28. package/schemas/probability-compare-input.schema.json +1 -1
  29. package/schemas/probability-evaluate-input.schema.json +1 -1
  30. package/schemas/probability-inspect-input.schema.json +1 -1
  31. package/schemas/probability-render-input.schema.json +1 -1
  32. package/schemas/probability-scenario-set.schema.json +1 -1
  33. package/schemas/probability-sequence-input.schema.json +1 -1
  34. package/schemas/probability-simulate-input.schema.json +1 -1
  35. package/schemas/probability-sweep-input.schema.json +1 -1
  36. package/server.json +2 -2
@@ -213,9 +213,17 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
213
213
  }
214
214
  return replacement;
215
215
  }, 'GUI localisation substitution');
216
- text = replaceTextBounded(text, /\[\?([A-Za-z0-9_.-]+)(?:\|[^\]]+)?\]/gu, (match) => {
216
+ text = replaceTextBounded(text, /\[\?([^\]|]+)(?:\|[^\]]+)?\]/gu, (match) => {
217
217
  const key = match[1] ?? '';
218
- const replacement = rowValues?.[key] ?? scenario.variables[key] ?? scenario.scriptedGui[key];
218
+ const shortKey = key.slice(key.lastIndexOf('.') + 1);
219
+ const replacement = rowValues?.[key] ??
220
+ rowValues?.[shortKey] ??
221
+ scenario.values[key] ??
222
+ scenario.values[shortKey] ??
223
+ scenario.variables[key] ??
224
+ scenario.variables[shortKey] ??
225
+ scenario.scriptedGui[key] ??
226
+ scenario.scriptedGui[shortKey];
219
227
  if (replacement === undefined) {
220
228
  unresolved.push(match[0]);
221
229
  return /(?:^|\.)Get[A-Za-z0-9_]+$/u.test(key)
@@ -229,7 +237,10 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
229
237
  if (key === 'X' || key === 'dynamic_loc')
230
238
  return match[0];
231
239
  const countryKey = key.replace(/^(?:ROOT|This)\./u, '');
232
- const replacement = rowValues?.[countryKey] ??
240
+ const replacement = rowValues?.[key] ??
241
+ rowValues?.[countryKey] ??
242
+ scenario.values[key] ??
243
+ scenario.values[countryKey] ??
233
244
  scenario.country?.[countryKey] ??
234
245
  scenario.stateValues?.[countryKey] ??
235
246
  scenario.scriptedGui[key];
@@ -241,6 +252,15 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
241
252
  }, 'GUI scope substitution');
242
253
  return { text, unresolved: [...new Set(unresolved)].sort(), missingLocalisation };
243
254
  }
255
+ function scenarioExpressionValue(expression, values) {
256
+ if (typeof expression !== 'string')
257
+ return undefined;
258
+ const variableMatch = /^\[\?([^\]|]+)(?:\|[^\]]+)?\]$/u.exec(expression);
259
+ const localisationMatch = /^\[([^\]]+)\]$/u.exec(expression);
260
+ const token = variableMatch?.[1] ?? localisationMatch?.[1] ?? expression;
261
+ const shortToken = token.slice(token.lastIndexOf('.') + 1);
262
+ return values[token] ?? values[shortToken];
263
+ }
244
264
  function measureTextWithInlineIcons(catalog, fontName, text, fontSize, inlineIcons) {
245
265
  if (inlineIcons.size === 0 || ![...inlineIcons.keys()].some((marker) => text.includes(marker)))
246
266
  return catalog.measureText(fontName, text, fontSize);
@@ -454,11 +474,16 @@ function colourRunsForLine(catalog, fontName, line, colours, fontSize, work, inl
454
474
  }
455
475
  return runs;
456
476
  }
457
- function frameFor(element, sprite, scenario) {
477
+ function frameFor(element, sprite, scenario, rowValues) {
458
478
  const frameCount = Math.max(1, sprite.frameCount);
459
479
  const selected = scenario.selectedFrames[element.name] ?? scenario.selectedFrames[sprite.name];
460
480
  if (selected !== undefined)
461
481
  return Math.min(frameCount - 1, selected);
482
+ const scriptedFrame = rowValues?.[`${element.name}.frame`] ??
483
+ scenario.values[`${element.name}.frame`] ??
484
+ scenario.scriptedGui[`${element.name}.frame`];
485
+ if (typeof scriptedFrame === 'number' && Number.isFinite(scriptedFrame))
486
+ return Math.min(frameCount - 1, Math.max(0, Math.trunc(scriptedFrame > 0 ? scriptedFrame - 1 : scriptedFrame)));
462
487
  const explicit = scalarNumber(property(element.attributes, 'frame'), frameCount);
463
488
  if (explicit !== undefined)
464
489
  return Math.min(frameCount - 1, Math.max(0, Math.trunc(explicit > 0 ? explicit - 1 : explicit)));
@@ -583,7 +608,7 @@ class GuiSceneWorkBudget {
583
608
  this.textLayoutOperations += amount;
584
609
  }
585
610
  }
586
- async function layoutElement(definition, parentRect, inheritedClip, parentScale, depth, context, instanceSuffix = '', rowIndex, rowValues, parentInstanceId, inheritedOrientation = 'upper_left') {
611
+ async function layoutElement(definition, parentRect, inheritedClip, parentScale, depth, context, instanceSuffix = '', rowIndex, rowValues, parentInstanceId, inheritedOrientation = 'upper_left', inheritedVisible = true) {
587
612
  context.work.admitElement(depth);
588
613
  const { scenario, catalog, fidelity, diagnostics } = context;
589
614
  const instanceId = instanceSuffix.length === 0 ? definition.id : `${definition.id}${instanceSuffix}`;
@@ -591,12 +616,33 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
591
616
  const scale = parentScale * localScale;
592
617
  const position = objectProperty(property(definition.attributes, 'position'));
593
618
  const size = objectProperty(property(definition.attributes, 'size'));
594
- const localX = scaledNumber(position === undefined ? undefined : property(position, 'x'), parentRect.width, parentScale) ?? 0;
595
- const localY = scaledNumber(position === undefined ? undefined : property(position, 'y'), parentRect.height, parentScale) ?? 0;
619
+ const scriptedX = rowValues?.[`${definition.name}.x`] ??
620
+ scenario.values[`${definition.name}.x`] ??
621
+ scenario.scriptedGui[`${definition.name}.x`];
622
+ const scriptedY = rowValues?.[`${definition.name}.y`] ??
623
+ scenario.values[`${definition.name}.y`] ??
624
+ scenario.scriptedGui[`${definition.name}.y`];
625
+ const localX = (typeof scriptedX === 'number' && Number.isFinite(scriptedX)
626
+ ? scriptedX * parentScale
627
+ : undefined) ??
628
+ scaledNumber(position === undefined ? undefined : property(position, 'x'), parentRect.width, parentScale) ??
629
+ 0;
630
+ const localY = (typeof scriptedY === 'number' && Number.isFinite(scriptedY)
631
+ ? scriptedY * parentScale
632
+ : undefined) ??
633
+ scaledNumber(position === undefined ? undefined : property(position, 'y'), parentRect.height, parentScale) ??
634
+ 0;
596
635
  let width = scaledNumber(size === undefined ? undefined : property(size, 'width', 'x'), parentRect.width, scale) ?? 0;
597
636
  let height = scaledNumber(size === undefined ? undefined : property(size, 'height', 'y'), parentRect.height, scale) ?? 0;
598
637
  const background = objectProperty(property(definition.attributes, 'background'));
599
- const spriteName = scalarString(property(definition.attributes, 'spriteType', 'quadTextureSprite')) ??
638
+ const scriptedImage = rowValues?.[`${definition.name}.image`] ??
639
+ rowValues?.[definition.name] ??
640
+ scenario.values[`${definition.name}.image`] ??
641
+ scenario.values[definition.name] ??
642
+ scenario.scriptedGui[`${definition.name}.image`] ??
643
+ scenario.scriptedGui[definition.name];
644
+ const spriteName = (typeof scriptedImage === 'string' && scriptedImage.length > 0 ? scriptedImage : undefined) ??
645
+ scalarString(property(definition.attributes, 'spriteType', 'quadTextureSprite')) ??
600
646
  scalarString(background === undefined
601
647
  ? undefined
602
648
  : property(background, 'spriteType', 'quadTextureSprite'));
@@ -632,7 +678,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
632
678
  }
633
679
  if (partialAppearance.length > 0)
634
680
  diagnostics.push(diagnostic('GUI_SPRITE_RENDER_PARTIAL', 'warning', `Element ${definition.name} has a partial sprite appearance: ${partialAppearance.join(' ')}`, definition));
635
- const frame = frameFor(definition, spriteDefinition, scenario);
681
+ const frame = frameFor(definition, spriteDefinition, scenario, rowValues);
636
682
  sprite = await catalog.loadSpriteFrame(spriteDefinition, frame);
637
683
  if (spriteDefinition.texturePath2 !== undefined)
638
684
  secondarySprite = await catalog.loadSecondarySpriteFrame(spriteDefinition, frame);
@@ -676,8 +722,14 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
676
722
  }
677
723
  }
678
724
  }
725
+ if (typeof scriptedImage === 'string' && scriptedImage.length > 0)
726
+ addFidelity(fidelity, 'modelled', 'scripted_gui_image', `Scenario property selected ${scriptedImage} for ${definition.name}.`, definition);
727
+ if (typeof scriptedX === 'number' || typeof scriptedY === 'number')
728
+ addFidelity(fidelity, 'modelled', 'scripted_gui_position', `Scenario properties positioned ${definition.name} at ${localX},${localY} before anchoring.`, definition);
679
729
  const rawButtonText = scalarString(property(definition.attributes, 'buttonText'));
680
730
  const rawText = scalarString(property(definition.attributes, 'text')) ?? rawButtonText;
731
+ const embeddedButtonText = rawText !== undefined &&
732
+ (rawButtonText !== undefined || definition.elementType.toLowerCase().includes('button'));
681
733
  let text;
682
734
  if (rawText !== undefined) {
683
735
  const resolved = resolveTokenText(rawText, scenario, context.localisation, rowValues);
@@ -758,7 +810,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
758
810
  measuredWidth,
759
811
  measuredHeight,
760
812
  metricSource: wrapped.metricSource,
761
- ...alignment(definition.attributes, rawButtonText !== undefined),
813
+ ...alignment(definition.attributes, embeddedButtonText),
762
814
  ...(fontName === undefined ? {} : { fontName }),
763
815
  glyphLines,
764
816
  overflowX: width > 0 && measuredWidth > width + 0.01,
@@ -817,17 +869,35 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
817
869
  const clipRect = inheritedClip;
818
870
  const clipped = clipRect !== undefined &&
819
871
  (availableClip === undefined || !equalRect(unclippedRect, availableClip));
820
- const explicitlyVisible = scenario.visibility[definition.name] ?? scenario.visibility[definition.id];
821
- const visible = explicitlyVisible ?? (availableClip !== undefined || inheritedClip === undefined);
872
+ const scriptedVisible = rowValues?.[`${definition.name}.visible`] ??
873
+ scenario.values[`${definition.name}.visible`] ??
874
+ scenario.scriptedGui[`${definition.name}.visible`];
875
+ const explicitlyVisible = scenario.visibility[definition.name] ??
876
+ scenario.visibility[definition.id] ??
877
+ (typeof scriptedVisible === 'boolean' ? scriptedVisible : undefined);
878
+ const visible = inheritedVisible &&
879
+ (explicitlyVisible ?? (availableClip !== undefined || inheritedClip === undefined));
822
880
  const clickThrough = scalarBoolean(property(definition.attributes, 'clickThrough', 'alwaystransparent', 'allwaystransparent')) ?? false;
823
- const clickable = clickableTypes.test(definition.elementType) && !clickThrough;
881
+ const scriptedEnabled = rowValues?.[`${definition.name}.enabled`] ??
882
+ scenario.values[`${definition.name}.enabled`] ??
883
+ scenario.scriptedGui[`${definition.name}.enabled`] ??
884
+ context.constantElementEnabled[definition.name];
885
+ const clickable = clickableTypes.test(definition.elementType) && !clickThrough && scriptedEnabled !== false;
824
886
  const state = scenario.elementStates[definition.name] ?? scenario.state;
825
887
  const zPriority = scalarNumber(property(definition.attributes, 'priority'), 0) ?? 0;
826
888
  let progressRatio;
827
889
  if (/progressbar/iu.test(definition.elementType)) {
828
890
  const minimum = scalarNumber(property(definition.attributes, 'minValue'), 1) ?? 0;
829
891
  const maximum = scalarNumber(property(definition.attributes, 'maxValue'), 1) ?? 100;
830
- const scriptedValue = scenario.scriptedGui[definition.name];
892
+ const startValueToken = scalarString(property(definition.attributes, 'startValue'));
893
+ const shortStartValueToken = startValueToken?.slice(startValueToken.lastIndexOf('.') + 1);
894
+ const scriptedValue = rowValues?.[definition.name] ??
895
+ scenario.values[definition.name] ??
896
+ (startValueToken === undefined ? undefined : scenario.values[startValueToken]) ??
897
+ (shortStartValueToken === undefined ? undefined : scenario.values[shortStartValueToken]) ??
898
+ (startValueToken === undefined ? undefined : scenario.variables[startValueToken]) ??
899
+ (shortStartValueToken === undefined ? undefined : scenario.variables[shortStartValueToken]) ??
900
+ scenario.scriptedGui[definition.name];
831
901
  let value = typeof scriptedValue === 'number'
832
902
  ? scriptedValue
833
903
  : (scalarNumber(property(definition.attributes, 'startValue'), maximum) ?? minimum);
@@ -911,9 +981,11 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
911
981
  rows = [];
912
982
  if (state === 'full-list' && rows === undefined)
913
983
  rows = Array.from({ length: 12 }, (_unused, index) => ({ index }));
914
- if (rows !== undefined &&
915
- childDefinitions.length > 0 &&
916
- /(?:grid|listbox|scroll)/iu.test(definition.elementType)) {
984
+ const dynamicList = context.dynamicListsByName.get(definition.name);
985
+ const listElement = /(?:grid|listbox|scroll)/iu.test(definition.elementType);
986
+ const slotSize = objectProperty(property(definition.attributes, 'slotsize'));
987
+ const slotHeight = scaledNumber(slotSize === undefined ? undefined : property(slotSize, 'height', 'y'), height, scale);
988
+ if (rows !== undefined && childDefinitions.length > 0 && listElement) {
917
989
  const spacingValue = objectProperty(property(definition.attributes, 'spacing'));
918
990
  const spacingY = scalarNumber(spacingValue === undefined
919
991
  ? property(definition.attributes, 'spacing')
@@ -930,18 +1002,50 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
930
1002
  ...childParentRect,
931
1003
  y: childParentRect.y + rowY - originalY * scale,
932
1004
  };
933
- await layoutElement(child, shiftedParent, childClip, scale, depth + 1, context, `${instanceSuffix}#row-${index}`, index, row, instanceId, origo);
1005
+ await layoutElement(child, shiftedParent, childClip, scale, depth + 1, context, `${instanceSuffix}#row-${index}`, index, row, instanceId, origo, visible);
934
1006
  const rendered = context.instancesById.get(`${child.id}${instanceSuffix}#row-${index}`);
935
1007
  rowHeight = Math.max(rowHeight, rendered?.unclippedRect.height ?? 0);
936
1008
  }
937
- rowY += rowHeight + spacingY * scale;
1009
+ rowY += (slotHeight ?? rowHeight) + spacingY * scale;
938
1010
  }
939
1011
  addFidelity(fidelity, 'modelled', 'scroll_rows', `Expanded ${rows.length} scenario rows for ${definition.name}.`, definition);
940
1012
  }
1013
+ else if (rows !== undefined && dynamicList !== undefined && listElement) {
1014
+ let rowY = 0;
1015
+ let renderedRows = 0;
1016
+ for (const [index, row] of rows.entries()) {
1017
+ context.work.spend('scripted GUI dynamic-list row expansion');
1018
+ const explicitTemplate = row.entryContainer;
1019
+ const templateName = (typeof explicitTemplate === 'string' && explicitTemplate.length > 0
1020
+ ? explicitTemplate
1021
+ : undefined) ??
1022
+ (row.countryScope === true
1023
+ ? (dynamicList.countryScopeEntryContainer ?? dynamicList.entryContainer)
1024
+ : (dynamicList.entryContainer ?? dynamicList.countryScopeEntryContainer));
1025
+ const template = templateName === undefined ? undefined : context.elementsByName.get(templateName);
1026
+ if (template === undefined) {
1027
+ diagnostics.push(diagnostic('GUI_DYNAMIC_LIST_TEMPLATE_MISSING', 'error', templateName === undefined
1028
+ ? `Dynamic list ${definition.name} has no entry container for scenario row ${index}.`
1029
+ : `Dynamic list ${definition.name} cannot resolve entry container ${templateName}.`, definition));
1030
+ continue;
1031
+ }
1032
+ const templatePosition = objectProperty(property(template.attributes, 'position'));
1033
+ const originalY = scalarNumber(templatePosition === undefined ? undefined : property(templatePosition, 'y'), height) ?? 0;
1034
+ const shiftedParent = {
1035
+ ...childParentRect,
1036
+ y: childParentRect.y + rowY - originalY * scale,
1037
+ };
1038
+ await layoutElement(template, shiftedParent, childClip, scale, depth + 1, context, `${instanceSuffix}#row-${index}`, index, row, instanceId, origo, visible);
1039
+ const rendered = context.instancesById.get(`${template.id}${instanceSuffix}#row-${index}`);
1040
+ rowY += slotHeight ?? rendered?.unclippedRect.height ?? 0;
1041
+ renderedRows += 1;
1042
+ }
1043
+ addFidelity(fidelity, renderedRows === rows.length ? 'modelled' : 'missing', 'scripted_gui_dynamic_list', `Expanded ${renderedRows}/${rows.length} scenario rows for ${definition.name} through its scripted-GUI entry containers.`, definition);
1044
+ }
941
1045
  else {
942
1046
  for (const child of childDefinitions) {
943
1047
  context.work.spend('nested child expansion');
944
- await layoutElement(child, childParentRect, childClip, scale, depth + 1, context, instanceSuffix, rowIndex, rowValues, instanceId, origo);
1048
+ await layoutElement(child, childParentRect, childClip, scale, depth + 1, context, instanceSuffix, rowIndex, rowValues, instanceId, origo, visible);
945
1049
  }
946
1050
  }
947
1051
  }
@@ -998,24 +1102,86 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
998
1102
  localisation.set(entry.key, entry.value);
999
1103
  addFidelity(fidelity, 'approximated', 'language', `No exact ${scenario.language} bucket; used available localisation entries.`);
1000
1104
  }
1001
- let scriptedWindowVisible = true;
1002
- for (const scripted of graph.scriptedGuis.filter((definition) => definition.windowName === windowName || definition.parentWindowName === windowName)) {
1105
+ const relevantScriptedGuiNames = new Set(graph.scriptedGuis
1106
+ .filter((definition) => definition.windowName === windowName || definition.parentWindowName === windowName)
1107
+ .map(({ name }) => name));
1108
+ let addedRelatedScriptedGui = true;
1109
+ while (addedRelatedScriptedGui) {
1110
+ addedRelatedScriptedGui = false;
1111
+ for (const definition of graph.scriptedGuis) {
1112
+ if (definition.parentScriptedGui === undefined ||
1113
+ !relevantScriptedGuiNames.has(definition.parentScriptedGui) ||
1114
+ relevantScriptedGuiNames.has(definition.name))
1115
+ continue;
1116
+ relevantScriptedGuiNames.add(definition.name);
1117
+ addedRelatedScriptedGui = true;
1118
+ }
1119
+ }
1120
+ const scriptedWindowVisibility = {};
1121
+ const constantElementVisibility = {};
1122
+ const constantElementEnabled = {};
1123
+ const resolvedScriptedProperties = {};
1124
+ const dynamicListsByName = new Map();
1125
+ for (const scripted of graph.scriptedGuis.filter(({ name }) => relevantScriptedGuiNames.has(name))) {
1126
+ for (const dynamicList of scripted.dynamicListDefinitions)
1127
+ dynamicListsByName.set(dynamicList.name, dynamicList);
1128
+ for (const propertyDefinition of scripted.propertyDefinitions) {
1129
+ for (const [attribute, expression] of Object.entries(propertyDefinition.attributes)) {
1130
+ const suffix = attribute.toLowerCase();
1131
+ if (!['image', 'frame', 'x', 'y', 'visible', 'enabled'].includes(suffix))
1132
+ continue;
1133
+ const resolved = scenarioExpressionValue(expression, scenario.values);
1134
+ if (resolved === undefined)
1135
+ continue;
1136
+ const key = `${propertyDefinition.elementName}.${suffix}`;
1137
+ if (scenario.values[key] === undefined)
1138
+ resolvedScriptedProperties[key] = resolved;
1139
+ addFidelity(fidelity, 'modelled', 'scripted_gui_scenario_property', `${key} resolved from a scenario runtime value.`);
1140
+ }
1141
+ }
1142
+ for (const trigger of scripted.triggerDefinitions) {
1143
+ if (trigger.constantResult === undefined)
1144
+ continue;
1145
+ if (trigger.name.endsWith('_visible'))
1146
+ constantElementVisibility[trigger.elementName] = trigger.constantResult;
1147
+ if (trigger.name.endsWith('_click_enabled'))
1148
+ constantElementEnabled[trigger.elementName] = trigger.constantResult;
1149
+ addFidelity(fidelity, 'modelled', 'scripted_gui_constant_trigger', `${trigger.name} resolves to ${trigger.constantResult ? 'yes' : 'no'}.`);
1150
+ }
1003
1151
  addFidelity(fidelity, 'modelled', 'scripted_gui_context', `${scripted.name} uses ${scripted.contextType ?? '<unspecified>'} context.`);
1004
1152
  if (scripted.visibleExpression === undefined)
1005
1153
  continue;
1006
- const mockedVisibility = scenario.visibility[scripted.name] ?? scenario.scriptedGui[`${scripted.name}.visible`];
1154
+ const mockedVisibility = scenario.visibility[scripted.name] ??
1155
+ scenario.values[`${scripted.name}.visible`] ??
1156
+ scenario.scriptedGui[`${scripted.name}.visible`];
1007
1157
  if (typeof mockedVisibility === 'boolean') {
1008
- scriptedWindowVisible &&= mockedVisibility;
1158
+ if (scripted.windowName !== undefined)
1159
+ scriptedWindowVisibility[scripted.windowName] =
1160
+ (scriptedWindowVisibility[scripted.windowName] ?? true) && mockedVisibility;
1009
1161
  addFidelity(fidelity, 'modelled', 'scripted_gui_visibility', `${scripted.name}.visible was supplied by the preview scenario.`);
1010
1162
  }
1011
1163
  else {
1012
1164
  addFidelity(fidelity, 'unresolved', 'scripted_gui_visibility', `${scripted.name}.visible requires an explicit scenario mock; the offline renderer leaves it visible.`);
1013
1165
  }
1014
1166
  }
1015
- const layoutScenario = scriptedWindowVisible
1016
- ? scenario
1017
- : { ...scenario, visibility: { ...scenario.visibility, [root.name]: false } };
1167
+ const layoutScenario = {
1168
+ ...scenario,
1169
+ values: {
1170
+ ...resolvedScriptedProperties,
1171
+ ...scenario.values,
1172
+ },
1173
+ visibility: {
1174
+ ...scriptedWindowVisibility,
1175
+ ...constantElementVisibility,
1176
+ ...scenario.visibility,
1177
+ },
1178
+ };
1018
1179
  const output = [];
1180
+ const elementsByName = new Map();
1181
+ for (const element of [...graph.elements].sort((left, right) => compareCodeUnits(right.sourcePath, left.sourcePath) ||
1182
+ right.definitionOrder - left.definitionOrder))
1183
+ if (!elementsByName.has(element.name))
1184
+ elementsByName.set(element.name, element);
1019
1185
  const context = {
1020
1186
  graph,
1021
1187
  scenario: layoutScenario,
@@ -1023,6 +1189,9 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1023
1189
  fidelity,
1024
1190
  diagnostics,
1025
1191
  elementsById,
1192
+ elementsByName,
1193
+ dynamicListsByName,
1194
+ constantElementEnabled,
1026
1195
  spritesByName: new Map(graph.sprites.map((sprite) => [sprite.name.toLowerCase(), sprite])),
1027
1196
  localisation,
1028
1197
  output,
@@ -1037,6 +1206,44 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1037
1206
  height: scenario.resolution.height,
1038
1207
  };
1039
1208
  await layoutElement(root, viewport, viewport, baseScale, 0, context);
1209
+ const scriptedByName = new Map(graph.scriptedGuis.map((scripted) => [scripted.name, scripted]));
1210
+ const pendingAttached = graph.scriptedGuis
1211
+ .filter(({ name, parentScriptedGui, windowName: scriptedWindowName }) => relevantScriptedGuiNames.has(name) &&
1212
+ parentScriptedGui !== undefined &&
1213
+ scriptedWindowName !== undefined &&
1214
+ scriptedWindowName !== windowName)
1215
+ .sort((left, right) => compareCodeUnits(left.name, right.name));
1216
+ let attachmentProgress = true;
1217
+ while (pendingAttached.length > 0 && attachmentProgress) {
1218
+ attachmentProgress = false;
1219
+ for (let index = pendingAttached.length - 1; index >= 0; index -= 1) {
1220
+ const scripted = pendingAttached[index];
1221
+ const parentScripted = scripted.parentScriptedGui === undefined
1222
+ ? undefined
1223
+ : scriptedByName.get(scripted.parentScriptedGui);
1224
+ const parentWindowName = parentScripted?.windowName;
1225
+ const parentDefinition = parentWindowName === undefined ? undefined : elementsByName.get(parentWindowName);
1226
+ const parentScene = parentDefinition === undefined ? undefined : context.instancesById.get(parentDefinition.id);
1227
+ const attachedDefinition = scripted.windowName === undefined ? undefined : elementsByName.get(scripted.windowName);
1228
+ if (parentScene === undefined || attachedDefinition === undefined)
1229
+ continue;
1230
+ if (!context.instancesById.has(attachedDefinition.id)) {
1231
+ await layoutElement(attachedDefinition, parentScene.unclippedRect, parentScene.unclippedRect, parentScene.scale, parentScene.depth + 1, context, '', undefined, undefined, parentScene.id, 'upper_left', parentScene.visible);
1232
+ addFidelity(fidelity, 'modelled', 'parent_scripted_gui_attachment', `${scripted.windowName} is attached to ${parentWindowName} through ${scripted.parentScriptedGui}.`, attachedDefinition);
1233
+ }
1234
+ pendingAttached.splice(index, 1);
1235
+ attachmentProgress = true;
1236
+ }
1237
+ }
1238
+ for (const scripted of pendingAttached) {
1239
+ diagnostics.push({
1240
+ code: 'GUI_PARENT_SCRIPTED_WINDOW_UNRESOLVED',
1241
+ severity: 'error',
1242
+ category: 'reference',
1243
+ message: `Could not attach ${scripted.windowName ?? '<missing window>'} through parent scripted GUI ${scripted.parentScriptedGui ?? '<missing parent>'}.`,
1244
+ ...(scripted.location === undefined ? {} : { location: scripted.location }),
1245
+ });
1246
+ }
1040
1247
  output.sort((left, right) => left.zIndex - right.zIndex || compareCodeUnits(left.id, right.id));
1041
1248
  const bounds = unionRects(output.filter(({ visible }) => visible).map(({ unclippedRect }) => unclippedRect));
1042
1249
  return {
@@ -118,6 +118,7 @@ const GuiPreviewScenarioBodySchema = z
118
118
  flags: z.record(z.string(), z.boolean()).default({}),
119
119
  lists: z.record(z.string(), z.array(objectSchema).max(10_000)).default({}),
120
120
  localisation: z.record(z.string(), z.string()).default({}),
121
+ values: objectSchema.default({}),
121
122
  scriptedGui: objectSchema.default({}),
122
123
  visibility: z.record(z.string(), z.boolean()).default({}),
123
124
  elementStates: z.record(z.string(), previewStateSchema).default({}),
@@ -63,6 +63,8 @@ const modelledElementAttributes = new Set([
63
63
  'allwaystransparent',
64
64
  'clickThrough',
65
65
  'spacing',
66
+ 'slotsize',
67
+ 'max_slots_horizontal',
66
68
  'priority',
67
69
  'minValue',
68
70
  'maxValue',
@@ -420,6 +422,9 @@ function indexSpritesAndFonts(document, file, fileNodeId, nodes, edges, sprites,
420
422
  .concat(childBlocks(child, 'fontfiles').flatMap((fontFiles) => fontFiles.entries
421
423
  .filter((entry) => entry.type === 'scalar')
422
424
  .map((entry) => entry.value)));
425
+ const languages = childBlocks(child, 'languages').flatMap((languageBlock) => languageBlock.entries
426
+ .filter((entry) => entry.type === 'scalar')
427
+ .map((entry) => entry.value));
423
428
  const id = deterministicId('gui_font', { path: file.displayPath, name });
424
429
  const font = {
425
430
  id,
@@ -427,6 +432,8 @@ function indexSpritesAndFonts(document, file, fileNodeId, nodes, edges, sprites,
427
432
  sourcePath: file.displayPath,
428
433
  location: nodeLocation(document, assignment, name),
429
434
  kind: 'bitmapfont',
435
+ override: assignment.key.value === 'bitmapfont_override',
436
+ languages,
430
437
  assetPaths,
431
438
  rawSource: raw(document, assignment),
432
439
  };
@@ -437,7 +444,12 @@ function indexSpritesAndFonts(document, file, fileNodeId, nodes, edges, sprites,
437
444
  name,
438
445
  path: file.displayPath,
439
446
  ...(font.location === undefined ? {} : { location: font.location }),
440
- metadata: { kind: font.kind, assetPaths },
447
+ metadata: {
448
+ kind: font.kind,
449
+ override: font.override,
450
+ languages,
451
+ assetPaths,
452
+ },
441
453
  });
442
454
  addEdge(edges, 'contains', fileNodeId, id, true, {}, font.location);
443
455
  }
@@ -476,6 +488,21 @@ function directEffectCosts(block) {
476
488
  }
477
489
  return costs;
478
490
  }
491
+ function constantTriggerResult(value) {
492
+ if (value.type !== 'block')
493
+ return undefined;
494
+ const entries = assignments(value);
495
+ if (entries.length !== 1 || entries[0]?.key.value.toLowerCase() !== 'always')
496
+ return undefined;
497
+ const result = entries[0].value;
498
+ if (result.type !== 'scalar')
499
+ return undefined;
500
+ if (result.value.toLowerCase() === 'yes')
501
+ return true;
502
+ if (result.value.toLowerCase() === 'no')
503
+ return false;
504
+ return undefined;
505
+ }
479
506
  function indexScriptedGuis(document, file, fileNodeId, nodes, edges, scriptedGuis) {
480
507
  for (const root of childBlocks(document.root, 'scripted_gui')) {
481
508
  for (const assignment of assignments(root)) {
@@ -493,9 +520,43 @@ function indexScriptedGuis(document, file, fileNodeId, nodes, edges, scriptedGui
493
520
  location: nodeLocation(document, effect, effect.key.value),
494
521
  }));
495
522
  const effects = effectDefinitions.map(({ name: effect }) => effect);
496
- const triggers = namedAssignments(block, 'triggers').map(({ key }) => key.value);
497
- const properties = namedAssignments(block, 'properties').map(({ key }) => key.value);
498
- const dynamicLists = namedAssignments(block, 'dynamic_lists').map(({ key }) => key.value);
523
+ const triggerDefinitions = namedAssignments(block, 'triggers').map((trigger) => {
524
+ const constantResult = constantTriggerResult(trigger.value);
525
+ return {
526
+ name: trigger.key.value,
527
+ elementName: actionElementName(trigger.key.value),
528
+ ...(constantResult === undefined ? {} : { constantResult }),
529
+ rawSource: raw(document, trigger),
530
+ location: nodeLocation(document, trigger, trigger.key.value),
531
+ };
532
+ });
533
+ const triggers = triggerDefinitions.map(({ name: trigger }) => trigger);
534
+ const propertyAssignments = namedAssignments(block, 'properties');
535
+ const properties = propertyAssignments.map(({ key }) => key.value);
536
+ const propertyDefinitions = propertyAssignments.map((property) => ({
537
+ elementName: property.key.value,
538
+ attributes: property.value.type === 'block'
539
+ ? sourceValueToProperty(property.value, constantsFor(document))
540
+ : {},
541
+ rawSource: raw(document, property),
542
+ location: nodeLocation(document, property, property.key.value),
543
+ }));
544
+ const dynamicListDefinitions = namedAssignments(block, 'dynamic_lists').map((dynamicList) => {
545
+ const entryContainer = dynamicList.value.type === 'block'
546
+ ? firstScalarInsensitive(dynamicList.value, 'entry_container')
547
+ : undefined;
548
+ const countryScopeEntryContainer = dynamicList.value.type === 'block'
549
+ ? firstScalarInsensitive(dynamicList.value, 'country_scope_entry_container')
550
+ : undefined;
551
+ return {
552
+ name: dynamicList.key.value,
553
+ ...(entryContainer === undefined ? {} : { entryContainer }),
554
+ ...(countryScopeEntryContainer === undefined ? {} : { countryScopeEntryContainer }),
555
+ rawSource: raw(document, dynamicList),
556
+ location: nodeLocation(document, dynamicList, dynamicList.key.value),
557
+ };
558
+ });
559
+ const dynamicLists = dynamicListDefinitions.map(({ name: dynamicList }) => dynamicList);
499
560
  const aiWeights = namedAssignments(block, 'ai_weights').map(({ key }) => key.value);
500
561
  const contextType = firstScalarInsensitive(block, 'context_type');
501
562
  const windowName = firstScalarInsensitive(block, 'window_name');
@@ -517,8 +578,11 @@ function indexScriptedGuis(document, file, fileNodeId, nodes, edges, scriptedGui
517
578
  effects: effects.sort((a, b) => compareCodeUnits(a, b)),
518
579
  effectDefinitions: effectDefinitions.sort((a, b) => compareCodeUnits(a.name, b.name)),
519
580
  triggers: triggers.sort((a, b) => compareCodeUnits(a, b)),
581
+ triggerDefinitions: triggerDefinitions.sort((a, b) => compareCodeUnits(a.name, b.name)),
520
582
  properties: properties.sort((a, b) => compareCodeUnits(a, b)),
583
+ propertyDefinitions: propertyDefinitions.sort((a, b) => compareCodeUnits(a.elementName, b.elementName)),
521
584
  dynamicLists: dynamicLists.sort((a, b) => compareCodeUnits(a, b)),
585
+ dynamicListDefinitions: dynamicListDefinitions.sort((left, right) => compareCodeUnits(left.name, right.name)),
522
586
  aiWeights: aiWeights.sort((a, b) => compareCodeUnits(a, b)),
523
587
  aiEnabled: childBlocks(block, 'ai_enabled').length > 0 || childBlocks(block, 'ai_check').length > 0,
524
588
  rawSource: raw(document, assignment),
@@ -533,8 +597,23 @@ function indexScriptedGuis(document, file, fileNodeId, nodes, edges, scriptedGui
533
597
  metadata: {
534
598
  effects: definition.effects,
535
599
  triggers: definition.triggers,
600
+ constantTriggers: definition.triggerDefinitions.flatMap(({ name: trigger, elementName, constantResult }) => constantResult === undefined ? [] : [{ trigger, elementName, constantResult }]),
536
601
  properties: definition.properties,
537
602
  dynamicLists: definition.dynamicLists,
603
+ dynamicListEntryContainers: definition.dynamicListDefinitions.flatMap(({ name: dynamicList, entryContainer, countryScopeEntryContainer }) => [
604
+ ...(entryContainer === undefined
605
+ ? []
606
+ : [{ dynamicList, scope: 'generic', container: entryContainer }]),
607
+ ...(countryScopeEntryContainer === undefined
608
+ ? []
609
+ : [
610
+ {
611
+ dynamicList,
612
+ scope: 'country',
613
+ container: countryScopeEntryContainer,
614
+ },
615
+ ]),
616
+ ]),
538
617
  aiWeights: definition.aiWeights,
539
618
  aiEnabled: definition.aiEnabled,
540
619
  },
@@ -718,7 +797,15 @@ function indexDecisionEntries(document, file, nodes, pendingDecisionEdges) {
718
797
  }
719
798
  function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scriptedLocalisation, localisation, textureNodes, fontAssetNodes, pendingDecisionEdges) {
720
799
  const spriteByName = new Map(sprites.map((sprite) => [sprite.name.toLocaleLowerCase('en-US'), sprite]));
721
- const fontByName = new Map(fonts.map((font) => [font.name.toLocaleLowerCase('en-US'), font]));
800
+ const fontByName = new Map();
801
+ for (const font of fonts) {
802
+ const key = font.name.toLocaleLowerCase('en-US');
803
+ const existing = fontByName.get(key);
804
+ if (existing === undefined ||
805
+ (existing.override && !font.override) ||
806
+ (existing.languages.length > 0 && font.languages.length === 0))
807
+ fontByName.set(key, font);
808
+ }
722
809
  const elementByName = new Map();
723
810
  for (const element of [...elements].sort((a, b) => a.definitionOrder - b.definitionOrder)) {
724
811
  if (!elementByName.has(element.name))
@@ -778,7 +865,9 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
778
865
  }
779
866
  for (const key of localisationAttributeNames) {
780
867
  const value = element.attributes[key];
781
- if (typeof value !== 'string' || value.trim().length === 0)
868
+ if (typeof value !== 'string' ||
869
+ value.trim().length === 0 ||
870
+ !isStaticGuiLocalisationReference(value))
782
871
  continue;
783
872
  const entry = localisationByKey.get(value);
784
873
  addEdge(edges, 'uses_localisation', element.id, entry === undefined ? `localisation:${value}` : deterministicId('gui_loc', entry), entry !== undefined, { field: key, key: value }, element.location);
@@ -824,6 +913,17 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
824
913
  const element = elementByName.get(property);
825
914
  addEdge(edges, 'property_target', gui.id, element?.id ?? `gui_element:${property}`, element !== undefined, { property }, gui.location);
826
915
  }
916
+ for (const dynamicList of gui.dynamicListDefinitions) {
917
+ for (const [scope, containerName] of [
918
+ ['generic', dynamicList.entryContainer],
919
+ ['country', dynamicList.countryScopeEntryContainer],
920
+ ]) {
921
+ if (containerName === undefined)
922
+ continue;
923
+ const element = elementByName.get(containerName);
924
+ addEdge(edges, 'dynamic_list_template', gui.id, element?.id ?? `gui_element:${containerName}`, element !== undefined, { dynamicList: dynamicList.name, scope, containerName }, dynamicList.location);
925
+ }
926
+ }
827
927
  }
828
928
  for (const pending of pendingDecisionEdges) {
829
929
  const target = scriptedByName.get(pending.target);
@@ -837,12 +937,18 @@ const localisationAttributeNames = [
837
937
  'pdx_tooltip_delayed',
838
938
  'hint_tag',
839
939
  ];
940
+ function isStaticGuiLocalisationReference(value) {
941
+ const trimmed = value.trim();
942
+ return !(trimmed.startsWith('[') && trimmed.endsWith(']'));
943
+ }
840
944
  function referencedGuiLocalisationKeys(elements, scriptedLocalisation) {
841
945
  const keys = new Set(scriptedLocalisation.flatMap(({ localisationKeys }) => localisationKeys));
842
946
  for (const element of elements) {
843
947
  for (const attribute of localisationAttributeNames) {
844
948
  const value = element.attributes[attribute];
845
- if (typeof value === 'string' && value.trim().length > 0)
949
+ if (typeof value === 'string' &&
950
+ value.trim().length > 0 &&
951
+ isStaticGuiLocalisationReference(value))
846
952
  keys.add(value);
847
953
  }
848
954
  }
@@ -862,6 +968,7 @@ function skippedInventoryCouldResolve(edge, sharedIndex) {
862
968
  case 'button_effect':
863
969
  case 'button_trigger':
864
970
  case 'property_target':
971
+ case 'dynamic_list_template':
865
972
  return sharedIndex.hasSkippedSourceForKind('gui_element');
866
973
  case 'parent_scripted_gui':
867
974
  case 'decision_category_entry':
@@ -1016,6 +1123,8 @@ export function buildGuiSourceGraph(files, sharedIndex) {
1016
1123
  name: path.posix.basename(normalized, '.fnt'),
1017
1124
  sourcePath: file.displayPath,
1018
1125
  kind: 'bmfont',
1126
+ override: false,
1127
+ languages: [],
1019
1128
  assetPaths: [file.relativePath],
1020
1129
  }, 'font');
1021
1130
  }