hoi4-agent-tools 4.5.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.
- package/CHANGELOG.md +16 -0
- package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
- package/dist/hoi4_agent_tools/map/derived.js +16 -3
- package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
- package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
- package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
- package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
- package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
- package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
- package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
- package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
- package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
- package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
- package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
- package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
- package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
- package/dist/hoi4_agent_tools/map/operations.js +109 -32
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +43 -0
- package/dist/hoi4_agent_tools/map/service.js +78 -16
- package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
- package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
- package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
- package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
- package/dist/hoi4_agent_tools/map/territory.js +1108 -0
- package/dist/hoi4_agent_tools/map/tool-operations.js +78 -0
- package/dist/hoi4_agent_tools/map/validation.js +4 -0
- package/dist/hoi4_agent_tools/map/world.js +301 -107
- package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
- package/dist/hoi4_agent_tools/schemas/map.js +206 -21
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/map.md +98 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/geography-model.schema.json +13 -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 +1355 -736
- 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,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.6.0 - 2026-10-25
|
|
4
|
+
|
|
5
|
+
- 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_*`.
|
|
6
|
+
- Land listed in `assign_territory.unowned`, and landmasses without a seed under `islandPolicy: "unowned"`, lose their owner (and controller) with `ownership.unowned: "clear"`, the default, or keep it with `"keep"`; states outside `scope` are never edited. Refusals name the state or province of the request (`MAP_TERRITORY_SEED_UNAVAILABLE`, `_FORCED_BLOCKED`, `_FORCED_CONFLICT`, `_FORCED_UNREACHABLE` carry `stateId` or `provinceId`). Results of a territory operation add `data.territory`.
|
|
7
|
+
- Add `borderQuality` (`off`, `report`, `enforce`) and `borderTargets` to the territory operations and to `create_world` (G-58). The border naturalness of the proposed map is measured after the whole request and reported (`MAP_BORDER_QUALITY_MEASURED`, artifact `map-territory-report.json`); `enforce` blocks the write below the targets (`MAP_TERRITORY_BORDER_ARTIFICIAL`, `MAP_WORLD_BORDER_ARTIFICIAL`) and is refused for the legacy partition.
|
|
8
|
+
- A feature-aware `create_world` without a reference picture now grows its generated countries along natural lines instead of blind breadth-first growth; legacy worlds are byte-identical, and workspace-owned countries (`countries.source: "workspace"`) keep the realm growth of ADR 0069.
|
|
9
|
+
- Public schema change: new operation kinds `assign_territory`, `repartition_states` and `update_states` and new optional fields `borderQuality` and `borderTargets` on `create_world` (minor version at release; the schemas are regenerated).
|
|
10
|
+
- Plan straits, passes and walls from a geography model (ADR 0062). A world built from a geography now writes each `strait` as a named `sea` row in `adjacencies.csv` with a rule in `adjacency_rules.txt` (`open`, `army-closed`, `navy-closed`), `impassable` rows for every pair of land provinces across a range with `barrier: "impassable"` except at its passes, land forts (`bunker`) at passes with a `fort` level, and sea provinces for a lake with `gameplay: "inland-sea"` with a canal row to the open sea. The partition now uses the model's regions, named seas and walls. New optional `create_world.autoStraits` (`true` or `{ maxGapPx }`). Diagnostics: `MAP_CROSSINGS_PLANNED`, `MAP_INLAND_SEA_CREATED`, `MAP_INLAND_SEA_SEALED`, `MAP_STRAIT_UNRESOLVED`, `MAP_STRAIT_DUPLICATE`, `MAP_STRAIT_NOT_NARROW`, `MAP_PASS_BLOCKED`, `MAP_RANGE_NOT_CLOSED` and the refusal `MAP_IMPASSABLE_ROW_LIMIT`. `hoi4.map_inspect` and every created world also check rules against rows and wall rows on any map: `MAP_ADJACENCY_RULE_MISSING`, `_DUPLICATE`, `_INVALID`, `_UNUSED`, `MAP_IMPASSABLE_NOT_ADJACENT`, `MAP_IMPASSABLE_NOT_LAND`, `MAP_ADJACENCY_ROW_LIMIT`, `MAP_RAILWAY_CROSSES_IMPASSABLE`, `MAP_UNIT_POSITION_MISSING`; thresholds are set so the installed game raises nothing above information. Worlds without a geography are unchanged.
|
|
11
|
+
- Plan ports, development, names and railways from a geography model (ADR 0063). Ports follow a harbour score (concavity, shelter, river mouth, depth class, sites) with levels from site importance; population, category, infrastructure, factories, resources (ranges, `mine` sites, jungle) and victory points follow fertility, rivers, coast, passes and sites with no random rolls; states, strategic regions, victory points and lake provinces are named from sites, lakes, rivers, ranges, regions, lands and seas by containment, with counted placeholders (`MAP_NAME_PLACEHOLDER`); railways use a terrain-aware cost (ground, climb, rivers, gates, roads) and trunk lines of level 2 reach large cities. New optional `create_world.development: { model: "geography" | "legacy" }` (default `geography` for a geography world) and an optional `resource` on geography `mine` sites. Other diagnostics: `MAP_DEVELOPMENT_PLANNED`, `MAP_PORT_LAKE_UNSUPPORTED`, `MAP_PORT_SITE_NOT_COASTAL`, `MAP_SITE_UNPLACED`.
|
|
12
|
+
- Accept a strait between two coasts of one land as a crossing between two provinces of that land when its straight line crosses water (`MAP_GEOGRAPHY_STRAIT_SAME_LAND` is then `info`; across no water it stays an error), and add `bay` as a sea `kind` of the geography model (ADR 0062). Public schema change: one more allowed value of `seas[].kind` (minor version).
|
|
13
|
+
- Public schema change: new optional fields `development` and `autoStraits` on `create_world` and `resource` on geography sites (minor version at release; the schemas are regenerated; compatibility review: additive optional input only, no output field changed, behaviour changes only for worlds built from a geography).
|
|
14
|
+
- Stop reporting the validation findings of a map that a failed `create_world` left in place: a world that cannot generate now returns only its own blockers, and a successful replacement of a map is validated on the map it produces instead of being compared with the map it replaces (ADR 0070). Before, a river that could not reach its mouth came back with three `MAP_VALIDATION_OPERATION_UNOWNED` blockers about the old map.
|
|
15
|
+
- Let drawn rivers carve their valleys (ADR 0071). A river whose course crosses a divide higher than 22 height units (a volcano cone, a foothill, the tip of a range) is no longer refused: its bed is cut through the rise as a gorge, and each crossing is reported as the warning `MAP_RIVER_CARVED_CROSSING` with the river, climb and divide location (world pixel, height, nearest range or volcano). Models that were refused now write; models that wrote are unchanged.
|
|
16
|
+
- Report every named river with an impossible course in one run: `MAP_RIVER_UNREACHABLE_MOUTH` (a mouth on another landmass, a source or via point that cannot be reached, a lake without a shore) keeps its code and top-level fields and gains `details.rivers`, each with its reason or `blockedBy` another failed river.
|
|
17
|
+
- Add `create_world.synthesisCheck` (with `preview: true` and a geography): run only the relief and river synthesis and report all impossible rivers and carved crossings without writing (`MAP_SYNTHESIS_CHECK_READY`, `MAP_SYNTHESIS_CHECK_BLOCKED`, `data.synthesisCheck`). Public schema change: one new optional field on `create_world` (minor version).
|
|
18
|
+
|
|
3
19
|
## 4.5.0 - 2026-10-25
|
|
4
20
|
|
|
5
21
|
- Refuse `hoi4.focus_rewrite` with `FOCUS_SOURCE_CHANGED_SINCE_PLAN` when the focus source changed in meaning after the plan was inspected, instead of reverting the external change to the plan's values (national trees and continuous palettes). Comment and formatting changes and a source that already equals the plan are still accepted. Reported by the HOI4 Factory S01 EXP-02 probe.
|
|
@@ -25,9 +25,11 @@ export const BORDER_FEATURE_BITS = {
|
|
|
25
25
|
};
|
|
26
26
|
const BORDER_FEATURES = Object.keys(BORDER_FEATURE_BITS);
|
|
27
27
|
/** Barrier set: the plan's measurement (rivers, steep gradients) plus lake shores and extras. */
|
|
28
|
-
const
|
|
28
|
+
export const BORDER_BARRIER_MASK = 1 | 2 | 4 | 16;
|
|
29
|
+
const BARRIER_MASK = BORDER_BARRIER_MASK;
|
|
29
30
|
/** Natural set: the barrier set plus terrain-class changes. */
|
|
30
|
-
const
|
|
31
|
+
export const BORDER_NATURAL_MASK = 31;
|
|
32
|
+
const NATURAL_MASK = BORDER_NATURAL_MASK;
|
|
31
33
|
/** Province kinds used by the metric. Only land provinces have land borders. */
|
|
32
34
|
export const BORDER_PROVINCE_KIND = { unknown: 0, land: 1, sea: 2, lake: 3 };
|
|
33
35
|
/** Targets for an engineered world: the design's thresholds, measured against vanilla. */
|
|
@@ -374,13 +376,16 @@ function validateInput(input) {
|
|
|
374
376
|
if (planes.terrain !== undefined && planes.terrain.classes.length !== 256)
|
|
375
377
|
throw new ServiceError('MAP_BORDER_QUALITY_INPUT_INVALID', 'The terrain class table needs 256 entries');
|
|
376
378
|
}
|
|
377
|
-
/**
|
|
378
|
-
|
|
379
|
+
/**
|
|
380
|
+
* One streamed pass over the raster: collect every land border edge and the chance baseline. The
|
|
381
|
+
* pass is a generator that yields every 16 rows so a synchronous planner can interleave its own
|
|
382
|
+
* checkpoints; `buildBorderEdgeTable` drives it and yields to the event loop.
|
|
383
|
+
*/
|
|
384
|
+
export function* buildBorderEdgeTableSteps(input, options = {}) {
|
|
379
385
|
const resolved = resolveOptions(options);
|
|
380
386
|
validateInput(input);
|
|
381
387
|
const { width, height, provinceIds, provinceKinds } = input;
|
|
382
388
|
const rows = new FeatureRows(input, resolved);
|
|
383
|
-
const pace = new Pace(resolved.signal);
|
|
384
389
|
let capacity = Math.min(65_536, resolved.maxEdges);
|
|
385
390
|
let keys = new Int32Array(capacity);
|
|
386
391
|
let provinceA = new Int32Array(capacity);
|
|
@@ -446,8 +451,10 @@ export async function buildBorderEdgeTable(input, options = {}) {
|
|
|
446
451
|
}
|
|
447
452
|
};
|
|
448
453
|
for (let y = 0; y < height; y += 1) {
|
|
449
|
-
if (y % 16 === 0)
|
|
450
|
-
|
|
454
|
+
if (y % 16 === 0) {
|
|
455
|
+
resolved.signal?.throwIfAborted();
|
|
456
|
+
yield;
|
|
457
|
+
}
|
|
451
458
|
if (y + 1 < height)
|
|
452
459
|
rows.rowFlags(y + 1, next);
|
|
453
460
|
const rowStart = y * width;
|
|
@@ -506,6 +513,17 @@ export async function buildBorderEdgeTable(input, options = {}) {
|
|
|
506
513
|
options: resolved,
|
|
507
514
|
};
|
|
508
515
|
}
|
|
516
|
+
/** Drive `buildBorderEdgeTableSteps`, yielding to the event loop about every 12 milliseconds. */
|
|
517
|
+
export async function buildBorderEdgeTable(input, options = {}) {
|
|
518
|
+
const steps = buildBorderEdgeTableSteps(input, options);
|
|
519
|
+
const pace = new Pace(options.signal);
|
|
520
|
+
for (;;) {
|
|
521
|
+
const step = steps.next();
|
|
522
|
+
if (step.done)
|
|
523
|
+
return step.value;
|
|
524
|
+
await pace.tick();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
509
527
|
function round(value, places = 4) {
|
|
510
528
|
const scale = 10 ** places;
|
|
511
529
|
return Math.round(value * scale) / scale;
|
|
@@ -623,7 +623,7 @@ function shortestRailPath(neighbors, sources, isTarget, cost) {
|
|
|
623
623
|
return path;
|
|
624
624
|
}
|
|
625
625
|
for (const next of neighbors(province)) {
|
|
626
|
-
const candidate = current + cost(next);
|
|
626
|
+
const candidate = current + cost(province, next);
|
|
627
627
|
if (candidate < (distance.get(next) ?? Number.POSITIVE_INFINITY)) {
|
|
628
628
|
distance.set(next, candidate);
|
|
629
629
|
previous.set(next, province);
|
|
@@ -726,12 +726,25 @@ export function planSupplyRebuild(index, request) {
|
|
|
726
726
|
const pending = new Set(ownerScope
|
|
727
727
|
.map((state) => state.provinces.find((id) => nodes.has(id)))
|
|
728
728
|
.filter((id) => id !== undefined && !connected.has(id)));
|
|
729
|
+
// Trunk lines reach the major cities of the country even where no supply node stands.
|
|
730
|
+
const trunk = new Set();
|
|
731
|
+
for (const id of request.railCost?.trunk ?? [])
|
|
732
|
+
if (ownerOf(id) === owner && !connected.has(id) && neighbors(id).length > 0) {
|
|
733
|
+
trunk.add(id);
|
|
734
|
+
pending.add(id);
|
|
735
|
+
}
|
|
736
|
+
const ownerFactor = (id) => (ownerOf(id) === owner ? 1 : 4);
|
|
737
|
+
const stepCost = (from, to) => (request.railCost?.cost(from, to) ?? 1) * ownerFactor(to);
|
|
729
738
|
while (pending.size > 0) {
|
|
730
|
-
const path = shortestRailPath(neighbors, connected, (id) => pending.has(id),
|
|
739
|
+
const path = shortestRailPath(neighbors, connected, (id) => pending.has(id), stepCost);
|
|
731
740
|
if (path === undefined)
|
|
732
741
|
break;
|
|
733
742
|
// A path that leaves the connected set once: from its target back to the network.
|
|
734
|
-
const level = path.includes(hub)
|
|
743
|
+
const level = path.includes(hub)
|
|
744
|
+
? capitalLevel
|
|
745
|
+
: trunk.has(path[0])
|
|
746
|
+
? Math.max(railwayLevel, request.trunkRailwayLevel ?? 2)
|
|
747
|
+
: railwayLevel;
|
|
735
748
|
newRailways.push({ level, provinces: [...path].reverse() });
|
|
736
749
|
railways.push(path);
|
|
737
750
|
for (const id of path) {
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { assignments, firstScalar, parseClausewitz } from '../../core/source/index.js';
|
|
2
|
+
import { defaultMapSelectorValue } from '../model.js';
|
|
3
|
+
import { closestApproach } from './facts.js';
|
|
4
|
+
/** Widest land-to-land crossing the validator accepts as a strait (the base game's is 30). */
|
|
5
|
+
export const STRAIT_VALIDATOR_LIMIT_PX = 48;
|
|
6
|
+
/** Total adjacency rows above which a map is reported as unusually dense with barriers. */
|
|
7
|
+
export const ADJACENCY_ROW_LIMIT = 2_000;
|
|
8
|
+
const ANALYSED_ROWS = 400;
|
|
9
|
+
function normalized(value) {
|
|
10
|
+
return value.replaceAll('\\', '/').replace(/^\.\//u, '').toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
function rulesFile(index) {
|
|
13
|
+
let name = 'adjacency_rules.txt';
|
|
14
|
+
try {
|
|
15
|
+
name = defaultMapSelectorValue(index.defaultMapFile, 'adjacency_rules', name);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// An untrustworthy selector is reported elsewhere; the default name is still worth checking.
|
|
19
|
+
}
|
|
20
|
+
for (const root of index.sourceRoots.map) {
|
|
21
|
+
const found = index.activeFiles.byRelativePath.get(normalized(`${root}/${name}`));
|
|
22
|
+
if (found !== undefined)
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/** The rules a file defines; `duplicates` lists names defined more than once. */
|
|
28
|
+
export function parseAdjacencyRules(file) {
|
|
29
|
+
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
30
|
+
const rules = [];
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const duplicates = [];
|
|
33
|
+
for (const entry of assignments(document.root, 'adjacency_rule')) {
|
|
34
|
+
if (entry.value.type !== 'block')
|
|
35
|
+
continue;
|
|
36
|
+
const name = firstScalar(entry.value, 'name')?.value ?? '';
|
|
37
|
+
const required = [];
|
|
38
|
+
const list = assignments(entry.value, 'required_provinces')[0]?.value;
|
|
39
|
+
if (list?.type === 'block')
|
|
40
|
+
for (const item of list.entries)
|
|
41
|
+
if (item.type === 'scalar' && /^\d+$/u.test(item.value))
|
|
42
|
+
required.push(Number(item.value));
|
|
43
|
+
const icon = firstScalar(entry.value, 'icon')?.value;
|
|
44
|
+
rules.push({
|
|
45
|
+
name,
|
|
46
|
+
required,
|
|
47
|
+
...(icon !== undefined && /^\d+$/u.test(icon) ? { icon: Number(icon) } : {}),
|
|
48
|
+
});
|
|
49
|
+
if (name !== '') {
|
|
50
|
+
if (seen.has(name))
|
|
51
|
+
duplicates.push(name);
|
|
52
|
+
seen.add(name);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { rules, duplicates };
|
|
56
|
+
}
|
|
57
|
+
function bounded(items, limit = 8) {
|
|
58
|
+
return items.slice(0, limit);
|
|
59
|
+
}
|
|
60
|
+
function* checkRules(index, report) {
|
|
61
|
+
const file = rulesFile(index);
|
|
62
|
+
if (file === undefined)
|
|
63
|
+
return;
|
|
64
|
+
const { rules, duplicates } = parseAdjacencyRules(file);
|
|
65
|
+
yield;
|
|
66
|
+
const location = {
|
|
67
|
+
path: file.displayPath,
|
|
68
|
+
start: { line: 1, column: 1, offset: 0 },
|
|
69
|
+
end: { line: 1, column: 1, offset: 0 },
|
|
70
|
+
};
|
|
71
|
+
for (const name of bounded(duplicates))
|
|
72
|
+
report({
|
|
73
|
+
code: 'MAP_ADJACENCY_RULE_DUPLICATE',
|
|
74
|
+
severity: 'error',
|
|
75
|
+
message: `Adjacency rule ${name} is defined more than once`,
|
|
76
|
+
location,
|
|
77
|
+
details: { rule: name },
|
|
78
|
+
});
|
|
79
|
+
const defined = new Map(rules.map((rule) => [rule.name, rule]));
|
|
80
|
+
for (const rule of rules) {
|
|
81
|
+
const problems = [];
|
|
82
|
+
if (rule.name === '')
|
|
83
|
+
problems.push('has no name');
|
|
84
|
+
if (rule.required.length < 2)
|
|
85
|
+
problems.push('lists fewer than two required_provinces');
|
|
86
|
+
const unknown = rule.required.filter((id) => !index.definitionsById.has(id));
|
|
87
|
+
if (unknown.length > 0)
|
|
88
|
+
problems.push(`names unknown province(s) ${bounded(unknown).join(', ')}`);
|
|
89
|
+
if (rule.icon !== undefined && !index.definitionsById.has(rule.icon))
|
|
90
|
+
problems.push(`has an icon on unknown province ${rule.icon}`);
|
|
91
|
+
if (problems.length > 0)
|
|
92
|
+
report({
|
|
93
|
+
code: 'MAP_ADJACENCY_RULE_INVALID',
|
|
94
|
+
severity: 'error',
|
|
95
|
+
message: `Adjacency rule ${rule.name === '' ? '(unnamed)' : rule.name} ${problems.join(' and ')}`,
|
|
96
|
+
location,
|
|
97
|
+
details: { rule: rule.name, problems },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
yield;
|
|
101
|
+
const used = new Set();
|
|
102
|
+
for (const row of index.adjacencies) {
|
|
103
|
+
if (row.rule === '')
|
|
104
|
+
continue;
|
|
105
|
+
used.add(row.rule);
|
|
106
|
+
if (!defined.has(row.rule))
|
|
107
|
+
report({
|
|
108
|
+
code: 'MAP_ADJACENCY_RULE_MISSING',
|
|
109
|
+
severity: 'error',
|
|
110
|
+
message: `Adjacency ${row.from}-${row.to} uses rule ${row.rule}, which adjacency_rules.txt does not define`,
|
|
111
|
+
location,
|
|
112
|
+
details: { rule: row.rule, from: row.from, to: row.to },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const unused = rules.filter((rule) => rule.name !== '' && !used.has(rule.name));
|
|
116
|
+
if (unused.length > 0)
|
|
117
|
+
report({
|
|
118
|
+
code: 'MAP_ADJACENCY_RULE_UNUSED',
|
|
119
|
+
severity: 'info',
|
|
120
|
+
message: `${unused.length} adjacency rule(s) are defined but no adjacency row uses them`,
|
|
121
|
+
location,
|
|
122
|
+
details: { rules: bounded(unused.map((rule) => rule.name)) },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** Pixels of one province, from its bounding box. */
|
|
126
|
+
function pixelsOf(index, id) {
|
|
127
|
+
const raster = index.raster;
|
|
128
|
+
const geometry = raster?.geometry.get(id);
|
|
129
|
+
if (raster === undefined || geometry === undefined)
|
|
130
|
+
return [];
|
|
131
|
+
const out = [];
|
|
132
|
+
const stride = Math.max(1, Math.ceil(Math.sqrt(geometry.pixelCount / 4_000)));
|
|
133
|
+
for (let y = geometry.minY; y <= geometry.maxY; y += 1)
|
|
134
|
+
for (let x = geometry.minX; x <= geometry.maxX; x += 1)
|
|
135
|
+
if ((x + y) % stride === 0 && raster.provinceIds[y * raster.width + x] === id)
|
|
136
|
+
out.push(y * raster.width + x);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
function* checkRows(index, report) {
|
|
140
|
+
const raster = index.raster;
|
|
141
|
+
if (raster === undefined)
|
|
142
|
+
return;
|
|
143
|
+
const impassable = index.adjacencies.filter(({ type }) => type === 'impassable');
|
|
144
|
+
const isolated = [];
|
|
145
|
+
const nonLand = [];
|
|
146
|
+
for (const row of impassable) {
|
|
147
|
+
const from = index.definitionsById.get(row.from);
|
|
148
|
+
const to = index.definitionsById.get(row.to);
|
|
149
|
+
if (from === undefined || to === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
if (!(raster.adjacency.get(row.from)?.has(row.to) ?? false))
|
|
152
|
+
isolated.push(`${row.from}-${row.to}`);
|
|
153
|
+
if (from.type !== 'land' || to.type !== 'land')
|
|
154
|
+
nonLand.push(`${row.from}-${row.to}`);
|
|
155
|
+
}
|
|
156
|
+
yield;
|
|
157
|
+
// The base game has one such row among 93, so a stray row is information and a pattern a warning.
|
|
158
|
+
if (isolated.length > 0)
|
|
159
|
+
report({
|
|
160
|
+
code: 'MAP_IMPASSABLE_NOT_ADJACENT',
|
|
161
|
+
severity: isolated.length >= 3 && isolated.length > impassable.length * 0.05 ? 'warning' : 'info',
|
|
162
|
+
message: `${isolated.length} impassable row(s) join provinces that do not touch, so they block nothing`,
|
|
163
|
+
details: { rows: bounded(isolated), count: isolated.length },
|
|
164
|
+
});
|
|
165
|
+
if (nonLand.length > 0)
|
|
166
|
+
report({
|
|
167
|
+
code: 'MAP_IMPASSABLE_NOT_LAND',
|
|
168
|
+
severity: 'warning',
|
|
169
|
+
message: `${nonLand.length} impassable row(s) join a province that is not land; walls separate land provinces`,
|
|
170
|
+
details: { rows: bounded(nonLand), count: nonLand.length },
|
|
171
|
+
});
|
|
172
|
+
if (index.adjacencies.length > ADJACENCY_ROW_LIMIT)
|
|
173
|
+
report({
|
|
174
|
+
code: 'MAP_ADJACENCY_ROW_LIMIT',
|
|
175
|
+
severity: 'warning',
|
|
176
|
+
message: `adjacencies.csv has ${index.adjacencies.length} rows; the base game has 244. Large walls slow the load and the AI; consider fewer or larger provinces along the wall`,
|
|
177
|
+
details: { rows: index.adjacencies.length, limit: ADJACENCY_ROW_LIMIT },
|
|
178
|
+
});
|
|
179
|
+
// Land-to-land sea crossings narrow enough to be a strait.
|
|
180
|
+
const wide = [];
|
|
181
|
+
let analysed = 0;
|
|
182
|
+
const view = {
|
|
183
|
+
width: raster.width,
|
|
184
|
+
height: raster.height,
|
|
185
|
+
pixel: raster.provinceIds,
|
|
186
|
+
count: 0,
|
|
187
|
+
};
|
|
188
|
+
for (const row of index.adjacencies) {
|
|
189
|
+
if (row.type !== 'sea' || analysed >= ANALYSED_ROWS)
|
|
190
|
+
continue;
|
|
191
|
+
const from = index.definitionsById.get(row.from);
|
|
192
|
+
const to = index.definitionsById.get(row.to);
|
|
193
|
+
if (from?.type !== 'land' || to?.type !== 'land')
|
|
194
|
+
continue;
|
|
195
|
+
if (raster.adjacency.get(row.from)?.has(row.to) ?? false)
|
|
196
|
+
continue;
|
|
197
|
+
analysed += 1;
|
|
198
|
+
const approach = closestApproach(pixelsOf(index, row.from), pixelsOf(index, row.to), view.width, 200);
|
|
199
|
+
if (approach !== undefined && approach.gap > STRAIT_VALIDATOR_LIMIT_PX)
|
|
200
|
+
wide.push(`${row.from}-${row.to} (${Math.round(approach.gap)} px)`);
|
|
201
|
+
if (analysed % 16 === 0)
|
|
202
|
+
yield;
|
|
203
|
+
}
|
|
204
|
+
if (wide.length > 0)
|
|
205
|
+
report({
|
|
206
|
+
code: 'MAP_STRAIT_NOT_NARROW',
|
|
207
|
+
severity: 'warning',
|
|
208
|
+
message: `${wide.length} land crossing(s) span more than ${STRAIT_VALIDATOR_LIMIT_PX} pixels between their provinces (the base game's widest is 30): ${bounded(wide, 4).join(', ')}`,
|
|
209
|
+
details: { crossings: bounded(wide), count: wide.length, limitPx: STRAIT_VALIDATOR_LIMIT_PX },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function* checkRailways(index, report) {
|
|
213
|
+
if (index.railways.length === 0)
|
|
214
|
+
return;
|
|
215
|
+
const blocked = new Set();
|
|
216
|
+
for (const row of index.adjacencies)
|
|
217
|
+
if (row.type === 'impassable') {
|
|
218
|
+
blocked.add(`${row.from}:${row.to}`);
|
|
219
|
+
blocked.add(`${row.to}:${row.from}`);
|
|
220
|
+
}
|
|
221
|
+
if (blocked.size === 0)
|
|
222
|
+
return;
|
|
223
|
+
const crossing = [];
|
|
224
|
+
for (const railway of index.railways) {
|
|
225
|
+
for (let step = 1; step < railway.provinces.length; step += 1) {
|
|
226
|
+
const from = railway.provinces[step - 1];
|
|
227
|
+
const to = railway.provinces[step];
|
|
228
|
+
if (blocked.has(`${from}:${to}`))
|
|
229
|
+
crossing.push(`${from}-${to}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
yield;
|
|
233
|
+
if (crossing.length > 0)
|
|
234
|
+
report({
|
|
235
|
+
code: 'MAP_RAILWAY_CROSSES_IMPASSABLE',
|
|
236
|
+
severity: 'warning',
|
|
237
|
+
message: `${crossing.length} railway step(s) run between provinces separated by an impassable row; a railway cannot cross a wall except at a gate`,
|
|
238
|
+
details: { steps: bounded(crossing), count: crossing.length },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function* checkUnitPositions(index, report) {
|
|
242
|
+
if (index.unitPositions.length === 0 || index.raster === undefined)
|
|
243
|
+
return;
|
|
244
|
+
const covered = new Set(index.unitPositions.map(({ provinceId }) => provinceId));
|
|
245
|
+
const missing = [];
|
|
246
|
+
for (const definition of index.definitions)
|
|
247
|
+
if (definition.id > 0 &&
|
|
248
|
+
index.raster.geometry.has(definition.id) &&
|
|
249
|
+
definition.type !== 'lake' &&
|
|
250
|
+
!covered.has(definition.id))
|
|
251
|
+
missing.push(definition.id);
|
|
252
|
+
yield;
|
|
253
|
+
// The base game lacks positions for 7 of 13,000 provinces: a few are information, many a warning.
|
|
254
|
+
if (missing.length > 0)
|
|
255
|
+
report({
|
|
256
|
+
code: 'MAP_UNIT_POSITION_MISSING',
|
|
257
|
+
severity: missing.length > Math.max(3, index.definitions.length * 0.01) ? 'warning' : 'info',
|
|
258
|
+
message: `${missing.length} province(s) have no unit-stack positions, so units there have no place to stand`,
|
|
259
|
+
details: { provinces: bounded(missing, 12), count: missing.length },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
/** Run the crossing, wall, railway and position checks; findings go to `report`. */
|
|
263
|
+
export function* validateCrossings(index, report) {
|
|
264
|
+
yield* checkRules(index, report);
|
|
265
|
+
yield* checkRows(index, report);
|
|
266
|
+
yield* checkRailways(index, report);
|
|
267
|
+
yield* checkUnitPositions(index, report);
|
|
268
|
+
}
|