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