hoi4-agent-tools 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +21 -18
  3. package/dist/hoi4_agent_tools/core/engine.js +12 -0
  4. package/dist/hoi4_agent_tools/core/workspace.js +31 -3
  5. package/dist/hoi4_agent_tools/event/algorithms.js +208 -0
  6. package/dist/hoi4_agent_tools/event/artifact-validation.js +572 -0
  7. package/dist/hoi4_agent_tools/event/compare.js +413 -0
  8. package/dist/hoi4_agent_tools/event/graph.js +1586 -0
  9. package/dist/hoi4_agent_tools/event/index.js +10 -0
  10. package/dist/hoi4_agent_tools/event/layout.js +218 -0
  11. package/dist/hoi4_agent_tools/event/limits.js +41 -0
  12. package/dist/hoi4_agent_tools/event/model.js +2 -0
  13. package/dist/hoi4_agent_tools/event/queries.js +643 -0
  14. package/dist/hoi4_agent_tools/event/render.js +443 -0
  15. package/dist/hoi4_agent_tools/event/scc.js +72 -0
  16. package/dist/hoi4_agent_tools/event/service.js +1147 -0
  17. package/dist/hoi4_agent_tools/event/source-analysis.js +1458 -0
  18. package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
  19. package/dist/hoi4_agent_tools/gui/layout.js +122 -27
  20. package/dist/hoi4_agent_tools/gui/renderer.js +16 -0
  21. package/dist/hoi4_agent_tools/mcp/resources/register.js +1 -1
  22. package/dist/hoi4_agent_tools/mcp/server/create.js +4 -2
  23. package/dist/hoi4_agent_tools/mcp/server/result.js +8 -14
  24. package/dist/hoi4_agent_tools/mcp/tools/event.js +459 -0
  25. package/dist/hoi4_agent_tools/mcp/tools/focus.js +6 -3
  26. package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -3
  27. package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -3
  28. package/dist/hoi4_agent_tools/runtime.js +112 -2
  29. package/dist/hoi4_agent_tools/schemas/common.js +5 -1
  30. package/dist/hoi4_agent_tools/schemas/event.js +95 -0
  31. package/dist/hoi4_agent_tools/version.js +1 -1
  32. package/docs/README.md +1 -0
  33. package/docs/development.md +10 -0
  34. package/docs/events.md +113 -0
  35. package/docs/focus.md +1 -1
  36. package/docs/gui.md +3 -1
  37. package/docs/map.md +1 -1
  38. package/docs/setup.md +10 -7
  39. package/package.json +7 -3
  40. package/server.json +3 -3
  41. package/dist/hoi4_agent_tools/mcp/tools/mods.js +0 -54
