hoi4-agent-tools 4.0.5 → 4.2.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 (104) hide show
  1. package/CHANGELOG.md +32 -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/canonical.js +42 -0
  5. package/dist/hoi4_agent_tools/core/condition-evaluator.js +12 -8
  6. package/dist/hoi4_agent_tools/core/dependency-walk.js +21 -2
  7. package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
  8. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  9. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  10. package/dist/hoi4_agent_tools/core/impact-graph.js +6 -2
  11. package/dist/hoi4_agent_tools/core/impact-semantic.js +12 -2
  12. package/dist/hoi4_agent_tools/core/index.js +1 -0
  13. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  14. package/dist/hoi4_agent_tools/core/job-worker-host.js +12 -2
  15. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  16. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  17. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  18. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  19. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  20. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  21. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  22. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  23. package/dist/hoi4_agent_tools/decision/service.js +22 -1
  24. package/dist/hoi4_agent_tools/event/algorithms.js +3 -2
  25. package/dist/hoi4_agent_tools/event/graph.js +52 -35
  26. package/dist/hoi4_agent_tools/event/limits.js +4 -0
  27. package/dist/hoi4_agent_tools/event/operations.js +11 -0
  28. package/dist/hoi4_agent_tools/event/queries.js +13 -34
  29. package/dist/hoi4_agent_tools/event/render.js +4 -2
  30. package/dist/hoi4_agent_tools/event/service.js +93 -33
  31. package/dist/hoi4_agent_tools/event/source-analysis.js +94 -4
  32. package/dist/hoi4_agent_tools/event/state-keys.js +63 -0
  33. package/dist/hoi4_agent_tools/event/summary.js +130 -0
  34. package/dist/hoi4_agent_tools/impact/service.js +2 -1
  35. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  36. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  37. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  38. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  39. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  40. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  41. package/dist/hoi4_agent_tools/map/render.js +9 -1
  42. package/dist/hoi4_agent_tools/map/service.js +222 -18
  43. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  44. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  45. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  46. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  47. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  48. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  49. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  50. package/dist/hoi4_agent_tools/map/world.js +374 -253
  51. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +1 -0
  52. package/dist/hoi4_agent_tools/mcp/tools/event.js +66 -0
  53. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  54. package/dist/hoi4_agent_tools/reference/source-lookup.js +4 -2
  55. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  56. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  57. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  58. package/dist/hoi4_agent_tools/version.js +2 -2
  59. package/docs/analysis.md +4 -0
  60. package/docs/events.md +12 -0
  61. package/docs/images/reference-maps/political.png +0 -0
  62. package/docs/images/reference-maps/sketch.png +0 -0
  63. package/docs/map.md +45 -0
  64. package/docs/third-party-notices.md +6 -0
  65. package/npm-shrinkwrap.json +124 -2
  66. package/package.json +4 -1
  67. package/schemas/configuration.schema.json +1 -1
  68. package/schemas/continuous-focus-palette.schema.json +1 -1
  69. package/schemas/custom-weighted-pool.schema.json +1 -1
  70. package/schemas/decision-inspect-input.schema.json +1 -1
  71. package/schemas/error-log-input.schema.json +1 -1
  72. package/schemas/event-feature-manifest.schema.json +1 -1
  73. package/schemas/focus-plan.schema.json +1 -1
  74. package/schemas/focus-planning-sidecar.schema.json +1 -1
  75. package/schemas/gui-animation-source.schema.json +1 -1
  76. package/schemas/gui-helper.schema.json +1 -1
  77. package/schemas/gui-scenario.schema.json +1 -1
  78. package/schemas/helper-expansion-request.schema.json +1 -1
  79. package/schemas/helper-expansion-summary.schema.json +1 -1
  80. package/schemas/impact-inspect-input.schema.json +1 -1
  81. package/schemas/map-operation.schema.json +373 -70
  82. package/schemas/mechanic-test-input.schema.json +1 -1
  83. package/schemas/mod-index-input.schema.json +1 -1
  84. package/schemas/operation-result.schema.json +1 -1
  85. package/schemas/package-check-input.schema.json +1 -1
  86. package/schemas/package-manifest.schema.json +1 -1
  87. package/schemas/probability-analysis-result.schema.json +1 -1
  88. package/schemas/probability-compare-input.schema.json +1 -1
  89. package/schemas/probability-evaluate-input.schema.json +1 -1
  90. package/schemas/probability-inspect-input.schema.json +1 -1
  91. package/schemas/probability-render-input.schema.json +1 -1
  92. package/schemas/probability-scenario-set.schema.json +1 -1
  93. package/schemas/probability-sequence-input.schema.json +1 -1
  94. package/schemas/probability-simulate-input.schema.json +1 -1
  95. package/schemas/probability-sweep-input.schema.json +1 -1
  96. package/schemas/reference-context-input.schema.json +1 -1
  97. package/schemas/reference-read-input.schema.json +1 -1
  98. package/schemas/reference-search-input.schema.json +1 -1
  99. package/schemas/scenario-suite.schema.json +1 -1
  100. package/schemas/scenario-test-input.schema.json +1 -1
  101. package/schemas/script-validate-input.schema.json +1 -1
  102. package/schemas/shared-scenario.schema.json +1 -1
  103. package/schemas/source-lookup-input.schema.json +1 -1
  104. package/server.json +2 -2
