hoi4-agent-tools 2.8.4 → 2.8.6

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 (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +25 -25
  3. package/dist/hoi4_agent_tools/core/artifacts.js +14 -6
  4. package/dist/hoi4_agent_tools/gui/assets.js +9 -1
  5. package/dist/hoi4_agent_tools/gui/index.js +1 -0
  6. package/dist/hoi4_agent_tools/gui/layout.js +30 -18
  7. package/dist/hoi4_agent_tools/gui/renderer.js +23 -15
  8. package/dist/hoi4_agent_tools/gui/scenario-generator.js +556 -0
  9. package/dist/hoi4_agent_tools/gui/scenario.js +44 -0
  10. package/dist/hoi4_agent_tools/gui/source-graph.js +84 -9
  11. package/dist/hoi4_agent_tools/gui/studio.js +94 -16
  12. package/dist/hoi4_agent_tools/gui/validators.js +6 -1
  13. package/dist/hoi4_agent_tools/mcp/tools/gui.js +23 -6
  14. package/dist/hoi4_agent_tools/version.js +1 -1
  15. package/docs/gui.md +22 -3
  16. package/package.json +1 -1
  17. package/schemas/configuration.schema.json +1 -1
  18. package/schemas/continuous-focus-palette.schema.json +1 -1
  19. package/schemas/custom-weighted-pool.schema.json +1 -1
  20. package/schemas/event-feature-manifest.schema.json +1 -1
  21. package/schemas/focus-plan.schema.json +1 -1
  22. package/schemas/focus-planning-sidecar.schema.json +1 -1
  23. package/schemas/gui-animation-source.schema.json +1 -1
  24. package/schemas/gui-helper.schema.json +1 -1
  25. package/schemas/gui-scenario.schema.json +1 -1
  26. package/schemas/map-operation.schema.json +1 -1
  27. package/schemas/operation-result.schema.json +1 -1
  28. package/schemas/probability-analysis-result.schema.json +1 -1
  29. package/schemas/probability-compare-input.schema.json +1 -1
  30. package/schemas/probability-evaluate-input.schema.json +1 -1
  31. package/schemas/probability-inspect-input.schema.json +1 -1
  32. package/schemas/probability-render-input.schema.json +1 -1
  33. package/schemas/probability-scenario-set.schema.json +1 -1
  34. package/schemas/probability-sequence-input.schema.json +1 -1
  35. package/schemas/probability-simulate-input.schema.json +1 -1
  36. package/schemas/probability-sweep-input.schema.json +1 -1
  37. package/server.json +2 -2
@@ -0,0 +1,556 @@
1
+ import { sha256Bytes } from '../core/canonical.js';
2
+ import { parsePreviewScenario } from './scenario.js';
3
+ const countryNames = ['Germany', 'France', 'Poland', 'Italy', 'Brazil', 'India', 'Canada'];
4
+ const countryAdjectives = [
5
+ 'German',
6
+ 'French',
7
+ 'Polish',
8
+ 'Italian',
9
+ 'Brazilian',
10
+ 'Indian',
11
+ 'Canadian',
12
+ ];
13
+ const leaderNames = ['Anna Keller', 'Michael Byrne', 'Elena Rossi', 'Jan Kowalski', 'Rafael Silva'];
14
+ const monthNames = [
15
+ 'January',
16
+ 'February',
17
+ 'March',
18
+ 'April',
19
+ 'May',
20
+ 'June',
21
+ 'July',
22
+ 'August',
23
+ 'September',
24
+ 'October',
25
+ 'November',
26
+ 'December',
27
+ ];
28
+ const variedStates = [
29
+ 'normal',
30
+ 'selected',
31
+ 'active',
32
+ 'warning',
33
+ 'disabled',
34
+ 'completed',
35
+ ];
36
+ const transientWindowPattern = /(?:confirm|details|dialog|dropdown|menu|modal|overlay|popup|tooltip)/iu;
37
+ class ScenarioRandom {
38
+ #state;
39
+ constructor(seed) {
40
+ this.#state = Number.parseInt(sha256Bytes(seed).slice(0, 8), 16) || 0x6d2b_79f5;
41
+ }
42
+ next() {
43
+ this.#state = (this.#state + 0x6d2b_79f5) >>> 0;
44
+ let value = this.#state;
45
+ value = Math.imul(value ^ (value >>> 15), value | 1);
46
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
47
+ return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
48
+ }
49
+ boolean(probability) {
50
+ return this.next() < probability;
51
+ }
52
+ integer(minimum, maximum) {
53
+ return Math.floor(this.next() * (maximum - minimum + 1)) + minimum;
54
+ }
55
+ pick(values) {
56
+ return values[Math.min(values.length - 1, Math.floor(this.next() * values.length))];
57
+ }
58
+ }
59
+ function relatedScriptedGuis(graph, windowName) {
60
+ const names = new Set(graph.scriptedGuis
61
+ .filter((definition) => definition.windowName === windowName || definition.parentWindowName === windowName)
62
+ .map(({ name }) => name));
63
+ let added = true;
64
+ while (added) {
65
+ added = false;
66
+ for (const definition of graph.scriptedGuis) {
67
+ if (definition.parentScriptedGui === undefined ||
68
+ !names.has(definition.parentScriptedGui) ||
69
+ names.has(definition.name))
70
+ continue;
71
+ names.add(definition.name);
72
+ added = true;
73
+ }
74
+ }
75
+ return graph.scriptedGuis.filter(({ name }) => names.has(name));
76
+ }
77
+ function relevantElements(graph, windowName, scripted) {
78
+ const rootNames = new Set([windowName]);
79
+ for (const definition of scripted) {
80
+ if (definition.windowName !== undefined)
81
+ rootNames.add(definition.windowName);
82
+ for (const dynamicList of definition.dynamicListDefinitions) {
83
+ if (dynamicList.entryContainer !== undefined)
84
+ rootNames.add(dynamicList.entryContainer);
85
+ if (dynamicList.countryScopeEntryContainer !== undefined)
86
+ rootNames.add(dynamicList.countryScopeEntryContainer);
87
+ }
88
+ }
89
+ const byId = new Map(graph.elements.map((element) => [element.id, element]));
90
+ const pending = graph.elements.filter(({ name }) => rootNames.has(name)).map(({ id }) => id);
91
+ const selected = new Map();
92
+ while (pending.length > 0) {
93
+ const id = pending.pop();
94
+ if (id === undefined || selected.has(id))
95
+ continue;
96
+ const element = byId.get(id);
97
+ if (element === undefined)
98
+ continue;
99
+ selected.set(id, element);
100
+ pending.push(...element.childIds);
101
+ }
102
+ return [...selected.values()];
103
+ }
104
+ function stringsIn(value) {
105
+ if (typeof value === 'string')
106
+ return [value];
107
+ if (typeof value !== 'object')
108
+ return [];
109
+ return Object.values(value).flatMap(stringsIn);
110
+ }
111
+ function localisationText(graph, language, key) {
112
+ const normalizedLanguage = language.toLocaleLowerCase('en-US');
113
+ return graph.localisation.find((entry) => entry.key === key && entry.language.toLocaleLowerCase('en-US') === normalizedLanguage)?.value;
114
+ }
115
+ function sourceTexts(graph, elements, language, scripted) {
116
+ const values = elements.flatMap((element) => Object.values(element.attributes).flatMap(stringsIn));
117
+ const resolvedValues = values.flatMap((value) => [
118
+ value,
119
+ localisationText(graph, language, value) ?? '',
120
+ ]);
121
+ const relatedScriptedLocalisationKeys = new Set(graph.scriptedLocalisation
122
+ .filter((definition) => resolvedValues.some((value) => value.includes(`[${definition.name}]`)))
123
+ .flatMap(({ localisationKeys }) => localisationKeys));
124
+ const propertyValues = scripted.flatMap(({ propertyDefinitions }) => propertyDefinitions.flatMap(({ attributes }) => Object.values(attributes).flatMap(stringsIn)));
125
+ const normalizedLanguage = language.toLocaleLowerCase('en-US');
126
+ return [
127
+ ...resolvedValues,
128
+ ...propertyValues,
129
+ ...graph.localisation
130
+ .filter(({ language: entryLanguage, key }) => entryLanguage.toLocaleLowerCase('en-US') === normalizedLanguage &&
131
+ relatedScriptedLocalisationKeys.has(key))
132
+ .map(({ value }) => value),
133
+ ];
134
+ }
135
+ function expressionToken(value) {
136
+ if (typeof value !== 'string')
137
+ return undefined;
138
+ const variable = /^\[\?([^\]|]+)(?:\|[^\]]+)?\]$/u.exec(value);
139
+ const dynamic = /^\[([^\]]+)\]$/u.exec(value);
140
+ return variable?.[1] ?? dynamic?.[1];
141
+ }
142
+ function tokensIn(texts) {
143
+ const numeric = new Set();
144
+ const textual = new Set();
145
+ for (const text of texts) {
146
+ for (const match of text.matchAll(/\[\?([^\]|]+)(?:\|[^\]]+)?\]/gu)) {
147
+ const key = match[1] ?? '';
148
+ if (/(?:^|\.)Get[A-Za-z0-9_]+$/u.test(key))
149
+ textual.add(key);
150
+ else
151
+ numeric.add(key);
152
+ }
153
+ for (const match of text.matchAll(/\[([A-Za-z][A-Za-z0-9_.:-]+)\]/gu)) {
154
+ const key = match[1] ?? '';
155
+ if (key !== 'X' && key !== 'dynamic_loc')
156
+ textual.add(key);
157
+ }
158
+ }
159
+ return { numeric, textual };
160
+ }
161
+ function plausibleText(key, random, samples, scopedCountries) {
162
+ const lower = key.toLocaleLowerCase('en-US');
163
+ if (lower.includes('date'))
164
+ return `${random.integer(1, 28)} ${random.pick(monthNames)} ${random.integer(1936, 1950)}`;
165
+ if (lower.includes('adjective'))
166
+ return random.pick(countryAdjectives);
167
+ if (lower.includes('leader') || lower.includes('character'))
168
+ return random.pick(leaderNames);
169
+ if (lower.includes('country') || lower.endsWith('getname') || lower.includes('.getname')) {
170
+ const scope = key.includes('.') ? key.slice(0, key.indexOf('.')) : 'ROOT';
171
+ const existing = scopedCountries.get(scope);
172
+ if (existing !== undefined)
173
+ return existing;
174
+ const used = new Set(scopedCountries.values());
175
+ const choices = countryNames.filter((candidate) => !used.has(candidate));
176
+ const value = random.pick(choices.length === 0 ? countryNames : choices);
177
+ scopedCountries.set(scope, value);
178
+ return value;
179
+ }
180
+ return random.pick(samples);
181
+ }
182
+ function generatedNumber(options, random, minimum = options.numericMinimum, maximum = options.numericMaximum) {
183
+ const boundedMinimum = Math.max(options.numericMinimum, minimum);
184
+ const boundedMaximum = Math.min(options.numericMaximum, maximum);
185
+ const low = Math.min(boundedMinimum, boundedMaximum);
186
+ const high = Math.max(boundedMinimum, boundedMaximum);
187
+ const value = low + random.next() * (high - low);
188
+ return options.integerValues ? Math.round(value) : Math.round(value * 100) / 100;
189
+ }
190
+ function generatedNumberForKey(key, options, random) {
191
+ const lower = key.toLocaleLowerCase('en-US');
192
+ if (/(?:ratio|fraction|progress)$/u.test(lower))
193
+ return generatedNumber(options, random, 0, 1);
194
+ if (/(?:percent|percentage|support|stability|war_support|legitimacy|threat|charge)/u.test(lower))
195
+ return generatedNumber(options, random, 0, 100);
196
+ return generatedNumber(options, random);
197
+ }
198
+ function setIfMissing(target, key, value) {
199
+ target[key] ??= value;
200
+ }
201
+ function scriptedLocalisationValue(graph, token, language, random, kind, values) {
202
+ const shortToken = token.slice(token.lastIndexOf('.') + 1);
203
+ const definitions = graph.scriptedLocalisation.filter(({ name }) => name === token || name === shortToken);
204
+ if (definitions.length === 0)
205
+ return undefined;
206
+ const sprites = new Set(graph.sprites.map(({ name }) => name.toLocaleLowerCase('en-US')));
207
+ const choices = definitions.flatMap(({ choices }) => choices);
208
+ const eligible = choices.filter(({ localisationKey }) => kind === 'image'
209
+ ? sprites.has(localisationKey.toLocaleLowerCase('en-US'))
210
+ : localisationText(graph, language, localisationKey) !== undefined);
211
+ if (eligible.length === 0)
212
+ return undefined;
213
+ const evaluated = eligible.map((choice) => ({
214
+ choice,
215
+ result: evaluateSimpleTrigger(choice.triggerExpression, values),
216
+ }));
217
+ const matched = evaluated.find(({ result }) => result === true)?.choice;
218
+ const firstThreshold = evaluated
219
+ .map(({ choice }) => simpleThreshold(choice.triggerExpression))
220
+ .find((threshold) => threshold !== undefined && typeof values[threshold.variable] === 'number');
221
+ const unknownExists = evaluated.some(({ result }) => result === undefined);
222
+ const ranked = matched === undefined && unknownExists && firstThreshold !== undefined
223
+ ? eligible[Math.min(eligible.length - 1, Math.max(0, Math.round((1 - Math.max(0, Math.min(100, Number(values[firstThreshold.variable]))) / 100) *
224
+ (eligible.length - 1))))]
225
+ : undefined;
226
+ const fallback = evaluated.find(({ result }) => result === 'fallback')?.choice;
227
+ const selected = matched ?? ranked ?? fallback ?? random.pick(eligible);
228
+ if (kind === 'image')
229
+ return selected.localisationKey;
230
+ return localisationText(graph, language, selected.localisationKey);
231
+ }
232
+ function simpleThreshold(triggerExpression) {
233
+ if (triggerExpression === undefined)
234
+ return undefined;
235
+ const direct = /([A-Za-z_][A-Za-z0-9_.:^]*)\s*([<>])\s*(-?(?:\d+\.?\d*|\.\d+))/u.exec(triggerExpression);
236
+ if (direct !== null)
237
+ return {
238
+ variable: direct[1],
239
+ operator: direct[2],
240
+ threshold: Number(direct[3]),
241
+ };
242
+ const variable = /\bvar\s*=\s*([A-Za-z_][A-Za-z0-9_.:^]*)/u.exec(triggerExpression)?.[1];
243
+ const threshold = /\bvalue\s*=\s*(-?(?:\d+\.?\d*|\.\d+))/u.exec(triggerExpression)?.[1];
244
+ const compare = /\bcompare\s*=\s*(greater_than|less_than)/u.exec(triggerExpression)?.[1];
245
+ if (variable === undefined || threshold === undefined || compare === undefined)
246
+ return undefined;
247
+ return {
248
+ variable,
249
+ operator: compare === 'greater_than' ? '>' : '<',
250
+ threshold: Number(threshold),
251
+ };
252
+ }
253
+ function evaluateSimpleTrigger(triggerExpression, values) {
254
+ if (triggerExpression === undefined || /\balways\s*=\s*yes\b/u.test(triggerExpression))
255
+ return 'fallback';
256
+ if (/\balways\s*=\s*no\b/u.test(triggerExpression))
257
+ return false;
258
+ const threshold = simpleThreshold(triggerExpression);
259
+ if (threshold === undefined)
260
+ return undefined;
261
+ const value = values[threshold.variable];
262
+ if (typeof value !== 'number')
263
+ return undefined;
264
+ return threshold.operator === '>' ? value > threshold.threshold : value < threshold.threshold;
265
+ }
266
+ function materializeDynamicText(template, values) {
267
+ const valueFor = (key) => {
268
+ const value = values[key] ?? values[key.slice(key.lastIndexOf('.') + 1)];
269
+ return value === undefined ? undefined : String(value);
270
+ };
271
+ return template
272
+ .replace(/\[\?([^\]|]+)(?:\|[^\]]+)?\]/gu, (match, key) => valueFor(key) ?? match)
273
+ .replace(/\[([A-Za-z][A-Za-z0-9_.:-]+)\]/gu, (match, key) => {
274
+ if (key === 'X' || key === 'dynamic_loc')
275
+ return match;
276
+ return valueFor(key) ?? match;
277
+ });
278
+ }
279
+ function scalarNumber(value) {
280
+ if (typeof value === 'number' && Number.isFinite(value))
281
+ return value;
282
+ if (typeof value !== 'string' || !/^-?(?:\d+\.?\d*|\.\d+)$/u.test(value))
283
+ return undefined;
284
+ return Number(value);
285
+ }
286
+ function pointOrSize(element, attribute) {
287
+ const value = element.attributes[attribute];
288
+ if (typeof value !== 'object' || Array.isArray(value))
289
+ return undefined;
290
+ const x = scalarNumber(value.x ?? value.width);
291
+ const y = scalarNumber(value.y ?? value.height);
292
+ return x === undefined || y === undefined ? undefined : { x, y };
293
+ }
294
+ function rootRect(graph, windowName) {
295
+ if (windowName === undefined)
296
+ return undefined;
297
+ const root = graph.elements.find((element) => element.name === windowName && element.parentId === undefined);
298
+ if (root === undefined)
299
+ return undefined;
300
+ const position = pointOrSize(root, 'position') ?? { x: 0, y: 0 };
301
+ const size = pointOrSize(root, 'size');
302
+ if (size === undefined || size.x <= 0 || size.y <= 0)
303
+ return undefined;
304
+ return { x: position.x, y: position.y, width: size.x, height: size.y };
305
+ }
306
+ function substantiallyOverlaps(left, right) {
307
+ if (left === undefined || right === undefined)
308
+ return false;
309
+ const width = Math.max(0, Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x));
310
+ const height = Math.max(0, Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y));
311
+ const smallerArea = Math.min(left.width * left.height, right.width * right.height);
312
+ return smallerArea > 0 && (width * height) / smallerArea >= 0.5;
313
+ }
314
+ function generatedVisibility(graph, scripted, options, random, base) {
315
+ const visibility = { ...base };
316
+ const dynamic = scripted.filter(({ visibleExpression }) => visibleExpression !== undefined);
317
+ if (options.visibility === 'show-all') {
318
+ for (const definition of dynamic)
319
+ visibility[definition.name] ??= true;
320
+ return visibility;
321
+ }
322
+ for (const definition of dynamic) {
323
+ if (visibility[definition.name] !== undefined)
324
+ continue;
325
+ visibility[definition.name] =
326
+ definition.parentScriptedGui === undefined ||
327
+ !transientWindowPattern.test(`${definition.name} ${definition.windowName ?? ''}`);
328
+ }
329
+ const siblingGroups = new Map();
330
+ for (const definition of dynamic) {
331
+ if (definition.parentScriptedGui === undefined)
332
+ continue;
333
+ const siblings = siblingGroups.get(definition.parentScriptedGui) ?? [];
334
+ siblings.push(definition);
335
+ siblingGroups.set(definition.parentScriptedGui, siblings);
336
+ }
337
+ for (const siblings of siblingGroups.values()) {
338
+ const unvisited = new Set(siblings);
339
+ while (unvisited.size > 0) {
340
+ const first = unvisited.values().next().value;
341
+ if (first === undefined)
342
+ break;
343
+ unvisited.delete(first);
344
+ const component = [first];
345
+ for (const current of component) {
346
+ for (const candidate of [...unvisited]) {
347
+ if (substantiallyOverlaps(rootRect(graph, current.windowName), rootRect(graph, candidate.windowName))) {
348
+ component.push(candidate);
349
+ unvisited.delete(candidate);
350
+ }
351
+ }
352
+ }
353
+ if (component.length < 2)
354
+ continue;
355
+ const selectable = component.filter((definition) => !transientWindowPattern.test(`${definition.name} ${definition.windowName ?? ''}`));
356
+ const selected = random.pick(selectable.length === 0 ? component : selectable);
357
+ for (const definition of component) {
358
+ if (base[definition.name] === undefined)
359
+ visibility[definition.name] = definition === selected;
360
+ }
361
+ }
362
+ }
363
+ const visiblePageNames = dynamic
364
+ .filter(({ name }) => visibility[name] === true)
365
+ .flatMap(({ name, windowName: visibleWindowName }) => [name, visibleWindowName ?? ''])
366
+ .map((name) => name.toLocaleLowerCase('en-US'));
367
+ const triggerVisibilityTargets = [
368
+ ...new Set(scripted.flatMap(({ triggerDefinitions }) => triggerDefinitions
369
+ .filter(({ name }) => name.endsWith('_visible'))
370
+ .map(({ elementName }) => elementName))),
371
+ ];
372
+ const triggerGroups = new Map();
373
+ for (const target of triggerVisibilityTargets) {
374
+ const key = target.replace(/_(?:active|disabled|enabled|idle|off|on|selected|unselected)$/iu, '');
375
+ const group = triggerGroups.get(key) ?? [];
376
+ group.push(target);
377
+ triggerGroups.set(key, group);
378
+ }
379
+ for (const [baseName, targets] of triggerGroups) {
380
+ if (targets.length === 1) {
381
+ visibility[targets[0]] ??= !transientWindowPattern.test(targets[0]);
382
+ continue;
383
+ }
384
+ const topic = baseName.replace(/_(?:tab_)?button$/iu, '').toLocaleLowerCase('en-US');
385
+ const pageSelected = visiblePageNames.some((name) => name.includes(topic));
386
+ const preferredSuffix = pageSelected
387
+ ? /_(?:active|on|selected)$/iu
388
+ : /_(?:idle|off|unselected)$/iu;
389
+ const preferred = targets.find((target) => preferredSuffix.test(target)) ?? random.pick(targets);
390
+ for (const target of targets)
391
+ if (base[target] === undefined)
392
+ visibility[target] = target === preferred;
393
+ }
394
+ return visibility;
395
+ }
396
+ function generatedRows(count, random, options) {
397
+ const countryOffset = random.integer(0, countryNames.length - 1);
398
+ return Array.from({ length: count }, (_unused, index) => {
399
+ const countryIndex = (countryOffset + index) % countryNames.length;
400
+ const name = countryNames[countryIndex];
401
+ const value = generatedNumber(options, random);
402
+ return {
403
+ index: index + 1,
404
+ id: index + 1,
405
+ label: name,
406
+ name,
407
+ GetName: name,
408
+ GetAdjective: countryAdjectives[countryIndex],
409
+ value,
410
+ amount: value,
411
+ percentage: Math.max(0, Math.min(100, value)),
412
+ selected: index === 0,
413
+ enabled: true,
414
+ };
415
+ });
416
+ }
417
+ export function generateGuiPreviewScenarios(graph, windowName, base, options) {
418
+ if (!options.enabled)
419
+ return [];
420
+ const scripted = relatedScriptedGuis(graph, windowName);
421
+ const elements = relevantElements(graph, windowName, scripted);
422
+ const tokens = tokensIn(sourceTexts(graph, elements, base.language, scripted));
423
+ const imageTokens = new Set();
424
+ const booleanTokens = new Set();
425
+ const frameTokens = new Set();
426
+ for (const definition of scripted) {
427
+ for (const property of definition.propertyDefinitions) {
428
+ for (const [attribute, expression] of Object.entries(property.attributes)) {
429
+ const token = expressionToken(expression);
430
+ if (token === undefined)
431
+ continue;
432
+ const normalized = attribute.toLocaleLowerCase('en-US');
433
+ if (normalized === 'image')
434
+ imageTokens.add(token);
435
+ else if (normalized === 'visible' || normalized === 'enabled')
436
+ booleanTokens.add(token);
437
+ else if (normalized === 'frame')
438
+ frameTokens.add(token);
439
+ else
440
+ tokensIn(stringsIn(expression)).numeric.forEach((value) => tokens.numeric.add(value));
441
+ }
442
+ }
443
+ }
444
+ for (const token of [...imageTokens, ...booleanTokens, ...frameTokens]) {
445
+ tokens.numeric.delete(token);
446
+ tokens.textual.delete(token);
447
+ }
448
+ const relevantDynamicTokens = new Set([...tokens.textual, ...imageTokens]);
449
+ for (const definition of graph.scriptedLocalisation) {
450
+ if (![...relevantDynamicTokens].some((token) => token === definition.name || token.endsWith(`.${definition.name}`)))
451
+ continue;
452
+ for (const choice of definition.choices) {
453
+ const threshold = simpleThreshold(choice.triggerExpression);
454
+ if (threshold !== undefined)
455
+ tokens.numeric.add(threshold.variable);
456
+ }
457
+ }
458
+ const progressRanges = new Map();
459
+ for (const element of elements) {
460
+ if (!/progressbar/iu.test(element.elementType))
461
+ continue;
462
+ const minimum = scalarNumber(element.attributes.minValue) ?? 0;
463
+ const maximum = scalarNumber(element.attributes.maxValue) ?? 100;
464
+ progressRanges.set(element.name, { minimum, maximum });
465
+ const startValue = element.attributes.startValue;
466
+ if (typeof startValue === 'string' && !/^-?(?:\d+\.?\d*|\.\d+)$/u.test(startValue))
467
+ tokens.numeric.add(startValue);
468
+ }
469
+ const dynamicLists = [...new Set(scripted.flatMap(({ dynamicLists }) => dynamicLists))].sort();
470
+ const enabledTargets = new Set(scripted.flatMap((definition) => [
471
+ ...definition.triggerDefinitions.map(({ elementName }) => elementName),
472
+ ...definition.propertyDefinitions
473
+ .filter((property) => Object.keys(property.attributes).some((key) => key.toLocaleLowerCase('en-US') === 'enabled'))
474
+ .map(({ elementName }) => elementName),
475
+ ]));
476
+ const sourceSeed = options.seed === 'auto' ? `${windowName}:${base.id}:${hashSourceGraph(graph)}` : options.seed;
477
+ return Array.from({ length: options.count }, (_unused, scenarioIndex) => {
478
+ const random = new ScenarioRandom(`${sourceSeed}:${scenarioIndex}`);
479
+ const values = { ...base.values };
480
+ const lists = Object.fromEntries(Object.entries(base.lists).map(([key, rows]) => [key, rows.map((row) => ({ ...row }))]));
481
+ const visibility = generatedVisibility(graph, scripted, options, random, base.visibility);
482
+ const elementStates = { ...base.elementStates };
483
+ const scopedCountries = new Map();
484
+ for (const key of tokens.numeric)
485
+ setIfMissing(values, key, generatedNumberForKey(key, options, random));
486
+ for (const [elementName, { minimum, maximum }] of progressRanges) {
487
+ const element = elements.find(({ name }) => name === elementName);
488
+ const startValueToken = typeof element?.attributes.startValue === 'string'
489
+ ? element.attributes.startValue
490
+ : undefined;
491
+ const existingElementValue = values[elementName];
492
+ const existingTokenValue = startValueToken === undefined ? undefined : values[startValueToken];
493
+ const value = typeof existingElementValue === 'number'
494
+ ? existingElementValue
495
+ : typeof existingTokenValue === 'number'
496
+ ? existingTokenValue
497
+ : generatedNumber(options, random, minimum, maximum);
498
+ setIfMissing(values, elementName, value);
499
+ if (startValueToken !== undefined)
500
+ setIfMissing(values, startValueToken, value);
501
+ }
502
+ for (const key of tokens.numeric) {
503
+ if (!key.endsWith('_bar') || base.values[key] !== undefined)
504
+ continue;
505
+ const sourceKey = key.slice(0, -'_bar'.length);
506
+ const sourceValue = values[sourceKey];
507
+ if (typeof sourceValue === 'number')
508
+ values[key] = sourceValue;
509
+ }
510
+ for (const key of tokens.textual)
511
+ setIfMissing(values, key, plausibleText(key, random, options.textSamples, scopedCountries));
512
+ const scriptedTextTemplates = new Map();
513
+ for (const key of tokens.textual) {
514
+ const scriptedValue = scriptedLocalisationValue(graph, key, base.language, random, 'text', values);
515
+ if (scriptedValue !== undefined && base.values[key] === undefined)
516
+ scriptedTextTemplates.set(key, scriptedValue);
517
+ }
518
+ for (let pass = 0; pass < 3; pass += 1)
519
+ for (const [key, template] of scriptedTextTemplates)
520
+ values[key] = materializeDynamicText(template, values);
521
+ for (const key of imageTokens) {
522
+ const sprite = scriptedLocalisationValue(graph, key, base.language, random, 'image', values);
523
+ if (sprite !== undefined)
524
+ setIfMissing(values, key, sprite);
525
+ }
526
+ for (const key of booleanTokens)
527
+ setIfMissing(values, key, random.boolean(options.trueProbability));
528
+ for (const key of frameTokens)
529
+ setIfMissing(values, key, 1);
530
+ if (options.elementStates === 'varied') {
531
+ for (const elementName of enabledTargets)
532
+ elementStates[elementName] ??= random.pick(variedStates);
533
+ }
534
+ for (const listName of dynamicLists) {
535
+ if (lists[listName] !== undefined)
536
+ continue;
537
+ const count = random.integer(options.listRowsMinimum, options.listRowsMaximum);
538
+ lists[listName] = generatedRows(count, random, options);
539
+ }
540
+ return parsePreviewScenario({
541
+ ...base,
542
+ id: `${base.id}-${options.idPrefix}-${scenarioIndex + 1}`,
543
+ description: `Generated source-aware scenario ${scenarioIndex + 1} (seed ${sourceSeed})`,
544
+ values,
545
+ lists,
546
+ visibility,
547
+ elementStates,
548
+ });
549
+ });
550
+ }
551
+ function hashSourceGraph(graph) {
552
+ return sha256Bytes(Object.entries(graph.sourceHashes)
553
+ .sort(([left], [right]) => left.localeCompare(right))
554
+ .map(([path, hash]) => `${path}:${hash}`)
555
+ .join('\n'));
556
+ }
@@ -193,9 +193,53 @@ export const GuiPreviewScenarioSchema = z.preprocess((input) => {
193
193
  assertGuiScenarioInputBudget(input);
194
194
  return input;
195
195
  }, GuiPreviewScenarioBodySchema);
