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
@@ -23,6 +23,7 @@ import { WORLD_OUTPUT_MANIFEST } from './world-outputs.js';
23
23
  import { renderMap, renderMapDiff, } from './render.js';
24
24
  import { validateMapAsync } from './validation.js';
25
25
  import { buildWorldPreviewArtifacts, generatedWorldOperationIds } from './world-preview.js';
26
+ import { evaluateTerritoryQuality, territoryArtifactContent, } from './territory-quality.js';
26
27
  /**
27
28
  * Evidence of an imported reference: registered sheets, an exact or frame-fitted picture, or a
28
29
  * geography model beside the picture. Plain pictures keep their previous output.
@@ -625,7 +626,7 @@ export class AgentNudger {
625
626
  this.scanner = this.engine.scanner;
626
627
  this.transactions = this.engine.transactions;
627
628
  }
628
- async scan(workspaceId, principal, signal, includeExternalScriptReferences = false, includeWorldContext = false, includeAppearance = false) {
629
+ async scan(workspaceId, principal, signal, includeExternalScriptReferences = false, includeWorldContext = false, includeAppearance = false, includeBorderInputs = false) {
629
630
  signal?.throwIfAborted();
630
631
  const workspace = this.resolver.get(workspaceId, principal);
631
632
  const sourceRoots = {
@@ -634,7 +635,9 @@ export class AgentNudger {
634
635
  localisation: workspace.registration.roots.localisation,
635
636
  };
636
637
  const selectorSnapshot = await this.engine.scan(workspaceId, { patterns: defaultMapPatterns(sourceRoots) }, principal, signal);
637
- const bitmapPatterns = provinceBitmapPatterns(selectorSnapshot.files, sourceRoots.map, includeAppearance ? BORDER_BITMAP_SELECTORS : PROVINCE_BITMAP_SELECTORS);
638
+ const bitmapPatterns = provinceBitmapPatterns(selectorSnapshot.files, sourceRoots.map, includeAppearance || includeBorderInputs
639
+ ? BORDER_BITMAP_SELECTORS
640
+ : PROVINCE_BITMAP_SELECTORS);
638
641
  const contentSnapshot = await this.engine.scan(workspaceId, { patterns: [...sourceTextPatterns(sourceRoots), ...bitmapPatterns] }, principal, signal);
639
642
  const locatorSnapshot = await this.engine.scan(workspaceId, { patterns: ['**/*.asset'], rootKinds: ['mod', 'fixture'] }, principal, signal);
640
643
  const externalScriptSnapshot = includeExternalScriptReferences
@@ -727,11 +730,11 @@ export class AgentNudger {
727
730
  scriptReferences: { references: consistency.references, missing: consistency.missing },
728
731
  };
729
732
  }
730
- async prepareOperations(workspaceId, operations, principal, signal) {
733
+ async prepareOperations(workspaceId, operations, principal, signal, synthesisCheck = false) {
731
734
  signal?.throwIfAborted();
732
735
  const snapshot = await this.scan(workspaceId, principal, signal, operations.some((operation) => operation.kind === 'renumber_map_entity' ||
733
736
  operation.kind === 'remap_map_references' ||
734
- 'remapReferences' in operation), operations.some((operation) => operation.kind === 'create_world'));
737
+ 'remapReferences' in operation), operations.some((operation) => operation.kind === 'create_world'), false, operations.some((operation) => operation.kind === 'assign_territory' || operation.kind === 'repartition_states'));
735
738
  const referenceWorlds = new Map();
736
739
  const referenceImages = [];
737
740
  const referenceLoads = new Map();
