hoi4-agent-tools 4.2.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 (124) hide show
  1. package/CHANGELOG.md +30 -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/domain-validation.js +6 -1
  5. package/dist/hoi4_agent_tools/core/engine.js +3 -0
  6. package/dist/hoi4_agent_tools/core/game-documentation.js +24 -0
  7. package/dist/hoi4_agent_tools/core/operation-context.js +17 -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/decision/service.js +1 -22
  14. package/dist/hoi4_agent_tools/event/graph.js +8 -1
  15. package/dist/hoi4_agent_tools/event/service.js +8 -0
  16. package/dist/hoi4_agent_tools/event/source-analysis.js +10 -6
  17. package/dist/hoi4_agent_tools/focus/importer.js +8 -6
  18. package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
  19. package/dist/hoi4_agent_tools/focus/tool-support.js +10 -1
  20. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  21. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  22. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  23. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  24. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  25. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  26. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  27. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  28. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  29. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  30. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  31. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  32. package/dist/hoi4_agent_tools/map/model.js +15 -2
  33. package/dist/hoi4_agent_tools/map/operations.js +111 -8
  34. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  35. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  36. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  37. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  38. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  39. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  40. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  41. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  42. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  43. package/dist/hoi4_agent_tools/map/reference-preparation.js +138 -5
  44. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  45. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  46. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  47. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  48. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  49. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  50. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  51. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  52. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  53. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  54. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  55. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  56. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  57. package/dist/hoi4_agent_tools/map/render.js +36 -0
  58. package/dist/hoi4_agent_tools/map/service.js +191 -16
  59. package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
  60. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  61. package/dist/hoi4_agent_tools/map/water.js +227 -0
  62. package/dist/hoi4_agent_tools/map/world-context.js +24 -1
  63. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  64. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  65. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  66. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  67. package/dist/hoi4_agent_tools/map/world.js +462 -124
  68. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  69. package/dist/hoi4_agent_tools/probability/service.js +18 -18
  70. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  71. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  72. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  73. package/dist/hoi4_agent_tools/schemas/map.js +209 -2
  74. package/dist/hoi4_agent_tools/schemas/probability.js +7 -1
  75. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  76. package/dist/hoi4_agent_tools/version.js +1 -1
  77. package/docs/analysis.md +1 -1
  78. package/docs/configuration.md +17 -14
  79. package/docs/map.md +258 -15
  80. package/docs/probability.md +1 -1
  81. package/docs/tools.md +4 -4
  82. package/docs/troubleshooting.md +11 -11
  83. package/npm-shrinkwrap.json +109 -91
  84. package/package.json +3 -3
  85. package/schemas/configuration.schema.json +65 -58
  86. package/schemas/continuous-focus-palette.schema.json +1 -1
  87. package/schemas/custom-weighted-pool.schema.json +1 -1
  88. package/schemas/decision-inspect-input.schema.json +1 -1
  89. package/schemas/error-log-input.schema.json +1 -1
  90. package/schemas/event-feature-manifest.schema.json +1 -1
  91. package/schemas/focus-plan.schema.json +1 -1
  92. package/schemas/focus-planning-sidecar.schema.json +1 -1
  93. package/schemas/geography-model.schema.json +1148 -0
  94. package/schemas/gui-animation-source.schema.json +1 -1
  95. package/schemas/gui-helper.schema.json +5 -11
  96. package/schemas/gui-scenario.schema.json +1 -1
  97. package/schemas/helper-expansion-request.schema.json +1 -1
  98. package/schemas/helper-expansion-summary.schema.json +1 -1
  99. package/schemas/impact-inspect-input.schema.json +1 -1
  100. package/schemas/map-operation.schema.json +2156 -259
  101. package/schemas/mechanic-test-input.schema.json +1 -1
  102. package/schemas/mod-index-input.schema.json +1 -1
  103. package/schemas/operation-result.schema.json +1 -1
  104. package/schemas/package-check-input.schema.json +1 -1
  105. package/schemas/package-manifest.schema.json +1 -1
  106. package/schemas/probability-analysis-result.schema.json +31 -7
  107. package/schemas/probability-compare-input.schema.json +1 -1
  108. package/schemas/probability-evaluate-input.schema.json +1 -1
  109. package/schemas/probability-inspect-input.schema.json +1 -1
  110. package/schemas/probability-render-input.schema.json +1 -1
  111. package/schemas/probability-scenario-set.schema.json +1 -1
  112. package/schemas/probability-sequence-input.schema.json +1 -1
  113. package/schemas/probability-simulate-input.schema.json +1 -1
  114. package/schemas/probability-sweep-input.schema.json +1 -1
  115. package/schemas/reference-context-input.schema.json +1 -1
  116. package/schemas/reference-read-input.schema.json +1 -1
  117. package/schemas/reference-search-input.schema.json +1 -1
  118. package/schemas/reference-sheets.schema.json +267 -0
  119. package/schemas/scenario-suite.schema.json +1 -1
  120. package/schemas/scenario-test-input.schema.json +1 -1
  121. package/schemas/script-validate-input.schema.json +1 -1
  122. package/schemas/shared-scenario.schema.json +1 -1
  123. package/schemas/source-lookup-input.schema.json +1 -1
  124. package/server.json +2 -2
@@ -9,9 +9,19 @@ import { ServiceError } from '../core/result.js';
9
9
  import { createBmp } from './bmp.js';
10
10
  import { generateWorldDrainage } from './world-drainage.js';
11
11
  import { generateWorldProvinces } from './world-provinces.js';
