hoi4-agent-tools 4.4.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +24 -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/border-quality.js +25 -7
  9. package/dist/hoi4_agent_tools/map/derived.js +16 -3
  10. package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
  11. package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
  12. package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
  13. package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
  14. package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
  15. package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
  16. package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
  17. package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
  18. package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
  19. package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
  20. package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
  21. package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
  22. package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
  23. package/dist/hoi4_agent_tools/map/model.js +2 -1
  24. package/dist/hoi4_agent_tools/map/operations.js +282 -38
  25. package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
  26. package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
  27. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +43 -0
  28. package/dist/hoi4_agent_tools/map/service.js +93 -16
  29. package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
  30. package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
  31. package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
  32. package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
  33. package/dist/hoi4_agent_tools/map/territory.js +1108 -0
  34. package/dist/hoi4_agent_tools/map/tool-operations.js +119 -1
  35. package/dist/hoi4_agent_tools/map/validation.js +4 -0
  36. package/dist/hoi4_agent_tools/map/world-context.js +23 -0
  37. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  38. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  39. package/dist/hoi4_agent_tools/map/world.js +400 -145
  40. package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
  41. package/dist/hoi4_agent_tools/schemas/map.js +277 -34
  42. package/dist/hoi4_agent_tools/version.js +1 -1
  43. package/docs/focus.md +6 -0
  44. package/docs/map.md +132 -4
  45. package/npm-shrinkwrap.json +2 -2
  46. package/package.json +1 -1
  47. package/schemas/configuration.schema.json +1 -1
  48. package/schemas/continuous-focus-palette.schema.json +1 -1
  49. package/schemas/custom-weighted-pool.schema.json +1 -1
  50. package/schemas/decision-inspect-input.schema.json +1 -1
  51. package/schemas/error-log-input.schema.json +1 -1
  52. package/schemas/event-feature-manifest.schema.json +1 -1
  53. package/schemas/focus-plan.schema.json +1 -1
  54. package/schemas/focus-planning-sidecar.schema.json +1 -1
  55. package/schemas/geography-model.schema.json +13 -1
  56. package/schemas/gui-animation-source.schema.json +1 -1
  57. package/schemas/gui-helper.schema.json +1 -1
  58. package/schemas/gui-scenario.schema.json +1 -1
  59. package/schemas/helper-expansion-request.schema.json +1 -1
  60. package/schemas/helper-expansion-summary.schema.json +1 -1
  61. package/schemas/impact-inspect-input.schema.json +1 -1
  62. package/schemas/map-operation.schema.json +1541 -786
  63. package/schemas/mechanic-test-input.schema.json +1 -1
  64. package/schemas/mod-index-input.schema.json +1 -1
  65. package/schemas/operation-result.schema.json +1 -1
  66. package/schemas/package-check-input.schema.json +1 -1
  67. package/schemas/package-manifest.schema.json +1 -1
  68. package/schemas/probability-analysis-result.schema.json +1 -1
  69. package/schemas/probability-compare-input.schema.json +1 -1
  70. package/schemas/probability-evaluate-input.schema.json +1 -1
  71. package/schemas/probability-inspect-input.schema.json +1 -1
  72. package/schemas/probability-render-input.schema.json +1 -1
  73. package/schemas/probability-scenario-set.schema.json +1 -1
  74. package/schemas/probability-sequence-input.schema.json +1 -1
  75. package/schemas/probability-simulate-input.schema.json +1 -1
  76. package/schemas/probability-sweep-input.schema.json +1 -1
  77. package/schemas/reference-context-input.schema.json +1 -1
  78. package/schemas/reference-read-input.schema.json +1 -1
  79. package/schemas/reference-search-input.schema.json +1 -1
  80. package/schemas/reference-sheets.schema.json +1 -1
  81. package/schemas/scenario-suite.schema.json +1 -1
  82. package/schemas/scenario-test-input.schema.json +1 -1
  83. package/schemas/script-validate-input.schema.json +1 -1
  84. package/schemas/shared-scenario.schema.json +1 -1
  85. package/schemas/source-lookup-input.schema.json +1 -1
  86. package/server.json +2 -2
@@ -6,7 +6,8 @@ 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
+ import { compactTerritoryReport } from './territory-operations.js';
10
11
  import { borderQualityDiagnostics } from './border-quality.js';
11
12
  import { measureIndexBorderNaturalness, summarizeBorderQuality, } from './border-quality-input.js';
12
13
  import { buildMapCatalog, lookupMapCoordinate, searchMapCatalog } from './catalog.js';
@@ -301,6 +302,10 @@ export async function prepareMapRewrite(nudger, input, context) {
301
302
  semanticDiff: compactSemantic.semantic,
302
303
  semanticDiffCounts: compactSemantic.counts,
303
304
  semanticDiffTruncated: compactSemantic.truncated,
305
+ ...(planned.plan.territory === undefined
306
+ ? {}
307
+ : { territory: planned.plan.territory.map(compactTerritoryReport) }),
308
+ ...worldOutputsData(planned.plan),
304
309
  }),
