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.
Files changed (87) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +124 -28
  3. package/dist/hoi4_agent_tools/core/configuration.js +2 -0
  4. package/dist/hoi4_agent_tools/core/engine.js +7 -7
  5. package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
  6. package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
  7. package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
  8. package/dist/hoi4_agent_tools/core/job-store.js +19 -40
  9. package/dist/hoi4_agent_tools/core/job-worker-host.js +84 -19
  10. package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
  11. package/dist/hoi4_agent_tools/core/operation-tasks.js +2 -7
  12. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
  13. package/dist/hoi4_agent_tools/core/scanner.js +136 -47
  14. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +27 -13
  15. package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
  16. package/dist/hoi4_agent_tools/core/windows-sharing.js +42 -0
  17. package/dist/hoi4_agent_tools/core/workspace.js +114 -8
  18. package/dist/hoi4_agent_tools/event/artifact-validation.js +1 -1
  19. package/dist/hoi4_agent_tools/event/service.js +1 -1
  20. package/dist/hoi4_agent_tools/focus/service.js +2 -2
  21. package/dist/hoi4_agent_tools/gui/studio.js +16 -3
  22. package/dist/hoi4_agent_tools/map/model.js +81 -2
  23. package/dist/hoi4_agent_tools/map/service.js +3 -2
  24. package/dist/hoi4_agent_tools/map/validation.js +60 -4
  25. package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
  26. package/dist/hoi4_agent_tools/package-check/service.js +1 -1
  27. package/dist/hoi4_agent_tools/reference/lines.js +12 -0
  28. package/dist/hoi4_agent_tools/reference/operations.js +45 -2
  29. package/dist/hoi4_agent_tools/reference/ranking.js +309 -0
  30. package/dist/hoi4_agent_tools/reference/script-validation.js +267 -0
  31. package/dist/hoi4_agent_tools/reference/service.js +285 -177
  32. package/dist/hoi4_agent_tools/reference/source-locate.js +156 -0
  33. package/dist/hoi4_agent_tools/reference/source-lookup.js +125 -18
  34. package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
  35. package/dist/hoi4_agent_tools/reference/suggestions.js +38 -0
  36. package/dist/hoi4_agent_tools/schemas/reference.js +135 -2
  37. package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
  38. package/dist/hoi4_agent_tools/schemas/transaction.js +1 -1
  39. package/dist/hoi4_agent_tools/technology/service.js +1 -1
  40. package/dist/hoi4_agent_tools/version.js +1 -1
  41. package/docs/README.md +1 -0
  42. package/docs/development.md +6 -0
  43. package/docs/examples.md +3 -13
  44. package/docs/gui.md +1 -15
  45. package/docs/map.md +10 -0
  46. package/docs/reference.md +47 -4
  47. package/docs/research/reference-benchmark.md +80 -0
  48. package/docs/setup.md +15 -0
  49. package/docs/tools.md +37 -36
  50. package/npm-shrinkwrap.json +7101 -0
  51. package/package.json +8 -7
  52. package/schemas/configuration.schema.json +95 -88
  53. package/schemas/continuous-focus-palette.schema.json +1 -1
  54. package/schemas/custom-weighted-pool.schema.json +1 -1
  55. package/schemas/decision-inspect-input.schema.json +1 -1
  56. package/schemas/event-feature-manifest.schema.json +1 -1
  57. package/schemas/focus-plan.schema.json +1 -1
  58. package/schemas/focus-planning-sidecar.schema.json +1 -1
  59. package/schemas/gui-animation-source.schema.json +1 -1
  60. package/schemas/gui-helper.schema.json +1 -1
  61. package/schemas/gui-scenario.schema.json +1 -1
  62. package/schemas/helper-expansion-request.schema.json +1 -1
  63. package/schemas/helper-expansion-summary.schema.json +1 -1
  64. package/schemas/impact-inspect-input.schema.json +1 -1
  65. package/schemas/map-operation.schema.json +1 -1
  66. package/schemas/mechanic-test-input.schema.json +1 -1
  67. package/schemas/operation-result.schema.json +1 -1
  68. package/schemas/package-check-input.schema.json +1 -1
  69. package/schemas/package-manifest.schema.json +1 -1
  70. package/schemas/probability-analysis-result.schema.json +1 -1
  71. package/schemas/probability-compare-input.schema.json +1 -1
  72. package/schemas/probability-evaluate-input.schema.json +1 -1
  73. package/schemas/probability-inspect-input.schema.json +1 -1
  74. package/schemas/probability-render-input.schema.json +1 -1
  75. package/schemas/probability-scenario-set.schema.json +1 -1
  76. package/schemas/probability-sequence-input.schema.json +1 -1
  77. package/schemas/probability-simulate-input.schema.json +1 -1
  78. package/schemas/probability-sweep-input.schema.json +1 -1
  79. package/schemas/reference-context-input.schema.json +1 -1
  80. package/schemas/reference-read-input.schema.json +9 -1
  81. package/schemas/reference-search-input.schema.json +1 -1
  82. package/schemas/scenario-suite.schema.json +1 -1
  83. package/schemas/scenario-test-input.schema.json +1 -1
  84. package/schemas/script-validate-input.schema.json +92 -0
  85. package/schemas/shared-scenario.schema.json +1 -1
  86. package/schemas/source-lookup-input.schema.json +144 -15
  87. 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
- constructor(stateRoot, capacity = 4) {
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
- const pendingDeletionFailures = new Map();
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
- pendingDeletionFailures.delete(index);
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
- // No work is admitted until a later mkdir actually succeeds.
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 failures = (pendingDeletionFailures.get(index) ?? 0) + 1;
55
- pendingDeletionFailures.set(index, failures);
56
- if (failures >= 50)
57
- throw error;
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
- pendingDeletionFailures.delete(index);
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
- await delay(100, undefined, { signal });
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: dependencyRoots.length + 1,
548
- replacePaths: [...registration.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: [...dependencyRegistrations[index].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: dependencyRoots.length + 2,
677
+ loadOrder: generatedOrder,
573
678
  replacePaths: [],
574
679
  },
575
680
  {
576
681
  kind: 'cache',
577
682
  path: cacheRoot,
578
683
  writable: true,
579
- loadOrder: dependencyRoots.length + 2,
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
- return [
248
+ const names = [
247
249
  ...new Set(exactPaths
248
250
  .filter((candidate) => !/[*?[\]{}!]/u.test(candidate) && !resolved.has(candidate.toLowerCase()))
249
- .map((candidate) => `**/${path.posix.basename(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
- break;
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 fg from 'fast-glob';
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(fg.posix.escapePath(selectedPath));
430
+ patterns.add(escapeFileGlob(selectedPath));
430
431
  }
431
432
  }
432
433
  return [...patterns].sort((left, right) => compareCodeUnits(left, right));