hoi4-agent-tools 3.8.1 → 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.
Files changed (67) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/README.md +1 -1
  3. package/dist/bin/setup.js +5 -2
  4. package/dist/bin/stdio.js +27 -1
  5. package/dist/hoi4_agent_tools/core/configuration.js +8 -0
  6. package/dist/hoi4_agent_tools/core/job-worker-host.js +40 -2
  7. package/dist/hoi4_agent_tools/core/job-worker.js +14 -0
  8. package/dist/hoi4_agent_tools/core/scenario-model.js +1 -1
  9. package/dist/hoi4_agent_tools/gui/renderer.js +8 -2
  10. package/dist/hoi4_agent_tools/gui/scenario-generator.js +48 -0
  11. package/dist/hoi4_agent_tools/gui/scenario.js +2 -0
  12. package/dist/hoi4_agent_tools/gui/studio.js +15 -9
  13. package/dist/hoi4_agent_tools/gui/validators.js +22 -0
  14. package/dist/hoi4_agent_tools/mcp/tools/reference.js +1 -1
  15. package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +9 -0
  16. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +83 -0
  17. package/dist/hoi4_agent_tools/reference/event-popups.js +87 -0
  18. package/dist/hoi4_agent_tools/reference/operations.js +151 -1
  19. package/dist/hoi4_agent_tools/reference/player-text.js +158 -0
  20. package/dist/hoi4_agent_tools/reference/script-validation.js +315 -33
  21. package/dist/hoi4_agent_tools/reference/source-locate.js +1 -1
  22. package/dist/hoi4_agent_tools/schemas/gui-requests.js +18 -4
  23. package/dist/hoi4_agent_tools/schemas/script-validation.js +40 -7
  24. package/dist/hoi4_agent_tools/version.js +1 -1
  25. package/docs/gui.md +25 -0
  26. package/docs/jobs.md +8 -0
  27. package/docs/reference.md +38 -0
  28. package/docs/setup.md +10 -0
  29. package/docs/tools.md +1 -1
  30. package/npm-shrinkwrap.json +2 -2
  31. package/package.json +1 -1
  32. package/schemas/configuration.schema.json +163 -128
  33. package/schemas/continuous-focus-palette.schema.json +1 -1
  34. package/schemas/custom-weighted-pool.schema.json +1 -1
  35. package/schemas/decision-inspect-input.schema.json +1 -1
  36. package/schemas/event-feature-manifest.schema.json +1 -1
  37. package/schemas/focus-plan.schema.json +1 -1
  38. package/schemas/focus-planning-sidecar.schema.json +1 -1
  39. package/schemas/gui-animation-source.schema.json +1 -1
  40. package/schemas/gui-helper.schema.json +1 -1
  41. package/schemas/gui-scenario.schema.json +38 -22
  42. package/schemas/helper-expansion-request.schema.json +1 -1
  43. package/schemas/helper-expansion-summary.schema.json +1 -1
  44. package/schemas/impact-inspect-input.schema.json +1 -1
  45. package/schemas/map-operation.schema.json +1 -1
  46. package/schemas/mechanic-test-input.schema.json +1 -1
  47. package/schemas/operation-result.schema.json +1 -1
  48. package/schemas/package-check-input.schema.json +1 -1
  49. package/schemas/package-manifest.schema.json +1 -1
  50. package/schemas/probability-analysis-result.schema.json +1 -1
  51. package/schemas/probability-compare-input.schema.json +1 -1
  52. package/schemas/probability-evaluate-input.schema.json +1 -1
  53. package/schemas/probability-inspect-input.schema.json +1 -1
  54. package/schemas/probability-render-input.schema.json +1 -1
  55. package/schemas/probability-scenario-set.schema.json +1 -1
  56. package/schemas/probability-sequence-input.schema.json +1 -1
  57. package/schemas/probability-simulate-input.schema.json +1 -1
  58. package/schemas/probability-sweep-input.schema.json +1 -1
  59. package/schemas/reference-context-input.schema.json +1 -1
  60. package/schemas/reference-read-input.schema.json +1 -1
  61. package/schemas/reference-search-input.schema.json +1 -1
  62. package/schemas/scenario-suite.schema.json +1 -1
  63. package/schemas/scenario-test-input.schema.json +1 -1
  64. package/schemas/script-validate-input.schema.json +60 -36
  65. package/schemas/shared-scenario.schema.json +1 -1
  66. package/schemas/source-lookup-input.schema.json +1 -1
  67. package/server.json +2 -2
