hoi4-agent-tools 2.2.0 → 2.3.3

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 (72) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +30 -21
  3. package/dist/hoi4_agent_tools/core/clausewitz-evaluation.js +90 -0
  4. package/dist/hoi4_agent_tools/core/engine.js +5 -0
  5. package/dist/hoi4_agent_tools/core/index.js +57 -1
  6. package/dist/hoi4_agent_tools/core/source/ast-path.js +41 -0
  7. package/dist/hoi4_agent_tools/core/source/index.js +1 -0
  8. package/dist/hoi4_agent_tools/event/algorithms.js +178 -1
  9. package/dist/hoi4_agent_tools/event/graph.js +24 -9
  10. package/dist/hoi4_agent_tools/event/limits.js +5 -2
  11. package/dist/hoi4_agent_tools/event/service.js +182 -45
  12. package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
  13. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +13 -63
  14. package/dist/hoi4_agent_tools/mcp/tools/probability.js +413 -0
  15. package/dist/hoi4_agent_tools/mcp/tools/technology.js +4 -1
  16. package/dist/hoi4_agent_tools/probability/adapters.js +344 -0
  17. package/dist/hoi4_agent_tools/probability/compare.js +178 -0
  18. package/dist/hoi4_agent_tools/probability/evaluation.js +649 -0
  19. package/dist/hoi4_agent_tools/probability/index.js +13 -0
  20. package/dist/hoi4_agent_tools/probability/model.js +5 -0
  21. package/dist/hoi4_agent_tools/probability/rational.js +145 -0
  22. package/dist/hoi4_agent_tools/probability/render.js +307 -0
  23. package/dist/hoi4_agent_tools/probability/sequence.js +858 -0
  24. package/dist/hoi4_agent_tools/probability/service.js +1168 -0
  25. package/dist/hoi4_agent_tools/probability/simulation.js +545 -0
  26. package/dist/hoi4_agent_tools/probability/source-analysis.js +458 -0
  27. package/dist/hoi4_agent_tools/probability/sweep.js +265 -0
  28. package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +280 -0
  29. package/dist/hoi4_agent_tools/probability/value-evaluator.js +357 -0
  30. package/dist/hoi4_agent_tools/runtime.js +109 -7
  31. package/dist/hoi4_agent_tools/schemas/probability.js +930 -0
  32. package/dist/hoi4_agent_tools/technology/graph.js +30 -3
  33. package/dist/hoi4_agent_tools/technology/queries.js +70 -3
  34. package/dist/hoi4_agent_tools/technology/service.js +38 -2
  35. package/dist/hoi4_agent_tools/version.js +1 -1
  36. package/docs/README.md +1 -0
  37. package/docs/development.md +2 -0
  38. package/docs/events.md +2 -2
  39. package/docs/probability.md +80 -0
  40. package/docs/research/probability-adapter-evidence.md +64 -0
  41. package/docs/setup.md +2 -0
  42. package/docs/technology.md +4 -2
  43. package/examples/clients/codex-config.toml +3 -0
  44. package/examples/clients/generic.json +8 -0
  45. package/examples/probability/adaptive-event-pool.json +92 -0
  46. package/examples/probability/adaptive-event-sequence-input.json +57 -0
  47. package/examples/probability/event-mtth-scenarios.json +57 -0
  48. package/examples/probability/event-mtth-simulate-input.json +37 -0
  49. package/examples/probability/focus-route-scenarios.json +61 -0
  50. package/examples/probability/focus-route-sweep-input.json +36 -0
  51. package/package.json +19 -8
  52. package/schemas/configuration.schema.json +747 -0
  53. package/schemas/continuous-focus-palette.schema.json +325 -0
  54. package/schemas/custom-weighted-pool.schema.json +298 -0
  55. package/schemas/event-feature-manifest.schema.json +63 -0
  56. package/schemas/focus-plan.schema.json +927 -0
  57. package/schemas/focus-planning-sidecar.schema.json +407 -0
  58. package/schemas/gui-animation-source.schema.json +176 -0
  59. package/schemas/gui-helper.schema.json +301 -0
  60. package/schemas/gui-scenario.schema.json +298 -0
  61. package/schemas/map-operation.schema.json +2284 -0
  62. package/schemas/operation-result.schema.json +327 -0
  63. package/schemas/probability-analysis-result.schema.json +2211 -0
  64. package/schemas/probability-compare-input.schema.json +664 -0
  65. package/schemas/probability-evaluate-input.schema.json +665 -0
  66. package/schemas/probability-inspect-input.schema.json +97 -0
  67. package/schemas/probability-render-input.schema.json +141 -0
  68. package/schemas/probability-scenario-set.schema.json +430 -0
  69. package/schemas/probability-sequence-input.schema.json +886 -0
  70. package/schemas/probability-simulate-input.schema.json +691 -0
  71. package/schemas/probability-sweep-input.schema.json +702 -0
  72. package/server.json +3 -3
