hoi4-agent-tools 4.0.4 → 4.1.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 (85) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -0
  3. package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
  4. package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
  5. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  6. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  7. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  8. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  9. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  10. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  11. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  12. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  13. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  14. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  15. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  16. package/dist/hoi4_agent_tools/gui/layout.js +61 -25
  17. package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
  18. package/dist/hoi4_agent_tools/gui/source-graph.js +9 -6
  19. package/dist/hoi4_agent_tools/gui/studio.js +4 -1
  20. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  21. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  22. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  23. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  24. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  25. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  26. package/dist/hoi4_agent_tools/map/render.js +9 -1
  27. package/dist/hoi4_agent_tools/map/service.js +222 -18
  28. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  29. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  30. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  31. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  32. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  33. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  34. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  35. package/dist/hoi4_agent_tools/map/world.js +374 -253
  36. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  37. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  38. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  39. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  40. package/dist/hoi4_agent_tools/version.js +2 -2
  41. package/docs/gui.md +6 -2
  42. package/docs/images/reference-maps/political.png +0 -0
  43. package/docs/images/reference-maps/sketch.png +0 -0
  44. package/docs/map.md +45 -0
  45. package/docs/third-party-notices.md +6 -0
  46. package/npm-shrinkwrap.json +124 -2
  47. package/package.json +4 -1
  48. package/schemas/configuration.schema.json +1 -1
  49. package/schemas/continuous-focus-palette.schema.json +1 -1
  50. package/schemas/custom-weighted-pool.schema.json +1 -1
  51. package/schemas/decision-inspect-input.schema.json +1 -1
  52. package/schemas/error-log-input.schema.json +1 -1
  53. package/schemas/event-feature-manifest.schema.json +1 -1
  54. package/schemas/focus-plan.schema.json +1 -1
  55. package/schemas/focus-planning-sidecar.schema.json +1 -1
  56. package/schemas/gui-animation-source.schema.json +1 -1
  57. package/schemas/gui-helper.schema.json +1 -1
  58. package/schemas/gui-scenario.schema.json +1 -1
  59. package/schemas/helper-expansion-request.schema.json +1 -1
  60. package/schemas/helper-expansion-summary.schema.json +1 -1
  61. package/schemas/impact-inspect-input.schema.json +1 -1
  62. package/schemas/map-operation.schema.json +373 -70
  63. package/schemas/mechanic-test-input.schema.json +1 -1
  64. package/schemas/mod-index-input.schema.json +1 -1
  65. package/schemas/operation-result.schema.json +1 -1
  66. package/schemas/package-check-input.schema.json +1 -1
  67. package/schemas/package-manifest.schema.json +1 -1
  68. package/schemas/probability-analysis-result.schema.json +1 -1
  69. package/schemas/probability-compare-input.schema.json +1 -1
  70. package/schemas/probability-evaluate-input.schema.json +1 -1
  71. package/schemas/probability-inspect-input.schema.json +1 -1
  72. package/schemas/probability-render-input.schema.json +1 -1
  73. package/schemas/probability-scenario-set.schema.json +1 -1
  74. package/schemas/probability-sequence-input.schema.json +1 -1
  75. package/schemas/probability-simulate-input.schema.json +1 -1
  76. package/schemas/probability-sweep-input.schema.json +1 -1
  77. package/schemas/reference-context-input.schema.json +1 -1
  78. package/schemas/reference-read-input.schema.json +1 -1
  79. package/schemas/reference-search-input.schema.json +1 -1
  80. package/schemas/scenario-suite.schema.json +1 -1
  81. package/schemas/scenario-test-input.schema.json +1 -1
  82. package/schemas/script-validate-input.schema.json +1 -1
  83. package/schemas/shared-scenario.schema.json +1 -1
  84. package/schemas/source-lookup-input.schema.json +1 -1
  85. package/server.json +2 -2
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import { ServiceError } from '../core/result.js';
9
9
  import { createBmp } from './bmp.js';
10
+ import { generateWorldDrainage } from './world-drainage.js';
11
+ import { generateWorldProvinces } from './world-provinces.js';
10
12
  // ---------------------------------------------------------------------------------------------
11
13
  // Deterministic randomness and noise
12
14
  class Random {
@@ -191,199 +193,17 @@ function generateSurface(spec, random) {
191
193
  }
192
194
  }
193
195
  for (let pixel = 0; pixel < total; pixel += 1) {
194
- const inland = Math.min(1, (coastDistance[pixel] ?? 0) / 40);
196
+ const inland = Math.min(1, (coastDistance[pixel] ?? 0) / 80);
195
197
  const x = pixel % width;
196
198
  const y = (pixel - x) / width;
197
199
  const ridges = Math.abs(fractal(spec.seed + 104729, x, y, scale / 2, 4) - 0.5) * 2;
198
200
  elevation[pixel] =
199
201
  kind[pixel] === LAND
200
- ? Math.min(1, 0.08 + inland * 0.35 + Math.pow(1 - ridges, 3) * 0.6 * inland)
202
+ ? Math.min(1, inland * (0.12 + Math.pow(1 - ridges, 5) * 0.65))
201
203
  : -Math.min(1, (coastDistance[pixel] ?? 0) / 30);
202
204
  }
203
205
  return { kind, elevation, moisture };
204
206
  }
