hoi4-agent-tools 4.3.0 → 4.4.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 (110) hide show
  1. package/CHANGELOG.md +25 -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/operation-context.js +17 -1
  5. package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
  6. package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
  7. package/dist/hoi4_agent_tools/core/transactions.js +23 -4
  8. package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
  9. package/dist/hoi4_agent_tools/core/workspace.js +42 -6
  10. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  11. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  12. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  13. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  14. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  15. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  16. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  17. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  18. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  19. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  20. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  21. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  22. package/dist/hoi4_agent_tools/map/model.js +15 -2
  23. package/dist/hoi4_agent_tools/map/operations.js +111 -8
  24. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  25. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  26. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  27. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  28. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  29. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  30. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  31. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  32. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  33. package/dist/hoi4_agent_tools/map/reference-preparation.js +138 -5
  34. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  35. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  36. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  37. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  38. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  39. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  40. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  41. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  42. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  43. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  44. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  45. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  46. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  47. package/dist/hoi4_agent_tools/map/render.js +36 -0
  48. package/dist/hoi4_agent_tools/map/service.js +191 -16
  49. package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
  50. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  51. package/dist/hoi4_agent_tools/map/water.js +227 -0
  52. package/dist/hoi4_agent_tools/map/world-context.js +24 -1
  53. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  54. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  55. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  56. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  57. package/dist/hoi4_agent_tools/map/world.js +462 -124
  58. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  59. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  60. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  61. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  62. package/dist/hoi4_agent_tools/schemas/map.js +209 -2
  63. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  64. package/dist/hoi4_agent_tools/version.js +1 -1
  65. package/docs/configuration.md +17 -14
  66. package/docs/map.md +258 -15
  67. package/docs/tools.md +4 -4
  68. package/docs/troubleshooting.md +11 -11
  69. package/npm-shrinkwrap.json +109 -91
  70. package/package.json +3 -3
  71. package/schemas/configuration.schema.json +65 -58
  72. package/schemas/continuous-focus-palette.schema.json +1 -1
  73. package/schemas/custom-weighted-pool.schema.json +1 -1
  74. package/schemas/decision-inspect-input.schema.json +1 -1
  75. package/schemas/error-log-input.schema.json +1 -1
  76. package/schemas/event-feature-manifest.schema.json +1 -1
  77. package/schemas/focus-plan.schema.json +1 -1
  78. package/schemas/focus-planning-sidecar.schema.json +1 -1
  79. package/schemas/geography-model.schema.json +1148 -0
  80. package/schemas/gui-animation-source.schema.json +1 -1
  81. package/schemas/gui-helper.schema.json +5 -11
  82. package/schemas/gui-scenario.schema.json +1 -1
  83. package/schemas/helper-expansion-request.schema.json +1 -1
  84. package/schemas/helper-expansion-summary.schema.json +1 -1
  85. package/schemas/impact-inspect-input.schema.json +1 -1
  86. package/schemas/map-operation.schema.json +2156 -259
  87. package/schemas/mechanic-test-input.schema.json +1 -1
  88. package/schemas/mod-index-input.schema.json +1 -1
  89. package/schemas/operation-result.schema.json +1 -1
  90. package/schemas/package-check-input.schema.json +1 -1
  91. package/schemas/package-manifest.schema.json +1 -1
  92. package/schemas/probability-analysis-result.schema.json +25 -7
  93. package/schemas/probability-compare-input.schema.json +1 -1
  94. package/schemas/probability-evaluate-input.schema.json +1 -1
  95. package/schemas/probability-inspect-input.schema.json +1 -1
  96. package/schemas/probability-render-input.schema.json +1 -1
  97. package/schemas/probability-scenario-set.schema.json +1 -1
  98. package/schemas/probability-sequence-input.schema.json +1 -1
  99. package/schemas/probability-simulate-input.schema.json +1 -1
  100. package/schemas/probability-sweep-input.schema.json +1 -1
  101. package/schemas/reference-context-input.schema.json +1 -1
  102. package/schemas/reference-read-input.schema.json +1 -1
  103. package/schemas/reference-search-input.schema.json +1 -1
  104. package/schemas/reference-sheets.schema.json +267 -0
  105. package/schemas/scenario-suite.schema.json +1 -1
  106. package/schemas/scenario-test-input.schema.json +1 -1
  107. package/schemas/script-validate-input.schema.json +1 -1
  108. package/schemas/shared-scenario.schema.json +1 -1
  109. package/schemas/source-lookup-input.schema.json +1 -1
  110. package/server.json +2 -2