@@ -0,0 +1,1458 @@
1
+ import { compareCodeUnits, deterministicId } from '../core/canonical.js';
2
+ import { assignments, childBlocks, firstScalar, nodeLocation, parseClausewitz, parseLocalisation, sourcePartialLimitDiagnostics, } from '../core/source/index.js';
3
+ import { nativeEffectKeys } from '../focus/native-effects.js';
4
+ import { EVENT_FRAGMENT_CACHE_MAX_ENTRIES, EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES, EVENT_GRAPH_MAX_CONDITION_TEXT, EventAnalysisBudget, } from './limits.js';
5
+ const EVENT_TYPES = new Set([
6
+ 'country_event',
7
+ 'news_event',
8
+ 'state_event',
9
+ 'unit_leader_event',
10
+ 'operative_leader_event',
11
+ ]);
12
+ const EVENT_CALLS = EVENT_TYPES;
13
+ const CONTROL_BLOCKS = new Set([
14
+ 'if',
15
+ 'else_if',
16
+ 'else',
17
+ 'while',
18
+ 'every_country',
19
+ 'every_state',
20
+ 'every_owned_state',
21
+ 'every_character',
22
+ 'random_country',
23
+ 'random_state',
24
+ 'random_list',
25
+ 'hidden_effect',
26
+ ]);
27
+ const SCOPE_BLOCKS = {
28
+ country: 'country',
29
+ every_country: 'country',
30
+ every_enemy_country: 'country',
31
+ every_faction_member: 'country',
32
+ every_neighbor_country: 'country',
33
+ every_subject_country: 'country',
34
+ random_country: 'country',
35
+ random_enemy_country: 'country',
36
+ random_neighbor_country: 'country',
37
+ random_occupied_country: 'country',
38
+ random_other_country: 'country',
39
+ random_subject_country: 'country',
40
+ owner: 'country',
41
+ controller: 'country',
42
+ capital_scope: 'state',
43
+ state: 'state',
44
+ every_controlled_state: 'state',
45
+ every_core_state: 'state',
46
+ every_neighbor_state: 'state',
47
+ every_state: 'state',
48
+ every_owned_state: 'state',
49
+ random_controlled_state: 'state',
50
+ random_core_state: 'state',
51
+ random_neighbor_state: 'state',
52
+ random_owned_state: 'state',
53
+ random_state: 'state',
54
+ unit_leader: 'unit_leader',
55
+ operative_leader: 'operative',
56
+ character: 'character',
57
+ every_army_leader: 'character',
58
+ every_character: 'character',
59
+ every_navy_leader: 'character',
60
+ every_unit_leader: 'character',
61
+ random_army_leader: 'character',
62
+ random_character: 'character',
63
+ random_navy_leader: 'character',
64
+ random_unit_leader: 'character',
65
+ every_operative: 'operative',
66
+ random_operative: 'operative',
67
+ root: 'unknown',
68
+ prev: 'unknown',
69
+ from: 'unknown',
70
+ this: 'unknown',
71
+ };
72
+ const STATE_RULES = {
73
+ set_country_flag: { kind: 'country_flag', access: 'write', nameField: 'flag' },
74
+ clr_country_flag: { kind: 'country_flag', access: 'clear', nameField: 'flag' },
75
+ has_country_flag: { kind: 'country_flag', access: 'read', nameField: 'flag' },
76
+ set_global_flag: { kind: 'global_flag', access: 'write', nameField: 'flag' },
77
+ clr_global_flag: { kind: 'global_flag', access: 'clear', nameField: 'flag' },
78
+ has_global_flag: { kind: 'global_flag', access: 'read', nameField: 'flag' },
79
+ set_state_flag: { kind: 'state_flag', access: 'write', nameField: 'flag' },
80
+ clr_state_flag: { kind: 'state_flag', access: 'clear', nameField: 'flag' },
81
+ has_state_flag: { kind: 'state_flag', access: 'read', nameField: 'flag' },
82
+ set_variable: { kind: 'variable', access: 'write' },
83
+ add_to_variable: { kind: 'variable', access: 'read_write' },
84
+ subtract_from_variable: { kind: 'variable', access: 'read_write' },
85
+ multiply_variable: { kind: 'variable', access: 'read_write' },
86
+ divide_variable: { kind: 'variable', access: 'read_write' },
87
+ clamp_variable: { kind: 'variable', access: 'read_write' },
88
+ check_variable: { kind: 'variable', access: 'read' },
89
+ has_variable: { kind: 'variable', access: 'read' },
90
+ is_variable_equal: { kind: 'variable', access: 'read' },
91
+ clr_variable: { kind: 'variable', access: 'clear' },
92
+ clear_variable: { kind: 'variable', access: 'clear' },
93
+ set_global_variable: { kind: 'global_variable', access: 'write' },
94
+ add_to_global_variable: { kind: 'global_variable', access: 'read_write' },
95
+ subtract_from_global_variable: { kind: 'global_variable', access: 'read_write' },
96
+ check_global_variable: { kind: 'global_variable', access: 'read' },
97
+ has_global_variable: { kind: 'global_variable', access: 'read' },
98
+ clear_global_variable: { kind: 'global_variable', access: 'clear' },
99
+ add_to_array: { kind: 'array', access: 'write', nameField: 'array' },
100
+ remove_from_array: { kind: 'array', access: 'read_write', nameField: 'array' },
101
+ is_in_array: { kind: 'array', access: 'read', nameField: 'array' },
102
+ clear_array: { kind: 'array', access: 'clear' },
103
+ save_event_target_as: { kind: 'event_target', access: 'replace' },
104
+ has_event_target: { kind: 'event_target', access: 'read' },
105
+ save_global_event_target_as: { kind: 'global_event_target', access: 'replace' },
106
+ has_global_event_target: { kind: 'global_event_target', access: 'read' },
107
+ clear_global_event_target: { kind: 'global_event_target', access: 'clear' },
108
+ clear_global_event_targets: { kind: 'global_event_target', access: 'clear' },
109
+ save_scope_as: { kind: 'saved_scope', access: 'replace', storage: 'local' },
110
+ save_global_scope_as: { kind: 'saved_scope', access: 'replace', storage: 'global' },
111
+ clear_saved_scope: { kind: 'saved_scope', access: 'clear', storage: 'local' },
112
+ clear_global_scope: { kind: 'saved_scope', access: 'clear', storage: 'global' },
113
+ };
114
+ function normalizedPath(file) {
115
+ return file.relativePath.replaceAll('\\', '/').replace(/^\.\//u, '').toLowerCase();
116
+ }
117
+ function isDynamic(value) {
118
+ return /\[|\]|\?|@|^event_target:|^var:|^scope:/iu.test(value);
119
+ }
120
+ function isQualifiedDynamicScope(value) {
121
+ return /^(?:event_target|global_event_target|scope|var|temp_var):\S+$/iu.test(value);
122
+ }
123
+ function isScopeChain(value) {
124
+ return /^(?:root|prev|from|this)(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/iu.test(value);
125
+ }
126
+ function isLiteralCountryTag(value) {
127
+ return /^[A-Z][A-Z0-9]{2}$/u.test(value);
128
+ }
129
+ const EFFECT_WRAPPER_BLOCKS = new Set([
130
+ ...CONTROL_BLOCKS,
131
+ 'effect',
132
+ 'completion_reward',
133
+ 'meta_effect',
134
+ 'text',
135
+ ]);
136
+ const NON_HELPER_BLOCKS = new Set([
137
+ ...EFFECT_WRAPPER_BLOCKS,
138
+ 'trigger',
139
+ 'limit',
140
+ 'ai_chance',
141
+ 'modifier',
142
+ 'random_events',
143
+ 'mean_time_to_happen',
144
+ 'effect_tooltip',
145
+ 'custom_effect_tooltip',
146
+ ]);
147
+ const NON_EXECUTING_BLOCKS = new Set([
148
+ 'trigger',
149
+ 'limit',
150
+ 'ai_chance',
151
+ 'modifier',
152
+ 'mean_time_to_happen',
153
+ 'effect_tooltip',
154
+ 'custom_effect_tooltip',
155
+ ]);
156
+ const TOOLTIP_ONLY_BLOCKS = new Set(['effect_tooltip', 'custom_effect_tooltip']);
157
+ const COUNTRY_ON_ACTIONS = new Set([
158
+ 'on_startup',
159
+ 'on_daily',
160
+ 'on_weekly',
161
+ 'on_monthly',
162
+ // Official on-action examples scope ROOT to the new controller country;
163
+ // the affected state is exposed through the FROM chain.
164
+ 'on_state_control_changed',
165
+ ]);
166
+ const UNIT_LEADER_ON_ACTIONS = new Set([
167
+ 'on_unit_leader_created',
168
+ 'on_army_leader_daily',
169
+ 'on_army_leader_won_combat',
170
+ 'on_army_leader_lost_combat',
171
+ 'on_unit_leader_level_up',
172
+ 'on_army_leader_promoted',
173
+ 'on_deployed_leader_defeated',
174
+ ]);
175
+ const OPERATIVE_ON_ACTIONS = new Set([
176
+ 'on_operative_detected_during_operation',
177
+ 'on_operative_on_mission_spotted',
178
+ 'on_operative_captured',
179
+ 'on_operative_created',
180
+ 'on_operative_death',
181
+ 'on_operative_recruited',
182
+ ]);
183
+ function onActionScope(name) {
184
+ const normalized = name.toLowerCase();
185
+ if (COUNTRY_ON_ACTIONS.has(normalized) ||
186
+ /^on_(?:daily|weekly|monthly)_[a-z0-9_]+$/u.test(normalized)) {
187
+ return 'country';
188
+ }
189
+ if (UNIT_LEADER_ON_ACTIONS.has(normalized))
190
+ return 'unit_leader';
191
+ if (OPERATIVE_ON_ACTIONS.has(normalized))
192
+ return 'operative';
193
+ return 'unknown';
194
+ }
195
+ function localisationKeysFromValue(value) {
196
+ if (value.type === 'scalar')
197
+ return [value.value];
198
+ const keys = [];
199
+ for (const assignment of assignments(value)) {
200
+ const key = assignment.key.value.toLowerCase();
201
+ if (key === 'text' || key === 'fallback' || key === 'localisation_key') {
202
+ if (assignment.value.type === 'scalar')
203
+ keys.push(assignment.value.value);
204
+ else
205
+ keys.push(...localisationKeysFromValue(assignment.value));
206
+ }
207
+ }
208
+ return [...new Set(keys)].sort(compareCodeUnits);
209
+ }
210
+ function missingHelperCandidate(key, value, effectContext) {
211
+ const normalizedKey = key.toLowerCase();
212
+ if (!effectContext ||
213
+ nativeEffectKeys.has(normalizedKey) ||
214
+ NON_HELPER_BLOCKS.has(normalizedKey) ||
215
+ SCOPE_BLOCKS[normalizedKey] !== undefined ||
216
+ STATE_RULES[normalizedKey] !== undefined ||
217
+ isDynamic(key) ||
218
+ /^\d+(?:\.\d+)?$/u.test(key) ||
219
+ /^(?:event_target|global_event_target|scope):/iu.test(key)) {
220
+ return false;
221
+ }
222
+ if (value.type === 'scalar')
223
+ return /^(?:yes|no)$/iu.test(value.value);
224
+ // Unknown blocks are frequently definitions, date scopes, or native-effect
225
+ // argument objects. Retain the established explicit naming signal for an
226
+ // absent block helper instead of inventing calls from arbitrary containers.
227
+ return /(?:_helper|_scripted_effect)$/u.test(normalizedKey);
228
+ }
229
+ function weightValidity(weight) {
230
+ if (/^\d+(?:\.\d+)?$/u.test(weight))
231
+ return true;
232
+ if (/^(?:(?:var|temp_var|global_var|constant):)?[A-Za-z_][A-Za-z0-9_.:@^-]*$/u.test(weight)) {
233
+ return 'unknown';
234
+ }
235
+ return false;
236
+ }
237
+ function expectedScope(type) {
238
+ if (type === 'state_event')
239
+ return 'state';
240
+ if (type === 'unit_leader_event')
241
+ return 'unit_leader';
242
+ if (type === 'operative_leader_event')
243
+ return 'operative';
244
+ return 'country';
245
+ }
246
+ function valueText(document, value) {
247
+ return document.text
248
+ .slice(value.start, value.end)
249
+ .trim()
250
+ .slice(0, EVENT_GRAPH_MAX_CONDITION_TEXT);
251
+ }
252
+ function scalarOrField(value, field) {
253
+ if (value.type === 'scalar')
254
+ return value.value;
255
+ return firstScalar(value, field)?.value;
256
+ }
257
+ function firstScalarForKey(block, key) {
258
+ return assignments(block, key)
259
+ .map(({ value }) => value)
260
+ .find((value) => value.type === 'scalar');
261
+ }
262
+ /**
263
+ * Location- and trivia-independent Clausewitz content used only for semantic
264
+ * identity. Entry order remains significant because it is significant to the
265
+ * source language, while comments, whitespace, quoting style, and offsets do
266
+ * not participate in the identity.
267
+ */
268
+ function semanticSourceEntry(entry) {
269
+ if (entry.type === 'scalar')
270
+ return { type: 'scalar', value: entry.value };
271
+ if (entry.type === 'assignment') {
272
+ return {
273
+ type: 'assignment',
274
+ key: entry.key.value,
275
+ operator: entry.operator.text,
276
+ value: semanticSourceEntry(entry.value),
277
+ };
278
+ }
279
+ return {
280
+ type: 'block',
281
+ entries: entry.entries.map(semanticSourceEntry),
282
+ };
283
+ }
284
+ function semanticOccurrenceSegment(label, semantic, occurrences) {
285
+ const identity = deterministicId('event_semantic_path', { label, semantic });
286
+ const duplicateOrdinal = occurrences.get(identity) ?? 0;
287
+ occurrences.set(identity, duplicateOrdinal + 1);
288
+ return duplicateOrdinal === 0 ? identity : `${identity}:${duplicateOrdinal}`;
289
+ }
290
+ function issueFromDiagnostic(diagnostic) {
291
+ return {
292
+ code: diagnostic.code,
293
+ classification: 'unresolved_analysis',
294
+ severity: diagnostic.severity,
295
+ message: diagnostic.message,
296
+ confidence: 'unresolved',
297
+ ...(diagnostic.location === undefined ? {} : { location: diagnostic.location }),
298
+ ...(diagnostic.related === undefined ? {} : { related: diagnostic.related }),
299
+ blockers: [{ code: diagnostic.code, message: diagnostic.message }],
300
+ details: {},
301
+ };
302
+ }
303
+ export function eventSemanticFragmentCacheKey(file, catalogFingerprint = '') {
304
+ return deterministicId('event_fragment', {
305
+ parser: 'clausewitz-cst.v1',
306
+ sourcePath: file.displayPath,
307
+ sourceHash: file.sha256,
308
+ catalogFingerprint,
309
+ });
310
+ }
311
+ /** Bounded content-addressed cache suitable for direct graph-builder use. */
312
+ export class EventSemanticFragmentCache {
313
+ #entries = new Map();
314
+ #bytes = 0;
315
+ get(key) {
316
+ const hit = this.#entries.get(key);
317
+ if (hit === undefined)
318
+ return undefined;
319
+ this.#entries.delete(key);
320
+ this.#entries.set(key, hit);
321
+ return hit.fragment;
322
+ }
323
+ set(key, fragment, sourceBytes) {
324
+ if (!Number.isSafeInteger(sourceBytes) || sourceBytes < 0)
325
+ return;
326
+ const previous = this.#entries.get(key);
327
+ if (previous !== undefined) {
328
+ this.#bytes -= previous.bytes;
329
+ this.#entries.delete(key);
330
+ }
331
+ if (sourceBytes > EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES)
332
+ return;
333
+ this.#entries.set(key, { fragment, bytes: sourceBytes });
334
+ this.#bytes += sourceBytes;
335
+ while (this.#entries.size > EVENT_FRAGMENT_CACHE_MAX_ENTRIES ||
336
+ this.#bytes > EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES) {
337
+ const oldest = this.#entries.keys().next().value;
338
+ if (oldest === undefined)
339
+ break;
340
+ const removed = this.#entries.get(oldest);
341
+ this.#entries.delete(oldest);
342
+ this.#bytes -= removed?.bytes ?? 0;
343
+ }
344
+ }
345
+ }
346
+ class FragmentAnalyzer {
347
+ file;
348
+ document;
349
+ context;
350
+ nodes = [];
351
+ edges = [];
352
+ stateAccesses = [];
353
+ issues = [];
354
+ unresolved = [];
355
+ #budget;
356
+ #knownEvents;
357
+ #knownEventTypes;
358
+ #knownHelpers;
359
+ #seenStateWriters = new Set();
360
+ #edgeCollisions = new Map();
361
+ #conditionCollisions = new Map();
362
+ #stateCollisions = new Map();
363
+ #unresolvedCollisions = new Map();
364
+ constructor(file, document, context) {
365
+ this.file = file;
366
+ this.document = document;
367
+ this.context = context;
368
+ this.#budget = context.budget ?? new EventAnalysisBudget(context.signal);
369
+ this.#knownEvents = context.knownEventIds ?? new Set();
370
+ this.#knownEventTypes = context.knownEventTypes ?? new Map();
371
+ this.#knownHelpers = context.knownHelperIds ?? new Set();
372
+ }
373
+ analyze() {
374
+ const sourcePath = normalizedPath(this.file);
375
+ if (sourcePath.startsWith('events/'))
376
+ this.analyzeEvents();
377
+ else if (sourcePath.startsWith('common/scripted_effects/'))
378
+ this.analyzeHelpers();
379
+ else if (sourcePath.startsWith('common/on_actions/'))
380
+ this.analyzeOnActions();
381
+ else if (/common\/(?:national_)?focus/u.test(sourcePath))
382
+ this.analyzeFocuses();
383
+ else if (sourcePath.startsWith('common/decisions/'))
384
+ this.analyzeDecisions();
385
+ else if (sourcePath.startsWith('history/countries/'))
386
+ this.analyzeSetup('country_setup', 'country');
387
+ else if (sourcePath.startsWith('history/states/'))
388
+ this.analyzeSetup('state_setup', 'state');
389
+ else
390
+ this.analyzeOther();
391
+ }
392
+ location(node, symbol) {
393
+ return nodeLocation(this.document, node, symbol);
394
+ }
395
+ uniqueId(prefix, semantic) {
396
+ return deterministicId(prefix, { source: this.file.displayPath, semantic });
397
+ }
398
+ uniqueOccurrenceId(prefix, semantic, occurrences) {
399
+ const identity = this.uniqueId(prefix, semantic);
400
+ const duplicateOrdinal = occurrences.get(identity) ?? 0;
401
+ occurrences.set(identity, duplicateOrdinal + 1);
402
+ return duplicateOrdinal === 0
403
+ ? identity
404
+ : this.uniqueId(prefix, { semantic, duplicateOrdinal });
405
+ }
406
+ addNode(node) {
407
+ this.#budget.spend('event source node');
408
+ this.nodes.push(node);
409
+ }
410
+ analyzeEvents() {
411
+ const occurrences = new Map();
412
+ for (const assignment of assignments(this.document.root)) {
413
+ if (!EVENT_TYPES.has(assignment.key.value) || assignment.value.type !== 'block')
414
+ continue;
415
+ const type = assignment.key.value;
416
+ const idScalar = firstScalar(assignment.value, 'id');
417
+ if (idScalar === undefined)
418
+ continue;
419
+ const id = idScalar.value;
420
+ const retainedPaths = this.context.retainedEventPaths?.get(id);
421
+ if (retainedPaths !== undefined
422
+ ? !retainedPaths.has(this.file.displayPath)
423
+ : this.context.activeEventPaths !== undefined &&
424
+ this.context.activeEventPaths.get(id) !== this.file.displayPath) {
425
+ continue;
426
+ }
427
+ const ordinal = occurrences.get(id) ?? 0;
428
+ occurrences.set(id, ordinal + 1);
429
+ // Definitions remain unique even when an invalid duplicate ID appears in
430
+ // another active file. Calls use an event:<logical-id> placeholder that
431
+ // the graph stage resolves to the selected definition.
432
+ const ownerId = this.uniqueId('event_definition', { id, ordinal });
433
+ const eventAssignments = assignments(assignment.value);
434
+ const titleAssignments = eventAssignments.filter(({ key }) => key.value.toLowerCase() === 'title');
435
+ const descriptionAssignments = eventAssignments.filter(({ key }) => key.value.toLowerCase() === 'desc');
436
+ const titleKeys = [
437
+ ...new Set(titleAssignments.flatMap(({ value }) => localisationKeysFromValue(value))),
438
+ ].sort(compareCodeUnits);
439
+ const descriptionKeys = [
440
+ ...new Set(descriptionAssignments.flatMap(({ value }) => localisationKeysFromValue(value))),
441
+ ].sort(compareCodeUnits);
442
+ const titleKey = titleKeys[0];
443
+ const descKey = descriptionKeys[0];
444
+ const triggeredOnlyScalar = firstScalar(assignment.value, 'is_triggered_only');
445
+ const triggeredOnly = triggeredOnlyScalar?.value === 'yes';
446
+ const hidden = firstScalar(assignment.value, 'hidden')?.value === 'yes' ||
447
+ firstScalar(assignment.value, 'hide_window')?.value === 'yes';
448
+ this.addNode({
449
+ id: ownerId,
450
+ kind: 'event',
451
+ label: id,
452
+ eventId: id,
453
+ namespace: id.includes('.') ? id.slice(0, id.indexOf('.')) : id,
454
+ sourcePath: this.file.displayPath,
455
+ location: this.location(assignment, id),
456
+ metadata: {
457
+ eventType: type,
458
+ expectedScope: expectedScope(type),
459
+ hidden,
460
+ hasTitle: titleAssignments.length > 0,
461
+ hasDescription: descriptionAssignments.length > 0,
462
+ ...(triggeredOnlyScalar === undefined ? {} : { isTriggeredOnly: triggeredOnly }),
463
+ ...(titleKey === undefined ? {} : { titleKey }),
464
+ ...(descKey === undefined ? {} : { descKey }),
465
+ ...(titleKeys.length === 0 ? {} : { titleKeys }),
466
+ ...(descriptionKeys.length === 0 ? {} : { descriptionKeys }),
467
+ ...(titleKeys.length + descriptionKeys.length === 0
468
+ ? {}
469
+ : {
470
+ localisationKeys: [...new Set([...titleKeys, ...descriptionKeys])].sort(compareCodeUnits),
471
+ }),
472
+ },
473
+ });
474
+ const scope = expectedScope(type);
475
+ const trigger = childBlocks(assignment.value, 'trigger')[0];
476
+ const eventConditions = trigger === undefined
477
+ ? []
478
+ : [this.condition(ownerId, 'event_trigger', trigger, ['trigger'])];
479
+ if (trigger !== undefined) {
480
+ this.walk(trigger, {
481
+ ownerId,
482
+ sourceScope: scope,
483
+ phase: 'other',
484
+ conditions: eventConditions,
485
+ helperStack: [],
486
+ semanticPath: [id, 'trigger'],
487
+ });
488
+ }
489
+ for (const [field, fieldAssignments] of [
490
+ ['title', titleAssignments],
491
+ ['desc', descriptionAssignments],
492
+ ]) {
493
+ const fieldOccurrences = new Map();
494
+ for (const fieldAssignment of fieldAssignments) {
495
+ if (fieldAssignment.value.type !== 'block')
496
+ continue;
497
+ this.walk(fieldAssignment.value, {
498
+ ownerId,
499
+ sourceScope: scope,
500
+ phase: 'other',
501
+ conditions: [],
502
+ helperStack: [],
503
+ semanticPath: [
504
+ id,
505
+ semanticOccurrenceSegment(field, semanticSourceEntry(fieldAssignment.value), fieldOccurrences),
506
+ ],
507
+ effectContext: false,
508
+ });
509
+ }
510
+ }
511
+ for (const [phase, key] of [
512
+ ['immediate', 'immediate'],
513
+ ['after', 'after'],
514
+ ]) {
515
+ const phaseOccurrences = new Map();
516
+ childBlocks(assignment.value, key).forEach((block) => this.walk(block, {
517
+ ownerId,
518
+ sourceScope: scope,
519
+ phase,
520
+ conditions: eventConditions,
521
+ helperStack: [],
522
+ semanticPath: [
523
+ id,
524
+ semanticOccurrenceSegment(key, semanticSourceEntry(block), phaseOccurrences),
525
+ ],
526
+ }));
527
+ }
528
+ const options = childBlocks(assignment.value, 'option');
529
+ const optionNameCounts = new Map();
530
+ for (const option of options) {
531
+ const nameKey = firstScalar(option, 'name')?.value ?? '<unnamed>';
532
+ optionNameCounts.set(nameKey, (optionNameCounts.get(nameKey) ?? 0) + 1);
533
+ }
534
+ const optionOccurrences = new Map();
535
+ options.forEach((option, optionIndex) => {
536
+ const nameKey = firstScalar(option, 'name')?.value;
537
+ const semanticName = nameKey ?? '<unnamed>';
538
+ const optionSemantic = nameKey !== undefined && optionNameCounts.get(semanticName) === 1
539
+ ? { nameKey }
540
+ : { nameKey: semanticName, content: semanticSourceEntry(option) };
541
+ const optionOccurrence = semanticOccurrenceSegment('option', optionSemantic, optionOccurrences);
542
+ const optionId = this.uniqueId('option', {
543
+ eventDefinition: ownerId,
544
+ optionOccurrence,
545
+ });
546
+ const optionTrigger = childBlocks(option, 'trigger')[0];
547
+ const conditions = optionTrigger === undefined
548
+ ? []
549
+ : [
550
+ this.condition(optionId, 'option_trigger', optionTrigger, [
551
+ 'option',
552
+ optionId,
553
+ 'trigger',
554
+ ]),
555
+ ];
556
+ const effectCount = assignments(option).filter(({ key }) => !['name', 'trigger', 'ai_chance'].includes(key.value)).length;
557
+ this.addNode({
558
+ id: optionId,
559
+ kind: 'option',
560
+ label: nameKey ?? `Option ${optionIndex + 1}`,
561
+ eventId: id,
562
+ sourcePath: this.file.displayPath,
563
+ location: this.location(option, nameKey ?? id),
564
+ metadata: {
565
+ optionIndex,
566
+ effectCount,
567
+ hasEffects: effectCount > 0,
568
+ hasName: nameKey !== undefined,
569
+ ...(nameKey === undefined ? {} : { nameKey }),
570
+ },
571
+ });
572
+ this.addEdge(ownerId, optionId, 'option_branch', option, conditions, {
573
+ phase: 'option',
574
+ sourceScope: scope,
575
+ semanticPath: [id, 'option_branch', optionId],
576
+ });
577
+ this.walk(option, {
578
+ ownerId: optionId,
579
+ sourceScope: scope,
580
+ phase: 'option',
581
+ conditions,
582
+ helperStack: [],
583
+ semanticPath: [id, 'option', optionId],
584
+ });
585
+ });
586
+ const meanTimeToHappen = childBlocks(assignment.value, 'mean_time_to_happen')[0];
587
+ if (!triggeredOnly && (trigger !== undefined || meanTimeToHappen !== undefined)) {
588
+ const meanTimeConditions = meanTimeToHappen === undefined
589
+ ? []
590
+ : childBlocks(meanTimeToHappen, 'modifier').map((modifier, modifierIndex) => this.condition(ownerId, 'weight_modifier', modifier, [
591
+ id,
592
+ 'mean_time_to_happen',
593
+ `modifier:${modifierIndex}`,
594
+ ]));
595
+ if (meanTimeToHappen !== undefined) {
596
+ this.walk(meanTimeToHappen, {
597
+ ownerId,
598
+ sourceScope: scope,
599
+ phase: 'other',
600
+ conditions: meanTimeConditions,
601
+ helperStack: [],
602
+ semanticPath: [id, 'mean_time_to_happen'],
603
+ effectContext: false,
604
+ });
605
+ }
606
+ const entryId = this.uniqueId('entry', {
607
+ kind: 'implicit_event_trigger',
608
+ event: id,
609
+ ordinal,
610
+ });
611
+ this.addNode({
612
+ id: entryId,
613
+ kind: 'entry',
614
+ label: `${id} automatic trigger`,
615
+ sourcePath: this.file.displayPath,
616
+ location: this.location(assignment, id),
617
+ metadata: { entryKind: 'implicit_event_trigger', scope },
618
+ });
619
+ const entryEvidence = meanTimeToHappen ?? trigger ?? assignment;
620
+ this.addEdge(entryId, ownerId, 'implicit_event_entry', entryEvidence, [...eventConditions, ...meanTimeConditions], {
621
+ phase: 'entry',
622
+ sourceScope: scope,
623
+ ...(meanTimeToHappen === undefined
624
+ ? {}
625
+ : {
626
+ timing: this.meanTimeTiming(meanTimeToHappen),
627
+ }),
628
+ semanticPath: [id, 'implicit_event_trigger'],
629
+ });
630
+ }
631
+ }
632
+ }
633
+ analyzeHelpers() {
634
+ for (const assignment of assignments(this.document.root)) {
635
+ if (assignment.value.type !== 'block')
636
+ continue;
637
+ if (this.#knownHelpers.size > 0 && !this.#knownHelpers.has(assignment.key.value))
638
+ continue;
639
+ const name = assignment.key.value;
640
+ if (this.context.activeHelperPaths !== undefined &&
641
+ this.context.activeHelperPaths.get(name) !== this.file.displayPath) {
642
+ continue;
643
+ }
644
+ const ownerId = `helper:${name}`;
645
+ this.addNode({
646
+ id: ownerId,
647
+ kind: 'helper',
648
+ label: name,
649
+ sourcePath: this.file.displayPath,
650
+ location: this.location(assignment, name),
651
+ metadata: { name },
652
+ });
653
+ this.walk(assignment.value, {
654
+ ownerId,
655
+ sourceScope: 'unknown',
656
+ phase: 'helper',
657
+ conditions: [],
658
+ helperStack: [name],
659
+ semanticPath: ['helper', name],
660
+ });
661
+ }
662
+ }
663
+ analyzeOnActions() {
664
+ const containers = childBlocks(this.document.root, 'on_actions');
665
+ const roots = containers.length === 0 ? [this.document.root] : containers;
666
+ roots.forEach((root) => {
667
+ for (const action of assignments(root)) {
668
+ if (action.value.type !== 'block' ||
669
+ (containers.length === 0 && !action.key.value.startsWith('on_'))) {
670
+ continue;
671
+ }
672
+ const actionScope = onActionScope(action.key.value);
673
+ const ownerId = this.entryNode('on_action', action.key.value, action, actionScope);
674
+ const eventOccurrences = new Map();
675
+ for (const events of childBlocks(action.value, 'events')) {
676
+ events.entries.forEach((entry) => {
677
+ if (entry.type !== 'scalar')
678
+ return;
679
+ this.recordEventCall(entry.value, entry, {
680
+ ownerId,
681
+ sourceScope: actionScope,
682
+ phase: 'entry',
683
+ conditions: [],
684
+ helperStack: [],
685
+ semanticPath: [
686
+ action.key.value,
687
+ 'events',
688
+ semanticOccurrenceSegment('event', { target: entry.value }, eventOccurrences),
689
+ ],
690
+ reason: 'on_action_entry',
691
+ });
692
+ });
693
+ }
694
+ this.walk(action.value, {
695
+ ownerId,
696
+ sourceScope: actionScope,
697
+ phase: 'entry',
698
+ conditions: [],
699
+ helperStack: [],
700
+ semanticPath: [action.key.value],
701
+ });
702
+ }
703
+ });
704
+ }
705
+ analyzeFocuses() {
706
+ this.visitAssignments(this.document.root, (assignment, ancestors) => {
707
+ if (assignment.key.value !== 'focus' || assignment.value.type !== 'block')
708
+ return;
709
+ const id = firstScalar(assignment.value, 'id')?.value;
710
+ if (id === undefined)
711
+ return;
712
+ const ownerId = this.entryNode('focus', id, assignment, 'country');
713
+ for (const key of ['available', 'bypass', 'allow_branch']) {
714
+ const blockOccurrences = new Map();
715
+ childBlocks(assignment.value, key).forEach((block) => {
716
+ const occurrence = semanticOccurrenceSegment(key, semanticSourceEntry(block), blockOccurrences);
717
+ this.walk(block, {
718
+ ownerId,
719
+ sourceScope: 'country',
720
+ phase: 'other',
721
+ conditions: [this.condition(ownerId, 'branch_guard', block, [id, occurrence])],
722
+ helperStack: [],
723
+ semanticPath: [...ancestors, id, occurrence],
724
+ });
725
+ });
726
+ }
727
+ const rewardOccurrences = new Map();
728
+ childBlocks(assignment.value, 'completion_reward').forEach((block) => this.walk(block, {
729
+ ownerId,
730
+ sourceScope: 'country',
731
+ phase: 'entry',
732
+ conditions: [],
733
+ helperStack: [],
734
+ semanticPath: [
735
+ ...ancestors,
736
+ id,
737
+ semanticOccurrenceSegment('completion_reward', semanticSourceEntry(block), rewardOccurrences),
738
+ ],
739
+ }));
740
+ });
741
+ }
742
+ analyzeDecisions() {
743
+ this.visitAssignments(this.document.root, (assignment, ancestors) => {
744
+ if (assignment.value.type !== 'block')
745
+ return;
746
+ const id = assignment.key.value;
747
+ if (this.context.knownDecisionIds !== undefined && !this.context.knownDecisionIds.has(id))
748
+ return;
749
+ if (this.context.activeDecisionPaths !== undefined &&
750
+ this.context.activeDecisionPaths.get(id) !== this.file.displayPath) {
751
+ return;
752
+ }
753
+ if (this.context.knownDecisionIds === undefined && ancestors.length !== 1)
754
+ return;
755
+ const mission = firstScalar(assignment.value, 'days_mission_timeout') !== undefined;
756
+ const ownerId = this.entryNode(mission ? 'mission' : 'decision', id, assignment, 'country');
757
+ for (const key of [
758
+ 'visible',
759
+ 'available',
760
+ 'allowed',
761
+ 'activation',
762
+ 'remove_trigger',
763
+ 'complete_trigger',
764
+ 'target_trigger',
765
+ ]) {
766
+ const blockOccurrences = new Map();
767
+ childBlocks(assignment.value, key).forEach((block) => {
768
+ const occurrence = semanticOccurrenceSegment(key, semanticSourceEntry(block), blockOccurrences);
769
+ this.walk(block, {
770
+ ownerId,
771
+ sourceScope: 'country',
772
+ phase: 'other',
773
+ conditions: [this.condition(ownerId, 'branch_guard', block, [id, occurrence])],
774
+ helperStack: [],
775
+ semanticPath: [id, occurrence],
776
+ });
777
+ });
778
+ }
779
+ for (const key of [
780
+ 'complete_effect',
781
+ 'remove_effect',
782
+ 'timeout_effect',
783
+ 'cancel_effect',
784
+ 'select_effect',
785
+ ]) {
786
+ const blockOccurrences = new Map();
787
+ childBlocks(assignment.value, key).forEach((block) => this.walk(block, {
788
+ ownerId,
789
+ sourceScope: 'country',
790
+ phase: 'entry',
791
+ conditions: [],
792
+ helperStack: [],
793
+ semanticPath: [
794
+ id,
795
+ semanticOccurrenceSegment(key, semanticSourceEntry(block), blockOccurrences),
796
+ ],
797
+ }));
798
+ }
799
+ });
800
+ }
801
+ analyzeSetup(kind, scope) {
802
+ const name = this.file.relativePath.replaceAll('\\', '/').split('/').at(-1) ?? this.file.displayPath;
803
+ const ownerId = this.uniqueId('entry', { kind, name });
804
+ const edgeCount = this.edges.length;
805
+ const stateCount = this.stateAccesses.length;
806
+ this.walk(this.document.root, {
807
+ ownerId,
808
+ sourceScope: scope,
809
+ phase: 'entry',
810
+ conditions: [],
811
+ helperStack: [],
812
+ semanticPath: [kind, name],
813
+ });
814
+ if (this.edges.length === edgeCount) {
815
+ this.stateAccesses.splice(stateCount);
816
+ return;
817
+ }
818
+ this.addNode({
819
+ id: ownerId,
820
+ kind: 'entry',
821
+ label: name,
822
+ sourcePath: this.file.displayPath,
823
+ location: this.location(this.document.root, name),
824
+ metadata: { entryKind: kind, scope },
825
+ });
826
+ }
827
+ analyzeOther() {
828
+ const ownerId = this.uniqueId('entry', { kind: 'other', path: this.file.displayPath });
829
+ const before = this.edges.length + this.stateAccesses.length;
830
+ this.walk(this.document.root, {
831
+ ownerId,
832
+ sourceScope: 'unknown',
833
+ phase: 'other',
834
+ conditions: [],
835
+ helperStack: [],
836
+ semanticPath: ['other'],
837
+ effectContext: true,
838
+ });
839
+ if (this.edges.length + this.stateAccesses.length > before) {
840
+ this.addNode({
841
+ id: ownerId,
842
+ kind: 'entry',
843
+ label: this.file.relativePath,
844
+ sourcePath: this.file.displayPath,
845
+ location: this.location(this.document.root, this.file.relativePath),
846
+ metadata: { entryKind: 'other', scope: 'unknown' },
847
+ });
848
+ }
849
+ }
850
+ entryNode(kind, name, node, scope) {
851
+ const id = this.uniqueId('entry', { kind, name });
852
+ const reason = kind === 'focus' ? 'focus' : kind;
853
+ this.addNode({
854
+ id,
855
+ kind: 'entry',
856
+ label: name,
857
+ sourcePath: this.file.displayPath,
858
+ location: this.location(node, name),
859
+ metadata: { entryKind: kind, scope, reason },
860
+ });
861
+ return id;
862
+ }
863
+ condition(ownerId, kind, node, _semanticPath) {
864
+ const semantic = {
865
+ ownerId,
866
+ kind,
867
+ content: semanticSourceEntry(node),
868
+ };
869
+ return {
870
+ id: this.uniqueOccurrenceId('event_condition', semantic, this.#conditionCollisions),
871
+ kind,
872
+ expression: this.document.text
873
+ .slice(node.start, node.end)
874
+ .trim()
875
+ .slice(0, EVENT_GRAPH_MAX_CONDITION_TEXT),
876
+ location: this.location(node),
877
+ confidence: 'confirmed',
878
+ };
879
+ }
880
+ walk(block, context) {
881
+ const semanticOccurrences = new Map();
882
+ block.entries.forEach((entry) => {
883
+ this.#budget.spend('event effect entry');
884
+ if (entry.type !== 'assignment')
885
+ return;
886
+ const key = entry.key.value;
887
+ const normalizedKey = key.toLowerCase();
888
+ const occurrence = semanticOccurrenceSegment(key, semanticSourceEntry(entry), semanticOccurrences);
889
+ const semanticPath = [...context.semanticPath, occurrence];
890
+ const effectContext = context.effectContext ?? context.phase !== 'other';
891
+ const qualifiedDynamicScope = isQualifiedDynamicScope(key);
892
+ const chainedScope = isScopeChain(key);
893
+ const literalCountryTag = isLiteralCountryTag(key);
894
+ if (effectContext && EVENT_CALLS.has(normalizedKey)) {
895
+ const id = scalarOrField(entry.value, 'id');
896
+ if (id !== undefined)
897
+ this.recordEventCall(id, entry, { ...context, semanticPath });
898
+ else
899
+ this.recordDynamicEvent(valueText(this.document, entry.value), entry, context.ownerId, semanticPath);
900
+ return;
901
+ }
902
+ if (context.suppressSemantics !== true)
903
+ this.recordState(entry, context, semanticPath);
904
+ if (effectContext && this.#knownHelpers.has(key)) {
905
+ const target = `helper:${key}`;
906
+ this.addEdge(context.ownerId, target, 'scripted_effect_call', entry, context.conditions, {
907
+ phase: context.phase,
908
+ sourceScope: context.sourceScope,
909
+ helperStack: context.helperStack,
910
+ semanticPath,
911
+ });
912
+ return;
913
+ }
914
+ if (this.context.inventoryComplete !== false &&
915
+ missingHelperCandidate(key, entry.value, effectContext)) {
916
+ const target = this.unresolvedNode('missing_helper', key, entry, context.ownerId, semanticPath);
917
+ this.addEdge(context.ownerId, target, 'scripted_effect_call', entry, context.conditions, {
918
+ phase: context.phase,
919
+ sourceScope: context.sourceScope,
920
+ helperStack: context.helperStack,
921
+ semanticPath,
922
+ });
923
+ return;
924
+ }
925
+ if (effectContext && isDynamic(key) && !qualifiedDynamicScope) {
926
+ this.recordDynamicHelper(key, entry, context.ownerId, semanticPath);
927
+ return;
928
+ }
929
+ if (effectContext && normalizedKey === 'random_events' && entry.value.type === 'block') {
930
+ const memberOccurrences = new Map();
931
+ for (const weighted of assignments(entry.value)) {
932
+ if (weighted.value.type !== 'scalar' || weighted.value.value === '0')
933
+ continue;
934
+ const weight = weighted.key.value;
935
+ this.recordEventCall(weighted.value.value, weighted, {
936
+ ...context,
937
+ semanticPath: [
938
+ ...semanticPath,
939
+ semanticOccurrenceSegment('weighted_event', { weight, target: weighted.value.value }, memberOccurrences),
940
+ ],
941
+ weight,
942
+ reason: 'random_event_call',
943
+ });
944
+ }
945
+ return;
946
+ }
947
+ if (entry.value.type !== 'block')
948
+ return;
949
+ let conditions = context.conditions;
950
+ if (normalizedKey === 'if' || normalizedKey === 'else_if' || normalizedKey === 'while') {
951
+ const limit = childBlocks(entry.value, 'limit')[0];
952
+ if (limit !== undefined)
953
+ conditions = [
954
+ ...conditions,
955
+ this.condition(context.ownerId, 'limit', limit, [...semanticPath, 'limit']),
956
+ ];
957
+ }
958
+ if (normalizedKey === 'limit')
959
+ conditions = [
960
+ ...conditions,
961
+ this.condition(context.ownerId, 'limit', entry.value, semanticPath),
962
+ ];
963
+ if (normalizedKey === 'trigger') {
964
+ conditions = [
965
+ ...conditions,
966
+ this.condition(context.ownerId, 'branch_guard', entry.value, semanticPath),
967
+ ];
968
+ }
969
+ if (normalizedKey === 'random') {
970
+ const chance = firstScalar(entry.value, 'chance');
971
+ if (chance !== undefined) {
972
+ conditions = [
973
+ ...conditions,
974
+ this.condition(context.ownerId, 'random_chance', chance, [...semanticPath, 'chance']),
975
+ ];
976
+ }
977
+ }
978
+ const scopeBlock = SCOPE_BLOCKS[normalizedKey];
979
+ const explicitDynamicScope = qualifiedDynamicScope || chainedScope;
980
+ const scope = explicitDynamicScope
981
+ ? 'unknown'
982
+ : literalCountryTag
983
+ ? 'country'
984
+ : (scopeBlock ?? context.sourceScope);
985
+ const scopeExpression = explicitDynamicScope || literalCountryTag || scopeBlock !== undefined
986
+ ? key
987
+ : context.scopeExpression;
988
+ const phase = normalizedKey === 'hidden_effect' ? 'hidden' : context.phase;
989
+ if (effectContext && normalizedKey === 'random_list') {
990
+ const branchOccurrences = new Map();
991
+ for (const weighted of assignments(entry.value)) {
992
+ const weight = weighted.key.value;
993
+ if (weighted.value.type !== 'block')
994
+ continue;
995
+ const branchOccurrence = semanticOccurrenceSegment('weighted_branch', { weight, content: semanticSourceEntry(weighted.value) }, branchOccurrences);
996
+ const weightModifiers = [];
997
+ childBlocks(weighted.value, 'modifier').forEach((modifier, modifierIndex) => {
998
+ weightModifiers.push(this.condition(context.ownerId, 'weight_modifier', modifier, [
999
+ ...semanticPath,
1000
+ branchOccurrence,
1001
+ `modifier:${modifierIndex}`,
1002
+ ]));
1003
+ });
1004
+ this.walk(weighted.value, {
1005
+ ...context,
1006
+ conditions,
1007
+ sourceScope: scope,
1008
+ ...(scopeExpression === undefined ? {} : { scopeExpression }),
1009
+ semanticPath: [...semanticPath, branchOccurrence],
1010
+ weight,
1011
+ weightModifiers,
1012
+ reason: 'weighted_event_call',
1013
+ effectContext: true,
1014
+ });
1015
+ }
1016
+ return;
1017
+ }
1018
+ if (CONTROL_BLOCKS.has(normalizedKey) ||
1019
+ scopeBlock !== undefined ||
1020
+ normalizedKey === 'effect' ||
1021
+ normalizedKey === 'completion_reward' ||
1022
+ normalizedKey === 'random') {
1023
+ this.walk(entry.value, {
1024
+ ...context,
1025
+ conditions,
1026
+ sourceScope: scope,
1027
+ phase,
1028
+ semanticPath,
1029
+ ...(scopeExpression === undefined ? {} : { scopeExpression }),
1030
+ effectContext: NON_EXECUTING_BLOCKS.has(normalizedKey) ? false : effectContext,
1031
+ suppressSemantics: context.suppressSemantics === true || TOOLTIP_ONLY_BLOCKS.has(normalizedKey),
1032
+ ...(normalizedKey === 'random' ? { reason: 'random_event_call' } : {}),
1033
+ });
1034
+ return;
1035
+ }
1036
+ this.walk(entry.value, {
1037
+ ...context,
1038
+ conditions,
1039
+ sourceScope: scope,
1040
+ phase,
1041
+ effectContext: NON_EXECUTING_BLOCKS.has(normalizedKey) ? false : effectContext,
1042
+ suppressSemantics: context.suppressSemantics === true || TOOLTIP_ONLY_BLOCKS.has(normalizedKey),
1043
+ semanticPath,
1044
+ ...(scopeExpression === undefined ? {} : { scopeExpression }),
1045
+ });
1046
+ });
1047
+ }
1048
+ recordEventCall(id, node, context) {
1049
+ if (isDynamic(id)) {
1050
+ this.recordDynamicEvent(id, node, context.ownerId, context.semanticPath);
1051
+ return;
1052
+ }
1053
+ const known = this.#knownEvents.size === 0 || this.#knownEvents.has(id);
1054
+ const target = known
1055
+ ? this.eventNodeId(id)
1056
+ : this.unresolvedNode(this.context.inventoryComplete === false ? 'partial_source' : 'missing_event', id, node, context.ownerId, context.semanticPath);
1057
+ const assignmentValue = node.type === 'assignment' ? node.value : undefined;
1058
+ const reason = context.reason ?? this.edgeReason(context.phase, assignmentValue, context.weight);
1059
+ const timing = assignmentValue === undefined ? undefined : this.timing(assignmentValue);
1060
+ this.addEdge(context.ownerId, target, reason, node, context.conditions, {
1061
+ phase: context.phase,
1062
+ sourceScope: context.sourceScope,
1063
+ helperStack: context.helperStack,
1064
+ ...(timing === undefined ? {} : { timing }),
1065
+ ...(context.weight === undefined ? {} : { weight: context.weight }),
1066
+ ...(context.weightModifiers === undefined
1067
+ ? {}
1068
+ : { weightModifiers: context.weightModifiers }),
1069
+ ...(context.scopeExpression === undefined
1070
+ ? {}
1071
+ : { scopeExpression: context.scopeExpression }),
1072
+ semanticPath: context.semanticPath,
1073
+ targetEventId: id,
1074
+ });
1075
+ }
1076
+ eventNodeId(eventId) {
1077
+ return `event:${eventId}`;
1078
+ }
1079
+ edgeReason(phase, value, weight) {
1080
+ if (weight !== undefined)
1081
+ return 'weighted_event_call';
1082
+ const timing = value === undefined ? undefined : this.timing(value);
1083
+ if (timing !== undefined && timing.mode !== 'immediate')
1084
+ return 'delayed_event_call';
1085
+ if (phase === 'immediate')
1086
+ return 'immediate_event_call';
1087
+ if (phase === 'option')
1088
+ return 'option_event_call';
1089
+ if (phase === 'hidden')
1090
+ return 'hidden_event_call';
1091
+ if (phase === 'after')
1092
+ return 'after_event_call';
1093
+ if (phase === 'entry')
1094
+ return 'other_entry';
1095
+ return 'immediate_event_call';
1096
+ }
1097
+ meanTimeTiming(value) {
1098
+ const timing = this.timing(value);
1099
+ if (timing?.mode === 'unknown')
1100
+ return timing;
1101
+ return {
1102
+ ...(timing ?? {}),
1103
+ mode: 'mean_time_to_happen',
1104
+ expression: valueText(this.document, value),
1105
+ };
1106
+ }
1107
+ timing(value) {
1108
+ if (value.type !== 'block')
1109
+ return undefined;
1110
+ const fields = {
1111
+ years: firstScalar(value, 'years')?.value,
1112
+ hours: firstScalar(value, 'hours')?.value,
1113
+ days: firstScalar(value, 'days')?.value,
1114
+ months: firstScalar(value, 'months')?.value,
1115
+ randomHours: firstScalar(value, 'random_hours')?.value ?? firstScalarForKey(value, 'random')?.value,
1116
+ randomDays: firstScalar(value, 'random_days')?.value,
1117
+ randomMonths: firstScalar(value, 'random_months')?.value,
1118
+ date: firstScalar(value, 'date')?.value,
1119
+ };
1120
+ if (Object.values(fields).every((field) => field === undefined))
1121
+ return undefined;
1122
+ const numericFields = [
1123
+ fields.years,
1124
+ fields.hours,
1125
+ fields.days,
1126
+ fields.months,
1127
+ fields.randomHours,
1128
+ fields.randomDays,
1129
+ fields.randomMonths,
1130
+ ];
1131
+ const dynamic = numericFields.some((field) => field !== undefined && !/^[+-]?\d+(?:\.\d+)?$/u.test(field)) ||
1132
+ (fields.date !== undefined && !/^\d{1,4}\.\d{1,2}\.\d{1,2}$/u.test(fields.date));
1133
+ const fixed = fields.years !== undefined ||
1134
+ fields.hours !== undefined ||
1135
+ fields.days !== undefined ||
1136
+ fields.months !== undefined;
1137
+ const random = fields.randomHours !== undefined ||
1138
+ fields.randomDays !== undefined ||
1139
+ fields.randomMonths !== undefined;
1140
+ const mode = dynamic
1141
+ ? 'unknown'
1142
+ : fields.date !== undefined
1143
+ ? 'date'
1144
+ : fixed && random
1145
+ ? 'fixed_and_random'
1146
+ : random
1147
+ ? 'random'
1148
+ : 'fixed';
1149
+ return {
1150
+ mode,
1151
+ ...(fields.years === undefined ? {} : { years: fields.years }),
1152
+ ...(fields.hours === undefined ? {} : { hours: fields.hours }),
1153
+ ...(fields.days === undefined ? {} : { days: fields.days }),
1154
+ ...(fields.months === undefined ? {} : { months: fields.months }),
1155
+ ...(fields.randomHours === undefined ? {} : { randomHours: fields.randomHours }),
1156
+ ...(fields.randomDays === undefined ? {} : { randomDays: fields.randomDays }),
1157
+ ...(fields.randomMonths === undefined ? {} : { randomMonths: fields.randomMonths }),
1158
+ ...(fields.date === undefined ? {} : { date: fields.date }),
1159
+ ...(dynamic ? { expression: valueText(this.document, value) } : {}),
1160
+ };
1161
+ }
1162
+ addEdge(from, to, reason, node, conditions, extra) {
1163
+ const location = this.location(node);
1164
+ const semanticIdentity = {
1165
+ from,
1166
+ to,
1167
+ reason,
1168
+ phase: extra.phase,
1169
+ sourceScope: extra.sourceScope,
1170
+ conditions: conditions.map(({ id: conditionId }) => conditionId),
1171
+ helperStack: extra.helperStack ?? [],
1172
+ timing: extra.timing,
1173
+ weight: extra.weight,
1174
+ weightModifiers: extra.weightModifiers?.map(({ id }) => id),
1175
+ targetEventId: extra.targetEventId,
1176
+ scopeExpression: extra.scopeExpression,
1177
+ };
1178
+ const collisionKey = deterministicId('event_edge', semanticIdentity);
1179
+ const collisionOrdinal = this.#edgeCollisions.get(collisionKey) ?? 0;
1180
+ this.#edgeCollisions.set(collisionKey, collisionOrdinal + 1);
1181
+ const id = collisionOrdinal === 0
1182
+ ? collisionKey
1183
+ : deterministicId('event_edge', { ...semanticIdentity, collisionOrdinal });
1184
+ const destinationType = extra.targetEventId === undefined
1185
+ ? undefined
1186
+ : this.#knownEventTypes.get(extra.targetEventId);
1187
+ this.edges.push({
1188
+ id,
1189
+ from,
1190
+ to,
1191
+ reason,
1192
+ conditions,
1193
+ helperStack: [...(extra.helperStack ?? [])],
1194
+ location,
1195
+ provenance: [
1196
+ {
1197
+ role: reason === 'scripted_effect_call'
1198
+ ? 'helper_call'
1199
+ : extra.phase === 'entry'
1200
+ ? 'entry'
1201
+ : 'invocation',
1202
+ location,
1203
+ },
1204
+ ],
1205
+ confidence: to.startsWith('unresolved_')
1206
+ ? 'unresolved'
1207
+ : extra.timing?.mode === 'unknown'
1208
+ ? 'low'
1209
+ : 'confirmed',
1210
+ derived: false,
1211
+ ...(extra.timing === undefined ? {} : { timing: extra.timing }),
1212
+ ...(extra.weight === undefined
1213
+ ? {}
1214
+ : {
1215
+ weight: {
1216
+ value: extra.weight,
1217
+ modifiers: [...(extra.weightModifiers ?? [])],
1218
+ valid: weightValidity(extra.weight),
1219
+ },
1220
+ }),
1221
+ ...(destinationType === undefined
1222
+ ? {}
1223
+ : {
1224
+ scope: {
1225
+ source: extra.sourceScope,
1226
+ destination: expectedScope(destinationType),
1227
+ ...(extra.scopeExpression === undefined ? {} : { expression: extra.scopeExpression }),
1228
+ confidence: extra.sourceScope === 'unknown' ? 'low' : 'high',
1229
+ },
1230
+ }),
1231
+ metadata: {
1232
+ ...(extra.targetEventId === undefined ? {} : { targetEventId: extra.targetEventId }),
1233
+ ...(extra.timing?.mode === 'unknown'
1234
+ ? { blocker: 'Event timing could not be resolved statically.' }
1235
+ : {}),
1236
+ },
1237
+ });
1238
+ }
1239
+ recordDynamicEvent(expression, node, ownerId, semanticPath) {
1240
+ const target = this.unresolvedNode('dynamic_event', expression, node, ownerId, semanticPath);
1241
+ this.addEdge(ownerId, target, 'unresolved_dynamic_reference', node, [], {
1242
+ phase: 'other',
1243
+ sourceScope: 'unknown',
1244
+ semanticPath,
1245
+ });
1246
+ }
1247
+ recordDynamicHelper(expression, node, ownerId, semanticPath) {
1248
+ const target = this.unresolvedNode('dynamic_helper', expression, node, ownerId, semanticPath);
1249
+ this.addEdge(ownerId, target, 'unresolved_dynamic_reference', node, [], {
1250
+ phase: 'helper',
1251
+ sourceScope: 'unknown',
1252
+ semanticPath,
1253
+ });
1254
+ }
1255
+ unresolvedNode(kind, expression, node, ownerId, _semanticPath) {
1256
+ const id = this.uniqueOccurrenceId('unresolved', { kind, ownerId, expression }, this.#unresolvedCollisions);
1257
+ const blocker = kind === 'missing_event'
1258
+ ? {
1259
+ code: 'EVENT_REFERENCE_MISSING',
1260
+ message: `Event ${expression} is absent from the active catalog`,
1261
+ }
1262
+ : kind === 'missing_helper'
1263
+ ? {
1264
+ code: 'EVENT_HELPER_UNRESOLVED',
1265
+ message: `Scripted helper ${expression} is absent from the active catalog`,
1266
+ }
1267
+ : kind === 'partial_source'
1268
+ ? {
1269
+ code: 'PARTIAL_SOURCE_INVENTORY',
1270
+ message: 'A skipped source could contain this definition',
1271
+ }
1272
+ : {
1273
+ code: kind === 'dynamic_helper'
1274
+ ? 'EVENT_HELPER_UNRESOLVED'
1275
+ : 'EVENT_DYNAMIC_DISPATCH_UNRESOLVED',
1276
+ message: 'Runtime expression cannot be resolved',
1277
+ };
1278
+ this.addNode({
1279
+ id,
1280
+ kind: 'unresolved',
1281
+ label: expression,
1282
+ sourcePath: this.file.displayPath,
1283
+ location: this.location(node),
1284
+ metadata: { unresolvedKind: kind, expression },
1285
+ });
1286
+ this.unresolved.push({
1287
+ id,
1288
+ kind,
1289
+ expression,
1290
+ ownerId,
1291
+ location: this.location(node),
1292
+ confidence: 'unresolved',
1293
+ blockers: [blocker],
1294
+ });
1295
+ return id;
1296
+ }
1297
+ recordState(assignment, context, semanticPath) {
1298
+ const keyedTarget = /^(event_target|global_event_target|scope):([A-Za-z0-9_.-]+)$/u.exec(assignment.key.value);
1299
+ if (keyedTarget !== null) {
1300
+ this.addState(context, keyedTarget[1] === 'global_event_target'
1301
+ ? 'global_event_target'
1302
+ : keyedTarget[1] === 'scope'
1303
+ ? 'saved_scope'
1304
+ : 'event_target', keyedTarget[2], 'read', assignment.key, [...semanticPath, 'scope_read'], false);
1305
+ }
1306
+ const rule = STATE_RULES[assignment.key.value];
1307
+ if (rule === undefined) {
1308
+ const raw = valueText(this.document, assignment.value);
1309
+ for (const match of raw.matchAll(/\b(event_target|global_event_target):([A-Za-z0-9_.-]+)/gu)) {
1310
+ this.addState(context, match[1] === 'global_event_target' ? 'global_event_target' : 'event_target', match[2], 'read', assignment, semanticPath, false);
1311
+ }
1312
+ return;
1313
+ }
1314
+ let kind = rule.kind;
1315
+ let name = scalarOrField(assignment.value, rule.nameField ?? 'name');
1316
+ if (name === undefined && assignment.value.type === 'block') {
1317
+ const candidate = assignments(assignment.value).find(({ key }) => !['value', 'compare', 'tooltip'].includes(key.value));
1318
+ name = candidate?.key.value;
1319
+ }
1320
+ if (assignment.key.value === 'clear_global_event_targets')
1321
+ name = '*';
1322
+ if (name === undefined)
1323
+ return;
1324
+ if ((kind === 'variable' || kind === 'global_variable') && name.startsWith('global.')) {
1325
+ kind = 'global_variable';
1326
+ name = name.slice('global.'.length);
1327
+ }
1328
+ let access = rule.access;
1329
+ const writerKey = `${kind}:${name}:${rule.storage ?? ''}`;
1330
+ if (access === 'replace' &&
1331
+ (kind === 'event_target' || kind === 'global_event_target' || kind === 'saved_scope')) {
1332
+ access = this.#seenStateWriters.has(writerKey) ? 'replace' : 'write';
1333
+ this.#seenStateWriters.add(writerKey);
1334
+ }
1335
+ else if (access === 'write') {
1336
+ this.#seenStateWriters.add(writerKey);
1337
+ }
1338
+ this.addState(context, kind, name, access, assignment, semanticPath, isDynamic(name), rule.storage === undefined ? {} : { storage: rule.storage });
1339
+ }
1340
+ addState(context, kind, name, access, node, _semanticPath, dynamic, metadata = {}) {
1341
+ const semantic = {
1342
+ ownerId: context.ownerId,
1343
+ kind,
1344
+ name,
1345
+ access,
1346
+ scope: context.sourceScope,
1347
+ helperStack: context.helperStack,
1348
+ conditions: context.conditions.map(({ id }) => id),
1349
+ dynamic,
1350
+ metadata,
1351
+ };
1352
+ this.stateAccesses.push({
1353
+ id: this.uniqueOccurrenceId('event_state', semantic, this.#stateCollisions),
1354
+ ownerId: context.ownerId,
1355
+ kind,
1356
+ name,
1357
+ access,
1358
+ location: this.location(node, name),
1359
+ confidence: dynamic ? 'unresolved' : 'confirmed',
1360
+ scope: context.sourceScope,
1361
+ helperStack: [...context.helperStack],
1362
+ conditions: [...context.conditions],
1363
+ dynamic,
1364
+ metadata,
1365
+ });
1366
+ }
1367
+ visitAssignments(block, visit, ancestors = []) {
1368
+ for (const entry of block.entries) {
1369
+ if (entry.type !== 'assignment')
1370
+ continue;
1371
+ visit(entry, ancestors);
1372
+ if (entry.value.type === 'block')
1373
+ this.visitAssignments(entry.value, visit, [...ancestors, entry.key.value]);
1374
+ }
1375
+ }
1376
+ }
1377
+ export function analyzeEventSource(file, context = {}) {
1378
+ context.signal?.throwIfAborted();
1379
+ const cacheKey = eventSemanticFragmentCacheKey(file, context.catalogFingerprint);
1380
+ const base = {
1381
+ cacheKey,
1382
+ sourcePath: file.displayPath,
1383
+ sourceHash: file.sha256,
1384
+ };
1385
+ const sourcePath = normalizedPath(file);
1386
+ if (sourcePath.endsWith('.yml') || sourcePath.endsWith('.yaml')) {
1387
+ const document = parseLocalisation(file.bytes, file.displayPath);
1388
+ return {
1389
+ ...base,
1390
+ nodes: [],
1391
+ edges: [],
1392
+ stateAccesses: [],
1393
+ issues: document.diagnostics.map(issueFromDiagnostic),
1394
+ unresolved: [],
1395
+ localisation: document.entries
1396
+ .map((entry) => ({
1397
+ key: entry.key,
1398
+ language: entry.language,
1399
+ value: entry.value,
1400
+ location: {
1401
+ path: file.displayPath,
1402
+ start: { line: entry.line, column: 1, offset: entry.start },
1403
+ end: {
1404
+ line: entry.line,
1405
+ column: Math.max(1, entry.end - entry.start + 1),
1406
+ offset: entry.end,
1407
+ },
1408
+ },
1409
+ }))
1410
+ .sort((left, right) => compareCodeUnits(left.key, right.key) ||
1411
+ compareCodeUnits(left.language, right.language)),
1412
+ };
1413
+ }
1414
+ if (!sourcePath.endsWith('.txt')) {
1415
+ return {
1416
+ ...base,
1417
+ nodes: [],
1418
+ edges: [],
1419
+ stateAccesses: [],
1420
+ issues: [],
1421
+ unresolved: [],
1422
+ localisation: [],
1423
+ };
1424
+ }
1425
+ const document = parseClausewitz(file.bytes, file.displayPath);
1426
+ const analyzer = new FragmentAnalyzer(file, document, context);
1427
+ analyzer.issues.push(...document.diagnostics.map(issueFromDiagnostic));
1428
+ const partial = sourcePartialLimitDiagnostics(document.diagnostics);
1429
+ if (partial.length > 0) {
1430
+ analyzer.unresolved.push({
1431
+ id: deterministicId('unresolved', {
1432
+ source: file.displayPath,
1433
+ hash: file.sha256,
1434
+ kind: 'partial_source',
1435
+ }),
1436
+ kind: 'partial_source',
1437
+ expression: file.displayPath,
1438
+ confidence: 'unresolved',
1439
+ blockers: partial.map(({ code, message, location }) => ({
1440
+ code,
1441
+ message,
1442
+ ...(location === undefined ? {} : { location }),
1443
+ })),
1444
+ });
1445
+ }
1446
+ else {
1447
+ analyzer.analyze();
1448
+ }
1449
+ return {
1450
+ ...base,
1451
+ nodes: analyzer.nodes,
1452
+ edges: analyzer.edges,
1453
+ stateAccesses: analyzer.stateAccesses,
1454
+ issues: analyzer.issues,
1455
+ unresolved: analyzer.unresolved,
1456
+ localisation: [],
1457
+ };
1458
+ }