@@ -1,3 +1,5 @@
1
+ import { hashCanonical } from '../core/canonical.js';
2
+ import { parseClausewitz } from '../core/source/index.js';
1
3
  import { requireOperationScope, resolveOperationWorkspaceId, } from '../core/operation-context.js';
2
4
  import { emptyServiceResult } from '../core/result.js';
3
5
  import { toolResult } from '../core/operation-result.js';
@@ -6,6 +8,35 @@ import { ReferenceService } from './service.js';
6
8
  import { sourceLookup } from './source-lookup.js';
7
9
  import { scriptValidateDataSchema, scriptValidateRequestSchema, } from '../schemas/script-validation.js';
8
10
  import { validateScript } from './script-validation.js';
11
+ import { eventPopupDefinitions } from './event-popups.js';
12
+ import { locatePatterns, selectFile } from './source-locate.js';
13
+ /**
14
+ * Database folders whose entry names are trigger keys: `<folder> = { <name> = { ... } }` in
15
+ * each file, checked as the documented generic trigger.
16
+ */
17
+ const implicitTriggerSources = {
18
+ buildings: 'building_count_trigger',
19
+ ideologies: 'ideology_support_trigger',
20
+ resources: 'resource_count_trigger',
21
+ };
22
+ /** Building, ideology and resource names from every scanned source layer. */
23
+ function implicitTriggerNames(snapshot) {
24
+ const names = new Map();
25
+ for (const file of snapshot.index.files.values()) {
26
+ const folder = /^common\/([a-z_]+)\/[^/]+\.txt$/u.exec(file.relativePath)?.[1];
27
+ const heading = folder === undefined ? undefined : implicitTriggerSources[folder];
28
+ if (heading === undefined)
29
+ continue;
30
+ for (const top of parseClausewitz(file.bytes, file.displayPath).root.entries) {
31
+ if (top.type !== 'assignment' || top.key.value !== folder || top.value.type !== 'block')
32
+ continue;
33
+ for (const entry of top.value.entries)
34
+ if (entry.type === 'assignment' && entry.value.type === 'block')
35
+ names.set(entry.key.value, heading);
36
+ }
37
+ }
38
+ return names;
39
+ }
9
40
  const schemas = {
10
41
  'hoi4.reference_search': referenceSearchRequestSchema,
11
42
  'hoi4.reference_read': referenceReadRequestSchema,
@@ -39,7 +70,7 @@ export class ReferenceToolService {
39
70
  : name === 'hoi4.reference_context'
40
71
  ? await this.references.context(workspace, referenceContextRequestSchema.parse(parsed), signal)
41
72
  : name === 'hoi4.script_validate'
42
- ? await validateScript(this.references, workspace, scriptValidateRequestSchema.parse(parsed), signal)
73
+ ? await this.validateScript(workspaceId, workspace, scriptValidateRequestSchema.parse(parsed), context.principal, signal)
43
74
  : await sourceLookup(this.engine, workspaceId, sourceLookupRequestSchema.parse(parsed), context.principal, signal);
44
75
  const result = emptyServiceResult(workspaceId, data);
45
76
  if (name === 'hoi4.script_validate') {
@@ -81,4 +112,123 @@ export class ReferenceToolService {
81
112
  }
82
113
  return toolResult(result);
83
114
  }
115
+ #eventPopups = new Map();
116
+ /** The latest player-text context of each workspace, keyed by its source files' hashes. */
117
+ #playerText = new Map();
118
+ /** File mode reads the scanned file and the mod's scripted helper names from one scan. */
119
+ async validateScript(workspaceId, workspace, request, principal, signal) {
120
+ if (request.path === undefined)
121
+ return validateScript(this.references, workspace, request, signal);
122
+ const snapshot = await this.engine.scan(workspaceId, {
123
+ patterns: [
124
+ ...(locatePatterns(this.engine, workspaceId, request.path, principal) ?? []),
125
+ 'common/scripted_effects/**/*.txt',
126
+ 'common/scripted_triggers/**/*.txt',
127
+ ...Object.keys(implicitTriggerSources).map((folder) => `common/${folder}/*.txt`),
128
+ // One scan serves every check: the engine keeps one snapshot per workspace.
129
+ ...playerTextPatterns,
130
+ 'events/**/*.txt',
131
+ ],
132
+ }, principal, signal);
133
+ const file = selectFile(snapshot, request.path);
134
+ const playerText = playerTextFamily(file.relativePath)
135
+ ? this.playerTextContext(workspaceId, snapshot)
136
+ : undefined;
137
+ const helpers = (kind) => new Set(snapshot.index.symbols.filter((symbol) => symbol.kind === kind).map(({ id }) => id));
138
+ return validateScript(this.references, workspace, request, signal, {
139
+ path: file.displayPath,
140
+ relativePath: file.relativePath,
141
+ bytes: file.bytes,
142
+ helpers: { effect: helpers('scripted_effect'), trigger: helpers('scripted_trigger') },
143
+ implicitTriggers: implicitTriggerNames(snapshot),
144
+ ...(playerText === undefined ? {} : { playerText }),
145
+ eventPopups: this.eventPopupContext(workspaceId, snapshot),
146
+ });
147
+ }
148
+ /** Every active event definition, cached by the hashes of the events files. */
149
+ eventPopupContext(workspaceId, snapshot) {
150
+ const sources = [...snapshot.index.files.values()]
151
+ .filter(({ relativePath, shadowedBy }) => shadowedBy === undefined && relativePath.startsWith('events/'))
152
+ .sort((left, right) => left.loadOrder - right.loadOrder);
153
+ const cacheKey = hashCanonical(sources.map(({ displayPath, sha256 }) => [displayPath, sha256]));
154
+ const cached = this.#eventPopups.get(workspaceId);
155
+ if (cached?.key === cacheKey)
156
+ return cached.events;
157
+ const events = new Map();
158
+ for (const file of sources)
159
+ for (const [id, definition] of eventPopupDefinitions(file.bytes, file.displayPath))
160
+ events.set(id, definition);
161
+ this.#eventPopups.set(workspaceId, { key: cacheKey, events });
162
+ return events;
163
+ }
164
+ /** English localisation as the game resolves it, and the branches of scripted localisation. */
165
+ playerTextContext(workspaceId, snapshot) {
166
+ const sources = [...snapshot.index.files.values()].filter(({ relativePath }) => playerTextSource(relativePath));
167
+ const cacheKey = hashCanonical(sources.map(({ displayPath, sha256 }) => [displayPath, sha256]));
168
+ const cached = this.#playerText.get(workspaceId);
169
+ if (cached?.key === cacheKey)
170
+ return cached.context;
171
+ const localisation = new Map();
172
+ for (const symbol of snapshot.index.symbols) {
173
+ if (symbol.kind !== 'localisation' || !symbol.id.startsWith('l_english:'))
174
+ continue;
175
+ const key = symbol.id.slice('l_english:'.length);
176
+ const previous = localisation.get(key);
177
+ if (previous !== undefined && previous.loadOrder > symbol.loadOrder)
178
+ continue;
179
+ localisation.set(key, {
180
+ value: typeof symbol.metadata.value === 'string' ? symbol.metadata.value : '',
181
+ path: symbol.path,
182
+ line: symbol.location?.start.line ?? 1,
183
+ loadOrder: symbol.loadOrder,
184
+ });
185
+ }
186
+ const scriptedLocalisation = new Map();
187
+ for (const file of sources) {
188
+ if (!file.relativePath.startsWith('common/scripted_localisation/'))
189
+ continue;
190
+ for (const definition of parseClausewitz(file.bytes, file.displayPath).root.entries) {
191
+ if (definition.type !== 'assignment' ||
192
+ definition.key.value !== 'defined_text' ||
193
+ definition.value.type !== 'block')
194
+ continue;
195
+ let name;
196
+ const keys = [];
197
+ for (const entry of definition.value.entries) {
198
+ if (entry.type !== 'assignment')
199
+ continue;
200
+ if (entry.key.value === 'name' && entry.value.type === 'scalar')
201
+ name = entry.value.value;
202
+ if (entry.key.value !== 'text' || entry.value.type !== 'block')
203
+ continue;
204
+ for (const branch of entry.value.entries)
205
+ if (branch.type === 'assignment' &&
206
+ /^locali[sz]ation_key$/u.test(branch.key.value) &&
207
+ branch.value.type === 'scalar')
208
+ keys.push(branch.value.value);
209
+ }
210
+ if (name !== undefined)
211
+ scriptedLocalisation.set(name, keys);
212
+ }
213
+ }
214
+ const context = { localisation, scriptedLocalisation };
215
+ this.#playerText.set(workspaceId, { key: cacheKey, context });
216
+ return context;
217
+ }
218
+ }
219
+ const playerTextPatterns = [
220
+ 'localisation/**/*_l_english.yml',
221
+ 'common/scripted_localisation/**/*.txt',
222
+ ];
223
+ function playerTextSource(relativePath) {
224
+ const file = relativePath.replaceAll('\\', '/').toLowerCase();
225
+ return ((file.startsWith('localisation/') && file.endsWith('_l_english.yml')) ||
226
+ file.startsWith('common/scripted_localisation/'));
227
+ }
228
+ /** Files whose player-facing text and requirement tooltips file mode checks. */
229
+ function playerTextFamily(relativePath) {
230
+ const file = relativePath.replaceAll('\\', '/').toLowerCase();
231
+ return (file.startsWith('events/') ||
232
+ file.startsWith('common/national_focus/') ||
233
+ (file.startsWith('common/decisions/') && !file.startsWith('common/decisions/categories/')));
84
234
  }
@@ -0,0 +1,158 @@
1
+ import { locationFor } from '../core/source/index.js';
2
+ const flagTriggers = new Set(['has_country_flag', 'has_global_flag', 'has_state_flag']);
3
+ /** Blocks whose triggers the game lists in requirement tooltips. */
4
+ const tooltipTriggerBlocks = {
5
+ decisions: new Set(['available']),
6
+ national_focus: new Set(['available', 'bypass']),
7
+ };
8
+ /** Words that describe the mod's development rather than the game world. */
9
+ const implementationWording = /\b(?:TODO|FIXME|TBD|hard-?coded|newly added|(?:has been|was|been) reworked|placeholder)\b/iu;
10
+ const colourCode = /[§£]/u;
11
+ function blockOf(entry) {
12
+ return entry.value.type === 'block' ? entry.value : undefined;
13
+ }
14
+ function scalarOf(entry) {
15
+ return entry.value.type === 'block' ? undefined : entry.value.value;
16
+ }
17
+ function assignmentsOf(block) {
18
+ return block.entries.filter((entry) => entry.type === 'assignment');
19
+ }
20
+ /** Localisation keys an event, decision or focus text field names: `key` or `{ text = key }`. */
21
+ function textKeys(entry) {
22
+ const scalar = scalarOf(entry);
23
+ if (scalar !== undefined)
24
+ return [{ key: scalar, node: entry }];
25
+ return assignmentsOf(blockOf(entry))
26
+ .filter((child) => child.key.value === 'text' && scalarOf(child) !== undefined)
27
+ .map((child) => ({ key: scalarOf(child), node: child }));
28
+ }
29
+ /** Scripted localisation names a text calls, such as `GetStatus` in `[ROOT.GetStatus]`. */
30
+ function scriptedTokens(value, context) {
31
+ const names = [];
32
+ for (const match of value.matchAll(/\[(?!\?)([A-Za-z0-9_.:]+)\]/gu)) {
33
+ const name = match[1].slice(match[1].lastIndexOf('.') + 1);
34
+ if (context.scriptedLocalisation.has(name))
35
+ names.push(name);
36
+ }
37
+ return names;
38
+ }
39
+ /**
40
+ * Player-facing text problems in an event, decision or focus file. News and report events
41
+ * carry no colour codes, directly or through scripted localisation; shown text has no
42
+ * development wording; and a flag trigger listed in a requirement tooltip has a localisation
43
+ * key, because the game prints the flag name as that tooltip's key.
44
+ */
45
+ export function playerTextFindings(family, document, context) {
46
+ const findings = [];
47
+ const top = assignmentsOf(document.root);
48
+ const add = (node, code, message) => findings.push({
49
+ code,
50
+ command: node.key.value,
51
+ offset: node.start,
52
+ end: node.end,
53
+ message: message.slice(0, 500),
54
+ });
55
+ const where = (text) => `${text.path}:${text.line}`;
56
+ const checkText = (key, node, options) => {
57
+ const text = context.localisation.get(key);
58
+ if (text === undefined)
59
+ return;
60
+ if (implementationWording.test(text.value))
61
+ add(node, 'SCRIPT_TEXT_IMPLEMENTATION_WORDING', `${key} (${where(text)}) describes development rather than the game: "${implementationWording.exec(text.value)[0]}"`);
62
+ if (!options.colourFree)
63
+ return;
64
+ if (colourCode.test(text.value)) {
65
+ add(node, 'SCRIPT_NEWS_TEXT_COLOUR_CODE', `${key} (${where(text)}) uses a colour code in news or report event text`);
66
+ return;
67
+ }
68
+ for (const name of scriptedTokens(text.value, context))
69
+ for (const branchKey of context.scriptedLocalisation.get(name) ?? []) {
70
+ const branch = context.localisation.get(branchKey);
71
+ if (branch !== undefined && colourCode.test(branch.value)) {
72
+ add(node, 'SCRIPT_NEWS_TEXT_COLOUR_CODE', `${key} (${where(text)}) shows [${name}], whose branch ${branchKey} (${where(branch)}) uses a colour code in news or report event text`);
73
+ return;
74
+ }
75
+ }
76
+ };
77
+ if (family === 'events') {
78
+ for (const event of top) {
79
+ const body = blockOf(event);
80
+ if (body === undefined || !event.key.value.endsWith('_event'))
81
+ continue;
82
+ const fields = assignmentsOf(body);
83
+ const picture = fields.find(({ key }) => key.value === 'picture');
84
+ const colourFree = event.key.value === 'news_event' ||
85
+ /^GFX_report_event/iu.test((picture && scalarOf(picture)) ?? '');
86
+ for (const field of fields) {
87
+ if (field.key.value === 'title' || field.key.value === 'desc')
88
+ for (const { key, node } of textKeys(field))
89
+ checkText(key, node, { colourFree });
90
+ else if (field.key.value === 'option')
91
+ for (const name of assignmentsOf(blockOf(field) ?? { ...body, entries: [] }))
92
+ if (name.key.value === 'name' && scalarOf(name) !== undefined)
93
+ checkText(scalarOf(name), name, { colourFree });
94
+ }
95
+ }
96
+ return findings;
97
+ }
98
+ const requirementBlocks = tooltipTriggerBlocks[family];
99
+ if (requirementBlocks === undefined)
100
+ return findings;
101
+ const visitTrigger = (block) => {
102
+ for (const entry of assignmentsOf(block)) {
103
+ const key = entry.key.value;
104
+ // These blocks hide their contents or replace them with their own tooltip.
105
+ if (key === 'hidden_trigger' || key === 'custom_trigger_tooltip')
106
+ continue;
107
+ if (flagTriggers.has(key)) {
108
+ const flag = scalarOf(entry) ??
109
+ assignmentsOf(blockOf(entry))
110
+ .filter(({ key: child }) => child.value === 'flag')
111
+ .map(scalarOf)[0];
112
+ if (flag !== undefined && /^[A-Za-z0-9_]+$/u.test(flag) && !context.localisation.has(flag))
113
+ add(entry, 'SCRIPT_FLAG_TOOLTIP_UNLOCALISED', `${flag} has no localisation key, so the requirement tooltip shows the raw flag name; localise it or wrap the check in hidden_trigger with a custom_trigger_tooltip`);
114
+ continue;
115
+ }
116
+ const child = blockOf(entry);
117
+ if (child !== undefined)
118
+ visitTrigger(child);
119
+ }
120
+ };
121
+ const visitDefinition = (definition, id) => {
122
+ const body = blockOf(definition);
123
+ if (body === undefined)
124
+ return;
125
+ if (id !== undefined) {
126
+ checkText(id, definition, { colourFree: false });
127
+ checkText(`${id}_desc`, definition, { colourFree: false });
128
+ }
129
+ for (const field of assignmentsOf(body)) {
130
+ const block = blockOf(field);
131
+ if (block !== undefined && requirementBlocks.has(field.key.value))
132
+ visitTrigger(block);
133
+ }
134
+ };
135
+ if (family === 'decisions') {
136
+ for (const category of top)
137
+ for (const decision of assignmentsOf(blockOf(category) ?? { ...document.root, entries: [] }))
138
+ visitDefinition(decision, decision.key.value);
139
+ }
140
+ else {
141
+ for (const entry of top) {
142
+ const focuses = entry.key.value === 'shared_focus'
143
+ ? [entry]
144
+ : entry.key.value === 'focus_tree'
145
+ ? assignmentsOf(blockOf(entry) ?? { ...document.root, entries: [] }).filter(({ key }) => key.value === 'focus')
146
+ : [];
147
+ for (const focus of focuses) {
148
+ const id = assignmentsOf(blockOf(focus) ?? { ...document.root, entries: [] }).find(({ key }) => key.value === 'id');
149
+ visitDefinition(focus, id === undefined ? undefined : scalarOf(id));
150
+ }
151
+ }
152
+ }
153
+ return findings;
154
+ }
155
+ /** Line and column of a finding in the checked file. */
156
+ export function playerTextPosition(path, lineIndex, finding) {
157
+ return locationFor(path, lineIndex, finding.offset, finding.end).start;
158
+ }