hoi4-agent-tools 3.10.0 → 4.0.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 +109 -0
- package/README.md +115 -147
- package/dist/bin/http.js +10 -0
- package/dist/bin/setup.js +3 -2
- package/dist/bin/stdio.js +10 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
- package/dist/hoi4_agent_tools/core/canonical.js +18 -4
- package/dist/hoi4_agent_tools/core/configuration.js +7 -0
- package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
- package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
- package/dist/hoi4_agent_tools/core/engine.js +29 -2
- package/dist/hoi4_agent_tools/core/index.js +2 -2
- package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
- package/dist/hoi4_agent_tools/core/job-store.js +78 -5
- package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
- package/dist/hoi4_agent_tools/core/job-worker-host.js +215 -116
- package/dist/hoi4_agent_tools/core/job-worker.js +90 -36
- package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
- package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
- package/dist/hoi4_agent_tools/core/server-registry.js +22 -5
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
- package/dist/hoi4_agent_tools/core/source/cache.js +20 -10
- package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
- package/dist/hoi4_agent_tools/core/transactions.js +114 -23
- package/dist/hoi4_agent_tools/core/workspace.js +84 -8
- package/dist/hoi4_agent_tools/decision/ai.js +1 -1
- package/dist/hoi4_agent_tools/decision/service.js +28 -0
- package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
- package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
- package/dist/hoi4_agent_tools/event/operations.js +14 -1
- package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
- package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
- package/dist/hoi4_agent_tools/gui/validators.js +8 -6
- package/dist/hoi4_agent_tools/map/bmp.js +1 -1
- package/dist/hoi4_agent_tools/map/derived.js +748 -0
- package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
- package/dist/hoi4_agent_tools/map/model.js +103 -0
- package/dist/hoi4_agent_tools/map/operations.js +427 -58
- package/dist/hoi4_agent_tools/map/references.js +483 -0
- package/dist/hoi4_agent_tools/map/service.js +136 -61
- package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
- package/dist/hoi4_agent_tools/map/validation.js +3 -0
- package/dist/hoi4_agent_tools/map/world.js +1280 -0
- package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
- package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
- package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
- package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
- package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
- package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
- package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -29
- package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +31 -8
- package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
- package/dist/hoi4_agent_tools/probability/operations.js +175 -13
- package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
- package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
- package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
- package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
- package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
- package/dist/hoi4_agent_tools/reference/operations.js +88 -19
- package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
- package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
- package/dist/hoi4_agent_tools/reference/service.js +6 -0
- package/dist/hoi4_agent_tools/schemas/event.js +78 -19
- package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
- package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
- package/dist/hoi4_agent_tools/schemas/map.js +122 -3
- package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
- package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
- package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
- package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
- package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
- package/dist/hoi4_agent_tools/schemas/technology.js +79 -21
- package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
- package/dist/hoi4_agent_tools/technology/operations.js +14 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +7 -4
- package/docs/analysis.md +13 -0
- package/docs/configuration.md +281 -0
- package/docs/development.md +24 -2
- package/docs/events.md +27 -3
- package/docs/examples.md +6 -6
- package/docs/focus.md +125 -4
- package/docs/gui.md +98 -17
- package/docs/helper-expansion.md +8 -8
- package/docs/http.md +3 -1
- package/docs/jobs.md +19 -13
- package/docs/map.md +197 -6
- package/docs/mechanics.md +4 -0
- package/docs/probability.md +42 -15
- package/docs/reference.md +71 -4
- package/docs/setup.md +44 -11
- package/docs/technology.md +26 -1
- package/docs/third-party-notices.md +11 -0
- package/docs/tools.md +151 -42
- package/docs/troubleshooting.md +61 -0
- package/examples/clients/codex-config.toml +5 -2
- package/examples/clients/generic.json +2 -2
- package/examples/config/config.json +7 -0
- package/npm-shrinkwrap.json +151 -156
- package/package.json +13 -8
- package/schemas/configuration.schema.json +144 -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/error-log-input.schema.json +55 -0
- 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 +597 -126
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +51 -0
- package/schemas/operation-result.schema.json +2 -2
- 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 +3 -5
- package/schemas/scenario-test-input.schema.json +3 -5
- 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,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map references in script: the states, provinces and strategic regions that events, focuses,
|
|
3
|
+
* decisions, scripted helpers, on-actions, ideas, country and unit history, and AI areas name by
|
|
4
|
+
* number. Map edits use this to find references a split, merge, removal or renumbering leaves
|
|
5
|
+
* dangling, and to rewrite them, including in game files the mod overrides.
|
|
6
|
+
*/
|
|
7
|
+
import { compareCodeUnits } from '../core/canonical.js';
|
|
8
|
+
import { nodeLocation, parseClausewitz, } from '../core/source/index.js';
|
|
9
|
+
/** Folders whose script names map entities by number. */
|
|
10
|
+
export const MAP_REFERENCE_SCRIPT_PATTERNS = [
|
|
11
|
+
'events/**/*.txt',
|
|
12
|
+
'common/national_focus/**/*.txt',
|
|
13
|
+
'common/decisions/**/*.txt',
|
|
14
|
+
'common/scripted_effects/**/*.txt',
|
|
15
|
+
'common/scripted_triggers/**/*.txt',
|
|
16
|
+
'common/on_actions/**/*.txt',
|
|
17
|
+
'common/ideas/**/*.txt',
|
|
18
|
+
'common/characters/**/*.txt',
|
|
19
|
+
'common/ai_strategy/**/*.txt',
|
|
20
|
+
'common/ai_areas/**/*.txt',
|
|
21
|
+
'history/countries/**/*.txt',
|
|
22
|
+
'history/units/**/*.txt',
|
|
23
|
+
'common/country_tags/*.txt',
|
|
24
|
+
];
|
|
25
|
+
const SCRIPT_FILE = /^(?:events|common\/(?:national_focus|decisions|scripted_effects|scripted_triggers|on_actions|ideas|characters|ai_strategy|ai_areas)|history\/(?:countries|units))\/.+\.txt$/iu;
|
|
26
|
+
export function mapReferenceScriptFile(relativePath) {
|
|
27
|
+
return SCRIPT_FILE.test(relativePath.replaceAll('\\', '/'));
|
|
28
|
+
}
|
|
29
|
+
const FIELD_KEYS = {
|
|
30
|
+
state: new Set([
|
|
31
|
+
'state',
|
|
32
|
+
'state_id',
|
|
33
|
+
'start_state',
|
|
34
|
+
'target_state',
|
|
35
|
+
'owns_state',
|
|
36
|
+
'controls_state',
|
|
37
|
+
'has_full_control_of_state',
|
|
38
|
+
'transfer_state',
|
|
39
|
+
'transfer_state_to',
|
|
40
|
+
'set_state_controller',
|
|
41
|
+
'add_state_core',
|
|
42
|
+
'add_state_claim',
|
|
43
|
+
'remove_state_core',
|
|
44
|
+
'remove_state_claim',
|
|
45
|
+
'to_state',
|
|
46
|
+
'border_state',
|
|
47
|
+
'capital',
|
|
48
|
+
'has_naval_invasion_against_state',
|
|
49
|
+
]),
|
|
50
|
+
province: new Set([
|
|
51
|
+
'province',
|
|
52
|
+
'province_id',
|
|
53
|
+
'start_province',
|
|
54
|
+
'target_province',
|
|
55
|
+
'controls_province',
|
|
56
|
+
'set_province_controller',
|
|
57
|
+
'reset_province_name',
|
|
58
|
+
'goto_province',
|
|
59
|
+
'prioritize_location',
|
|
60
|
+
'to_province',
|
|
61
|
+
]),
|
|
62
|
+
'strategic-region': new Set(['strategic_region', 'strategic_region_id', 'region', 'area']),
|
|
63
|
+
};
|
|
64
|
+
/** Blocks whose `id = <province>` children name provinces: `province = { id = 500 }`. */
|
|
65
|
+
const PROVINCE_ID_BLOCKS = new Set(['province', 'provinces', 'set_province_name']);
|
|
66
|
+
/** Blocks that list states by number: `random_state = { prioritize = { 64 65 } }`. */
|
|
67
|
+
const STATE_LIST_BLOCKS = new Set(['prioritize']);
|
|
68
|
+
/** Keys whose meaning depends on the folder they appear in. */
|
|
69
|
+
function folderFieldEntity(relativePath, key) {
|
|
70
|
+
const normalized = relativePath.replaceAll('\\', '/').toLowerCase();
|
|
71
|
+
if (normalized.startsWith('history/countries/') && key === 'capital')
|
|
72
|
+
return 'state';
|
|
73
|
+
if (normalized.startsWith('history/units/') && (key === 'location' || key === 'base'))
|
|
74
|
+
return 'province';
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
function fieldEntity(relativePath, key) {
|
|
78
|
+
const byFolder = folderFieldEntity(relativePath, key);
|
|
79
|
+
if (byFolder !== undefined)
|
|
80
|
+
return byFolder;
|
|
81
|
+
for (const [entity, keys] of Object.entries(FIELD_KEYS))
|
|
82
|
+
if (keys.has(key))
|
|
83
|
+
return entity;
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
/** Blocks whose numeric keys are weights, not state scopes. */
|
|
87
|
+
const WEIGHTED_BLOCKS = new Set(['random_list', 'random_events', 'random']);
|
|
88
|
+
const NUMBER = /^\d+$/u;
|
|
89
|
+
/**
|
|
90
|
+
* Keys that take exactly one value, such as `capital = 64` or the `state` argument of an effect.
|
|
91
|
+
* Copying them for a second state would give the same block two values, and deleting them would
|
|
92
|
+
* leave an incomplete command, so a remap changes them only to a single new target and leaves
|
|
93
|
+
* them for review when their state has no successor.
|
|
94
|
+
*/
|
|
95
|
+
const SINGLE_VALUED_KEYS = new Set([
|
|
96
|
+
'state',
|
|
97
|
+
'state_id',
|
|
98
|
+
'capital',
|
|
99
|
+
'to_state',
|
|
100
|
+
'border_state',
|
|
101
|
+
'start_state',
|
|
102
|
+
'target_state',
|
|
103
|
+
'province',
|
|
104
|
+
'province_id',
|
|
105
|
+
'start_province',
|
|
106
|
+
'target_province',
|
|
107
|
+
'to_province',
|
|
108
|
+
'goto_province',
|
|
109
|
+
'prioritize_location',
|
|
110
|
+
'strategic_region',
|
|
111
|
+
'strategic_region_id',
|
|
112
|
+
'region',
|
|
113
|
+
'area',
|
|
114
|
+
'set_province_name.id',
|
|
115
|
+
]);
|
|
116
|
+
export function singleValuedReference(reference) {
|
|
117
|
+
return reference.form === 'field' && SINGLE_VALUED_KEYS.has(reference.key);
|
|
118
|
+
}
|
|
119
|
+
/** Collect every numbered map reference in the active (unshadowed) script files given. */
|
|
120
|
+
export function collectMapScriptReferences(files) {
|
|
121
|
+
const references = [];
|
|
122
|
+
const unparsable = [];
|
|
123
|
+
const active = files
|
|
124
|
+
.filter((file) => file.shadowedBy === undefined && mapReferenceScriptFile(file.relativePath))
|
|
125
|
+
.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
|
|
126
|
+
for (const file of active) {
|
|
127
|
+
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
128
|
+
if (document.diagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker'))
|
|
129
|
+
unparsable.push({ file, diagnostics: document.diagnostics });
|
|
130
|
+
const aiAreas = file.relativePath
|
|
131
|
+
.replaceAll('\\', '/')
|
|
132
|
+
.toLowerCase()
|
|
133
|
+
.startsWith('common/ai_areas/');
|
|
134
|
+
const visit = (entry, weighted) => {
|
|
135
|
+
if (entry.type === 'block') {
|
|
136
|
+
for (const child of entry.entries)
|
|
137
|
+
visit(child, weighted);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (entry.type !== 'assignment')
|
|
141
|
+
return;
|
|
142
|
+
const key = entry.key.value;
|
|
143
|
+
if (entry.value.type === 'block') {
|
|
144
|
+
if (!weighted && NUMBER.test(key) && !aiAreas)
|
|
145
|
+
references.push({
|
|
146
|
+
entity: 'state',
|
|
147
|
+
id: Number(key),
|
|
148
|
+
key,
|
|
149
|
+
form: 'scope',
|
|
150
|
+
file,
|
|
151
|
+
document,
|
|
152
|
+
node: entry.key,
|
|
153
|
+
assignment: entry,
|
|
154
|
+
});
|
|
155
|
+
const listEntity = aiAreas && key === 'strategic_regions' ? 'strategic-region' : undefined;
|
|
156
|
+
const listedEntity = listEntity ?? (STATE_LIST_BLOCKS.has(key) ? 'state' : undefined);
|
|
157
|
+
if (listedEntity !== undefined)
|
|
158
|
+
for (const item of entry.value.entries)
|
|
159
|
+
if (item.type === 'scalar' && NUMBER.test(item.value))
|
|
160
|
+
references.push({
|
|
161
|
+
entity: listedEntity,
|
|
162
|
+
id: Number(item.value),
|
|
163
|
+
key,
|
|
164
|
+
form: 'list',
|
|
165
|
+
file,
|
|
166
|
+
document,
|
|
167
|
+
node: item,
|
|
168
|
+
assignment: entry,
|
|
169
|
+
});
|
|
170
|
+
if (PROVINCE_ID_BLOCKS.has(key))
|
|
171
|
+
for (const child of entry.value.entries)
|
|
172
|
+
if (child.type === 'assignment' &&
|
|
173
|
+
child.key.value === 'id' &&
|
|
174
|
+
child.value.type === 'scalar' &&
|
|
175
|
+
NUMBER.test(child.value.value))
|
|
176
|
+
references.push({
|
|
177
|
+
entity: 'province',
|
|
178
|
+
id: Number(child.value.value),
|
|
179
|
+
key: `${key}.id`,
|
|
180
|
+
form: 'field',
|
|
181
|
+
file,
|
|
182
|
+
document,
|
|
183
|
+
node: child.value,
|
|
184
|
+
// Several ids may sit in one block, so a copy or a removal acts on the id line.
|
|
185
|
+
assignment: key === 'set_province_name' ? entry : child,
|
|
186
|
+
});
|
|
187
|
+
const childWeighted = WEIGHTED_BLOCKS.has(key);
|
|
188
|
+
for (const child of entry.value.entries)
|
|
189
|
+
visit(child, childWeighted);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!NUMBER.test(entry.value.value))
|
|
193
|
+
return;
|
|
194
|
+
const entity = fieldEntity(file.relativePath, key);
|
|
195
|
+
if (entity === undefined)
|
|
196
|
+
return;
|
|
197
|
+
references.push({
|
|
198
|
+
entity,
|
|
199
|
+
id: Number(entry.value.value),
|
|
200
|
+
key,
|
|
201
|
+
form: 'field',
|
|
202
|
+
file,
|
|
203
|
+
document,
|
|
204
|
+
node: entry.value,
|
|
205
|
+
assignment: entry,
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
visit(document.root, false);
|
|
209
|
+
}
|
|
210
|
+
return { references, unparsable };
|
|
211
|
+
}
|
|
212
|
+
/** References that name an entity the map does not define. */
|
|
213
|
+
export function missingMapReferenceDiagnostics(references, exists) {
|
|
214
|
+
const diagnostics = [];
|
|
215
|
+
for (const reference of references) {
|
|
216
|
+
if (exists[reference.entity](reference.id))
|
|
217
|
+
continue;
|
|
218
|
+
const owned = reference.file.rootKind === 'mod' || reference.file.rootKind === 'fixture';
|
|
219
|
+
diagnostics.push({
|
|
220
|
+
code: 'MAP_SCRIPT_REFERENCE_MISSING',
|
|
221
|
+
severity: owned ? 'error' : 'warning',
|
|
222
|
+
category: 'map',
|
|
223
|
+
message: `${reference.file.relativePath} names ${reference.entity} ${reference.id} (${reference.key}), which the map does not define; remap it with map_rewrite remap_map_references`,
|
|
224
|
+
location: nodeLocation(reference.document, reference.node),
|
|
225
|
+
details: {
|
|
226
|
+
entity: reference.entity,
|
|
227
|
+
id: reference.id,
|
|
228
|
+
key: reference.key,
|
|
229
|
+
form: reference.form,
|
|
230
|
+
layer: reference.file.rootKind,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return diagnostics;
|
|
235
|
+
}
|
|
236
|
+
/** The block that directly contains an assignment, for duplication and removal. */
|
|
237
|
+
export function containingBlock(root, target) {
|
|
238
|
+
const search = (block) => {
|
|
239
|
+
for (const entry of block.entries) {
|
|
240
|
+
if (entry === target)
|
|
241
|
+
return block;
|
|
242
|
+
const child = entry.type === 'block'
|
|
243
|
+
? entry
|
|
244
|
+
: entry.type === 'assignment' && entry.value.type === 'block'
|
|
245
|
+
? entry.value
|
|
246
|
+
: undefined;
|
|
247
|
+
if (child !== undefined) {
|
|
248
|
+
const found = search(child);
|
|
249
|
+
if (found !== undefined)
|
|
250
|
+
return found;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
};
|
|
255
|
+
return search(root);
|
|
256
|
+
}
|
|
257
|
+
export class ReferenceRemapError extends Error {
|
|
258
|
+
code;
|
|
259
|
+
details;
|
|
260
|
+
constructor(code, message, details) {
|
|
261
|
+
super(message);
|
|
262
|
+
this.code = code;
|
|
263
|
+
this.details = details;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function indentBefore(text, offset) {
|
|
267
|
+
const lineStart = Math.max(text.lastIndexOf('\n', Math.max(0, offset - 1)), text.lastIndexOf('\r', Math.max(0, offset - 1))) + 1;
|
|
268
|
+
return /^[\t ]*/u.exec(text.slice(lineStart, offset))?.[0] ?? '';
|
|
269
|
+
}
|
|
270
|
+
function within(node, range) {
|
|
271
|
+
return node.start >= range.start && node.end <= range.end;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Rewrite the references of one entity kind after a map change. Each mapped number is replaced;
|
|
275
|
+
* a reference mapped to several targets keeps its first target and gains a copy of its whole
|
|
276
|
+
* assignment for each further target (so `64 = { add_core_of = ROOT }` becomes one block per
|
|
277
|
+
* successor state); a reference mapped to null loses its assignment.
|
|
278
|
+
*/
|
|
279
|
+
export function planReferenceRemap(files, request, replace) {
|
|
280
|
+
const mapping = new Map();
|
|
281
|
+
for (const { from, to } of request.mapping)
|
|
282
|
+
mapping.set(from, to === null ? null : typeof to === 'number' ? [to] : [...to]);
|
|
283
|
+
const prefixes = (request.pathPrefixes ?? []).map((prefix) => prefix.replaceAll('\\', '/').toLowerCase());
|
|
284
|
+
// Parse only the scripts that mention a mapped number at all; a large mod has thousands.
|
|
285
|
+
const numbers = new RegExp(`(?<![\\w.])(?:${[...mapping.keys()].join('|')})(?![\\w.])`, 'u');
|
|
286
|
+
const candidates = files.filter((file) => mapReferenceScriptFile(file.relativePath) && numbers.test(file.bytes.toString('latin1')));
|
|
287
|
+
const { references, unparsable } = collectMapScriptReferences(candidates);
|
|
288
|
+
const selected = references.filter((reference) => reference.entity === request.entity &&
|
|
289
|
+
mapping.has(reference.id) &&
|
|
290
|
+
(prefixes.length === 0 ||
|
|
291
|
+
prefixes.some((prefix) => reference.file.relativePath.replaceAll('\\', '/').toLowerCase().startsWith(prefix))));
|
|
292
|
+
const blocked = unparsable.filter(({ file }) => selected.some((reference) => reference.file === file));
|
|
293
|
+
if (blocked.length > 0)
|
|
294
|
+
throw new ReferenceRemapError('MAP_EXTERNAL_SCRIPT_PARSE_BLOCKED', 'A script with map references has syntax errors; fix them before remapping', { files: blocked.slice(0, 20).map(({ file }) => file.relativePath) });
|
|
295
|
+
const readOnly = [
|
|
296
|
+
...new Set(selected
|
|
297
|
+
.filter(({ file }) => file.rootKind !== 'mod' && file.rootKind !== 'fixture')
|
|
298
|
+
.map(({ file }) => file.relativePath)),
|
|
299
|
+
].sort(compareCodeUnits);
|
|
300
|
+
if (readOnly.length > 0 && request.readOnlySources !== 'override')
|
|
301
|
+
throw new ReferenceRemapError('MAP_EXTERNAL_REFERENCE_READ_ONLY', `${readOnly.length} game or dependency file(s) reference the remapped ${request.entity}s; set readOnlySources to override to copy them into the mod with the references changed`, { files: readOnly.slice(0, 20), total: readOnly.length });
|
|
302
|
+
const byFile = new Map();
|
|
303
|
+
for (const reference of selected) {
|
|
304
|
+
const list = byFile.get(reference.file) ?? [];
|
|
305
|
+
list.push(reference);
|
|
306
|
+
byFile.set(reference.file, list);
|
|
307
|
+
}
|
|
308
|
+
const unchanged = [];
|
|
309
|
+
const plan = {
|
|
310
|
+
files: [],
|
|
311
|
+
rewritten: 0,
|
|
312
|
+
duplicated: 0,
|
|
313
|
+
removed: 0,
|
|
314
|
+
unchanged,
|
|
315
|
+
};
|
|
316
|
+
for (const [file, fileReferences] of [...byFile].sort(([left], [right]) => compareCodeUnits(left.relativePath, right.relativePath))) {
|
|
317
|
+
const document = fileReferences[0].document;
|
|
318
|
+
const text = document.text;
|
|
319
|
+
// A list item is removed or extended on its own; any other reference acts on its assignment.
|
|
320
|
+
const removedRanges = fileReferences
|
|
321
|
+
.filter((reference) => mapping.get(reference.id) === null && !singleValuedReference(reference))
|
|
322
|
+
.map((reference) => (reference.form === 'list' ? reference.node : reference.assignment));
|
|
323
|
+
const duplicating = fileReferences.filter((reference) => reference.form !== 'list' &&
|
|
324
|
+
!singleValuedReference(reference) &&
|
|
325
|
+
(mapping.get(reference.id)?.length ?? 0) > 1);
|
|
326
|
+
for (const reference of fileReferences)
|
|
327
|
+
if (singleValuedReference(reference) &&
|
|
328
|
+
(mapping.get(reference.id) === null || (mapping.get(reference.id)?.length ?? 0) > 1))
|
|
329
|
+
unchanged.push({
|
|
330
|
+
relativePath: file.relativePath,
|
|
331
|
+
line: nodeLocation(reference.document, reference.node).start.line,
|
|
332
|
+
key: reference.key,
|
|
333
|
+
id: reference.id,
|
|
334
|
+
});
|
|
335
|
+
for (const outer of duplicating)
|
|
336
|
+
for (const inner of duplicating)
|
|
337
|
+
if (inner.assignment !== outer.assignment && within(inner.assignment, outer.assignment))
|
|
338
|
+
throw new ReferenceRemapError('MAP_REFERENCE_REMAP_NESTED', 'A reference mapped to several targets lies inside another one; remap them in two operations', { relativePath: file.relativePath, outer: outer.id, inner: inner.id });
|
|
339
|
+
// Point substitutions to each reference's first target, outside removed assignments.
|
|
340
|
+
const points = fileReferences.filter((reference) => mapping.get(reference.id) !== null &&
|
|
341
|
+
!removedRanges.some((range) => within(reference.node, range)));
|
|
342
|
+
const replacements = points.map((reference) => ({
|
|
343
|
+
start: reference.node.start,
|
|
344
|
+
end: reference.node.end,
|
|
345
|
+
text: String(mapping.get(reference.id)[0]),
|
|
346
|
+
description: `Remap ${reference.entity} ${reference.id}`,
|
|
347
|
+
}));
|
|
348
|
+
plan.rewritten += points.length;
|
|
349
|
+
for (const reference of points.filter(({ id, form }) => form === 'list' && (mapping.get(id)?.length ?? 0) > 1)) {
|
|
350
|
+
const extra = mapping.get(reference.id).slice(1);
|
|
351
|
+
replacements.push({
|
|
352
|
+
start: reference.node.end,
|
|
353
|
+
end: reference.node.end,
|
|
354
|
+
text: extra.map((target) => ` ${target}`).join(''),
|
|
355
|
+
description: `Add ${reference.entity} ${extra.join(', ')} beside ${reference.id}`,
|
|
356
|
+
});
|
|
357
|
+
plan.duplicated += extra.length;
|
|
358
|
+
}
|
|
359
|
+
// Removal of whole assignments; a nested removal is covered by the outermost one.
|
|
360
|
+
for (const range of removedRanges) {
|
|
361
|
+
if (removedRanges.some((other) => other !== range && within(range, other)))
|
|
362
|
+
continue;
|
|
363
|
+
replacements.push({
|
|
364
|
+
start: range.start,
|
|
365
|
+
end: range.end,
|
|
366
|
+
text: '',
|
|
367
|
+
description: 'Remove a reference to a removed map entity',
|
|
368
|
+
});
|
|
369
|
+
plan.removed += 1;
|
|
370
|
+
}
|
|
371
|
+
// Copies for further targets: the assignment's own text with every point substitution
|
|
372
|
+
// inside it applied, and this reference set to the extra target.
|
|
373
|
+
for (const reference of duplicating) {
|
|
374
|
+
const { assignment } = reference;
|
|
375
|
+
const targets = mapping.get(reference.id);
|
|
376
|
+
const innerPoints = points.filter((point) => point !== reference && within(point.node, assignment));
|
|
377
|
+
for (const target of targets.slice(1)) {
|
|
378
|
+
const substitutions = [
|
|
379
|
+
...innerPoints.map((point) => ({
|
|
380
|
+
node: point.node,
|
|
381
|
+
text: String(mapping.get(point.id)[0]),
|
|
382
|
+
})),
|
|
383
|
+
{ node: reference.node, text: String(target) },
|
|
384
|
+
].sort((left, right) => left.node.start - right.node.start);
|
|
385
|
+
const pieces = [];
|
|
386
|
+
let cursor = assignment.start;
|
|
387
|
+
for (const substitution of substitutions) {
|
|
388
|
+
pieces.push(text.slice(cursor, substitution.node.start), substitution.text);
|
|
389
|
+
cursor = substitution.node.end;
|
|
390
|
+
}
|
|
391
|
+
pieces.push(text.slice(cursor, assignment.end));
|
|
392
|
+
replacements.push({
|
|
393
|
+
start: assignment.end,
|
|
394
|
+
end: assignment.end,
|
|
395
|
+
text: `${document.newline}${indentBefore(text, assignment.start)}${pieces.join('')}`,
|
|
396
|
+
description: `Copy the ${reference.entity} reference for ${target}`,
|
|
397
|
+
});
|
|
398
|
+
plan.duplicated += 1;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
plan.files.push({
|
|
402
|
+
relativePath: file.relativePath,
|
|
403
|
+
content: replace(document, replacements),
|
|
404
|
+
overridesReadOnly: file.rootKind !== 'mod' && file.rootKind !== 'fixture',
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return plan;
|
|
408
|
+
}
|
|
409
|
+
/** Country tags declared in `common/country_tags`, or undefined when none were scanned. */
|
|
410
|
+
export function declaredCountryTags(files) {
|
|
411
|
+
const tagFiles = files.filter((file) => file.shadowedBy === undefined &&
|
|
412
|
+
/^common\/country_tags\/[^/]+\.txt$/iu.test(file.relativePath.replaceAll('\\', '/')));
|
|
413
|
+
if (tagFiles.length === 0)
|
|
414
|
+
return undefined;
|
|
415
|
+
const tags = new Set();
|
|
416
|
+
for (const file of tagFiles) {
|
|
417
|
+
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
418
|
+
for (const entry of document.root.entries)
|
|
419
|
+
if (entry.type === 'assignment' && /^[A-Z0-9]{3}$/u.test(entry.key.value))
|
|
420
|
+
tags.add(entry.key.value);
|
|
421
|
+
}
|
|
422
|
+
return tags;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Checks that need the scripts around the map: references to states, provinces and regions that
|
|
426
|
+
* do not exist, state owners, controllers, cores and claims with undeclared tags, and country
|
|
427
|
+
* capitals in states the country does not own.
|
|
428
|
+
*/
|
|
429
|
+
export function mapScriptConsistency(index) {
|
|
430
|
+
const { references } = collectMapScriptReferences(index.sourceFiles);
|
|
431
|
+
const diagnostics = missingMapReferenceDiagnostics(references, {
|
|
432
|
+
state: (id) => index.statesById.has(id),
|
|
433
|
+
province: (id) => index.definitionsById.has(id),
|
|
434
|
+
'strategic-region': (id) => index.regionsById.has(id),
|
|
435
|
+
});
|
|
436
|
+
const missing = diagnostics.length;
|
|
437
|
+
const tags = declaredCountryTags(index.sourceFiles);
|
|
438
|
+
if (tags !== undefined)
|
|
439
|
+
for (const state of index.states) {
|
|
440
|
+
const used = [
|
|
441
|
+
...(state.owner === undefined ? [] : [['owner', state.owner]]),
|
|
442
|
+
...(state.controller === undefined ? [] : [['controller', state.controller]]),
|
|
443
|
+
...state.cores.map((tag) => ['core', tag]),
|
|
444
|
+
...state.claims.map((tag) => ['claim', tag]),
|
|
445
|
+
];
|
|
446
|
+
for (const [role, tag] of used)
|
|
447
|
+
if (!tags.has(tag))
|
|
448
|
+
diagnostics.push({
|
|
449
|
+
code: 'MAP_STATE_TAG_UNKNOWN',
|
|
450
|
+
severity: 'error',
|
|
451
|
+
category: 'map',
|
|
452
|
+
message: `State ${state.id} names ${tag} as its ${role}, but no common/country_tags file declares ${tag}`,
|
|
453
|
+
location: {
|
|
454
|
+
path: state.file.displayPath,
|
|
455
|
+
start: { line: 1, column: 1, offset: 0 },
|
|
456
|
+
end: { line: 1, column: 1, offset: 0 },
|
|
457
|
+
},
|
|
458
|
+
details: { stateId: state.id, tag, role },
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
// Releasable countries keep capitals in states they do not own; only countries that start
|
|
462
|
+
// with land must own their capital.
|
|
463
|
+
const owners = new Set(index.states.flatMap(({ owner }) => (owner === undefined ? [] : [owner])));
|
|
464
|
+
for (const reference of references) {
|
|
465
|
+
if (reference.form !== 'field' || reference.key !== 'capital')
|
|
466
|
+
continue;
|
|
467
|
+
if (!/^history[/\\]countries[/\\]/iu.test(reference.file.relativePath))
|
|
468
|
+
continue;
|
|
469
|
+
const tag = /^([A-Z0-9]{3})\b/u.exec(reference.file.relativePath.replaceAll('\\', '/').split('/').at(-1) ?? '')?.[1];
|
|
470
|
+
const owner = index.statesById.get(reference.id)?.owner;
|
|
471
|
+
if (tag === undefined || owner === undefined || owner === tag || !owners.has(tag))
|
|
472
|
+
continue;
|
|
473
|
+
diagnostics.push({
|
|
474
|
+
code: 'MAP_COUNTRY_CAPITAL_NOT_OWNED',
|
|
475
|
+
severity: 'warning',
|
|
476
|
+
category: 'map',
|
|
477
|
+
message: `${tag} has its capital in state ${reference.id}, which ${owner} owns at the start`,
|
|
478
|
+
location: nodeLocation(reference.document, reference.node),
|
|
479
|
+
details: { tag, stateId: reference.id, owner },
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return { diagnostics, references: references.length, missing };
|
|
483
|
+
}
|