12
+ import { clusterRegions, clusterStates, partitionWorldFromRequest, } from './partition.js';
13
+ import { repairSeamCorners, smoothDiagonalContacts } from './partition-fields.js';
14
+ import { PARTITION_PROVINCE_ERROR, PARTITION_PROVINCE_WARNING } from './partition-tuning.js';
15
+ import { geographyPartitionFields, provinceCountNote, surfacePartitionFields, } from './world-partition.js';
16
+ import { fractal, lattice } from './world-noise.js';
17
+ import { calibratedColourMap, COLOUR_CLASSES, } from './colormap.js';
18
+ import { ambientObjectText, applyEdgePolicy, assertFeaturesOutsideBand, edgeWaterDarkness, RIM_COLOUR_FACTOR, rimAdjacencyRows, rimHeight, rimStrength, } from './world-edge.js';
19
+ import { landDistanceField, lakeHeight, normalizeLakes, waterColourLevel } from './water.js';
20
+ import { overlayGeographyLakes, rasterizeGeographyKind } from './relief/land.js';
21
+ import { synthesizeTerrain } from './relief/terrain-synthesis.js';
12
22
  // ---------------------------------------------------------------------------------------------
13
23
  // Deterministic randomness and noise
14
- class Random {
24
+ export class Random {
15
25
  #state;
16
26
  constructor(seed) {
17
27
  this.#state = (seed ^ 0x9e3779b9) >>> 0;
@@ -27,38 +37,6 @@ class Random {
27
37
  return Math.floor(this.next() * limit);
28
38
  }
29
39
  }
30
- function lattice(seed, x, y) {
31
- let hash = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 982451653);
32
- hash = Math.imul(hash ^ (hash >>> 13), 1274126177);
33
- return ((hash ^ (hash >>> 16)) >>> 0) / 4294967296;
34
- }
35
- function smooth(t) {
36
- return t * t * (3 - 2 * t);
37
- }
38
- function valueNoise(seed, x, y) {
39
- const x0 = Math.floor(x);
40
- const y0 = Math.floor(y);
41
- const tx = smooth(x - x0);
42
- const ty = smooth(y - y0);
43
- const a = lattice(seed, x0, y0);
44
- const b = lattice(seed, x0 + 1, y0);
45
- const c = lattice(seed, x0, y0 + 1);
46
- const d = lattice(seed, x0 + 1, y0 + 1);
47
- return a + (b - a) * tx + (c - a) * ty + (a - b - c + d) * tx * ty;
48
- }
49
- function fractal(seed, x, y, scale, octaves = 5) {
50
- let total = 0;
51
- let amplitude = 1;
52
- let weight = 0;
53
- let frequency = 1 / scale;
54
- for (let octave = 0; octave < octaves; octave += 1) {
55
- total += valueNoise(seed + octave * 101, x * frequency, y * frequency) * amplitude;
56
- weight += amplitude;
57
- amplitude *= 0.5;
58
- frequency *= 2;
59
- }
60
- return total / weight;
61
- }
62
40
  // ---------------------------------------------------------------------------------------------
63
41
  // Raster helpers
64
42
  const LAND = 1;
@@ -204,10 +182,99 @@ function generateSurface(spec, random) {
204
182
  }
205
183
  return { kind, elevation, moisture };
206
184
  }
185
+ /**
186
+ * Surface of a world described by a geography model: land and lakes from the model (or lakes laid
187
+ * over a reference picture's coast); elevation is filled in by the terrain synthesis later.
188
+ */
189
+ /** Province count the vanilla map holds; defaulted sizes grow towards it. */
190
+ const PROVINCE_COUNT_GOAL = 13_000;
191
+ /**
192
+ * Check the province count the sizes give before partitioning. Defaulted sizes above the warning
193
+ * grow until the world holds about the base game's count; sizes the caller chose that give more
194
+ * than the text tables allow are refused before any work.
195
+ */
196
+ function provinceBudget(spec, kind) {
197
+ let land = 0;
198
+ let water = 0;
199
+ for (const value of kind)
200
+ if (value === LAND)
201
+ land += 1;
202
+ else
203
+ water += 1;
204
+ const expected = land / spec.landProvincePixels + water / spec.seaProvincePixels;
205
+ const sizes = {
206
+ landProvincePixels: spec.landProvincePixels,
207
+ seaProvincePixels: spec.seaProvincePixels,
208
+ };
209
+ if (expected <= PARTITION_PROVINCE_WARNING)
210
+ return sizes;
211
+ if (spec.provinceSizesDefaulted === true) {
212
+ const factor = expected / PROVINCE_COUNT_GOAL;
213
+ const landProvincePixels = Math.ceil(spec.landProvincePixels * factor);
214
+ const seaProvincePixels = Math.ceil(spec.seaProvincePixels * factor);
215
+ return {
216
+ landProvincePixels,
217
+ seaProvincePixels,
218
+ note: {
219
+ code: 'MAP_WORLD_PROVINCE_SIZE_RAISED',
220
+ severity: 'info',
221
+ message: `The default province sizes would give about ${Math.round(expected)} provinces on this much land; they were raised to ${landProvincePixels} land and ${seaProvincePixels} sea pixels for about ${PROVINCE_COUNT_GOAL}. Set landProvincePixels and seaProvincePixels to choose them`,
222
+ details: { expectedProvinces: Math.round(expected), landProvincePixels, seaProvincePixels },
223
+ },
224
+ };
225
+ }
226
+ if (expected > PARTITION_PROVINCE_ERROR)
227
+ throw new ServiceError('MAP_PARTITION_PROVINCE_COUNT_HIGH', `landProvincePixels ${spec.landProvincePixels} and seaProvincePixels ${spec.seaProvincePixels} give about ${Math.round(expected)} provinces on this map, above the ${PARTITION_PROVINCE_ERROR} the text tables allow; raise them`, { expectedProvinces: Math.round(expected), maximum: PARTITION_PROVINCE_ERROR });
228
+ return sizes;
229
+ }
230
+ function geographySurface(spec, geography, reference) {
231
+ const { width, height } = spec;
232
+ const total = width * height;
233
+ const options = { width, height, seed: spec.seed };
234
+ let kind;
235
+ let moisture;
236
+ if (reference === undefined) {
237
+ kind = rasterizeGeographyKind(geography, options);
238
+ moisture = new Float32Array(total);
239
+ const scale = Math.max(width, height) / 6;
240
+ for (let y = 0; y < height; y += 1)
241
+ for (let x = 0; x < width; x += 1)
242
+ moisture[y * width + x] = fractal(spec.seed + 7919, x, y, scale * 0.8, 4);
243
+ }
244
+ else {
245
+ kind = new Uint8Array(reference.kind);
246
+ overlayGeographyLakes(kind, geography, options);
247
+ moisture = new Float32Array(reference.moisture);
248
+ }
249
+ return { kind, elevation: new Float32Array(total), moisture };
250
+ }
251
+ function terrainSummaryNote(terrain, geography) {
252
+ const { relief, riverStatistics } = terrain;
253
+ return {
254
+ code: 'MAP_RELIEF_SYNTHESIZED',
255
+ severity: 'info',
256
+ message: `Synthesised relief and rivers from the geography model: land height median ${relief.median}, 99th percentile ${relief.p99}, maximum ${relief.maximum}, mean step ${relief.meanStep.toFixed(2)} per half-resolution pixel; ${riverStatistics.count} rivers (${riverStatistics.named} named) on ${(riverStatistics.landShare * 100).toFixed(2)}% of the land`,
257
+ details: {
258
+ modelHash: geography.hash,
259
+ relief,
260
+ rivers: riverStatistics,
261
+ // The first of the named rivers only: the response budget is shared with everything else the
262
+ // world reports, and a river that did not complete is always listed.
263
+ namedRivers: terrain.rivers
264
+ .filter((river) => river.named && river.junction !== 'distributary')
265
+ .slice(0, 24)
266
+ .map(({ id, pixels, mouth, complete }) => ({ id, pixels, mouth, complete })),
267
+ incompleteRivers: terrain.rivers
268
+ .filter((river) => river.named && !river.complete)
269
+ .slice(0, 24)
270
+ .map(({ id }) => id),
271
+ },
272
+ };
273
+ }
207
274
  // ---------------------------------------------------------------------------------------------
