hoi4-agent-tools 3.0.8 → 3.0.9

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 (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/hoi4_agent_tools/core/condition-evaluator.js +399 -0
  3. package/dist/hoi4_agent_tools/core/condition-model.js +1 -0
  4. package/dist/hoi4_agent_tools/core/condition-schema.js +42 -0
  5. package/dist/hoi4_agent_tools/core/scenario-state.js +97 -0
  6. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +34 -17
  7. package/dist/hoi4_agent_tools/gui/scenario-conditions.js +119 -0
  8. package/dist/hoi4_agent_tools/gui/scenario-generator.js +59 -73
  9. package/dist/hoi4_agent_tools/gui/scenario.js +3 -0
  10. package/dist/hoi4_agent_tools/gui/studio.js +5 -2
  11. package/dist/hoi4_agent_tools/probability/scenario-state.js +2 -95
  12. package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +1 -287
  13. package/dist/hoi4_agent_tools/version.js +1 -1
  14. package/docs/gui.md +3 -1
  15. package/package.json +1 -1
  16. package/schemas/configuration.schema.json +1 -1
  17. package/schemas/continuous-focus-palette.schema.json +1 -1
  18. package/schemas/custom-weighted-pool.schema.json +1 -1
  19. package/schemas/event-feature-manifest.schema.json +1 -1
  20. package/schemas/focus-plan.schema.json +1 -1
  21. package/schemas/focus-planning-sidecar.schema.json +1 -1
  22. package/schemas/gui-animation-source.schema.json +1 -1
  23. package/schemas/gui-helper.schema.json +1 -1
  24. package/schemas/gui-scenario.schema.json +189 -33
  25. package/schemas/map-operation.schema.json +1 -1
  26. package/schemas/operation-result.schema.json +1 -1
  27. package/schemas/probability-analysis-result.schema.json +1 -1
  28. package/schemas/probability-compare-input.schema.json +1 -1
  29. package/schemas/probability-evaluate-input.schema.json +1 -1
  30. package/schemas/probability-inspect-input.schema.json +1 -1
  31. package/schemas/probability-render-input.schema.json +1 -1
  32. package/schemas/probability-scenario-set.schema.json +1 -1
  33. package/schemas/probability-sequence-input.schema.json +1 -1
  34. package/schemas/probability-simulate-input.schema.json +1 -1
  35. package/schemas/probability-sweep-input.schema.json +1 -1
  36. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@
4
4
 
5
5
  No unreleased changes.
6
6
 
7
+ ## 3.0.9 - 2026-09-08
8
+
9
+ - Evaluate GUI scripted-localisation branches through the shared structured condition evaluator instead of regex threshold guesses.
10
+ - Correct inclusive boundaries, compound conditions, negation, variable operands, constants, and named scopes; retain the existing probability API through core compatibility exports.
11
+ - Resolve source-backed scripted-trigger calls, including declared scalar parameters, and retain helper provenance and unresolved conditions.
12
+ - Keep an unknown earlier localisation branch unresolved instead of selecting a ranked candidate or an unproven fallback.
13
+ - Preserve explicit values and seeded exploratory previews, expose optional named GUI scope bindings, and include per-choice condition evidence in scenario artifacts.
14
+ - Add independent condition truth-table tests and generated-scenario-to-render regressions for the reported failures, colours, scoped helpers, placeholders, and repeatable SVG/PNG output.
15
+ - Align the sparse-mod stdio test's overall budget with its RPC waits and honor matching progress notifications without removing the finite test deadline.
16
+ - Avoid Windows path-resolution races while concurrent stdio processes release capacity slots, retaining private-root validation and explicit linked-slot rejection.
17
+ - Retry Windows delete-pending capacity-slot creation without admitting duplicate work; persistent permission failures remain bounded errors.
18
+
7
19
  ## 3.0.8 - 2026-09-05
8
20
 
9
21
  - Queue simultaneous tool calls fairly across HTTP sessions and coordinate expensive operations across local task processes sharing private server state.
@@ -0,0 +1,399 @@
1
+ import { assignments, astPathFor, firstScalar, nodeLocation, } from './source/index.js';
2
+ import { resolveScopeContext, rootScopeContext, rootStateValue, scopeIdentity, scopeStateValue, } from './scenario-state.js';
3
+ function helperWithArguments(block, invocation) {
4
+ const parameters = new Map();
5
+ for (const entry of invocation.entries) {
6
+ if (entry.type !== 'assignment' || entry.value.type !== 'scalar')
7
+ return undefined;
8
+ parameters.set(entry.key.value, entry.value.value);
9
+ }
10
+ const missing = new Set();
11
+ const substitute = (value) => value.replace(/\$([A-Za-z0-9_]+)\$/gu, (match, key) => {
12
+ const replacement = parameters.get(key);
13
+ if (replacement === undefined) {
14
+ missing.add(key);
15
+ return match;
16
+ }
17
+ return replacement;
18
+ });
19
+ const visit = (entry) => {
20
+ if (entry.type === 'scalar')
21
+ return { ...entry, value: substitute(entry.value) };
22
+ if (entry.type === 'block')
23
+ return { ...entry, entries: entry.entries.map(visit) };
24
+ return {
25
+ ...entry,
26
+ key: { ...entry.key, value: substitute(entry.key.value) },
27
+ value: entry.value.type === 'scalar'
28
+ ? { ...entry.value, value: substitute(entry.value.value) }
29
+ : { ...entry.value, entries: entry.value.entries.map(visit) },
30
+ };
31
+ };
32
+ const expanded = { ...block, entries: block.entries.map(visit) };
33
+ return missing.size === 0 ? expanded : undefined;
34
+ }
35
+ function combineAnd(values) {
36
+ if (values.includes('false'))
37
+ return 'false';
38
+ return values.includes('unresolved') ? 'unresolved' : 'true';
39
+ }
40
+ function combineOr(values) {
41
+ if (values.includes('true'))
42
+ return 'true';
43
+ return values.includes('unresolved') ? 'unresolved' : 'false';
44
+ }
45
+ function invert(value) {
46
+ return value === 'true' ? 'false' : value === 'false' ? 'true' : 'unresolved';
47
+ }
48
+ function bool(value) {
49
+ if (typeof value === 'boolean')
50
+ return value;
51
+ if (value === 'yes' || value === 'true')
52
+ return true;
53
+ if (value === 'no' || value === 'false')
54
+ return false;
55
+ return undefined;
56
+ }
57
+ function numeric(value) {
58
+ if (typeof value === 'number' && Number.isFinite(value))
59
+ return value;
60
+ if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))
61
+ return Number(value);
62
+ return undefined;
63
+ }
64
+ function compare(left, right, operator) {
65
+ if (typeof left === 'number' && typeof right === 'number') {
66
+ if (operator === '>')
67
+ return left > right;
68
+ if (operator === '<')
69
+ return left < right;
70
+ }
71
+ return operator === '!=' ? left !== right : left === right;
72
+ }
73
+ function numberValue(expression, scenario, context, definitions, path, seen = new Set()) {
74
+ const literal = numeric(expression);
75
+ if (literal !== undefined)
76
+ return literal;
77
+ const identity = `${path ?? ''}:${expression}`;
78
+ if (seen.has(identity))
79
+ return undefined;
80
+ seen.add(identity);
81
+ const constant = expression.startsWith('constant:')
82
+ ? definitions.scriptConstants.get(expression.slice(9))
83
+ : expression.startsWith('@') && path !== undefined
84
+ ? definitions.localConstants.get(path)?.get(expression)
85
+ : undefined;
86
+ if (constant !== undefined)
87
+ return numberValue(constant.value, scenario, context, definitions, constant.file.displayPath, seen);
88
+ const direct = scopeStateValue(scenario, expression, context) ??
89
+ scopeStateValue(scenario, `variable.${expression}`, context);
90
+ if (direct !== undefined)
91
+ return numeric(direct);
92
+ for (let split = expression.lastIndexOf('.'); split > 0; split = expression.lastIndexOf('.', split - 1)) {
93
+ const scope = resolveScopeContext(scenario, expression.slice(0, split), context);
94
+ if (scope !== undefined)
95
+ return numberValue(expression.slice(split + 1), scenario, scope, definitions, path, seen);
96
+ }
97
+ return undefined;
98
+ }
99
+ function unresolved(assignment, candidate, reason) {
100
+ return {
101
+ state: 'unresolved',
102
+ unresolved: [
103
+ {
104
+ code: 'TRIGGER_UNRESOLVED',
105
+ message: reason,
106
+ path: assignment.key.value,
107
+ candidateId: candidate.id,
108
+ ...(candidate.provenance[0] === undefined ? {} : { provenance: candidate.provenance[0] }),
109
+ },
110
+ ],
111
+ };
112
+ }
113
+ function malformedBlock(block, candidate) {
114
+ if (block.entries.every(({ type }) => type === 'assignment'))
115
+ return undefined;
116
+ return {
117
+ state: 'unresolved',
118
+ unresolved: [
119
+ {
120
+ code: 'TRIGGER_UNRESOLVED',
121
+ message: 'Condition contains a bare value or block instead of a trigger assignment',
122
+ candidateId: candidate.id,
123
+ ...(candidate.provenance[0] === undefined ? {} : { provenance: candidate.provenance[0] }),
124
+ },
125
+ ],
126
+ };
127
+ }
128
+ function evaluateCheckVariable(block, scenario, candidate, assignment, scopeContext, definitions) {
129
+ const short = assignments(block).find(({ key }) => !['var', 'value', 'compare', 'tooltip'].includes(key.value));
130
+ const variable = firstScalar(block, 'var')?.value ?? short?.key.value;
131
+ const target = firstScalar(block, 'value')?.value ??
132
+ (short?.value.type === 'scalar' ? short.value.value : undefined);
133
+ if (variable === undefined || target === undefined)
134
+ return unresolved(assignment, candidate, 'check_variable requires declared var and value');
135
+ const path = candidate.provenance[0]?.path;
136
+ const leftNumber = numberValue(variable, scenario, scopeContext, definitions, path);
137
+ const rightNumber = numberValue(target, scenario, scopeContext, definitions, path);
138
+ if (leftNumber === undefined || rightNumber === undefined)
139
+ return unresolved(assignment, candidate, `Scenario does not resolve numeric operands ${variable} and ${target}`);
140
+ const compareName = firstScalar(block, 'compare')?.value ??
141
+ (short?.operator.text === '>'
142
+ ? 'greater_than'
143
+ : short?.operator.text === '<'
144
+ ? 'less_than'
145
+ : 'equals');
146
+ if (![
147
+ 'greater_than',
148
+ 'greater_than_or_equals',
149
+ 'less_than',
150
+ 'less_than_or_equals',
151
+ 'not_equals',
152
+ 'equals',
153
+ ].includes(compareName))
154
+ return unresolved(assignment, candidate, `Unsupported check_variable comparison ${compareName}`);
155
+ const result = compareName === 'greater_than'
156
+ ? leftNumber > rightNumber
157
+ : compareName === 'greater_than_or_equals'
158
+ ? leftNumber >= rightNumber
159
+ : compareName === 'less_than'
160
+ ? leftNumber < rightNumber
161
+ : compareName === 'less_than_or_equals'
162
+ ? leftNumber <= rightNumber
163
+ : compareName === 'not_equals'
164
+ ? leftNumber !== rightNumber
165
+ : leftNumber === rightNumber;
166
+ return { state: result ? 'true' : 'false', unresolved: [] };
167
+ }
168
+ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, helperStack, scopeContext) {
169
+ const key = assignment.key.value;
170
+ if (assignment.value.type === 'block') {
171
+ const malformed = malformedBlock(assignment.value, candidate);
172
+ if (malformed !== undefined)
173
+ return malformed;
174
+ if (key === 'AND')
175
+ return evaluateTriggerBlock(assignment.value, scenario, candidate, definitions, helperStack, scopeContext);
176
+ if (key === 'OR') {
177
+ const children = assignments(assignment.value).map((child) => evaluateAssignment(child, scenario, candidate, definitions, helperStack, scopeContext));
178
+ return {
179
+ state: combineOr(children.map(({ state }) => state)),
180
+ unresolved: children.flatMap(({ unresolved: items }) => items),
181
+ helperProvenance: children.flatMap(({ helperProvenance }) => helperProvenance ?? []),
182
+ };
183
+ }
184
+ if (key === 'NOT' || key === 'NOR' || key === 'NAND') {
185
+ const children = assignments(assignment.value).map((child) => evaluateAssignment(child, scenario, candidate, definitions, helperStack, scopeContext));
186
+ const base = key === 'NAND'
187
+ ? combineAnd(children.map(({ state }) => state))
188
+ : combineOr(children.map(({ state }) => state));
189
+ return {
190
+ state: invert(base),
191
+ unresolved: children.flatMap(({ unresolved: items }) => items),
192
+ helperProvenance: children.flatMap(({ helperProvenance }) => helperProvenance ?? []),
193
+ };
194
+ }
195
+ if (key === 'check_variable')
196
+ return evaluateCheckVariable(assignment.value, scenario, candidate, assignment, scopeContext, definitions);
197
+ if (key === 'is_in_array' || key === 'is_variable_in_array') {
198
+ const arrayId = firstScalar(assignment.value, 'array')?.value;
199
+ const declaredValue = firstScalar(assignment.value, 'value')?.value;
200
+ if (arrayId === undefined || declaredValue === undefined)
201
+ return unresolved(assignment, candidate, `${key} requires array and value`);
202
+ const array = scopeStateValue(scenario, `array.${arrayId}`, scopeContext) ??
203
+ rootStateValue(scenario, `array.${arrayId}`);
204
+ if (!Array.isArray(array))
205
+ return unresolved(assignment, candidate, `Scenario does not declare array ${arrayId}`);
206
+ const compared = ['THIS', 'ROOT', 'PREV'].includes(declaredValue.toUpperCase()) ||
207
+ resolveScopeContext(scenario, declaredValue, scopeContext) !== undefined
208
+ ? scopeIdentity(resolveScopeContext(scenario, declaredValue, scopeContext) ?? scopeContext)
209
+ : (scopeStateValue(scenario, declaredValue, scopeContext) ?? declaredValue);
210
+ return { state: array.includes(compared) ? 'true' : 'false', unresolved: [] };
211
+ }
212
+ const resolvedScope = resolveScopeContext(scenario, key, scopeContext);
213
+ if (resolvedScope !== undefined)
214
+ return evaluateTriggerBlock(assignment.value, scenario, candidate, definitions, helperStack, resolvedScope);
215
+ const helper = definitions.scriptedTriggers.get(key);
216
+ if (helper !== undefined) {
217
+ if (helperStack.includes(key))
218
+ return unresolved(assignment, candidate, `Scripted trigger recursion detected at ${key}`);
219
+ const expanded = helperWithArguments(helper.value, assignment.value);
220
+ if (expanded === undefined)
221
+ return unresolved(assignment, candidate, `Scripted trigger ${key} requires declared scalar arguments for every parameter`);
222
+ const helperProvenance = {
223
+ path: helper.file.displayPath,
224
+ rootKind: helper.file.rootKind,
225
+ loadOrder: helper.file.loadOrder,
226
+ sourceHash: helper.file.sha256,
227
+ location: nodeLocation(helper.document, helper.node, key),
228
+ ...(astPathFor(helper.document, helper.node) === undefined
229
+ ? {}
230
+ : { astPath: astPathFor(helper.document, helper.node) }),
231
+ symbol: key,
232
+ helperChain: [...helperStack, key],
233
+ };
234
+ const evaluated = evaluateTriggerBlock(expanded, scenario, {
235
+ ...candidate,
236
+ document: helper.document,
237
+ provenance: [helperProvenance, ...candidate.provenance],
238
+ }, definitions, [...helperStack, key], scopeContext);
239
+ return {
240
+ ...evaluated,
241
+ helperProvenance: [helperProvenance, ...(evaluated.helperProvenance ?? [])],
242
+ };
243
+ }
244
+ const declaredScope = bool(rootStateValue(scenario, `scope.${key}`));
245
+ if (declaredScope !== undefined)
246
+ return { state: declaredScope ? 'true' : 'false', unresolved: [] };
247
+ return unresolved(assignment, candidate, `Scoped or compound trigger ${key} is not declared by the scenario`);
248
+ }
249
+ const right = assignment.value.value;
250
+ if (key === 'always') {
251
+ const value = bool(right);
252
+ return value === undefined
253
+ ? unresolved(assignment, candidate, 'always trigger is not boolean')
254
+ : { state: value ? 'true' : 'false', unresolved: [] };
255
+ }
256
+ if (key === 'has_country_flag' || key === 'has_global_flag' || key === 'has_state_flag') {
257
+ const explicit = bool(scopeStateValue(scenario, `flag.${right}`, scopeContext));
258
+ if (explicit !== undefined)
259
+ return { state: explicit ? 'true' : 'false', unresolved: [] };
260
+ const flags = scopeContext.expression === 'ROOT' ? scenario.flags : scopeContext.binding.flags;
261
+ if (scenario.closedFlags === false && flags?.includes(right) !== true)
262
+ return unresolved(assignment, candidate, `Scenario does not declare flag ${right}`);
263
+ return { state: flags?.includes(right) === true ? 'true' : 'false', unresolved: [] };
264
+ }
265
+ if (key === 'has_event_target') {
266
+ const eventTargets = scopeContext.binding.eventTargets ?? scenario.eventTargets;
267
+ return {
268
+ state: Object.hasOwn(eventTargets ?? {}, right) ? 'true' : 'false',
269
+ unresolved: [],
270
+ };
271
+ }
272
+ if (key === 'tag' || key === 'original_tag') {
273
+ const actor = scopeContext.expression === 'ROOT'
274
+ ? scenario.actor
275
+ : (scopeContext.binding.actor ?? scopeContext.binding.id);
276
+ if (actor === undefined)
277
+ return unresolved(assignment, candidate, `Scenario does not declare actor for ${key}`);
278
+ return {
279
+ state: compare(actor, right, assignment.operator.text) ? 'true' : 'false',
280
+ unresolved: [],
281
+ };
282
+ }
283
+ if (key === 'date') {
284
+ if (scenario.date === undefined)
285
+ return unresolved(assignment, candidate, 'Scenario does not declare date');
286
+ const dateNumber = (value) => {
287
+ const parts = /^(\d+)\.(\d{1,2})\.(\d{1,2})$/u.exec(value);
288
+ if (parts === null)
289
+ return undefined;
290
+ const month = Number(parts[2]);
291
+ const day = Number(parts[3]);
292
+ return month < 1 || month > 12 || day < 1 || day > 31
293
+ ? undefined
294
+ : Number(parts[1]) * 10_000 + month * 100 + day;
295
+ };
296
+ const leftDate = dateNumber(scenario.date);
297
+ const rightDate = dateNumber(right);
298
+ if (leftDate === undefined || rightDate === undefined)
299
+ return unresolved(assignment, candidate, 'Date comparison is not numeric');
300
+ return {
301
+ state: compare(leftDate, rightDate, assignment.operator.text) ? 'true' : 'false',
302
+ unresolved: [],
303
+ };
304
+ }
305
+ const helper = definitions.scriptedTriggers.get(key);
306
+ if (helper !== undefined && bool(right) !== undefined) {
307
+ if (helperStack.includes(key))
308
+ return unresolved(assignment, candidate, `Scripted trigger recursion detected at ${key}`);
309
+ const helperProvenance = {
310
+ path: helper.file.displayPath,
311
+ rootKind: helper.file.rootKind,
312
+ loadOrder: helper.file.loadOrder,
313
+ sourceHash: helper.file.sha256,
314
+ location: nodeLocation(helper.document, helper.node, key),
315
+ ...(astPathFor(helper.document, helper.node) === undefined
316
+ ? {}
317
+ : { astPath: astPathFor(helper.document, helper.node) }),
318
+ symbol: key,
319
+ helperChain: [...helperStack, key],
320
+ };
321
+ const evaluated = evaluateTriggerBlock(helper.value, scenario, {
322
+ ...candidate,
323
+ document: helper.document,
324
+ provenance: [helperProvenance, ...candidate.provenance],
325
+ }, definitions, [...helperStack, key], scopeContext);
326
+ return {
327
+ ...evaluated,
328
+ state: bool(right) === false ? invert(evaluated.state) : evaluated.state,
329
+ helperProvenance: [helperProvenance, ...(evaluated.helperProvenance ?? [])],
330
+ };
331
+ }
332
+ const declared = scopeStateValue(scenario, key, scopeContext) ??
333
+ numberValue(key, scenario, scopeContext, definitions, candidate.provenance[0]?.path);
334
+ if (declared === undefined)
335
+ return unresolved(assignment, candidate, `Scenario does not declare trigger ${key}`);
336
+ const expectedBoolean = bool(right);
337
+ const actualBoolean = bool(declared);
338
+ if (expectedBoolean !== undefined && actualBoolean !== undefined)
339
+ return { state: expectedBoolean === actualBoolean ? 'true' : 'false', unresolved: [] };
340
+ const leftNumber = numeric(declared);
341
+ const rightNumber = numberValue(right, scenario, scopeContext, definitions, candidate.provenance[0]?.path);
342
+ if (leftNumber !== undefined && rightNumber !== undefined)
343
+ return {
344
+ state: compare(leftNumber, rightNumber, assignment.operator.text) ? 'true' : 'false',
345
+ unresolved: [],
346
+ };
347
+ if (typeof declared === 'string')
348
+ return {
349
+ state: compare(declared, right, assignment.operator.text) ? 'true' : 'false',
350
+ unresolved: [],
351
+ };
352
+ return unresolved(assignment, candidate, `Trigger ${key} cannot compare the declared scenario value`);
353
+ }
354
+ function evaluateAssignment(assignment, scenario, candidate, definitions, helperStack, scopeContext) {
355
+ const evaluated = evaluateAssignmentCore(assignment, scenario, candidate, definitions, helperStack, scopeContext);
356
+ const source = candidate.provenance[0];
357
+ const provenance = candidate.document === undefined || source === undefined
358
+ ? source
359
+ : {
360
+ ...source,
361
+ location: nodeLocation(candidate.document, assignment, assignment.key.value),
362
+ ...(astPathFor(candidate.document, assignment) === undefined
363
+ ? {}
364
+ : { astPath: astPathFor(candidate.document, assignment) }),
365
+ symbol: assignment.key.value,
366
+ };
367
+ const expression = assignment.value.type === 'scalar'
368
+ ? `${assignment.key.value} ${assignment.operator.text} ${assignment.value.value}`
369
+ : assignment.key.value;
370
+ return {
371
+ ...evaluated,
372
+ trace: [
373
+ {
374
+ operation: 'eligibility',
375
+ expression,
376
+ applied: evaluated.state,
377
+ ...(provenance === undefined ? {} : { provenance }),
378
+ },
379
+ ...(evaluated.trace ?? []),
380
+ ],
381
+ };
382
+ }
383
+ export function evaluateTriggerBlock(block, scenario, candidate, definitions, helperStack = [], scopeContext = rootScopeContext(scenario)) {
384
+ const override = scenario.candidateOverrides?.[candidate.id];
385
+ if (override !== undefined)
386
+ return { state: override ? 'true' : 'false', unresolved: [] };
387
+ if (block === undefined)
388
+ return { state: 'true', unresolved: [] };
389
+ const malformed = malformedBlock(block, candidate);
390
+ if (malformed !== undefined)
391
+ return malformed;
392
+ const children = assignments(block).map((assignment) => evaluateAssignment(assignment, scenario, candidate, definitions, helperStack, scopeContext));
393
+ return {
394
+ state: combineAnd(children.map(({ state }) => state)),
395
+ unresolved: children.flatMap(({ unresolved: items }) => items),
396
+ helperProvenance: children.flatMap(({ helperProvenance }) => helperProvenance ?? []),
397
+ trace: children.flatMap(({ trace }) => trace ?? []),
398
+ };
399
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+ export const ScenarioScalarSchema = z.union([
3
+ z.string().max(16_384),
4
+ z.number(),
5
+ z.boolean(),
6
+ z.null(),
7
+ ]);
8
+ export const ScenarioValueSchema = z.union([
9
+ ScenarioScalarSchema,
10
+ z.array(ScenarioScalarSchema).max(10_000),
11
+ ]);
12
+ export const ConditionScopeBindingSchema = z
13
+ .object({
14
+ id: z.string().min(1).max(256),
15
+ type: z
16
+ .enum([
17
+ 'country',
18
+ 'state',
19
+ 'character',
20
+ 'unit_leader',
21
+ 'operative',
22
+ 'strategic_region',
23
+ 'province',
24
+ 'unknown',
25
+ ])
26
+ .optional(),
27
+ actor: z.string().max(256).optional(),
28
+ state: z.record(z.string(), ScenarioValueSchema).default({}),
29
+ flags: z.array(z.string().max(256)).max(10_000).optional(),
30
+ eventTargets: z.record(z.string(), z.string().max(256)).optional(),
31
+ weight: z.union([z.number(), z.string().max(256)]).optional(),
32
+ })
33
+ .strict();
34
+ export const ConditionResultSchema = z
35
+ .object({
36
+ token: z.string(),
37
+ sourcePath: z.string(),
38
+ state: z.enum(['true', 'false', 'unresolved']),
39
+ localisationKey: z.string().optional(),
40
+ unresolved: z.array(z.string()),
41
+ })
42
+ .strict();
@@ -0,0 +1,97 @@
1
+ function caseInsensitiveEntry(record, key) {
2
+ if (record === undefined)
3
+ return undefined;
4
+ if (Object.hasOwn(record, key))
5
+ return record[key];
6
+ const normalized = key.toLowerCase();
7
+ const found = Object.entries(record).find(([candidate]) => candidate.toLowerCase() === normalized);
8
+ return found?.[1];
9
+ }
10
+ export function rootScopeContext(scenario) {
11
+ return {
12
+ expression: 'ROOT',
13
+ binding: {
14
+ id: scenario.actor ?? 'ROOT',
15
+ ...(scenario.actor === undefined ? {} : { actor: scenario.actor }),
16
+ state: scenario.state,
17
+ ...(scenario.flags === undefined ? {} : { flags: scenario.flags }),
18
+ ...(scenario.eventTargets === undefined ? {} : { eventTargets: scenario.eventTargets }),
19
+ },
20
+ };
21
+ }
22
+ function explicitScope(scenario, expression, parent) {
23
+ const binding = caseInsensitiveEntry(scenario.scopes, expression);
24
+ return binding === undefined ? undefined : { expression, binding, parent };
25
+ }
26
+ export function resolveScopeContext(scenario, expression, current) {
27
+ const upper = expression.toUpperCase();
28
+ if (upper === 'ROOT')
29
+ return current.expression === 'ROOT'
30
+ ? current
31
+ : { ...rootScopeContext(scenario), parent: current };
32
+ if (upper === 'THIS')
33
+ return current;
34
+ if (upper === 'PREV')
35
+ return current.parent ?? explicitScope(scenario, expression, current);
36
+ const chained = current.expression === 'ROOT' || current.expression === 'THIS'
37
+ ? expression
38
+ : `${current.expression}.${expression}`;
39
+ const chainedBinding = explicitScope(scenario, chained, current);
40
+ if (chainedBinding !== undefined)
41
+ return chainedBinding;
42
+ const direct = explicitScope(scenario, expression, current);
43
+ if (direct !== undefined)
44
+ return direct;
45
+ const targetMatch = /^(?:event_target|scope):(.+)$/iu.exec(expression);
46
+ const targetId = targetMatch === null
47
+ ? undefined
48
+ : (caseInsensitiveEntry(current.binding.eventTargets, targetMatch[1]) ??
49
+ caseInsensitiveEntry(scenario.eventTargets, targetMatch[1]));
50
+ if (targetId !== undefined) {
51
+ const declaredBinding = Object.values(scenario.scopes ?? {}).find((binding) => binding.id === targetId || binding.actor === targetId);
52
+ return {
53
+ expression,
54
+ binding: declaredBinding ?? { id: targetId, actor: targetId, state: {} },
55
+ parent: current,
56
+ };
57
+ }
58
+ return undefined;
59
+ }
60
+ export function scopeStateValue(scenario, key, current) {
61
+ if (Object.hasOwn(current.binding.state, key))
62
+ return current.binding.state[key];
63
+ if (Object.hasOwn(current.binding.state, `trigger.${key}`))
64
+ return current.binding.state[`trigger.${key}`];
65
+ if (current.expression !== 'ROOT')
66
+ return undefined;
67
+ if (Object.hasOwn(scenario.state, key))
68
+ return scenario.state[key];
69
+ if (Object.hasOwn(scenario.state, `trigger.${key}`))
70
+ return scenario.state[`trigger.${key}`];
71
+ return undefined;
72
+ }
73
+ export function rootStateValue(scenario, key) {
74
+ if (Object.hasOwn(scenario.state, key))
75
+ return scenario.state[key];
76
+ if (Object.hasOwn(scenario.state, `trigger.${key}`))
77
+ return scenario.state[`trigger.${key}`];
78
+ return undefined;
79
+ }
80
+ export function scopeIdentity(current) {
81
+ return current.binding.actor ?? current.binding.id;
82
+ }
83
+ export function scopedExpressionValue(scenario, expression) {
84
+ const scopes = Object.entries(scenario.scopes ?? {}).sort(([left], [right]) => right.length - left.length);
85
+ for (const [scope, binding] of scopes) {
86
+ for (const prefix of [`${scope}.`, `scope.${scope}.`, `scopes.${scope}.`]) {
87
+ if (!expression.toLowerCase().startsWith(prefix.toLowerCase()))
88
+ continue;
89
+ const key = expression.slice(prefix.length).replace(/^state\./u, '');
90
+ if (Object.hasOwn(binding.state, key))
91
+ return binding.state[key];
92
+ if (Object.hasOwn(binding.state, `variable.${key}`))
93
+ return binding.state[`variable.${key}`];
94
+ }
95
+ }
96
+ return undefined;
97
+ }
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readdir, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
2
+ import { lstat, mkdir, readdir, rmdir, unlink, writeFile } from 'node:fs/promises';
3
3
  import { hostname } from 'node:os';
4
+ import path from 'node:path';
4
5
  import { setTimeout as delay } from 'node:timers/promises';
5
6
  import { sha256Bytes } from './canonical.js';
6
7
  import { canonicalPath, containedGeneratedPath } from './workspace.js';
8
+ import { ServiceError } from './result.js';
7
9
  function processAlive(pid) {
8
10
  try {
9
11
  process.kill(pid, 0);
@@ -28,31 +30,40 @@ export class SharedRequestCapacity {
28
30
  const root = await containedGeneratedPath(await canonicalPath(this.stateRoot, signal), 'request-capacity', sha256Bytes(hostname().toLowerCase()).slice(0, 16));
29
31
  await mkdir(root, { recursive: true });
30
32
  const lease = `${process.pid}-${randomUUID()}.lease`;
33
+ const pendingDeletionFailures = new Map();
31
34
  for (;;) {
32
35
  signal.throwIfAborted();
33
36
  for (let index = 0; index < this.capacity; index += 1) {
34
- let slot;
35
- try {
36
- slot = await containedGeneratedPath(root, String(index));
37
- }
38
- catch (error) {
39
- // Another process can finish a slot while canonical containment is checked.
40
- if (error.code === 'ENOENT')
41
- continue;
42
- throw error;
43
- }
37
+ // Only the stable private parent is canonicalized. A slot can disappear during
38
+ // another owner's release; realpath on that ephemeral directory races on Windows.
39
+ // The numeric component cannot escape, and mkdir atomically proves new ownership.
40
+ const slot = path.join(root, String(index));
44
41
  try {
45
42
  await mkdir(slot);
43
+ pendingDeletionFailures.delete(index);
46
44
  }
47
45
  catch (error) {
48
- if (error.code !== 'EEXIST')
46
+ const code = error.code;
47
+ // Windows can retain a delete-pending directory handle after rmdir
48
+ // returns. Recreating that slot reports EPERM/EBUSY, not EEXIST.
49
+ // No work is admitted until a later mkdir actually succeeds.
50
+ if (process.platform === 'win32' && ['EPERM', 'EBUSY'].includes(code ?? '')) {
51
+ const failures = (pendingDeletionFailures.get(index) ?? 0) + 1;
52
+ pendingDeletionFailures.set(index, failures);
53
+ if (failures >= 50)
54
+ throw error;
55
+ await this.reap(slot);
56
+ continue;
57
+ }
58
+ if (code !== 'EEXIST')
49
59
  throw error;
60
+ pendingDeletionFailures.delete(index);
50
61
  await this.reap(slot);
51
62
  continue;
52
63
  }
53
64
  let owner;
54
65
  try {
55
- owner = await containedGeneratedPath(slot, lease);
66
+ owner = path.join(slot, lease);
56
67
  await writeFile(owner, '', { flag: 'wx', mode: 0o600 });
57
68
  }
58
69
  catch (error) {
@@ -71,7 +82,8 @@ export class SharedRequestCapacity {
71
82
  await unlink(owner);
72
83
  await rmdir(slot).catch((error) => {
73
84
  const code = error.code;
74
- if (code !== 'ENOENT' && code !== 'ENOTEMPTY')
85
+ if (!['ENOENT', 'ENOTEMPTY'].includes(code ?? '') &&
86
+ !(process.platform === 'win32' && ['EPERM', 'EBUSY'].includes(code ?? '')))
75
87
  throw error;
76
88
  });
77
89
  }
@@ -81,9 +93,12 @@ export class SharedRequestCapacity {
81
93
  }
82
94
  async reap(slot) {
83
95
  try {
96
+ const metadata = await lstat(slot);
97
+ if (metadata.isSymbolicLink() || !metadata.isDirectory())
98
+ throw new ServiceError('PATH_GENERATED_ROOT_ESCAPE', 'Execution capacity slot must be a real directory beneath its private root');
84
99
  const owners = await readdir(slot);
85
100
  if (owners.length === 0) {
86
- if (Date.now() - (await stat(slot)).mtimeMs < 30_000)
101
+ if (Date.now() - metadata.mtimeMs < 30_000)
87
102
  return;
88
103
  }
89
104
  else {
@@ -92,7 +107,7 @@ export class SharedRequestCapacity {
92
107
  if (match === null || processAlive(Number(match[1])))
93
108
  return;
94
109
  // Unique owner names prevent a concurrent reaper from deleting a new lease.
95
- await unlink(await containedGeneratedPath(slot, name)).catch((error) => {
110
+ await unlink(path.join(slot, name)).catch((error) => {
96
111
  if (error.code !== 'ENOENT')
97
112
  throw error;
98
113
  });
@@ -101,7 +116,9 @@ export class SharedRequestCapacity {
101
116
  await rmdir(slot);
102
117
  }
103
118
  catch (error) {
104
- if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code ?? ''))
119
+ const code = error.code ?? '';
120
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(code) &&
121
+ !(process.platform === 'win32' && ['EPERM', 'EBUSY'].includes(code)))
105
122
  throw error;
106
123
  }
107
124
  }