hoi4-agent-tools 4.0.0 → 4.0.3
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 +35 -0
- package/README.md +27 -53
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +18 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +115 -27
- package/dist/hoi4_agent_tools/core/job-worker.js +7 -2
- package/dist/hoi4_agent_tools/core/temporary-directories.js +293 -0
- package/dist/hoi4_agent_tools/core/worker-exit.js +80 -0
- package/dist/hoi4_agent_tools/gui/assets.js +3 -0
- package/dist/hoi4_agent_tools/gui/dds.js +8 -4
- package/dist/hoi4_agent_tools/gui/layout.js +45 -5
- package/dist/hoi4_agent_tools/gui/native-state.js +244 -0
- package/dist/hoi4_agent_tools/gui/renderer.js +12 -7
- package/dist/hoi4_agent_tools/gui/source-graph.js +17 -4
- package/dist/hoi4_agent_tools/gui/studio.js +33 -21
- package/dist/hoi4_agent_tools/mcp/prompts/probability.js +2 -2
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +8 -8
- package/dist/hoi4_agent_tools/mcp/tools/event.js +3 -3
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +5 -5
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -6
- package/dist/hoi4_agent_tools/mcp/tools/job.js +4 -4
- package/dist/hoi4_agent_tools/mcp/tools/map.js +4 -4
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +4 -4
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +12 -12
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +3 -3
- package/dist/hoi4_agent_tools/runtime.js +14 -1
- package/dist/hoi4_agent_tools/schemas/map-requests.js +1 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/analysis.md +2 -2
- package/docs/development.md +2 -0
- package/docs/events.md +2 -3
- package/docs/examples.md +8 -23
- package/docs/focus.md +3 -2
- package/docs/gui.md +7 -4
- package/docs/helper-expansion.md +1 -1
- package/docs/jobs.md +5 -2
- package/docs/map.md +5 -4
- package/docs/mechanics.md +2 -2
- package/docs/probability.md +5 -2
- package/docs/reference.md +2 -1
- package/docs/setup.md +2 -4
- package/docs/technology.md +2 -3
- package/docs/tools.md +12 -13
- package/docs/troubleshooting.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-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 +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { assignments, parseClausewitz } from '../core/source/index.js';
|
|
2
|
+
/** Windows whose branches the engine selects, keyed by the window's root element name. */
|
|
3
|
+
const NATIVE_BRANCHES = {
|
|
4
|
+
countrypoliticsview: [
|
|
5
|
+
{
|
|
6
|
+
elements: ['no_faction'],
|
|
7
|
+
shown: (state) => state.faction === 'none',
|
|
8
|
+
describe: 'the country is not in a faction',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
elements: ['original_in_faction'],
|
|
12
|
+
shown: (state) => state.faction === 'leader',
|
|
13
|
+
describe: 'the country leads its faction',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
elements: ['in_faction'],
|
|
17
|
+
shown: (state) => state.faction === 'member',
|
|
18
|
+
describe: 'the country is a member of a faction it does not lead',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
elements: ['power_balance_button'],
|
|
22
|
+
shown: (state) => state.balanceOfPower,
|
|
23
|
+
describe: 'the country has an active balance of power',
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Icons whose frame the game picks from the ruling ideology, as the ideology group at each
|
|
29
|
+
* one-based frame. `pol_factions_strip` holds fascism, communism, democratic and neutrality.
|
|
30
|
+
*/
|
|
31
|
+
const IDEOLOGY_FRAME_ICONS = {
|
|
32
|
+
pol_faction_icon: ['fascism', 'communism', 'democratic', 'neutrality'],
|
|
33
|
+
};
|
|
34
|
+
const DEFAULT_SCENARIO_DATE = '1936.1.1';
|
|
35
|
+
function scenarioDate(scenario) {
|
|
36
|
+
const parts = (scenario.date ?? DEFAULT_SCENARIO_DATE).split('.').map(Number);
|
|
37
|
+
return dateKey(parts[0] ?? 1936, parts[1] ?? 1, parts[2] ?? 1);
|
|
38
|
+
}
|
|
39
|
+
function dateKey(year, month, day) {
|
|
40
|
+
return year * 10_000 + month * 100 + day;
|
|
41
|
+
}
|
|
42
|
+
function scenarioCountryTag(scenario) {
|
|
43
|
+
const candidate = [
|
|
44
|
+
scenario.country?.tag,
|
|
45
|
+
scenario.country?.countryTag,
|
|
46
|
+
scenario.country?.GetTag,
|
|
47
|
+
].find((value) => typeof value === 'string' && /^[A-Za-z0-9_]{2,64}$/u.test(value));
|
|
48
|
+
return candidate?.toUpperCase();
|
|
49
|
+
}
|
|
50
|
+
function scenarioString(scenario, ...keys) {
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
const value = scenario.country?.[key];
|
|
53
|
+
if (typeof value === 'string' && /^[A-Za-z0-9_]{2,64}$/u.test(value))
|
|
54
|
+
return value.toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
function scenarioBoolean(scenario, ...keys) {
|
|
59
|
+
for (const key of keys) {
|
|
60
|
+
const value = scenario.country?.[key];
|
|
61
|
+
if (typeof value === 'boolean')
|
|
62
|
+
return value;
|
|
63
|
+
if (typeof value === 'string' && /^(?:yes|true)$/iu.test(value))
|
|
64
|
+
return true;
|
|
65
|
+
if (typeof value === 'string' && /^(?:no|false)$/iu.test(value))
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
function explicitFaction(scenario) {
|
|
71
|
+
const named = scenarioString(scenario, 'faction', 'factionRole');
|
|
72
|
+
if (named === 'none' || named === 'member' || named === 'leader')
|
|
73
|
+
return named;
|
|
74
|
+
const leader = scenarioBoolean(scenario, 'factionLeader', 'isFactionLeader');
|
|
75
|
+
if (leader === true)
|
|
76
|
+
return 'leader';
|
|
77
|
+
const member = scenarioBoolean(scenario, 'inFaction', 'isInFaction', 'faction');
|
|
78
|
+
if (member === false)
|
|
79
|
+
return 'none';
|
|
80
|
+
if (member === true)
|
|
81
|
+
return 'member';
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const FACTION_CREATION = new Set(['create_faction', 'create_faction_from_template']);
|
|
85
|
+
function visitHistory(entries, cutoff, facts, members, owner) {
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (entry.type !== 'assignment')
|
|
88
|
+
continue;
|
|
89
|
+
const key = entry.key.value;
|
|
90
|
+
const dated = /^(\d{1,4})\.(\d{1,2})\.(\d{1,2})$/u.exec(key);
|
|
91
|
+
if (dated !== null) {
|
|
92
|
+
// A dated block applies from its date; later blocks have not happened yet.
|
|
93
|
+
if (entry.value.type === 'block' && dateKey(+dated[1], +dated[2], +dated[3]) <= cutoff)
|
|
94
|
+
visitHistory(entry.value.entries, cutoff, facts, members, owner);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (key === 'set_politics' && entry.value.type === 'block') {
|
|
98
|
+
const party = assignments(entry.value, 'ruling_party')[0]?.value;
|
|
99
|
+
if (party?.type === 'scalar')
|
|
100
|
+
facts.ideology = party.value.toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
else if (FACTION_CREATION.has(key)) {
|
|
103
|
+
facts.createsFaction = true;
|
|
104
|
+
}
|
|
105
|
+
else if (key === 'set_power_balance') {
|
|
106
|
+
facts.powerBalance = true;
|
|
107
|
+
}
|
|
108
|
+
else if (key === 'remove_power_balance') {
|
|
109
|
+
facts.powerBalance = false;
|
|
110
|
+
}
|
|
111
|
+
else if (key === 'add_to_faction' &&
|
|
112
|
+
entry.value.type === 'scalar' &&
|
|
113
|
+
entry.value.value.toUpperCase() !== owner) {
|
|
114
|
+
const tag = entry.value.value.toUpperCase();
|
|
115
|
+
const joined = members.get(tag) ?? new Set();
|
|
116
|
+
joined.add(owner);
|
|
117
|
+
members.set(tag, joined);
|
|
118
|
+
}
|
|
119
|
+
else if ((key === 'if' || key === 'else' || key === 'else_if') &&
|
|
120
|
+
entry.value.type === 'block') {
|
|
121
|
+
// Conditions such as DLC checks cannot be evaluated here; their content is treated as
|
|
122
|
+
// part of the start, which matches a fully owned game.
|
|
123
|
+
visitHistory(entry.value.entries, cutoff, facts, members, owner);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function historyTag(file) {
|
|
128
|
+
const match = /^history\/countries\/([A-Za-z0-9_]{2,64})(?:\s*-[^/]*)?\.txt$/u.exec(file.relativePath.replaceAll('\\', '/'));
|
|
129
|
+
return match?.[1]?.toUpperCase();
|
|
130
|
+
}
|
|
131
|
+
function historyState(tag, files, cutoff) {
|
|
132
|
+
const facts = {
|
|
133
|
+
createsFaction: false,
|
|
134
|
+
powerBalance: false,
|
|
135
|
+
};
|
|
136
|
+
// `add_to_faction = X` in a leader's history adds X to that leader's faction, so a country
|
|
137
|
+
// is a member when another country's history adds it.
|
|
138
|
+
const additions = new Map();
|
|
139
|
+
let found = false;
|
|
140
|
+
for (const file of files) {
|
|
141
|
+
if (file.shadowedBy !== undefined)
|
|
142
|
+
continue;
|
|
143
|
+
const fileTag = historyTag(file);
|
|
144
|
+
if (fileTag === undefined)
|
|
145
|
+
continue;
|
|
146
|
+
const owned = fileTag === tag;
|
|
147
|
+
if (owned)
|
|
148
|
+
found = true;
|
|
149
|
+
// Only the country's own history carries its other facts; every other file can matter
|
|
150
|
+
// solely through the members it adds to a faction.
|
|
151
|
+
else if (!file.bytes.includes('add_to_faction'))
|
|
152
|
+
continue;
|
|
153
|
+
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
154
|
+
const own = {
|
|
155
|
+
createsFaction: false,
|
|
156
|
+
powerBalance: false,
|
|
157
|
+
};
|
|
158
|
+
visitHistory(document.root.entries, cutoff, owned ? facts : own, additions, fileTag);
|
|
159
|
+
}
|
|
160
|
+
const memberOfFaction = (additions.get(tag)?.size ?? 0) > 0;
|
|
161
|
+
return { facts, found, memberOfFaction };
|
|
162
|
+
}
|
|
163
|
+
/** Resolve the country facts the engine would use, explicit scenario values first. */
|
|
164
|
+
export function resolveNativeCountryState(scenario, files) {
|
|
165
|
+
const tag = scenarioCountryTag(scenario);
|
|
166
|
+
const history = tag === undefined
|
|
167
|
+
? undefined
|
|
168
|
+
: historyState(tag, files.filter(({ relativePath }) => relativePath.startsWith('history/countries/')), scenarioDate(scenario));
|
|
169
|
+
const knownHistory = history?.found === true ? history : undefined;
|
|
170
|
+
const ideologyFromScenario = scenarioString(scenario, 'ideology', 'rulingIdeology', 'countryIdeology');
|
|
171
|
+
const factionFromScenario = explicitFaction(scenario);
|
|
172
|
+
const bopFromScenario = scenarioBoolean(scenario, 'balanceOfPower', 'hasBalanceOfPower', 'powerBalance');
|
|
173
|
+
const ideology = ideologyFromScenario ?? knownHistory?.facts.ideology;
|
|
174
|
+
const faction = factionFromScenario ??
|
|
175
|
+
(knownHistory === undefined
|
|
176
|
+
? 'none'
|
|
177
|
+
: knownHistory.facts.createsFaction
|
|
178
|
+
? 'leader'
|
|
179
|
+
: knownHistory.memberOfFaction
|
|
180
|
+
? 'member'
|
|
181
|
+
: 'none');
|
|
182
|
+
return {
|
|
183
|
+
tag,
|
|
184
|
+
ideology,
|
|
185
|
+
faction,
|
|
186
|
+
balanceOfPower: bopFromScenario ?? knownHistory?.facts.powerBalance ?? false,
|
|
187
|
+
sources: {
|
|
188
|
+
ideology: ideologyFromScenario !== undefined
|
|
189
|
+
? 'scenario'
|
|
190
|
+
: knownHistory?.facts.ideology !== undefined
|
|
191
|
+
? 'history'
|
|
192
|
+
: 'unknown',
|
|
193
|
+
faction: factionFromScenario !== undefined
|
|
194
|
+
? 'scenario'
|
|
195
|
+
: knownHistory !== undefined
|
|
196
|
+
? 'history'
|
|
197
|
+
: 'default',
|
|
198
|
+
balanceOfPower: bopFromScenario !== undefined
|
|
199
|
+
? 'scenario'
|
|
200
|
+
: knownHistory !== undefined
|
|
201
|
+
? 'history'
|
|
202
|
+
: 'default',
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Whether any native decision can apply to this window, so the country state is worth resolving. */
|
|
207
|
+
export function windowHasNativeState(windowName, elementNames) {
|
|
208
|
+
return (NATIVE_BRANCHES[windowName] !== undefined ||
|
|
209
|
+
Object.keys(IDEOLOGY_FRAME_ICONS).some((name) => elementNames.has(name)));
|
|
210
|
+
}
|
|
211
|
+
/** The engine-selected branches and frames for a scene, below any explicit scenario input. */
|
|
212
|
+
export function nativeWindowState(windowName, elementNames, scenario, files) {
|
|
213
|
+
if (!windowHasNativeState(windowName, elementNames))
|
|
214
|
+
return undefined;
|
|
215
|
+
const state = resolveNativeCountryState(scenario, files);
|
|
216
|
+
const visibility = {};
|
|
217
|
+
const values = {};
|
|
218
|
+
const notes = [];
|
|
219
|
+
const subject = state.tag ?? 'the scenario country';
|
|
220
|
+
for (const rule of NATIVE_BRANCHES[windowName] ?? []) {
|
|
221
|
+
const shown = rule.shown(state);
|
|
222
|
+
for (const name of rule.elements)
|
|
223
|
+
visibility[name] = shown;
|
|
224
|
+
notes.push(`${rule.elements.join(', ')} ${shown ? 'is shown' : 'is hidden'} for ${subject}; the game shows it only when ${rule.describe}.`);
|
|
225
|
+
}
|
|
226
|
+
for (const [name, order] of Object.entries(IDEOLOGY_FRAME_ICONS)) {
|
|
227
|
+
if (!elementNames.has(name) || state.ideology === undefined)
|
|
228
|
+
continue;
|
|
229
|
+
const index = order.indexOf(state.ideology);
|
|
230
|
+
if (index < 0)
|
|
231
|
+
continue;
|
|
232
|
+
values[`${name}.frame`] = index + 1;
|
|
233
|
+
notes.push(`${name} shows the ${state.ideology} frame for ${subject}.`);
|
|
234
|
+
}
|
|
235
|
+
return { visibility, values, notes, state };
|
|
236
|
+
}
|
|
237
|
+
/** Whether the scenario needs history to resolve its native state, and so to be scanned. */
|
|
238
|
+
export function scenarioNeedsCountryHistory(scenario) {
|
|
239
|
+
if (scenarioCountryTag(scenario) === undefined)
|
|
240
|
+
return false;
|
|
241
|
+
return (scenarioString(scenario, 'ideology', 'rulingIdeology', 'countryIdeology') === undefined ||
|
|
242
|
+
explicitFaction(scenario) === undefined ||
|
|
243
|
+
scenarioBoolean(scenario, 'balanceOfPower', 'hasBalanceOfPower', 'powerBalance') === undefined);
|
|
244
|
+
}
|
|
@@ -658,15 +658,20 @@ export async function renderGuiScene(scene, variants = defaultVariants, signal,
|
|
|
658
658
|
const basePng = await sharp(Buffer.from(full.svg), { limitInputPixels: RENDER_MAX_PIXELS })
|
|
659
659
|
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
660
660
|
.toBuffer();
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
661
|
+
// Derived variants are rasterized one at a time: each decodes the full-size base raster, so
|
|
662
|
+
// running them together multiplies the peak native memory of one render.
|
|
663
|
+
const rendered = [];
|
|
664
|
+
for (const item of prepared) {
|
|
665
|
+
if (item.variant === 'full') {
|
|
666
|
+
rendered.push({
|
|
664
667
|
variant: item.variant,
|
|
665
668
|
svg: item.svg,
|
|
666
669
|
png: basePng,
|
|
667
670
|
width: item.view.width,
|
|
668
671
|
height: item.view.height,
|
|
669
|
-
};
|
|
672
|
+
});
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
670
675
|
signal?.throwIfAborted();
|
|
671
676
|
budget.reserveRasterOperation(`gui-derived-variant:${sha256Bytes(item.svg)}`, `GUI ${item.variant} derived rasterization`);
|
|
672
677
|
const png = item.variant === 'cropped'
|
|
@@ -684,14 +689,14 @@ export async function renderGuiScene(scene, variants = defaultVariants, signal,
|
|
|
684
689
|
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
685
690
|
.toBuffer();
|
|
686
691
|
signal?.throwIfAborted();
|
|
687
|
-
|
|
692
|
+
rendered.push({
|
|
688
693
|
variant: item.variant,
|
|
689
694
|
svg: item.svg,
|
|
690
695
|
png,
|
|
691
696
|
width: item.view.width,
|
|
692
697
|
height: item.view.height,
|
|
693
|
-
};
|
|
694
|
-
}
|
|
698
|
+
});
|
|
699
|
+
}
|
|
695
700
|
images = rendered;
|
|
696
701
|
}
|
|
697
702
|
return {
|
|
@@ -76,6 +76,7 @@ const modelledElementAttributes = new Set([
|
|
|
76
76
|
'hide',
|
|
77
77
|
'hidden',
|
|
78
78
|
'visible',
|
|
79
|
+
'show',
|
|
79
80
|
'enabled',
|
|
80
81
|
'context_aware_text',
|
|
81
82
|
]);
|
|
@@ -1063,9 +1064,13 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
|
|
|
1063
1064
|
for (const font of fonts) {
|
|
1064
1065
|
const key = font.name.toLocaleLowerCase('en-US');
|
|
1065
1066
|
const existing = fontByName.get(key);
|
|
1067
|
+
const declared = font.kind === 'bitmapfont';
|
|
1068
|
+
const existingDeclared = existing?.kind === 'bitmapfont';
|
|
1066
1069
|
if (existing === undefined ||
|
|
1067
|
-
(
|
|
1068
|
-
(
|
|
1070
|
+
(!existingDeclared && declared) ||
|
|
1071
|
+
(existingDeclared === declared &&
|
|
1072
|
+
((existing.override && !font.override) ||
|
|
1073
|
+
(existing.languages.length > 0 && font.languages.length === 0))))
|
|
1069
1074
|
fontByName.set(key, font);
|
|
1070
1075
|
}
|
|
1071
1076
|
const elementByName = new Map();
|
|
@@ -1126,7 +1131,7 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
|
|
|
1126
1131
|
addEdge(edges, 'uses_font', element.id, font?.id ?? `font:${fontName}`, font !== undefined, { fontName }, element.location);
|
|
1127
1132
|
}
|
|
1128
1133
|
for (const key of localisationAttributeNames) {
|
|
1129
|
-
const value = element.attributes
|
|
1134
|
+
const value = localisationAttributeValue(element.attributes, key);
|
|
1130
1135
|
if (typeof value !== 'string' ||
|
|
1131
1136
|
value.trim().length === 0 ||
|
|
1132
1137
|
!isStaticGuiLocalisationReference(value))
|
|
@@ -1206,6 +1211,14 @@ const localisationAttributeNames = [
|
|
|
1206
1211
|
'pdx_tooltip_delayed',
|
|
1207
1212
|
'hint_tag',
|
|
1208
1213
|
];
|
|
1214
|
+
/** Clausewitz keys are case-insensitive: vanilla spells the button label `buttontext`. */
|
|
1215
|
+
function localisationAttributeValue(attributes, name) {
|
|
1216
|
+
const lowered = name.toLowerCase();
|
|
1217
|
+
for (const [key, value] of Object.entries(attributes))
|
|
1218
|
+
if (key.toLowerCase() === lowered)
|
|
1219
|
+
return value;
|
|
1220
|
+
return undefined;
|
|
1221
|
+
}
|
|
1209
1222
|
function isStaticGuiLocalisationReference(value) {
|
|
1210
1223
|
const trimmed = value.trim();
|
|
1211
1224
|
return !(trimmed.startsWith('[') && trimmed.endsWith(']'));
|
|
@@ -1214,7 +1227,7 @@ function referencedGuiLocalisationKeys(elements, scriptedLocalisation) {
|
|
|
1214
1227
|
const keys = new Set(scriptedLocalisation.flatMap(({ localisationKeys }) => localisationKeys));
|
|
1215
1228
|
for (const element of elements) {
|
|
1216
1229
|
for (const attribute of localisationAttributeNames) {
|
|
1217
|
-
const value = element.attributes
|
|
1230
|
+
const value = localisationAttributeValue(element.attributes, attribute);
|
|
1218
1231
|
if (typeof value === 'string' &&
|
|
1219
1232
|
value.trim().length > 0 &&
|
|
1220
1233
|
isStaticGuiLocalisationReference(value))
|
|
@@ -17,6 +17,7 @@ import { TRANSACTION_MAX_DIAGNOSTICS } from '../core/transaction-limits.js';
|
|
|
17
17
|
import { PACKAGE_VERSION } from '../version.js';
|
|
18
18
|
import { isSafeAnimationSourcePath } from './animation-manifest.js';
|
|
19
19
|
import { GuiAssetCatalog, parseBmFont } from './assets.js';
|
|
20
|
+
import { scenarioNeedsCountryHistory } from './native-state.js';
|
|
20
21
|
import { projectGuiGraphForArtifact } from './inspection-artifact.js';
|
|
21
22
|
import { buildGuiScene } from './layout.js';
|
|
22
23
|
import { GUI_VALIDATION_MAX_DIAGNOSTICS } from './limits.js';
|
|
@@ -333,7 +334,13 @@ function assertTargetWindowAvailable(graph, windowName) {
|
|
|
333
334
|
})),
|
|
334
335
|
});
|
|
335
336
|
}
|
|
337
|
+
const lowerCaseKeySets = new WeakMap();
|
|
336
338
|
function collectNamedAttributes(value, keys, output) {
|
|
339
|
+
let lowered = lowerCaseKeySets.get(keys);
|
|
340
|
+
if (lowered === undefined) {
|
|
341
|
+
lowered = new Set([...keys].map((key) => key.toLowerCase()));
|
|
342
|
+
lowerCaseKeySets.set(keys, lowered);
|
|
343
|
+
}
|
|
337
344
|
if (Array.isArray(value)) {
|
|
338
345
|
for (const entry of value)
|
|
339
346
|
collectNamedAttributes(entry, keys, output);
|
|
@@ -342,7 +349,7 @@ function collectNamedAttributes(value, keys, output) {
|
|
|
342
349
|
if (typeof value !== 'object' || value === null)
|
|
343
350
|
return;
|
|
344
351
|
for (const [key, entry] of Object.entries(value)) {
|
|
345
|
-
if (
|
|
352
|
+
if (lowered.has(key.toLowerCase()) && typeof entry === 'string')
|
|
346
353
|
output.add(entry);
|
|
347
354
|
collectNamedAttributes(entry, keys, output);
|
|
348
355
|
}
|
|
@@ -559,6 +566,16 @@ export function scenarioCountryFlagPatterns(scenarios) {
|
|
|
559
566
|
`gfx/flags/${directory}${tag}_*.{bmp,dds,png,tga}`,
|
|
560
567
|
]));
|
|
561
568
|
}
|
|
569
|
+
/**
|
|
570
|
+
* Engine-controlled windows read the scenario country's faction, balance of power and ruling
|
|
571
|
+
* party from country history; the faction facts also live in other countries' histories.
|
|
572
|
+
*/
|
|
573
|
+
function scenarioCountryHistoryPatterns(scenarios) {
|
|
574
|
+
return scenarios.some(scenarioNeedsCountryHistory) ? ['history/countries/*.txt'] : [];
|
|
575
|
+
}
|
|
576
|
+
function scenarioCountryAssetPatterns(scenarios) {
|
|
577
|
+
return [...scenarioCountryFlagPatterns(scenarios), ...scenarioCountryHistoryPatterns(scenarios)];
|
|
578
|
+
}
|
|
562
579
|
function bmFontPagePatterns(files) {
|
|
563
580
|
const references = files.flatMap((file) => {
|
|
564
581
|
if (!file.relativePath.toLowerCase().endsWith('.fnt') || file.shadowedBy !== undefined)
|
|
@@ -1072,7 +1089,7 @@ export class ScriptedGuiStudio {
|
|
|
1072
1089
|
const generatedOptions = input.generatedScenarios === undefined
|
|
1073
1090
|
? undefined
|
|
1074
1091
|
: parseGeneratedScenarioOptions(input.generatedScenarios);
|
|
1075
|
-
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]),
|
|
1092
|
+
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryAssetPatterns([placeholderScenario, ...explicitRelatedScenarios]), scenarioLocalisationKeys([placeholderScenario, ...explicitRelatedScenarios]), [placeholderScenario, ...explicitRelatedScenarios].some(({ tooltip }) => tooltip !== undefined));
|
|
1076
1093
|
let builtDefinitions;
|
|
1077
1094
|
const evaluationDefinitions = () => (builtDefinitions ??= ClausewitzEvaluationDefinitions.build(scanned));
|
|
1078
1095
|
const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
|
|
@@ -1106,15 +1123,14 @@ export class ScriptedGuiStudio {
|
|
|
1106
1123
|
const budget = new RenderBudget();
|
|
1107
1124
|
const beforeScenario = parsePreviewScenario(input.before);
|
|
1108
1125
|
const afterScenario = parsePreviewScenario(input.after);
|
|
1109
|
-
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]), scenarioTemplateNames([beforeScenario, afterScenario]),
|
|
1126
|
+
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]), scenarioTemplateNames([beforeScenario, afterScenario]), scenarioCountryAssetPatterns([beforeScenario, afterScenario]), scenarioLocalisationKeys([beforeScenario, afterScenario]), [beforeScenario, afterScenario].some(({ tooltip }) => tooltip !== undefined));
|
|
1110
1127
|
const beforeCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, beforeScenario.language);
|
|
1111
1128
|
const afterCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, afterScenario.language);
|
|
1112
1129
|
const before = await buildGuiScene(scanned.graph, scanned.files, input.windowName, beforeScenario, beforeCatalog);
|
|
1113
1130
|
const after = await buildGuiScene(scanned.graph, scanned.files, input.windowName, afterScenario, afterCatalog);
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
]);
|
|
1131
|
+
// One full raster at a time keeps the peak native memory of a comparison to a single render.
|
|
1132
|
+
const beforeRender = await renderGuiScene(before, ['full'], input.signal, budget);
|
|
1133
|
+
const afterRender = await renderGuiScene(after, ['full'], input.signal, budget);
|
|
1118
1134
|
const comparison = await compareGuiImages(fullImage(beforeRender).png, fullImage(afterRender).png, budget, input.signal);
|
|
1119
1135
|
const evidenceJson = `${canonicalJson({
|
|
1120
1136
|
offline: true,
|
|
@@ -1159,7 +1175,7 @@ export class ScriptedGuiStudio {
|
|
|
1159
1175
|
placeholderScenario,
|
|
1160
1176
|
...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
|
|
1161
1177
|
...explicitRelatedScenarios,
|
|
1162
|
-
]),
|
|
1178
|
+
]), scenarioCountryAssetPatterns([
|
|
1163
1179
|
placeholderScenario,
|
|
1164
1180
|
...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
|
|
1165
1181
|
...explicitRelatedScenarios,
|
|
@@ -1316,17 +1332,13 @@ export class ScriptedGuiStudio {
|
|
|
1316
1332
|
galleryBudget.reserveRasterOperation(`gui-gallery:${sha256Bytes(scenarioGallerySvg)}`, 'GUI scripted scenario gallery SVG rasterization');
|
|
1317
1333
|
galleryBudget.reserveRasterOperation(`gui-gallery:${sha256Bytes(stateGallerySvg)}`, 'GUI state gallery SVG rasterization');
|
|
1318
1334
|
galleryBudget.reserveRasterOperation(`gui-gallery:${sha256Bytes(resolutionGallerySvg)}`, 'GUI resolution gallery SVG rasterization');
|
|
1319
|
-
const
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
sharp(Buffer.from(resolutionGallerySvg), { limitInputPixels: RENDER_MAX_PIXELS })
|
|
1327
|
-
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
1328
|
-
.toBuffer(),
|
|
1329
|
-
]);
|
|
1335
|
+
const rasterizeGallery = (svg) => sharp(Buffer.from(svg), { limitInputPixels: RENDER_MAX_PIXELS })
|
|
1336
|
+
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
1337
|
+
.toBuffer();
|
|
1338
|
+
// Gallery canvases are large and independent; rasterize them one at a time.
|
|
1339
|
+
const scenarioGalleryPng = await rasterizeGallery(scenarioGallerySvg);
|
|
1340
|
+
const stateGalleryPng = await rasterizeGallery(stateGallerySvg);
|
|
1341
|
+
const resolutionGalleryPng = await rasterizeGallery(resolutionGallerySvg);
|
|
1330
1342
|
const slug = safeSlug(input.windowName);
|
|
1331
1343
|
const projectedArtifactGraph = projectGuiGraphForArtifact(scanned.graph, [
|
|
1332
1344
|
...new Set([...scenarioScenes, ...stateScenes, ...resolutionScenes, baselineScene].flatMap(({ elements }) => elements.map(({ sourceId }) => sourceId))),
|
|
@@ -1522,7 +1534,7 @@ export class ScriptedGuiStudio {
|
|
|
1522
1534
|
const previewScenario = input.scenario === undefined ? undefined : parsePreviewScenario(input.scenario);
|
|
1523
1535
|
const initiallyScanned = input.windowName === undefined
|
|
1524
1536
|
? await this.scan(input.workspaceId, input.principal, input.signal)
|
|
1525
|
-
: await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]), scenarioTemplateNames([previewScenario]),
|
|
1537
|
+
: await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]), scenarioTemplateNames([previewScenario]), scenarioCountryAssetPatterns([previewScenario]), scenarioLocalisationKeys([previewScenario]), previewScenario.tooltip !== undefined);
|
|
1526
1538
|
const exactTargets = await this.scanner.scan(workspace, {
|
|
1527
1539
|
patterns: prepared.map(({ relativePath }) => relativePath),
|
|
1528
1540
|
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
@@ -1544,7 +1556,7 @@ export class ScriptedGuiStudio {
|
|
|
1544
1556
|
let proposedGraph = buildGuiSourceGraph(proposedFiles, this.engine.indexFiles(proposedFiles));
|
|
1545
1557
|
const proposedAssetPatterns = [
|
|
1546
1558
|
...referencedAssetPatternsForWindow(proposedGraph, input.windowName, [], [scenario.language], scenarioTemplateNames([scenario])),
|
|
1547
|
-
...
|
|
1559
|
+
...scenarioCountryAssetPatterns([scenario]),
|
|
1548
1560
|
];
|
|
1549
1561
|
const exactProposedAssets = proposedAssetPatterns.length === 0
|
|
1550
1562
|
? []
|
|
@@ -5,8 +5,8 @@ export const probabilityPromptArguments = z.object({
|
|
|
5
5
|
});
|
|
6
6
|
export const probabilityPromptDefinition = {
|
|
7
7
|
name: 'hoi4.probability_analysis',
|
|
8
|
-
title: 'Analyze
|
|
9
|
-
description: '
|
|
8
|
+
title: 'Analyze AI choices and event timing',
|
|
9
|
+
description: 'Outline an analysis of AI choices, event timing, random chances or a custom weighted pool.',
|
|
10
10
|
};
|
|
11
11
|
/** One prompt body for both protocol generations; it does not run an analysis. */
|
|
12
12
|
export function probabilityPrompt(input) {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Shared agent-facing guidance for both MCP protocol generations. */
|
|
2
|
-
export const SERVER_INSTRUCTIONS = 'Start with the matching inspect tool; use hoi4.probability_inspect for weights and timing. Find local documentation with hoi4.reference_context, hoi4.reference_search and hoi4.reference_read, and source definitions with hoi4.source_lookup.
|
|
2
|
+
export const SERVER_INSTRUCTIONS = 'Start with the matching inspect tool; use hoi4.probability_inspect for weights and timing. Find local documentation with hoi4.reference_context, hoi4.reference_search and hoi4.reference_read, and source definitions with hoi4.source_lookup. Rewrite tools edit mod source. Results describe offline source and scenario models, not observed game behavior. Unresolved states are not confirmed coverage. Read linked artifacts when summaries omit detail. Long operations support negotiated MCP tasks and hoi4.job_inspect/hoi4.job_cancel; background rewrites need a stable requestKey.';
|
|
@@ -112,7 +112,7 @@ const packageInputSchema = z
|
|
|
112
112
|
const scenarioInputSchema = z
|
|
113
113
|
.object({
|
|
114
114
|
...scenarioTestRequestSchema.shape,
|
|
115
|
-
suite: compactValidatedInputSchema(scenarioSuiteSchema, 'Named mechanic, package
|
|
115
|
+
suite: compactValidatedInputSchema(scenarioSuiteSchema, 'Named mechanic, package or inspection cases. See docs/mechanics.md.').optional(),
|
|
116
116
|
})
|
|
117
117
|
.strict()
|
|
118
118
|
.superRefine((input, context) => {
|
|
@@ -131,8 +131,8 @@ const readOnlyAnalysisTool = {
|
|
|
131
131
|
export const analysisTaskTools = [
|
|
132
132
|
{
|
|
133
133
|
name: 'hoi4.impact_inspect',
|
|
134
|
-
title: '
|
|
135
|
-
description: '
|
|
134
|
+
title: 'Find what a script change affects',
|
|
135
|
+
description: 'Find what a script change affects across focuses, events, decisions, ideas, windows and helpers. Trace definitions and their uses, including proposed source. Example: {"symbols":[{"kind":"scripted_effect","id":"my_effect"}]}. Returns consumer counts inline; the revision-bound graph is a linked resource.',
|
|
136
136
|
inputSchema: impactInspectRequestSchema,
|
|
137
137
|
outputSchema: impactOutputSchema,
|
|
138
138
|
annotations: readOnlyAnalysisTool,
|
|
@@ -140,15 +140,15 @@ export const analysisTaskTools = [
|
|
|
140
140
|
{
|
|
141
141
|
name: 'hoi4.decision_inspect',
|
|
142
142
|
title: 'Inspect decisions and missions',
|
|
143
|
-
description: '
|
|
143
|
+
description: 'Explain why a decision or mission is hidden, unavailable or unaffordable. Inspect conditions, costs and lifecycle for declared actors and targets, or compare proposed source. Example: {"mode":"inspect","id":"my_decision","scenarios":[{"id":"base","actor":"GER","state":{}}]}. Returns each scenario outcome inline; full evidence is linked.',
|
|
144
144
|
inputSchema: decisionInspectRequestSchema,
|
|
145
145
|
outputSchema: decisionOutputSchema,
|
|
146
146
|
annotations: readOnlyAnalysisTool,
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
149
|
name: 'hoi4.mechanic_test',
|
|
150
|
-
title: 'Test
|
|
151
|
-
description: '
|
|
150
|
+
title: 'Test scripted effects',
|
|
151
|
+
description: 'Test what an effect, event option, decision or focus reward does to a declared state. Run steps and check assertions; unsupported effects stay unresolved. See docs/mechanics.md. Example: {"test":{"id":"t","scenario":{"schemaVersion":"1.0","id":"t","state":{"treasury":10}},"steps":[...],"assertions":[...]}}.',
|
|
152
152
|
inputSchema: mechanicInputSchema,
|
|
153
153
|
outputSchema: mechanicOutputSchema,
|
|
154
154
|
annotations: readOnlyAnalysisTool,
|
|
@@ -156,7 +156,7 @@ export const analysisTaskTools = [
|
|
|
156
156
|
{
|
|
157
157
|
name: 'hoi4.package_check',
|
|
158
158
|
title: 'Check package connections',
|
|
159
|
-
description: '
|
|
159
|
+
description: 'Find missing pieces of a feature by checking its package manifest against definitions, calls, registrations, localisation, assets and required test cases. Example: {"manifest":{"schemaVersion":"1.0","id":"my_package","definitions":[{"kind":"scripted_effect","id":"my_effect"}]}}. Returns present, absent, shadowed and unresolved counts inline.',
|
|
160
160
|
inputSchema: packageInputSchema,
|
|
161
161
|
outputSchema: packageOutputSchema,
|
|
162
162
|
annotations: readOnlyAnalysisTool,
|
|
@@ -164,7 +164,7 @@ export const analysisTaskTools = [
|
|
|
164
164
|
{
|
|
165
165
|
name: 'hoi4.scenario_test',
|
|
166
166
|
title: 'Run scenario suite',
|
|
167
|
-
description: 'Run
|
|
167
|
+
description: 'Run named mechanic, package or inspection cases from a suite or workspace file. Continue unfinished batches and report failures and unresolved cases. Example: {"suitePath":"tests/my_suite.json","maxCases":20}. Returns completed, failed, unresolved and pending counts, and a continuation while cases remain.',
|
|
168
168
|
inputSchema: scenarioInputSchema,
|
|
169
169
|
outputSchema: scenarioOutputSchema,
|
|
170
170
|
annotations: readOnlyAnalysisTool,
|
|
@@ -169,15 +169,15 @@ export const eventTaskTools = [
|
|
|
169
169
|
{
|
|
170
170
|
name: 'hoi4.event_inspect',
|
|
171
171
|
title: 'Inspect event chains',
|
|
172
|
-
description: '
|
|
172
|
+
description: 'Follow event chains and explain their options, conditions, timing and state changes. Find broken calls, inspect affected events or compare revisions with mode compare. Example: {"mode":"trace","selector":"my_mod.1","direction":"downstream"}; a selector is an event ID, a namespace, my_mod.* or a file. Returns counts and the main findings inline; the full source-linked report is a resource.',
|
|
173
173
|
inputSchema: eventInspectInputSchema,
|
|
174
174
|
outputSchema: eventInspectOutputSchema,
|
|
175
175
|
annotations: readOnlyEventTool,
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
178
|
name: 'hoi4.event_render',
|
|
179
|
-
title: '
|
|
180
|
-
description: '
|
|
179
|
+
title: 'Preview event chains',
|
|
180
|
+
description: 'Preview event routes, options, timing, state and scope as JSON, SVG and PNG. Example: {"view":"overview","selector":"my_mod"}. Images are linked artifacts; calls that cannot be resolved statically stay explicit unresolved edges.',
|
|
181
181
|
inputSchema: eventRenderInputSchema,
|
|
182
182
|
outputSchema: eventRenderOutputSchema,
|
|
183
183
|
annotations: readOnlyEventTool,
|
|
@@ -135,23 +135,23 @@ export const focusTaskTools = [
|
|
|
135
135
|
{
|
|
136
136
|
name: 'hoi4.focus_inspect',
|
|
137
137
|
title: 'Inspect focus trees',
|
|
138
|
-
description: '
|
|
138
|
+
description: 'Find focus branches, prerequisites, layout problems and missing references in national trees and continuous-focus palettes. Example: {"treeId":"my_focus_tree"}. Returns counts and the main diagnostics inline; the full report is a linked resource.',
|
|
139
139
|
inputSchema: focusInspectRequestSchema,
|
|
140
140
|
outputSchema: focusInspectOutputSchema,
|
|
141
141
|
annotations: artifactProducing,
|
|
142
142
|
},
|
|
143
143
|
{
|
|
144
144
|
name: 'hoi4.focus_render',
|
|
145
|
-
title: '
|
|
146
|
-
description: '
|
|
145
|
+
title: 'Preview a focus tree or continuous palette',
|
|
146
|
+
description: 'Preview a focus tree or palette with its icons. Export PNG, HTML, SVG and JSON source maps; png:false skips the PNG and cropFocusIds adds close-ups. Example: {"relativePath":"common/national_focus/my_tree.txt","treeId":"my_focus_tree"}. Images are linked artifacts, not inline.',
|
|
147
147
|
inputSchema: focusRenderRequestSchema,
|
|
148
148
|
outputSchema: focusRenderOutputSchema,
|
|
149
149
|
annotations: artifactProducing,
|
|
150
150
|
},
|
|
151
151
|
{
|
|
152
152
|
name: 'hoi4.focus_rewrite',
|
|
153
|
-
title: '
|
|
154
|
-
description: 'Create
|
|
153
|
+
title: 'Build or arrange a focus tree',
|
|
154
|
+
description: 'Create focus trees and continuous-focus palettes, or rearrange existing branches. Use createIfMissing with a complete plan to create content, or layoutMode to arrange an existing tree. Example: {"relativePath":"common/national_focus/my_tree.txt","treeId":"my_focus_tree","layoutMode":"compact"}. Returns the changed files.',
|
|
155
155
|
inputSchema: focusRewriteRequestSchema,
|
|
156
156
|
outputSchema: focusPlanOutputSchema,
|
|
157
157
|
annotations: {
|