hoi4-agent-tools 3.8.0 → 3.9.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 +19 -1
- package/README.md +1 -1
- package/dist/bin/setup.js +5 -2
- package/dist/bin/stdio.js +27 -1
- package/dist/hoi4_agent_tools/core/configuration.js +8 -0
- 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/transactions.js +31 -2
- package/dist/hoi4_agent_tools/gui/renderer.js +8 -2
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +48 -0
- package/dist/hoi4_agent_tools/gui/scenario.js +2 -0
- package/dist/hoi4_agent_tools/gui/studio.js +15 -9
- package/dist/hoi4_agent_tools/gui/validators.js +22 -0
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +1 -1
- package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +9 -0
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +83 -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/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 +10 -0
- package/docs/tools.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- 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();
|
|
@@ -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.9.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/gui.md
CHANGED
|
@@ -184,6 +184,31 @@ Source or scenario `enabled = false` selects disabled appearance and removes the
|
|
|
184
184
|
|
|
185
185
|
The renderer does not run the game engine. Each render includes a fidelity report that separates fields it models from fields it approximates, ignores, cannot resolve, or does not support. Treat that report as part of the review.
|
|
186
186
|
|
|
187
|
+
### Scenario fields
|
|
188
|
+
|
|
189
|
+
Every `scenario` needs an `id`, a short label such as `"usa_1936"`. The other fields describe the game state the window is drawn under:
|
|
190
|
+
|
|
191
|
+
| Field | Meaning |
|
|
192
|
+
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
193
|
+
| `date` | Game date, `"1936.1.1"`. |
|
|
194
|
+
| `controls` | State controllers, `{"64": "GER"}`. |
|
|
195
|
+
| `flags` | Flags on the viewing country, `{"show_settings_page": true}`. |
|
|
196
|
+
| `closedFlags` | `true` treats every flag not listed in `flags` as unset, as on a country that never received it. Without it, an undeclared flag is unknown and every condition that reads it stays unresolved. |
|
|
197
|
+
| `variables` | Variables on the viewing country, `{"settings_page": 1}`. |
|
|
198
|
+
| `values` | Exact runtime values for localisation tokens and scripted GUI properties, keyed as they appear in source. |
|
|
199
|
+
| `visibility` | Forced visibility by element or scripted GUI name, `{"settings_page_events": false}`; it overrides evaluated triggers. |
|
|
200
|
+
| `scopes` | Named scope bindings such as `FROM` with their own state. |
|
|
201
|
+
| `resolution`, `uiScale`, `state`, `language` | Screen size, in-game UI scale, preview element state, and localisation language. |
|
|
202
|
+
| `background` | `"transparent"` renders without the preview backdrop, so pixels the window does not cover are fully transparent in every PNG. |
|
|
203
|
+
|
|
204
|
+
A scripted GUI `visible` or `<element>_visible` trigger that the scenario decides hides or shows its element exactly.
|
|
205
|
+
For compositing, each visible element is its own `<g>` group in the full SVG, with an `id` that matches the element in `layout.json`, which also records every element's box.
|
|
206
|
+
A trigger the scenario cannot decide leaves the element drawn and lists it in the fidelity report as unresolved, so pages that the game shows one at a time can overlap in the preview.
|
|
207
|
+
For tabbed windows, set the flags or variables of the page to show and `closedFlags: true`, then render each page as its own scenario through `relatedScenarios`.
|
|
208
|
+
`GUI_UNRESOLVED_VISIBILITY_OVERLAP` names each overlapping pair drawn together for that reason.
|
|
209
|
+
`GUI_NEAR_CENTRE_OFFSET` names an element whose horizontal centre is between half a pixel and 3 pixels from its parent's centre, which usually means it was meant to be centred.
|
|
210
|
+
Scripted localisation (`defined_text`) used by the window resolves to its first branch whose trigger the scenario decides as true, as long as every earlier branch is decided false; otherwise the token renders as `[dynamic_loc]`. A value in `values` always takes precedence.
|
|
211
|
+
|
|
187
212
|
The generated primary view replaces discovered numeric and text runtime tokens with plausible values. Set `generatedScenarios.preservePlaceholder` to `true`, or disable generation, when the same run should also show unresolved numeric values such as `[?variable|format]` as `[X]` and unresolved text-returning scripted or scoped localisation such as `[GetStatusText]`, `[FROM.GetName]`, and `[?leader_scope.GetName]` as `[dynamic_loc]`. Put `variable` or `GetStatusText` in the supplied scenario's `values` object when an exact value matters; explicit values always take precedence over generated ones and can drive the related meter or state-dependent controls. Supported HOI4 `§` localisation colour controls use the active font's palette and `§!` restores the face colour.
|
|
188
213
|
|
|
189
214
|
A rewrite stops if malformed or unsupported GUI script makes the requested change ambiguous. Frame-sheet animation, nine-slice tiling, progress composition, masking, and the primary and secondary textures referenced by vanilla GFX definitions render offline. Font selection follows mod/game load order and preview language, accepts Paradox hex and numeric color declarations, and uses the supplied HOI4 bitmap or outline font assets without requiring host font installation. Shader operations outside the supported colour subset, hardcoded controls, and dynamic values that are not supplied by a scenario remain fidelity-report entries; an unexecuted shader never hides its resolved base texture.
|
package/docs/jobs.md
CHANGED
|
@@ -51,6 +51,14 @@ A running cancellation is observed by the isolated worker; if a rewrite has alre
|
|
|
51
51
|
Cancelling an ordinary synchronous call prevents admission when possible and otherwise requests cooperative cancellation of its durable job.
|
|
52
52
|
Closing the originating connection does not cancel an accepted native task.
|
|
53
53
|
|
|
54
|
+
A worker busy in long synchronous analysis cannot observe its own cancellation, so the server that started it also supervises read-only jobs.
|
|
55
|
+
A read-only job still running `jobCancelGraceSeconds` (default 10) after cancellation is stopped and settles as cancelled.
|
|
56
|
+
A read-only job still running `jobDeadlineSeconds` (default 1,800) after it was dispatched is stopped and fails with `JOB_DEADLINE_EXCEEDED`; narrow its selector or limits, or raise the setting in the server configuration.
|
|
57
|
+
Rewrite jobs are never stopped this way, because their transaction journal owns write recovery; they keep cooperative cancellation and reconciliation.
|
|
58
|
+
Supervision belongs to the server process that dispatched the worker. A read-only worker whose dispatching server has exited enforces the same deadline itself and then exits; a rewrite worker keeps running until it finishes.
|
|
59
|
+
`jobWorkerMaxHeapMiB` sets a V8 heap ceiling for each worker; without it, workers use Node's default.
|
|
60
|
+
At most `maxSharedTools` (default 4) workers and expensive calls run at once across every server process that shares a `serverStateRoot`; further work waits for capacity.
|
|
61
|
+
|
|
54
62
|
Read-only jobs stage a source-revision-bound result checkpoint before terminal publication.
|
|
55
63
|
If a worker or server stops after that checkpoint, a later authorized process publishes the retained result without rerunning the operation.
|
|
56
64
|
Event and technology helper expansion also saves an authenticated intermediate frontier with its exact source revision and traversal contract.
|