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
|
@@ -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 {
|
|
2
|
+
import { planManagedOutputs } from '../core/managed-outputs.js';
|
|
3
3
|
export const WORLD_OUTPUT_MANIFEST = '.hoi4-agent-world.json';
|
|
4
|
+
/** Country outputs of a generated world: tags, country files, histories, units, flags, bookmark. */
|
|
5
|
+
export function isWorldCountryOutput(relativePath) {
|
|
6
|
+
const dir = path.posix.dirname(relativePath), file = path.posix.basename(relativePath);
|
|
7
|
+
return ((['common/countries', 'history/countries'].includes(dir) &&
|
|
8
|
+
/^[A-Z][A-Z0-9]{2} - [^/]+\.txt$/u.test(file)) ||
|
|
9
|
+
(dir === 'history/units' && /^[A-Z][A-Z0-9]{2}_world\.txt$/u.test(file)) ||
|
|
10
|
+
(['gfx/flags', 'gfx/flags/medium', 'gfx/flags/small'].includes(dir) &&
|
|
11
|
+
/^[A-Z][A-Z0-9]{2}\.tga$/u.test(file)) ||
|
|
12
|
+
[
|
|
13
|
+
'common/countries/colors.txt',
|
|
14
|
+
'common/country_tags/zz_world_countries.txt',
|
|
15
|
+
'common/bookmarks/world.txt',
|
|
16
|
+
].includes(relativePath));
|
|
17
|
+
}
|
|
4
18
|
export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots) {
|
|
19
|
+
return planWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots)
|
|
20
|
+
.changes;
|
|
21
|
+
}
|
|
22
|
+
export function planWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots, options = {}) {
|
|
5
23
|
const mapFiles = new Set([
|
|
6
24
|
'provinces.bmp',
|
|
7
25
|
'definition.csv',
|
|
@@ -29,15 +47,8 @@ export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, s
|
|
|
29
47
|
(dir === `${mapRoot}/terrain` && /^colormap_[a-z0-9_]+\.dds$/u.test(file)) ||
|
|
30
48
|
(dir === `${mapRoot}/strategicregions` && /^\d+.*\.txt$/u.test(file)) ||
|
|
31
49
|
(stateRoots.includes(dir) && /^\d+.*\.txt$/u.test(file)) ||
|
|
32
|
-
(
|
|
33
|
-
/^[A-Z][A-Z0-9]{2} - [^/]+\.txt$/u.test(file)) ||
|
|
34
|
-
(dir === 'history/units' && /^[A-Z][A-Z0-9]{2}_world\.txt$/u.test(file)) ||
|
|
35
|
-
(['gfx/flags', 'gfx/flags/medium', 'gfx/flags/small'].includes(dir) &&
|
|
36
|
-
/^[A-Z][A-Z0-9]{2}\.tga$/u.test(file)) ||
|
|
50
|
+
isWorldCountryOutput(name) ||
|
|
37
51
|
[
|
|
38
|
-
'common/countries/colors.txt',
|
|
39
|
-
'common/country_tags/zz_world_countries.txt',
|
|
40
|
-
'common/bookmarks/world.txt',
|
|
41
52
|
'common/on_actions/world.txt',
|
|
42
53
|
'common/ai_areas/world.txt',
|
|
43
54
|
'common/ai_strategy_plans/world.txt',
|
|
@@ -45,11 +56,12 @@ export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, s
|
|
|
45
56
|
localisationRoots.some((root) => name === `${root}/english/world_l_english.yml` ||
|
|
46
57
|
name === `${root}/replace/world_l_english.yml`));
|
|
47
58
|
};
|
|
48
|
-
return
|
|
59
|
+
return planManagedOutputs({
|
|
49
60
|
manifestPath: WORLD_OUTPUT_MANIFEST,
|
|
50
61
|
sources,
|
|
51
62
|
proposed,
|
|
52
63
|
operationId,
|
|
53
64
|
permitsPath,
|
|
65
|
+
...(options.releaseCountryOutputs === true ? { releasable: isWorldCountryOutput } : {}),
|
|
54
66
|
});
|
|
55
67
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-owned countries in a generated world (ADR 0069): realm points and polygons resolved to
|
|
3
|
+
* provinces, and the rule for land that no realm reached.
|
|
4
|
+
*/
|
|
5
|
+
import { ServiceError } from '../core/result.js';
|
|
6
|
+
function inside(x, y, polygon) {
|
|
7
|
+
let result = false;
|
|
8
|
+
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
|
|
9
|
+
const a = polygon[index], b = polygon[previous];
|
|
10
|
+
if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x)
|
|
11
|
+
result = !result;
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
/** Land provinces with more than half their pixels inside any of the polygons. */
|
|
16
|
+
function coveredProvinces(polygons, raster) {
|
|
17
|
+
const counts = new Map();
|
|
18
|
+
const pixels = new Set();
|
|
19
|
+
for (const polygon of polygons) {
|
|
20
|
+
const xs = polygon.map(({ x }) => x), ys = polygon.map(({ y }) => y);
|
|
21
|
+
const left = Math.max(0, Math.floor(Math.min(...xs)));
|
|
22
|
+
const right = Math.min(raster.width - 1, Math.ceil(Math.max(...xs)));
|
|
23
|
+
const top = Math.max(0, Math.floor(Math.min(...ys)));
|
|
24
|
+
const bottom = Math.min(raster.height - 1, Math.ceil(Math.max(...ys)));
|
|
25
|
+
for (let y = top; y <= bottom; y += 1)
|
|
26
|
+
for (let x = left; x <= right; x += 1) {
|
|
27
|
+
const pixel = y * raster.width + x;
|
|
28
|
+
if (pixels.has(pixel) || !inside(x + 0.5, y + 0.5, polygon))
|
|
29
|
+
continue;
|
|
30
|
+
pixels.add(pixel);
|
|
31
|
+
const province = raster.pixelProvince[pixel];
|
|
32
|
+
counts.set(province, (counts.get(province) ?? 0) + 1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const covered = new Set();
|
|
36
|
+
for (const [province, count] of [...counts].sort((left, right) => left[0] - right[0]))
|
|
37
|
+
if (raster.isLand(province) && count * 2 > raster.provinceSize[province])
|
|
38
|
+
covered.add(province);
|
|
39
|
+
return covered;
|
|
40
|
+
}
|
|
41
|
+
function pointProvince(tag, role, point, raster) {
|
|
42
|
+
const x = Math.floor(point.x), y = Math.floor(point.y);
|
|
43
|
+
if (x < 0 || y < 0 || x >= raster.width || y >= raster.height)
|
|
44
|
+
throw new ServiceError('MAP_WORLD_REALM_POINT_OUTSIDE', `The ${role} of ${tag} lies outside the ${raster.width}x${raster.height} world`, { tag, role, point });
|
|
45
|
+
const province = raster.pixelProvince[y * raster.width + x];
|
|
46
|
+
if (!raster.isLand(province))
|
|
47
|
+
throw new ServiceError('MAP_WORLD_REALM_POINT_WATER', `The ${role} of ${tag} lies in water`, {
|
|
48
|
+
tag,
|
|
49
|
+
role,
|
|
50
|
+
point,
|
|
51
|
+
});
|
|
52
|
+
return province;
|
|
53
|
+
}
|
|
54
|
+
export function resolveRealmRules(realms, raster) {
|
|
55
|
+
const unowned = realms.unowned === undefined ? new Set() : coveredProvinces(realms.unowned, raster);
|
|
56
|
+
const excluded = realms.list.map((realm) => realm.exclude === undefined ? new Set() : coveredProvinces(realm.exclude, raster));
|
|
57
|
+
const forced = new Map();
|
|
58
|
+
const claimedBy = (province) => realms.list[forced.get(province)].tag;
|
|
59
|
+
const conflict = (tag, role, other, details) => new ServiceError('MAP_WORLD_REALM_POINT_CONFLICT', `The ${role} of ${tag} lies in land ${other}`, { tag, role, ...details });
|
|
60
|
+
const capitals = [];
|
|
61
|
+
// Points first, so a claim that covers another realm's point names that point.
|
|
62
|
+
for (const [index, realm] of realms.list.entries()) {
|
|
63
|
+
for (const [role, point] of [
|
|
64
|
+
['capital', realm.capital],
|
|
65
|
+
...(realm.seeds ?? []).map((seed) => ['seed', seed]),
|
|
66
|
+
]) {
|
|
67
|
+
const province = pointProvince(realm.tag, role, point, raster);
|
|
68
|
+
if (unowned.has(province))
|
|
69
|
+
throw conflict(realm.tag, role, 'marked unowned', { point, province });
|
|
70
|
+
if (excluded[index].has(province))
|
|
71
|
+
throw conflict(realm.tag, role, 'its own exclusion covers', { point, province });
|
|
72
|
+
const holder = forced.get(province);
|
|
73
|
+
if (holder !== undefined && holder !== index)
|
|
74
|
+
throw conflict(realm.tag, role, `that a point of ${claimedBy(province)} names`, {
|
|
75
|
+
point,
|
|
76
|
+
province,
|
|
77
|
+
otherTag: claimedBy(province),
|
|
78
|
+
});
|
|
79
|
+
forced.set(province, index);
|
|
80
|
+
if (role === 'capital')
|
|
81
|
+
capitals.push(province);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const [index, realm] of realms.list.entries()) {
|
|
85
|
+
if (realm.claim === undefined)
|
|
86
|
+
continue;
|
|
87
|
+
for (const province of coveredProvinces(realm.claim, raster)) {
|
|
88
|
+
const holder = forced.get(province);
|
|
89
|
+
if (unowned.has(province) || excluded[index].has(province))
|
|
90
|
+
throw new ServiceError('MAP_WORLD_REALM_CLAIM_OVERLAP', `A claim of ${realm.tag} covers land that is ${unowned.has(province) ? 'marked unowned' : 'in its own exclusion'}`, { tag: realm.tag, province });
|
|
91
|
+
if (holder !== undefined && holder !== index)
|
|
92
|
+
throw new ServiceError('MAP_WORLD_REALM_CLAIM_OVERLAP', `A claim of ${realm.tag} covers land that ${claimedBy(province)} claims or names`, { tag: realm.tag, otherTag: claimedBy(province), province });
|
|
93
|
+
forced.set(province, index);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { capitals, forced, excluded, unowned };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Land growth did not reach: a landmass that a realm already holds goes to the nearest realm that
|
|
100
|
+
* may own it; a landmass without any realm follows the island rule.
|
|
101
|
+
*/
|
|
102
|
+
export function assignUnreachedRealmLand(input) {
|
|
103
|
+
const held = new Set();
|
|
104
|
+
for (const id of input.owner.keys())
|
|
105
|
+
held.add(input.part(id));
|
|
106
|
+
const distance = (a, b) => (a.x - b.x) ** 2 + (a.y - b.y) ** 2;
|
|
107
|
+
const nearest = (point, allowed) => {
|
|
108
|
+
let best = -1;
|
|
109
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
110
|
+
for (const [realm, capital] of input.capitals.entries()) {
|
|
111
|
+
if (!allowed(realm))
|
|
112
|
+
continue;
|
|
113
|
+
const next = distance(point, input.center(capital));
|
|
114
|
+
if (next < bestDistance) {
|
|
115
|
+
best = realm;
|
|
116
|
+
bestDistance = next;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return best;
|
|
120
|
+
};
|
|
121
|
+
const pending = input.landIds.filter((id) => !input.owner.has(id) && !input.unowned.has(id));
|
|
122
|
+
const partOwner = new Map();
|
|
123
|
+
if (input.islands === 'one-owner') {
|
|
124
|
+
const members = new Map();
|
|
125
|
+
for (const id of pending)
|
|
126
|
+
if (!held.has(input.part(id))) {
|
|
127
|
+
const list = members.get(input.part(id)) ?? [];
|
|
128
|
+
list.push(id);
|
|
129
|
+
members.set(input.part(id), list);
|
|
130
|
+
}
|
|
131
|
+
for (const [part, ids] of members) {
|
|
132
|
+
const mean = ids.reduce((sum, id) => ({ x: sum.x + input.center(id).x, y: sum.y + input.center(id).y }), { x: 0, y: 0 });
|
|
133
|
+
partOwner.set(part, nearest({ x: mean.x / ids.length, y: mean.y / ids.length }, () => true));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const id of pending) {
|
|
137
|
+
const allowed = (realm) => !input.excluded[realm].has(id);
|
|
138
|
+
const island = !held.has(input.part(id));
|
|
139
|
+
if (island && input.islands === 'unowned') {
|
|
140
|
+
input.unowned.add(id);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const preferred = island ? partOwner.get(input.part(id)) : undefined;
|
|
144
|
+
const realm = preferred !== undefined && preferred >= 0 && allowed(preferred)
|
|
145
|
+
? preferred
|
|
146
|
+
: nearest(input.center(id), allowed);
|
|
147
|
+
if (realm < 0)
|
|
148
|
+
input.unowned.add(id);
|
|
149
|
+
else
|
|
150
|
+
input.owner.set(id, realm);
|
|
151
|
+
}
|
|
152
|
+
}
|