hoi4-agent-tools 3.9.0 → 4.0.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 (157) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +115 -147
  3. package/dist/bin/http.js +12 -0
  4. package/dist/bin/setup.js +3 -2
  5. package/dist/bin/stdio.js +12 -0
  6. package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
  7. package/dist/hoi4_agent_tools/core/canonical.js +18 -4
  8. package/dist/hoi4_agent_tools/core/configuration.js +7 -0
  9. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
  10. package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
  11. package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
  12. package/dist/hoi4_agent_tools/core/engine.js +56 -10
  13. package/dist/hoi4_agent_tools/core/index.js +45 -20
  14. package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
  15. package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
  16. package/dist/hoi4_agent_tools/core/job-store.js +78 -5
  17. package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
  18. package/dist/hoi4_agent_tools/core/job-worker-host.js +215 -116
  19. package/dist/hoi4_agent_tools/core/job-worker.js +90 -36
  20. package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
  21. package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
  22. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
  23. package/dist/hoi4_agent_tools/core/server-registry.js +155 -0
  24. package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
  25. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
  26. package/dist/hoi4_agent_tools/core/source/cache.js +108 -29
  27. package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
  28. package/dist/hoi4_agent_tools/core/transactions.js +114 -23
  29. package/dist/hoi4_agent_tools/core/workspace.js +84 -8
  30. package/dist/hoi4_agent_tools/decision/ai.js +1 -1
  31. package/dist/hoi4_agent_tools/decision/service.js +28 -0
  32. package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
  33. package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
  34. package/dist/hoi4_agent_tools/event/operations.js +14 -1
  35. package/dist/hoi4_agent_tools/event/service.js +7 -0
  36. package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
  37. package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
  38. package/dist/hoi4_agent_tools/gui/scenario-generator.js +11 -3
  39. package/dist/hoi4_agent_tools/gui/studio.js +20 -7
  40. package/dist/hoi4_agent_tools/gui/validators.js +8 -6
  41. package/dist/hoi4_agent_tools/map/bmp.js +1 -1
  42. package/dist/hoi4_agent_tools/map/derived.js +748 -0
  43. package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
  44. package/dist/hoi4_agent_tools/map/model.js +103 -0
  45. package/dist/hoi4_agent_tools/map/operations.js +427 -58
  46. package/dist/hoi4_agent_tools/map/references.js +483 -0
  47. package/dist/hoi4_agent_tools/map/service.js +136 -61
  48. package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
  49. package/dist/hoi4_agent_tools/map/validation.js +3 -0
  50. package/dist/hoi4_agent_tools/map/world.js +1280 -0
  51. package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
  52. package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
  53. package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
  54. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
  55. package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
  56. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
  57. package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
  58. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
  59. package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
  60. package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
  61. package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
  62. package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
  63. package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
  64. package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
  65. package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
  66. package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -26
  67. package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
  68. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +37 -18
  69. package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
  70. package/dist/hoi4_agent_tools/probability/operations.js +175 -13
  71. package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
  72. package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
  73. package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
  74. package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
  75. package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
  76. package/dist/hoi4_agent_tools/reference/operations.js +88 -19
  77. package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
  78. package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
  79. package/dist/hoi4_agent_tools/reference/service.js +6 -0
  80. package/dist/hoi4_agent_tools/schemas/event.js +78 -19
  81. package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
  82. package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
  83. package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
  84. package/dist/hoi4_agent_tools/schemas/map.js +122 -3
  85. package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
  86. package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
  87. package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
  88. package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
  89. package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
  90. package/dist/hoi4_agent_tools/schemas/technology.js +83 -23
  91. package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
  92. package/dist/hoi4_agent_tools/technology/operations.js +14 -1
  93. package/dist/hoi4_agent_tools/technology/service.js +7 -0
  94. package/dist/hoi4_agent_tools/version.js +1 -1
  95. package/docs/README.md +7 -4
  96. package/docs/analysis.md +13 -0
  97. package/docs/configuration.md +281 -0
  98. package/docs/development.md +24 -2
  99. package/docs/events.md +27 -3
  100. package/docs/examples.md +6 -6
  101. package/docs/focus.md +125 -4
  102. package/docs/gui.md +98 -17
  103. package/docs/helper-expansion.md +8 -8
  104. package/docs/http.md +3 -1
  105. package/docs/jobs.md +19 -13
  106. package/docs/map.md +197 -6
  107. package/docs/mechanics.md +4 -0
  108. package/docs/probability.md +42 -15
  109. package/docs/reference.md +71 -4
  110. package/docs/setup.md +45 -11
  111. package/docs/technology.md +26 -1
  112. package/docs/third-party-notices.md +11 -0
  113. package/docs/tools.md +151 -42
  114. package/docs/troubleshooting.md +61 -0
  115. package/examples/clients/codex-config.toml +5 -2
  116. package/examples/clients/generic.json +2 -2
  117. package/examples/config/config.json +7 -0
  118. package/npm-shrinkwrap.json +161 -162
  119. package/package.json +13 -8
  120. package/schemas/configuration.schema.json +144 -128
  121. package/schemas/continuous-focus-palette.schema.json +1 -1
  122. package/schemas/custom-weighted-pool.schema.json +1 -1
  123. package/schemas/decision-inspect-input.schema.json +1 -1
  124. package/schemas/error-log-input.schema.json +55 -0
  125. package/schemas/event-feature-manifest.schema.json +1 -1
  126. package/schemas/focus-plan.schema.json +1 -1
  127. package/schemas/focus-planning-sidecar.schema.json +1 -1
  128. package/schemas/gui-animation-source.schema.json +1 -1
  129. package/schemas/gui-helper.schema.json +1 -1
  130. package/schemas/gui-scenario.schema.json +1 -1
  131. package/schemas/helper-expansion-request.schema.json +1 -1
  132. package/schemas/helper-expansion-summary.schema.json +1 -1
  133. package/schemas/impact-inspect-input.schema.json +1 -1
  134. package/schemas/map-operation.schema.json +597 -126
  135. package/schemas/mechanic-test-input.schema.json +1 -1
  136. package/schemas/mod-index-input.schema.json +51 -0
  137. package/schemas/operation-result.schema.json +2 -2
  138. package/schemas/package-check-input.schema.json +1 -1
  139. package/schemas/package-manifest.schema.json +1 -1
  140. package/schemas/probability-analysis-result.schema.json +1 -1
  141. package/schemas/probability-compare-input.schema.json +1 -1
  142. package/schemas/probability-evaluate-input.schema.json +1 -1
  143. package/schemas/probability-inspect-input.schema.json +1 -1
  144. package/schemas/probability-render-input.schema.json +1 -1
  145. package/schemas/probability-scenario-set.schema.json +1 -1
  146. package/schemas/probability-sequence-input.schema.json +1 -1
  147. package/schemas/probability-simulate-input.schema.json +1 -1
  148. package/schemas/probability-sweep-input.schema.json +1 -1
  149. package/schemas/reference-context-input.schema.json +1 -1
  150. package/schemas/reference-read-input.schema.json +1 -1
  151. package/schemas/reference-search-input.schema.json +1 -1
  152. package/schemas/scenario-suite.schema.json +3 -5
  153. package/schemas/scenario-test-input.schema.json +3 -5
  154. package/schemas/script-validate-input.schema.json +1 -1
  155. package/schemas/shared-scenario.schema.json +1 -1
  156. package/schemas/source-lookup-input.schema.json +1 -1
  157. package/server.json +3 -3
