hoi4-agent-tools 4.0.5 → 4.1.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 (80) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -0
  3. package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
  4. package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
  5. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  6. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  7. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  8. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  9. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  10. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  11. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  12. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  13. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  14. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  15. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  16. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  17. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  18. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  19. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  20. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  21. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  22. package/dist/hoi4_agent_tools/map/render.js +9 -1
  23. package/dist/hoi4_agent_tools/map/service.js +222 -18
  24. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  25. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  26. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  27. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  28. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  29. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  30. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  31. package/dist/hoi4_agent_tools/map/world.js +374 -253
  32. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  33. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  34. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  35. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  36. package/dist/hoi4_agent_tools/version.js +2 -2
  37. package/docs/images/reference-maps/political.png +0 -0
  38. package/docs/images/reference-maps/sketch.png +0 -0
  39. package/docs/map.md +45 -0
  40. package/docs/third-party-notices.md +6 -0
  41. package/npm-shrinkwrap.json +124 -2
  42. package/package.json +4 -1
  43. package/schemas/configuration.schema.json +1 -1
  44. package/schemas/continuous-focus-palette.schema.json +1 -1
  45. package/schemas/custom-weighted-pool.schema.json +1 -1
  46. package/schemas/decision-inspect-input.schema.json +1 -1
  47. package/schemas/error-log-input.schema.json +1 -1
  48. package/schemas/event-feature-manifest.schema.json +1 -1
  49. package/schemas/focus-plan.schema.json +1 -1
  50. package/schemas/focus-planning-sidecar.schema.json +1 -1
  51. package/schemas/gui-animation-source.schema.json +1 -1
  52. package/schemas/gui-helper.schema.json +1 -1
  53. package/schemas/gui-scenario.schema.json +1 -1
  54. package/schemas/helper-expansion-request.schema.json +1 -1
  55. package/schemas/helper-expansion-summary.schema.json +1 -1
  56. package/schemas/impact-inspect-input.schema.json +1 -1
  57. package/schemas/map-operation.schema.json +373 -70
  58. package/schemas/mechanic-test-input.schema.json +1 -1
  59. package/schemas/mod-index-input.schema.json +1 -1
  60. package/schemas/operation-result.schema.json +1 -1
  61. package/schemas/package-check-input.schema.json +1 -1
  62. package/schemas/package-manifest.schema.json +1 -1
  63. package/schemas/probability-analysis-result.schema.json +1 -1
  64. package/schemas/probability-compare-input.schema.json +1 -1
  65. package/schemas/probability-evaluate-input.schema.json +1 -1
  66. package/schemas/probability-inspect-input.schema.json +1 -1
  67. package/schemas/probability-render-input.schema.json +1 -1
  68. package/schemas/probability-scenario-set.schema.json +1 -1
  69. package/schemas/probability-sequence-input.schema.json +1 -1
  70. package/schemas/probability-simulate-input.schema.json +1 -1
  71. package/schemas/probability-sweep-input.schema.json +1 -1
  72. package/schemas/reference-context-input.schema.json +1 -1
  73. package/schemas/reference-read-input.schema.json +1 -1
  74. package/schemas/reference-search-input.schema.json +1 -1
  75. package/schemas/scenario-suite.schema.json +1 -1
  76. package/schemas/scenario-test-input.schema.json +1 -1
  77. package/schemas/script-validate-input.schema.json +1 -1
  78. package/schemas/shared-scenario.schema.json +1 -1
  79. package/schemas/source-lookup-input.schema.json +1 -1
  80. package/server.json +2 -2
