hoi4-agent-tools 4.0.4 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -0
- package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
- package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
- package/dist/hoi4_agent_tools/core/engine.js +2 -1
- package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
- package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
- package/dist/hoi4_agent_tools/core/scanner.js +17 -0
- package/dist/hoi4_agent_tools/core/server-state.js +9 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
- package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
- package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
- package/dist/hoi4_agent_tools/core/transactions.js +355 -111
- package/dist/hoi4_agent_tools/gui/layout.js +61 -25
- package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
- package/dist/hoi4_agent_tools/gui/source-graph.js +9 -6
- package/dist/hoi4_agent_tools/gui/studio.js +4 -1
- package/dist/hoi4_agent_tools/map/derived.js +21 -6
- package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
- package/dist/hoi4_agent_tools/map/operations.js +72 -21
- package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
- package/dist/hoi4_agent_tools/map/render.js +9 -1
- package/dist/hoi4_agent_tools/map/service.js +222 -18
- package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
- package/dist/hoi4_agent_tools/map/world-context.js +148 -0
- package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
- package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
- package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
- package/dist/hoi4_agent_tools/map/world.js +374 -253
- package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
- package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
- package/dist/hoi4_agent_tools/schemas/map.js +103 -10
- package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
- package/dist/hoi4_agent_tools/version.js +2 -2
- package/docs/gui.md +6 -2
- package/docs/images/reference-maps/political.png +0 -0
- package/docs/images/reference-maps/sketch.png +0 -0
- package/docs/map.md +45 -0
- package/docs/third-party-notices.md +6 -0
- package/npm-shrinkwrap.json +124 -2
- package/package.json +4 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +373 -70
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { ServiceError } from '../core/result.js';
|
|
2
|
+
function fail(message) {
|
|
3
|
+
throw new ServiceError('INVALID_WORLD_DRAINAGE', message);
|
|
4
|
+
}
|
|
5
|
+
function validate(spec) {
|
|
6
|
+
const { width, height, kind, elevation, count, seed } = spec;
|
|
7
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1)
|
|
8
|
+
fail('Drainage dimensions must be positive integers.');
|
|
9
|
+
if (width * height > 13_238_272)
|
|
10
|
+
fail('Drainage raster exceeds 13,238,272 pixels.');
|
|
11
|
+
if (!(kind instanceof Uint8Array) || !(elevation instanceof Float32Array))
|
|
12
|
+
fail('Drainage requires typed land and elevation rasters.');
|
|
13
|
+
if (kind.length !== width * height || elevation.length !== kind.length)
|
|
14
|
+
fail('Drainage raster lengths must match the dimensions.');
|
|
15
|
+
if (!Number.isSafeInteger(seed) || !Number.isSafeInteger(count) || count < 0 || count > 4096)
|
|
16
|
+
fail('Drainage requires an integer seed and a river count between 0 and 4,096.');
|
|
17
|
+
for (let pixel = 0; pixel < kind.length; pixel += 1) {
|
|
18
|
+
const value = elevation[pixel];
|
|
19
|
+
if (kind[pixel] > 2)
|
|
20
|
+
fail('Drainage contains an unknown land or water kind.');
|
|
21
|
+
if (!Number.isFinite(value) ||
|
|
22
|
+
(kind[pixel] === 1 ? value < 0 || value > 1 : value < -1 || value > 0))
|
|
23
|
+
fail('Drainage elevations must be finite, with land in [0, 1] and water in [-1, 0].');
|
|
24
|
+
}
|
|
25
|
+
let points = 0;
|
|
26
|
+
for (const line of spec.referenceRivers ?? []) {
|
|
27
|
+
if (line.length < 2)
|
|
28
|
+
fail('A reference river requires at least two points.');
|
|
29
|
+
points += line.length;
|
|
30
|
+
if (points > 100_000)
|
|
31
|
+
fail('Reference rivers exceed 100,000 points.');
|
|
32
|
+
for (const point of line)
|
|
33
|
+
if (!Number.isFinite(point.x) ||
|
|
34
|
+
!Number.isFinite(point.y) ||
|
|
35
|
+
point.x < 0 ||
|
|
36
|
+
point.y < 0 ||
|
|
37
|
+
point.x >= width ||
|
|
38
|
+
point.y >= height)
|
|
39
|
+
fail('Reference river coordinates must lie inside the raster.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Four cardinal directions, with opposite directions paired. No wrapping across map edges. */
|
|
43
|
+
function neighbor(pixel, direction, width, height) {
|
|
44
|
+
if (direction === 1)
|
|
45
|
+
return pixel % width > 0 ? pixel - 1 : -1;
|
|
46
|
+
if (direction === 2)
|
|
47
|
+
return pixel % width < width - 1 ? pixel + 1 : -1;
|
|
48
|
+
if (direction === 3)
|
|
49
|
+
return pixel >= width ? pixel - width : -1;
|
|
50
|
+
return pixel < width * (height - 1) ? pixel + width : -1;
|
|
51
|
+
}
|
|
52
|
+
function hash(pixel, seed) {
|
|
53
|
+
let value = Math.imul(pixel ^ Math.imul(seed, 0x9e3779b1), 0x45d9f3b);
|
|
54
|
+
value = Math.imul(value ^ (value >>> 16), 0x45d9f3b);
|
|
55
|
+
return (value ^ (value >>> 16)) >>> 0;
|
|
56
|
+
}
|
|
57
|
+
/** A packed heap avoids millions of JS objects on full-size maps. */
|
|
58
|
+
class FloodHeap {
|
|
59
|
+
#pixels;
|
|
60
|
+
#levels;
|
|
61
|
+
#seed;
|
|
62
|
+
#length = 0;
|
|
63
|
+
constructor(capacity, levels, seed) {
|
|
64
|
+
this.#pixels = new Uint32Array(capacity);
|
|
65
|
+
this.#levels = levels;
|
|
66
|
+
this.#seed = seed;
|
|
67
|
+
}
|
|
68
|
+
get length() {
|
|
69
|
+
return this.#length;
|
|
70
|
+
}
|
|
71
|
+
#before(left, right) {
|
|
72
|
+
const level = this.#levels[left] - this.#levels[right];
|
|
73
|
+
if (level !== 0)
|
|
74
|
+
return level < 0;
|
|
75
|
+
const order = hash(left, this.#seed) - hash(right, this.#seed);
|
|
76
|
+
return order < 0 || (order === 0 && left < right);
|
|
77
|
+
}
|
|
78
|
+
push(pixel) {
|
|
79
|
+
let index = this.#length++;
|
|
80
|
+
while (index > 0) {
|
|
81
|
+
const parent = (index - 1) >>> 1;
|
|
82
|
+
if (!this.#before(pixel, this.#pixels[parent]))
|
|
83
|
+
break;
|
|
84
|
+
this.#pixels[index] = this.#pixels[parent];
|
|
85
|
+
index = parent;
|
|
86
|
+
}
|
|
87
|
+
this.#pixels[index] = pixel;
|
|
88
|
+
}
|
|
89
|
+
pop() {
|
|
90
|
+
const first = this.#pixels[0];
|
|
91
|
+
const last = this.#pixels[--this.#length];
|
|
92
|
+
if (this.#length === 0)
|
|
93
|
+
return first;
|
|
94
|
+
let index = 0;
|
|
95
|
+
while (index * 2 + 1 < this.#length) {
|
|
96
|
+
let child = index * 2 + 1;
|
|
97
|
+
if (child + 1 < this.#length && this.#before(this.#pixels[child + 1], this.#pixels[child]))
|
|
98
|
+
child += 1;
|
|
99
|
+
if (!this.#before(this.#pixels[child], last))
|
|
100
|
+
break;
|
|
101
|
+
this.#pixels[index] = this.#pixels[child];
|
|
102
|
+
index = child;
|
|
103
|
+
}
|
|
104
|
+
this.#pixels[index] = last;
|
|
105
|
+
return first;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Priority-flood assigns each land pixel an acyclic route to sea or lake. Flood levels are
|
|
110
|
+
* temporary routing costs: only accepted channels alter the returned elevation, by lowering
|
|
111
|
+
* downstream pixels. This preserves downhill flow after any monotone heightmap quantization.
|
|
112
|
+
* Rivers are disjoint, cardinal, one-pixel paths; width is encoded by palette index, not dilation.
|
|
113
|
+
* Work is O(N log N), with a bounded O(N) tracing budget and packed raster storage.
|
|
114
|
+
*/
|
|
115
|
+
export function generateWorldDrainage(spec) {
|
|
116
|
+
validate(spec);
|
|
117
|
+
const { width, height, kind, seed } = spec;
|
|
118
|
+
const total = kind.length;
|
|
119
|
+
const elevation = Float32Array.from(spec.elevation);
|
|
120
|
+
const riverPixels = Uint8Array.from(kind, (value) => (value === 1 ? 255 : 254));
|
|
121
|
+
const warnings = [];
|
|
122
|
+
if (spec.count === 0)
|
|
123
|
+
return { riverPixels, elevation, count: 0, warnings };
|
|
124
|
+
const referenceSources = [];
|
|
125
|
+
let referenceBudget = total * 4;
|
|
126
|
+
for (const [index, line] of (spec.referenceRivers ?? []).entries()) {
|
|
127
|
+
let source = -1;
|
|
128
|
+
// Lower routing costs along hints without committing an uphill or disconnected channel.
|
|
129
|
+
for (let vertex = 1; vertex < line.length; vertex += 1) {
|
|
130
|
+
const a = line[vertex - 1];
|
|
131
|
+
const b = line[vertex];
|
|
132
|
+
let x = Math.floor(a.x);
|
|
133
|
+
let y = Math.floor(a.y);
|
|
134
|
+
const endX = Math.floor(b.x);
|
|
135
|
+
const endY = Math.floor(b.y);
|
|
136
|
+
const dx = Math.abs(endX - x);
|
|
137
|
+
const dy = Math.abs(endY - y);
|
|
138
|
+
let error = dx - dy;
|
|
139
|
+
while (referenceBudget-- > 0) {
|
|
140
|
+
const pixel = y * width + x;
|
|
141
|
+
if (kind[pixel] === 1) {
|
|
142
|
+
elevation[pixel] = spec.elevation[pixel] * 0.85;
|
|
143
|
+
if (source < 0 || spec.elevation[pixel] > spec.elevation[source])
|
|
144
|
+
source = pixel;
|
|
145
|
+
}
|
|
146
|
+
if (x === endX && y === endY)
|
|
147
|
+
break;
|
|
148
|
+
const twice = error * 2;
|
|
149
|
+
if (twice > -dy) {
|
|
150
|
+
error -= dy;
|
|
151
|
+
x += x < endX ? 1 : -1;
|
|
152
|
+
}
|
|
153
|
+
if (twice < dx) {
|
|
154
|
+
error += dx;
|
|
155
|
+
y += y < endY ? 1 : -1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (source >= 0)
|
|
160
|
+
referenceSources.push(source);
|
|
161
|
+
warnings.push(source >= 0
|
|
162
|
+
? `Reference river ${index + 1} is a drainage hint; its route may divert to a safe outlet.`
|
|
163
|
+
: `Reference river ${index + 1} contains no land; generated drainage will be used.`);
|
|
164
|
+
}
|
|
165
|
+
if (referenceBudget <= 0)
|
|
166
|
+
warnings.push('Reference river rasterization exceeded its bounded budget; remaining hints were omitted.');
|
|
167
|
+
// Direction zero means undiscovered; every assigned direction points to an earlier visit.
|
|
168
|
+
const downstream = new Uint8Array(total);
|
|
169
|
+
const heap = new FloodHeap(total, elevation, seed);
|
|
170
|
+
for (let pixel = 0; pixel < total; pixel += 1) {
|
|
171
|
+
if (kind[pixel] !== 1)
|
|
172
|
+
continue;
|
|
173
|
+
for (let direction = 1; direction <= 4; direction += 1) {
|
|
174
|
+
const next = neighbor(pixel, direction, width, height);
|
|
175
|
+
if (next < 0 || kind[next] === 1)
|
|
176
|
+
continue;
|
|
177
|
+
downstream[pixel] = direction;
|
|
178
|
+
heap.push(pixel);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (heap.length === 0) {
|
|
183
|
+
elevation.set(spec.elevation);
|
|
184
|
+
warnings.push('No river can be generated because the land has no sea or lake outlet.');
|
|
185
|
+
return { riverPixels, elevation, count: 0, warnings };
|
|
186
|
+
}
|
|
187
|
+
while (heap.length > 0) {
|
|
188
|
+
const pixel = heap.pop();
|
|
189
|
+
for (let direction = 1; direction <= 4; direction += 1) {
|
|
190
|
+
const next = neighbor(pixel, direction, width, height);
|
|
191
|
+
if (next < 0 || kind[next] !== 1 || downstream[next] !== 0)
|
|
192
|
+
continue;
|
|
193
|
+
downstream[next] = direction % 2 === 1 ? direction + 1 : direction - 1;
|
|
194
|
+
elevation[next] = Math.max(elevation[next], elevation[pixel]);
|
|
195
|
+
heap.push(next);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
elevation.set(spec.elevation);
|
|
199
|
+
// Sample across the whole raster, then prefer higher headwaters. Never sort all N pixels.
|
|
200
|
+
const candidates = new Set(referenceSources);
|
|
201
|
+
const sampleCount = Math.min(total, Math.max(256, Math.min(65_536, spec.count * 128)));
|
|
202
|
+
for (let sample = 0; sample < sampleCount; sample += 1) {
|
|
203
|
+
const pixel = Math.floor((hash(sample, seed ^ 0x9e3779b9) / 4294967296) * total);
|
|
204
|
+
if (kind[pixel] === 1 && downstream[pixel] !== 0)
|
|
205
|
+
candidates.add(pixel);
|
|
206
|
+
}
|
|
207
|
+
const hinted = new Set(referenceSources);
|
|
208
|
+
const sources = [...candidates].sort((a, b) => Number(hinted.has(b)) - Number(hinted.has(a)) ||
|
|
209
|
+
elevation[b] - elevation[a] ||
|
|
210
|
+
hash(a, seed) - hash(b, seed) ||
|
|
211
|
+
a - b);
|
|
212
|
+
const blocked = new Uint8Array(total);
|
|
213
|
+
const minLength = Math.max(3, Math.min(12, Math.floor(Math.min(width, height) / 4)));
|
|
214
|
+
const maxPathSteps = Math.min(total, 65_536, 8 * (width + height));
|
|
215
|
+
let budget = total * 4;
|
|
216
|
+
let count = 0;
|
|
217
|
+
for (const source of sources) {
|
|
218
|
+
if (count >= spec.count || budget <= 0)
|
|
219
|
+
break;
|
|
220
|
+
if (blocked[source] !== 0 || downstream[source] === 0)
|
|
221
|
+
continue;
|
|
222
|
+
const path = [];
|
|
223
|
+
const positions = new Map();
|
|
224
|
+
let current = source;
|
|
225
|
+
let reachedWater = false;
|
|
226
|
+
let collided = false;
|
|
227
|
+
for (let step = 0; step < maxPathSteps && budget-- > 0; step += 1) {
|
|
228
|
+
if (kind[current] !== 1) {
|
|
229
|
+
reachedWater = true;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
if (blocked[current] !== 0 || downstream[current] === 0) {
|
|
233
|
+
collided = blocked[current] !== 0;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
// Remove chords as a node arrives: the final path never touches itself or forms a 2x2.
|
|
237
|
+
let predecessor = path.length - 1;
|
|
238
|
+
for (let direction = 1; direction <= 4; direction += 1) {
|
|
239
|
+
const around = neighbor(current, direction, width, height);
|
|
240
|
+
const position = positions.get(around);
|
|
241
|
+
if (position !== undefined)
|
|
242
|
+
predecessor = Math.min(predecessor, position);
|
|
243
|
+
}
|
|
244
|
+
while (path.length > predecessor + 1)
|
|
245
|
+
positions.delete(path.pop());
|
|
246
|
+
positions.set(current, path.length);
|
|
247
|
+
path.push(current);
|
|
248
|
+
current = neighbor(current, downstream[current], width, height);
|
|
249
|
+
}
|
|
250
|
+
if (collided)
|
|
251
|
+
for (const pixel of path)
|
|
252
|
+
blocked[pixel] = 1;
|
|
253
|
+
if (!reachedWater || path.length < minLength)
|
|
254
|
+
continue;
|
|
255
|
+
let level = elevation[source];
|
|
256
|
+
for (const [position, pixel] of path.entries()) {
|
|
257
|
+
level = Math.min(level, elevation[pixel]);
|
|
258
|
+
elevation[pixel] = level;
|
|
259
|
+
riverPixels[pixel] =
|
|
260
|
+
position === 0 ? 0 : 3 + Math.min(8, Math.floor((position * 9) / path.length));
|
|
261
|
+
blocked[pixel] = 1;
|
|
262
|
+
for (let direction = 1; direction <= 4; direction += 1) {
|
|
263
|
+
const around = neighbor(pixel, direction, width, height);
|
|
264
|
+
if (around >= 0)
|
|
265
|
+
blocked[around] = 1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
count += 1;
|
|
269
|
+
}
|
|
270
|
+
if (count < spec.count)
|
|
271
|
+
warnings.push(`Generated ${count} of ${spec.count} rivers; remaining routes lack a clear disjoint outlet.`);
|
|
272
|
+
return { riverPixels, elevation, count, warnings };
|
|
273
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { reconcileManagedOutputs } from '../core/managed-outputs.js';
|
|
3
|
+
export const WORLD_OUTPUT_MANIFEST = '.hoi4-agent-world.json';
|
|
4
|
+
export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots) {
|
|
5
|
+
const mapFiles = new Set([
|
|
6
|
+
'provinces.bmp',
|
|
7
|
+
'definition.csv',
|
|
8
|
+
'default.map',
|
|
9
|
+
'continent.txt',
|
|
10
|
+
'adjacencies.csv',
|
|
11
|
+
'adjacency_rules.txt',
|
|
12
|
+
'positions.txt',
|
|
13
|
+
'ambient_object.txt',
|
|
14
|
+
'heightmap.bmp',
|
|
15
|
+
'terrain.bmp',
|
|
16
|
+
'rivers.bmp',
|
|
17
|
+
'trees.bmp',
|
|
18
|
+
'cities.bmp',
|
|
19
|
+
'world_normal.bmp',
|
|
20
|
+
'buildings.txt',
|
|
21
|
+
'unitstacks.txt',
|
|
22
|
+
'weatherpositions.txt',
|
|
23
|
+
'supply_nodes.txt',
|
|
24
|
+
'railways.txt',
|
|
25
|
+
]);
|
|
26
|
+
const permitsPath = (name) => {
|
|
27
|
+
const dir = path.posix.dirname(name), file = path.posix.basename(name);
|
|
28
|
+
return ((dir === mapRoot && mapFiles.has(file)) ||
|
|
29
|
+
(dir === `${mapRoot}/terrain` && /^colormap_[a-z0-9_]+\.dds$/u.test(file)) ||
|
|
30
|
+
(dir === `${mapRoot}/strategicregions` && /^\d+.*\.txt$/u.test(file)) ||
|
|
31
|
+
(stateRoots.includes(dir) && /^\d+.*\.txt$/u.test(file)) ||
|
|
32
|
+
(['common/countries', 'history/countries'].includes(dir) &&
|
|
33
|
+
/^[A-Z][A-Z0-9]{2} - [^/]+\.txt$/u.test(file)) ||
|
|
34
|
+
(dir === 'history/units' && /^[A-Z][A-Z0-9]{2}_world\.txt$/u.test(file)) ||
|
|
35
|
+
(['gfx/flags', 'gfx/flags/medium', 'gfx/flags/small'].includes(dir) &&
|
|
36
|
+
/^[A-Z][A-Z0-9]{2}\.tga$/u.test(file)) ||
|
|
37
|
+
[
|
|
38
|
+
'common/countries/colors.txt',
|
|
39
|
+
'common/country_tags/zz_world_countries.txt',
|
|
40
|
+
'common/bookmarks/world.txt',
|
|
41
|
+
'common/on_actions/world.txt',
|
|
42
|
+
'common/ai_areas/world.txt',
|
|
43
|
+
'common/ai_strategy_plans/world.txt',
|
|
44
|
+
].includes(name) ||
|
|
45
|
+
localisationRoots.some((root) => name === `${root}/english/world_l_english.yml` ||
|
|
46
|
+
name === `${root}/replace/world_l_english.yml`));
|
|
47
|
+
};
|
|
48
|
+
return reconcileManagedOutputs({
|
|
49
|
+
manifestPath: WORLD_OUTPUT_MANIFEST,
|
|
50
|
+
sources,
|
|
51
|
+
proposed,
|
|
52
|
+
operationId,
|
|
53
|
+
permitsPath,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { compareCodeUnits, sha256Bytes } from '../core/canonical.js';
|
|
2
|
+
import { hasBlockingDiagnostics } from '../core/diagnostics.js';
|
|
3
|
+
import { assignments, childBlocks, firstScalar, parseClausewitz, } from '../core/source/index.js';
|
|
4
|
+
import { BmpImage } from './bmp.js';
|
|
5
|
+
import { defaultMapSelectorValue } from './model.js';
|
|
6
|
+
const PREVIEW_STYLE_FILE_BYTES = 65_536;
|
|
7
|
+
const PREVIEW_STYLE_TOTAL_BYTES = 4_194_304;
|
|
8
|
+
const PREVIEW_STYLE_FILE_COUNT = 512;
|
|
9
|
+
function normalized(value) {
|
|
10
|
+
return value.replaceAll('\\', '/').replace(/^\.\//u, '').toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
/** Preview interpretation stays bounded and uses only authorized active source bytes. */
|
|
13
|
+
class PreviewStyleParser {
|
|
14
|
+
#bytes = 0;
|
|
15
|
+
#files = 0;
|
|
16
|
+
parse(file) {
|
|
17
|
+
if (file.bytes.length > PREVIEW_STYLE_FILE_BYTES ||
|
|
18
|
+
this.#bytes + file.bytes.length > PREVIEW_STYLE_TOTAL_BYTES ||
|
|
19
|
+
this.#files >= PREVIEW_STYLE_FILE_COUNT)
|
|
20
|
+
return undefined;
|
|
21
|
+
this.#bytes += file.bytes.length;
|
|
22
|
+
this.#files += 1;
|
|
23
|
+
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
24
|
+
return hasBlockingDiagnostics(document.diagnostics) ? undefined : document;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Resolve the generated/active tag binding and its byte RGB color through the shared parser. */
|
|
28
|
+
export function previewPoliticalPalette(index, owners) {
|
|
29
|
+
const parser = new PreviewStyleParser();
|
|
30
|
+
const bindings = new Map();
|
|
31
|
+
for (const file of index.activeFiles.all) {
|
|
32
|
+
if (!/^common\/country_tags\/[^/]+\.txt$/u.test(normalized(file.relativePath)))
|
|
33
|
+
continue;
|
|
34
|
+
const document = parser.parse(file);
|
|
35
|
+
if (document === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
for (const entry of assignments(document.root)) {
|
|
38
|
+
if (!owners.has(entry.key.value) ||
|
|
39
|
+
entry.operator.text !== '=' ||
|
|
40
|
+
entry.value.type !== 'scalar')
|
|
41
|
+
continue;
|
|
42
|
+
const path = normalized(entry.value.value);
|
|
43
|
+
if (/^countries\/(?:[^/:]+\/)*[^/:]+\.txt$/u.test(path) &&
|
|
44
|
+
!path.split('/').some((segment) => segment === '.' || segment === '..'))
|
|
45
|
+
bindings.set(entry.key.value, `common/${path}`);
|
|
46
|
+
else
|
|
47
|
+
bindings.delete(entry.key.value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const colors = new Map();
|
|
51
|
+
const sources = new Map();
|
|
52
|
+
const fallbackTags = [];
|
|
53
|
+
for (const tag of [...owners].sort(compareCodeUnits)) {
|
|
54
|
+
const binding = bindings.get(tag);
|
|
55
|
+
const file = binding === undefined ? undefined : index.activeFiles.byRelativePath.get(binding);
|
|
56
|
+
const document = file === undefined ? undefined : parser.parse(file);
|
|
57
|
+
const blocks = document === undefined ? [] : childBlocks(document.root, 'color');
|
|
58
|
+
const values = blocks.length === 1 &&
|
|
59
|
+
document !== undefined &&
|
|
60
|
+
assignments(document.root, 'color').length === 1
|
|
61
|
+
? blocks[0].entries.flatMap((entry) => entry.type === 'scalar' && /^\d+$/u.test(entry.value)
|
|
62
|
+
? [Number(entry.value)]
|
|
63
|
+
: [Number.NaN])
|
|
64
|
+
: [];
|
|
65
|
+
if (values.length === 3 &&
|
|
66
|
+
values.every((value) => Number.isInteger(value) && value >= 0 && value <= 255) &&
|
|
67
|
+
file !== undefined) {
|
|
68
|
+
colors.set(tag, [values[0], values[1], values[2]]);
|
|
69
|
+
sources.set(tag, file.displayPath);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const digest = sha256Bytes(tag);
|
|
73
|
+
colors.set(tag, [0, 2, 4].map((offset) => 72 + (Number.parseInt(digest.slice(offset, offset + 2), 16) % 144)));
|
|
74
|
+
fallbackTags.push(tag);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { colors, sources, fallbackTags };
|
|
78
|
+
}
|
|
79
|
+
/** Read graphical terrain and city footprints without changing combat province definitions. */
|
|
80
|
+
export function previewGraphicalTerrain(index) {
|
|
81
|
+
const parser = new PreviewStyleParser();
|
|
82
|
+
const types = new Map();
|
|
83
|
+
const snowIndices = new Set();
|
|
84
|
+
const sourceFiles = [];
|
|
85
|
+
for (const file of index.activeFiles.all) {
|
|
86
|
+
if (!/^common\/terrain\/[^/]+\.txt$/u.test(normalized(file.relativePath)))
|
|
87
|
+
continue;
|
|
88
|
+
const document = parser.parse(file);
|
|
89
|
+
if (document === undefined)
|
|
90
|
+
continue;
|
|
91
|
+
sourceFiles.push(file.displayPath);
|
|
92
|
+
for (const block of childBlocks(document.root, 'terrain'))
|
|
93
|
+
for (const entry of assignments(block)) {
|
|
94
|
+
if (entry.value.type !== 'block')
|
|
95
|
+
continue;
|
|
96
|
+
const type = firstScalar(entry.value, 'type')?.value;
|
|
97
|
+
const colorBlocks = childBlocks(entry.value, 'color');
|
|
98
|
+
if (type === undefined || colorBlocks.length !== 1)
|
|
99
|
+
continue;
|
|
100
|
+
const values = colorBlocks[0].entries.flatMap((value) => value.type === 'scalar' && /^\d+$/u.test(value.value)
|
|
101
|
+
? [Number(value.value)]
|
|
102
|
+
: [Number.NaN]);
|
|
103
|
+
if (!values.length ||
|
|
104
|
+
values.some((value) => !Number.isInteger(value) || value < 0 || value > 255))
|
|
105
|
+
continue;
|
|
106
|
+
const snow = firstScalar(entry.value, 'perm_snow')?.value === 'yes';
|
|
107
|
+
for (const paletteIndex of values) {
|
|
108
|
+
types.set(paletteIndex, type);
|
|
109
|
+
if (snow)
|
|
110
|
+
snowIndices.add(paletteIndex);
|
|
111
|
+
else
|
|
112
|
+
snowIndices.delete(paletteIndex);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (sourceFiles.length === 0)
|
|
117
|
+
for (const [paletteIndex, type] of [
|
|
118
|
+
[0, 'plains'],
|
|
119
|
+
[1, 'forest'],
|
|
120
|
+
[17, 'hills'],
|
|
121
|
+
[3, 'desert'],
|
|
122
|
+
[6, 'mountain'],
|
|
123
|
+
[9, 'marsh'],
|
|
124
|
+
[13, 'urban'],
|
|
125
|
+
[14, 'lakes'],
|
|
126
|
+
[15, 'ocean'],
|
|
127
|
+
[21, 'jungle'],
|
|
128
|
+
])
|
|
129
|
+
types.set(paletteIndex, type);
|
|
130
|
+
// Generated worlds retain the vanilla mountain/snow-plain slots without installed definitions.
|
|
131
|
+
for (const paletteIndex of [16, 19])
|
|
132
|
+
if (!types.has(paletteIndex))
|
|
133
|
+
snowIndices.add(paletteIndex);
|
|
134
|
+
const bitmap = (name) => {
|
|
135
|
+
if (index.raster === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
const selector = defaultMapSelectorValue(index.defaultMapFile, name, `${name}.bmp`);
|
|
138
|
+
const file = index.sourceRoots.map
|
|
139
|
+
.map((root) => index.activeFiles.byRelativePath.get(normalized(`${root}/${selector}`)))
|
|
140
|
+
.find((value) => value !== undefined);
|
|
141
|
+
if (file === undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
try {
|
|
144
|
+
const image = BmpImage.decode(file.bytes);
|
|
145
|
+
if (image.bitsPerPixel !== 8 ||
|
|
146
|
+
image.width !== index.raster.width ||
|
|
147
|
+
image.height !== index.raster.height)
|
|
148
|
+
return undefined;
|
|
149
|
+
sourceFiles.push(file.displayPath);
|
|
150
|
+
return image;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
return { terrain: bitmap('terrain'), cities: bitmap('cities'), types, snowIndices, sourceFiles };
|
|
157
|
+
}
|