hoi4-agent-tools 2.1.1 → 2.2.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.
@@ -0,0 +1,967 @@
1
+ import path from 'node:path';
2
+ import { compareCodeUnits, deterministicId } from '../core/canonical.js';
3
+ import { assignments, childBlocks, firstScalar, nodeLocation, parseClausewitz, sourcePartialLimitDiagnostics, } from '../core/source/index.js';
4
+ const technologyStructuralFields = new Set([
5
+ 'ai_research_weights',
6
+ 'ai_will_do',
7
+ 'allow',
8
+ 'allow_branch',
9
+ 'categories',
10
+ 'desc',
11
+ 'doctrine',
12
+ 'doctrine_name',
13
+ 'enable_ability',
14
+ 'enable_abilities',
15
+ 'enable_building',
16
+ 'enable_equipment_modules',
17
+ 'enable_equipments',
18
+ 'enable_subunits',
19
+ 'enable_tactic',
20
+ 'folder',
21
+ 'force_use_small_tech_layout',
22
+ 'on_research_complete',
23
+ 'on_research_complete_limit',
24
+ 'path',
25
+ 'research_cost',
26
+ 'show_equipment_icon',
27
+ 'start_year',
28
+ 'sub_technologies',
29
+ 'tags',
30
+ 'xp_boost_cost',
31
+ 'xp_research_bonus',
32
+ 'xp_unlock_cost',
33
+ 'xor',
34
+ ]);
35
+ const eventDefinitionKeys = new Set([
36
+ 'country_event',
37
+ 'news_event',
38
+ 'state_event',
39
+ 'unit_leader_event',
40
+ 'operative_leader_event',
41
+ ]);
42
+ const decisionStructuralKeys = new Set([
43
+ 'allowed',
44
+ 'available',
45
+ 'icon',
46
+ 'picture',
47
+ 'priority',
48
+ 'scripted_gui',
49
+ 'target_root_trigger',
50
+ 'visible',
51
+ 'visible_when_empty',
52
+ ]);
53
+ const staticIdentifier = /^[A-Za-z0-9_.:-]+$/u;
54
+ function normalizedPath(file) {
55
+ return file.relativePath.replaceAll('\\', '/').toLowerCase();
56
+ }
57
+ function provenance(file, document, node, symbol) {
58
+ return {
59
+ path: file.displayPath,
60
+ rootKind: file.rootKind,
61
+ loadOrder: file.loadOrder,
62
+ location: nodeLocation(document, node, symbol),
63
+ sourceHash: file.sha256,
64
+ };
65
+ }
66
+ function sourceText(document, node) {
67
+ return document.text.slice(node.start, node.end);
68
+ }
69
+ function scalarEntries(block) {
70
+ return block.entries.filter((entry) => entry.type === 'scalar');
71
+ }
72
+ function scalarValues(block) {
73
+ return scalarEntries(block).map(({ value }) => value);
74
+ }
75
+ function assignmentScalars(block, key) {
76
+ return assignments(block, key).flatMap(({ value }) => (value.type === 'scalar' ? [value] : []));
77
+ }
78
+ function firstValue(block, key) {
79
+ return firstScalar(block, key)?.value;
80
+ }
81
+ function booleanValue(value) {
82
+ if (value?.toLowerCase() === 'yes')
83
+ return true;
84
+ if (value?.toLowerCase() === 'no')
85
+ return false;
86
+ return undefined;
87
+ }
88
+ function numericLiteral(value) {
89
+ if (value === undefined || !/^-?(?:\d+\.?\d*|\.\d+)$/u.test(value))
90
+ return undefined;
91
+ const parsed = Number(value);
92
+ return Number.isFinite(parsed) ? parsed : undefined;
93
+ }
94
+ function constantsIn(block) {
95
+ const result = new Map();
96
+ for (const assignment of assignments(block)) {
97
+ if (!assignment.key.value.startsWith('@') || assignment.value.type !== 'scalar')
98
+ continue;
99
+ result.set(assignment.key.value, assignment.value.value);
100
+ }
101
+ return result;
102
+ }
103
+ function resolvedNumber(value, constants) {
104
+ let current = value;
105
+ const seen = new Set();
106
+ for (let depth = 0; depth < 32; depth += 1) {
107
+ const literal = numericLiteral(current);
108
+ if (literal !== undefined)
109
+ return literal;
110
+ if (current === undefined || !current.startsWith('@') || seen.has(current))
111
+ return undefined;
112
+ seen.add(current);
113
+ current = constants.get(current);
114
+ }
115
+ return undefined;
116
+ }
117
+ function aiMetadata(block) {
118
+ const ai = childBlocks(block, 'ai_will_do')[0];
119
+ const weights = childBlocks(block, 'ai_research_weights')[0];
120
+ const researchWeights = Object.fromEntries((weights === undefined ? [] : assignments(weights))
121
+ .flatMap(({ key, value }) => value.type === 'scalar' ? [[key.value, value.value]] : [])
122
+ .sort(([left], [right]) => compareCodeUnits(left, right)));
123
+ if (ai === undefined)
124
+ return { present: false, zero: 'unknown', researchWeights };
125
+ const base = firstValue(ai, 'base');
126
+ const factor = firstValue(ai, 'factor');
127
+ const zero = numericLiteral(base ?? factor) === 0
128
+ ? true
129
+ : base === undefined && factor === undefined
130
+ ? 'unknown'
131
+ : false;
132
+ return {
133
+ present: true,
134
+ ...(base === undefined ? {} : { base }),
135
+ ...(factor === undefined ? {} : { factor }),
136
+ zero,
137
+ researchWeights,
138
+ expression: sourceTextForBlock(ai),
139
+ };
140
+ }
141
+ function sourceTextForBlock(block) {
142
+ // The exact source text is attached later when an unsupported expression needs it. AI metadata
143
+ // keeps a stable structural summary to avoid retaining a second copy of every source file.
144
+ return assignments(block)
145
+ .map(({ key, value }) => `${key.value}=${value.type === 'scalar' ? value.value : '{...}'}`)
146
+ .join(' ');
147
+ }
148
+ function hiddenStatus(block) {
149
+ const allow = childBlocks(block, 'allow')[0];
150
+ if (allow === undefined)
151
+ return false;
152
+ const always = booleanValue(firstValue(allow, 'always'));
153
+ if (always === false)
154
+ return true;
155
+ if (always === true && assignments(allow).length === 1)
156
+ return false;
157
+ return 'unknown';
158
+ }
159
+ function isDynamic(value) {
160
+ return !staticIdentifier.test(value) || /\[|\]|\$|\?|^var:|^event_target:/u.test(value);
161
+ }
162
+ function unresolved(kind, expression, ownerId, location, code, message) {
163
+ return {
164
+ id: deterministicId('tech-unresolved', { kind, expression, ownerId, location }),
165
+ kind,
166
+ expression,
167
+ ...(ownerId === undefined ? {} : { ownerId }),
168
+ ...(location === undefined ? {} : { location }),
169
+ confidence: 'unresolved',
170
+ blockers: [{ code, message, ...(location === undefined ? {} : { location }) }],
171
+ };
172
+ }
173
+ function addDynamicOrStatic(values, ownerId, document, staticAction, output, kind) {
174
+ for (const value of values) {
175
+ if (!isDynamic(value.value)) {
176
+ staticAction(value);
177
+ continue;
178
+ }
179
+ const location = nodeLocation(document, value, ownerId);
180
+ output.push(unresolved(kind, value.value, ownerId, location, 'TECH_DYNAMIC_REFERENCE_UNRESOLVED', `Dynamic technology reference ${value.value} cannot be resolved statically`));
181
+ }
182
+ }
183
+ function parseTechnologyDefinitions(file, document, fragment) {
184
+ for (const wrapper of childBlocks(document.root, 'technologies')) {
185
+ const constants = constantsIn(wrapper);
186
+ for (const definition of assignments(wrapper)) {
187
+ if (definition.key.value.startsWith('@') || definition.value.type !== 'block')
188
+ continue;
189
+ const block = definition.value;
190
+ const id = definition.key.value;
191
+ const source = provenance(file, document, definition, id);
192
+ const folders = [];
193
+ for (const folder of childBlocks(block, 'folder')) {
194
+ const folderScalar = firstScalar(folder, 'name');
195
+ if (folderScalar === undefined)
196
+ continue;
197
+ const folderId = folderScalar.value;
198
+ if (isDynamic(folderId)) {
199
+ fragment.unresolved.push(unresolved('dynamic_reference', folderId, id, nodeLocation(document, folderScalar, id), 'TECH_DYNAMIC_FOLDER_UNRESOLVED', `Dynamic folder reference ${folderId} cannot be resolved statically`));
200
+ continue;
201
+ }
202
+ folders.push(folderId);
203
+ const position = childBlocks(folder, 'position')[0];
204
+ const xScalar = position === undefined ? undefined : firstScalar(position, 'x');
205
+ const yScalar = position === undefined ? undefined : firstScalar(position, 'y');
206
+ const x = resolvedNumber(xScalar?.value, constants);
207
+ const y = resolvedNumber(yScalar?.value, constants);
208
+ fragment.placements.push({
209
+ id: deterministicId('tech-placement', {
210
+ technologyId: id,
211
+ folderId,
212
+ offset: folder.start,
213
+ path: file.displayPath,
214
+ }),
215
+ technologyId: id,
216
+ folderId,
217
+ ...(x === undefined ? {} : { x }),
218
+ ...(y === undefined ? {} : { y }),
219
+ ...(xScalar === undefined || x !== undefined ? {} : { xExpression: xScalar.value }),
220
+ ...(yScalar === undefined || y !== undefined ? {} : { yExpression: yScalar.value }),
221
+ sourceAccurate: true,
222
+ location: nodeLocation(document, folder, id),
223
+ });
224
+ }
225
+ for (const pathBlock of childBlocks(block, 'path')) {
226
+ const target = firstScalar(pathBlock, 'leads_to_tech');
227
+ if (target === undefined)
228
+ continue;
229
+ if (isDynamic(target.value)) {
230
+ fragment.unresolved.push(unresolved('dynamic_reference', target.value, id, nodeLocation(document, target, id), 'TECH_DYNAMIC_PREREQUISITE_UNRESOLVED', `Dynamic prerequisite target ${target.value} cannot be resolved statically`));
231
+ continue;
232
+ }
233
+ fragment.edges.push({
234
+ id: deterministicId('tech-edge', {
235
+ kind: 'prerequisite',
236
+ from: id,
237
+ to: target.value,
238
+ path: file.displayPath,
239
+ offset: target.start,
240
+ }),
241
+ kind: 'prerequisite',
242
+ from: id,
243
+ to: target.value,
244
+ ...(firstValue(pathBlock, 'research_cost_coeff') === undefined
245
+ ? {}
246
+ : { coefficient: firstValue(pathBlock, 'research_cost_coeff') }),
247
+ ...(booleanValue(firstValue(pathBlock, 'ignore_for_layout')) === undefined
248
+ ? {}
249
+ : { ignoreForLayout: booleanValue(firstValue(pathBlock, 'ignore_for_layout')) }),
250
+ location: nodeLocation(document, target, id),
251
+ confidence: 'confirmed',
252
+ });
253
+ }
254
+ for (const xor of childBlocks(block, 'xor')) {
255
+ addDynamicOrStatic(scalarEntries(xor), id, document, (target) => fragment.edges.push({
256
+ id: deterministicId('tech-edge', {
257
+ kind: 'exclusive',
258
+ from: id,
259
+ to: target.value,
260
+ path: file.displayPath,
261
+ offset: target.start,
262
+ }),
263
+ kind: 'exclusive',
264
+ from: id,
265
+ to: target.value,
266
+ location: nodeLocation(document, target, id),
267
+ confidence: 'confirmed',
268
+ }), fragment.unresolved, 'dynamic_reference');
269
+ }
270
+ const subTechnologies = [];
271
+ for (const sub of childBlocks(block, 'sub_technologies')) {
272
+ addDynamicOrStatic(scalarEntries(sub), id, document, (target) => {
273
+ subTechnologies.push(target.value);
274
+ fragment.edges.push({
275
+ id: deterministicId('tech-edge', {
276
+ kind: 'sub_technology',
277
+ from: id,
278
+ to: target.value,
279
+ path: file.displayPath,
280
+ offset: target.start,
281
+ }),
282
+ kind: 'sub_technology',
283
+ from: id,
284
+ to: target.value,
285
+ location: nodeLocation(document, target, id),
286
+ confidence: 'confirmed',
287
+ });
288
+ }, fragment.unresolved, 'dynamic_reference');
289
+ }
290
+ const unlockSignatureFields = [];
291
+ const addUnlock = (kind, target, level) => {
292
+ if (isDynamic(target.value)) {
293
+ fragment.unresolved.push(unresolved('dynamic_reference', target.value, id, nodeLocation(document, target, id), 'TECH_DYNAMIC_UNLOCK_UNRESOLVED', `Dynamic unlock target ${target.value} cannot be resolved statically`));
294
+ return;
295
+ }
296
+ unlockSignatureFields.push(`${kind}:${target.value}:${level ?? ''}`);
297
+ fragment.unlocks.push({
298
+ id: deterministicId('tech-unlock', {
299
+ technologyId: id,
300
+ kind,
301
+ targetId: target.value,
302
+ level,
303
+ path: file.displayPath,
304
+ offset: target.start,
305
+ }),
306
+ technologyId: id,
307
+ kind,
308
+ targetId: target.value,
309
+ ...(level === undefined ? {} : { level }),
310
+ location: nodeLocation(document, target, id),
311
+ confidence: 'confirmed',
312
+ resolved: 'unknown',
313
+ });
314
+ };
315
+ for (const [field, kind] of [
316
+ ['enable_equipments', 'equipment'],
317
+ ['enable_equipment_modules', 'equipment_module'],
318
+ ['enable_subunits', 'sub_unit'],
319
+ ['enable_abilities', 'ability'],
320
+ ]) {
321
+ for (const unlockBlock of childBlocks(block, field))
322
+ for (const target of scalarEntries(unlockBlock))
323
+ addUnlock(kind, target);
324
+ }
325
+ for (const building of childBlocks(block, 'enable_building')) {
326
+ const target = firstScalar(building, 'building');
327
+ if (target !== undefined)
328
+ addUnlock('building', target, firstValue(building, 'level'));
329
+ }
330
+ for (const tactic of assignmentScalars(block, 'enable_tactic'))
331
+ addUnlock('tactic', tactic);
332
+ for (const ability of assignmentScalars(block, 'enable_ability'))
333
+ addUnlock('ability', ability);
334
+ for (const assignment of assignments(block)) {
335
+ if (!assignment.key.value.startsWith('enable_') ||
336
+ technologyStructuralFields.has(assignment.key.value))
337
+ continue;
338
+ if (assignment.value.type === 'scalar')
339
+ addUnlock('other', assignment.value);
340
+ else
341
+ for (const target of scalarEntries(assignment.value))
342
+ addUnlock('other', target);
343
+ }
344
+ const categories = childBlocks(block, 'categories').flatMap(scalarValues);
345
+ const tags = childBlocks(block, 'tags').flatMap(scalarValues);
346
+ const effectKeys = [
347
+ ...new Set(assignments(block)
348
+ .map(({ key }) => key.value)
349
+ .filter((key) => !technologyStructuralFields.has(key) && !key.startsWith('@'))),
350
+ ].sort(compareCodeUnits);
351
+ const unsupportedFields = fragment.placements
352
+ .filter(({ technologyId, xExpression, yExpression }) => technologyId === id && (xExpression !== undefined || yExpression !== undefined))
353
+ .map((placement) => ({
354
+ field: 'folder.position',
355
+ expression: `${placement.xExpression ?? placement.x ?? '?'}:${placement.yExpression ?? placement.y ?? '?'}`,
356
+ location: placement.location,
357
+ reason: 'The source coordinate expression is not a resolvable file-local numeric constant',
358
+ }));
359
+ const doctrine = booleanValue(firstValue(block, 'doctrine')) === true;
360
+ fragment.technologies.push({
361
+ id,
362
+ kind: doctrine ? 'legacy_doctrine' : 'technology',
363
+ source,
364
+ rawSource: document.text.slice(definition.start, definition.end),
365
+ ...(firstValue(block, 'start_year') === undefined
366
+ ? {}
367
+ : { startYear: firstValue(block, 'start_year') }),
368
+ ...(firstValue(block, 'research_cost') === undefined
369
+ ? {}
370
+ : { researchCost: firstValue(block, 'research_cost') }),
371
+ ...(firstValue(block, 'doctrine_name') === undefined
372
+ ? {}
373
+ : { doctrineName: firstValue(block, 'doctrine_name') }),
374
+ hidden: hiddenStatus(block),
375
+ folders: [...new Set(folders)].sort(compareCodeUnits),
376
+ categories: [...new Set(categories)].sort(compareCodeUnits),
377
+ tags: [...new Set(tags)].sort(compareCodeUnits),
378
+ subTechnologies: [...new Set(subTechnologies)].sort(compareCodeUnits),
379
+ ai: aiMetadata(block),
380
+ iconSprite: `GFX_${id}_medium`,
381
+ effectKeys,
382
+ effectSignatureFields: [...effectKeys, ...unlockSignatureFields].sort(compareCodeUnits),
383
+ unsupportedFields,
384
+ });
385
+ }
386
+ }
387
+ }
388
+ function parseTechnologyTags(file, document, fragment) {
389
+ for (const block of childBlocks(document.root, 'technology_categories')) {
390
+ for (const scalar of scalarEntries(block)) {
391
+ fragment.categories.push({
392
+ id: scalar.value,
393
+ kind: 'category',
394
+ source: provenance(file, document, scalar, scalar.value),
395
+ });
396
+ }
397
+ }
398
+ for (const block of childBlocks(document.root, 'technology_tags')) {
399
+ for (const scalar of scalarEntries(block)) {
400
+ fragment.categories.push({
401
+ id: scalar.value,
402
+ kind: 'tag',
403
+ source: provenance(file, document, scalar, scalar.value),
404
+ });
405
+ }
406
+ }
407
+ for (const wrapper of childBlocks(document.root, 'technology_folders')) {
408
+ for (const definition of assignments(wrapper)) {
409
+ if (definition.value.type !== 'block')
410
+ continue;
411
+ const id = definition.key.value;
412
+ const block = definition.value;
413
+ fragment.folders.push({
414
+ id,
415
+ doctrine: booleanValue(firstValue(block, 'doctrine')) === true,
416
+ ...(firstValue(block, 'ledger') === undefined
417
+ ? {}
418
+ : { ledger: firstValue(block, 'ledger') }),
419
+ ...(childBlocks(block, 'available')[0] === undefined
420
+ ? {}
421
+ : { availableExpression: sourceText(document, childBlocks(block, 'available')[0]) }),
422
+ source: provenance(file, document, definition, id),
423
+ localisation: { language: 'l_english', status: 'missing' },
424
+ });
425
+ }
426
+ }
427
+ }
428
+ function parseUnlockTargets(file, document, fragment) {
429
+ const sourcePath = normalizedPath(file);
430
+ const wrappers = [];
431
+ if (sourcePath.startsWith('common/units/equipment/modules/'))
432
+ wrappers.push({ key: 'equipment_modules', kind: 'equipment_module' });
433
+ else if (sourcePath.startsWith('common/units/equipment/'))
434
+ wrappers.push({ key: 'equipments', kind: 'equipment' });
435
+ if (sourcePath.startsWith('common/units/') && !sourcePath.startsWith('common/units/equipment/'))
436
+ wrappers.push({ key: 'sub_units', kind: 'sub_unit' });
437
+ if (sourcePath.startsWith('common/buildings/'))
438
+ wrappers.push({ key: 'buildings', kind: 'building' });
439
+ if (sourcePath.startsWith('common/abilities/'))
440
+ wrappers.push({ key: 'ability', kind: 'ability' });
441
+ if (sourcePath === 'common/combat_tactics.txt' || sourcePath.startsWith('common/combat_tactics/'))
442
+ wrappers.push({ key: 'combat_tactics', kind: 'tactic' });
443
+ for (const { key, kind } of wrappers) {
444
+ for (const wrapper of childBlocks(document.root, key)) {
445
+ for (const definition of assignments(wrapper)) {
446
+ if (definition.value.type !== 'block' || definition.key.value === 'limit')
447
+ continue;
448
+ const id = definition.key.value;
449
+ fragment.unlockTargets.push({
450
+ id: `${kind}:${id}`,
451
+ kind,
452
+ targetId: id,
453
+ source: provenance(file, document, definition, id),
454
+ });
455
+ }
456
+ }
457
+ }
458
+ }
459
+ function doctrineKind(sourcePath) {
460
+ if (sourcePath.startsWith('common/doctrines/folders/'))
461
+ return 'folder';
462
+ if (sourcePath.startsWith('common/doctrines/grand_doctrines/'))
463
+ return 'grand_doctrine';
464
+ if (sourcePath.startsWith('common/doctrines/tracks/'))
465
+ return 'track';
466
+ if (sourcePath.startsWith('common/doctrines/subdoctrines/'))
467
+ return 'subdoctrine';
468
+ return undefined;
469
+ }
470
+ function parseModernDoctrines(file, document, fragment) {
471
+ const kind = doctrineKind(normalizedPath(file));
472
+ if (kind === undefined)
473
+ return;
474
+ for (const definition of assignments(document.root)) {
475
+ if (definition.value.type !== 'block' || definition.key.value.startsWith('@'))
476
+ continue;
477
+ const block = definition.value;
478
+ const id = definition.key.value;
479
+ const trackIds = [
480
+ ...assignmentScalars(block, 'track').map(({ value }) => value),
481
+ ...childBlocks(block, 'track').flatMap(scalarValues),
482
+ ...childBlocks(block, 'tracks').flatMap(scalarValues),
483
+ ];
484
+ const exclusiveIds = childBlocks(block, 'xor').flatMap(scalarValues);
485
+ const base = {
486
+ id,
487
+ kind,
488
+ ...(firstValue(block, 'folder') === undefined
489
+ ? {}
490
+ : { folderId: firstValue(block, 'folder') }),
491
+ trackIds: [...new Set(trackIds)].sort(compareCodeUnits),
492
+ exclusiveIds: [...new Set(exclusiveIds)].sort(compareCodeUnits),
493
+ ...(firstValue(block, 'name') === undefined ? {} : { nameKey: firstValue(block, 'name') }),
494
+ ...(firstValue(block, 'description') === undefined
495
+ ? {}
496
+ : { descriptionKey: firstValue(block, 'description') }),
497
+ ...(firstValue(block, 'xp_cost') === undefined
498
+ ? {}
499
+ : { xpCost: firstValue(block, 'xp_cost') }),
500
+ ...(firstValue(block, 'xp_type') === undefined
501
+ ? {}
502
+ : { xpType: firstValue(block, 'xp_type') }),
503
+ ai: aiMetadata(block),
504
+ effectKeys: assignments(block)
505
+ .map(({ key }) => key.value)
506
+ .filter((field) => !new Set([
507
+ 'active',
508
+ 'ai_will_do',
509
+ 'allow_in_multiple_tracks',
510
+ 'available',
511
+ 'description',
512
+ 'folder',
513
+ 'icon',
514
+ 'mastery',
515
+ 'max_track_columns',
516
+ 'max_track_rows',
517
+ 'milestones',
518
+ 'name',
519
+ 'rewards',
520
+ 'track',
521
+ 'tracks',
522
+ 'visible',
523
+ 'xp_cost',
524
+ 'xp_type',
525
+ 'xor',
526
+ ]).has(field))
527
+ .sort(compareCodeUnits),
528
+ source: provenance(file, document, definition, id),
529
+ ...(firstValue(block, 'icon') === undefined
530
+ ? {}
531
+ : { iconSprite: firstValue(block, 'icon') }),
532
+ };
533
+ fragment.doctrineDefinitions.push(base);
534
+ if (kind !== 'subdoctrine')
535
+ continue;
536
+ for (const rewards of childBlocks(block, 'rewards')) {
537
+ let index = 0;
538
+ for (const entry of rewards.entries) {
539
+ if (entry.type === 'scalar')
540
+ continue;
541
+ const rewardId = entry.type === 'assignment' ? entry.key.value : `${id}_reward_${String(index + 1)}`;
542
+ const rewardBlock = entry.type === 'assignment'
543
+ ? entry.value.type === 'block'
544
+ ? entry.value
545
+ : undefined
546
+ : entry;
547
+ index += 1;
548
+ fragment.doctrineDefinitions.push({
549
+ id: rewardId,
550
+ kind: 'reward',
551
+ trackIds: [...base.trackIds],
552
+ parentId: id,
553
+ exclusiveIds: [],
554
+ ai: { present: false, zero: 'unknown', researchWeights: {} },
555
+ effectKeys: rewardBlock === undefined
556
+ ? []
557
+ : assignments(rewardBlock)
558
+ .map(({ key }) => key.value)
559
+ .sort(compareCodeUnits),
560
+ source: provenance(file, document, entry, rewardId),
561
+ });
562
+ }
563
+ }
564
+ }
565
+ }
566
+ function parseTechnologyGridboxes(file, document, fragment, signal) {
567
+ if (!normalizedPath(file).endsWith('.gui'))
568
+ return;
569
+ const stack = [
570
+ { block: document.root, namedContainers: [] },
571
+ ];
572
+ let visited = 0;
573
+ while (stack.length > 0) {
574
+ if (visited % 128 === 0)
575
+ signal?.throwIfAborted();
576
+ visited += 1;
577
+ const current = stack.pop();
578
+ for (const assignment of assignments(current.block)) {
579
+ if (assignment.value.type !== 'block')
580
+ continue;
581
+ const block = assignment.value;
582
+ const elementType = assignment.key.value.toLowerCase();
583
+ const name = firstScalar(block, 'name');
584
+ if (elementType === 'gridboxtype' && name?.value.endsWith('_tree') === true) {
585
+ const position = childBlocks(block, 'position')[0];
586
+ const slotSize = childBlocks(block, 'slotsize')[0];
587
+ const x = numericLiteral(position === undefined ? undefined : firstValue(position, 'x'));
588
+ const y = numericLiteral(position === undefined ? undefined : firstValue(position, 'y'));
589
+ const width = numericLiteral(slotSize === undefined ? undefined : firstValue(slotSize, 'width'));
590
+ const height = numericLiteral(slotSize === undefined ? undefined : firstValue(slotSize, 'height'));
591
+ const gridbox = {
592
+ id: deterministicId('tech-gridbox', {
593
+ name: name.value,
594
+ path: file.displayPath,
595
+ offset: assignment.start,
596
+ }),
597
+ name: name.value,
598
+ ...(current.namedContainers.at(-1) === undefined
599
+ ? {}
600
+ : { folderId: current.namedContainers.at(-1) }),
601
+ position: {
602
+ ...(x === undefined ? {} : { x }),
603
+ ...(y === undefined ? {} : { y }),
604
+ ...(position === undefined || x !== undefined
605
+ ? {}
606
+ : { xExpression: firstValue(position, 'x') ?? '<missing>' }),
607
+ ...(position === undefined || y !== undefined
608
+ ? {}
609
+ : { yExpression: firstValue(position, 'y') ?? '<missing>' }),
610
+ },
611
+ slotSize: {
612
+ ...(width === undefined ? {} : { width }),
613
+ ...(height === undefined ? {} : { height }),
614
+ ...(slotSize === undefined || width !== undefined
615
+ ? {}
616
+ : { widthExpression: firstValue(slotSize, 'width') ?? '<missing>' }),
617
+ ...(slotSize === undefined || height !== undefined
618
+ ? {}
619
+ : { heightExpression: firstValue(slotSize, 'height') ?? '<missing>' }),
620
+ },
621
+ ...(firstValue(block, 'format') === undefined
622
+ ? {}
623
+ : { format: firstValue(block, 'format') }),
624
+ location: nodeLocation(document, assignment, name.value),
625
+ sourcePath: file.displayPath,
626
+ loadOrder: file.loadOrder,
627
+ };
628
+ fragment.gridboxes.push(gridbox);
629
+ }
630
+ const namedContainers = name !== undefined &&
631
+ (elementType === 'containerwindowtype' || elementType === 'windowtype')
632
+ ? [...current.namedContainers, name.value]
633
+ : current.namedContainers;
634
+ stack.push({ block, namedContainers });
635
+ }
636
+ }
637
+ }
638
+ function initialOwner(file) {
639
+ const sourcePath = normalizedPath(file);
640
+ if (sourcePath.startsWith('history/countries/')) {
641
+ const filename = path.posix.basename(file.relativePath.replaceAll('\\', '/'));
642
+ return { kind: 'country_history', id: filename.slice(0, 3).toUpperCase() };
643
+ }
644
+ return undefined;
645
+ }
646
+ function nextOwner(file, assignment, depth, ancestors, current) {
647
+ if (assignment.value.type !== 'block')
648
+ return current;
649
+ const sourcePath = normalizedPath(file);
650
+ const block = assignment.value;
651
+ const key = assignment.key.value;
652
+ if (key === 'focus') {
653
+ const id = firstValue(block, 'id');
654
+ if (id !== undefined)
655
+ return { kind: 'focus', id };
656
+ }
657
+ if (eventDefinitionKeys.has(key)) {
658
+ const id = firstValue(block, 'id');
659
+ if (id !== undefined)
660
+ return { kind: 'event', id };
661
+ }
662
+ if (sourcePath.startsWith('common/scripted_effects/') && depth === 0)
663
+ return { kind: 'scripted_effect', id: key };
664
+ if (sourcePath.startsWith('common/on_actions/') && ancestors.at(-1) === 'on_actions')
665
+ return { kind: 'on_action', id: key };
666
+ if (sourcePath.startsWith('common/decisions/') &&
667
+ depth === 1 &&
668
+ !decisionStructuralKeys.has(key)) {
669
+ const mission = firstScalar(block, 'days_mission_timeout') !== undefined ||
670
+ firstScalar(block, 'days_remove') !== undefined ||
671
+ /mission/iu.test(key);
672
+ return { kind: mission ? 'mission' : 'decision', id: key };
673
+ }
674
+ if (sourcePath.startsWith('common/ideas/') && ancestors[0] === 'ideas' && ancestors.length >= 1)
675
+ return { kind: 'idea', id: key };
676
+ if (sourcePath.startsWith('common/characters/') && ancestors.length >= 1)
677
+ return { kind: 'character', id: key };
678
+ if (sourcePath.startsWith('history/countries/') && key === '1936.1.1')
679
+ return current === undefined ? undefined : { kind: 'startup_effect', id: current.id };
680
+ return current;
681
+ }
682
+ function referenceOwner(file, owner) {
683
+ return (owner ?? {
684
+ kind: 'other',
685
+ id: file.relativePath.replaceAll('\\', '/'),
686
+ });
687
+ }
688
+ function addExternalReference(output, input) {
689
+ output.push({
690
+ id: deterministicId('tech-external', {
691
+ kind: input.kind,
692
+ sourceKind: input.sourceKind,
693
+ sourceId: input.sourceId,
694
+ technologyId: input.technologyId,
695
+ categoryId: input.categoryId,
696
+ location: input.location,
697
+ expression: input.expression,
698
+ }),
699
+ ...input,
700
+ });
701
+ }
702
+ function parseExternalReferences(file, document, fragment, context) {
703
+ const stack = [
704
+ {
705
+ block: document.root,
706
+ depth: 0,
707
+ ancestors: [],
708
+ ...(initialOwner(file) === undefined ? {} : { owner: initialOwner(file) }),
709
+ },
710
+ ];
711
+ let visited = 0;
712
+ while (stack.length > 0) {
713
+ if (visited % 256 === 0)
714
+ context.signal?.throwIfAborted();
715
+ visited += 1;
716
+ const current = stack.pop();
717
+ for (const assignment of [...assignments(current.block)].reverse()) {
718
+ const key = assignment.key.value;
719
+ const currentOwner = referenceOwner(file, current.owner);
720
+ if (assignment.value.type === 'scalar') {
721
+ if (['has_tech', 'has_technology', 'is_researching_technology'].includes(key)) {
722
+ const target = assignment.value;
723
+ addExternalReference(fragment.externalReferences, {
724
+ kind: 'reference',
725
+ sourceKind: currentOwner.kind,
726
+ sourceId: currentOwner.id,
727
+ technologyId: target.value,
728
+ expression: sourceText(document, assignment),
729
+ location: nodeLocation(document, target, currentOwner.id),
730
+ helperStack: currentOwner.kind === 'scripted_effect' ? [currentOwner.id] : [],
731
+ confidence: isDynamic(target.value) ? 'unresolved' : 'confirmed',
732
+ dynamic: isDynamic(target.value),
733
+ metadata: { trigger: key },
734
+ });
735
+ }
736
+ if (current.depth > 0 &&
737
+ context.helperIds.has(key) &&
738
+ !(currentOwner.kind === 'scripted_effect' && currentOwner.id === key)) {
739
+ fragment.helperCalls.push({
740
+ id: deterministicId('tech-helper-call', {
741
+ sourceKind: currentOwner.kind,
742
+ sourceId: currentOwner.id,
743
+ helperId: key,
744
+ path: file.displayPath,
745
+ offset: assignment.start,
746
+ }),
747
+ sourceKind: currentOwner.kind,
748
+ sourceId: currentOwner.id,
749
+ helperId: key,
750
+ location: nodeLocation(document, assignment, currentOwner.id),
751
+ confidence: 'high',
752
+ });
753
+ }
754
+ continue;
755
+ }
756
+ const owner = nextOwner(file, assignment, current.depth, current.ancestors, current.owner);
757
+ const selectedOwner = referenceOwner(file, owner);
758
+ const block = assignment.value;
759
+ if (key === 'set_technology') {
760
+ for (const target of assignments(block)) {
761
+ if (target.key.value === 'popup')
762
+ continue;
763
+ const targetId = target.key.value;
764
+ const expression = sourceText(document, target);
765
+ const location = nodeLocation(document, target, selectedOwner.id);
766
+ if (isDynamic(targetId)) {
767
+ fragment.unresolved.push(unresolved('dynamic_reference', expression, selectedOwner.id, location, 'TECH_DYNAMIC_GRANT_UNRESOLVED', 'Dynamic set_technology target cannot be resolved statically'));
768
+ continue;
769
+ }
770
+ const value = target.value.type === 'scalar' ? target.value.value : expression;
771
+ const removal = value === '0' || value.toLowerCase() === 'no';
772
+ addExternalReference(fragment.externalReferences, {
773
+ kind: selectedOwner.kind === 'country_history' || selectedOwner.kind === 'startup_effect'
774
+ ? 'starting_technology'
775
+ : removal
776
+ ? 'remove'
777
+ : 'grant',
778
+ sourceKind: selectedOwner.kind,
779
+ sourceId: selectedOwner.id,
780
+ technologyId: targetId,
781
+ expression,
782
+ location,
783
+ helperStack: selectedOwner.kind === 'scripted_effect' ? [selectedOwner.id] : [],
784
+ confidence: 'confirmed',
785
+ dynamic: false,
786
+ metadata: { value },
787
+ });
788
+ }
789
+ }
790
+ if (key === 'add_tech_bonus' || key === 'add_doctrine_cost_reduction') {
791
+ for (const target of [
792
+ ...assignmentScalars(block, 'technology'),
793
+ ...assignmentScalars(block, 'category'),
794
+ ]) {
795
+ const targetKind = assignments(block, 'technology').some(({ value }) => value === target)
796
+ ? 'technology'
797
+ : 'category';
798
+ const expression = sourceText(document, target);
799
+ const location = nodeLocation(document, target, selectedOwner.id);
800
+ if (isDynamic(target.value)) {
801
+ fragment.unresolved.push(unresolved('dynamic_reference', expression, selectedOwner.id, location, 'TECH_DYNAMIC_BONUS_UNRESOLVED', 'Dynamic research-bonus target cannot be resolved statically'));
802
+ continue;
803
+ }
804
+ addExternalReference(fragment.externalReferences, {
805
+ kind: 'research_bonus',
806
+ sourceKind: selectedOwner.kind,
807
+ sourceId: selectedOwner.id,
808
+ ...(targetKind === 'technology'
809
+ ? { technologyId: target.value }
810
+ : { categoryId: target.value }),
811
+ expression,
812
+ location,
813
+ helperStack: selectedOwner.kind === 'scripted_effect' ? [selectedOwner.id] : [],
814
+ confidence: 'confirmed',
815
+ dynamic: false,
816
+ metadata: {
817
+ effect: key,
818
+ bonus: firstValue(block, 'bonus') ?? firstValue(block, 'cost_reduction') ?? null,
819
+ uses: firstValue(block, 'uses') ?? null,
820
+ aheadReduction: firstValue(block, 'ahead_reduction') ?? null,
821
+ },
822
+ });
823
+ }
824
+ }
825
+ if (key === 'research_bonus') {
826
+ for (const target of assignments(block)) {
827
+ if (target.value.type !== 'scalar')
828
+ continue;
829
+ const location = nodeLocation(document, target, selectedOwner.id);
830
+ addExternalReference(fragment.externalReferences, {
831
+ kind: 'research_bonus',
832
+ sourceKind: selectedOwner.kind,
833
+ sourceId: selectedOwner.id,
834
+ categoryId: target.key.value,
835
+ expression: sourceText(document, target),
836
+ location,
837
+ helperStack: selectedOwner.kind === 'scripted_effect' ? [selectedOwner.id] : [],
838
+ confidence: isDynamic(target.key.value) ? 'unresolved' : 'confirmed',
839
+ dynamic: isDynamic(target.key.value),
840
+ metadata: { value: target.value.value, effect: 'research_bonus' },
841
+ });
842
+ }
843
+ }
844
+ if (key === 'technology_sharing_group') {
845
+ const sharingId = firstValue(block, 'id') ?? `${file.displayPath}:${assignment.start}`;
846
+ for (const category of childBlocks(block, 'categories').flatMap(scalarEntries)) {
847
+ addExternalReference(fragment.externalReferences, {
848
+ kind: 'technology_sharing',
849
+ sourceKind: 'technology_sharing',
850
+ sourceId: sharingId,
851
+ categoryId: category.value,
852
+ expression: sourceText(document, category),
853
+ location: nodeLocation(document, category, sharingId),
854
+ helperStack: [],
855
+ confidence: isDynamic(category.value) ? 'unresolved' : 'confirmed',
856
+ dynamic: isDynamic(category.value),
857
+ metadata: {},
858
+ });
859
+ }
860
+ }
861
+ if (current.depth > 0 &&
862
+ context.helperIds.has(key) &&
863
+ !(selectedOwner.kind === 'scripted_effect' && selectedOwner.id === key)) {
864
+ const call = {
865
+ id: deterministicId('tech-helper-call', {
866
+ sourceKind: selectedOwner.kind,
867
+ sourceId: selectedOwner.id,
868
+ helperId: key,
869
+ path: file.displayPath,
870
+ offset: assignment.start,
871
+ }),
872
+ sourceKind: selectedOwner.kind,
873
+ sourceId: selectedOwner.id,
874
+ helperId: key,
875
+ location: nodeLocation(document, assignment, selectedOwner.id),
876
+ confidence: 'high',
877
+ };
878
+ fragment.helperCalls.push(call);
879
+ }
880
+ stack.push({
881
+ block,
882
+ depth: current.depth + 1,
883
+ ancestors: [...current.ancestors, key],
884
+ ...(owner === undefined ? {} : { owner }),
885
+ });
886
+ }
887
+ }
888
+ }
889
+ export function analyzeTechnologySource(file, context) {
890
+ context.signal?.throwIfAborted();
891
+ const fragment = {
892
+ sourcePath: file.displayPath,
893
+ sourceHash: file.sha256,
894
+ technologies: [],
895
+ folders: [],
896
+ placements: [],
897
+ gridboxes: [],
898
+ edges: [],
899
+ categories: [],
900
+ doctrineDefinitions: [],
901
+ unlockTargets: [],
902
+ unlocks: [],
903
+ externalReferences: [],
904
+ helperCalls: [],
905
+ unresolved: [],
906
+ diagnostics: [],
907
+ };
908
+ if (!/\.(?:txt|gui|gfx)$/iu.test(file.relativePath))
909
+ return fragment;
910
+ if (!file.bytes.includes(0x3d))
911
+ return fragment;
912
+ const document = parseClausewitz(file.bytes, file.displayPath);
913
+ const limitDiagnostics = sourcePartialLimitDiagnostics(document.diagnostics);
914
+ if (limitDiagnostics.length > 0) {
915
+ fragment.diagnostics.push(...limitDiagnostics);
916
+ fragment.unresolved.push(unresolved('partial_source', file.displayPath, undefined, limitDiagnostics.find(({ location }) => location !== undefined)?.location, 'TECH_SOURCE_PARTIAL', 'Technology analysis skipped a source that exceeded a shared parser limit'));
917
+ return fragment;
918
+ }
919
+ fragment.diagnostics.push(...document.diagnostics);
920
+ const sourcePath = normalizedPath(file);
921
+ if (sourcePath.startsWith('common/technologies/'))
922
+ parseTechnologyDefinitions(file, document, fragment);
923
+ if (sourcePath.startsWith('common/technology_tags/'))
924
+ parseTechnologyTags(file, document, fragment);
925
+ parseUnlockTargets(file, document, fragment);
926
+ parseModernDoctrines(file, document, fragment);
927
+ parseTechnologyGridboxes(file, document, fragment, context.signal);
928
+ parseExternalReferences(file, document, fragment, context);
929
+ context.signal?.throwIfAborted();
930
+ return fragment;
931
+ }
932
+ export function technologySourceFragmentCacheKey(file, catalogFingerprint) {
933
+ return `${file.sha256}:${catalogFingerprint}`;
934
+ }
935
+ export function technologySourceFiles(files) {
936
+ return files
937
+ .filter(({ shadowedBy, relativePath }) => {
938
+ if (shadowedBy !== undefined)
939
+ return false;
940
+ const sourcePath = relativePath.replaceAll('\\', '/').toLowerCase();
941
+ return (sourcePath.startsWith('common/technologies/') ||
942
+ sourcePath.startsWith('common/technology_tags/') ||
943
+ sourcePath.startsWith('common/technology_sharing/') ||
944
+ sourcePath.startsWith('common/doctrines/') ||
945
+ sourcePath.startsWith('common/units/') ||
946
+ sourcePath.startsWith('common/buildings/') ||
947
+ sourcePath.startsWith('common/abilities/') ||
948
+ sourcePath.startsWith('common/national_focus/') ||
949
+ sourcePath.startsWith('common/decisions/') ||
950
+ sourcePath.startsWith('common/scripted_effects/') ||
951
+ sourcePath.startsWith('common/on_actions/') ||
952
+ sourcePath.startsWith('common/ideas/') ||
953
+ sourcePath.startsWith('common/characters/') ||
954
+ sourcePath.startsWith('events/') ||
955
+ sourcePath.startsWith('history/countries/') ||
956
+ (sourcePath.startsWith('interface/') && sourcePath.endsWith('.gui')) ||
957
+ sourcePath === 'common/combat_tactics.txt');
958
+ })
959
+ .sort((left, right) => compareCodeUnits(left.displayPath, right.displayPath));
960
+ }
961
+ export function technologyAnalysisDiagnostics(fragments, includeGameDiagnostics = false) {
962
+ return fragments
963
+ .flatMap(({ diagnostics }) => diagnostics)
964
+ .filter((diagnostic) => includeGameDiagnostics ||
965
+ diagnostic.location?.path.startsWith('mod:') === true ||
966
+ diagnostic.location?.path.startsWith('fixture:') === true);
967
+ }