hoi4-agent-tools 3.6.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 +23 -0
- package/README.md +124 -28
- package/dist/hoi4_agent_tools/core/configuration.js +2 -0
- package/dist/hoi4_agent_tools/core/engine.js +7 -7
- package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
- package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
- package/dist/hoi4_agent_tools/core/job-store.js +19 -40
- package/dist/hoi4_agent_tools/core/job-worker-host.js +84 -19
- package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
- package/dist/hoi4_agent_tools/core/operation-tasks.js +2 -7
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
- package/dist/hoi4_agent_tools/core/scanner.js +136 -47
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +27 -13
- package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
- 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 +3 -2
- package/dist/hoi4_agent_tools/map/validation.js +60 -4
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
- package/dist/hoi4_agent_tools/package-check/service.js +1 -1
- package/dist/hoi4_agent_tools/reference/lines.js +12 -0
- package/dist/hoi4_agent_tools/reference/operations.js +45 -2
- package/dist/hoi4_agent_tools/reference/ranking.js +309 -0
- package/dist/hoi4_agent_tools/reference/script-validation.js +267 -0
- package/dist/hoi4_agent_tools/reference/service.js +285 -177
- package/dist/hoi4_agent_tools/reference/source-locate.js +156 -0
- package/dist/hoi4_agent_tools/reference/source-lookup.js +125 -18
- package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
- package/dist/hoi4_agent_tools/reference/suggestions.js +38 -0
- package/dist/hoi4_agent_tools/schemas/reference.js +135 -2
- package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
- 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/README.md +1 -0
- package/docs/development.md +6 -0
- package/docs/examples.md +3 -13
- package/docs/gui.md +1 -15
- package/docs/map.md +10 -0
- package/docs/reference.md +47 -4
- package/docs/research/reference-benchmark.md +80 -0
- package/docs/setup.md +15 -0
- package/docs/tools.md +37 -36
- package/npm-shrinkwrap.json +7101 -0
- package/package.json +8 -7
- 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 +9 -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 +92 -0
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +144 -15
- package/server.json +2 -2
|
@@ -6,6 +6,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
6
6
|
import { sha256Bytes } from './canonical.js';
|
|
7
7
|
import { canonicalPath, containedGeneratedPath } from './workspace.js';
|
|
8
8
|
import { ServiceError } from './result.js';
|
|
9
|
+
import { retryWindowsSharing, sharingRetryDelay } from './windows-sharing.js';
|
|
9
10
|
function processAlive(pid) {
|
|
10
11
|
try {
|
|
11
12
|
process.kill(pid, 0);
|
|
@@ -18,9 +19,13 @@ function processAlive(pid) {
|
|
|
18
19
|
export class SharedRequestCapacity {
|
|
19
20
|
stateRoot;
|
|
20
21
|
capacity;
|
|
21
|
-
|
|
22
|
+
deletePendingLimitMs;
|
|
23
|
+
now;
|
|
24
|
+
constructor(stateRoot, capacity = 4, options = {}) {
|
|
22
25
|
this.stateRoot = stateRoot;
|
|
23
26
|
this.capacity = capacity;
|
|
27
|
+
this.deletePendingLimitMs = options.deletePendingLimitMs ?? 60_000;
|
|
28
|
+
this.now = options.now ?? Date.now;
|
|
24
29
|
}
|
|
25
30
|
async run(signal, action) {
|
|
26
31
|
signal.throwIfAborted();
|
|
@@ -33,7 +38,9 @@ export class SharedRequestCapacity {
|
|
|
33
38
|
const root = await containedGeneratedPath(await canonicalPath(this.stateRoot, signal), 'request-capacity', sha256Bytes(hostname().toLowerCase()).slice(0, 16));
|
|
34
39
|
await mkdir(root, { recursive: true });
|
|
35
40
|
const lease = `${process.pid}-${randomUUID()}.lease`;
|
|
36
|
-
|
|
41
|
+
// First observation time of each slot that Windows still reports as delete-pending.
|
|
42
|
+
const pendingDeletionSince = new Map();
|
|
43
|
+
let waits = 0;
|
|
37
44
|
for (;;) {
|
|
38
45
|
signal.throwIfAborted();
|
|
39
46
|
for (let index = 0; index < this.capacity; index += 1) {
|
|
@@ -43,31 +50,35 @@ export class SharedRequestCapacity {
|
|
|
43
50
|
const slot = path.join(root, String(index));
|
|
44
51
|
try {
|
|
45
52
|
await mkdir(slot);
|
|
46
|
-
|
|
53
|
+
pendingDeletionSince.delete(index);
|
|
47
54
|
}
|
|
48
55
|
catch (error) {
|
|
49
56
|
const code = error.code;
|
|
50
57
|
// Windows can retain a delete-pending directory handle after rmdir
|
|
51
58
|
// returns. Recreating that slot reports EPERM/EBUSY, not EEXIST.
|
|
52
|
-
//
|
|
59
|
+
// Contending waiters keep such a slot pending while they inspect it, so the
|
|
60
|
+
// bound is elapsed time rather than a count of observations. No work is
|
|
61
|
+
// admitted until a later mkdir actually succeeds.
|
|
53
62
|
if (process.platform === 'win32' && ['EPERM', 'EBUSY'].includes(code ?? '')) {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
throw
|
|
63
|
+
const since = pendingDeletionSince.get(index) ?? this.now();
|
|
64
|
+
pendingDeletionSince.set(index, since);
|
|
65
|
+
if (this.now() - since >= this.deletePendingLimitMs)
|
|
66
|
+
throw new ServiceError('REQUEST_CAPACITY_UNAVAILABLE', `An execution capacity slot stayed unavailable (${code}) beyond its admission window`);
|
|
58
67
|
await this.reap(slot);
|
|
59
68
|
continue;
|
|
60
69
|
}
|
|
61
70
|
if (code !== 'EEXIST')
|
|
62
71
|
throw error;
|
|
63
|
-
|
|
72
|
+
pendingDeletionSince.delete(index);
|
|
64
73
|
await this.reap(slot);
|
|
65
74
|
continue;
|
|
66
75
|
}
|
|
67
76
|
let owner;
|
|
68
77
|
try {
|
|
69
78
|
owner = path.join(slot, lease);
|
|
70
|
-
await writeFile(owner, '', { flag: 'wx', mode: 0o600 })
|
|
79
|
+
await retryWindowsSharing(() => writeFile(owner, '', { flag: 'wx', mode: 0o600 }), {
|
|
80
|
+
signal,
|
|
81
|
+
});
|
|
71
82
|
}
|
|
72
83
|
catch (error) {
|
|
73
84
|
// A competing stale-owner cleanup can remove an empty slot before our
|
|
@@ -91,7 +102,7 @@ export class SharedRequestCapacity {
|
|
|
91
102
|
throw new ServiceError('REQUEST_LEASE_OWNER_INVALID', 'Capacity can only transfer to another live process');
|
|
92
103
|
const next = path.join(slot, `${pid}-${randomUUID()}.lease`);
|
|
93
104
|
try {
|
|
94
|
-
await rename(owner, next);
|
|
105
|
+
await retryWindowsSharing(() => rename(owner, next));
|
|
95
106
|
}
|
|
96
107
|
catch (error) {
|
|
97
108
|
if (error.code === 'ENOENT')
|
|
@@ -107,7 +118,7 @@ export class SharedRequestCapacity {
|
|
|
107
118
|
// A launcher can fail while its admitted child still runs. Keep that child's
|
|
108
119
|
// lease until process-exit evidence permits a later reaper to reclaim it.
|
|
109
120
|
if (transferredPid === undefined || !processAlive(transferredPid)) {
|
|
110
|
-
await unlink(owner).catch((error) => {
|
|
121
|
+
await retryWindowsSharing(() => unlink(owner)).catch((error) => {
|
|
111
122
|
if (error.code !== 'ENOENT')
|
|
112
123
|
throw error;
|
|
113
124
|
});
|
|
@@ -120,7 +131,10 @@ export class SharedRequestCapacity {
|
|
|
120
131
|
}
|
|
121
132
|
}
|
|
122
133
|
}
|
|
123
|
-
|
|
134
|
+
// Short first waits keep a briefly held lock responsive; the cap and jitter keep
|
|
135
|
+
// many contending processes from polling the shared directory in lockstep.
|
|
136
|
+
waits += 1;
|
|
137
|
+
await delay(sharingRetryDelay(waits) * (0.75 + Math.random() / 2), undefined, { signal });
|
|
124
138
|
}
|
|
125
139
|
}
|
|
126
140
|
async reap(slot) {
|
|
@@ -51,6 +51,10 @@ export class SourceDocumentCache {
|
|
|
51
51
|
this.#entries.set(key, encoded);
|
|
52
52
|
return decodedDocument(encoded);
|
|
53
53
|
}
|
|
54
|
+
/** Availability check without deserialization or cache-hit accounting. */
|
|
55
|
+
has(key) {
|
|
56
|
+
return this.#entries.has(key);
|
|
57
|
+
}
|
|
54
58
|
put(key, document) {
|
|
55
59
|
// Avoid allocating a large serialized copy just to discover that it cannot
|
|
56
60
|
// be retained. This conservative estimate affects caching, never parsing.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
/**
|
|
3
|
+
* Error codes Windows reports while another handle blocks a replacement, or while a
|
|
4
|
+
* removed name is still delete-pending. Other platforms never retry them.
|
|
5
|
+
*/
|
|
6
|
+
const sharingCodes = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
7
|
+
/** Backoff before retry `attempt + 1`: 5, 10, 20, 40, 80, then 100 milliseconds. */
|
|
8
|
+
export function sharingRetryDelay(attempt) {
|
|
9
|
+
return Math.min(100, 5 * 2 ** Math.min(attempt - 1, 5));
|
|
10
|
+
}
|
|
11
|
+
export function isWindowsSharingViolation(error, platform = process.platform) {
|
|
12
|
+
return (platform === 'win32' &&
|
|
13
|
+
typeof error === 'object' &&
|
|
14
|
+
error !== null &&
|
|
15
|
+
'code' in error &&
|
|
16
|
+
typeof error.code === 'string' &&
|
|
17
|
+
sharingCodes.has(error.code));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Retry one idempotent filesystem step while Windows reports a transient sharing
|
|
21
|
+
* violation. Concurrent readers, indexers, and scanners hold short-lived handles that
|
|
22
|
+
* make rename, unlink, and open fail without any persistent permission problem.
|
|
23
|
+
* The final error is rethrown unchanged once the bounded attempts are spent.
|
|
24
|
+
*/
|
|
25
|
+
export async function retryWindowsSharing(operation, options = {}) {
|
|
26
|
+
const platform = options.platform ?? process.platform;
|
|
27
|
+
const wait = options.wait ?? delay;
|
|
28
|
+
const maxAttempts = options.maxAttempts ?? 200;
|
|
29
|
+
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1)
|
|
30
|
+
throw new RangeError('Sharing-violation retry attempts must be a positive integer');
|
|
31
|
+
for (let attempt = 1;; attempt += 1) {
|
|
32
|
+
try {
|
|
33
|
+
return await operation();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (!isWindowsSharingViolation(error, platform) || attempt >= maxAttempts)
|
|
37
|
+
throw error;
|
|
38
|
+
options.signal?.throwIfAborted();
|
|
39
|
+
await wait(sharingRetryDelay(attempt));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -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
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import
|
|
2
|
+
import { escapeFileGlob } from '../core/file-glob.js';
|
|
3
3
|
import { ArtifactStore, boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
|
|
4
4
|
import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
|
|
5
5
|
import { sortDiagnostics } from '../core/diagnostics.js';
|
|
@@ -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}`);
|
|
@@ -426,7 +427,7 @@ function provinceBitmapPatterns(defaultMaps, mapRoots) {
|
|
|
426
427
|
const selectedPath = path.posix.normalize(path.posix.join(normalizedRoot, normalizedName));
|
|
427
428
|
if (selectedPath !== normalizedRoot && !selectedPath.startsWith(`${normalizedRoot}/`))
|
|
428
429
|
continue;
|
|
429
|
-
patterns.add(
|
|
430
|
+
patterns.add(escapeFileGlob(selectedPath));
|
|
430
431
|
}
|
|
431
432
|
}
|
|
432
433
|
return [...patterns].sort((left, right) => compareCodeUnits(left, right));
|