@@ -0,0 +1,247 @@
1
+ import { readReferenceImageSource } from '../core/bounded-source.js';
2
+ import { extractReferenceLabels } from './reference-labels.js';
3
+ import { extractReferenceWorld } from './reference-world.js';
4
+ import { ServiceError } from '../core/result.js';
5
+ /** Analyze only bounded, authorized sources; the transaction retains the original source hashes. */
6
+ export async function prepareReferenceWorld(resolver, workspaceId, operation, principal, signal) {
7
+ const reference = operation.reference;
8
+ if (reference === undefined)
9
+ throw new ServiceError('MAP_REFERENCE_REQUIRED', 'Reference preparation requires imagePath');
10
+ const images = [];
11
+ const load = async (relativePath) => {
12
+ const image = await readReferenceImageSource(resolver, workspaceId, relativePath, principal, signal);
13
+ images.push(image);
14
+ return image;
15
+ };
16
+ const main = await load(reference.imagePath);
17
+ if (reference.crop !== undefined &&
18
+ (reference.crop.x + reference.crop.width > main.originalWidth ||
19
+ reference.crop.y + reference.crop.height > main.originalHeight))
20
+ throw new ServiceError('MAP_REFERENCE_CROP_BLOCKED', 'Reference crop exceeds the original image bounds');
21
+ const political = reference.politicalImagePath === undefined
22
+ ? undefined
23
+ : await load(reference.politicalImagePath);
24
+ const terrain = reference.terrainImagePath === undefined ? undefined : await load(reference.terrainImagePath);
25
+ const height = reference.heightImagePath === undefined ? undefined : await load(reference.heightImagePath);
26
+ const crop = reference.crop === undefined
27
+ ? undefined
28
+ : {
29
+ x: Math.floor((reference.crop.x * main.workingWidth) / main.originalWidth),
30
+ y: Math.floor((reference.crop.y * main.workingHeight) / main.originalHeight),
31
+ width: Math.max(1, Math.round((reference.crop.width * main.workingWidth) / main.originalWidth)),
32
+ height: Math.max(1, Math.round((reference.crop.height * main.workingHeight) / main.originalHeight)),
33
+ };
34
+ const text = await extractReferenceLabels(main.bytes);
35
+ signal?.throwIfAborted();
36
+ const textMasks = text.labels
37
+ .slice(0, Math.max(0, 256 - (reference.ignoreMasks?.length ?? 0)))
38
+ .map((label) => {
39
+ const left = Math.max(0, label.x - label.width / 2 - 3 / main.workingWidth), right = Math.min(1, label.x + label.width / 2 + 3 / main.workingWidth);
40
+ const top = Math.max(0, label.y - label.height / 2 - 3 / main.workingHeight), bottom = Math.min(1, label.y + label.height / 2 + 3 / main.workingHeight);
41
+ return [
42
+ { x: left, y: top },
43
+ { x: right, y: top },
44
+ { x: right, y: bottom },
45
+ { x: left, y: bottom },
46
+ ];
47
+ });
48
+ const surface = await extractReferenceWorld(main.bytes, {
49
+ width: operation.width ?? 5_632,
50
+ height: operation.height ?? 2_048,
51
+ seed: operation.seed ?? 1,
52
+ ...(reference.mode === undefined ? {} : { mode: reference.mode }),
53
+ ...(crop === undefined ? {} : { crop }),
54
+ ignoreMasks: [...(reference.ignoreMasks ?? []), ...textMasks],
55
+ ...(reference.annotations === undefined ? {} : { annotations: reference.annotations }),
56
+ ...(reference.riverAnnotations === undefined
57
+ ? {}
58
+ : { riverAnnotations: reference.riverAnnotations }),
59
+ ...(reference.palette === undefined ? {} : { palette: reference.palette }),
60
+ ...(reference.coastDetail === undefined ? {} : { coastDetail: reference.coastDetail }),
61
+ ...(political === undefined ? {} : { politicalImageBytes: political.bytes }),
62
+ ...(terrain === undefined ? {} : { terrainImageBytes: terrain.bytes }),
63
+ ...(height === undefined ? {} : { heightImageBytes: height.bytes }),
64
+ });
65
+ signal?.throwIfAborted();
66
+ const warnings = [
67
+ ...surface.diagnostics,
68
+ ...text.warnings,
69
+ 'Reference interpretation uses visual heuristics. Terrain, borders, labels and generated demographic values are approximations; review the offline previews.',
70
+ ...(textMasks.length === 0
71
+ ? []
72
+ : [
73
+ 'Recognized text bounds are excluded from terrain classification; nearby coastlines may need review.',
74
+ ]),
75
+ ];
76
+ const toTarget = (x, y) => ({
77
+ x: ((x * main.workingWidth - (crop?.x ?? 0)) / (crop?.width ?? main.workingWidth)) *
78
+ surface.referenceBounds.width +
79
+ surface.referenceBounds.x,
80
+ y: ((y * main.workingHeight - (crop?.y ?? 0)) / (crop?.height ?? main.workingHeight)) *
81
+ surface.referenceBounds.height +
82
+ surface.referenceBounds.y,
83
+ });
84
+ const nearestLand = (x, y) => {
85
+ const cx = Math.round(x), cy = Math.round(y);
86
+ const radius = Math.max(2, Math.round(Math.min(surface.width, surface.height) * 0.02));
87
+ let best;
88
+ for (let dy = -radius; dy <= radius; dy++)
89
+ for (let dx = -radius; dx <= radius; dx++) {
90
+ const px = cx + dx, py = cy + dy, distance = dx * dx + dy * dy;
91
+ if (px < 0 ||
92
+ py < 0 ||
93
+ px >= surface.width ||
94
+ py >= surface.height ||
95
+ surface.kind[py * surface.width + px] !== 1)
96
+ continue;
97
+ if (best === undefined || distance < best.distance)
98
+ best = { x: px, y: py, distance };
99
+ }
100
+ return best;
101
+ };
102
+ const reviewedLabels = (reference.labels ?? []).map((label) => ({ ...label, ...toTarget(label.x, label.y) }));
103
+ const nearReviewed = (point) => reviewedLabels.some((reviewed) => Math.hypot(reviewed.x - point.x, reviewed.y - point.y) <
104
+ Math.min(surface.width, surface.height) * 0.025);
105
+ // An explicit review is authoritative, including an empty list. OCR still helps mask ink,
106
+ // but must not reintroduce discarded or uncertain names outside the supplied label set.
107
+ const automaticText = reference.labels === undefined ? text.labels : [];
108
+ const labels = automaticText
109
+ .filter(({ text: label }) => !/\b(?:map|world|sea|ocean|bay|strait|channel|flow|belt|signature|north|south|east|west|compass)\b/iu.test(label))
110
+ .slice(0, 200)
111
+ .flatMap((label) => {
112
+ const point = toTarget(label.x, label.y);
113
+ const land = nearestLand(point.x, point.y);
114
+ return land === undefined ? [] : [{ name: label.text, x: land.x, y: land.y }];
115
+ });
116
+ const reviewedCountries = reviewedLabels.filter((label) => label.kind === 'country');
117
+ const countryNames = [...reviewedCountries, ...labels.filter((label) => !nearReviewed(label))];
118
+ const defaults = ['Aster', 'Brum', 'Corvel', 'Doran', 'Evin', 'Faron', 'Galen', 'Haven'];
119
+ const politicalRegionAt = (point) => {
120
+ const x = Math.floor(point.x), y = Math.floor(point.y);
121
+ if (x < 0 || y < 0 || x >= surface.width || y >= surface.height)
122
+ return undefined;
123
+ const pixel = y * surface.width + x;
124
+ if (surface.kind[pixel] !== 1)
125
+ return undefined;
126
+ const id = surface.country[pixel];
127
+ return id === 0 ? undefined : id;
128
+ };
129
+ let countries;
130
+ if (operation.countries !== undefined)
131
+ countries = operation.countries;
132
+ else if (surface.inferredCountries.length > 1) {
133
+ countries = surface.inferredCountries.slice(0, 200).map((country, index) => ({
134
+ tag: `X${(index + 1).toString(36).toUpperCase().padStart(2, '0')}`,
135
+ name: country.name,
136
+ color: country.color,
137
+ }));
138
+ const centres = new Map();
139
+ for (let pixel = 0; pixel < surface.country.length; pixel++) {
140
+ const id = surface.country[pixel];
141
+ if (id === 0)
142
+ continue;
143
+ const centre = centres.get(id) ?? { x: 0, y: 0, n: 0 };
144
+ centre.x += pixel % surface.width;
145
+ centre.y += Math.floor(pixel / surface.width);
146
+ centre.n++;
147
+ centres.set(id, centre);
148
+ }
149
+ const used = new Set();
150
+ for (const [index, country] of countries.entries()) {
151
+ const regionId = surface.inferredCountries[index].id;
152
+ const centre = centres.get(regionId);
153
+ if (centre === undefined)
154
+ continue;
155
+ const label = countryNames
156
+ .filter((label) => !used.has(label.name) && politicalRegionAt(label) === regionId)
157
+ .sort((a, b) => Math.hypot(a.x - centre.x / centre.n, a.y - centre.y / centre.n) -
158
+ Math.hypot(b.x - centre.x / centre.n, b.y - centre.y / centre.n))[0];
159
+ if (label !== undefined) {
160
+ country.name = label.name;
161
+ used.add(label.name);
162
+ }
163
+ else
164
+ warnings.push(`Political region ${regionId} had no reliable name label within its borders; ${country.name} was retained.`);
165
+ }
166
+ warnings.push('Flat political colors define inferred countries; text within each region is an uncertain country-name candidate.');
167
+ }
168
+ else {
169
+ const count = reviewedCountries.length || Math.max(4, Math.min(8, countryNames.length || 4));
170
+ countries = Array.from({ length: count }, (_, index) => ({
171
+ tag: `X${(index + 1).toString(36).toUpperCase().padStart(2, '0')}`,
172
+ name: countryNames[index]?.name ?? defaults[index],
173
+ }));
174
+ warnings.push('Political borders were not reliably encoded; generated countries and ownership use deterministic defaults.');
175
+ }
176
+ const automaticLabels = automaticText
177
+ .filter(({ text: label }) => !/\b(?:map|world|signature|compass|north|south|east|west|flow|belt)\b/iu.test(label))
178
+ .map((label) => ({
179
+ ...toTarget(label.x, label.y),
180
+ name: label.text,
181
+ kind: /\b(?:sea|ocean|bay|strait|channel)\b/iu.test(label.text)
182
+ ? 'sea'
183
+ : /\b(?:mountain|mountains|range)\b/iu.test(label.text)
184
+ ? 'mountain'
185
+ : 'city',
186
+ }));
187
+ const geographicLabels = [
188
+ ...reviewedLabels,
189
+ ...automaticLabels.filter((label) => !reviewedLabels.some((reviewed) => Math.hypot(reviewed.x - label.x, reviewed.y - label.y) <
190
+ Math.min(surface.width, surface.height) * 0.025)),
191
+ ].slice(0, 2048);
192
+ const cityInputs = reference.cities === undefined
193
+ ? geographicLabels
194
+ .filter((label) => label.kind === 'city')
195
+ .map(({ name, x, y }) => ({ name, x, y }))
196
+ : reference.cities.map((city) => ({ ...city, ...toTarget(city.x, city.y) }));
197
+ const cities = cityInputs.flatMap((city) => {
198
+ if ('countryTag' in city &&
199
+ city.countryTag !== undefined &&
200
+ !countries.some((country) => country.tag === city.countryTag))
201
+ throw new ServiceError('MAP_REFERENCE_CITY_COUNTRY_INVALID', 'City countryTag must name a generated country');
202
+ const land = nearestLand(city.x, city.y);
203
+ if (land === undefined) {
204
+ warnings.push(`City ${city.name} is outside nearby land and was omitted.`);
205
+ return [];
206
+ }
207
+ return [{ ...city, x: land.x, y: land.y }];
208
+ });
209
+ for (const [index, country] of countries.entries()) {
210
+ let candidates = cities;
211
+ if (surface.inferredCountries.length > 1) {
212
+ const color = country.color;
213
+ const matches = color === undefined
214
+ ? []
215
+ : surface.inferredCountries
216
+ .map((region) => ({
217
+ id: region.id,
218
+ distance: (region.color.r - color.r) ** 2 +
219
+ (region.color.g - color.g) ** 2 +
220
+ (region.color.b - color.b) ** 2,
221
+ }))
222
+ .sort((a, b) => a.distance - b.distance || a.id - b.id);
223
+ const regionId = matches[0] !== undefined &&
224
+ matches[0].distance <= 3 * 24 ** 2 &&
225
+ (matches[1] === undefined || matches[0].distance < matches[1].distance)
226
+ ? matches[0].id
227
+ : undefined;
228
+ if (regionId === undefined)
229
+ continue;
230
+ candidates = cities.filter((city) => city.countryTag === undefined && politicalRegionAt(city) === regionId);
231
+ }
232
+ const city = candidates.find(({ name }) => name === country.name) ??
233
+ (surface.inferredCountries.length > 1 ? candidates[0] : candidates[index]);
234
+ if (city !== undefined && city.countryTag === undefined)
235
+ city.countryTag = country.tag;
236
+ }
237
+ return {
238
+ prepared: {
239
+ surface,
240
+ countries,
241
+ cities,
242
+ labels: geographicLabels,
243
+ diagnostics: [...new Set(warnings)],
244
+ },
245
+ images,
246
+ };
247
+ }