hoi4-agent-tools 2.8.10 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +35 -10
  3. package/dist/bin/stdio.js +0 -4
  4. package/dist/hoi4_agent_tools/core/configuration.js +16 -3
  5. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +10 -13
  6. package/dist/hoi4_agent_tools/core/engine.js +3 -0
  7. package/dist/hoi4_agent_tools/core/scanner.js +63 -7
  8. package/dist/hoi4_agent_tools/core/source/limits.js +6 -7
  9. package/dist/hoi4_agent_tools/event/graph.js +3 -4
  10. package/dist/hoi4_agent_tools/event/limits.js +4 -4
  11. package/dist/hoi4_agent_tools/event/service.js +34 -39
  12. package/dist/hoi4_agent_tools/gui/assets.js +280 -12
  13. package/dist/hoi4_agent_tools/gui/layout.js +70 -9
  14. package/dist/hoi4_agent_tools/gui/renderer.js +81 -12
  15. package/dist/hoi4_agent_tools/gui/scenario-generator.js +36 -0
  16. package/dist/hoi4_agent_tools/gui/studio.js +90 -25
  17. package/dist/hoi4_agent_tools/mcp/tools/gui.js +1 -1
  18. package/dist/hoi4_agent_tools/mcp/tools/probability.js +72 -14
  19. package/dist/hoi4_agent_tools/probability/sequence.js +43 -18
  20. package/dist/hoi4_agent_tools/probability/service.js +187 -24
  21. package/dist/hoi4_agent_tools/schemas/probability.js +2 -0
  22. package/dist/hoi4_agent_tools/technology/service.js +41 -8
  23. package/dist/hoi4_agent_tools/version.js +1 -1
  24. package/docs/development.md +2 -0
  25. package/docs/gui.md +14 -3
  26. package/docs/images/readme/chaos-meter-ingame.png +0 -0
  27. package/docs/images/readme/chaos-meter-mcp.png +0 -0
  28. package/docs/images/readme/communist-insurgency-ingame.png +0 -0
  29. package/docs/images/readme/communist-insurgency-mcp.png +0 -0
  30. package/docs/images/readme/event-chain-mcp.png +0 -0
  31. package/docs/images/readme/focus-tree-mcp.png +0 -0
  32. package/docs/images/readme/map-mcp.png +0 -0
  33. package/docs/images/readme/probability-mcp.png +0 -0
  34. package/docs/images/readme/settings-advanced-mcp.png +0 -0
  35. package/docs/images/readme/settings-events-mcp.png +0 -0
  36. package/docs/images/readme/technology-tree-mcp.png +0 -0
  37. package/package.json +3 -1
  38. package/schemas/configuration.schema.json +3 -3
  39. package/schemas/continuous-focus-palette.schema.json +1 -1
  40. package/schemas/custom-weighted-pool.schema.json +1 -1
  41. package/schemas/event-feature-manifest.schema.json +1 -1
  42. package/schemas/focus-plan.schema.json +1 -1
  43. package/schemas/focus-planning-sidecar.schema.json +1 -1
  44. package/schemas/gui-animation-source.schema.json +1 -1
  45. package/schemas/gui-helper.schema.json +1 -1
  46. package/schemas/gui-scenario.schema.json +1 -1
  47. package/schemas/map-operation.schema.json +1 -1
  48. package/schemas/operation-result.schema.json +1 -1
  49. package/schemas/probability-analysis-result.schema.json +58 -39
  50. package/schemas/probability-compare-input.schema.json +1 -1
  51. package/schemas/probability-evaluate-input.schema.json +1 -1
  52. package/schemas/probability-inspect-input.schema.json +1 -1
  53. package/schemas/probability-render-input.schema.json +1 -1
  54. package/schemas/probability-scenario-set.schema.json +1 -1
  55. package/schemas/probability-sequence-input.schema.json +1 -1
  56. package/schemas/probability-simulate-input.schema.json +1 -1
  57. package/schemas/probability-sweep-input.schema.json +1 -1
  58. package/server.json +2 -2
  59. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +0 -483
