hoi4-agent-tools 3.7.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +59 -57
  3. package/dist/hoi4_agent_tools/core/configuration.js +2 -0
  4. package/dist/hoi4_agent_tools/core/job-store.js +19 -40
  5. package/dist/hoi4_agent_tools/core/job-worker-host.js +57 -8
  6. package/dist/hoi4_agent_tools/core/operation-tasks.js +2 -7
  7. package/dist/hoi4_agent_tools/core/scanner.js +3 -0
  8. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +27 -13
  9. package/dist/hoi4_agent_tools/core/transactions.js +31 -2
  10. package/dist/hoi4_agent_tools/core/windows-sharing.js +42 -0
  11. package/dist/hoi4_agent_tools/core/workspace.js +114 -8
  12. package/dist/hoi4_agent_tools/event/artifact-validation.js +1 -1
  13. package/dist/hoi4_agent_tools/event/service.js +1 -1
  14. package/dist/hoi4_agent_tools/focus/service.js +2 -2
  15. package/dist/hoi4_agent_tools/gui/studio.js +16 -3
  16. package/dist/hoi4_agent_tools/map/model.js +81 -2
  17. package/dist/hoi4_agent_tools/map/service.js +1 -0
  18. package/dist/hoi4_agent_tools/map/validation.js +60 -4
  19. package/dist/hoi4_agent_tools/mcp/tools/reference.js +1 -1
  20. package/dist/hoi4_agent_tools/package-check/service.js +1 -1
  21. package/dist/hoi4_agent_tools/reference/ranking.js +309 -0
  22. package/dist/hoi4_agent_tools/reference/script-validation.js +3 -13
  23. package/dist/hoi4_agent_tools/reference/service.js +152 -116
  24. package/dist/hoi4_agent_tools/reference/source-locate.js +156 -0
  25. package/dist/hoi4_agent_tools/reference/source-lookup.js +37 -6
  26. package/dist/hoi4_agent_tools/reference/suggestions.js +38 -0
  27. package/dist/hoi4_agent_tools/schemas/reference.js +80 -2
  28. package/dist/hoi4_agent_tools/schemas/transaction.js +1 -1
  29. package/dist/hoi4_agent_tools/technology/service.js +1 -1
  30. package/dist/hoi4_agent_tools/version.js +1 -1
  31. package/docs/development.md +6 -0
  32. package/docs/examples.md +3 -3
  33. package/docs/map.md +10 -0
  34. package/docs/reference.md +5 -3
  35. package/docs/research/reference-benchmark.md +39 -0
  36. package/docs/setup.md +15 -0
  37. package/docs/tools.md +37 -37
  38. package/npm-shrinkwrap.json +18 -5
  39. package/package.json +2 -2
  40. package/schemas/configuration.schema.json +95 -88
  41. package/schemas/continuous-focus-palette.schema.json +1 -1
  42. package/schemas/custom-weighted-pool.schema.json +1 -1
  43. package/schemas/decision-inspect-input.schema.json +1 -1
  44. package/schemas/event-feature-manifest.schema.json +1 -1
  45. package/schemas/focus-plan.schema.json +1 -1
  46. package/schemas/focus-planning-sidecar.schema.json +1 -1
  47. package/schemas/gui-animation-source.schema.json +1 -1
  48. package/schemas/gui-helper.schema.json +1 -1
  49. package/schemas/gui-scenario.schema.json +1 -1
  50. package/schemas/helper-expansion-request.schema.json +1 -1
  51. package/schemas/helper-expansion-summary.schema.json +1 -1
  52. package/schemas/impact-inspect-input.schema.json +1 -1
  53. package/schemas/map-operation.schema.json +1 -1
  54. package/schemas/mechanic-test-input.schema.json +1 -1
  55. package/schemas/operation-result.schema.json +1 -1
  56. package/schemas/package-check-input.schema.json +1 -1
  57. package/schemas/package-manifest.schema.json +1 -1
  58. package/schemas/probability-analysis-result.schema.json +1 -1
  59. package/schemas/probability-compare-input.schema.json +1 -1
  60. package/schemas/probability-evaluate-input.schema.json +1 -1
  61. package/schemas/probability-inspect-input.schema.json +1 -1
  62. package/schemas/probability-render-input.schema.json +1 -1
  63. package/schemas/probability-scenario-set.schema.json +1 -1
  64. package/schemas/probability-sequence-input.schema.json +1 -1
  65. package/schemas/probability-simulate-input.schema.json +1 -1
  66. package/schemas/probability-sweep-input.schema.json +1 -1
  67. package/schemas/reference-context-input.schema.json +1 -1
  68. package/schemas/reference-read-input.schema.json +1 -1
  69. package/schemas/reference-search-input.schema.json +1 -1
  70. package/schemas/scenario-suite.schema.json +1 -1
  71. package/schemas/scenario-test-input.schema.json +1 -1
  72. package/schemas/script-validate-input.schema.json +1 -1
  73. package/schemas/shared-scenario.schema.json +1 -1
  74. package/schemas/source-lookup-input.schema.json +113 -50
  75. package/server.json +2 -2