@@ -0,0 +1,357 @@
1
+ import { assignments, astPathFor, nodeLocation, } from '../core/source/index.js';
2
+ import { Rational } from './rational.js';
3
+ import { evaluateTriggerBlock } from './trigger-evaluator.js';
4
+ function appendReference(evaluated, reference) {
5
+ return {
6
+ ...evaluated,
7
+ referencedProvenance: [reference, ...(evaluated.referencedProvenance ?? [])],
8
+ };
9
+ }
10
+ function interval(value) {
11
+ return { min: value, max: value };
12
+ }
13
+ function nodeProvenance(candidate, node, symbol) {
14
+ const source = candidate.provenance[0];
15
+ if (source === undefined || candidate.document === undefined)
16
+ return source;
17
+ const astPath = astPathFor(candidate.document, node);
18
+ return {
19
+ ...source,
20
+ location: nodeLocation(candidate.document, node, symbol),
21
+ ...(astPath === undefined ? {} : { astPath }),
22
+ ...(symbol === undefined ? {} : { symbol }),
23
+ };
24
+ }
25
+ function conditionSignature(block) {
26
+ const encode = (value) => assignments(value).map((assignment) => [
27
+ assignment.key.value,
28
+ assignment.operator.text,
29
+ assignment.value.type === 'scalar' ? assignment.value.value : encode(assignment.value),
30
+ ]);
31
+ const encoded = encode(block);
32
+ return encoded.length === 0 ? 'always' : JSON.stringify(encoded);
33
+ }
34
+ function multiplyIntervals(left, right) {
35
+ const products = [
36
+ left.min.multiply(right.min),
37
+ left.min.multiply(right.max),
38
+ left.max.multiply(right.min),
39
+ left.max.multiply(right.max),
40
+ ].sort((a, b) => a.compare(b));
41
+ return { min: products[0], max: products.at(-1) };
42
+ }
43
+ function addIntervals(left, right) {
44
+ return { min: left.min.add(right.min), max: left.max.add(right.max) };
45
+ }
46
+ function scenarioNumber(scenario, expression) {
47
+ if (Object.hasOwn(scenario.state, expression))
48
+ return scenario.state[expression];
49
+ if (Object.hasOwn(scenario.state, `variable.${expression}`))
50
+ return scenario.state[`variable.${expression}`];
51
+ if (expression.startsWith('var:') &&
52
+ Object.hasOwn(scenario.state, `variable.${expression.slice(4)}`))
53
+ return scenario.state[`variable.${expression.slice(4)}`];
54
+ return undefined;
55
+ }
56
+ function expressionValue(expression, candidate, scenario, definitions, mtthStack) {
57
+ const numeric = Rational.parse(expression);
58
+ if (numeric !== undefined && Number.isFinite(numeric.toNumber()))
59
+ return { value: numeric, interval: interval(numeric), trace: [], unresolved: [] };
60
+ if (numeric !== undefined || /^(?:[+-]?(?:inf(?:inity)?|nan))$/iu.test(expression.trim()))
61
+ return {
62
+ trace: [],
63
+ unresolved: [
64
+ {
65
+ code: 'VALUE_NON_FINITE',
66
+ message: `Numeric expression ${expression} is outside finite analyzer range`,
67
+ path: expression,
68
+ candidateId: candidate.id,
69
+ ...(candidate.provenance[0] === undefined ? {} : { provenance: candidate.provenance[0] }),
70
+ },
71
+ ],
72
+ };
73
+ if (expression.startsWith('@')) {
74
+ const definition = definitions.localConstants
75
+ .get(candidate.provenance[0]?.path ?? '')
76
+ ?.get(expression);
77
+ if (definition !== undefined)
78
+ return appendReference(expressionValue(definition.value, candidate, scenario, definitions, mtthStack), {
79
+ path: definition.file.displayPath,
80
+ rootKind: definition.file.rootKind,
81
+ loadOrder: definition.file.loadOrder,
82
+ sourceHash: definition.file.sha256,
83
+ location: nodeLocation(definition.document, definition.node, expression),
84
+ ...(astPathFor(definition.document, definition.node) === undefined
85
+ ? {}
86
+ : { astPath: astPathFor(definition.document, definition.node) }),
87
+ symbol: expression,
88
+ });
89
+ const inline = candidate.document === undefined
90
+ ? undefined
91
+ : assignments(candidate.document.root, expression).find(({ value }) => value.type === 'scalar');
92
+ if (inline?.value.type === 'scalar')
93
+ return appendReference(expressionValue(inline.value.value, candidate, scenario, definitions, mtthStack), {
94
+ path: candidate.provenance[0]?.path ?? 'proposed:inline-probability-source.txt',
95
+ rootKind: candidate.provenance[0]?.rootKind ?? 'fixture',
96
+ loadOrder: candidate.provenance[0]?.loadOrder ?? 0,
97
+ sourceHash: candidate.provenance[0]?.sourceHash ?? '',
98
+ location: nodeLocation(candidate.document, inline, expression),
99
+ ...(astPathFor(candidate.document, inline) === undefined
100
+ ? {}
101
+ : { astPath: astPathFor(candidate.document, inline) }),
102
+ symbol: expression,
103
+ });
104
+ }
105
+ if (expression.startsWith('constant:')) {
106
+ const id = expression.slice('constant:'.length);
107
+ const definition = definitions.scriptConstants.get(id);
108
+ if (definition !== undefined)
109
+ return appendReference(expressionValue(definition.value, candidate, scenario, definitions, mtthStack), {
110
+ path: definition.file.displayPath,
111
+ rootKind: definition.file.rootKind,
112
+ loadOrder: definition.file.loadOrder,
113
+ sourceHash: definition.file.sha256,
114
+ location: nodeLocation(definition.document, definition.node, id),
115
+ ...(astPathFor(definition.document, definition.node) === undefined
116
+ ? {}
117
+ : { astPath: astPathFor(definition.document, definition.node) }),
118
+ symbol: id,
119
+ });
120
+ }
121
+ if (expression.startsWith('mtth:')) {
122
+ const id = expression.slice('mtth:'.length);
123
+ const definition = definitions.mtthVariables.get(id);
124
+ if (definition !== undefined && !mtthStack.includes(id)) {
125
+ const definitionCandidate = {
126
+ ...candidate,
127
+ document: definition.document,
128
+ provenance: [
129
+ {
130
+ path: definition.file.displayPath,
131
+ rootKind: definition.file.rootKind,
132
+ loadOrder: definition.file.loadOrder,
133
+ sourceHash: definition.file.sha256,
134
+ location: nodeLocation(definition.document, definition.node, id),
135
+ ...(astPathFor(definition.document, definition.node) === undefined
136
+ ? {}
137
+ : { astPath: astPathFor(definition.document, definition.node) }),
138
+ symbol: id,
139
+ helperChain: [...mtthStack, id],
140
+ },
141
+ ...candidate.provenance,
142
+ ],
143
+ };
144
+ return appendReference(evaluateValueBlock(definition.value, definitionCandidate, scenario, definitions, Rational.one, [...mtthStack, id]), definitionCandidate.provenance[0]);
145
+ }
146
+ if (mtthStack.includes(id))
147
+ return {
148
+ trace: [],
149
+ unresolved: [
150
+ {
151
+ code: 'MTTH_RECURSION',
152
+ message: `MTTH variable recursion detected at ${id}`,
153
+ candidateId: candidate.id,
154
+ ...(candidate.provenance[0] === undefined
155
+ ? {}
156
+ : { provenance: candidate.provenance[0] }),
157
+ },
158
+ ],
159
+ };
160
+ }
161
+ const declared = scenarioNumber(scenario, expression);
162
+ if (typeof declared === 'number' || typeof declared === 'string') {
163
+ const parsed = Rational.parse(declared);
164
+ if (parsed !== undefined)
165
+ return { value: parsed, interval: interval(parsed), trace: [], unresolved: [] };
166
+ }
167
+ return {
168
+ trace: [],
169
+ unresolved: [
170
+ {
171
+ code: 'VALUE_UNRESOLVED',
172
+ message: `Scenario or source definitions do not resolve numeric expression ${expression}`,
173
+ path: expression,
174
+ candidateId: candidate.id,
175
+ ...(candidate.provenance[0] === undefined ? {} : { provenance: candidate.provenance[0] }),
176
+ },
177
+ ],
178
+ };
179
+ }
180
+ function conditionBlock(block) {
181
+ const operationKeys = new Set(['base', 'add', 'factor', 'days', 'months', 'years']);
182
+ return {
183
+ ...block,
184
+ entries: block.entries.filter((entry) => entry.type !== 'assignment' || !operationKeys.has(entry.key.value)),
185
+ };
186
+ }
187
+ function applyOperation(current, operation, operand) {
188
+ if (operation === 'base')
189
+ return operand;
190
+ return operation === 'add' ? addIntervals(current, operand) : multiplyIntervals(current, operand);
191
+ }
192
+ export function evaluateValueBlock(block, candidate, scenario, definitions, initial, mtthStack = []) {
193
+ let current = interval(initial);
194
+ const trace = [];
195
+ const unresolved = [];
196
+ const referencedProvenance = [];
197
+ if (block === undefined)
198
+ return { value: initial, interval: current, trace, unresolved };
199
+ for (const assignment of assignments(block)) {
200
+ if (assignment.value.type === 'scalar') {
201
+ let operation;
202
+ let unit = Rational.one;
203
+ if (assignment.key.value === 'base')
204
+ operation = 'base';
205
+ else if (assignment.key.value === 'add')
206
+ operation = 'add';
207
+ else if (assignment.key.value === 'factor')
208
+ operation = 'factor';
209
+ else if (assignment.key.value === 'days')
210
+ operation = 'add';
211
+ else if (assignment.key.value === 'months') {
212
+ operation = 'add';
213
+ unit = new Rational(30n);
214
+ }
215
+ else if (assignment.key.value === 'years') {
216
+ operation = 'add';
217
+ unit = new Rational(365n);
218
+ }
219
+ if (operation === undefined)
220
+ continue;
221
+ const operand = expressionValue(assignment.value.value, candidate, scenario, definitions, mtthStack);
222
+ unresolved.push(...operand.unresolved);
223
+ referencedProvenance.push(...(operand.referencedProvenance ?? []));
224
+ if (operand.interval === undefined)
225
+ continue;
226
+ const scaled = multiplyIntervals(operand.interval, interval(unit));
227
+ const before = current;
228
+ current = applyOperation(current, operation, scaled);
229
+ if (!Number.isFinite(current.min.toNumber()) || !Number.isFinite(current.max.toNumber())) {
230
+ unresolved.push({
231
+ code: 'VALUE_NON_FINITE',
232
+ message: `${candidate.id} modifier chain exceeds finite analyzer range`,
233
+ candidateId: candidate.id,
234
+ ...(nodeProvenance(candidate, assignment, assignment.key.value) === undefined
235
+ ? {}
236
+ : { provenance: nodeProvenance(candidate, assignment, assignment.key.value) }),
237
+ });
238
+ return { trace, unresolved, referencedProvenance };
239
+ }
240
+ trace.push({
241
+ operation,
242
+ expression: assignment.value.value,
243
+ applied: 'true',
244
+ ...(scaled.min.compare(scaled.max) === 0 ? { value: scaled.min.toJSON() } : {}),
245
+ ...(before.min.compare(before.max) === 0 ? { before: before.min.toJSON() } : {}),
246
+ ...(current.min.compare(current.max) === 0 ? { after: current.min.toJSON() } : {}),
247
+ ...(nodeProvenance(candidate, assignment, assignment.key.value) === undefined
248
+ ? {}
249
+ : { provenance: nodeProvenance(candidate, assignment, assignment.key.value) }),
250
+ });
251
+ continue;
252
+ }
253
+ if (assignment.key.value !== 'modifier')
254
+ continue;
255
+ const modifier = assignment.value;
256
+ const condition = evaluateTriggerBlock(conditionBlock(modifier), scenario, candidate, definitions);
257
+ const conditionExpression = conditionSignature(conditionBlock(modifier));
258
+ unresolved.push(...condition.unresolved);
259
+ referencedProvenance.push(...(condition.helperProvenance ?? []));
260
+ for (const modifierAssignment of assignments(modifier)) {
261
+ if (modifierAssignment.value.type !== 'scalar' ||
262
+ !['base', 'add', 'factor'].includes(modifierAssignment.key.value))
263
+ continue;
264
+ const operation = modifierAssignment.key.value;
265
+ const expression = modifierAssignment.value.value;
266
+ const operand = expressionValue(expression, candidate, scenario, definitions, mtthStack);
267
+ unresolved.push(...operand.unresolved);
268
+ referencedProvenance.push(...(operand.referencedProvenance ?? []));
269
+ if (operand.interval === undefined || condition.state === 'false') {
270
+ trace.push({
271
+ operation,
272
+ expression,
273
+ applied: condition.state,
274
+ conditionExpression,
275
+ ...(nodeProvenance(candidate, modifierAssignment, modifierAssignment.key.value) ===
276
+ undefined
277
+ ? {}
278
+ : {
279
+ provenance: nodeProvenance(candidate, modifierAssignment, modifierAssignment.key.value),
280
+ }),
281
+ });
282
+ continue;
283
+ }
284
+ const before = current;
285
+ const applied = applyOperation(current, operation, operand.interval);
286
+ current =
287
+ condition.state === 'true'
288
+ ? applied
289
+ : {
290
+ min: current.min.min(applied.min),
291
+ max: current.max.max(applied.max),
292
+ };
293
+ if (!Number.isFinite(current.min.toNumber()) || !Number.isFinite(current.max.toNumber())) {
294
+ unresolved.push({
295
+ code: 'VALUE_NON_FINITE',
296
+ message: `${candidate.id} modifier chain exceeds finite analyzer range`,
297
+ candidateId: candidate.id,
298
+ ...(nodeProvenance(candidate, modifierAssignment, modifierAssignment.key.value) ===
299
+ undefined
300
+ ? {}
301
+ : {
302
+ provenance: nodeProvenance(candidate, modifierAssignment, modifierAssignment.key.value),
303
+ }),
304
+ });
305
+ return { trace, unresolved, referencedProvenance };
306
+ }
307
+ trace.push({
308
+ operation,
309
+ expression,
310
+ applied: condition.state,
311
+ conditionExpression,
312
+ ...(condition.helperProvenance?.length
313
+ ? {
314
+ note: `Condition helper chain: ${condition.helperProvenance
315
+ .map(({ symbol }) => symbol)
316
+ .filter(Boolean)
317
+ .join(' -> ')}`,
318
+ }
319
+ : {}),
320
+ ...(operand.interval.min.compare(operand.interval.max) === 0
321
+ ? { value: operand.interval.min.toJSON() }
322
+ : {}),
323
+ ...(before.min.compare(before.max) === 0 ? { before: before.min.toJSON() } : {}),
324
+ ...(current.min.compare(current.max) === 0 ? { after: current.min.toJSON() } : {}),
325
+ ...(nodeProvenance(candidate, modifierAssignment, modifierAssignment.key.value) ===
326
+ undefined
327
+ ? {}
328
+ : {
329
+ provenance: nodeProvenance(candidate, modifierAssignment, modifierAssignment.key.value),
330
+ }),
331
+ });
332
+ }
333
+ }
334
+ return {
335
+ ...(current.min.compare(current.max) === 0 ? { value: current.min } : {}),
336
+ interval: current,
337
+ trace,
338
+ unresolved,
339
+ ...(referencedProvenance.length === 0 ? {} : { referencedProvenance }),
340
+ };
341
+ }
342
+ export function evaluateCandidateValue(candidate, scenario, definitions) {
343
+ const initialExpression = candidate.valueExpression ?? candidate.defaultValue;
344
+ const initial = expressionValue(initialExpression, candidate, scenario, definitions, []);
345
+ if (initial.interval === undefined)
346
+ return initial;
347
+ const evaluated = evaluateValueBlock(candidate.weightBlock, candidate, scenario, definitions, initial.interval.min);
348
+ return {
349
+ ...evaluated,
350
+ trace: [...initial.trace, ...evaluated.trace],
351
+ unresolved: [...initial.unresolved, ...evaluated.unresolved],
352
+ referencedProvenance: [
353
+ ...(initial.referencedProvenance ?? []),
354
+ ...(evaluated.referencedProvenance ?? []),
355
+ ],
356
+ };
357
+ }
@@ -1,4 +1,4 @@
1
- import { stat } from 'node:fs/promises';
1
+ import { lstat, readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { loadConfiguration, serverConfigurationSchema, } from './core/configuration.js';
@@ -39,15 +39,117 @@ async function isFile(candidate) {
39
39
  return false;
40
40
  }
41
41
  }
