hoi4-agent-tools 4.3.0 → 4.4.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 +25 -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/operation-context.js +17 -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/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 +15 -2
- package/dist/hoi4_agent_tools/map/operations.js +111 -8
- 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 +138 -5
- 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 +191 -16
- package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
- 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 +24 -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-partition.js +119 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
- package/dist/hoi4_agent_tools/map/world.js +462 -124
- 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 +209 -2
- 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/map.md +258 -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 +2156 -259
- 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
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lake and water conventions (ADR 0053). The engine draws a water layer everywhere with an alpha
|
|
3
|
+
* that depends on the heightmap, so what a lake looks like is decided by its height, not by its
|
|
4
|
+
* province type alone. These functions are shared by the generator and the validators.
|
|
5
|
+
*/
|
|
6
|
+
import { fractal } from './world-noise.js';
|
|
7
|
+
const SEA = 0;
|
|
8
|
+
const LAND = 1;
|
|
9
|
+
const LAKE = 2;
|
|
10
|
+
/** Engine constant: the water surface is a plane at 9.5 world units, heightmap value 95. */
|
|
11
|
+
export const WATER_SURFACE_HEIGHT = 95;
|
|
12
|
+
/** Height of sea and lake interiors in the base game (99.995% of its sea pixels). */
|
|
13
|
+
export const WATER_INTERIOR_HEIGHT = 89;
|
|
14
|
+
/** Largest height at which the water layer is fully opaque. */
|
|
15
|
+
export const WATER_OPAQUE_HEIGHT = 91;
|
|
16
|
+
/** Lowest height of land; coasts rise to this value. */
|
|
17
|
+
export const LAND_MINIMUM_HEIGHT = 96;
|
|
18
|
+
/**
|
|
19
|
+
* Heights of lake pixels at distance 1, 2, ... from the shore; deeper pixels use the interior
|
|
20
|
+
* height. Only the shore ring is translucent, so the lake reads as open water, not as specks.
|
|
21
|
+
*/
|
|
22
|
+
export const LAKE_BANK_HEIGHTS = [93, 91];
|
|
23
|
+
/** A lake pixel within this distance of land may stand above `WATER_OPAQUE_HEIGHT` (up to 95). */
|
|
24
|
+
export const LAKE_BANK_ALLOWANCE_PX = 3;
|
|
25
|
+
export const LAKE_BANK_MAXIMUM_HEIGHT = 95;
|
|
26
|
+
/** Enclosed water below this size is not a lake; it becomes land. */
|
|
27
|
+
export const LAKE_MINIMUM_PIXELS = 60;
|
|
28
|
+
/**
|
|
29
|
+
* Opacity of the water layer at a heightmap value: `1 - saturate((h / 95.7 - 0.954) * 25)`, as
|
|
30
|
+
* `pdxwater.shader` computes it (on a 3 x 3 average of the heightmap, which this ignores).
|
|
31
|
+
*/
|
|
32
|
+
export function waterAlpha(height) {
|
|
33
|
+
const t = (height / 95.7 - 0.954) * 25;
|
|
34
|
+
return 1 - Math.min(1, Math.max(0, t));
|
|
35
|
+
}
|
|
36
|
+
/** Height of a lake pixel `distance` pixels from the nearest land (1 is adjacent to land). */
|
|
37
|
+
export function lakeHeight(distance) {
|
|
38
|
+
return distance >= 1 && distance <= LAKE_BANK_HEIGHTS.length
|
|
39
|
+
? LAKE_BANK_HEIGHTS[distance - 1]
|
|
40
|
+
: WATER_INTERIOR_HEIGHT;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Four-neighbour distance from the nearest land pixel through water: 0 on land, 1 for water
|
|
44
|
+
* next to land, capped at `limit`. Pixels the search does not reach hold `limit`.
|
|
45
|
+
*/
|
|
46
|
+
export function landDistanceField(kind, width, height, limit = 255) {
|
|
47
|
+
const total = width * height;
|
|
48
|
+
const distance = new Uint8Array(total).fill(limit);
|
|
49
|
+
const queue = new Int32Array(total);
|
|
50
|
+
let head = 0;
|
|
51
|
+
let tail = 0;
|
|
52
|
+
for (let pixel = 0; pixel < total; pixel += 1)
|
|
53
|
+
if (kind[pixel] === LAND) {
|
|
54
|
+
distance[pixel] = 0;
|
|
55
|
+
queue[tail++] = pixel;
|
|
56
|
+
}
|
|
57
|
+
while (head < tail) {
|
|
58
|
+
const pixel = queue[head++];
|
|
59
|
+
const next = distance[pixel] + 1;
|
|
60
|
+
if (next >= limit)
|
|
61
|
+
continue;
|
|
62
|
+
const x = pixel % width;
|
|
63
|
+
const visit = (other) => {
|
|
64
|
+
if (distance[other] === limit && kind[other] !== LAND) {
|
|
65
|
+
distance[other] = next;
|
|
66
|
+
queue[tail++] = other;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
if (x > 0)
|
|
70
|
+
visit(pixel - 1);
|
|
71
|
+
if (x < width - 1)
|
|
72
|
+
visit(pixel + 1);
|
|
73
|
+
if (pixel >= width)
|
|
74
|
+
visit(pixel - width);
|
|
75
|
+
if (pixel < total - width)
|
|
76
|
+
visit(pixel + width);
|
|
77
|
+
}
|
|
78
|
+
return distance;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Enforce the lake rules on a raster of 0 sea, 1 land and 2 lake (modified in place, with the
|
|
82
|
+
* matching elevation values): lake water that touches the open sea is sea, and enclosed water
|
|
83
|
+
* below `LAKE_MINIMUM_PIXELS` is land. Every change is reported.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeLakes(kind, elevation, width, height, minimumPixels = LAKE_MINIMUM_PIXELS) {
|
|
86
|
+
const total = width * height;
|
|
87
|
+
const notes = [];
|
|
88
|
+
const seen = new Uint8Array(total);
|
|
89
|
+
const queue = new Int32Array(total);
|
|
90
|
+
for (let start = 0; start < total; start += 1) {
|
|
91
|
+
if (kind[start] !== LAKE || seen[start])
|
|
92
|
+
continue;
|
|
93
|
+
let head = 0;
|
|
94
|
+
let tail = 0;
|
|
95
|
+
const found = { sea: false };
|
|
96
|
+
queue[tail++] = start;
|
|
97
|
+
seen[start] = 1;
|
|
98
|
+
while (head < tail) {
|
|
99
|
+
const pixel = queue[head++];
|
|
100
|
+
const x = pixel % width;
|
|
101
|
+
const visit = (other) => {
|
|
102
|
+
if (kind[other] === SEA)
|
|
103
|
+
found.sea = true;
|
|
104
|
+
else if (kind[other] === LAKE && !seen[other]) {
|
|
105
|
+
seen[other] = 1;
|
|
106
|
+
queue[tail++] = other;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
if (x > 0)
|
|
110
|
+
visit(pixel - 1);
|
|
111
|
+
if (x < width - 1)
|
|
112
|
+
visit(pixel + 1);
|
|
113
|
+
if (pixel >= width)
|
|
114
|
+
visit(pixel - width);
|
|
115
|
+
if (pixel < total - width)
|
|
116
|
+
visit(pixel + width);
|
|
117
|
+
}
|
|
118
|
+
if (!found.sea && tail >= minimumPixels)
|
|
119
|
+
continue;
|
|
120
|
+
const toSea = found.sea;
|
|
121
|
+
for (let index = 0; index < tail; index += 1) {
|
|
122
|
+
const pixel = queue[index];
|
|
123
|
+
kind[pixel] = toSea ? SEA : LAND;
|
|
124
|
+
elevation[pixel] = toSea ? -0.15 : 0.02;
|
|
125
|
+
}
|
|
126
|
+
const x = start % width;
|
|
127
|
+
notes.push({
|
|
128
|
+
code: toSea ? 'MAP_LAKE_CONNECTED_TO_SEA' : 'MAP_LAKE_TOO_SMALL',
|
|
129
|
+
message: toSea
|
|
130
|
+
? `Lake water of ${tail} pixels touches the open sea and was written as sea`
|
|
131
|
+
: `Enclosed water of ${tail} pixels is below the ${minimumPixels} pixel lake floor and was written as land`,
|
|
132
|
+
pixels: tail,
|
|
133
|
+
x,
|
|
134
|
+
y: (start - x) / width,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return notes;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Sea tint by distance from land in vanilla-map pixels, fitted to the base game's water colour
|
|
141
|
+
* map: bright over the shelf, falling to a dark deep blue (measured means 59,102,138 at the
|
|
142
|
+
* coast, 83,130,175 at 5 to 10 pixels, 34,57,78 beyond 160).
|
|
143
|
+
*/
|
|
144
|
+
const SEA_CURVE = [
|
|
145
|
+
[0, 59, 102, 138],
|
|
146
|
+
[3, 73, 120, 161],
|
|
147
|
+
[7, 83, 130, 175],
|
|
148
|
+
[15, 69, 111, 153],
|
|
149
|
+
[30, 57, 92, 130],
|
|
150
|
+
[60, 45, 74, 106],
|
|
151
|
+
[120, 36, 60, 86],
|
|
152
|
+
[200, 34, 57, 78],
|
|
153
|
+
];
|
|
154
|
+
const LAKE_INTERIOR_TINT = [58, 102, 147];
|
|
155
|
+
const LAKE_SHORE_TINT = [78, 128, 168];
|
|
156
|
+
const DEEPEST_TINT = [26, 43, 59];
|
|
157
|
+
const VANILLA_MAP_WIDTH = 5632;
|
|
158
|
+
function curveAt(curve, distance) {
|
|
159
|
+
if (distance <= curve[0][0])
|
|
160
|
+
return [curve[0][1], curve[0][2], curve[0][3]];
|
|
161
|
+
for (let index = 1; index < curve.length; index += 1) {
|
|
162
|
+
const upper = curve[index];
|
|
163
|
+
if (distance <= upper[0]) {
|
|
164
|
+
const lower = curve[index - 1];
|
|
165
|
+
const t = (distance - lower[0]) / (upper[0] - lower[0]);
|
|
166
|
+
return [
|
|
167
|
+
lower[1] + (upper[1] - lower[1]) * t,
|
|
168
|
+
lower[2] + (upper[2] - lower[2]) * t,
|
|
169
|
+
lower[3] + (upper[3] - lower[3]) * t,
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const last = curve.at(-1);
|
|
174
|
+
return [last[1], last[2], last[3]];
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* One level of the water colour map as BGRA bytes (alpha 255), sampling the full-resolution
|
|
178
|
+
* rasters every `2 ** (level + 1)` pixels. Pure arithmetic plus value noise: platform-stable.
|
|
179
|
+
*/
|
|
180
|
+
export function waterColourLevel(input, level) {
|
|
181
|
+
const { width, height, kind, landDistance } = input;
|
|
182
|
+
const factor = 2 ** (level + 1);
|
|
183
|
+
const levelWidth = width / factor;
|
|
184
|
+
const levelHeight = height / factor;
|
|
185
|
+
const scale = Math.min(1.5, Math.max(0.35, width / VANILLA_MAP_WIDTH));
|
|
186
|
+
const patch = Math.max(48, width / 9);
|
|
187
|
+
const output = new Uint8Array(levelWidth * levelHeight * 4);
|
|
188
|
+
for (let y = 0; y < levelHeight; y += 1)
|
|
189
|
+
for (let x = 0; x < levelWidth; x += 1) {
|
|
190
|
+
const sx = x * factor;
|
|
191
|
+
const sy = y * factor;
|
|
192
|
+
const pixel = sy * width + sx;
|
|
193
|
+
const distance = landDistance[pixel];
|
|
194
|
+
let r;
|
|
195
|
+
let g;
|
|
196
|
+
let b;
|
|
197
|
+
const noise = fractal(input.seed + 3571, sx, sy, patch, 3) - 0.5;
|
|
198
|
+
if (kind[pixel] === LAKE) {
|
|
199
|
+
const t = Math.min(1, Math.max(0, (distance - 1) / 7));
|
|
200
|
+
const variation = 1 + noise * 0.3;
|
|
201
|
+
r = (LAKE_SHORE_TINT[0] + (LAKE_INTERIOR_TINT[0] - LAKE_SHORE_TINT[0]) * t) * variation;
|
|
202
|
+
g = (LAKE_SHORE_TINT[1] + (LAKE_INTERIOR_TINT[1] - LAKE_SHORE_TINT[1]) * t) * variation;
|
|
203
|
+
b = (LAKE_SHORE_TINT[2] + (LAKE_INTERIOR_TINT[2] - LAKE_SHORE_TINT[2]) * t) * variation;
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
const [cr, cg, cb] = curveAt(SEA_CURVE, distance / scale);
|
|
207
|
+
// Open water varies in large patches; the shelf stays calmer.
|
|
208
|
+
const strength = 0.25 + 0.75 * Math.min(1, distance / (60 * scale));
|
|
209
|
+
const variation = Math.min(1.5, Math.max(0.6, 1 + noise * 2 * strength));
|
|
210
|
+
r = cr * variation;
|
|
211
|
+
g = cg * variation;
|
|
212
|
+
b = cb * variation;
|
|
213
|
+
}
|
|
214
|
+
const darkness = input.darken?.(sx, sy) ?? 0;
|
|
215
|
+
if (darkness > 0) {
|
|
216
|
+
r += (DEEPEST_TINT[0] - r) * darkness;
|
|
217
|
+
g += (DEEPEST_TINT[1] - g) * darkness;
|
|
218
|
+
b += (DEEPEST_TINT[2] - b) * darkness;
|
|
219
|
+
}
|
|
220
|
+
const offset = (y * levelWidth + x) * 4;
|
|
221
|
+
output[offset] = Math.min(255, Math.max(0, Math.round(b)));
|
|
222
|
+
output[offset + 1] = Math.min(255, Math.max(0, Math.round(g)));
|
|
223
|
+
output[offset + 2] = Math.min(255, Math.max(0, Math.round(r)));
|
|
224
|
+
output[offset + 3] = 255;
|
|
225
|
+
}
|
|
226
|
+
return output;
|
|
227
|
+
}
|
|
@@ -68,6 +68,8 @@ export function buildWorldContext(base) {
|
|
|
68
68
|
.filter((file) => /^common\/terrain\/[^/]+\.txt$/u.test(normalized(file.relativePath)))
|
|
69
69
|
.sort((a, b) => compareCodeUnits(normalized(a.relativePath), normalized(b.relativePath)));
|
|
70
70
|
const terrainIndex = new Map();
|
|
71
|
+
const unrendered = [];
|
|
72
|
+
const entries = [];
|
|
71
73
|
for (const file of terrainFiles) {
|
|
72
74
|
const candidates = new Map();
|
|
73
75
|
const terrain = assignments(documentFor(file).root, 'terrain');
|
|
@@ -102,9 +104,25 @@ export function buildWorldContext(base) {
|
|
|
102
104
|
invalid(file, 'Graphical terrain refers to a missing terrain palette entry.');
|
|
103
105
|
index = Math.min(index, value);
|
|
104
106
|
}
|
|
107
|
+
const name = entry.key.value;
|
|
108
|
+
const textureValue = firstScalar(entry.value, 'texture')?.value;
|
|
109
|
+
entries.push({
|
|
110
|
+
name,
|
|
111
|
+
index,
|
|
112
|
+
type,
|
|
113
|
+
...(textureValue !== undefined && /^\d+$/u.test(textureValue)
|
|
114
|
+
? { texture: Number(textureValue) }
|
|
115
|
+
: {}),
|
|
116
|
+
permSnow: firstScalar(entry.value, 'perm_snow')?.value === 'yes',
|
|
117
|
+
});
|
|
118
|
+
// Texture 255 names no atlas tile: the engine draws nothing useful for that index, so it
|
|
119
|
+
// is never chosen to represent a category (the base game's `lakes` entry is one).
|
|
120
|
+
if (firstScalar(entry.value, 'texture')?.value === '255') {
|
|
121
|
+
unrendered.push({ name, index, type, reason: 'texture = 255 selects no atlas tile' });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
105
124
|
// Prefer ordinary category variants over cross-category names or permanent snow.
|
|
106
125
|
// Within an equal rank, the lowest declared index is the stable representative.
|
|
107
|
-
const name = entry.key.value;
|
|
108
126
|
let rank = name === type ? 0 : name.startsWith(`${type}_`) || /^terrain_\d+$/u.test(name) ? 1 : 2;
|
|
109
127
|
if (firstScalar(entry.value, 'perm_snow')?.value === 'yes')
|
|
110
128
|
rank += 4;
|
|
@@ -119,6 +137,11 @@ export function buildWorldContext(base) {
|
|
|
119
137
|
for (const [type, candidate] of candidates)
|
|
120
138
|
terrainIndex.set(type, candidate.index);
|
|
121
139
|
}
|
|
140
|
+
if (entries.length > 0)
|
|
141
|
+
context.terrainEntries = entries.sort((a, b) => a.index - b.index || compareCodeUnits(a.name, b.name));
|
|
142
|
+
const gaps = unrendered.filter(({ type }) => !terrainIndex.has(type));
|
|
143
|
+
if (gaps.length > 0)
|
|
144
|
+
context.unrenderedTerrain = gaps;
|
|
122
145
|
if (terrainIndex.size > 0)
|
|
123
146
|
context.terrainIndex = new Map([...terrainIndex].sort(([a], [b]) => compareCodeUnits(a, b)));
|
|
124
147
|
const categoryFiles = base.activeFiles.all
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* World edge and seam policy (ADR 0061). The engine wraps the map horizontally and draws it as a
|
|
3
|
+
* sphere for day and night, so land that touches column 0 or the last column is cut by a straight
|
|
4
|
+
* line where the world repeats. This module keeps land away from the seam and the poles, or ends
|
|
5
|
+
* it in an impassable rim, and writes the north and south frame borders the base map has.
|
|
6
|
+
*/
|
|
7
|
+
import { ServiceError } from '../core/result.js';
|
|
8
|
+
import { valueNoise } from './world-noise.js';
|
|
9
|
+
const SEA = 0;
|
|
10
|
+
const LAND = 1;
|
|
11
|
+
/** Lowest height of the rim range at its inner foot, and its height at the seam. */
|
|
12
|
+
export const RIM_FOOT_HEIGHT = 150;
|
|
13
|
+
export const RIM_SEAM_HEIGHT = 230;
|
|
14
|
+
/** The darkest rim colour multiplier, at the seam. */
|
|
15
|
+
export const RIM_COLOUR_FACTOR = 0.55;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the optional `edge` input against the world size. Omitting it keeps the previous
|
|
18
|
+
* behaviour (`none`); an `edge` object without a mode asks for the open-sea band.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveEdge(input, width, height) {
|
|
21
|
+
if (input === undefined)
|
|
22
|
+
return { mode: 'none', bandPx: 0, polarPx: 0, rimPx: 0 };
|
|
23
|
+
const mode = input.mode ?? 'sea-band';
|
|
24
|
+
const quarter = Math.floor(width / 4);
|
|
25
|
+
const polarLimit = Math.floor(height / 4);
|
|
26
|
+
const bandPx = input.bandPx ?? Math.min(Math.max(192, Math.round(0.04 * width)), quarter);
|
|
27
|
+
const polarPx = input.polarPx ?? Math.min(64, Math.floor(height / 8));
|
|
28
|
+
const rimPx = input.rimPx ?? Math.min(160, quarter);
|
|
29
|
+
if (bandPx > quarter || rimPx > quarter || polarPx > polarLimit)
|
|
30
|
+
throw new ServiceError('MAP_EDGE_BAND_TOO_WIDE', 'The edge band and rim may not exceed a quarter of the world width, nor the polar band a quarter of its height', { width, height, bandPx, rimPx, polarPx, maximumWidth: quarter, maximumHeight: polarLimit });
|
|
31
|
+
if (mode === 'none')
|
|
32
|
+
return { mode, bandPx: 0, polarPx: 0, rimPx: 0 };
|
|
33
|
+
return {
|
|
34
|
+
mode,
|
|
35
|
+
bandPx: mode === 'sea-band' ? bandPx : 0,
|
|
36
|
+
polarPx,
|
|
37
|
+
rimPx: mode === 'rim' ? rimPx : 0,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Cities and named land features may not stand where the policy clears land. */
|
|
41
|
+
export function assertFeaturesOutsideBand(edge, width, height, features) {
|
|
42
|
+
if (edge.mode === 'none')
|
|
43
|
+
return;
|
|
44
|
+
const offenders = features.filter((feature) => {
|
|
45
|
+
if (feature.kind === 'sea')
|
|
46
|
+
return false;
|
|
47
|
+
const northSouth = feature.y < edge.polarPx || feature.y >= height - edge.polarPx;
|
|
48
|
+
const eastWest = edge.mode === 'sea-band' && (feature.x < edge.bandPx || feature.x >= width - edge.bandPx);
|
|
49
|
+
return northSouth || eastWest;
|
|
50
|
+
});
|
|
51
|
+
if (offenders.length === 0)
|
|
52
|
+
return;
|
|
53
|
+
throw new ServiceError('MAP_EDGE_FEATURE_IN_BAND', `${offenders.length} named feature(s) stand inside the world edge band, where land is cleared: ${offenders
|
|
54
|
+
.slice(0, 5)
|
|
55
|
+
.map((feature) => `${feature.name} (${Math.round(feature.x)}, ${Math.round(feature.y)})`)
|
|
56
|
+
.join('; ')}. Move them inland or choose edge.mode "none" or "rim".`, {
|
|
57
|
+
mode: edge.mode,
|
|
58
|
+
bandPx: edge.bandPx,
|
|
59
|
+
polarPx: edge.polarPx,
|
|
60
|
+
features: offenders.slice(0, 20),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Per-row (east-west) and per-column (north-south) clearing limits with a ragged outline. */
|
|
64
|
+
function limits(edge, width, height, seed) {
|
|
65
|
+
const rows = new Int32Array(height);
|
|
66
|
+
const columns = new Int32Array(width);
|
|
67
|
+
const scaleY = Math.max(8, edge.bandPx / 3);
|
|
68
|
+
const scaleX = Math.max(8, edge.polarPx / 1.5);
|
|
69
|
+
for (let y = 0; y < height; y += 1)
|
|
70
|
+
rows[y] =
|
|
71
|
+
edge.mode === 'sea-band'
|
|
72
|
+
? edge.bandPx + Math.round(valueNoise(seed + 9187, y / scaleY, 0.5) * edge.bandPx * 0.5)
|
|
73
|
+
: 0;
|
|
74
|
+
for (let x = 0; x < width; x += 1)
|
|
75
|
+
columns[x] =
|
|
76
|
+
edge.polarPx > 0
|
|
77
|
+
? edge.polarPx + Math.round(valueNoise(seed + 9293, x / scaleX, 0.5) * edge.polarPx * 0.5)
|
|
78
|
+
: 0;
|
|
79
|
+
return { rows, columns };
|
|
80
|
+
}
|
|
81
|
+
function landComponents(kind, width) {
|
|
82
|
+
const label = new Int32Array(kind.length).fill(-1);
|
|
83
|
+
const sizes = [];
|
|
84
|
+
const queue = new Int32Array(kind.length);
|
|
85
|
+
const total = kind.length;
|
|
86
|
+
for (let start = 0; start < total; start += 1) {
|
|
87
|
+
if (kind[start] !== LAND || label[start] !== -1)
|
|
88
|
+
continue;
|
|
89
|
+
const id = sizes.length;
|
|
90
|
+
let head = 0;
|
|
91
|
+
let tail = 0;
|
|
92
|
+
queue[tail++] = start;
|
|
93
|
+
label[start] = id;
|
|
94
|
+
while (head < tail) {
|
|
95
|
+
const pixel = queue[head++];
|
|
96
|
+
const x = pixel % width;
|
|
97
|
+
const visit = (other) => {
|
|
98
|
+
if (kind[other] === LAND && label[other] === -1) {
|
|
99
|
+
label[other] = id;
|
|
100
|
+
queue[tail++] = other;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
if (x > 0)
|
|
104
|
+
visit(pixel - 1);
|
|
105
|
+
if (x < width - 1)
|
|
106
|
+
visit(pixel + 1);
|
|
107
|
+
if (pixel >= width)
|
|
108
|
+
visit(pixel - width);
|
|
109
|
+
if (pixel < total - width)
|
|
110
|
+
visit(pixel + width);
|
|
111
|
+
}
|
|
112
|
+
sizes.push(tail);
|
|
113
|
+
}
|
|
114
|
+
return { label, sizes };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Clear land and lakes from the east, west, north and south bands (modifying `kind` and
|
|
118
|
+
* `elevation` in place). The outline is ragged so the cleared coast does not read as a ruled
|
|
119
|
+
* line; land fragments the clearing detached sink when smaller than `minimumIsland`.
|
|
120
|
+
*/
|
|
121
|
+
export function applyEdgePolicy(kind, elevation, width, height, edge, seed, minimumIsland) {
|
|
122
|
+
if (edge.mode === 'none')
|
|
123
|
+
return { notes: [], clearedLandPixels: 0 };
|
|
124
|
+
const before = landComponents(kind, width);
|
|
125
|
+
const { rows, columns } = limits(edge, width, height, seed);
|
|
126
|
+
const cleared = new Uint8Array(kind.length);
|
|
127
|
+
let clearedLand = 0;
|
|
128
|
+
for (let y = 0; y < height; y += 1)
|
|
129
|
+
for (let x = 0; x < width; x += 1) {
|
|
130
|
+
const limitX = rows[y];
|
|
131
|
+
const clear = x < limitX || x >= width - limitX || y < columns[x] || y >= height - columns[x];
|
|
132
|
+
if (!clear)
|
|
133
|
+
continue;
|
|
134
|
+
const pixel = y * width + x;
|
|
135
|
+
cleared[pixel] = 1;
|
|
136
|
+
if (kind[pixel] === LAND)
|
|
137
|
+
clearedLand += 1;
|
|
138
|
+
if (kind[pixel] !== SEA)
|
|
139
|
+
kind[pixel] = SEA;
|
|
140
|
+
}
|
|
141
|
+
const after = landComponents(kind, width);
|
|
142
|
+
let sunk = 0;
|
|
143
|
+
for (let pixel = 0; pixel < kind.length; pixel += 1) {
|
|
144
|
+
if (kind[pixel] !== LAND)
|
|
145
|
+
continue;
|
|
146
|
+
const size = after.sizes[after.label[pixel]];
|
|
147
|
+
if (size < before.sizes[before.label[pixel]] && size < minimumIsland) {
|
|
148
|
+
kind[pixel] = SEA;
|
|
149
|
+
cleared[pixel] = 1;
|
|
150
|
+
sunk += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
reshapeClearedCoast(kind, elevation, cleared, width, height);
|
|
154
|
+
const notes = [];
|
|
155
|
+
if (clearedLand + sunk > 0)
|
|
156
|
+
notes.push({
|
|
157
|
+
code: 'MAP_EDGE_LAND_CLEARED',
|
|
158
|
+
severity: 'warning',
|
|
159
|
+
message: `The ${edge.mode} edge policy cleared ${clearedLand} land pixels to open sea${sunk > 0 ? ` and sank ${sunk} pixels of fragments it detached` : ''}; land that must reach the edge needs edge.mode "rim"`,
|
|
160
|
+
details: { mode: edge.mode, clearedLandPixels: clearedLand, sunkFragmentPixels: sunk },
|
|
161
|
+
});
|
|
162
|
+
return { notes, clearedLandPixels: clearedLand + sunk };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Give the new coast a shelf and a coastal land ramp, as the reference interpretation does for
|
|
166
|
+
* its own coast: cleared sea deepens from the shore, and land next to the cut rises from sea
|
|
167
|
+
* level. Cleared pixels away from any coast become the deep sea value.
|
|
168
|
+
*/
|
|
169
|
+
function reshapeClearedCoast(kind, elevation, cleared, width, height) {
|
|
170
|
+
const total = kind.length;
|
|
171
|
+
const ramp = Math.max(4, Math.round(Math.min(width, height) / 120));
|
|
172
|
+
const distance = new Uint16Array(total).fill(65535);
|
|
173
|
+
const queue = new Int32Array(total);
|
|
174
|
+
let head = 0;
|
|
175
|
+
let tail = 0;
|
|
176
|
+
for (let pixel = 0; pixel < total; pixel += 1) {
|
|
177
|
+
if (cleared[pixel])
|
|
178
|
+
elevation[pixel] = -0.15;
|
|
179
|
+
if (kind[pixel] !== LAND)
|
|
180
|
+
continue;
|
|
181
|
+
const x = pixel % width;
|
|
182
|
+
const touchesCleared = (x > 0 && cleared[pixel - 1] === 1 && kind[pixel - 1] === SEA) ||
|
|
183
|
+
(x < width - 1 && cleared[pixel + 1] === 1 && kind[pixel + 1] === SEA) ||
|
|
184
|
+
(pixel >= width && cleared[pixel - width] === 1 && kind[pixel - width] === SEA) ||
|
|
185
|
+
(pixel < total - width && cleared[pixel + width] === 1 && kind[pixel + width] === SEA);
|
|
186
|
+
if (touchesCleared) {
|
|
187
|
+
distance[pixel] = 0;
|
|
188
|
+
queue[tail++] = pixel;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
while (head < tail) {
|
|
192
|
+
const pixel = queue[head++];
|
|
193
|
+
const next = distance[pixel] + 1;
|
|
194
|
+
if (next > ramp)
|
|
195
|
+
continue;
|
|
196
|
+
const x = pixel % width;
|
|
197
|
+
const visit = (other) => {
|
|
198
|
+
if (distance[other] !== 65535)
|
|
199
|
+
return;
|
|
200
|
+
// Land spreads inland; the shelf spreads only over cleared water.
|
|
201
|
+
if (kind[other] === LAND || (kind[other] === SEA && cleared[other] === 1)) {
|
|
202
|
+
distance[other] = next;
|
|
203
|
+
queue[tail++] = other;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
if (x > 0)
|
|
207
|
+
visit(pixel - 1);
|
|
208
|
+
if (x < width - 1)
|
|
209
|
+
visit(pixel + 1);
|
|
210
|
+
if (pixel >= width)
|
|
211
|
+
visit(pixel - width);
|
|
212
|
+
if (pixel < total - width)
|
|
213
|
+
visit(pixel + width);
|
|
214
|
+
}
|
|
215
|
+
for (let pixel = 0; pixel < total; pixel += 1) {
|
|
216
|
+
const d = distance[pixel];
|
|
217
|
+
if (d === 65535)
|
|
218
|
+
continue;
|
|
219
|
+
const t = Math.min(1, d / ramp);
|
|
220
|
+
const factor = t * t * (3 - 2 * t);
|
|
221
|
+
if (kind[pixel] === LAND)
|
|
222
|
+
elevation[pixel] = elevation[pixel] * factor;
|
|
223
|
+
else
|
|
224
|
+
elevation[pixel] = -0.15 * factor;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function smooth(t) {
|
|
228
|
+
const clamped = Math.min(1, Math.max(0, t));
|
|
229
|
+
return clamped * clamped * (3 - 2 * clamped);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Rim strength 0 to 1 at a column: 0 at the inner foot, 1 at the seam. Zero outside `rim` mode.
|
|
233
|
+
*/
|
|
234
|
+
export function rimStrength(edge, width, x) {
|
|
235
|
+
if (edge.mode !== 'rim')
|
|
236
|
+
return 0;
|
|
237
|
+
const distance = Math.min(x, width - 1 - x);
|
|
238
|
+
return distance >= edge.rimPx ? 0 : smooth(1 - distance / edge.rimPx);
|
|
239
|
+
}
|
|
240
|
+
/** The rim height for an existing land height at the given strength. */
|
|
241
|
+
export function rimHeight(existing, strength) {
|
|
242
|
+
if (strength <= 0)
|
|
243
|
+
return existing;
|
|
244
|
+
const target = RIM_FOOT_HEIGHT + (RIM_SEAM_HEIGHT - RIM_FOOT_HEIGHT) * strength;
|
|
245
|
+
return Math.min(255, Math.round(existing + (target - existing) * strength));
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Darkening of the water colour map toward the deepest tint, 0 to 1: full at the seam, none at
|
|
249
|
+
* the inner edge of the band, and a shorter fade at the poles.
|
|
250
|
+
*/
|
|
251
|
+
export function edgeWaterDarkness(edge, width, height, x, y) {
|
|
252
|
+
if (edge.mode === 'none')
|
|
253
|
+
return 0;
|
|
254
|
+
const sideDistance = Math.min(x, width - 1 - x);
|
|
255
|
+
const side = edge.mode === 'sea-band' ? smooth(1 - sideDistance / (edge.bandPx * 1.2)) : 0;
|
|
256
|
+
const poleDistance = Math.min(y, height - 1 - y);
|
|
257
|
+
const pole = edge.polarPx > 0 ? smooth(1 - poleDistance / (edge.polarPx * 1.2)) * 0.8 : 0;
|
|
258
|
+
return Math.max(side, pole);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Impassable rows for every pair of distinct land provinces that meet across the seam, in
|
|
262
|
+
* `adjacencies.csv` order (rows are sorted by the lower then the higher province ID).
|
|
263
|
+
*/
|
|
264
|
+
export function rimAdjacencyRows(input) {
|
|
265
|
+
const { provincePixels, provinceKinds, width, height } = input;
|
|
266
|
+
const pairs = new Set();
|
|
267
|
+
for (let y = 0; y < height; y += 1) {
|
|
268
|
+
const left = provincePixels[y * width];
|
|
269
|
+
const right = provincePixels[y * width + width - 1];
|
|
270
|
+
if (left === right || provinceKinds[left] !== LAND || provinceKinds[right] !== LAND)
|
|
271
|
+
continue;
|
|
272
|
+
const low = Math.min(left, right) + 1;
|
|
273
|
+
const high = Math.max(left, right) + 1;
|
|
274
|
+
pairs.add(`${low};${high}`);
|
|
275
|
+
}
|
|
276
|
+
return [...pairs]
|
|
277
|
+
.sort((a, b) => {
|
|
278
|
+
const [al, ah] = a.split(';').map(Number);
|
|
279
|
+
const [bl, bh] = b.split(';').map(Number);
|
|
280
|
+
return al - bl || ah - bh;
|
|
281
|
+
})
|
|
282
|
+
.map((pair) => `${pair};impassable;-1;-1;-1;-1;-1;;World rim`);
|
|
283
|
+
}
|
|
284
|
+
/** Heights and positions of the base map's frame meshes, scaled to the world size. */
|
|
285
|
+
export function frameBorderPositions(width, height) {
|
|
286
|
+
return {
|
|
287
|
+
top: height + 142,
|
|
288
|
+
bottom: -140,
|
|
289
|
+
logoX: Math.round(0.5327 * width),
|
|
290
|
+
logoZ: height + 82,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* `ambient_object.txt` with the wind entity and the north, south and logo frame borders. Ambient
|
|
295
|
+
* water objects and the DLC wall mesh of the base map are left out: their positions belong to
|
|
296
|
+
* the base map's geography.
|
|
297
|
+
*/
|
|
298
|
+
export function ambientObjectText(width, height) {
|
|
299
|
+
const frame = frameBorderPositions(width, height);
|
|
300
|
+
const block = (type, options) => [
|
|
301
|
+
'type={',
|
|
302
|
+
`\ttype="${type}"`,
|
|
303
|
+
'\tuse_animation=no',
|
|
304
|
+
...(options.scale === undefined ? [] : [`\tscale=${options.scale.toFixed(6)}`]),
|
|
305
|
+
'\talways_visible=yes',
|
|
306
|
+
'\tobject={',
|
|
307
|
+
`\t\tname="${options.name}"`,
|
|
308
|
+
'\t\tposition={',
|
|
309
|
+
`\t\t\t${options.position.join(' ')} `,
|
|
310
|
+
'\t\t}',
|
|
311
|
+
'\t\trotation={',
|
|
312
|
+
'\t\t\t0 0 0 ',
|
|
313
|
+
'\t\t}',
|
|
314
|
+
'\t}',
|
|
315
|
+
'}',
|
|
316
|
+
].join('\n');
|
|
317
|
+
return `${[
|
|
318
|
+
block('ambient_wind_entity', { name: 'ambient_wind', position: [0, 0, 0] }),
|
|
319
|
+
block('frame_border_entity', {
|
|
320
|
+
scale: 100,
|
|
321
|
+
name: 'frame_border_entity_top',
|
|
322
|
+
position: [0, 0, frame.top],
|
|
323
|
+
}),
|
|
324
|
+
block('frame_border_bottom_entity', {
|
|
325
|
+
scale: 100,
|
|
326
|
+
name: 'frame_border_bottom_entity_bottom',
|
|
327
|
+
position: [0, 0, frame.bottom],
|
|
328
|
+
}),
|
|
329
|
+
block('frame_border_logo_entity', {
|
|
330
|
+
scale: 300,
|
|
331
|
+
name: 'frame_border_logo_entity_top',
|
|
332
|
+
position: [frame.logoX, -1, frame.logoZ],
|
|
333
|
+
}),
|
|
334
|
+
].join('\n')}\n`;
|
|
335
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Deterministic value noise shared by world generation. Only + - * / are used, so the output is
|
|
2
|
+
* identical on every platform. */
|
|
3
|
+
export function lattice(seed, x, y) {
|
|
4
|
+
let hash = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 982451653);
|
|
5
|
+
hash = Math.imul(hash ^ (hash >>> 13), 1274126177);
|
|
6
|
+
return ((hash ^ (hash >>> 16)) >>> 0) / 4294967296;
|
|
7
|
+
}
|
|
8
|
+
export function smooth(t) {
|
|
9
|
+
return t * t * (3 - 2 * t);
|
|
10
|
+
}
|
|
11
|
+
export function valueNoise(seed, x, y) {
|
|
12
|
+
const x0 = Math.floor(x);
|
|
13
|
+
const y0 = Math.floor(y);
|
|
14
|
+
const tx = smooth(x - x0);
|
|
15
|
+
const ty = smooth(y - y0);
|
|
16
|
+
const a = lattice(seed, x0, y0);
|
|
17
|
+
const b = lattice(seed, x0 + 1, y0);
|
|
18
|
+
const c = lattice(seed, x0, y0 + 1);
|
|
19
|
+
const d = lattice(seed, x0 + 1, y0 + 1);
|
|
20
|
+
return a + (b - a) * tx + (c - a) * ty + (a - b - c + d) * tx * ty;
|
|
21
|
+
}
|
|
22
|
+
export function fractal(seed, x, y, scale, octaves = 5) {
|
|
23
|
+
let total = 0;
|
|
24
|
+
let amplitude = 1;
|
|
25
|
+
let weight = 0;
|
|
26
|
+
let frequency = 1 / scale;
|
|
27
|
+
for (let octave = 0; octave < octaves; octave += 1) {
|
|
28
|
+
total += valueNoise(seed + octave * 101, x * frequency, y * frequency) * amplitude;
|
|
29
|
+
weight += amplitude;
|
|
30
|
+
amplitude *= 0.5;
|
|
31
|
+
frequency *= 2;
|
|
32
|
+
}
|
|
33
|
+
return total / weight;
|
|
34
|
+
}
|