hoi4-agent-tools 2.8.11 → 3.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 (61) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +38 -11
  3. package/dist/hoi4_agent_tools/core/configuration.js +16 -3
  4. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +10 -1
  5. package/dist/hoi4_agent_tools/core/engine.js +3 -0
  6. package/dist/hoi4_agent_tools/core/scanner.js +63 -7
  7. package/dist/hoi4_agent_tools/event/service.js +30 -23
  8. package/dist/hoi4_agent_tools/gui/assets.js +218 -0
  9. package/dist/hoi4_agent_tools/gui/layout.js +70 -9
  10. package/dist/hoi4_agent_tools/gui/renderer.js +80 -11
  11. package/dist/hoi4_agent_tools/gui/scenario-generator.js +36 -0
  12. package/dist/hoi4_agent_tools/gui/studio.js +90 -25
  13. package/dist/hoi4_agent_tools/mcp/tools/gui.js +1 -1
  14. package/dist/hoi4_agent_tools/mcp/tools/probability.js +9 -5
  15. package/dist/hoi4_agent_tools/probability/service.js +81 -14
  16. package/dist/hoi4_agent_tools/schemas/probability.js +2 -0
  17. package/dist/hoi4_agent_tools/technology/compare.js +6 -1
  18. package/dist/hoi4_agent_tools/technology/graph.js +67 -2
  19. package/dist/hoi4_agent_tools/technology/model.js +1 -1
  20. package/dist/hoi4_agent_tools/technology/queries.js +4 -0
  21. package/dist/hoi4_agent_tools/technology/render.js +73 -28
  22. package/dist/hoi4_agent_tools/technology/service.js +4 -1
  23. package/dist/hoi4_agent_tools/technology/source-analysis.js +102 -19
  24. package/dist/hoi4_agent_tools/version.js +1 -1
  25. package/docs/development.md +2 -0
  26. package/docs/gui.md +14 -3
  27. package/docs/images/readme/chaos-meter-ingame.png +0 -0
  28. package/docs/images/readme/chaos-meter-mcp.png +0 -0
  29. package/docs/images/readme/communist-insurgency-ingame.png +0 -0
  30. package/docs/images/readme/communist-insurgency-mcp.png +0 -0
  31. package/docs/images/readme/event-chain-mcp.png +0 -0
  32. package/docs/images/readme/focus-tree-mcp.png +0 -0
  33. package/docs/images/readme/map-mcp.png +0 -0
  34. package/docs/images/readme/probability-mcp.png +0 -0
  35. package/docs/images/readme/settings-advanced-mcp.png +0 -0
  36. package/docs/images/readme/settings-events-mcp.png +0 -0
  37. package/docs/images/readme/technology-tree-mcp.png +0 -0
  38. package/docs/probability.md +3 -1
  39. package/docs/technology.md +1 -1
  40. package/package.json +3 -1
  41. package/schemas/configuration.schema.json +3 -3
  42. package/schemas/continuous-focus-palette.schema.json +1 -1
  43. package/schemas/custom-weighted-pool.schema.json +1 -1
  44. package/schemas/event-feature-manifest.schema.json +1 -1
  45. package/schemas/focus-plan.schema.json +1 -1
  46. package/schemas/focus-planning-sidecar.schema.json +1 -1
  47. package/schemas/gui-animation-source.schema.json +1 -1
  48. package/schemas/gui-helper.schema.json +1 -1
  49. package/schemas/gui-scenario.schema.json +1 -1
  50. package/schemas/map-operation.schema.json +1 -1
  51. package/schemas/operation-result.schema.json +1 -1
  52. package/schemas/probability-analysis-result.schema.json +58 -39
  53. package/schemas/probability-compare-input.schema.json +1 -1
  54. package/schemas/probability-evaluate-input.schema.json +1 -1
  55. package/schemas/probability-inspect-input.schema.json +1 -1
  56. package/schemas/probability-render-input.schema.json +1 -1
  57. package/schemas/probability-scenario-set.schema.json +1 -1
  58. package/schemas/probability-sequence-input.schema.json +1 -1
  59. package/schemas/probability-simulate-input.schema.json +1 -1
  60. package/schemas/probability-sweep-input.schema.json +1 -1
  61. package/server.json +2 -2