@@ -110,6 +110,7 @@ export function parseBmFont(text) {
110
110
  ? fields.width
111
111
  : 0,
112
112
  page: typeof fields.page === 'number' ? fields.page : 0,
113
+ channelMask: typeof fields.chnl === 'number' ? fields.chnl : 15,
113
114
  });
114
115
  }
115
116
  recognised = true;
@@ -155,6 +156,7 @@ export class GuiAssetCatalog {
155
156
  basenames = new Map();
156
157
  rasters = new Map();
157
158
  frames = new Map();
159
+ countryFlags = new Map();
158
160
  glyphRasters = new Map();
159
161
  metrics = new Map();
160
162
  fontDefinitions = new Map();
@@ -292,6 +294,192 @@ export class GuiAssetCatalog {
292
294
  loadSecondarySpriteFrame(sprite, requestedFrame) {
293
295
  return this.loadSpriteTextureFrame(sprite, sprite.texturePath2, requestedFrame, 'secondary');
294
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
+ }
295
483
  loadSpriteTextureFrame(sprite, texturePath, requestedFrame, layer) {
296
484
  if (texturePath === undefined)
297
485
  return Promise.resolve(undefined);
@@ -471,7 +659,9 @@ export class GuiAssetCatalog {
471
659
  if (previous !== undefined)
472
660
  penX += (entry.bmfont.kerning.get(`${previous}:${codePoint}`) ?? 0) * scale;
473
661
  const pagePath = entry.bmfont.pages[metric.page];
474
- const pageFile = pagePath === undefined ? undefined : this.resolveBmFontPage(entry, pagePath, metric.page);
662
+ // Older shipped HOI4 descriptors omit the page record entirely. Page zero still uses the
663
+ // descriptor stem (for example garamond_12.fnt -> garamond_12.dds/tga).
664
+ const pageFile = this.resolveBmFontPage(entry, pagePath ?? '', metric.page);
475
665
  const raster = pageFile === undefined
476
666
  ? undefined
477
667
  : await this.loadBmFontGlyph(entry, pageFile, metric, codePoint);
@@ -619,26 +809,104 @@ export class GuiAssetCatalog {
619
809
  .raw()
620
810
  .toBuffer({ resolveWithObject: true });
621
811
  const channelRoles = entry.bmfont?.channelRoles;
622
- let facePixels = extracted.data;
812
+ const channelRanges = [0, 1, 2, 3].map((channel) => {
813
+ let minimum = 255;
814
+ let maximum = 0;
815
+ for (let offset = channel; offset < extracted.data.length; offset += 4) {
816
+ const sample = extracted.data[offset] ?? 0;
817
+ minimum = Math.min(minimum, sample);
818
+ maximum = Math.max(maximum, sample);
819
+ }
820
+ return maximum - minimum;
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
+ });
829
+ const channelSelected = (channel) => {
830
+ if (metric.channelMask === 15)
831
+ return true;
832
+ const maskForChannel = [4, 2, 1, 8][channel] ?? 0;
833
+ return (metric.channelMask & maskForChannel) !== 0;
834
+ };
835
+ const informativeChannels = (role) => channelRoles === undefined
836
+ ? []
837
+ : channelRoles
838
+ .map((candidate, channel) => ({ candidate, channel }))
839
+ .filter(({ candidate, channel }) => candidate === role &&
840
+ channelSelected(channel) &&
841
+ (channelRanges[channel] ?? 0) > 0)
842
+ .map(({ channel }) => channel);
843
+ const alphaIsInformative = (channelRanges[3] ?? 0) > 0;
844
+ let faceChannels = informativeChannels(0);
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
+ }
870
+ if (faceChannels.length === 0) {
871
+ if (borderChannels.length > 0) {
872
+ // Some vanilla fonts declare their only useful alpha bitmap as the outline
873
+ // channel even though the atlas contains no separate face channel. In that case
874
+ // the alpha bitmap is the face; treating constant-white RGB as coverage produces
875
+ // an opaque rectangle for every glyph.
876
+ faceChannels = borderChannels;
877
+ borderChannels = [];
878
+ }
879
+ else {
880
+ faceChannels = alphaIsInformative
881
+ ? [3]
882
+ : channelRanges
883
+ .map((range, channel) => ({ range, channel }))
884
+ .filter(({ range }) => range > 0)
885
+ .map(({ channel }) => channel);
886
+ }
887
+ }
888
+ const facePixels = Buffer.alloc(extracted.data.length);
623
889
  let borderPixels;
624
- if (channelRoles?.some((role) => role === 0) === true) {
625
- facePixels = Buffer.alloc(extracted.data.length);
626
- borderPixels = channelRoles.some((role) => role === 1)
627
- ? Buffer.alloc(extracted.data.length)
628
- : undefined;
890
+ if (faceChannels.length > 0) {
891
+ borderPixels =
892
+ borderChannels.length > 0 ? Buffer.alloc(extracted.data.length) : undefined;
629
893
  // BMFont role 0 is coverage, not a per-glyph light/dark bitmap. Vanilla atlases may
630
894
  // contain glyphs with either more filled or more empty pixels, so polarity inference
631
895
  // from an individual crop corrupts neighboring letters inconsistently.
632
896
  for (let offset = 0; offset < extracted.data.length; offset += 4) {
633
897
  let faceCoverage = 0;
634
898
  let borderCoverage = 0;
635
- for (let channel = 0; channel < 4; channel += 1) {
899
+ for (const channel of faceChannels) {
636
900
  const sample = extracted.data[offset + channel] ?? 0;
637
- if (channelRoles[channel] === 0)
638
- faceCoverage = Math.max(faceCoverage, sample);
639
- if (channelRoles[channel] === 1)
640
- borderCoverage = Math.max(borderCoverage, sample);
901
+ faceCoverage = Math.max(faceCoverage, sample);
641
902
  }
903
+ for (const channel of borderChannels)
904
+ borderCoverage = Math.max(borderCoverage, extracted.data[offset + channel] ?? 0);
905
+ // Vanilla's compressed multi-channel atlases can bleed a neighboring glyph into
906
+ // an RGB face crop while the alpha silhouette remains correctly bounded. The
907
+ // face is the intersection, not the union, of those declared layers.
908
+ if (borderChannels.length > 0)
909
+ faceCoverage = Math.min(faceCoverage, borderCoverage);
642
910
  facePixels[offset] = 255;
643
911
  facePixels[offset + 1] = 255;
644
912
  facePixels[offset + 2] = 255;
@@ -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,
@@ -60,7 +60,7 @@ function sceneGlyphDefinitions(scene) {
60
60
  .map(([key, glyphPath]) => `<path id="${outlineDefinitionId(key)}" d="${glyphPath}"/>`),
61
61
  ...[...bitmaps.entries()]
62
62
  .sort(([left], [right]) => compareCodeUnits(left, right))
63
- .map(([key, glyph]) => `<image id="${bitmapDefinitionId(key)}" width="${finite(glyph.width)}" height="${finite(glyph.height)}" href="${glyph.dataUri}" preserveAspectRatio="none"/>${glyph.borderDataUri === undefined ? '' : `<image id="${bitmapDefinitionId(key, 'border')}" width="${finite(glyph.width)}" height="${finite(glyph.height)}" href="${glyph.borderDataUri}" preserveAspectRatio="none"/>`}`),
63
+ .map(([key, glyph]) => `<image id="${bitmapDefinitionId(key)}" width="${finite(glyph.width)}" height="${finite(glyph.height)}" href="${glyph.dataUri}" preserveAspectRatio="none" image-rendering="optimizeSpeed"/>${glyph.borderDataUri === undefined ? '' : `<image id="${bitmapDefinitionId(key, 'border')}" width="${finite(glyph.width)}" height="${finite(glyph.height)}" href="${glyph.borderDataUri}" preserveAspectRatio="none" image-rendering="optimizeSpeed"/>`}`),
64
64
  ].join('');
65
65
  }
66
66
  function bitmapTintMarkup(id, colour, glyphs, rect) {
@@ -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,