hoi4-agent-tools 4.4.0 → 4.5.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 +8 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
- package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
- package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
- package/dist/hoi4_agent_tools/focus/drift.js +34 -0
- package/dist/hoi4_agent_tools/focus/service.js +9 -3
- package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
- package/dist/hoi4_agent_tools/map/model.js +2 -1
- package/dist/hoi4_agent_tools/map/operations.js +173 -6
- package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
- package/dist/hoi4_agent_tools/map/service.js +15 -0
- package/dist/hoi4_agent_tools/map/tool-operations.js +41 -1
- package/dist/hoi4_agent_tools/map/world-context.js +23 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
- package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
- package/dist/hoi4_agent_tools/map/world.js +100 -39
- package/dist/hoi4_agent_tools/schemas/map.js +71 -13
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/focus.md +6 -0
- package/docs/map.md +34 -0
- 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 +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 +725 -589
- 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
|
@@ -6,7 +6,7 @@ import { autonomousResultArtifacts, } from '../core/transaction-execution.js';
|
|
|
6
6
|
import { emptyServiceResult, ServiceError } from '../core/result.js';
|
|
7
7
|
import { setInlineFilesScanned } from '../core/operation-result.js';
|
|
8
8
|
import { PACKAGE_VERSION } from '../version.js';
|
|
9
|
-
import { allocateMapIdentifiersAsync, } from './operations.js';
|
|
9
|
+
import { allocateMapIdentifiersAsync, WORLD_OUTPUT_PATH_SAMPLE, } from './operations.js';
|
|
10
10
|
import { borderQualityDiagnostics } from './border-quality.js';
|
|
11
11
|
import { measureIndexBorderNaturalness, summarizeBorderQuality, } from './border-quality-input.js';
|
|
12
12
|
import { buildMapCatalog, lookupMapCoordinate, searchMapCatalog } from './catalog.js';
|
|
@@ -301,6 +301,7 @@ export async function prepareMapRewrite(nudger, input, context) {
|
|
|
301
301
|
semanticDiff: compactSemantic.semantic,
|
|
302
302
|
semanticDiffCounts: compactSemantic.counts,
|
|
303
303
|
semanticDiffTruncated: compactSemantic.truncated,
|
|
304
|
+
...worldOutputsData(planned.plan),
|
|
304
305
|
}),
|
|
305
306
|
filesScanned: planned.filesScanned,
|
|
306
307
|
planBlockers: planned.plan.blockers
|
|
@@ -309,6 +310,44 @@ export async function prepareMapRewrite(nudger, input, context) {
|
|
|
309
310
|
}),
|
|
310
311
|
};
|
|
311
312
|
}
|
|
313
|
+
/** Counts and sorted samples of the managed outputs each generated world writes, retires and releases. */
|
|
314
|
+
function worldOutputsData(plan) {
|
|
315
|
+
if (plan.worldOutputs === undefined)
|
|
316
|
+
return {};
|
|
317
|
+
const sample = (paths) => paths.slice(0, WORLD_OUTPUT_PATH_SAMPLE);
|
|
318
|
+
const realms = plan.worldOutputs.flatMap(({ operationId, realms: placed }) => placed === undefined
|
|
319
|
+
? []
|
|
320
|
+
: [
|
|
321
|
+
{
|
|
322
|
+
operationId,
|
|
323
|
+
countries: placed.countries.slice(0, 100).map((country) => ({
|
|
324
|
+
tag: country.tag,
|
|
325
|
+
capitalState: country.capitalState,
|
|
326
|
+
capitalProvince: country.capitalProvince,
|
|
327
|
+
states: country.states.length,
|
|
328
|
+
provinces: country.provinces,
|
|
329
|
+
landShare: country.landShare,
|
|
330
|
+
targetShare: country.targetShare,
|
|
331
|
+
})),
|
|
332
|
+
countryCount: placed.countries.length,
|
|
333
|
+
unownedStates: placed.unownedStates.slice(0, WORLD_OUTPUT_PATH_SAMPLE),
|
|
334
|
+
unownedStateCount: placed.unownedStates.length,
|
|
335
|
+
},
|
|
336
|
+
]);
|
|
337
|
+
return {
|
|
338
|
+
...(realms.length === 0 ? {} : { realms: realms.slice(0, 16) }),
|
|
339
|
+
outputs: plan.worldOutputs.slice(0, 16).map((world) => ({
|
|
340
|
+
operationId: world.operationId,
|
|
341
|
+
written: world.written.length,
|
|
342
|
+
retired: world.retired.length,
|
|
343
|
+
released: world.released.length,
|
|
344
|
+
retiredFiles: sample(world.retired),
|
|
345
|
+
releasedFiles: sample(world.released),
|
|
346
|
+
truncated: world.retired.length > WORLD_OUTPUT_PATH_SAMPLE ||
|
|
347
|
+
world.released.length > WORLD_OUTPUT_PATH_SAMPLE,
|
|
348
|
+
})),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
312
351
|
export function completeMapRewrite(workspaceId, execution, recipe) {
|
|
313
352
|
const parsed = mapRewriteRecipeSchema.parse(recipe);
|
|
314
353
|
const transaction = execution.transaction;
|
|
@@ -492,6 +531,7 @@ export async function previewMapRewrite(nudger, input, context) {
|
|
|
492
531
|
semanticDiffTruncated: compactSemantic.truncated,
|
|
493
532
|
fileCount: planned.plan.changes.length,
|
|
494
533
|
artifactCount: planned.artifacts.length,
|
|
534
|
+
...worldOutputsData(planned.plan),
|
|
495
535
|
...(input.operations.some(({ kind }) => kind === 'create_world') &&
|
|
496
536
|
generatedWorldOperationIds(planned.plan).size === 0
|
|
497
537
|
? {}
|
|
@@ -169,3 +169,26 @@ export function buildWorldContext(base) {
|
|
|
169
169
|
}
|
|
170
170
|
return context;
|
|
171
171
|
}
|
|
172
|
+
/** The tag file a generated world writes for the countries it creates. */
|
|
173
|
+
export const WORLD_COUNTRY_TAG_FILE = 'common/country_tags/zz_world_countries.txt';
|
|
174
|
+
/** Country tags the active sources declare, apart from those of a previously generated world. */
|
|
175
|
+
export function countryTagCatalog(base) {
|
|
176
|
+
const declared = new Set();
|
|
177
|
+
const dynamic = new Set();
|
|
178
|
+
for (const file of base.activeFiles.all) {
|
|
179
|
+
const name = normalized(file.relativePath);
|
|
180
|
+
if (!/^common\/country_tags\/[^/]+\.txt$/u.test(name) || name === WORLD_COUNTRY_TAG_FILE)
|
|
181
|
+
continue;
|
|
182
|
+
const entries = assignments(documentFor(file).root);
|
|
183
|
+
const isDynamic = entries.some((entry) => entry.key.value === 'dynamic_tags' &&
|
|
184
|
+
entry.value.type === 'scalar' &&
|
|
185
|
+
entry.value.value === 'yes');
|
|
186
|
+
for (const entry of entries)
|
|
187
|
+
if (/^[A-Z][A-Z0-9]{2}$/u.test(entry.key.value) && entry.value.type === 'scalar')
|
|
188
|
+
(isDynamic ? dynamic : declared).add(entry.key.value);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
static: [...declared].sort(compareCodeUnits),
|
|
192
|
+
dynamic: [...dynamic].filter((tag) => !declared.has(tag)).sort(compareCodeUnits),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { planManagedOutputs } from '../core/managed-outputs.js';
|
|
3
3
|
export const WORLD_OUTPUT_MANIFEST = '.hoi4-agent-world.json';
|
|
4
|
+
/** Country outputs of a generated world: tags, country files, histories, units, flags, bookmark. */
|
|
5
|
+
export function isWorldCountryOutput(relativePath) {
|
|
6
|
+
const dir = path.posix.dirname(relativePath), file = path.posix.basename(relativePath);
|
|
7
|
+
return ((['common/countries', 'history/countries'].includes(dir) &&
|
|
8
|
+
/^[A-Z][A-Z0-9]{2} - [^/]+\.txt$/u.test(file)) ||
|
|
9
|
+
(dir === 'history/units' && /^[A-Z][A-Z0-9]{2}_world\.txt$/u.test(file)) ||
|
|
10
|
+
(['gfx/flags', 'gfx/flags/medium', 'gfx/flags/small'].includes(dir) &&
|
|
11
|
+
/^[A-Z][A-Z0-9]{2}\.tga$/u.test(file)) ||
|
|
12
|
+
[
|
|
13
|
+
'common/countries/colors.txt',
|
|
14
|
+
'common/country_tags/zz_world_countries.txt',
|
|
15
|
+
'common/bookmarks/world.txt',
|
|
16
|
+
].includes(relativePath));
|
|
17
|
+
}
|
|
4
18
|
export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots) {
|
|
19
|
+
return planWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots)
|
|
20
|
+
.changes;
|
|
21
|
+
}
|
|
22
|
+
export function planWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots, options = {}) {
|
|
5
23
|
const mapFiles = new Set([
|
|
6
24
|
'provinces.bmp',
|
|
7
25
|
'definition.csv',
|
|
@@ -29,15 +47,8 @@ export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, s
|
|
|
29
47
|
(dir === `${mapRoot}/terrain` && /^colormap_[a-z0-9_]+\.dds$/u.test(file)) ||
|
|
30
48
|
(dir === `${mapRoot}/strategicregions` && /^\d+.*\.txt$/u.test(file)) ||
|
|
31
49
|
(stateRoots.includes(dir) && /^\d+.*\.txt$/u.test(file)) ||
|
|
32
|
-
(
|
|
33
|
-
/^[A-Z][A-Z0-9]{2} - [^/]+\.txt$/u.test(file)) ||
|
|
34
|
-
(dir === 'history/units' && /^[A-Z][A-Z0-9]{2}_world\.txt$/u.test(file)) ||
|
|
35
|
-
(['gfx/flags', 'gfx/flags/medium', 'gfx/flags/small'].includes(dir) &&
|
|
36
|
-
/^[A-Z][A-Z0-9]{2}\.tga$/u.test(file)) ||
|
|
50
|
+
isWorldCountryOutput(name) ||
|
|
37
51
|
[
|
|
38
|
-
'common/countries/colors.txt',
|
|
39
|
-
'common/country_tags/zz_world_countries.txt',
|
|
40
|
-
'common/bookmarks/world.txt',
|
|
41
52
|
'common/on_actions/world.txt',
|
|
42
53
|
'common/ai_areas/world.txt',
|
|
43
54
|
'common/ai_strategy_plans/world.txt',
|
|
@@ -45,11 +56,12 @@ export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, s
|
|
|
45
56
|
localisationRoots.some((root) => name === `${root}/english/world_l_english.yml` ||
|
|
46
57
|
name === `${root}/replace/world_l_english.yml`));
|
|
47
58
|
};
|
|
48
|
-
return
|
|
59
|
+
return planManagedOutputs({
|
|
49
60
|
manifestPath: WORLD_OUTPUT_MANIFEST,
|
|
50
61
|
sources,
|
|
51
62
|
proposed,
|
|
52
63
|
operationId,
|
|
53
64
|
permitsPath,
|
|
65
|
+
...(options.releaseCountryOutputs === true ? { releasable: isWorldCountryOutput } : {}),
|
|
54
66
|
});
|
|
55
67
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-owned countries in a generated world (ADR 0069): realm points and polygons resolved to
|
|
3
|
+
* provinces, and the rule for land that no realm reached.
|
|
4
|
+
*/
|
|
5
|
+
import { ServiceError } from '../core/result.js';
|
|
6
|
+
function inside(x, y, polygon) {
|
|
7
|
+
let result = false;
|
|
8
|
+
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
|
|
9
|
+
const a = polygon[index], b = polygon[previous];
|
|
10
|
+
if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x)
|
|
11
|
+
result = !result;
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
/** Land provinces with more than half their pixels inside any of the polygons. */
|
|
16
|
+
function coveredProvinces(polygons, raster) {
|
|
17
|
+
const counts = new Map();
|
|
18
|
+
const pixels = new Set();
|
|
19
|
+
for (const polygon of polygons) {
|
|
20
|
+
const xs = polygon.map(({ x }) => x), ys = polygon.map(({ y }) => y);
|
|
21
|
+
const left = Math.max(0, Math.floor(Math.min(...xs)));
|
|
22
|
+
const right = Math.min(raster.width - 1, Math.ceil(Math.max(...xs)));
|
|
23
|
+
const top = Math.max(0, Math.floor(Math.min(...ys)));
|
|
24
|
+
const bottom = Math.min(raster.height - 1, Math.ceil(Math.max(...ys)));
|
|
25
|
+
for (let y = top; y <= bottom; y += 1)
|
|
26
|
+
for (let x = left; x <= right; x += 1) {
|
|
27
|
+
const pixel = y * raster.width + x;
|
|
28
|
+
if (pixels.has(pixel) || !inside(x + 0.5, y + 0.5, polygon))
|
|
29
|
+
continue;
|
|
30
|
+
pixels.add(pixel);
|
|
31
|
+
const province = raster.pixelProvince[pixel];
|
|
32
|
+
counts.set(province, (counts.get(province) ?? 0) + 1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const covered = new Set();
|
|
36
|
+
for (const [province, count] of [...counts].sort((left, right) => left[0] - right[0]))
|
|
37
|
+
if (raster.isLand(province) && count * 2 > raster.provinceSize[province])
|
|
38
|
+
covered.add(province);
|
|
39
|
+
return covered;
|
|
40
|
+
}
|
|
41
|
+
function pointProvince(tag, role, point, raster) {
|
|
42
|
+
const x = Math.floor(point.x), y = Math.floor(point.y);
|
|
43
|
+
if (x < 0 || y < 0 || x >= raster.width || y >= raster.height)
|
|
44
|
+
throw new ServiceError('MAP_WORLD_REALM_POINT_OUTSIDE', `The ${role} of ${tag} lies outside the ${raster.width}x${raster.height} world`, { tag, role, point });
|
|
45
|
+
const province = raster.pixelProvince[y * raster.width + x];
|
|
46
|
+
if (!raster.isLand(province))
|
|
47
|
+
throw new ServiceError('MAP_WORLD_REALM_POINT_WATER', `The ${role} of ${tag} lies in water`, {
|
|
48
|
+
tag,
|
|
49
|
+
role,
|
|
50
|
+
point,
|
|
51
|
+
});
|
|
52
|
+
return province;
|
|
53
|
+
}
|
|
54
|
+
export function resolveRealmRules(realms, raster) {
|
|
55
|
+
const unowned = realms.unowned === undefined ? new Set() : coveredProvinces(realms.unowned, raster);
|
|
56
|
+
const excluded = realms.list.map((realm) => realm.exclude === undefined ? new Set() : coveredProvinces(realm.exclude, raster));
|
|
57
|
+
const forced = new Map();
|
|
58
|
+
const claimedBy = (province) => realms.list[forced.get(province)].tag;
|
|
59
|
+
const conflict = (tag, role, other, details) => new ServiceError('MAP_WORLD_REALM_POINT_CONFLICT', `The ${role} of ${tag} lies in land ${other}`, { tag, role, ...details });
|
|
60
|
+
const capitals = [];
|
|
61
|
+
// Points first, so a claim that covers another realm's point names that point.
|
|
62
|
+
for (const [index, realm] of realms.list.entries()) {
|
|
63
|
+
for (const [role, point] of [
|
|
64
|
+
['capital', realm.capital],
|
|
65
|
+
...(realm.seeds ?? []).map((seed) => ['seed', seed]),
|
|
66
|
+
]) {
|
|
67
|
+
const province = pointProvince(realm.tag, role, point, raster);
|
|
68
|
+
if (unowned.has(province))
|
|
69
|
+
throw conflict(realm.tag, role, 'marked unowned', { point, province });
|
|
70
|
+
if (excluded[index].has(province))
|
|
71
|
+
throw conflict(realm.tag, role, 'its own exclusion covers', { point, province });
|
|
72
|
+
const holder = forced.get(province);
|
|
73
|
+
if (holder !== undefined && holder !== index)
|
|
74
|
+
throw conflict(realm.tag, role, `that a point of ${claimedBy(province)} names`, {
|
|
75
|
+
point,
|
|
76
|
+
province,
|
|
77
|
+
otherTag: claimedBy(province),
|
|
78
|
+
});
|
|
79
|
+
forced.set(province, index);
|
|
80
|
+
if (role === 'capital')
|
|
81
|
+
capitals.push(province);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const [index, realm] of realms.list.entries()) {
|
|
85
|
+
if (realm.claim === undefined)
|
|
86
|
+
continue;
|
|
87
|
+
for (const province of coveredProvinces(realm.claim, raster)) {
|
|
88
|
+
const holder = forced.get(province);
|
|
89
|
+
if (unowned.has(province) || excluded[index].has(province))
|
|
90
|
+
throw new ServiceError('MAP_WORLD_REALM_CLAIM_OVERLAP', `A claim of ${realm.tag} covers land that is ${unowned.has(province) ? 'marked unowned' : 'in its own exclusion'}`, { tag: realm.tag, province });
|
|
91
|
+
if (holder !== undefined && holder !== index)
|
|
92
|
+
throw new ServiceError('MAP_WORLD_REALM_CLAIM_OVERLAP', `A claim of ${realm.tag} covers land that ${claimedBy(province)} claims or names`, { tag: realm.tag, otherTag: claimedBy(province), province });
|
|
93
|
+
forced.set(province, index);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { capitals, forced, excluded, unowned };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Land growth did not reach: a landmass that a realm already holds goes to the nearest realm that
|
|
100
|
+
* may own it; a landmass without any realm follows the island rule.
|
|
101
|
+
*/
|
|
102
|
+
export function assignUnreachedRealmLand(input) {
|
|
103
|
+
const held = new Set();
|
|
104
|
+
for (const id of input.owner.keys())
|
|
105
|
+
held.add(input.part(id));
|
|
106
|
+
const distance = (a, b) => (a.x - b.x) ** 2 + (a.y - b.y) ** 2;
|
|
107
|
+
const nearest = (point, allowed) => {
|
|
108
|
+
let best = -1;
|
|
109
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
110
|
+
for (const [realm, capital] of input.capitals.entries()) {
|
|
111
|
+
if (!allowed(realm))
|
|
112
|
+
continue;
|
|
113
|
+
const next = distance(point, input.center(capital));
|
|
114
|
+
if (next < bestDistance) {
|
|
115
|
+
best = realm;
|
|
116
|
+
bestDistance = next;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return best;
|
|
120
|
+
};
|
|
121
|
+
const pending = input.landIds.filter((id) => !input.owner.has(id) && !input.unowned.has(id));
|
|
122
|
+
const partOwner = new Map();
|
|
123
|
+
if (input.islands === 'one-owner') {
|
|
124
|
+
const members = new Map();
|
|
125
|
+
for (const id of pending)
|
|
126
|
+
if (!held.has(input.part(id))) {
|
|
127
|
+
const list = members.get(input.part(id)) ?? [];
|
|
128
|
+
list.push(id);
|
|
129
|
+
members.set(input.part(id), list);
|
|
130
|
+
}
|
|
131
|
+
for (const [part, ids] of members) {
|
|
132
|
+
const mean = ids.reduce((sum, id) => ({ x: sum.x + input.center(id).x, y: sum.y + input.center(id).y }), { x: 0, y: 0 });
|
|
133
|
+
partOwner.set(part, nearest({ x: mean.x / ids.length, y: mean.y / ids.length }, () => true));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const id of pending) {
|
|
137
|
+
const allowed = (realm) => !input.excluded[realm].has(id);
|
|
138
|
+
const island = !held.has(input.part(id));
|
|
139
|
+
if (island && input.islands === 'unowned') {
|
|
140
|
+
input.unowned.add(id);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const preferred = island ? partOwner.get(input.part(id)) : undefined;
|
|
144
|
+
const realm = preferred !== undefined && preferred >= 0 && allowed(preferred)
|
|
145
|
+
? preferred
|
|
146
|
+
: nearest(input.center(id), allowed);
|
|
147
|
+
if (realm < 0)
|
|
148
|
+
input.unowned.add(id);
|
|
149
|
+
else
|
|
150
|
+
input.owner.set(id, realm);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -19,6 +19,7 @@ import { ambientObjectText, applyEdgePolicy, assertFeaturesOutsideBand, edgeWate
|
|
|
19
19
|
import { landDistanceField, lakeHeight, normalizeLakes, waterColourLevel } from './water.js';
|
|
20
20
|
import { overlayGeographyLakes, rasterizeGeographyKind } from './relief/land.js';
|
|
21
21
|
import { synthesizeTerrain } from './relief/terrain-synthesis.js';
|
|
22
|
+
import { assignUnreachedRealmLand, resolveRealmRules } from './world-realms.js';
|
|
22
23
|
// ---------------------------------------------------------------------------------------------
|
|
23
24
|
// Deterministic randomness and noise
|
|
24
25
|
export class Random {
|
|
@@ -763,7 +764,9 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
763
764
|
const countryCount = spec.countries.length;
|
|
764
765
|
const interpretationWarnings = [];
|
|
765
766
|
const referenceOwners = new Map();
|
|
766
|
-
if (
|
|
767
|
+
if (spec.realms === undefined &&
|
|
768
|
+
reference?.inferredCountries.length === countryCount &&
|
|
769
|
+
countryCount > 1) {
|
|
767
770
|
const indexes = new Map();
|
|
768
771
|
const unresolved = [];
|
|
769
772
|
for (const [index, country] of spec.countries.entries()) {
|
|
@@ -844,6 +847,17 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
844
847
|
});
|
|
845
848
|
const capitals = [];
|
|
846
849
|
const largest = [...landIds].sort((left, right) => provinces.size[right] - provinces.size[left] || left - right);
|
|
850
|
+
const realmRules = spec.realms === undefined
|
|
851
|
+
? undefined
|
|
852
|
+
: resolveRealmRules(spec.realms, {
|
|
853
|
+
width,
|
|
854
|
+
height,
|
|
855
|
+
pixelProvince: provinces.pixel,
|
|
856
|
+
provinceSize: provinces.size,
|
|
857
|
+
isLand: (id) => provinces.kind[id] === LAND,
|
|
858
|
+
});
|
|
859
|
+
if (realmRules !== undefined)
|
|
860
|
+
capitals.push(...realmRules.capitals);
|
|
847
861
|
while (capitals.length < countryCount) {
|
|
848
862
|
const countryIndex = capitals.length;
|
|
849
863
|
const regionCandidates = largest.filter((id) => referenceOwners.get(id) === countryIndex && !capitals.includes(id));
|
|
@@ -879,20 +893,26 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
879
893
|
}
|
|
880
894
|
const shares = spec.countries.map((country) => Math.max(0.05, country.share ?? 1));
|
|
881
895
|
const owned = new Array(countryCount).fill(0);
|
|
882
|
-
for (const [id, country] of referenceOwners) {
|
|
896
|
+
for (const [id, country] of realmRules?.forced ?? referenceOwners) {
|
|
883
897
|
owner.set(id, country);
|
|
884
898
|
owned[country] += provinces.size[id];
|
|
885
899
|
}
|
|
900
|
+
const unownedLand = realmRules?.unowned ?? new Set();
|
|
901
|
+
const blockedFor = (index, id) => unownedLand.has(id) || realmRules?.excluded[index].has(id) === true;
|
|
886
902
|
const frontiers = capitals.map((capital, index) => {
|
|
887
|
-
owner.
|
|
888
|
-
if (!referenceOwners.has(capital))
|
|
903
|
+
if (!owner.has(capital))
|
|
889
904
|
owned[index] += provinces.size[capital];
|
|
905
|
+
owner.set(capital, index);
|
|
906
|
+
if (realmRules !== undefined)
|
|
907
|
+
return [...owner]
|
|
908
|
+
.filter(([, country]) => country === index)
|
|
909
|
+
.flatMap(([id]) => [...provinces.neighbors[id]]);
|
|
890
910
|
return [...provinces.neighbors[capital]];
|
|
891
911
|
});
|
|
892
912
|
for (;;) {
|
|
893
913
|
let chosen = -1;
|
|
894
914
|
for (let index = 0; index < countryCount; index += 1) {
|
|
895
|
-
frontiers[index] = frontiers[index].filter((id) => provinces.kind[id] === LAND && !owner.has(id));
|
|
915
|
+
frontiers[index] = frontiers[index].filter((id) => provinces.kind[id] === LAND && !owner.has(id) && !blockedFor(index, id));
|
|
896
916
|
if (frontiers[index].length === 0)
|
|
897
917
|
continue;
|
|
898
918
|
if (chosen < 0 || owned[index] / shares[index] < owned[chosen] / shares[chosen])
|
|
@@ -908,9 +928,21 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
908
928
|
if (!owner.has(neighbor))
|
|
909
929
|
frontier.push(neighbor);
|
|
910
930
|
}
|
|
931
|
+
// Land no workspace country reached follows the island rule; the rest of the land is unowned.
|
|
932
|
+
if (realmRules !== undefined)
|
|
933
|
+
assignUnreachedRealmLand({
|
|
934
|
+
landIds,
|
|
935
|
+
owner,
|
|
936
|
+
unowned: unownedLand,
|
|
937
|
+
capitals,
|
|
938
|
+
excluded: realmRules.excluded,
|
|
939
|
+
islands: spec.realms?.islands ?? 'nearest',
|
|
940
|
+
part: (id) => landParts.get(id),
|
|
941
|
+
center,
|
|
942
|
+
});
|
|
911
943
|
// Islands no country reached go to the nearest capital's country.
|
|
912
944
|
for (const id of landIds)
|
|
913
|
-
if (!owner.has(id)) {
|
|
945
|
+
if (!owner.has(id) && !unownedLand.has(id)) {
|
|
914
946
|
const c = center(id);
|
|
915
947
|
let best = 0;
|
|
916
948
|
for (const [index, capital] of capitals.entries())
|
|
@@ -946,7 +978,7 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
946
978
|
// States: within each region and country, clusters of about provincesPerState.
|
|
947
979
|
const groups = new Map();
|
|
948
980
|
for (const id of landIds) {
|
|
949
|
-
const key = `${regionOf.get(id)}:${owner.get(id)}`;
|
|
981
|
+
const key = `${regionOf.get(id)}:${owner.get(id) ?? -1}`;
|
|
950
982
|
const list = groups.get(key) ?? [];
|
|
951
983
|
list.push(id);
|
|
952
984
|
groups.set(key, list);
|
|
@@ -970,7 +1002,7 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
970
1002
|
const draft = {
|
|
971
1003
|
id: states.length + 1,
|
|
972
1004
|
provinces: sorted,
|
|
973
|
-
owner: spec.countries[countryIndex].tag,
|
|
1005
|
+
...(countryIndex < 0 ? {} : { owner: spec.countries[countryIndex].tag }),
|
|
974
1006
|
region,
|
|
975
1007
|
capital: sorted[0],
|
|
976
1008
|
manpower: 0,
|
|
@@ -1031,7 +1063,7 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1031
1063
|
}[terrain.get(id) ?? 'plains'] ?? 0.5;
|
|
1032
1064
|
return sum + value * provinces.size[id];
|
|
1033
1065
|
}, 0) / pixels;
|
|
1034
|
-
const isCapital = countryCapitalState.get(state.owner) === state;
|
|
1066
|
+
const isCapital = state.owner !== undefined && countryCapitalState.get(state.owner) === state;
|
|
1035
1067
|
const development = Math.min(1, habitability * (0.5 + random.next()) * (isCapital ? 1.8 : 1));
|
|
1036
1068
|
state.manpower = Math.max(1_000, Math.round((pixels * 180 * development) / 1_000) * 1_000);
|
|
1037
1069
|
const categoryIndex = Math.max(0, Math.min(categories.length - 1, Math.round(development * 5.2 + (isCapital ? 1 : 0))));
|
|
@@ -1140,6 +1172,7 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1140
1172
|
const files = [];
|
|
1141
1173
|
const add = (relativePath, content, mediaType = 'text/plain') => files.push({ relativePath, content, mediaType });
|
|
1142
1174
|
const countryNames = new Map(spec.countries.map(({ tag, name }) => [tag, name]));
|
|
1175
|
+
const ownerName = (state) => state.owner === undefined ? 'Free Land' : (countryNames.get(state.owner) ?? state.owner);
|
|
1143
1176
|
const map = context.mapRoot;
|
|
1144
1177
|
// provinces.bmp
|
|
1145
1178
|
const rgb = new Array(width * height);
|
|
@@ -1543,8 +1576,9 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1543
1576
|
`\tresources = {\n${resources.map(([resource, amount]) => `\t\t${resource} = ${amount}`).join('\n')}\n\t}`,
|
|
1544
1577
|
]),
|
|
1545
1578
|
'\thistory = {',
|
|
1546
|
-
|
|
1547
|
-
|
|
1579
|
+
...(state.owner === undefined
|
|
1580
|
+
? []
|
|
1581
|
+
: [`\t\towner = ${state.owner}`, `\t\tadd_core_of = ${state.owner}`]),
|
|
1548
1582
|
...[...state.victoryPoints].map(([id, value]) => `\t\tvictory_points = { ${provinceId(id)} ${value} }`),
|
|
1549
1583
|
'\t\tbuildings = {',
|
|
1550
1584
|
...buildings,
|
|
@@ -1559,7 +1593,8 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1559
1593
|
const countryLocalisation = [];
|
|
1560
1594
|
const countryLines = [];
|
|
1561
1595
|
const countryColors = [];
|
|
1562
|
-
|
|
1596
|
+
// Workspace-owned countries keep their own tags, files, histories, flags and names.
|
|
1597
|
+
for (const [index, country] of (spec.realms === undefined ? spec.countries : []).entries()) {
|
|
1563
1598
|
const color = country.color ?? {
|
|
1564
1599
|
r: 60 + random.int(170),
|
|
1565
1600
|
g: 60 + random.int(170),
|
|
@@ -1658,10 +1693,12 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1658
1693
|
add(`gfx/flags/${folder}${country.tag}.tga`, encodeTga(flagWidth, flagHeight, (_x, y) => y < flagHeight / 3 ? light : y < (flagHeight * 2) / 3 ? color : dark), 'image/x-tga');
|
|
1659
1694
|
void index;
|
|
1660
1695
|
}
|
|
1661
|
-
|
|
1662
|
-
|
|
1696
|
+
if (spec.realms === undefined) {
|
|
1697
|
+
add('common/country_tags/zz_world_countries.txt', text(`${countryLines.join('\n')}\n`));
|
|
1698
|
+
add('common/countries/colors.txt', text(`${countryColors.join('\n')}\n`));
|
|
1699
|
+
}
|
|
1663
1700
|
if (spec.startingScenario) {
|
|
1664
|
-
for (const tag of context.inheritedCountryTags ?? [])
|
|
1701
|
+
for (const tag of spec.realms === undefined ? (context.inheritedCountryTags ?? []) : [])
|
|
1665
1702
|
if (!countryNames.has(tag))
|
|
1666
1703
|
add(`history/countries/${tag} - inactive world.txt`, text('# Inherited tag has no territory or active starting country in this world.\n'));
|
|
1667
1704
|
add('common/on_actions/world.txt', text('# Old-world startup hooks are replaced for this generated map.\non_actions = { }\n'));
|
|
@@ -1675,34 +1712,53 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1675
1712
|
states
|
|
1676
1713
|
.filter((state) => state.owner === left.tag)
|
|
1677
1714
|
.reduce((sum, state) => sum + state.manpower, 0) || (left.tag < right.tag ? -1 : 1));
|
|
1678
|
-
const majors = ranked.slice(0, Math.min(7, ranked.length));
|
|
1679
|
-
|
|
1680
|
-
'bookmarks
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1715
|
+
const majors = spec.realms === undefined ? ranked.slice(0, Math.min(7, ranked.length)) : [];
|
|
1716
|
+
if (spec.realms === undefined)
|
|
1717
|
+
add('common/bookmarks/world.txt', text([
|
|
1718
|
+
'bookmarks = {',
|
|
1719
|
+
'\tbookmark = {',
|
|
1720
|
+
'\t\tname = "WORLD_BOOKMARK_NAME"',
|
|
1721
|
+
'\t\tdesc = "WORLD_BOOKMARK_DESC"',
|
|
1722
|
+
`\t\tdate = ${spec.startDate}.12`,
|
|
1723
|
+
'\t\tpicture = "GFX_select_date_1936"',
|
|
1724
|
+
`\t\tdefault_country = "${majors[0].tag}"`,
|
|
1725
|
+
'\t\tdefault = yes',
|
|
1726
|
+
...majors.map((country) => `\t\t"${country.tag}" = {\n\t\t\thistory = "${country.tag}_WORLD_DESC"\n\t\t\tideology = ${country.ideology ?? 'neutrality'}\n\t\t}`),
|
|
1727
|
+
'\t\t"---" = {\n\t\t\thistory = "WORLD_OTHER_DESC"\n\t\t}',
|
|
1728
|
+
'\t\teffect = {\n\t\t\trandomize_weather = 22345\n\t\t}',
|
|
1729
|
+
'\t}',
|
|
1730
|
+
'}',
|
|
1731
|
+
'',
|
|
1732
|
+
].join('\n')));
|
|
1733
|
+
const realmSummary = () => {
|
|
1734
|
+
const landPixels = landIds.reduce((sum, id) => sum + provinces.size[id], 0);
|
|
1735
|
+
const weights = spec.countries.reduce((sum, country) => sum + (country.share ?? 1), 0);
|
|
1736
|
+
return {
|
|
1737
|
+
countries: spec.countries.map((country, index) => {
|
|
1738
|
+
const ownedStates = states.filter((state) => state.owner === country.tag);
|
|
1739
|
+
const pixels = ownedStates.reduce((sum, state) => sum + state.provinces.reduce((inner, id) => inner + provinces.size[id], 0), 0);
|
|
1740
|
+
return {
|
|
1741
|
+
tag: country.tag,
|
|
1742
|
+
capitalState: countryCapitalState.get(country.tag).id,
|
|
1743
|
+
capitalProvince: provinceId(capitals[index]),
|
|
1744
|
+
states: ownedStates.map(({ id }) => id),
|
|
1745
|
+
provinces: ownedStates.reduce((sum, state) => sum + state.provinces.length, 0),
|
|
1746
|
+
landShare: Math.round((pixels / landPixels) * 10_000) / 10_000,
|
|
1747
|
+
targetShare: Math.round(((country.share ?? 1) / weights) * 10_000) / 10_000,
|
|
1748
|
+
};
|
|
1749
|
+
}),
|
|
1750
|
+
unownedStates: states.filter((state) => state.owner === undefined).map(({ id }) => id),
|
|
1751
|
+
};
|
|
1752
|
+
};
|
|
1695
1753
|
// Localisation for everything named above.
|
|
1696
1754
|
add(`${context.localisationRoot}/replace/world_l_english.yml`, localisation([
|
|
1697
1755
|
...states.map((state) => [
|
|
1698
1756
|
`STATE_${state.id}`,
|
|
1699
|
-
stateNames.get(state.id) ??
|
|
1700
|
-
`${countryNames.get(state.owner) ?? state.owner} District ${state.id}`,
|
|
1757
|
+
stateNames.get(state.id) ?? `${ownerName(state)} District ${state.id}`,
|
|
1701
1758
|
]),
|
|
1702
1759
|
...states.flatMap((state) => [...state.victoryPoints.keys()].map((id) => [
|
|
1703
1760
|
`VICTORY_POINTS_${provinceId(id)}`,
|
|
1704
|
-
cityNames.get(id) ??
|
|
1705
|
-
`${countryNames.get(state.owner) ?? state.owner} Settlement ${provinceId(id)}`,
|
|
1761
|
+
cityNames.get(id) ?? `${ownerName(state)} Settlement ${provinceId(id)}`,
|
|
1706
1762
|
])),
|
|
1707
1763
|
...[...regionMembers.keys()]
|
|
1708
1764
|
.sort((a, b) => a - b)
|
|
@@ -1710,9 +1766,13 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1710
1766
|
...(spec.referenceLabels ?? []).map((label, index) => [`WORLD_GEOGRAPHY_${index + 1}`, label.name]),
|
|
1711
1767
|
...continentNames.map((name, index) => [`world_continent_${index + 1}`, name]),
|
|
1712
1768
|
...countryLocalisation,
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1769
|
+
...(spec.realms === undefined
|
|
1770
|
+
? [
|
|
1771
|
+
['WORLD_BOOKMARK_NAME', spec.worldName ?? 'A New World'],
|
|
1772
|
+
['WORLD_BOOKMARK_DESC', 'Nations of an unfamiliar map at the dawn of the war.'],
|
|
1773
|
+
['WORLD_OTHER_DESC', 'Any other nation of this world.'],
|
|
1774
|
+
]
|
|
1775
|
+
: []),
|
|
1716
1776
|
...majors.map((country) => [
|
|
1717
1777
|
`${country.tag}_WORLD_DESC`,
|
|
1718
1778
|
`${country.name} begins the game among the strongest nations.`,
|
|
@@ -1735,5 +1795,6 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1735
1795
|
countries: spec.countries.length,
|
|
1736
1796
|
rivers: riverCount,
|
|
1737
1797
|
},
|
|
1798
|
+
...(spec.realms === undefined ? {} : { realms: realmSummary() }),
|
|
1738
1799
|
};
|
|
1739
1800
|
}
|