hoi4-agent-tools 3.7.0 → 3.8.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 +12 -0
- package/README.md +59 -57
- package/dist/hoi4_agent_tools/core/configuration.js +2 -0
- package/dist/hoi4_agent_tools/core/job-store.js +19 -40
- package/dist/hoi4_agent_tools/core/job-worker-host.js +57 -8
- package/dist/hoi4_agent_tools/core/operation-tasks.js +2 -7
- package/dist/hoi4_agent_tools/core/scanner.js +3 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +27 -13
- package/dist/hoi4_agent_tools/core/windows-sharing.js +42 -0
- package/dist/hoi4_agent_tools/core/workspace.js +114 -8
- package/dist/hoi4_agent_tools/event/artifact-validation.js +1 -1
- package/dist/hoi4_agent_tools/event/service.js +1 -1
- package/dist/hoi4_agent_tools/focus/service.js +2 -2
- package/dist/hoi4_agent_tools/gui/studio.js +16 -3
- package/dist/hoi4_agent_tools/map/model.js +81 -2
- package/dist/hoi4_agent_tools/map/service.js +1 -0
- package/dist/hoi4_agent_tools/map/validation.js +60 -4
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +1 -1
- package/dist/hoi4_agent_tools/package-check/service.js +1 -1
- package/dist/hoi4_agent_tools/reference/ranking.js +309 -0
- package/dist/hoi4_agent_tools/reference/script-validation.js +3 -13
- package/dist/hoi4_agent_tools/reference/service.js +152 -116
- package/dist/hoi4_agent_tools/reference/source-locate.js +156 -0
- package/dist/hoi4_agent_tools/reference/source-lookup.js +37 -6
- package/dist/hoi4_agent_tools/reference/suggestions.js +38 -0
- package/dist/hoi4_agent_tools/schemas/reference.js +80 -2
- package/dist/hoi4_agent_tools/schemas/transaction.js +1 -1
- package/dist/hoi4_agent_tools/technology/service.js +1 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/development.md +6 -0
- package/docs/examples.md +3 -3
- package/docs/map.md +10 -0
- package/docs/reference.md +5 -3
- package/docs/research/reference-benchmark.md +39 -0
- package/docs/setup.md +15 -0
- package/docs/tools.md +37 -37
- package/npm-shrinkwrap.json +18 -5
- package/package.json +2 -2
- package/schemas/configuration.schema.json +95 -88
- 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/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 +1 -1
- package/schemas/mechanic-test-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 +113 -50
- package/server.json +2 -2
|
@@ -1,11 +1,94 @@
|
|
|
1
|
-
import { lstat, mkdir, opendir, realpath } from 'node:fs/promises';
|
|
1
|
+
import { lstat, mkdir, opendir, readFile, readdir, realpath } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { WORKSPACE_MAX_REGISTRATIONS, workspaceRegistrationSchema, } from './configuration.js';
|
|
4
4
|
import { compareCodeUnits, hashCanonical, sha256Bytes } from './canonical.js';
|
|
5
5
|
import { ServiceError } from './result.js';
|
|
6
6
|
import { ServerState } from './server-state.js';
|
|
7
|
+
import { parseClausewitz } from './source/parser.js';
|
|
7
8
|
/** Omitted MCP workspace arguments resolve to the mod containing the server cwd. */
|
|
8
9
|
export const CURRENT_WORKSPACE_ID = 'current';
|
|
10
|
+
const DLC_CONTAINERS = ['dlc', 'integrated_dlc'];
|
|
11
|
+
const MAX_DLC_LAYERS = 256;
|
|
12
|
+
const MAX_DESCRIPTOR_BYTES = 64 * 1024;
|
|
13
|
+
/**
|
|
14
|
+
* Installed DLC data folders in the order the engine loads them: after the base game and
|
|
15
|
+
* before mods, sorted by the internal ID in each folder's `dlcNNN.dlc` descriptor. Only real
|
|
16
|
+
* directories inside the game root are admitted; archives are not unpacked.
|
|
17
|
+
*/
|
|
18
|
+
async function discoverDlcLayers(gameRoot) {
|
|
19
|
+
const layers = [];
|
|
20
|
+
for (const container of DLC_CONTAINERS) {
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = await readdir(path.join(gameRoot, container), { withFileTypes: true });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (['ENOENT', 'ENOTDIR'].includes(error.code ?? ''))
|
|
27
|
+
continue;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (!entry.isDirectory() || !/^[A-Za-z0-9_.-]{1,128}$/u.test(entry.name))
|
|
32
|
+
continue;
|
|
33
|
+
const folder = path.join(gameRoot, container, entry.name);
|
|
34
|
+
let names;
|
|
35
|
+
try {
|
|
36
|
+
names = await readdir(folder);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const descriptor = names
|
|
42
|
+
.map((name) => /^dlc(\d{1,6})\.dlc$/iu.exec(name))
|
|
43
|
+
.find((match) => match !== null);
|
|
44
|
+
if (descriptor === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
const canonical = await canonicalPath(folder);
|
|
47
|
+
if (!isWithin(gameRoot, canonical) || canonical === gameRoot)
|
|
48
|
+
continue;
|
|
49
|
+
layers.push({ id: Number(descriptor[1]), folder: entry.name, path: canonical });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (layers.length > MAX_DLC_LAYERS)
|
|
53
|
+
throw new ServiceError('WORKSPACE_DLC_LIMIT', `The game root lists more than ${MAX_DLC_LAYERS} DLC folders`);
|
|
54
|
+
return layers.sort((left, right) => left.id - right.id || compareCodeUnits(left.folder, right.folder));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `replace_path` entries from a mod descriptor. The engine unloads earlier-loaded files
|
|
58
|
+
* under each path. Entries that are not safe relative folders are ignored.
|
|
59
|
+
*/
|
|
60
|
+
export async function descriptorReplacePaths(root) {
|
|
61
|
+
const descriptor = path.join(root, 'descriptor.mod');
|
|
62
|
+
let bytes;
|
|
63
|
+
try {
|
|
64
|
+
const metadata = await lstat(descriptor);
|
|
65
|
+
if (!metadata.isFile() || metadata.size > MAX_DESCRIPTOR_BYTES)
|
|
66
|
+
return [];
|
|
67
|
+
bytes = await readFile(descriptor);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const document = parseClausewitz(bytes, descriptor);
|
|
73
|
+
const paths = [];
|
|
74
|
+
for (const entry of document.root.entries) {
|
|
75
|
+
if (entry.type !== 'assignment' ||
|
|
76
|
+
entry.key.value.toLowerCase() !== 'replace_path' ||
|
|
77
|
+
entry.value.type !== 'scalar')
|
|
78
|
+
continue;
|
|
79
|
+
const normalized = entry.value.value.replaceAll('\\', '/').replace(/\/+$/u, '');
|
|
80
|
+
if (normalized.length === 0 ||
|
|
81
|
+
normalized.length > 1024 ||
|
|
82
|
+
normalized.startsWith('/') ||
|
|
83
|
+
normalized.includes(':') ||
|
|
84
|
+
/[*?[\]{}!\0]/u.test(normalized) ||
|
|
85
|
+
normalized.split('/').some((segment) => segment === '..' || segment === '.' || segment === ''))
|
|
86
|
+
continue;
|
|
87
|
+
if (!paths.includes(normalized))
|
|
88
|
+
paths.push(normalized);
|
|
89
|
+
}
|
|
90
|
+
return paths;
|
|
91
|
+
}
|
|
9
92
|
async function exists(value, signal) {
|
|
10
93
|
signal?.throwIfAborted();
|
|
11
94
|
try {
|
|
@@ -454,7 +537,7 @@ export class WorkspaceResolver {
|
|
|
454
537
|
this.configuration.http.principals.some((entry) => entry.principal === principal && entry.allowDiscoveredMods));
|
|
455
538
|
}
|
|
456
539
|
assertWorkspaceIsolation(workspace) {
|
|
457
|
-
const readRoots = workspace.roots.filter((root) => ['game', 'dependency', 'fixture'].includes(root.kind));
|
|
540
|
+
const readRoots = workspace.roots.filter((root) => ['game', 'dlc', 'dependency', 'fixture'].includes(root.kind));
|
|
458
541
|
const ownedRoots = workspace.roots.filter((root) => ['mod', 'artifact', 'cache'].includes(root.kind));
|
|
459
542
|
for (const existing of this.#byId.values()) {
|
|
460
543
|
if (existing.roots.some((root) => pathsOverlap(workspace.modRoot, root.path))) {
|
|
@@ -479,6 +562,12 @@ export class WorkspaceResolver {
|
|
|
479
562
|
? registration.dependencies
|
|
480
563
|
: registration.dependencyRoots.map((root) => ({ root, replacePaths: [] }));
|
|
481
564
|
const dependencyRoots = await Promise.all(dependencyRegistrations.map(({ root }) => canonicalPath(root)));
|
|
565
|
+
// Descriptor `replace_path` entries join any configured ones, as the engine applies them.
|
|
566
|
+
const replacePathsFor = async (root, configured) => [
|
|
567
|
+
...new Set([...configured, ...(await descriptorReplacePaths(root))]),
|
|
568
|
+
];
|
|
569
|
+
const modReplacePaths = registration.kind === 'game' ? [] : await replacePathsFor(modRoot, registration.replacePaths);
|
|
570
|
+
const dependencyReplacePaths = await Promise.all(dependencyRoots.map((root, index) => replacePathsFor(root, dependencyRegistrations[index].replacePaths)));
|
|
482
571
|
if (registration.kind !== 'mod' &&
|
|
483
572
|
(registration.artifactRoot === undefined || registration.cacheRoot === undefined)) {
|
|
484
573
|
throw new ServiceError('WORKSPACE_GENERATED_ROOT_REQUIRED', 'Read-only game and dependency workspaces require explicit operator-owned storage roots', { workspaceId: registration.id });
|
|
@@ -539,20 +628,36 @@ export class WorkspaceResolver {
|
|
|
539
628
|
}
|
|
540
629
|
await mkdir(artifactRoot, { recursive: true });
|
|
541
630
|
await mkdir(cacheRoot, { recursive: true });
|
|
631
|
+
// Load order follows the engine: base game, DLC by internal ID, dependencies, then the
|
|
632
|
+
// workspace's own root. A game workspace is itself the base, so its DLC follow it.
|
|
633
|
+
const dlcBase = registration.kind === 'game' ? modRoot : gameRoot;
|
|
634
|
+
const dlcLayers = dlcBase === undefined || !registration.includeGameDlc ? [] : await discoverDlcLayers(dlcBase);
|
|
635
|
+
const dependencyOffset = registration.kind === 'game' ? 0 : dlcLayers.length;
|
|
636
|
+
const primaryOrder = dependencyRoots.length + dependencyOffset + 1;
|
|
637
|
+
const firstDlcOrder = registration.kind === 'game' ? primaryOrder + 1 : 1;
|
|
638
|
+
const generatedOrder = dependencyRoots.length + dlcLayers.length + 2;
|
|
542
639
|
const roots = [
|
|
543
640
|
{
|
|
544
641
|
kind: registration.kind,
|
|
545
642
|
path: modRoot,
|
|
546
643
|
writable: registration.kind === 'mod',
|
|
547
|
-
loadOrder:
|
|
548
|
-
replacePaths:
|
|
644
|
+
loadOrder: primaryOrder,
|
|
645
|
+
replacePaths: modReplacePaths,
|
|
549
646
|
},
|
|
550
647
|
...dependencyRoots.map((dependency, index) => ({
|
|
551
648
|
kind: 'dependency',
|
|
552
649
|
path: dependency,
|
|
553
650
|
writable: false,
|
|
554
|
-
loadOrder: index + 1,
|
|
555
|
-
replacePaths: [
|
|
651
|
+
loadOrder: index + 1 + dependencyOffset,
|
|
652
|
+
replacePaths: dependencyReplacePaths[index],
|
|
653
|
+
})),
|
|
654
|
+
...dlcLayers.map((layer, index) => ({
|
|
655
|
+
kind: 'dlc',
|
|
656
|
+
path: layer.path,
|
|
657
|
+
writable: false,
|
|
658
|
+
loadOrder: firstDlcOrder + index,
|
|
659
|
+
replacePaths: [],
|
|
660
|
+
label: layer.folder,
|
|
556
661
|
})),
|
|
557
662
|
...(gameRoot === undefined
|
|
558
663
|
? []
|
|
@@ -569,14 +674,14 @@ export class WorkspaceResolver {
|
|
|
569
674
|
kind: 'artifact',
|
|
570
675
|
path: artifactRoot,
|
|
571
676
|
writable: true,
|
|
572
|
-
loadOrder:
|
|
677
|
+
loadOrder: generatedOrder,
|
|
573
678
|
replacePaths: [],
|
|
574
679
|
},
|
|
575
680
|
{
|
|
576
681
|
kind: 'cache',
|
|
577
682
|
path: cacheRoot,
|
|
578
683
|
writable: true,
|
|
579
|
-
loadOrder:
|
|
684
|
+
loadOrder: generatedOrder,
|
|
580
685
|
replacePaths: [],
|
|
581
686
|
},
|
|
582
687
|
...(fixtureRoot === undefined
|
|
@@ -602,6 +707,7 @@ export class WorkspaceResolver {
|
|
|
602
707
|
modRoot,
|
|
603
708
|
...(gameRoot === undefined ? {} : { gameRoot }),
|
|
604
709
|
dependencyRoots,
|
|
710
|
+
dlcLayers,
|
|
605
711
|
artifactRoot,
|
|
606
712
|
cacheRoot,
|
|
607
713
|
...(fixtureRoot === undefined ? {} : { fixtureRoot }),
|
|
@@ -103,7 +103,7 @@ const unresolvedKinds = new Set([
|
|
|
103
103
|
'state',
|
|
104
104
|
'partial_source',
|
|
105
105
|
]);
|
|
106
|
-
const rootKinds = new Set(['mod', 'game', 'dependency', 'artifact', 'cache', 'fixture']);
|
|
106
|
+
const rootKinds = new Set(['mod', 'game', 'dlc', 'dependency', 'artifact', 'cache', 'fixture']);
|
|
107
107
|
const symbolKinds = new Set([
|
|
108
108
|
'focus_tree',
|
|
109
109
|
'focus',
|
|
@@ -196,7 +196,7 @@ async function scanEventSources(engine, workspaceId, workspace, focused = false,
|
|
|
196
196
|
if (workspace.roots.some(({ kind }) => kind === 'game'))
|
|
197
197
|
files.push(...(await engine.scanner.scan(workspace, {
|
|
198
198
|
patterns: gamePatterns,
|
|
199
|
-
rootKinds: ['game'],
|
|
199
|
+
rootKinds: ['game', 'dlc'],
|
|
200
200
|
...(signal === undefined ? {} : { signal }),
|
|
201
201
|
})));
|
|
202
202
|
recomputeShadowing(files);
|
|
@@ -108,7 +108,7 @@ export class FocusWorkbench {
|
|
|
108
108
|
this.artifactStore = artifactStore;
|
|
109
109
|
}
|
|
110
110
|
async importPath(workspaceId, relativePath, principal, sidecar, references) {
|
|
111
|
-
const resolved = await this.resolver.resolvePath(workspaceId, relativePath, 'read', ['mod', 'dependency', 'game', 'fixture'], principal);
|
|
111
|
+
const resolved = await this.resolver.resolvePath(workspaceId, relativePath, 'read', ['mod', 'dependency', 'game', 'dlc', 'fixture'], principal);
|
|
112
112
|
const document = parseClausewitz(await readFile(resolved.path), `${resolved.root.kind}:${relativePath}`);
|
|
113
113
|
const result = importFocusTrees(document, references === undefined ? {} : { references });
|
|
114
114
|
if (sidecar !== undefined) {
|
|
@@ -121,7 +121,7 @@ export class FocusWorkbench {
|
|
|
121
121
|
return { document, result };
|
|
122
122
|
}
|
|
123
123
|
async importContinuousPath(workspaceId, relativePath, principal) {
|
|
124
|
-
const resolved = await this.resolver.resolvePath(workspaceId, relativePath, 'read', ['mod', 'dependency', 'game', 'fixture'], principal);
|
|
124
|
+
const resolved = await this.resolver.resolvePath(workspaceId, relativePath, 'read', ['mod', 'dependency', 'game', 'dlc', 'fixture'], principal);
|
|
125
125
|
const document = parseClausewitz(await readFile(resolved.path), `${resolved.root.kind}:${relativePath}`);
|
|
126
126
|
return { document, result: importContinuousFocusPalettes(document) };
|
|
127
127
|
}
|
|
@@ -8,6 +8,7 @@ import { sortDiagnostics } from '../core/diagnostics.js';
|
|
|
8
8
|
import { CoreEngine } from '../core/engine.js';
|
|
9
9
|
import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
|
|
10
10
|
import { assertRenderDimensions, RenderBudget, RENDER_MAX_PIXELS } from '../core/render-budget.js';
|
|
11
|
+
import { escapeFileGlob } from '../core/file-glob.js';
|
|
11
12
|
import { ServiceError } from '../core/result.js';
|
|
12
13
|
import { WorkspaceScanner } from '../core/scanner.js';
|
|
13
14
|
import { applyReplacements, decodeSource, encodeSource, parseClausewitz, parseLocalisation, SOURCE_MAX_BYTES, sourcePartialLimitDiagnostics, } from '../core/source/index.js';
|
|
@@ -239,15 +240,27 @@ function referenceVariants(value) {
|
|
|
239
240
|
: [normalized];
|
|
240
241
|
return candidates;
|
|
241
242
|
}
|
|
243
|
+
const FALLBACK_NAMES_PER_PATTERN = 100;
|
|
242
244
|
function basenameFallbackPatterns(exactPaths, exactFiles) {
|
|
243
245
|
const resolved = new Set(exactFiles
|
|
244
246
|
.filter(({ shadowedBy }) => shadowedBy === undefined)
|
|
245
247
|
.map(({ relativePath }) => relativePath.replaceAll('\\', '/').toLowerCase()));
|
|
246
|
-
|
|
248
|
+
const names = [
|
|
247
249
|
...new Set(exactPaths
|
|
248
250
|
.filter((candidate) => !/[*?[\]{}!]/u.test(candidate) && !resolved.has(candidate.toLowerCase()))
|
|
249
|
-
.map((candidate) =>
|
|
251
|
+
.map((candidate) => path.posix.basename(candidate))),
|
|
250
252
|
].sort((left, right) => compareCodeUnits(left, right));
|
|
253
|
+
// One brace group searches many names in a single bounded wildcard pattern, so a window
|
|
254
|
+
// with hundreds of unresolved assets stays inside the scanner's wildcard-pattern ceiling.
|
|
255
|
+
const grouped = names.filter((name) => !/[,{}]/u.test(name));
|
|
256
|
+
const patterns = names
|
|
257
|
+
.filter((name) => /[,{}]/u.test(name))
|
|
258
|
+
.map((name) => `**/${escapeFileGlob(name)}`);
|
|
259
|
+
for (let start = 0; start < grouped.length; start += FALLBACK_NAMES_PER_PATTERN) {
|
|
260
|
+
const chunk = grouped.slice(start, start + FALLBACK_NAMES_PER_PATTERN).map(escapeFileGlob);
|
|
261
|
+
patterns.push(chunk.length === 1 ? `**/${chunk[0]}` : `**/{${chunk.join(',')}}`);
|
|
262
|
+
}
|
|
263
|
+
return patterns.sort((left, right) => compareCodeUnits(left, right));
|
|
251
264
|
}
|
|
252
265
|
function selectedElements(graph, windowName, additionalTemplateNames = []) {
|
|
253
266
|
const byId = new Map(graph.elements.map((element) => [element.id, element]));
|
|
@@ -987,7 +1000,7 @@ export class ScriptedGuiStudio {
|
|
|
987
1000
|
collectNamedAttributes(element.attributes, new Set(['verticalScrollbar', 'horizontalScrollbar', 'scrollbarType', 'scrollbartype']), requiredNames);
|
|
988
1001
|
const availableNames = new Set(layoutGraph.elements.map(({ name }) => name));
|
|
989
1002
|
if ([...requiredNames].some((name) => !availableNames.has(name))) {
|
|
990
|
-
const gameLayouts = await this.engine.scan(workspaceId, { patterns: layoutPatterns, rootKinds: ['game'] }, principal, signal);
|
|
1003
|
+
const gameLayouts = await this.engine.scan(workspaceId, { patterns: layoutPatterns, rootKinds: ['game', 'dlc'] }, principal, signal);
|
|
991
1004
|
const gameGraph = this.graphForFiles(gameLayouts.files, `game-layout:${workspaceId}`).graph;
|
|
992
1005
|
const selectedPaths = new Set(selectedElements(gameGraph, windowName, [...requiredNames]).map(({ sourcePath }) => sourcePath));
|
|
993
1006
|
layoutFiles = mergeScannedFiles(layoutFiles, gameLayouts.files.filter(({ displayPath }) => selectedPaths.has(displayPath)));
|
|
@@ -456,6 +456,44 @@ export function parseState(file, diagnostics, earliestBookmark) {
|
|
|
456
456
|
...(buildingsBlock === undefined ? {} : { buildingsBlock }),
|
|
457
457
|
};
|
|
458
458
|
}
|
|
459
|
+
/**
|
|
460
|
+
* Provincial terrain categories from the active `common/terrain` files. Files are read in
|
|
461
|
+
* ASCII path order, so a later file's category replaces an earlier one of the same name.
|
|
462
|
+
*/
|
|
463
|
+
export function parseTerrainCategories(files, diagnostics) {
|
|
464
|
+
const categories = new Map();
|
|
465
|
+
for (const file of [...files].sort((left, right) => compareCodeUnits(normalizedPath(left.relativePath), normalizedPath(right.relativePath)))) {
|
|
466
|
+
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
467
|
+
addMapDiagnostics(diagnostics, document.diagnostics);
|
|
468
|
+
for (const entry of document.root.entries) {
|
|
469
|
+
if (entry.type !== 'assignment' || entry.key.value !== 'categories')
|
|
470
|
+
continue;
|
|
471
|
+
if (entry.value.type !== 'block')
|
|
472
|
+
continue;
|
|
473
|
+
for (const category of entry.value.entries) {
|
|
474
|
+
if (category.type !== 'assignment' || category.value.type !== 'block')
|
|
475
|
+
continue;
|
|
476
|
+
const flag = (key) => firstScalar(category.value, key)?.value === 'yes';
|
|
477
|
+
categories.set(category.key.value, {
|
|
478
|
+
name: category.key.value,
|
|
479
|
+
water: flag('is_water'),
|
|
480
|
+
naval: flag('naval_terrain'),
|
|
481
|
+
file,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return categories;
|
|
487
|
+
}
|
|
488
|
+
/** Continent names in `continent.txt`; their one-based positions are the continent IDs. */
|
|
489
|
+
export function parseContinents(file, diagnostics) {
|
|
490
|
+
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
491
|
+
addMapDiagnostics(diagnostics, document.diagnostics);
|
|
492
|
+
const assignment = firstTopLevelAssignment(document, 'continents');
|
|
493
|
+
if (assignment?.value.type !== 'block')
|
|
494
|
+
return undefined;
|
|
495
|
+
return scalarList(assignment.value);
|
|
496
|
+
}
|
|
459
497
|
export function parseStrategicRegion(file, diagnostics) {
|
|
460
498
|
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
461
499
|
addMapDiagnostics(diagnostics, document.diagnostics);
|
|
@@ -499,13 +537,22 @@ export function parseAdjacencies(file, diagnostics) {
|
|
|
499
537
|
const document = parseTextDocument(file);
|
|
500
538
|
const result = [];
|
|
501
539
|
let dataIndex = 0;
|
|
540
|
+
let terminator;
|
|
541
|
+
const ignored = [];
|
|
502
542
|
for (const line of document.lines) {
|
|
503
543
|
const trimmed = line.text.trim();
|
|
504
544
|
if (line.index === 0 || trimmed === '' || trimmed.startsWith('#'))
|
|
505
545
|
continue;
|
|
546
|
+
// The engine stops reading at the first -1 row; later rows have no effect.
|
|
547
|
+
if (terminator !== undefined) {
|
|
548
|
+
ignored.push(line);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
506
551
|
const parts = splitDelimitedRow(line.text, 10);
|
|
507
|
-
if (parts[0] === '-1')
|
|
508
|
-
|
|
552
|
+
if (parts[0] === '-1') {
|
|
553
|
+
terminator = line;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
509
556
|
if (parts.length < 9) {
|
|
510
557
|
addMapDiagnostic(diagnostics, {
|
|
511
558
|
code: 'MAP_ADJACENCY_ROW_MALFORMED',
|
|
@@ -545,6 +592,23 @@ export function parseAdjacencies(file, diagnostics) {
|
|
|
545
592
|
});
|
|
546
593
|
dataIndex += 1;
|
|
547
594
|
}
|
|
595
|
+
if (ignored.length > 0)
|
|
596
|
+
addMapDiagnostic(diagnostics, {
|
|
597
|
+
code: 'MAP_ADJACENCY_AFTER_TERMINATOR',
|
|
598
|
+
severity: 'warning',
|
|
599
|
+
category: 'map',
|
|
600
|
+
message: `${ignored.length} adjacency row(s) follow the -1 terminator row and are not read`,
|
|
601
|
+
location: lineLocation(document, ignored[0]),
|
|
602
|
+
details: { ignoredRows: ignored.length },
|
|
603
|
+
});
|
|
604
|
+
if (terminator === undefined)
|
|
605
|
+
addMapDiagnostic(diagnostics, {
|
|
606
|
+
code: 'MAP_ADJACENCY_TERMINATOR_MISSING',
|
|
607
|
+
severity: 'warning',
|
|
608
|
+
category: 'map',
|
|
609
|
+
message: 'Adjacency file has no -1;-1;-1;-1;-1;-1;-1;-1;-1 terminator row',
|
|
610
|
+
location: lineLocation(document, document.lines.at(-1) ?? document.lines[0]),
|
|
611
|
+
});
|
|
548
612
|
return result;
|
|
549
613
|
}
|
|
550
614
|
function parseWhitespaceRows(file, diagnostics) {
|
|
@@ -971,6 +1035,11 @@ export class MapWorkspaceIndex {
|
|
|
971
1035
|
defaultMapFile;
|
|
972
1036
|
definitionFile;
|
|
973
1037
|
adjacencyFile;
|
|
1038
|
+
continentFile;
|
|
1039
|
+
/** Continent names when `continent.txt` was read; its positions are the IDs. */
|
|
1040
|
+
continents;
|
|
1041
|
+
/** Terrain categories when any `common/terrain` file was read. */
|
|
1042
|
+
terrainCategories;
|
|
974
1043
|
supplyNodeFile;
|
|
975
1044
|
railwayFile;
|
|
976
1045
|
constructor(files, sourceRoots = defaultMapIndexSourceRoots, sharedIndex = SymbolIndex.build(files)) {
|
|
@@ -1119,6 +1188,16 @@ export class MapWorkspaceIndex {
|
|
|
1119
1188
|
this.regionsByProvince.set(provinceId, memberships);
|
|
1120
1189
|
}
|
|
1121
1190
|
}
|
|
1191
|
+
this.continentFile = mapFile(this.activeFiles, defaultMapSelectorValue(this.defaultMapFile, 'continent', 'continent.txt'), this.sourceRoots.map);
|
|
1192
|
+
this.continents =
|
|
1193
|
+
this.continentFile === undefined
|
|
1194
|
+
? undefined
|
|
1195
|
+
: parseContinents(this.continentFile, this.diagnostics);
|
|
1196
|
+
const terrainFiles = this.activeFiles.all.filter((file) => /^common\/terrain\/[^/]+\.txt$/iu.test(normalizedPath(file.relativePath)));
|
|
1197
|
+
this.terrainCategories =
|
|
1198
|
+
terrainFiles.length === 0
|
|
1199
|
+
? undefined
|
|
1200
|
+
: parseTerrainCategories(terrainFiles, this.diagnostics);
|
|
1122
1201
|
this.adjacencyFile = mapFile(this.activeFiles, defaultMapSelectorValue(this.defaultMapFile, 'adjacencies', 'adjacencies.csv'), this.sourceRoots.map);
|
|
1123
1202
|
this.adjacencies =
|
|
1124
1203
|
this.adjacencyFile === undefined
|
|
@@ -380,6 +380,7 @@ function defaultMapPatterns(roots) {
|
|
|
380
380
|
function sourceTextPatterns(roots) {
|
|
381
381
|
return [
|
|
382
382
|
'common/bookmarks/**/*.txt',
|
|
383
|
+
'common/terrain/*.txt',
|
|
383
384
|
...roots.map.flatMap((root) => {
|
|
384
385
|
const normalized = normalizeSourceRoot(root);
|
|
385
386
|
return ['map', 'csv', 'txt'].map((extension) => `${normalized}/**/*.${extension}`);
|
|
@@ -546,6 +546,36 @@ function* validateDefinitions(index, diagnostics, options) {
|
|
|
546
546
|
}
|
|
547
547
|
for (const [definitionIndex, definition] of index.definitions.entries()) {
|
|
548
548
|
yield* cancellationCheckpoint(options.signal, definitionIndex, 256);
|
|
549
|
+
const terrain = index.terrainCategories?.get(definition.terrain);
|
|
550
|
+
if (index.terrainCategories !== undefined && terrain === undefined) {
|
|
551
|
+
addDiagnostic(diagnostics, options, {
|
|
552
|
+
code: 'MAP_TERRAIN_UNKNOWN',
|
|
553
|
+
severity: 'error',
|
|
554
|
+
category: 'map',
|
|
555
|
+
message: `Province ${definition.id} uses terrain ${definition.terrain}, which no common/terrain category defines`,
|
|
556
|
+
location: definitionLocation(definition),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
if (definition.type === 'land' &&
|
|
560
|
+
index.continents !== undefined &&
|
|
561
|
+
definition.continent > index.continents.length) {
|
|
562
|
+
addDiagnostic(diagnostics, options, {
|
|
563
|
+
code: 'MAP_CONTINENT_UNKNOWN',
|
|
564
|
+
severity: 'error',
|
|
565
|
+
category: 'map',
|
|
566
|
+
message: `Land province ${definition.id} uses continent ${definition.continent}, but continent.txt defines ${index.continents.length}`,
|
|
567
|
+
location: definitionLocation(definition),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
if (definition.type === 'land' && terrain?.water === true) {
|
|
571
|
+
addDiagnostic(diagnostics, options, {
|
|
572
|
+
code: 'MAP_LAND_TERRAIN_WATER',
|
|
573
|
+
severity: 'error',
|
|
574
|
+
category: 'map',
|
|
575
|
+
message: `Land province ${definition.id} uses water terrain ${definition.terrain}`,
|
|
576
|
+
location: definitionLocation(definition),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
549
579
|
if (definition.type === 'land' && definition.continent === 0) {
|
|
550
580
|
addDiagnostic(diagnostics, options, {
|
|
551
581
|
code: 'MAP_LAND_CONTINENT_MISSING',
|
|
@@ -555,21 +585,30 @@ function* validateDefinitions(index, diagnostics, options) {
|
|
|
555
585
|
location: definitionLocation(definition),
|
|
556
586
|
});
|
|
557
587
|
}
|
|
558
|
-
|
|
588
|
+
// With terrain categories available, any is_water category suits seas and lakes; without
|
|
589
|
+
// them, the base game's ocean and lakes categories are the only known water terrains.
|
|
590
|
+
const water = (fallback) => index.terrainCategories === undefined
|
|
591
|
+
? definition.terrain === fallback
|
|
592
|
+
: terrain === undefined || terrain.water;
|
|
593
|
+
if (definition.type === 'sea' && !water('ocean')) {
|
|
559
594
|
addDiagnostic(diagnostics, options, {
|
|
560
595
|
code: 'MAP_SEA_TERRAIN_INVALID',
|
|
561
596
|
severity: 'error',
|
|
562
597
|
category: 'map',
|
|
563
|
-
message:
|
|
598
|
+
message: index.terrainCategories === undefined
|
|
599
|
+
? `Sea province ${definition.id} must use ocean terrain`
|
|
600
|
+
: `Sea province ${definition.id} must use a water terrain (is_water = yes)`,
|
|
564
601
|
location: definitionLocation(definition),
|
|
565
602
|
});
|
|
566
603
|
}
|
|
567
|
-
if (definition.type === 'lake' &&
|
|
604
|
+
if (definition.type === 'lake' && !water('lakes')) {
|
|
568
605
|
addDiagnostic(diagnostics, options, {
|
|
569
606
|
code: 'MAP_LAKE_TERRAIN_INVALID',
|
|
570
607
|
severity: 'error',
|
|
571
608
|
category: 'map',
|
|
572
|
-
message:
|
|
609
|
+
message: index.terrainCategories === undefined
|
|
610
|
+
? `Lake province ${definition.id} must use lakes terrain`
|
|
611
|
+
: `Lake province ${definition.id} must use a water terrain (is_water = yes)`,
|
|
573
612
|
location: definitionLocation(definition),
|
|
574
613
|
});
|
|
575
614
|
}
|
|
@@ -711,6 +750,23 @@ function* validateStatesAndRegions(index, diagnostics, options) {
|
|
|
711
750
|
});
|
|
712
751
|
}
|
|
713
752
|
}
|
|
753
|
+
if (index.terrainCategories !== undefined)
|
|
754
|
+
for (const region of index.regions) {
|
|
755
|
+
if (region.navalTerrain === undefined)
|
|
756
|
+
continue;
|
|
757
|
+
const category = index.terrainCategories.get(region.navalTerrain);
|
|
758
|
+
if (category?.naval === true)
|
|
759
|
+
continue;
|
|
760
|
+
addDiagnostic(diagnostics, options, {
|
|
761
|
+
code: 'MAP_REGION_NAVAL_TERRAIN_INVALID',
|
|
762
|
+
severity: 'error',
|
|
763
|
+
category: 'map',
|
|
764
|
+
message: category === undefined
|
|
765
|
+
? `Strategic region ${region.id} names naval terrain ${region.navalTerrain}, which no common/terrain category defines`
|
|
766
|
+
: `Strategic region ${region.id} names ${region.navalTerrain}, which is not a naval terrain (naval_terrain = yes)`,
|
|
767
|
+
location: regionLocation(region),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
714
770
|
for (const [id, regions] of duplicateValues(index.regions, (region) => String(region.id))) {
|
|
715
771
|
const firstRegion = regions[0];
|
|
716
772
|
if (firstRegion === undefined)
|
|
@@ -36,7 +36,7 @@ export const referenceTools = [
|
|
|
36
36
|
{
|
|
37
37
|
name: 'hoi4.source_lookup',
|
|
38
38
|
title: 'Look up HOI4 source symbol',
|
|
39
|
-
description: 'Find exact Clausewitz definitions and usages; navigate nested keyPath blocks or list bounded child structure with view=structure.',
|
|
39
|
+
description: 'Find exact Clausewitz definitions and usages; navigate nested keyPath blocks or list bounded child structure with view=structure. Or pass path and line to get the definitions and keyPath containing that line.',
|
|
40
40
|
inputSchema: sourceLookupRequestSchema,
|
|
41
41
|
outputSchema: strictOperationResultSchema(sourceLookupDataSchema),
|
|
42
42
|
annotations: readOnly,
|
|
@@ -100,7 +100,7 @@ export class PackageAnalyzer {
|
|
|
100
100
|
});
|
|
101
101
|
const results = indexedChecks(snapshot, input.manifest);
|
|
102
102
|
const read = async (relativePath) => {
|
|
103
|
-
const resolved = await this.engine.resolver.resolvePath(input.workspaceId, relativePath, 'read', ['mod', 'game', 'dependency', 'fixture'], input.principal);
|
|
103
|
+
const resolved = await this.engine.resolver.resolvePath(input.workspaceId, relativePath, 'read', ['mod', 'game', 'dlc', 'dependency', 'fixture'], input.principal);
|
|
104
104
|
const metadata = await stat(resolved.path);
|
|
105
105
|
if (!metadata.isFile() || metadata.size > MAX_FILE_BYTES)
|
|
106
106
|
throw new ServiceError('PACKAGE_FILE_LIMIT', 'Package file exceeds the bounded reader');
|