hoi4-agent-tools 4.0.4 → 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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -0
- package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
- package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
- package/dist/hoi4_agent_tools/core/engine.js +2 -1
- package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
- package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
- package/dist/hoi4_agent_tools/core/scanner.js +17 -0
- package/dist/hoi4_agent_tools/core/server-state.js +9 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
- package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
- package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
- package/dist/hoi4_agent_tools/core/transactions.js +355 -111
- package/dist/hoi4_agent_tools/gui/layout.js +61 -25
- package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
- package/dist/hoi4_agent_tools/gui/source-graph.js +9 -6
- package/dist/hoi4_agent_tools/gui/studio.js +4 -1
- package/dist/hoi4_agent_tools/map/derived.js +21 -6
- package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
- package/dist/hoi4_agent_tools/map/operations.js +72 -21
- package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
- package/dist/hoi4_agent_tools/map/render.js +9 -1
- package/dist/hoi4_agent_tools/map/service.js +222 -18
- package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
- package/dist/hoi4_agent_tools/map/world-context.js +148 -0
- package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
- package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
- package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
- package/dist/hoi4_agent_tools/map/world.js +374 -253
- package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
- package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
- package/dist/hoi4_agent_tools/schemas/map.js +103 -10
- package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
- package/dist/hoi4_agent_tools/version.js +2 -2
- package/docs/gui.md +6 -2
- package/docs/images/reference-maps/political.png +0 -0
- package/docs/images/reference-maps/sketch.png +0 -0
- package/docs/map.md +45 -0
- package/docs/third-party-notices.md +6 -0
- package/npm-shrinkwrap.json +124 -2
- package/package.json +4 -1
- 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 +373 -70
- 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 +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
|
+
}
|