@@ -0,0 +1,1280 @@
1
+ /**
2
+ * A new world from a compact description: land and sea, provinces, terrain, heights, rivers,
3
+ * trees, colour maps, continents, strategic regions with weather, countries, states with their
4
+ * population, buildings, resources and victory points, country history and flags, a start
5
+ * bookmark, and localisation. Generation is deterministic for a seed. Positions and the supply
6
+ * network are derived afterwards from the generated map by the shared planners in derived.ts.
7
+ */
8
+ import { ServiceError } from '../core/result.js';
9
+ import { createBmp } from './bmp.js';
10
+ // ---------------------------------------------------------------------------------------------
11
+ // Deterministic randomness and noise
12
+ class Random {
13
+ #state;
14
+ constructor(seed) {
15
+ this.#state = (seed ^ 0x9e3779b9) >>> 0;
16
+ }
17
+ next() {
18
+ this.#state = (this.#state + 0x6d2b79f5) >>> 0;
19
+ let value = this.#state;
20
+ value = Math.imul(value ^ (value >>> 15), value | 1);
21
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
22
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
23
+ }
24
+ int(limit) {
25
+ return Math.floor(this.next() * limit);
26
+ }
27
+ }
28
+ function lattice(seed, x, y) {
29
+ let hash = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 982451653);
30
+ hash = Math.imul(hash ^ (hash >>> 13), 1274126177);
31
+ return ((hash ^ (hash >>> 16)) >>> 0) / 4294967296;
32
+ }
33
+ function smooth(t) {
34
+ return t * t * (3 - 2 * t);
35
+ }
36
+ function valueNoise(seed, x, y) {
37
+ const x0 = Math.floor(x);
38
+ const y0 = Math.floor(y);
39
+ const tx = smooth(x - x0);
40
+ const ty = smooth(y - y0);
41
+ const a = lattice(seed, x0, y0);
42
+ const b = lattice(seed, x0 + 1, y0);
43
+ const c = lattice(seed, x0, y0 + 1);
44
+ const d = lattice(seed, x0 + 1, y0 + 1);
45
+ return a + (b - a) * tx + (c - a) * ty + (a - b - c + d) * tx * ty;
46
+ }
47
+ function fractal(seed, x, y, scale, octaves = 5) {
48
+ let total = 0;
49
+ let amplitude = 1;
50
+ let weight = 0;
51
+ let frequency = 1 / scale;
52
+ for (let octave = 0; octave < octaves; octave += 1) {
53
+ total += valueNoise(seed + octave * 101, x * frequency, y * frequency) * amplitude;
54
+ weight += amplitude;
55
+ amplitude *= 0.5;
56
+ frequency *= 2;
57
+ }
58
+ return total / weight;
59
+ }
60
+ // ---------------------------------------------------------------------------------------------
61
+ // Raster helpers
62
+ const LAND = 1;
63
+ const SEA = 0;
64
+ const LAKE = 2;
65
+ function neighbors4(width, height, pixel) {
66
+ const x = pixel % width;
67
+ const y = (pixel - x) / width;
68
+ const result = [];
69
+ if (x > 0)
70
+ result.push(pixel - 1);
71
+ if (x < width - 1)
72
+ result.push(pixel + 1);
73
+ if (y > 0)
74
+ result.push(pixel - width);
75
+ if (y < height - 1)
76
+ result.push(pixel + width);
77
+ return result;
78
+ }
79
+ /** Connected components of equal values; returns the component of each pixel and their sizes. */
80
+ function components(values, width, height) {
81
+ const component = new Int32Array(values.length).fill(-1);
82
+ const sizes = [];
83
+ const kinds = [];
84
+ const queue = new Int32Array(values.length);
85
+ for (let start = 0; start < values.length; start += 1) {
86
+ if (component[start] !== -1)
87
+ continue;
88
+ const id = sizes.length;
89
+ let head = 0;
90
+ let tail = 0;
91
+ queue[tail++] = start;
92
+ component[start] = id;
93
+ while (head < tail) {
94
+ const pixel = queue[head++];
95
+ for (const next of neighbors4(width, height, pixel))
96
+ if (component[next] === -1 && values[next] === values[start]) {
97
+ component[next] = id;
98
+ queue[tail++] = next;
99
+ }
100
+ }
101
+ sizes.push(tail);
102
+ kinds.push(values[start]);
103
+ }
104
+ return { component, sizes, values: kinds };
105
+ }
106
+ function pointInPolygon(x, y, points) {
107
+ let inside = false;
108
+ for (let index = 0, previous = points.length - 1; index < points.length; previous = index++) {
109
+ const a = points[index];
110
+ const b = points[previous];
111
+ if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x)
112
+ inside = !inside;
113
+ }
114
+ return inside;
115
+ }
116
+ function generateSurface(spec, random) {
117
+ const { width, height } = spec;
118
+ const total = width * height;
119
+ const elevation = new Float32Array(total);
120
+ const moisture = new Float32Array(total);
121
+ const kind = new Uint8Array(total);
122
+ const margin = Math.max(4, Math.round(width * 0.02));
123
+ const polar = Math.max(4, Math.round(height * 0.05));
124
+ const scale = Math.max(width, height) / 6;
125
+ const centers = Array.from({ length: Math.max(1, spec.continents) }, () => ({
126
+ x: margin * 3 + random.next() * (width - margin * 6),
127
+ y: polar * 3 + random.next() * (height - polar * 6),
128
+ radius: (0.55 + random.next() * 0.6) *
129
+ Math.sqrt((width * height * spec.landCoverage) / Math.max(1, spec.continents) / Math.PI),
130
+ }));
131
+ for (let y = 0; y < height; y += 1)
132
+ for (let x = 0; x < width; x += 1) {
133
+ const pixel = y * width + x;
134
+ let blob = 0;
135
+ for (const center of centers) {
136
+ const distance = Math.hypot(x - center.x, y - center.y) / center.radius;
137
+ blob = Math.max(blob, Math.exp(-distance * distance * 1.4));
138
+ }
139
+ const noise = fractal(spec.seed, x, y, scale);
140
+ elevation[pixel] = spec.landPolygons === undefined ? blob * 0.65 + noise * 0.55 : noise;
141
+ moisture[pixel] = fractal(spec.seed + 7919, x, y, scale * 0.8, 4);
142
+ }
143
+ if (spec.landPolygons !== undefined) {
144
+ for (let y = 0; y < height; y += 1)
145
+ for (let x = 0; x < width; x += 1)
146
+ if (spec.landPolygons.some((polygon) => pointInPolygon(x + 0.5, y + 0.5, polygon)))
147
+ kind[y * width + x] = LAND;
148
+ }
149
+ else {
150
+ // The land threshold that gives the requested coverage.
151
+ const sorted = Float32Array.from(elevation).sort();
152
+ const threshold = sorted[Math.min(total - 1, Math.floor(total * (1 - spec.landCoverage)))];
153
+ for (let pixel = 0; pixel < total; pixel += 1)
154
+ kind[pixel] = elevation[pixel] >= threshold ? LAND : SEA;
155
+ }
156
+ // Keep the wrapping east-west seam and the polar rows at sea, so no land touches an edge.
157
+ for (let y = 0; y < height; y += 1)
158
+ for (let x = 0; x < width; x += 1)
159
+ if (x < margin || x >= width - margin || y < polar || y >= height - polar)
160
+ kind[y * width + x] = SEA;
161
+ // Tiny islands sink; enclosed water becomes land when small and a lake when large.
162
+ const parts = components(kind, width, height);
163
+ const minimumIsland = Math.max(40, Math.round(spec.landProvincePixels / 4));
164
+ const ocean = new Set();
165
+ for (let x = 0; x < width; x += 1)
166
+ ocean.add(parts.component[x]);
167
+ for (let pixel = 0; pixel < total; pixel += 1) {
168
+ const part = parts.component[pixel];
169
+ if (kind[pixel] === LAND && parts.sizes[part] < minimumIsland)
170
+ kind[pixel] = SEA;
171
+ else if (kind[pixel] === SEA && !ocean.has(part))
172
+ kind[pixel] = parts.sizes[part] < spec.landProvincePixels * 1.5 ? LAND : LAKE;
173
+ }
174
+ // Normalised land height above the coast, and sea depth below it.
175
+ const coastDistance = new Int32Array(total).fill(-1);
176
+ const queue = new Int32Array(total);
177
+ let head = 0;
178
+ let tail = 0;
179
+ for (let pixel = 0; pixel < total; pixel += 1)
180
+ if (kind[pixel] === LAND &&
181
+ neighbors4(width, height, pixel).some((next) => kind[next] !== LAND)) {
182
+ coastDistance[pixel] = 0;
183
+ queue[tail++] = pixel;
184
+ }
185
+ while (head < tail) {
186
+ const pixel = queue[head++];
187
+ for (const next of neighbors4(width, height, pixel))
188
+ if (coastDistance[next] === -1) {
189
+ coastDistance[next] = coastDistance[pixel] + 1;
190
+ queue[tail++] = next;
191
+ }
192
+ }
193
+ for (let pixel = 0; pixel < total; pixel += 1) {
194
+ const inland = Math.min(1, (coastDistance[pixel] ?? 0) / 40);
195
+ const x = pixel % width;
196
+ const y = (pixel - x) / width;
197
+ const ridges = Math.abs(fractal(spec.seed + 104729, x, y, scale / 2, 4) - 0.5) * 2;
198
+ elevation[pixel] =
199
+ kind[pixel] === LAND
200
+ ? Math.min(1, 0.08 + inland * 0.35 + Math.pow(1 - ridges, 3) * 0.6 * inland)
201
+ : -Math.min(1, (coastDistance[pixel] ?? 0) / 30);
202
+ }
203
+ return { kind, elevation, moisture };
204
+ }
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
+ // ---------------------------------------------------------------------------------------------
388
+ // Grouping provinces into regions and states
389
+ /** Grow clusters of about `target` members over a graph from spread-out seeds. */
390
+ function growClusters(members, neighbors, target, random, weight = () => 1) {
391
+ const cluster = new Map();
392
+ const memberSet = new Set(members);
393
+ const pending = new Set(members);
394
+ let clusterId = 0;
395
+ while (pending.size > 0) {
396
+ // Start each cluster from the remaining member with the fewest remaining neighbours, so
397
+ // clusters fill pockets before open ground.
398
+ const ordered = [...pending];
399
+ const start = ordered[random.int(ordered.length)];
400
+ const queue = [start];
401
+ let filled = 0;
402
+ while (queue.length > 0 && filled < target) {
403
+ const current = queue.shift();
404
+ if (!pending.has(current))
405
+ continue;
406
+ pending.delete(current);
407
+ cluster.set(current, clusterId);
408
+ filled += weight(current);
409
+ for (const next of neighbors(current))
410
+ if (memberSet.has(next) && pending.has(next))
411
+ queue.push(next);
412
+ }
413
+ clusterId += 1;
414
+ }
415
+ // Absorb tiny leftover clusters into a neighbouring one.
416
+ const sizes = new Map();
417
+ for (const id of cluster.values())
418
+ sizes.set(id, (sizes.get(id) ?? 0) + 1);
419
+ for (const member of members) {
420
+ const id = cluster.get(member);
421
+ if ((sizes.get(id) ?? 0) * 3 >= target || target <= 2)
422
+ continue;
423
+ for (const next of neighbors(member)) {
424
+ const other = cluster.get(next);
425
+ if (other !== undefined && other !== id && memberSet.has(next)) {
426
+ sizes.set(id, (sizes.get(id) ?? 1) - 1);
427
+ sizes.set(other, (sizes.get(other) ?? 0) + 1);
428
+ cluster.set(member, other);
429
+ break;
430
+ }
431
+ }
432
+ }
433
+ return cluster;
434
+ }
435
+ // ---------------------------------------------------------------------------------------------
436
+ // File writers
437
+ function bmpPalette(base, fallback) {
438
+ return Array.from({ length: 256 }, (_, index) => base?.[index] ?? fallback(index));
439
+ }
440
+ /** Uncompressed 32-bit DDS (A8R8G8B8, one mip level), as the map colour maps use. */
441
+ export function encodeDds(width, height, bgra) {
442
+ const header = Buffer.alloc(128);
443
+ header.write('DDS ', 0, 'ascii');
444
+ header.writeUInt32LE(124, 4);
445
+ header.writeUInt32LE(0x100f, 8);
446
+ header.writeUInt32LE(height, 12);
447
+ header.writeUInt32LE(width, 16);
448
+ header.writeUInt32LE(width * 4, 20);
449
+ header.writeUInt32LE(1, 24);
450
+ header.writeUInt32LE(1, 28);
451
+ header.writeUInt32LE(32, 76);
452
+ header.writeUInt32LE(0x41, 80);
453
+ header.writeUInt32LE(32, 88);
454
+ header.writeUInt32LE(0x00ff0000, 92);
455
+ header.writeUInt32LE(0x0000ff00, 96);
456
+ header.writeUInt32LE(0x000000ff, 100);
457
+ header.writeUInt32LE(0xff000000, 104);
458
+ header.writeUInt32LE(0x1000, 108);
459
+ return Buffer.concat([header, Buffer.from(bgra)]);
460
+ }
461
+ /** Uncompressed 32-bit TGA with a bottom-left origin, as country flags use. */
462
+ export function encodeTga(width, height, rgba) {
463
+ const header = Buffer.alloc(18);
464
+ header[2] = 2;
465
+ header.writeUInt16LE(width, 12);
466
+ header.writeUInt16LE(height, 14);
467
+ header[16] = 32;
468
+ header[17] = 8;
469
+ const pixels = Buffer.alloc(width * height * 4);
470
+ for (let row = 0; row < height; row += 1)
471
+ for (let x = 0; x < width; x += 1) {
472
+ const color = rgba(x, height - 1 - row);
473
+ const offset = (row * width + x) * 4;
474
+ pixels[offset] = color.b;
475
+ pixels[offset + 1] = color.g;
476
+ pixels[offset + 2] = color.r;
477
+ pixels[offset + 3] = 255;
478
+ }
479
+ return Buffer.concat([header, pixels]);
480
+ }
481
+ function text(value) {
482
+ return Buffer.from(value, 'utf8');
483
+ }
484
+ function localisation(entries) {
485
+ const escape = (value) => value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
486
+ return Buffer.from(`\ufeffl_english:\n${entries.map(([key, value]) => ` ${key}: "${escape(value)}"`).join('\n')}\n`, 'utf8');
487
+ }
488
+ function slug(value) {
489
+ return value.replace(/[^A-Za-z0-9 _-]/gu, '').trim() || 'Country';
490
+ }
491
+ const DEFAULT_TERRAIN_INDEX = new Map([
492
+ ['plains', 0],
493
+ ['forest', 1],
494
+ ['hills', 2],
495
+ ['desert', 3],
496
+ ['mountain', 6],
497
+ ['marsh', 9],
498
+ ['urban', 13],
499
+ ['lakes', 14],
500
+ ['ocean', 15],
501
+ ['jungle', 21],
502
+ ]);
503
+ /** Approximate 1936 weather for a latitude between 0 (equator) and 1 (pole). */
504
+ function weatherBlock(latitude, desert) {
505
+ const periods = [];
506
+ const days = [30, 27, 30, 29, 30, 29, 30, 30, 29, 30, 29, 30];
507
+ for (let month = 0; month < 12; month += 1) {
508
+ const season = Math.cos(((month - 6.5) / 12) * Math.PI * 2);
509
+ const mean = 30 - latitude * 45 - season * (6 + latitude * 18);
510
+ const low = Math.round(mean - 8);
511
+ const high = Math.round(mean + 8);
512
+ const snow = Math.max(0, Math.min(0.6, (-mean + 2) / 20));
513
+ const rain = desert ? 0.1 : Math.max(0.2, 0.9 - snow);
514
+ periods.push([
515
+ '\t\tperiod={',
516
+ `\t\t\tbetween={ 0.${month} ${days[month]}.${month} }`,
517
+ `\t\t\ttemperature={ ${low.toFixed(1)} ${high.toFixed(1)} }`,
518
+ '\t\t\tno_phenomenon=0.500',
519
+ `\t\t\train_light=${rain.toFixed(3)}`,
520
+ `\t\t\train_heavy=${(rain * 0.15).toFixed(3)}`,
521
+ `\t\t\tsnow=${snow.toFixed(3)}`,
522
+ `\t\t\tblizzard=${(snow * 0.25).toFixed(3)}`,
523
+ '\t\t\tarctic_water=0.000',
524
+ `\t\t\tmud=${(desert ? 0.05 : 0.3).toFixed(3)}`,
525
+ `\t\t\tsandstorm=${(desert ? 0.2 : 0).toFixed(3)}`,
526
+ `\t\t\tmin_snow_level=${(snow > 0.3 ? 0.1 : 0).toFixed(3)}`,
527
+ '\t\t}',
528
+ ].join('\n'));
529
+ }
530
+ return `\tweather={\n${periods.join('\n')}\n\t}`;
531
+ }
532
+ export function generateWorld(spec, context) {
533
+ const { width, height } = spec;
534
+ if (width % 256 !== 0 || height % 256 !== 0)
535
+ throw new ServiceError('MAP_WORLD_DIMENSIONS_INVALID', 'Both map dimensions must be multiples of 256', { width, height });
536
+ if (width * height > 13_238_272)
537
+ throw new ServiceError('MAP_WORLD_DIMENSIONS_INVALID', 'The game draws at most 13,238,272 province pixels; choose a smaller width or height', { width, height });
538
+ const tags = new Set();
539
+ for (const country of spec.countries) {
540
+ if (!/^[A-Z][A-Z0-9]{2}$/u.test(country.tag) || tags.has(country.tag))
541
+ throw new ServiceError('MAP_WORLD_COUNTRY_TAG_INVALID', `Country tags must be three unique characters, uppercase letter first: ${country.tag}`);
542
+ tags.add(country.tag);
543
+ }
544
+ const random = new Random(spec.seed);
545
+ // The game warns about provinces wider or taller than an eighth of the map; keep cells well
546
+ // inside that.
547
+ const cellLimit = Math.floor(Math.min(width, height) / 14) ** 2;
548
+ spec = {
549
+ ...spec,
550
+ landProvincePixels: Math.min(spec.landProvincePixels, cellLimit),
551
+ seaProvincePixels: Math.min(spec.seaProvincePixels, cellLimit),
552
+ };
553
+ const surface = generateSurface(spec, random);
554
+ const provinces = generateProvinces(surface, spec, random);
555
+ const count = provinces.kind.length;
556
+ const center = (id) => ({
557
+ x: provinces.sumX[id] / provinces.size[id],
558
+ y: provinces.sumY[id] / provinces.size[id],
559
+ });
560
+ const landIds = [...Array(count).keys()].filter((id) => provinces.kind[id] === LAND);
561
+ const seaIds = [...Array(count).keys()].filter((id) => provinces.kind[id] === SEA);
562
+ const lakeIds = [...Array(count).keys()].filter((id) => provinces.kind[id] === LAKE);
563
+ if (landIds.length < spec.countries.length)
564
+ throw new ServiceError('MAP_WORLD_LAND_TOO_SMALL', 'The generated land has fewer provinces than countries; raise landCoverage or lower landProvincePixels', { landProvinces: landIds.length, countries: spec.countries.length });
565
+ const coastal = (id) => [...provinces.neighbors[id]].some((other) => provinces.kind[id] === SEA
566
+ ? provinces.kind[other] === LAND
567
+ : provinces.kind[other] === SEA && provinces.kind[id] === LAND);
568
+ // Province mean elevation, moisture and latitude decide terrain.
569
+ const meanElevation = new Float64Array(count);
570
+ const meanMoisture = new Float64Array(count);
571
+ for (let pixel = 0; pixel < width * height; pixel += 1) {
572
+ const id = provinces.pixel[pixel];
573
+ meanElevation[id] += surface.elevation[pixel];
574
+ meanMoisture[id] += surface.moisture[pixel];
575
+ }
576
+ const latitude = (id) => Math.abs(center(id).y / height - 0.5) * 2;
577
+ const terrain = new Map();
578
+ const elevations = landIds
579
+ .map((id) => meanElevation[id] / provinces.size[id])
580
+ .sort((a, b) => a - b);
581
+ const quantile = (q) => elevations[Math.floor(q * (elevations.length - 1))] ?? 0;
582
+ const mountainLevel = quantile(0.88);
583
+ const hillLevel = quantile(0.72);
584
+ for (const id of landIds) {
585
+ const elevation = meanElevation[id] / provinces.size[id];
586
+ const wet = meanMoisture[id] / provinces.size[id];
587
+ const lat = latitude(id);
588
+ terrain.set(id, elevation >= mountainLevel
589
+ ? 'mountain'
590
+ : elevation >= hillLevel
591
+ ? 'hills'
592
+ : lat < 0.3 && wet > 0.55
593
+ ? 'jungle'
594
+ : lat > 0.25 && lat < 0.55 && wet < 0.42
595
+ ? 'desert'
596
+ : wet > 0.62 && elevation < quantile(0.2)
597
+ ? 'marsh'
598
+ : wet > 0.5
599
+ ? 'forest'
600
+ : 'plains');
601
+ }
602
+ for (const id of seaIds)
603
+ terrain.set(id, 'ocean');
604
+ for (const id of lakeIds)
605
+ terrain.set(id, 'lakes');
606
+ // Continents: the largest land masses, with islands joining the nearest one.
607
+ const landParts = new Map();
608
+ {
609
+ let part = 0;
610
+ for (const start of landIds) {
611
+ if (landParts.has(start))
612
+ continue;
613
+ const queue = [start];
614
+ landParts.set(start, part);
615
+ while (queue.length > 0) {
616
+ const current = queue.pop();
617
+ for (const next of provinces.neighbors[current])
618
+ if (provinces.kind[next] === LAND && !landParts.has(next)) {
619
+ landParts.set(next, part);
620
+ queue.push(next);
621
+ }
622
+ }
623
+ part += 1;
624
+ }
625
+ }
626
+ const partSizes = new Map();
627
+ for (const [id, part] of landParts)
628
+ partSizes.set(part, (partSizes.get(part) ?? 0) + provinces.size[id]);
629
+ const majorParts = [...partSizes]
630
+ .sort((left, right) => right[1] - left[1] || left[0] - right[0])
631
+ .slice(0, Math.max(1, spec.continents))
632
+ .map(([part]) => part);
633
+ const partCenter = (part) => {
634
+ let sx = 0;
635
+ let sy = 0;
636
+ let n = 0;
637
+ for (const [id, p] of landParts)
638
+ if (p === part) {
639
+ sx += provinces.sumX[id];
640
+ sy += provinces.sumY[id];
641
+ n += provinces.size[id];
642
+ }
643
+ return { x: sx / n, y: sy / n };
644
+ };
645
+ const majorCenters = majorParts.map(partCenter);
646
+ const continentOfPart = new Map();
647
+ for (const part of partSizes.keys()) {
648
+ const index = majorParts.indexOf(part);
649
+ if (index >= 0) {
650
+ continentOfPart.set(part, index + 1);
651
+ continue;
652
+ }
653
+ const c = partCenter(part);
654
+ let best = 0;
655
+ for (const [position, majorCenter] of majorCenters.entries())
656
+ if (Math.hypot(majorCenter.x - c.x, majorCenter.y - c.y) <
657
+ Math.hypot(majorCenters[best].x - c.x, majorCenters[best].y - c.y))
658
+ best = position;
659
+ continentOfPart.set(part, best + 1);
660
+ }
661
+ const continentNames = majorParts.map((_, index) => spec.continentNames?.[index] ?? `Continent ${String.fromCharCode(65 + (index % 26))}`);
662
+ // Countries: grow territories from spread-out capitals in proportion to their shares.
663
+ const owner = new Map();
664
+ const countryCount = spec.countries.length;
665
+ const capitals = [];
666
+ 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
+ while (capitals.length < countryCount) {
669
+ let best = -1;
670
+ let bestDistance = -1;
671
+ for (const id of landIds) {
672
+ if (capitals.includes(id))
673
+ continue;
674
+ const c = center(id);
675
+ const distance = Math.min(...capitals.map((other) => Math.hypot(center(other).x - c.x, center(other).y - c.y)));
676
+ if (distance > bestDistance) {
677
+ bestDistance = distance;
678
+ best = id;
679
+ }
680
+ }
681
+ capitals.push(best);
682
+ }
683
+ const shares = spec.countries.map((country) => Math.max(0.05, country.share ?? 1));
684
+ const owned = new Array(countryCount).fill(0);
685
+ const frontiers = capitals.map((capital, index) => {
686
+ owner.set(capital, index);
687
+ owned[index] = provinces.size[capital];
688
+ return [...provinces.neighbors[capital]];
689
+ });
690
+ for (;;) {
691
+ let chosen = -1;
692
+ for (let index = 0; index < countryCount; index += 1) {
693
+ frontiers[index] = frontiers[index].filter((id) => provinces.kind[id] === LAND && !owner.has(id));
694
+ if (frontiers[index].length === 0)
695
+ continue;
696
+ if (chosen < 0 || owned[index] / shares[index] < owned[chosen] / shares[chosen])
697
+ chosen = index;
698
+ }
699
+ if (chosen < 0)
700
+ break;
701
+ const frontier = frontiers[chosen];
702
+ const next = frontier.splice(random.int(frontier.length), 1)[0];
703
+ owner.set(next, chosen);
704
+ owned[chosen] += provinces.size[next];
705
+ for (const neighbor of provinces.neighbors[next])
706
+ if (!owner.has(neighbor))
707
+ frontier.push(neighbor);
708
+ }
709
+ // Islands no country reached go to the nearest capital's country.
710
+ for (const id of landIds)
711
+ if (!owner.has(id)) {
712
+ const c = center(id);
713
+ let best = 0;
714
+ for (const [index, capital] of capitals.entries())
715
+ if (Math.hypot(center(capital).x - c.x, center(capital).y - c.y) <
716
+ Math.hypot(center(capitals[best]).x - c.x, center(capitals[best]).y - c.y))
717
+ best = index;
718
+ owner.set(id, best);
719
+ }
720
+ // Strategic regions: land regions over the land graph, sea regions over the sea graph.
721
+ const graph = (id) => provinces.neighbors[id];
722
+ const landRegion = growClusters(landIds, graph, spec.landProvincesPerRegion, random);
723
+ const seaRegion = growClusters(seaIds, graph, spec.seaProvincesPerRegion, random);
724
+ const regionOf = new Map();
725
+ const landRegionCount = Math.max(-1, ...landRegion.values()) + 1;
726
+ for (const [id, region] of landRegion)
727
+ regionOf.set(id, region + 1);
728
+ for (const [id, region] of seaRegion)
729
+ regionOf.set(id, landRegionCount + region + 1);
730
+ for (const id of lakeIds) {
731
+ const land = [...provinces.neighbors[id]].find((other) => landRegion.has(other));
732
+ const sea = [...provinces.neighbors[id]].find((other) => seaRegion.has(other));
733
+ regionOf.set(id, land !== undefined
734
+ ? landRegion.get(land) + 1
735
+ : sea !== undefined
736
+ ? landRegionCount + seaRegion.get(sea) + 1
737
+ : 1);
738
+ }
739
+ const seaRegionCount = Math.max(-1, ...seaRegion.values()) + 1;
740
+ // States: within each region and country, clusters of about provincesPerState.
741
+ const groups = new Map();
742
+ for (const id of landIds) {
743
+ const key = `${regionOf.get(id)}:${owner.get(id)}`;
744
+ const list = groups.get(key) ?? [];
745
+ list.push(id);
746
+ groups.set(key, list);
747
+ }
748
+ const states = [];
749
+ const stateOf = new Map();
750
+ for (const key of [...groups.keys()].sort()) {
751
+ const members = groups.get(key);
752
+ const clusters = growClusters(members, graph, spec.provincesPerState, random);
753
+ const byCluster = new Map();
754
+ for (const [id, cluster] of clusters) {
755
+ const list = byCluster.get(cluster) ?? [];
756
+ list.push(id);
757
+ byCluster.set(cluster, list);
758
+ }
759
+ for (const memberIds of [...byCluster.values()].sort((left, right) => Math.min(...left) - Math.min(...right))) {
760
+ const [region, countryIndex] = key.split(':').map(Number);
761
+ const sorted = memberIds.sort((left, right) => provinces.size[right] - provinces.size[left] || left - right);
762
+ const draft = {
763
+ id: states.length + 1,
764
+ provinces: sorted,
765
+ owner: spec.countries[countryIndex].tag,
766
+ region,
767
+ capital: sorted[0],
768
+ manpower: 0,
769
+ category: 'rural',
770
+ infrastructure: 1,
771
+ civilian: 0,
772
+ military: 0,
773
+ dockyard: 0,
774
+ airBase: 0,
775
+ resources: new Map(),
776
+ victoryPoints: new Map(),
777
+ navalBases: new Map(),
778
+ };
779
+ states.push(draft);
780
+ for (const id of sorted)
781
+ stateOf.set(id, draft);
782
+ }
783
+ }
784
+ // State values from size, terrain and development, with the country capital boosted.
785
+ const categories = [
786
+ 'wasteland',
787
+ 'pastoral',
788
+ 'rural',
789
+ 'town',
790
+ 'large_town',
791
+ 'city',
792
+ 'large_city',
793
+ 'metropolis',
794
+ ];
795
+ const slotsFor = {
796
+ wasteland: 0,
797
+ pastoral: 1,
798
+ rural: 2,
799
+ town: 4,
800
+ large_town: 5,
801
+ city: 6,
802
+ large_city: 8,
803
+ metropolis: 10,
804
+ };
805
+ const countryCapitalState = new Map();
806
+ for (const [index, capital] of capitals.entries())
807
+ countryCapitalState.set(spec.countries[index].tag, stateOf.get(capital));
808
+ for (const state of states) {
809
+ const pixels = state.provinces.reduce((sum, id) => sum + provinces.size[id], 0);
810
+ const habitability = state.provinces.reduce((sum, id) => {
811
+ const value = {
812
+ plains: 1,
813
+ forest: 0.8,
814
+ hills: 0.6,
815
+ jungle: 0.5,
816
+ marsh: 0.4,
817
+ desert: 0.25,
818
+ mountain: 0.3,
819
+ urban: 1.5,
820
+ }[terrain.get(id) ?? 'plains'] ?? 0.5;
821
+ return sum + value * provinces.size[id];
822
+ }, 0) / pixels;
823
+ const isCapital = countryCapitalState.get(state.owner) === state;
824
+ const development = Math.min(1, habitability * (0.5 + random.next()) * (isCapital ? 1.8 : 1));
825
+ state.manpower = Math.max(1_000, Math.round((pixels * 180 * development) / 1_000) * 1_000);
826
+ const categoryIndex = Math.max(0, Math.min(categories.length - 1, Math.round(development * 5.2 + (isCapital ? 1 : 0))));
827
+ state.category = categories[categoryIndex];
828
+ if (context.stateCategories !== undefined && !context.stateCategories.has(state.category))
829
+ state.category = context.stateCategories.has('rural')
830
+ ? 'rural'
831
+ : ([...context.stateCategories][0] ?? 'rural');
832
+ const slots = slotsFor[state.category] ?? 2;
833
+ state.infrastructure = Math.max(1, Math.min(5, Math.round(1 + development * 3 + (isCapital ? 1 : 0))));
834
+ state.civilian = Math.round(slots * (0.25 + random.next() * 0.3));
835
+ state.military = Math.min(slots - state.civilian, Math.round(slots * random.next() * 0.3));
836
+ if (isCapital) {
837
+ terrain.set(state.capital, 'urban');
838
+ state.airBase = 2;
839
+ }
840
+ const coastalProvinces = state.provinces.filter(coastal);
841
+ if (coastalProvinces.length > 0 && (isCapital || random.next() < 0.35)) {
842
+ state.navalBases.set(coastalProvinces[0], isCapital ? 3 : 1);
843
+ if (slots - state.civilian - state.military > 0 && random.next() < 0.5)
844
+ state.dockyard = 1;
845
+ }
846
+ for (const id of state.provinces) {
847
+ const kindOfLand = terrain.get(id);
848
+ const roll = random.next();
849
+ const add = (resource, amount) => state.resources.set(resource, (state.resources.get(resource) ?? 0) + amount);
850
+ if (kindOfLand === 'desert' && roll < 0.3)
851
+ add('oil', 2 + random.int(6));
852
+ if ((kindOfLand === 'mountain' || kindOfLand === 'hills') && roll < 0.35)
853
+ add('steel', 2 + random.int(8));
854
+ if (kindOfLand === 'mountain' && roll < 0.15)
855
+ add('tungsten', 1 + random.int(4));
856
+ if (kindOfLand === 'hills' && roll > 0.85)
857
+ add('chromium', 1 + random.int(4));
858
+ if (kindOfLand === 'jungle' && roll < 0.35)
859
+ add('rubber', 1 + random.int(5));
860
+ if (kindOfLand === 'plains' && roll < 0.05)
861
+ add('aluminium', 1 + random.int(4));
862
+ }
863
+ state.victoryPoints.set(state.capital, isCapital ? 25 : Math.max(1, Math.round(development * 8)));
864
+ }
865
+ // Province IDs: 1-based in reading order of the province's first pixel; 0 is the null row.
866
+ const provinceId = (index) => index + 1;
867
+ // Unique colours, never black.
868
+ const colors = [];
869
+ const usedColors = new Set([0]);
870
+ for (let index = 0; index < count; index += 1) {
871
+ let value;
872
+ do
873
+ value = 1 + random.int(0xfffffe);
874
+ while (usedColors.has(value));
875
+ usedColors.add(value);
876
+ colors.push({ r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 });
877
+ }
878
+ const files = [];
879
+ const add = (relativePath, content, mediaType = 'text/plain') => files.push({ relativePath, content, mediaType });
880
+ const map = context.mapRoot;
881
+ // provinces.bmp
882
+ const rgb = new Array(width * height);
883
+ for (let pixel = 0; pixel < width * height; pixel += 1)
884
+ rgb[pixel] = colors[provinces.pixel[pixel]];
885
+ add(`${map}/provinces.bmp`, createBmp({ width, height, bitsPerPixel: 24, rgbPixels: rgb }), 'image/bmp');
886
+ // definition.csv: CRLF line endings, as the game expects.
887
+ const continentOf = (id) => provinces.kind[id] === LAND ? (continentOfPart.get(landParts.get(id)) ?? 1) : 0;
888
+ const definitionRows = ['0;0;0;0;land;false;unknown;0'];
889
+ for (let index = 0; index < count; index += 1) {
890
+ const color = colors[index];
891
+ const type = provinces.kind[index] === LAND ? 'land' : provinces.kind[index] === SEA ? 'sea' : 'lake';
892
+ definitionRows.push(`${provinceId(index)};${color.r};${color.g};${color.b};${type};${coastal(index) ? 'true' : 'false'};${terrain.get(index)};${continentOf(index)}`);
893
+ }
894
+ add(`${map}/definition.csv`, text(`${definitionRows.join('\r\n')}\r\n`));
895
+ add(`${map}/default.map`, text([
896
+ 'definitions = "definition.csv"',
897
+ 'provinces = "provinces.bmp"',
898
+ 'positions = "positions.txt"',
899
+ 'terrain = "terrain.bmp"',
900
+ 'rivers = "rivers.bmp"',
901
+ 'heightmap = "heightmap.bmp"',
902
+ 'tree_definition = "trees.bmp"',
903
+ 'continent = "continent.txt"',
904
+ 'adjacency_rules = "adjacency_rules.txt"',
905
+ 'adjacencies = "adjacencies.csv"',
906
+ 'ambient_object = "ambient_object.txt"',
907
+ 'seasons = "seasons.txt"',
908
+ '',
909
+ '# Indices in the trees.bmp palette that count as trees for automatic terrain assignment',
910
+ 'tree = { 3 4 7 10 }',
911
+ '',
912
+ ].join('\n')));
913
+ add(`${map}/continent.txt`, text(`continents = {\n${continentNames.map((_, index) => `\tworld_continent_${index + 1}`).join('\n')}\n}\n`));
914
+ 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
+ add(`${map}/adjacency_rules.txt`, text('# No straits or canals in this world yet.\n'));
916
+ add(`${map}/positions.txt`, text(''));
917
+ add(`${map}/ambient_object.txt`, text('# No ambient objects: the base-game map frame does not fit this map.\n'));
918
+ // heightmap.bmp: sea below 95, land from 96 up.
919
+ const heights = new Uint8Array(width * height);
920
+ for (let pixel = 0; pixel < width * height; pixel += 1) {
921
+ const value = surface.elevation[pixel];
922
+ heights[pixel] =
923
+ surface.kind[pixel] === LAND
924
+ ? Math.min(255, Math.round(96 + value * 110))
925
+ : surface.kind[pixel] === LAKE
926
+ ? 93
927
+ : Math.max(60, Math.round(94 + value * 30));
928
+ }
929
+ const grey = Array.from({ length: 256 }, (_, index) => ({ r: index, g: index, b: index }));
930
+ add(`${map}/heightmap.bmp`, createBmp({ width, height, bitsPerPixel: 8, palette: grey, indexedPixels: heights }), 'image/bmp');
931
+ // terrain.bmp from province terrain, with snow on high-latitude mountains.
932
+ const terrainIndex = context.terrainIndex ?? DEFAULT_TERRAIN_INDEX;
933
+ const terrainPixels = new Uint8Array(width * height);
934
+ for (let pixel = 0; pixel < width * height; pixel += 1) {
935
+ const id = provinces.pixel[pixel];
936
+ const type = terrain.get(id);
937
+ const snowy = type === 'mountain' && latitude(id) > 0.7;
938
+ terrainPixels[pixel] = snowy
939
+ ? 16
940
+ : (terrainIndex.get(type) ?? DEFAULT_TERRAIN_INDEX.get(type) ?? 0);
941
+ }
942
+ add(`${map}/terrain.bmp`, createBmp({
943
+ width,
944
+ height,
945
+ bitsPerPixel: 8,
946
+ palette: bmpPalette(context.palettes.terrain, (index) => ({
947
+ r: index,
948
+ g: 128,
949
+ b: 255 - index,
950
+ })),
951
+ indexedPixels: terrainPixels,
952
+ }), 'image/bmp');
953
+ // 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
+ }
988
+ const riverPalette = bmpPalette(context.palettes.rivers, (index) => index === 0
989
+ ? { r: 0, g: 255, b: 0 }
990
+ : index === 1
991
+ ? { r: 255, g: 0, b: 0 }
992
+ : index === 2
993
+ ? { r: 255, g: 252, b: 0 }
994
+ : index <= 11
995
+ ? { r: 0, g: Math.max(0, 255 - index * 20), b: 255 }
996
+ : index === 254
997
+ ? { r: 122, g: 122, b: 122 }
998
+ : { r: 255, g: 255, b: 255 });
999
+ add(`${map}/rivers.bmp`, createBmp({
1000
+ width,
1001
+ height,
1002
+ bitsPerPixel: 8,
1003
+ palette: riverPalette,
1004
+ indexedPixels: riverPixels,
1005
+ colorsUsed: 0,
1006
+ }), 'image/bmp');
1007
+ // trees.bmp: 75/256 of the map size, forests and jungles only.
1008
+ const treeWidth = Math.round((width * 75) / 256);
1009
+ const treeHeight = Math.round((height * 75) / 256);
1010
+ const treePixels = new Uint8Array(treeWidth * treeHeight);
1011
+ for (let y = 0; y < treeHeight; y += 1)
1012
+ for (let x = 0; x < treeWidth; x += 1) {
1013
+ const source = Math.floor((y * height) / treeHeight) * width + Math.floor((x * width) / treeWidth);
1014
+ const type = terrain.get(provinces.pixel[source]);
1015
+ const dense = surface.moisture[source] > 0.6;
1016
+ treePixels[y * treeWidth + x] =
1017
+ type === 'forest' ? (dense ? 6 : 5) : type === 'jungle' ? (dense ? 29 : 28) : 0;
1018
+ }
1019
+ add(`${map}/trees.bmp`, createBmp({
1020
+ width: treeWidth,
1021
+ height: treeHeight,
1022
+ bitsPerPixel: 8,
1023
+ palette: bmpPalette(context.palettes.trees, (index) => ({
1024
+ r: (index * 7) % 256,
1025
+ g: 120,
1026
+ b: 40,
1027
+ })),
1028
+ indexedPixels: treePixels,
1029
+ }), '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;
1034
+ add(`${map}/cities.bmp`, createBmp({
1035
+ width,
1036
+ height,
1037
+ bitsPerPixel: 8,
1038
+ palette: bmpPalette(context.palettes.cities, (index) => ({ r: index, g: index, b: index })),
1039
+ indexedPixels: cityPixels,
1040
+ }), 'image/bmp');
1041
+ // world_normal.bmp at half size from the height gradient.
1042
+ const halfWidth = width / 2;
1043
+ const halfHeight = height / 2;
1044
+ const normals = new Array(halfWidth * halfHeight);
1045
+ const heightAt = (x, y) => heights[Math.min(height - 1, Math.max(0, y)) * width + Math.min(width - 1, Math.max(0, x))];
1046
+ for (let y = 0; y < halfHeight; y += 1)
1047
+ for (let x = 0; x < halfWidth; x += 1) {
1048
+ const dx = (heightAt(x * 2 + 2, y * 2) - heightAt(x * 2 - 2, y * 2)) / 4;
1049
+ const dy = (heightAt(x * 2, y * 2 - 2) - heightAt(x * 2, y * 2 + 2)) / 4;
1050
+ const length = Math.hypot(dx, dy, 8);
1051
+ normals[y * halfWidth + x] = {
1052
+ r: Math.round(128 - (dx / length) * 127),
1053
+ g: Math.round(128 + (dy / length) * 127),
1054
+ b: Math.round(128 + (8 / length) * 127),
1055
+ };
1056
+ }
1057
+ add(`${map}/world_normal.bmp`, createBmp({ width: halfWidth, height: halfHeight, bitsPerPixel: 24, rgbPixels: normals }), 'image/bmp');
1058
+ // Colour maps: land tint and city lights at half size; water at half, quarter and eighth.
1059
+ const tint = {
1060
+ plains: { r: 121, g: 133, b: 76 },
1061
+ forest: { r: 72, g: 96, b: 54 },
1062
+ hills: { r: 128, g: 117, b: 80 },
1063
+ mountain: { r: 132, g: 124, b: 112 },
1064
+ desert: { r: 196, g: 170, b: 116 },
1065
+ jungle: { r: 56, g: 92, b: 48 },
1066
+ marsh: { r: 92, g: 104, b: 74 },
1067
+ urban: { r: 128, g: 120, b: 104 },
1068
+ lakes: { r: 70, g: 96, b: 110 },
1069
+ ocean: { r: 50, g: 70, b: 92 },
1070
+ };
1071
+ const colormap = new Uint8Array(halfWidth * halfHeight * 4);
1072
+ for (let y = 0; y < halfHeight; y += 1)
1073
+ for (let x = 0; x < halfWidth; x += 1) {
1074
+ // DDS rows run top to bottom, like the bitmaps' top-left coordinates used here.
1075
+ const source = y * 2 * width + x * 2;
1076
+ const id = provinces.pixel[source];
1077
+ const color = tint[terrain.get(id) ?? 'plains'];
1078
+ const shade = 0.85 + surface.moisture[source] * 0.3;
1079
+ const offset = (y * halfWidth + x) * 4;
1080
+ colormap[offset] = Math.min(255, Math.round(color.b * shade));
1081
+ colormap[offset + 1] = Math.min(255, Math.round(color.g * shade));
1082
+ colormap[offset + 2] = Math.min(255, Math.round(color.r * shade));
1083
+ colormap[offset + 3] = terrain.get(id) === 'urban' ? 200 : 0;
1084
+ }
1085
+ add(`${map}/terrain/colormap_rgb_cityemissivemask_a.dds`, encodeDds(halfWidth, halfHeight, colormap), 'image/vnd-ms.dds');
1086
+ for (const level of [0, 1, 2]) {
1087
+ const factor = 2 ** (level + 1);
1088
+ const levelWidth = width / factor;
1089
+ const levelHeight = height / factor;
1090
+ const water = new Uint8Array(levelWidth * levelHeight * 4);
1091
+ for (let y = 0; y < levelHeight; y += 1)
1092
+ for (let x = 0; x < levelWidth; x += 1) {
1093
+ const depth = Math.max(0, -surface.elevation[y * factor * width + x * factor]);
1094
+ const offset = (y * levelWidth + x) * 4;
1095
+ water[offset] = Math.round(120 - depth * 50);
1096
+ water[offset + 1] = Math.round(90 - depth * 40);
1097
+ water[offset + 2] = Math.round(40 - depth * 20);
1098
+ water[offset + 3] = 255;
1099
+ }
1100
+ add(`${map}/terrain/colormap_water_${level}.dds`, encodeDds(levelWidth, levelHeight, water), 'image/vnd-ms.dds');
1101
+ }
1102
+ // Strategic regions with weather by latitude.
1103
+ const regionMembers = new Map();
1104
+ for (const [id, region] of regionOf) {
1105
+ const list = regionMembers.get(region) ?? [];
1106
+ list.push(provinceId(id));
1107
+ regionMembers.set(region, list);
1108
+ }
1109
+ for (const [region, members] of [...regionMembers].sort((left, right) => left[0] - right[0])) {
1110
+ const indexes = members.map((member) => member - 1);
1111
+ const meanLatitude = indexes.reduce((sum, id) => sum + latitude(id), 0) / indexes.length;
1112
+ const desert = indexes.filter((id) => terrain.get(id) === 'desert').length > indexes.length / 2;
1113
+ const naval = indexes.every((id) => provinces.kind[id] !== LAND);
1114
+ add(`${map}/strategicregions/${region}-World region ${region}.txt`, text([
1115
+ 'strategic_region={',
1116
+ `\tid=${region}`,
1117
+ `\tname="STRATEGICREGION_${region}"`,
1118
+ '\tprovinces={',
1119
+ `\t\t${members.sort((a, b) => a - b).join(' ')} `,
1120
+ '\t}',
1121
+ ...(naval ? ['\tnaval_terrain=water_deep_ocean'] : []),
1122
+ weatherBlock(meanLatitude, desert),
1123
+ '}',
1124
+ '',
1125
+ ].join('\n')));
1126
+ }
1127
+ // States.
1128
+ for (const state of states) {
1129
+ const provinceList = state.provinces.map(provinceId).sort((a, b) => a - b);
1130
+ const resources = [...state.resources].sort(([left], [right]) => (left < right ? -1 : 1));
1131
+ const buildings = [
1132
+ `\t\t\tinfrastructure = ${state.infrastructure}`,
1133
+ ...(state.civilian > 0 ? [`\t\t\tindustrial_complex = ${state.civilian}`] : []),
1134
+ ...(state.military > 0 ? [`\t\t\tarms_factory = ${state.military}`] : []),
1135
+ ...(state.dockyard > 0 ? [`\t\t\tdockyard = ${state.dockyard}`] : []),
1136
+ ...(state.airBase > 0 ? [`\t\t\tair_base = ${state.airBase}`] : []),
1137
+ ...[...state.navalBases].map(([id, level]) => `\t\t\t${provinceId(id)} = { naval_base = ${level} }`),
1138
+ ];
1139
+ add(`${context.statesRoot}/${state.id}-STATE_${state.id}.txt`, text([
1140
+ 'state = {',
1141
+ `\tid = ${state.id}`,
1142
+ `\tname = "STATE_${state.id}"`,
1143
+ `\tmanpower = ${state.manpower}`,
1144
+ `\tstate_category = ${state.category}`,
1145
+ ...(resources.length === 0
1146
+ ? []
1147
+ : [
1148
+ `\tresources = {\n${resources.map(([resource, amount]) => `\t\t${resource} = ${amount}`).join('\n')}\n\t}`,
1149
+ ]),
1150
+ '\thistory = {',
1151
+ `\t\towner = ${state.owner}`,
1152
+ `\t\tadd_core_of = ${state.owner}`,
1153
+ ...[...state.victoryPoints].map(([id, value]) => `\t\tvictory_points = { ${provinceId(id)} ${value} }`),
1154
+ '\t\tbuildings = {',
1155
+ ...buildings,
1156
+ '\t\t}',
1157
+ '\t}',
1158
+ `\tprovinces = { ${provinceList.join(' ')} }`,
1159
+ '}',
1160
+ '',
1161
+ ].join('\n')));
1162
+ }
1163
+ // Countries.
1164
+ const countryLocalisation = [];
1165
+ const countryLines = [];
1166
+ for (const [index, country] of spec.countries.entries()) {
1167
+ const color = country.color ?? {
1168
+ r: 60 + random.int(170),
1169
+ g: 60 + random.int(170),
1170
+ b: 60 + random.int(170),
1171
+ };
1172
+ const fileName = slug(country.name);
1173
+ const ideology = country.ideology ?? 'neutrality';
1174
+ const capitalState = countryCapitalState.get(country.tag);
1175
+ countryLines.push(`${country.tag} = "countries/${fileName}.txt"`);
1176
+ 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
+ const popularity = { democratic: 10, communism: 10, fascism: 10, neutrality: 10 };
1178
+ popularity[ideology] = 70;
1179
+ add(`history/countries/${country.tag} - ${fileName}.txt`, text([
1180
+ `capital = ${capitalState.id}`,
1181
+ '',
1182
+ 'set_research_slots = 3',
1183
+ 'set_stability = 0.6',
1184
+ 'set_war_support = 0.2',
1185
+ '',
1186
+ 'set_technology = {',
1187
+ '\tinfantry_weapons = 1',
1188
+ '\ttech_support = 1',
1189
+ '\ttech_recon = 1',
1190
+ '}',
1191
+ '',
1192
+ 'set_politics = {',
1193
+ `\truling_party = ${ideology}`,
1194
+ `\tlast_election = "${Number(spec.startDate.split('.')[0]) - 4}.1.1"`,
1195
+ '\telection_frequency = 48',
1196
+ `\telections_allowed = ${ideology === 'democratic' ? 'yes' : 'no'}`,
1197
+ '}',
1198
+ 'set_popularities = {',
1199
+ ...Object.entries(popularity).map(([name, value]) => `\t${name} = ${value}`),
1200
+ '}',
1201
+ '',
1202
+ ].join('\n')));
1203
+ countryLocalisation.push([country.tag, country.name], [`${country.tag}_DEF`, country.name], [`${country.tag}_ADJ`, country.adjective ?? country.name]);
1204
+ // A three-band flag in the country colour, in the three sizes the game draws.
1205
+ const dark = {
1206
+ r: Math.round(color.r * 0.55),
1207
+ g: Math.round(color.g * 0.55),
1208
+ b: Math.round(color.b * 0.55),
1209
+ };
1210
+ const light = {
1211
+ r: Math.min(255, color.r + 80),
1212
+ g: Math.min(255, color.g + 80),
1213
+ b: Math.min(255, color.b + 80),
1214
+ };
1215
+ for (const [folder, flagWidth, flagHeight] of [
1216
+ ['', 82, 52],
1217
+ ['medium/', 41, 26],
1218
+ ['small/', 10, 7],
1219
+ ])
1220
+ 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');
1221
+ void index;
1222
+ }
1223
+ add('common/country_tags/zz_world_countries.txt', text(`${countryLines.join('\n')}\n`));
1224
+ // The start bookmark lists the largest countries as majors.
1225
+ const ranked = [...spec.countries].sort((left, right) => states
1226
+ .filter((state) => state.owner === right.tag)
1227
+ .reduce((sum, state) => sum + state.manpower, 0) -
1228
+ states
1229
+ .filter((state) => state.owner === left.tag)
1230
+ .reduce((sum, state) => sum + state.manpower, 0) || (left.tag < right.tag ? -1 : 1));
1231
+ const majors = ranked.slice(0, Math.min(7, ranked.length));
1232
+ add('common/bookmarks/world.txt', text([
1233
+ 'bookmarks = {',
1234
+ '\tbookmark = {',
1235
+ '\t\tname = "WORLD_BOOKMARK_NAME"',
1236
+ '\t\tdesc = "WORLD_BOOKMARK_DESC"',
1237
+ `\t\tdate = ${spec.startDate}.12`,
1238
+ '\t\tpicture = "GFX_select_date_1936"',
1239
+ `\t\tdefault_country = "${majors[0].tag}"`,
1240
+ '\t\tdefault = yes',
1241
+ ...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}`),
1242
+ '\t\t"---" = {\n\t\t\thistory = "WORLD_OTHER_DESC"\n\t\t}',
1243
+ '\t\teffect = {\n\t\t\trandomize_weather = 22345\n\t\t}',
1244
+ '\t}',
1245
+ '}',
1246
+ '',
1247
+ ].join('\n')));
1248
+ // 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)}`])),
1252
+ ...[...regionMembers.keys()]
1253
+ .sort((a, b) => a - b)
1254
+ .map((region) => [`STRATEGICREGION_${region}`, `Region ${region}`]),
1255
+ ...continentNames.map((name, index) => [`world_continent_${index + 1}`, name]),
1256
+ ...countryLocalisation,
1257
+ ['WORLD_BOOKMARK_NAME', 'A New World'],
1258
+ ['WORLD_BOOKMARK_DESC', 'Nations of an unfamiliar map at the dawn of the war.'],
1259
+ ['WORLD_OTHER_DESC', 'Any other nation of this world.'],
1260
+ ...majors.map((country) => [
1261
+ `${country.tag}_WORLD_DESC`,
1262
+ `${country.name} begins the game among the strongest nations.`,
1263
+ ]),
1264
+ ]));
1265
+ return {
1266
+ files,
1267
+ summary: {
1268
+ width,
1269
+ height,
1270
+ landProvinces: landIds.length,
1271
+ seaProvinces: seaIds.length,
1272
+ lakeProvinces: lakeIds.length,
1273
+ states: states.length,
1274
+ landRegions: landRegionCount,
1275
+ seaRegions: seaRegionCount,
1276
+ countries: spec.countries.length,
1277
+ rivers: riverCount,
1278
+ },
1279
+ };
1280
+ }