hoi4-agent-tools 4.4.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
  3. package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
  4. package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
  5. package/dist/hoi4_agent_tools/focus/drift.js +34 -0
  6. package/dist/hoi4_agent_tools/focus/service.js +9 -3
  7. package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
  8. package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
  9. package/dist/hoi4_agent_tools/map/derived.js +16 -3
  10. package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
  11. package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
  12. package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
  13. package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
  14. package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
  15. package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
  16. package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
  17. package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
  18. package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
  19. package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
  20. package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
  21. package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
  22. package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
  23. package/dist/hoi4_agent_tools/map/model.js +2 -1
  24. package/dist/hoi4_agent_tools/map/operations.js +282 -38
  25. package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
  26. package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
  27. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +43 -0
  28. package/dist/hoi4_agent_tools/map/service.js +93 -16
  29. package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
  30. package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
  31. package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
  32. package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
  33. package/dist/hoi4_agent_tools/map/territory.js +1108 -0
  34. package/dist/hoi4_agent_tools/map/tool-operations.js +119 -1
  35. package/dist/hoi4_agent_tools/map/validation.js +4 -0
  36. package/dist/hoi4_agent_tools/map/world-context.js +23 -0
  37. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  38. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  39. package/dist/hoi4_agent_tools/map/world.js +400 -145
  40. package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
  41. package/dist/hoi4_agent_tools/schemas/map.js +277 -34
  42. package/dist/hoi4_agent_tools/version.js +1 -1
  43. package/docs/focus.md +6 -0
  44. package/docs/map.md +132 -4
  45. package/npm-shrinkwrap.json +2 -2
  46. package/package.json +1 -1
  47. package/schemas/configuration.schema.json +1 -1
  48. package/schemas/continuous-focus-palette.schema.json +1 -1
  49. package/schemas/custom-weighted-pool.schema.json +1 -1
  50. package/schemas/decision-inspect-input.schema.json +1 -1
  51. package/schemas/error-log-input.schema.json +1 -1
  52. package/schemas/event-feature-manifest.schema.json +1 -1
  53. package/schemas/focus-plan.schema.json +1 -1
  54. package/schemas/focus-planning-sidecar.schema.json +1 -1
  55. package/schemas/geography-model.schema.json +13 -1
  56. package/schemas/gui-animation-source.schema.json +1 -1
  57. package/schemas/gui-helper.schema.json +1 -1
  58. package/schemas/gui-scenario.schema.json +1 -1
  59. package/schemas/helper-expansion-request.schema.json +1 -1
  60. package/schemas/helper-expansion-summary.schema.json +1 -1
  61. package/schemas/impact-inspect-input.schema.json +1 -1
  62. package/schemas/map-operation.schema.json +1541 -786
  63. package/schemas/mechanic-test-input.schema.json +1 -1
  64. package/schemas/mod-index-input.schema.json +1 -1
  65. package/schemas/operation-result.schema.json +1 -1
  66. package/schemas/package-check-input.schema.json +1 -1
  67. package/schemas/package-manifest.schema.json +1 -1
  68. package/schemas/probability-analysis-result.schema.json +1 -1
  69. package/schemas/probability-compare-input.schema.json +1 -1
  70. package/schemas/probability-evaluate-input.schema.json +1 -1
  71. package/schemas/probability-inspect-input.schema.json +1 -1
  72. package/schemas/probability-render-input.schema.json +1 -1
  73. package/schemas/probability-scenario-set.schema.json +1 -1
  74. package/schemas/probability-sequence-input.schema.json +1 -1
  75. package/schemas/probability-simulate-input.schema.json +1 -1
  76. package/schemas/probability-sweep-input.schema.json +1 -1
  77. package/schemas/reference-context-input.schema.json +1 -1
  78. package/schemas/reference-read-input.schema.json +1 -1
  79. package/schemas/reference-search-input.schema.json +1 -1
  80. package/schemas/reference-sheets.schema.json +1 -1
  81. package/schemas/scenario-suite.schema.json +1 -1
  82. package/schemas/scenario-test-input.schema.json +1 -1
  83. package/schemas/script-validate-input.schema.json +1 -1
  84. package/schemas/shared-scenario.schema.json +1 -1
  85. package/schemas/source-lookup-input.schema.json +1 -1
  86. package/server.json +2 -2
