hoi4-agent-tools 4.3.0 → 4.5.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 +33 -1
- package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
- package/dist/hoi4_agent_tools/core/configuration.js +11 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
- package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
- package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
- package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
- package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
- package/dist/hoi4_agent_tools/core/transactions.js +23 -4
- package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
- package/dist/hoi4_agent_tools/core/workspace.js +42 -6
- package/dist/hoi4_agent_tools/focus/drift.js +34 -0
- package/dist/hoi4_agent_tools/focus/service.js +9 -3
- package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
- package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
- package/dist/hoi4_agent_tools/map/bmp.js +9 -1
- package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
- package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
- package/dist/hoi4_agent_tools/map/colormap.js +128 -0
- package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
- package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
- package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
- package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
- package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
- package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
- package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
- package/dist/hoi4_agent_tools/map/model.js +17 -3
- package/dist/hoi4_agent_tools/map/operations.js +284 -14
- package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
- package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
- package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
- package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
- package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
- package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
- package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
- package/dist/hoi4_agent_tools/map/partition.js +163 -0
- package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +142 -6
- package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
- package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
- package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
- package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
- package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
- package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
- package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
- package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
- package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
- package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
- package/dist/hoi4_agent_tools/map/render.js +36 -0
- package/dist/hoi4_agent_tools/map/service.js +206 -16
- package/dist/hoi4_agent_tools/map/tool-operations.js +249 -3
- package/dist/hoi4_agent_tools/map/validation.js +9 -0
- package/dist/hoi4_agent_tools/map/water.js +227 -0
- package/dist/hoi4_agent_tools/map/world-context.js +47 -1
- package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
- package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
- package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
- package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
- package/dist/hoi4_agent_tools/map/world.js +562 -163
- package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
- package/dist/hoi4_agent_tools/reference/operations.js +3 -2
- package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
- package/dist/hoi4_agent_tools/schemas/map.js +280 -15
- package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/configuration.md +17 -14
- package/docs/focus.md +6 -0
- package/docs/map.md +292 -15
- package/docs/tools.md +4 -4
- package/docs/troubleshooting.md +11 -11
- package/npm-shrinkwrap.json +109 -91
- package/package.json +3 -3
- package/schemas/configuration.schema.json +65 -58
- 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/geography-model.schema.json +1148 -0
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +5 -11
- 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 +2325 -292
- 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 +25 -7
- 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/reference-sheets.schema.json +267 -0
- 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,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence for an imported reference (ADR 0067): what each registered sheet supplied, the class
|
|
3
|
+
* shares of the interpreted picture and how far the geography model's land agrees with it.
|
|
4
|
+
* Disagreement above the threshold is an error diagnostic, which blocks the write but leaves the
|
|
5
|
+
* preview and its evidence available.
|
|
6
|
+
*/
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
import { canonicalJson } from '../core/canonical.js';
|
|
9
|
+
import { PACKAGE_VERSION } from '../version.js';
|
|
10
|
+
import { rasterizeGeographyKind } from './relief/land.js';
|
|
11
|
+
import { REFERENCE_TERRAIN } from './reference-world.js';
|
|
12
|
+
/** Conservative defaults: block only on a gross mismatch, warn on any visible one. */
|
|
13
|
+
export const DEFAULT_IMPORT_AGREEMENT = {
|
|
14
|
+
minLandIou: 0.85,
|
|
15
|
+
warnLandIou: 0.95,
|
|
16
|
+
};
|
|
17
|
+
const CELL = 128;
|
|
18
|
+
const WORST_CELLS = 12;
|
|
19
|
+
const FEATURE_LIMIT = 64;
|
|
20
|
+
/** A point this many pixels from the drawing's coast still counts as on either side. */
|
|
21
|
+
const COAST_TOLERANCE_PX = 3;
|
|
22
|
+
const TERRAIN_NAMES = Object.fromEntries(Object.entries(REFERENCE_TERRAIN).map(([name, index]) => [index, name]));
|
|
23
|
+
function surfaceAt(surface, x, y) {
|
|
24
|
+
const px = Math.floor(x * surface.width);
|
|
25
|
+
const py = Math.floor(y * surface.height);
|
|
26
|
+
if (px < 0 || py < 0 || px >= surface.width || py >= surface.height)
|
|
27
|
+
return undefined;
|
|
28
|
+
return surface.kind[py * surface.width + px];
|
|
29
|
+
}
|
|
30
|
+
/** True when some pixel within the coast tolerance of the point has the wanted surface. */
|
|
31
|
+
function nearSurface(surface, x, y, wanted) {
|
|
32
|
+
const cx = Math.floor(x * surface.width);
|
|
33
|
+
const cy = Math.floor(y * surface.height);
|
|
34
|
+
for (let dy = -COAST_TOLERANCE_PX; dy <= COAST_TOLERANCE_PX; dy += 1)
|
|
35
|
+
for (let dx = -COAST_TOLERANCE_PX; dx <= COAST_TOLERANCE_PX; dx += 1) {
|
|
36
|
+
const px = cx + dx;
|
|
37
|
+
const py = cy + dy;
|
|
38
|
+
if (px < 0 || py < 0 || px >= surface.width || py >= surface.height)
|
|
39
|
+
continue;
|
|
40
|
+
if (wanted(surface.kind[py * surface.width + px]))
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
function measureAgreement(surface, geography, seed, thresholds) {
|
|
46
|
+
const { width, height } = surface;
|
|
47
|
+
const model = rasterizeGeographyKind(geography, { width, height, seed });
|
|
48
|
+
const columns = Math.ceil(width / CELL);
|
|
49
|
+
const rows = Math.ceil(height / CELL);
|
|
50
|
+
const drawingOnlyCells = new Float64Array(columns * rows);
|
|
51
|
+
const modelOnlyCells = new Float64Array(columns * rows);
|
|
52
|
+
let drawingLand = 0;
|
|
53
|
+
let modelLand = 0;
|
|
54
|
+
let both = 0;
|
|
55
|
+
for (let y = 0; y < height; y += 1)
|
|
56
|
+
for (let x = 0; x < width; x += 1) {
|
|
57
|
+
const pixel = y * width + x;
|
|
58
|
+
const drawn = surface.kind[pixel] === 1;
|
|
59
|
+
const modelled = model[pixel] === 1;
|
|
60
|
+
if (drawn)
|
|
61
|
+
drawingLand += 1;
|
|
62
|
+
if (modelled)
|
|
63
|
+
modelLand += 1;
|
|
64
|
+
if (drawn && modelled)
|
|
65
|
+
both += 1;
|
|
66
|
+
else if (drawn || modelled) {
|
|
67
|
+
const cell = Math.floor(y / CELL) * columns + Math.floor(x / CELL);
|
|
68
|
+
if (drawn)
|
|
69
|
+
drawingOnlyCells[cell] = drawingOnlyCells[cell] + 1;
|
|
70
|
+
else
|
|
71
|
+
modelOnlyCells[cell] = modelOnlyCells[cell] + 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const union = drawingLand + modelLand - both;
|
|
75
|
+
const worstCells = Array.from({ length: columns * rows }, (_, cell) => cell)
|
|
76
|
+
.map((cell) => ({
|
|
77
|
+
x: (cell % columns) * CELL,
|
|
78
|
+
y: Math.floor(cell / columns) * CELL,
|
|
79
|
+
drawingOnly: drawingOnlyCells[cell],
|
|
80
|
+
modelOnly: modelOnlyCells[cell],
|
|
81
|
+
}))
|
|
82
|
+
.filter((cell) => cell.drawingOnly + cell.modelOnly > 0)
|
|
83
|
+
.sort((left, right) => right.drawingOnly + right.modelOnly - (left.drawingOnly + left.modelOnly) ||
|
|
84
|
+
left.y - right.y ||
|
|
85
|
+
left.x - right.x)
|
|
86
|
+
.slice(0, WORST_CELLS);
|
|
87
|
+
const features = [];
|
|
88
|
+
const isLand = (kind) => kind === 1;
|
|
89
|
+
const isWater = (kind) => kind !== 1;
|
|
90
|
+
for (const site of geography.sites) {
|
|
91
|
+
if (site.onWater || site.lakeId !== undefined)
|
|
92
|
+
continue;
|
|
93
|
+
if (!nearSurface(surface, site.at.x, site.at.y, isLand))
|
|
94
|
+
features.push({ id: site.id, kind: 'site', issue: 'in the drawing’s water', ...site.at });
|
|
95
|
+
}
|
|
96
|
+
for (const volcano of geography.volcanoes)
|
|
97
|
+
if (!nearSurface(surface, volcano.at.x, volcano.at.y, isLand))
|
|
98
|
+
features.push({
|
|
99
|
+
id: volcano.id,
|
|
100
|
+
kind: 'volcano',
|
|
101
|
+
issue: 'in the drawing’s water',
|
|
102
|
+
...volcano.at,
|
|
103
|
+
});
|
|
104
|
+
for (const river of geography.rivers) {
|
|
105
|
+
const { source, mouth } = river;
|
|
106
|
+
if (source.type === 'point' && surfaceAt(surface, source.x, source.y) !== 1)
|
|
107
|
+
features.push({
|
|
108
|
+
id: river.id,
|
|
109
|
+
kind: 'river',
|
|
110
|
+
issue: 'source in the drawing’s water',
|
|
111
|
+
x: source.x,
|
|
112
|
+
y: source.y,
|
|
113
|
+
});
|
|
114
|
+
if (mouth.type === 'point' && !nearSurface(surface, mouth.x, mouth.y, isWater))
|
|
115
|
+
features.push({
|
|
116
|
+
id: river.id,
|
|
117
|
+
kind: 'river',
|
|
118
|
+
issue: 'mouth on the drawing’s land',
|
|
119
|
+
x: mouth.x,
|
|
120
|
+
y: mouth.y,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
features.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
|
|
124
|
+
return {
|
|
125
|
+
landIou: union === 0 ? 1 : Number((both / union).toFixed(4)),
|
|
126
|
+
drawingLandPx: drawingLand,
|
|
127
|
+
modelLandPx: modelLand,
|
|
128
|
+
bothLandPx: both,
|
|
129
|
+
drawingOnlyLandPx: drawingLand - both,
|
|
130
|
+
modelOnlyLandPx: modelLand - both,
|
|
131
|
+
thresholds,
|
|
132
|
+
worstCells,
|
|
133
|
+
features: features.slice(0, FEATURE_LIMIT),
|
|
134
|
+
featureCount: features.length,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export function buildImportReport(input) {
|
|
138
|
+
const { surface, operationId } = input;
|
|
139
|
+
const terrainPx = {};
|
|
140
|
+
let sea = 0;
|
|
141
|
+
let lake = 0;
|
|
142
|
+
let land = 0;
|
|
143
|
+
for (let pixel = 0; pixel < surface.kind.length; pixel += 1) {
|
|
144
|
+
const kind = surface.kind[pixel];
|
|
145
|
+
if (kind === 0)
|
|
146
|
+
sea += 1;
|
|
147
|
+
else if (kind === 2)
|
|
148
|
+
lake += 1;
|
|
149
|
+
else {
|
|
150
|
+
land += 1;
|
|
151
|
+
const name = TERRAIN_NAMES[surface.terrain[pixel]] ?? 'other';
|
|
152
|
+
terrainPx[name] = (terrainPx[name] ?? 0) + 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const thresholds = {
|
|
156
|
+
minLandIou: input.thresholds?.minLandIou ?? DEFAULT_IMPORT_AGREEMENT.minLandIou,
|
|
157
|
+
warnLandIou: input.thresholds?.warnLandIou ?? DEFAULT_IMPORT_AGREEMENT.warnLandIou,
|
|
158
|
+
};
|
|
159
|
+
const report = {
|
|
160
|
+
operationId,
|
|
161
|
+
width: surface.width,
|
|
162
|
+
height: surface.height,
|
|
163
|
+
...(input.registration === undefined
|
|
164
|
+
? {}
|
|
165
|
+
: {
|
|
166
|
+
registration: {
|
|
167
|
+
sheets: input.registration.coverage,
|
|
168
|
+
uncoveredPx: input.registration.uncovered,
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
classes: { seaPx: sea, lakePx: lake, landPx: land, terrainPx },
|
|
172
|
+
...(input.geography === undefined
|
|
173
|
+
? {}
|
|
174
|
+
: { agreement: measureAgreement(surface, input.geography, input.seed, thresholds) }),
|
|
175
|
+
};
|
|
176
|
+
const diagnostics = [];
|
|
177
|
+
for (const sheet of input.registration?.coverage ?? [])
|
|
178
|
+
if (sheet.pixels === 0)
|
|
179
|
+
diagnostics.push({
|
|
180
|
+
code: 'MAP_REFERENCE_SHEET_HIDDEN',
|
|
181
|
+
severity: 'warning',
|
|
182
|
+
category: 'map',
|
|
183
|
+
operationId,
|
|
184
|
+
message: `Sheet ${sheet.id} supplies no world pixel: higher-order sheets cover it or it lies outside the frame`,
|
|
185
|
+
details: { sheetId: sheet.id, hiddenPx: sheet.hidden },
|
|
186
|
+
});
|
|
187
|
+
const agreement = report.agreement;
|
|
188
|
+
if (agreement !== undefined) {
|
|
189
|
+
const severity = agreement.landIou < thresholds.minLandIou
|
|
190
|
+
? 'error'
|
|
191
|
+
: agreement.landIou < thresholds.warnLandIou
|
|
192
|
+
? 'warning'
|
|
193
|
+
: 'info';
|
|
194
|
+
diagnostics.push({
|
|
195
|
+
code: severity === 'error' ? 'MAP_IMPORT_SOURCES_DISAGREE' : 'MAP_IMPORT_SOURCES_AGREEMENT',
|
|
196
|
+
severity,
|
|
197
|
+
category: 'map',
|
|
198
|
+
operationId,
|
|
199
|
+
message: `The geography model's land and the reference's land overlap with intersection over union ${agreement.landIou} (block below ${thresholds.minLandIou}, warn below ${thresholds.warnLandIou}); ${agreement.drawingOnlyLandPx} pixels are land only in the reference and ${agreement.modelOnlyLandPx} only in the model. map-import-report.json lists the worst areas and map-import-alignment.png draws both coasts.`,
|
|
200
|
+
details: {
|
|
201
|
+
landIou: agreement.landIou,
|
|
202
|
+
drawingOnlyLandPx: agreement.drawingOnlyLandPx,
|
|
203
|
+
modelOnlyLandPx: agreement.modelOnlyLandPx,
|
|
204
|
+
worstCells: agreement.worstCells.slice(0, 3),
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
if (agreement.featureCount > 0)
|
|
208
|
+
diagnostics.push({
|
|
209
|
+
code: 'MAP_IMPORT_FEATURE_SURFACE',
|
|
210
|
+
severity: 'warning',
|
|
211
|
+
category: 'map',
|
|
212
|
+
operationId,
|
|
213
|
+
message: `${agreement.featureCount} model features lie on the wrong surface of the reference (a site or volcano in its water, a river source in water or mouth on land); see map-import-report.json`,
|
|
214
|
+
details: { features: agreement.features.slice(0, 8) },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return { report, diagnostics };
|
|
218
|
+
}
|
|
219
|
+
const CLASS_COLOURS = {
|
|
220
|
+
0: [150, 200, 90],
|
|
221
|
+
1: [40, 120, 50],
|
|
222
|
+
2: [220, 205, 120],
|
|
223
|
+
3: [140, 120, 110],
|
|
224
|
+
4: [235, 240, 245],
|
|
225
|
+
5: [150, 40, 30],
|
|
226
|
+
6: [150, 120, 70],
|
|
227
|
+
7: [80, 130, 110],
|
|
228
|
+
8: [30, 90, 40],
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* Offline review drawings of the import, at most `maxSide` pixels per side: the classes, and the
|
|
232
|
+
* registered picture with the reference coast in red and the model coast in yellow. They are
|
|
233
|
+
* drawings of the tool's interpretation, never game images.
|
|
234
|
+
*/
|
|
235
|
+
export async function renderImportImages(input) {
|
|
236
|
+
const { surface } = input;
|
|
237
|
+
const { width, height } = surface;
|
|
238
|
+
const scale = Math.min(1, input.maxSide / Math.max(width, height));
|
|
239
|
+
const outWidth = Math.max(1, Math.round(width * scale));
|
|
240
|
+
const outHeight = Math.max(1, Math.round(height * scale));
|
|
241
|
+
const classes = Buffer.alloc(outWidth * outHeight * 3);
|
|
242
|
+
const alignment = Buffer.alloc(outWidth * outHeight * 3);
|
|
243
|
+
const registered = input.registered === undefined
|
|
244
|
+
? undefined
|
|
245
|
+
: await sharp(input.registered)
|
|
246
|
+
.resize(outWidth, outHeight, { fit: 'fill' })
|
|
247
|
+
.removeAlpha()
|
|
248
|
+
.raw()
|
|
249
|
+
.toBuffer();
|
|
250
|
+
const model = input.geography === undefined
|
|
251
|
+
? undefined
|
|
252
|
+
: rasterizeGeographyKind(input.geography, { width, height, seed: input.seed });
|
|
253
|
+
const landAt = (kind, x, y) => kind[Math.min(height - 1, y) * width + Math.min(width - 1, x)] === 1;
|
|
254
|
+
for (let oy = 0; oy < outHeight; oy += 1)
|
|
255
|
+
for (let ox = 0; ox < outWidth; ox += 1) {
|
|
256
|
+
const x = Math.min(width - 1, Math.floor(ox / scale));
|
|
257
|
+
const y = Math.min(height - 1, Math.floor(oy / scale));
|
|
258
|
+
const pixel = y * width + x;
|
|
259
|
+
const target = (oy * outWidth + ox) * 3;
|
|
260
|
+
const kind = surface.kind[pixel];
|
|
261
|
+
const colour = kind === 0
|
|
262
|
+
? [30, 70, 160]
|
|
263
|
+
: kind === 2
|
|
264
|
+
? [70, 170, 230]
|
|
265
|
+
: CLASS_COLOURS[surface.terrain[pixel]];
|
|
266
|
+
classes.set(colour, target);
|
|
267
|
+
const base = registered?.subarray(target, target + 3) ?? Buffer.from(colour);
|
|
268
|
+
alignment[target] = Math.round(base[0] * 0.6);
|
|
269
|
+
alignment[target + 1] = Math.round(base[1] * 0.6);
|
|
270
|
+
alignment[target + 2] = Math.round(base[2] * 0.6);
|
|
271
|
+
const step = Math.max(1, Math.ceil(1 / scale));
|
|
272
|
+
const coast = (raster) => landAt(raster, x, y) !== landAt(raster, x + step, y) ||
|
|
273
|
+
landAt(raster, x, y) !== landAt(raster, x, y + step);
|
|
274
|
+
if (model !== undefined && coast(model))
|
|
275
|
+
alignment.set([255, 220, 0], target);
|
|
276
|
+
if (coast(surface.kind))
|
|
277
|
+
alignment.set([255, 40, 40], target);
|
|
278
|
+
}
|
|
279
|
+
const encode = (data) => sharp(data, { raw: { width: outWidth, height: outHeight, channels: 3 } })
|
|
280
|
+
.png({ compressionLevel: 9 })
|
|
281
|
+
.toBuffer();
|
|
282
|
+
return { classes: await encode(classes), alignment: await encode(alignment) };
|
|
283
|
+
}
|
|
284
|
+
const IMPORT_IMAGE_MAX_SIDE = 2_048;
|
|
285
|
+
export async function buildImportArtifacts(prepared, ordinal) {
|
|
286
|
+
const suffix = ordinal === 0 ? '' : `-${ordinal + 1}`;
|
|
287
|
+
const images = await renderImportImages({
|
|
288
|
+
surface: prepared.surface,
|
|
289
|
+
registered: prepared.registered,
|
|
290
|
+
...(prepared.geography === undefined ? {} : { geography: prepared.geography }),
|
|
291
|
+
seed: prepared.seed,
|
|
292
|
+
maxSide: IMPORT_IMAGE_MAX_SIDE,
|
|
293
|
+
});
|
|
294
|
+
const provenance = {
|
|
295
|
+
kind: 'map-import',
|
|
296
|
+
toolVersion: PACKAGE_VERSION,
|
|
297
|
+
schemaVersion: 'map-import.v1',
|
|
298
|
+
sourceHashes: prepared.sourceHashes,
|
|
299
|
+
renderProfile: { offline: true, maxSide: IMPORT_IMAGE_MAX_SIDE },
|
|
300
|
+
metadata: {
|
|
301
|
+
operationId: prepared.report.operationId,
|
|
302
|
+
binding: 'offline drawing of the interpreted reference and the geography model; not a game screenshot',
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
const writes = [
|
|
306
|
+
{
|
|
307
|
+
name: `map-import-report${suffix}.json`,
|
|
308
|
+
mimeType: 'application/json',
|
|
309
|
+
content: `${canonicalJson(prepared.report)}\n`,
|
|
310
|
+
provenance,
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: `map-import-classes${suffix}.png`,
|
|
314
|
+
mimeType: 'image/png',
|
|
315
|
+
content: images.classes,
|
|
316
|
+
provenance,
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: `map-import-alignment${suffix}.png`,
|
|
320
|
+
mimeType: 'image/png',
|
|
321
|
+
content: images.alignment,
|
|
322
|
+
provenance,
|
|
323
|
+
},
|
|
324
|
+
];
|
|
325
|
+
if (prepared.registered !== undefined)
|
|
326
|
+
writes.push({
|
|
327
|
+
name: `map-import-registration${suffix}.png`,
|
|
328
|
+
mimeType: 'image/png',
|
|
329
|
+
content: prepared.registered,
|
|
330
|
+
provenance,
|
|
331
|
+
});
|
|
332
|
+
return writes;
|
|
333
|
+
}
|
|
@@ -1,19 +1,116 @@
|
|
|
1
|
-
import
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { readReferenceImageSource, } from '../core/bounded-source.js';
|
|
3
|
+
import { readWorkspaceJsonSource } from '../core/workspace-json.js';
|
|
4
|
+
import { REFERENCE_SHEETS_SOURCE_MAX_BYTES, referenceSheetsDocumentSchema, } from '../schemas/reference-sheets.js';
|
|
2
5
|
import { extractReferenceLabels } from './reference-labels.js';
|
|
6
|
+
import { registerSheets, sheetAffine, sheetWorkingSize, warpSheets, } from './reference-sheets.js';
|
|
3
7
|
import { extractReferenceWorld } from './reference-world.js';
|
|
4
8
|
import { ServiceError } from '../core/result.js';
|
|
9
|
+
const PREVIEW_MAX_SIDE = 3_072;
|
|
10
|
+
async function loadSheetsDocument(resolver, workspaceId, reference, principal, signal) {
|
|
11
|
+
if (reference.sheets !== undefined)
|
|
12
|
+
return { document: reference.sheets };
|
|
13
|
+
if (reference.sheetsPath === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
const read = await readWorkspaceJsonSource(resolver, workspaceId, reference.sheetsPath, REFERENCE_SHEETS_SOURCE_MAX_BYTES, {
|
|
16
|
+
pathInvalid: 'MAP_REFERENCE_SHEETS_PATH_INVALID',
|
|
17
|
+
tooLarge: 'MAP_REFERENCE_SHEETS_TOO_LARGE',
|
|
18
|
+
jsonInvalid: 'MAP_REFERENCE_SHEETS_JSON_INVALID',
|
|
19
|
+
label: 'Sheet document',
|
|
20
|
+
}, principal, signal);
|
|
21
|
+
const parsed = referenceSheetsDocumentSchema.safeParse(read.value);
|
|
22
|
+
if (!parsed.success)
|
|
23
|
+
throw new ServiceError('MAP_REFERENCE_SHEETS_INVALID', `Sheet document does not match reference-sheets version 1: ${parsed.error.issues
|
|
24
|
+
.slice(0, 3)
|
|
25
|
+
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
26
|
+
.join('; ')}`, {
|
|
27
|
+
issues: parsed.error.issues.slice(0, 20).map((issue) => ({
|
|
28
|
+
path: issue.path.join('.'),
|
|
29
|
+
message: issue.message,
|
|
30
|
+
})),
|
|
31
|
+
});
|
|
32
|
+
return { document: parsed.data, evidence: read.evidence };
|
|
33
|
+
}
|
|
5
34
|
/** Analyze only bounded, authorized sources; the transaction retains the original source hashes. */
|
|
6
35
|
export async function prepareReferenceWorld(resolver, workspaceId, operation, principal, signal) {
|
|
7
36
|
const reference = operation.reference;
|
|
8
37
|
if (reference === undefined)
|
|
9
38
|
throw new ServiceError('MAP_REFERENCE_REQUIRED', 'Reference preparation requires imagePath');
|
|
39
|
+
const sheetsSource = await loadSheetsDocument(resolver, workspaceId, reference, principal, signal);
|
|
40
|
+
const sheeted = sheetsSource !== undefined;
|
|
41
|
+
const frame = reference.fit === 'frame' || sheeted;
|
|
42
|
+
const exact = reference.paletteMatch === 'exact';
|
|
43
|
+
if (sheeted &&
|
|
44
|
+
(reference.crop !== undefined ||
|
|
45
|
+
reference.politicalImagePath !== undefined ||
|
|
46
|
+
reference.terrainImagePath !== undefined ||
|
|
47
|
+
reference.heightImagePath !== undefined))
|
|
48
|
+
throw new ServiceError('MAP_REFERENCE_SHEETS_UNSUPPORTED', 'A registered sheet picture cannot be combined with crop or aligned political, terrain or height pictures yet');
|
|
49
|
+
if (frame && (operation.width === undefined || operation.height === undefined))
|
|
50
|
+
throw new ServiceError('MAP_REFERENCE_FRAME_SIZE_REQUIRED', 'A reference fitted to the frame needs explicit width and height');
|
|
51
|
+
// A frame-fitted picture keeps the frame's resolution instead of the default 3,072-pixel
|
|
52
|
+
// working copy; an exact palette is never resampled between colours.
|
|
53
|
+
const frameSize = { width: operation.width ?? 0, height: operation.height ?? 0 };
|
|
54
|
+
// Each sheet keeps at least one working pixel per world pixel; the maps alone decide the size.
|
|
55
|
+
const working = sheeted
|
|
56
|
+
? {
|
|
57
|
+
size: () => sheetWorkingSize(sheetsSource.document.sheets.map((sheet) => sheetAffine(sheet)), frameSize),
|
|
58
|
+
kernel: exact ? 'nearest' : 'area',
|
|
59
|
+
}
|
|
60
|
+
: frame
|
|
61
|
+
? {
|
|
62
|
+
size: (originalWidth, originalHeight) => {
|
|
63
|
+
const cropWidth = reference.crop?.width ?? originalWidth;
|
|
64
|
+
const cropHeight = reference.crop?.height ?? originalHeight;
|
|
65
|
+
return {
|
|
66
|
+
width: Math.ceil((originalWidth * operation.width) / cropWidth),
|
|
67
|
+
height: Math.ceil((originalHeight * operation.height) / cropHeight),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
kernel: exact ? 'nearest' : 'area',
|
|
71
|
+
}
|
|
72
|
+
: undefined;
|
|
10
73
|
const images = [];
|
|
11
74
|
const load = async (relativePath) => {
|
|
12
|
-
const image = await readReferenceImageSource(resolver, workspaceId, relativePath, principal, signal);
|
|
75
|
+
const image = await readReferenceImageSource(resolver, workspaceId, relativePath, principal, signal, working);
|
|
13
76
|
images.push(image);
|
|
14
77
|
return image;
|
|
15
78
|
};
|
|
16
79
|
const main = await load(reference.imagePath);
|
|
80
|
+
let warped;
|
|
81
|
+
let picture;
|
|
82
|
+
if (sheetsSource !== undefined) {
|
|
83
|
+
const sheets = registerSheets(sheetsSource.document, frameSize, {
|
|
84
|
+
width: main.originalWidth,
|
|
85
|
+
height: main.originalHeight,
|
|
86
|
+
sha256: main.source.sha256,
|
|
87
|
+
});
|
|
88
|
+
const raw = await sharp(main.bytes)
|
|
89
|
+
.flatten({ background: '#245b82' })
|
|
90
|
+
.removeAlpha()
|
|
91
|
+
.toColourspace('srgb')
|
|
92
|
+
.raw()
|
|
93
|
+
.toBuffer({ resolveWithObject: true });
|
|
94
|
+
signal?.throwIfAborted();
|
|
95
|
+
warped = warpSheets({ data: raw.data, width: raw.info.width, height: raw.info.height }, sheets, frameSize, {
|
|
96
|
+
fill: sheetsSource.document.fill,
|
|
97
|
+
sampling: exact ? 'nearest' : 'bilinear',
|
|
98
|
+
...(signal === undefined ? {} : { signal }),
|
|
99
|
+
});
|
|
100
|
+
picture = { data: warped.rgb, width: warped.width, height: warped.height };
|
|
101
|
+
}
|
|
102
|
+
if (frame && !sheeted) {
|
|
103
|
+
const sourceAspect = (reference.crop?.width ?? main.originalWidth) /
|
|
104
|
+
(reference.crop?.height ?? main.originalHeight);
|
|
105
|
+
const frameAspect = operation.width / operation.height;
|
|
106
|
+
if (Math.abs(sourceAspect - frameAspect) > frameAspect * 0.01)
|
|
107
|
+
throw new ServiceError('MAP_REFERENCE_FRAME_ASPECT', `The reference (${reference.crop?.width ?? main.originalWidth} x ${reference.crop?.height ?? main.originalHeight}) does not have the frame's shape (${operation.width} x ${operation.height}); fitting it would distort the map. Crop or resize the picture, or use the default fit.`, {
|
|
108
|
+
sourceWidth: reference.crop?.width ?? main.originalWidth,
|
|
109
|
+
sourceHeight: reference.crop?.height ?? main.originalHeight,
|
|
110
|
+
width: operation.width,
|
|
111
|
+
height: operation.height,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
17
114
|
if (reference.crop !== undefined &&
|
|
18
115
|
(reference.crop.x + reference.crop.width > main.originalWidth ||
|
|
19
116
|
reference.crop.y + reference.crop.height > main.originalHeight))
|
|
@@ -31,7 +128,24 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
|
|
|
31
128
|
width: Math.max(1, Math.round((reference.crop.width * main.workingWidth) / main.originalWidth)),
|
|
32
129
|
height: Math.max(1, Math.round((reference.crop.height * main.workingHeight) / main.originalHeight)),
|
|
33
130
|
};
|
|
34
|
-
|
|
131
|
+
// The registered picture, reduced, is what previews show and what text recognition reads.
|
|
132
|
+
const previewBytes = picture === undefined
|
|
133
|
+
? undefined
|
|
134
|
+
: await sharp(picture.data, {
|
|
135
|
+
raw: { width: picture.width, height: picture.height, channels: 3 },
|
|
136
|
+
})
|
|
137
|
+
.resize({
|
|
138
|
+
width: PREVIEW_MAX_SIDE,
|
|
139
|
+
height: PREVIEW_MAX_SIDE,
|
|
140
|
+
fit: 'inside',
|
|
141
|
+
withoutEnlargement: true,
|
|
142
|
+
})
|
|
143
|
+
.png()
|
|
144
|
+
.toBuffer();
|
|
145
|
+
// Text recognition masks labels out of heuristic classification; an exact palette has none.
|
|
146
|
+
const text = exact
|
|
147
|
+
? { labels: [], warnings: [] }
|
|
148
|
+
: await extractReferenceLabels(previewBytes ?? main.bytes);
|
|
35
149
|
signal?.throwIfAborted();
|
|
36
150
|
const textMasks = text.labels
|
|
37
151
|
.slice(0, Math.max(0, 256 - (reference.ignoreMasks?.length ?? 0)))
|
|
@@ -45,7 +159,10 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
|
|
|
45
159
|
{ x: left, y: bottom },
|
|
46
160
|
];
|
|
47
161
|
});
|
|
48
|
-
const
|
|
162
|
+
const uncovered = warped === undefined
|
|
163
|
+
? undefined
|
|
164
|
+
: Uint8Array.from(warped.owner, (owner) => (owner === 255 ? 1 : 0));
|
|
165
|
+
const surface = await extractReferenceWorld(picture ?? main.bytes, {
|
|
49
166
|
width: operation.width ?? 5_632,
|
|
50
167
|
height: operation.height ?? 2_048,
|
|
51
168
|
seed: operation.seed ?? 1,
|
|
@@ -57,6 +174,9 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
|
|
|
57
174
|
? {}
|
|
58
175
|
: { riverAnnotations: reference.riverAnnotations }),
|
|
59
176
|
...(reference.palette === undefined ? {} : { palette: reference.palette }),
|
|
177
|
+
...(frame ? { fit: 'stretch' } : {}),
|
|
178
|
+
...(exact ? { paletteMatch: 'exact' } : {}),
|
|
179
|
+
...(uncovered === undefined ? {} : { uncovered }),
|
|
60
180
|
...(reference.coastDetail === undefined ? {} : { coastDetail: reference.coastDetail }),
|
|
61
181
|
...(political === undefined ? {} : { politicalImageBytes: political.bytes }),
|
|
62
182
|
...(terrain === undefined ? {} : { terrainImageBytes: terrain.bytes }),
|
|
@@ -104,7 +224,8 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
|
|
|
104
224
|
Math.min(surface.width, surface.height) * 0.025);
|
|
105
225
|
// An explicit review is authoritative, including an empty list. OCR still helps mask ink,
|
|
106
226
|
// but must not reintroduce discarded or uncertain names outside the supplied label set.
|
|
107
|
-
|
|
227
|
+
// Names are never read from a registered drawing: its labels are the owner's to review.
|
|
228
|
+
const automaticText = reference.labels === undefined && !sheeted ? text.labels : [];
|
|
108
229
|
const labels = automaticText
|
|
109
230
|
.filter(({ text: label }) => !/\b(?:map|world|sea|ocean|bay|strait|channel|flow|belt|signature|north|south|east|west|compass)\b/iu.test(label))
|
|
110
231
|
.slice(0, 200)
|
|
@@ -127,8 +248,11 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
|
|
|
127
248
|
return id === 0 ? undefined : id;
|
|
128
249
|
};
|
|
129
250
|
let countries;
|
|
130
|
-
if (operation.countries
|
|
251
|
+
if (Array.isArray(operation.countries))
|
|
131
252
|
countries = operation.countries;
|
|
253
|
+
// Workspace-owned countries are placed from their own points; the picture's colours are unused.
|
|
254
|
+
else if (operation.countries !== undefined)
|
|
255
|
+
countries = [];
|
|
132
256
|
else if (surface.inferredCountries.length > 1) {
|
|
133
257
|
countries = surface.inferredCountries.slice(0, 200).map((country, index) => ({
|
|
134
258
|
tag: `X${(index + 1).toString(36).toUpperCase().padStart(2, '0')}`,
|
|
@@ -243,5 +367,17 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
|
|
|
243
367
|
diagnostics: [...new Set(warnings)],
|
|
244
368
|
},
|
|
245
369
|
images,
|
|
370
|
+
documents: sheetsSource?.evidence === undefined ? [] : [sheetsSource.evidence],
|
|
371
|
+
...(previewBytes === undefined ? {} : { previewBytes }),
|
|
372
|
+
...(warped === undefined
|
|
373
|
+
? {}
|
|
374
|
+
: {
|
|
375
|
+
registration: {
|
|
376
|
+
coverage: warped.coverage,
|
|
377
|
+
uncovered: warped.uncovered,
|
|
378
|
+
width: warped.width,
|
|
379
|
+
height: warped.height,
|
|
380
|
+
},
|
|
381
|
+
}),
|
|
246
382
|
};
|
|
247
383
|
}
|