@@ -0,0 +1,647 @@
1
+ import sharp from 'sharp';
2
+ import { canonicalJson } from '../../core/canonical.js';
3
+ import { RenderBudget } from '../../core/render-budget.js';
4
+ import { PACKAGE_VERSION } from '../../version.js';
5
+ import { GEOGRAPHY_FEATURE_KINDS, } from './model.js';
6
+ import { areaPixels, createBandStrip, rasterizeBandStrip, sampleAlong, scanAreaStrip, segmentCount, } from './raster.js';
7
+ const OVERLAY_MAX_WIDTH = 1_536;
8
+ const OVERLAY_MAX_HEIGHT = 1_024;
9
+ const LABEL_LIMIT = 320;
10
+ const colors = {
11
+ openWater: [20, 36, 60],
12
+ seaShelf: [52, 96, 134],
13
+ seaOpen: [36, 68, 108],
14
+ seaAbyss: [22, 44, 80],
15
+ land: [138, 150, 100],
16
+ lake: [90, 150, 205],
17
+ river: [96, 170, 235],
18
+ range: [120, 92, 64],
19
+ rangeBarrier: [200, 60, 50],
20
+ plateau: [220, 210, 170],
21
+ volcano: [235, 90, 40],
22
+ strait: [220, 90, 200],
23
+ pass: [255, 224, 90],
24
+ site: [250, 250, 250],
25
+ road: [200, 190, 160],
26
+ place: [250, 210, 120],
27
+ region: [255, 255, 255],
28
+ error: [255, 40, 40],
29
+ warning: [255, 165, 0],
30
+ edge: [0, 0, 0],
31
+ };
32
+ const biomeColors = {
33
+ forest: [40, 110, 56],
34
+ desert: [214, 188, 112],
35
+ marsh: [88, 140, 130],
36
+ ice: [236, 242, 248],
37
+ plains: [168, 190, 104],
38
+ farmland: [196, 200, 90],
39
+ jungle: [26, 92, 52],
40
+ steppe: [190, 176, 110],
41
+ tundra: [170, 184, 176],
42
+ };
43
+ // 5 by 7 glyphs, one 5-bit row per entry, so labels never depend on installed fonts.
44
+ const glyphs = {
45
+ '0': [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e],
46
+ '1': [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e],
47
+ '2': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f],
48
+ '3': [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e],
49
+ '4': [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02],
50
+ '5': [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e],
51
+ '6': [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e],
52
+ '7': [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
53
+ '8': [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e],
54
+ '9': [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c],
55
+ A: [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11],
56
+ B: [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e],
57
+ C: [0x0e, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0e],
58
+ D: [0x1c, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1c],
59
+ E: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f],
60
+ F: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10],
61
+ G: [0x0e, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0f],
62
+ H: [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11],
63
+ I: [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e],
64
+ J: [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c],
65
+ K: [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
66
+ L: [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f],
67
+ M: [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11],
68
+ N: [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11],
69
+ O: [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e],
70
+ P: [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10],
71
+ Q: [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d],
72
+ R: [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11],
73
+ S: [0x0f, 0x10, 0x10, 0x0e, 0x01, 0x01, 0x1e],
74
+ T: [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
75
+ U: [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e],
76
+ V: [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04],
77
+ W: [0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0a],
78
+ X: [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11],
79
+ Y: [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04],
80
+ Z: [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f],
81
+ '-': [0, 0, 0, 0x1f, 0, 0, 0],
82
+ _: [0, 0, 0, 0, 0, 0, 0x1f],
83
+ '.': [0, 0, 0, 0, 0, 0x0c, 0x0c],
84
+ };
85
+ class Canvas {
86
+ width;
87
+ height;
88
+ data;
89
+ constructor(width, height, background) {
90
+ this.width = width;
91
+ this.height = height;
92
+ this.data = new Uint8Array(width * height * 3);
93
+ for (let index = 0; index < width * height; index++)
94
+ this.data.set(background, index * 3);
95
+ }
96
+ blend(x, y, color, alpha) {
97
+ if (x < 0 || y < 0 || x >= this.width || y >= this.height)
98
+ return;
99
+ const base = (y * this.width + x) * 3;
100
+ for (let channel = 0; channel < 3; channel++)
101
+ this.data[base + channel] = Math.floor((color[channel] * alpha + this.data[base + channel] * (255 - alpha) + 127) / 255);
102
+ }
103
+ fillArea(area, color, alpha = 255) {
104
+ const grid = { width: this.width, height: this.height };
105
+ scanAreaStrip(area, grid, { y0: 0, y1: this.height }, (y, x0, x1) => {
106
+ for (let x = x0; x < x1; x++)
107
+ this.blend(x, y, color, alpha);
108
+ });
109
+ }
110
+ outlineArea(area, color, thickness = 1) {
111
+ for (const ring of [...area.outers, ...area.holes])
112
+ for (let index = 0; index < ring.xs.length; index++) {
113
+ const next = (index + 1) % ring.xs.length;
114
+ this.line(ring.xs[index] * this.width, ring.ys[index] * this.height, ring.xs[next] * this.width, ring.ys[next] * this.height, color, thickness);
115
+ }
116
+ }
117
+ line(x0, y0, x1, y1, color, thickness = 1) {
118
+ const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))));
119
+ const half = Math.floor(thickness / 2);
120
+ for (let step = 0; step <= steps; step++) {
121
+ const x = Math.floor(x0 + ((x1 - x0) * step) / steps);
122
+ const y = Math.floor(y0 + ((y1 - y0) * step) / steps);
123
+ for (let dy = -half; dy < thickness - half; dy++)
124
+ for (let dx = -half; dx < thickness - half; dx++)
125
+ this.blend(x + dx, y + dy, color, 255);
126
+ }
127
+ }
128
+ disc(cx, cy, radius, color, alpha = 255) {
129
+ const r = Math.ceil(radius);
130
+ for (let dy = -r; dy <= r; dy++)
131
+ for (let dx = -r; dx <= r; dx++)
132
+ if (dx * dx + dy * dy <= radius * radius)
133
+ this.blend(Math.floor(cx) + dx, Math.floor(cy) + dy, color, alpha);
134
+ }
135
+ /** An opaque two pixel ring, readable over any fill. */
136
+ ring(cx, cy, radius, color) {
137
+ const outer = radius * radius, inner = (radius - 2) * (radius - 2);
138
+ const r = Math.ceil(radius);
139
+ for (let dy = -r; dy <= r; dy++)
140
+ for (let dx = -r; dx <= r; dx++) {
141
+ const distance = dx * dx + dy * dy;
142
+ if (distance <= outer && distance >= inner)
143
+ this.blend(Math.floor(cx) + dx, Math.floor(cy) + dy, color, 255);
144
+ }
145
+ }
146
+ square(cx, cy, half, color) {
147
+ for (let dy = -half; dy <= half; dy++)
148
+ for (let dx = -half; dx <= half; dx++)
149
+ this.blend(Math.floor(cx) + dx, Math.floor(cy) + dy, color, 255);
150
+ }
151
+ /** Draw text; returns its pixel box. */
152
+ text(text, cx, cy, scale) {
153
+ const label = text.toUpperCase().slice(0, 24);
154
+ const width = label.length * 6 * scale - scale;
155
+ const height = 7 * scale;
156
+ const x0 = Math.max(0, Math.min(this.width - width, Math.floor(cx - width / 2)));
157
+ const y0 = Math.max(0, Math.min(this.height - height, Math.floor(cy - height / 2)));
158
+ for (const [pass, color] of [
159
+ [1, [0, 0, 0]],
160
+ [0, [255, 255, 255]],
161
+ ])
162
+ for (let index = 0; index < label.length; index++) {
163
+ const rows = glyphs[label.charAt(index)];
164
+ if (rows === undefined)
165
+ continue;
166
+ for (let row = 0; row < 7; row++)
167
+ for (let column = 0; column < 5; column++)
168
+ if ((rows[row] >> (4 - column)) & 1)
169
+ for (let sy = 0; sy < scale; sy++)
170
+ for (let sx = 0; sx < scale; sx++)
171
+ this.blend(x0 + (index * 6 + column) * scale + sx + pass * scale, y0 + row * scale + sy + pass * scale, color, 255);
172
+ }
173
+ return { x0, y0, x1: x0 + width + scale, y1: y0 + height + scale };
174
+ }
175
+ }
176
+ function seaColor(depth) {
177
+ return depth === 'shelf' ? colors.seaShelf : depth === 'abyss' ? colors.seaAbyss : colors.seaOpen;
178
+ }
179
+ function round6(value) {
180
+ return Math.round(value * 1e6) / 1e6;
181
+ }
182
+ const legend = [
183
+ {
184
+ layer: 'open-water',
185
+ color: [...colors.openWater],
186
+ meaning: 'Water that no sea, lake or land feature claims',
187
+ },
188
+ { layer: 'sea', color: [...colors.seaOpen], meaning: 'Sea; shelf is lighter and abyss darker' },
189
+ { layer: 'land', color: [...colors.land], meaning: 'Land with no biome polygon on top' },
190
+ ...Object.entries(biomeColors).map(([name, color]) => ({
191
+ layer: `biome:${name}`,
192
+ color: [...color],
193
+ meaning: `Biome ${name}, drawn in ascending priority z`,
194
+ })),
195
+ { layer: 'plateau', color: [...colors.plateau], meaning: 'Plateau (light tint)' },
196
+ { layer: 'lake', color: [...colors.lake], meaning: 'Lake' },
197
+ {
198
+ layer: 'river',
199
+ color: [...colors.river],
200
+ meaning: 'River through source, via points and mouth; width follows size',
201
+ },
202
+ {
203
+ layer: 'range',
204
+ color: [...colors.range],
205
+ meaning: 'Mountain range footprint (half-width) with its crest line',
206
+ },
207
+ {
208
+ layer: 'range-barrier',
209
+ color: [...colors.rangeBarrier],
210
+ meaning: 'Crest line of a range with an impassable or fort-line barrier',
211
+ },
212
+ { layer: 'pass', color: [...colors.pass], meaning: 'Pass position on its range' },
213
+ { layer: 'volcano', color: [...colors.volcano], meaning: 'Volcano cone with crater' },
214
+ { layer: 'strait', color: [...colors.strait], meaning: 'Strait between its two ends' },
215
+ { layer: 'site', color: [...colors.site], meaning: 'Site; a larger square is a capital' },
216
+ { layer: 'road', color: [...colors.road], meaning: 'Road (dashed line)' },
217
+ {
218
+ layer: 'place',
219
+ color: [...colors.place],
220
+ meaning: 'Named place: cape, bay, gulf, reef or landmark (ring)',
221
+ },
222
+ {
223
+ layer: 'edge-band',
224
+ color: [...colors.edge],
225
+ meaning: 'Darkened default edge band where land is cut by the world wrap',
226
+ },
227
+ {
228
+ layer: 'error',
229
+ color: [...colors.error],
230
+ meaning: 'Outline of a feature with an error diagnostic',
231
+ },
232
+ {
233
+ layer: 'warning',
234
+ color: [...colors.warning],
235
+ meaning: 'Outline of a feature with a warning diagnostic',
236
+ },
237
+ ];
238
+ /** Draw the resolved model offline: shapes, ids, and red or orange outlines for defects. */
239
+ export function renderGeographyOverlay(geography, diagnostics, edgeBand) {
240
+ const scale = Math.min(1, OVERLAY_MAX_WIDTH / geography.frame.width, OVERLAY_MAX_HEIGHT / geography.frame.height);
241
+ const width = Math.max(64, Math.round(geography.frame.width * scale));
242
+ const height = Math.max(32, Math.round(geography.frame.height * scale));
243
+ const canvas = new Canvas(width, height, colors.openWater);
244
+ const grid = { width, height };
245
+ for (const sea of geography.seas) {
246
+ canvas.fillArea(sea.area, seaColor(sea.depthClass));
247
+ if (sea.ice > 0)
248
+ canvas.fillArea(sea.area, biomeColors.ice, Math.round(sea.ice * 160));
249
+ }
250
+ for (const land of geography.lands)
251
+ canvas.fillArea(land.area, colors.land);
252
+ for (const biome of geography.biomes)
253
+ canvas.fillArea(biome.area, biomeColors[biome.biome] ?? colors.land, 200);
254
+ for (const plateau of geography.plateaus)
255
+ canvas.fillArea(plateau.area, colors.plateau, 80);
256
+ for (const lake of geography.lakes)
257
+ canvas.fillArea(lake.area, colors.lake);
258
+ for (const region of geography.regions)
259
+ if (region.area !== undefined)
260
+ canvas.outlineArea(region.area, colors.region, 1);
261
+ const strip = { y0: 0, y1: height };
262
+ const band = createBandStrip(grid, strip);
263
+ for (const range of geography.ranges) {
264
+ const half = Math.max(...range.path.halfWidth) * width;
265
+ rasterizeBandStrip(range.path, grid, strip, half, band);
266
+ const b = range.path.bounds;
267
+ const x0 = Math.max(0, Math.floor(b.x0 * width - half - 1));
268
+ const x1 = Math.min(width - 1, Math.ceil(b.x1 * width + half + 1));
269
+ const y0 = Math.max(0, Math.floor(b.y0 * height - half - 1));
270
+ const y1 = Math.min(height - 1, Math.ceil(b.y1 * height + half + 1));
271
+ for (let y = y0; y <= y1; y++)
272
+ for (let x = x0; x <= x1; x++) {
273
+ const cell = y * width + x;
274
+ const distance = band.distance[cell];
275
+ if (distance > half)
276
+ continue;
277
+ const local = sampleAlong(range.path, grid, band.t[cell]).halfWidth * width;
278
+ if (distance <= local)
279
+ canvas.blend(x, y, colors.range, 150);
280
+ band.distance[cell] = Infinity;
281
+ }
282
+ const centre = range.barrier === 'none' ? [70, 52, 36] : colors.rangeBarrier;
283
+ const segments = segmentCount(range.path);
284
+ for (let segment = 0; segment < segments; segment++) {
285
+ const next = (segment + 1) % range.path.xs.length;
286
+ canvas.line(range.path.xs[segment] * width, range.path.ys[segment] * height, range.path.xs[next] * width, range.path.ys[next] * height, centre, 1);
287
+ }
288
+ }
289
+ for (const road of geography.roads)
290
+ for (let index = 0; index + 1 < road.path.xs.length; index++)
291
+ if (index % 2 === 0)
292
+ canvas.line(road.path.xs[index] * width, road.path.ys[index] * height, road.path.xs[index + 1] * width, road.path.ys[index + 1] * height, colors.road, 1);
293
+ for (const river of geography.rivers) {
294
+ const anchors = [];
295
+ if ('x' in river.source)
296
+ anchors.push({ x: river.source.x, y: river.source.y });
297
+ anchors.push(...river.via);
298
+ if ('x' in river.mouth)
299
+ anchors.push({ x: river.mouth.x, y: river.mouth.y });
300
+ for (let index = 1; index < anchors.length; index++)
301
+ canvas.line(anchors[index - 1].x * width, anchors[index - 1].y * height, anchors[index].x * width, anchors[index].y * height, colors.river, river.size >= 4 ? 3 : river.size >= 2 ? 2 : 1);
302
+ }
303
+ for (const strait of geography.straits) {
304
+ if (strait.path !== undefined) {
305
+ for (let index = 1; index < strait.path.length; index++)
306
+ canvas.line(strait.path[index - 1].x * width, strait.path[index - 1].y * height, strait.path[index].x * width, strait.path[index].y * height, colors.strait, 2);
307
+ continue;
308
+ }
309
+ const [a, b] = strait.between.map((end) => {
310
+ if (end.type === 'point')
311
+ return { x: end.x, y: end.y };
312
+ const land = geography.get('land', end.id);
313
+ return land === undefined ? undefined : land.area.interior;
314
+ });
315
+ if (a !== undefined && b !== undefined)
316
+ canvas.line(a.x * width, a.y * height, b.x * width, b.y * height, colors.strait, 1);
317
+ }
318
+ for (const pass of geography.passes)
319
+ canvas.disc(pass.position.x * width, pass.position.y * height, 3, colors.pass);
320
+ for (const volcano of geography.volcanoes) {
321
+ canvas.disc(volcano.at.x * width, volcano.at.y * height, Math.max(3, volcano.radius * width), colors.volcano, 140);
322
+ canvas.disc(volcano.at.x * width, volcano.at.y * height, Math.max(1, volcano.crater * width), [60, 20, 10], 255);
323
+ }
324
+ for (const site of geography.sites)
325
+ canvas.square(site.at.x * width, site.at.y * height, site.siteKind === 'capital' ? 3 : 2, colors.site);
326
+ for (const place of geography.places)
327
+ canvas.ring(place.at.x * width, place.at.y * height, 3, colors.place);
328
+ // Edge band shading.
329
+ const bandX = Math.round(edgeBand.westEastPx * scale);
330
+ const bandY = Math.round(edgeBand.northSouthPx * scale);
331
+ for (let y = 0; y < height; y++)
332
+ for (let x = 0; x < width; x++)
333
+ if (x < bandX || x >= width - bandX || y < bandY || y >= height - bandY)
334
+ canvas.blend(x, y, colors.edge, 80);
335
+ // Defect outlines.
336
+ const worst = new Map();
337
+ for (const diagnostic of diagnostics) {
338
+ const featureId = diagnostic.details?.featureId;
339
+ if (typeof featureId !== 'string')
340
+ continue;
341
+ if (diagnostic.severity === 'error' || diagnostic.severity === 'blocker')
342
+ worst.set(featureId, 'error');
343
+ else if (diagnostic.severity === 'warning' && !worst.has(featureId))
344
+ worst.set(featureId, 'warning');
345
+ }
346
+ for (const [id, severity] of [...worst].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
347
+ const feature = geography.find(id);
348
+ if (feature === undefined)
349
+ continue;
350
+ const color = severity === 'error' ? colors.error : colors.warning;
351
+ const area = areaOf(feature);
352
+ if (area !== undefined)
353
+ canvas.outlineArea(area, color, 2);
354
+ else {
355
+ const anchor = anchorOf(feature);
356
+ if (anchor !== undefined)
357
+ canvas.ring(anchor.x * width, anchor.y * height, 7, color);
358
+ }
359
+ }
360
+ // Labels: larger features first, no overlaps, bounded count.
361
+ const candidates = labelCandidates(geography);
362
+ // Large text while the picture is sparse, small text once it would drown the map.
363
+ const textScale = width >= 1_000 && candidates.length <= 80 ? 2 : 1;
364
+ const placed = [];
365
+ let labels = 0;
366
+ for (const candidate of candidates) {
367
+ if (labels >= LABEL_LIMIT)
368
+ break;
369
+ const length = Math.min(24, candidate.id.length);
370
+ const boxWidth = length * 6 * textScale;
371
+ const cx = Math.max(boxWidth / 2, Math.min(width - boxWidth / 2, candidate.x * width));
372
+ const cy = Math.max(7 * textScale, Math.min(height - 7 * textScale, candidate.y * height));
373
+ const box = {
374
+ x0: cx - boxWidth / 2 - 1,
375
+ y0: cy - 4 * textScale,
376
+ x1: cx + boxWidth / 2 + 1,
377
+ y1: cy + 4 * textScale,
378
+ };
379
+ if (placed.some((other) => box.x0 < other.x1 && other.x0 < box.x1 && box.y0 < other.y1 && other.y0 < box.y1))
380
+ continue;
381
+ placed.push(canvas.text(candidate.id, cx, cy, textScale));
382
+ labels++;
383
+ }
384
+ return { rgb: canvas.data, width, height, labels, legend };
385
+ }
386
+ function areaOf(feature) {
387
+ return 'area' in feature ? feature.area : undefined;
388
+ }
389
+ function anchorOf(feature) {
390
+ switch (feature.kind) {
391
+ case 'range': {
392
+ const middle = Math.floor(feature.path.xs.length / 2);
393
+ return { x: feature.path.xs[middle], y: feature.path.ys[middle] };
394
+ }
395
+ case 'river': {
396
+ const ends = [feature.source, ...feature.via, feature.mouth].filter((end) => 'x' in end);
397
+ return ends[Math.floor(ends.length / 2)];
398
+ }
399
+ case 'volcano':
400
+ case 'site':
401
+ case 'place':
402
+ return feature.at;
403
+ case 'road': {
404
+ const middle = Math.floor(feature.path.xs.length / 2);
405
+ return { x: feature.path.xs[middle], y: feature.path.ys[middle] };
406
+ }
407
+ case 'pass':
408
+ return feature.position;
409
+ case 'strait': {
410
+ const first = feature.between[0];
411
+ return first.type === 'point' ? { x: first.x, y: first.y } : undefined;
412
+ }
413
+ default:
414
+ return undefined;
415
+ }
416
+ }
417
+ function labelCandidates(geography) {
418
+ const list = [];
419
+ const add = (feature, priority, size, at) => {
420
+ const point = at ?? areaOf(feature)?.interior ?? anchorOf(feature);
421
+ if (point !== undefined)
422
+ list.push({ id: feature.id, x: point.x, y: point.y, priority, size });
423
+ };
424
+ for (const land of geography.lands)
425
+ add(land, 0, land.area.areaFraction);
426
+ for (const sea of geography.seas)
427
+ add(sea, 1, sea.area.areaFraction);
428
+ for (const range of geography.ranges)
429
+ add(range, 2, range.path.length);
430
+ for (const lake of geography.lakes)
431
+ add(lake, 3, lake.area.areaFraction);
432
+ for (const river of geography.rivers)
433
+ add(river, 4, river.anchorLength);
434
+ for (const volcano of geography.volcanoes)
435
+ add(volcano, 5, volcano.radius);
436
+ for (const site of geography.sites)
437
+ add(site, 6, site.importance);
438
+ for (const place of geography.places)
439
+ add(place, 7, 0);
440
+ return list.sort((a, b) => a.priority - b.priority || b.size - a.size || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
441
+ }
442
+ function endLabel(end) {
443
+ return end.type === 'point' ? 'point' : { [end.type]: end.id ?? null };
444
+ }
445
+ function featureReport(geography, feature) {
446
+ const frame = geography.frame;
447
+ const base = {
448
+ id: feature.id,
449
+ kind: feature.kind,
450
+ ...(feature.name === undefined ? {} : { name: feature.name }),
451
+ };
452
+ const area = areaOf(feature);
453
+ if (area !== undefined) {
454
+ base.areaFraction = round6(area.areaFraction);
455
+ base.areaPixels = Math.round(areaPixels(area, frame));
456
+ base.vertices = [...area.outers, ...area.holes].reduce((sum, ring) => sum + ring.xs.length, 0);
457
+ base.polygons = area.outers.length;
458
+ base.holes = area.holes.length;
459
+ base.bounds = {
460
+ x0: round6(area.bounds.x0),
461
+ y0: round6(area.bounds.y0),
462
+ x1: round6(area.bounds.x1),
463
+ y1: round6(area.bounds.y1),
464
+ };
465
+ base.interior = { x: round6(area.interior.x), y: round6(area.interior.y) };
466
+ }
467
+ switch (feature.kind) {
468
+ case 'sea':
469
+ base.depthClass = feature.depthClass;
470
+ break;
471
+ case 'land':
472
+ if (feature.regionId !== undefined) {
473
+ base.region = feature.regionId;
474
+ base.regionSource = feature.regionSource;
475
+ }
476
+ break;
477
+ case 'region':
478
+ base.landIds = feature.landIds;
479
+ if (feature.terrain !== undefined)
480
+ base.terrain = feature.terrain;
481
+ if (feature.provincesPerState !== undefined)
482
+ base.provincesPerState = feature.provincesPerState;
483
+ if (feature.provinceAreaPx !== undefined)
484
+ base.provinceAreaPx = feature.provinceAreaPx;
485
+ break;
486
+ case 'range':
487
+ base.vertices = feature.path.xs.length;
488
+ base.lengthPixels = Math.round(feature.path.length * frame.width);
489
+ base.profile = feature.profile;
490
+ base.barrier = feature.barrier;
491
+ base.passes = feature.passIds;
492
+ break;
493
+ case 'pass':
494
+ base.range = feature.rangeId;
495
+ base.at = round6(feature.at);
496
+ base.positionSource = feature.positionSource;
497
+ base.position = { x: round6(feature.position.x), y: round6(feature.position.y) };
498
+ break;
499
+ case 'volcano':
500
+ base.at = { x: round6(feature.at.x), y: round6(feature.at.y) };
501
+ base.radiusPixels = Math.round(feature.radius * frame.width);
502
+ if (feature.landId !== undefined)
503
+ base.land = feature.landId;
504
+ break;
505
+ case 'river':
506
+ base.size = feature.size;
507
+ base.source = endLabel(feature.source);
508
+ base.mouth = endLabel(feature.mouth);
509
+ base.tributaries = feature.tributaryIds;
510
+ base.anchorLengthPixels = Math.round(feature.anchorLength * frame.width);
511
+ break;
512
+ case 'lake':
513
+ base.gameplay = feature.gameplay;
514
+ if (feature.landId !== undefined)
515
+ base.land = feature.landId;
516
+ if (feature.outflowId !== undefined)
517
+ base.outflow = feature.outflowId;
518
+ break;
519
+ case 'biome':
520
+ base.biome = feature.biome;
521
+ base.z = feature.z;
522
+ break;
523
+ case 'strait':
524
+ base.ends = feature.ends;
525
+ if (feature.path !== undefined)
526
+ base.pathVertices = feature.path.length;
527
+ base.between = feature.between.map((end) => end.type === 'land'
528
+ ? { land: end.id }
529
+ : {
530
+ x: round6(end.x),
531
+ y: round6(end.y),
532
+ ...(end.landId === undefined ? {} : { land: end.landId }),
533
+ });
534
+ break;
535
+ case 'site':
536
+ base.siteKind = feature.siteKind;
537
+ if (feature.onWater)
538
+ base.onWater = true;
539
+ if (feature.landId !== undefined)
540
+ base.land = feature.landId;
541
+ if (feature.regionId !== undefined)
542
+ base.region = feature.regionId;
543
+ if (feature.lakeId !== undefined)
544
+ base.lake = feature.lakeId;
545
+ break;
546
+ case 'road':
547
+ base.vertices = feature.path.xs.length;
548
+ base.lengthPixels = Math.round(feature.path.length * frame.width);
549
+ base.importance = feature.importance;
550
+ break;
551
+ case 'place':
552
+ base.placeKind = feature.placeKind;
553
+ base.surface = feature.surface;
554
+ base.at = { x: round6(feature.at.x), y: round6(feature.at.y) };
555
+ break;
556
+ default:
557
+ break;
558
+ }
559
+ return base;
560
+ }
561
+ /** The content of `map-geography-report.json`: resolved ids, areas, references and defects. */
562
+ export function buildGeographyReport(prepared) {
563
+ const geography = prepared.geography;
564
+ const counts = {};
565
+ for (const kind of GEOGRAPHY_FEATURE_KINDS)
566
+ counts[kind] = geography.list(kind).length;
567
+ const severities = { error: 0, warning: 0, info: 0 };
568
+ for (const diagnostic of prepared.diagnostics)
569
+ if (diagnostic.severity === 'error' || diagnostic.severity === 'blocker')
570
+ severities.error++;
571
+ else
572
+ severities[diagnostic.severity]++;
573
+ return {
574
+ schemaVersion: 1,
575
+ offlineRepresentation: true,
576
+ operationId: prepared.operationId,
577
+ geographyVersion: 1,
578
+ modelHash: geography.hash,
579
+ valid: prepared.valid,
580
+ source: prepared.source.kind === 'file'
581
+ ? {
582
+ kind: 'file',
583
+ path: prepared.source.evidence.displayPath,
584
+ bytes: prepared.source.bytes,
585
+ sha256: prepared.source.sha256,
586
+ }
587
+ : { kind: 'inline', bytes: prepared.source.bytes, sha256: prepared.source.sha256 },
588
+ frame: prepared.frame,
589
+ edgeBand: prepared.edgeBand,
590
+ hasReferenceImage: prepared.hasReference,
591
+ synthesis: 'not applied: this report validates the model; a world request without preview synthesises relief, rivers and lakes from it, and the climate and biome stage is not implemented yet',
592
+ totals: { features: geography.featureCount, points: geography.pointCount },
593
+ counts,
594
+ climate: geography.climate,
595
+ diagnosticCounts: severities,
596
+ diagnostics: prepared.diagnostics.map(({ code, severity, message, details }) => ({
597
+ code,
598
+ severity,
599
+ message,
600
+ ...(details === undefined ? {} : { details }),
601
+ })),
602
+ features: geography.features().map((feature) => featureReport(geography, feature)),
603
+ overlayLegend: legend,
604
+ };
605
+ }
606
+ /** The overlay PNG and the JSON report, content-addressed by the artifact store. */
607
+ export async function buildGeographyArtifacts(input) {
608
+ input.signal?.throwIfAborted();
609
+ const { prepared } = input;
610
+ const overlay = renderGeographyOverlay(prepared.geography, prepared.diagnostics, prepared.edgeBand);
611
+ (input.budget ?? new RenderBudget()).reserve(overlay.width, overlay.height, 'geography overlay');
612
+ const png = await sharp(overlay.rgb, {
613
+ raw: { width: overlay.width, height: overlay.height, channels: 3 },
614
+ })
615
+ .png()
616
+ .toBuffer();
617
+ input.signal?.throwIfAborted();
618
+ const suffix = input.ordinal === 0 ? '' : `-${input.ordinal + 1}`;
619
+ const provenance = {
620
+ kind: 'map-geography',
621
+ toolVersion: PACKAGE_VERSION,
622
+ schemaVersion: 'map-geography.v1',
623
+ sourceHashes: input.sourceHashes,
624
+ renderProfile: { offline: true, width: overlay.width, height: overlay.height },
625
+ metadata: {
626
+ operationId: prepared.operationId,
627
+ modelHash: prepared.geography.hash,
628
+ binding: 'offline drawing of the geography model; not a game screenshot and not a synthesised map',
629
+ },
630
+ };
631
+ return [
632
+ {
633
+ name: `map-geography-overlay${suffix}.png`,
634
+ mimeType: 'image/png',
635
+ content: png,
636
+ provenance,
637
+ description: 'Offline overlay of the geography model with feature ids; defects outlined in red and orange',
638
+ },
639
+ {
640
+ name: `map-geography-report${suffix}.json`,
641
+ mimeType: 'application/json',
642
+ content: `${canonicalJson(buildGeographyReport(prepared))}\n`,
643
+ provenance,
644
+ description: 'Resolved ids, areas, references and diagnostics of the geography model',
645
+ },
646
+ ];
647
+ }