196
+ export const GuiGeneratedScenarioOptionsSchema = z
197
+ .object({
198
+ enabled: z.boolean().default(true),
199
+ count: z.number().int().min(1).max(16).default(1),
200
+ seed: z.string().min(1).max(256).default('auto'),
201
+ idPrefix: z
202
+ .string()
203
+ .min(1)
204
+ .max(64)
205
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u)
206
+ .default('generated'),
207
+ preservePlaceholder: z.boolean().default(false),
208
+ numericMinimum: z.number().min(-1_000_000_000).max(1_000_000_000).default(0),
209
+ numericMaximum: z.number().min(-1_000_000_000).max(1_000_000_000).default(100),
210
+ integerValues: z.boolean().default(true),
211
+ listRowsMinimum: z.number().int().min(0).max(100).default(1),
212
+ listRowsMaximum: z.number().int().min(0).max(100).default(5),
213
+ trueProbability: z.number().min(0).max(1).default(0.7),
214
+ visibility: z.enum(['show-all', 'varied']).default('varied'),
215
+ elementStates: z.enum(['normal', 'varied']).default('normal'),
216
+ textSamples: z
217
+ .array(z.string().min(1).max(128))
218
+ .min(1)
219
+ .max(64)
220
+ .default(['Operational', 'Ready', 'Stable', 'Available', 'In Progress']),
221
+ })
222
+ .strict()
223
+ .superRefine((options, context) => {
224
+ if (options.numericMaximum < options.numericMinimum)
225
+ context.addIssue({
226
+ code: 'custom',
227
+ path: ['numericMaximum'],
228
+ message: 'numericMaximum must be greater than or equal to numericMinimum',
229
+ });
230
+ if (options.listRowsMaximum < options.listRowsMinimum)
231
+ context.addIssue({
232
+ code: 'custom',
233
+ path: ['listRowsMaximum'],
234
+ message: 'listRowsMaximum must be greater than or equal to listRowsMinimum',
235
+ });
236
+ });
196
237
  export function parsePreviewScenario(input) {
197
238
  return GuiPreviewScenarioSchema.parse(input);
198
239
  }
199
240
  export function defaultPreviewScenario(id = 'default') {
200
241
  return parsePreviewScenario({ id });
201
242
  }
243
+ export function parseGeneratedScenarioOptions(input) {
244
+ return GuiGeneratedScenarioOptionsSchema.parse(input);
245
+ }