205
- function seedsFor(surface, spec, random, wanted, cellPixels) {
206
- const { width, height } = spec;
207
- const cell = Math.max(3, Math.round(Math.sqrt(cellPixels)));
208
- const seeds = [];
209
- for (let cy = 0; cy < height; cy += cell)
210
- for (let cx = 0; cx < width; cx += cell) {
211
- for (let attempt = 0; attempt < 6; attempt += 1) {
212
- const x = Math.min(width - 1, cx + random.int(cell));
213
- const y = Math.min(height - 1, cy + random.int(cell));
214
- const pixel = y * width + x;
215
- if (surface.kind[pixel] === wanted) {
216
- seeds.push(pixel);
217
- break;
218
- }
219
- }
220
- }
221
- return seeds;
222
- }
223
- function generateProvinces(surface, spec, random) {
224
- const { width, height } = spec;
225
- const total = width * height;
226
- const pixel = new Int32Array(total).fill(-1);
227
- const seeds = [
228
- ...seedsFor(surface, spec, random, LAND, spec.landProvincePixels),
229
- ...seedsFor(surface, spec, random, SEA, spec.seaProvincePixels),
230
- ...seedsFor(surface, spec, random, LAKE, spec.landProvincePixels * 2),
231
- ];
232
- // Every connected patch of one kind needs at least one seed.
233
- const parts = components(surface.kind, width, height);
234
- const seeded = new Set(seeds.map((seed) => parts.component[seed]));
235
- for (let start = 0; start < total; start += 1)
236
- if (!seeded.has(parts.component[start])) {
237
- seeds.push(start);
238
- seeded.add(parts.component[start]);
239
- }
240
- // Grow every province at the same pace, only through pixels of its own kind, so each
241
- // province is connected and its shape approximates a Voronoi cell.
242
- let frontier = [];
243
- for (const [id, seed] of seeds.entries()) {
244
- if (pixel[seed] !== -1)
245
- continue;
246
- pixel[seed] = id;
247
- frontier.push(seed);
248
- }
249
- while (frontier.length > 0) {
250
- const next = [];
251
- for (const current of frontier)
252
- for (const neighbor of neighbors4(width, height, current))
253
- if (pixel[neighbor] === -1 && surface.kind[neighbor] === surface.kind[current]) {
254
- pixel[neighbor] = pixel[current];
255
- next.push(neighbor);
256
- }
257
- frontier = next;
258
- }
259
- removeCrossings(pixel, surface.kind, width, height);
260
- return compactProvinces(pixel, surface.kind, spec, width, height);
261
- }
262
- /**
263
- * Remove "X crossings", where four provinces meet at one pixel corner, by giving one pixel of
264
- * the 2x2 block to a neighbour of the same kind.
265
- */
266
- function removeCrossings(pixel, kind, width, height) {
267
- for (let pass = 0; pass < 4; pass += 1) {
268
- let changed = false;
269
- for (let y = 0; y + 1 < height; y += 1)
270
- // The map wraps east to west, so the last column meets the first.
271
- for (let x = 0; x < width; x += 1) {
272
- const a = y * width + x;
273
- const b = x + 1 < width ? a + 1 : y * width;
274
- const c = a + width;
275
- const d = x + 1 < width ? c + 1 : (y + 1) * width;
276
- const ids = new Set([pixel[a], pixel[b], pixel[c], pixel[d]]);
277
- if (ids.size < 4)
278
- continue;
279
- if (kind[d] === kind[b])
280
- pixel[d] = pixel[b];
281
- else if (kind[d] === kind[c])
282
- pixel[d] = pixel[c];
283
- else if (kind[a] === kind[b])
284
- pixel[a] = pixel[b];
285
- else if (kind[a] === kind[c])
286
- pixel[a] = pixel[c];
287
- else
288
- continue;
289
- changed = true;
290
- }
291
- if (!changed)
292
- return;
293
- }
294
- }
295
- /**
296
- * Keep each province's largest connected piece, give smaller pieces and tiny provinces to a
297
- * neighbour of the same kind, and renumber the survivors densely.
298
- */
299
- function compactProvinces(pixel, kind, spec, width, height) {
300
- const total = width * height;
301
- for (let pass = 0; pass < 3; pass += 1) {
302
- const seen = new Int32Array(total).fill(-1);
303
- const best = new Map();
304
- const pieceSizes = [];
305
- const pieceOwner = [];
306
- const queue = new Int32Array(total);
307
- for (let start = 0; start < total; start += 1) {
308
- if (seen[start] !== -1)
309
- continue;
310
- const piece = pieceSizes.length;
311
- let head = 0;
312
- let tail = 0;
313
- queue[tail++] = start;
314
- seen[start] = piece;
315
- while (head < tail) {
316
- const current = queue[head++];
317
- for (const next of neighbors4(width, height, current))
318
- if (seen[next] === -1 && pixel[next] === pixel[start]) {
319
- seen[next] = piece;
320
- queue[tail++] = next;
321
- }
322
- }
323
- pieceSizes.push(tail);
324
- pieceOwner.push(pixel[start]);
325
- const current = best.get(pixel[start]);
326
- if (current === undefined || tail > current.size)
327
- best.set(pixel[start], { piece, size: tail });
328
- }
329
- const minimum = 20;
330
- let changed = false;
331
- for (let current = 0; current < total; current += 1) {
332
- const piece = seen[current];
333
- const owner = pieceOwner[piece];
334
- const keep = best.get(owner).piece === piece && pieceSizes[piece] >= minimum;
335
- if (keep)
336
- continue;
337
- for (const next of neighbors4(width, height, current))
338
- if (seen[next] !== piece && kind[next] === kind[current]) {
339
- pixel[current] = pixel[next];
340
- changed = true;
341
- break;
342
- }
343
- }
344
- if (!changed)
345
- break;
346
- }
347
- removeCrossings(pixel, kind, width, height);
348
- // Dense renumbering in reading order of each province's first pixel.
349
- const renumber = new Map();
350
- const result = {
351
- pixel: new Int32Array(total),
352
- kind: [],
353
- size: [],
354
- sumX: [],
355
- sumY: [],
356
- neighbors: [],
357
- };
358
- for (let current = 0; current < total; current += 1) {
359
- let id = renumber.get(pixel[current]);
360
- if (id === undefined) {
361
- id = result.kind.length;
362
- renumber.set(pixel[current], id);
363
- result.kind.push(kind[current]);
364
- result.size.push(0);
365
- result.sumX.push(0);
366
- result.sumY.push(0);
367
- result.neighbors.push(new Set());
368
- }
369
- result.pixel[current] = id;
370
- result.size[id] += 1;
371
- result.sumX[id] += current % width;
372
- result.sumY[id] += Math.floor(current / width);
373
- }
374
- for (let current = 0; current < total; current += 1) {
375
- const id = result.pixel[current];
376
- for (const next of neighbors4(width, height, current)) {
377
- const other = result.pixel[next];
378
- if (other !== id)
379
- result.neighbors[id].add(other);
380
- }
381
- }
382
- if (result.kind.length > 65_000)
383
- throw new ServiceError('MAP_WORLD_TOO_MANY_PROVINCES', 'The world would have more provinces than the game can draw; raise landProvincePixels or seaProvincePixels', { provinces: result.kind.length });
384
- void spec;
385
- return result;
386
- }
387
207
  // ---------------------------------------------------------------------------------------------
