hoi4-agent-tools 4.5.0 → 4.6.1
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 +21 -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/operations.js +109 -32
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
- package/dist/hoi4_agent_tools/map/relief/macro-relief.js +107 -0
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +52 -0
- package/dist/hoi4_agent_tools/map/service.js +78 -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 +78 -0
- package/dist/hoi4_agent_tools/map/validation.js +4 -0
- package/dist/hoi4_agent_tools/map/world.js +301 -107
- package/dist/hoi4_agent_tools/reference/known-mistakes.js +3 -0
- package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
- package/dist/hoi4_agent_tools/schemas/map.js +206 -21
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/map.md +100 -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 +1355 -736
- 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
|
@@ -7,6 +7,7 @@ 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
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,9 @@ 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) }),
|
|
304
308
|
...worldOutputsData(planned.plan),
|
|
305
309
|
}),
|
|
306
310
|
filesScanned: planned.filesScanned,
|
|
@@ -503,8 +507,79 @@ async function previewGeographyRewrite(nudger, input, context) {
|
|
|
503
507
|
await context.progress(3, 3, 'Geography model preview complete; workspace files were not changed');
|
|
504
508
|
return result;
|
|
505
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
|
+
}
|
|
506
579
|
/** Generate and validate the same proposed map without creating a write transaction. */
|
|
507
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);
|
|
508
583
|
if (input.operations.some((operation) => operation.kind === 'create_world' && operationHasGeography(operation)))
|
|
509
584
|
return previewGeographyRewrite(nudger, input, context);
|
|
510
585
|
await context.progress(0, 5, 'Generating and validating an offline map preview');
|
|
@@ -529,6 +604,9 @@ export async function previewMapRewrite(nudger, input, context) {
|
|
|
529
604
|
semanticDiff: compactSemantic.semantic,
|
|
530
605
|
semanticDiffCounts: compactSemantic.counts,
|
|
531
606
|
semanticDiffTruncated: compactSemantic.truncated,
|
|
607
|
+
...(planned.plan.territory === undefined
|
|
608
|
+
? {}
|
|
609
|
+
: { territory: planned.plan.territory.map(compactTerritoryReport) }),
|
|
532
610
|
fileCount: planned.plan.changes.length,
|
|
533
611
|
artifactCount: planned.artifacts.length,
|
|
534
612
|
...worldOutputsData(planned.plan),
|
|
@@ -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'))),
|
|
@@ -12,13 +12,19 @@ import { generateWorldProvinces } from './world-provinces.js';
|
|
|
12
12
|
import { clusterRegions, clusterStates, partitionWorldFromRequest, } from './partition.js';
|
|
13
13
|
import { repairSeamCorners, smoothDiagonalContacts } from './partition-fields.js';
|
|
14
14
|
import { PARTITION_PROVINCE_ERROR, PARTITION_PROVINCE_WARNING } from './partition-tuning.js';
|
|
15
|
+
import { buildTerritoryAtoms, territoryGraphFromPartition } from './territory-graph.js';
|
|
16
|
+
import { growTerritory } from './territory.js';
|
|
15
17
|
import { geographyPartitionFields, provinceCountNote, surfacePartitionFields, } from './world-partition.js';
|
|
18
|
+
import { adjacencyRowText, adjacencyRulesText } from './gameplay/crossings.js';
|
|
19
|
+
import { convertInlandSeas } from './gameplay/inland-sea.js';
|
|
20
|
+
import { planEconomy } from './gameplay/economy.js';
|
|
21
|
+
import { prepareGameplay } from './gameplay/prepare.js';
|
|
16
22
|
import { fractal, lattice } from './world-noise.js';
|
|
17
23
|
import { calibratedColourMap, COLOUR_CLASSES, } from './colormap.js';
|
|
18
24
|
import { ambientObjectText, applyEdgePolicy, assertFeaturesOutsideBand, edgeWaterDarkness, RIM_COLOUR_FACTOR, rimAdjacencyRows, rimHeight, rimStrength, } from './world-edge.js';
|
|
19
25
|
import { landDistanceField, lakeHeight, normalizeLakes, waterColourLevel } from './water.js';
|
|
20
26
|
import { overlayGeographyLakes, rasterizeGeographyKind } from './relief/land.js';
|
|
21
|
-
import { synthesizeTerrain } from './relief/terrain-synthesis.js';
|
|
27
|
+
import { checkTerrainRivers, synthesizeTerrain, } from './relief/terrain-synthesis.js';
|
|
22
28
|
import { assignUnreachedRealmLand, resolveRealmRules } from './world-realms.js';
|
|
23
29
|
// ---------------------------------------------------------------------------------------------
|
|
24
30
|
// Deterministic randomness and noise
|
|
@@ -507,6 +513,48 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
507
513
|
let seamRepaired = geography !== undefined ? repairSeamCorners(surface.kind, width, height) : [];
|
|
508
514
|
// A geography model gets its relief and rivers from the synthesis of ADR 0056; every other world
|
|
509
515
|
// keeps the original elevation classes and drainage, byte for byte.
|
|
516
|
+
if (options.riverCheck === true && geography !== undefined) {
|
|
517
|
+
const checked = checkTerrainRivers({
|
|
518
|
+
geography,
|
|
519
|
+
width,
|
|
520
|
+
height,
|
|
521
|
+
seed: spec.seed,
|
|
522
|
+
kind: surface.kind,
|
|
523
|
+
relief: spec.relief,
|
|
524
|
+
rivers: { density: spec.riverDensity, count: spec.riverCount },
|
|
525
|
+
adjustHeight: edge.mode === 'rim'
|
|
526
|
+
? (x, _y, value) => rimHeight(value, rimStrength(edge, width, x))
|
|
527
|
+
: undefined,
|
|
528
|
+
signal: options.signal,
|
|
529
|
+
});
|
|
530
|
+
for (const note of checked) {
|
|
531
|
+
if (note.severity === 'error')
|
|
532
|
+
throw new ServiceError(note.code, note.message, note.details);
|
|
533
|
+
notes.push({
|
|
534
|
+
code: note.code,
|
|
535
|
+
severity: note.severity,
|
|
536
|
+
message: note.message,
|
|
537
|
+
...(note.details === undefined ? {} : { details: note.details }),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
files: [],
|
|
542
|
+
warnings: [],
|
|
543
|
+
notes,
|
|
544
|
+
summary: {
|
|
545
|
+
width,
|
|
546
|
+
height,
|
|
547
|
+
landProvinces: 0,
|
|
548
|
+
seaProvinces: 0,
|
|
549
|
+
lakeProvinces: 0,
|
|
550
|
+
states: 0,
|
|
551
|
+
landRegions: 0,
|
|
552
|
+
seaRegions: 0,
|
|
553
|
+
countries: spec.countries.length,
|
|
554
|
+
rivers: 0,
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
}
|
|
510
558
|
const synthesized = geography === undefined
|
|
511
559
|
? undefined
|
|
512
560
|
: synthesizeTerrain({
|
|
@@ -535,6 +583,26 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
535
583
|
}
|
|
536
584
|
notes.push(terrainSummaryNote(synthesized, geography));
|
|
537
585
|
}
|
|
586
|
+
// A lake the model calls an inland sea becomes navigable water (ADR 0062).
|
|
587
|
+
const inland = geography === undefined || synthesized === undefined
|
|
588
|
+
? undefined
|
|
589
|
+
: convertInlandSeas(surface.kind, surface.elevation, width, height, geography);
|
|
590
|
+
if (inland !== undefined && geography !== undefined) {
|
|
591
|
+
for (const { lake, pixels } of inland.converted)
|
|
592
|
+
notes.push({
|
|
593
|
+
code: 'MAP_INLAND_SEA_CREATED',
|
|
594
|
+
severity: 'info',
|
|
595
|
+
message: `Lake ${geography.lakes[lake].id} is an inland sea: its ${pixels} water pixels are written as sea provinces so ships and ports can use them`,
|
|
596
|
+
details: { lakeId: geography.lakes[lake].id, pixels },
|
|
597
|
+
});
|
|
598
|
+
for (const lake of inland.missing)
|
|
599
|
+
notes.push({
|
|
600
|
+
code: 'MAP_INLAND_SEA_NO_WATER',
|
|
601
|
+
severity: 'info',
|
|
602
|
+
message: `Lake ${geography.lakes[lake].id} is an inland sea but has no separate water body: it joined the open sea or was too small to keep`,
|
|
603
|
+
details: { lakeId: geography.lakes[lake].id },
|
|
604
|
+
});
|
|
605
|
+
}
|
|
538
606
|
const drainage = synthesized === undefined
|
|
539
607
|
? generateWorldDrainage({
|
|
540
608
|
width,
|
|
@@ -628,6 +696,25 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
628
696
|
const countNote = provinceCountNote(count, featurePartition === undefined ? 'legacy' : 'feature-aware');
|
|
629
697
|
if (countNote !== undefined)
|
|
630
698
|
notes.push(countNote);
|
|
699
|
+
// Crossings, passes and walls of a geography world (ADR 0062).
|
|
700
|
+
const gameplay = geography === undefined || synthesized === undefined
|
|
701
|
+
? undefined
|
|
702
|
+
: prepareGameplay({
|
|
703
|
+
width,
|
|
704
|
+
height,
|
|
705
|
+
geography,
|
|
706
|
+
provinces,
|
|
707
|
+
heights: synthesized.heights,
|
|
708
|
+
riverBitmap: drainage.riverPixels,
|
|
709
|
+
fields: synthesized.fields,
|
|
710
|
+
riverPaths: synthesized.riverPaths,
|
|
711
|
+
autoStraits: spec.autoStraits,
|
|
712
|
+
impassableLimit: spec.impassableRowLimit,
|
|
713
|
+
inlandSeas: inland?.converted.map(({ lake }) => lake) ?? [],
|
|
714
|
+
});
|
|
715
|
+
if (gameplay !== undefined)
|
|
716
|
+
for (const note of gameplay.notes)
|
|
717
|
+
notes.push(note);
|
|
631
718
|
const center = (id) => ({
|
|
632
719
|
x: provinces.sumX[id] / provinces.size[id],
|
|
633
720
|
y: provinces.sumY[id] / provinces.size[id],
|
|
@@ -834,7 +921,20 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
834
921
|
}
|
|
835
922
|
return selected;
|
|
836
923
|
};
|
|
837
|
-
|
|
924
|
+
// Sites of the model that anchor a country (its capital) choose that country's capital province.
|
|
925
|
+
const siteAnchors = geography === undefined
|
|
926
|
+
? []
|
|
927
|
+
: geography.sites.flatMap((site) => site.country === undefined || site.onWater
|
|
928
|
+
? []
|
|
929
|
+
: [
|
|
930
|
+
{
|
|
931
|
+
name: site.name ?? site.id,
|
|
932
|
+
x: site.at.x * width,
|
|
933
|
+
y: site.at.y * height,
|
|
934
|
+
countryTag: site.country,
|
|
935
|
+
},
|
|
936
|
+
]);
|
|
937
|
+
const namedCities = [...(spec.referenceCities ?? []), ...siteAnchors].map((city) => {
|
|
838
938
|
const pixel = city.x >= 0 && city.y >= 0 && city.x < width && city.y < height
|
|
839
939
|
? Math.floor(city.y) * width + Math.floor(city.x)
|
|
840
940
|
: undefined;
|
|
@@ -909,7 +1009,22 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
909
1009
|
.flatMap(([id]) => [...provinces.neighbors[id]]);
|
|
910
1010
|
return [...provinces.neighbors[capital]];
|
|
911
1011
|
});
|
|
912
|
-
|
|
1012
|
+
let grownByTerritory = false;
|
|
1013
|
+
// The feature-aware partition grows generated countries along its natural lines (ADR 0060).
|
|
1014
|
+
// Workspace-owned countries keep the realm growth with its forced and blocked land (ADR 0069).
|
|
1015
|
+
if (featurePartition !== undefined && referenceOwners.size === 0 && realmRules === undefined) {
|
|
1016
|
+
const atoms = buildTerritoryAtoms(territoryGraphFromPartition(featurePartition.graph, provinces.sumX, provinces.sumY), landIds.map((id) => [id]));
|
|
1017
|
+
const grown = growTerritory(atoms, spec.countries.map((country, index) => ({
|
|
1018
|
+
tag: country.tag,
|
|
1019
|
+
weight: shares[index],
|
|
1020
|
+
seeds: [atoms.atomOfProvince[capitals[index]]],
|
|
1021
|
+
})), { seed: spec.seed });
|
|
1022
|
+
for (const [atom, id] of landIds.entries())
|
|
1023
|
+
if (grown.owner[atom] >= 0)
|
|
1024
|
+
owner.set(id, grown.owner[atom]);
|
|
1025
|
+
grownByTerritory = true;
|
|
1026
|
+
}
|
|
1027
|
+
while (!grownByTerritory) {
|
|
913
1028
|
let chosen = -1;
|
|
914
1029
|
for (let index = 0; index < countryCount; index += 1) {
|
|
915
1030
|
frontiers[index] = frontiers[index].filter((id) => provinces.kind[id] === LAND && !owner.has(id) && !blockedFor(index, id));
|
|
@@ -1015,12 +1130,33 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1015
1130
|
resources: new Map(),
|
|
1016
1131
|
victoryPoints: new Map(),
|
|
1017
1132
|
navalBases: new Map(),
|
|
1133
|
+
forts: new Map(),
|
|
1018
1134
|
};
|
|
1019
1135
|
states.push(draft);
|
|
1020
1136
|
for (const id of sorted)
|
|
1021
1137
|
stateOf.set(id, draft);
|
|
1022
1138
|
}
|
|
1023
1139
|
}
|
|
1140
|
+
// Names from reference labels (the nearest province to each label), which win over any other.
|
|
1141
|
+
const stateNames = new Map();
|
|
1142
|
+
const regionNames = new Map();
|
|
1143
|
+
for (const label of spec.referenceLabels ?? []) {
|
|
1144
|
+
if (label.kind === 'state') {
|
|
1145
|
+
const nearby = nearestProvince(label, landIds);
|
|
1146
|
+
const state = stateOf.get(nearby);
|
|
1147
|
+
const previous = stateNames.get(state.id);
|
|
1148
|
+
stateNames.set(state.id, previous === undefined ? label.name : `${previous} / ${label.name}`);
|
|
1149
|
+
}
|
|
1150
|
+
else if (label.kind === 'sea' || label.kind === 'region') {
|
|
1151
|
+
const members = label.kind === 'sea' ? seaIds : landIds;
|
|
1152
|
+
if (members.length === 0)
|
|
1153
|
+
continue;
|
|
1154
|
+
const region = regionOf.get(nearestProvince(label, members));
|
|
1155
|
+
const previous = regionNames.get(region);
|
|
1156
|
+
regionNames.set(region, previous === undefined ? label.name : `${previous} / ${label.name}`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const lakeNames = new Map();
|
|
1024
1160
|
// State values from size, terrain and development, with the country capital boosted.
|
|
1025
1161
|
const categories = [
|
|
1026
1162
|
'wasteland',
|
|
@@ -1048,112 +1184,153 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1048
1184
|
state.capital = capital;
|
|
1049
1185
|
countryCapitalState.set(spec.countries[index].tag, state);
|
|
1050
1186
|
}
|
|
1051
|
-
for (const state of states) {
|
|
1052
|
-
const pixels = state.provinces.reduce((sum, id) => sum + provinces.size[id], 0);
|
|
1053
|
-
const habitability = state.provinces.reduce((sum, id) => {
|
|
1054
|
-
const value = {
|
|
1055
|
-
plains: 1,
|
|
1056
|
-
forest: 0.8,
|
|
1057
|
-
hills: 0.6,
|
|
1058
|
-
jungle: 0.5,
|
|
1059
|
-
marsh: 0.4,
|
|
1060
|
-
desert: 0.25,
|
|
1061
|
-
mountain: 0.3,
|
|
1062
|
-
urban: 1.5,
|
|
1063
|
-
}[terrain.get(id) ?? 'plains'] ?? 0.5;
|
|
1064
|
-
return sum + value * provinces.size[id];
|
|
1065
|
-
}, 0) / pixels;
|
|
1066
|
-
const isCapital = state.owner !== undefined && countryCapitalState.get(state.owner) === state;
|
|
1067
|
-
const development = Math.min(1, habitability * (0.5 + random.next()) * (isCapital ? 1.8 : 1));
|
|
1068
|
-
state.manpower = Math.max(1_000, Math.round((pixels * 180 * development) / 1_000) * 1_000);
|
|
1069
|
-
const categoryIndex = Math.max(0, Math.min(categories.length - 1, Math.round(development * 5.2 + (isCapital ? 1 : 0))));
|
|
1070
|
-
state.category = categories[categoryIndex];
|
|
1071
|
-
if (context.stateCategories !== undefined && !context.stateCategories.has(state.category))
|
|
1072
|
-
state.category = context.stateCategories.has('rural')
|
|
1073
|
-
? 'rural'
|
|
1074
|
-
: ([...context.stateCategories][0] ?? 'rural');
|
|
1075
|
-
const slots = slotsFor[state.category] ?? 2;
|
|
1076
|
-
state.infrastructure = Math.max(1, Math.min(5, Math.round(1 + development * 3 + (isCapital ? 1 : 0))));
|
|
1077
|
-
state.civilian = Math.round(slots * (0.25 + random.next() * 0.3));
|
|
1078
|
-
state.military = Math.min(slots - state.civilian, Math.round(slots * random.next() * 0.3));
|
|
1079
|
-
if (isCapital) {
|
|
1080
|
-
terrain.set(state.capital, 'urban');
|
|
1081
|
-
state.airBase = 2;
|
|
1082
|
-
}
|
|
1083
|
-
const coastalProvinces = state.provinces.filter(coastal);
|
|
1084
|
-
if (coastalProvinces.length > 0 && (isCapital || random.next() < 0.35)) {
|
|
1085
|
-
state.navalBases.set(coastalProvinces[0], isCapital ? 3 : 1);
|
|
1086
|
-
if (slots - state.civilian - state.military > 0 && random.next() < 0.5)
|
|
1087
|
-
state.dockyard = 1;
|
|
1088
|
-
}
|
|
1089
|
-
for (const id of state.provinces) {
|
|
1090
|
-
const kindOfLand = terrain.get(id);
|
|
1091
|
-
const roll = random.next();
|
|
1092
|
-
const add = (resource, amount) => state.resources.set(resource, (state.resources.get(resource) ?? 0) + amount);
|
|
1093
|
-
if (kindOfLand === 'desert' && roll < 0.3)
|
|
1094
|
-
add('oil', 2 + random.int(6));
|
|
1095
|
-
if ((kindOfLand === 'mountain' || kindOfLand === 'hills') && roll < 0.35)
|
|
1096
|
-
add('steel', 2 + random.int(8));
|
|
1097
|
-
if (kindOfLand === 'mountain' && roll < 0.15)
|
|
1098
|
-
add('tungsten', 1 + random.int(4));
|
|
1099
|
-
if (kindOfLand === 'hills' && roll > 0.85)
|
|
1100
|
-
add('chromium', 1 + random.int(4));
|
|
1101
|
-
if (kindOfLand === 'jungle' && roll < 0.35)
|
|
1102
|
-
add('rubber', 1 + random.int(5));
|
|
1103
|
-
if (kindOfLand === 'plains' && roll < 0.05)
|
|
1104
|
-
add('aluminium', 1 + random.int(4));
|
|
1105
|
-
}
|
|
1106
|
-
state.victoryPoints.set(state.capital, isCapital ? 25 : Math.max(1, Math.round(development * 8)));
|
|
1107
|
-
}
|
|
1108
1187
|
const cityNames = new Map();
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1188
|
+
// A geography world gets its economy, ports, names and railway costs from the model (ADR 0063);
|
|
1189
|
+
// every other world keeps the random economy below, byte for byte.
|
|
1190
|
+
const economy = gameplay === undefined || spec.developmentModel === 'legacy'
|
|
1191
|
+
? undefined
|
|
1192
|
+
: planEconomy(gameplay, {
|
|
1193
|
+
states: states.map((state) => ({
|
|
1194
|
+
id: state.id,
|
|
1195
|
+
provinces: state.provinces,
|
|
1196
|
+
owner: state.owner,
|
|
1197
|
+
isCapital: state.owner !== undefined && countryCapitalState.get(state.owner) === state,
|
|
1198
|
+
})),
|
|
1199
|
+
terrain,
|
|
1200
|
+
options: {
|
|
1201
|
+
categories,
|
|
1202
|
+
slotsFor,
|
|
1203
|
+
allowedCategories: context.stateCategories,
|
|
1204
|
+
capitalProvinces: new Set(capitals),
|
|
1205
|
+
},
|
|
1206
|
+
regionOf,
|
|
1207
|
+
fixedStateNames: stateNames,
|
|
1208
|
+
fixedRegionNames: regionNames,
|
|
1209
|
+
countryNames: new Map(spec.countries.map(({ tag, name }) => [tag, name])),
|
|
1210
|
+
});
|
|
1211
|
+
if (economy !== undefined) {
|
|
1212
|
+
for (const state of states) {
|
|
1213
|
+
const plan = economy.development.states.get(state.id);
|
|
1214
|
+
state.manpower = plan.manpower;
|
|
1215
|
+
state.category = plan.category;
|
|
1216
|
+
state.infrastructure = plan.infrastructure;
|
|
1217
|
+
state.civilian = plan.civilian;
|
|
1218
|
+
state.military = plan.military;
|
|
1219
|
+
state.dockyard = plan.dockyard;
|
|
1220
|
+
state.airBase = plan.airBase;
|
|
1221
|
+
state.resources = plan.resources;
|
|
1222
|
+
state.victoryPoints = plan.victoryPoints;
|
|
1223
|
+
for (const id of state.provinces) {
|
|
1224
|
+
const port = economy.ports.ports.get(id);
|
|
1225
|
+
if (port !== undefined)
|
|
1226
|
+
state.navalBases.set(id, port.level);
|
|
1227
|
+
const fort = economy.forts.get(id);
|
|
1228
|
+
if (fort !== undefined)
|
|
1229
|
+
state.forts.set(id, fort);
|
|
1230
|
+
}
|
|
1137
1231
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1232
|
+
for (const id of economy.development.urban)
|
|
1233
|
+
terrain.set(id, 'urban');
|
|
1234
|
+
for (const [id, name] of economy.names.victory)
|
|
1235
|
+
cityNames.set(id, name);
|
|
1236
|
+
for (const [id, name] of economy.names.states)
|
|
1237
|
+
stateNames.set(id, name);
|
|
1238
|
+
for (const [id, name] of economy.names.regions)
|
|
1239
|
+
regionNames.set(id, name);
|
|
1240
|
+
for (const [id, name] of economy.names.lakes)
|
|
1241
|
+
lakeNames.set(id, name);
|
|
1242
|
+
for (const note of economy.notes)
|
|
1243
|
+
notes.push(note);
|
|
1140
1244
|
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1245
|
+
if (economy === undefined) {
|
|
1246
|
+
for (const state of states) {
|
|
1247
|
+
const pixels = state.provinces.reduce((sum, id) => sum + provinces.size[id], 0);
|
|
1248
|
+
const habitability = state.provinces.reduce((sum, id) => {
|
|
1249
|
+
const value = {
|
|
1250
|
+
plains: 1,
|
|
1251
|
+
forest: 0.8,
|
|
1252
|
+
hills: 0.6,
|
|
1253
|
+
jungle: 0.5,
|
|
1254
|
+
marsh: 0.4,
|
|
1255
|
+
desert: 0.25,
|
|
1256
|
+
mountain: 0.3,
|
|
1257
|
+
urban: 1.5,
|
|
1258
|
+
}[terrain.get(id) ?? 'plains'] ?? 0.5;
|
|
1259
|
+
return sum + value * provinces.size[id];
|
|
1260
|
+
}, 0) / pixels;
|
|
1261
|
+
const isCapital = state.owner !== undefined && countryCapitalState.get(state.owner) === state;
|
|
1262
|
+
const development = Math.min(1, habitability * (0.5 + random.next()) * (isCapital ? 1.8 : 1));
|
|
1263
|
+
state.manpower = Math.max(1_000, Math.round((pixels * 180 * development) / 1_000) * 1_000);
|
|
1264
|
+
const categoryIndex = Math.max(0, Math.min(categories.length - 1, Math.round(development * 5.2 + (isCapital ? 1 : 0))));
|
|
1265
|
+
state.category = categories[categoryIndex];
|
|
1266
|
+
if (context.stateCategories !== undefined && !context.stateCategories.has(state.category))
|
|
1267
|
+
state.category = context.stateCategories.has('rural')
|
|
1268
|
+
? 'rural'
|
|
1269
|
+
: ([...context.stateCategories][0] ?? 'rural');
|
|
1270
|
+
const slots = slotsFor[state.category] ?? 2;
|
|
1271
|
+
state.infrastructure = Math.max(1, Math.min(5, Math.round(1 + development * 3 + (isCapital ? 1 : 0))));
|
|
1272
|
+
state.civilian = Math.round(slots * (0.25 + random.next() * 0.3));
|
|
1273
|
+
state.military = Math.min(slots - state.civilian, Math.round(slots * random.next() * 0.3));
|
|
1274
|
+
if (isCapital) {
|
|
1275
|
+
terrain.set(state.capital, 'urban');
|
|
1276
|
+
state.airBase = 2;
|
|
1277
|
+
}
|
|
1278
|
+
const coastalProvinces = state.provinces.filter(coastal);
|
|
1279
|
+
if (coastalProvinces.length > 0 && (isCapital || random.next() < 0.35)) {
|
|
1280
|
+
state.navalBases.set(coastalProvinces[0], isCapital ? 3 : 1);
|
|
1281
|
+
if (slots - state.civilian - state.military > 0 && random.next() < 0.5)
|
|
1282
|
+
state.dockyard = 1;
|
|
1283
|
+
}
|
|
1284
|
+
for (const id of state.provinces) {
|
|
1285
|
+
const kindOfLand = terrain.get(id);
|
|
1286
|
+
const roll = random.next();
|
|
1287
|
+
const add = (resource, amount) => state.resources.set(resource, (state.resources.get(resource) ?? 0) + amount);
|
|
1288
|
+
if (kindOfLand === 'desert' && roll < 0.3)
|
|
1289
|
+
add('oil', 2 + random.int(6));
|
|
1290
|
+
if ((kindOfLand === 'mountain' || kindOfLand === 'hills') && roll < 0.35)
|
|
1291
|
+
add('steel', 2 + random.int(8));
|
|
1292
|
+
if (kindOfLand === 'mountain' && roll < 0.15)
|
|
1293
|
+
add('tungsten', 1 + random.int(4));
|
|
1294
|
+
if (kindOfLand === 'hills' && roll > 0.85)
|
|
1295
|
+
add('chromium', 1 + random.int(4));
|
|
1296
|
+
if (kindOfLand === 'jungle' && roll < 0.35)
|
|
1297
|
+
add('rubber', 1 + random.int(5));
|
|
1298
|
+
if (kindOfLand === 'plains' && roll < 0.05)
|
|
1299
|
+
add('aluminium', 1 + random.int(4));
|
|
1300
|
+
}
|
|
1301
|
+
state.victoryPoints.set(state.capital, isCapital ? 25 : Math.max(1, Math.round(development * 8)));
|
|
1149
1302
|
}
|
|
1150
|
-
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1303
|
+
for (const city of namedCities) {
|
|
1304
|
+
const state = stateOf.get(city.province);
|
|
1305
|
+
cityNames.set(city.province, city.name);
|
|
1306
|
+
state.victoryPoints.set(city.province, Math.max(5, state.victoryPoints.get(city.province) ?? 0));
|
|
1307
|
+
terrain.set(city.province, 'urban');
|
|
1308
|
+
}
|
|
1309
|
+
// Each coastal territory needs a port even when its capital lies inland or on another island.
|
|
1310
|
+
const portChecked = new Set();
|
|
1311
|
+
for (const start of landIds) {
|
|
1312
|
+
if (portChecked.has(start))
|
|
1153
1313
|
continue;
|
|
1154
|
-
const
|
|
1155
|
-
|
|
1156
|
-
|
|
1314
|
+
const queue = [start];
|
|
1315
|
+
portChecked.add(start);
|
|
1316
|
+
let bestCoast;
|
|
1317
|
+
let hasPort = false;
|
|
1318
|
+
for (const id of queue) {
|
|
1319
|
+
if (stateOf.get(id).navalBases.has(id))
|
|
1320
|
+
hasPort = true;
|
|
1321
|
+
if (coastal(id) &&
|
|
1322
|
+
(bestCoast === undefined || provinces.size[id] > provinces.size[bestCoast]))
|
|
1323
|
+
bestCoast = id;
|
|
1324
|
+
for (const next of provinces.neighbors[id])
|
|
1325
|
+
if (!portChecked.has(next) &&
|
|
1326
|
+
provinces.kind[next] === LAND &&
|
|
1327
|
+
owner.get(next) === owner.get(start)) {
|
|
1328
|
+
portChecked.add(next);
|
|
1329
|
+
queue.push(next);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (!hasPort && bestCoast !== undefined)
|
|
1333
|
+
stateOf.get(bestCoast).navalBases.set(bestCoast, 1);
|
|
1157
1334
|
}
|
|
1158
1335
|
}
|
|
1159
1336
|
// Province IDs: 1-based in reading order of the province's first pixel; 0 is the null row.
|
|
@@ -1222,8 +1399,11 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1222
1399
|
message: `The rim edge policy wrote ${rimRows.length} impassable adjacency row(s) for provinces that meet across the seam`,
|
|
1223
1400
|
details: { rows: rimRows.length },
|
|
1224
1401
|
});
|
|
1225
|
-
|
|
1226
|
-
add(`${map}/
|
|
1402
|
+
const crossingRows = (gameplay?.crossings.rows ?? []).map(adjacencyRowText);
|
|
1403
|
+
add(`${map}/adjacencies.csv`, text(`From;To;Type;Through;start_x;start_y;stop_x;stop_y;adjacency_rule_name;Comment\r\n${[...crossingRows, ...rimRows].map((row) => `${row}\r\n`).join('')}-1;-1;;-1;-1;-1;-1;-1;-1\r\n`));
|
|
1404
|
+
add(`${map}/adjacency_rules.txt`, text(gameplay === undefined
|
|
1405
|
+
? '# No straits or canals in this world yet.\n'
|
|
1406
|
+
: adjacencyRulesText(gameplay.crossings.rules)));
|
|
1227
1407
|
add(`${map}/positions.txt`, text(''));
|
|
1228
1408
|
add(`${map}/ambient_object.txt`, text(spec.ambientFrame === true
|
|
1229
1409
|
? ambientObjectText(width, height)
|
|
@@ -1562,7 +1742,16 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1562
1742
|
...(state.military > 0 ? [`\t\t\tarms_factory = ${state.military}`] : []),
|
|
1563
1743
|
...(state.dockyard > 0 ? [`\t\t\tdockyard = ${state.dockyard}`] : []),
|
|
1564
1744
|
...(state.airBase > 0 ? [`\t\t\tair_base = ${state.airBase}`] : []),
|
|
1565
|
-
...[
|
|
1745
|
+
...[
|
|
1746
|
+
...state.navalBases.keys(),
|
|
1747
|
+
...[...state.forts.keys()].filter((id) => !state.navalBases.has(id)).sort((a, b) => a - b),
|
|
1748
|
+
].map((id) => {
|
|
1749
|
+
const parts = [
|
|
1750
|
+
...(state.navalBases.has(id) ? [`naval_base = ${state.navalBases.get(id)}`] : []),
|
|
1751
|
+
...(state.forts.has(id) ? [`bunker = ${state.forts.get(id)}`] : []),
|
|
1752
|
+
];
|
|
1753
|
+
return `\t\t\t${provinceId(id)} = { ${parts.join(' ')} }`;
|
|
1754
|
+
}),
|
|
1566
1755
|
];
|
|
1567
1756
|
add(`${context.statesRoot}/${state.id}-${slug(stateNames.get(state.id) ?? `STATE_${state.id}`).slice(0, 80)}.txt`, text([
|
|
1568
1757
|
'state = {',
|
|
@@ -1764,6 +1953,9 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1764
1953
|
.sort((a, b) => a - b)
|
|
1765
1954
|
.map((region) => [`STRATEGICREGION_${region}`, regionNames.get(region) ?? `Region ${region}`]),
|
|
1766
1955
|
...(spec.referenceLabels ?? []).map((label, index) => [`WORLD_GEOGRAPHY_${index + 1}`, label.name]),
|
|
1956
|
+
...[...lakeNames]
|
|
1957
|
+
.sort((a, b) => a[0] - b[0])
|
|
1958
|
+
.map(([id, name]) => [`PROV${provinceId(id)}`, name]),
|
|
1767
1959
|
...continentNames.map((name, index) => [`world_continent_${index + 1}`, name]),
|
|
1768
1960
|
...countryLocalisation,
|
|
1769
1961
|
...(spec.realms === undefined
|
|
@@ -1783,6 +1975,8 @@ export function generateWorld(spec, context, options = {}) {
|
|
|
1783
1975
|
warnings: [...interpretationWarnings, ...drainage.warnings],
|
|
1784
1976
|
notes,
|
|
1785
1977
|
...(synthesized === undefined ? {} : { terrain: synthesized }),
|
|
1978
|
+
...(economy === undefined ? {} : { railCost: economy.railCost }),
|
|
1979
|
+
...(gameplay === undefined ? {} : { gameplay: { crossings: gameplay.crossings, economy } }),
|
|
1786
1980
|
summary: {
|
|
1787
1981
|
width,
|
|
1788
1982
|
height,
|
|
@@ -256,6 +256,9 @@ const detectors = {
|
|
|
256
256
|
const findings = [];
|
|
257
257
|
for (const { node, token } of names) {
|
|
258
258
|
const name = token.slice(token.lastIndexOf('.') + 1);
|
|
259
|
+
// `constant:category.key` reads a script constant, not a scoped variable.
|
|
260
|
+
if (token.startsWith('constant:'))
|
|
261
|
+
continue;
|
|
259
262
|
if (!temporary.has(name) || regular.has(name))
|
|
260
263
|
continue;
|
|
261
264
|
if (!/^(?:[A-Za-z_][\w]*|[a-z_]+:[\w]+)(?:\.[A-Za-z_][\w]*)+$/u.test(token))
|