hoi4-agent-tools 4.6.0 → 4.6.2
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 +13 -0
- package/dist/hoi4_agent_tools/map/partition-provinces.js +16 -3
- package/dist/hoi4_agent_tools/map/partition.js +2 -1
- package/dist/hoi4_agent_tools/map/relief/macro-relief.js +107 -0
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +21 -12
- package/dist/hoi4_agent_tools/map/tool-operations.js +10 -2
- package/dist/hoi4_agent_tools/map/world-realms.js +139 -0
- package/dist/hoi4_agent_tools/map/world.js +72 -21
- package/dist/hoi4_agent_tools/reference/known-mistakes.js +3 -0
- package/dist/hoi4_agent_tools/schemas/map.js +1 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/map.md +5 -1
- package/npm-shrinkwrap.json +38 -32
- package/package.json +4 -4
- 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/geography-model.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 +2 -2
- 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/reference-sheets.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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.6.2 - 2026-10-31
|
|
4
|
+
|
|
5
|
+
- Check the points of workspace countries together and give each capital a province (ADR 0073). `create_world` now reports every capital or seed that lies outside the world or in water (with the nearest land), in the unowned area, an own exclusion or another realm's claim, or within 10 pixels of a point of another realm, in one error (`MAP_WORLD_REALM_POINTS_INVALID`, `details.problems`; a single problem keeps its own code), before the relief synthesis starts. `create_world.synthesisCheck` runs the same check and lists the points beside the failing rivers (`data.synthesisCheck.realmPoints`). In the feature-aware partition every capital and seed is a province seed of its own, so capitals that are 20 to 40 pixels apart no longer fail with `MAP_WORLD_REALM_POINT_CONFLICT`. Feature-aware worlds with workspace countries get a slightly different province partition; other worlds are unchanged.
|
|
6
|
+
|
|
7
|
+
## Unreleased
|
|
8
|
+
|
|
9
|
+
- Raise the pinned overrides of three transitive dependencies to their fixed releases, clearing every `npm audit --omit=dev` advisory: `fast-uri` 3.1.9 (GHSA-w74x-v6f9-j3xg, GHSA-xhq7-xmqj-769h, GHSA-wjr9-j795-jxcr, GHSA-333x-w4r6-6crp), `hono` 4.13.13 (GHSA-5r4p-p66f-jhc7) and `ip-address` 10.7.4 (GHSA-rxp7-6hxx-x8q5). No source or schema change.
|
|
10
|
+
|
|
11
|
+
## 4.6.1 - 2026-10-26
|
|
12
|
+
|
|
13
|
+
- Stop flagging `constant:<category>.<key>` script-constant reads as scoped temporary-variable reads (`KNOWN_MISTAKE_TEMP_VARIABLE_SCOPE_PREFIX`).
|
|
14
|
+
- Make declared passes saddles whatever else stands there (ADR 0072). After the macro relief is composed, the heights in a pass's corridor are capped at the pass's saddle level, so a volcano cone, plateau or neighbouring feature that overlaps a pass no longer fills the gap and no longer fails the world with `MAP_PASS_NOT_LOWER`. Another range's body is left alone. `create_world.synthesisCheck` now also reports `MAP_PASS_NOT_LOWER`, so a preview catches what the write would block on. Models whose passes were refused now write; worlds with no overlapping feature on a pass are unchanged.
|
|
15
|
+
|
|
3
16
|
## 4.6.0 - 2026-10-25
|
|
4
17
|
|
|
5
18
|
- Add the territory assignment service (ADR 0060). `assign_territory` grows up to 512 countries over the states or provinces of a map from capital and anchor seeds (state, province or pixel) with forced and blocked land, preferred strategic regions, subjects inside an overlord, merges, island policies, size targets by weight, extra cores and claims, and writes owner, controller, cores and claims. Borders follow rivers, ranges, lake shores and terrain changes (a shortest-path growth with barrier-weighted steps, size balancing, a corridor for cut-off seeds and a local search that shortens the artificial border); the result reports sizes against targets and the same seeds grown on distance alone. With `unit: "province"` the states a border cuts are regrouped in one pass, conserving population, resources and buildings exactly. `repartition_states` regroups the states of chosen owners along natural lines, and `update_states` edits up to 5,000 states in one rewrite. New diagnostics `MAP_TERRITORY_*`.
|
|
@@ -653,17 +653,30 @@ export function partitionProvinces(input) {
|
|
|
653
653
|
seeded[pixel] = 1;
|
|
654
654
|
}
|
|
655
655
|
};
|
|
656
|
-
|
|
656
|
+
const landMinArea = Math.max(tuning.minProvincePixels, plan.minLand * 0.85);
|
|
657
|
+
const pins = (input.pins ?? []).filter((pixel) => kind[pixel] === LAND);
|
|
658
|
+
addSeeds(Int32Array.from(pins));
|
|
659
|
+
// Land seeds within a province radius of a pin would cut the pinned province in two.
|
|
660
|
+
const pinRadius = Math.max(4, 0.6 * Math.sqrt(landMinArea));
|
|
661
|
+
const pinPoints = pins.map((pixel) => [pixel % width, Math.floor(pixel / width)]);
|
|
662
|
+
const landSeeds = placeSeeds({
|
|
657
663
|
width,
|
|
658
664
|
height,
|
|
659
665
|
kind,
|
|
660
666
|
wanted: LAND,
|
|
661
667
|
areaAt: plan.land,
|
|
662
|
-
minArea:
|
|
668
|
+
minArea: landMinArea,
|
|
663
669
|
barrier,
|
|
664
670
|
seed: input.seed,
|
|
665
671
|
salt: 17,
|
|
666
|
-
})
|
|
672
|
+
});
|
|
673
|
+
addSeeds(pins.length === 0
|
|
674
|
+
? landSeeds
|
|
675
|
+
: landSeeds.filter((pixel) => {
|
|
676
|
+
const x = pixel % width;
|
|
677
|
+
const y = Math.floor(pixel / width);
|
|
678
|
+
return !pinPoints.some(([px, py]) => Math.hypot(px - x, py - y) < pinRadius);
|
|
679
|
+
}));
|
|
667
680
|
addSeeds(placeSeeds({
|
|
668
681
|
width,
|
|
669
682
|
height,
|
|
@@ -85,10 +85,11 @@ export function partitionWorld(input) {
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
/** Resolve tuning against a request and partition in one call. */
|
|
88
|
-
export function partitionWorldFromRequest(fields, seed, tuning, base, signal) {
|
|
88
|
+
export function partitionWorldFromRequest(fields, seed, tuning, base, signal, pins) {
|
|
89
89
|
return partitionWorld({
|
|
90
90
|
fields,
|
|
91
91
|
seed,
|
|
92
|
+
pins,
|
|
92
93
|
signal,
|
|
93
94
|
tuning: resolvePartitionTuning(tuning, {
|
|
94
95
|
...base,
|
|
@@ -478,6 +478,112 @@ function applyVolcano(volcano, base, kind, mw, mh, scale, seed, targets) {
|
|
|
478
478
|
}
|
|
479
479
|
}
|
|
480
480
|
// ---------------------------------------------------------------------------------------------
|
|
481
|
+
/**
|
|
482
|
+
* A declared pass is authoritative over the other relief (ADR 0072). The saddle of a range's
|
|
483
|
+
* envelope is lowered by the range alone; a volcano cone, a plateau or a neighbouring range that
|
|
484
|
+
* overlaps the pass adds height back and the gap disappears. After everything is composed, the
|
|
485
|
+
* heights along the pass corridor are capped at the saddle level (base plus crest times the
|
|
486
|
+
* envelope), with a soft edge across the corridor, so the pass is lower than the crest on both
|
|
487
|
+
* sides wherever it lies.
|
|
488
|
+
*/
|
|
489
|
+
function carvePassSaddles(geography, tables, heights, kind, mw, mh) {
|
|
490
|
+
const aspect = mh / mw;
|
|
491
|
+
// The line of every range in cell units, one point per table entry, with its footprint.
|
|
492
|
+
const lines = new Map();
|
|
493
|
+
for (const range of geography.ranges) {
|
|
494
|
+
const table = tables.find((candidate) => candidate.id === range.id);
|
|
495
|
+
if (table === undefined)
|
|
496
|
+
continue;
|
|
497
|
+
const x = new Float64Array(table.count);
|
|
498
|
+
const y = new Float64Array(table.count);
|
|
499
|
+
let widest = 0;
|
|
500
|
+
for (let j = 0; j < table.count; j += 1) {
|
|
501
|
+
const point = sampleAlong(range.path, geography.frame, j / (table.count - 1));
|
|
502
|
+
x[j] = point.x * mw;
|
|
503
|
+
y[j] = point.y * mh;
|
|
504
|
+
widest = Math.max(widest, table.halfWidth[j]);
|
|
505
|
+
}
|
|
506
|
+
lines.set(range.id, { x, y, reach: Math.max(2, widest * mw) });
|
|
507
|
+
}
|
|
508
|
+
for (const range of geography.ranges) {
|
|
509
|
+
const table = tables.find((candidate) => candidate.id === range.id);
|
|
510
|
+
const own = lines.get(range.id);
|
|
511
|
+
if (table === undefined || own === undefined)
|
|
512
|
+
continue;
|
|
513
|
+
const lineX = own.x;
|
|
514
|
+
const lineY = own.y;
|
|
515
|
+
for (const entry of table.passes) {
|
|
516
|
+
if (entry.depth <= 0)
|
|
517
|
+
continue;
|
|
518
|
+
const k = Math.min(table.count - 1, Math.max(0, Math.round(entry.at * (table.count - 1))));
|
|
519
|
+
// Half-width of the corridor in cells: the range's footprint, at least three cells, plus a
|
|
520
|
+
// soft edge of three more.
|
|
521
|
+
const corridor = Math.max(2, table.halfWidth[k] * mw);
|
|
522
|
+
const edge = 2;
|
|
523
|
+
const entries = Math.ceil((entry.halfLength / table.length) * (table.count - 1));
|
|
524
|
+
const first = Math.max(0, k - entries);
|
|
525
|
+
const last = Math.min(table.count - 1, k + entries);
|
|
526
|
+
let minX = Infinity;
|
|
527
|
+
let maxX = -Infinity;
|
|
528
|
+
let minY = Infinity;
|
|
529
|
+
let maxY = -Infinity;
|
|
530
|
+
for (let j = first; j <= last; j += 1) {
|
|
531
|
+
minX = Math.min(minX, lineX[j]);
|
|
532
|
+
maxX = Math.max(maxX, lineX[j]);
|
|
533
|
+
minY = Math.min(minY, lineY[j]);
|
|
534
|
+
maxY = Math.max(maxY, lineY[j]);
|
|
535
|
+
}
|
|
536
|
+
const reach = Math.ceil(corridor + edge) + 1;
|
|
537
|
+
const x0 = Math.max(0, Math.floor(minX) - reach);
|
|
538
|
+
const x1 = Math.min(mw - 1, Math.ceil(maxX) + reach);
|
|
539
|
+
const y0 = Math.max(0, Math.floor(minY) - reach);
|
|
540
|
+
const y1 = Math.min(mh - 1, Math.ceil(maxY) + reach);
|
|
541
|
+
const baseLevel = table.base[k];
|
|
542
|
+
const crestLevel = table.crest[k];
|
|
543
|
+
for (let y = y0; y <= y1; y += 1)
|
|
544
|
+
for (let x = x0; x <= x1; x += 1) {
|
|
545
|
+
const cell = y * mw + x;
|
|
546
|
+
if (kind[cell] !== LAND)
|
|
547
|
+
continue;
|
|
548
|
+
// The nearest point of the range line, which must lie inside the pass window.
|
|
549
|
+
let nearest = -1;
|
|
550
|
+
let nearestDistance = Infinity;
|
|
551
|
+
for (let j = 0; j < table.count; j += 1) {
|
|
552
|
+
const distance = Math.hypot(lineX[j] - (x + 0.5), lineY[j] - (y + 0.5));
|
|
553
|
+
if (distance < nearestDistance) {
|
|
554
|
+
nearestDistance = distance;
|
|
555
|
+
nearest = j;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (nearest < first || nearest > last || nearestDistance > corridor + edge)
|
|
559
|
+
continue;
|
|
560
|
+
// A cell on the body of another range belongs to that range, not to this pass.
|
|
561
|
+
let onOther = false;
|
|
562
|
+
for (const [id, other] of lines) {
|
|
563
|
+
if (id === range.id)
|
|
564
|
+
continue;
|
|
565
|
+
for (let j = 0; j < other.x.length && !onOther; j += 1)
|
|
566
|
+
if (Math.hypot(other.x[j] - (x + 0.5), other.y[j] - (y + 0.5)) <= other.reach)
|
|
567
|
+
onOther = true;
|
|
568
|
+
if (onOther)
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
if (onOther)
|
|
572
|
+
continue;
|
|
573
|
+
const along = ((nearest - k) / (table.count - 1)) * table.length;
|
|
574
|
+
const s = (along * along) / (entry.halfLength * entry.halfLength);
|
|
575
|
+
if (s >= 1)
|
|
576
|
+
continue;
|
|
577
|
+
const bump = (1 - s) * (1 - s);
|
|
578
|
+
const cap = baseLevel + (crestLevel - baseLevel) * (1 - entry.depth * bump);
|
|
579
|
+
const weight = nearestDistance <= corridor ? 1 : 1 - smoothstep((nearestDistance - corridor) / edge);
|
|
580
|
+
if (cap < heights[cell])
|
|
581
|
+
heights[cell] = heights[cell] + (cap - heights[cell]) * weight;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
void aspect;
|
|
586
|
+
}
|
|
481
587
|
/** Build the macro relief of the model. */
|
|
482
588
|
export function synthesizeMacroRelief(input) {
|
|
483
589
|
const { geography, width, height, seed } = input;
|
|
@@ -519,6 +625,7 @@ export function synthesizeMacroRelief(input) {
|
|
|
519
625
|
heights[index] = Math.min(HEIGHT_CEILING, heights[index] + targets.delta[index]);
|
|
520
626
|
footprint[index] = Math.max(footprint[index], targets.footprint[index]);
|
|
521
627
|
}
|
|
628
|
+
carvePassSaddles(geography, ranges, heights, kind, mw, mh);
|
|
522
629
|
return {
|
|
523
630
|
width: mw,
|
|
524
631
|
height: mh,
|
|
@@ -115,18 +115,27 @@ export function checkTerrainRivers(input) {
|
|
|
115
115
|
if (macro.kind[cell] === LAND)
|
|
116
116
|
macro.heights[cell] = adjust(2 * x + 1, 2 * y + 1, macro.heights[cell]);
|
|
117
117
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
118
|
+
const hydrology = synthesizeHydrology({
|
|
119
|
+
geography,
|
|
120
|
+
macro,
|
|
121
|
+
seed,
|
|
122
|
+
density: input.rivers?.density,
|
|
123
|
+
count: input.rivers?.count,
|
|
124
|
+
rainfall: input.rainfall,
|
|
125
|
+
landPixels,
|
|
126
|
+
signal: input.signal,
|
|
127
|
+
});
|
|
128
|
+
const notes = [...hydrology.notes];
|
|
129
|
+
// The pass test of the write, on the macro grid: a declared pass must be a saddle lower than
|
|
130
|
+
// the crest on both sides. Only its error is reported, so a preview never blocks on a warning
|
|
131
|
+
// the full-resolution write would not raise.
|
|
132
|
+
const quantised = new Uint8Array(macro.width * macro.height);
|
|
133
|
+
for (let index = 0; index < quantised.length; index += 1)
|
|
134
|
+
quantised[index] = Math.max(0, Math.min(255, Math.round(hydrology.heights[index])));
|
|
135
|
+
for (const note of checkRangesAndFeatures(geography, macro.ranges, { width: macro.width, height: macro.height, kind: macro.kind, heights: quantised }, (peak) => peakHeight(peak, macro.scale), (peak) => peakHeight(peak, macro.scale)))
|
|
136
|
+
if (note.code === 'MAP_PASS_NOT_LOWER')
|
|
137
|
+
notes.push(note);
|
|
138
|
+
return notes;
|
|
130
139
|
}
|
|
131
140
|
/** Build relief, hydrology, normals and fields for a geography model on a map of the given size. */
|
|
132
141
|
export function synthesizeTerrain(input) {
|
|
@@ -521,8 +521,14 @@ async function previewSynthesisCheck(nudger, input, context) {
|
|
|
521
521
|
});
|
|
522
522
|
const blocked = checked.diagnostics.filter(({ severity }) => severity === 'error' || severity === 'blocker');
|
|
523
523
|
const valid = blocked.length === 0 && checked.plan.blockers.length === 0;
|
|
524
|
-
|
|
525
|
-
|
|
524
|
+
// Failures of the synthesis can sit inside a realm-point error that reports both (ADR 0073).
|
|
525
|
+
const synthesisDetails = (details) => details?.synthesis?.details ?? details;
|
|
526
|
+
const rivers = checked.diagnostics.flatMap(({ details }) => {
|
|
527
|
+
const found = synthesisDetails(details)?.rivers;
|
|
528
|
+
return Array.isArray(found) ? found : [];
|
|
529
|
+
});
|
|
530
|
+
const realmPoints = checked.diagnostics.flatMap(({ code, details }) => code.startsWith('MAP_WORLD_REALM_POINT') && Array.isArray(details?.problems)
|
|
531
|
+
? details.problems
|
|
526
532
|
: []);
|
|
527
533
|
// Drawn rivers are authoritative: crossings of relief they cut through are warnings (ADR 0071).
|
|
528
534
|
const carved = checked.diagnostics.flatMap(({ code, details }) => code === 'MAP_RIVER_CARVED_CROSSING' && details !== undefined ? [details] : []);
|
|
@@ -546,6 +552,8 @@ async function previewSynthesisCheck(nudger, input, context) {
|
|
|
546
552
|
rivers: rivers.slice(0, 200),
|
|
547
553
|
carvedCrossings: carved.length,
|
|
548
554
|
crossings: carved.slice(0, 200),
|
|
555
|
+
realmPointProblems: realmPoints.length,
|
|
556
|
+
realmPoints: realmPoints.slice(0, 500),
|
|
549
557
|
},
|
|
550
558
|
});
|
|
551
559
|
result.status = valid ? 'ok' : 'blocked';
|
|
@@ -51,6 +51,145 @@ function pointProvince(tag, role, point, raster) {
|
|
|
51
51
|
});
|
|
52
52
|
return province;
|
|
53
53
|
}
|
|
54
|
+
/** Two points of different realms closer than this many pixels cannot get a province each. */
|
|
55
|
+
export const REALM_POINT_MIN_SEPARATION_PX = 10;
|
|
56
|
+
/** How far from a point in water the nearest land is searched, in pixels. */
|
|
57
|
+
const NEAREST_LAND_REACH_PX = 80;
|
|
58
|
+
/**
|
|
59
|
+
* Check every capital and seed against the generated surface before any province exists, and
|
|
60
|
+
* return all problems at once: a point outside the frame or in water (with the nearest land), a
|
|
61
|
+
* point inside the unowned area, its own exclusion or another realm's claim, and points of
|
|
62
|
+
* different realms closer than `REALM_POINT_MIN_SEPARATION_PX`. Points of one realm may share a
|
|
63
|
+
* province; points of different realms each get their own province (ADR 0073).
|
|
64
|
+
*/
|
|
65
|
+
export function checkRealmPoints(realms, raster) {
|
|
66
|
+
const problems = [];
|
|
67
|
+
const points = [];
|
|
68
|
+
for (const [index, realm] of realms.list.entries()) {
|
|
69
|
+
points.push({ tag: realm.tag, role: 'capital', point: realm.capital, index });
|
|
70
|
+
for (const seed of realm.seeds ?? [])
|
|
71
|
+
points.push({ tag: realm.tag, role: 'seed', point: seed, index });
|
|
72
|
+
}
|
|
73
|
+
const nearestLand = (point) => {
|
|
74
|
+
const cx = Math.floor(point.x);
|
|
75
|
+
const cy = Math.floor(point.y);
|
|
76
|
+
let best;
|
|
77
|
+
for (let radius = 1; radius <= NEAREST_LAND_REACH_PX; radius += 1) {
|
|
78
|
+
for (let dy = -radius; dy <= radius; dy += 1)
|
|
79
|
+
for (let dx = -radius; dx <= radius; dx += 1) {
|
|
80
|
+
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius)
|
|
81
|
+
continue;
|
|
82
|
+
const x = cx + dx;
|
|
83
|
+
const y = cy + dy;
|
|
84
|
+
if (x < 0 || y < 0 || x >= raster.width || y >= raster.height)
|
|
85
|
+
continue;
|
|
86
|
+
if (!raster.land(y * raster.width + x))
|
|
87
|
+
continue;
|
|
88
|
+
const distance = Math.hypot(dx, dy);
|
|
89
|
+
if (best === undefined || distance < best.distance)
|
|
90
|
+
best = { x, y, distance };
|
|
91
|
+
}
|
|
92
|
+
if (best !== undefined && best.distance <= radius)
|
|
93
|
+
return best;
|
|
94
|
+
}
|
|
95
|
+
return best;
|
|
96
|
+
};
|
|
97
|
+
for (const { tag, role, point, index } of points) {
|
|
98
|
+
const x = Math.floor(point.x);
|
|
99
|
+
const y = Math.floor(point.y);
|
|
100
|
+
if (x < 0 || y < 0 || x >= raster.width || y >= raster.height) {
|
|
101
|
+
problems.push({
|
|
102
|
+
code: 'MAP_WORLD_REALM_POINT_OUTSIDE',
|
|
103
|
+
tag,
|
|
104
|
+
role,
|
|
105
|
+
point,
|
|
106
|
+
message: `The ${role} of ${tag} lies outside the ${raster.width}x${raster.height} world`,
|
|
107
|
+
});
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!raster.land(y * raster.width + x)) {
|
|
111
|
+
const near = nearestLand(point);
|
|
112
|
+
problems.push({
|
|
113
|
+
code: 'MAP_WORLD_REALM_POINT_WATER',
|
|
114
|
+
tag,
|
|
115
|
+
role,
|
|
116
|
+
point,
|
|
117
|
+
message: `The ${role} of ${tag} at (${x}, ${y}) lies in water${near === undefined
|
|
118
|
+
? ''
|
|
119
|
+
: `; the nearest land is (${near.x}, ${near.y}), ${Math.round(near.distance)} px away`}`,
|
|
120
|
+
...(near === undefined ? {} : { nearestLand: near }),
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const px = x + 0.5;
|
|
125
|
+
const py = y + 0.5;
|
|
126
|
+
const realm = realms.list[index];
|
|
127
|
+
if (realms.unowned?.some((polygon) => inside(px, py, polygon)) === true)
|
|
128
|
+
problems.push({
|
|
129
|
+
code: 'MAP_WORLD_REALM_POINT_CONFLICT',
|
|
130
|
+
tag,
|
|
131
|
+
role,
|
|
132
|
+
point,
|
|
133
|
+
message: `The ${role} of ${tag} at (${x}, ${y}) lies in the unowned area`,
|
|
134
|
+
});
|
|
135
|
+
else if (realm.exclude?.some((polygon) => inside(px, py, polygon)) === true)
|
|
136
|
+
problems.push({
|
|
137
|
+
code: 'MAP_WORLD_REALM_POINT_CONFLICT',
|
|
138
|
+
tag,
|
|
139
|
+
role,
|
|
140
|
+
point,
|
|
141
|
+
message: `The ${role} of ${tag} at (${x}, ${y}) lies in its own exclusion`,
|
|
142
|
+
});
|
|
143
|
+
else
|
|
144
|
+
for (const [otherIndex, other] of realms.list.entries()) {
|
|
145
|
+
if (otherIndex === index)
|
|
146
|
+
continue;
|
|
147
|
+
if (other.claim?.some((polygon) => inside(px, py, polygon)) === true) {
|
|
148
|
+
problems.push({
|
|
149
|
+
code: 'MAP_WORLD_REALM_POINT_CONFLICT',
|
|
150
|
+
tag,
|
|
151
|
+
role,
|
|
152
|
+
point,
|
|
153
|
+
otherTag: other.tag,
|
|
154
|
+
message: `The ${role} of ${tag} at (${x}, ${y}) lies in a claim of ${other.tag}`,
|
|
155
|
+
});
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Points of different realms that are too close to each have a province of their own.
|
|
161
|
+
for (let a = 0; a < points.length; a += 1)
|
|
162
|
+
for (let b = a + 1; b < points.length; b += 1) {
|
|
163
|
+
const left = points[a];
|
|
164
|
+
const right = points[b];
|
|
165
|
+
if (left.index === right.index)
|
|
166
|
+
continue;
|
|
167
|
+
const distance = Math.hypot(left.point.x - right.point.x, left.point.y - right.point.y);
|
|
168
|
+
if (distance >= REALM_POINT_MIN_SEPARATION_PX)
|
|
169
|
+
continue;
|
|
170
|
+
problems.push({
|
|
171
|
+
code: 'MAP_WORLD_REALM_POINTS_TOO_CLOSE',
|
|
172
|
+
tag: left.tag,
|
|
173
|
+
role: left.role,
|
|
174
|
+
point: left.point,
|
|
175
|
+
otherTag: right.tag,
|
|
176
|
+
otherPoint: right.point,
|
|
177
|
+
distance: Math.round(distance * 10) / 10,
|
|
178
|
+
message: `The ${left.role} of ${left.tag} and the ${right.role} of ${right.tag} are ${Math.round(distance * 10) / 10} px apart; points of different realms need ${REALM_POINT_MIN_SEPARATION_PX} px to get a province each`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return problems;
|
|
182
|
+
}
|
|
183
|
+
/** One error for all point problems; the first one's code, every problem in `details.problems`. */
|
|
184
|
+
export function realmPointsError(problems) {
|
|
185
|
+
const first = problems[0];
|
|
186
|
+
return new ServiceError(problems.length === 1 ? first.code : 'MAP_WORLD_REALM_POINTS_INVALID', problems.length === 1
|
|
187
|
+
? first.message
|
|
188
|
+
: `${problems.length} realm points cannot be placed: ${problems
|
|
189
|
+
.slice(0, 6)
|
|
190
|
+
.map(({ message }) => message)
|
|
191
|
+
.join('; ')}${problems.length > 6 ? '; ...' : ''}`, { problems: problems.slice(0, 500), count: problems.length });
|
|
192
|
+
}
|
|
54
193
|
export function resolveRealmRules(realms, raster) {
|
|
55
194
|
const unowned = realms.unowned === undefined ? new Set() : coveredProvinces(realms.unowned, raster);
|
|
56
195
|
const excluded = realms.list.map((realm) => realm.exclude === undefined ? new Set() : coveredProvinces(realm.exclude, raster));
|
|
@@ -25,7 +25,7 @@ import { ambientObjectText, applyEdgePolicy, assertFeaturesOutsideBand, edgeWate
|
|
|
25
25
|
import { landDistanceField, lakeHeight, normalizeLakes, waterColourLevel } from './water.js';
|
|
26
26
|
import { overlayGeographyLakes, rasterizeGeographyKind } from './relief/land.js';
|
|
27
27
|
import { checkTerrainRivers, synthesizeTerrain, } from './relief/terrain-synthesis.js';
|
|
28
|
-
import { assignUnreachedRealmLand, resolveRealmRules } from './world-realms.js';
|
|
28
|
+
import { assignUnreachedRealmLand, checkRealmPoints, realmPointsError, resolveRealmRules, } from './world-realms.js';
|
|
29
29
|
// ---------------------------------------------------------------------------------------------
|
|
30
30
|
// Deterministic randomness and noise
|
|
31
31
|
export class Random {
|
|
@@ -445,6 +445,26 @@ function weatherBlock(signedLatitude, desert) {
|
|
|
445
445
|
}
|
|
446
446
|
return `\tweather={\n${periods.join('\n')}\n\t}`;
|
|
447
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* The land pixels that each get a province of their own: every capital and seed. A point of the
|
|
450
|
+
* same realm within a few pixels of an earlier pin shares its province.
|
|
451
|
+
*/
|
|
452
|
+
function realmPins(realms, width, height, kind) {
|
|
453
|
+
if (realms === undefined)
|
|
454
|
+
return undefined;
|
|
455
|
+
const pins = [];
|
|
456
|
+
for (const [index, realm] of realms.list.entries())
|
|
457
|
+
for (const point of [realm.capital, ...(realm.seeds ?? [])]) {
|
|
458
|
+
const x = Math.floor(point.x);
|
|
459
|
+
const y = Math.floor(point.y);
|
|
460
|
+
if (x < 0 || y < 0 || x >= width || y >= height || kind[y * width + x] !== LAND)
|
|
461
|
+
continue;
|
|
462
|
+
if (pins.some((pin) => pin.realm === index && Math.hypot(pin.x - x, pin.y - y) < 6))
|
|
463
|
+
continue;
|
|
464
|
+
pins.push({ pixel: y * width + x, x, y, realm: index });
|
|
465
|
+
}
|
|
466
|
+
return pins.map(({ pixel }) => pixel);
|
|
467
|
+
}
|
|
448
468
|
export function generateWorld(spec, context, options = {}) {
|
|
449
469
|
const { width, height } = spec;
|
|
450
470
|
if (width % 256 !== 0 || height % 256 !== 0)
|
|
@@ -513,30 +533,61 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
513
533
|
let seamRepaired = geography !== undefined ? repairSeamCorners(surface.kind, width, height) : [];
|
|
514
534
|
// A geography model gets its relief and rivers from the synthesis of ADR 0056; every other world
|
|
515
535
|
// keeps the original elevation classes and drainage, byte for byte.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
536
|
+
// Every capital and seed is checked against the generated surface before anything else runs, and
|
|
537
|
+
// all problems are reported together (ADR 0073).
|
|
538
|
+
const pointProblems = spec.realms === undefined
|
|
539
|
+
? []
|
|
540
|
+
: checkRealmPoints(spec.realms, {
|
|
519
541
|
width,
|
|
520
542
|
height,
|
|
521
|
-
|
|
522
|
-
kind: surface.kind,
|
|
523
|
-
relief: spec.relief,
|
|
524
|
-
rivers: { density: spec.riverDensity, count: spec.riverCount },
|
|
525
|
-
adjustHeight: edge.mode === 'rim'
|
|
526
|
-
? (x, _y, value) => rimHeight(value, rimStrength(edge, width, x))
|
|
527
|
-
: undefined,
|
|
528
|
-
signal: options.signal,
|
|
543
|
+
land: (pixel) => surface.kind[pixel] === LAND,
|
|
529
544
|
});
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
545
|
+
if (pointProblems.length > 0 && options.riverCheck !== true)
|
|
546
|
+
throw realmPointsError(pointProblems);
|
|
547
|
+
if (options.riverCheck === true && geography !== undefined) {
|
|
548
|
+
let failure;
|
|
549
|
+
try {
|
|
550
|
+
const checked = checkTerrainRivers({
|
|
551
|
+
geography,
|
|
552
|
+
width,
|
|
553
|
+
height,
|
|
554
|
+
seed: spec.seed,
|
|
555
|
+
kind: surface.kind,
|
|
556
|
+
relief: spec.relief,
|
|
557
|
+
rivers: { density: spec.riverDensity, count: spec.riverCount },
|
|
558
|
+
adjustHeight: edge.mode === 'rim'
|
|
559
|
+
? (x, _y, value) => rimHeight(value, rimStrength(edge, width, x))
|
|
560
|
+
: undefined,
|
|
561
|
+
signal: options.signal,
|
|
538
562
|
});
|
|
563
|
+
for (const note of checked) {
|
|
564
|
+
if (note.severity === 'error')
|
|
565
|
+
throw new ServiceError(note.code, note.message, note.details);
|
|
566
|
+
notes.push({
|
|
567
|
+
code: note.code,
|
|
568
|
+
severity: note.severity,
|
|
569
|
+
message: note.message,
|
|
570
|
+
...(note.details === undefined ? {} : { details: note.details }),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
if (!(error instanceof ServiceError))
|
|
576
|
+
throw error;
|
|
577
|
+
failure = error;
|
|
578
|
+
}
|
|
579
|
+
// The preview reports the realm points and the synthesis together.
|
|
580
|
+
if (pointProblems.length > 0) {
|
|
581
|
+
const pointError = realmPointsError(pointProblems);
|
|
582
|
+
throw failure === undefined
|
|
583
|
+
? pointError
|
|
584
|
+
: new ServiceError(pointError.code, `${pointError.message}. The relief synthesis also failed: ${failure.message}`, {
|
|
585
|
+
...pointError.details,
|
|
586
|
+
synthesis: { code: failure.code, message: failure.message, details: failure.details },
|
|
587
|
+
});
|
|
539
588
|
}
|
|
589
|
+
if (failure !== undefined)
|
|
590
|
+
throw failure;
|
|
540
591
|
return {
|
|
541
592
|
files: [],
|
|
542
593
|
warnings: [],
|
|
@@ -676,7 +727,7 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
676
727
|
provincesPerState: spec.provincesPerState,
|
|
677
728
|
landProvincesPerRegion: spec.landProvincesPerRegion,
|
|
678
729
|
seaProvincesPerRegion: spec.seaProvincesPerRegion,
|
|
679
|
-
}, options.signal);
|
|
730
|
+
}, options.signal, realmPins(spec.realms, width, height, surface.kind));
|
|
680
731
|
provinces = featurePartition.provinces;
|
|
681
732
|
for (const note of featurePartition.notes)
|
|
682
733
|
notes.push(note);
|
|
@@ -256,6 +256,9 @@ const detectors = {
|
|
|
256
256
|
const findings = [];
|
|
257
257
|
for (const { node, token } of names) {
|
|
258
258
|
const name = token.slice(token.lastIndexOf('.') + 1);
|
|
259
|
+
// `constant:category.key` reads a script constant, not a scoped variable.
|
|
260
|
+
if (token.startsWith('constant:'))
|
|
261
|
+
continue;
|
|
259
262
|
if (!temporary.has(name) || regular.has(name))
|
|
260
263
|
continue;
|
|
261
264
|
if (!/^(?:[A-Za-z_][\w]*|[a-z_]+:[\w]+)(?:\.[A-Za-z_][\w]*)+$/u.test(token))
|
|
@@ -30,7 +30,7 @@ const realmPolygonsSchema = z.array(realmPolygonSchema).min(1).max(64);
|
|
|
30
30
|
export const workspaceRealmSchema = z
|
|
31
31
|
.object({
|
|
32
32
|
tag: countryTagSchema,
|
|
33
|
-
capital: pointSchema.describe('World pixel, top-left origin, on land; the capital province.'),
|
|
33
|
+
capital: pointSchema.describe('World pixel, top-left origin, on land; the capital province. Capitals and seeds of different realms must be at least 10 pixels apart; each gets a province of its own.'),
|
|
34
34
|
seeds: z
|
|
35
35
|
.array(pointSchema)
|
|
36
36
|
.max(256)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
export const PACKAGE_NAME = 'hoi4-agent-tools';
|
|
3
|
-
export const PACKAGE_VERSION = '4.6.
|
|
3
|
+
export const PACKAGE_VERSION = '4.6.2';
|
|
4
4
|
export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
|
|
5
5
|
export const CONFIG_VERSION = 1;
|
|
6
6
|
export const TRANSACTION_VERSION = 2;
|
package/docs/map.md
CHANGED
|
@@ -541,6 +541,8 @@ A mod that writes its own country tags, country files, histories, orders of batt
|
|
|
541
541
|
|
|
542
542
|
Points and polygon vertices are world pixels with the origin at the top left. A capital or seed names the land province under it; a land province with more than half its pixels inside a claim belongs to that country, inside an exclusion never does, and inside `unowned` belongs to nobody. Countries grow from their capitals, seeds and claims by `weight` (default 1). Land that growth does not reach goes, on a landmass a country already holds, to the nearest country that may own it; a landmass no country starts on follows `islands`: `nearest` (each province to the nearest capital), `one-owner` (the whole landmass to the country nearest its centre) or `unowned`. At most 512 countries.
|
|
543
543
|
|
|
544
|
+
Every capital and seed is checked against the generated coast before any province is built, and all problems are reported together (`MAP_WORLD_REALM_POINTS_INVALID`, `details.problems`; one problem keeps its own code): a point outside the world, in water (with the nearest land and its distance), in the unowned area, in the realm's own exclusion or in another realm's claim, and points of different realms closer than 10 pixels. `synthesisCheck` runs the same check. In the feature-aware partition each capital and seed is the centre of a province of its own, so capitals only 10 to 40 pixels apart each get a province and a state.
|
|
545
|
+
|
|
544
546
|
Every tag must be declared by an active `common/country_tags` file other than the world's own (`MAP_WORLD_REALM_TAG_UNKNOWN`) and not only as a dynamic tag (`MAP_WORLD_REALM_TAG_DYNAMIC`). A point outside the world (`MAP_WORLD_REALM_POINT_OUTSIDE`) or in water (`MAP_WORLD_REALM_POINT_WATER`), a point in unowned land, in the country's own exclusion or on another country's point (`MAP_WORLD_REALM_POINT_CONFLICT`), and claims that overlap each other or unowned land (`MAP_WORLD_REALM_CLAIM_OVERLAP`) are refused with the tag and position. A reference picture's political colours are not used with this form.
|
|
545
547
|
|
|
546
548
|
The world then writes no country tag file, country file, `colors.txt`, country history, order of battle, flag, country name or bookmark. Unowned land forms states with no `owner`, `controller` or `add_core_of` (`MAP_WORLD_STATE_UNOWNED`, and `MAP_STATE_OWNER_MISSING` for each); whether the game build accepts unowned land is the mod's to verify. `MAP_WORLD_REALMS_PLACED`, `data.realms` and `map-world-realms.json` give each country's capital state and province, states, provinces and land share against its target. The country histories stay the mod's: a `capital` in a state the country does not own is the existing warning `MAP_COUNTRY_CAPITAL_NOT_OWNED`.
|
|
@@ -557,7 +559,9 @@ A river the model draws is authoritative over the synthesised relief (ADR 0071).
|
|
|
557
559
|
|
|
558
560
|
Only a course that cannot exist is refused with `MAP_RIVER_UNREACHABLE_MOUTH`: a mouth on another landmass or not reachable over land, an unreachable source or via point, a lake without a free shore. The error lists every such river in `details.rivers` with its reason, or `blockedBy` another failed river.
|
|
559
561
|
|
|
560
|
-
|
|
562
|
+
A declared pass is a saddle lower than the crest on both sides (ADR 0072): the heights in its corridor are capped at the saddle level after all relief is composed, so a volcano cone or plateau on the pass is cut down there (move the volcano if the cone should stay whole). Another range's body is not touched.
|
|
563
|
+
|
|
564
|
+
To check all of this without a write, call `hoi4.map_rewrite` with `preview: true` and `synthesisCheck: true` on the `create_world` operation: it runs the relief and river synthesis only, writes nothing and returns `MAP_SYNTHESIS_CHECK_READY` or `MAP_SYNTHESIS_CHECK_BLOCKED` with `data.synthesisCheck` (unreachable rivers, carved crossings and passes that are not lower than their crest). It does not run the partition, the countries or the climate stage (ADR 0070). A world that fails to generate returns only its own blockers, never the findings of the map it would replace.
|
|
561
565
|
|
|
562
566
|
### Feature-aware provinces, states and strategic regions
|
|
563
567
|
|