@@ -156,6 +156,7 @@ export class GuiAssetCatalog {
156
156
  basenames = new Map();
157
157
  rasters = new Map();
158
158
  frames = new Map();
159
+ countryFlags = new Map();
159
160
  glyphRasters = new Map();
160
161
  metrics = new Map();
161
162
  fontDefinitions = new Map();
@@ -293,6 +294,192 @@ export class GuiAssetCatalog {
293
294
  loadSecondarySpriteFrame(sprite, requestedFrame) {
294
295
  return this.loadSpriteTextureFrame(sprite, sprite.texturePath2, requestedFrame, 'secondary');
295
296
  }
297
+ loadCountryFlag(tag, sprite, ideology, slotName) {
298
+ const normalizedTag = tag
299
+ .trim()
300
+ .replace(/[^A-Za-z0-9_]/gu, '')
301
+ .toUpperCase();
302
+ const normalizedIdeology = ideology
303
+ ?.trim()
304
+ .replace(/[^A-Za-z0-9_]/gu, '')
305
+ .toLowerCase();
306
+ const spriteKey = (sprite?.name ?? slotName)?.toLocaleLowerCase('en-US') ?? 'plain';
307
+ const key = `${normalizedTag}:${normalizedIdeology ?? ''}:${spriteKey}`;
308
+ let promise = this.countryFlags.get(key);
309
+ if (promise !== undefined)
310
+ return promise;
311
+ promise = this.decodeCountryFlag(normalizedTag, sprite, normalizedIdeology, slotName);
312
+ this.countryFlags.set(key, promise);
313
+ return promise;
314
+ }
315
+ async decodeCountryFlag(tag, sprite, ideology, slotName) {
316
+ if (!/^[A-Z0-9_]{2,64}$/u.test(tag))
317
+ return {
318
+ spriteName: `GFX_flag_${tag || 'invalid'}`,
319
+ texturePath: '',
320
+ frame: 0,
321
+ frameCount: 1,
322
+ width: 0,
323
+ height: 0,
324
+ format: 'unknown',
325
+ supported: false,
326
+ reason: `Invalid country tag: ${tag || '<empty>'}`,
327
+ };
328
+ const normalizedSlotName = (sprite?.name ?? slotName)?.toLocaleLowerCase('en-US') ?? '';
329
+ const preferredDirectories = normalizedSlotName.includes('smallest')
330
+ ? ['small', 'medium', '']
331
+ : normalizedSlotName.includes('small')
332
+ ? ['medium', '', 'small']
333
+ : normalizedSlotName.includes('medium')
334
+ ? ['medium', '', 'small']
335
+ : ['', 'medium', 'small'];
336
+ const directories = [...new Set([...preferredDirectories, '', 'medium', 'small'])];
337
+ const ideologyCandidates = ideology === undefined ? ['democratic', 'neutrality', 'fascism', 'communism'] : [ideology];
338
+ const stems = [...ideologyCandidates.map((candidate) => `${tag}_${candidate}`), tag];
339
+ const maskedShield = sprite?.spriteType.toLocaleLowerCase('en-US') === 'maskedshieldtype';
340
+ const [overlay, mask] = maskedShield
341
+ ? await Promise.all([
342
+ sprite.texturePath === undefined
343
+ ? undefined
344
+ : this.loadRaster(sprite.texturePath, sprite.sourcePath),
345
+ sprite.texturePath2 === undefined
346
+ ? undefined
347
+ : this.loadRaster(sprite.texturePath2, sprite.sourcePath),
348
+ ])
349
+ : [undefined, undefined];
350
+ const targetRaster = mask?.supported === true ? mask : overlay?.supported === true ? overlay : undefined;
351
+ const candidates = [];
352
+ for (const directory of directories) {
353
+ for (const stem of stems) {
354
+ for (const extension of ['tga', 'png', 'dds', 'bmp']) {
355
+ const candidate = this.resolveFile(`gfx/flags/${directory.length === 0 ? '' : `${directory}/`}${stem}.${extension}`);
356
+ if (candidate !== undefined && !candidates.includes(candidate))
357
+ candidates.push(candidate);
358
+ }
359
+ }
360
+ }
361
+ if (candidates.length === 0)
362
+ return {
363
+ spriteName: `GFX_flag_${tag}`,
364
+ texturePath: `gfx/flags/${tag}.tga`,
365
+ frame: 0,
366
+ frameCount: 1,
367
+ width: 0,
368
+ height: 0,
369
+ format: 'unknown',
370
+ supported: false,
371
+ reason: `Country flag texture not found for ${tag}.`,
372
+ };
373
+ let firstDecoded;
374
+ let selected;
375
+ for (const file of candidates) {
376
+ const raster = await this.loadRaster(file.relativePath);
377
+ firstDecoded ??= { file, raster };
378
+ if (!raster.supported)
379
+ continue;
380
+ selected ??= { file, raster };
381
+ if (raster.width === targetRaster?.width && raster.height === targetRaster.height) {
382
+ selected = { file, raster };
383
+ break;
384
+ }
385
+ if (targetRaster === undefined)
386
+ break;
387
+ }
388
+ const decoded = selected ?? firstDecoded;
389
+ const flagFile = decoded.file;
390
+ const flag = decoded.raster;
391
+ if (!flag.supported)
392
+ return {
393
+ spriteName: `GFX_flag_${tag}`,
394
+ texturePath: flagFile.relativePath,
395
+ frame: 0,
396
+ frameCount: 1,
397
+ width: flag.width,
398
+ height: flag.height,
399
+ format: flag.format,
400
+ supported: false,
401
+ ...(flag.reason === undefined ? {} : { reason: flag.reason }),
402
+ };
403
+ const width = targetRaster?.width ?? flag.width;
404
+ const height = targetRaster?.height ?? flag.height;
405
+ this.budget.reserveRasterOperation(`gui-country-flag:${flagFile.displayPath}:${sprite?.name ?? slotName ?? 'plain'}`, `GUI country flag rasterization ${tag}`);
406
+ this.budget.reserve(width, height, `GUI country flag ${tag}`, {
407
+ maximumPixels: RENDER_MAX_DECODED_PIXELS,
408
+ });
409
+ const flagPng = await sharp(flag.data, {
410
+ raw: { width: flag.width, height: flag.height, channels: 4 },
411
+ limitInputPixels: RENDER_MAX_DECODED_PIXELS,
412
+ })
413
+ .resize(width, height, { fit: 'fill', kernel: 'nearest' })
414
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
415
+ .toBuffer();
416
+ const composites = [];
417
+ if (mask?.supported === true) {
418
+ const maskPixels = await sharp(mask.data, {
419
+ raw: { width: mask.width, height: mask.height, channels: 4 },
420
+ limitInputPixels: RENDER_MAX_DECODED_PIXELS,
421
+ })
422
+ .resize(width, height, { fit: 'fill', kernel: 'nearest' })
423
+ .raw()
424
+ .toBuffer();
425
+ let alphaVaries = false;
426
+ const firstAlpha = maskPixels[3] ?? 255;
427
+ for (let offset = 7; offset < maskPixels.length; offset += 4) {
428
+ if (maskPixels[offset] !== firstAlpha) {
429
+ alphaVaries = true;
430
+ break;
431
+ }
432
+ }
433
+ const alphaMask = Buffer.alloc(maskPixels.length);
434
+ for (let offset = 0; offset < maskPixels.length; offset += 4) {
435
+ alphaMask[offset] = 255;
436
+ alphaMask[offset + 1] = 255;
437
+ alphaMask[offset + 2] = 255;
438
+ alphaMask[offset + 3] = alphaVaries
439
+ ? maskPixels[offset + 3]
440
+ : Math.round(maskPixels[offset] * 0.2126 +
441
+ maskPixels[offset + 1] * 0.7152 +
442
+ maskPixels[offset + 2] * 0.0722);
443
+ }
444
+ composites.push({
445
+ input: await sharp(alphaMask, {
446
+ raw: { width, height, channels: 4 },
447
+ limitInputPixels: RENDER_MAX_DECODED_PIXELS,
448
+ })
449
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
450
+ .toBuffer(),
451
+ blend: 'dest-in',
452
+ });
453
+ }
454
+ if (overlay?.supported === true)
455
+ composites.push({
456
+ input: await sharp(overlay.data, {
457
+ raw: { width: overlay.width, height: overlay.height, channels: 4 },
458
+ limitInputPixels: RENDER_MAX_DECODED_PIXELS,
459
+ })
460
+ .resize(width, height, { fit: 'fill', kernel: 'nearest' })
461
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
462
+ .toBuffer(),
463
+ blend: 'over',
464
+ });
465
+ const png = composites.length === 0
466
+ ? flagPng
467
+ : await sharp(flagPng, { limitInputPixels: RENDER_MAX_DECODED_PIXELS })
468
+ .composite(composites)
469
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
470
+ .toBuffer();
471
+ return {
472
+ spriteName: `GFX_flag_${tag}`,
473
+ texturePath: flagFile.relativePath,
474
+ frame: 0,
475
+ frameCount: 1,
476
+ width,
477
+ height,
478
+ dataUri: `data:image/png;base64,${png.toString('base64')}`,
479
+ format: flag.format,
480
+ supported: true,
481
+ };
482
+ }
296
483
  loadSpriteTextureFrame(sprite, texturePath, requestedFrame, layer) {
297
484
  if (texturePath === undefined)
298
485
  return Promise.resolve(undefined);
@@ -632,6 +819,13 @@ export class GuiAssetCatalog {
632
819
  }
633
820
  return maximum - minimum;
634
821
  });
822
+ const channelActivePixels = [0, 1, 2, 3].map((channel) => {
823
+ let active = 0;
824
+ for (let offset = channel; offset < extracted.data.length; offset += 4)
825
+ if ((extracted.data[offset] ?? 0) > 0)
826
+ active += 1;
827
+ return active;
828
+ });
635
829
  const channelSelected = (channel) => {
636
830
  if (metric.channelMask === 15)
637
831
  return true;
@@ -649,6 +843,30 @@ export class GuiAssetCatalog {
649
843
  const alphaIsInformative = (channelRanges[3] ?? 0) > 0;
650
844
  let faceChannels = informativeChannels(0);
651
845
  let borderChannels = informativeChannels(1);
846
+ const undeclaredRgbFaceChannels = channelRoles === undefined
847
+ ? []
848
+ : [0, 1, 2].filter((channel) => channelRoles[channel] === 4 &&
849
+ (channelRanges[channel] ?? 0) > 0 &&
850
+ (channelActivePixels[channel] ?? 0) > 0);
851
+ const undeclaredRgbChannelsAgree = undeclaredRgbFaceChannels.every((channel) => channel === undeclaredRgbFaceChannels[0] ||
852
+ extracted.data.every((sample, offset) => offset % 4 !== channel ||
853
+ sample ===
854
+ extracted.data[offset - channel + (undeclaredRgbFaceChannels[0] ?? channel)]));
855
+ if (faceChannels.includes(3) &&
856
+ borderChannels.length === 0 &&
857
+ undeclaredRgbFaceChannels.length > 0 &&
858
+ undeclaredRgbChannelsAgree) {
859
+ const rgbFace = undeclaredRgbFaceChannels[0];
860
+ const rgbPixels = channelActivePixels[rgbFace] ?? 0;
861
+ const alphaPixels = channelActivePixels[3] ?? 0;
862
+ if (rgbPixels * 1.15 < alphaPixels) {
863
+ // Some shipped HOI4 descriptors label RGB as constant even though RGB stores the
864
+ // thin face and alpha stores the wider face-plus-outline mask. Trusting the declared
865
+ // alpha role paints the outline in the face colour and visibly fattens the font.
866
+ faceChannels = [rgbFace];
867
+ borderChannels = [3];
868
+ }
869
+ }
652
870
  if (faceChannels.length === 0) {
653
871
  if (borderChannels.length > 0) {
654
872
  // Some vanilla fonts declare their only useful alpha bitmap as the outline
@@ -570,6 +570,41 @@ function anchoredCoordinate(start, span, offset, anchor) {
570
570
  ? start + span / 2 + offset
571
571
  : start + offset;
572
572
  }
573
+ function dynamicCountryFlag(elementName, expression, scenario, rowValues) {
574
+ if (typeof expression !== 'string')
575
+ return undefined;
576
+ const match = /^\[\?([^\]|]+)(?:\|[^\]]+)?\]$|^\[([^\]]+)\]$/u.exec(expression);
577
+ const token = match?.[1] ?? match?.[2];
578
+ const shortToken = token?.slice(token.lastIndexOf('.') + 1);
579
+ const tagCandidates = [
580
+ rowValues?.[`${elementName}.countryTag`],
581
+ rowValues?.countryTag,
582
+ rowValues?.GetTag,
583
+ rowValues?.tag,
584
+ scenario.values[`${elementName}.countryTag`],
585
+ scenario.values[`${elementName}.tag`],
586
+ token === undefined ? undefined : scenario.values[token],
587
+ shortToken === undefined ? undefined : scenario.values[shortToken],
588
+ scenario.scriptedGui[`${elementName}.countryTag`],
589
+ scenario.country?.countryTag,
590
+ scenario.country?.GetTag,
591
+ scenario.country?.tag,
592
+ ];
593
+ const rawTag = tagCandidates.find((candidate) => typeof candidate === 'string' && /^[A-Za-z0-9_]{2,64}$/u.test(candidate));
594
+ if (rawTag === undefined)
595
+ return undefined;
596
+ const ideologyCandidates = [
597
+ rowValues?.[`${elementName}.ideology`],
598
+ rowValues?.ideology,
599
+ rowValues?.countryIdeology,
600
+ scenario.values[`${elementName}.ideology`],
601
+ scenario.values[`${elementName}.countryIdeology`],
602
+ scenario.country?.ideology,
603
+ scenario.country?.countryIdeology,
604
+ ];
605
+ const ideology = ideologyCandidates.find((candidate) => typeof candidate === 'string' && /^[A-Za-z0-9_]{2,64}$/u.test(candidate));
606
+ return { tag: rawTag, ...(ideology === undefined ? {} : { ideology }) };
607
+ }
573
608
  class GuiSceneWorkBudget {
574
609
  work = 0;
575
610
  elements = 0;
@@ -643,6 +678,13 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
643
678
  0;
644
679
  let width = scaledNumber(size === undefined ? undefined : property(size, 'width', 'x'), parentRect.width, scale) ?? 0;
645
680
  let height = scaledNumber(size === undefined ? undefined : property(size, 'height', 'y'), parentRect.height, scale) ?? 0;
681
+ const scriptedVisible = rowValues?.[`${definition.name}.visible`] ??
682
+ scenario.values[`${definition.name}.visible`] ??
683
+ scenario.scriptedGui[`${definition.name}.visible`];
684
+ const explicitlyVisible = scenario.visibility[definition.name] ??
685
+ scenario.visibility[definition.id] ??
686
+ (typeof scriptedVisible === 'boolean' ? scriptedVisible : undefined);
687
+ const resolveVisibleAppearance = inheritedVisible && explicitlyVisible !== false;
646
688
  const background = objectProperty(property(definition.attributes, 'background'));
647
689
  const scriptedImage = rowValues?.[`${definition.name}.image`] ??
648
690
  rowValues?.[definition.name] ??
@@ -659,7 +701,23 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
659
701
  let sprite;
660
702
  let secondarySprite;
661
703
  let spriteRenderMode;
662
- if (spriteName !== undefined && spriteDefinition === undefined) {
704
+ const countryFlag = dynamicCountryFlag(definition.name, context.countryFlagProperties.get(definition.name), scenario, rowValues);
705
+ if (countryFlag !== undefined && resolveVisibleAppearance) {
706
+ sprite = await catalog.loadCountryFlag(countryFlag.tag, spriteDefinition, countryFlag.ideology, spriteName);
707
+ spriteRenderMode = 'stretch';
708
+ if (sprite.supported) {
709
+ if (width === 0)
710
+ width = sprite.width * scale;
711
+ if (height === 0)
712
+ height = sprite.height * scale;
713
+ addFidelity(fidelity, 'modelled', 'dynamic_country_flag', `${definition.name} resolved the ${countryFlag.tag} flag from scenario country context.`, definition);
714
+ }
715
+ else {
716
+ addFidelity(fidelity, 'missing', 'dynamic_country_flag', sprite.reason ?? `Country flag ${countryFlag.tag} could not be rendered.`, definition);
717
+ diagnostics.push(diagnostic('GUI_COUNTRY_FLAG_MISSING', 'warning', sprite.reason ?? `Country flag ${countryFlag.tag} could not be rendered.`, definition));
718
+ }
719
+ }
720
+ else if (spriteName !== undefined && spriteDefinition === undefined) {
663
721
  const partialInventory = context.graph.edges.some((edge) => edge.kind === 'uses_sprite' &&
664
722
  edge.from === definition.id &&
665
723
  !edge.resolved &&
@@ -669,7 +727,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
669
727
  ? `The partial GUI inventory cannot resolve sprite ${spriteName} for ${definition.name}; a skipped source could define it.`
670
728
  : `Element ${definition.name} references missing sprite ${spriteName}.`, definition));
671
729
  }
672
- else if (spriteDefinition !== undefined) {
730
+ else if (spriteDefinition !== undefined && resolveVisibleAppearance) {
673
731
  const partialAppearance = [];
674
732
  const spriteType = spriteDefinition.spriteType.toLowerCase();
675
733
  spriteRenderMode =
@@ -740,7 +798,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
740
798
  const embeddedButtonText = rawText !== undefined &&
741
799
  (rawButtonText !== undefined || definition.elementType.toLowerCase().includes('button'));
742
800
  let text;
743
- if (rawText !== undefined) {
801
+ if (rawText !== undefined && resolveVisibleAppearance) {
744
802
  const resolved = resolveTokenText(rawText, scenario, context.localisation, rowValues);
745
803
  let displayText = resolved.text;
746
804
  const state = scenario.elementStates[definition.name] ?? scenario.state;
@@ -888,12 +946,6 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
888
946
  const clipRect = inheritedClip;
889
947
  const clipped = clipRect !== undefined &&
890
948
  (availableClip === undefined || !equalRect(unclippedRect, availableClip));
891
- const scriptedVisible = rowValues?.[`${definition.name}.visible`] ??
892
- scenario.values[`${definition.name}.visible`] ??
893
- scenario.scriptedGui[`${definition.name}.visible`];
894
- const explicitlyVisible = scenario.visibility[definition.name] ??
895
- scenario.visibility[definition.id] ??
896
- (typeof scriptedVisible === 'boolean' ? scriptedVisible : undefined);
897
949
  const visible = inheritedVisible &&
898
950
  (explicitlyVisible ?? (availableClip !== undefined || inheritedClip === undefined));
899
951
  const clickThrough = scalarBoolean(property(definition.attributes, 'clickThrough', 'alwaystransparent', 'allwaystransparent')) ?? false;
@@ -1141,6 +1193,7 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1141
1193
  const constantElementEnabled = {};
1142
1194
  const resolvedScriptedProperties = {};
1143
1195
  const dynamicListsByName = new Map();
1196
+ const countryFlagProperties = new Map();
1144
1197
  for (const scripted of graph.scriptedGuis.filter(({ name }) => relevantScriptedGuiNames.has(name))) {
1145
1198
  for (const dynamicList of scripted.dynamicListDefinitions)
1146
1199
  dynamicListsByName.set(dynamicList.name, dynamicList);
@@ -1149,6 +1202,13 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1149
1202
  const suffix = attribute.toLowerCase();
1150
1203
  if (!['image', 'frame', 'x', 'y', 'visible', 'enabled'].includes(suffix))
1151
1204
  continue;
1205
+ const countryFlagExpression = suffix === 'image' &&
1206
+ typeof expression === 'string' &&
1207
+ /(?:^|\.)GetFlag\]$/u.test(expression);
1208
+ if (countryFlagExpression)
1209
+ countryFlagProperties.set(propertyDefinition.elementName, expression);
1210
+ if (countryFlagExpression)
1211
+ continue;
1152
1212
  const resolved = scenarioExpressionValue(expression, scenario.values);
1153
1213
  if (resolved === undefined)
1154
1214
  continue;
@@ -1210,6 +1270,7 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1210
1270
  elementsById,
1211
1271
  elementsByName,
1212
1272
  dynamicListsByName,
1273
+ countryFlagProperties,
1213
1274
  constantElementEnabled,
1214
1275
  spritesByName: new Map(graph.sprites.map((sprite) => [sprite.name.toLowerCase(), sprite])),
1215
1276
  localisation,
@@ -87,7 +87,13 @@ function actualGlyphMarkup(glyphLine, originX, baseline, horizontalScale, bitmap
87
87
  return glyphLine.glyphs
88
88
  .filter((glyph) => glyph.kind === 'bitmap')
89
89
  .filter((glyph) => bitmapLayer === 'face' || glyph.borderDataUri !== undefined)
90
- .map((glyph) => `<use href="#${bitmapDefinitionId(glyph.key, bitmapLayer)}" transform="translate(${finite(originX + glyph.x * horizontalScale)} ${finite(baseline + glyph.y - glyphLine.baseline)}) scale(${finite(horizontalScale)} 1)"/>`)
90
+ .map((glyph) => {
91
+ const nativePixelGrid = Math.abs(horizontalScale - 1) < 0.000_001 &&
92
+ [glyph.x, glyph.y, glyph.width, glyph.height, glyphLine.baseline].every((value) => Math.abs(value - Math.round(value)) < 0.000_001);
93
+ const x = originX + glyph.x * horizontalScale;
94
+ const y = baseline + glyph.y - glyphLine.baseline;
95
+ return `<use href="#${bitmapDefinitionId(glyph.key, bitmapLayer)}" transform="translate(${finite(nativePixelGrid ? Math.round(x) : x)} ${finite(nativePixelGrid ? Math.round(y) : y)}) scale(${finite(horizontalScale)} 1)"/>`;
96
+ })
91
97
  .join('');
92
98
  return '';
93
99
  }
@@ -399,16 +405,23 @@ function viewFor(scene, variant) {
399
405
  };
400
406
  }
401
407
  const padding = 16;
402
- const x = Math.max(0, scene.bounds.x - padding);
403
- const y = Math.max(0, scene.bounds.y - padding);
404
- const right = Math.min(scene.resolution.width, scene.bounds.x + scene.bounds.width + padding);
405
- const bottom = Math.min(scene.resolution.height, scene.bounds.y + scene.bounds.height + padding);
408
+ const x = Math.floor(Math.max(0, scene.bounds.x - padding));
409
+ const y = Math.floor(Math.max(0, scene.bounds.y - padding));
410
+ const right = Math.ceil(Math.min(scene.resolution.width, scene.bounds.x + scene.bounds.width + padding));
411
+ const bottom = Math.ceil(Math.min(scene.resolution.height, scene.bounds.y + scene.bounds.height + padding));
406
412
  return {
407
413
  viewBox: { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) },
408
414
  width: Math.max(1, Math.ceil(right - x)),
409
415
  height: Math.max(1, Math.ceil(bottom - y)),
410
416
  };
411
417
  }
418
+ function overlayToSvg(scene, variant) {
419
+ const toolText = new DeterministicSvgTextRenderer();
420
+ const overlays = scene.elements
421
+ .map((element) => renderOverlay(element, variant, toolText))
422
+ .join('');
423
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${scene.resolution.width}" height="${scene.resolution.height}" viewBox="0 0 ${scene.resolution.width} ${scene.resolution.height}"><defs>${toolText.definitions()}</defs>${overlays}</svg>`;
424
+ }
412
425
  export function sceneToSvg(scene, variant) {
413
426
  const view = viewFor(scene, variant);
414
427
  assertRenderDimensions(view.width, view.height, `GUI ${variant} SVG`);
@@ -548,7 +561,7 @@ export async function renderGuiScene(scene, variants = defaultVariants, signal,
548
561
  if (scene.elements.length > GUI_SCENE_MAX_ELEMENTS) {
549
562
  throw new ServiceError('GUI_RENDER_ELEMENT_BUDGET_BLOCKED', 'GUI scene exceeds the fixed render element ceiling', { elements: scene.elements.length, maximumElements: GUI_SCENE_MAX_ELEMENTS });
550
563
  }
551
- const images = [];
564
+ const prepared = [];
552
565
  for (const variant of variants) {
553
566
  signal?.throwIfAborted();
554
567
  const view = viewFor(scene, variant);
@@ -556,13 +569,69 @@ export async function renderGuiScene(scene, variants = defaultVariants, signal,
556
569
  const svg = signal === undefined
557
570
  ? sceneToSvg(scene, variant)
558
571
  : await sceneToSvgCooperative(scene, variant, signal);
559
- assertRenderDimensions(view.width, view.height, `GUI ${variant} Sharp raster`);
560
- budget.reserveRasterOperation(`gui-variant:${sha256Bytes(svg)}`, `GUI ${variant} SVG rasterization`);
561
- const png = await sharp(Buffer.from(svg), { limitInputPixels: RENDER_MAX_PIXELS })
572
+ prepared.push({ variant, view, svg });
573
+ }
574
+ const full = prepared.find(({ variant }) => variant === 'full');
575
+ let images;
576
+ if (full === undefined) {
577
+ images = [];
578
+ for (const item of prepared) {
579
+ assertRenderDimensions(item.view.width, item.view.height, `GUI ${item.variant} Sharp raster`);
580
+ budget.reserveRasterOperation(`gui-variant:${sha256Bytes(item.svg)}`, `GUI ${item.variant} SVG rasterization`);
581
+ const png = await sharp(Buffer.from(item.svg), { limitInputPixels: RENDER_MAX_PIXELS })
582
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
583
+ .toBuffer();
584
+ signal?.throwIfAborted();
585
+ images.push({
586
+ variant: item.variant,
587
+ svg: item.svg,
588
+ png,
589
+ width: item.view.width,
590
+ height: item.view.height,
591
+ });
592
+ }
593
+ }
594
+ else {
595
+ assertRenderDimensions(full.view.width, full.view.height, 'GUI full Sharp raster');
596
+ budget.reserveRasterOperation(`gui-variant:${sha256Bytes(full.svg)}`, 'GUI full SVG rasterization');
597
+ const basePng = await sharp(Buffer.from(full.svg), { limitInputPixels: RENDER_MAX_PIXELS })
562
598
  .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
563
599
  .toBuffer();
564
- signal?.throwIfAborted();
565
- images.push({ variant, svg, png, width: view.width, height: view.height });
600
+ const rendered = await Promise.all(prepared.map(async (item) => {
601
+ if (item.variant === 'full')
602
+ return {
603
+ variant: item.variant,
604
+ svg: item.svg,
605
+ png: basePng,
606
+ width: item.view.width,
607
+ height: item.view.height,
608
+ };
609
+ signal?.throwIfAborted();
610
+ budget.reserveRasterOperation(`gui-derived-variant:${sha256Bytes(item.svg)}`, `GUI ${item.variant} derived rasterization`);
611
+ const png = item.variant === 'cropped'
612
+ ? await sharp(basePng, { limitInputPixels: RENDER_MAX_PIXELS })
613
+ .extract({
614
+ left: item.view.viewBox.x,
615
+ top: item.view.viewBox.y,
616
+ width: item.view.width,
617
+ height: item.view.height,
618
+ })
619
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
620
+ .toBuffer()
621
+ : await sharp(basePng, { limitInputPixels: RENDER_MAX_PIXELS })
622
+ .composite([{ input: Buffer.from(overlayToSvg(scene, item.variant)) }])
623
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
624
+ .toBuffer();
625
+ signal?.throwIfAborted();
626
+ return {
627
+ variant: item.variant,
628
+ svg: item.svg,
629
+ png,
630
+ width: item.view.width,
631
+ height: item.view.height,
632
+ };
633
+ }));
634
+ images = rendered;
566
635
  }
567
636
  return {
568
637
  scene,
@@ -1,6 +1,16 @@
1
1
  import { sha256Bytes } from '../core/canonical.js';
2
2
  import { parsePreviewScenario } from './scenario.js';
3
3
  const countryNames = ['Germany', 'France', 'Poland', 'Italy', 'Brazil', 'India', 'Canada'];
4
+ const countryTags = ['GER', 'FRA', 'POL', 'ITA', 'BRA', 'RAJ', 'CAN'];
5
+ const countryIdeologies = [
6
+ 'fascism',
7
+ 'democratic',
8
+ 'democratic',
9
+ 'fascism',
10
+ 'democratic',
11
+ 'neutrality',
12
+ 'democratic',
13
+ ];
4
14
  const countryAdjectives = [
5
15
  'German',
6
16
  'French',
@@ -447,6 +457,8 @@ function generatedRows(count, random, options) {
447
457
  return Array.from({ length: count }, (_unused, index) => {
448
458
  const countryIndex = (countryOffset + index) % countryNames.length;
449
459
  const name = countryNames[countryIndex];
460
+ const tag = countryTags[countryIndex];
461
+ const ideology = countryIdeologies[countryIndex];
450
462
  const value = generatedNumber(options, random);
451
463
  return {
452
464
  index: index + 1,
@@ -455,6 +467,12 @@ function generatedRows(count, random, options) {
455
467
  name,
456
468
  GetName: name,
457
469
  GetAdjective: countryAdjectives[countryIndex],
470
+ GetTag: tag,
471
+ tag,
472
+ countryTag: tag,
473
+ ideology,
474
+ countryIdeology: ideology,
475
+ countryScope: true,
458
476
  value,
459
477
  amount: value,
460
478
  percentage: Math.max(0, Math.min(100, value)),
@@ -532,6 +550,8 @@ export function generateGuiPreviewScenarios(graph, windowName, base, options) {
532
550
  const visibility = generatedVisibility(graph, scripted, options, random, base.visibility);
533
551
  const elementStates = { ...base.elementStates };
534
552
  const scopedCountries = new Map();
553
+ const generatedCountryIndex = random.integer(0, countryNames.length - 1);
554
+ const generatedCountryTag = countryTags[generatedCountryIndex];
535
555
  for (const key of tokens.numeric)
536
556
  setIfMissing(values, key, generatedNumberForKey(key, options, random, numericConstraints.ranges.get(key)));
537
557
  for (const [elementName, { minimum, maximum }] of progressRanges) {
@@ -580,6 +600,10 @@ export function generateGuiPreviewScenarios(graph, windowName, base, options) {
580
600
  for (const [key, template] of scriptedTextTemplates)
581
601
  values[key] = materializeDynamicText(template, values);
582
602
  for (const key of imageTokens) {
603
+ if (/(?:^|\.)GetFlag$/u.test(key)) {
604
+ setIfMissing(values, key, generatedCountryTag);
605
+ continue;
606
+ }
583
607
  const sprite = scriptedLocalisationValue(graph, key, base.language, random, 'image', values);
584
608
  if (sprite !== undefined)
585
609
  setIfMissing(values, key, sprite);
@@ -603,6 +627,18 @@ export function generateGuiPreviewScenarios(graph, windowName, base, options) {
603
627
  id: `${base.id}-${options.idPrefix}-${scenarioIndex + 1}`,
604
628
  description: `Generated source-aware scenario ${scenarioIndex + 1} (seed ${sourceSeed})`,
605
629
  values,
630
+ country: [...imageTokens].some((key) => /(?:^|\.)GetFlag$/u.test(key))
631
+ ? {
632
+ GetName: countryNames[generatedCountryIndex],
633
+ GetAdjective: countryAdjectives[generatedCountryIndex],
634
+ GetTag: generatedCountryTag,
635
+ tag: generatedCountryTag,
636
+ countryTag: generatedCountryTag,
637
+ ideology: countryIdeologies[generatedCountryIndex],
638
+ countryIdeology: countryIdeologies[generatedCountryIndex],
639
+ ...base.country,
640
+ }
641
+ : base.country,
606
642
  lists,
607
643
  visibility,
608
644
  elementStates,