hoi4-agent-tools 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +10 -2
  3. package/dist/hoi4_agent_tools/core/analysis-evidence.js +9 -0
  4. package/dist/hoi4_agent_tools/core/analysis-job-operations.js +48 -0
  5. package/dist/hoi4_agent_tools/core/condition-evaluator.js +21 -1
  6. package/dist/hoi4_agent_tools/core/impact-comparison.js +49 -0
  7. package/dist/hoi4_agent_tools/core/impact-graph.js +301 -0
  8. package/dist/hoi4_agent_tools/core/impact-semantic.js +256 -0
  9. package/dist/hoi4_agent_tools/core/job-worker-host.js +5 -0
  10. package/dist/hoi4_agent_tools/core/job-worker.js +2 -0
  11. package/dist/hoi4_agent_tools/core/operation-tasks.js +12 -0
  12. package/dist/hoi4_agent_tools/core/scenario-model.js +85 -0
  13. package/dist/hoi4_agent_tools/core/source-overlay.js +125 -0
  14. package/dist/hoi4_agent_tools/decision/ai.js +104 -0
  15. package/dist/hoi4_agent_tools/decision/comparison.js +106 -0
  16. package/dist/hoi4_agent_tools/decision/cost.js +177 -0
  17. package/dist/hoi4_agent_tools/decision/evaluation.js +256 -0
  18. package/dist/hoi4_agent_tools/decision/lifecycle.js +114 -0
  19. package/dist/hoi4_agent_tools/decision/service.js +172 -0
  20. package/dist/hoi4_agent_tools/decision/source-inventory.js +182 -0
  21. package/dist/hoi4_agent_tools/gui/scenario-conditions.js +2 -23
  22. package/dist/hoi4_agent_tools/impact/scenario-suites.js +74 -0
  23. package/dist/hoi4_agent_tools/impact/service.js +127 -0
  24. package/dist/hoi4_agent_tools/mcp/server/create.js +2 -0
  25. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  26. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +5 -0
  27. package/dist/hoi4_agent_tools/mcp/server/task-tool-catalog.js +2 -0
  28. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +155 -0
  29. package/dist/hoi4_agent_tools/mechanic/assertions.js +112 -0
  30. package/dist/hoi4_agent_tools/mechanic/interpreter.js +619 -0
  31. package/dist/hoi4_agent_tools/mechanic/service.js +145 -0
  32. package/dist/hoi4_agent_tools/mechanic/source.js +89 -0
  33. package/dist/hoi4_agent_tools/package-check/service.js +231 -0
  34. package/dist/hoi4_agent_tools/probability/evaluation.js +3 -1
  35. package/dist/hoi4_agent_tools/scenario-suite/service.js +341 -0
  36. package/dist/hoi4_agent_tools/schemas/analysis.js +228 -0
  37. package/dist/hoi4_agent_tools/schemas/scenarios.js +264 -0
  38. package/dist/hoi4_agent_tools/version.js +1 -1
  39. package/docs/README.md +2 -0
  40. package/docs/analysis.md +100 -0
  41. package/docs/jobs.md +2 -1
  42. package/docs/mechanics.md +70 -0
  43. package/package.json +6 -2
  44. package/schemas/configuration.schema.json +1 -1
  45. package/schemas/continuous-focus-palette.schema.json +1 -1
  46. package/schemas/custom-weighted-pool.schema.json +1 -1
  47. package/schemas/decision-inspect-input.schema.json +290 -0
  48. package/schemas/event-feature-manifest.schema.json +1 -1
  49. package/schemas/focus-plan.schema.json +1 -1
  50. package/schemas/focus-planning-sidecar.schema.json +1 -1
  51. package/schemas/gui-animation-source.schema.json +1 -1
  52. package/schemas/gui-helper.schema.json +1 -1
  53. package/schemas/gui-scenario.schema.json +1 -1
  54. package/schemas/helper-expansion-request.schema.json +1 -1
  55. package/schemas/helper-expansion-summary.schema.json +1 -1
  56. package/schemas/impact-inspect-input.schema.json +203 -0
  57. package/schemas/map-operation.schema.json +1 -1
  58. package/schemas/mechanic-test-input.schema.json +650 -0
  59. package/schemas/operation-result.schema.json +1 -1
  60. package/schemas/package-check-input.schema.json +164 -0
  61. package/schemas/package-manifest.schema.json +142 -0
  62. package/schemas/probability-analysis-result.schema.json +1 -1
  63. package/schemas/probability-compare-input.schema.json +1 -1
  64. package/schemas/probability-evaluate-input.schema.json +1 -1
  65. package/schemas/probability-inspect-input.schema.json +1 -1
  66. package/schemas/probability-render-input.schema.json +1 -1
  67. package/schemas/probability-scenario-set.schema.json +1 -1
  68. package/schemas/probability-sequence-input.schema.json +1 -1
  69. package/schemas/probability-simulate-input.schema.json +1 -1
  70. package/schemas/probability-sweep-input.schema.json +1 -1
  71. package/schemas/scenario-suite.schema.json +1004 -0
  72. package/schemas/scenario-test-input.schema.json +1057 -0
  73. package/schemas/shared-scenario.schema.json +237 -0
  74. package/server.json +3 -3
