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.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
  3. package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
  4. package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
  5. package/dist/hoi4_agent_tools/focus/drift.js +34 -0
  6. package/dist/hoi4_agent_tools/focus/service.js +9 -3
  7. package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
  8. package/dist/hoi4_agent_tools/map/model.js +2 -1
  9. package/dist/hoi4_agent_tools/map/operations.js +173 -6
  10. package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
  11. package/dist/hoi4_agent_tools/map/service.js +15 -0
  12. package/dist/hoi4_agent_tools/map/tool-operations.js +41 -1
  13. package/dist/hoi4_agent_tools/map/world-context.js +23 -0
  14. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  15. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  16. package/dist/hoi4_agent_tools/map/world.js +100 -39
  17. package/dist/hoi4_agent_tools/schemas/map.js +71 -13
  18. package/dist/hoi4_agent_tools/version.js +1 -1
  19. package/docs/focus.md +6 -0
  20. package/docs/map.md +34 -0
  21. package/npm-shrinkwrap.json +2 -2
  22. package/package.json +1 -1
  23. package/schemas/configuration.schema.json +1 -1
  24. package/schemas/continuous-focus-palette.schema.json +1 -1
  25. package/schemas/custom-weighted-pool.schema.json +1 -1
  26. package/schemas/decision-inspect-input.schema.json +1 -1
  27. package/schemas/error-log-input.schema.json +1 -1
  28. package/schemas/event-feature-manifest.schema.json +1 -1
  29. package/schemas/focus-plan.schema.json +1 -1
  30. package/schemas/focus-planning-sidecar.schema.json +1 -1
  31. package/schemas/geography-model.schema.json +1 -1
  32. package/schemas/gui-animation-source.schema.json +1 -1
  33. package/schemas/gui-helper.schema.json +1 -1
  34. package/schemas/gui-scenario.schema.json +1 -1
  35. package/schemas/helper-expansion-request.schema.json +1 -1
  36. package/schemas/helper-expansion-summary.schema.json +1 -1
  37. package/schemas/impact-inspect-input.schema.json +1 -1
  38. package/schemas/map-operation.schema.json +725 -589
  39. package/schemas/mechanic-test-input.schema.json +1 -1
  40. package/schemas/mod-index-input.schema.json +1 -1
  41. package/schemas/operation-result.schema.json +1 -1
  42. package/schemas/package-check-input.schema.json +1 -1
  43. package/schemas/package-manifest.schema.json +1 -1
  44. package/schemas/probability-analysis-result.schema.json +1 -1
  45. package/schemas/probability-compare-input.schema.json +1 -1
  46. package/schemas/probability-evaluate-input.schema.json +1 -1
  47. package/schemas/probability-inspect-input.schema.json +1 -1
  48. package/schemas/probability-render-input.schema.json +1 -1
  49. package/schemas/probability-scenario-set.schema.json +1 -1
  50. package/schemas/probability-sequence-input.schema.json +1 -1
  51. package/schemas/probability-simulate-input.schema.json +1 -1
  52. package/schemas/probability-sweep-input.schema.json +1 -1
  53. package/schemas/reference-context-input.schema.json +1 -1
  54. package/schemas/reference-read-input.schema.json +1 -1
  55. package/schemas/reference-search-input.schema.json +1 -1
  56. package/schemas/reference-sheets.schema.json +1 -1
  57. package/schemas/scenario-suite.schema.json +1 -1
  58. package/schemas/scenario-test-input.schema.json +1 -1
  59. package/schemas/script-validate-input.schema.json +1 -1
  60. package/schemas/shared-scenario.schema.json +1 -1
  61. package/schemas/source-lookup-input.schema.json +1 -1
  62. 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 { reconcileManagedOutputs } from '../core/managed-outputs.js';
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
- (['common/countries', 'history/countries'].includes(dir) &&
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 reconcileManagedOutputs({
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 (reference?.inferredCountries.length === countryCount && countryCount > 1) {
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.set(capital, index);
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
- `\t\towner = ${state.owner}`,
1547
- `\t\tadd_core_of = ${state.owner}`,
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
- for (const [index, country] of spec.countries.entries()) {
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
- add('common/country_tags/zz_world_countries.txt', text(`${countryLines.join('\n')}\n`));
1662
- add('common/countries/colors.txt', text(`${countryColors.join('\n')}\n`));
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
- add('common/bookmarks/world.txt', text([
1680
- 'bookmarks = {',
1681
- '\tbookmark = {',
1682
- '\t\tname = "WORLD_BOOKMARK_NAME"',
1683
- '\t\tdesc = "WORLD_BOOKMARK_DESC"',
1684
- `\t\tdate = ${spec.startDate}.12`,
1685
- '\t\tpicture = "GFX_select_date_1936"',
1686
- `\t\tdefault_country = "${majors[0].tag}"`,
1687
- '\t\tdefault = yes',
1688
- ...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}`),
1689
- '\t\t"---" = {\n\t\t\thistory = "WORLD_OTHER_DESC"\n\t\t}',
1690
- '\t\teffect = {\n\t\t\trandomize_weather = 22345\n\t\t}',
1691
- '\t}',
1692
- '}',
1693
- '',
1694
- ].join('\n')));
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
- ['WORLD_BOOKMARK_NAME', spec.worldName ?? 'A New World'],
1714
- ['WORLD_BOOKMARK_DESC', 'Nations of an unfamiliar map at the dawn of the war.'],
1715
- ['WORLD_OTHER_DESC', 'Any other nation of this world.'],
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
  }