388
208
  // Grouping provinces into regions and states
389
209
  /** Grow clusters of about `target` members over a graph from spread-out seeds. */
@@ -430,6 +250,10 @@ function growClusters(members, neighbors, target, random, weight = () => 1) {
430
250
  }
431
251
  }
432
252
  }
253
+ // Absorption can empty a cluster. Native region identifiers and reported counts must agree.
254
+ const dense = new Map([...new Set(cluster.values())].sort((a, b) => a - b).map((id, index) => [id, index]));
255
+ for (const [member, id] of cluster)
256
+ cluster.set(member, dense.get(id));
433
257
  return cluster;
434
258
  }
435
259
  // ---------------------------------------------------------------------------------------------
@@ -482,7 +306,11 @@ function text(value) {
482
306
  return Buffer.from(value, 'utf8');
483
307
  }
484
308
  function localisation(entries) {
485
- const escape = (value) => value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
309
+ const escape = (value) => value
310
+ .replaceAll('\\', '\\\\')
311
+ .replaceAll('"', '\\"')
312
+ .replaceAll('\r', '\\r')
313
+ .replaceAll('\n', '\\n');
486
314
  return Buffer.from(`\ufeffl_english:\n${entries.map(([key, value]) => ` ${key}: "${escape(value)}"`).join('\n')}\n`, 'utf8');
487
315
  }