@@ -0,0 +1,619 @@
1
+ import { evaluateTriggerBlock } from '../core/condition-evaluator.js';
2
+ import { copyScenario, scenarioValue, setScenarioValue, } from '../core/scenario-model.js';
3
+ import { resolveScopeContext, rootScopeContext } from '../core/scenario-state.js';
4
+ import { assignments, firstScalar, parseClausewitz, } from '../core/source/index.js';
5
+ const MAX_OPERATIONS = 20_000;
6
+ const MAX_DEPTH = 32;
7
+ const MAX_ITERATION = 2_048;
8
+ const numericEffects = new Map([
9
+ ['add_political_power', { key: 'political_power', mode: 'add' }],
10
+ ['set_political_power', { key: 'political_power', mode: 'set' }],
11
+ ['add_manpower', { key: 'manpower', mode: 'add' }],
12
+ ['add_stability', { key: 'stability', mode: 'add', min: 0, max: 1 }],
13
+ ['add_war_support', { key: 'war_support', mode: 'add', min: 0, max: 1 }],
14
+ ]);
15
+ const harmlessEffects = new Set(['hidden_effect', 'custom_effect_tooltip', 'log', 'tooltip']);
16
+ function numeric(value) {
17
+ if (typeof value === 'number' && Number.isFinite(value))
18
+ return value;
19
+ if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))
20
+ return Number(value);
21
+ return undefined;
22
+ }
23
+ function asText(value) {
24
+ return value.type === 'scalar' ? value.value : undefined;
25
+ }
26
+ function entriesOnly(block) {
27
+ return block.entries.every((entry) => entry.type === 'assignment') ? block.entries : undefined;
28
+ }
29
+ function substitute(block, parameters) {
30
+ const missing = new Set();
31
+ const replace = (value) => value.replace(/\$([A-Za-z0-9_]+)\$/gu, (match, key) => {
32
+ const found = parameters.get(key);
33
+ if (found === undefined) {
34
+ missing.add(key);
35
+ return match;
36
+ }
37
+ return found;
38
+ });
39
+ const visit = (entry) => {
40
+ if (entry.type === 'scalar')
41
+ return { ...entry, value: replace(entry.value) };
42
+ if (entry.type === 'block')
43
+ return { ...entry, entries: entry.entries.map(visit) };
44
+ return {
45
+ ...entry,
46
+ key: { ...entry.key, value: replace(entry.key.value) },
47
+ value: entry.value.type === 'scalar'
48
+ ? { ...entry.value, value: replace(entry.value.value) }
49
+ : { ...entry.value, entries: entry.value.entries.map(visit) },
50
+ };
51
+ };
52
+ const result = { ...block, entries: block.entries.map(visit) };
53
+ return missing.size > 0 ? undefined : result;
54
+ }
55
+ /**
56
+ * A bounded source interpreter. It models declared state transitions, never a
57
+ * campaign tick or an implicit on action. Unsupported operations taint state.
58
+ */
59
+ export class MechanicInterpreter {
60
+ definitions;
61
+ helpers;
62
+ state;
63
+ trace = [];
64
+ unresolved = [];
65
+ expiry = new Map();
66
+ localTargets = new Set();
67
+ operations = 0;
68
+ constructor(scenario, definitions, helpers) {
69
+ this.definitions = definitions;
70
+ this.helpers = helpers;
71
+ this.state = copyScenario(scenario);
72
+ }
73
+ result() {
74
+ return {
75
+ state: this.state,
76
+ trace: this.trace,
77
+ unresolved: this.unresolved,
78
+ complete: this.unresolved.length === 0,
79
+ };
80
+ }
81
+ advanceDays(days) {
82
+ if (!Number.isInteger(days) || days < 0 || days > 36_500)
83
+ throw new RangeError('Explicit time advance must be between zero and 36500 days');
84
+ this.state.day += days;
85
+ for (const [key, expiry] of this.expiry) {
86
+ if (expiry > this.state.day)
87
+ continue;
88
+ const [scope, flag] = key.split('|', 2);
89
+ const flags = this.flags(scope);
90
+ if (flags !== undefined) {
91
+ const index = flags.indexOf(flag);
92
+ if (index >= 0)
93
+ flags.splice(index, 1);
94
+ }
95
+ this.expiry.delete(key);
96
+ }
97
+ }
98
+ execute(source, scope = 'ROOT') {
99
+ this.block(source.block, source.path, scope, [], 0);
100
+ for (const target of this.localTargets)
101
+ delete this.state.scenario.eventTargets?.[target];
102
+ this.localTargets.clear();
103
+ }
104
+ /** Apply a source-declared engine decision cost before complete_effect. */
105
+ payDecisionCost(amount, path) {
106
+ const before = numeric(scenarioValue(this.state, 'political_power'));
107
+ if (before === undefined || !Number.isFinite(amount) || amount < 0) {
108
+ this.unknown('decision_cost', path, 'ROOT', 'Decision cost or balance is unknown');
109
+ return;
110
+ }
111
+ if (before < amount) {
112
+ this.note('decision_cost', path, 'ROOT', 'skipped', before, before, 'Political power is below the declared engine cost');
113
+ return;
114
+ }
115
+ const after = before - amount;
116
+ setScenarioValue(this.state, 'political_power', after);
117
+ this.note('decision_cost', path, 'ROOT', 'applied', before, after, undefined, 'political_power');
118
+ }
119
+ skipDecision(path, reason) {
120
+ this.note('decision', path, 'ROOT', 'skipped', undefined, undefined, reason);
121
+ }
122
+ unresolvedDecision(path, reason) {
123
+ this.unknown('decision', path, 'ROOT', reason);
124
+ }
125
+ flags(scope) {
126
+ if (scope === 'GLOBAL')
127
+ return (this.state.scenario.globalFlags ??= []);
128
+ if (scope === 'ROOT')
129
+ return (this.state.scenario.flags ??= []);
130
+ const binding = this.state.scenario.scopes?.[scope];
131
+ if (binding === undefined)
132
+ return undefined;
133
+ return (binding.flags ??= []);
134
+ }
135
+ note(effect, path, scope, status, before, after, reason, target) {
136
+ this.trace.push({
137
+ effect,
138
+ path,
139
+ scope,
140
+ status,
141
+ ...(before === undefined ? {} : { before }),
142
+ ...(after === undefined ? {} : { after }),
143
+ ...(reason === undefined ? {} : { reason }),
144
+ ...(target === undefined ? {} : { target }),
145
+ });
146
+ if (status === 'unresolved')
147
+ this.unresolved.push(`${path}: ${effect}: ${reason ?? 'unresolved'}`);
148
+ }
149
+ unknown(effect, path, scope, reason) {
150
+ this.state.unknown.add('*');
151
+ this.note(effect, path, scope, 'unresolved', undefined, undefined, reason);
152
+ }
153
+ number(expression, path, scope, seen = new Set()) {
154
+ const literal = numeric(expression);
155
+ if (literal !== undefined)
156
+ return literal;
157
+ if (seen.has(expression))
158
+ return undefined;
159
+ seen.add(expression);
160
+ if (expression.startsWith('constant:')) {
161
+ const definition = this.definitions.scriptConstants.get(expression.slice(9));
162
+ return definition === undefined
163
+ ? undefined
164
+ : this.number(definition.value, definition.file.displayPath, scope, seen);
165
+ }
166
+ if (expression.startsWith('@')) {
167
+ const definition = this.definitions.localConstants.get(path)?.get(expression);
168
+ return definition === undefined
169
+ ? undefined
170
+ : this.number(definition.value, definition.file.displayPath, scope, seen);
171
+ }
172
+ const dotted = /^(ROOT|THIS|FROM|PREV)\.(.+)$/iu.exec(expression);
173
+ const targetScope = dotted?.[1]?.toUpperCase() === 'THIS' ? scope : (dotted?.[1]?.toUpperCase() ?? scope);
174
+ const key = dotted?.[2] ?? expression;
175
+ return (numeric(scenarioValue(this.state, key, targetScope)) ??
176
+ numeric(scenarioValue(this.state, `variable.${key}`, targetScope)));
177
+ }
178
+ mathExpression(block, path, scope) {
179
+ const entries = entriesOnly(block);
180
+ if (entries === undefined || entries[0]?.key.value !== 'value')
181
+ return undefined;
182
+ const initial = asText(entries[0].value);
183
+ let accumulator = initial === undefined ? undefined : this.number(initial, path, scope);
184
+ for (const entry of entries.slice(1)) {
185
+ if (accumulator === undefined)
186
+ return undefined;
187
+ const operator = entry.key.value;
188
+ if (operator === 'round' && asText(entry.value) === 'yes') {
189
+ accumulator = Math.round(accumulator);
190
+ continue;
191
+ }
192
+ if (operator === 'clamp' && entry.value.type === 'block') {
193
+ const minimum = firstScalar(entry.value, 'min')?.value;
194
+ const maximum = firstScalar(entry.value, 'max')?.value;
195
+ const min = minimum === undefined ? undefined : this.number(minimum, path, scope);
196
+ const max = maximum === undefined ? undefined : this.number(maximum, path, scope);
197
+ accumulator =
198
+ min === undefined || max === undefined
199
+ ? undefined
200
+ : Math.max(min, Math.min(max, accumulator));
201
+ continue;
202
+ }
203
+ const operandText = asText(entry.value);
204
+ const operand = operandText === undefined ? undefined : this.number(operandText, path, scope);
205
+ if (operand === undefined)
206
+ return undefined;
207
+ switch (operator) {
208
+ case 'add':
209
+ accumulator += operand;
210
+ break;
211
+ case 'subtract':
212
+ accumulator -= operand;
213
+ break;
214
+ case 'multiply':
215
+ accumulator *= operand;
216
+ break;
217
+ case 'divide':
218
+ accumulator = operand === 0 ? undefined : accumulator / operand;
219
+ break;
220
+ case 'min':
221
+ accumulator = Math.min(accumulator, operand);
222
+ break;
223
+ case 'max':
224
+ accumulator = Math.max(accumulator, operand);
225
+ break;
226
+ default:
227
+ return undefined;
228
+ }
229
+ }
230
+ return accumulator;
231
+ }
232
+ condition(block, path, scope, stack) {
233
+ const scenario = this.state.scenario;
234
+ const subject = {
235
+ id: 'mechanic',
236
+ provenance: [{ path, rootKind: 'mod', loadOrder: 0, sourceHash: '' }],
237
+ };
238
+ let context = rootScopeContext(scenario);
239
+ for (const previous of stack.filter((item) => item.startsWith('scope:'))) {
240
+ context = resolveScopeContext(scenario, previous.slice(6), context) ?? context;
241
+ }
242
+ context = resolveScopeContext(scenario, scope, context) ?? context;
243
+ return evaluateTriggerBlock(block, scenario, subject, this.definitions, [], context).state;
244
+ }
245
+ block(block, path, scope, stack, depth) {
246
+ if (depth > MAX_DEPTH) {
247
+ this.unknown('depth', path, scope, 'Helper or scope depth limit reached');
248
+ return;
249
+ }
250
+ const entries = entriesOnly(block);
251
+ if (entries === undefined) {
252
+ this.unknown('block', path, scope, 'Effect block has bare values or malformed entries');
253
+ return;
254
+ }
255
+ let precedingBranch;
256
+ for (const entry of entries) {
257
+ if (++this.operations > MAX_OPERATIONS) {
258
+ this.unknown('work', path, scope, 'Operation budget exceeded');
259
+ return;
260
+ }
261
+ const key = entry.key.value;
262
+ const child = entry.value.type === 'block' ? entry.value : undefined;
263
+ if (key === 'else_if' || key === 'else') {
264
+ if (precedingBranch === undefined) {
265
+ this.unknown(key, path, scope, 'Branch has no preceding if');
266
+ continue;
267
+ }
268
+ if (precedingBranch === 'true') {
269
+ this.note(key, path, scope, 'skipped');
270
+ continue;
271
+ }
272
+ if (precedingBranch === 'unresolved') {
273
+ this.unknown(key, path, scope, 'Earlier branch eligibility is unresolved');
274
+ continue;
275
+ }
276
+ }
277
+ else
278
+ precedingBranch = undefined;
279
+ if (key === 'if' || key === 'else_if' || key === 'else') {
280
+ if (child === undefined) {
281
+ this.unknown(key, path, scope, 'Branch requires an effect block');
282
+ continue;
283
+ }
284
+ const gate = assignments(child, 'limit')[0]?.value;
285
+ const eligible = key === 'else'
286
+ ? 'true'
287
+ : gate?.type === 'block'
288
+ ? this.condition(gate, path, scope, stack)
289
+ : 'unresolved';
290
+ precedingBranch = eligible;
291
+ if (eligible === 'true')
292
+ this.block({
293
+ ...child,
294
+ entries: child.entries.filter((item) => item.type !== 'assignment' || item.key.value !== 'limit'),
295
+ }, path, scope, stack, depth + 1);
296
+ else if (eligible === 'false')
297
+ this.note(key, path, scope, 'skipped');
298
+ else
299
+ this.unknown(key, path, scope, 'Branch eligibility is unresolved');
300
+ continue;
301
+ }
302
+ this.effect(entry, path, scope, stack, depth);
303
+ }
304
+ }
305
+ effect(entry, path, scope, stack, depth) {
306
+ const key = entry.key.value;
307
+ const child = entry.value.type === 'block' ? entry.value : undefined;
308
+ if (key === 'hidden_effect' && child !== undefined) {
309
+ this.block(child, path, scope, stack, depth + 1);
310
+ return;
311
+ }
312
+ if (key === 'meta_effect') {
313
+ const template = child === undefined ? undefined : assignments(child, 'text')[0]?.value;
314
+ if (child === undefined || template?.type !== 'block') {
315
+ this.unknown(key, path, scope, 'Meta effect has no text block');
316
+ return;
317
+ }
318
+ const parameters = new Map();
319
+ for (const item of assignments(child)) {
320
+ if (item.key.value === 'text' || item.key.value === 'debug')
321
+ continue;
322
+ const raw = asText(item.value);
323
+ if (raw === undefined) {
324
+ this.unknown(key, path, scope, 'Meta parameter must be scalar');
325
+ return;
326
+ }
327
+ const variable = /^\[\?([A-Za-z0-9_.]+)(?:\|\.0)?\]$/u.exec(raw);
328
+ const resolved = variable === null ? raw : this.number(variable[1], path, scope)?.toString();
329
+ if (resolved === undefined || !/^[A-Za-z0-9_.:+-]+$/u.test(resolved)) {
330
+ this.unknown(key, path, scope, 'Meta parameter is unknown or not a safe token');
331
+ return;
332
+ }
333
+ parameters.set(item.key.value, resolved);
334
+ }
335
+ const missing = new Set();
336
+ const replace = (value) => value.replace(/\[([A-Za-z0-9_]+)\]/gu, (match, token) => {
337
+ const resolved = parameters.get(token);
338
+ if (resolved === undefined) {
339
+ missing.add(token);
340
+ return match;
341
+ }
342
+ return resolved;
343
+ });
344
+ const visit = (item) => {
345
+ if (item.type === 'scalar')
346
+ return { ...item, value: replace(item.value) };
347
+ if (item.type === 'block')
348
+ return { ...item, entries: item.entries.map(visit) };
349
+ return {
350
+ ...item,
351
+ key: { ...item.key, value: replace(item.key.value) },
352
+ value: item.value.type === 'scalar'
353
+ ? { ...item.value, value: replace(item.value.value) }
354
+ : { ...item.value, entries: item.value.entries.map(visit) },
355
+ };
356
+ };
357
+ const expanded = { ...template, entries: template.entries.map(visit) };
358
+ if (missing.size > 0)
359
+ this.unknown(key, path, scope, 'Meta parameter is missing');
360
+ else
361
+ this.block(expanded, path, scope, stack, depth + 1);
362
+ return;
363
+ }
364
+ if (key === 'ROOT' ||
365
+ key === 'THIS' ||
366
+ key === 'FROM' ||
367
+ key === 'PREV' ||
368
+ key.startsWith('event_target:')) {
369
+ const savedTarget = key.startsWith('event_target:')
370
+ ? this.state.scenario.eventTargets?.[key.slice(13)]
371
+ : undefined;
372
+ const target = key === 'THIS'
373
+ ? scope
374
+ : key === 'PREV'
375
+ ? stack.findLast((item) => item.startsWith('scope:'))?.slice(6)
376
+ : key.startsWith('event_target:')
377
+ ? savedTarget === this.state.scenario.actor
378
+ ? 'ROOT'
379
+ : Object.entries(this.state.scenario.scopes ?? {}).find(([, binding]) => binding.id === savedTarget)?.[0]
380
+ : key;
381
+ const found = target === 'ROOT' || this.state.scenario.scopes?.[target ?? ''] !== undefined;
382
+ if (child === undefined || target === undefined || !found)
383
+ this.unknown(key, path, scope, 'Scope is not declared');
384
+ else
385
+ this.block(child, path, target, [...stack, `scope:${scope}`], depth + 1);
386
+ return;
387
+ }
388
+ if (key === 'for_each_scope_loop' || key === 'every_country') {
389
+ if (child === undefined) {
390
+ this.unknown(key, path, scope, 'Iterator requires a block');
391
+ return;
392
+ }
393
+ const catalogId = key === 'every_country' ? 'countries' : firstScalar(child, 'array')?.value;
394
+ const catalog = catalogId === undefined ? undefined : this.state.scenario.scopeCatalogs?.[catalogId];
395
+ if (catalog === undefined || !catalog.complete || catalog.members.length > MAX_ITERATION) {
396
+ this.unknown(key, path, scope, 'Finite complete scope catalog is required');
397
+ return;
398
+ }
399
+ const body = {
400
+ ...child,
401
+ entries: child.entries.filter((item) => item.type !== 'assignment' || !['array', 'break', 'tooltip'].includes(item.key.value)),
402
+ };
403
+ for (const member of catalog.members) {
404
+ const binding = Object.entries(this.state.scenario.scopes ?? {}).find(([, value]) => value.id === member);
405
+ if (binding === undefined) {
406
+ this.unknown(key, path, scope, `Scope catalog member ${member} has no binding`);
407
+ break;
408
+ }
409
+ this.block(body, path, binding[0], [...stack, `scope:${scope}`], depth + 1);
410
+ }
411
+ return;
412
+ }
413
+ if ([
414
+ 'set_variable',
415
+ 'add_to_variable',
416
+ 'subtract_from_variable',
417
+ 'multiply_variable',
418
+ 'divide_variable',
419
+ 'clamp_variable',
420
+ ].includes(key)) {
421
+ if (child === undefined) {
422
+ this.unknown(key, path, scope, 'Variable effect requires a block');
423
+ return;
424
+ }
425
+ const short = assignments(child).find((item) => !['var', 'value', 'min', 'max', 'tooltip'].includes(item.key.value));
426
+ const variable = firstScalar(child, 'var')?.value ?? short?.key.value;
427
+ const expression = firstScalar(child, 'value')?.value ??
428
+ (short === undefined ? undefined : asText(short.value));
429
+ if (variable === undefined || expression === undefined) {
430
+ if (variable === undefined || short?.value.type !== 'block') {
431
+ this.unknown(key, path, scope, 'Variable and operand must resolve');
432
+ return;
433
+ }
434
+ }
435
+ const value = expression === undefined
436
+ ? this.mathExpression(short.value, path, scope)
437
+ : this.number(expression, path, scope);
438
+ const before = numeric(scenarioValue(this.state, variable, scope)) ?? 0;
439
+ let after = value;
440
+ if (value !== undefined && key === 'add_to_variable')
441
+ after = before + value;
442
+ if (value !== undefined && key === 'subtract_from_variable')
443
+ after = before - value;
444
+ if (value !== undefined && key === 'multiply_variable')
445
+ after = before * value;
446
+ if (value !== undefined && key === 'divide_variable')
447
+ after = value === 0 ? undefined : before / value;
448
+ if (value !== undefined && key === 'clamp_variable') {
449
+ const minimum = firstScalar(child, 'min')?.value;
450
+ const maximum = firstScalar(child, 'max')?.value;
451
+ const min = minimum === undefined ? -Infinity : this.number(minimum, path, scope);
452
+ const max = maximum === undefined ? Infinity : this.number(maximum, path, scope);
453
+ after =
454
+ min === undefined || max === undefined ? undefined : Math.max(min, Math.min(max, before));
455
+ }
456
+ setScenarioValue(this.state, variable, after, scope);
457
+ this.note(key, path, scope, after === undefined ? 'unresolved' : 'applied', before, after, after === undefined ? 'Numeric operand is unknown or division by zero' : undefined, variable);
458
+ return;
459
+ }
460
+ if (key === 'set_country_flag' ||
461
+ key === 'clr_country_flag' ||
462
+ key === 'set_state_flag' ||
463
+ key === 'clr_state_flag' ||
464
+ key === 'set_global_flag' ||
465
+ key === 'clr_global_flag') {
466
+ const flag = asText(entry.value) ??
467
+ (child === undefined ? undefined : firstScalar(child, 'flag')?.value);
468
+ const flagScope = key.includes('global_flag') ? 'GLOBAL' : scope;
469
+ const flags = this.flags(flagScope);
470
+ if (flag === undefined || flags === undefined) {
471
+ this.unknown(key, path, scope, 'Flag or scope is unresolved');
472
+ return;
473
+ }
474
+ const before = flags.includes(flag);
475
+ if (key.startsWith('set_') && !before)
476
+ flags.push(flag);
477
+ if (key.startsWith('clr_') && before)
478
+ flags.splice(flags.indexOf(flag), 1);
479
+ const days = child === undefined ? undefined : firstScalar(child, 'days')?.value;
480
+ if (days !== undefined) {
481
+ const duration = this.number(days, path, scope);
482
+ if (duration === undefined)
483
+ this.unknown(key, path, scope, 'Flag duration is unknown');
484
+ else
485
+ this.expiry.set(`${flagScope}|${flag}`, this.state.day + duration);
486
+ }
487
+ this.note(key, path, scope, 'applied', before, flags.includes(flag));
488
+ return;
489
+ }
490
+ if (key === 'save_event_target_as' || key === 'save_global_event_target_as') {
491
+ const target = asText(entry.value);
492
+ if (target === undefined)
493
+ this.unknown(key, path, scope, 'Target name is unresolved');
494
+ else {
495
+ (this.state.scenario.eventTargets ??= {})[target] =
496
+ scope === 'ROOT'
497
+ ? (this.state.scenario.actor ?? 'ROOT')
498
+ : (this.state.scenario.scopes?.[scope]?.id ?? scope);
499
+ if (key === 'save_event_target_as')
500
+ this.localTargets.add(target);
501
+ else
502
+ this.localTargets.delete(target);
503
+ this.note(key, path, scope, 'applied', undefined, this.state.scenario.eventTargets[target]);
504
+ }
505
+ return;
506
+ }
507
+ if (key === 'clear_global_event_target') {
508
+ const target = asText(entry.value);
509
+ if (target === undefined)
510
+ this.unknown(key, path, scope, 'Target name is unresolved');
511
+ else {
512
+ delete this.state.scenario.eventTargets?.[target];
513
+ this.note(key, path, scope, 'applied');
514
+ }
515
+ return;
516
+ }
517
+ if (key === 'add_to_array' || key === 'remove_from_array' || key === 'clear_array') {
518
+ const short = child === undefined
519
+ ? undefined
520
+ : assignments(child).find((item) => !['array', 'value', 'index'].includes(item.key.value));
521
+ const array = asText(entry.value) ??
522
+ (child === undefined
523
+ ? undefined
524
+ : (firstScalar(child, 'array')?.value ?? short?.key.value));
525
+ const expression = child === undefined
526
+ ? undefined
527
+ : (firstScalar(child, 'value')?.value ??
528
+ (short === undefined ? undefined : asText(short.value)));
529
+ const current = array === undefined ? undefined : scenarioValue(this.state, array, scope);
530
+ if (array === undefined || (current !== undefined && !Array.isArray(current))) {
531
+ this.unknown(key, path, scope, 'Array name or current array is unresolved');
532
+ return;
533
+ }
534
+ const next = [...(current ?? [])];
535
+ if (key === 'clear_array')
536
+ next.length = 0;
537
+ else {
538
+ const numberValue = expression === undefined ? undefined : this.number(expression, path, scope);
539
+ const value = numberValue ?? expression;
540
+ if (value === undefined) {
541
+ this.unknown(key, path, scope, 'Array element is unresolved');
542
+ return;
543
+ }
544
+ if (key === 'add_to_array') {
545
+ const index = child === undefined ? undefined : firstScalar(child, 'index')?.value;
546
+ const position = index === undefined ? next.length : this.number(index, path, scope);
547
+ if (position === undefined ||
548
+ !Number.isInteger(position) ||
549
+ position < 0 ||
550
+ position > next.length) {
551
+ this.unknown(key, path, scope, 'Array index is unresolved');
552
+ return;
553
+ }
554
+ next.splice(position, 0, value);
555
+ }
556
+ else {
557
+ const index = next.indexOf(value);
558
+ if (index >= 0)
559
+ next.splice(index, 1);
560
+ }
561
+ }
562
+ setScenarioValue(this.state, array, next, scope);
563
+ this.note(key, path, scope, 'applied', current, next);
564
+ return;
565
+ }
566
+ const balance = numericEffects.get(key);
567
+ if (balance !== undefined) {
568
+ const expression = asText(entry.value);
569
+ const value = expression === undefined ? undefined : this.number(expression, path, scope);
570
+ const before = numeric(scenarioValue(this.state, balance.key, scope));
571
+ const after = value === undefined || (balance.mode === 'add' && before === undefined)
572
+ ? undefined
573
+ : balance.mode === 'set'
574
+ ? value
575
+ : before + value;
576
+ const bounded = after === undefined
577
+ ? undefined
578
+ : Math.max(balance.min ?? -Infinity, Math.min(balance.max ?? Infinity, after));
579
+ setScenarioValue(this.state, balance.key, bounded, scope);
580
+ this.note(key, path, scope, bounded === undefined ? 'unresolved' : 'applied', before, bounded, bounded === undefined ? 'Balance input is unknown' : undefined, balance.key);
581
+ return;
582
+ }
583
+ const helper = this.helpers.get(key);
584
+ if (helper !== undefined && (child !== undefined || asText(entry.value) === 'yes')) {
585
+ if (stack.includes(`helper:${key}`)) {
586
+ this.unknown(key, path, scope, 'Scripted helper cycle');
587
+ return;
588
+ }
589
+ const parameters = new Map();
590
+ for (const item of child === undefined ? [] : assignments(child)) {
591
+ const value = asText(item.value);
592
+ if (value === undefined) {
593
+ this.unknown(key, path, scope, 'Scripted helper argument is not scalar');
594
+ return;
595
+ }
596
+ parameters.set(item.key.value, value);
597
+ }
598
+ const expanded = substitute(helper.block, parameters);
599
+ if (expanded === undefined)
600
+ this.unknown(key, path, scope, 'Scripted helper parameter is missing');
601
+ else
602
+ this.block(expanded, helper.path, scope, [...stack, `helper:${key}`], depth + 1);
603
+ return;
604
+ }
605
+ if (harmlessEffects.has(key)) {
606
+ this.note(key, path, scope, 'skipped');
607
+ return;
608
+ }
609
+ this.unknown(key, path, scope, 'Effect is outside the documented interpreter subset');
610
+ }
611
+ }
612
+ export function parseMechanicBlock(text, path = 'fixture:mechanic.txt') {
613
+ const document = parseClausewitz(Buffer.from(`mechanic = { ${text} }`), path);
614
+ const block = assignments(document.root, 'mechanic')[0]?.value;
615
+ if (block?.type !== 'block' ||
616
+ document.diagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker'))
617
+ throw new Error('Mechanic source block is invalid');
618
+ return { id: 'mechanic', path, block };
619
+ }