208
275
  // Grouping provinces into regions and states
209
276
  /** Grow clusters of about `target` members over a graph from spread-out seeds. */
210
- function growClusters(members, neighbors, target, random, weight = () => 1) {
277
+ export function growClusters(members, neighbors, target, random, weight = () => 1) {
211
278
  const cluster = new Map();
212
279
  const memberSet = new Set(members);
213
280
  const pending = new Set(members);
@@ -324,10 +391,23 @@ const DEFAULT_TERRAIN_INDEX = new Map([
324
391
  ['mountain', 6],
325
392
  ['marsh', 9],
326
393
  ['urban', 13],
327
- ['lakes', 14],
394
+ ['lakes', 15],
328
395
  ['ocean', 15],
329
396
  ['jungle', 21],
330
397
  ]);
398
+ /**
399
+ * A graphical terrain index of the base game's numbering (farmland 5, sparse forest 4, mountain
400
+ * with grass 20), used only when the active terrain files confirm that index still has that
401
+ * category and draws a tile. Without any terrain files the base game's numbering is assumed.
402
+ */
403
+ function terrainVariant(context, index, type) {
404
+ const entries = context.terrainEntries;
405
+ if (entries === undefined)
406
+ return context.terrainIndex === undefined ? index : undefined;
407
+ return entries.some((entry) => entry.index === index && entry.type === type && entry.texture !== 255 && !entry.permSnow)
408
+ ? index
409
+ : undefined;
410
+ }
331
411
  /** Approximate seasonal weather: signed latitude is positive north of the equator. */
332
412
  function weatherBlock(signedLatitude, desert) {
333
413
  const latitude = Math.abs(signedLatitude);
@@ -358,7 +438,7 @@ function weatherBlock(signedLatitude, desert) {
358
438
  }
359
439
  return `\tweather={\n${periods.join('\n')}\n\t}`;
360
440
  }
361
- export function generateWorld(spec, context) {
441
+ export function generateWorld(spec, context, options = {}) {
362
442
  const { width, height } = spec;
363
443
  if (width % 256 !== 0 || height % 256 !== 0)
364
444
  throw new ServiceError('MAP_WORLD_DIMENSIONS_INVALID', 'Both map dimensions must be multiples of 256', { width, height });
@@ -382,34 +462,171 @@ export function generateWorld(spec, context) {
382
462
  const reference = spec.referenceSurface;
383
463
  if (reference !== undefined && (reference.width !== width || reference.height !== height))
384
464
  throw new ServiceError('MAP_WORLD_REFERENCE_DIMENSIONS', 'Reference raster dimensions do not match the world');
385
- const surface = reference === undefined
386
- ? generateSurface(spec, random)
465
+ const geography = spec.geography;
466
+ if (geography !== undefined &&
467
+ (geography.frame.width !== width || geography.frame.height !== height))
468
+ throw new ServiceError('MAP_GEOGRAPHY_FRAME_MISMATCH', 'The geography model was resolved for a different map size than the world being created', {
469
+ model: { width: geography.frame.width, height: geography.frame.height },
470
+ world: { width, height },
471
+ });
472
+ const surface = geography !== undefined
473
+ ? geographySurface(spec, geography, reference)
474
+ : reference === undefined
475
+ ? generateSurface(spec, random)
476
+ : {
477
+ kind: new Uint8Array(reference.kind),
478
+ elevation: new Float32Array(reference.elevation),
479
+ moisture: new Float32Array(reference.moisture),
480
+ };
481
+ const notes = [];
482
+ const edge = spec.edge ?? { mode: 'none', bandPx: 0, polarPx: 0, rimPx: 0 };
483
+ if (edge.mode !== 'none') {
484
+ assertFeaturesOutsideBand(edge, width, height, (spec.referenceCities ?? []).map(({ name, x, y }) => ({ name, x, y, kind: 'city' })));
485
+ const applied = applyEdgePolicy(surface.kind, surface.elevation, width, height, edge, spec.seed, Math.max(40, Math.round(spec.landProvincePixels / 4)));
486
+ notes.push(...applied.notes.map((note) => note));
487
+ }
488
+ // Lakes need a minimum size and may not touch the open sea (ADR 0053).
489
+ for (const note of normalizeLakes(surface.kind, surface.elevation, width, height))
490
+ notes.push({
491
+ code: note.code,
492
+ severity: 'warning',
493
+ message: `${note.message} near (${note.x}, ${note.y})`,
494
+ details: { pixels: note.pixels, x: note.x, y: note.y },
495
+ });
496
+ // A coast that touches itself only at corners cannot be cut into valid provinces, so the
497
+ // feature-aware partition smooths it when it builds its own fields. A geography world smooths
498
+ // before the synthesis, so its heights, rivers and fields follow the smoothed mask; any other
499
+ // world smooths after its drainage and patches those rasters, as it always has.
500
+ const smoothMask = spec.partition?.mode === 'feature-aware' && spec.partition.fields === undefined;
501
+ let smoothed = smoothMask && geography !== undefined
502
+ ? smoothDiagonalContacts(surface.kind, width, height)
503
+ : [];
504
+ // A coast crossing the east-west seam is repaired in every world, before the synthesis of a
505
+ // geography world and after the drainage of any other, like the smoothing.
506
+ let seamRepaired = geography !== undefined ? repairSeamCorners(surface.kind, width, height) : [];
507
+ // A geography model gets its relief and rivers from the synthesis of ADR 0056; every other world
508
+ // keeps the original elevation classes and drainage, byte for byte.
509
+ const synthesized = geography === undefined
510
+ ? undefined
511
+ : synthesizeTerrain({
512
+ geography,
513
+ width,
514
+ height,
515
+ seed: spec.seed,
516
+ kind: surface.kind,
517
+ relief: spec.relief,
518
+ rivers: { density: spec.riverDensity, count: spec.riverCount },
519
+ adjustHeight: edge.mode === 'rim'
520
+ ? (x, _y, value) => rimHeight(value, rimStrength(edge, width, x))
521
+ : undefined,
522
+ signal: options.signal,
523
+ });
524
+ if (synthesized !== undefined) {
525
+ for (const note of synthesized.notes) {
526
+ if (note.severity === 'error')
527
+ throw new ServiceError(note.code, note.message, note.details);
528
+ notes.push({
529
+ code: note.code,
530
+ severity: note.severity,
531
+ message: note.message,
532
+ ...(note.details === undefined ? {} : { details: note.details }),
533
+ });
534
+ }
535
+ notes.push(terrainSummaryNote(synthesized, geography));
536
+ }
537
+ const drainage = synthesized === undefined
538
+ ? generateWorldDrainage({
539
+ width,
540
+ height,
541
+ kind: surface.kind,
542
+ elevation: surface.elevation,
543
+ seed: spec.seed,
544
+ count: spec.rivers,
545
+ ...(reference === undefined ? {} : { referenceRivers: reference.rivers }),
546
+ })
387
547
  : {
388
- kind: new Uint8Array(reference.kind),
389
- elevation: new Float32Array(reference.elevation),
390
- moisture: new Float32Array(reference.moisture),
548
+ riverPixels: synthesized.riverBitmap,
549
+ elevation: synthesized.elevation,
550
+ count: synthesized.riverStatistics.count,
551
+ warnings: [],
391
552
  };
392
- const drainage = generateWorldDrainage({
393
- width,
394
- height,
395
- kind: surface.kind,
396
- elevation: surface.elevation,
397
- seed: spec.seed,
398
- count: spec.rivers,
399
- ...(reference === undefined ? {} : { referenceRivers: reference.rivers }),
400
- });
401
553
  surface.elevation = drainage.elevation;
402
- const provinces = generateWorldProvinces({
403
- width,
404
- height,
405
- seed: spec.seed,
406
- kind: surface.kind,
407
- elevation: surface.elevation,
408
- moisture: surface.moisture,
409
- landProvincePixels: spec.landProvincePixels,
410
- seaProvincePixels: spec.seaProvincePixels,
411
- });
554
+ if (smoothMask && geography === undefined) {
555
+ smoothed = smoothDiagonalContacts(surface.kind, width, height);
556
+ for (const pixel of smoothed) {
557
+ const isLand = surface.kind[pixel] === LAND;
558
+ surface.elevation[pixel] = isLand ? 0.02 : -0.02;
559
+ drainage.riverPixels[pixel] = isLand ? 255 : 254;
560
+ }
561
+ }
562
+ if (geography === undefined) {
563
+ seamRepaired = repairSeamCorners(surface.kind, width, height);
564
+ for (const pixel of seamRepaired) {
565
+ const isLand = surface.kind[pixel] === LAND;
566
+ surface.elevation[pixel] = isLand ? 0.02 : -0.02;
567
+ drainage.riverPixels[pixel] = isLand ? 255 : 254;
568
+ }
569
+ }
570
+ if (seamRepaired.length > 0)
571
+ notes.push({
572
+ code: 'MAP_WORLD_SEAM_CORNER_REPAIRED',
573
+ severity: 'info',
574
+ message: `Changed ${seamRepaired.length} pixel(s) at the east-west seam where a coast crosses it, because province parts cannot join across the seam`,
575
+ details: { pixels: seamRepaired.length },
576
+ });
577
+ if (smoothed.length > 0)
578
+ notes.push({
579
+ code: 'MAP_PARTITION_MASK_SMOOTHED',
580
+ severity: 'info',
581
+ message: `Changed ${smoothed.length} coast pixel(s) that touched their own kind only at a corner, which no province partition can represent`,
582
+ details: { pixels: smoothed.length },
583
+ });
584
+ const sizes = provinceBudget(spec, surface.kind);
585
+ if (sizes.note !== undefined)
586
+ notes.push(sizes.note);
587
+ let featurePartition;
588
+ let provinces;
589
+ if (spec.partition?.mode === 'feature-aware') {
590
+ const surfaceInput = {
591
+ width,
592
+ height,
593
+ kind: surface.kind,
594
+ elevation: surface.elevation,
595
+ moisture: surface.moisture,
596
+ riverPixels: drainage.riverPixels,
597
+ ...(reference === undefined ? {} : { referenceTerrain: reference.terrain }),
598
+ };
599
+ // A geography world hands the partition its regions, densities and walls, the synthesised
600
+ // heights and the model-drawn lines of the synthesis (ADR 0058).
601
+ featurePartition = partitionWorldFromRequest(spec.partition.fields ??
602
+ (synthesized === undefined || geography === undefined
603
+ ? surfacePartitionFields(surfaceInput)
604
+ : geographyPartitionFields({ ...surfaceInput, heights: synthesized.heights }, geography, synthesized.fields)), spec.seed, spec.partition.tuning, {
605
+ landProvincePixels: sizes.landProvincePixels,
606
+ seaProvincePixels: sizes.seaProvincePixels,
607
+ provincesPerState: spec.provincesPerState,
608
+ landProvincesPerRegion: spec.landProvincesPerRegion,
609
+ seaProvincesPerRegion: spec.seaProvincesPerRegion,
610
+ }, options.signal);
611
+ provinces = featurePartition.provinces;
612
+ for (const note of featurePartition.notes)
613
+ notes.push(note);
614
+ }
615
+ else
616
+ provinces = generateWorldProvinces({
617
+ width,
618
+ height,
619
+ seed: spec.seed,
620
+ kind: surface.kind,
621
+ elevation: surface.elevation,
622
+ moisture: surface.moisture,
623
+ landProvincePixels: sizes.landProvincePixels,
624
+ seaProvincePixels: sizes.seaProvincePixels,
625
+ });
412
626
  const count = provinces.kind.length;
627
+ const countNote = provinceCountNote(count, featurePartition === undefined ? 'legacy' : 'feature-aware');
628
+ if (countNote !== undefined)
629
+ notes.push(countNote);
413
630
  const center = (id) => ({
414
631
  x: provinces.sumX[id] / provinces.size[id],
415
632
  y: provinces.sumY[id] / provinces.size[id],
@@ -704,8 +921,12 @@ export function generateWorld(spec, context) {
704
921
  }
705
922
  // Strategic regions: land regions over the land graph, sea regions over the sea graph.
706
923
  const graph = (id) => provinces.neighbors[id];
707
- const landRegion = growClusters(landIds, graph, spec.landProvincesPerRegion, random);
708
- const seaRegion = growClusters(seaIds, graph, spec.seaProvincesPerRegion, random);
924
+ const landRegion = featurePartition === undefined
925
+ ? growClusters(landIds, graph, spec.landProvincesPerRegion, random)
926
+ : clusterRegions(featurePartition, landIds, 'land');
927
+ const seaRegion = featurePartition === undefined
928
+ ? growClusters(seaIds, graph, spec.seaProvincesPerRegion, random)
929
+ : clusterRegions(featurePartition, seaIds, 'sea');
709
930
  const regionOf = new Map();
710
931
  const landRegionCount = Math.max(-1, ...landRegion.values()) + 1;
711
932
  for (const [id, region] of landRegion)
@@ -734,7 +955,9 @@ export function generateWorld(spec, context) {
734
955
  const stateOf = new Map();
735
956
  for (const key of [...groups.keys()].sort()) {
736
957
  const members = groups.get(key);
737
- const clusters = growClusters(members, graph, spec.provincesPerState, random);
958
+ const clusters = featurePartition === undefined
959
+ ? growClusters(members, graph, spec.provincesPerState, random)
960
+ : clusterStates(featurePartition, members);
738
961
  const byCluster = new Map();
739
962
  for (const [id, cluster] of clusters) {
740
963
  const list = byCluster.get(cluster) ?? [];
@@ -951,10 +1174,27 @@ export function generateWorld(spec, context) {
951
1174
  '',
952
1175
  ].join('\n')));
953
1176
  add(`${map}/continent.txt`, text(`continents = {\n${[...new Set([...continentNames.map((_, index) => `world_continent_${index + 1}`), ...(context.continentKeys ?? [])])].map((key) => `\t${key}`).join('\n')}\n}\n`));
954
- add(`${map}/adjacencies.csv`, text('From;To;Type;Through;start_x;start_y;stop_x;stop_y;adjacency_rule_name;Comment\r\n-1;-1;;-1;-1;-1;-1;-1;-1\r\n'));
1177
+ const rimRows = edge.mode === 'rim'
1178
+ ? rimAdjacencyRows({
1179
+ provincePixels: provinces.pixel,
1180
+ provinceKinds: provinces.kind,
1181
+ width,
1182
+ height,
1183
+ })
1184
+ : [];
1185
+ if (rimRows.length > 0)
1186
+ notes.push({
1187
+ code: 'MAP_EDGE_RIM_ADJACENCIES',
1188
+ severity: 'info',
1189
+ message: `The rim edge policy wrote ${rimRows.length} impassable adjacency row(s) for provinces that meet across the seam`,
1190
+ details: { rows: rimRows.length },
1191
+ });
1192
+ add(`${map}/adjacencies.csv`, text(`From;To;Type;Through;start_x;start_y;stop_x;stop_y;adjacency_rule_name;Comment\r\n${rimRows.map((row) => `${row}\r\n`).join('')}-1;-1;;-1;-1;-1;-1;-1;-1\r\n`));
955
1193
  add(`${map}/adjacency_rules.txt`, text('# No straits or canals in this world yet.\n'));
956
1194
  add(`${map}/positions.txt`, text(''));
957
- add(`${map}/ambient_object.txt`, text('# No ambient objects: the base-game map frame does not fit this map.\n'));
1195
+ add(`${map}/ambient_object.txt`, text(spec.ambientFrame === true
1196
+ ? ambientObjectText(width, height)
1197
+ : '# No ambient objects: the base-game map frame does not fit this map.\n'));
958
1198
  // City groups use palette 15 only for urban footprints. Water and undeveloped land stay 0.
959
1199
  const cityPixels = new Uint8Array(width * height);
960
1200
  const nearestCenter = new Int32Array(count).fill(-1);
@@ -996,33 +1236,90 @@ export function generateWorld(spec, context) {
996
1236
  const type = terrain.get(provinces.pixel[pixel]) ?? 'plains';
997
1237
  return type === 'urban' ? 'plains' : type;
998
1238
  };
999
- // heightmap.bmp: sea below 95, land from 96 up.
1000
- const heights = new Uint8Array(width * height);
1001
- for (let pixel = 0; pixel < width * height; pixel += 1) {
1002
- const value = surface.elevation[pixel];
1003
- heights[pixel] =
1004
- surface.kind[pixel] === LAND
1005
- ? Math.min(255, Math.round(96 + value * 110))
1006
- : surface.kind[pixel] === LAKE
1007
- ? 93
1008
- : Math.max(60, Math.round(94 + value * 30));
1009
- }
1239
+ // heightmap.bmp: sea below 95, land from 96 up. Lakes sit at the sea's interior height with a
1240
+ // one to two pixel translucent bank, so the water layer draws them opaque (ADR 0053).
1241
+ const landDistance = landDistanceField(surface.kind, width, height);
1242
+ const heights = synthesized?.heights ?? new Uint8Array(width * height);
1243
+ if (synthesized === undefined)
1244
+ for (let pixel = 0; pixel < width * height; pixel += 1) {
1245
+ const value = surface.elevation[pixel];
1246
+ heights[pixel] =
1247
+ surface.kind[pixel] === LAND
1248
+ ? rimHeight(Math.min(255, Math.round(96 + value * 110)), rimStrength(edge, width, pixel % width))
1249
+ : surface.kind[pixel] === LAKE
1250
+ ? lakeHeight(landDistance[pixel])
1251
+ : Math.max(60, Math.round(94 + value * 30));
1252
+ }
1010
1253
  const grey = Array.from({ length: 256 }, (_, index) => ({ r: index, g: index, b: index }));
1011
1254
  add(`${map}/heightmap.bmp`, createBmp({ width, height, bitsPerPixel: 8, palette: grey, indexedPixels: heights }), 'image/bmp');
1012
1255
  // terrain.bmp from province terrain, with snow on high-latitude mountains.
1013
1256
  const terrainIndex = context.terrainIndex ?? DEFAULT_TERRAIN_INDEX;
1257
+ // Lake pixels use the ocean's graphical terrain: the lake entry names no atlas tile.
1258
+ const lakeIndex = terrainIndex.get('lakes') ?? terrainIndex.get('ocean') ?? 15;
1259
+ const calibrated = spec.colormapProfile === 'calibrated';
1260
+ const farmlandIndex = calibrated ? terrainVariant(context, 5, 'plains') : undefined;
1261
+ const sparseForestIndex = calibrated ? terrainVariant(context, 4, 'forest') : undefined;
1262
+ const mountainGrassIndex = calibrated ? terrainVariant(context, 20, 'mountain') : undefined;
1014
1263
  const terrainPixels = new Uint8Array(width * height);
1264
+ const colourClasses = calibrated ? new Uint8Array(width * height) : undefined;
1265
+ const classCode = (name) => COLOUR_CLASSES.indexOf(name);
1266
+ const flatSlope = (x, y) => Math.abs(heights[y * width + Math.min(width - 1, x + 1)] - heights[y * width + Math.max(0, x - 1)]) +
1267
+ Math.abs(heights[Math.min(height - 1, y + 1) * width + x] -
1268
+ heights[Math.max(0, y - 1) * width + x]) <=
1269
+ 2;
1015
1270
  for (let pixel = 0; pixel < width * height; pixel += 1) {
1016
1271
  const id = provinces.pixel[pixel];
1017
1272
  const type = visualTerrain(pixel);
1018
- const snowy = reference === undefined
1019
- ? type === 'mountain' && latitude(id) > 0.7
1020
- : reference.terrain[pixel] === 4 && surface.kind[pixel] === LAND;
1021
- terrainPixels[pixel] = snowy
1273
+ const snowy = synthesized !== undefined
1274
+ ? (synthesized.snow[pixel] === 1 || reference?.terrain[pixel] === 4) &&
1275
+ surface.kind[pixel] === LAND
1276
+ : reference === undefined
1277
+ ? type === 'mountain' && latitude(id) > 0.7
1278
+ : reference.terrain[pixel] === 4 && surface.kind[pixel] === LAND;
1279
+ const x = pixel % width;
1280
+ const rimmed = rimStrength(edge, width, x) > 0.35 && surface.kind[pixel] === LAND;
1281
+ let index = snowy
1022
1282
  ? reference === undefined
1023
1283
  ? 16
1024
1284
  : 19
1025
- : (terrainIndex.get(type) ?? DEFAULT_TERRAIN_INDEX.get(type) ?? 0);
1285
+ : rimmed
1286
+ ? 16
1287
+ : type === 'lakes'
1288
+ ? lakeIndex
1289
+ : (terrainIndex.get(type) ?? DEFAULT_TERRAIN_INDEX.get(type) ?? 0);
1290
+ let colour = type === 'lakes' || type === 'ocean' || type === 'urban'
1291
+ ? type === 'urban'
1292
+ ? 'urban'
1293
+ : type
1294
+ : snowy || rimmed
1295
+ ? 'snow'
1296
+ : type;
1297
+ if (calibrated && !snowy && !rimmed && surface.kind[pixel] === LAND) {
1298
+ // Variant graphical terrains by rule, so one class is not one flat texture.
1299
+ const y = (pixel - x) / width;
1300
+ if (type === 'plains' &&
1301
+ farmlandIndex !== undefined &&
1302
+ surface.moisture[pixel] >= 0.6 &&
1303
+ flatSlope(x, y)) {
1304
+ index = farmlandIndex;
1305
+ colour = 'farmland';
1306
+ }
1307
+ else if (type === 'forest' &&
1308
+ sparseForestIndex !== undefined &&
1309
+ fractal(spec.seed + 7001, x, y, Math.max(16, width / 30), 3) < 0.42)
1310
+ index = sparseForestIndex;
1311
+ else if (type === 'mountain' &&
1312
+ mountainGrassIndex !== undefined &&
1313
+ surface.moisture[pixel] > 0.45 &&
1314
+ fractal(spec.seed + 7919, x, y, Math.max(16, width / 20), 3) > 0.5)
1315
+ index = mountainGrassIndex;
1316
+ }
1317
+ terrainPixels[pixel] = index;
1318
+ if (colourClasses !== undefined) {
1319
+ if (!COLOUR_CLASSES.includes(colour))
1320
+ colour = 'plains';
1321
+ colourClasses[pixel] = classCode(colour);
1322
+ }
1026
1323
  }
1027
1324
  add(`${map}/terrain.bmp`, createBmp({
1028
1325
  width,
@@ -1098,23 +1395,33 @@ export function generateWorld(spec, context) {
1098
1395
  palette: bmpPalette(context.palettes.cities, (index) => ({ r: index, g: index, b: index })),
1099
1396
  indexedPixels: cityPixels,
1100
1397
  }), 'image/bmp');
1101
- // world_normal.bmp at half size from the height gradient.
1398
+ // world_normal.bmp at half size. Geography worlds shade from the float height field at the
1399
+ // base game's response; every other world from the quantised heights.
1102
1400
  const halfWidth = width / 2;
1103
1401
  const halfHeight = height / 2;
1104
- const normals = new Array(halfWidth * halfHeight);
1105
- const heightAt = (x, y) => heights[Math.min(height - 1, Math.max(0, y)) * width + Math.min(width - 1, Math.max(0, x))];
1106
- for (let y = 0; y < halfHeight; y += 1)
1107
- for (let x = 0; x < halfWidth; x += 1) {
1108
- const dx = (heightAt(x * 2 + 2, y * 2) - heightAt(x * 2 - 2, y * 2)) / 4;
1109
- const dy = (heightAt(x * 2, y * 2 - 2) - heightAt(x * 2, y * 2 + 2)) / 4;
1110
- const length = Math.hypot(dx, dy, 8);
1111
- normals[y * halfWidth + x] = {
1112
- r: Math.round(128 - (dx / length) * 127),
1113
- g: Math.round(128 + (dy / length) * 127),
1114
- b: Math.round(128 + (8 / length) * 127),
1115
- };
1116
- }
1117
- add(`${map}/world_normal.bmp`, createBmp({ width: halfWidth, height: halfHeight, bitsPerPixel: 24, rgbPixels: normals }), 'image/bmp');
1402
+ if (synthesized !== undefined)
1403
+ add(`${map}/world_normal.bmp`, createBmp({
1404
+ width: halfWidth,
1405
+ height: halfHeight,
1406
+ bitsPerPixel: 24,
1407
+ rgbBytes: synthesized.normals,
1408
+ }), 'image/bmp');
1409
+ else {
1410
+ const normals = new Array(halfWidth * halfHeight);
1411
+ const heightAt = (x, y) => heights[Math.min(height - 1, Math.max(0, y)) * width + Math.min(width - 1, Math.max(0, x))];
1412
+ for (let y = 0; y < halfHeight; y += 1)
1413
+ for (let x = 0; x < halfWidth; x += 1) {
1414
+ const dx = (heightAt(x * 2 + 2, y * 2) - heightAt(x * 2 - 2, y * 2)) / 4;
1415
+ const dy = (heightAt(x * 2, y * 2 - 2) - heightAt(x * 2, y * 2 + 2)) / 4;
1416
+ const length = Math.hypot(dx, dy, 8);
1417
+ normals[y * halfWidth + x] = {
1418
+ r: Math.round(128 - (dx / length) * 127),
1419
+ g: Math.round(128 + (dy / length) * 127),
1420
+ b: Math.round(128 + (8 / length) * 127),
1421
+ };
1422
+ }
1423
+ add(`${map}/world_normal.bmp`, createBmp({ width: halfWidth, height: halfHeight, bitsPerPixel: 24, rgbPixels: normals }), 'image/bmp');
1424
+ }
1118
1425
  // Colour maps: land tint and city lights at half size; water at half, quarter and eighth.
1119
1426
  const tint = {
1120
1427
  plains: { r: 121, g: 133, b: 76 },
@@ -1128,36 +1435,65 @@ export function generateWorld(spec, context) {
1128
1435
  lakes: { r: 70, g: 96, b: 110 },
1129
1436
  ocean: { r: 50, g: 70, b: 92 },
1130
1437
  };
1131
- const colormap = new Uint8Array(halfWidth * halfHeight * 4);
1132
- for (let y = 0; y < halfHeight; y += 1)
1133
- for (let x = 0; x < halfWidth; x += 1) {
1134
- // DDS rows run top to bottom, like the bitmaps' top-left coordinates used here.
1135
- const source = y * 2 * width + x * 2;
1136
- const color = tint[visualTerrain(source)];
1137
- const shade = 0.85 + surface.moisture[source] * 0.3;
1138
- const offset = (y * halfWidth + x) * 4;
1139
- colormap[offset] = Math.min(255, Math.round(color.b * shade));
1140
- colormap[offset + 1] = Math.min(255, Math.round(color.g * shade));
1141
- colormap[offset + 2] = Math.min(255, Math.round(color.r * shade));
1142
- colormap[offset + 3] = cityPixels[source] === 15 ? 200 : 0;
1143
- }
1144
- add(`${map}/terrain/colormap_rgb_cityemissivemask_a.dds`, encodeDds(halfWidth, halfHeight, colormap), 'image/vnd-ms.dds');
1145
- for (const level of [0, 1, 2]) {
1146
- const factor = 2 ** (level + 1);
1147
- const levelWidth = width / factor;
1148
- const levelHeight = height / factor;
1149
- const water = new Uint8Array(levelWidth * levelHeight * 4);
1150
- for (let y = 0; y < levelHeight; y += 1)
1151
- for (let x = 0; x < levelWidth; x += 1) {
1152
- const depth = Math.max(0, -surface.elevation[y * factor * width + x * factor]);
1153
- const offset = (y * levelWidth + x) * 4;
1154
- water[offset] = Math.round(120 - depth * 50);
1155
- water[offset + 1] = Math.round(90 - depth * 40);
1156
- water[offset + 2] = Math.round(40 - depth * 20);
1157
- water[offset + 3] = 255;
1438
+ let colormap;
1439
+ if (colourClasses !== undefined) {
1440
+ const lights = new Uint8Array(width * height);
1441
+ for (let pixel = 0; pixel < lights.length; pixel += 1)
1442
+ if (cityPixels[pixel] === 15)
1443
+ lights[pixel] = 200;
1444
+ colormap = calibratedColourMap({
1445
+ width,
1446
+ height,
1447
+ seed: spec.seed,
1448
+ classes: colourClasses,
1449
+ moisture: surface.moisture,
1450
+ heights,
1451
+ lights,
1452
+ look: spec.look ?? 'vanilla-temperate',
1453
+ ...(spec.colormapPalette === undefined ? {} : { overrides: spec.colormapPalette }),
1454
+ });
1455
+ }
1456
+ else {
1457
+ colormap = new Uint8Array(halfWidth * halfHeight * 4);
1458
+ for (let y = 0; y < halfHeight; y += 1)
1459
+ for (let x = 0; x < halfWidth; x += 1) {
1460
+ // DDS rows run top to bottom, like the bitmaps' top-left coordinates used here.
1461
+ const source = y * 2 * width + x * 2;
1462
+ const color = tint[visualTerrain(source)];
1463
+ const shade = 0.85 + surface.moisture[source] * 0.3;
1464
+ const offset = (y * halfWidth + x) * 4;
1465
+ colormap[offset] = Math.min(255, Math.round(color.b * shade));
1466
+ colormap[offset + 1] = Math.min(255, Math.round(color.g * shade));
1467
+ colormap[offset + 2] = Math.min(255, Math.round(color.r * shade));
1468
+ colormap[offset + 3] = cityPixels[source] === 15 ? 200 : 0;
1158
1469
  }
1159
- add(`${map}/terrain/colormap_water_${level}.dds`, encodeDds(levelWidth, levelHeight, water), 'image/vnd-ms.dds');
1160
1470
  }
1471
+ if (edge.mode === 'rim')
1472
+ for (let y = 0; y < halfHeight; y += 1)
1473
+ for (let x = 0; x < halfWidth; x += 1) {
1474
+ const strength = rimStrength(edge, width, x * 2);
1475
+ if (strength === 0 || surface.kind[y * 2 * width + x * 2] !== LAND)
1476
+ continue;
1477
+ const factor = 1 - (1 - RIM_COLOUR_FACTOR) * strength;
1478
+ const offset = (y * halfWidth + x) * 4;
1479
+ colormap[offset] = Math.round(colormap[offset] * factor);
1480
+ colormap[offset + 1] = Math.round(colormap[offset + 1] * factor);
1481
+ colormap[offset + 2] = Math.round(colormap[offset + 2] * factor);
1482
+ }
1483
+ add(`${map}/terrain/colormap_rgb_cityemissivemask_a.dds`, encodeDds(halfWidth, halfHeight, colormap), 'image/vnd-ms.dds');
1484
+ // Water colour maps: sea tint by distance from land, lake tint with a shore gradient, and the
1485
+ // darkest tint at the world edge (ADR 0053, 0061).
1486
+ for (const level of [0, 1, 2])
1487
+ add(`${map}/terrain/colormap_water_${level}.dds`, encodeDds(width / 2 ** (level + 1), height / 2 ** (level + 1), waterColourLevel({
1488
+ width,
1489
+ height,
1490
+ kind: surface.kind,
1491
+ landDistance,
1492
+ seed: spec.seed,
1493
+ ...(edge.mode === 'none'
1494
+ ? {}
1495
+ : { darken: (x, y) => edgeWaterDarkness(edge, width, height, x, y) }),
1496
+ }, level)), 'image/vnd-ms.dds');
1161
1497
  // Strategic regions with weather by latitude.
1162
1498
  const regionMembers = new Map();
1163
1499
  for (const [id, region] of regionOf) {
@@ -1385,6 +1721,8 @@ export function generateWorld(spec, context) {
1385
1721
  return {
1386
1722
  files,
1387
1723
  warnings: [...interpretationWarnings, ...drainage.warnings],
1724
+ notes,
1725
+ ...(synthesized === undefined ? {} : { terrain: synthesized }),
1388
1726
  summary: {
1389
1727
  width,
1390
1728
  height,