42
+ /**
43
+ * Standard content directories that can be the only content in a valid local
44
+ * mod. Keep this list limited to HOI4 roots so an arbitrary project directory
45
+ * is not mistaken for a mod just because it contains a generic `common`
46
+ * folder.
47
+ */
48
+ const MOD_CONTENT_DIRECTORIES = [
49
+ 'events',
50
+ 'gfx',
51
+ 'history',
52
+ 'interface',
53
+ 'localisation',
54
+ 'localisation_synced',
55
+ 'map',
56
+ 'music',
57
+ 'portraits',
58
+ 'sound',
59
+ ];
60
+ const MOD_CONTENT_EXTENSIONS = {
61
+ events: ['.txt'],
62
+ gfx: ['.dds', '.gfx', '.png', '.tga'],
63
+ history: ['.txt'],
64
+ interface: ['.gfx', '.gui', '.txt'],
65
+ localisation: ['.yml', '.yaml'],
66
+ localisation_synced: ['.yml', '.yaml'],
67
+ map: ['.bmp', '.csv', '.map', '.txt'],
68
+ music: ['.asset', '.ogg', '.txt', '.wav'],
69
+ portraits: ['.dds', '.jpg', '.jpeg', '.png', '.tga'],
70
+ sound: ['.asset', '.ogg', '.txt', '.wav'],
71
+ };
72
+ const MOD_COMMON_DIRECTORIES = [
73
+ 'abilities',
74
+ 'ai_focuses',
75
+ 'ai_strategy',
76
+ 'ai_strategy_plans',
77
+ 'bop',
78
+ 'buildings',
79
+ 'characters',
80
+ 'continuous_focus',
81
+ 'decisions',
82
+ 'doctrines',
83
+ 'ideas',
84
+ 'mtth',
85
+ 'national_focus',
86
+ 'on_actions',
87
+ 'operations',
88
+ 'raids',
89
+ 'resistance_compliance_modifiers',
90
+ 'script_constants',
91
+ 'scripted_effects',
92
+ 'scripted_guis',
93
+ 'scripted_triggers',
94
+ 'special_projects',
95
+ 'technology_sharing',
96
+ 'technologies',
97
+ 'technology_tags',
98
+ 'units',
99
+ 'unit_tags',
100
+ ];
101
+ async function hasEntries(candidate, extensions) {
102
+ try {
103
+ const entries = await readdir(candidate, { withFileTypes: true });
104
+ return entries.some((entry) => entry.isFile() &&
105
+ (extensions === undefined ||
106
+ extensions.some((extension) => entry.name.toLowerCase().endsWith(extension))));
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
112
+ async function isRealDirectory(candidate) {
113
+ try {
114
+ const metadata = await lstat(candidate);
115
+ return metadata.isDirectory() && !metadata.isSymbolicLink();
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ async function isLikelyGameRoot(candidate) {
122
+ // Vanilla installations have an executable or launcher marker that a mod
123
+ // root should never contain. The marker check also protects a nested cwd
124
+ // such as `<game>/common` when the server is started from there.
125
+ return ((await isFile(path.join(candidate, 'hoi4.exe'))) ||
126
+ (await isFile(path.join(candidate, 'run_hoi4'))) ||
127
+ (await isFile(path.join(candidate, 'steam_appid.txt'))));
128
+ }
129
+ async function isLikelyModRoot(candidate) {
130
+ if (await isFile(path.join(candidate, 'descriptor.mod')))
131
+ return true;
132
+ if (await isLikelyGameRoot(candidate))
133
+ return false;
134
+ for (const directory of MOD_COMMON_DIRECTORIES) {
135
+ if (await isRealDirectory(path.join(candidate, 'common', directory)))
136
+ return true;
137
+ }
138
+ if (await isFile(path.join(candidate, 'common', 'combat_tactics.txt')))
139
+ return true;
140
+ for (const directory of MOD_CONTENT_DIRECTORIES) {
141
+ if ((await isRealDirectory(path.join(candidate, directory))) &&
142
+ (await hasEntries(path.join(candidate, directory), MOD_CONTENT_EXTENSIONS[directory])))
143
+ return true;
144
+ }
145
+ return false;
146
+ }
42
147
  async function findCurrentModRoot(start = process.cwd()) {
43
148
  for (let cursor = path.resolve(start);;) {
44
- if ((await isFile(path.join(cursor, 'descriptor.mod'))) ||
45
- ((await isDirectory(path.join(cursor, 'common'))) &&
46
- ((await isDirectory(path.join(cursor, 'history'))) ||
47
- (await isDirectory(path.join(cursor, 'interface'))) ||
48
- (await isDirectory(path.join(cursor, 'map')))))) {
149
+ if (await isLikelyGameRoot(cursor))
150
+ break;
151
+ if (await isLikelyModRoot(cursor))
49
152
  return cursor;
50
- }
51
153
  const parent = path.dirname(cursor);
52
154
  if (parent === cursor)
53
155
  break;