@@ -0,0 +1,1108 @@
1
+ import { ServiceError } from '../core/result.js';
2
+ import { provinceEdgeCosts } from './partition-graph.js';
3
+ /**
4
+ * Territory assignment (ADR 0060): grow countries over a graph of atoms (provinces or states) from
5
+ * capital and anchor seeds so that their borders follow rivers, ranges and shores.
6
+ *
7
+ * 1. Every country is a source set (seeds and forced atoms). One multi-source Dijkstra grows them
8
+ * over the land graph. A step costs the centroid distance times `1 + beta * barrier` (the
9
+ * barrier is the mean strength of the natural lines on the shared border) divided by the
10
+ * country's multiplier, so a wavefront stops at a river and meets the other country there.
11
+ * 2. Multipliers are corrected with `sqrt(target / achieved)` until every share is within the
12
+ * tolerance, and the best round is kept.
13
+ * 3. Landmasses without a seed are reached over sea edges to the nearest atoms (`islandPolicy`).
14
+ * 4. A country whose seeds on its capital's landmass are cut apart is joined by a forced corridor.
15
+ * 5. A deterministic local search moves boundary atoms to the neighbouring country when that
16
+ * shortens the artificial (not natural) border, within the balance and connectivity limits.
17
+ *
18
+ * Everything is deterministic: ties go to the lower country and atom index, jitter comes from a
19
+ * hash of the seed and the atom pair, and no step uses `Math.pow`, `Math.hypot` or a clock.
20
+ */
21
+ export const TERRITORY_MAX_COUNTRIES = 512;
22
+ export const TERRITORY_MAX_SEEDS = 64;
23
+ const TAG_PATTERN = /^[A-Z0-9]{3}$/u;
24
+ function refuse(code, message, details = {}) {
25
+ throw new ServiceError(code, message, details);
26
+ }
27
+ // ---------------------------------------------------------------------------------------------
28
+ // Heap
29
+ /** A binary min-heap on (cost, entity, atom); the entity and atom break ties deterministically. */
30
+ class GrowHeap {
31
+ #cost = new Float64Array(1024);
32
+ #entity = new Int32Array(1024);
33
+ #atom = new Int32Array(1024);
34
+ #size = 0;
35
+ get size() {
36
+ return this.#size;
37
+ }
38
+ #less(i, j) {
39
+ const a = this.#cost[i];
40
+ const b = this.#cost[j];
41
+ if (a !== b)
42
+ return a < b;
43
+ if (this.#entity[i] !== this.#entity[j])
44
+ return this.#entity[i] < this.#entity[j];
45
+ return this.#atom[i] < this.#atom[j];
46
+ }
47
+ #swap(i, j) {
48
+ const cost = this.#cost[i];
49
+ const entity = this.#entity[i];
50
+ const atom = this.#atom[i];
51
+ this.#cost[i] = this.#cost[j];
52
+ this.#entity[i] = this.#entity[j];
53
+ this.#atom[i] = this.#atom[j];
54
+ this.#cost[j] = cost;
55
+ this.#entity[j] = entity;
56
+ this.#atom[j] = atom;
57
+ }
58
+ push(cost, entity, atom) {
59
+ if (this.#size === this.#cost.length) {
60
+ const grow = (array) => {
61
+ const bigger = new array.constructor(array.length * 2);
62
+ bigger.set(array);
63
+ return bigger;
64
+ };
65
+ this.#cost = grow(this.#cost);
66
+ this.#entity = grow(this.#entity);
67
+ this.#atom = grow(this.#atom);
68
+ }
69
+ let i = this.#size++;
70
+ this.#cost[i] = cost;
71
+ this.#entity[i] = entity;
72
+ this.#atom[i] = atom;
73
+ while (i > 0) {
74
+ const parent = (i - 1) >> 1;
75
+ if (!this.#less(i, parent))
76
+ break;
77
+ this.#swap(i, parent);
78
+ i = parent;
79
+ }
80
+ }
81
+ /** Remove the smallest; read it from `top*` first. */
82
+ topCost() {
83
+ return this.#cost[0];
84
+ }
85
+ topEntity() {
86
+ return this.#entity[0];
87
+ }
88
+ topAtom() {
89
+ return this.#atom[0];
90
+ }
91
+ pop() {
92
+ const last = --this.#size;
93
+ if (last > 0) {
94
+ this.#cost[0] = this.#cost[last];
95
+ this.#entity[0] = this.#entity[last];
96
+ this.#atom[0] = this.#atom[last];
97
+ let i = 0;
98
+ for (;;) {
99
+ const left = i * 2 + 1;
100
+ const right = left + 1;
101
+ let best = i;
102
+ if (left < last && this.#less(left, best))
103
+ best = left;
104
+ if (right < last && this.#less(right, best))
105
+ best = right;
106
+ if (best === i)
107
+ break;
108
+ this.#swap(i, best);
109
+ i = best;
110
+ }
111
+ }
112
+ }
113
+ }
114
+ // ---------------------------------------------------------------------------------------------
115
+ // Helpers
116
+ function hash32(seed, a, b) {
117
+ let h = (seed ^ 0x9e3779b9) >>> 0;
118
+ h = Math.imul(h ^ a, 0x85ebca6b) >>> 0;
119
+ h = Math.imul(h ^ (h >>> 13) ^ b, 0xc2b2ae35) >>> 0;
120
+ h ^= h >>> 16;
121
+ return (Math.imul(h, 0x27d4eb2f) >>> 0) / 4_294_967_296;
122
+ }
123
+ function wrappedDistance(graph, ax, ay, bx, by) {
124
+ let dx = ax - bx;
125
+ if (dx > graph.width / 2)
126
+ dx -= graph.width;
127
+ else if (dx < -graph.width / 2)
128
+ dx += graph.width;
129
+ const dy = ay - by;
130
+ return Math.sqrt(dx * dx + dy * dy);
131
+ }
132
+ /** Connected components of the allowed atoms over land edges; -1 outside. */
133
+ function landComponents(graph, allowed) {
134
+ const component = new Int32Array(graph.count).fill(-1);
135
+ let count = 0;
136
+ const stack = [];
137
+ for (let start = 0; start < graph.count; start += 1) {
138
+ if (allowed[start] === 0 || component[start] >= 0)
139
+ continue;
140
+ component[start] = count;
141
+ stack.push(start);
142
+ while (stack.length > 0) {
143
+ const atom = stack.pop();
144
+ for (let slot = graph.offsets[atom]; slot < graph.offsets[atom + 1]; slot += 1) {
145
+ const next = graph.to[slot];
146
+ if (allowed[next] === 0 || component[next] >= 0)
147
+ continue;
148
+ component[next] = count;
149
+ stack.push(next);
150
+ }
151
+ }
152
+ count += 1;
153
+ }
154
+ return { component, count };
155
+ }
156
+ /** A uniform grid of atom centres for nearest-neighbour searches. */
157
+ class CentreGrid {
158
+ graph;
159
+ #cell;
160
+ #columns;
161
+ #rows;
162
+ #cells;
163
+ constructor(graph, atoms) {
164
+ this.graph = graph;
165
+ this.#cell = Math.max(8, Math.sqrt((graph.width * graph.height) / Math.max(1, atoms.length)) * 2);
166
+ this.#columns = Math.max(1, Math.ceil(graph.width / this.#cell));
167
+ this.#rows = Math.max(1, Math.ceil(graph.height / this.#cell));
168
+ this.#cells = Array.from({ length: this.#columns * this.#rows }, () => []);
169
+ for (const atom of atoms)
170
+ this.#cells[this.#cellOf(atom)].push(atom);
171
+ }
172
+ #cellOf(atom) {
173
+ const column = Math.min(this.#columns - 1, Math.max(0, Math.floor(this.graph.centerX[atom] / this.#cell)));
174
+ const row = Math.min(this.#rows - 1, Math.max(0, Math.floor(this.graph.centerY[atom] / this.#cell)));
175
+ return row * this.#columns + column;
176
+ }
177
+ /** The `k` atoms nearest to `atom` (wrapping east-west) that satisfy `accept`, nearest first. */
178
+ nearest(atom, k, accept) {
179
+ const graph = this.graph;
180
+ const column = Math.min(this.#columns - 1, Math.max(0, Math.floor(graph.centerX[atom] / this.#cell)));
181
+ const row = Math.min(this.#rows - 1, Math.max(0, Math.floor(graph.centerY[atom] / this.#cell)));
182
+ const found = [];
183
+ const limit = Math.max(this.#columns, this.#rows);
184
+ for (let ring = 0; ring <= limit; ring += 1) {
185
+ if (found.length >= k) {
186
+ const reach = Math.max(0, ring - 1) * this.#cell;
187
+ if (found[k - 1].distance <= reach)
188
+ break;
189
+ }
190
+ for (let dy = -ring; dy <= ring; dy += 1) {
191
+ const y = row + dy;
192
+ if (y < 0 || y >= this.#rows)
193
+ continue;
194
+ for (let dx = -ring; dx <= ring; dx += 1) {
195
+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring)
196
+ continue;
197
+ const x = (((column + dx) % this.#columns) + this.#columns) % this.#columns;
198
+ for (const candidate of this.#cells[y * this.#columns + x]) {
199
+ if (!accept(candidate))
200
+ continue;
201
+ found.push({
202
+ atom: candidate,
203
+ distance: wrappedDistance(graph, graph.centerX[atom], graph.centerY[atom], graph.centerX[candidate], graph.centerY[candidate]),
204
+ });
205
+ }
206
+ }
207
+ }
208
+ found.sort((x, y) => x.distance - y.distance || x.atom - y.atom);
209
+ if (found.length > k * 4)
210
+ found.length = k * 4;
211
+ }
212
+ return found.slice(0, k).map(({ atom: candidate }) => candidate);
213
+ }
214
+ }
215
+ // ---------------------------------------------------------------------------------------------
216
+ // The algorithm
217
+ class TerritoryRun {
218
+ atoms;
219
+ rules;
220
+ barriers;
221
+ seaCost;
222
+ jitter;
223
+ seed;
224
+ tolerance;
225
+ smoothing;
226
+ maxRounds;
227
+ unowned;
228
+ excluded;
229
+ graph;
230
+ baseCost;
231
+ notes = [];
232
+ rounds = 0;
233
+ smoothingMoves = 0;
234
+ corridors = 0;
235
+ /** Feasible target (pixels) of each entity of the level grown last. */
236
+ lastTargets = new Float64Array(0);
237
+ #grid;
238
+ constructor(atoms, options) {
239
+ this.atoms = atoms;
240
+ this.graph = atoms.graph;
241
+ this.rules = {
242
+ connectedMainland: options.rules?.connectedMainland ?? true,
243
+ islandPolicy: options.rules?.islandPolicy ?? 'one-owner',
244
+ };
245
+ this.barriers = options.barriers ?? 'natural';
246
+ this.seaCost = options.seaCost ?? 3;
247
+ this.jitter = options.jitter ?? 0.08;
248
+ this.seed = options.seed ?? 0;
249
+ this.tolerance = options.shareTolerance ?? 0.15;
250
+ this.smoothing = {
251
+ passes: options.smoothing?.passes ?? 4,
252
+ maxShareChange: options.smoothing?.maxShareChange ?? 0.15,
253
+ };
254
+ this.maxRounds = options.maxRounds ?? 20;
255
+ this.unowned = new Uint8Array(atoms.count);
256
+ this.excluded = new Uint8Array(atoms.count);
257
+ for (const atom of options.unowned ?? [])
258
+ this.unowned[atom] = 1;
259
+ for (const atom of options.excluded ?? [])
260
+ this.excluded[atom] = 1;
261
+ const costs = provinceEdgeCosts(this.graph, {
262
+ barrier: this.barriers === 'natural' ? (options.barrierStrength ?? 6) : 0,
263
+ water: 1,
264
+ });
265
+ // Seeded noise, the same in both directions of an edge.
266
+ if (this.jitter > 0)
267
+ for (let a = 0; a < this.graph.count; a += 1)
268
+ for (let slot = this.graph.offsets[a]; slot < this.graph.offsets[a + 1]; slot += 1) {
269
+ const b = this.graph.to[slot];
270
+ const noise = hash32(this.seed, Math.min(a, b), Math.max(a, b)) * 2 - 1;
271
+ costs[slot] = costs[slot] * (1 + this.jitter * noise);
272
+ }
273
+ this.baseCost = costs;
274
+ }
275
+ signalCheck(signal) {
276
+ signal?.throwIfAborted();
277
+ }
278
+ centreDistance(a, b) {
279
+ const g = this.graph;
280
+ return wrappedDistance(g, g.centerX[a], g.centerY[a], g.centerX[b], g.centerY[b]);
281
+ }
282
+ grid() {
283
+ if (this.#grid === undefined) {
284
+ const all = [];
285
+ for (let atom = 0; atom < this.graph.count; atom += 1)
286
+ if (this.excluded[atom] === 0)
287
+ all.push(atom);
288
+ this.#grid = new CentreGrid(this.graph, all);
289
+ }
290
+ return this.#grid;
291
+ }
292
+ /** Grow `entities` inside `allowed` and return the entity index of every atom, -1 for none. */
293
+ growLevel(entities, allowed, signal, label) {
294
+ const graph = this.graph;
295
+ const n = graph.count;
296
+ const entityCount = entities.length;
297
+ // Atoms nobody may take are outside the land the entities share.
298
+ const open = new Uint8Array(n);
299
+ let openWeight = 0;
300
+ for (let atom = 0; atom < n; atom += 1)
301
+ if (allowed[atom] === 1 && this.excluded[atom] === 0 && this.unowned[atom] === 0) {
302
+ open[atom] = 1;
303
+ openWeight += graph.size[atom];
304
+ }
305
+ if (entityCount === 1) {
306
+ this.lastTargets = Float64Array.of(openWeight);
307
+ const owner = new Int32Array(n).fill(-1);
308
+ const entity = entities[0];
309
+ for (let atom = 0; atom < n; atom += 1)
310
+ if (open[atom] === 1 && !entity.blocked.has(atom))
311
+ owner[atom] = 0;
312
+ return owner;
313
+ }
314
+ const sourceOwner = new Int32Array(n).fill(-1);
315
+ for (const [index, entity] of entities.entries())
316
+ for (const atom of [...entity.seeds, ...entity.forced]) {
317
+ if (open[atom] === 0)
318
+ refuse('MAP_TERRITORY_SEED_UNAVAILABLE', `Atom ${atom}, a seed or forced territory of ${entity.name}, is outside the land that can be assigned in ${label} (excluded, unowned or outside its overlord)`, { atom, tag: entity.name, level: label });
319
+ sourceOwner[atom] = index;
320
+ }
321
+ const { component, count: componentCount } = landComponents(graph, open);
322
+ const seeded = new Uint8Array(componentCount);
323
+ for (let atom = 0; atom < n; atom += 1)
324
+ if (sourceOwner[atom] >= 0)
325
+ seeded[component[atom]] = 1;
326
+ // Cut-off check: the sources on each capital's landmass must reach each other.
327
+ if (this.rules.connectedMainland)
328
+ for (const [index, entity] of entities.entries()) {
329
+ const capital = entity.seeds[0] ?? entity.forced[0];
330
+ if (capital === undefined)
331
+ continue;
332
+ const reached = this.#reach(capital, index, entity, open, sourceOwner);
333
+ for (const atom of [...entity.seeds, ...entity.forced])
334
+ if (component[atom] === component[capital] && !reached.has(atom))
335
+ refuse('MAP_TERRITORY_FORCED_UNREACHABLE', `Atom ${atom} is on the same landmass as the capital of ${entity.name} but blocked or forced territory of other countries cuts it off`, { atom, capital, tag: entity.name, level: label });
336
+ }
337
+ // Sea edges: every atom of a landmass without a source reaches its nearest neighbours elsewhere.
338
+ const seaFrom = [];
339
+ const seaTo = [];
340
+ if (this.rules.islandPolicy !== 'unowned') {
341
+ const grid = this.grid();
342
+ const present = new Set();
343
+ for (let atom = 0; atom < n; atom += 1) {
344
+ if (open[atom] === 0 || seeded[component[atom]] === 1)
345
+ continue;
346
+ const near = grid.nearest(atom, 3, (candidate) => {
347
+ return open[candidate] === 1 && component[candidate] !== component[atom];
348
+ });
349
+ for (const other of near) {
350
+ const key = atom < other ? `${atom}:${other}` : `${other}:${atom}`;
351
+ if (present.has(key))
352
+ continue;
353
+ present.add(key);
354
+ seaFrom.push(atom, other);
355
+ seaTo.push(other, atom);
356
+ }
357
+ }
358
+ }
359
+ const seaOffsets = new Int32Array(n + 1);
360
+ for (const from of seaFrom)
361
+ seaOffsets[from + 1]++;
362
+ for (let atom = 0; atom < n; atom += 1)
363
+ seaOffsets[atom + 1] += seaOffsets[atom];
364
+ const seaAdjacent = new Int32Array(seaFrom.length);
365
+ {
366
+ const cursor = Int32Array.from(seaOffsets.subarray(0, n));
367
+ for (let i = 0; i < seaFrom.length; i += 1)
368
+ seaAdjacent[cursor[seaFrom[i]]++] = seaTo[i];
369
+ }
370
+ // Island atoms by component, for the one-owner policy.
371
+ const islandMembers = Array.from({ length: componentCount }, () => []);
372
+ for (let atom = 0; atom < n; atom += 1)
373
+ if (open[atom] === 1 && seeded[component[atom]] === 0)
374
+ islandMembers[component[atom]].push(atom);
375
+ // Region preference.
376
+ const homeOwners = new Map();
377
+ for (const [index, entity] of entities.entries())
378
+ for (const region of entity.home) {
379
+ const set = homeOwners.get(region) ?? new Set();
380
+ set.add(index);
381
+ homeOwners.set(region, set);
382
+ }
383
+ const regionFactor = (index, atom) => {
384
+ const region = this.atoms.region[atom];
385
+ if (region < 0)
386
+ return 1;
387
+ const owners = homeOwners.get(region);
388
+ if (owners === undefined)
389
+ return 1;
390
+ return owners.has(index) ? 0.7 : 1.8;
391
+ };
392
+ const jitterSea = (a, b) => 1 + this.jitter * (hash32(this.seed + 1, Math.min(a, b), Math.max(a, b)) * 2 - 1);
393
+ const owner = new Int32Array(n);
394
+ const multiplier = new Float64Array(entityCount).fill(1);
395
+ const targets = new Float64Array(entityCount);
396
+ // Feasible targets: a country alone on a landmass owns all of it, and cannot own more than the
397
+ // landmasses it starts on and the islands; the rest is shared by weight (water filling).
398
+ const componentSize = new Float64Array(componentCount);
399
+ for (let atom = 0; atom < n; atom += 1)
400
+ if (open[atom] === 1)
401
+ componentSize[component[atom]] += graph.size[atom];
402
+ const startsOn = entities.map(() => new Set());
403
+ for (const [index, entity] of entities.entries())
404
+ for (const atom of [...entity.seeds, ...entity.forced])
405
+ startsOn[index].add(component[atom]);
406
+ const holders = new Int32Array(componentCount);
407
+ for (const set of startsOn)
408
+ for (const part of set)
409
+ holders[part] += 1;
410
+ let islandTotal = 0;
411
+ for (let part = 0; part < componentCount; part += 1)
412
+ if (seeded[part] === 0)
413
+ islandTotal += componentSize[part];
414
+ const low = new Float64Array(entityCount);
415
+ const high = new Float64Array(entityCount);
416
+ for (const [index, set] of startsOn.entries()) {
417
+ for (const part of set) {
418
+ high[index] += componentSize[part];
419
+ if (holders[part] === 1)
420
+ low[index] += componentSize[part];
421
+ }
422
+ high[index] += islandTotal;
423
+ }
424
+ const fixedTarget = new Float64Array(entityCount).fill(-1);
425
+ this.lastTargets = targets;
426
+ for (let pass = 0; pass < entityCount + 1; pass += 1) {
427
+ let remaining = openWeight;
428
+ let remainingWeight = 0;
429
+ for (let i = 0; i < entityCount; i += 1)
430
+ if (fixedTarget[i] >= 0)
431
+ remaining -= fixedTarget[i];
432
+ else
433
+ remainingWeight += entities[i].weight;
434
+ let violated = false;
435
+ for (let i = 0; i < entityCount; i += 1) {
436
+ if (fixedTarget[i] >= 0)
437
+ continue;
438
+ const wanted = (remaining * entities[i].weight) / remainingWeight;
439
+ if (wanted < low[i]) {
440
+ fixedTarget[i] = low[i];
441
+ violated = true;
442
+ }
443
+ else if (wanted > high[i]) {
444
+ fixedTarget[i] = high[i];
445
+ violated = true;
446
+ }
447
+ }
448
+ if (!violated) {
449
+ for (let i = 0; i < entityCount; i += 1)
450
+ targets[i] =
451
+ fixedTarget[i] >= 0
452
+ ? fixedTarget[i]
453
+ : (remaining * entities[i].weight) / remainingWeight;
454
+ break;
455
+ }
456
+ }
457
+ const forcedExtra = entities.map(() => new Set());
458
+ const sources = (index) => [
459
+ ...entities[index].seeds,
460
+ ...entities[index].forced,
461
+ ...forcedExtra[index],
462
+ ];
463
+ const grow = () => {
464
+ owner.fill(-1);
465
+ const heap = new GrowHeap();
466
+ const assigned = [];
467
+ const give = (atom, index) => {
468
+ owner[atom] = index;
469
+ assigned.push(atom);
470
+ };
471
+ for (let index = 0; index < entityCount; index += 1)
472
+ for (const atom of sources(index))
473
+ give(atom, index);
474
+ const relax = (atom, index, distance) => {
475
+ const entity = entities[index];
476
+ // With the nearest policy an unseeded landmass is reached atom by atom over sea edges.
477
+ const byLand = !(this.rules.islandPolicy === 'nearest' && seeded[component[atom]] === 0);
478
+ for (let slot = graph.offsets[atom]; byLand && slot < graph.offsets[atom + 1]; slot += 1) {
479
+ const next = graph.to[slot];
480
+ if (open[next] === 0 || owner[next] >= 0 || entity.blocked.has(next))
481
+ continue;
482
+ heap.push(distance + (this.baseCost[slot] * regionFactor(index, next)) / multiplier[index], index, next);
483
+ }
484
+ for (let slot = seaOffsets[atom]; slot < seaOffsets[atom + 1]; slot += 1) {
485
+ const next = seaAdjacent[slot];
486
+ if (owner[next] >= 0 || entity.blocked.has(next))
487
+ continue;
488
+ heap.push(distance +
489
+ (this.centreDistance(atom, next) * this.seaCost * jitterSea(atom, next)) /
490
+ multiplier[index], index, next);
491
+ }
492
+ };
493
+ for (const atom of [...assigned])
494
+ relax(atom, owner[atom], 0);
495
+ while (heap.size > 0) {
496
+ const distance = heap.topCost();
497
+ const index = heap.topEntity();
498
+ const atom = heap.topAtom();
499
+ heap.pop();
500
+ if (owner[atom] >= 0)
501
+ continue;
502
+ const island = seeded[component[atom]] === 0 && this.rules.islandPolicy === 'one-owner';
503
+ const entity = entities[index];
504
+ if (island) {
505
+ // The whole island, unless this country may not own some of it.
506
+ const members = islandMembers[component[atom]];
507
+ if (members.some((member) => entity.blocked.has(member) || owner[member] >= 0))
508
+ continue;
509
+ for (const member of members)
510
+ give(member, index);
511
+ for (const member of members)
512
+ relax(member, index, distance);
513
+ }
514
+ else {
515
+ give(atom, index);
516
+ relax(atom, index, distance);
517
+ }
518
+ }
519
+ };
520
+ const measure = () => {
521
+ const area = new Float64Array(entityCount);
522
+ for (let atom = 0; atom < n; atom += 1)
523
+ if (owner[atom] >= 0)
524
+ area[owner[atom]] += graph.size[atom];
525
+ let error = 0;
526
+ for (let i = 0; i < entityCount; i += 1)
527
+ error = Math.max(error, Math.abs(area[i] - targets[i]) / Math.max(1, targets[i]));
528
+ return { area, error };
529
+ };
530
+ // Balancing rounds: the multiplier of a country that is too small rises.
531
+ let best;
532
+ let bestMultiplier = Float64Array.from(multiplier);
533
+ let bestScore = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
534
+ let sinceBest = 0;
535
+ for (let round = 0; round < Math.max(1, this.maxRounds); round += 1) {
536
+ signal?.throwIfAborted();
537
+ this.rounds += 1;
538
+ grow();
539
+ const { area, error } = measure();
540
+ const score = [
541
+ Math.max(0, error - this.tolerance * 0.5),
542
+ this.#artificial(owner),
543
+ ];
544
+ if (score[0] < bestScore[0] || (score[0] === bestScore[0] && score[1] < bestScore[1])) {
545
+ best = Int32Array.from(owner);
546
+ bestMultiplier = Float64Array.from(multiplier);
547
+ bestScore = score;
548
+ sinceBest = 0;
549
+ }
550
+ else
551
+ sinceBest += 1;
552
+ if (error <= this.tolerance * 0.4 || sinceBest >= 4)
553
+ break;
554
+ for (let i = 0; i < entityCount; i += 1) {
555
+ const ratio = Math.sqrt(targets[i] / Math.max(1, area[i]));
556
+ const step = Math.min(1.5, Math.max(0.67, ratio));
557
+ multiplier[i] = Math.min(16, Math.max(1 / 16, multiplier[i] * step));
558
+ }
559
+ }
560
+ // The best round is the working assignment.
561
+ owner.set(best);
562
+ multiplier.set(bestMultiplier);
563
+ // Connectivity: a country cut apart on its capital's landmass is joined by a forced corridor.
564
+ if (this.rules.connectedMainland) {
565
+ for (let attempt = 0; attempt < 8; attempt += 1) {
566
+ signal?.throwIfAborted();
567
+ const added = this.#joinCutSeeds(entities, owner, forcedExtra, open, component, sourceOwner);
568
+ if (!added)
569
+ break;
570
+ this.corridors += 1;
571
+ grow();
572
+ }
573
+ }
574
+ const fixed = new Uint8Array(n);
575
+ for (let index = 0; index < entityCount; index += 1)
576
+ for (const atom of sources(index))
577
+ fixed[atom] = 1;
578
+ if (this.barriers === 'natural' && this.smoothing.passes > 0)
579
+ this.#smooth(entities, owner, fixed, targets, signal);
580
+ return owner;
581
+ }
582
+ /** Atoms reachable from `capital` through open atoms the entity may own. */
583
+ #reach(capital, index, entity, open, sourceOwner) {
584
+ const graph = this.graph;
585
+ const seen = new Set([capital]);
586
+ const stack = [capital];
587
+ while (stack.length > 0) {
588
+ const atom = stack.pop();
589
+ for (let slot = graph.offsets[atom]; slot < graph.offsets[atom + 1]; slot += 1) {
590
+ const next = graph.to[slot];
591
+ if (open[next] === 0 || seen.has(next) || entity.blocked.has(next))
592
+ continue;
593
+ if (sourceOwner[next] >= 0 && sourceOwner[next] !== index)
594
+ continue;
595
+ seen.add(next);
596
+ stack.push(next);
597
+ }
598
+ }
599
+ return seen;
600
+ }
601
+ /** The artificial (not natural) border length of an assignment, in pixel edges. */
602
+ #artificial(owner) {
603
+ const graph = this.graph;
604
+ let total = 0;
605
+ for (let a = 0; a < graph.count; a += 1) {
606
+ const mine = owner[a];
607
+ for (let slot = graph.offsets[a]; slot < graph.offsets[a + 1]; slot += 1) {
608
+ const b = graph.to[slot];
609
+ if (b < a)
610
+ continue;
611
+ const other = owner[b];
612
+ if (mine === other)
613
+ continue;
614
+ total += graph.length[slot] - this.atoms.credit[slot];
615
+ }
616
+ }
617
+ return total;
618
+ }
619
+ /**
620
+ * Where a country's atoms on its capital's landmass fall into several pieces, force the cheapest
621
+ * corridor of atoms (not forced for another country) that joins a stray piece to the capital's
622
+ * piece. Returns whether any corridor was added.
623
+ */
624
+ #joinCutSeeds(entities, owner, forcedExtra, open, component, sourceOwner) {
625
+ const graph = this.graph;
626
+ for (const [index, entity] of entities.entries()) {
627
+ const capital = entity.seeds[0] ?? entity.forced[0];
628
+ if (capital === undefined)
629
+ continue;
630
+ // The capital's piece.
631
+ const piece = new Set([capital]);
632
+ const stack = [capital];
633
+ while (stack.length > 0) {
634
+ const atom = stack.pop();
635
+ for (let slot = graph.offsets[atom]; slot < graph.offsets[atom + 1]; slot += 1) {
636
+ const next = graph.to[slot];
637
+ if (owner[next] === index && !piece.has(next)) {
638
+ piece.add(next);
639
+ stack.push(next);
640
+ }
641
+ }
642
+ }
643
+ const stray = [...entity.seeds, ...entity.forced, ...forcedExtra[index]].find((atom) => component[atom] === component[capital] && !piece.has(atom));
644
+ if (stray === undefined)
645
+ continue;
646
+ // Cheapest path from the stray source to the capital's piece through atoms that are free.
647
+ const heap = new GrowHeap();
648
+ const distance = new Float64Array(graph.count).fill(Number.POSITIVE_INFINITY);
649
+ const parent = new Int32Array(graph.count).fill(-1);
650
+ distance[stray] = 0;
651
+ heap.push(0, 0, stray);
652
+ let reached = -1;
653
+ while (heap.size > 0) {
654
+ const cost = heap.topCost();
655
+ const atom = heap.topAtom();
656
+ heap.pop();
657
+ if (cost > distance[atom])
658
+ continue;
659
+ if (piece.has(atom)) {
660
+ reached = atom;
661
+ break;
662
+ }
663
+ for (let slot = graph.offsets[atom]; slot < graph.offsets[atom + 1]; slot += 1) {
664
+ const next = graph.to[slot];
665
+ if (open[next] === 0 || entity.blocked.has(next))
666
+ continue;
667
+ if (sourceOwner[next] >= 0 && sourceOwner[next] !== index)
668
+ continue;
669
+ if (forcedExtra.some((set, other) => other !== index && set.has(next)))
670
+ continue;
671
+ const step = this.baseCost[slot] * (owner[next] === index || piece.has(next) ? 1 : 4);
672
+ if (cost + step < distance[next]) {
673
+ distance[next] = cost + step;
674
+ parent[next] = atom;
675
+ heap.push(cost + step, 0, next);
676
+ }
677
+ }
678
+ }
679
+ if (reached < 0)
680
+ refuse('MAP_TERRITORY_FORCED_UNREACHABLE', `No corridor joins atom ${stray} to the capital of entity ${index}`, { atom: stray, capital, entity: index });
681
+ for (let atom = reached; atom >= 0; atom = parent[atom])
682
+ forcedExtra[index].add(atom);
683
+ return true;
684
+ }
685
+ return false;
686
+ }
687
+ /** Move boundary atoms to a neighbouring country when that shortens the artificial border. */
688
+ #smooth(entities, owner, fixed, targets, signal) {
689
+ const graph = this.graph;
690
+ const entityCount = entities.length;
691
+ const area = new Float64Array(entityCount);
692
+ for (let atom = 0; atom < graph.count; atom += 1)
693
+ if (owner[atom] >= 0)
694
+ area[owner[atom]] += graph.size[atom];
695
+ const start = Float64Array.from(area);
696
+ const tolerance = this.tolerance;
697
+ const errorOf = (index, value) => Math.abs(value - targets[index]) / Math.max(1, targets[index]);
698
+ const allowedSize = (index, value) => {
699
+ const change = Math.abs(value - start[index]) / Math.max(1, start[index]);
700
+ if (change > this.smoothing.maxShareChange)
701
+ return false;
702
+ return errorOf(index, value) <= Math.max(tolerance, errorOf(index, area[index]));
703
+ };
704
+ for (let pass = 0; pass < this.smoothing.passes; pass += 1) {
705
+ let moved = 0;
706
+ for (let atom = 0; atom < graph.count; atom += 1) {
707
+ if ((atom & 1023) === 0)
708
+ signal?.throwIfAborted();
709
+ const from = owner[atom];
710
+ if (from < 0 || fixed[atom] === 1)
711
+ continue;
712
+ // Candidate owners among the neighbours.
713
+ let gainBest = 0;
714
+ let target = -1;
715
+ let seenOther = false;
716
+ for (let slot = graph.offsets[atom]; slot < graph.offsets[atom + 1]; slot += 1) {
717
+ const other = owner[graph.to[slot]];
718
+ if (other >= 0 && other !== from) {
719
+ seenOther = true;
720
+ break;
721
+ }
722
+ }
723
+ if (!seenOther)
724
+ continue;
725
+ const tried = new Set();
726
+ for (let slot = graph.offsets[atom]; slot < graph.offsets[atom + 1]; slot += 1) {
727
+ const candidate = owner[graph.to[slot]];
728
+ if (candidate < 0 || candidate === from || tried.has(candidate))
729
+ continue;
730
+ tried.add(candidate);
731
+ if (entities[candidate].blocked.has(atom))
732
+ continue;
733
+ // Artificial border length before and after, over the atom's edges.
734
+ let gain = 0;
735
+ for (let s = graph.offsets[atom]; s < graph.offsets[atom + 1]; s += 1) {
736
+ const neighbour = owner[graph.to[s]];
737
+ if (neighbour < 0)
738
+ continue;
739
+ const artificial = graph.length[s] - this.atoms.credit[s];
740
+ if (neighbour !== from)
741
+ gain += artificial;
742
+ if (neighbour !== candidate)
743
+ gain -= artificial;
744
+ }
745
+ if (gain > gainBest + 1e-9) {
746
+ gainBest = gain;
747
+ target = candidate;
748
+ }
749
+ }
750
+ if (target < 0)
751
+ continue;
752
+ const weight = graph.size[atom];
753
+ if (!allowedSize(from, area[from] - weight) ||
754
+ !allowedSize(target, area[target] + weight))
755
+ continue;
756
+ if (!this.#staysConnected(atom, from, owner))
757
+ continue;
758
+ owner[atom] = target;
759
+ area[from] -= weight;
760
+ area[target] += weight;
761
+ moved += 1;
762
+ this.smoothingMoves += 1;
763
+ }
764
+ if (moved === 0)
765
+ break;
766
+ }
767
+ }
768
+ /** Whether the neighbours of `atom` owned by `from` stay joined when `atom` is taken away. */
769
+ #staysConnected(atom, from, owner) {
770
+ const graph = this.graph;
771
+ const neighbours = [];
772
+ for (let slot = graph.offsets[atom]; slot < graph.offsets[atom + 1]; slot += 1)
773
+ if (owner[graph.to[slot]] === from)
774
+ neighbours.push(graph.to[slot]);
775
+ if (neighbours.length <= 1)
776
+ return true;
777
+ const wanted = new Set(neighbours.slice(1));
778
+ const seen = new Set([neighbours[0], atom]);
779
+ const queue = [neighbours[0]];
780
+ let budget = 4000;
781
+ for (let head = 0; head < queue.length && wanted.size > 0; head += 1) {
782
+ if (budget-- <= 0)
783
+ return false;
784
+ const current = queue[head];
785
+ for (let slot = graph.offsets[current]; slot < graph.offsets[current + 1]; slot += 1) {
786
+ const next = graph.to[slot];
787
+ if (seen.has(next) || owner[next] !== from)
788
+ continue;
789
+ seen.add(next);
790
+ wanted.delete(next);
791
+ queue.push(next);
792
+ }
793
+ }
794
+ return wanted.size === 0;
795
+ }
796
+ }
797
+ // ---------------------------------------------------------------------------------------------
798
+ // Entry point
799
+ function validateCountries(atoms, countries, options) {
800
+ if (countries.length === 0)
801
+ refuse('MAP_TERRITORY_COUNTRIES_MISSING', 'Territory assignment needs at least one country');
802
+ if (countries.length > TERRITORY_MAX_COUNTRIES)
803
+ refuse('MAP_TERRITORY_TOO_MANY_COUNTRIES', `Territory assignment accepts at most ${TERRITORY_MAX_COUNTRIES} countries`, { countries: countries.length, limit: TERRITORY_MAX_COUNTRIES });
804
+ const tags = new Map();
805
+ const atomOk = (atom) => Number.isInteger(atom) && atom >= 0 && atom < atoms.count;
806
+ const excluded = new Set(options.excluded ?? []);
807
+ const unowned = new Set(options.unowned ?? []);
808
+ for (const atom of [...excluded, ...unowned])
809
+ if (!atomOk(atom))
810
+ refuse('MAP_TERRITORY_ATOM_UNKNOWN', `Atom ${atom} does not exist`, { atom });
811
+ const resolved = [];
812
+ for (const [index, country] of countries.entries()) {
813
+ if (!TAG_PATTERN.test(country.tag))
814
+ refuse('MAP_TERRITORY_TAG_INVALID', `Country tag ${country.tag} must be three capital letters or digits`, { tag: country.tag });
815
+ if (tags.has(country.tag))
816
+ refuse('MAP_TERRITORY_TAG_DUPLICATE', `Country tag ${country.tag} appears twice`, {
817
+ tag: country.tag,
818
+ });
819
+ tags.set(country.tag, index);
820
+ if (!Number.isFinite(country.weight) || country.weight <= 0)
821
+ refuse('MAP_TERRITORY_WEIGHT_INVALID', `Country ${country.tag} needs a weight above zero`, {
822
+ tag: country.tag,
823
+ weight: country.weight,
824
+ });
825
+ for (const [kind, list] of [
826
+ ['seeds', country.seeds],
827
+ ['forced', country.forced ?? []],
828
+ ['blocked', country.blocked ?? []],
829
+ ])
830
+ for (const atom of list)
831
+ if (!atomOk(atom))
832
+ refuse('MAP_TERRITORY_ATOM_UNKNOWN', `Country ${country.tag} names atom ${atom} in ${kind}, which does not exist`, { tag: country.tag, kind, atom });
833
+ if (country.seeds.length > TERRITORY_MAX_SEEDS)
834
+ refuse('MAP_TERRITORY_TOO_MANY_SEEDS', `Country ${country.tag} has more than ${TERRITORY_MAX_SEEDS} seeds`, { tag: country.tag });
835
+ const seeds = [...new Set(country.seeds)];
836
+ const forced = [...new Set(country.forced ?? [])].filter((atom) => !seeds.includes(atom));
837
+ if (seeds.length + forced.length === 0)
838
+ refuse('MAP_TERRITORY_SEED_MISSING', `Country ${country.tag} has no seed and no forced territory`, { tag: country.tag });
839
+ resolved.push({
840
+ tag: country.tag,
841
+ weight: country.weight,
842
+ seeds,
843
+ forced,
844
+ blocked: new Set(country.blocked ?? []),
845
+ home: new Set(country.homeRegions ?? []),
846
+ overlord: -1,
847
+ mergeInto: -1,
848
+ });
849
+ }
850
+ for (const [index, country] of countries.entries()) {
851
+ if (country.overlord !== undefined) {
852
+ const overlord = tags.get(country.overlord);
853
+ if (overlord === undefined || overlord === index)
854
+ refuse('MAP_TERRITORY_OVERLORD_INVALID', `Country ${country.tag} names overlord ${country.overlord}, which is not another listed country`, { tag: country.tag, overlord: country.overlord });
855
+ resolved[index].overlord = overlord;
856
+ }
857
+ if (country.mergeInto !== undefined) {
858
+ const target = tags.get(country.mergeInto);
859
+ if (target === undefined || target === index || countries[target].mergeInto !== undefined)
860
+ refuse('MAP_TERRITORY_MERGE_INVALID', `Country ${country.tag} merges into ${country.mergeInto}, which must be another listed country that is not itself merged`, { tag: country.tag, mergeInto: country.mergeInto });
861
+ resolved[index].mergeInto = target;
862
+ }
863
+ }
864
+ for (const [index] of resolved.entries()) {
865
+ const seen = new Set([index]);
866
+ for (let node = resolved[index].overlord; node >= 0; node = resolved[node].overlord) {
867
+ if (seen.has(node))
868
+ refuse('MAP_TERRITORY_OVERLORD_INVALID', `The overlord chain of ${resolved[index].tag} is a cycle`, { tag: resolved[index].tag });
869
+ seen.add(node);
870
+ }
871
+ }
872
+ // Seeds and forced atoms are exclusive.
873
+ const claimed = new Map();
874
+ for (const [index, country] of resolved.entries())
875
+ for (const atom of [...country.seeds, ...country.forced]) {
876
+ const holder = claimed.get(atom);
877
+ if (holder !== undefined && holder !== index)
878
+ refuse('MAP_TERRITORY_FORCED_CONFLICT', `Atom ${atom} is a seed or forced territory of both ${resolved[holder].tag} and ${country.tag}`, { atom, tags: [resolved[holder].tag, country.tag] });
879
+ claimed.set(atom, index);
880
+ if (country.blocked.has(atom))
881
+ refuse('MAP_TERRITORY_FORCED_BLOCKED', `Atom ${atom} is both forced or a seed and blocked for ${country.tag}`, { atom, tag: country.tag });
882
+ if (excluded.has(atom) || unowned.has(atom))
883
+ refuse('MAP_TERRITORY_SEED_UNAVAILABLE', `Atom ${atom} of ${country.tag} is excluded from the assignment or declared unowned`, { atom, tag: country.tag });
884
+ }
885
+ return resolved;
886
+ }
887
+ /** Territory assignment: grow the countries over the atoms and describe the result. */
888
+ export function growTerritory(atoms, countries, options = {}) {
889
+ for (const [name, value, low, high] of [
890
+ ['shareTolerance', options.shareTolerance ?? 0.15, 0.01, 1],
891
+ ['jitter', options.jitter ?? 0.08, 0, 0.5],
892
+ ['barrierStrength', options.barrierStrength ?? 6, 0, 60],
893
+ ['seaCost', options.seaCost ?? 3, 1, 50],
894
+ ['smoothing.maxShareChange', options.smoothing?.maxShareChange ?? 0.15, 0, 1],
895
+ ])
896
+ if (!Number.isFinite(value) || value < low || value > high)
897
+ refuse('MAP_TERRITORY_OPTION_INVALID', `Territory option ${name} must be from ${low} to ${high}`, {
898
+ option: name,
899
+ value,
900
+ });
901
+ const passes = options.smoothing?.passes ?? 4;
902
+ if (!Number.isInteger(passes) || passes < 0 || passes > 20)
903
+ refuse('MAP_TERRITORY_OPTION_INVALID', 'Territory option smoothing.passes must be from 0 to 20', {
904
+ option: 'smoothing.passes',
905
+ value: passes,
906
+ });
907
+ const resolved = validateCountries(atoms, countries, options);
908
+ const run = new TerritoryRun(atoms, options);
909
+ const signal = options.signal;
910
+ const n = atoms.count;
911
+ const toEntity = (index) => ({
912
+ name: resolved[index].tag,
913
+ weight: resolved[index].weight,
914
+ seeds: resolved[index].seeds,
915
+ forced: resolved[index].forced,
916
+ blocked: resolved[index].blocked,
917
+ home: resolved[index].home,
918
+ });
919
+ // Subjects form a tree under their overlord; a bloc is grown as one entity first.
920
+ const children = new Map();
921
+ for (const [index, country] of resolved.entries())
922
+ if (country.overlord >= 0) {
923
+ const list = children.get(country.overlord) ?? [];
924
+ list.push(index);
925
+ children.set(country.overlord, list);
926
+ }
927
+ const subtree = (index) => [
928
+ index,
929
+ ...(children.get(index) ?? []).flatMap((child) => subtree(child)),
930
+ ];
931
+ const bloc = (index) => {
932
+ const members = subtree(index);
933
+ const blocked = new Set(resolved[index].blocked);
934
+ for (const atom of [...blocked])
935
+ if (!members.every((member) => resolved[member].blocked.has(atom)))
936
+ blocked.delete(atom);
937
+ return {
938
+ name: resolved[index].tag,
939
+ weight: members.reduce((sum, member) => sum + resolved[member].weight, 0),
940
+ seeds: members.flatMap((member) => resolved[member].seeds),
941
+ forced: members.flatMap((member) => resolved[member].forced),
942
+ blocked,
943
+ home: new Set(members.flatMap((member) => [...resolved[member].home])),
944
+ };
945
+ };
946
+ const origin = new Int32Array(n).fill(-1);
947
+ const feasible = new Float64Array(resolved.length).fill(-1);
948
+ const assignTree = (index, mask, label) => {
949
+ const direct = children.get(index) ?? [];
950
+ if (direct.length === 0) {
951
+ for (let atom = 0; atom < n; atom += 1)
952
+ if (mask[atom] === 1)
953
+ origin[atom] = index;
954
+ return;
955
+ }
956
+ const entities = [toEntity(index), ...direct.map((child) => bloc(child))];
957
+ const local = run.growLevel(entities, mask, signal, label);
958
+ const levelTargets = run.lastTargets;
959
+ feasible[index] = levelTargets[0];
960
+ for (const [position, child] of direct.entries())
961
+ if ((children.get(child) ?? []).length === 0)
962
+ feasible[child] = levelTargets[position + 1];
963
+ const own = new Uint8Array(n);
964
+ for (let atom = 0; atom < n; atom += 1)
965
+ if (local[atom] === 0)
966
+ own[atom] = 1;
967
+ for (let atom = 0; atom < n; atom += 1)
968
+ if (own[atom] === 1)
969
+ origin[atom] = index;
970
+ for (const [position, child] of direct.entries()) {
971
+ const sub = new Uint8Array(n);
972
+ for (let atom = 0; atom < n; atom += 1)
973
+ if (local[atom] === position + 1)
974
+ sub[atom] = 1;
975
+ assignTree(child, sub, `${label}>${resolved[child].tag}`);
976
+ }
977
+ };
978
+ const roots = resolved.flatMap((country, index) => (country.overlord < 0 ? [index] : []));
979
+ const everywhere = new Uint8Array(n).fill(1);
980
+ const top = run.growLevel(roots.map((root) => bloc(root)), everywhere, signal, 'the countries');
981
+ const topTargets = Float64Array.from(run.lastTargets);
982
+ for (const [position, root] of roots.entries()) {
983
+ const mask = new Uint8Array(n);
984
+ for (let atom = 0; atom < n; atom += 1)
985
+ if (top[atom] === position)
986
+ mask[atom] = 1;
987
+ if ((children.get(root) ?? []).length === 0)
988
+ feasible[root] = topTargets[position];
989
+ assignTree(root, mask, resolved[root].tag);
990
+ }
991
+ // A subject never holds atoms its blocked set forbids; merged territory is relabelled last.
992
+ const owner = Int32Array.from(origin);
993
+ for (let atom = 0; atom < n; atom += 1) {
994
+ const country = owner[atom];
995
+ if (country >= 0 && resolved[country].mergeInto >= 0)
996
+ owner[atom] = resolved[country].mergeInto;
997
+ }
998
+ const stats = describeTerritory(atoms, resolved, owner, origin, run, feasible);
999
+ return {
1000
+ tags: resolved.map(({ tag }) => tag),
1001
+ owner,
1002
+ origin,
1003
+ stats,
1004
+ notes: run.notes,
1005
+ };
1006
+ }
1007
+ function describeTerritory(atoms, countries, owner, origin, run, feasible) {
1008
+ const graph = atoms.graph;
1009
+ const n = atoms.count;
1010
+ const count = countries.length;
1011
+ const area = new Float64Array(count);
1012
+ const atomCount = new Int32Array(count);
1013
+ let assignedWeight = 0;
1014
+ let assigned = 0;
1015
+ let unowned = 0;
1016
+ for (let atom = 0; atom < n; atom += 1) {
1017
+ if (run.excluded[atom] === 1)
1018
+ continue;
1019
+ if (owner[atom] < 0) {
1020
+ unowned += 1;
1021
+ continue;
1022
+ }
1023
+ // Targets are by country as grown, so size is measured by origin before merging.
1024
+ const grown = origin[atom];
1025
+ area[grown] += graph.size[atom];
1026
+ atomCount[grown] += 1;
1027
+ assignedWeight += graph.size[atom];
1028
+ assigned += 1;
1029
+ }
1030
+ // Components over the final owner of each country's atoms.
1031
+ const landmass = landComponents(graph, Uint8Array.from(origin, (value) => (value >= 0 ? 1 : 0)));
1032
+ const landmasses = Array.from({ length: count }, () => new Set());
1033
+ const pieces = new Int32Array(count);
1034
+ const seen = new Uint8Array(n);
1035
+ for (let start = 0; start < n; start += 1) {
1036
+ const country = origin[start];
1037
+ if (country < 0 || seen[start] === 1)
1038
+ continue;
1039
+ pieces[country] += 1;
1040
+ seen[start] = 1;
1041
+ const stack = [start];
1042
+ while (stack.length > 0) {
1043
+ const atom = stack.pop();
1044
+ landmasses[country].add(landmass.component[atom]);
1045
+ for (let slot = graph.offsets[atom]; slot < graph.offsets[atom + 1]; slot += 1) {
1046
+ const next = graph.to[slot];
1047
+ if (origin[next] === country && seen[next] === 0) {
1048
+ seen[next] = 1;
1049
+ stack.push(next);
1050
+ }
1051
+ }
1052
+ }
1053
+ }
1054
+ let totalWeight = 0;
1055
+ for (const country of countries)
1056
+ totalWeight += country.weight;
1057
+ const stats = [];
1058
+ let maxShareError = 0;
1059
+ for (let index = 0; index < count; index += 1) {
1060
+ const requestedShare = countries[index].weight / totalWeight;
1061
+ const target = feasible[index] >= 0 ? feasible[index] : requestedShare * assignedWeight;
1062
+ const targetShare = assignedWeight === 0 ? requestedShare : target / assignedWeight;
1063
+ const share = assignedWeight === 0 ? 0 : area[index] / assignedWeight;
1064
+ const error = Math.abs(area[index] - target) / Math.max(1, target);
1065
+ maxShareError = Math.max(maxShareError, error);
1066
+ stats.push({
1067
+ tag: countries[index].tag,
1068
+ atoms: atomCount[index],
1069
+ weight: area[index],
1070
+ targetWeight: round(target, 1),
1071
+ share: round(share),
1072
+ targetShare: round(targetShare),
1073
+ requestedShare: round(requestedShare),
1074
+ shareError: round(error),
1075
+ landmasses: landmasses[index].size,
1076
+ pieces: pieces[index],
1077
+ });
1078
+ }
1079
+ let borderLength = 0;
1080
+ let naturalLength = 0;
1081
+ for (let a = 0; a < n; a += 1) {
1082
+ if (owner[a] < 0)
1083
+ continue;
1084
+ for (let slot = graph.offsets[a]; slot < graph.offsets[a + 1]; slot += 1) {
1085
+ const b = graph.to[slot];
1086
+ if (b < a || owner[b] < 0 || owner[b] === owner[a])
1087
+ continue;
1088
+ borderLength += graph.length[slot];
1089
+ naturalLength += atoms.credit[slot];
1090
+ }
1091
+ }
1092
+ return {
1093
+ countries: stats,
1094
+ assignedAtoms: assigned,
1095
+ unownedAtoms: unowned,
1096
+ maxShareError: round(maxShareError),
1097
+ borderLength,
1098
+ naturalLength: round(naturalLength, 1),
1099
+ naturalShare: borderLength === 0 ? null : round(naturalLength / borderLength),
1100
+ rounds: run.rounds,
1101
+ smoothingMoves: run.smoothingMoves,
1102
+ corridors: run.corridors,
1103
+ };
1104
+ }
1105
+ function round(value, places = 4) {
1106
+ const scale = 10 ** places;
1107
+ return Math.round(value * scale) / scale;
1108
+ }