305
310
  filesScanned: planned.filesScanned,
306
311
  planBlockers: planned.plan.blockers
@@ -309,6 +314,44 @@ export async function prepareMapRewrite(nudger, input, context) {
309
314
  }),
310
315
  };
311
316
  }
317
+ /** Counts and sorted samples of the managed outputs each generated world writes, retires and releases. */
318
+ function worldOutputsData(plan) {
319
+ if (plan.worldOutputs === undefined)
320
+ return {};
321
+ const sample = (paths) => paths.slice(0, WORLD_OUTPUT_PATH_SAMPLE);
322
+ const realms = plan.worldOutputs.flatMap(({ operationId, realms: placed }) => placed === undefined
323
+ ? []
324
+ : [
325
+ {
326
+ operationId,
327
+ countries: placed.countries.slice(0, 100).map((country) => ({
328
+ tag: country.tag,
329
+ capitalState: country.capitalState,
330
+ capitalProvince: country.capitalProvince,
331
+ states: country.states.length,
332
+ provinces: country.provinces,
333
+ landShare: country.landShare,
334
+ targetShare: country.targetShare,
335
+ })),
336
+ countryCount: placed.countries.length,
337
+ unownedStates: placed.unownedStates.slice(0, WORLD_OUTPUT_PATH_SAMPLE),
338
+ unownedStateCount: placed.unownedStates.length,
339
+ },
340
+ ]);
341
+ return {
342
+ ...(realms.length === 0 ? {} : { realms: realms.slice(0, 16) }),
343
+ outputs: plan.worldOutputs.slice(0, 16).map((world) => ({
344
+ operationId: world.operationId,
345
+ written: world.written.length,
346
+ retired: world.retired.length,
347
+ released: world.released.length,
348
+ retiredFiles: sample(world.retired),
349
+ releasedFiles: sample(world.released),
350
+ truncated: world.retired.length > WORLD_OUTPUT_PATH_SAMPLE ||
351
+ world.released.length > WORLD_OUTPUT_PATH_SAMPLE,
352
+ })),
353
+ };
354
+ }
312
355
  export function completeMapRewrite(workspaceId, execution, recipe) {
313
356
  const parsed = mapRewriteRecipeSchema.parse(recipe);
314
357
  const transaction = execution.transaction;
@@ -464,8 +507,79 @@ async function previewGeographyRewrite(nudger, input, context) {
464
507
  await context.progress(3, 3, 'Geography model preview complete; workspace files were not changed');
465
508
  return result;
466
509
  }
510
+ /**
511
+ * `create_world.synthesisCheck`: run the model's relief and river synthesis only and report every
512
+ * river that cannot reach its mouth, with its divide. No map is generated and nothing is written.
513
+ */
514
+ async function previewSynthesisCheck(nudger, input, context) {
515
+ await context.progress(0, 2, 'Synthesising relief and rivers to check every river');
516
+ const checked = await nudger.checkSynthesis({
517
+ workspaceId: context.workspaceId,
518
+ operations: input.operations,
519
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
520
+ ...(context.signal === undefined ? {} : { signal: context.signal }),
521
+ });
522
+ const blocked = checked.diagnostics.filter(({ severity }) => severity === 'error' || severity === 'blocker');
523
+ const valid = blocked.length === 0 && checked.plan.blockers.length === 0;
524
+ const rivers = checked.diagnostics.flatMap(({ code, details }) => code === 'MAP_RIVER_UNREACHABLE_MOUTH' && Array.isArray(details?.rivers)
525
+ ? details.rivers
526
+ : []);
527
+ // Drawn rivers are authoritative: crossings of relief they cut through are warnings (ADR 0071).
528
+ const carved = checked.diagnostics.flatMap(({ code, details }) => code === 'MAP_RIVER_CARVED_CROSSING' && details !== undefined ? [details] : []);
529
+ const compactSemantic = compactMapSemanticDiff(EMPTY_SEMANTIC_DIFF);
530
+ const result = emptyServiceResult(context.workspaceId, {
531
+ execution: 'preview',
532
+ allocations: [],
533
+ operationBlockers: [],
534
+ expectedChangedBounds: null,
535
+ changedProvinceIds: [],
536
+ changedProvinceCount: 0,
537
+ semanticDiff: compactSemantic.semantic,
538
+ semanticDiffCounts: compactSemantic.counts,
539
+ semanticDiffTruncated: compactSemantic.truncated,
540
+ fileCount: 0,
541
+ artifactCount: checked.artifacts.length,
542
+ geography: checked.geography.slice(0, 16).map(geographySummary),
543
+ synthesisCheck: {
544
+ passed: valid,
545
+ unreachableRivers: rivers.length,
546
+ rivers: rivers.slice(0, 200),
547
+ carvedCrossings: carved.length,
548
+ crossings: carved.slice(0, 200),
549
+ },
550
+ });
551
+ result.status = valid ? 'ok' : 'blocked';
552
+ result.code = valid ? 'MAP_SYNTHESIS_CHECK_READY' : 'MAP_SYNTHESIS_CHECK_BLOCKED';
553
+ setInlineFilesScanned(result, checked.filesScanned);
554
+ result.proposedFiles = [];
555
+ result.diagnostics = checked.diagnostics.slice(0, 100);
556
+ result.artifacts = checked.artifacts.map(publicArtifactLink);
557
+ result.validation = {
558
+ passed: valid,
559
+ checks: [
560
+ {
561
+ id: 'river-synthesis',
562
+ passed: valid,
563
+ message: valid
564
+ ? 'Every river of the model reaches its mouth'
565
+ : `${rivers.length || blocked.length} river(s) or model parts block the synthesis; see MAP_RIVER_UNREACHABLE_MOUTH details.rivers`,
566
+ },
567
+ ],
568
+ };
569
+ result.blockers = [
570
+ ...checked.plan.blockers.map(({ code, message, details }) => ({
571
+ code,
572
+ message,
573
+ ...(details === undefined ? {} : { details }),
574
+ })),
575
+ ].slice(0, 100);
576
+ await context.progress(2, 2, 'River check complete; nothing was written');
577
+ return result;
578
+ }
467
579
  /** Generate and validate the same proposed map without creating a write transaction. */
468
580
  export async function previewMapRewrite(nudger, input, context) {
581
+ if (input.operations.some((operation) => operation.kind === 'create_world' && operation.synthesisCheck === true))
582
+ return previewSynthesisCheck(nudger, input, context);
469
583
  if (input.operations.some((operation) => operation.kind === 'create_world' && operationHasGeography(operation)))
470
584
  return previewGeographyRewrite(nudger, input, context);
471
585
  await context.progress(0, 5, 'Generating and validating an offline map preview');
@@ -490,8 +604,12 @@ export async function previewMapRewrite(nudger, input, context) {
490
604
  semanticDiff: compactSemantic.semantic,
491
605
  semanticDiffCounts: compactSemantic.counts,
492
606
  semanticDiffTruncated: compactSemantic.truncated,
607
+ ...(planned.plan.territory === undefined
608
+ ? {}
609
+ : { territory: planned.plan.territory.map(compactTerritoryReport) }),
493
610
  fileCount: planned.plan.changes.length,
494
611
  artifactCount: planned.artifacts.length,
612
+ ...worldOutputsData(planned.plan),
495
613
  ...(input.operations.some(({ kind }) => kind === 'create_world') &&
496
614
  generatedWorldOperationIds(planned.plan).size === 0
497
615
  ? {}
@@ -3,6 +3,7 @@ import { sortDiagnostics } from '../core/diagnostics.js';
3
3
  import { assertRenderDimensions } from '../core/render-budget.js';
4
4
  import { addActiveMapDiagnostic, addMapDiagnostic } from './diagnostic-limit.js';
5
5
  import { validateAppearance } from './appearance-validation.js';
6
+ import { validateCrossings } from './gameplay/crossing-validation.js';
6
7
  import { rgbKey } from './bmp.js';
7
8
  import { MAP_PROVINCE_ENGINE_MAX_PIXELS } from './model.js';
8
9
  const MAP_DIAGNOSTIC_DETAIL_SAMPLE_LIMIT = 100;
@@ -1206,6 +1207,8 @@ function* validateMapSteps(index, options = {}) {
1206
1207
  yield* cancellationCheckpoint(options.signal);
1207
1208
  yield* validateNetworksAndAdjacencies(index, diagnostics, options);
1208
1209
  yield* cancellationCheckpoint(options.signal);
1210
+ yield* validateCrossings(index, (diagnostic) => addDiagnostic(diagnostics, options, { ...diagnostic, category: 'map' }));
1211
+ yield* cancellationCheckpoint(options.signal);
1209
1212
  yield* validatePositions(index, diagnostics, options);
1210
1213
  yield* cancellationCheckpoint(options.signal);
1211
1214
  yield* validateAppearance(index, (diagnostic) => addDiagnostic(diagnostics, options, { ...diagnostic, category: 'map' }), { signal: options.signal, edgeMode: options.edgeMode });
@@ -1247,6 +1250,7 @@ function* validateMapSteps(index, options = {}) {
1247
1250
  passed: !sorted.some(({ code, severity }) => (severity === 'error' || severity === 'blocker') &&
1248
1251
  (code.startsWith('MAP_ADJACENCY') ||
1249
1252
  code.startsWith('MAP_IMPASSABLE') ||
1253
+ code.startsWith('MAP_STRAIT') ||
1250
1254
  code.startsWith('MAP_SEA_ADJACENCY') ||
1251
1255
  code.startsWith('MAP_SUPPLY') ||
1252
1256
  code.startsWith('MAP_RAILWAY'))),
@@ -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
+ }