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.
- package/CHANGELOG.md +16 -0
- package/README.md +38 -11
- package/dist/hoi4_agent_tools/core/configuration.js +16 -3
- package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +10 -1
- package/dist/hoi4_agent_tools/core/engine.js +3 -0
- package/dist/hoi4_agent_tools/core/scanner.js +63 -7
- package/dist/hoi4_agent_tools/event/service.js +30 -23
- package/dist/hoi4_agent_tools/gui/assets.js +218 -0
- package/dist/hoi4_agent_tools/gui/layout.js +70 -9
- package/dist/hoi4_agent_tools/gui/renderer.js +80 -11
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +36 -0
- package/dist/hoi4_agent_tools/gui/studio.js +90 -25
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +9 -5
- package/dist/hoi4_agent_tools/probability/service.js +81 -14
- package/dist/hoi4_agent_tools/schemas/probability.js +2 -0
- package/dist/hoi4_agent_tools/technology/compare.js +6 -1
- package/dist/hoi4_agent_tools/technology/graph.js +67 -2
- package/dist/hoi4_agent_tools/technology/model.js +1 -1
- package/dist/hoi4_agent_tools/technology/queries.js +4 -0
- package/dist/hoi4_agent_tools/technology/render.js +73 -28
- package/dist/hoi4_agent_tools/technology/service.js +4 -1
- package/dist/hoi4_agent_tools/technology/source-analysis.js +102 -19
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/development.md +2 -0
- package/docs/gui.md +14 -3
- package/docs/images/readme/chaos-meter-ingame.png +0 -0
- package/docs/images/readme/chaos-meter-mcp.png +0 -0
- package/docs/images/readme/communist-insurgency-ingame.png +0 -0
- package/docs/images/readme/communist-insurgency-mcp.png +0 -0
- package/docs/images/readme/event-chain-mcp.png +0 -0
- package/docs/images/readme/focus-tree-mcp.png +0 -0
- package/docs/images/readme/map-mcp.png +0 -0
- package/docs/images/readme/probability-mcp.png +0 -0
- package/docs/images/readme/settings-advanced-mcp.png +0 -0
- package/docs/images/readme/settings-events-mcp.png +0 -0
- package/docs/images/readme/technology-tree-mcp.png +0 -0
- package/docs/probability.md +3 -1
- package/docs/technology.md +1 -1
- package/package.json +3 -1
- package/schemas/configuration.schema.json +3 -3
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +58 -39
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- 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
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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,
|