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.
- package/CHANGELOG.md +25 -0
- package/dist/hoi4_agent_tools/core/canonical.js +42 -0
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +12 -8
- package/dist/hoi4_agent_tools/core/dependency-walk.js +21 -2
- package/dist/hoi4_agent_tools/core/domain-validation.js +6 -1
- package/dist/hoi4_agent_tools/core/engine.js +3 -0
- package/dist/hoi4_agent_tools/core/game-documentation.js +24 -0
- package/dist/hoi4_agent_tools/core/impact-graph.js +6 -2
- package/dist/hoi4_agent_tools/core/impact-semantic.js +12 -2
- package/dist/hoi4_agent_tools/core/index.js +1 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +12 -2
- package/dist/hoi4_agent_tools/event/algorithms.js +3 -2
- package/dist/hoi4_agent_tools/event/graph.js +60 -36
- package/dist/hoi4_agent_tools/event/limits.js +4 -0
- package/dist/hoi4_agent_tools/event/operations.js +11 -0
- package/dist/hoi4_agent_tools/event/queries.js +13 -34
- package/dist/hoi4_agent_tools/event/render.js +4 -2
- package/dist/hoi4_agent_tools/event/service.js +101 -33
- package/dist/hoi4_agent_tools/event/source-analysis.js +101 -7
- package/dist/hoi4_agent_tools/event/state-keys.js +63 -0
- package/dist/hoi4_agent_tools/event/summary.js +130 -0
- package/dist/hoi4_agent_tools/focus/importer.js +8 -6
- package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
- package/dist/hoi4_agent_tools/focus/tool-support.js +10 -1
- package/dist/hoi4_agent_tools/impact/service.js +2 -1
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +1 -0
- package/dist/hoi4_agent_tools/mcp/tools/event.js +66 -0
- package/dist/hoi4_agent_tools/probability/service.js +18 -18
- package/dist/hoi4_agent_tools/reference/source-lookup.js +4 -2
- package/dist/hoi4_agent_tools/schemas/probability.js +7 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/analysis.md +4 -0
- package/docs/events.md +12 -0
- package/docs/probability.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +7 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
964
|
-
? await this.engine.artifacts.putChunked(workspace, graphSnapshotName, 'application/json', `${canonicalJson(
|
|
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
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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_
|
|
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),
|
|
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
|
+
}
|