hoi4-agent-tools 1.2.0 → 2.1.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 +21 -1
- package/README.md +6 -7
- package/dist/hoi4_agent_tools/core/artifacts.js +109 -4
- package/dist/hoi4_agent_tools/core/diagnostic-collector.js +28 -8
- package/dist/hoi4_agent_tools/core/diff.js +162 -4
- package/dist/hoi4_agent_tools/core/engine.js +2 -0
- package/dist/hoi4_agent_tools/core/index.js +17 -6
- package/dist/hoi4_agent_tools/core/render-budget.js +5 -3
- package/dist/hoi4_agent_tools/core/scanner.js +3 -2
- package/dist/hoi4_agent_tools/core/source/limits.js +4 -1
- package/dist/hoi4_agent_tools/core/workspace.js +31 -3
- package/dist/hoi4_agent_tools/event/graph.js +28 -6
- package/dist/hoi4_agent_tools/event/limits.js +1 -1
- package/dist/hoi4_agent_tools/event/service.js +103 -7
- package/dist/hoi4_agent_tools/focus/continuous-render.js +17 -8
- package/dist/hoi4_agent_tools/focus/presentation.js +96 -41
- package/dist/hoi4_agent_tools/focus/render.js +27 -16
- package/dist/hoi4_agent_tools/gui/assets.js +9 -2
- package/dist/hoi4_agent_tools/gui/inspection-artifact.js +99 -0
- package/dist/hoi4_agent_tools/gui/layout.js +126 -27
- package/dist/hoi4_agent_tools/gui/renderer.js +16 -0
- package/dist/hoi4_agent_tools/gui/source-graph.js +61 -28
- package/dist/hoi4_agent_tools/gui/studio.js +23 -8
- package/dist/hoi4_agent_tools/map/diagnostic-limit.js +90 -9
- package/dist/hoi4_agent_tools/map/model.js +22 -4
- package/dist/hoi4_agent_tools/map/service.js +4 -3
- package/dist/hoi4_agent_tools/map/validation.js +3 -2
- package/dist/hoi4_agent_tools/mcp/server/create.js +1 -3
- package/dist/hoi4_agent_tools/mcp/server/output-schemas.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/event.js +6 -6
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +149 -224
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +35 -25
- package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -3
- package/dist/hoi4_agent_tools/runtime.js +112 -2
- package/dist/hoi4_agent_tools/schemas/common.js +5 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/events.md +4 -5
- package/docs/focus.md +5 -4
- package/docs/gui.md +5 -1
- package/docs/map.md +1 -1
- package/docs/setup.md +9 -6
- package/package.json +1 -1
- package/server.json +2 -2
- package/dist/hoi4_agent_tools/mcp/tools/mods.js +0 -54
|
@@ -451,6 +451,26 @@ function helperProjections(graph, budget) {
|
|
|
451
451
|
if (projectedKeys.has(projectionKey))
|
|
452
452
|
continue;
|
|
453
453
|
projectedKeys.add(projectionKey);
|
|
454
|
+
if (projected.length >= EVENT_GRAPH_MAX_HELPER_PROJECTIONS) {
|
|
455
|
+
addIssue(graph.issues, {
|
|
456
|
+
code: 'EVENT_HELPER_PROJECTION_LIMIT',
|
|
457
|
+
classification: 'unresolved_analysis',
|
|
458
|
+
severity: 'blocker',
|
|
459
|
+
message: 'Collapsed helper projection reached its materialization ceiling; structural helper calls remain available.',
|
|
460
|
+
confidence: 'unresolved',
|
|
461
|
+
location: start.location,
|
|
462
|
+
blockers: [
|
|
463
|
+
{
|
|
464
|
+
code: 'HELPER_PROJECTION_LIMIT',
|
|
465
|
+
message: 'Use a bounded helper-expanded trace for paths omitted from the collapsed workspace graph.',
|
|
466
|
+
location: start.location,
|
|
467
|
+
details: { maximum: EVENT_GRAPH_MAX_HELPER_PROJECTIONS },
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
subjectIds: [start.from, start.to],
|
|
471
|
+
});
|
|
472
|
+
return stableUnique(projected);
|
|
473
|
+
}
|
|
454
474
|
projected.push({
|
|
455
475
|
id: `edge:helper-projection:${hashCanonical({
|
|
456
476
|
from: start.from,
|
|
@@ -480,9 +500,6 @@ function helperProjections(graph, budget) {
|
|
|
480
500
|
terminalDispatchLocation: locationKey(final.location),
|
|
481
501
|
},
|
|
482
502
|
});
|
|
483
|
-
if (projected.length > EVENT_GRAPH_MAX_HELPER_PROJECTIONS) {
|
|
484
|
-
throw new ServiceError('EVENT_HELPER_PROJECTION_LIMIT', 'Event-chain helper expansion exceeds the fixed projection ceiling', { maximum: EVENT_GRAPH_MAX_HELPER_PROJECTIONS });
|
|
485
|
-
}
|
|
486
503
|
}
|
|
487
504
|
}
|
|
488
505
|
}
|
|
@@ -1523,7 +1540,8 @@ export function buildEventGraph(snapshot, options = {}) {
|
|
|
1523
1540
|
const fragments = analyzeFragments(snapshot, files, catalog, options, budget);
|
|
1524
1541
|
const graph = mergeFragments(fragments);
|
|
1525
1542
|
const partial = partialInventoryEvidence(snapshot);
|
|
1526
|
-
|
|
1543
|
+
for (const unresolved of partial.unresolved)
|
|
1544
|
+
graph.unresolved.push(unresolved);
|
|
1527
1545
|
assertGraphLimit(graph.nodes.length, EVENT_GRAPH_MAX_NODES, 'EVENT_NODE_LIMIT', 'node count');
|
|
1528
1546
|
assertGraphLimit(graph.edges.length, EVENT_GRAPH_MAX_EDGES, 'EVENT_EDGE_LIMIT', 'edge count');
|
|
1529
1547
|
assertGraphLimit(graph.stateAccesses.length, EVENT_GRAPH_MAX_STATE_ACCESSES, 'EVENT_STATE_ACCESS_LIMIT', 'state-access count');
|
|
@@ -1531,8 +1549,12 @@ export function buildEventGraph(snapshot, options = {}) {
|
|
|
1531
1549
|
normalizePublicEventNodeIds(graph);
|
|
1532
1550
|
normalizeCrossFileEdges(graph);
|
|
1533
1551
|
ensureReferencedNodes(graph);
|
|
1534
|
-
|
|
1535
|
-
|
|
1552
|
+
if (options.projectHelpers !== false) {
|
|
1553
|
+
for (const edge of helperProjections(graph, budget))
|
|
1554
|
+
graph.edges.push(edge);
|
|
1555
|
+
for (const access of projectHelperStateAccesses(graph, budget))
|
|
1556
|
+
graph.stateAccesses.push(access);
|
|
1557
|
+
}
|
|
1536
1558
|
addTerminalNodes(graph);
|
|
1537
1559
|
graph.nodes = sortNodes(graph.nodes);
|
|
1538
1560
|
graph.edges = stableUnique(graph.edges);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ServiceError } from '../core/result.js';
|
|
2
2
|
export const EVENT_GRAPH_MAX_NODES = 100_000;
|
|
3
|
-
export const EVENT_GRAPH_MAX_EDGES =
|
|
3
|
+
export const EVENT_GRAPH_MAX_EDGES = 500_000;
|
|
4
4
|
export const EVENT_GRAPH_MAX_STATE_ACCESSES = 250_000;
|
|
5
5
|
export const EVENT_GRAPH_MAX_STATE_LINKS = 250_000;
|
|
6
6
|
export const EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES = 500_000;
|
|
@@ -42,11 +42,75 @@ class BoundedEventFragmentCache {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
function eventGraphCacheKey(workspaceId, projectHelpers) {
|
|
46
|
+
return `${workspaceId}\0${projectHelpers ? 'expanded' : 'structural'}`;
|
|
47
|
+
}
|
|
45
48
|
const serviceState = new WeakMap();
|
|
46
49
|
const EVENT_GRAPH_ARTIFACT_MAX_BYTES = 67_108_864;
|
|
47
50
|
const EVENT_GRAPH_ARTIFACT_MAX_CHUNKS = 1_024;
|
|
51
|
+
const EVENT_SCAN_FULL_GRAPH_RECORD_LIMIT = 100_000;
|
|
52
|
+
const EVENT_SCAN_SUMMARY_SAMPLE_LIMIT = 100;
|
|
48
53
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
49
54
|
const graphHashCache = new WeakMap();
|
|
55
|
+
function groupedCounts(values, key) {
|
|
56
|
+
const counts = new Map();
|
|
57
|
+
for (const value of values) {
|
|
58
|
+
const group = key(value);
|
|
59
|
+
counts.set(group, (counts.get(group) ?? 0) + 1);
|
|
60
|
+
}
|
|
61
|
+
return Object.fromEntries([...counts].sort(([left], [right]) => compareCodeUnits(left, right)));
|
|
62
|
+
}
|
|
63
|
+
export function eventScanReport(graph, fullGraphRecordLimit = EVENT_SCAN_FULL_GRAPH_RECORD_LIMIT) {
|
|
64
|
+
const recordCount = graph.nodes.length +
|
|
65
|
+
graph.edges.length +
|
|
66
|
+
graph.stateAccesses.length +
|
|
67
|
+
graph.stateLinks.length +
|
|
68
|
+
graph.issues.length +
|
|
69
|
+
graph.diagnostics.length +
|
|
70
|
+
graph.unresolved.length;
|
|
71
|
+
if (recordCount <= fullGraphRecordLimit)
|
|
72
|
+
return { graph };
|
|
73
|
+
const sourceEvidence = boundedSourceHashEvidence(graph.sourceHashes);
|
|
74
|
+
return {
|
|
75
|
+
graphSummary: {
|
|
76
|
+
schemaVersion: graph.schemaVersion,
|
|
77
|
+
parserVersion: graph.parserVersion,
|
|
78
|
+
workspaceId: graph.workspaceId,
|
|
79
|
+
workspaceIdentity: graph.workspaceIdentity,
|
|
80
|
+
revision: graph.revision,
|
|
81
|
+
complete: graph.complete,
|
|
82
|
+
statistics: graph.statistics,
|
|
83
|
+
filesScanned: graph.filesScanned.length,
|
|
84
|
+
skippedSourceCount: graph.skippedSourceCount,
|
|
85
|
+
sourceHashInventory: sourceEvidence.inventory,
|
|
86
|
+
recordCounts: {
|
|
87
|
+
nodes: graph.nodes.length,
|
|
88
|
+
edges: graph.edges.length,
|
|
89
|
+
stateAccesses: graph.stateAccesses.length,
|
|
90
|
+
stateLinks: graph.stateLinks.length,
|
|
91
|
+
issues: graph.issues.length,
|
|
92
|
+
diagnostics: graph.diagnostics.length,
|
|
93
|
+
unresolved: graph.unresolved.length,
|
|
94
|
+
},
|
|
95
|
+
nodeKinds: groupedCounts(graph.nodes, ({ kind }) => kind),
|
|
96
|
+
edgeReasons: groupedCounts(graph.edges, ({ reason }) => reason),
|
|
97
|
+
issueCodes: groupedCounts(graph.issues, ({ code }) => code),
|
|
98
|
+
issueSeverities: groupedCounts(graph.issues, ({ severity }) => severity),
|
|
99
|
+
diagnosticCodes: groupedCounts(graph.diagnostics, ({ code }) => code),
|
|
100
|
+
diagnosticSeverities: groupedCounts(graph.diagnostics, ({ severity }) => severity),
|
|
101
|
+
unresolvedKinds: groupedCounts(graph.unresolved, ({ kind }) => kind),
|
|
102
|
+
},
|
|
103
|
+
issueSamples: graph.issues.slice(0, EVENT_SCAN_SUMMARY_SAMPLE_LIMIT),
|
|
104
|
+
diagnosticSamples: graph.diagnostics.slice(0, EVENT_SCAN_SUMMARY_SAMPLE_LIMIT),
|
|
105
|
+
unresolvedSamples: graph.unresolved.slice(0, EVENT_SCAN_SUMMARY_SAMPLE_LIMIT),
|
|
106
|
+
artifactProjection: {
|
|
107
|
+
mode: 'large-scan-summary',
|
|
108
|
+
fullGraphRecordCount: recordCount,
|
|
109
|
+
fullGraphRecordLimit,
|
|
110
|
+
detailQueries: ['roots', 'trace', 'explain_path', 'state_flow', 'impact'],
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
50
114
|
function cachedEventGraphHash(graph, signal) {
|
|
51
115
|
signal?.throwIfAborted();
|
|
52
116
|
const cached = graphHashCache.get(graph);
|
|
@@ -553,11 +617,16 @@ export class EventChainViewer {
|
|
|
553
617
|
// principal bypass the resolver check for a later caller.
|
|
554
618
|
const workspace = this.engine.resolver.get(workspaceId, options.principal);
|
|
555
619
|
const generation = engineGeneration(this.engine, workspaceId);
|
|
556
|
-
const
|
|
620
|
+
const projectHelpers = options.projectHelpers ?? true;
|
|
621
|
+
const cacheKey = eventGraphCacheKey(workspaceId, projectHelpers);
|
|
622
|
+
const cached = this.#state.current.get(cacheKey);
|
|
557
623
|
if (options.refresh !== true && cached?.generation === generation) {
|
|
558
624
|
return cached.graph;
|
|
559
625
|
}
|
|
560
|
-
const
|
|
626
|
+
const sibling = this.#state.current.get(eventGraphCacheKey(workspaceId, !projectHelpers));
|
|
627
|
+
const snapshot = options.refresh !== true && sibling?.generation === generation
|
|
628
|
+
? sibling.snapshot
|
|
629
|
+
: await this.engine.scan(workspaceId, {}, options.principal, options.signal);
|
|
561
630
|
options.signal?.throwIfAborted();
|
|
562
631
|
if (cached?.snapshot.revision === snapshot.revision && cached.generation === generation) {
|
|
563
632
|
return cached.graph;
|
|
@@ -566,8 +635,9 @@ export class EventChainViewer {
|
|
|
566
635
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
567
636
|
cache: this.#state.fragments,
|
|
568
637
|
workspaceIdentity: workspace.workspaceIdentity,
|
|
638
|
+
projectHelpers,
|
|
569
639
|
});
|
|
570
|
-
this.#state.current.set(
|
|
640
|
+
this.#state.current.set(cacheKey, { generation, snapshot, graph });
|
|
571
641
|
rememberGraph(this.#state, workspaceId, graph);
|
|
572
642
|
return graph;
|
|
573
643
|
}
|
|
@@ -575,6 +645,7 @@ export class EventChainViewer {
|
|
|
575
645
|
input.signal?.throwIfAborted();
|
|
576
646
|
const graph = await this.scan(input.workspaceId, {
|
|
577
647
|
refresh: input.refresh ?? input.mode === 'scan',
|
|
648
|
+
projectHelpers: input.mode !== 'scan' && input.mode !== 'roots',
|
|
578
649
|
...(input.principal === undefined ? {} : { principal: input.principal }),
|
|
579
650
|
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
580
651
|
});
|
|
@@ -597,7 +668,7 @@ export class EventChainViewer {
|
|
|
597
668
|
let report;
|
|
598
669
|
switch (input.mode) {
|
|
599
670
|
case 'scan':
|
|
600
|
-
report =
|
|
671
|
+
report = eventScanReport(graph);
|
|
601
672
|
break;
|
|
602
673
|
case 'roots':
|
|
603
674
|
report = {
|
|
@@ -662,6 +733,16 @@ export class EventChainViewer {
|
|
|
662
733
|
}
|
|
663
734
|
const graphHash = cachedEventGraphHash(graph, input.signal);
|
|
664
735
|
const name = `${safeSlug(`event-${input.mode}`)}-${graph.revision.slice(0, 12)}.json`;
|
|
736
|
+
const projectedScan = input.mode === 'scan' &&
|
|
737
|
+
typeof report === 'object' &&
|
|
738
|
+
report !== null &&
|
|
739
|
+
'artifactProjection' in report;
|
|
740
|
+
const artifactSourceEvidence = projectedScan
|
|
741
|
+
? boundedSourceHashEvidence(graph.sourceHashes)
|
|
742
|
+
: undefined;
|
|
743
|
+
const artifactUnresolved = projectedScan
|
|
744
|
+
? graph.unresolved.slice(0, EVENT_SCAN_SUMMARY_SAMPLE_LIMIT)
|
|
745
|
+
: graph.unresolved;
|
|
665
746
|
const reportJson = `${canonicalJson({
|
|
666
747
|
schemaVersion: 'event-analysis.v1',
|
|
667
748
|
graphSchemaVersion: graph.schemaVersion,
|
|
@@ -671,10 +752,22 @@ export class EventChainViewer {
|
|
|
671
752
|
workspaceIdentity: graph.workspaceIdentity,
|
|
672
753
|
graphRevision: graph.revision,
|
|
673
754
|
graphHash,
|
|
674
|
-
sourceHashes: graph.sourceHashes,
|
|
755
|
+
sourceHashes: artifactSourceEvidence?.sourceHashes ?? graph.sourceHashes,
|
|
756
|
+
...(artifactSourceEvidence === undefined
|
|
757
|
+
? {}
|
|
758
|
+
: { sourceHashInventory: artifactSourceEvidence.inventory }),
|
|
675
759
|
complete: graph.complete,
|
|
676
760
|
filters: analysisFilters,
|
|
677
|
-
unresolved:
|
|
761
|
+
unresolved: artifactUnresolved,
|
|
762
|
+
...(projectedScan
|
|
763
|
+
? {
|
|
764
|
+
unresolvedInventory: {
|
|
765
|
+
count: graph.unresolved.length,
|
|
766
|
+
retainedCount: artifactUnresolved.length,
|
|
767
|
+
truncated: artifactUnresolved.length !== graph.unresolved.length,
|
|
768
|
+
},
|
|
769
|
+
}
|
|
770
|
+
: {}),
|
|
678
771
|
resources: [{ name, mimeType: 'application/json' }],
|
|
679
772
|
report,
|
|
680
773
|
})}\n`;
|
|
@@ -695,6 +788,7 @@ export class EventChainViewer {
|
|
|
695
788
|
async renderAndStore(input) {
|
|
696
789
|
const graph = await this.scan(input.workspaceId, {
|
|
697
790
|
...(input.refresh === undefined ? {} : { refresh: input.refresh }),
|
|
791
|
+
projectHelpers: true,
|
|
698
792
|
...(input.principal === undefined ? {} : { principal: input.principal }),
|
|
699
793
|
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
700
794
|
});
|
|
@@ -892,7 +986,7 @@ export class EventChainViewer {
|
|
|
892
986
|
if (sources.length === 0)
|
|
893
987
|
throw new ServiceError('EVENT_PROPOSED_SOURCE_REQUIRED', 'Proposed comparison source list is empty');
|
|
894
988
|
assertProposedSourceBounds(sources);
|
|
895
|
-
const cached = this.#state.current.get(workspaceId);
|
|
989
|
+
const cached = this.#state.current.get(eventGraphCacheKey(workspaceId, false));
|
|
896
990
|
if (cached === undefined)
|
|
897
991
|
throw new ServiceError('EVENT_BASELINE_MISSING', 'Scan the workspace before comparing proposed source');
|
|
898
992
|
const paths = sources.map(({ relativePath }) => validRelativeSourcePath(relativePath));
|
|
@@ -974,6 +1068,7 @@ export class EventChainViewer {
|
|
|
974
1068
|
...(signal === undefined ? {} : { signal }),
|
|
975
1069
|
cache: this.#state.fragments,
|
|
976
1070
|
workspaceIdentity: workspace.workspaceIdentity,
|
|
1071
|
+
projectHelpers: false,
|
|
977
1072
|
});
|
|
978
1073
|
}
|
|
979
1074
|
async #resolveGraphReference(workspaceId, reference, current, principal, signal) {
|
|
@@ -1005,6 +1100,7 @@ export class EventChainViewer {
|
|
|
1005
1100
|
input.after === undefined);
|
|
1006
1101
|
const current = await this.scan(input.workspaceId, {
|
|
1007
1102
|
refresh,
|
|
1103
|
+
projectHelpers: false,
|
|
1008
1104
|
...(input.principal === undefined ? {} : { principal: input.principal }),
|
|
1009
1105
|
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
1010
1106
|
});
|
|
@@ -31,7 +31,9 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
|
|
|
31
31
|
const dataUri = iconSprite === undefined ? undefined : options.presentation?.icons[iconSprite]?.dataUri;
|
|
32
32
|
return dataUri === undefined ? [] : [dataUri];
|
|
33
33
|
});
|
|
34
|
-
|
|
34
|
+
if (options.rasterize !== false) {
|
|
35
|
+
await admitFocusIconDataUris(options, budget, renderedDataUris);
|
|
36
|
+
}
|
|
35
37
|
const columns = Math.max(1, Math.min(12, options.columns ?? 5));
|
|
36
38
|
const padding = Math.max(24, options.padding ?? 64);
|
|
37
39
|
const cardWidth = 190;
|
|
@@ -41,7 +43,9 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
|
|
|
41
43
|
const usedColumns = Math.max(1, Math.min(columns, plan.focuses.length));
|
|
42
44
|
const width = padding * 2 + usedColumns * cardWidth + Math.max(0, usedColumns - 1) * gap;
|
|
43
45
|
const height = padding * 2 + 84 + rows * cardHeight + Math.max(0, rows - 1) * gap;
|
|
44
|
-
|
|
46
|
+
if (options.rasterize !== false) {
|
|
47
|
+
budget.reserve(width, height, 'continuous focus palette PNG');
|
|
48
|
+
}
|
|
45
49
|
const diagnosticsById = new Map();
|
|
46
50
|
const toolText = new DeterministicSvgTextRenderer();
|
|
47
51
|
for (const diagnostic of diagnostics) {
|
|
@@ -120,10 +124,13 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
|
|
|
120
124
|
})}\n`;
|
|
121
125
|
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeXml(plan.id)} continuous focus palette</title><style>html{background:#101820;color:#f3f6f8;font:14px system-ui}body{margin:0;padding:24px}.notice{color:#d7b65b}svg{max-width:100%;height:auto;border:1px solid #385064}pre{white-space:pre-wrap}</style></head><body><h1>${escapeXml(plan.id)}</h1><p class="notice">Offline source-derived review artifact — not an in-game screenshot or editor.</p>${svg}<details><summary>Structured source model</summary><pre>${escapeXml(json)}</pre></details></body></html>`;
|
|
122
126
|
options.signal?.throwIfAborted();
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
.
|
|
127
|
+
let png = Buffer.alloc(0);
|
|
128
|
+
if (options.rasterize !== false) {
|
|
129
|
+
assertRenderDimensions(width, height, 'continuous focus palette Sharp raster');
|
|
130
|
+
png = await sharp(Buffer.from(svg, 'utf8'), { limitInputPixels: RENDER_MAX_PIXELS })
|
|
131
|
+
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
132
|
+
.toBuffer();
|
|
133
|
+
}
|
|
127
134
|
options.signal?.throwIfAborted();
|
|
128
135
|
return {
|
|
129
136
|
html,
|
|
@@ -133,7 +140,7 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
|
|
|
133
140
|
hashes: {
|
|
134
141
|
html: sha256Bytes(html),
|
|
135
142
|
svg: sha256Bytes(svg),
|
|
136
|
-
png: sha256Bytes(png),
|
|
143
|
+
...(options.rasterize === false ? {} : { png: sha256Bytes(png) }),
|
|
137
144
|
json: sha256Bytes(json),
|
|
138
145
|
},
|
|
139
146
|
sourceMap: compiled.sourceMap,
|
|
@@ -158,7 +165,9 @@ export async function storeContinuousFocusRenderArtifacts(workspace, store, plan
|
|
|
158
165
|
const entries = [
|
|
159
166
|
[`${stem}.continuous.html`, 'text/html', bundle.html, 'interactive-review'],
|
|
160
167
|
[`${stem}.continuous.svg`, 'image/svg+xml', bundle.svg, 'vector-render'],
|
|
161
|
-
|
|
168
|
+
...(options.rasterize === false
|
|
169
|
+
? []
|
|
170
|
+
: [[`${stem}.continuous.png`, 'image/png', bundle.png, 'raster-render']]),
|
|
162
171
|
[`${stem}.continuous.json`, 'application/json', bundle.json, 'structured-render'],
|
|
163
172
|
[
|
|
164
173
|
`${stem}.continuous.source-map.json`,
|
|
@@ -70,15 +70,34 @@ function diagnosticLocation(target) {
|
|
|
70
70
|
: { location: target.sourceLocation }
|
|
71
71
|
: { location: target.iconLocation };
|
|
72
72
|
}
|
|
73
|
+
async function mapWithConcurrency(values, concurrency, callback) {
|
|
74
|
+
const results = new Array(values.length);
|
|
75
|
+
let next = 0;
|
|
76
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
|
77
|
+
while (next < values.length) {
|
|
78
|
+
const index = next;
|
|
79
|
+
next += 1;
|
|
80
|
+
results[index] = await callback(values[index]);
|
|
81
|
+
}
|
|
82
|
+
}));
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
73
85
|
export async function resolveFocusPresentation(input) {
|
|
74
86
|
input.signal?.throwIfAborted();
|
|
75
87
|
const language = input.language ?? 'l_english';
|
|
76
88
|
const palettes = input.palettes ?? [];
|
|
77
89
|
const targets = presentationTargets(input.plans, palettes);
|
|
78
|
-
const baseGraph = buildGuiSourceGraph(input.files, input.index);
|
|
79
90
|
const spriteNames = [
|
|
80
91
|
...new Set(targets.flatMap(({ sprite }) => (sprite === undefined ? [] : [sprite]))),
|
|
81
92
|
];
|
|
93
|
+
// Focus presentation needs sprite declarations, not the entire GUI, decision, and
|
|
94
|
+
// localisation graph. Localised focus text is resolved directly through the shared index.
|
|
95
|
+
const spriteSourcePaths = new Set(spriteNames.flatMap((spriteName) => {
|
|
96
|
+
const symbol = input.index.find('sprite', spriteName);
|
|
97
|
+
return symbol === undefined ? [] : [symbol.path];
|
|
98
|
+
}));
|
|
99
|
+
const presentationFiles = input.files.filter(({ displayPath }) => spriteSourcePaths.has(displayPath));
|
|
100
|
+
const baseGraph = buildGuiSourceGraph(presentationFiles, input.index);
|
|
82
101
|
const texturePaths = new Set();
|
|
83
102
|
for (const spriteName of spriteNames) {
|
|
84
103
|
const sprite = relevantSprite(spriteName, baseGraph.sprites, input.index);
|
|
@@ -97,16 +116,17 @@ export async function resolveFocusPresentation(input) {
|
|
|
97
116
|
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
98
117
|
});
|
|
99
118
|
const combined = new Map();
|
|
100
|
-
for (const file of [...
|
|
119
|
+
for (const file of [...presentationFiles, ...assets])
|
|
101
120
|
combined.set(file.displayPath, file);
|
|
102
121
|
const files = [...combined.values()].sort((left, right) => compareCodeUnits(left.displayPath, right.displayPath));
|
|
103
|
-
const graph =
|
|
122
|
+
const graph = baseGraph;
|
|
104
123
|
const catalog = new GuiAssetCatalog(graph, files, input.budget);
|
|
105
124
|
const diagnostics = [];
|
|
106
125
|
const entries = {};
|
|
107
126
|
const icons = {};
|
|
108
127
|
const sourceHashes = {};
|
|
109
128
|
const relevantPaths = new Set();
|
|
129
|
+
const iconTargets = new Map();
|
|
110
130
|
for (const target of targets) {
|
|
111
131
|
input.signal?.throwIfAborted();
|
|
112
132
|
const title = input.index.find('localisation', `${language}:${target.titleKey}`);
|
|
@@ -125,6 +145,8 @@ export async function resolveFocusPresentation(input) {
|
|
|
125
145
|
: { descriptionSourceLocation: description.location }),
|
|
126
146
|
...(target.sprite === undefined ? {} : { iconSprite: target.sprite }),
|
|
127
147
|
};
|
|
148
|
+
if (target.sprite !== undefined && !iconTargets.has(target.sprite))
|
|
149
|
+
iconTargets.set(target.sprite, target);
|
|
128
150
|
for (const [key, symbol] of [
|
|
129
151
|
[target.titleKey, title],
|
|
130
152
|
[target.descriptionKey, description],
|
|
@@ -147,54 +169,87 @@ export async function resolveFocusPresentation(input) {
|
|
|
147
169
|
details: { language, key, focusId: target.id },
|
|
148
170
|
});
|
|
149
171
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
172
|
+
}
|
|
173
|
+
const iconEntries = [...iconTargets.entries()];
|
|
174
|
+
const iconResults = await mapWithConcurrency(iconEntries, 8, async ([spriteName, target]) => {
|
|
175
|
+
const sprite = relevantSprite(spriteName, graph.sprites, input.index);
|
|
153
176
|
if (sprite === undefined) {
|
|
154
177
|
const partial = input.index.hasSkippedSourceForKind('sprite');
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
return {
|
|
179
|
+
paths: [],
|
|
180
|
+
diagnostic: {
|
|
181
|
+
code: partial ? 'FOCUS_ICON_REFERENCE_PARTIAL' : 'FOCUS_ICON_REFERENCE_MISSING',
|
|
182
|
+
severity: partial ? 'warning' : 'error',
|
|
183
|
+
category: 'reference',
|
|
184
|
+
message: partial
|
|
185
|
+
? `The partial shared inventory cannot verify sprite ${spriteName} for focus ${target.id}`
|
|
186
|
+
: `Focus ${target.id} references missing sprite ${spriteName}`,
|
|
187
|
+
...diagnosticLocation(target),
|
|
188
|
+
details: { focusId: target.id, sprite: spriteName },
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const paths = [sprite.sourcePath];
|
|
193
|
+
if (input.decodeIcons === false) {
|
|
194
|
+
const texturePath = sprite.texturePath;
|
|
195
|
+
const asset = texturePath === undefined ? undefined : catalog.resolveFile(texturePath, sprite.sourcePath);
|
|
196
|
+
if (asset !== undefined)
|
|
197
|
+
return { paths: [...paths, asset.displayPath] };
|
|
198
|
+
return {
|
|
199
|
+
paths,
|
|
200
|
+
diagnostic: {
|
|
201
|
+
code: 'FOCUS_ICON_TEXTURE_MISSING',
|
|
202
|
+
severity: 'error',
|
|
203
|
+
category: 'reference',
|
|
204
|
+
message: `Focus ${target.id} cannot resolve sprite texture ${texturePath ?? '<missing>'}`,
|
|
205
|
+
...diagnosticLocation(target),
|
|
206
|
+
details: { focusId: target.id, sprite: spriteName, texturePath: texturePath ?? null },
|
|
207
|
+
},
|
|
208
|
+
};
|
|
166
209
|
}
|
|
167
|
-
relevantPaths.add(sprite.sourcePath);
|
|
168
210
|
const frame = await catalog.loadSpriteFrame(sprite, 0);
|
|
169
211
|
if (frame?.supported !== true || frame.dataUri === undefined) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
212
|
+
return {
|
|
213
|
+
paths,
|
|
214
|
+
diagnostic: {
|
|
215
|
+
code: 'FOCUS_ICON_TEXTURE_MISSING',
|
|
216
|
+
severity: 'error',
|
|
217
|
+
category: 'reference',
|
|
218
|
+
message: `Focus ${target.id} cannot load sprite texture ${sprite.texturePath ?? '<missing>'}: ${frame?.reason ?? 'texturefile is missing'}`,
|
|
219
|
+
...diagnosticLocation(target),
|
|
220
|
+
details: {
|
|
221
|
+
focusId: target.id,
|
|
222
|
+
sprite: spriteName,
|
|
223
|
+
texturePath: sprite.texturePath ?? null,
|
|
224
|
+
},
|
|
180
225
|
},
|
|
181
|
-
}
|
|
182
|
-
continue;
|
|
226
|
+
};
|
|
183
227
|
}
|
|
184
228
|
const asset = catalog.resolveFile(frame.texturePath, sprite.sourcePath);
|
|
185
229
|
if (asset !== undefined)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
230
|
+
paths.push(asset.displayPath);
|
|
231
|
+
return {
|
|
232
|
+
paths,
|
|
233
|
+
icon: {
|
|
234
|
+
sprite: spriteName,
|
|
235
|
+
sourcePath: sprite.sourcePath,
|
|
236
|
+
texturePath: frame.texturePath,
|
|
237
|
+
frame: frame.frame,
|
|
238
|
+
frameCount: frame.frameCount,
|
|
239
|
+
width: frame.width,
|
|
240
|
+
height: frame.height,
|
|
241
|
+
format: frame.format,
|
|
242
|
+
dataUri: frame.dataUri,
|
|
243
|
+
},
|
|
197
244
|
};
|
|
245
|
+
});
|
|
246
|
+
for (const [index, result] of iconResults.entries()) {
|
|
247
|
+
for (const sourcePath of result.paths)
|
|
248
|
+
relevantPaths.add(sourcePath);
|
|
249
|
+
if (result.diagnostic !== undefined)
|
|
250
|
+
diagnostics.push(result.diagnostic);
|
|
251
|
+
if (result.icon !== undefined)
|
|
252
|
+
icons[iconEntries[index][0]] = result.icon;
|
|
198
253
|
}
|
|
199
254
|
for (const file of files) {
|
|
200
255
|
if (relevantPaths.has(file.displayPath))
|
|
@@ -168,7 +168,9 @@ function svgDocument(plan, layout, diagnostics, options) {
|
|
|
168
168
|
const logicalHeight = Math.max(240, (maximumY - minimumY) * vertical + nodeHeight + padding * 2);
|
|
169
169
|
const width = Math.max(1, Math.round(logicalWidth * outputScale));
|
|
170
170
|
const height = Math.max(1, Math.round(logicalHeight * outputScale));
|
|
171
|
-
(options.
|
|
171
|
+
if (options.rasterize !== false) {
|
|
172
|
+
(options.budget ?? new RenderBudget()).reserve(width, height, 'focus tree PNG');
|
|
173
|
+
}
|
|
172
174
|
const pixel = new Map(nodes.map((node) => [
|
|
173
175
|
node.id,
|
|
174
176
|
focusNodeOrigin(node, minimumX, minimumY, padding, horizontal, vertical),
|
|
@@ -415,18 +417,23 @@ export async function renderFocusTree(plan, layout, diagnostics, options = {}) {
|
|
|
415
417
|
const dataUri = focusIconDataUri(focus, resolvedOptions);
|
|
416
418
|
return dataUri === undefined ? [] : [dataUri];
|
|
417
419
|
});
|
|
418
|
-
|
|
420
|
+
if (options.rasterize !== false) {
|
|
421
|
+
await admitFocusIconDataUris(resolvedOptions, budget, renderedDataUris);
|
|
422
|
+
}
|
|
419
423
|
const compiled = compileFocusTreeWithSourceMap(plan, layout);
|
|
420
424
|
const rendered = svgDocument(plan, layout, diagnostics, resolvedOptions);
|
|
421
425
|
const json = `${canonicalJson({ ...rendered.graph, generatedSourceMap: compiled.sourceMap })}\n`;
|
|
422
426
|
const html = htmlDocument(plan, rendered.svg, json, diagnostics);
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
427
|
+
let png = Buffer.alloc(0);
|
|
428
|
+
if (options.rasterize !== false) {
|
|
429
|
+
assertRenderDimensions(rendered.width, rendered.height, 'focus tree Sharp raster');
|
|
430
|
+
budget.reserveRasterOperation(`focus-tree:${sha256Bytes(rendered.svg)}`, 'focus tree SVG rasterization');
|
|
431
|
+
png = await sharp(Buffer.from(rendered.svg, 'utf8'), {
|
|
432
|
+
limitInputPixels: RENDER_MAX_PIXELS,
|
|
433
|
+
})
|
|
434
|
+
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
435
|
+
.toBuffer();
|
|
436
|
+
}
|
|
430
437
|
options.signal?.throwIfAborted();
|
|
431
438
|
return {
|
|
432
439
|
html,
|
|
@@ -436,7 +443,7 @@ export async function renderFocusTree(plan, layout, diagnostics, options = {}) {
|
|
|
436
443
|
hashes: {
|
|
437
444
|
html: sha256Bytes(html),
|
|
438
445
|
svg: sha256Bytes(rendered.svg),
|
|
439
|
-
png: sha256Bytes(png),
|
|
446
|
+
...(options.rasterize === false ? {} : { png: sha256Bytes(png) }),
|
|
440
447
|
json: sha256Bytes(json),
|
|
441
448
|
},
|
|
442
449
|
sourceMap: compiled.sourceMap,
|
|
@@ -478,12 +485,16 @@ export async function storeFocusRenderArtifacts(workspace, artifacts, plan, bund
|
|
|
478
485
|
content: bundle.svg,
|
|
479
486
|
provenance: { ...provenance, kind: 'focus-svg' },
|
|
480
487
|
},
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
488
|
+
...(options.rasterize === false
|
|
489
|
+
? []
|
|
490
|
+
: [
|
|
491
|
+
{
|
|
492
|
+
name: `${stem}.focus.png`,
|
|
493
|
+
mimeType: 'image/png',
|
|
494
|
+
content: bundle.png,
|
|
495
|
+
provenance: { ...provenance, kind: 'focus-png' },
|
|
496
|
+
},
|
|
497
|
+
]),
|
|
487
498
|
{
|
|
488
499
|
name: `${stem}.focus.json`,
|
|
489
500
|
mimeType: 'application/json',
|
|
@@ -219,13 +219,20 @@ export class GuiAssetCatalog {
|
|
|
219
219
|
return Promise.resolve(undefined);
|
|
220
220
|
const frameCount = Math.max(1, sprite.frameCount);
|
|
221
221
|
const frame = Math.max(0, Math.min(frameCount - 1, Math.trunc(requestedFrame)));
|
|
222
|
-
|
|
222
|
+
// Different sprite names frequently reuse the same frame strip. Cache by resolved texture and
|
|
223
|
+
// crop instead of sprite identity so large surfaces do not rasterize identical pixels hundreds
|
|
224
|
+
// of times.
|
|
225
|
+
const textureFile = this.resolveFile(texturePath, sprite.sourcePath);
|
|
226
|
+
const textureIdentity = textureFile === undefined
|
|
227
|
+
? `${sprite.sourcePath}:${texturePath}`
|
|
228
|
+
: `${textureFile.displayPath}:${textureFile.sha256}`;
|
|
229
|
+
const key = `${textureIdentity}:${frameCount}:${frame}`;
|
|
223
230
|
let promise = this.frames.get(key);
|
|
224
231
|
if (promise === undefined) {
|
|
225
232
|
promise = this.decodeSpriteFrame(sprite, texturePath, frameCount, frame, key);
|
|
226
233
|
this.frames.set(key, promise);
|
|
227
234
|
}
|
|
228
|
-
return promise;
|
|
235
|
+
return promise.then((decoded) => decoded === undefined ? undefined : { ...decoded, spriteName: sprite.name });
|
|
229
236
|
}
|
|
230
237
|
async decodeSpriteFrame(sprite, texturePath, frameCount, frame, operationKey) {
|
|
231
238
|
const raster = await this.loadRaster(texturePath, sprite.sourcePath);
|