hoi4-agent-tools 3.6.0 → 3.7.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 +11 -0
- package/README.md +122 -28
- package/dist/hoi4_agent_tools/core/engine.js +7 -7
- package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
- package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +27 -11
- package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
- package/dist/hoi4_agent_tools/core/scanner.js +133 -47
- package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
- package/dist/hoi4_agent_tools/map/service.js +2 -2
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
- package/dist/hoi4_agent_tools/reference/lines.js +12 -0
- package/dist/hoi4_agent_tools/reference/operations.js +45 -2
- package/dist/hoi4_agent_tools/reference/script-validation.js +277 -0
- package/dist/hoi4_agent_tools/reference/service.js +139 -67
- package/dist/hoi4_agent_tools/reference/source-lookup.js +91 -15
- package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
- package/dist/hoi4_agent_tools/schemas/reference.js +55 -0
- package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/examples.md +1 -11
- package/docs/gui.md +1 -15
- package/docs/reference.md +43 -2
- package/docs/research/reference-benchmark.md +41 -0
- package/docs/tools.md +37 -36
- package/npm-shrinkwrap.json +7088 -0
- package/package.json +7 -6
- 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/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/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 +1 -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 +9 -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 +92 -0
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +68 -2
- package/server.json +2 -2
|
@@ -1,21 +1,72 @@
|
|
|
1
1
|
import { ServiceError } from '../core/result.js';
|
|
2
2
|
import { decodeSource } from '../core/source/encoding.js';
|
|
3
|
-
import {
|
|
3
|
+
import { readBoundedJsonLines } from './lines.js';
|
|
4
4
|
import { scanImpactSemanticReferences } from '../core/impact-semantic.js';
|
|
5
|
+
import { parseClausewitz } from '../core/source/parser.js';
|
|
6
|
+
import { navigateSource } from './source-navigation.js';
|
|
7
|
+
const semanticCache = new WeakMap();
|
|
5
8
|
/** Exact source definitions and usage over the existing shared Clausewitz index. */
|
|
6
9
|
export async function sourceLookup(engine, workspaceId, input, principal, signal) {
|
|
7
|
-
const
|
|
10
|
+
const narrowPatterns = {
|
|
11
|
+
event: ['events/**/*.txt'],
|
|
12
|
+
scripted_effect: ['common/scripted_effects/**/*.txt'],
|
|
13
|
+
scripted_trigger: ['common/scripted_triggers/**/*.txt'],
|
|
14
|
+
};
|
|
15
|
+
const patterns = input.kind === 'event' && input.includeReferences
|
|
16
|
+
? [
|
|
17
|
+
'events/**/*.txt',
|
|
18
|
+
'common/national_focus/**/*.txt',
|
|
19
|
+
'common/continuous_focus/**/*.txt',
|
|
20
|
+
'common/decisions/**/*.txt',
|
|
21
|
+
'common/ideas/**/*.txt',
|
|
22
|
+
'common/technologies/**/*.txt',
|
|
23
|
+
'common/scripted_effects/**/*.txt',
|
|
24
|
+
'common/scripted_triggers/**/*.txt',
|
|
25
|
+
'common/scripted_guis/**/*.txt',
|
|
26
|
+
'common/on_actions/**/*.txt',
|
|
27
|
+
]
|
|
28
|
+
: input.includeReferences || input.kind === undefined
|
|
29
|
+
? undefined
|
|
30
|
+
: narrowPatterns[input.kind];
|
|
31
|
+
const snapshot = await engine.scan(workspaceId, patterns === undefined ? {} : { patterns }, principal, signal);
|
|
8
32
|
if (input.expectedRevision !== undefined && input.expectedRevision !== snapshot.revision) {
|
|
9
33
|
throw new ServiceError('SOURCE_REVISION_STALE', 'Indexed source changed; look up the symbol again', { currentRevision: snapshot.revision });
|
|
10
34
|
}
|
|
11
|
-
const
|
|
35
|
+
const navigating = input.keyPath !== undefined || input.view === 'structure';
|
|
36
|
+
const matchedDefinitions = snapshot.index.symbols
|
|
12
37
|
.filter((entry) => entry.id === input.symbol && (input.kind === undefined || entry.kind === input.kind))
|
|
13
|
-
.sort((left, right) => Number(left.overridden) - Number(right.overridden) || right.loadOrder - left.loadOrder)
|
|
14
|
-
|
|
38
|
+
.sort((left, right) => Number(left.overridden) - Number(right.overridden) || right.loadOrder - left.loadOrder);
|
|
39
|
+
if (navigating && new Set(matchedDefinitions.map(({ kind }) => kind)).size > 1)
|
|
40
|
+
throw new ServiceError('SOURCE_KIND_AMBIGUOUS', 'Nested navigation requires kind when the identifier has multiple symbol kinds');
|
|
41
|
+
const definitions = matchedDefinitions
|
|
42
|
+
.slice(0, navigating ? 1 : input.maxDefinitions)
|
|
15
43
|
.map((entry, index) => {
|
|
16
44
|
const source = snapshot.index.files.get(entry.path);
|
|
17
45
|
const start = entry.location?.start.line;
|
|
18
46
|
const end = entry.location?.end.line;
|
|
47
|
+
const navigation = navigating
|
|
48
|
+
? (() => {
|
|
49
|
+
if (source === undefined ||
|
|
50
|
+
entry.location === undefined ||
|
|
51
|
+
/\.ya?ml$/iu.test(entry.path))
|
|
52
|
+
throw new ServiceError('SOURCE_STRUCTURE_UNAVAILABLE', 'Nested navigation requires an indexed Clausewitz definition');
|
|
53
|
+
return navigateSource(parseClausewitz(source.bytes, source.displayPath), {
|
|
54
|
+
start: entry.location.start.offset,
|
|
55
|
+
end: entry.location.end.offset,
|
|
56
|
+
}, input.keyPath ?? [], {
|
|
57
|
+
structure: input.view === 'structure',
|
|
58
|
+
maxChildren: input.maxChildren,
|
|
59
|
+
childOffset: input.childOffset,
|
|
60
|
+
maxLines: input.maxLines,
|
|
61
|
+
...(index === 0 && input.fromLine !== undefined
|
|
62
|
+
? { fromLine: input.fromLine }
|
|
63
|
+
: {}),
|
|
64
|
+
...(index === 0 && input.fromColumn !== undefined
|
|
65
|
+
? { fromColumn: input.fromColumn }
|
|
66
|
+
: {}),
|
|
67
|
+
});
|
|
68
|
+
})()
|
|
69
|
+
: undefined;
|
|
19
70
|
const fromLine = index === 0 ? (input.fromLine ?? start) : start;
|
|
20
71
|
if (fromLine !== undefined &&
|
|
21
72
|
start !== undefined &&
|
|
@@ -30,7 +81,7 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
30
81
|
.slice((start ?? fromLine) - 1, end);
|
|
31
82
|
const selected = fromLine === undefined || lines.length === 0
|
|
32
83
|
? null
|
|
33
|
-
:
|
|
84
|
+
: readBoundedJsonLines(lines, start ?? fromLine, fromLine, index === 0 ? (input.fromColumn ?? 1) : 1, input.maxLines, 2_000, 4_096);
|
|
34
85
|
return {
|
|
35
86
|
kind: entry.kind,
|
|
36
87
|
id: entry.id,
|
|
@@ -41,16 +92,19 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
41
92
|
sourceShadowed: entry.sourceShadowed,
|
|
42
93
|
startLine: start ?? null,
|
|
43
94
|
endLine: end ?? null,
|
|
44
|
-
fromLine: fromLine ?? null,
|
|
45
|
-
toLine: selected?.endLine ?? null,
|
|
46
|
-
nextLine: selected?.nextLine ?? null,
|
|
47
|
-
nextColumn: selected?.nextColumn ?? null,
|
|
48
|
-
text: selected?.text ?? '',
|
|
95
|
+
fromLine: navigation?.fromLine ?? fromLine ?? null,
|
|
96
|
+
toLine: navigation?.endLine ?? selected?.endLine ?? null,
|
|
97
|
+
nextLine: navigation === undefined ? (selected?.nextLine ?? null) : navigation.nextLine,
|
|
98
|
+
nextColumn: navigation === undefined ? (selected?.nextColumn ?? null) : navigation.nextColumn,
|
|
99
|
+
text: navigation?.text ?? selected?.text ?? '',
|
|
100
|
+
...(navigation === undefined ? {} : { navigation: navigation.navigation }),
|
|
49
101
|
};
|
|
50
102
|
});
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
:
|
|
103
|
+
let semantic = input.includeReferences ? semanticCache.get(snapshot) : undefined;
|
|
104
|
+
if (input.includeReferences && semantic === undefined) {
|
|
105
|
+
semantic = scanImpactSemanticReferences(snapshot, 200_000, { includeOnActions: true });
|
|
106
|
+
semanticCache.set(snapshot, semantic);
|
|
107
|
+
}
|
|
54
108
|
const referenceIdentities = new Set();
|
|
55
109
|
const matchedReferences = input.includeReferences
|
|
56
110
|
? [...snapshot.index.references, ...(semantic?.references ?? [])]
|
|
@@ -70,11 +124,19 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
70
124
|
path: entry.path,
|
|
71
125
|
line: entry.location?.start.line ?? null,
|
|
72
126
|
}));
|
|
73
|
-
|
|
127
|
+
const result = {
|
|
128
|
+
sourceScope: input.kind === 'event'
|
|
129
|
+
? input.includeReferences
|
|
130
|
+
? 'event_consumers'
|
|
131
|
+
: 'event_definitions'
|
|
132
|
+
: patterns === undefined
|
|
133
|
+
? 'workspace_index'
|
|
134
|
+
: 'helper_definitions',
|
|
74
135
|
revision: snapshot.revision,
|
|
75
136
|
complete: snapshot.complete,
|
|
76
137
|
skippedSourceCount: snapshot.skippedSourceCount,
|
|
77
138
|
definitionCount: snapshot.index.symbols.filter((entry) => entry.id === input.symbol && (input.kind === undefined || entry.kind === input.kind)).length,
|
|
139
|
+
definitionsTruncated: matchedDefinitions.length > definitions.length,
|
|
78
140
|
definitions,
|
|
79
141
|
references,
|
|
80
142
|
referencesIncluded: input.includeReferences,
|
|
@@ -87,4 +149,18 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
87
149
|
matchedReferences.length <= references.length,
|
|
88
150
|
unresolvedReferenceCount: semantic?.unresolved.length ?? 0,
|
|
89
151
|
};
|
|
152
|
+
while (Buffer.byteLength(JSON.stringify(result), 'utf8') > 24_000 &&
|
|
153
|
+
result.references.length > 0) {
|
|
154
|
+
result.references.pop();
|
|
155
|
+
result.referencesTruncated = true;
|
|
156
|
+
result.referencesComplete = false;
|
|
157
|
+
}
|
|
158
|
+
while (Buffer.byteLength(JSON.stringify(result), 'utf8') > 24_000 &&
|
|
159
|
+
result.definitions.length > 1) {
|
|
160
|
+
result.definitions.pop();
|
|
161
|
+
result.definitionsTruncated = true;
|
|
162
|
+
}
|
|
163
|
+
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > 24_000)
|
|
164
|
+
throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Selected source metadata exceeds the bounded reply; use entry-index selectors for long nested keys');
|
|
165
|
+
return result;
|
|
90
166
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { ServiceError } from '../core/result.js';
|
|
2
|
+
import { locationFor } from '../core/source/lexer.js';
|
|
3
|
+
import { readBoundedJsonLines } from './lines.js';
|
|
4
|
+
function preview(text, limit) {
|
|
5
|
+
let end = Math.min(text.length, limit);
|
|
6
|
+
if (end > 0 &&
|
|
7
|
+
/[\uD800-\uDBFF]/u.test(text[end - 1]) &&
|
|
8
|
+
/[\uDC00-\uDFFF]/u.test(text[end] ?? ''))
|
|
9
|
+
end--;
|
|
10
|
+
return { text: text.slice(0, end), truncated: end < text.length };
|
|
11
|
+
}
|
|
12
|
+
function findEntry(block, range) {
|
|
13
|
+
for (const entry of block.entries) {
|
|
14
|
+
if (entry.start === range.start && entry.end === range.end)
|
|
15
|
+
return entry;
|
|
16
|
+
const value = entry.type === 'assignment' ? entry.value : entry;
|
|
17
|
+
if (value.type !== 'block' || value.start > range.start || value.end < range.end)
|
|
18
|
+
continue;
|
|
19
|
+
if (value.start === range.start && value.end === range.end)
|
|
20
|
+
return value;
|
|
21
|
+
const found = findEntry(value, range);
|
|
22
|
+
if (found !== undefined)
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Navigate actual parser nodes, preserving duplicate keys and exact source boundaries. */
|
|
28
|
+
export function navigateSource(document, range, keyPath, options) {
|
|
29
|
+
if (document.diagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker'))
|
|
30
|
+
throw new ServiceError('SOURCE_STRUCTURE_UNAVAILABLE', 'Cannot navigate a source document with parse errors');
|
|
31
|
+
const definition = findEntry(document.root, range);
|
|
32
|
+
if (definition === undefined)
|
|
33
|
+
throw new ServiceError('SOURCE_STRUCTURE_UNAVAILABLE', 'Indexed definition does not identify an exact parser node');
|
|
34
|
+
let selected = definition;
|
|
35
|
+
const resolvedPath = [];
|
|
36
|
+
for (const step of keyPath) {
|
|
37
|
+
const value = selected.type === 'assignment' ? selected.value : selected;
|
|
38
|
+
if (value.type !== 'block')
|
|
39
|
+
throw new ServiceError('SOURCE_PATH_NOT_BLOCK', 'Cannot navigate inside a scalar source value', { keyPath: resolvedPath });
|
|
40
|
+
if ('index' in step) {
|
|
41
|
+
const child = value.entries[step.index];
|
|
42
|
+
if (child === undefined)
|
|
43
|
+
throw new ServiceError('SOURCE_INDEX_UNKNOWN', 'No source child exists at the requested entry index', { index: step.index });
|
|
44
|
+
selected = child;
|
|
45
|
+
resolvedPath.push({ index: step.index });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const matches = value.entries.filter((entry) => entry.type === 'assignment' && entry.key.value === step.key);
|
|
49
|
+
if (step.occurrence === undefined && matches.length > 1)
|
|
50
|
+
throw new ServiceError('SOURCE_PATH_AMBIGUOUS', 'Repeated key requires an explicit zero-based occurrence', { key: step.key, occurrences: matches.length });
|
|
51
|
+
const occurrence = step.occurrence ?? 0;
|
|
52
|
+
const child = matches[occurrence];
|
|
53
|
+
if (child === undefined)
|
|
54
|
+
throw new ServiceError('SOURCE_PATH_UNKNOWN', 'No source child matches the requested key and occurrence', { key: step.key, occurrence });
|
|
55
|
+
selected = child;
|
|
56
|
+
resolvedPath.push(Buffer.byteLength(step.key, 'utf8') > 128
|
|
57
|
+
? { index: value.entries.indexOf(child) }
|
|
58
|
+
: { key: step.key, occurrence });
|
|
59
|
+
}
|
|
60
|
+
const location = locationFor(document.path, document.lineIndex, selected.start, selected.end);
|
|
61
|
+
const fromLine = options.fromLine ?? location.start.line;
|
|
62
|
+
const fromColumn = options.fromColumn ?? (fromLine === location.start.line ? location.start.column : 1);
|
|
63
|
+
if (fromLine < location.start.line ||
|
|
64
|
+
fromLine > location.end.line ||
|
|
65
|
+
(fromLine === location.start.line && fromColumn < location.start.column) ||
|
|
66
|
+
(fromLine === location.end.line && fromColumn > location.end.column))
|
|
67
|
+
throw new ServiceError('SOURCE_POSITION_OUTSIDE_SELECTION', 'Requested position is outside the selected parser node');
|
|
68
|
+
const lines = document.text.split(/\r?\n/u).slice(location.start.line - 1, location.end.line);
|
|
69
|
+
lines[lines.length - 1] = lines.at(-1).slice(0, location.end.column - 1);
|
|
70
|
+
const read = options.structure
|
|
71
|
+
? { text: '', endLine: location.end.line, nextLine: null, nextColumn: null }
|
|
72
|
+
: readBoundedJsonLines(lines, location.start.line, fromLine, fromColumn, options.maxLines, 2000, 4096);
|
|
73
|
+
const value = selected.type === 'assignment' ? selected.value : selected;
|
|
74
|
+
const entries = options.structure && value.type === 'block' ? value.entries : [];
|
|
75
|
+
const occurrences = new Map();
|
|
76
|
+
if (options.structure && options.childOffset > entries.length)
|
|
77
|
+
throw new ServiceError('SOURCE_CHILD_OFFSET_OUTSIDE_SELECTION', 'Child offset exceeds the selected block');
|
|
78
|
+
for (const entry of entries.slice(0, options.childOffset)) {
|
|
79
|
+
const key = entry.type === 'assignment' ? entry.key.value : null;
|
|
80
|
+
occurrences.set(key, (occurrences.get(key) ?? 0) + 1);
|
|
81
|
+
}
|
|
82
|
+
const candidates = entries
|
|
83
|
+
.slice(options.childOffset, options.childOffset + options.maxChildren)
|
|
84
|
+
.map((entry, index) => {
|
|
85
|
+
const key = entry.type === 'assignment' ? entry.key.value : null;
|
|
86
|
+
const occurrence = occurrences.get(key) ?? 0;
|
|
87
|
+
occurrences.set(key, occurrence + 1);
|
|
88
|
+
const childValue = entry.type === 'assignment' ? entry.value : entry;
|
|
89
|
+
const childLocation = locationFor(document.path, document.lineIndex, entry.start, entry.end);
|
|
90
|
+
const keyPreview = key === null ? null : preview(key, 1024);
|
|
91
|
+
const valuePreview = childValue.type === 'scalar' ? preview(childValue.value, 120) : null;
|
|
92
|
+
return {
|
|
93
|
+
key: keyPreview?.text ?? null,
|
|
94
|
+
index: options.childOffset + index,
|
|
95
|
+
keyTruncated: keyPreview?.truncated ?? false,
|
|
96
|
+
occurrence,
|
|
97
|
+
kind: childValue.type,
|
|
98
|
+
value: valuePreview?.text ?? null,
|
|
99
|
+
valueTruncated: valuePreview?.truncated ?? false,
|
|
100
|
+
startLine: childLocation.start.line,
|
|
101
|
+
endLine: childLocation.end.line,
|
|
102
|
+
childCount: childValue.type === 'block' ? childValue.entries.length : 0,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
const children = [];
|
|
106
|
+
let childBytes = 2;
|
|
107
|
+
for (const child of candidates) {
|
|
108
|
+
const bytes = Buffer.byteLength(JSON.stringify(child), 'utf8') + 1;
|
|
109
|
+
if (childBytes + bytes > 6000)
|
|
110
|
+
break;
|
|
111
|
+
children.push(child);
|
|
112
|
+
childBytes += bytes;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
...read,
|
|
116
|
+
fromLine,
|
|
117
|
+
navigation: {
|
|
118
|
+
keyPath: resolvedPath,
|
|
119
|
+
startLine: location.start.line,
|
|
120
|
+
startColumn: location.start.column,
|
|
121
|
+
endLine: location.end.line,
|
|
122
|
+
endColumn: location.end.column,
|
|
123
|
+
children,
|
|
124
|
+
omittedChildren: Math.max(0, entries.length - options.childOffset - children.length),
|
|
125
|
+
nextChildOffset: options.childOffset + children.length < entries.length
|
|
126
|
+
? options.childOffset + children.length
|
|
127
|
+
: null,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -26,6 +26,7 @@ export const referenceReadRequestSchema = z
|
|
|
26
26
|
workspaceId: workspaceIdSchema,
|
|
27
27
|
id: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
28
28
|
revision: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
29
|
+
source: referenceSourceSchema.optional(),
|
|
29
30
|
startLine: z.number().int().min(1).optional(),
|
|
30
31
|
startColumn: z.number().int().min(1).optional(),
|
|
31
32
|
maxLines: z.number().int().min(1).max(80).default(40),
|
|
@@ -39,6 +40,17 @@ export const referenceContextRequestSchema = z
|
|
|
39
40
|
limit: z.number().int().min(1).max(24).default(16),
|
|
40
41
|
})
|
|
41
42
|
.strict();
|
|
43
|
+
export const sourceKeyPathSchema = z
|
|
44
|
+
.array(z.union([
|
|
45
|
+
z
|
|
46
|
+
.object({
|
|
47
|
+
key: z.string().min(1).max(1024),
|
|
48
|
+
occurrence: z.number().int().min(0).max(100_000).optional(),
|
|
49
|
+
})
|
|
50
|
+
.strict(),
|
|
51
|
+
z.object({ index: z.number().int().min(0).max(100_000) }).strict(),
|
|
52
|
+
]))
|
|
53
|
+
.max(24);
|
|
42
54
|
export const sourceLookupRequestSchema = z
|
|
43
55
|
.object({
|
|
44
56
|
workspaceId: workspaceIdSchema,
|
|
@@ -51,6 +63,10 @@ export const sourceLookupRequestSchema = z
|
|
|
51
63
|
maxDefinitions: z.number().int().min(1).max(3).default(3),
|
|
52
64
|
maxReferences: z.number().int().min(0).max(20).default(10),
|
|
53
65
|
maxLines: z.number().int().min(1).max(80).default(30),
|
|
66
|
+
keyPath: sourceKeyPathSchema.optional(),
|
|
67
|
+
view: z.enum(['source', 'structure']).default('source'),
|
|
68
|
+
maxChildren: z.number().int().min(1).max(40).default(20),
|
|
69
|
+
childOffset: z.number().int().min(0).max(100_000).default(0),
|
|
54
70
|
fromLine: z.number().int().min(1).optional(),
|
|
55
71
|
fromColumn: z.number().int().min(1).optional(),
|
|
56
72
|
expectedRevision: z
|
|
@@ -67,6 +83,7 @@ const coverage = z
|
|
|
67
83
|
files: count,
|
|
68
84
|
sections: count,
|
|
69
85
|
unavailable: z.boolean(),
|
|
86
|
+
included: z.boolean(),
|
|
70
87
|
})
|
|
71
88
|
.strict();
|
|
72
89
|
const coverageBySource = z
|
|
@@ -90,6 +107,8 @@ export const referenceSearchDataSchema = z
|
|
|
90
107
|
.object({
|
|
91
108
|
results: z.array(referenceSectionSchema.extend({ score: z.number() })).max(12),
|
|
92
109
|
total: count,
|
|
110
|
+
omitted: count,
|
|
111
|
+
limitedByBytes: z.boolean(),
|
|
93
112
|
coverage: coverageBySource,
|
|
94
113
|
skipped: count,
|
|
95
114
|
})
|
|
@@ -109,6 +128,7 @@ export const referenceContextDataSchema = z
|
|
|
109
128
|
surface: referenceSurfaceSchema,
|
|
110
129
|
sections: z.array(referenceSectionSchema).max(24),
|
|
111
130
|
omitted: count,
|
|
131
|
+
limitedByBytes: z.boolean(),
|
|
112
132
|
omittedSources: z.array(z.string().max(256)).max(32),
|
|
113
133
|
missing: z.array(z.string().max(256)).max(32),
|
|
114
134
|
coverage: coverageBySource,
|
|
@@ -117,10 +137,17 @@ export const referenceContextDataSchema = z
|
|
|
117
137
|
.strict();
|
|
118
138
|
export const sourceLookupDataSchema = z
|
|
119
139
|
.object({
|
|
140
|
+
sourceScope: z.enum([
|
|
141
|
+
'workspace_index',
|
|
142
|
+
'event_definitions',
|
|
143
|
+
'event_consumers',
|
|
144
|
+
'helper_definitions',
|
|
145
|
+
]),
|
|
120
146
|
revision: hash,
|
|
121
147
|
complete: z.boolean(),
|
|
122
148
|
skippedSourceCount: count,
|
|
123
149
|
definitionCount: count,
|
|
150
|
+
definitionsTruncated: z.boolean(),
|
|
124
151
|
referencesIncluded: z.boolean(),
|
|
125
152
|
referenceCount: count,
|
|
126
153
|
referencesTruncated: z.boolean(),
|
|
@@ -143,6 +170,34 @@ export const sourceLookupDataSchema = z
|
|
|
143
170
|
nextLine: line.nullable(),
|
|
144
171
|
nextColumn: line.nullable(),
|
|
145
172
|
text: z.string().max(2000),
|
|
173
|
+
navigation: z
|
|
174
|
+
.object({
|
|
175
|
+
keyPath: sourceKeyPathSchema,
|
|
176
|
+
startLine: line,
|
|
177
|
+
startColumn: line,
|
|
178
|
+
endLine: line,
|
|
179
|
+
endColumn: line,
|
|
180
|
+
children: z
|
|
181
|
+
.array(z
|
|
182
|
+
.object({
|
|
183
|
+
key: z.string().max(1024).nullable(),
|
|
184
|
+
index: count,
|
|
185
|
+
keyTruncated: z.boolean(),
|
|
186
|
+
occurrence: count,
|
|
187
|
+
kind: z.enum(['scalar', 'block']),
|
|
188
|
+
value: z.string().max(120).nullable(),
|
|
189
|
+
valueTruncated: z.boolean(),
|
|
190
|
+
startLine: line,
|
|
191
|
+
endLine: line,
|
|
192
|
+
childCount: count,
|
|
193
|
+
})
|
|
194
|
+
.strict())
|
|
195
|
+
.max(40),
|
|
196
|
+
omittedChildren: count,
|
|
197
|
+
nextChildOffset: count.nullable(),
|
|
198
|
+
})
|
|
199
|
+
.strict()
|
|
200
|
+
.optional(),
|
|
146
201
|
})
|
|
147
202
|
.strict())
|
|
148
203
|
.max(3),
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
import { workspaceIdSchema } from './common.js';
|
|
3
|
+
import { referenceSectionSchema } from './reference.js';
|
|
4
|
+
const scope = z.enum([
|
|
5
|
+
'country',
|
|
6
|
+
'state',
|
|
7
|
+
'character',
|
|
8
|
+
'combatant',
|
|
9
|
+
'ace',
|
|
10
|
+
'strategic_region',
|
|
11
|
+
'operation',
|
|
12
|
+
'industrial_org',
|
|
13
|
+
'purchase_contract',
|
|
14
|
+
'raid_instance',
|
|
15
|
+
'special_project',
|
|
16
|
+
'faction',
|
|
17
|
+
]);
|
|
18
|
+
export const scriptValidateRequestSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
workspaceId: workspaceIdSchema,
|
|
21
|
+
source: z.string().min(1).max(64_000),
|
|
22
|
+
kind: z.enum(['effect', 'trigger']),
|
|
23
|
+
scope,
|
|
24
|
+
documentation: z.enum(['game_doc', 'script_doc']).default('game_doc'),
|
|
25
|
+
bindings: z
|
|
26
|
+
.array(z.object({ name: z.string().min(1).max(256), scope }).strict())
|
|
27
|
+
.max(16)
|
|
28
|
+
.default([]),
|
|
29
|
+
limit: z.number().int().min(1).max(32).default(12),
|
|
30
|
+
})
|
|
31
|
+
.strict();
|
|
32
|
+
export const scriptValidateDataSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
valid: z.boolean().nullable(),
|
|
35
|
+
checksPerformed: z.array(z.enum(['syntax', 'command_kind', 'declared_scope'])),
|
|
36
|
+
parametersChecked: z.literal(false),
|
|
37
|
+
documentation: z
|
|
38
|
+
.object({
|
|
39
|
+
source: z.enum(['game_doc', 'script_doc']),
|
|
40
|
+
revision: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
41
|
+
effectCount: z.number().int().min(0),
|
|
42
|
+
triggerCount: z.number().int().min(0),
|
|
43
|
+
skippedSources: z.number().int().min(0),
|
|
44
|
+
})
|
|
45
|
+
.strict(),
|
|
46
|
+
findings: z
|
|
47
|
+
.array(z
|
|
48
|
+
.object({
|
|
49
|
+
code: z.string().max(80),
|
|
50
|
+
status: z.enum(['supported', 'error', 'unresolved']),
|
|
51
|
+
command: z.string().max(256),
|
|
52
|
+
kind: z.enum(['effect', 'trigger']),
|
|
53
|
+
scope: z.string().max(64),
|
|
54
|
+
line: z.number().int().min(1),
|
|
55
|
+
column: z.number().int().min(1),
|
|
56
|
+
message: z.string().max(500),
|
|
57
|
+
suggestions: z.array(z.string().max(256)).max(3),
|
|
58
|
+
reference: referenceSectionSchema.optional(),
|
|
59
|
+
})
|
|
60
|
+
.strict())
|
|
61
|
+
.max(32),
|
|
62
|
+
totalFindings: z.number().int().min(0),
|
|
63
|
+
omittedFindings: z.number().int().min(0),
|
|
64
|
+
checkedCommands: z.number().int().min(0),
|
|
65
|
+
unresolvedCount: z.number().int().min(0),
|
|
66
|
+
truncated: z.boolean(),
|
|
67
|
+
argumentBlocksUnchecked: z.number().int().min(0),
|
|
68
|
+
})
|
|
69
|
+
.strict();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
export const PACKAGE_NAME = 'hoi4-agent-tools';
|
|
3
|
-
export const PACKAGE_VERSION = '3.
|
|
3
|
+
export const PACKAGE_VERSION = '3.7.0';
|
|
4
4
|
export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
|
|
5
5
|
export const CONFIG_VERSION = 1;
|
|
6
6
|
export const TRANSACTION_VERSION = 1;
|
package/docs/README.md
CHANGED
|
@@ -20,6 +20,7 @@ You can ask your coding agent for these tasks in ordinary language; you do not n
|
|
|
20
20
|
## See the results
|
|
21
21
|
|
|
22
22
|
- [Examples](examples.md): practical prompts with real renders.
|
|
23
|
+
- [Latest release](https://github.com/klimPaskov/hoi4-agent-tools/releases/latest): a short introduction to the published server.
|
|
23
24
|
- [Focus and technology comparisons](visual-comparisons.md): supplied game captures beside source renders.
|
|
24
25
|
- [Interface comparisons](gui-comparisons.md): vanilla and Chaos Redux windows.
|
|
25
26
|
|
package/docs/examples.md
CHANGED
|
@@ -14,16 +14,6 @@ No wiki download or game launch was needed.
|
|
|
14
14
|
|
|
15
15
|
Use [Local references](reference.md) to configure an offline wiki snapshot or narrow a search.
|
|
16
16
|
|
|
17
|
-
## Check alternative focus prerequisites
|
|
18
|
-
|
|
19
|
-
> Find Fury's focus tree. Render the branch leading to “The Last Neighbor Has Fallen” and show whether both preceding focuses are required or either one is enough.
|
|
20
|
-
|
|
21
|
-

|
|
22
|
-
|
|
23
|
-
Dotted connectors mark alternatives within one prerequisite group; solid connectors mark separate required prerequisites.
|
|
24
|
-
The agent can link each finding to the source block before you change the route.
|
|
25
|
-
The [in-game comparison](visual-comparisons.md#focus-trees) shows the same branch and explains the presentation differences.
|
|
26
|
-
|
|
27
17
|
## Review a large tree
|
|
28
18
|
|
|
29
19
|
> Render the Utopia Manifesto focus tree. Check its branch connections, alternative prerequisites, missing references and crowded areas before suggesting edits.
|
|
@@ -41,7 +31,7 @@ The [focus guide](focus.md) covers inspecting, rendering and reorganizing trees.
|
|
|
41
31
|
[](images/comparisons/infantry-mcp.png)
|
|
42
32
|
|
|
43
33
|
The preview follows the loaded technology and GUI definitions, including wide equipment cards.
|
|
44
|
-
The [technology comparisons](visual-comparisons.md#technology-folders) also show chemical
|
|
34
|
+
The [technology comparisons](visual-comparisons.md#technology-folders) also show a chemical folder rendered from a matching recorded source revision.
|
|
45
35
|
See the [technology guide](technology.md) for grants, bonuses and doctrine paths.
|
|
46
36
|
|
|
47
37
|
## Inspect an interface before editing it
|
package/docs/gui.md
CHANGED
|
@@ -14,22 +14,8 @@ These are source-model results, not proof of native click execution.
|
|
|
14
14
|
|
|
15
15
|
## Preview immediate tooltip text
|
|
16
16
|
|
|
17
|
-
An explicit tooltip request uses a visible element's `pdx_tooltip` localisation key
|
|
17
|
+
An explicit tooltip request uses a visible element's `pdx_tooltip` localisation key. Supply `tooltip.target` with its inspected instance ID or a unique element name, plus viewport `x`, `y`, `width` and `padding` values.
|
|
18
18
|
|
|
19
|
-
```json
|
|
20
|
-
{
|
|
21
|
-
"id": "close-tooltip",
|
|
22
|
-
"tooltip": {
|
|
23
|
-
"target": "close_button",
|
|
24
|
-
"x": 980,
|
|
25
|
-
"y": 100,
|
|
26
|
-
"width": 160,
|
|
27
|
-
"padding": 16
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
The target can be an instance ID from inspection or a unique element name.
|
|
33
19
|
Position, width and padding are declared viewport pixels; height follows the measured text.
|
|
34
20
|
The preview uses the loaded `ToolTip` sprite and `cg_16b` font.
|
|
35
21
|
Missing or ambiguous targets, hidden or unresolved visibility, missing text/assets, and bounds errors produce diagnostics without a guessed popup.
|
package/docs/reference.md
CHANGED
|
@@ -15,9 +15,50 @@ Only Markdown headings and recognizable standalone names in generated `.log` fil
|
|
|
15
15
|
## Tools
|
|
16
16
|
|
|
17
17
|
- `hoi4.reference_context`: request a surface such as `event`, `focus`, `technology`, `gui`, or `map`. It reserves citations for available installed documentation and offline wiki sources before adding further question matches. Supply `question` to select the best matching section within each required source. At a limit of two or more, both source kinds are represented when available; a one-result limit prioritizes installed documentation. `omittedSources` names required sources excluded by the result limit, while `missing` names sources absent from the configured roots. Increase the limit or search an omitted source directly before treating the bundle as sufficient. Citations are pointers and excerpts; read the pertinent sections before changing code.
|
|
18
|
-
- `hoi4.reference_search`: search page names, headings, exact script tokens, and matching body lines. The default limit is six results and the maximum is twelve. Filter `sources` to `game_doc`, `wiki`, or `script_doc` when appropriate.
|
|
19
|
-
- `hoi4.reference_read`: provide a search/context result's `id` and `revision`. Read up to 80 lines and 8,000 UTF-8 bytes. Continue with the returned `nextLine`, `nextColumn` as `startColumn`, and the same revision. Long source lines continue without losing characters. A changed source returns `REFERENCE_REVISION_STALE`.
|
|
18
|
+
- `hoi4.reference_search`: search page names, headings, exact script tokens, and matching body lines. The default limit is six results and the maximum is twelve. Filter `sources` to `game_doc`, `wiki`, or `script_doc` when appropriate. Only selected authorities are scanned; `coverage.included = false` means that an authority was excluded from this query, not that it is missing.
|
|
19
|
+
- `hoi4.reference_read`: provide a search/context result's `id` and `revision`. Optionally pass its `source` for a focused authority read; resident citations provide this hint automatically. Read up to 80 lines and 8,000 UTF-8 bytes, with JSON escaping also bounded. Continue with the returned `nextLine`, `nextColumn` as `startColumn`, and the same revision. Long source lines continue without losing characters. A changed source returns `REFERENCE_REVISION_STALE`.
|
|
20
20
|
- `hoi4.source_lookup`: find exact definitions and indexed usages in the installed game and configured mod source. The response includes load order, override status, up to three source blocks bounded to 2,000 bytes each, a scan revision, and explicit reference counts and truncation. Follow `nextLine` and `nextColumn` as `fromLine` and `fromColumn`, with `expectedRevision`, to read more of the active definition.
|
|
21
|
+
- `hoi4.script_validate`: check a bounded effect or trigger body against installed native-command documentation. Supply the body in `source`, its `kind`, and its current `scope`. Results cover syntax, command kind, and declared scope, with exact documentation citations. They do not validate command parameters or prove game execution.
|
|
22
|
+
|
|
23
|
+
## Navigate a definition
|
|
24
|
+
|
|
25
|
+
Use `view: "structure"` to list the immediate children of the active definition, then pass `keyPath` to select a nested block or scalar assignment.
|
|
26
|
+
Keyed path steps have a `key` and an optional zero-based `occurrence`; repeated keys require an explicit occurrence.
|
|
27
|
+
Use an `{ "index": 1 }` step to select an unkeyed array entry or a child with an unusually long key.
|
|
28
|
+
For example, `[{"key":"option","occurrence":1},{"key":"hidden_effect"}]` selects the second option's hidden effects.
|
|
29
|
+
Navigation reads exact parser boundaries, including when several assignments share a line.
|
|
30
|
+
If an identifier has multiple symbol kinds, supply `kind` before navigating it.
|
|
31
|
+
Override counts remain visible, while navigation selects the active definition.
|
|
32
|
+
|
|
33
|
+
Structure replies have at most 40 children.
|
|
34
|
+
They also have a byte ceiling, with `keyTruncated` and `valueTruncated` identifying shortened previews.
|
|
35
|
+
Continue with `navigation.nextChildOffset` as `childOffset`, retaining the same `keyPath`, query settings, and `expectedRevision`.
|
|
36
|
+
Source replies continue with `nextLine` and `nextColumn` as before.
|
|
37
|
+
Use `includeReferences: false` with an exact `event`, `scripted_effect`, or `scripted_trigger` kind when only the definition is needed; this limits scanning to that source family.
|
|
38
|
+
Enable references when looking for consumers across the workspace.
|
|
39
|
+
Event-consumer queries scan the source families supported by event reference analysis rather than unrelated presentation files.
|
|
40
|
+
`sourceScope` labels the scanned surface, and `definitionsTruncated` and reference omission fields identify bounded output.
|
|
41
|
+
Revisions describe the selected scan, so keep the scan settings unchanged during continuation.
|
|
42
|
+
|
|
43
|
+
## Check native commands
|
|
44
|
+
|
|
45
|
+
`hoi4.script_validate` accepts a standalone effect or trigger body rather than an entire event or focus definition.
|
|
46
|
+
It recognizes documented control wrappers, conditional limits, country/state iterators, fixed country/state scopes, and the `ROOT`, `THIS`, and `PREV` traversal context.
|
|
47
|
+
Use `bindings` to declare external scope names such as `event_target:recipient` when their type is known.
|
|
48
|
+
Those bindings are supplied facts, not observed game state.
|
|
49
|
+
|
|
50
|
+
The default documentation authority is `game_doc`.
|
|
51
|
+
Use `documentation: "script_doc"` explicitly for a configured generated dump; the checker does not silently mix authorities or substitute wiki text for native signatures.
|
|
52
|
+
It reads supported scopes from `effects_documentation.md`, `triggers_documentation.md`, and corresponding `effect_docs.log`/`trigger_docs.log` files.
|
|
53
|
+
|
|
54
|
+
`valid: false` identifies a syntax, kind, or scope error.
|
|
55
|
+
`valid: null` identifies missing or conflicting documentation, an unresolved command/helper, an unchecked native argument block, or exhausted coverage.
|
|
56
|
+
`valid: true` applies only to `checksPerformed`; `parametersChecked` remains false.
|
|
57
|
+
Inspect the cited parameter syntax and use the relevant domain tools for broader validation.
|
|
58
|
+
Replies prioritize problems, bound `findings`, and report `omittedFindings`; execution is bounded to 256 visited entries and 64,000 UTF-8 source bytes.
|
|
59
|
+
Search and context replies also report `limitedByBytes` when metadata exceeds their reply budget.
|
|
60
|
+
Read omitted required sources with targeted calls rather than repeatedly requesting the same oversized bundle.
|
|
61
|
+
Reference text supports UTF-8, Windows-1252, and explicitly BOM-marked UTF-16; native engine source encoding rules remain separate.
|
|
21
62
|
|
|
22
63
|
Example:
|
|
23
64
|
|
|
@@ -40,3 +40,44 @@ For the GUI question “Where are scripted GUI click effects and triggers define
|
|
|
40
40
|
| 8 | Installed documentation and wiki | 0 | 5,926 | 339 |
|
|
41
41
|
|
|
42
42
|
The default six-surface context bundles used 3,545–5,556 bytes including omission metadata. A smaller response is not sufficient evidence when `omittedSources` is nonempty: increase the limit or search and read those sources directly. The evaluation script fails when a compact budget drops a required source kind or silently omits its required-source pointers.
|
|
43
|
+
|
|
44
|
+
## Source navigation and scan measurements
|
|
45
|
+
|
|
46
|
+
On 2026-10-03, `scripts/evaluate-source-tools.ts --game-only` selected the installed `political.1` definition, listed its options, read exactly `add_political_power = -100` from its first option, and checked that command against installed documentation.
|
|
47
|
+
The country-scope check succeeded and the state-scope check reported the documented mismatch.
|
|
48
|
+
The evaluation retained source and documentation revisions and compared repeated reference results for equality.
|
|
49
|
+
|
|
50
|
+
| Operation | Initial cache-enabled run | Scoped-cache run | Reply bytes |
|
|
51
|
+
| ------------------------------ | ------------------------: | ---------------: | ----------: |
|
|
52
|
+
| Cold event structure | 55,550 ms | 24,723 ms | 2,258 |
|
|
53
|
+
| Repeated nested read | 20,936 ms | 3,565 ms | 862 |
|
|
54
|
+
| Installed command check | 1,471 ms | 10,804 ms | 1,152 |
|
|
55
|
+
| Event-consumer lookup | Timed out at 180,002 ms | 145,532 ms | 1,363 |
|
|
56
|
+
| Repeated event-consumer lookup | Not completed in that run | 16,233 ms | 1,363 |
|
|
57
|
+
|
|
58
|
+
A separate run with persistent caching disabled completed the broader lookup in 102,437 ms and its repeated lookup in 122,023 ms.
|
|
59
|
+
This helped identify filesystem and cache costs; it is not a causal comparison under identical machine load.
|
|
60
|
+
The candidate bounds optional cache writes, skips resident hydration, overlaps source verification, and scopes event queries to the analyser's supported owner sources.
|
|
61
|
+
Its persistent cache recorded 32 writes across two bounded batches, with no invalid entries or evictions.
|
|
62
|
+
The process recorded 616–625 MiB resident memory during the consumer lookups; these are sampled values, not peak-memory guarantees.
|
|
63
|
+
|
|
64
|
+
Other work was running on the host, and latency varied materially.
|
|
65
|
+
The command-document check was slower in the scoped-cache run, so these measurements must not be presented as an improvement for every operation or as a latency guarantee.
|
|
66
|
+
The selected source index was complete, while reference completeness remained false because 2,379 dynamic references were unresolved.
|
|
67
|
+
Static retrieval does not resolve those engine-dependent dispatches or establish runtime behavior.
|
|
68
|
+
|
|
69
|
+
These source timings preceded the subsequent batched reference reader and glob-library replacement.
|
|
70
|
+
A separate 2026-10-03 reference evaluation found all nine expected pages and answer-bearing sections and passed all eighteen compact-context cases, with no missing required sources or budget failures.
|
|
71
|
+
Its six context replies used 3,616–5,627 JSON bytes.
|
|
72
|
+
The candidate's complete MCP discovery list contains 35 tools and is tested against a 75 KiB ceiling.
|
|
73
|
+
|
|
74
|
+
## Worker startup
|
|
75
|
+
|
|
76
|
+
Five isolated readiness handshakes on 2026-10-03 averaged 2,163 ms with eager imports and 1,408 ms with authenticated domain loading.
|
|
77
|
+
The individual samples were 2,056/2,570/2,349/1,857/1,985 ms and 3,213/930/995/960/942 ms respectively.
|
|
78
|
+
The first changed-worker sample includes a cold TypeScript loader cache.
|
|
79
|
+
These samples measure the readiness handshake without running a job, and do not establish complete request latency or throughput.
|
|
80
|
+
Concurrent HTTP qualification keeps its original four-minute total request ceiling.
|
|
81
|
+
Two earlier 64-request runs exceeded that ceiling for 23 and five requests respectively.
|
|
82
|
+
The isolated run after domain loading passed all 64 requests in a 124.49-second test, including fixture setup and cleanup.
|
|
83
|
+
Shared-host load varied between runs, so these are qualification receipts rather than a controlled throughput comparison.
|