hoi4-agent-tools 4.6.1 → 4.6.3
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 +12 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +10 -2
- package/dist/hoi4_agent_tools/map/operations.js +16 -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/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/schemas/map.js +1 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/map.md +3 -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,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.6.3 - 2026-11-01
|
|
4
|
+
|
|
5
|
+
- Write a world over a mod whose output inventory lists files the world does not manage. `.hoi4-agent-world.json` could record files that were never outputs of a generated world (for example country tag files and localisation files registered by the mod's own tooling), and one such entry made every write fail with `MANAGED_OUTPUT_MANIFEST_INVALID`. A safe relative path outside the allow-list is now left alone (never read, compared, edited or deleted), dropped from the next inventory and reported (`MAP_WORLD_OUTPUT_RECORD_IGNORED`, `data.outputs`). A path that could leave the workspace, or a duplicate, still refuses the inventory. See the amendment to ADR 0052.
|
|
6
|
+
|
|
7
|
+
## 4.6.2 - 2026-10-31
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
## Unreleased
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 4.6.1 - 2026-10-26
|
|
4
16
|
|
|
5
17
|
- Stop flagging `constant:<category>.<key>` script-constant reads as scoped temporary-variable reads (`KNOWN_MISTAKE_TEMP_VARIABLE_SCOPE_PREFIX`).
|
|
@@ -39,16 +39,22 @@ export function planManagedOutputs(input) {
|
|
|
39
39
|
const outputs = input.proposed.filter((f) => f.content !== null && input.permitsPath(f.relativePath));
|
|
40
40
|
const next = new Set(outputs.map((f) => f.relativePath.toLowerCase()));
|
|
41
41
|
const released = new Set();
|
|
42
|
+
const ignored = new Set();
|
|
42
43
|
const seen = new Set();
|
|
43
44
|
for (const file of previous) {
|
|
44
45
|
const key = file.relativePath.toLowerCase();
|
|
46
|
+
// A path that could leave the workspace, or a duplicate, makes the whole inventory untrustworthy.
|
|
45
47
|
if (file.relativePath.includes('\\') ||
|
|
46
48
|
file.relativePath.split('/').some((s) => !s || s === '.' || s === '..') ||
|
|
47
49
|
/^[a-z]:/iu.test(file.relativePath) ||
|
|
48
|
-
!input.permitsPath(file.relativePath) ||
|
|
49
50
|
seen.has(key))
|
|
50
51
|
throw new ServiceError('MANAGED_OUTPUT_MANIFEST_INVALID', 'Generated-output inventory contains an unsupported or duplicate path');
|
|
51
52
|
seen.add(key);
|
|
53
|
+
// A safe path this inventory does not manage is left alone: not compared, not deleted.
|
|
54
|
+
if (!input.permitsPath(file.relativePath)) {
|
|
55
|
+
ignored.add(file.relativePath);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
52
58
|
const current = sources.get(key);
|
|
53
59
|
if (current === undefined || sha256Bytes(current.bytes) === file.sha256)
|
|
54
60
|
continue;
|
|
@@ -58,10 +64,11 @@ export function planManagedOutputs(input) {
|
|
|
58
64
|
}
|
|
59
65
|
throw new ServiceError('MANAGED_OUTPUT_EDITED', 'A generated file has been edited; regeneration would discard those changes', { relativePath: file.relativePath });
|
|
60
66
|
}
|
|
67
|
+
const ignoredKeys = new Set([...ignored].map((path) => path.toLowerCase()));
|
|
61
68
|
const deletions = previous
|
|
62
69
|
.filter((f) => {
|
|
63
70
|
const key = f.relativePath.toLowerCase();
|
|
64
|
-
return !next.has(key) && !released.has(key) && sources.has(key);
|
|
71
|
+
return !next.has(key) && !released.has(key) && !ignoredKeys.has(key) && sources.has(key);
|
|
65
72
|
})
|
|
66
73
|
.map((f) => ({
|
|
67
74
|
relativePath: f.relativePath,
|
|
@@ -88,5 +95,6 @@ export function planManagedOutputs(input) {
|
|
|
88
95
|
written: sorted(outputs.map((f) => f.relativePath)),
|
|
89
96
|
retired: sorted(deletions.map((f) => f.relativePath)),
|
|
90
97
|
released: sorted(previous.filter((f) => released.has(f.relativePath.toLowerCase())).map((f) => f.relativePath)),
|
|
98
|
+
ignored: sorted(ignored),
|
|
91
99
|
};
|
|
92
100
|
}
|
|
@@ -3520,6 +3520,7 @@ function applyCreateWorld(base, changes, operation, context, signal) {
|
|
|
3520
3520
|
written: outputs.written,
|
|
3521
3521
|
retired: outputs.retired,
|
|
3522
3522
|
released: outputs.released,
|
|
3523
|
+
ignored: outputs.ignored,
|
|
3523
3524
|
...(world.realms === undefined ? {} : { realms: world.realms }),
|
|
3524
3525
|
});
|
|
3525
3526
|
return [
|
|
@@ -3551,6 +3552,21 @@ function applyCreateWorld(base, changes, operation, context, signal) {
|
|
|
3551
3552
|
},
|
|
3552
3553
|
]),
|
|
3553
3554
|
worldOutputsDiagnostic(operation.id, outputs),
|
|
3555
|
+
...(outputs.ignored.length === 0
|
|
3556
|
+
? []
|
|
3557
|
+
: [
|
|
3558
|
+
{
|
|
3559
|
+
code: 'MAP_WORLD_OUTPUT_RECORD_IGNORED',
|
|
3560
|
+
severity: 'warning',
|
|
3561
|
+
category: 'map',
|
|
3562
|
+
operationId: operation.id,
|
|
3563
|
+
message: `${outputs.ignored.length} path(s) in the world's output inventory are not outputs of a generated world; they were left in place and dropped from the inventory`,
|
|
3564
|
+
details: {
|
|
3565
|
+
count: outputs.ignored.length,
|
|
3566
|
+
files: outputs.ignored.slice(0, WORLD_OUTPUT_PATH_SAMPLE),
|
|
3567
|
+
},
|
|
3568
|
+
},
|
|
3569
|
+
]),
|
|
3554
3570
|
...worldTagDiagnostics(operation.id, tagCatalog, world, workspaceCountries !== undefined),
|
|
3555
3571
|
...worldRealmDiagnostics(operation.id, world),
|
|
3556
3572
|
...(outputs.released.length === 0
|
|
@@ -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,
|
|
@@ -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);
|
|
@@ -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.3';
|
|
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
|
@@ -23,7 +23,7 @@ Use a cleaned, unlabelled raster for production generation. Handwriting, sheet j
|
|
|
23
23
|
|
|
24
24
|
Reference worlds enable `startingScenario` by default; procedural worlds keep it off unless requested. The starter includes a bookmark, country histories, provisional councils, recruitable infantry templates, two equipped divisions per country, stockpiles, an infantry production line, supply and building positions. `worldName` sets the bookmark title. Demographics, industry and ownership remain provisional. Existing world events, decisions, startup hooks and country-specific AI strategy folders are replaced; shared game systems remain dependencies and can report unused-definition warnings. Generated names use the localisation replacement folder, and explicit country color declarations match the flags. Inherited country tags receive inactive histories. The adjacent launcher `.mod` file must mirror the generated `descriptor.mod` replacement paths. The server cannot enable a launcher playset or test a campaign.
|
|
25
25
|
|
|
26
|
-
Regeneration records generated paths and hashes in `.hoi4-agent-world.json`. It removes obsolete recorded outputs through the same atomic rewrite and refuses to overwrite recorded files edited since generation. Preserve that inventory. Unrelated authored files are not deleted; validation can block a rebuild if they conflict with the generated world. Large world diffs remain linked evidence so counts and preview images fit within the MCP response budget.
|
|
26
|
+
Regeneration records generated paths and hashes in `.hoi4-agent-world.json`. A recorded path outside what a world manages (such as a file another tool registered) is left alone, dropped from the inventory and reported as `MAP_WORLD_OUTPUT_RECORD_IGNORED`; an unsafe or duplicate path refuses the inventory. It removes obsolete recorded outputs through the same atomic rewrite and refuses to overwrite recorded files edited since generation. Preserve that inventory. Unrelated authored files are not deleted; validation can block a rebuild if they conflict with the generated world. Large world diffs remain linked evidence so counts and preview images fit within the MCP response budget.
|
|
27
27
|
|
|
28
28
|
Automatic interpretation recognizes visual land/water and terrain cues, derives relief and drainage, and uses flat political colors when available. Offline English OCR supplies candidate names and city locations; recognized text bounds are masked before terrain classification so ocean labels do not become islands. Masks near a coastline can soften its detail and produce a review warning. Decorative text, handwriting, unknown languages and visually ambiguous borders may be missed or misclassified; diagnostics identify uncertain interpretation and deterministic defaults. Population, resources, buildings and country ownership inferred from artwork are generated assumptions, not historical facts or a balance assessment.
|
|
29
29
|
|
|
@@ -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`.
|