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.
- package/CHANGELOG.md +17 -0
- package/README.md +35 -10
- package/dist/bin/stdio.js +0 -4
- package/dist/hoi4_agent_tools/core/configuration.js +16 -3
- package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +10 -13
- 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/core/source/limits.js +6 -7
- package/dist/hoi4_agent_tools/event/graph.js +3 -4
- package/dist/hoi4_agent_tools/event/limits.js +4 -4
- package/dist/hoi4_agent_tools/event/service.js +34 -39
- package/dist/hoi4_agent_tools/gui/assets.js +280 -12
- package/dist/hoi4_agent_tools/gui/layout.js +70 -9
- package/dist/hoi4_agent_tools/gui/renderer.js +81 -12
- 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 +72 -14
- package/dist/hoi4_agent_tools/probability/sequence.js +43 -18
- package/dist/hoi4_agent_tools/probability/service.js +187 -24
- package/dist/hoi4_agent_tools/schemas/probability.js +2 -0
- package/dist/hoi4_agent_tools/technology/service.js +41 -8
- 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/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
- 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
|
-
|
|
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
|
-
|
|
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 (
|
|
625
|
-
|
|
626
|
-
|
|
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 (
|
|
899
|
+
for (const channel of faceChannels) {
|
|
636
900
|
const sample = extracted.data[offset + channel] ?? 0;
|
|
637
|
-
|
|
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
|
-
|
|
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) =>
|
|
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,
|