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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -0
- package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
- package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
- package/dist/hoi4_agent_tools/core/engine.js +2 -1
- package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
- package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
- package/dist/hoi4_agent_tools/core/scanner.js +17 -0
- package/dist/hoi4_agent_tools/core/server-state.js +9 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
- package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
- package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
- package/dist/hoi4_agent_tools/core/transactions.js +355 -111
- package/dist/hoi4_agent_tools/gui/layout.js +61 -25
- package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
- package/dist/hoi4_agent_tools/gui/source-graph.js +9 -6
- package/dist/hoi4_agent_tools/gui/studio.js +4 -1
- package/dist/hoi4_agent_tools/map/derived.js +21 -6
- package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
- package/dist/hoi4_agent_tools/map/operations.js +72 -21
- package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
- package/dist/hoi4_agent_tools/map/render.js +9 -1
- package/dist/hoi4_agent_tools/map/service.js +222 -18
- package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
- package/dist/hoi4_agent_tools/map/world-context.js +148 -0
- package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
- package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
- package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
- package/dist/hoi4_agent_tools/map/world.js +374 -253
- package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
- package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
- package/dist/hoi4_agent_tools/schemas/map.js +103 -10
- package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
- package/dist/hoi4_agent_tools/version.js +2 -2
- package/docs/gui.md +6 -2
- package/docs/images/reference-maps/political.png +0 -0
- package/docs/images/reference-maps/sketch.png +0 -0
- package/docs/map.md +45 -0
- package/docs/third-party-notices.md +6 -0
- package/npm-shrinkwrap.json +124 -2
- package/package.json +4 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +373 -70
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- 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) /
|
|
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,
|
|
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
|
|
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',
|
|
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
|
|
504
|
-
function weatherBlock(
|
|
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
|
|
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
|
|
554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 =
|
|
937
|
-
const snowy =
|
|
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
|
-
?
|
|
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 =
|
|
955
|
-
|
|
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 =
|
|
1067
|
+
const type = visualTerrain(source);
|
|
1015
1068
|
const dense = surface.moisture[source] > 0.6;
|
|
1016
1069
|
treePixels[y * treeWidth + x] =
|
|
1017
|
-
|
|
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:
|
|
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
|
|
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] =
|
|
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 +
|
|
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}
|
|
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}
|
|
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} - ${
|
|
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
|
-
|
|
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}/
|
|
1250
|
-
...states.map((state) => [
|
|
1251
|
-
|
|
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,
|