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
@@ -9,9 +9,20 @@ 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';
22
+ import { assignUnreachedRealmLand, resolveRealmRules } from './world-realms.js';
12
23
  // ---------------------------------------------------------------------------------------------
13
24
  // Deterministic randomness and noise
14
- class Random {
25
+ export class Random {
15
26
  #state;
16
27
  constructor(seed) {
17
28
  this.#state = (seed ^ 0x9e3779b9) >>> 0;
@@ -27,38 +38,6 @@ class Random {
27
38
  return Math.floor(this.next() * limit);
28
39
  }
29
40
  }
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
41
  // ---------------------------------------------------------------------------------------------
63
42
  // Raster helpers
64
43
  const LAND = 1;
@@ -204,10 +183,99 @@ function generateSurface(spec, random) {
204
183
  }
205
184
  return { kind, elevation, moisture };
206
185
  }
186
+ /**
187
+ * Surface of a world described by a geography model: land and lakes from the model (or lakes laid
188
+ * over a reference picture's coast); elevation is filled in by the terrain synthesis later.
189
+ */
190
+ /** Province count the vanilla map holds; defaulted sizes grow towards it. */
191
+ const PROVINCE_COUNT_GOAL = 13_000;
192
+ /**
193
+ * Check the province count the sizes give before partitioning. Defaulted sizes above the warning
194
+ * grow until the world holds about the base game's count; sizes the caller chose that give more
195
+ * than the text tables allow are refused before any work.
196
+ */
197
+ function provinceBudget(spec, kind) {
198
+ let land = 0;
199
+ let water = 0;
200
+ for (const value of kind)
201
+ if (value === LAND)
202
+ land += 1;
203
+ else
204
+ water += 1;
205
+ const expected = land / spec.landProvincePixels + water / spec.seaProvincePixels;
206
+ const sizes = {
207
+ landProvincePixels: spec.landProvincePixels,
208
+ seaProvincePixels: spec.seaProvincePixels,
209
+ };
210
+ if (expected <= PARTITION_PROVINCE_WARNING)
211
+ return sizes;
212
+ if (spec.provinceSizesDefaulted === true) {
213
+ const factor = expected / PROVINCE_COUNT_GOAL;
214
+ const landProvincePixels = Math.ceil(spec.landProvincePixels * factor);
215
+ const seaProvincePixels = Math.ceil(spec.seaProvincePixels * factor);
216
+ return {
217
+ landProvincePixels,
218
+ seaProvincePixels,
219
+ note: {
220
+ code: 'MAP_WORLD_PROVINCE_SIZE_RAISED',
221
+ severity: 'info',
222
+ 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`,
223
+ details: { expectedProvinces: Math.round(expected), landProvincePixels, seaProvincePixels },
224
+ },
225
+ };
226
+ }
227
+ if (expected > PARTITION_PROVINCE_ERROR)
228
+ 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 });
229
+ return sizes;
230
+ }
231
+ function geographySurface(spec, geography, reference) {
232
+ const { width, height } = spec;
233
+ const total = width * height;
234
+ const options = { width, height, seed: spec.seed };
235
+ let kind;
236
+ let moisture;
237
+ if (reference === undefined) {
238
+ kind = rasterizeGeographyKind(geography, options);
239
+ moisture = new Float32Array(total);
240
+ const scale = Math.max(width, height) / 6;
241
+ for (let y = 0; y < height; y += 1)
242
+ for (let x = 0; x < width; x += 1)
243
+ moisture[y * width + x] = fractal(spec.seed + 7919, x, y, scale * 0.8, 4);
244
+ }
245
+ else {
246
+ kind = new Uint8Array(reference.kind);
247
+ overlayGeographyLakes(kind, geography, options);
248
+ moisture = new Float32Array(reference.moisture);
249
+ }
250
+ return { kind, elevation: new Float32Array(total), moisture };
251
+ }
252
+ function terrainSummaryNote(terrain, geography) {
253
+ const { relief, riverStatistics } = terrain;
254
+ return {
255
+ code: 'MAP_RELIEF_SYNTHESIZED',
256
+ severity: 'info',
257
+ 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`,
258
+ details: {
259
+ modelHash: geography.hash,
260
+ relief,
261
+ rivers: riverStatistics,
262
+ // The first of the named rivers only: the response budget is shared with everything else the
263
+ // world reports, and a river that did not complete is always listed.
264
+ namedRivers: terrain.rivers
265
+ .filter((river) => river.named && river.junction !== 'distributary')
266
+ .slice(0, 24)
267
+ .map(({ id, pixels, mouth, complete }) => ({ id, pixels, mouth, complete })),
268
+ incompleteRivers: terrain.rivers
269
+ .filter((river) => river.named && !river.complete)
270
+ .slice(0, 24)
271
+ .map(({ id }) => id),
272
+ },
273
+ };
274
+ }
207
275
  // ---------------------------------------------------------------------------------------------
208
276
  // Grouping provinces into regions and states
209
277
  /** Grow clusters of about `target` members over a graph from spread-out seeds. */
210
- function growClusters(members, neighbors, target, random, weight = () => 1) {
278
+ export function growClusters(members, neighbors, target, random, weight = () => 1) {
211
279
  const cluster = new Map();
212
280
  const memberSet = new Set(members);
213
281
  const pending = new Set(members);
@@ -324,10 +392,23 @@ const DEFAULT_TERRAIN_INDEX = new Map([
324
392
  ['mountain', 6],
325
393
  ['marsh', 9],
326
394
  ['urban', 13],
327
- ['lakes', 14],
395
+ ['lakes', 15],
328
396
  ['ocean', 15],
329
397
  ['jungle', 21],
330
398
  ]);
399
+ /**
400
+ * A graphical terrain index of the base game's numbering (farmland 5, sparse forest 4, mountain
401
+ * with grass 20), used only when the active terrain files confirm that index still has that
402
+ * category and draws a tile. Without any terrain files the base game's numbering is assumed.
403
+ */
404
+ function terrainVariant(context, index, type) {
405
+ const entries = context.terrainEntries;
406
+ if (entries === undefined)
407
+ return context.terrainIndex === undefined ? index : undefined;
408
+ return entries.some((entry) => entry.index === index && entry.type === type && entry.texture !== 255 && !entry.permSnow)
409
+ ? index
410
+ : undefined;
411
+ }
331
412
  /** Approximate seasonal weather: signed latitude is positive north of the equator. */
332
413
  function weatherBlock(signedLatitude, desert) {
333
414
  const latitude = Math.abs(signedLatitude);
@@ -358,7 +439,7 @@ function weatherBlock(signedLatitude, desert) {
358
439
  }
359
440
  return `\tweather={\n${periods.join('\n')}\n\t}`;
360
441
  }
361
- export function generateWorld(spec, context) {
442
+ export function generateWorld(spec, context, options = {}) {
362
443
  const { width, height } = spec;
363
444
  if (width % 256 !== 0 || height % 256 !== 0)
364
445
  throw new ServiceError('MAP_WORLD_DIMENSIONS_INVALID', 'Both map dimensions must be multiples of 256', { width, height });
@@ -382,34 +463,171 @@ export function generateWorld(spec, context) {
382
463
  const reference = spec.referenceSurface;
383
464
  if (reference !== undefined && (reference.width !== width || reference.height !== height))
384
465
  throw new ServiceError('MAP_WORLD_REFERENCE_DIMENSIONS', 'Reference raster dimensions do not match the world');
385
- const surface = reference === undefined
386
- ? generateSurface(spec, random)
466
+ const geography = spec.geography;
467
+ if (geography !== undefined &&
468
+ (geography.frame.width !== width || geography.frame.height !== height))
469
+ throw new ServiceError('MAP_GEOGRAPHY_FRAME_MISMATCH', 'The geography model was resolved for a different map size than the world being created', {
470
+ model: { width: geography.frame.width, height: geography.frame.height },
471
+ world: { width, height },
472
+ });
473
+ const surface = geography !== undefined
474
+ ? geographySurface(spec, geography, reference)
475
+ : reference === undefined
476
+ ? generateSurface(spec, random)
477
+ : {
478
+ kind: new Uint8Array(reference.kind),
479
+ elevation: new Float32Array(reference.elevation),
480
+ moisture: new Float32Array(reference.moisture),
481
+ };
482
+ const notes = [];
483
+ const edge = spec.edge ?? { mode: 'none', bandPx: 0, polarPx: 0, rimPx: 0 };
484
+ if (edge.mode !== 'none') {
485
+ assertFeaturesOutsideBand(edge, width, height, (spec.referenceCities ?? []).map(({ name, x, y }) => ({ name, x, y, kind: 'city' })));
486
+ const applied = applyEdgePolicy(surface.kind, surface.elevation, width, height, edge, spec.seed, Math.max(40, Math.round(spec.landProvincePixels / 4)));
487
+ notes.push(...applied.notes.map((note) => note));
488
+ }
489
+ // Lakes need a minimum size and may not touch the open sea (ADR 0053).
490
+ for (const note of normalizeLakes(surface.kind, surface.elevation, width, height))
491
+ notes.push({
492
+ code: note.code,
493
+ severity: 'warning',
494
+ message: `${note.message} near (${note.x}, ${note.y})`,
495
+ details: { pixels: note.pixels, x: note.x, y: note.y },
496
+ });
497
+ // A coast that touches itself only at corners cannot be cut into valid provinces, so the
498
+ // feature-aware partition smooths it when it builds its own fields. A geography world smooths
499
+ // before the synthesis, so its heights, rivers and fields follow the smoothed mask; any other
500
+ // world smooths after its drainage and patches those rasters, as it always has.
501
+ const smoothMask = spec.partition?.mode === 'feature-aware' && spec.partition.fields === undefined;
502
+ let smoothed = smoothMask && geography !== undefined
503
+ ? smoothDiagonalContacts(surface.kind, width, height)
504
+ : [];
505
+ // A coast crossing the east-west seam is repaired in every world, before the synthesis of a
506
+ // geography world and after the drainage of any other, like the smoothing.
507
+ let seamRepaired = geography !== undefined ? repairSeamCorners(surface.kind, width, height) : [];
508
+ // A geography model gets its relief and rivers from the synthesis of ADR 0056; every other world
509
+ // keeps the original elevation classes and drainage, byte for byte.
510
+ const synthesized = geography === undefined
511
+ ? undefined
512
+ : synthesizeTerrain({
513
+ geography,
514
+ width,
515
+ height,
516
+ seed: spec.seed,
517
+ kind: surface.kind,
518
+ relief: spec.relief,
519
+ rivers: { density: spec.riverDensity, count: spec.riverCount },
520
+ adjustHeight: edge.mode === 'rim'
521
+ ? (x, _y, value) => rimHeight(value, rimStrength(edge, width, x))
522
+ : undefined,
523
+ signal: options.signal,
524
+ });
525
+ if (synthesized !== undefined) {
526
+ for (const note of synthesized.notes) {
527
+ if (note.severity === 'error')
528
+ throw new ServiceError(note.code, note.message, note.details);
529
+ notes.push({
530
+ code: note.code,
531
+ severity: note.severity,
532
+ message: note.message,
533
+ ...(note.details === undefined ? {} : { details: note.details }),
534
+ });
535
+ }
536
+ notes.push(terrainSummaryNote(synthesized, geography));
537
+ }
538
+ const drainage = synthesized === undefined
539
+ ? generateWorldDrainage({
540
+ width,
541
+ height,
542
+ kind: surface.kind,
543
+ elevation: surface.elevation,
544
+ seed: spec.seed,
545
+ count: spec.rivers,
546
+ ...(reference === undefined ? {} : { referenceRivers: reference.rivers }),
547
+ })
387
548
  : {
388
- kind: new Uint8Array(reference.kind),
389
- elevation: new Float32Array(reference.elevation),
390
- moisture: new Float32Array(reference.moisture),
549
+ riverPixels: synthesized.riverBitmap,
550
+ elevation: synthesized.elevation,
551
+ count: synthesized.riverStatistics.count,
552
+ warnings: [],
391
553
  };
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
554
  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
- });
555
+ if (smoothMask && geography === undefined) {
556
+ smoothed = smoothDiagonalContacts(surface.kind, width, height);
557
+ for (const pixel of smoothed) {
558
+ const isLand = surface.kind[pixel] === LAND;
559
+ surface.elevation[pixel] = isLand ? 0.02 : -0.02;
560
+ drainage.riverPixels[pixel] = isLand ? 255 : 254;
561
+ }
562
+ }
563
+ if (geography === undefined) {
564
+ seamRepaired = repairSeamCorners(surface.kind, width, height);
565
+ for (const pixel of seamRepaired) {
566
+ const isLand = surface.kind[pixel] === LAND;
567
+ surface.elevation[pixel] = isLand ? 0.02 : -0.02;
568
+ drainage.riverPixels[pixel] = isLand ? 255 : 254;
569
+ }
570
+ }
571
+ if (seamRepaired.length > 0)
572
+ notes.push({
573
+ code: 'MAP_WORLD_SEAM_CORNER_REPAIRED',
574
+ severity: 'info',
575
+ message: `Changed ${seamRepaired.length} pixel(s) at the east-west seam where a coast crosses it, because province parts cannot join across the seam`,
576
+ details: { pixels: seamRepaired.length },
577
+ });
578
+ if (smoothed.length > 0)
579
+ notes.push({
580
+ code: 'MAP_PARTITION_MASK_SMOOTHED',
581
+ severity: 'info',
582
+ message: `Changed ${smoothed.length} coast pixel(s) that touched their own kind only at a corner, which no province partition can represent`,
583
+ details: { pixels: smoothed.length },
584
+ });
585
+ const sizes = provinceBudget(spec, surface.kind);
586
+ if (sizes.note !== undefined)
587
+ notes.push(sizes.note);
588
+ let featurePartition;
589
+ let provinces;
590
+ if (spec.partition?.mode === 'feature-aware') {
591
+ const surfaceInput = {
592
+ width,
593
+ height,
594
+ kind: surface.kind,
595
+ elevation: surface.elevation,
596
+ moisture: surface.moisture,
597
+ riverPixels: drainage.riverPixels,
598
+ ...(reference === undefined ? {} : { referenceTerrain: reference.terrain }),
599
+ };
600
+ // A geography world hands the partition its regions, densities and walls, the synthesised
601
+ // heights and the model-drawn lines of the synthesis (ADR 0058).
602
+ featurePartition = partitionWorldFromRequest(spec.partition.fields ??
603
+ (synthesized === undefined || geography === undefined
604
+ ? surfacePartitionFields(surfaceInput)
605
+ : geographyPartitionFields({ ...surfaceInput, heights: synthesized.heights }, geography, synthesized.fields)), spec.seed, spec.partition.tuning, {
606
+ landProvincePixels: sizes.landProvincePixels,
607
+ seaProvincePixels: sizes.seaProvincePixels,
608
+ provincesPerState: spec.provincesPerState,
609
+ landProvincesPerRegion: spec.landProvincesPerRegion,
610
+ seaProvincesPerRegion: spec.seaProvincesPerRegion,
611
+ }, options.signal);
612
+ provinces = featurePartition.provinces;
613
+ for (const note of featurePartition.notes)
614
+ notes.push(note);
615
+ }
616
+ else
617
+ provinces = generateWorldProvinces({
618
+ width,
619
+ height,
620
+ seed: spec.seed,
621
+ kind: surface.kind,
622
+ elevation: surface.elevation,
623
+ moisture: surface.moisture,
624
+ landProvincePixels: sizes.landProvincePixels,
625
+ seaProvincePixels: sizes.seaProvincePixels,
626
+ });
412
627
  const count = provinces.kind.length;
628
+ const countNote = provinceCountNote(count, featurePartition === undefined ? 'legacy' : 'feature-aware');
629
+ if (countNote !== undefined)
630
+ notes.push(countNote);
413
631
  const center = (id) => ({
414
632
  x: provinces.sumX[id] / provinces.size[id],
415
633
  y: provinces.sumY[id] / provinces.size[id],
@@ -546,7 +764,9 @@ export function generateWorld(spec, context) {
546
764
  const countryCount = spec.countries.length;
547
765
  const interpretationWarnings = [];
548
766
  const referenceOwners = new Map();
549
- if (reference?.inferredCountries.length === countryCount && countryCount > 1) {
767
+ if (spec.realms === undefined &&
768
+ reference?.inferredCountries.length === countryCount &&
769
+ countryCount > 1) {
550
770
  const indexes = new Map();
551
771
  const unresolved = [];
552
772
  for (const [index, country] of spec.countries.entries()) {
@@ -627,6 +847,17 @@ export function generateWorld(spec, context) {
627
847
  });
628
848
  const capitals = [];
629
849
  const largest = [...landIds].sort((left, right) => provinces.size[right] - provinces.size[left] || left - right);
850
+ const realmRules = spec.realms === undefined
851
+ ? undefined
852
+ : resolveRealmRules(spec.realms, {
853
+ width,
854
+ height,
855
+ pixelProvince: provinces.pixel,
856
+ provinceSize: provinces.size,
857
+ isLand: (id) => provinces.kind[id] === LAND,
858
+ });
859
+ if (realmRules !== undefined)
860
+ capitals.push(...realmRules.capitals);
630
861
  while (capitals.length < countryCount) {
631
862
  const countryIndex = capitals.length;
632
863
  const regionCandidates = largest.filter((id) => referenceOwners.get(id) === countryIndex && !capitals.includes(id));
@@ -662,20 +893,26 @@ export function generateWorld(spec, context) {
662
893
  }
663
894
  const shares = spec.countries.map((country) => Math.max(0.05, country.share ?? 1));
664
895
  const owned = new Array(countryCount).fill(0);
665
- for (const [id, country] of referenceOwners) {
896
+ for (const [id, country] of realmRules?.forced ?? referenceOwners) {
666
897
  owner.set(id, country);
667
898
  owned[country] += provinces.size[id];
668
899
  }
900
+ const unownedLand = realmRules?.unowned ?? new Set();
901
+ const blockedFor = (index, id) => unownedLand.has(id) || realmRules?.excluded[index].has(id) === true;
669
902
  const frontiers = capitals.map((capital, index) => {
670
- owner.set(capital, index);
671
- if (!referenceOwners.has(capital))
903
+ if (!owner.has(capital))
672
904
  owned[index] += provinces.size[capital];
905
+ owner.set(capital, index);
906
+ if (realmRules !== undefined)
907
+ return [...owner]
908
+ .filter(([, country]) => country === index)
909
+ .flatMap(([id]) => [...provinces.neighbors[id]]);
673
910
  return [...provinces.neighbors[capital]];
674
911
  });
675
912
  for (;;) {
676
913
  let chosen = -1;
677
914
  for (let index = 0; index < countryCount; index += 1) {
678
- frontiers[index] = frontiers[index].filter((id) => provinces.kind[id] === LAND && !owner.has(id));
915
+ frontiers[index] = frontiers[index].filter((id) => provinces.kind[id] === LAND && !owner.has(id) && !blockedFor(index, id));
679
916
  if (frontiers[index].length === 0)
680
917
  continue;
681
918
  if (chosen < 0 || owned[index] / shares[index] < owned[chosen] / shares[chosen])
@@ -691,9 +928,21 @@ export function generateWorld(spec, context) {
691
928
  if (!owner.has(neighbor))
692
929
  frontier.push(neighbor);
693
930
  }
931
+ // Land no workspace country reached follows the island rule; the rest of the land is unowned.
932
+ if (realmRules !== undefined)
933
+ assignUnreachedRealmLand({
934
+ landIds,
935
+ owner,
936
+ unowned: unownedLand,
937
+ capitals,
938
+ excluded: realmRules.excluded,
939
+ islands: spec.realms?.islands ?? 'nearest',
940
+ part: (id) => landParts.get(id),
941
+ center,
942
+ });
694
943
  // Islands no country reached go to the nearest capital's country.
695
944
  for (const id of landIds)
696
- if (!owner.has(id)) {
945
+ if (!owner.has(id) && !unownedLand.has(id)) {
697
946
  const c = center(id);
698
947
  let best = 0;
699
948
  for (const [index, capital] of capitals.entries())
@@ -704,8 +953,12 @@ export function generateWorld(spec, context) {
704
953
  }
705
954
  // Strategic regions: land regions over the land graph, sea regions over the sea graph.
706
955
  const graph = (id) => provinces.neighbors[id];
707
- const landRegion = growClusters(landIds, graph, spec.landProvincesPerRegion, random);
708
- const seaRegion = growClusters(seaIds, graph, spec.seaProvincesPerRegion, random);
956
+ const landRegion = featurePartition === undefined
957
+ ? growClusters(landIds, graph, spec.landProvincesPerRegion, random)
958
+ : clusterRegions(featurePartition, landIds, 'land');
959
+ const seaRegion = featurePartition === undefined
960
+ ? growClusters(seaIds, graph, spec.seaProvincesPerRegion, random)
961
+ : clusterRegions(featurePartition, seaIds, 'sea');
709
962
  const regionOf = new Map();
710
963
  const landRegionCount = Math.max(-1, ...landRegion.values()) + 1;
711
964
  for (const [id, region] of landRegion)
@@ -725,7 +978,7 @@ export function generateWorld(spec, context) {
725
978
  // States: within each region and country, clusters of about provincesPerState.
726
979
  const groups = new Map();
727
980
  for (const id of landIds) {
728
- const key = `${regionOf.get(id)}:${owner.get(id)}`;
981
+ const key = `${regionOf.get(id)}:${owner.get(id) ?? -1}`;
729
982
  const list = groups.get(key) ?? [];
730
983
  list.push(id);
731
984
  groups.set(key, list);
@@ -734,7 +987,9 @@ export function generateWorld(spec, context) {
734
987
  const stateOf = new Map();
735
988
  for (const key of [...groups.keys()].sort()) {
736
989
  const members = groups.get(key);
737
- const clusters = growClusters(members, graph, spec.provincesPerState, random);
990
+ const clusters = featurePartition === undefined
991
+ ? growClusters(members, graph, spec.provincesPerState, random)
992
+ : clusterStates(featurePartition, members);
738
993
  const byCluster = new Map();
739
994
  for (const [id, cluster] of clusters) {
740
995
  const list = byCluster.get(cluster) ?? [];
@@ -747,7 +1002,7 @@ export function generateWorld(spec, context) {
747
1002
  const draft = {
748
1003
  id: states.length + 1,
749
1004
  provinces: sorted,
750
- owner: spec.countries[countryIndex].tag,
1005
+ ...(countryIndex < 0 ? {} : { owner: spec.countries[countryIndex].tag }),
751
1006
  region,
752
1007
  capital: sorted[0],
753
1008
  manpower: 0,
@@ -808,7 +1063,7 @@ export function generateWorld(spec, context) {
808
1063
  }[terrain.get(id) ?? 'plains'] ?? 0.5;
809
1064
  return sum + value * provinces.size[id];
810
1065
  }, 0) / pixels;
811
- const isCapital = countryCapitalState.get(state.owner) === state;
1066
+ const isCapital = state.owner !== undefined && countryCapitalState.get(state.owner) === state;
812
1067
  const development = Math.min(1, habitability * (0.5 + random.next()) * (isCapital ? 1.8 : 1));
813
1068
  state.manpower = Math.max(1_000, Math.round((pixels * 180 * development) / 1_000) * 1_000);
814
1069
  const categoryIndex = Math.max(0, Math.min(categories.length - 1, Math.round(development * 5.2 + (isCapital ? 1 : 0))));
@@ -917,6 +1172,7 @@ export function generateWorld(spec, context) {
917
1172
  const files = [];
918
1173
  const add = (relativePath, content, mediaType = 'text/plain') => files.push({ relativePath, content, mediaType });
919
1174
  const countryNames = new Map(spec.countries.map(({ tag, name }) => [tag, name]));
1175
+ const ownerName = (state) => state.owner === undefined ? 'Free Land' : (countryNames.get(state.owner) ?? state.owner);
920
1176
  const map = context.mapRoot;
921
1177
  // provinces.bmp
922
1178
  const rgb = new Array(width * height);
@@ -951,10 +1207,27 @@ export function generateWorld(spec, context) {
951
1207
  '',
952
1208
  ].join('\n')));
953
1209
  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'));
1210
+ const rimRows = edge.mode === 'rim'
1211
+ ? rimAdjacencyRows({
1212
+ provincePixels: provinces.pixel,
1213
+ provinceKinds: provinces.kind,
1214
+ width,
1215
+ height,
1216
+ })
1217
+ : [];
1218
+ if (rimRows.length > 0)
1219
+ notes.push({
1220
+ code: 'MAP_EDGE_RIM_ADJACENCIES',
1221
+ severity: 'info',
1222
+ message: `The rim edge policy wrote ${rimRows.length} impassable adjacency row(s) for provinces that meet across the seam`,
1223
+ details: { rows: rimRows.length },
1224
+ });
1225
+ 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
1226
  add(`${map}/adjacency_rules.txt`, text('# No straits or canals in this world yet.\n'));
956
1227
  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'));
1228
+ add(`${map}/ambient_object.txt`, text(spec.ambientFrame === true
1229
+ ? ambientObjectText(width, height)
1230
+ : '# No ambient objects: the base-game map frame does not fit this map.\n'));
958
1231
  // City groups use palette 15 only for urban footprints. Water and undeveloped land stay 0.
959
1232
  const cityPixels = new Uint8Array(width * height);
960
1233
  const nearestCenter = new Int32Array(count).fill(-1);
@@ -996,33 +1269,90 @@ export function generateWorld(spec, context) {
996
1269
  const type = terrain.get(provinces.pixel[pixel]) ?? 'plains';
997
1270
  return type === 'urban' ? 'plains' : type;
998
1271
  };
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
- }
1272
+ // heightmap.bmp: sea below 95, land from 96 up. Lakes sit at the sea's interior height with a
1273
+ // one to two pixel translucent bank, so the water layer draws them opaque (ADR 0053).
1274
+ const landDistance = landDistanceField(surface.kind, width, height);
1275
+ const heights = synthesized?.heights ?? new Uint8Array(width * height);
1276
+ if (synthesized === undefined)
1277
+ for (let pixel = 0; pixel < width * height; pixel += 1) {
1278
+ const value = surface.elevation[pixel];
1279
+ heights[pixel] =
1280
+ surface.kind[pixel] === LAND
1281
+ ? rimHeight(Math.min(255, Math.round(96 + value * 110)), rimStrength(edge, width, pixel % width))
1282
+ : surface.kind[pixel] === LAKE
1283
+ ? lakeHeight(landDistance[pixel])
1284
+ : Math.max(60, Math.round(94 + value * 30));
1285
+ }
1010
1286
  const grey = Array.from({ length: 256 }, (_, index) => ({ r: index, g: index, b: index }));
1011
1287
  add(`${map}/heightmap.bmp`, createBmp({ width, height, bitsPerPixel: 8, palette: grey, indexedPixels: heights }), 'image/bmp');
1012
1288
  // terrain.bmp from province terrain, with snow on high-latitude mountains.
1013
1289
  const terrainIndex = context.terrainIndex ?? DEFAULT_TERRAIN_INDEX;
1290
+ // Lake pixels use the ocean's graphical terrain: the lake entry names no atlas tile.
1291
+ const lakeIndex = terrainIndex.get('lakes') ?? terrainIndex.get('ocean') ?? 15;
1292
+ const calibrated = spec.colormapProfile === 'calibrated';
1293
+ const farmlandIndex = calibrated ? terrainVariant(context, 5, 'plains') : undefined;
1294
+ const sparseForestIndex = calibrated ? terrainVariant(context, 4, 'forest') : undefined;
1295
+ const mountainGrassIndex = calibrated ? terrainVariant(context, 20, 'mountain') : undefined;
1014
1296
  const terrainPixels = new Uint8Array(width * height);
1297
+ const colourClasses = calibrated ? new Uint8Array(width * height) : undefined;
1298
+ const classCode = (name) => COLOUR_CLASSES.indexOf(name);
1299
+ const flatSlope = (x, y) => Math.abs(heights[y * width + Math.min(width - 1, x + 1)] - heights[y * width + Math.max(0, x - 1)]) +
1300
+ Math.abs(heights[Math.min(height - 1, y + 1) * width + x] -
1301
+ heights[Math.max(0, y - 1) * width + x]) <=
1302
+ 2;
1015
1303
  for (let pixel = 0; pixel < width * height; pixel += 1) {
1016
1304
  const id = provinces.pixel[pixel];
1017
1305
  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
1306
+ const snowy = synthesized !== undefined
1307
+ ? (synthesized.snow[pixel] === 1 || reference?.terrain[pixel] === 4) &&
1308
+ surface.kind[pixel] === LAND
1309
+ : reference === undefined
1310
+ ? type === 'mountain' && latitude(id) > 0.7
1311
+ : reference.terrain[pixel] === 4 && surface.kind[pixel] === LAND;
1312
+ const x = pixel % width;
1313
+ const rimmed = rimStrength(edge, width, x) > 0.35 && surface.kind[pixel] === LAND;
1314
+ let index = snowy
1022
1315
  ? reference === undefined
1023
1316
  ? 16
1024
1317
  : 19
1025
- : (terrainIndex.get(type) ?? DEFAULT_TERRAIN_INDEX.get(type) ?? 0);
1318
+ : rimmed
1319
+ ? 16
1320
+ : type === 'lakes'
1321
+ ? lakeIndex
1322
+ : (terrainIndex.get(type) ?? DEFAULT_TERRAIN_INDEX.get(type) ?? 0);
1323
+ let colour = type === 'lakes' || type === 'ocean' || type === 'urban'
1324
+ ? type === 'urban'
1325
+ ? 'urban'
1326
+ : type
1327
+ : snowy || rimmed
1328
+ ? 'snow'
1329
+ : type;
1330
+ if (calibrated && !snowy && !rimmed && surface.kind[pixel] === LAND) {
1331
+ // Variant graphical terrains by rule, so one class is not one flat texture.
1332
+ const y = (pixel - x) / width;
1333
+ if (type === 'plains' &&
1334
+ farmlandIndex !== undefined &&
1335
+ surface.moisture[pixel] >= 0.6 &&
1336
+ flatSlope(x, y)) {
1337
+ index = farmlandIndex;
1338
+ colour = 'farmland';
1339
+ }
1340
+ else if (type === 'forest' &&
1341
+ sparseForestIndex !== undefined &&
1342
+ fractal(spec.seed + 7001, x, y, Math.max(16, width / 30), 3) < 0.42)
1343
+ index = sparseForestIndex;
1344
+ else if (type === 'mountain' &&
1345
+ mountainGrassIndex !== undefined &&
1346
+ surface.moisture[pixel] > 0.45 &&
1347
+ fractal(spec.seed + 7919, x, y, Math.max(16, width / 20), 3) > 0.5)
1348
+ index = mountainGrassIndex;
1349
+ }
1350
+ terrainPixels[pixel] = index;
1351
+ if (colourClasses !== undefined) {
1352
+ if (!COLOUR_CLASSES.includes(colour))
1353
+ colour = 'plains';
1354
+ colourClasses[pixel] = classCode(colour);
1355
+ }
1026
1356
  }
1027
1357
  add(`${map}/terrain.bmp`, createBmp({
1028
1358
  width,
@@ -1098,23 +1428,33 @@ export function generateWorld(spec, context) {
1098
1428
  palette: bmpPalette(context.palettes.cities, (index) => ({ r: index, g: index, b: index })),
1099
1429
  indexedPixels: cityPixels,
1100
1430
  }), 'image/bmp');
1101
- // world_normal.bmp at half size from the height gradient.
1431
+ // world_normal.bmp at half size. Geography worlds shade from the float height field at the
1432
+ // base game's response; every other world from the quantised heights.
1102
1433
  const halfWidth = width / 2;
1103
1434
  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');
1435
+ if (synthesized !== undefined)
1436
+ add(`${map}/world_normal.bmp`, createBmp({
1437
+ width: halfWidth,
1438
+ height: halfHeight,
1439
+ bitsPerPixel: 24,
1440
+ rgbBytes: synthesized.normals,
1441
+ }), 'image/bmp');
1442
+ else {
1443
+ const normals = new Array(halfWidth * halfHeight);
1444
+ const heightAt = (x, y) => heights[Math.min(height - 1, Math.max(0, y)) * width + Math.min(width - 1, Math.max(0, x))];
1445
+ for (let y = 0; y < halfHeight; y += 1)
1446
+ for (let x = 0; x < halfWidth; x += 1) {
1447
+ const dx = (heightAt(x * 2 + 2, y * 2) - heightAt(x * 2 - 2, y * 2)) / 4;
1448
+ const dy = (heightAt(x * 2, y * 2 - 2) - heightAt(x * 2, y * 2 + 2)) / 4;
1449
+ const length = Math.hypot(dx, dy, 8);
1450
+ normals[y * halfWidth + x] = {
1451
+ r: Math.round(128 - (dx / length) * 127),
1452
+ g: Math.round(128 + (dy / length) * 127),
1453
+ b: Math.round(128 + (8 / length) * 127),
1454
+ };
1455
+ }
1456
+ add(`${map}/world_normal.bmp`, createBmp({ width: halfWidth, height: halfHeight, bitsPerPixel: 24, rgbPixels: normals }), 'image/bmp');
1457
+ }
1118
1458
  // Colour maps: land tint and city lights at half size; water at half, quarter and eighth.
1119
1459
  const tint = {
1120
1460
  plains: { r: 121, g: 133, b: 76 },
@@ -1128,36 +1468,65 @@ export function generateWorld(spec, context) {
1128
1468
  lakes: { r: 70, g: 96, b: 110 },
1129
1469
  ocean: { r: 50, g: 70, b: 92 },
1130
1470
  };
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;
1471
+ let colormap;
1472
+ if (colourClasses !== undefined) {
1473
+ const lights = new Uint8Array(width * height);
1474
+ for (let pixel = 0; pixel < lights.length; pixel += 1)
1475
+ if (cityPixels[pixel] === 15)
1476
+ lights[pixel] = 200;
1477
+ colormap = calibratedColourMap({
1478
+ width,
1479
+ height,
1480
+ seed: spec.seed,
1481
+ classes: colourClasses,
1482
+ moisture: surface.moisture,
1483
+ heights,
1484
+ lights,
1485
+ look: spec.look ?? 'vanilla-temperate',
1486
+ ...(spec.colormapPalette === undefined ? {} : { overrides: spec.colormapPalette }),
1487
+ });
1488
+ }
1489
+ else {
1490
+ colormap = new Uint8Array(halfWidth * halfHeight * 4);
1491
+ for (let y = 0; y < halfHeight; y += 1)
1492
+ for (let x = 0; x < halfWidth; x += 1) {
1493
+ // DDS rows run top to bottom, like the bitmaps' top-left coordinates used here.
1494
+ const source = y * 2 * width + x * 2;
1495
+ const color = tint[visualTerrain(source)];
1496
+ const shade = 0.85 + surface.moisture[source] * 0.3;
1497
+ const offset = (y * halfWidth + x) * 4;
1498
+ colormap[offset] = Math.min(255, Math.round(color.b * shade));
1499
+ colormap[offset + 1] = Math.min(255, Math.round(color.g * shade));
1500
+ colormap[offset + 2] = Math.min(255, Math.round(color.r * shade));
1501
+ colormap[offset + 3] = cityPixels[source] === 15 ? 200 : 0;
1158
1502
  }
1159
- add(`${map}/terrain/colormap_water_${level}.dds`, encodeDds(levelWidth, levelHeight, water), 'image/vnd-ms.dds');
1160
1503
  }
1504
+ if (edge.mode === 'rim')
1505
+ for (let y = 0; y < halfHeight; y += 1)
1506
+ for (let x = 0; x < halfWidth; x += 1) {
1507
+ const strength = rimStrength(edge, width, x * 2);
1508
+ if (strength === 0 || surface.kind[y * 2 * width + x * 2] !== LAND)
1509
+ continue;
1510
+ const factor = 1 - (1 - RIM_COLOUR_FACTOR) * strength;
1511
+ const offset = (y * halfWidth + x) * 4;
1512
+ colormap[offset] = Math.round(colormap[offset] * factor);
1513
+ colormap[offset + 1] = Math.round(colormap[offset + 1] * factor);
1514
+ colormap[offset + 2] = Math.round(colormap[offset + 2] * factor);
1515
+ }
1516
+ add(`${map}/terrain/colormap_rgb_cityemissivemask_a.dds`, encodeDds(halfWidth, halfHeight, colormap), 'image/vnd-ms.dds');
1517
+ // Water colour maps: sea tint by distance from land, lake tint with a shore gradient, and the
1518
+ // darkest tint at the world edge (ADR 0053, 0061).
1519
+ for (const level of [0, 1, 2])
1520
+ add(`${map}/terrain/colormap_water_${level}.dds`, encodeDds(width / 2 ** (level + 1), height / 2 ** (level + 1), waterColourLevel({
1521
+ width,
1522
+ height,
1523
+ kind: surface.kind,
1524
+ landDistance,
1525
+ seed: spec.seed,
1526
+ ...(edge.mode === 'none'
1527
+ ? {}
1528
+ : { darken: (x, y) => edgeWaterDarkness(edge, width, height, x, y) }),
1529
+ }, level)), 'image/vnd-ms.dds');
1161
1530
  // Strategic regions with weather by latitude.
1162
1531
  const regionMembers = new Map();
1163
1532
  for (const [id, region] of regionOf) {
@@ -1207,8 +1576,9 @@ export function generateWorld(spec, context) {
1207
1576
  `\tresources = {\n${resources.map(([resource, amount]) => `\t\t${resource} = ${amount}`).join('\n')}\n\t}`,
1208
1577
  ]),
1209
1578
  '\thistory = {',
1210
- `\t\towner = ${state.owner}`,
1211
- `\t\tadd_core_of = ${state.owner}`,
1579
+ ...(state.owner === undefined
1580
+ ? []
1581
+ : [`\t\towner = ${state.owner}`, `\t\tadd_core_of = ${state.owner}`]),
1212
1582
  ...[...state.victoryPoints].map(([id, value]) => `\t\tvictory_points = { ${provinceId(id)} ${value} }`),
1213
1583
  '\t\tbuildings = {',
1214
1584
  ...buildings,
@@ -1223,7 +1593,8 @@ export function generateWorld(spec, context) {
1223
1593
  const countryLocalisation = [];
1224
1594
  const countryLines = [];
1225
1595
  const countryColors = [];
1226
- for (const [index, country] of spec.countries.entries()) {
1596
+ // Workspace-owned countries keep their own tags, files, histories, flags and names.
1597
+ for (const [index, country] of (spec.realms === undefined ? spec.countries : []).entries()) {
1227
1598
  const color = country.color ?? {
1228
1599
  r: 60 + random.int(170),
1229
1600
  g: 60 + random.int(170),
@@ -1322,10 +1693,12 @@ export function generateWorld(spec, context) {
1322
1693
  add(`gfx/flags/${folder}${country.tag}.tga`, encodeTga(flagWidth, flagHeight, (_x, y) => y < flagHeight / 3 ? light : y < (flagHeight * 2) / 3 ? color : dark), 'image/x-tga');
1323
1694
  void index;
1324
1695
  }
1325
- add('common/country_tags/zz_world_countries.txt', text(`${countryLines.join('\n')}\n`));
1326
- add('common/countries/colors.txt', text(`${countryColors.join('\n')}\n`));
1696
+ if (spec.realms === undefined) {
1697
+ add('common/country_tags/zz_world_countries.txt', text(`${countryLines.join('\n')}\n`));
1698
+ add('common/countries/colors.txt', text(`${countryColors.join('\n')}\n`));
1699
+ }
1327
1700
  if (spec.startingScenario) {
1328
- for (const tag of context.inheritedCountryTags ?? [])
1701
+ for (const tag of spec.realms === undefined ? (context.inheritedCountryTags ?? []) : [])
1329
1702
  if (!countryNames.has(tag))
1330
1703
  add(`history/countries/${tag} - inactive world.txt`, text('# Inherited tag has no territory or active starting country in this world.\n'));
1331
1704
  add('common/on_actions/world.txt', text('# Old-world startup hooks are replaced for this generated map.\non_actions = { }\n'));
@@ -1339,34 +1712,53 @@ export function generateWorld(spec, context) {
1339
1712
  states
1340
1713
  .filter((state) => state.owner === left.tag)
1341
1714
  .reduce((sum, state) => sum + state.manpower, 0) || (left.tag < right.tag ? -1 : 1));
1342
- const majors = ranked.slice(0, Math.min(7, ranked.length));
1343
- add('common/bookmarks/world.txt', text([
1344
- 'bookmarks = {',
1345
- '\tbookmark = {',
1346
- '\t\tname = "WORLD_BOOKMARK_NAME"',
1347
- '\t\tdesc = "WORLD_BOOKMARK_DESC"',
1348
- `\t\tdate = ${spec.startDate}.12`,
1349
- '\t\tpicture = "GFX_select_date_1936"',
1350
- `\t\tdefault_country = "${majors[0].tag}"`,
1351
- '\t\tdefault = yes',
1352
- ...majors.map((country) => `\t\t"${country.tag}" = {\n\t\t\thistory = "${country.tag}_WORLD_DESC"\n\t\t\tideology = ${country.ideology ?? 'neutrality'}\n\t\t}`),
1353
- '\t\t"---" = {\n\t\t\thistory = "WORLD_OTHER_DESC"\n\t\t}',
1354
- '\t\teffect = {\n\t\t\trandomize_weather = 22345\n\t\t}',
1355
- '\t}',
1356
- '}',
1357
- '',
1358
- ].join('\n')));
1715
+ const majors = spec.realms === undefined ? ranked.slice(0, Math.min(7, ranked.length)) : [];
1716
+ if (spec.realms === undefined)
1717
+ add('common/bookmarks/world.txt', text([
1718
+ 'bookmarks = {',
1719
+ '\tbookmark = {',
1720
+ '\t\tname = "WORLD_BOOKMARK_NAME"',
1721
+ '\t\tdesc = "WORLD_BOOKMARK_DESC"',
1722
+ `\t\tdate = ${spec.startDate}.12`,
1723
+ '\t\tpicture = "GFX_select_date_1936"',
1724
+ `\t\tdefault_country = "${majors[0].tag}"`,
1725
+ '\t\tdefault = yes',
1726
+ ...majors.map((country) => `\t\t"${country.tag}" = {\n\t\t\thistory = "${country.tag}_WORLD_DESC"\n\t\t\tideology = ${country.ideology ?? 'neutrality'}\n\t\t}`),
1727
+ '\t\t"---" = {\n\t\t\thistory = "WORLD_OTHER_DESC"\n\t\t}',
1728
+ '\t\teffect = {\n\t\t\trandomize_weather = 22345\n\t\t}',
1729
+ '\t}',
1730
+ '}',
1731
+ '',
1732
+ ].join('\n')));
1733
+ const realmSummary = () => {
1734
+ const landPixels = landIds.reduce((sum, id) => sum + provinces.size[id], 0);
1735
+ const weights = spec.countries.reduce((sum, country) => sum + (country.share ?? 1), 0);
1736
+ return {
1737
+ countries: spec.countries.map((country, index) => {
1738
+ const ownedStates = states.filter((state) => state.owner === country.tag);
1739
+ const pixels = ownedStates.reduce((sum, state) => sum + state.provinces.reduce((inner, id) => inner + provinces.size[id], 0), 0);
1740
+ return {
1741
+ tag: country.tag,
1742
+ capitalState: countryCapitalState.get(country.tag).id,
1743
+ capitalProvince: provinceId(capitals[index]),
1744
+ states: ownedStates.map(({ id }) => id),
1745
+ provinces: ownedStates.reduce((sum, state) => sum + state.provinces.length, 0),
1746
+ landShare: Math.round((pixels / landPixels) * 10_000) / 10_000,
1747
+ targetShare: Math.round(((country.share ?? 1) / weights) * 10_000) / 10_000,
1748
+ };
1749
+ }),
1750
+ unownedStates: states.filter((state) => state.owner === undefined).map(({ id }) => id),
1751
+ };
1752
+ };
1359
1753
  // Localisation for everything named above.
1360
1754
  add(`${context.localisationRoot}/replace/world_l_english.yml`, localisation([
1361
1755
  ...states.map((state) => [
1362
1756
  `STATE_${state.id}`,
1363
- stateNames.get(state.id) ??
1364
- `${countryNames.get(state.owner) ?? state.owner} District ${state.id}`,
1757
+ stateNames.get(state.id) ?? `${ownerName(state)} District ${state.id}`,
1365
1758
  ]),
1366
1759
  ...states.flatMap((state) => [...state.victoryPoints.keys()].map((id) => [
1367
1760
  `VICTORY_POINTS_${provinceId(id)}`,
1368
- cityNames.get(id) ??
1369
- `${countryNames.get(state.owner) ?? state.owner} Settlement ${provinceId(id)}`,
1761
+ cityNames.get(id) ?? `${ownerName(state)} Settlement ${provinceId(id)}`,
1370
1762
  ])),
1371
1763
  ...[...regionMembers.keys()]
1372
1764
  .sort((a, b) => a - b)
@@ -1374,9 +1766,13 @@ export function generateWorld(spec, context) {
1374
1766
  ...(spec.referenceLabels ?? []).map((label, index) => [`WORLD_GEOGRAPHY_${index + 1}`, label.name]),
1375
1767
  ...continentNames.map((name, index) => [`world_continent_${index + 1}`, name]),
1376
1768
  ...countryLocalisation,
1377
- ['WORLD_BOOKMARK_NAME', spec.worldName ?? 'A New World'],
1378
- ['WORLD_BOOKMARK_DESC', 'Nations of an unfamiliar map at the dawn of the war.'],
1379
- ['WORLD_OTHER_DESC', 'Any other nation of this world.'],
1769
+ ...(spec.realms === undefined
1770
+ ? [
1771
+ ['WORLD_BOOKMARK_NAME', spec.worldName ?? 'A New World'],
1772
+ ['WORLD_BOOKMARK_DESC', 'Nations of an unfamiliar map at the dawn of the war.'],
1773
+ ['WORLD_OTHER_DESC', 'Any other nation of this world.'],
1774
+ ]
1775
+ : []),
1380
1776
  ...majors.map((country) => [
1381
1777
  `${country.tag}_WORLD_DESC`,
1382
1778
  `${country.name} begins the game among the strongest nations.`,
@@ -1385,6 +1781,8 @@ export function generateWorld(spec, context) {
1385
1781
  return {
1386
1782
  files,
1387
1783
  warnings: [...interpretationWarnings, ...drainage.warnings],
1784
+ notes,
1785
+ ...(synthesized === undefined ? {} : { terrain: synthesized }),
1388
1786
  summary: {
1389
1787
  width,
1390
1788
  height,
@@ -1397,5 +1795,6 @@ export function generateWorld(spec, context) {
1397
1795
  countries: spec.countries.length,
1398
1796
  rivers: riverCount,
1399
1797
  },
1798
+ ...(spec.realms === undefined ? {} : { realms: realmSummary() }),
1400
1799
  };
1401
1800
  }