@@ -599,10 +599,19 @@ export class TransactionManager {
599
599
  continue;
600
600
  const manifest = await this.load(workspace, entry.name, {
601
601
  headMode: 'none',
602
+ rootBinding: 'ignored',
602
603
  ...(signal === undefined ? {} : { signal }),
603
604
  });
604
605
  this.assertRecoveryPrincipal(manifest, principal);
605
606
  const interrupted = manifest.state === 'applying' || manifest.state === 'rolling_back';
607
+ if (manifest.rootFingerprint !== this.rootFingerprint(workspace)) {
608
+ // A finished journal recorded before the roots changed, for example before an
609
+ // installed DLC folder became a source layer, needs no recovery and must not stop
610
+ // the server. An interrupted one still cannot be replayed against changed roots.
611
+ if (interrupted)
612
+ throw new ServiceError('TRANSACTION_ROOT_CHANGED', 'Workspace root configuration changed after planning');
613
+ continue;
614
+ }
606
615
  if (!interrupted) {
607
616
  try {
608
617
  // Ordinary startup must not contend with a live writer just to read terminal journals.
@@ -842,6 +851,25 @@ export class TransactionManager {
842
851
  continue;
843
852
  }
844
853
  let manifest;
854
+ try {
855
+ const recorded = await this.load(workspace, entry.name, {
856
+ headMode: 'none',
857
+ rootBinding: 'ignored',
858
+ });
859
+ if (recorded.rootFingerprint !== this.rootFingerprint(workspace)) {
860
+ // Journals from earlier roots can no longer be applied or rolled back here. Once a
861
+ // finished one expires, remove it so it stops counting against the journal quota.
862
+ if (['planned', 'applied', 'rolled_back', 'failed'].includes(recorded.state) &&
863
+ Date.parse(recorded.expiresAt) <= now) {
864
+ this.removeManifestByteCache(this.manifestCacheKey(workspace, entry.name));
865
+ await rm(directory, { recursive: true, force: true });
866
+ }
867
+ continue;
868
+ }
869
+ }
870
+ catch {
871
+ // Unreadable journals keep their previous handling below.
872
+ }
845
873
  try {
846
874
  manifest = await this.load(workspace, entry.name, { headMode: 'reconcile' });
847
875
  }
@@ -1056,7 +1084,7 @@ export class TransactionManager {
1056
1084
  await hooks?.afterProtectedHeadCommit?.();
1057
1085
  }
1058
1086
  async load(workspace, transactionId, options = {}) {
1059
- const { headMode = 'verify', signal, cacheBytes = false } = options;
1087
+ const { headMode = 'verify', signal, cacheBytes = false, rootBinding = 'required' } = options;
1060
1088
  signal?.throwIfAborted();
1061
1089
  let manifestText;
1062
1090
  let manifestPath;
@@ -1110,7 +1138,8 @@ export class TransactionManager {
1110
1138
  if (manifest.transactionId !== transactionId || manifest.workspaceId !== workspace.id) {
1111
1139
  throw new ServiceError('TRANSACTION_WORKSPACE_MISMATCH', 'Transaction manifest identity does not match its workspace or journal directory');
1112
1140
  }
1113
- if (manifest.rootFingerprint !== this.rootFingerprint(workspace)) {
1141
+ if (rootBinding === 'required' &&
1142
+ manifest.rootFingerprint !== this.rootFingerprint(workspace)) {
1114
1143
  throw new ServiceError('TRANSACTION_ROOT_CHANGED', 'Workspace root configuration changed after planning');
1115
1144
  }
1116
1145
  if (hashCanonical(manifestIntegrityPayload(manifest)) !== manifest.integrityHash) {
@@ -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
@@ -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
- if (definition.type === 'sea' && definition.terrain !== 'ocean') {
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: `Sea province ${definition.id} must use ocean terrain`,
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' && definition.terrain !== 'lakes') {
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: `Lake province ${definition.id} must use lakes terrain`,
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');