488
316
  function slug(value) {
@@ -491,7 +319,7 @@ function slug(value) {
491
319
  const DEFAULT_TERRAIN_INDEX = new Map([
492
320
  ['plains', 0],
493
321
  ['forest', 1],
494
- ['hills', 2],
322
+ ['hills', 17],
495
323
  ['desert', 3],
496
324
  ['mountain', 6],
497
325
  ['marsh', 9],
@@ -500,13 +328,14 @@ const DEFAULT_TERRAIN_INDEX = new Map([
500
328
  ['ocean', 15],
501
329
  ['jungle', 21],
502
330
  ]);
503
- /** Approximate 1936 weather for a latitude between 0 (equator) and 1 (pole). */
504
- function weatherBlock(latitude, desert) {
331
+ /** Approximate seasonal weather: signed latitude is positive north of the equator. */
332
+ function weatherBlock(signedLatitude, desert) {
333
+ const latitude = Math.abs(signedLatitude);
505
334
  const periods = [];
506
335
  const days = [30, 27, 30, 29, 30, 29, 30, 30, 29, 30, 29, 30];
507
336
  for (let month = 0; month < 12; month += 1) {
508
337
  const season = Math.cos(((month - 6.5) / 12) * Math.PI * 2);
509
- const mean = 30 - latitude * 45 - season * (6 + latitude * 18);
338
+ const mean = 30 - latitude * 45 + season * (6 + latitude * 18) * Math.sign(signedLatitude);
510
339
  const low = Math.round(mean - 8);
511
340
  const high = Math.round(mean + 8);
512
341
  const snow = Math.max(0, Math.min(0.6, (-mean + 2) / 20));
@@ -550,8 +379,36 @@ export function generateWorld(spec, context) {
550
379
  landProvincePixels: Math.min(spec.landProvincePixels, cellLimit),
551
380
  seaProvincePixels: Math.min(spec.seaProvincePixels, cellLimit),
552
381
  };
553
- const surface = generateSurface(spec, random);
554
- const provinces = generateProvinces(surface, spec, random);
382
+ const reference = spec.referenceSurface;
383
+ if (reference !== undefined && (reference.width !== width || reference.height !== height))
384
+ throw new ServiceError('MAP_WORLD_REFERENCE_DIMENSIONS', 'Reference raster dimensions do not match the world');
385
+ const surface = reference === undefined
386
+ ? generateSurface(spec, random)
387
+ : {
388
+ kind: new Uint8Array(reference.kind),
389
+ elevation: new Float32Array(reference.elevation),
390
+ moisture: new Float32Array(reference.moisture),
391
+ };
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
+ 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
412
  const count = provinces.kind.length;
556
413
  const center = (id) => ({
557
414
  x: provinces.sumX[id] / provinces.size[id],
@@ -603,6 +460,31 @@ export function generateWorld(spec, context) {
603
460
  terrain.set(id, 'ocean');
604
461
  for (const id of lakeIds)
605
462
  terrain.set(id, 'lakes');
463
+ const referenceTerrainNames = [
464
+ 'plains',
465
+ 'forest',
466
+ 'desert',
467
+ 'mountain',
468
+ 'plains',
469
+ 'mountain',
470
+ 'hills',
471
+ 'marsh',
472
+ 'jungle',
473
+ ];
474
+ if (reference !== undefined) {
475
+ const votes = new Uint32Array(count * referenceTerrainNames.length);
476
+ for (let pixel = 0; pixel < width * height; pixel += 1)
477
+ if (surface.kind[pixel] === LAND)
478
+ votes[provinces.pixel[pixel] * referenceTerrainNames.length + reference.terrain[pixel]] += 1;
479
+ for (const id of landIds) {
480
+ let selected = 0;
481
+ for (let type = 1; type < referenceTerrainNames.length; type += 1)
482
+ if (votes[id * referenceTerrainNames.length + type] >
483
+ votes[id * referenceTerrainNames.length + selected])
484
+ selected = type;
485
+ terrain.set(id, referenceTerrainNames[selected]);
486
+ }
487
+ }
606
488
  // Continents: the largest land masses, with islands joining the nearest one.
607
489
  const landParts = new Map();
608
490
  {
@@ -662,10 +544,108 @@ export function generateWorld(spec, context) {
662
544
  // Countries: grow territories from spread-out capitals in proportion to their shares.
663
545
  const owner = new Map();
664
546
  const countryCount = spec.countries.length;
547
+ const interpretationWarnings = [];
548
+ const referenceOwners = new Map();
549
+ if (reference?.inferredCountries.length === countryCount && countryCount > 1) {
550
+ const indexes = new Map();
551
+ const unresolved = [];
552
+ for (const [index, country] of spec.countries.entries()) {
553
+ const color = country.color;
554
+ const matches = color === undefined
555
+ ? []
556
+ : reference.inferredCountries
557
+ .map((region) => ({
558
+ id: region.id,
559
+ distance: (region.color.r - color.r) ** 2 +
560
+ (region.color.g - color.g) ** 2 +
561
+ (region.color.b - color.b) ** 2,
562
+ }))
563
+ .sort((a, b) => a.distance - b.distance || a.id - b.id);
564
+ const colorId = matches[0] !== undefined &&
565
+ matches[0].distance <= 3 * 24 ** 2 &&
566
+ (matches[1] === undefined || matches[0].distance < matches[1].distance)
567
+ ? matches[0].id
568
+ : undefined;
569
+ const city = spec.referenceCities?.find(({ countryTag }) => countryTag === country.tag);
570
+ const cityId = city !== undefined && city.x >= 0 && city.y >= 0 && city.x < width && city.y < height
571
+ ? reference.country[Math.floor(city.y) * width + Math.floor(city.x)]
572
+ : undefined;
573
+ if (colorId !== undefined && cityId !== undefined && cityId > 0 && colorId !== cityId)
574
+ throw new ServiceError('MAP_WORLD_COUNTRY_REFERENCE_CONFLICT', 'A country color and its capital anchor identify different political regions', { countryTag: country.tag });
575
+ const selected = colorId ?? (cityId === 0 ? undefined : cityId);
576
+ if (selected === undefined) {
577
+ unresolved.push(index);
578
+ continue;
579
+ }
580
+ if (indexes.has(selected))
581
+ throw new ServiceError('MAP_WORLD_COUNTRY_REFERENCE_CONFLICT', 'Two supplied countries identify the same political region', { countryTag: country.tag });
582
+ indexes.set(selected, index);
583
+ }
584
+ const remaining = reference.inferredCountries.filter(({ id }) => !indexes.has(id));
585
+ for (const [position, index] of unresolved.entries())
586
+ indexes.set(remaining[position].id, index);
587
+ if (unresolved.length > 0)
588
+ interpretationWarnings.push(`${unresolved.length} country name(s) had no distinct matching color or capital anchor; remaining political regions were assigned in deterministic reference order.`);
589
+ const votes = new Uint32Array(count * countryCount);
590
+ for (let pixel = 0; pixel < width * height; pixel += 1) {
591
+ const selected = indexes.get(reference.country[pixel]);
592
+ if (selected !== undefined && surface.kind[pixel] === LAND)
593
+ votes[provinces.pixel[pixel] * countryCount + selected] += 1;
594
+ }
595
+ for (const id of landIds) {
596
+ let selected = 0;
597
+ for (let country = 1; country < countryCount; country += 1)
598
+ if (votes[id * countryCount + country] > votes[id * countryCount + selected])
599
+ selected = country;
600
+ if (votes[id * countryCount + selected] > 0)
601
+ referenceOwners.set(id, selected);
602
+ }
603
+ }
604
+ const nearestProvince = (point, candidates) => {
605
+ let selected = candidates[0];
606
+ let distance = Number.POSITIVE_INFINITY;
607
+ for (const id of candidates) {
608
+ const c = center(id);
609
+ const next = (c.x - point.x) ** 2 + (c.y - point.y) ** 2;
610
+ if (next < distance) {
611
+ selected = id;
612
+ distance = next;
613
+ }
614
+ }
615
+ return selected;
616
+ };
617
+ const namedCities = (spec.referenceCities ?? []).map((city) => {
618
+ const pixel = city.x >= 0 && city.y >= 0 && city.x < width && city.y < height
619
+ ? Math.floor(city.y) * width + Math.floor(city.x)
620
+ : undefined;
621
+ return {
622
+ ...city,
623
+ province: pixel !== undefined && surface.kind[pixel] === LAND
624
+ ? provinces.pixel[pixel]
625
+ : nearestProvince(city, landIds),
626
+ };
627
+ });
665
628
  const capitals = [];
666
629
  const largest = [...landIds].sort((left, right) => provinces.size[right] - provinces.size[left] || left - right);
667
- capitals.push(largest[random.int(Math.min(largest.length, 8))]);
668
630
  while (capitals.length < countryCount) {
631
+ const countryIndex = capitals.length;
632
+ const regionCandidates = largest.filter((id) => referenceOwners.get(id) === countryIndex && !capitals.includes(id));
633
+ const candidates = regionCandidates.length > 0
634
+ ? regionCandidates
635
+ : largest.filter((id) => !capitals.includes(id));
636
+ const named = namedCities.find((city) => city.countryTag === spec.countries[countryIndex].tag);
637
+ if (named !== undefined) {
638
+ capitals.push(candidates.includes(named.province) ? named.province : nearestProvince(named, candidates));
639
+ continue;
640
+ }
641
+ if (regionCandidates.length > 0) {
642
+ capitals.push(regionCandidates[0]);
643
+ continue;
644
+ }
645
+ if (capitals.length === 0) {
646
+ capitals.push(candidates[random.int(Math.min(candidates.length, 8))]);
647
+ continue;
648
+ }
669
649
  let best = -1;
670
650
  let bestDistance = -1;
671
651
  for (const id of landIds) {
@@ -682,9 +662,14 @@ export function generateWorld(spec, context) {
682
662
  }
683
663
  const shares = spec.countries.map((country) => Math.max(0.05, country.share ?? 1));
684
664
  const owned = new Array(countryCount).fill(0);
665
+ for (const [id, country] of referenceOwners) {
666
+ owner.set(id, country);
667
+ owned[country] += provinces.size[id];
668
+ }
685
669
  const frontiers = capitals.map((capital, index) => {
686
670
  owner.set(capital, index);
687
- owned[index] = provinces.size[capital];
671
+ if (!referenceOwners.has(capital))
672
+ owned[index] += provinces.size[capital];
688
673
  return [...provinces.neighbors[capital]];
689
674
  });
690
675
  for (;;) {
@@ -803,8 +788,11 @@ export function generateWorld(spec, context) {
803
788
  metropolis: 10,
804
789
  };
805
790
  const countryCapitalState = new Map();
806
- for (const [index, capital] of capitals.entries())
807
- countryCapitalState.set(spec.countries[index].tag, stateOf.get(capital));
791
+ for (const [index, capital] of capitals.entries()) {
792
+ const state = stateOf.get(capital);
793
+ state.capital = capital;
794
+ countryCapitalState.set(spec.countries[index].tag, state);
795
+ }
808
796
  for (const state of states) {
809
797
  const pixels = state.provinces.reduce((sum, id) => sum + provinces.size[id], 0);
810
798
  const habitability = state.provinces.reduce((sum, id) => {
@@ -862,6 +850,57 @@ export function generateWorld(spec, context) {
862
850
  }
863
851
  state.victoryPoints.set(state.capital, isCapital ? 25 : Math.max(1, Math.round(development * 8)));
864
852
  }
853
+ const cityNames = new Map();
854
+ for (const city of namedCities) {
855
+ const state = stateOf.get(city.province);
856
+ cityNames.set(city.province, city.name);
857
+ state.victoryPoints.set(city.province, Math.max(5, state.victoryPoints.get(city.province) ?? 0));
858
+ terrain.set(city.province, 'urban');
859
+ }
860
+ // Each coastal territory needs a port even when its capital lies inland or on another island.
861
+ const portChecked = new Set();
862
+ for (const start of landIds) {
863
+ if (portChecked.has(start))
864
+ continue;
865
+ const queue = [start];
866
+ portChecked.add(start);
867
+ let bestCoast;
868
+ let hasPort = false;
869
+ for (const id of queue) {
870
+ if (stateOf.get(id).navalBases.has(id))
871
+ hasPort = true;
872
+ if (coastal(id) &&
873
+ (bestCoast === undefined || provinces.size[id] > provinces.size[bestCoast]))
874
+ bestCoast = id;
875
+ for (const next of provinces.neighbors[id])
876
+ if (!portChecked.has(next) &&
877
+ provinces.kind[next] === LAND &&
878
+ owner.get(next) === owner.get(start)) {
879
+ portChecked.add(next);
880
+ queue.push(next);
881
+ }
882
+ }
883
+ if (!hasPort && bestCoast !== undefined)
884
+ stateOf.get(bestCoast).navalBases.set(bestCoast, 1);
885
+ }
886
+ const stateNames = new Map();
887
+ const regionNames = new Map();
888
+ for (const label of spec.referenceLabels ?? []) {
889
+ if (label.kind === 'state') {
890
+ const nearby = nearestProvince(label, landIds);
891
+ const state = stateOf.get(nearby);
892
+ const previous = stateNames.get(state.id);
893
+ stateNames.set(state.id, previous === undefined ? label.name : `${previous} / ${label.name}`);
894
+ }
895
+ else if (label.kind === 'sea' || label.kind === 'region') {
896
+ const members = label.kind === 'sea' ? seaIds : landIds;
897
+ if (members.length === 0)
898
+ continue;
899
+ const region = regionOf.get(nearestProvince(label, members));
900
+ const previous = regionNames.get(region);
901
+ regionNames.set(region, previous === undefined ? label.name : `${previous} / ${label.name}`);
902
+ }
903
+ }
865
904
  // Province IDs: 1-based in reading order of the province's first pixel; 0 is the null row.
866
905
  const provinceId = (index) => index + 1;
867
906
  // Unique colours, never black.
@@ -877,6 +916,7 @@ export function generateWorld(spec, context) {
877
916
  }
878
917
  const files = [];
879
918
  const add = (relativePath, content, mediaType = 'text/plain') => files.push({ relativePath, content, mediaType });
919
+ const countryNames = new Map(spec.countries.map(({ tag, name }) => [tag, name]));
880
920
  const map = context.mapRoot;
881
921
  // provinces.bmp
882
922
  const rgb = new Array(width * height);
@@ -910,11 +950,52 @@ export function generateWorld(spec, context) {
910
950
  'tree = { 3 4 7 10 }',
911
951
  '',
912
952
  ].join('\n')));
913
- add(`${map}/continent.txt`, text(`continents = {\n${continentNames.map((_, index) => `\tworld_continent_${index + 1}`).join('\n')}\n}\n`));
953
+ 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`));
914
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'));
915
955
  add(`${map}/adjacency_rules.txt`, text('# No straits or canals in this world yet.\n'));
916
956
  add(`${map}/positions.txt`, text(''));
917
957
  add(`${map}/ambient_object.txt`, text('# No ambient objects: the base-game map frame does not fit this map.\n'));
958
+ // City groups use palette 15 only for urban footprints. Water and undeveloped land stay 0.
959
+ const cityPixels = new Uint8Array(width * height);
960
+ const nearestCenter = new Int32Array(count).fill(-1);
961
+ const centerDistance = new Float64Array(count).fill(Number.POSITIVE_INFINITY);
962
+ for (let pixel = 0; pixel < width * height; pixel += 1) {
963
+ if (surface.kind[pixel] !== LAND)
964
+ continue;
965
+ const id = provinces.pixel[pixel];
966
+ const x = pixel % width, y = Math.floor(pixel / width);
967
+ const distance = (x - provinces.sumX[id] / provinces.size[id]) ** 2 +
968
+ (y - provinces.sumY[id] / provinces.size[id]) ** 2;
969
+ if (distance < centerDistance[id]) {
970
+ nearestCenter[id] = pixel;
971
+ centerDistance[id] = distance;
972
+ }
973
+ }
974
+ for (const state of states)
975
+ for (const [id, value] of state.victoryPoints) {
976
+ const pixel = nearestCenter[id];
977
+ const cx = pixel % width, cy = Math.floor(pixel / width);
978
+ const radius = value >= 20 ? 6 : value >= 5 ? 4 : 2;
979
+ for (let dy = -radius; dy <= radius; dy += 1)
980
+ for (let dx = -radius; dx <= radius; dx += 1) {
981
+ const x = cx + dx, y = cy + dy;
982
+ if (x < 0 || x >= width || y < 0 || y >= height || dx * dx + dy * dy > radius * radius)
983
+ continue;
984
+ const target = y * width + x;
985
+ if (provinces.pixel[target] === id && surface.kind[target] === LAND)
986
+ cityPixels[target] = 15;
987
+ }
988
+ }
989
+ const visualTerrain = (pixel) => {
990
+ if (cityPixels[pixel] === 15)
991
+ return 'urban';
992
+ if (surface.kind[pixel] !== LAND)
993
+ return surface.kind[pixel] === LAKE ? 'lakes' : 'ocean';
994
+ if (reference !== undefined)
995
+ return referenceTerrainNames[reference.terrain[pixel]] ?? 'plains';
996
+ const type = terrain.get(provinces.pixel[pixel]) ?? 'plains';
997
+ return type === 'urban' ? 'plains' : type;
998
+ };
918
999
  // heightmap.bmp: sea below 95, land from 96 up.
919
1000
  const heights = new Uint8Array(width * height);
920
1001
  for (let pixel = 0; pixel < width * height; pixel += 1) {
@@ -933,10 +1014,14 @@ export function generateWorld(spec, context) {
933
1014
  const terrainPixels = new Uint8Array(width * height);
934
1015
  for (let pixel = 0; pixel < width * height; pixel += 1) {
935
1016
  const id = provinces.pixel[pixel];
936
- const type = terrain.get(id);
937
- const snowy = type === 'mountain' && latitude(id) > 0.7;
1017
+ 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;
938
1021
  terrainPixels[pixel] = snowy
939
- ? 16
1022
+ ? reference === undefined
1023
+ ? 16
1024
+ : 19
940
1025
  : (terrainIndex.get(type) ?? DEFAULT_TERRAIN_INDEX.get(type) ?? 0);
941
1026
  }
942
1027
  add(`${map}/terrain.bmp`, createBmp({
@@ -951,40 +1036,8 @@ export function generateWorld(spec, context) {
951
1036
  indexedPixels: terrainPixels,
952
1037
  }), 'image/bmp');
953
1038
  // rivers.bmp: 255 on land, 254 at sea, rivers flowing downhill to the coast.
954
- const riverPixels = new Uint8Array(width * height);
955
- for (let pixel = 0; pixel < width * height; pixel += 1)
956
- riverPixels[pixel] = surface.kind[pixel] === LAND ? 255 : 254;
957
- let riverCount = 0;
958
- for (let attempt = 0; attempt < spec.rivers * 30 && riverCount < spec.rivers; attempt += 1) {
959
- let current = random.int(width * height);
960
- if (surface.kind[current] !== LAND || surface.elevation[current] < 0.45)
961
- continue;
962
- const path = [current];
963
- const visited = new Set(path);
964
- let reachedCoast = false;
965
- for (let step = 0; step < width; step += 1) {
966
- const options = neighbors4(width, height, current).filter((next) => !visited.has(next));
967
- if (options.some((next) => surface.kind[next] === SEA)) {
968
- reachedCoast = true;
969
- break;
970
- }
971
- const landOptions = options.filter((next) => surface.kind[next] === LAND && riverPixels[next] === 255);
972
- // A river must not touch another river except at its own previous pixel.
973
- const clear = landOptions.filter((next) => neighbors4(width, height, next).every((around) => around === current || (riverPixels[around] ?? 255) >= 254 || path.includes(around)));
974
- if (clear.length === 0)
975
- break;
976
- clear.sort((left, right) => surface.elevation[left] - surface.elevation[right] || left - right);
977
- current = clear[0];
978
- path.push(current);
979
- visited.add(current);
980
- }
981
- if (!reachedCoast || path.length < 12)
982
- continue;
983
- for (const [position, pixel] of path.entries())
984
- riverPixels[pixel] =
985
- position === 0 ? 0 : Math.min(6, 3 + Math.floor((position / path.length) * 4));
986
- riverCount += 1;
987
- }
1039
+ const riverPixels = drainage.riverPixels;
1040
+ const riverCount = drainage.count;
988
1041
  const riverPalette = bmpPalette(context.palettes.rivers, (index) => index === 0
989
1042
  ? { r: 0, g: 255, b: 0 }
990
1043
  : index === 1
@@ -1011,10 +1064,20 @@ export function generateWorld(spec, context) {
1011
1064
  for (let y = 0; y < treeHeight; y += 1)
1012
1065
  for (let x = 0; x < treeWidth; x += 1) {
1013
1066
  const source = Math.floor((y * height) / treeHeight) * width + Math.floor((x * width) / treeWidth);
1014
- const type = terrain.get(provinces.pixel[source]);
1067
+ const type = visualTerrain(source);
1015
1068
  const dense = surface.moisture[source] > 0.6;
1016
1069
  treePixels[y * treeWidth + x] =
1017
- type === 'forest' ? (dense ? 6 : 5) : type === 'jungle' ? (dense ? 29 : 28) : 0;
1070
+ lattice(spec.seed + 32452843, x, y) > (dense ? 0.5 : 0.3)
1071
+ ? 0
1072
+ : type === 'forest'
1073
+ ? dense
1074
+ ? 6
1075
+ : 5
1076
+ : type === 'jungle'
1077
+ ? dense
1078
+ ? 29
1079
+ : 28
1080
+ : 0;
1018
1081
  }
1019
1082
  add(`${map}/trees.bmp`, createBmp({
1020
1083
  width: treeWidth,
@@ -1027,10 +1090,7 @@ export function generateWorld(spec, context) {
1027
1090
  })),
1028
1091
  indexedPixels: treePixels,
1029
1092
  }), 'image/bmp');
1030
- // cities.bmp: the city-type map cities.txt reads (land 0, water 15 in the base game).
1031
- const cityPixels = new Uint8Array(width * height);
1032
- for (let pixel = 0; pixel < width * height; pixel += 1)
1033
- cityPixels[pixel] = surface.kind[pixel] === LAND ? 0 : 15;
1093
+ // cities.bmp: sparse city footprints agree with the terrain and light maps.
1034
1094
  add(`${map}/cities.bmp`, createBmp({
1035
1095
  width,
1036
1096
  height,
@@ -1073,14 +1133,13 @@ export function generateWorld(spec, context) {
1073
1133
  for (let x = 0; x < halfWidth; x += 1) {
1074
1134
  // DDS rows run top to bottom, like the bitmaps' top-left coordinates used here.
1075
1135
  const source = y * 2 * width + x * 2;
1076
- const id = provinces.pixel[source];
1077
- const color = tint[terrain.get(id) ?? 'plains'];
1136
+ const color = tint[visualTerrain(source)];
1078
1137
  const shade = 0.85 + surface.moisture[source] * 0.3;
1079
1138
  const offset = (y * halfWidth + x) * 4;
1080
1139
  colormap[offset] = Math.min(255, Math.round(color.b * shade));
1081
1140
  colormap[offset + 1] = Math.min(255, Math.round(color.g * shade));
1082
1141
  colormap[offset + 2] = Math.min(255, Math.round(color.r * shade));
1083
- colormap[offset + 3] = terrain.get(id) === 'urban' ? 200 : 0;
1142
+ colormap[offset + 3] = cityPixels[source] === 15 ? 200 : 0;
1084
1143
  }
1085
1144
  add(`${map}/terrain/colormap_rgb_cityemissivemask_a.dds`, encodeDds(halfWidth, halfHeight, colormap), 'image/vnd-ms.dds');
1086
1145
  for (const level of [0, 1, 2]) {
@@ -1108,10 +1167,10 @@ export function generateWorld(spec, context) {
1108
1167
  }
1109
1168
  for (const [region, members] of [...regionMembers].sort((left, right) => left[0] - right[0])) {
1110
1169
  const indexes = members.map((member) => member - 1);
1111
- const meanLatitude = indexes.reduce((sum, id) => sum + latitude(id), 0) / indexes.length;
1170
+ const meanLatitude = indexes.reduce((sum, id) => sum + 1 - (2 * center(id).y) / height, 0) / indexes.length;
1112
1171
  const desert = indexes.filter((id) => terrain.get(id) === 'desert').length > indexes.length / 2;
1113
1172
  const naval = indexes.every((id) => provinces.kind[id] !== LAND);
1114
- add(`${map}/strategicregions/${region}-World region ${region}.txt`, text([
1173
+ add(`${map}/strategicregions/${region}-${slug(regionNames.get(region) ?? `World region ${region}`).slice(0, 80)}.txt`, text([
1115
1174
  'strategic_region={',
1116
1175
  `\tid=${region}`,
1117
1176
  `\tname="STRATEGICREGION_${region}"`,
@@ -1136,7 +1195,7 @@ export function generateWorld(spec, context) {
1136
1195
  ...(state.airBase > 0 ? [`\t\t\tair_base = ${state.airBase}`] : []),
1137
1196
  ...[...state.navalBases].map(([id, level]) => `\t\t\t${provinceId(id)} = { naval_base = ${level} }`),
1138
1197
  ];
1139
- add(`${context.statesRoot}/${state.id}-STATE_${state.id}.txt`, text([
1198
+ add(`${context.statesRoot}/${state.id}-${slug(stateNames.get(state.id) ?? `STATE_${state.id}`).slice(0, 80)}.txt`, text([
1140
1199
  'state = {',
1141
1200
  `\tid = ${state.id}`,
1142
1201
  `\tname = "STATE_${state.id}"`,
@@ -1163,21 +1222,35 @@ export function generateWorld(spec, context) {
1163
1222
  // Countries.
1164
1223
  const countryLocalisation = [];
1165
1224
  const countryLines = [];
1225
+ const countryColors = [];
1166
1226
  for (const [index, country] of spec.countries.entries()) {
1167
1227
  const color = country.color ?? {
1168
1228
  r: 60 + random.int(170),
1169
1229
  g: 60 + random.int(170),
1170
1230
  b: 60 + random.int(170),
1171
1231
  };
1172
- const fileName = slug(country.name);
1232
+ const fileName = `${country.tag} - ${slug(country.name)}`;
1173
1233
  const ideology = country.ideology ?? 'neutrality';
1234
+ countryColors.push(`${country.tag} = { color = rgb { ${color.r} ${color.g} ${color.b} } color_ui = rgb { ${color.r} ${color.g} ${color.b} } }`);
1235
+ const leaderIdeology = {
1236
+ neutrality: 'despotism',
1237
+ democratic: 'liberalism',
1238
+ communism: 'leninism',
1239
+ fascism: 'fascism_ideology',
1240
+ }[ideology];
1174
1241
  const capitalState = countryCapitalState.get(country.tag);
1175
1242
  countryLines.push(`${country.tag} = "countries/${fileName}.txt"`);
1176
1243
  add(`common/countries/${fileName}.txt`, text(`graphical_culture = western_european_gfx\ngraphical_culture_2d = western_european_2d\n\ncolor = { ${color.r} ${color.g} ${color.b} }\n`));
1177
1244
  const popularity = { democratic: 10, communism: 10, fascism: 10, neutrality: 10 };
1178
1245
  popularity[ideology] = 70;
1179
- add(`history/countries/${country.tag} - ${fileName}.txt`, text([
1246
+ add(`history/countries/${country.tag} - ${slug(country.name)}.txt`, text([
1180
1247
  `capital = ${capitalState.id}`,
1248
+ ...(spec.startingScenario
1249
+ ? [
1250
+ `oob = "${country.tag}_world"`,
1251
+ 'add_ideas = { limited_conscription civilian_economy export_focus }',
1252
+ ]
1253
+ : []),
1181
1254
  '',
1182
1255
  'set_research_slots = 3',
1183
1256
  'set_stability = 0.6',
@@ -1185,6 +1258,7 @@ export function generateWorld(spec, context) {
1185
1258
  '',
1186
1259
  'set_technology = {',
1187
1260
  '\tinfantry_weapons = 1',
1261
+ ...(spec.startingScenario ? ['\tinfantry_weapons1 = 1', '\tbasic_train = 1'] : []),
1188
1262
  '\ttech_support = 1',
1189
1263
  '\ttech_recon = 1',
1190
1264
  '}',
@@ -1198,9 +1272,37 @@ export function generateWorld(spec, context) {
1198
1272
  'set_popularities = {',
1199
1273
  ...Object.entries(popularity).map(([name, value]) => `\t${name} = ${value}`),
1200
1274
  '}',
1275
+ ...(spec.startingScenario
1276
+ ? [
1277
+ `create_country_leader = { name = "${country.tag}_WORLD_LEADER" picture = "gfx/leaders/leader_unknown.dds" ideology = ${leaderIdeology} expire = "1965.1.1" traits = { } }`,
1278
+ ]
1279
+ : []),
1201
1280
  '',
1202
1281
  ].join('\n')));
1203
- countryLocalisation.push([country.tag, country.name], [`${country.tag}_DEF`, country.name], [`${country.tag}_ADJ`, country.adjective ?? country.name]);
1282
+ if (spec.startingScenario) {
1283
+ add(`history/units/${country.tag}_world.txt`, text([
1284
+ '# Minimal map-acceptance scenario; no authored campaign or national content.',
1285
+ 'division_template = {',
1286
+ '\tname = "Infantry Division"',
1287
+ '\tregiments = {',
1288
+ ...Array.from({ length: 6 }, (_, i) => `\t\tinfantry = { x = ${Math.floor(i / 3)} y = ${i % 3} }`),
1289
+ '\t}',
1290
+ '}',
1291
+ 'units = {',
1292
+ ...[1, 2].map((i) => `\tdivision = { name = "${country.tag} Infantry ${i}" location = ${provinceId(capitalState.capital)} division_template = "Infantry Division" start_experience_factor = 0.1 start_equipment_factor = 1 }`),
1293
+ '}',
1294
+ 'instant_effect = {',
1295
+ `\tadd_equipment_to_stockpile = { type = infantry_equipment_1 amount = 3000 producer = ${country.tag} }`,
1296
+ `\tadd_equipment_to_stockpile = { type = convoy amount = 100 producer = ${country.tag} }`,
1297
+ `\tadd_equipment_to_stockpile = { type = train_equipment_1 amount = 100 producer = ${country.tag} }`,
1298
+ `\tadd_equipment_production = { equipment = { type = infantry_equipment_1 creator = ${country.tag} } requested_factories = 1 progress = 0.5 efficiency = 50 }`,
1299
+ '}',
1300
+ '',
1301
+ ].join('\n')));
1302
+ }
1303
+ countryLocalisation.push([country.tag, country.name], [`${country.tag}_DEF`, country.name], [`${country.tag}_ADJ`, country.adjective ?? country.name], ...(spec.startingScenario
1304
+ ? [[`${country.tag}_WORLD_LEADER`, 'Provisional Council']]
1305
+ : []));
1204
1306
  // A three-band flag in the country colour, in the three sizes the game draws.
1205
1307
  const dark = {
1206
1308
  r: Math.round(color.r * 0.55),
@@ -1221,6 +1323,15 @@ export function generateWorld(spec, context) {
1221
1323
  void index;
1222
1324
  }
1223
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`));
1327
+ if (spec.startingScenario) {
1328
+ for (const tag of context.inheritedCountryTags ?? [])
1329
+ if (!countryNames.has(tag))
1330
+ add(`history/countries/${tag} - inactive world.txt`, text('# Inherited tag has no territory or active starting country in this world.\n'));
1331
+ add('common/on_actions/world.txt', text('# Old-world startup hooks are replaced for this generated map.\non_actions = { }\n'));
1332
+ add('common/ai_strategy_plans/world.txt', text('# No country-specific old-world AI plans apply.\n'));
1333
+ add('common/ai_areas/world.txt', text(`areas = { world = { strategic_regions = { ${[...regionMembers.keys()].sort((a, b) => a - b).join(' ')} } } }\n`));
1334
+ }
1224
1335
  // The start bookmark lists the largest countries as majors.
1225
1336
  const ranked = [...spec.countries].sort((left, right) => states
1226
1337
  .filter((state) => state.owner === right.tag)
@@ -1246,15 +1357,24 @@ export function generateWorld(spec, context) {
1246
1357
  '',
1247
1358
  ].join('\n')));
1248
1359
  // Localisation for everything named above.
1249
- add(`${context.localisationRoot}/english/world_l_english.yml`, localisation([
1250
- ...states.map((state) => [`STATE_${state.id}`, `${state.owner} ${state.id}`]),
1251
- ...states.flatMap((state) => [...state.victoryPoints.keys()].map((id) => [`VICTORY_POINTS_${provinceId(id)}`, `${state.owner} City ${provinceId(id)}`])),
1360
+ add(`${context.localisationRoot}/replace/world_l_english.yml`, localisation([
1361
+ ...states.map((state) => [
1362
+ `STATE_${state.id}`,
1363
+ stateNames.get(state.id) ??
1364
+ `${countryNames.get(state.owner) ?? state.owner} District ${state.id}`,
1365
+ ]),
1366
+ ...states.flatMap((state) => [...state.victoryPoints.keys()].map((id) => [
1367
+ `VICTORY_POINTS_${provinceId(id)}`,
1368
+ cityNames.get(id) ??
1369
+ `${countryNames.get(state.owner) ?? state.owner} Settlement ${provinceId(id)}`,
1370
+ ])),
1252
1371
  ...[...regionMembers.keys()]
1253
1372
  .sort((a, b) => a - b)
1254
- .map((region) => [`STRATEGICREGION_${region}`, `Region ${region}`]),
1373
+ .map((region) => [`STRATEGICREGION_${region}`, regionNames.get(region) ?? `Region ${region}`]),
1374
+ ...(spec.referenceLabels ?? []).map((label, index) => [`WORLD_GEOGRAPHY_${index + 1}`, label.name]),
1255
1375
  ...continentNames.map((name, index) => [`world_continent_${index + 1}`, name]),
1256
1376
  ...countryLocalisation,
1257
- ['WORLD_BOOKMARK_NAME', 'A New World'],
1377
+ ['WORLD_BOOKMARK_NAME', spec.worldName ?? 'A New World'],
1258
1378
  ['WORLD_BOOKMARK_DESC', 'Nations of an unfamiliar map at the dawn of the war.'],
1259
1379
  ['WORLD_OTHER_DESC', 'Any other nation of this world.'],
1260
1380
  ...majors.map((country) => [
@@ -1264,6 +1384,7 @@ export function generateWorld(spec, context) {
1264
1384
  ]));
1265
1385
  return {
1266
1386
  files,
1387
+ warnings: [...interpretationWarnings, ...drainage.warnings],
1267
1388
  summary: {
1268
1389
  width,
1269
1390
  height,