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.
Files changed (119) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
  3. package/dist/hoi4_agent_tools/core/configuration.js +11 -0
  4. package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
  5. package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
  6. package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
  7. package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
  8. package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
  9. package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
  10. package/dist/hoi4_agent_tools/core/transactions.js +23 -4
  11. package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
  12. package/dist/hoi4_agent_tools/core/workspace.js +42 -6
  13. package/dist/hoi4_agent_tools/focus/drift.js +34 -0
  14. package/dist/hoi4_agent_tools/focus/service.js +9 -3
  15. package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
  16. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  17. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  18. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  19. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  20. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  21. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  22. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  23. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  24. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  25. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  26. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  27. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  28. package/dist/hoi4_agent_tools/map/model.js +17 -3
  29. package/dist/hoi4_agent_tools/map/operations.js +284 -14
  30. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  31. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  32. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  33. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  34. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  35. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  36. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  37. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  38. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  39. package/dist/hoi4_agent_tools/map/reference-preparation.js +142 -6
  40. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  41. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  42. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  43. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  44. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  45. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  46. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  47. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  48. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  49. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  50. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  51. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  52. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  53. package/dist/hoi4_agent_tools/map/render.js +36 -0
  54. package/dist/hoi4_agent_tools/map/service.js +206 -16
  55. package/dist/hoi4_agent_tools/map/tool-operations.js +249 -3
  56. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  57. package/dist/hoi4_agent_tools/map/water.js +227 -0
  58. package/dist/hoi4_agent_tools/map/world-context.js +47 -1
  59. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  60. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  61. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  62. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  63. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  64. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  65. package/dist/hoi4_agent_tools/map/world.js +562 -163
  66. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  67. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  68. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  69. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  70. package/dist/hoi4_agent_tools/schemas/map.js +280 -15
  71. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  72. package/dist/hoi4_agent_tools/version.js +1 -1
  73. package/docs/configuration.md +17 -14
  74. package/docs/focus.md +6 -0
  75. package/docs/map.md +292 -15
  76. package/docs/tools.md +4 -4
  77. package/docs/troubleshooting.md +11 -11
  78. package/npm-shrinkwrap.json +109 -91
  79. package/package.json +3 -3
  80. package/schemas/configuration.schema.json +65 -58
  81. package/schemas/continuous-focus-palette.schema.json +1 -1
  82. package/schemas/custom-weighted-pool.schema.json +1 -1
  83. package/schemas/decision-inspect-input.schema.json +1 -1
  84. package/schemas/error-log-input.schema.json +1 -1
  85. package/schemas/event-feature-manifest.schema.json +1 -1
  86. package/schemas/focus-plan.schema.json +1 -1
  87. package/schemas/focus-planning-sidecar.schema.json +1 -1
  88. package/schemas/geography-model.schema.json +1148 -0
  89. package/schemas/gui-animation-source.schema.json +1 -1
  90. package/schemas/gui-helper.schema.json +5 -11
  91. package/schemas/gui-scenario.schema.json +1 -1
  92. package/schemas/helper-expansion-request.schema.json +1 -1
  93. package/schemas/helper-expansion-summary.schema.json +1 -1
  94. package/schemas/impact-inspect-input.schema.json +1 -1
  95. package/schemas/map-operation.schema.json +2325 -292
  96. package/schemas/mechanic-test-input.schema.json +1 -1
  97. package/schemas/mod-index-input.schema.json +1 -1
  98. package/schemas/operation-result.schema.json +1 -1
  99. package/schemas/package-check-input.schema.json +1 -1
  100. package/schemas/package-manifest.schema.json +1 -1
  101. package/schemas/probability-analysis-result.schema.json +25 -7
  102. package/schemas/probability-compare-input.schema.json +1 -1
  103. package/schemas/probability-evaluate-input.schema.json +1 -1
  104. package/schemas/probability-inspect-input.schema.json +1 -1
  105. package/schemas/probability-render-input.schema.json +1 -1
  106. package/schemas/probability-scenario-set.schema.json +1 -1
  107. package/schemas/probability-sequence-input.schema.json +1 -1
  108. package/schemas/probability-simulate-input.schema.json +1 -1
  109. package/schemas/probability-sweep-input.schema.json +1 -1
  110. package/schemas/reference-context-input.schema.json +1 -1
  111. package/schemas/reference-read-input.schema.json +1 -1
  112. package/schemas/reference-search-input.schema.json +1 -1
  113. package/schemas/reference-sheets.schema.json +267 -0
  114. package/schemas/scenario-suite.schema.json +1 -1
  115. package/schemas/scenario-test-input.schema.json +1 -1
  116. package/schemas/script-validate-input.schema.json +1 -1
  117. package/schemas/shared-scenario.schema.json +1 -1
  118. package/schemas/source-lookup-input.schema.json +1 -1
  119. package/server.json +2 -2
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Registration of a picture made of drawn sheets onto the world frame (ADR 0067, step 2).
3
+ *
4
+ * Every sheet becomes one affine map from normalised picture coordinates to normalised world
5
+ * coordinates. Each world pixel is mapped back by the inverse of the highest-order sheet whose box
6
+ * (and clip) holds it and sampled from the working picture, so the result is one frame-aligned
7
+ * picture from which every map layer is derived. Only `+ - * /` are used, so results are the same
8
+ * on every platform.
9
+ */
10
+ import { ServiceError } from '../core/result.js';
11
+ const NONE = 255;
12
+ const UNCOVERED_COLOUR = [0x24, 0x5b, 0x82];
13
+ export function sheetAffine(sheet) {
14
+ if (sheet.affine !== undefined)
15
+ return sheet.affine;
16
+ const [anchorX, anchorY] = sheet.anchor;
17
+ const [targetX, targetY] = sheet.target;
18
+ const [scaleX, scaleY] = sheet.scale;
19
+ return [scaleX, 0, targetX - scaleX * anchorX, 0, scaleY, targetY - scaleY * anchorY];
20
+ }
21
+ export function invertAffine(affine) {
22
+ const [a, b, c, d, e, f] = affine;
23
+ const determinant = a * e - b * d;
24
+ if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-12)
25
+ return undefined;
26
+ const ia = e / determinant;
27
+ const ib = -b / determinant;
28
+ const id = -d / determinant;
29
+ const ie = a / determinant;
30
+ return [ia, ib, -(ia * c + ib * f), id, ie, -(id * c + ie * f)];
31
+ }
32
+ export function applyAffine(affine, x, y) {
33
+ return [affine[0] * x + affine[1] * y + affine[2], affine[3] * x + affine[4] * y + affine[5]];
34
+ }
35
+ /**
36
+ * Check a document against the picture and the frame and build each sheet's maps. Throws for a
37
+ * picture the registrations were not made for, a degenerate map or a sheet entirely outside the
38
+ * frame.
39
+ */
40
+ export function registerSheets(document, frame, picture) {
41
+ if (document.source !== undefined &&
42
+ (document.source.width !== picture.width || document.source.height !== picture.height))
43
+ throw new ServiceError('MAP_REFERENCE_SHEETS_SOURCE_MISMATCH', `The sheet registrations were made for a ${document.source.width} x ${document.source.height} picture, but the reference is ${picture.width} x ${picture.height}`, {
44
+ expectedWidth: document.source.width,
45
+ expectedHeight: document.source.height,
46
+ width: picture.width,
47
+ height: picture.height,
48
+ });
49
+ if (document.source?.sha256 !== undefined && document.source.sha256 !== picture.sha256)
50
+ throw new ServiceError('MAP_REFERENCE_SHEETS_SOURCE_MISMATCH', 'The sheet registrations were made for a picture with another SHA-256', { expectedSha256: document.source.sha256, sha256: picture.sha256 });
51
+ return document.sheets.map((sheet, index) => {
52
+ const forward = sheetAffine(sheet);
53
+ const inverse = invertAffine(forward);
54
+ if (inverse === undefined)
55
+ throw new ServiceError('MAP_REFERENCE_SHEET_DEGENERATE', `Sheet ${sheet.id} maps its box onto a line or a point`, { sheetId: sheet.id });
56
+ const [left, top, right, bottom] = sheet.box;
57
+ const corners = [
58
+ applyAffine(forward, left, top),
59
+ applyAffine(forward, right, top),
60
+ applyAffine(forward, left, bottom),
61
+ applyAffine(forward, right, bottom),
62
+ ];
63
+ const minX = Math.min(...corners.map(([x]) => x));
64
+ const maxX = Math.max(...corners.map(([x]) => x));
65
+ const minY = Math.min(...corners.map(([, y]) => y));
66
+ const maxY = Math.max(...corners.map(([, y]) => y));
67
+ if (maxX <= 0 || minX >= 1 || maxY <= 0 || minY >= 1)
68
+ throw new ServiceError('MAP_REFERENCE_SHEET_OUTSIDE', `Sheet ${sheet.id} lies entirely outside the world frame`, { sheetId: sheet.id, bounds: { left: minX, top: minY, right: maxX, bottom: maxY } });
69
+ const clip = sheet.clip === undefined
70
+ ? undefined
71
+ : Float64Array.from(sheet.clip.flatMap(([x, y]) => [x, y]));
72
+ return {
73
+ id: sheet.id,
74
+ order: sheet.order,
75
+ index,
76
+ forward,
77
+ inverse,
78
+ box: sheet.box,
79
+ ...(clip === undefined ? {} : { clip }),
80
+ pixels: {
81
+ minX: Math.max(0, Math.floor(minX * frame.width)),
82
+ minY: Math.max(0, Math.floor(minY * frame.height)),
83
+ maxX: Math.min(frame.width - 1, Math.ceil(maxX * frame.width) - 1),
84
+ maxY: Math.min(frame.height - 1, Math.ceil(maxY * frame.height) - 1),
85
+ },
86
+ };
87
+ });
88
+ }
89
+ /**
90
+ * Working picture size at which every sheet still has at least one working pixel per world
91
+ * pixel: the longest world distance one picture unit travels along each picture axis.
92
+ */
93
+ export function sheetWorkingSize(forwards, frame) {
94
+ let width = 1;
95
+ let height = 1;
96
+ for (const forward of forwards) {
97
+ const [a, b, , d, e] = forward;
98
+ width = Math.max(width, Math.sqrt((a * frame.width) ** 2 + (d * frame.height) ** 2));
99
+ height = Math.max(height, Math.sqrt((b * frame.width) ** 2 + (e * frame.height) ** 2));
100
+ }
101
+ return { width: Math.ceil(width), height: Math.ceil(height) };
102
+ }
103
+ function insideSheet(sheet, x, y) {
104
+ const [left, top, right, bottom] = sheet.box;
105
+ if (x < left || x >= right || y < top || y >= bottom)
106
+ return false;
107
+ const clip = sheet.clip;
108
+ if (clip === undefined)
109
+ return true;
110
+ let inside = false;
111
+ for (let index = 0, previous = clip.length - 2; index < clip.length; previous = index, index += 2) {
112
+ const ax = clip[index];
113
+ const ay = clip[index + 1];
114
+ const bx = clip[previous];
115
+ const by = clip[previous + 1];
116
+ if (ay > y !== by > y && x < ((bx - ax) * (y - ay)) / (by - ay) + ax)
117
+ inside = !inside;
118
+ }
119
+ return inside;
120
+ }
121
+ /**
122
+ * Warp the working picture (raw RGB) into the frame. `nearest` sampling keeps the colours of an
123
+ * indexed picture; `bilinear` blends neighbours for drawings. Throws when two sheets of equal
124
+ * order both cover a world pixel, and when `fill` is `error` and a world pixel has no sheet.
125
+ */
126
+ export function warpSheets(picture, sheets, frame, options) {
127
+ const { width, height } = frame;
128
+ if (sheets.length > NONE)
129
+ throw new ServiceError('MAP_REFERENCE_SHEETS_LIMIT', 'Too many sheets');
130
+ const rgb = Buffer.alloc(width * height * 3);
131
+ const owner = new Uint8Array(width * height).fill(NONE);
132
+ const ordered = [...sheets].sort((left, right) => right.order - left.order || left.index - right.index);
133
+ const taken = new Float64Array(sheets.length);
134
+ const hidden = new Float64Array(sheets.length);
135
+ let ties = 0;
136
+ let tieSample;
137
+ const pictureWidth = picture.width;
138
+ const pictureHeight = picture.height;
139
+ const data = picture.data;
140
+ for (let y = 0; y < height; y += 1) {
141
+ if ((y & 63) === 0)
142
+ options.signal?.throwIfAborted();
143
+ const worldY = (y + 0.5) / height;
144
+ for (const sheet of ordered) {
145
+ const { minX, maxX, minY, maxY } = sheet.pixels;
146
+ if (y < minY || y > maxY)
147
+ continue;
148
+ const [ia, ib, ic, id, ie, iff] = sheet.inverse;
149
+ for (let x = minX; x <= maxX; x += 1) {
150
+ const worldX = (x + 0.5) / width;
151
+ const pictureX = ia * worldX + ib * worldY + ic;
152
+ const pictureY = id * worldX + ie * worldY + iff;
153
+ if (!insideSheet(sheet, pictureX, pictureY))
154
+ continue;
155
+ const pixel = y * width + x;
156
+ const current = owner[pixel];
157
+ if (current !== NONE) {
158
+ hidden[sheet.index] = hidden[sheet.index] + 1;
159
+ if (sheets[current].order === sheet.order) {
160
+ ties += 1;
161
+ tieSample ??= { x, y, sheets: [sheets[current].id, sheet.id] };
162
+ }
163
+ continue;
164
+ }
165
+ owner[pixel] = sheet.index;
166
+ taken[sheet.index] = taken[sheet.index] + 1;
167
+ const sourceX = pictureX * pictureWidth - 0.5;
168
+ const sourceY = pictureY * pictureHeight - 0.5;
169
+ const target = pixel * 3;
170
+ if (options.sampling === 'nearest') {
171
+ const column = Math.min(pictureWidth - 1, Math.max(0, Math.round(sourceX)));
172
+ const row = Math.min(pictureHeight - 1, Math.max(0, Math.round(sourceY)));
173
+ const source = (row * pictureWidth + column) * 3;
174
+ rgb[target] = data[source];
175
+ rgb[target + 1] = data[source + 1];
176
+ rgb[target + 2] = data[source + 2];
177
+ }
178
+ else {
179
+ const left = Math.min(pictureWidth - 1, Math.max(0, Math.floor(sourceX)));
180
+ const top = Math.min(pictureHeight - 1, Math.max(0, Math.floor(sourceY)));
181
+ const right = Math.min(pictureWidth - 1, left + 1);
182
+ const bottom = Math.min(pictureHeight - 1, top + 1);
183
+ const fx = Math.min(1, Math.max(0, sourceX - left));
184
+ const fy = Math.min(1, Math.max(0, sourceY - top));
185
+ const p00 = (top * pictureWidth + left) * 3;
186
+ const p10 = (top * pictureWidth + right) * 3;
187
+ const p01 = (bottom * pictureWidth + left) * 3;
188
+ const p11 = (bottom * pictureWidth + right) * 3;
189
+ for (let channel = 0; channel < 3; channel += 1) {
190
+ const upper = data[p00 + channel] + (data[p10 + channel] - data[p00 + channel]) * fx;
191
+ const lower = data[p01 + channel] + (data[p11 + channel] - data[p01 + channel]) * fx;
192
+ rgb[target + channel] = Math.round(upper + (lower - upper) * fy);
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+ if (ties > 0)
199
+ throw new ServiceError('MAP_REFERENCE_SHEETS_OVERLAP_ORDER', `${ties} world pixels are covered by two sheets of the same order (first at ${tieSample.x}, ${tieSample.y}: ${tieSample.sheets.join(' and ')}); give one of them a higher order or clip it`, { pixels: ties, sample: tieSample });
200
+ let uncovered = 0;
201
+ let uncoveredSample;
202
+ for (let pixel = 0; pixel < owner.length; pixel += 1)
203
+ if (owner[pixel] === NONE) {
204
+ uncovered += 1;
205
+ uncoveredSample ??= { x: pixel % width, y: Math.floor(pixel / width) };
206
+ // The open-sea colour every heuristic mode reads as water, so no ink appears at sheet edges.
207
+ rgb.set(UNCOVERED_COLOUR, pixel * 3);
208
+ }
209
+ if (uncovered > 0 && options.fill === 'error')
210
+ throw new ServiceError('MAP_REFERENCE_SHEETS_UNCOVERED', `${uncovered} world pixels lie outside every sheet (first at ${uncoveredSample.x}, ${uncoveredSample.y}); add a sheet or set fill to "sea"`, { pixels: uncovered, sample: uncoveredSample });
211
+ const coverage = sheets.map((sheet) => {
212
+ const [left, top, right, bottom] = sheet.box;
213
+ const corners = [
214
+ applyAffine(sheet.forward, left, top),
215
+ applyAffine(sheet.forward, right, bottom),
216
+ ];
217
+ return {
218
+ id: sheet.id,
219
+ order: sheet.order,
220
+ pixels: taken[sheet.index],
221
+ hidden: hidden[sheet.index],
222
+ bounds: {
223
+ left: Math.min(corners[0][0], corners[1][0]),
224
+ top: Math.min(corners[0][1], corners[1][1]),
225
+ right: Math.max(corners[0][0], corners[1][0]),
226
+ bottom: Math.max(corners[0][1], corners[1][1]),
227
+ },
228
+ };
229
+ });
230
+ return { width, height, rgb, owner, coverage, uncovered };
231
+ }
@@ -75,12 +75,22 @@ export const referenceWorldOptionsSchema = z
75
75
  .optional(),
76
76
  coastDetail: z.number().min(0).max(1).optional(),
77
77
  fit: z.enum(['contain', 'stretch']).optional(),
78
+ paletteMatch: z.enum(['nearest', 'exact']).optional(),
79
+ uncovered: bytesSchema.optional(),
78
80
  politicalImageBytes: bytesSchema.optional(),
79
81
  terrainImageBytes: bytesSchema.optional(),
80
82
  heightImageBytes: bytesSchema.optional(),
81
83
  })
82
84
  .strict();
83
85
  async function decode(bytes) {
86
+ if (!(bytes instanceof Uint8Array)) {
87
+ if (bytes.width < 1 ||
88
+ bytes.height < 1 ||
89
+ bytes.width * bytes.height > REFERENCE_IMAGE_PIXEL_LIMIT ||
90
+ bytes.data.length !== bytes.width * bytes.height * 3)
91
+ throw new ServiceError('MAP_REFERENCE_IMAGE_BLOCKED', 'Reference image exceeds the bounded decode limit');
92
+ return bytes;
93
+ }
84
94
  if (bytes.byteLength === 0 || bytes.byteLength > REFERENCE_IMAGE_BYTE_LIMIT)
85
95
  throw new ServiceError('MAP_REFERENCE_IMAGE_BLOCKED', 'Reference image bytes exceed the bounded decode limit');
86
96
  try {
@@ -173,6 +183,54 @@ function classify(color, mode, palette) {
173
183
  return semanticCode('hills');
174
184
  return 3;
175
185
  }
186
+ const UNKNOWN_COLOR_LIMIT = 16;
187
+ const UNKNOWN_COLOR_SAMPLES = 5;
188
+ /** Classify by exact palette colour; report every other colour with counts and positions. */
189
+ function exactCodes(data, width, height, palette, toSource, uncovered) {
190
+ const known = new Map();
191
+ for (const entry of palette) {
192
+ const key = (entry.color.r << 16) | (entry.color.g << 8) | entry.color.b;
193
+ const code = semanticCode(entry.kind);
194
+ if (known.has(key) && known.get(key) !== code)
195
+ throw new ServiceError('MAP_REFERENCE_PALETTE_CONFLICT', 'One palette colour is given two different kinds', { color: [entry.color.r, entry.color.g, entry.color.b] });
196
+ known.set(key, code);
197
+ }
198
+ const codes = new Uint8Array(width * height);
199
+ const unknown = new Map();
200
+ let unknownPixels = 0;
201
+ for (let pixel = 0; pixel < codes.length; pixel += 1) {
202
+ // Pixels outside every registered sheet are open sea whatever colour stands there.
203
+ if (uncovered?.[pixel])
204
+ continue;
205
+ const key = (data[pixel * 3] << 16) | (data[pixel * 3 + 1] << 8) | data[pixel * 3 + 2];
206
+ const code = known.get(key);
207
+ if (code !== undefined) {
208
+ codes[pixel] = code;
209
+ continue;
210
+ }
211
+ unknownPixels += 1;
212
+ let entry = unknown.get(key);
213
+ if (entry === undefined) {
214
+ entry = { count: 0, samples: [] };
215
+ unknown.set(key, entry);
216
+ }
217
+ entry.count += 1;
218
+ if (entry.samples.length < UNKNOWN_COLOR_SAMPLES)
219
+ entry.samples.push(toSource(pixel % width, Math.floor(pixel / width)));
220
+ }
221
+ if (unknownPixels > 0) {
222
+ const colors = [...unknown.entries()]
223
+ .sort(([leftKey, left], [rightKey, right]) => right.count - left.count || leftKey - rightKey)
224
+ .slice(0, UNKNOWN_COLOR_LIMIT)
225
+ .map(([key, { count, samples }]) => ({
226
+ color: [key >> 16, (key >> 8) & 255, key & 255],
227
+ count,
228
+ samples,
229
+ }));
230
+ throw new ServiceError('MAP_REFERENCE_PALETTE_UNKNOWN_COLOR', `${unknownPixels} reference pixels in ${unknown.size} colours match no palette entry; add the colours to the palette or repaint them. Samples are source pixel coordinates.`, { unknownPixels, unknownColors: unknown.size, colors });
231
+ }
232
+ return codes;
233
+ }
176
234
  function fillUnknown(codes, width, height) {
177
235
  if (!codes.includes(255))
178
236
  return;
@@ -329,6 +387,9 @@ export async function extractReferenceWorld(imageBytes, input) {
329
387
  const height = options.height ?? 2048;
330
388
  if (width * height > REFERENCE_WORLD_PIXEL_LIMIT)
331
389
  throw new ServiceError('MAP_REFERENCE_PIXELS_BLOCKED', 'Reference output exceeds the pixel budget');
390
+ const exact = options.paletteMatch === 'exact';
391
+ if (exact && (options.palette?.length ?? 0) === 0)
392
+ throw new ServiceError('MAP_REFERENCE_PALETTE_REQUIRED', 'Exact palette matching needs a palette that lists every colour of the picture');
332
393
  const scale = options.fit === 'stretch' ? 1 : Math.min(width / crop.width, height / crop.height);
333
394
  const bounds = options.fit === 'stretch'
334
395
  ? { x: 0, y: 0, width, height }
@@ -345,14 +406,27 @@ export async function extractReferenceWorld(imageBytes, input) {
345
406
  .resize(bounds.width, bounds.height, { kernel: sharp.kernel.nearest })
346
407
  .raw()
347
408
  .toBuffer();
348
- const mode = options.mode === undefined || options.mode === 'auto' ? modeFor(resized) : options.mode;
409
+ const mode = exact
410
+ ? 'political'
411
+ : options.mode === undefined || options.mode === 'auto'
412
+ ? modeFor(resized)
413
+ : options.mode;
349
414
  const diagnostics = [
350
- `Reference interpretation uses ${mode} visual heuristics; illustrated labels and ambiguous biomes may need semantic overrides.`,
415
+ exact
416
+ ? 'Reference interpretation matches every pixel to the exact palette; no visual heuristics are applied.'
417
+ : `Reference interpretation uses ${mode} visual heuristics; illustrated labels and ambiguous biomes may need semantic overrides.`,
351
418
  ];
352
419
  const codes = new Uint8Array(width * height);
353
- const localCodes = mode === 'sketch' || mode === 'grayscale'
354
- ? grayscaleCodes(resized, bounds.width, bounds.height, mode)
355
- : undefined;
420
+ // Unknown-colour samples name the source pixel, so the picture can be repainted there.
421
+ const toSource = (x, y) => ({
422
+ x: Math.min(source.width - 1, crop.x + Math.floor(((x + 0.5) * crop.width) / bounds.width)),
423
+ y: Math.min(source.height - 1, crop.y + Math.floor(((y + 0.5) * crop.height) / bounds.height)),
424
+ });
425
+ const localCodes = exact
426
+ ? exactCodes(resized, bounds.width, bounds.height, options.palette ?? [], toSource, options.uncovered?.length === bounds.width * bounds.height ? options.uncovered : undefined)
427
+ : mode === 'sketch' || mode === 'grayscale'
428
+ ? grayscaleCodes(resized, bounds.width, bounds.height, mode)
429
+ : undefined;
356
430
  for (let y = 0; y < bounds.height; y += 1)
357
431
  for (let x = 0; x < bounds.width; x += 1) {
358
432
  const local = y * bounds.width + x;
@@ -360,9 +434,9 @@ export async function extractReferenceWorld(imageBytes, input) {
360
434
  codes[target] =
361
435
  localCodes?.[local] ?? classify(pixelColor(resized, local), mode, options.palette ?? []);
362
436
  }
363
- if (mode === 'illustrated' || mode === 'satellite')
437
+ if (!exact && (mode === 'illustrated' || mode === 'satellite'))
364
438
  cleanWaterTexture(codes, resized, width, bounds, options.coastDetail ?? 1, diagnostics);
365
- if (mode !== 'sketch' && mode !== 'grayscale')
439
+ if (!exact && mode !== 'sketch' && mode !== 'grayscale')
366
440
  maskOceanText(codes, resized, width, bounds, diagnostics);
367
441
  const transform = (point) => ({
368
442
  x: bounds.x + ((point.x * source.width - crop.x) / crop.width) * bounds.width,
@@ -376,8 +450,22 @@ export async function extractReferenceWorld(imageBytes, input) {
376
450
  throw new ServiceError('MAP_REFERENCE_MASK_WORK_BLOCKED', 'Reference polygon controls exceed the bounded scanline work budget');
377
451
  for (const mask of options.ignoreMasks ?? [])
378
452
  polygon(codes, width, height, mask.map(transform), 255);
453
+ const uncovered = options.uncovered;
454
+ if (uncovered !== undefined && uncovered.length !== codes.length)
455
+ throw new ServiceError('MAP_REFERENCE_OPTIONS_INVALID', 'The uncovered mask must have the frame size');
456
+ // Open sea outside every sheet is decided, not inferred: no mask fill, cleanup or smoothing moves it.
457
+ if (uncovered !== undefined)
458
+ for (let pixel = 0; pixel < codes.length; pixel += 1)
459
+ if (uncovered[pixel])
460
+ codes[pixel] = 0;
379
461
  fillUnknown(codes, width, height);
380
- const protectedFeatures = (options.annotations?.length ?? 0) > 0 ? new Uint8Array(codes.length) : undefined;
462
+ const protectedFeatures = (options.annotations?.length ?? 0) > 0 || uncovered !== undefined
463
+ ? new Uint8Array(codes.length)
464
+ : undefined;
465
+ if (protectedFeatures !== undefined && uncovered !== undefined)
466
+ for (let pixel = 0; pixel < codes.length; pixel += 1)
467
+ if (uncovered[pixel])
468
+ protectedFeatures[pixel] = 1;
381
469
  for (const annotation of options.annotations ?? []) {
382
470
  polygon(codes, width, height, annotation.polygon.map(transform), semanticCode(annotation.kind));
383
471
  if (protectedFeatures)
@@ -394,7 +482,7 @@ export async function extractReferenceWorld(imageBytes, input) {
394
482
  throw new ServiceError('MAP_REFERENCE_RIVER_COMPLEXITY', 'Annotated rivers exceed the bounded point budget');
395
483
  const rivers = (options.riverAnnotations ?? []).flatMap((points) => clipRiver(points.map(transform).map((point) => ({ x: point.x - bounds.x, y: point.y - bounds.y })), bounds.width, bounds.height).map((path) => path.map((point) => ({ x: point.x + bounds.x, y: point.y + bounds.y }))));
396
484
  traceCoastalRivers(kind, width, height, rivers, diagnostics);
397
- normalizeWater(kind, width, height, rivers, diagnostics);
485
+ normalizeWater(kind, width, height, rivers, diagnostics, !exact);
398
486
  cleanSmallComponents(kind, width, height, protectedFeatures, diagnostics);
399
487
  repairCorners(kind, width, height, diagnostics);
400
488
  // Corner repairs can detach an inlet from the ocean. Recheck sea fragments as well as
@@ -404,7 +492,7 @@ export async function extractReferenceWorld(imageBytes, input) {
404
492
  break;
405
493
  repairCorners(kind, width, height, diagnostics);
406
494
  }
407
- if (mode === 'illustrated')
495
+ if (!exact && mode === 'illustrated')
408
496
  smoothIllustratedTerrain(terrain, kind, width, height, protectedFeatures, diagnostics);
409
497
  const elevation = new Float32Array(codes.length);
410
498
  const moisture = new Float32Array(codes.length);
@@ -428,7 +516,8 @@ export async function extractReferenceWorld(imageBytes, input) {
428
516
  inferredCountries: [],
429
517
  diagnostics,
430
518
  };
431
- inferCountries(result, resized, mode);
519
+ if (!exact)
520
+ inferCountries(result, resized, mode);
432
521
  await applyLayers(result, input, crop);
433
522
  normalizeElevation(result, options.seed, options.heightImageBytes !== undefined);
434
523
  return result;
@@ -915,7 +1004,7 @@ function connectedPath(pixels, width, height) {
915
1004
  throw new ServiceError('MAP_REFERENCE_RIVER_COMPLEXITY', 'Visible river path exceeds the bounded trace length; use a smaller reference output');
916
1005
  return path;
917
1006
  }
918
- function normalizeWater(kind, width, height, rivers, diagnostics) {
1007
+ function normalizeWater(kind, width, height, rivers, diagnostics, thinWaterToRivers = true) {
919
1008
  const visited = new Uint8Array(kind.length);
920
1009
  const queue = new Int32Array(kind.length);
921
1010
  let lakeCount = 0;
@@ -953,7 +1042,9 @@ function normalizeWater(kind, width, height, rivers, diagnostics) {
953
1042
  const spanX = maxX - minX + 1;
954
1043
  const spanY = maxY - minY + 1;
955
1044
  const thin = tail / Math.max(spanX, spanY) <= Math.max(2, Math.min(width, height) / 350);
956
- if (thin && Math.max(spanX, spanY) >= Math.max(5, Math.min(width, height) / 100)) {
1045
+ if (thinWaterToRivers &&
1046
+ thin &&
1047
+ Math.max(spanX, spanY) >= Math.max(5, Math.min(width, height) / 100)) {
957
1048
  const points = connectedPath(queue.subarray(0, tail), width, height);
958
1049
  for (let index = 0; index < tail; index += 1)
959
1050
  kind[queue[index]] = 1;