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.
- package/CHANGELOG.md +24 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
- package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
- package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
- package/dist/hoi4_agent_tools/focus/drift.js +34 -0
- package/dist/hoi4_agent_tools/focus/service.js +9 -3
- package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
- package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
- package/dist/hoi4_agent_tools/map/derived.js +16 -3
- package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
- package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
- package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
- package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
- package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
- package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
- package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
- package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
- package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
- package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
- package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
- package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
- package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
- package/dist/hoi4_agent_tools/map/model.js +2 -1
- package/dist/hoi4_agent_tools/map/operations.js +282 -38
- package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +43 -0
- package/dist/hoi4_agent_tools/map/service.js +93 -16
- package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
- package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
- package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
- package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
- package/dist/hoi4_agent_tools/map/territory.js +1108 -0
- package/dist/hoi4_agent_tools/map/tool-operations.js +119 -1
- package/dist/hoi4_agent_tools/map/validation.js +4 -0
- package/dist/hoi4_agent_tools/map/world-context.js +23 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
- package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
- package/dist/hoi4_agent_tools/map/world.js +400 -145
- package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
- package/dist/hoi4_agent_tools/schemas/map.js +277 -34
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/focus.md +6 -0
- package/docs/map.md +132 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/geography-model.schema.json +13 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1541 -786
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/reference-sheets.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +2 -2
|
@@ -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
|
|
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
|
-
|
|
806
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
: {
|
|
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
|
+
}
|