@@ -0,0 +1,238 @@
1
+ /** Offline English OCR for reference artwork. No image paths, URLs, commands or model paths
2
+ * are accepted from callers. The pinned Tesseract worker protocol is used directly because
3
+ * its public createWorker promise hides the worker handle until initialization completes;
4
+ * owning the thread from creation lets us terminate stalled initialization as well as OCR.
5
+ * The synthetic integration test must pass before changing the pinned OCR dependencies.
6
+ */
7
+ import { readFile } from 'node:fs/promises';
8
+ import { createRequire } from 'node:module';
9
+ import { Worker } from 'node:worker_threads';
10
+ import sharp from 'sharp';
11
+ import { z } from 'zod';
12
+ import { compareCodeUnits } from '../core/canonical.js';
13
+ import { ServiceError } from '../core/result.js';
14
+ const MAX_BYTES = 32 * 1024 * 1024;
15
+ const MAX_PIXELS = 40_000_000;
16
+ const MAX_OCR_SIDE = 2560;
17
+ const MAX_LABELS = 512;
18
+ const MAX_TSV_BYTES = 2 * 1024 * 1024;
19
+ const OCR_TIMEOUT_MS = 30_000;
20
+ const require = createRequire(import.meta.url);
21
+ let active = false;
22
+ function hasRasterSignature(image) {
23
+ return (image.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
24
+ image.subarray(0, 3).equals(Buffer.from([255, 216, 255])) ||
25
+ ['GIF87a', 'GIF89a'].includes(image.subarray(0, 6).toString('ascii')) ||
26
+ (image.subarray(0, 4).toString('ascii') === 'RIFF' &&
27
+ image.subarray(8, 12).toString('ascii') === 'WEBP') ||
28
+ image.subarray(0, 4).equals(Buffer.from([73, 73, 42, 0])) ||
29
+ image.subarray(0, 4).equals(Buffer.from([77, 77, 0, 42])));
30
+ }
31
+ const packetSchema = z.object({
32
+ jobId: z.string(),
33
+ status: z.enum(['resolve', 'reject', 'progress']),
34
+ data: z.unknown(),
35
+ });
36
+ /** Validate and normalize the engine's TSV instead of trusting its untyped nested output. */
37
+ export function parseReferenceLabelTsv(tsv, width, height) {
38
+ if (Buffer.byteLength(tsv) > MAX_TSV_BYTES ||
39
+ !Number.isSafeInteger(width) ||
40
+ !Number.isSafeInteger(height) ||
41
+ width <= 0 ||
42
+ height <= 0) {
43
+ return { labels: [], warnings: ['Reference text output exceeded safe bounds.'] };
44
+ }
45
+ const lines = new Map();
46
+ const uncertainLines = new Set();
47
+ let rejected = 0;
48
+ for (const row of tsv.split(/\r?\n/).slice(1)) {
49
+ if (!row.trim())
50
+ continue;
51
+ const fields = row.split('\t');
52
+ if (fields[0] !== '5')
53
+ continue;
54
+ const key = fields.slice(1, 5).join(':');
55
+ const text = fields.slice(11).join(' ').normalize('NFC').trim();
56
+ const [left, top, wordWidth, wordHeight, confidence] = fields.slice(6, 11).map(Number);
57
+ if (fields.length < 12 ||
58
+ !text ||
59
+ left === undefined ||
60
+ top === undefined ||
61
+ wordWidth === undefined ||
62
+ wordHeight === undefined ||
63
+ confidence === undefined ||
64
+ ![left, top, wordWidth, wordHeight, confidence].every(Number.isFinite) ||
65
+ left < 0 ||
66
+ top < 0 ||
67
+ wordWidth <= 0 ||
68
+ wordHeight <= 0 ||
69
+ left + wordWidth > width ||
70
+ top + wordHeight > height ||
71
+ confidence < 70 ||
72
+ confidence > 100 ||
73
+ !/^[\p{L}\p{M}\d][\p{L}\p{M}\d.,'’()-]*$/u.test(text) ||
74
+ text.length > 80) {
75
+ rejected++;
76
+ uncertainLines.add(key);
77
+ continue;
78
+ }
79
+ const group = lines.get(key) ?? [];
80
+ group.push({ text, confidence, left, top, width: wordWidth, height: wordHeight });
81
+ lines.set(key, group);
82
+ }
83
+ const labels = [];
84
+ for (const [key, words] of lines) {
85
+ // A readable half of an uncertain multiword name is not a reliable place name.
86
+ if (uncertainLines.has(key))
87
+ continue;
88
+ words.sort((a, b) => a.left - b.left || a.top - b.top);
89
+ const groups = [];
90
+ for (const word of words) {
91
+ const group = groups.at(-1);
92
+ const previous = group?.at(-1);
93
+ if (!previous ||
94
+ word.left - (previous.left + previous.width) > 2.5 * Math.max(previous.height, word.height)) {
95
+ groups.push([word]);
96
+ }
97
+ else
98
+ group.push(word);
99
+ }
100
+ for (const group of groups) {
101
+ const text = group.map((word) => word.text).join(' ');
102
+ const confidence = group.reduce((sum, word) => sum + word.confidence, 0) / group.length;
103
+ if (confidence < 80 || text.length < 2 || text.length > 80 || !/[\p{L}]/u.test(text)) {
104
+ rejected++;
105
+ continue;
106
+ }
107
+ const left = Math.min(...group.map((word) => word.left));
108
+ const top = Math.min(...group.map((word) => word.top));
109
+ const right = Math.max(...group.map((word) => word.left + word.width));
110
+ const bottom = Math.max(...group.map((word) => word.top + word.height));
111
+ labels.push({
112
+ text,
113
+ confidence: Math.round(confidence * 100) / 100,
114
+ x: (left + right) / (2 * width),
115
+ y: (top + bottom) / (2 * height),
116
+ width: (right - left) / width,
117
+ height: (bottom - top) / height,
118
+ });
119
+ }
120
+ }
121
+ labels.sort((a, b) => a.y - b.y || a.x - b.x || compareCodeUnits(a.text, b.text));
122
+ const warnings = [];
123
+ if (rejected > 0)
124
+ warnings.push(`${rejected} uncertain or invalid reference text fragment(s) were omitted.`);
125
+ if (labels.length > MAX_LABELS)
126
+ warnings.push('Reference text was limited to 512 labels.');
127
+ if (labels.length === 0)
128
+ warnings.push('No reliable English labels were readable in the reference image.');
129
+ return { labels: labels.slice(0, MAX_LABELS), warnings };
130
+ }
131
+ /** One active OCR thread per process; contention is retryable instead of changing generated
132
+ * names. No queue retains reference image bytes. Worker stdout is never forwarded.
133
+ */
134
+ export async function extractReferenceLabels(image) {
135
+ if (!Buffer.isBuffer(image) || image.length === 0 || image.length > MAX_BYTES) {
136
+ return { labels: [], warnings: ['Reference text image exceeded safe byte bounds.'] };
137
+ }
138
+ if (!hasRasterSignature(image)) {
139
+ return {
140
+ labels: [],
141
+ warnings: ['Reference text recognition requires a supported raster image.'],
142
+ };
143
+ }
144
+ if (active)
145
+ throw new ServiceError('MAP_REFERENCE_OCR_BUSY', 'Offline reference text recognition is busy; retry the request.', { retryable: true });
146
+ active = true;
147
+ let worker;
148
+ let timer;
149
+ try {
150
+ const decoded = sharp(image, { limitInputPixels: MAX_PIXELS, animated: false });
151
+ const metadata = await decoded.metadata();
152
+ if (!['png', 'jpeg', 'webp', 'tiff', 'gif'].includes(metadata.format) ||
153
+ !metadata.width ||
154
+ !metadata.height) {
155
+ return {
156
+ labels: [],
157
+ warnings: ['Reference text recognition requires a supported raster image.'],
158
+ };
159
+ }
160
+ const { data: png, info } = await decoded
161
+ .flatten({ background: '#ffffff' })
162
+ .resize(MAX_OCR_SIDE, MAX_OCR_SIDE, { fit: 'inside', withoutEnlargement: true })
163
+ .png()
164
+ .toBuffer({ resolveWithObject: true });
165
+ // Paths resolve only fixed, installed, shrinkwrap-pinned package resources. Missing model
166
+ // data fails closed; we never let Tesseract choose its default remote language URL.
167
+ const model = await readFile(require.resolve('@tesseract.js-data/eng/4.0.0_best_int/eng.traineddata.gz'));
168
+ worker = new Worker(require.resolve('tesseract.js/src/worker-script/node/index.js'), {
169
+ stdout: true,
170
+ stderr: true,
171
+ resourceLimits: { maxOldGenerationSizeMb: 192 },
172
+ execArgv: [],
173
+ });
174
+ // Drain diagnostics without forwarding OCR input, recognized names, paths or stdout.
175
+ worker.stdout.resume();
176
+ worker.stderr.resume();
177
+ const currentWorker = worker;
178
+ let sequence = 0;
179
+ let rejectPending;
180
+ const timeout = new Promise((_resolve, reject) => {
181
+ timer = setTimeout(() => reject(new ServiceError('MAP_REFERENCE_OCR_TIMEOUT', 'Offline reference text recognition exceeded its time limit; retry the request.', { retryable: true })), OCR_TIMEOUT_MS);
182
+ });
183
+ currentWorker.on('error', () => rejectPending?.(new Error('OCR worker failed')));
184
+ currentWorker.on('exit', () => rejectPending?.(new Error('OCR worker exited')));
185
+ const send = (action, payload) => {
186
+ const jobId = `reference-${sequence++}`;
187
+ return new Promise((resolve, reject) => {
188
+ rejectPending = reject;
189
+ const receive = (raw) => {
190
+ const packet = packetSchema.safeParse(raw);
191
+ if (!packet.success || packet.data.jobId !== jobId || packet.data.status === 'progress')
192
+ return;
193
+ currentWorker.off('message', receive);
194
+ rejectPending = undefined;
195
+ if (packet.data.status === 'reject')
196
+ reject(new Error('OCR recognition failed'));
197
+ else
198
+ resolve(packet.data.data);
199
+ };
200
+ currentWorker.on('message', receive);
201
+ currentWorker.postMessage({ workerId: 'reference-labels', jobId, action, payload });
202
+ });
203
+ };
204
+ const recognize = async () => {
205
+ await send('load', { options: { lstmOnly: true, logging: false } });
206
+ await send('loadLanguage', {
207
+ langs: [{ code: 'eng', data: model }],
208
+ options: { cacheMethod: 'none', gzip: true, lstmOnly: true },
209
+ });
210
+ await send('initialize', { langs: 'eng', oem: 1, config: {} });
211
+ await send('setParameters', {
212
+ params: { tessedit_pageseg_mode: '11', user_defined_dpi: '300' },
213
+ });
214
+ return send('recognize', { image: png, options: {}, output: { text: false, tsv: true } });
215
+ };
216
+ const result = await Promise.race([recognize(), timeout]);
217
+ const output = z.object({ tsv: z.string().max(MAX_TSV_BYTES) }).safeParse(result);
218
+ if (!output.success)
219
+ throw new Error('Unsupported OCR output');
220
+ return parseReferenceLabelTsv(output.data.tsv, info.width, info.height);
221
+ }
222
+ catch (error) {
223
+ if (error instanceof ServiceError)
224
+ throw error;
225
+ throw new ServiceError('MAP_REFERENCE_OCR_UNAVAILABLE', 'Offline reference text recognition could not complete; verify the installed OCR dependencies and retry the request.', { retryable: true });
226
+ }
227
+ finally {
228
+ if (timer)
229
+ clearTimeout(timer);
230
+ try {
231
+ if (worker)
232
+ await worker.terminate();
233
+ }
234
+ finally {
235
+ active = false;
236
+ }
237
+ }
238
+ }
@@ -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
+ }