hoi4-agent-tools 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/hoi4_agent_tools/core/canonical.js +42 -0
  3. package/dist/hoi4_agent_tools/core/condition-evaluator.js +12 -8
  4. package/dist/hoi4_agent_tools/core/dependency-walk.js +21 -2
  5. package/dist/hoi4_agent_tools/core/domain-validation.js +6 -1
  6. package/dist/hoi4_agent_tools/core/engine.js +3 -0
  7. package/dist/hoi4_agent_tools/core/game-documentation.js +24 -0
  8. package/dist/hoi4_agent_tools/core/impact-graph.js +6 -2
  9. package/dist/hoi4_agent_tools/core/impact-semantic.js +12 -2
  10. package/dist/hoi4_agent_tools/core/index.js +1 -0
  11. package/dist/hoi4_agent_tools/core/job-worker-host.js +12 -2
  12. package/dist/hoi4_agent_tools/event/algorithms.js +3 -2
  13. package/dist/hoi4_agent_tools/event/graph.js +60 -36
  14. package/dist/hoi4_agent_tools/event/limits.js +4 -0
  15. package/dist/hoi4_agent_tools/event/operations.js +11 -0
  16. package/dist/hoi4_agent_tools/event/queries.js +13 -34
  17. package/dist/hoi4_agent_tools/event/render.js +4 -2
  18. package/dist/hoi4_agent_tools/event/service.js +101 -33
  19. package/dist/hoi4_agent_tools/event/source-analysis.js +101 -7
  20. package/dist/hoi4_agent_tools/event/state-keys.js +63 -0
  21. package/dist/hoi4_agent_tools/event/summary.js +130 -0
  22. package/dist/hoi4_agent_tools/focus/importer.js +8 -6
  23. package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
  24. package/dist/hoi4_agent_tools/focus/tool-support.js +10 -1
  25. package/dist/hoi4_agent_tools/impact/service.js +2 -1
  26. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +1 -0
  27. package/dist/hoi4_agent_tools/mcp/tools/event.js +66 -0
  28. package/dist/hoi4_agent_tools/probability/service.js +18 -18
  29. package/dist/hoi4_agent_tools/reference/source-lookup.js +4 -2
  30. package/dist/hoi4_agent_tools/schemas/probability.js +7 -1
  31. package/dist/hoi4_agent_tools/version.js +1 -1
  32. package/docs/analysis.md +4 -0
  33. package/docs/events.md +12 -0
  34. package/docs/probability.md +1 -1
  35. package/npm-shrinkwrap.json +2 -2
  36. package/package.json +1 -1
  37. package/schemas/configuration.schema.json +1 -1
  38. package/schemas/continuous-focus-palette.schema.json +1 -1
  39. package/schemas/custom-weighted-pool.schema.json +1 -1
  40. package/schemas/decision-inspect-input.schema.json +1 -1
  41. package/schemas/error-log-input.schema.json +1 -1
  42. package/schemas/event-feature-manifest.schema.json +1 -1
  43. package/schemas/focus-plan.schema.json +1 -1
  44. package/schemas/focus-planning-sidecar.schema.json +1 -1
  45. package/schemas/gui-animation-source.schema.json +1 -1
  46. package/schemas/gui-helper.schema.json +1 -1
  47. package/schemas/gui-scenario.schema.json +1 -1
  48. package/schemas/helper-expansion-request.schema.json +1 -1
  49. package/schemas/helper-expansion-summary.schema.json +1 -1
  50. package/schemas/impact-inspect-input.schema.json +1 -1
  51. package/schemas/map-operation.schema.json +1 -1
  52. package/schemas/mechanic-test-input.schema.json +1 -1
  53. package/schemas/mod-index-input.schema.json +1 -1
  54. package/schemas/operation-result.schema.json +1 -1
  55. package/schemas/package-check-input.schema.json +1 -1
  56. package/schemas/package-manifest.schema.json +1 -1
  57. package/schemas/probability-analysis-result.schema.json +7 -1
  58. package/schemas/probability-compare-input.schema.json +1 -1
  59. package/schemas/probability-evaluate-input.schema.json +1 -1
  60. package/schemas/probability-inspect-input.schema.json +1 -1
  61. package/schemas/probability-render-input.schema.json +1 -1
  62. package/schemas/probability-scenario-set.schema.json +1 -1
  63. package/schemas/probability-sequence-input.schema.json +1 -1
  64. package/schemas/probability-simulate-input.schema.json +1 -1
  65. package/schemas/probability-sweep-input.schema.json +1 -1
  66. package/schemas/reference-context-input.schema.json +1 -1
  67. package/schemas/reference-read-input.schema.json +1 -1
  68. package/schemas/reference-search-input.schema.json +1 -1
  69. package/schemas/scenario-suite.schema.json +1 -1
  70. package/schemas/scenario-test-input.schema.json +1 -1
  71. package/schemas/script-validate-input.schema.json +1 -1
  72. package/schemas/shared-scenario.schema.json +1 -1
  73. package/schemas/source-lookup-input.schema.json +1 -1
  74. package/server.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import { compareCodeUnits } from '../core/canonical.js';
2
2
  import { eventFlowEdges, eventStronglyConnectedComponents, reachableEventNodeIds, shortestEventPath, traceEventGraph, } from './algorithms.js';
