hoi4-agent-tools 3.8.1 → 3.10.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 +24 -1
- package/README.md +1 -1
- package/dist/bin/http.js +2 -0
- package/dist/bin/setup.js +5 -2
- package/dist/bin/stdio.js +29 -1
- package/dist/hoi4_agent_tools/core/configuration.js +8 -0
- package/dist/hoi4_agent_tools/core/engine.js +27 -8
- package/dist/hoi4_agent_tools/core/index.js +45 -20
- package/dist/hoi4_agent_tools/core/job-worker-host.js +40 -2
- package/dist/hoi4_agent_tools/core/job-worker.js +14 -0
- package/dist/hoi4_agent_tools/core/scenario-model.js +1 -1
- package/dist/hoi4_agent_tools/core/server-registry.js +138 -0
- package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
- package/dist/hoi4_agent_tools/core/source/cache.js +89 -20
- package/dist/hoi4_agent_tools/event/service.js +7 -0
- package/dist/hoi4_agent_tools/gui/renderer.js +8 -2
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +56 -0
- package/dist/hoi4_agent_tools/gui/scenario.js +2 -0
- package/dist/hoi4_agent_tools/gui/studio.js +31 -12
- package/dist/hoi4_agent_tools/gui/validators.js +22 -0
- package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
- package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +5 -2
- package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +9 -0
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +79 -0
- package/dist/hoi4_agent_tools/reference/event-popups.js +87 -0
- package/dist/hoi4_agent_tools/reference/operations.js +151 -1
- package/dist/hoi4_agent_tools/reference/player-text.js +158 -0
- package/dist/hoi4_agent_tools/reference/script-validation.js +315 -33
- package/dist/hoi4_agent_tools/reference/source-locate.js +1 -1
- package/dist/hoi4_agent_tools/schemas/gui-requests.js +18 -4
- package/dist/hoi4_agent_tools/schemas/script-validation.js +40 -7
- package/dist/hoi4_agent_tools/schemas/technology.js +4 -2
- package/dist/hoi4_agent_tools/technology/service.js +7 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/gui.md +25 -0
- package/docs/jobs.md +8 -0
- package/docs/reference.md +38 -0
- package/docs/setup.md +11 -0
- package/docs/tools.md +1 -1
- package/npm-shrinkwrap.json +12 -8
- package/package.json +1 -1
- package/schemas/configuration.schema.json +163 -128
- 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 +38 -22
- 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 +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 +60 -36
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +2 -2
|
@@ -2,9 +2,158 @@ import path from 'node:path';
|
|
|
2
2
|
import { hashCanonical } from '../core/canonical.js';
|
|
3
3
|
import { ServiceError } from '../core/result.js';
|
|
4
4
|
import { locationFor, parseClausewitz, } from '../core/source/index.js';
|
|
5
|
+
import { repeatedPopupFindings } from './event-popups.js';
|
|
6
|
+
import { playerTextFindings, playerTextPosition } from './player-text.js';
|
|
5
7
|
import { editDistance } from './suggestions.js';
|
|
6
8
|
import { publicReferenceSection } from './service.js';
|
|
7
9
|
const MAX_COMMANDS = 256;
|
|
10
|
+
const MAX_FILE_COMMANDS = 50_000;
|
|
11
|
+
/** A block whose scope the file's structure does not determine; scope checks are skipped. */
|
|
12
|
+
const UNKNOWN_SCOPE = 'unknown';
|
|
13
|
+
/**
|
|
14
|
+
* Scope links from the wiki Scopes page: the scopes each link is usable in and the scope it
|
|
15
|
+
* enters. The engine accepts these keys in any letter case.
|
|
16
|
+
*/
|
|
17
|
+
const scopeLinks = {
|
|
18
|
+
owner: { from: ['state', 'character', 'combatant'], to: 'country' },
|
|
19
|
+
controller: { from: ['state'], to: 'country' },
|
|
20
|
+
capital_scope: { from: ['country'], to: 'state' },
|
|
21
|
+
overlord: { from: ['country'], to: 'country' },
|
|
22
|
+
faction_leader: { from: ['country'], to: 'country' },
|
|
23
|
+
};
|
|
24
|
+
const eventScopes = {
|
|
25
|
+
country_event: 'country',
|
|
26
|
+
news_event: 'country',
|
|
27
|
+
state_event: 'state',
|
|
28
|
+
unit_leader_event: 'character',
|
|
29
|
+
operative_leader_event: 'character',
|
|
30
|
+
};
|
|
31
|
+
const optionMetadata = new Set([
|
|
32
|
+
'name',
|
|
33
|
+
'ai_chance',
|
|
34
|
+
'original_recipient_only',
|
|
35
|
+
'highlight_states',
|
|
36
|
+
'is_triggered_only',
|
|
37
|
+
]);
|
|
38
|
+
const decisionTriggers = new Set([
|
|
39
|
+
'allowed',
|
|
40
|
+
'visible',
|
|
41
|
+
'available',
|
|
42
|
+
'cancel_trigger',
|
|
43
|
+
'remove_trigger',
|
|
44
|
+
'activation',
|
|
45
|
+
]);
|
|
46
|
+
const decisionEffects = new Set([
|
|
47
|
+
'complete_effect',
|
|
48
|
+
'remove_effect',
|
|
49
|
+
'timeout_effect',
|
|
50
|
+
'cancel_effect',
|
|
51
|
+
]);
|
|
52
|
+
const focusTriggers = new Set(['available', 'bypass', 'cancel', 'allow_branch']);
|
|
53
|
+
const focusEffects = new Set([
|
|
54
|
+
'completion_reward',
|
|
55
|
+
'select_effect',
|
|
56
|
+
'cancel_effect',
|
|
57
|
+
'bypass_effect',
|
|
58
|
+
]);
|
|
59
|
+
function blockOf(entry) {
|
|
60
|
+
return entry.type === 'assignment' && entry.value.type === 'block' ? entry.value : undefined;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The effect and trigger bodies of a mod file, with the scope its structure fixes. Families
|
|
64
|
+
* whose scope depends on the caller, such as scripted helpers and on actions, are checked
|
|
65
|
+
* without scope rules. An unrecognized family yields no roots.
|
|
66
|
+
*/
|
|
67
|
+
export function scriptRoots(relativePath, document) {
|
|
68
|
+
const file = relativePath.replaceAll('\\', '/').toLowerCase();
|
|
69
|
+
const roots = [];
|
|
70
|
+
const add = (block, kind, scope) => {
|
|
71
|
+
if (block !== undefined)
|
|
72
|
+
roots.push({ block, kind, scope });
|
|
73
|
+
};
|
|
74
|
+
const top = document.root.entries.filter((entry) => entry.type === 'assignment');
|
|
75
|
+
if (file.startsWith('events/')) {
|
|
76
|
+
for (const event of top) {
|
|
77
|
+
const scope = eventScopes[event.key.value];
|
|
78
|
+
const body = blockOf(event);
|
|
79
|
+
if (scope === undefined || body === undefined)
|
|
80
|
+
continue;
|
|
81
|
+
for (const entry of body.entries) {
|
|
82
|
+
if (entry.type !== 'assignment')
|
|
83
|
+
continue;
|
|
84
|
+
const key = entry.key.value;
|
|
85
|
+
if (key === 'trigger' || key === 'show_major')
|
|
86
|
+
add(blockOf(entry), 'trigger', scope);
|
|
87
|
+
else if (key === 'immediate' || key === 'after')
|
|
88
|
+
add(blockOf(entry), 'effect', scope);
|
|
89
|
+
else if (key === 'option') {
|
|
90
|
+
const option = blockOf(entry);
|
|
91
|
+
if (option === undefined)
|
|
92
|
+
continue;
|
|
93
|
+
const effects = option.entries.filter((child) => child.type !== 'assignment' ||
|
|
94
|
+
(child.key.value !== 'trigger' && !optionMetadata.has(child.key.value)));
|
|
95
|
+
for (const child of option.entries)
|
|
96
|
+
if (child.type === 'assignment' && child.key.value === 'trigger')
|
|
97
|
+
add(blockOf(child), 'trigger', scope);
|
|
98
|
+
if (effects.length > 0)
|
|
99
|
+
add({ ...option, entries: effects }, 'effect', scope);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { family: 'events', roots };
|
|
104
|
+
}
|
|
105
|
+
if (file.startsWith('common/decisions/') && !file.startsWith('common/decisions/categories/')) {
|
|
106
|
+
for (const category of top)
|
|
107
|
+
for (const decision of blockOf(category)?.entries ?? []) {
|
|
108
|
+
const body = blockOf(decision);
|
|
109
|
+
if (body === undefined)
|
|
110
|
+
continue;
|
|
111
|
+
for (const entry of body.entries) {
|
|
112
|
+
if (entry.type !== 'assignment')
|
|
113
|
+
continue;
|
|
114
|
+
if (decisionTriggers.has(entry.key.value))
|
|
115
|
+
add(blockOf(entry), 'trigger', 'country');
|
|
116
|
+
else if (entry.key.value === 'target_trigger')
|
|
117
|
+
add(blockOf(entry), 'trigger', UNKNOWN_SCOPE);
|
|
118
|
+
else if (decisionEffects.has(entry.key.value))
|
|
119
|
+
add(blockOf(entry), 'effect', 'country');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { family: 'decisions', roots };
|
|
123
|
+
}
|
|
124
|
+
if (file.startsWith('common/national_focus/')) {
|
|
125
|
+
const focuses = top.flatMap((entry) => entry.key.value === 'shared_focus'
|
|
126
|
+
? [entry]
|
|
127
|
+
: entry.key.value === 'focus_tree'
|
|
128
|
+
? (blockOf(entry)?.entries ?? []).filter((child) => child.type === 'assignment' && child.key.value === 'focus')
|
|
129
|
+
: []);
|
|
130
|
+
for (const focus of focuses)
|
|
131
|
+
for (const entry of blockOf(focus)?.entries ?? []) {
|
|
132
|
+
if (entry.type !== 'assignment')
|
|
133
|
+
continue;
|
|
134
|
+
if (focusTriggers.has(entry.key.value))
|
|
135
|
+
add(blockOf(entry), 'trigger', 'country');
|
|
136
|
+
else if (focusEffects.has(entry.key.value))
|
|
137
|
+
add(blockOf(entry), 'effect', 'country');
|
|
138
|
+
}
|
|
139
|
+
return { family: 'national_focus', roots };
|
|
140
|
+
}
|
|
141
|
+
if (file.startsWith('common/scripted_effects/') || file.startsWith('common/scripted_triggers/')) {
|
|
142
|
+
const kind = file.startsWith('common/scripted_effects/') ? 'effect' : 'trigger';
|
|
143
|
+
for (const helper of top)
|
|
144
|
+
add(blockOf(helper), kind, UNKNOWN_SCOPE);
|
|
145
|
+
return { family: kind === 'effect' ? 'scripted_effects' : 'scripted_triggers', roots };
|
|
146
|
+
}
|
|
147
|
+
if (file.startsWith('common/on_actions/')) {
|
|
148
|
+
for (const container of top)
|
|
149
|
+
for (const action of blockOf(container)?.entries ?? [])
|
|
150
|
+
for (const entry of blockOf(action)?.entries ?? [])
|
|
151
|
+
if (entry.type === 'assignment' && entry.key.value === 'effect')
|
|
152
|
+
add(blockOf(entry), 'effect', UNKNOWN_SCOPE);
|
|
153
|
+
return { family: 'on_actions', roots };
|
|
154
|
+
}
|
|
155
|
+
return { family: 'unsupported', roots };
|
|
156
|
+
}
|
|
8
157
|
const booleanCommands = new Set(['AND', 'OR', 'NOT', 'XOR', 'NAND', 'NOR']);
|
|
9
158
|
const containers = new Set([
|
|
10
159
|
'and',
|
|
@@ -24,14 +173,21 @@ const iteratorParameters = new Set([
|
|
|
24
173
|
'count',
|
|
25
174
|
]);
|
|
26
175
|
/** Check only rules explicitly available from one selected documentation authority. */
|
|
27
|
-
export async function validateScript(references, workspace, input, signal) {
|
|
28
|
-
if (Buffer.byteLength(input.source, 'utf8') > 64_000)
|
|
176
|
+
export async function validateScript(references, workspace, input, signal, file) {
|
|
177
|
+
if (file === undefined && Buffer.byteLength(input.source ?? '', 'utf8') > 64_000)
|
|
29
178
|
throw new ServiceError('SCRIPT_SOURCE_TOO_LARGE', 'Snippet exceeds 64000 UTF-8 bytes');
|
|
30
179
|
const inventory = await references.inventory(workspace, signal, [input.documentation]);
|
|
31
180
|
const catalogs = { effect: new Map(), trigger: new Map() };
|
|
32
181
|
const sources = new Map();
|
|
182
|
+
const dynamicVariables = new Set();
|
|
183
|
+
// Generic triggers such as `building_count_trigger` are written with a listed name as the
|
|
184
|
+
// key (`arms_factory > 2`); each listed name checks as that documented trigger.
|
|
185
|
+
const implicitNames = new Map();
|
|
33
186
|
for (const section of inventory.sections) {
|
|
34
187
|
const filename = path.basename(section.path).toLowerCase();
|
|
188
|
+
if (filename === 'dynamic_variables_documentation.md' &&
|
|
189
|
+
/^[a-z_][a-z0-9_]{0,255}$/u.test(section.heading))
|
|
190
|
+
dynamicVariables.add(section.heading);
|
|
35
191
|
const match = /^(effects?|triggers?)_(?:documentation|docs)\.(?:md|log)$/u.exec(filename);
|
|
36
192
|
if (match === null)
|
|
37
193
|
continue;
|
|
@@ -48,6 +204,12 @@ export async function validateScript(references, workspace, input, signal) {
|
|
|
48
204
|
.filter(Boolean)),
|
|
49
205
|
].sort();
|
|
50
206
|
const kind = match[1].startsWith('effect') ? 'effect' : 'trigger';
|
|
207
|
+
const body = section.lines.join('\n');
|
|
208
|
+
const listed = /^Supported (?!Scopes|Targets)[A-Za-z ]+:\s*([a-z0-9_,\s]+?)\.?\s*$/mu.exec(body);
|
|
209
|
+
if (kind === 'trigger' && listed !== null && /^Usage:\s*<[A-Za-z]+>/mu.test(body))
|
|
210
|
+
for (const name of listed[1].split(/[,\s]+/u).filter(Boolean))
|
|
211
|
+
if (!implicitNames.has(name))
|
|
212
|
+
implicitNames.set(name, section.heading);
|
|
51
213
|
const previous = catalogs[kind].get(section.heading);
|
|
52
214
|
const reference = publicReferenceSection(section);
|
|
53
215
|
if (previous !== undefined) {
|
|
@@ -56,7 +218,15 @@ export async function validateScript(references, workspace, input, signal) {
|
|
|
56
218
|
else
|
|
57
219
|
catalogs[kind].set(section.heading, { reference, scopes, conflict: false });
|
|
58
220
|
}
|
|
59
|
-
const
|
|
221
|
+
for (const [name, heading] of [...implicitNames, ...(file?.implicitTriggers ?? [])]) {
|
|
222
|
+
const signature = catalogs.trigger.get(heading);
|
|
223
|
+
if (signature !== undefined && !catalogs.trigger.has(name))
|
|
224
|
+
catalogs.trigger.set(name, signature);
|
|
225
|
+
}
|
|
226
|
+
const documentPath = file?.path ?? 'snippet';
|
|
227
|
+
const document = parseClausewitz(file?.bytes ?? Buffer.from(input.source ?? '', 'utf8'), documentPath);
|
|
228
|
+
const commandLimit = file === undefined ? MAX_COMMANDS : MAX_FILE_COMMANDS;
|
|
229
|
+
let rootScope = input.scope ?? UNKNOWN_SCOPE;
|
|
60
230
|
const findings = [];
|
|
61
231
|
let visited = 0;
|
|
62
232
|
let checkedCommands = 0;
|
|
@@ -71,7 +241,11 @@ export async function validateScript(references, workspace, input, signal) {
|
|
|
71
241
|
bindings.set(binding.name, binding.scope);
|
|
72
242
|
}
|
|
73
243
|
const report = (entry, kind, scope, details) => {
|
|
74
|
-
const position = locationFor(
|
|
244
|
+
const position = locationFor(documentPath, document.lineIndex, entry.start, entry.end).start;
|
|
245
|
+
// A file check lists only problems; matching commands are counted, not itemized.
|
|
246
|
+
if (file !== undefined &&
|
|
247
|
+
(details.status === 'supported' || details.code === 'SCRIPT_ARGUMENT_BLOCK_UNCHECKED'))
|
|
248
|
+
return;
|
|
75
249
|
findings.push({
|
|
76
250
|
...details,
|
|
77
251
|
command: entry.key.value.slice(0, 256),
|
|
@@ -86,8 +260,8 @@ export async function validateScript(references, workspace, input, signal) {
|
|
|
86
260
|
code: diagnostic.code,
|
|
87
261
|
status: 'error',
|
|
88
262
|
command: '',
|
|
89
|
-
kind: input.kind,
|
|
90
|
-
scope: input.scope,
|
|
263
|
+
kind: input.kind ?? 'effect',
|
|
264
|
+
scope: input.scope ?? UNKNOWN_SCOPE,
|
|
91
265
|
line: diagnostic.location?.start.line ?? 1,
|
|
92
266
|
column: diagnostic.location?.start.column ?? 1,
|
|
93
267
|
message: diagnostic.message.slice(0, 500),
|
|
@@ -96,11 +270,11 @@ export async function validateScript(references, workspace, input, signal) {
|
|
|
96
270
|
const walk = (block, kind, scope, previous) => {
|
|
97
271
|
for (const entry of block.entries) {
|
|
98
272
|
signal?.throwIfAborted();
|
|
99
|
-
if (++visited >
|
|
273
|
+
if (++visited > commandLimit) {
|
|
100
274
|
return;
|
|
101
275
|
}
|
|
102
276
|
if (entry.type !== 'assignment') {
|
|
103
|
-
const position = locationFor(
|
|
277
|
+
const position = locationFor(documentPath, document.lineIndex, entry.start, entry.end).start;
|
|
104
278
|
findings.push({
|
|
105
279
|
code: 'SCRIPT_EXPECTED_COMMAND',
|
|
106
280
|
status: 'error',
|
|
@@ -119,7 +293,7 @@ export async function validateScript(references, workspace, input, signal) {
|
|
|
119
293
|
continue;
|
|
120
294
|
const name = booleanCommands.has(raw) ? raw.toLowerCase() : raw;
|
|
121
295
|
const explicitScope = raw === 'ROOT'
|
|
122
|
-
?
|
|
296
|
+
? rootScope
|
|
123
297
|
: raw === 'THIS'
|
|
124
298
|
? scope
|
|
125
299
|
: raw === 'PREV'
|
|
@@ -136,8 +310,57 @@ export async function validateScript(references, workspace, input, signal) {
|
|
|
136
310
|
walk(entry.value, kind, explicitScope ?? fixedScope, scope);
|
|
137
311
|
continue;
|
|
138
312
|
}
|
|
313
|
+
const link = entry.value.type === 'block' ? scopeLinks[raw.toLowerCase()] : undefined;
|
|
314
|
+
if (link !== undefined && entry.value.type === 'block') {
|
|
315
|
+
checkedCommands++;
|
|
316
|
+
if (scope !== UNKNOWN_SCOPE && !link.from.includes(scope))
|
|
317
|
+
report(entry, kind, scope, {
|
|
318
|
+
code: 'SCRIPT_SCOPE_LINK_WRONG_SCOPE',
|
|
319
|
+
status: 'error',
|
|
320
|
+
message: `${raw} is usable only within ${link.from.join(', ')} scope`,
|
|
321
|
+
suggestions: [],
|
|
322
|
+
});
|
|
323
|
+
walk(entry.value, kind, link.to, scope);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
// A file has no caller to declare FROM, chained links or targets; their blocks are
|
|
327
|
+
// still checked for command kinds, without scope rules.
|
|
328
|
+
if (file !== undefined &&
|
|
329
|
+
entry.value.type === 'block' &&
|
|
330
|
+
(/^(?:root|this|prev|from)(?:\.(?:root|this|prev|from))*$/iu.test(raw) ||
|
|
331
|
+
/^(?:event_target|var|mio|sp):/u.test(raw))) {
|
|
332
|
+
const keyword = raw.toUpperCase();
|
|
333
|
+
walk(entry.value, kind, keyword === 'ROOT'
|
|
334
|
+
? rootScope
|
|
335
|
+
: keyword === 'THIS'
|
|
336
|
+
? scope
|
|
337
|
+
: keyword === 'PREV'
|
|
338
|
+
? (previous ?? UNKNOWN_SCOPE)
|
|
339
|
+
: UNKNOWN_SCOPE, scope);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
139
342
|
const signature = catalogs[kind].get(name);
|
|
140
|
-
if (signature === undefined) {
|
|
343
|
+
if (signature === undefined && file?.helpers[kind].has(name) === true) {
|
|
344
|
+
// A scripted helper of the mod; its own file is checked separately.
|
|
345
|
+
checkedCommands++;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
// Only a scalar comparison (`num_owned_states > 1`) misuses a dynamic variable; a block
|
|
349
|
+
// with that key is a scope or helper the catalog does not describe.
|
|
350
|
+
if (signature === undefined &&
|
|
351
|
+
kind === 'trigger' &&
|
|
352
|
+
entry.value.type !== 'block' &&
|
|
353
|
+
dynamicVariables.has(name)) {
|
|
354
|
+
report(entry, kind, scope, {
|
|
355
|
+
code: 'SCRIPT_DYNAMIC_VARIABLE_AS_TRIGGER',
|
|
356
|
+
status: 'error',
|
|
357
|
+
message: `${name} is a documented dynamic variable, not a trigger; compare it with check_variable`,
|
|
358
|
+
suggestions: ['check_variable'],
|
|
359
|
+
});
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
// else_if and else are control flow even where the documentation lists only `if`.
|
|
363
|
+
if (signature === undefined && !conditionals.has(name) && !containers.has(name)) {
|
|
141
364
|
const other = catalogs[kind === 'effect' ? 'trigger' : 'effect'].get(name);
|
|
142
365
|
const suggestions = name.length > 128
|
|
143
366
|
? []
|
|
@@ -161,22 +384,26 @@ export async function validateScript(references, workspace, input, signal) {
|
|
|
161
384
|
continue;
|
|
162
385
|
}
|
|
163
386
|
checkedCommands++;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
387
|
+
if (signature !== undefined) {
|
|
388
|
+
const wrongScope = scope !== UNKNOWN_SCOPE &&
|
|
389
|
+
!signature.scopes.includes('any') &&
|
|
390
|
+
!signature.scopes.includes(scope);
|
|
391
|
+
report(entry, kind, scope, {
|
|
392
|
+
code: signature.conflict
|
|
393
|
+
? 'SCRIPT_DOCUMENTATION_CONFLICT'
|
|
394
|
+
: wrongScope
|
|
395
|
+
? 'SCRIPT_COMMAND_WRONG_SCOPE'
|
|
396
|
+
: 'SCRIPT_COMMAND_SUPPORTED',
|
|
397
|
+
status: signature.conflict ? 'unresolved' : wrongScope ? 'error' : 'supported',
|
|
398
|
+
suggestions: [],
|
|
399
|
+
reference: signature.reference,
|
|
400
|
+
message: signature.conflict
|
|
401
|
+
? 'Conflicting supported scopes in the selected documentation'
|
|
402
|
+
: wrongScope
|
|
403
|
+
? `Supported scopes: ${signature.scopes.join(', ')}`
|
|
404
|
+
: 'Command kind and declared scope match the selected documentation',
|
|
405
|
+
});
|
|
406
|
+
}
|
|
180
407
|
const iterator = /^(?:every|random|any|all)_(?:(?:owned|controlled|neighbor)_)?(state|country)$/u.exec(name);
|
|
181
408
|
if (entry.value.type !== 'block') {
|
|
182
409
|
if (containers.has(name) || conditionals.has(name) || iterator !== null)
|
|
@@ -185,7 +412,7 @@ export async function validateScript(references, workspace, input, signal) {
|
|
|
185
412
|
status: 'error',
|
|
186
413
|
message: 'This control command requires a block',
|
|
187
414
|
suggestions: [],
|
|
188
|
-
reference: signature.reference,
|
|
415
|
+
...(signature === undefined ? {} : { reference: signature.reference }),
|
|
189
416
|
});
|
|
190
417
|
continue;
|
|
191
418
|
}
|
|
@@ -223,25 +450,70 @@ export async function validateScript(references, workspace, input, signal) {
|
|
|
223
450
|
status: 'unresolved',
|
|
224
451
|
message: 'Native argument block is not validated from prose documentation; read the cited syntax before use',
|
|
225
452
|
suggestions: [],
|
|
226
|
-
reference: signature.reference,
|
|
453
|
+
...(signature === undefined ? {} : { reference: signature.reference }),
|
|
227
454
|
});
|
|
228
455
|
}
|
|
229
456
|
}
|
|
230
457
|
};
|
|
458
|
+
const plan = file === undefined
|
|
459
|
+
? {
|
|
460
|
+
family: 'snippet',
|
|
461
|
+
roots: [{ block: document.root, kind: input.kind, scope: input.scope }],
|
|
462
|
+
}
|
|
463
|
+
: scriptRoots(file.relativePath, document);
|
|
231
464
|
if (document.diagnostics.every(({ severity }) => severity !== 'error' && severity !== 'blocker'))
|
|
232
|
-
|
|
465
|
+
for (const root of plan.roots) {
|
|
466
|
+
rootScope = root.scope;
|
|
467
|
+
walk(root.block, root.kind, root.scope);
|
|
468
|
+
}
|
|
469
|
+
const playerText = plan.family === 'snippet' ? undefined : file?.playerText;
|
|
470
|
+
const playerTextChecked = playerText !== undefined;
|
|
471
|
+
const eventPopups = plan.family === 'snippet' ? undefined : file?.eventPopups;
|
|
472
|
+
const warnings = [
|
|
473
|
+
...(playerText === undefined ? [] : playerTextFindings(plan.family, document, playerText)),
|
|
474
|
+
...(eventPopups === undefined ? [] : repeatedPopupFindings(document, eventPopups)),
|
|
475
|
+
];
|
|
476
|
+
for (const finding of warnings) {
|
|
477
|
+
const position = playerTextPosition(documentPath, document.lineIndex, finding);
|
|
478
|
+
findings.push({
|
|
479
|
+
code: finding.code,
|
|
480
|
+
status: 'warning',
|
|
481
|
+
command: finding.command.slice(0, 256),
|
|
482
|
+
kind: finding.code === 'SCRIPT_FLAG_TOOLTIP_UNLOCALISED'
|
|
483
|
+
? 'trigger'
|
|
484
|
+
: finding.code === 'SCRIPT_EVENT_POPUP_REPEATED'
|
|
485
|
+
? 'effect'
|
|
486
|
+
: 'text',
|
|
487
|
+
scope: UNKNOWN_SCOPE,
|
|
488
|
+
line: position.line,
|
|
489
|
+
column: position.column,
|
|
490
|
+
message: finding.message,
|
|
491
|
+
suggestions: [],
|
|
492
|
+
});
|
|
493
|
+
}
|
|
233
494
|
const unresolvedCount = findings.filter(({ status }) => status === 'unresolved').length;
|
|
234
495
|
const invalid = findings.some(({ status }) => status === 'error');
|
|
235
|
-
const truncated = visited >
|
|
236
|
-
const
|
|
237
|
-
const
|
|
496
|
+
const truncated = visited > commandLimit;
|
|
497
|
+
const unknownScopeRoots = plan.roots.filter(({ scope }) => scope === UNKNOWN_SCOPE).length;
|
|
498
|
+
const incomplete = unresolvedCount > 0 ||
|
|
499
|
+
truncated ||
|
|
500
|
+
inventory.skipped > 0 ||
|
|
501
|
+
(file === undefined ? catalogs[input.kind].size === 0 : plan.roots.length === 0) ||
|
|
502
|
+
unknownScopeRoots > 0;
|
|
503
|
+
const order = { error: 0, unresolved: 1, warning: 2, supported: 3 };
|
|
238
504
|
findings.sort((a, b) => order[a.status] - order[b.status] ||
|
|
239
505
|
a.line - b.line ||
|
|
240
506
|
a.column - b.column ||
|
|
241
507
|
a.code.localeCompare(b.code, 'en'));
|
|
242
508
|
const result = {
|
|
243
509
|
valid: invalid ? false : incomplete ? null : true,
|
|
244
|
-
checksPerformed: [
|
|
510
|
+
checksPerformed: [
|
|
511
|
+
'syntax',
|
|
512
|
+
'command_kind',
|
|
513
|
+
'declared_scope',
|
|
514
|
+
...(playerTextChecked ? ['player_text'] : []),
|
|
515
|
+
...(eventPopups === undefined ? [] : ['event_popups']),
|
|
516
|
+
],
|
|
245
517
|
parametersChecked: false,
|
|
246
518
|
documentation: {
|
|
247
519
|
source: input.documentation,
|
|
@@ -257,6 +529,16 @@ export async function validateScript(references, workspace, input, signal) {
|
|
|
257
529
|
unresolvedCount,
|
|
258
530
|
truncated,
|
|
259
531
|
argumentBlocksUnchecked,
|
|
532
|
+
...(file === undefined
|
|
533
|
+
? {}
|
|
534
|
+
: {
|
|
535
|
+
file: {
|
|
536
|
+
path: file.path,
|
|
537
|
+
family: plan.family,
|
|
538
|
+
roots: plan.roots.length,
|
|
539
|
+
unknownScopeRoots,
|
|
540
|
+
},
|
|
541
|
+
}),
|
|
260
542
|
};
|
|
261
543
|
while (Buffer.byteLength(JSON.stringify(result), 'utf8') > 16_000 && result.findings.length > 1)
|
|
262
544
|
result.findings.pop();
|
|
@@ -19,7 +19,7 @@ function samePath(left, right) {
|
|
|
19
19
|
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
20
20
|
}
|
|
21
21
|
/** Resolve a display, relative, or absolute path to one scanned file, preferring the active layer. */
|
|
22
|
-
function selectFile(snapshot, requested) {
|
|
22
|
+
export function selectFile(snapshot, requested) {
|
|
23
23
|
const normalized = requested.replaceAll('\\', '/').replace(/^\.\//u, '');
|
|
24
24
|
const absolute = path.isAbsolute(requested) ? path.resolve(requested) : undefined;
|
|
25
25
|
const candidates = [...snapshot.index.files.values()].filter((file) => file.displayPath === normalized ||
|
|
@@ -8,7 +8,21 @@ import { backgroundRequestKeySchema, workspaceIdSchema, workspaceRelativePathSch
|
|
|
8
8
|
function compact(schema, description) {
|
|
9
9
|
return z.unknown().describe(description).pipe(schema);
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Published scenario shape: the required `id` and the fields that decide visibility, with the
|
|
13
|
+
* rest named in the description. Every field is validated by the full scenario schema.
|
|
14
|
+
*/
|
|
15
|
+
const compactGuiScenarioSchema = z
|
|
16
|
+
.looseObject({
|
|
17
|
+
id: z.string(),
|
|
18
|
+
date: z.string().optional(),
|
|
19
|
+
closedFlags: z.boolean().optional(),
|
|
20
|
+
})
|
|
21
|
+
.describe('Also flags {name: bool}, variables {name: number}, controls {stateId: tag}, values, visibility (docs/gui.md). closedFlags: undeclared flags are unset.')
|
|
22
|
+
.transform((value) => value)
|
|
23
|
+
.pipe(GuiPreviewScenarioSchema);
|
|
24
|
+
/** Related and comparison scenarios repeat the primary scenario's published shape. */
|
|
25
|
+
const compactRelatedScenarioSchema = compact(GuiPreviewScenarioSchema, 'Same shape as scenario.');
|
|
12
26
|
const compactGeneratedScenarioOptionsSchema = compact(GuiGeneratedScenarioOptionsSchema, 'GUI generated-scenario options.');
|
|
13
27
|
const compactGuiHelperSchema = compact(GuiHelperDocumentSchema, 'GUI helper document.');
|
|
14
28
|
export const guiPreviewStateSchema = z.enum([
|
|
@@ -32,7 +46,7 @@ export const guiInspectRequestSchema = z
|
|
|
32
46
|
workspaceId: workspaceIdSchema,
|
|
33
47
|
windowName: z.string().min(1).max(256).optional(),
|
|
34
48
|
scenario: compactGuiScenarioSchema.optional(),
|
|
35
|
-
relatedScenarios: z.array(
|
|
49
|
+
relatedScenarios: z.array(compactRelatedScenarioSchema).max(32).optional(),
|
|
36
50
|
generatedScenarios: compactGeneratedScenarioOptionsSchema.optional(),
|
|
37
51
|
})
|
|
38
52
|
.strict()
|
|
@@ -79,9 +93,9 @@ export const guiRenderRequestSchema = z
|
|
|
79
93
|
scenario: compactGuiScenarioSchema,
|
|
80
94
|
states: z.array(guiPreviewStateSchema).max(14).optional(),
|
|
81
95
|
resolutions: z.array(guiResolutionSchema).min(1).max(16).optional(),
|
|
82
|
-
relatedScenarios: z.array(
|
|
96
|
+
relatedScenarios: z.array(compactRelatedScenarioSchema).max(32).optional(),
|
|
83
97
|
generatedScenarios: compactGeneratedScenarioOptionsSchema.optional(),
|
|
84
|
-
comparisonScenario:
|
|
98
|
+
comparisonScenario: compactRelatedScenarioSchema.optional(),
|
|
85
99
|
sourceBaseline: z
|
|
86
100
|
.object({
|
|
87
101
|
relativePath: workspaceRelativePathSchema,
|
|
@@ -18,9 +18,12 @@ const scope = z.enum([
|
|
|
18
18
|
export const scriptValidateRequestSchema = z
|
|
19
19
|
.object({
|
|
20
20
|
workspaceId: workspaceIdSchema,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
scope
|
|
21
|
+
/** Snippet mode: an effect or trigger body checked under `kind` and `scope`. */
|
|
22
|
+
source: z.string().min(1).max(64_000).optional(),
|
|
23
|
+
/** File mode: a scanned mod file whose blocks are checked under the scope its structure fixes. */
|
|
24
|
+
path: z.string().trim().min(1).max(4096).optional(),
|
|
25
|
+
kind: z.enum(['effect', 'trigger']).optional(),
|
|
26
|
+
scope: scope.optional(),
|
|
24
27
|
documentation: z.enum(['game_doc', 'script_doc']).default('game_doc'),
|
|
25
28
|
bindings: z
|
|
26
29
|
.array(z.object({ name: z.string().min(1).max(256), scope }).strict())
|
|
@@ -28,11 +31,32 @@ export const scriptValidateRequestSchema = z
|
|
|
28
31
|
.default([]),
|
|
29
32
|
limit: z.number().int().min(1).max(32).default(12),
|
|
30
33
|
})
|
|
31
|
-
.strict()
|
|
34
|
+
.strict()
|
|
35
|
+
.superRefine((value, context) => {
|
|
36
|
+
if ((value.source === undefined) === (value.path === undefined))
|
|
37
|
+
context.addIssue({
|
|
38
|
+
code: 'custom',
|
|
39
|
+
path: ['source'],
|
|
40
|
+
message: 'Provide either source with kind and scope, or a mod file path',
|
|
41
|
+
});
|
|
42
|
+
if (value.source !== undefined && (value.kind === undefined || value.scope === undefined))
|
|
43
|
+
context.addIssue({
|
|
44
|
+
code: 'custom',
|
|
45
|
+
path: ['kind'],
|
|
46
|
+
message: 'A snippet check requires kind and scope',
|
|
47
|
+
});
|
|
48
|
+
if (value.path !== undefined &&
|
|
49
|
+
(value.kind !== undefined || value.scope !== undefined || value.bindings.length > 0))
|
|
50
|
+
context.addIssue({
|
|
51
|
+
code: 'custom',
|
|
52
|
+
path: ['path'],
|
|
53
|
+
message: 'A file check infers kinds and scopes from the file structure',
|
|
54
|
+
});
|
|
55
|
+
});
|
|
32
56
|
export const scriptValidateDataSchema = z
|
|
33
57
|
.object({
|
|
34
58
|
valid: z.boolean().nullable(),
|
|
35
|
-
checksPerformed: z.array(z.enum(['syntax', 'command_kind', 'declared_scope'])),
|
|
59
|
+
checksPerformed: z.array(z.enum(['syntax', 'command_kind', 'declared_scope', 'player_text', 'event_popups'])),
|
|
36
60
|
parametersChecked: z.literal(false),
|
|
37
61
|
documentation: z
|
|
38
62
|
.object({
|
|
@@ -47,9 +71,9 @@ export const scriptValidateDataSchema = z
|
|
|
47
71
|
.array(z
|
|
48
72
|
.object({
|
|
49
73
|
code: z.string().max(80),
|
|
50
|
-
status: z.enum(['supported', 'error', 'unresolved']),
|
|
74
|
+
status: z.enum(['supported', 'error', 'unresolved', 'warning']),
|
|
51
75
|
command: z.string().max(256),
|
|
52
|
-
kind: z.enum(['effect', 'trigger']),
|
|
76
|
+
kind: z.enum(['effect', 'trigger', 'text']),
|
|
53
77
|
scope: z.string().max(64),
|
|
54
78
|
line: z.number().int().min(1),
|
|
55
79
|
column: z.number().int().min(1),
|
|
@@ -65,5 +89,14 @@ export const scriptValidateDataSchema = z
|
|
|
65
89
|
unresolvedCount: z.number().int().min(0),
|
|
66
90
|
truncated: z.boolean(),
|
|
67
91
|
argumentBlocksUnchecked: z.number().int().min(0),
|
|
92
|
+
file: z
|
|
93
|
+
.object({
|
|
94
|
+
path: z.string().max(4096),
|
|
95
|
+
family: z.string().max(64),
|
|
96
|
+
roots: z.number().int().min(0),
|
|
97
|
+
unknownScopeRoots: z.number().int().min(0),
|
|
98
|
+
})
|
|
99
|
+
.strict()
|
|
100
|
+
.optional(),
|
|
68
101
|
})
|
|
69
102
|
.strict();
|
|
@@ -143,14 +143,16 @@ export const technologyRenderRequestSchema = z
|
|
|
143
143
|
refresh: z.boolean().optional(),
|
|
144
144
|
})
|
|
145
145
|
.strict()
|
|
146
|
-
.superRefine(
|
|
146
|
+
.superRefine(validateTechnologyRenderRequest);
|
|
147
|
+
/** Shared by the request schema and the published tool input, so clients see the same rule. */
|
|
148
|
+
export function validateTechnologyRenderRequest(value, context) {
|
|
147
149
|
if (value.view === 'folder' && value.folderId === undefined)
|
|
148
150
|
context.addIssue({
|
|
149
151
|
code: 'custom',
|
|
150
152
|
path: ['folderId'],
|
|
151
153
|
message: 'Folder view requires folderId',
|
|
152
154
|
});
|
|
153
|
-
}
|
|
155
|
+
}
|
|
154
156
|
export function validateTechnologyCompareRequest(value, context) {
|
|
155
157
|
if (value.after !== undefined && value.proposedSources !== undefined)
|
|
156
158
|
context.addIssue({
|
|
@@ -880,6 +880,13 @@ export class TechnologyTreeViewer {
|
|
|
880
880
|
input.signal?.throwIfAborted();
|
|
881
881
|
if (input.proposedSources !== undefined)
|
|
882
882
|
assertProposedBounds(input.proposedSources);
|
|
883
|
+
// Without references the comparison uses the previous cached revision; with none cached,
|
|
884
|
+
// say so before scanning the workspace.
|
|
885
|
+
if (input.before === undefined &&
|
|
886
|
+
input.after === undefined &&
|
|
887
|
+
input.proposedSources === undefined &&
|
|
888
|
+
(this.#state.history.get(input.workspaceId)?.size ?? 0) === 0)
|
|
889
|
+
throw new ServiceError('TECH_COMPARISON_BASELINE_REQUIRED', 'Provide a cached revision, graph artifact, or proposed source overlay');
|
|
883
890
|
const current = await this.scan(input.workspaceId, {
|
|
884
891
|
refresh: input.refresh ?? input.proposedSources !== undefined,
|
|
885
892
|
...(input.principal === undefined ? {} : { principal: input.principal }),
|
|
@@ -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.10.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;
|