hoi4-agent-tools 4.0.4 → 4.1.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 +20 -0
- package/README.md +2 -0
- package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
- package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
- package/dist/hoi4_agent_tools/core/engine.js +2 -1
- package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
- package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
- package/dist/hoi4_agent_tools/core/scanner.js +17 -0
- package/dist/hoi4_agent_tools/core/server-state.js +9 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
- package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
- package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
- package/dist/hoi4_agent_tools/core/transactions.js +355 -111
- package/dist/hoi4_agent_tools/gui/layout.js +61 -25
- package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
- package/dist/hoi4_agent_tools/gui/source-graph.js +9 -6
- package/dist/hoi4_agent_tools/gui/studio.js +4 -1
- package/dist/hoi4_agent_tools/map/derived.js +21 -6
- package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
- package/dist/hoi4_agent_tools/map/operations.js +72 -21
- package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
- package/dist/hoi4_agent_tools/map/render.js +9 -1
- package/dist/hoi4_agent_tools/map/service.js +222 -18
- package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
- package/dist/hoi4_agent_tools/map/world-context.js +148 -0
- package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
- package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
- package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
- package/dist/hoi4_agent_tools/map/world.js +374 -253
- package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
- package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
- package/dist/hoi4_agent_tools/schemas/map.js +103 -10
- package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
- package/dist/hoi4_agent_tools/version.js +2 -2
- package/docs/gui.md +6 -2
- package/docs/images/reference-maps/political.png +0 -0
- package/docs/images/reference-maps/sketch.png +0 -0
- package/docs/map.md +45 -0
- package/docs/third-party-notices.md +6 -0
- package/npm-shrinkwrap.json +124 -2
- package/package.json +4 -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/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 +373 -70
- 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/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
|
@@ -402,10 +402,18 @@ export async function renderMap(index, options = {}) {
|
|
|
402
402
|
throw new ServiceError('MAP_TILE_OUT_OF_BOUNDS', 'Map tile extends beyond the province raster', { tile, rasterWidth: raster.width, rasterHeight: raster.height });
|
|
403
403
|
const outputDimensions = budget.reserve((tile?.width ?? raster.width) * resolved.scale, (tile?.height ?? raster.height) * resolved.scale, 'map PNG output');
|
|
404
404
|
const raw = Buffer.alloc(raster.width * raster.height * 4);
|
|
405
|
+
// A base layer assigns one color to each province. Resolve that color once for this
|
|
406
|
+
// immutable render, rather than repeating state lookups and color hashes for every pixel.
|
|
407
|
+
const provinceColors = new Map();
|
|
405
408
|
for (let offset = 0; offset < raster.provinceIds.length; offset += 1) {
|
|
406
409
|
if (offset % Math.max(raster.width * 32, 1) === 0)
|
|
407
410
|
await renderCheckpoint(options.signal);
|
|
408
|
-
const
|
|
411
|
+
const provinceId = raster.provinceIds[offset] ?? -1;
|
|
412
|
+
let color = provinceColors.get(provinceId);
|
|
413
|
+
if (color === undefined) {
|
|
414
|
+
color = baseColor(index, provinceId, resolved.layer);
|
|
415
|
+
provinceColors.set(provinceId, color);
|
|
416
|
+
}
|
|
409
417
|
const target = offset * 4;
|
|
410
418
|
raw[target] = color.r;
|
|
411
419
|
raw[target + 1] = color.g;
|
|
@@ -4,6 +4,7 @@ import { escapeFileGlob } from '../core/file-glob.js';
|
|
|
4
4
|
import { ArtifactStore, boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
|
|
5
5
|
import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
|
|
6
6
|
import { sortDiagnostics } from '../core/diagnostics.js';
|
|
7
|
+
import { diagnosticSeverityCounts, rollupRepeatedSoftDiagnostics, } from '../core/diagnostic-rollup.js';
|
|
7
8
|
import { CoreEngine } from '../core/engine.js';
|
|
8
9
|
import { SymbolIndex } from '../core/index.js';
|
|
9
10
|
import { RenderBudget } from '../core/render-budget.js';
|
|
@@ -13,9 +14,12 @@ import { readDependenciesFromScannedFiles, } from '../core/transactions.js';
|
|
|
13
14
|
import { PACKAGE_VERSION } from '../version.js';
|
|
14
15
|
import { MAP_PROVINCE_GEOMETRY_SELECTOR_LIMIT, MAP_SELECTED_OFFSET_BYTES, MAP_SELECTED_PIXEL_LIMIT, } from './limits.js';
|
|
15
16
|
import { defaultMapSelectorValue, MapWorkspaceIndex, } from './model.js';
|
|
16
|
-
import { planMapOperationsAsync } from './operations.js';
|
|
17
|
+
import { planMapOperationsAsync, } from './operations.js';
|
|
18
|
+
import { prepareReferenceWorld } from './reference-preparation.js';
|
|
19
|
+
import { WORLD_OUTPUT_MANIFEST } from './world-outputs.js';
|
|
17
20
|
import { renderMap, renderMapDiff, } from './render.js';
|
|
18
21
|
import { validateMapAsync } from './validation.js';
|
|
22
|
+
import { buildWorldPreviewArtifacts, generatedWorldOperationIds } from './world-preview.js';
|
|
19
23
|
function compareNumbers(left, right) {
|
|
20
24
|
return left - right;
|
|
21
25
|
}
|
|
@@ -384,6 +388,7 @@ function sourceTextPatterns(roots) {
|
|
|
384
388
|
'common/terrain/*.txt',
|
|
385
389
|
'common/buildings/*.txt',
|
|
386
390
|
'descriptor.mod',
|
|
391
|
+
WORLD_OUTPUT_MANIFEST,
|
|
387
392
|
...roots.map.flatMap((root) => {
|
|
388
393
|
const normalized = normalizeSourceRoot(root);
|
|
389
394
|
return ['map', 'csv', 'txt'].map((extension) => `${normalized}/**/*.${extension}`);
|
|
@@ -391,7 +396,11 @@ function sourceTextPatterns(roots) {
|
|
|
391
396
|
...roots.states.map((root) => `${normalizeSourceRoot(root)}/**/*.txt`),
|
|
392
397
|
...roots.localisation.flatMap((root) => {
|
|
393
398
|
const normalized = normalizeSourceRoot(root);
|
|
394
|
-
return [
|
|
399
|
+
return [
|
|
400
|
+
`${normalized}/english/**/*.{yml,yaml}`,
|
|
401
|
+
`${normalized}/replace/**/*.{yml,yaml}`,
|
|
402
|
+
`${normalized}/*.{yml,yaml}`,
|
|
403
|
+
];
|
|
395
404
|
}),
|
|
396
405
|
];
|
|
397
406
|
}
|
|
@@ -441,8 +450,42 @@ function provinceBitmapPatterns(defaultMaps, mapRoots) {
|
|
|
441
450
|
}
|
|
442
451
|
return [...patterns].sort((left, right) => compareCodeUnits(left, right));
|
|
443
452
|
}
|
|
453
|
+
function mapSourceEvidence(snapshot) {
|
|
454
|
+
if (snapshot.referenceSources === undefined)
|
|
455
|
+
return snapshot.files;
|
|
456
|
+
return [
|
|
457
|
+
...new Map([...snapshot.files, ...(snapshot.referenceSources ?? [])].map((source) => [
|
|
458
|
+
source.displayPath,
|
|
459
|
+
source,
|
|
460
|
+
])).values(),
|
|
461
|
+
].sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
|
|
462
|
+
}
|
|
444
463
|
function sourceHashes(snapshot) {
|
|
445
|
-
return Object.fromEntries(snapshot.
|
|
464
|
+
return Object.fromEntries(mapSourceEvidence(snapshot).map(({ displayPath, sha256 }) => [displayPath, sha256]));
|
|
465
|
+
}
|
|
466
|
+
function mapValidationEvidence(snapshot, validation) {
|
|
467
|
+
const evidence = boundedSourceHashEvidence(sourceHashes(snapshot));
|
|
468
|
+
const counts = diagnosticSeverityCounts(validation.diagnostics);
|
|
469
|
+
const omissions = validation.diagnostics
|
|
470
|
+
.filter((diagnostic) => diagnostic.code === 'MAP_DIAGNOSTICS_TRUNCATED')
|
|
471
|
+
.map((diagnostic) => diagnostic.details ?? {});
|
|
472
|
+
return {
|
|
473
|
+
name: 'map-validation.json',
|
|
474
|
+
mimeType: 'application/json',
|
|
475
|
+
content: `${canonicalJson({ schemaVersion: 1, passed: validation.passed, checks: validation.checks, diagnosticCount: validation.diagnostics.length, counts, diagnostics: validation.diagnostics, omissionEvidence: omissions })}\n`,
|
|
476
|
+
description: 'Complete bounded map validation diagnostics, counts and omission evidence',
|
|
477
|
+
provenance: {
|
|
478
|
+
kind: 'map-validation',
|
|
479
|
+
toolVersion: PACKAGE_VERSION,
|
|
480
|
+
schemaVersion: 'map-validation.v1',
|
|
481
|
+
sourceHashes: evidence.sourceHashes,
|
|
482
|
+
metadata: {
|
|
483
|
+
sourceRevision: snapshot.revision,
|
|
484
|
+
sourceHashInventory: evidence.inventory,
|
|
485
|
+
diagnosticCount: validation.diagnostics.length,
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
};
|
|
446
489
|
}
|
|
447
490
|
function mapBundleWithSourceHashes(bundle, completeSourceHashes) {
|
|
448
491
|
const parsed = JSON.parse(bundle.json);
|
|
@@ -529,7 +572,7 @@ export class AgentNudger {
|
|
|
529
572
|
this.scanner = this.engine.scanner;
|
|
530
573
|
this.transactions = this.engine.transactions;
|
|
531
574
|
}
|
|
532
|
-
async scan(workspaceId, principal, signal, includeExternalScriptReferences = false) {
|
|
575
|
+
async scan(workspaceId, principal, signal, includeExternalScriptReferences = false, includeWorldContext = false) {
|
|
533
576
|
signal?.throwIfAborted();
|
|
534
577
|
const workspace = this.resolver.get(workspaceId, principal);
|
|
535
578
|
const sourceRoots = {
|
|
@@ -546,11 +589,38 @@ export class AgentNudger {
|
|
|
546
589
|
patterns: [...MAP_REFERENCE_SCRIPT_PATTERNS],
|
|
547
590
|
}, principal, signal)
|
|
548
591
|
: undefined;
|
|
592
|
+
const worldContextSnapshot = includeWorldContext
|
|
593
|
+
? await this.engine.scan(workspaceId, {
|
|
594
|
+
patterns: [
|
|
595
|
+
'common/state_category/**/*.txt',
|
|
596
|
+
'common/country_tags/*.txt',
|
|
597
|
+
...sourceRoots.map.flatMap((root) => ['terrain', 'rivers', 'trees', 'cities'].map((name) => `${root}/${name}.bmp`)),
|
|
598
|
+
],
|
|
599
|
+
}, principal, signal)
|
|
600
|
+
: undefined;
|
|
601
|
+
const generatedOutputsSnapshot = includeWorldContext
|
|
602
|
+
? await this.engine.scan(workspaceId, {
|
|
603
|
+
rootKinds: ['mod'],
|
|
604
|
+
patterns: [
|
|
605
|
+
'common/countries/*.txt',
|
|
606
|
+
'common/country_tags/*.txt',
|
|
607
|
+
'history/countries/*.txt',
|
|
608
|
+
'history/units/*.txt',
|
|
609
|
+
'common/on_actions/world.txt',
|
|
610
|
+
'common/ai_areas/world.txt',
|
|
611
|
+
'common/ai_strategy_plans/world.txt',
|
|
612
|
+
'gfx/flags/**/*.tga',
|
|
613
|
+
...sourceRoots.map.flatMap((root) => [`${root}/**/*.bmp`, `${root}/terrain/*.dds`]),
|
|
614
|
+
],
|
|
615
|
+
}, principal, signal)
|
|
616
|
+
: undefined;
|
|
549
617
|
signal?.throwIfAborted();
|
|
550
618
|
const files = [
|
|
551
619
|
...contentSnapshot.files,
|
|
552
620
|
...locatorSnapshot.files,
|
|
553
621
|
...(externalScriptSnapshot?.files ?? []),
|
|
622
|
+
...(worldContextSnapshot?.files ?? []).filter((file) => !contentSnapshot.files.some((existing) => existing.displayPath === file.displayPath)),
|
|
623
|
+
...(generatedOutputsSnapshot?.files ?? []).filter((file) => ![...contentSnapshot.files, ...(worldContextSnapshot?.files ?? [])].some((existing) => existing.displayPath === file.displayPath)),
|
|
554
624
|
].sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
|
|
555
625
|
const revision = hashCanonical(files.map(({ displayPath, loadOrder, sha256 }) => ({ displayPath, loadOrder, sha256 })));
|
|
556
626
|
return {
|
|
@@ -590,11 +660,43 @@ export class AgentNudger {
|
|
|
590
660
|
signal?.throwIfAborted();
|
|
591
661
|
const snapshot = await this.scan(workspaceId, principal, signal, operations.some((operation) => operation.kind === 'renumber_map_entity' ||
|
|
592
662
|
operation.kind === 'remap_map_references' ||
|
|
593
|
-
'remapReferences' in operation));
|
|
594
|
-
const
|
|
595
|
-
const
|
|
596
|
-
|
|
663
|
+
'remapReferences' in operation), operations.some((operation) => operation.kind === 'create_world'));
|
|
664
|
+
const referenceWorlds = new Map();
|
|
665
|
+
const referenceImages = [];
|
|
666
|
+
for (const operation of operations) {
|
|
667
|
+
if (operation.kind !== 'create_world' || operation.reference === undefined)
|
|
668
|
+
continue;
|
|
669
|
+
const loaded = await prepareReferenceWorld(this.resolver, workspaceId, operation, principal, signal);
|
|
670
|
+
referenceWorlds.set(operation.id, loaded.prepared);
|
|
671
|
+
referenceImages.push({
|
|
672
|
+
operationId: operation.id,
|
|
673
|
+
bytes: loaded.images[0].bytes,
|
|
674
|
+
source: loaded.images[0].source,
|
|
675
|
+
});
|
|
676
|
+
for (const image of loaded.images) {
|
|
677
|
+
snapshot.referenceSources ??= [];
|
|
678
|
+
const previous = snapshot.referenceSources.findIndex((file) => file.displayPath === image.source.displayPath);
|
|
679
|
+
if (previous < 0)
|
|
680
|
+
snapshot.referenceSources.push(image.source);
|
|
681
|
+
else
|
|
682
|
+
snapshot.referenceSources[previous] = image.source;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
snapshot.files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
|
|
686
|
+
snapshot.revision = hashCanonical(mapSourceEvidence(snapshot).map(({ displayPath, loadOrder, sha256 }) => ({
|
|
687
|
+
displayPath,
|
|
688
|
+
loadOrder,
|
|
689
|
+
sha256,
|
|
690
|
+
})));
|
|
691
|
+
const plan = await planMapOperationsAsync(snapshot.index, operations, signal, {
|
|
692
|
+
referenceWorlds,
|
|
693
|
+
});
|
|
597
694
|
const newWorld = operations.some(({ kind }) => kind === 'create_world');
|
|
695
|
+
const freshWorld = newWorld && snapshot.index.provinceBitmapFile?.rootKind !== 'mod';
|
|
696
|
+
const baselineValidation = freshWorld
|
|
697
|
+
? { passed: true, diagnostics: [], checks: [] }
|
|
698
|
+
: await validateMapAsync(snapshot.index, signal === undefined ? {} : { signal });
|
|
699
|
+
// A new world has nothing to compare its bitmap format and pixels with.
|
|
598
700
|
const rawValidation = await validateMapAsync(plan.finalIndex, {
|
|
599
701
|
...(newWorld ? {} : { baseline: snapshot.index }),
|
|
600
702
|
...(signal === undefined ? {} : { signal }),
|
|
@@ -615,6 +717,7 @@ export class AgentNudger {
|
|
|
615
717
|
snapshot,
|
|
616
718
|
plan,
|
|
617
719
|
validation: validationWithPlan(attributedValidation, plan),
|
|
720
|
+
referenceImages,
|
|
618
721
|
};
|
|
619
722
|
}
|
|
620
723
|
async plan(input) {
|
|
@@ -627,15 +730,24 @@ export class AgentNudger {
|
|
|
627
730
|
if (snapshot.workspaceId !== input.workspaceId) {
|
|
628
731
|
throw new ServiceError('MAP_PREPARED_WORKSPACE_MISMATCH', 'Prepared map operations belong to another workspace');
|
|
629
732
|
}
|
|
733
|
+
const reportWrite = mapValidationEvidence(snapshot, validation);
|
|
734
|
+
const existingReport = input.artifacts?.find(({ name }) => name === reportWrite.name ||
|
|
735
|
+
(name.startsWith(`${reportWrite.name}.`) && name.endsWith('.chunks.json')));
|
|
736
|
+
const report = existingReport ??
|
|
737
|
+
publicArtifactLink(await this.artifacts.putChunked(this.resolver.get(input.workspaceId, input.principal), reportWrite.name, reportWrite.mimeType, reportWrite.content, reportWrite.provenance, reportWrite.description, input.signal));
|
|
738
|
+
const journalDiagnostics = rollupRepeatedSoftDiagnostics(validation.diagnostics, report);
|
|
739
|
+
const artifacts = [
|
|
740
|
+
...(input.artifacts ?? []),
|
|
741
|
+
...(existingReport === undefined ? [report] : []),
|
|
742
|
+
];
|
|
630
743
|
const transaction = await this.transactions.plan({
|
|
631
744
|
workspaceId: input.workspaceId,
|
|
632
745
|
...(input.principal === undefined ? {} : { principal: input.principal }),
|
|
633
746
|
operationKind: 'agent-nudger-map-changes',
|
|
634
747
|
operations: plan.operations,
|
|
635
748
|
changes: plan.changes,
|
|
636
|
-
readDependencies: readDependenciesFromScannedFiles(snapshot
|
|
637
|
-
|
|
638
|
-
diagnostics: plan.diagnostics,
|
|
749
|
+
readDependencies: readDependenciesFromScannedFiles(mapSourceEvidence(snapshot)),
|
|
750
|
+
artifacts,
|
|
639
751
|
validate: (proposed, signal) => {
|
|
640
752
|
signal?.throwIfAborted();
|
|
641
753
|
const expected = new Map(plan.changes.map((change) => [change.relativePath, change.content]));
|
|
@@ -655,9 +767,9 @@ export class AgentNudger {
|
|
|
655
767
|
}) &&
|
|
656
768
|
[...proposed.keys()].every((relativePath) => expected.has(relativePath));
|
|
657
769
|
const diagnostics = exactProposedBytes
|
|
658
|
-
?
|
|
770
|
+
? journalDiagnostics
|
|
659
771
|
: [
|
|
660
|
-
...
|
|
772
|
+
...journalDiagnostics,
|
|
661
773
|
{
|
|
662
774
|
code: 'MAP_TRANSACTION_BYTES_MISMATCH',
|
|
663
775
|
severity: 'blocker',
|
|
@@ -737,7 +849,7 @@ export class AgentNudger {
|
|
|
737
849
|
bundle,
|
|
738
850
|
artifacts,
|
|
739
851
|
revision: snapshot.revision,
|
|
740
|
-
filesScanned: snapshot.
|
|
852
|
+
filesScanned: mapSourceEvidence(snapshot).map(({ displayPath }) => displayPath),
|
|
741
853
|
};
|
|
742
854
|
}
|
|
743
855
|
async renderDiffAndStore(workspaceId, operations, options = {}) {
|
|
@@ -764,9 +876,30 @@ export class AgentNudger {
|
|
|
764
876
|
}, prepared);
|
|
765
877
|
return { ...diff, ...planned };
|
|
766
878
|
}
|
|
879
|
+
/** Build validated offline evidence without planning or applying a write transaction. */
|
|
880
|
+
async previewRewriteWithDiff(input) {
|
|
881
|
+
const prepared = await this.prepareOperations(input.workspaceId, input.operations, input.principal, input.signal);
|
|
882
|
+
const diff = await this.renderPreparedDiffAndStore(prepared, input.operations, {
|
|
883
|
+
...(input.diffScale === undefined ? {} : { scale: input.diffScale }),
|
|
884
|
+
...(input.principal === undefined ? {} : { principal: input.principal }),
|
|
885
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
886
|
+
});
|
|
887
|
+
return {
|
|
888
|
+
...diff,
|
|
889
|
+
validation: prepared.validation,
|
|
890
|
+
diagnostics: rollupRepeatedSoftDiagnostics(prepared.validation.diagnostics, publicArtifactLink(diff.artifacts.find((artifact) => artifact.name === 'map-validation.json' ||
|
|
891
|
+
(artifact.name.startsWith('map-validation.json.') &&
|
|
892
|
+
artifact.name.endsWith('.chunks.json'))))),
|
|
893
|
+
};
|
|
894
|
+
}
|
|
767
895
|
async renderPreparedDiffAndStore(prepared, operations, options) {
|
|
768
896
|
const budget = new RenderBudget();
|
|
769
897
|
const { snapshot, plan, validation } = prepared;
|
|
898
|
+
const failedWorld = operations.some(({ kind }) => kind === 'create_world') &&
|
|
899
|
+
generatedWorldOperationIds(plan).size === 0 &&
|
|
900
|
+
plan.changes.length === 0;
|
|
901
|
+
// A rolled-back generation has no proposed world. Never present inherited game geography
|
|
902
|
+
// or all its province IDs as the result of a failed operation.
|
|
770
903
|
const review = {
|
|
771
904
|
operationIds: plan.operations.map(({ id }) => id),
|
|
772
905
|
affectedFiles: plan.changes.map(({ relativePath, operationIds, mediaType, content }) => ({
|
|
@@ -784,6 +917,63 @@ export class AgentNudger {
|
|
|
784
917
|
allocations: plan.allocations,
|
|
785
918
|
validation,
|
|
786
919
|
};
|
|
920
|
+
if (failedWorld) {
|
|
921
|
+
const report = mapValidationEvidence(snapshot, validation);
|
|
922
|
+
const json = canonicalJson({ generated: false, blockers: plan.blockers });
|
|
923
|
+
const emptyHash = sha256Bytes('');
|
|
924
|
+
// No raster exists after a failed first generation. This empty internal bundle is never
|
|
925
|
+
// published as an image; it carries only zero changed IDs to the normal result adapter.
|
|
926
|
+
const bundle = {
|
|
927
|
+
width: 0,
|
|
928
|
+
height: 0,
|
|
929
|
+
png: Buffer.alloc(0),
|
|
930
|
+
json,
|
|
931
|
+
html: '',
|
|
932
|
+
hashes: { png: emptyHash, json: sha256Bytes(json), html: emptyHash },
|
|
933
|
+
changedProvinceIds: [],
|
|
934
|
+
review,
|
|
935
|
+
semantic: {
|
|
936
|
+
definitions: [],
|
|
937
|
+
stateMembership: [],
|
|
938
|
+
regionMembership: [],
|
|
939
|
+
states: [],
|
|
940
|
+
ports: [],
|
|
941
|
+
buildingPositions: [],
|
|
942
|
+
unitPositions: [],
|
|
943
|
+
weatherPositions: [],
|
|
944
|
+
entityLocators: [],
|
|
945
|
+
supplyNodes: [],
|
|
946
|
+
railways: [],
|
|
947
|
+
adjacencies: [],
|
|
948
|
+
normalAdjacencies: [],
|
|
949
|
+
supplyNodesChanged: false,
|
|
950
|
+
railwaysChanged: false,
|
|
951
|
+
adjacenciesChanged: false,
|
|
952
|
+
normalAdjacenciesChanged: false,
|
|
953
|
+
},
|
|
954
|
+
};
|
|
955
|
+
const artifacts = await this.artifacts.withAtomicChunkedWrites(this.resolver.get(snapshot.workspaceId, options.principal), [
|
|
956
|
+
report,
|
|
957
|
+
{
|
|
958
|
+
name: 'map-generation-failure.json',
|
|
959
|
+
mimeType: 'application/json',
|
|
960
|
+
content: json,
|
|
961
|
+
provenance: {
|
|
962
|
+
...report.provenance,
|
|
963
|
+
kind: 'map-generation-failure',
|
|
964
|
+
schemaVersion: 'map-generation-failure.v1',
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
], (stored) => Promise.resolve([...stored]), options.signal);
|
|
968
|
+
return {
|
|
969
|
+
bundle,
|
|
970
|
+
proposedBundle: bundle,
|
|
971
|
+
plan,
|
|
972
|
+
artifacts,
|
|
973
|
+
revision: snapshot.revision,
|
|
974
|
+
filesScanned: mapSourceEvidence(snapshot).map(({ displayPath }) => displayPath),
|
|
975
|
+
};
|
|
976
|
+
}
|
|
787
977
|
const comparisonOverlays = [
|
|
788
978
|
'adjacencies',
|
|
789
979
|
'building-positions',
|
|
@@ -798,13 +988,16 @@ export class AgentNudger {
|
|
|
798
988
|
'victory-points',
|
|
799
989
|
'weather-positions',
|
|
800
990
|
];
|
|
801
|
-
const
|
|
802
|
-
snapshot.index.
|
|
991
|
+
const freshWorld = operations.some(({ kind }) => kind === 'create_world') &&
|
|
992
|
+
snapshot.index.provinceBitmapFile?.rootKind !== 'mod';
|
|
993
|
+
const hasBeforeMap = !freshWorld &&
|
|
994
|
+
(snapshot.index.raster !== undefined ||
|
|
995
|
+
snapshot.index.diagnostics.some(({ code }) => code.startsWith('RENDER_')));
|
|
803
996
|
const sameSize = snapshot.index.provinceBitmap !== undefined &&
|
|
804
997
|
snapshot.index.provinceBitmap.width === plan.finalIndex.provinceBitmap?.width &&
|
|
805
998
|
snapshot.index.provinceBitmap.height === plan.finalIndex.provinceBitmap.height;
|
|
806
999
|
const [rawBundle, rawBeforeBundle, rawProposedBundle] = await Promise.all([
|
|
807
|
-
sameSize
|
|
1000
|
+
sameSize && !freshWorld
|
|
808
1001
|
? renderMapDiff(snapshot.index, plan.finalIndex, {
|
|
809
1002
|
...(options.scale === undefined ? {} : { scale: options.scale }),
|
|
810
1003
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
@@ -874,6 +1067,7 @@ export class AgentNudger {
|
|
|
874
1067
|
},
|
|
875
1068
|
});
|
|
876
1069
|
const writes = [
|
|
1070
|
+
mapValidationEvidence(snapshot, validation),
|
|
877
1071
|
{
|
|
878
1072
|
name: 'map-diff.png',
|
|
879
1073
|
mimeType: 'image/png',
|
|
@@ -928,6 +1122,16 @@ export class AgentNudger {
|
|
|
928
1122
|
content: beforeBundle.html,
|
|
929
1123
|
provenance: comparisonProvenance('before'),
|
|
930
1124
|
});
|
|
1125
|
+
const generatedWorlds = generatedWorldOperationIds(plan);
|
|
1126
|
+
if (generatedWorlds.size > 0)
|
|
1127
|
+
writes.push(...(await buildWorldPreviewArtifacts({
|
|
1128
|
+
index: plan.finalIndex,
|
|
1129
|
+
references: prepared.referenceImages.filter(({ operationId }) => generatedWorlds.has(operationId)),
|
|
1130
|
+
diagnostics: validation.diagnostics,
|
|
1131
|
+
provenance,
|
|
1132
|
+
budget,
|
|
1133
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
1134
|
+
})));
|
|
931
1135
|
const artifacts = await this.artifacts.withAtomicChunkedWrites(workspace, writes, (stored) => Promise.resolve([...stored]), options.signal);
|
|
932
1136
|
return {
|
|
933
1137
|
bundle,
|
|
@@ -936,7 +1140,7 @@ export class AgentNudger {
|
|
|
936
1140
|
plan,
|
|
937
1141
|
artifacts,
|
|
938
1142
|
revision: snapshot.revision,
|
|
939
|
-
filesScanned: snapshot.
|
|
1143
|
+
filesScanned: mapSourceEvidence(snapshot).map(({ displayPath }) => displayPath),
|
|
940
1144
|
};
|
|
941
1145
|
}
|
|
942
1146
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
|
|
2
2
|
import { canonicalJson, hashCanonical } from '../core/canonical.js';
|
|
3
|
+
import { diagnosticSeverityCounts } from '../core/diagnostic-rollup.js';
|
|
3
4
|
import { autonomousResultArtifacts, } from '../core/transaction-execution.js';
|
|
4
5
|
import { emptyServiceResult, ServiceError } from '../core/result.js';
|
|
5
6
|
import { setInlineFilesScanned } from '../core/operation-result.js';
|
|
@@ -8,6 +9,7 @@ import { allocateMapIdentifiersAsync, } from './operations.js';
|
|
|
8
9
|
import { buildMapCatalog, lookupMapCoordinate, searchMapCatalog } from './catalog.js';
|
|
9
10
|
import { renderMap } from './render.js';
|
|
10
11
|
import { exportProvinceGeometryRowRuns } from './service.js';
|
|
12
|
+
import { generatedWorldOperationIds } from './world-preview.js';
|
|
11
13
|
const jsonObject = z.record(z.string(), z.json());
|
|
12
14
|
const mapRewriteRecipeSchema = z
|
|
13
15
|
.object({
|
|
@@ -44,7 +46,7 @@ function compactRecordDiffs(entries) {
|
|
|
44
46
|
after: compactNullableText(after),
|
|
45
47
|
}));
|
|
46
48
|
}
|
|
47
|
-
function compactMapSemanticDiff(semantic) {
|
|
49
|
+
export function compactMapSemanticDiff(semantic) {
|
|
48
50
|
const counts = {
|
|
49
51
|
definitions: semantic.definitions.length,
|
|
50
52
|
stateMembership: semantic.stateMembership.length,
|
|
@@ -60,7 +62,7 @@ function compactMapSemanticDiff(semantic) {
|
|
|
60
62
|
adjacencies: semantic.adjacencies.length,
|
|
61
63
|
normalAdjacencies: semantic.normalAdjacencies.length,
|
|
62
64
|
};
|
|
63
|
-
|
|
65
|
+
const result = {
|
|
64
66
|
semantic: {
|
|
65
67
|
definitions: semantic.definitions
|
|
66
68
|
.slice(0, mapInlineDiffLimit)
|
|
@@ -113,6 +115,15 @@ function compactMapSemanticDiff(semantic) {
|
|
|
113
115
|
(before?.length ?? 0) > mapInlineTextLimit ||
|
|
114
116
|
(after?.length ?? 0) > mapInlineTextLimit),
|
|
115
117
|
};
|
|
118
|
+
// Bound the aggregate, not just each collection: a complete world replaces every category.
|
|
119
|
+
// Full diffs remain in map-diff.json; leave enough wire space for preview image links.
|
|
120
|
+
while (Buffer.byteLength(canonicalJson(result.semantic), 'utf8') > 8_192) {
|
|
121
|
+
for (const value of Object.values(result.semantic))
|
|
122
|
+
if (Array.isArray(value))
|
|
123
|
+
value.splice(Math.floor(value.length / 2));
|
|
124
|
+
result.truncated = true;
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
116
127
|
}
|
|
117
128
|
function validationSummary(validation) {
|
|
118
129
|
return { passed: validation.passed, checks: validation.checks };
|
|
@@ -297,6 +308,64 @@ export function completeMapRewrite(workspaceId, execution, recipe) {
|
|
|
297
308
|
].slice(0, 100);
|
|
298
309
|
return result;
|
|
299
310
|
}
|
|
311
|
+
/** Generate and validate the same proposed map without creating a write transaction. */
|
|
312
|
+
export async function previewMapRewrite(nudger, input, context) {
|
|
313
|
+
await context.progress(0, 5, 'Generating and validating an offline map preview');
|
|
314
|
+
const planned = await nudger.previewRewriteWithDiff({
|
|
315
|
+
workspaceId: context.workspaceId,
|
|
316
|
+
operations: input.operations,
|
|
317
|
+
...(input.diffScale === undefined ? {} : { diffScale: input.diffScale }),
|
|
318
|
+
...(context.principal === undefined ? {} : { principal: context.principal }),
|
|
319
|
+
...(context.signal === undefined ? {} : { signal: context.signal }),
|
|
320
|
+
});
|
|
321
|
+
const compactSemantic = compactMapSemanticDiff(planned.bundle.semantic);
|
|
322
|
+
const index = planned.plan.finalIndex;
|
|
323
|
+
const result = emptyServiceResult(context.workspaceId, {
|
|
324
|
+
execution: 'preview',
|
|
325
|
+
allocations: planned.plan.allocations.slice(0, 100).map(compactAllocationEvidence),
|
|
326
|
+
operationBlockers: planned.plan.blockers
|
|
327
|
+
.slice(0, 100)
|
|
328
|
+
.map(({ code, message, operationId }) => ({ code, message, operationId })),
|
|
329
|
+
expectedChangedBounds: planned.plan.expectedChangedBounds ?? null,
|
|
330
|
+
changedProvinceIds: planned.bundle.changedProvinceIds.slice(0, 256),
|
|
331
|
+
changedProvinceCount: planned.bundle.changedProvinceIds.length,
|
|
332
|
+
semanticDiff: compactSemantic.semantic,
|
|
333
|
+
semanticDiffCounts: compactSemantic.counts,
|
|
334
|
+
semanticDiffTruncated: compactSemantic.truncated,
|
|
335
|
+
fileCount: planned.plan.changes.length,
|
|
336
|
+
artifactCount: planned.artifacts.length,
|
|
337
|
+
...(input.operations.some(({ kind }) => kind === 'create_world') &&
|
|
338
|
+
generatedWorldOperationIds(planned.plan).size === 0
|
|
339
|
+
? {}
|
|
340
|
+
: {
|
|
341
|
+
preview: {
|
|
342
|
+
offlineRepresentation: true,
|
|
343
|
+
width: index.raster?.width ?? 0,
|
|
344
|
+
height: index.raster?.height ?? 0,
|
|
345
|
+
provinces: index.definitions.filter(({ id }) => id !== 0).length,
|
|
346
|
+
states: index.states.length,
|
|
347
|
+
regions: index.regions.length,
|
|
348
|
+
countries: new Set(index.states.flatMap(({ owner }) => (owner === undefined ? [] : [owner]))).size,
|
|
349
|
+
warningCount: diagnosticSeverityCounts(planned.diagnostics).warning,
|
|
350
|
+
},
|
|
351
|
+
}),
|
|
352
|
+
});
|
|
353
|
+
result.status = planned.validation.passed ? 'ok' : 'blocked';
|
|
354
|
+
result.code = planned.validation.passed ? 'MAP_PREVIEW_READY' : 'MAP_PREVIEW_BLOCKED';
|
|
355
|
+
setInlineFilesScanned(result, planned.filesScanned);
|
|
356
|
+
result.proposedFiles = planned.plan.changes.slice(0, 100).map(({ relativePath }) => relativePath);
|
|
357
|
+
result.diagnostics = planned.diagnostics.slice(0, 100);
|
|
358
|
+
result.artifacts = planned.artifacts.map(publicArtifactLink);
|
|
359
|
+
result.validation = validationSummary(planned.validation);
|
|
360
|
+
result.blockers = [
|
|
361
|
+
...planned.plan.blockers.map(({ code, message }) => ({ code, message })),
|
|
362
|
+
...planned.diagnostics
|
|
363
|
+
.filter(({ severity }) => severity === 'error' || severity === 'blocker')
|
|
364
|
+
.map(({ code, message }) => ({ code, message })),
|
|
365
|
+
].slice(0, 100);
|
|
366
|
+
await context.progress(5, 5, 'Offline map preview complete; workspace files were not changed');
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
300
369
|
export async function inspectMap(engine, nudger, input, context) {
|
|
301
370
|
await context.progress(0, 3, 'Inspecting and validating the map');
|
|
302
371
|
if (input.lookupOnly) {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { compareCodeUnits } from '../core/canonical.js';
|
|
2
|
+
import { hasBlockingDiagnostics } from '../core/diagnostics.js';
|
|
3
|
+
import { ServiceError } from '../core/result.js';
|
|
4
|
+
import { assignments, childBlocks, firstScalar, parseClausewitz } from '../core/source/index.js';
|
|
5
|
+
import { BmpImage } from './bmp.js';
|
|
6
|
+
function normalized(value) {
|
|
7
|
+
return value.replaceAll('\\', '/').replace(/^\.\//u, '').toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
function invalid(file, message) {
|
|
10
|
+
throw new ServiceError('MAP_WORLD_CONTEXT_INVALID', message, { file: file.displayPath });
|
|
11
|
+
}
|
|
12
|
+
function documentFor(file) {
|
|
13
|
+
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
14
|
+
if (hasBlockingDiagnostics(document.diagnostics))
|
|
15
|
+
invalid(file, 'Installed world conventions contain malformed Clausewitz syntax.');
|
|
16
|
+
return document;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build conventions exclusively from authorized, already-scanned active sources. Missing
|
|
20
|
+
* conventions retain the generator's defaults; present but invalid conventions are refused.
|
|
21
|
+
* Source bitmap bytes and script files are never modified or copied into generated mod files.
|
|
22
|
+
*/
|
|
23
|
+
export function buildWorldContext(base) {
|
|
24
|
+
const context = {
|
|
25
|
+
palettes: {},
|
|
26
|
+
mapRoot: base.sourceRoots.map[0] ?? 'map',
|
|
27
|
+
statesRoot: base.sourceRoots.states[0] ?? 'history/states',
|
|
28
|
+
localisationRoot: base.sourceRoots.localisation[0] ?? 'localisation',
|
|
29
|
+
};
|
|
30
|
+
const continentKeys = new Set();
|
|
31
|
+
for (const file of base.sourceFiles.filter((file) => base.sourceRoots.map.some((root) => normalized(file.relativePath) === normalized(`${root}/continent.txt`)))) {
|
|
32
|
+
for (const block of childBlocks(documentFor(file).root, 'continents')) {
|
|
33
|
+
for (const entry of block.entries) {
|
|
34
|
+
if (entry.type !== 'scalar' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/u.test(entry.value))
|
|
35
|
+
invalid(file, 'Continent identifiers must be simple scalar keys.');
|
|
36
|
+
continentKeys.add(entry.value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (continentKeys.size)
|
|
41
|
+
context.continentKeys = [...continentKeys].sort(compareCodeUnits);
|
|
42
|
+
const inheritedCountryTags = new Set();
|
|
43
|
+
for (const file of base.activeFiles.all.filter((file) => /^common\/country_tags\/[^/]+\.txt$/u.test(normalized(file.relativePath))))
|
|
44
|
+
for (const entry of assignments(documentFor(file).root))
|
|
45
|
+
if (/^[A-Z][A-Z0-9]{2}$/u.test(entry.key.value) && entry.value.type === 'scalar')
|
|
46
|
+
inheritedCountryTags.add(entry.key.value);
|
|
47
|
+
if (inheritedCountryTags.size)
|
|
48
|
+
context.inheritedCountryTags = [...inheritedCountryTags].sort(compareCodeUnits);
|
|
49
|
+
for (const name of ['terrain', 'rivers', 'trees', 'cities']) {
|
|
50
|
+
const file = base.sourceRoots.map
|
|
51
|
+
.map((root) => base.activeFiles.byRelativePath.get(normalized(`${root}/${name}.bmp`)))
|
|
52
|
+
.find((entry) => entry !== undefined);
|
|
53
|
+
if (file === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
let bitmap;
|
|
56
|
+
try {
|
|
57
|
+
bitmap = BmpImage.decode(file.bytes);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
invalid(file, `The installed ${name} bitmap is malformed or unsupported.`);
|
|
61
|
+
}
|
|
62
|
+
const minimumEntries = { terrain: 22, rivers: 256, trees: 30, cities: 16 }[name];
|
|
63
|
+
if (bitmap.bitsPerPixel !== 8 || bitmap.palette.length < minimumEntries)
|
|
64
|
+
invalid(file, `The installed ${name} bitmap requires an 8-bit palette with at least ${minimumEntries} entries.`);
|
|
65
|
+
context.palettes[name] = bitmap.palette.map((color) => ({ ...color }));
|
|
66
|
+
}
|
|
67
|
+
const terrainFiles = base.activeFiles.all
|
|
68
|
+
.filter((file) => /^common\/terrain\/[^/]+\.txt$/u.test(normalized(file.relativePath)))
|
|
69
|
+
.sort((a, b) => compareCodeUnits(normalized(a.relativePath), normalized(b.relativePath)));
|
|
70
|
+
const terrainIndex = new Map();
|
|
71
|
+
for (const file of terrainFiles) {
|
|
72
|
+
const candidates = new Map();
|
|
73
|
+
const terrain = assignments(documentFor(file).root, 'terrain');
|
|
74
|
+
for (const section of terrain)
|
|
75
|
+
if (section.value.type !== 'block')
|
|
76
|
+
invalid(file, 'Graphical terrain sections must be blocks.');
|
|
77
|
+
for (const { value: block } of terrain) {
|
|
78
|
+
if (block.type !== 'block')
|
|
79
|
+
continue;
|
|
80
|
+
if (block.entries.some((entry) => entry.type !== 'assignment'))
|
|
81
|
+
invalid(file, 'Graphical terrain sections require named entries.');
|
|
82
|
+
for (const entry of assignments(block)) {
|
|
83
|
+
if (entry.value.type !== 'block')
|
|
84
|
+
invalid(file, 'Graphical terrain entries must be blocks.');
|
|
85
|
+
const type = firstScalar(entry.value, 'type')?.value;
|
|
86
|
+
const colors = childBlocks(entry.value, 'color');
|
|
87
|
+
if (type === undefined ||
|
|
88
|
+
type.length === 0 ||
|
|
89
|
+
assignments(entry.value, 'type').length !== 1 ||
|
|
90
|
+
assignments(entry.value, 'color').length !== 1 ||
|
|
91
|
+
colors.length !== 1 ||
|
|
92
|
+
colors[0].entries.length === 0)
|
|
93
|
+
invalid(file, 'Graphical terrain requires a type and a nonempty color-index block.');
|
|
94
|
+
let index = 256;
|
|
95
|
+
for (const color of colors[0].entries) {
|
|
96
|
+
if (color.type !== 'scalar' || !/^\d+$/u.test(color.value))
|
|
97
|
+
invalid(file, 'Graphical terrain colors must be integer palette indices.');
|
|
98
|
+
const value = Number(color.value);
|
|
99
|
+
if (!Number.isSafeInteger(value) || value > 255)
|
|
100
|
+
invalid(file, 'Graphical terrain palette indices must be between 0 and 255.');
|
|
101
|
+
if (context.palettes.terrain !== undefined && value >= context.palettes.terrain.length)
|
|
102
|
+
invalid(file, 'Graphical terrain refers to a missing terrain palette entry.');
|
|
103
|
+
index = Math.min(index, value);
|
|
104
|
+
}
|
|
105
|
+
// Prefer ordinary category variants over cross-category names or permanent snow.
|
|
106
|
+
// Within an equal rank, the lowest declared index is the stable representative.
|
|
107
|
+
const name = entry.key.value;
|
|
108
|
+
let rank = name === type ? 0 : name.startsWith(`${type}_`) || /^terrain_\d+$/u.test(name) ? 1 : 2;
|
|
109
|
+
if (firstScalar(entry.value, 'perm_snow')?.value === 'yes')
|
|
110
|
+
rank += 4;
|
|
111
|
+
const previous = candidates.get(type);
|
|
112
|
+
if (previous === undefined ||
|
|
113
|
+
rank < previous.rank ||
|
|
114
|
+
(rank === previous.rank && index < previous.index))
|
|
115
|
+
candidates.set(type, { index, rank });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Later active filenames replace an earlier file's representative for that category.
|
|
119
|
+
for (const [type, candidate] of candidates)
|
|
120
|
+
terrainIndex.set(type, candidate.index);
|
|
121
|
+
}
|
|
122
|
+
if (terrainIndex.size > 0)
|
|
123
|
+
context.terrainIndex = new Map([...terrainIndex].sort(([a], [b]) => compareCodeUnits(a, b)));
|
|
124
|
+
const categoryFiles = base.activeFiles.all
|
|
125
|
+
.filter((file) => /^common\/state_category\/[^/]+\.txt$/u.test(normalized(file.relativePath)))
|
|
126
|
+
.sort((a, b) => compareCodeUnits(normalized(a.relativePath), normalized(b.relativePath)));
|
|
127
|
+
if (categoryFiles.length > 0) {
|
|
128
|
+
const categories = new Set();
|
|
129
|
+
for (const file of categoryFiles) {
|
|
130
|
+
const blocks = childBlocks(documentFor(file).root, 'state_categories');
|
|
131
|
+
if (blocks.length === 0)
|
|
132
|
+
invalid(file, 'State category conventions require a state_categories block.');
|
|
133
|
+
for (const block of blocks) {
|
|
134
|
+
if (block.entries.some((entry) => entry.type !== 'assignment'))
|
|
135
|
+
invalid(file, 'State category conventions require named entries.');
|
|
136
|
+
for (const entry of assignments(block)) {
|
|
137
|
+
if (entry.value.type !== 'block')
|
|
138
|
+
invalid(file, 'State category definitions must be blocks.');
|
|
139
|
+
categories.add(entry.key.value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (categories.size === 0)
|
|
144
|
+
invalid(categoryFiles[0], 'Installed state category conventions define no categories.');
|
|
145
|
+
context.stateCategories = new Set([...categories].sort(compareCodeUnits));
|
|
146
|
+
}
|
|
147
|
+
return context;
|
|
148
|
+
}
|