3
+ import { compatibleProducerKeys, hasUnknownSavedScopeStorage, stateKey } from './state-keys.js';
3
4
  function reachableFromRootsWithout(graph, rootIds, removedNodeIds, signal) {
4
5
  signal?.throwIfAborted();
5
6
  const retainedNodeIds = new Set(graph.nodes.filter(({ id }) => !removedNodeIds.has(id)).map(({ id }) => id));
@@ -348,32 +349,6 @@ export function explainEventPath(graph, from, to, boundary, signal) {
348
349
  boundary,
349
350
  };
350
351
  }
351
- function stateKey(access) {
352
- const scope = access.kind === 'variable' || access.kind === 'array' ? `:${access.scope}` : '';
353
- const storage = access.kind === 'saved_scope'
354
- ? `:${access.metadata.storage === 'global' || access.metadata.storage === 'local'
355
- ? access.metadata.storage
356
- : 'unknown'}`
357
- : '';
358
- return `${access.kind}:${access.name}${scope}${storage}`;
359
- }
360
- function compatibleProducerKeys(access) {
361
- if (access.kind !== 'saved_scope' ||
362
- access.metadata.storage === 'global' ||
363
- access.metadata.storage === 'local') {
364
- return [stateKey(access)];
365
- }
366
- return [
367
- `saved_scope:${access.name}:global`,
368
- `saved_scope:${access.name}:local`,
369
- `saved_scope:${access.name}:unknown`,
370
- ];
371
- }
372
- function hasUnknownSavedScopeStorage(access) {
373
- return (access.kind === 'saved_scope' &&
374
- access.metadata.storage !== 'global' &&
375
- access.metadata.storage !== 'local');
376
- }
377
352
  function compatibleProducers(producers, consumer) {
378
353
  const compatible = new Map();
379
354
  for (const key of compatibleProducerKeys(consumer)) {
@@ -544,20 +519,24 @@ export function lintEventGraph(graph, selector, signal) {
544
519
  if (selector === undefined)
545
520
  return [...graph.issues];
546
521
  const selected = selectedOwnershipIds(graph, selector, signal);
547
- const selectedNodes = graph.nodes.filter(({ id }, index) => {
522
+ // Source ranges of the selected nodes, grouped by file so each issue checks only its own file.
523
+ const selectedRanges = new Map();
524
+ for (const [index, { id, location }] of graph.nodes.entries()) {
548
525
  if ((index & 255) === 0)
549
526
  signal?.throwIfAborted();
550
- return selected.has(id);
551
- });
527
+ if (location === undefined || !selected.has(id))
528
+ continue;
529
+ const ranges = selectedRanges.get(location.path) ?? [];
530
+ ranges.push({ start: location.start.offset, end: location.end.offset });
531
+ selectedRanges.set(location.path, ranges);
532
+ }
552
533
  return graph.issues.filter((issue, index) => {
553
534
  if ((index & 255) === 0)
554
535
  signal?.throwIfAborted();
536
+ const location = issue.location;
555
537
  return (issueSubjectIds(issue).some((id) => selected.has(id)) ||
556
- (issue.location !== undefined &&
557
- selectedNodes.some(({ location }) => location !== undefined &&
558
- location.path === issue.location?.path &&
559
- issue.location.start.offset >= location.start.offset &&
560
- issue.location.end.offset <= location.end.offset)));
538
+ (location !== undefined &&
539
+ (selectedRanges.get(location.path) ?? []).some(({ start, end }) => location.start.offset >= start && location.end.offset <= end)));
561
540
  });
562
541
  }
563
542
  export function issueSubjectIds(issue) {
@@ -103,12 +103,14 @@ function boundedSelection(graph, options) {
103
103
  else if (options.view === 'terminals') {
104
104
  const terminals = withinSelector(graph.nodes.filter(({ kind }) => kind === 'terminal').map(({ id }) => id));
105
105
  add(terminals);
106
- add(graph.edges.filter(({ to }) => terminals.includes(to)).flatMap(({ from, to }) => [from, to]));
106
+ const terminalIds = new Set(terminals);
107
+ add(graph.edges.filter(({ to }) => terminalIds.has(to)).flatMap(({ from, to }) => [from, to]));
107
108
  }
108
109
  else if (options.view === 'unresolved') {
109
110
  const unresolved = withinSelector(graph.nodes.filter(({ kind }) => kind === 'unresolved').map(({ id }) => id));
110
111
  add(unresolved);
111
- add(graph.edges.filter(({ to }) => unresolved.includes(to)).flatMap(({ from, to }) => [from, to]));
112
+ const unresolvedIds = new Set(unresolved);
113
+ add(graph.edges.filter(({ to }) => unresolvedIds.has(to)).flatMap(({ from, to }) => [from, to]));
112
114
  }
113
115
  else if (options.view === 'neighborhood' || options.view === 'reachability') {
114
116
  add(selectorTrace);
@@ -2,7 +2,8 @@ import path from 'node:path';
2
2
  import { inspectHelperExpansion } from '../core/helper-expansion.js';
3
3
  import { eventHelperInventory } from './helper-expansion.js';
4
4
  import { clearSemanticDependencies } from '../core/semantic-dependencies.js';
5
- import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
5
+ import { canonicalJson, canonicalJsonByteLength, compareCodeUnits, hashCanonical, sha256Bytes, } from '../core/canonical.js';
6
+ import { installedEffectCommands } from '../core/game-documentation.js';
6
7
  import { boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
7
8
  import { ServiceError } from '../core/result.js';
8
9
  import { RenderBudget } from '../core/render-budget.js';
@@ -10,7 +11,8 @@ import { SOURCE_MAX_BYTES } from '../core/source/index.js';
10
11
  import { isPortablePathSegment } from '../core/workspace.js';
11
12
  import { PACKAGE_VERSION } from '../version.js';
12
13
  import { buildEventGraph, buildEventGraphAsync } from './graph.js';
13
- import { EVENT_FRAGMENT_CACHE_MAX_ENTRIES, EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES, EVENT_PROPOSED_SOURCE_MAX_BYTES, EVENT_PROPOSED_SOURCE_MAX_FILES, } from './limits.js';
14
+ import { summarizeEventImpact, summarizeEventIssues, summarizeEventPath, summarizeEventRoots, summarizeEventTrace, } from './summary.js';
15
+ import { EVENT_FRAGMENT_CACHE_MAX_ENTRIES, EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES, EVENT_GRAPH_SNAPSHOT_MAX_BYTES, EVENT_PROPOSED_SOURCE_MAX_BYTES, EVENT_PROPOSED_SOURCE_MAX_FILES, } from './limits.js';
14
16
  import { analyzeEventImpact, discoverEventRoots, explainEventPath, inspectEventStateFlow, lintEventGraph, traceSelectedEvents, } from './queries.js';
15
17
  import { compareEventGraphs, eventGraphHash } from './compare.js';
16
18
  import { selectEventComparison } from './comparison-selection.js';
@@ -210,9 +212,23 @@ async function scanEventSources(engine, workspaceId, workspace, focused = false,
210
212
  complete: index.complete,
211
213
  skippedSourceCount: index.skippedSourceCount,
212
214
  skippedSources: index.skippedSources,
213
- diagnostics: index.diagnostics,
215
+ diagnostics: diagnosticsWithinScannedKinds(index),
214
216
  };
215
217
  }
218
+ /**
219
+ * Event analysis indexes only event-relevant folders, so it holds no sprite, interface or
220
+ * scripted-trigger definitions. A reference to a kind with no definition in this file set says
221
+ * nothing about the workspace and is left to the full index, which scans those folders.
222
+ */
223
+ function diagnosticsWithinScannedKinds(index) {
224
+ const indexedKinds = new Set(index.symbols.map(({ kind }) => kind));
225
+ return index.diagnostics.filter(({ code, details }) => {
226
+ if (code !== 'INDEX_UNRESOLVED_REFERENCE' && code !== 'INDEX_UNRESOLVED_REFERENCE_PARTIAL')
227
+ return true;
228
+ const toKind = details?.toKind;
229
+ return typeof toKind !== 'string' || indexedKinds.has(toKind);
230
+ });
231
+ }
216
232
  function rememberGraph(state, workspaceId, graph) {
217
233
  const history = state.history.get(workspaceId) ?? new Map();
218
234
  history.delete(graph.revision);
@@ -699,6 +715,8 @@ function coherentBranchPartitions(graph, nodeLimit, maximumGroups, signal) {
699
715
  export class EventChainViewer {
700
716
  engine;
701
717
  #state;
718
+ /** Effect names the installed game documented at each workspace's last scan. */
719
+ #documentedEffects = new Map();
702
720
  constructor(engine) {
703
721
  this.engine = engine;
704
722
  this.#state = sharedState(engine);
@@ -742,8 +760,11 @@ export class EventChainViewer {
742
760
  return cached.graph;
743
761
  const buildAnalysisMode = requestedAnalysisMode;
744
762
  const projectHelpers = buildAnalysisMode === 'focused' ? false : requestedProjectHelpers;
763
+ const documentedEffects = await installedEffectCommands(workspace.gameRoot);
764
+ this.#documentedEffects.set(workspaceId, documentedEffects);
745
765
  const graph = await buildEventGraphAsync(snapshot, {
746
766
  ...(options.signal === undefined ? {} : { signal: options.signal }),
767
+ ...(documentedEffects === undefined ? {} : { documentedEffects }),
747
768
  cache: this.#state.fragments,
748
769
  workspaceIdentity: workspace.workspaceIdentity,
749
770
  projectHelpers,
@@ -808,17 +829,21 @@ export class EventChainViewer {
808
829
  refresh: input.refresh ?? input.mode === 'scan',
809
830
  };
810
831
  let report;
832
+ let summary;
811
833
  switch (input.mode) {
812
834
  case 'scan':
813
835
  report = eventScanReport(graph);
836
+ summary = summarizeEventIssues(graph.issues);
814
837
  break;
815
838
  case 'roots':
816
839
  {
817
840
  const unresolved = boundedUnresolved(graph);
841
+ const roots = discoverEventRoots(graph, input.signal);
842
+ summary = summarizeEventRoots(roots);
818
843
  report = {
819
844
  graphRevision: graph.revision,
820
845
  sourceHashes: graph.sourceHashes,
821
- roots: discoverEventRoots(graph, input.signal),
846
+ roots,
822
847
  unresolved: unresolved.values,
823
848
  ...(unresolved.inventory === undefined
824
849
  ? {}
@@ -838,6 +863,10 @@ export class EventChainViewer {
838
863
  expandHelpers: input.expandHelpers ?? false,
839
864
  }, input.signal);
840
865
  const unresolved = boundedUnresolved(graph, new Set(trace.nodes.map(({ id }) => id)));
866
+ summary = {
867
+ trace: summarizeEventTrace(trace),
868
+ ...summarizeEventIssues(lintEventGraph(graph, input.selector, input.signal)),
869
+ };
841
870
  report = {
842
871
  graphRevision: graph.revision,
843
872
  sourceHashes: graph.sourceHashes,
@@ -852,16 +881,22 @@ export class EventChainViewer {
852
881
  case 'explain_path':
853
882
  if (input.from === undefined || input.to === undefined)
854
883
  throw new ServiceError('EVENT_PATH_SELECTORS_REQUIRED', 'Path explanation requires from and to selectors');
855
- report = {
856
- graphRevision: graph.revision,
857
- sourceHashes: graph.sourceHashes,
858
- explanation: explainEventPath(graph, input.from, input.to, { maxDepth, maxNodes, expandHelpers: input.expandHelpers ?? true }, input.signal),
859
- };
884
+ {
885
+ const explanation = explainEventPath(graph, input.from, input.to, { maxDepth, maxNodes, expandHelpers: input.expandHelpers ?? true }, input.signal);
886
+ summary = summarizeEventPath(explanation);
887
+ report = {
888
+ graphRevision: graph.revision,
889
+ sourceHashes: graph.sourceHashes,
890
+ explanation,
891
+ };
892
+ }
860
893
  break;
861
894
  case 'state_flow':
862
895
  {
863
896
  const unresolved = boundedUnresolved(graph);
864
- const issues = boundedEvidence(lintEventGraph(graph, input.selector, input.signal).filter(({ code }) => /(?:STATE|FLAG|VARIABLE|ARRAY|TARGET|SCOPE)/u.test(code)));
897
+ const stateIssues = lintEventGraph(graph, input.selector, input.signal).filter(({ code }) => /(?:STATE|FLAG|VARIABLE|ARRAY|TARGET|SCOPE|WRITER)/u.test(code));
898
+ summary = summarizeEventIssues(stateIssues);
899
+ const issues = boundedEvidence(stateIssues);
865
900
  report = {
866
901
  graphRevision: graph.revision,
867
902
  sourceHashes: graph.sourceHashes,
@@ -877,7 +912,9 @@ export class EventChainViewer {
877
912
  break;
878
913
  case 'lint':
879
914
  {
880
- const issues = boundedEvidence(lintEventGraph(graph, input.selector, input.signal));
915
+ const selectedIssues = lintEventGraph(graph, input.selector, input.signal);
916
+ summary = summarizeEventIssues(selectedIssues);
917
+ const issues = boundedEvidence(selectedIssues);
881
918
  const unresolved = boundedUnresolved(graph);
882
919
  report = {
883
920
  graphRevision: graph.revision,
@@ -897,10 +934,12 @@ export class EventChainViewer {
897
934
  throw new ServiceError('EVENT_IMPACT_SUBJECT_REQUIRED', 'Impact analysis requires a subject');
898
935
  {
899
936
  const unresolved = boundedUnresolved(graph);
937
+ const impact = analyzeEventImpact(graph, input.impactSubject, input.signal);
938
+ summary = summarizeEventImpact(impact);
900
939
  report = {
901
940
  graphRevision: graph.revision,
902
941
  sourceHashes: graph.sourceHashes,
903
- impact: analyzeEventImpact(graph, input.impactSubject, input.signal),
942
+ impact,
904
943
  unresolved: unresolved.values,
905
944
  ...(unresolved.inventory === undefined
906
945
  ? {}
@@ -912,6 +951,13 @@ export class EventChainViewer {
912
951
  const graphHash = cachedEventGraphHash(graph, input.signal);
913
952
  const name = `${safeSlug(`event-${input.mode}`)}-${graph.revision.slice(0, 12)}.json`;
914
953
  const graphSnapshotName = `event-graph-${graph.revision}.json`;
954
+ // A large workspace graph cannot be held as one JSON string or artifact. Measure it first and
955
+ // retain it only when it fits; comparisons then use the in-memory revision history.
956
+ const graphSnapshot = input.mode === 'scan' ? { schemaVersion: 'event-graph-snapshot.v1', graph } : undefined;
957
+ const graphSnapshotBytes = graphSnapshot === undefined
958
+ ? undefined
959
+ : canonicalJsonByteLength(graphSnapshot, EVENT_GRAPH_SNAPSHOT_MAX_BYTES);
960
+ const snapshotOmitted = graphSnapshot !== undefined && graphSnapshotBytes === undefined;
915
961
  const projectedScan = input.mode === 'scan' &&
916
962
  typeof report === 'object' &&
917
963
  report !== null &&
@@ -949,10 +995,13 @@ export class EventChainViewer {
949
995
  : {}),
950
996
  resources: [
951
997
  { name, mimeType: 'application/json' },
952
- ...(input.mode === 'scan'
998
+ ...(input.mode === 'scan' && !snapshotOmitted
953
999
  ? [{ name: graphSnapshotName, mimeType: 'application/json' }]
954
1000
  : []),
955
1001
  ],
1002
+ ...(snapshotOmitted
1003
+ ? { graphSnapshotOmitted: { maximumBytes: EVENT_GRAPH_SNAPSHOT_MAX_BYTES } }
1004
+ : {}),
956
1005
  report,
957
1006
  })}\n`;
958
1007
  const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
@@ -960,8 +1009,8 @@ export class EventChainViewer {
960
1009
  mode: input.mode,
961
1010
  ...(input.selector === undefined ? {} : { selector: input.selector }),
962
1011
  }, input.signal), `Authoritative Event Chain Viewer ${input.mode.replaceAll('_', ' ')} report`, input.signal);
963
- const graphArtifact = input.mode === 'scan'
964
- ? await this.engine.artifacts.putChunked(workspace, graphSnapshotName, 'application/json', `${canonicalJson({ schemaVersion: 'event-graph-snapshot.v1', graph })}\n`, eventProvenance(graph, 'event-graph-snapshot', { revision: graph.revision, graphHash }, input.signal), 'Revision-addressed Event Chain Viewer graph snapshot', input.signal)
1012
+ const graphArtifact = graphSnapshot !== undefined && !snapshotOmitted
1013
+ ? await this.engine.artifacts.putChunked(workspace, graphSnapshotName, 'application/json', `${canonicalJson(graphSnapshot)}\n`, eventProvenance(graph, 'event-graph-snapshot', { revision: graph.revision, graphHash }, input.signal), 'Revision-addressed Event Chain Viewer graph snapshot', input.signal)
965
1014
  : undefined;
966
1015
  return {
967
1016
  graph,
@@ -970,6 +1019,8 @@ export class EventChainViewer {
970
1019
  report,
971
1020
  reportJson,
972
1021
  artifacts: [artifact, ...(graphArtifact === undefined ? [] : [graphArtifact])],
1022
+ ...(snapshotOmitted ? { graphSnapshotOmitted: true } : {}),
1023
+ summary,
973
1024
  };
974
1025
  }
975
1026
  async renderAndStore(input) {
@@ -1001,25 +1052,38 @@ export class EventChainViewer {
1001
1052
  graph.nodes.length > render.selectedNodeIds.length
1002
1053
  ? coherentBranchPartitions(graph, branchNodeLimit, 64, input.signal)
1003
1054
  : { groups: [], omittedNodeIds: [] };
1004
- const branchGroups = partitions.groups;
1005
- for (const [branchIndex, nodeIds] of branchGroups.entries()) {
1055
+ // Branch images share the request's raster budget with the overview. When it is spent, the
1056
+ // remaining branches are listed as omitted instead of failing the whole render.
1057
+ let budgetOmittedBranches = 0;
1058
+ for (const [branchIndex, nodeIds] of partitions.groups.entries()) {
1006
1059
  input.signal?.throwIfAborted();
1007
- branches.push(await renderEventGraph(graph, {
1008
- view: 'neighborhood',
1009
- selector: {
1010
- kind: 'manifest',
1011
- manifest: { id: `overview-branch-${branchIndex + 1}`, nodeIds },
1012
- },
1013
- direction: 'both',
1014
- maxDepth: 0,
1015
- maxNodes: branchNodeLimit,
1016
- expandHelpers: input.expandHelpers ?? false,
1017
- includeHtml: false,
1018
- compactLayout: true,
1019
- budget,
1020
- ...(input.signal === undefined ? {} : { signal: input.signal }),
1021
- }));
1060
+ let branch;
1061
+ try {
1062
+ branch = await renderEventGraph(graph, {
1063
+ view: 'neighborhood',
1064
+ selector: {
1065
+ kind: 'manifest',
1066
+ manifest: { id: `overview-branch-${branchIndex + 1}`, nodeIds },
1067
+ },
1068
+ direction: 'both',
1069
+ maxDepth: 0,
1070
+ maxNodes: branchNodeLimit,
1071
+ expandHelpers: input.expandHelpers ?? false,
1072
+ includeHtml: false,
1073
+ compactLayout: true,
1074
+ budget,
1075
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
1076
+ });
1077
+ }
1078
+ catch (error) {
1079
+ if (!(error instanceof ServiceError) || error.code !== 'RENDER_AGGREGATE_BLOCKED')
1080
+ throw error;
1081
+ budgetOmittedBranches = partitions.groups.length - branchIndex;
1082
+ break;
1083
+ }
1084
+ branches.push(branch);
1022
1085
  }
1086
+ const branchGroups = partitions.groups.slice(0, branches.length);
1023
1087
  const prefix = safeSlug(`event-${input.view}-${graph.revision.slice(0, 12)}`);
1024
1088
  const manifestName = `${prefix}-manifest.json`;
1025
1089
  const resourceNames = {
@@ -1165,7 +1229,8 @@ export class EventChainViewer {
1165
1229
  omittedNodeCount: partitions.omittedNodeIds.length,
1166
1230
  omittedNodeIds: partitions.omittedNodeIds,
1167
1231
  omittedNodeHash: hashCanonical(partitions.omittedNodeIds),
1168
- truncated: partitions.omittedNodeIds.length > 0,
1232
+ budgetOmittedBranches,
1233
+ truncated: partitions.omittedNodeIds.length > 0 || budgetOmittedBranches > 0,
1169
1234
  },
1170
1235
  })}\n`;
1171
1236
  const manifestArtifact = await this.engine.artifacts.putChunked(workspace, manifestName, 'application/json', manifestJson, provenance, `Event-chain ${input.view} render and bounded branch manifest`, input.signal);
@@ -1175,6 +1240,7 @@ export class EventChainViewer {
1175
1240
  graphHash: cachedEventGraphHash(graph, input.signal),
1176
1241
  render,
1177
1242
  branches,
1243
+ budgetOmittedBranches,
1178
1244
  manifestJson,
1179
1245
  artifacts,
1180
1246
  };
@@ -1261,8 +1327,10 @@ export class EventChainViewer {
1261
1327
  skippedSources: index.skippedSources,
1262
1328
  diagnostics: index.diagnostics,
1263
1329
  };
1330
+ const documentedEffects = this.#documentedEffects.get(workspaceId);
1264
1331
  return buildEventGraph(snapshot, {
1265
1332
  ...(signal === undefined ? {} : { signal }),
1333
+ ...(documentedEffects === undefined ? {} : { documentedEffects }),
1266
1334
  cache: this.#state.fragments,
1267
1335
  workspaceIdentity: workspace.workspaceIdentity,
1268
1336
  projectHelpers: false,
@@ -86,6 +86,21 @@ const STATE_RULES = {
86
86
  multiply_variable: { kind: 'variable', access: 'read_write' },
87
87
  divide_variable: { kind: 'variable', access: 'read_write' },
88
88
  clamp_variable: { kind: 'variable', access: 'read_write' },
89
+ round_variable: { kind: 'variable', access: 'read_write' },
90
+ modulo_variable: { kind: 'variable', access: 'read_write' },
91
+ randomize_variable: { kind: 'variable', access: 'read_write' },
92
+ set_variable_to_random: { kind: 'variable', access: 'write' },
93
+ // `check_variable` and `has_variable` read a temporary variable of the same name as well.
94
+ set_temp_variable: { kind: 'variable', access: 'write', temporary: true },
95
+ set_temp_variable_to_random: { kind: 'variable', access: 'write', temporary: true },
96
+ add_to_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
97
+ subtract_from_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
98
+ multiply_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
99
+ divide_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
100
+ clamp_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
101
+ round_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
102
+ modulo_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
103
+ randomize_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
89
104
  check_variable: { kind: 'variable', access: 'read' },
90
105
  has_variable: { kind: 'variable', access: 'read' },
91
106
  is_variable_equal: { kind: 'variable', access: 'read' },
@@ -101,6 +116,16 @@ const STATE_RULES = {
101
116
  remove_from_array: { kind: 'array', access: 'read_write', nameField: 'array' },
102
117
  is_in_array: { kind: 'array', access: 'read', nameField: 'array' },
103
118
  clear_array: { kind: 'array', access: 'clear' },
119
+ resize_array: { kind: 'array', access: 'write', nameField: 'array' },
120
+ add_to_temp_array: { kind: 'array', access: 'write', nameField: 'array', temporary: true },
121
+ remove_from_temp_array: {
122
+ kind: 'array',
123
+ access: 'read_write',
124
+ nameField: 'array',
125
+ temporary: true,
126
+ },
127
+ resize_temp_array: { kind: 'array', access: 'write', nameField: 'array', temporary: true },
128
+ clear_temp_array: { kind: 'array', access: 'clear', temporary: true },
104
129
  save_event_target_as: { kind: 'event_target', access: 'replace' },
105
130
  has_event_target: { kind: 'event_target', access: 'read' },
106
131
  save_global_event_target_as: { kind: 'global_event_target', access: 'replace' },
@@ -153,7 +178,62 @@ const NON_EXECUTING_BLOCKS = new Set([
153
178
  'mean_time_to_happen',
154
179
  'effect_tooltip',
155
180
  'custom_effect_tooltip',
181
+ // Condition blocks of decisions, missions, ideas, focuses, raids and characters.
182
+ 'visible',
183
+ 'available',
184
+ 'allowed',
185
+ 'allowed_civil_war',
186
+ 'allowed_to_remove',
187
+ 'cancel_trigger',
188
+ 'remove_trigger',
189
+ 'target_trigger',
190
+ 'target_root_trigger',
191
+ 'activation',
192
+ 'ai_will_do',
193
+ 'bypass',
194
+ 'allow_branch',
195
+ 'hidden_trigger',
196
+ 'custom_trigger_tooltip',
197
+ 'and',
198
+ 'or',
199
+ 'not',
200
+ 'nor',
201
+ 'nand',
156
202
  ]);
203
+ /** Native effects whose body holds further effects rather than named arguments. */
204
+ const NATIVE_EFFECT_CONTAINERS = new Set([
205
+ 'create_dynamic_country',
206
+ 'custom_override_tooltip',
207
+ 'effect_tooltip',
208
+ 'for_each_loop',
209
+ 'for_each_scope_loop',
210
+ 'for_loop_effect',
211
+ 'hidden_effect',
212
+ 'if',
213
+ 'random',
214
+ 'random_list',
215
+ 'random_scope_in_array',
216
+ 'while_loop_effect',
217
+ ]);
218
+ /** Inside a native effect such as `add_building_construction`, `key = yes` is an argument. */
219
+ function isNativeEffect(normalizedKey, documented) {
220
+ return nativeEffectKeys.has(normalizedKey) || documented?.has(normalizedKey) === true;
221
+ }
222
+ function isNativeArgumentBlock(normalizedKey, documented) {
223
+ return (isNativeEffect(normalizedKey, documented) &&
224
+ !NATIVE_EFFECT_CONTAINERS.has(normalizedKey) &&
225
+ !CONTROL_BLOCKS.has(normalizedKey) &&
226
+ SCOPE_BLOCKS[normalizedKey] === undefined &&
227
+ !/^(?:every|random|global_every)_/u.test(normalizedKey));
228
+ }
229
+ /** Effect blocks of definition files by naming convention: `on_add`, `complete_effect`, `effects`. */
230
+ function isConventionalEffectBlock(normalizedKey) {
231
+ return /^on_[a-z0-9_]+$|(?:^|_)effects?$/u.test(normalizedKey);
232
+ }
233
+ /** Trigger iterators such as `any_owned_state` hold conditions, never effects. */
234
+ function isTriggerIterator(normalizedKey) {
235
+ return /^(?:any|all)_/u.test(normalizedKey);
236
+ }
157
237
  const TOOLTIP_ONLY_BLOCKS = new Set(['effect_tooltip', 'custom_effect_tooltip']);
158
238
  const COUNTRY_ON_ACTIONS = new Set([
159
239
  'on_startup',
@@ -208,10 +288,10 @@ function localisationKeysFromValue(value) {
208
288
  }
209
289
  return [...new Set(keys)].sort(compareCodeUnits);
210
290
  }
211
- function missingHelperCandidate(key, value, effectContext) {
291
+ function missingHelperCandidate(key, value, effectContext, documented) {
212
292
  const normalizedKey = key.toLowerCase();
213
293
  if (!effectContext ||
214
- nativeEffectKeys.has(normalizedKey) ||
294
+ isNativeEffect(normalizedKey, documented) ||
215
295
  NON_HELPER_BLOCKS.has(normalizedKey) ||
216
296
  SCOPE_BLOCKS[normalizedKey] !== undefined ||
217
297
  STATE_RULES[normalizedKey] !== undefined ||
@@ -836,6 +916,7 @@ class FragmentAnalyzer {
836
916
  helperStack: [],
837
917
  semanticPath: ['other'],
838
918
  effectContext: true,
919
+ inferMissingHelpers: false,
839
920
  });
840
921
  if (this.edges.length + this.stateAccesses.length > before) {
841
922
  this.addNode({
@@ -913,7 +994,9 @@ class FragmentAnalyzer {
913
994
  return;
914
995
  }
915
996
  if (this.context.inventoryComplete !== false &&
916
- missingHelperCandidate(key, entry.value, effectContext)) {
997
+ context.nativeArguments !== true &&
998
+ context.inferMissingHelpers !== false &&
999
+ missingHelperCandidate(key, entry.value, effectContext, this.context.documentedEffectKeys)) {
917
1000
  const target = this.unresolvedNode('missing_helper', key, entry, context.ownerId, semanticPath);
918
1001
  this.addEdge(context.ownerId, target, 'scripted_effect_call', entry, context.conditions, {
919
1002
  phase: context.phase,
@@ -1039,7 +1122,14 @@ class FragmentAnalyzer {
1039
1122
  conditions,
1040
1123
  sourceScope: scope,
1041
1124
  phase,
1042
- effectContext: NON_EXECUTING_BLOCKS.has(normalizedKey) ? false : effectContext,
1125
+ effectContext: NON_EXECUTING_BLOCKS.has(normalizedKey) || isTriggerIterator(normalizedKey)
1126
+ ? false
1127
+ : effectContext,
1128
+ nativeArguments: context.nativeArguments === true ||
1129
+ isNativeArgumentBlock(normalizedKey, this.context.documentedEffectKeys),
1130
+ ...(context.inferMissingHelpers === false && isConventionalEffectBlock(normalizedKey)
1131
+ ? { inferMissingHelpers: true }
1132
+ : {}),
1043
1133
  suppressSemantics: context.suppressSemantics === true || TOOLTIP_ONLY_BLOCKS.has(normalizedKey),
1044
1134
  semanticPath,
1045
1135
  ...(scopeExpression === undefined ? {} : { scopeExpression }),
@@ -1296,7 +1386,8 @@ class FragmentAnalyzer {
1296
1386
  return id;
1297
1387
  }
1298
1388
  recordState(assignment, context, semanticPath) {
1299
- const keyedTarget = /^(event_target|global_event_target|scope):([A-Za-z0-9_.-]+)$/u.exec(assignment.key.value);
1389
+ // `event_target:founder.districts` names the target `founder`; the rest is a value in its scope.
1390
+ const keyedTarget = /^(event_target|global_event_target|scope):([A-Za-z0-9_-]+)(?:\.[A-Za-z0-9_.-]+)?$/u.exec(assignment.key.value);
1300
1391
  if (keyedTarget !== null) {
1301
1392
  this.addState(context, keyedTarget[1] === 'global_event_target'
1302
1393
  ? 'global_event_target'
@@ -1307,7 +1398,7 @@ class FragmentAnalyzer {
1307
1398
  const rule = STATE_RULES[assignment.key.value];
1308
1399
  if (rule === undefined) {
1309
1400
  const raw = valueText(this.document, assignment.value);
1310
- for (const match of raw.matchAll(/\b(event_target|global_event_target):([A-Za-z0-9_.-]+)/gu)) {
1401
+ for (const match of raw.matchAll(/\b(event_target|global_event_target):([A-Za-z0-9_-]+)/gu)) {
1311
1402
  this.addState(context, match[1] === 'global_event_target' ? 'global_event_target' : 'event_target', match[2], 'read', assignment, semanticPath, false);
1312
1403
  }
1313
1404
  return;
@@ -1336,7 +1427,10 @@ class FragmentAnalyzer {
1336
1427
  else if (access === 'write') {
1337
1428
  this.#seenStateWriters.add(writerKey);
1338
1429
  }
1339
- this.addState(context, kind, name, access, assignment, semanticPath, isDynamic(name), rule.storage === undefined ? {} : { storage: rule.storage });
1430
+ this.addState(context, kind, name, access, assignment, semanticPath, isDynamic(name), {
1431
+ ...(rule.storage === undefined ? {} : { storage: rule.storage }),
1432
+ ...(rule.temporary === true ? { temporary: true } : {}),
1433
+ });
1340
1434
  }
1341
1435
  addState(context, kind, name, access, node, _semanticPath, dynamic, metadata = {}) {
1342
1436
  const semantic = {
@@ -0,0 +1,63 @@
1
+ const SCOPE_KINDS = [
2
+ 'country',
3
+ 'state',
4
+ 'province',
5
+ 'unit_leader',
6
+ 'operative',
7
+ 'character',
8
+ 'global',
9
+ 'unknown',
10
+ ];
11
+ /** Identity of one stored value: variables and arrays live per scope, saved scopes per storage. */
12
+ export function stateKey(value) {
13
+ const scope = value.kind === 'variable' || value.kind === 'array' ? `:${value.scope}` : '';
14
+ const storage = value.kind === 'saved_scope'
15
+ ? `:${value.metadata.storage === 'global' || value.metadata.storage === 'local'
16
+ ? value.metadata.storage
17
+ : 'unknown'}`
18
+ : '';
19
+ return `${value.kind}:${value.name}${scope}${storage}`;
20
+ }
21
+ /** Keys of every write that a read of this value can observe. */
22
+ export function compatibleProducerKeys(value) {
23
+ // `event_target:name` reads local and global targets alike, whereas
24
+ // `global_event_target:name` reads only global ones.
25
+ if (value.kind === 'event_target') {
26
+ return [stateKey(value), `global_event_target:${value.name}`];
27
+ }
28
+ // A helper body runs in whatever scope calls it, so its scope is unknown. A write there can
29
+ // reach a read in any scope, and a read there can observe a write in any scope.
30
+ if (value.kind === 'variable' || value.kind === 'array') {
31
+ const scopes = value.scope === 'unknown' ? SCOPE_KINDS : [value.scope, 'unknown'];
32
+ const keys = scopes.map((scope) => `${value.kind}:${value.name}:${scope}`);
33
+ // `entries^num` and `entries^3` read the array `entries`; `GER.value` and `ROOT.value` read
34
+ // `value` in another scope, which may be written under its bare name in any scope.
35
+ const element = /^([^^]+)\^/u.exec(value.name)?.[1];
36
+ if (element !== undefined)
37
+ keys.push(...SCOPE_KINDS.map((scope) => `array:${unscopedName(element)}:${scope}`));
38
+ const bare = unscopedName(value.name);
39
+ if (bare !== value.name)
40
+ keys.push(...SCOPE_KINDS.map((scope) => `${value.kind}:${bare}:${scope}`));
41
+ return [...new Set(keys)];
42
+ }
43
+ if (value.kind !== 'saved_scope' ||
44
+ value.metadata.storage === 'global' ||
45
+ value.metadata.storage === 'local') {
46
+ return [stateKey(value)];
47
+ }
48
+ return [
49
+ `saved_scope:${value.name}:global`,
50
+ `saved_scope:${value.name}:local`,
51
+ `saved_scope:${value.name}:unknown`,
52
+ ];
53
+ }
54
+ /** `GER.value`, `ROOT.FROM.value` or `var:target.value` without its scope prefix. */
55
+ function unscopedName(name) {
56
+ const scoped = /^(?:(?:[A-Z][A-Z0-9]{2}|ROOT|FROM|PREV|THIS|OWNER|CONTROLLER|CAPITAL)\.|(?:var|event_target):[A-Za-z0-9_]+\.)+(.+)$/u.exec(name);
57
+ return scoped?.[1] ?? name;
58
+ }
59
+ export function hasUnknownSavedScopeStorage(value) {
60
+ return (value.kind === 'saved_scope' &&
61
+ value.metadata.storage !== 'global' &&
62
+ value.metadata.storage !== 'local');
63
+ }