@@ -796,28 +799,56 @@ export class AgentNudger {
796
799
  const plan = await planMapOperationsAsync(snapshot.index, operations, signal, {
797
800
  referenceWorlds,
798
801
  geographyModels,
802
+ ...(synthesisCheck ? { synthesisCheck: true } : {}),
799
803
  });
800
804
  // Model diagnostics (including warnings of a model that is otherwise usable) travel with the plan.
801
805
  for (const geography of geographyModels.values())
802
806
  plan.diagnostics.push(...geography.diagnostics);
807
+ // Border naturalness of what would be written, for the operations that ask for it.
808
+ const territoryQuality = await evaluateTerritoryQuality(plan, signal);
809
+ if (territoryQuality !== undefined) {
810
+ plan.diagnostics.push(...territoryQuality.diagnostics);
811
+ plan.blockers.push(...territoryQuality.blockers);
812
+ for (const report of plan.territory ?? []) {
813
+ const found = territoryQuality.operations.find(({ operationId }) => operationId === report.operationId);
814
+ if (found !== undefined)
815
+ report.borderQuality.verdicts = found.layers.map(({ layer, report: measured, check }) => ({
816
+ layer,
817
+ status: measured.status,
818
+ enrichment: measured.barrier.enrichment,
819
+ naturalShare: measured.natural.share,
820
+ straightFraction: measured.straightness.fraction,
821
+ passed: check.passed,
822
+ }));
823
+ }
824
+ }
803
825
  plan.diagnostics.push(...importDiagnostics);
804
826
  const newWorld = operations.some(({ kind }) => kind === 'create_world');
805
- const freshWorld = newWorld && snapshot.index.provinceBitmapFile?.rootKind !== 'mod';
806
- const baselineValidation = freshWorld
827
+ // A new world replaces the whole map: findings of the map it replaces are not its baseline,
828
+ // so every finding of the generated map is judged on its own and belongs to the world
829
+ // operation (ADR 0070).
830
+ const baselineValidation = newWorld
807
831
  ? { passed: true, diagnostics: [], checks: [] }
808
832
  : await validateMapAsync(snapshot.index, signal === undefined ? {} : { signal });
809
833
  // A new world has nothing to compare its bitmap format and pixels with.
810
834
  const worldOperation = operations.find((operation) => operation.kind === 'create_world');
811
- const rawValidation = await validateMapAsync(plan.finalIndex, {
812
- ...(newWorld ? {} : { baseline: snapshot.index }),
813
- ...(worldOperation?.kind === 'create_world' && worldOperation.edge !== undefined
814
- ? { edgeMode: worldOperation.edge.mode ?? 'sea-band' }
815
- : {}),
816
- ...(signal === undefined ? {} : { signal }),
817
- ...(plan.expectedChangedBounds === undefined
818
- ? {}
819
- : { expectedChangedBounds: plan.expectedChangedBounds }),
820
- });
835
+ // A world that failed to generate left the workspace map unchanged; validating that map would
836
+ // report only its old findings, so the generation blocker stands alone.
837
+ const worldFailed = worldOperation !== undefined &&
838
+ (plan.blockers.some(({ operationId }) => operationId === worldOperation.id) ||
839
+ synthesisCheck);
840
+ const rawValidation = worldFailed
841
+ ? { passed: true, diagnostics: [], checks: [] }
842
+ : await validateMapAsync(plan.finalIndex, {
843
+ ...(newWorld ? {} : { baseline: snapshot.index }),
844
+ ...(worldOperation?.kind === 'create_world' && worldOperation.edge !== undefined
845
+ ? { edgeMode: worldOperation.edge.mode ?? 'sea-band' }
846
+ : {}),
847
+ ...(signal === undefined ? {} : { signal }),
848
+ ...(plan.expectedChangedBounds === undefined
849
+ ? {}
850
+ : { expectedChangedBounds: plan.expectedChangedBounds }),
851
+ });
821
852
  const attributedValidation = {
822
853
  ...rawValidation,
823
854
  diagnostics: attributeMapValidationDiagnostics({
@@ -833,6 +864,7 @@ export class AgentNudger {
833
864
  validation: validationWithPlan(attributedValidation, plan),
834
865
  referenceImages,
835
866
  geography: [...geographyModels.values()],
867
+ territory: { reports: plan.territory ?? [], quality: territoryQuality },
836
868
  imports,
837
869
  };
838
870
  }
@@ -992,6 +1024,25 @@ export class AgentNudger {
992
1024
  }, prepared);
993
1025
  return { ...diff, ...planned };
994
1026
  }
1027
+ /**
1028
+ * Run the relief and river synthesis of every `create_world` with `synthesisCheck` and report
1029
+ * the model's rivers that cannot reach their mouths, without generating or writing anything.
1030
+ */
1031
+ async checkSynthesis(input) {
1032
+ const prepared = await this.prepareOperations(input.workspaceId, input.operations, input.principal, input.signal, true);
1033
+ const writes = await this.geographyArtifacts(prepared, input.signal, new RenderBudget());
1034
+ const artifacts = await this.artifacts.withAtomicChunkedWrites(this.resolver.get(input.workspaceId, input.principal), writes, (stored) => Promise.resolve([...stored]), input.signal);
1035
+ return {
1036
+ plan: prepared.plan,
1037
+ geography: prepared.geography,
1038
+ diagnostics: sortDiagnostics([
1039
+ ...prepared.plan.diagnostics,
1040
+ ...prepared.geography.flatMap(({ diagnostics }) => diagnostics),
1041
+ ]),
1042
+ artifacts,
1043
+ filesScanned: mapSourceEvidence(prepared.snapshot).map(({ displayPath }) => displayPath),
1044
+ };
1045
+ }
995
1046
  /** Build validated offline evidence without planning or applying a write transaction. */
996
1047
  async previewRewriteWithDiff(input) {
997
1048
  const prepared = await this.prepareOperations(input.workspaceId, input.operations, input.principal, input.signal);
@@ -1295,8 +1346,34 @@ export class AgentNudger {
1295
1346
  provenance: comparisonProvenance('before'),
1296
1347
  });
1297
1348
  writes.push(...(await this.geographyArtifacts(prepared, options.signal, budget)));
1349
+ if (prepared.territory.reports.length > 0 || prepared.territory.quality !== undefined)
1350
+ writes.push({
1351
+ name: 'map-territory-report.json',
1352
+ mimeType: 'application/json',
1353
+ content: `${canonicalJson(territoryArtifactContent(prepared.territory.reports, prepared.territory.quality))}\n`,
1354
+ provenance: {
1355
+ ...provenance,
1356
+ kind: 'map-territory-report',
1357
+ schemaVersion: 'map-territory.v1',
1358
+ },
1359
+ });
1298
1360
  for (const [ordinal, imported] of prepared.imports.entries())
1299
1361
  writes.push(...(await buildImportArtifacts(imported, ordinal)));
1362
+ if (plan.worldOutputs !== undefined)
1363
+ writes.push({
1364
+ name: 'map-world-outputs.json',
1365
+ mimeType: 'application/json',
1366
+ content: `${canonicalJson({ schemaVersion: 1, worlds: plan.worldOutputs.map(({ realms: _realms, ...outputs }) => outputs) })}\n`,
1367
+ provenance,
1368
+ });
1369
+ const realmWorlds = (plan.worldOutputs ?? []).flatMap(({ operationId, realms }) => realms === undefined ? [] : [{ operationId, ...realms }]);
1370
+ if (realmWorlds.length > 0)
1371
+ writes.push({
1372
+ name: 'map-world-realms.json',
1373
+ mimeType: 'application/json',
1374
+ content: `${canonicalJson({ schemaVersion: 1, worlds: realmWorlds })}\n`,
1375
+ provenance,
1376
+ });
1300
1377
  const generatedWorlds = generatedWorldOperationIds(plan);
1301
1378
  if (generatedWorlds.size > 0)
1302
1379
  writes.push(...(await buildWorldPreviewArtifacts({
@@ -0,0 +1,378 @@
1
+ import { compareCodeUnits } from '../core/canonical.js';
2
+ import { ServiceError } from '../core/result.js';
3
+ import { assignments, encodeSource, parseLocalisation } from '../core/source/index.js';
4
+ import { parseMapDate } from './model.js';
5
+ import { addChange, appendLocalisationEntry, applyTextRanges, assertDatedStateHistorySafe, buildingPositionLine, cloneState, commitState, compileState, currentIndex, escapedLocalisationValue, isLevelStateBuilding, localisationEntryLine, maximumObservedId, normalizeRelative, regionMembershipId, removalContent, remapScriptReferences, requireSafeLocalisationDocument, rootEvidence, uniqueStrings, } from './operations.js';
6
+ /**
7
+ * Split `total` into whole shares proportional to `weights` so the parts add up exactly. Integers
8
+ * use the largest-remainder rule; a value with decimals is shared in hundredths.
9
+ */
10
+ export function apportion(total, weights) {
11
+ const scale = Number.isInteger(total) ? 1 : 100;
12
+ const units = Math.round(total * scale);
13
+ const sum = weights.reduce((acc, weight) => acc + weight, 0);
14
+ if (weights.length === 0)
15
+ return [];
16
+ if (sum <= 0) {
17
+ const shares = weights.map(() => 0);
18
+ shares[0] = units;
19
+ return shares.map((share) => share / scale);
20
+ }
21
+ const raw = weights.map((weight) => (units * weight) / sum);
22
+ const shares = raw.map((value) => Math.floor(value));
23
+ let remaining = units - shares.reduce((acc, share) => acc + share, 0);
24
+ const order = raw
25
+ .map((value, index) => ({ index, remainder: value - Math.floor(value) }))
26
+ .sort((left, right) => right.remainder - left.remainder || left.index - right.index);
27
+ for (let position = 0; remaining > 0 && order.length > 0; position += 1, remaining -= 1)
28
+ shares[order[position % order.length].index] += 1;
29
+ return shares.map((share) => share / scale);
30
+ }
31
+ function statesOfGroups(index, groups) {
32
+ const owner = new Map();
33
+ const seen = new Map();
34
+ for (const [position, group] of groups.entries()) {
35
+ if (group.provinces.length === 0)
36
+ throw new ServiceError('MAP_STATE_WOULD_BE_EMPTY', `State group ${position} has no provinces`);
37
+ for (const province of group.provinces) {
38
+ if (seen.has(province))
39
+ throw new ServiceError('MAP_STATE_PROVINCE_SELECTION_INVALID', `Province ${province} appears in state groups ${seen.get(province)} and ${position}`, { province });
40
+ seen.set(province, position);
41
+ const states = index.stateForProvince(province);
42
+ if (states.length !== 1)
43
+ throw new ServiceError('MAP_STATE_MEMBERSHIP_AMBIGUOUS', `Province ${province} must belong to exactly one state to be regrouped`, { province, states: states.map(({ id }) => id) });
44
+ owner.set(province, states[0]);
45
+ }
46
+ }
47
+ return { stateOfProvince: owner, groupOfProvince: seen };
48
+ }
49
+ /** Rewrite the state files so that exactly `groups` hold the provinces of the states they touch. */
50
+ export function restructureStates(base, changes, allocations, groups, options) {
51
+ const index = currentIndex(base, changes);
52
+ const operationId = options.operationId;
53
+ const { stateOfProvince, groupOfProvince } = statesOfGroups(index, groups);
54
+ const affected = new Map();
55
+ for (const state of stateOfProvince.values())
56
+ affected.set(state.id, state);
57
+ if (affected.size === 0)
58
+ return { changed: [], created: [], removed: [], heirs: [], stateIds: [] };
59
+ // The groups must cover every province of every state they touch.
60
+ for (const state of affected.values())
61
+ for (const province of state.provinces)
62
+ if (!groupOfProvince.has(province))
63
+ throw new ServiceError('MAP_STATE_RESTRUCTURE_INCOMPLETE', `State ${state.id} would lose province ${province} to no state; every province of a regrouped state needs a group`, { stateId: state.id, province });
64
+ const pixels = (province) => index.raster?.geometry.get(province)?.pixelCount ?? 0;
65
+ // Pixel overlap of every group with every old state.
66
+ const overlap = groups.map(() => new Map());
67
+ const stateTotals = new Map();
68
+ for (const [position, group] of groups.entries())
69
+ for (const province of group.provinces) {
70
+ const state = stateOfProvince.get(province);
71
+ const size = pixels(province);
72
+ overlap[position].set(state.id, (overlap[position].get(state.id) ?? 0) + size);
73
+ }
74
+ for (const state of affected.values())
75
+ stateTotals.set(state.id, state.provinces.reduce((sum, province) => sum + pixels(province), 0));
76
+ for (const [stateId, total] of stateTotals)
77
+ if (total <= 0)
78
+ throw new ServiceError('MAP_STATE_DISTRIBUTION_GEOMETRY_MISSING', `State ${stateId} has no province geometry; proportional distribution needs the province map`, { stateId });
79
+ // Heirs: the biggest overlaps first; ties go to the lower state id and group index.
80
+ const pairs = [];
81
+ for (const [position, map] of overlap.entries())
82
+ for (const [state, px] of map)
83
+ pairs.push({ group: position, state, pixels: px });
84
+ pairs.sort((a, b) => b.pixels - a.pixels || a.state - b.state || a.group - b.group);
85
+ const heirOfGroup = new Map();
86
+ const groupOfHeir = new Map();
87
+ for (const pair of pairs) {
88
+ if (heirOfGroup.has(pair.group) || groupOfHeir.has(pair.state))
89
+ continue;
90
+ heirOfGroup.set(pair.group, pair.state);
91
+ groupOfHeir.set(pair.state, pair.group);
92
+ }
93
+ // A group takes provinces of one strategic region only.
94
+ for (const [position, group] of groups.entries()) {
95
+ const regions = new Set(group.provinces.map((province) => regionMembershipId(index, province)));
96
+ if (regions.size > 1)
97
+ throw new ServiceError('MAP_STATE_REGION_DISTRIBUTION_CONFLICT', `State group ${position} (provinces ${group.provinces.slice(0, 6).join(', ')}...) spans strategic regions ${[...regions].join(', ')}; a state may hold provinces of one region only`, { group: position, regions: [...regions] });
98
+ }
99
+ // Dated history cannot follow a regrouping safely.
100
+ const removedStates = [...affected.values()].filter((state) => !groupOfHeir.has(state.id));
101
+ for (const state of removedStates) {
102
+ const dated = state.historyBlock === undefined
103
+ ? undefined
104
+ : assignments(state.historyBlock).find((entry) => entry.value.type === 'block' && parseMapDate(entry.key.value) !== undefined);
105
+ if (dated !== undefined)
106
+ throw new ServiceError('MAP_DATED_STATE_HISTORY_CONFLICT', `State ${state.id} has dated history (${dated.key.value}) that would be lost when its provinces move to other states; edit or move that block first`, { stateId: state.id, datedAt: dated.key.value, sourcePath: state.file.displayPath });
107
+ }
108
+ // Allocate identifiers for groups with no heir: the lowest positive ids no root uses.
109
+ const used = new Set(index.statesAcrossRoots.map(({ id }) => id));
110
+ const highest = maximumObservedId(index.statesAcrossRoots.map(({ id }) => id), 0);
111
+ const roots = rootEvidence(index.statesAcrossRoots.map(({ id, file }) => ({ id, file })));
112
+ const stateIds = [];
113
+ let cursor = 1;
114
+ let probes = 0;
115
+ for (const position of groups.keys()) {
116
+ const heir = heirOfGroup.get(position);
117
+ if (heir !== undefined) {
118
+ stateIds.push(heir);
119
+ continue;
120
+ }
121
+ while (used.has(cursor)) {
122
+ cursor += 1;
123
+ probes += 1;
124
+ }
125
+ if (cursor >= 2_147_483_647)
126
+ throw new ServiceError('MAP_STATE_ID_EXHAUSTED', 'No safe state ID remains available');
127
+ used.add(cursor);
128
+ stateIds.push(cursor);
129
+ allocations.push({
130
+ kind: 'state-id',
131
+ allocated: cursor,
132
+ strategy: 'lowest-positive-unused-active-id-across-roots',
133
+ highestObserved: highest,
134
+ probes: probes + 1,
135
+ roots,
136
+ });
137
+ }
138
+ const contributions = groups.map(() => new Map());
139
+ for (const state of affected.values()) {
140
+ const takers = groups.flatMap((_, position) => overlap[position].has(state.id) ? [position] : []);
141
+ const weights = takers.map((position) => overlap[position].get(state.id));
142
+ const manpower = apportion(state.manpower, weights);
143
+ const resourceShares = new Map();
144
+ for (const [resource, amount] of state.resources)
145
+ resourceShares.set(resource, apportion(amount, weights));
146
+ const buildingShares = new Map();
147
+ for (const [building, amount] of state.stateBuildings)
148
+ buildingShares.set(building, isLevelStateBuilding(index, building) ? 'level' : apportion(amount, weights));
149
+ for (const [slot, position] of takers.entries()) {
150
+ const resources = new Map();
151
+ for (const [resource, shares] of resourceShares)
152
+ if (shares[slot] !== 0)
153
+ resources.set(resource, shares[slot]);
154
+ const buildings = new Map();
155
+ for (const [building, shares] of buildingShares) {
156
+ const value = shares === 'level' ? state.stateBuildings.get(building) : shares[slot];
157
+ if (value !== 0)
158
+ buildings.set(building, value);
159
+ }
160
+ contributions[position].set(state.id, { manpower: manpower[slot], resources, buildings });
161
+ }
162
+ }
163
+ // Ownership fields written for a group, and whether they differ from the heir's.
164
+ const sameList = (left, right) => left.length === right.length && left.every((value, position) => value === right[position]);
165
+ const changed = [];
166
+ const created = [];
167
+ const names = [];
168
+ const nameCounts = new Map();
169
+ const provinceState = new Map();
170
+ const statesRoot = index.sourceRoots.states[0] ?? 'history/states';
171
+ const reservedFiles = new Set();
172
+ for (const [position, group] of groups.entries()) {
173
+ const heirId = heirOfGroup.get(position);
174
+ const parts = [...contributions[position].keys()].sort((a, b) => a - b);
175
+ const sources = parts.map((id) => affected.get(id));
176
+ const primaryId = heirId ?? [...overlap[position]].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0][0];
177
+ const primary = affected.get(primaryId);
178
+ const provinces = [...group.provinces].sort((a, b) => a - b);
179
+ const set = new Set(provinces);
180
+ const data = cloneState(primary);
181
+ data.provinces = provinces;
182
+ data.manpower = 0;
183
+ data.resources = new Map();
184
+ data.stateBuildings = new Map();
185
+ for (const id of parts) {
186
+ const part = contributions[position].get(id);
187
+ data.manpower += part.manpower;
188
+ for (const [resource, amount] of part.resources)
189
+ data.resources.set(resource, (data.resources.get(resource) ?? 0) + amount);
190
+ for (const [building, amount] of part.buildings)
191
+ data.stateBuildings.set(building, isLevelStateBuilding(index, building)
192
+ ? Math.max(data.stateBuildings.get(building) ?? 0, amount)
193
+ : (data.stateBuildings.get(building) ?? 0) + amount);
194
+ }
195
+ for (const [resource, amount] of data.resources)
196
+ data.resources.set(resource, Math.round(amount * 100) / 100);
197
+ data.victoryPoints = sources.flatMap((state) => state.victoryPoints
198
+ .filter(({ provinceId }) => set.has(provinceId))
199
+ .map(({ provinceId, value }) => ({ provinceId, value })));
200
+ data.provinceBuildings = new Map(sources.flatMap((state) => [...state.provinceBuildings]
201
+ .filter(([provinceId]) => set.has(provinceId))
202
+ .map(([provinceId, values]) => [provinceId, new Map(values)])));
203
+ data.cores = uniqueStrings(sources.flatMap((state) => state.cores));
204
+ data.claims = uniqueStrings(sources.flatMap((state) => state.claims));
205
+ if (group.cores !== undefined)
206
+ data.cores = uniqueStrings(group.cores);
207
+ if (group.claims !== undefined)
208
+ data.claims = uniqueStrings(group.claims);
209
+ if (group.addCores !== undefined)
210
+ data.cores = uniqueStrings([...data.cores, ...group.addCores]);
211
+ if (group.addClaims !== undefined)
212
+ data.claims = uniqueStrings([...data.claims, ...group.addClaims]);
213
+ if (group.owner !== undefined)
214
+ data.owner = group.owner ?? undefined;
215
+ if (group.controller !== undefined)
216
+ data.controller = group.controller ?? undefined;
217
+ const finalId = stateIds[position];
218
+ for (const province of provinces)
219
+ provinceState.set(province, finalId);
220
+ if (heirId !== undefined) {
221
+ const heir = affected.get(heirId);
222
+ const fields = {
223
+ manpower: true,
224
+ resources: true,
225
+ provinces: true,
226
+ victoryPoints: true,
227
+ buildings: true,
228
+ };
229
+ if (!sameList(data.cores, uniqueStrings(heir.cores)))
230
+ fields.cores = true;
231
+ if (!sameList(data.claims, uniqueStrings(heir.claims)))
232
+ fields.claims = true;
233
+ if (data.owner !== heir.owner)
234
+ fields.owner = true;
235
+ if (data.controller !== heir.controller)
236
+ fields.controller = true;
237
+ commitState(changes, heir, data, fields, operationId);
238
+ changed.push(heirId);
239
+ continue;
240
+ }
241
+ // A new state: the largest source's history, with its undated blocks, under a new identifier.
242
+ assertDatedStateHistorySafe(primary, { provinces: true }, operationId);
243
+ const dated = [];
244
+ if (primary.historyBlock !== undefined)
245
+ for (const entry of assignments(primary.historyBlock))
246
+ if (entry.value.type === 'block' && parseMapDate(entry.key.value) !== undefined)
247
+ dated.push(primary.document.text.slice(entry.start, entry.end));
248
+ data.id = finalId;
249
+ data.name = `STATE_${finalId}`;
250
+ data.datedHistory = dated;
251
+ let relativePath = `${statesRoot}/${finalId}-AGENT_STATE.txt`;
252
+ for (let suffix = 2;; suffix += 1) {
253
+ const key = normalizeRelative(relativePath);
254
+ if (!index.activeFiles.byRelativePath.has(key) && !reservedFiles.has(key))
255
+ break;
256
+ relativePath = `${statesRoot}/${finalId}-AGENT_STATE_${suffix}.txt`;
257
+ }
258
+ reservedFiles.add(normalizeRelative(relativePath));
259
+ addChange(changes, relativePath, compileState(data, primary.document.newline), operationId, 'text/plain');
260
+ created.push(finalId);
261
+ const label = index.localisationByKey.get(`l_english:${primary.name}`)?.[0]?.entry.value ??
262
+ `State ${primary.id}`;
263
+ const ordinal = (nameCounts.get(primary.id) ?? 1) + 1;
264
+ nameCounts.set(primary.id, ordinal);
265
+ names.push([`STATE_${finalId}`, `${label} (${ordinal})`]);
266
+ }
267
+ // Dated history of a heir that loses land is refused by `commitState` above; removed states go.
268
+ for (const state of removedStates)
269
+ addChange(changes, state.file.relativePath, removalContent(index, state.file, `State ${state.id} regrouped into other states`), operationId, 'text/plain');
270
+ remapPositions(index, changes, provinceState, operationId);
271
+ upsertStateNames(index, changes, names, operationId);
272
+ if (options.remapReferences !== undefined) {
273
+ const mapping = [];
274
+ for (const state of affected.values()) {
275
+ const takers = groups.flatMap((_, position) => overlap[position].has(state.id) ? [position] : []);
276
+ const targets = [...new Set(takers.map((position) => stateIds[position]))];
277
+ const heirGroup = groupOfHeir.get(state.id);
278
+ const keepsId = heirGroup !== undefined;
279
+ const others = targets.filter((id) => id !== state.id);
280
+ if (keepsId && others.length === 0)
281
+ continue;
282
+ mapping.push({
283
+ from: state.id,
284
+ to: keepsId
285
+ ? [state.id, ...others]
286
+ : takers.length === 1
287
+ ? stateIds[takers[0]]
288
+ : (() => {
289
+ const best = takers
290
+ .slice()
291
+ .sort((a, b) => overlap[b].get(state.id) - overlap[a].get(state.id) || a - b)[0];
292
+ return [stateIds[best], ...targets.filter((id) => id !== stateIds[best])];
293
+ })(),
294
+ });
295
+ }
296
+ if (mapping.length > 0)
297
+ remapScriptReferences(base, changes, operationId, {
298
+ entity: 'state',
299
+ mapping,
300
+ readOnlySources: options.remapReferences,
301
+ });
302
+ }
303
+ return {
304
+ changed: changed.sort((a, b) => a - b),
305
+ created,
306
+ removed: removedStates.map(({ id }) => id).sort((a, b) => a - b),
307
+ heirs: [...groupOfHeir]
308
+ .map(([stateId, group]) => ({ stateId, group }))
309
+ .sort((a, b) => a.stateId - b.stateId),
310
+ stateIds,
311
+ };
312
+ }
313
+ /** Point every building position at the state that now holds the province it lies in. */
314
+ function remapPositions(index, changes, provinceState, operationId) {
315
+ const documents = new Map(index.buildingPositions.map((record) => [record.document.file.relativePath, record.document]));
316
+ for (const [relativePath, document] of documents) {
317
+ const replacements = index.buildingPositions
318
+ .filter((record) => record.document === document)
319
+ .flatMap((record) => {
320
+ const provinceId = index.provinceAtMapCoordinate(record.x, record.z);
321
+ const target = provinceId === undefined ? undefined : provinceState.get(provinceId);
322
+ if (target === undefined || target === record.stateId)
323
+ return [];
324
+ const line = document.lines[record.line - 1];
325
+ return line === undefined
326
+ ? []
327
+ : [
328
+ {
329
+ start: line.start,
330
+ end: line.end,
331
+ text: buildingPositionLine({ ...record, stateId: target }),
332
+ description: 'Remap building-position state reference',
333
+ },
334
+ ];
335
+ });
336
+ if (replacements.length > 0)
337
+ addChange(changes, relativePath, applyTextRanges(document, replacements), operationId, 'text/plain');
338
+ }
339
+ }
340
+ /** English names for new states: existing keys in mod files are replaced, others are appended. */
341
+ function upsertStateNames(index, changes, entries, operationId) {
342
+ if (entries.length === 0)
343
+ return;
344
+ const defaultFile = `${index.sourceRoots.localisation[0] ?? 'localisation'}/english/hoi4_agent_map_l_english.yml`;
345
+ const working = new Map();
346
+ const current = (relativePath) => {
347
+ const key = normalizeRelative(relativePath);
348
+ const staged = working.get(key);
349
+ if (staged !== undefined)
350
+ return staged;
351
+ const change = changes.get(key);
352
+ if (change !== undefined)
353
+ return change.content ?? undefined;
354
+ return index.activeFiles.byRelativePath.get(key)?.bytes;
355
+ };
356
+ const paths = new Map();
357
+ for (const [key, value] of entries) {
358
+ const existing = index.localisationByKey.get(`l_english:${key}`) ?? [];
359
+ if (existing.length > 1)
360
+ throw new ServiceError('MAP_STATE_LOCALISATION_AMBIGUOUS', `English localisation key ${key} has multiple active definitions`, { key });
361
+ const target = existing[0]?.file.rootKind === 'mod' ? existing[0].file.relativePath : defaultFile;
362
+ const bytes = current(target);
363
+ paths.set(normalizeRelative(target), target);
364
+ if (bytes === undefined) {
365
+ working.set(normalizeRelative(target), encodeSource(`l_english:\n${key}: "${escapedLocalisationValue(value)}"\n`, 'utf8-bom'));
366
+ continue;
367
+ }
368
+ const document = parseLocalisation(bytes, target);
369
+ requireSafeLocalisationDocument(document, target);
370
+ const entry = document.entries.find((candidate) => candidate.key === key && candidate.language === 'l_english');
371
+ if (entry === undefined)
372
+ working.set(normalizeRelative(target), appendLocalisationEntry(document, key, value));
373
+ else
374
+ working.set(normalizeRelative(target), encodeSource(`${document.text.slice(0, entry.start)}${localisationEntryLine(document, entry, value)}${document.text.slice(entry.end)}`, 'utf8-bom'));
375
+ }
376
+ for (const [key, bytes] of [...working].sort(([left], [right]) => compareCodeUnits(left, right)))
377
+ addChange(changes, paths.get(key), bytes, operationId, 'text/yaml');
378
+ }