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.
- package/CHANGELOG.md +12 -0
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +399 -0
- package/dist/hoi4_agent_tools/core/condition-model.js +1 -0
- package/dist/hoi4_agent_tools/core/condition-schema.js +42 -0
- package/dist/hoi4_agent_tools/core/scenario-state.js +97 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +34 -17
- package/dist/hoi4_agent_tools/gui/scenario-conditions.js +119 -0
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +59 -73
- package/dist/hoi4_agent_tools/gui/scenario.js +3 -0
- package/dist/hoi4_agent_tools/gui/studio.js +5 -2
- package/dist/hoi4_agent_tools/probability/scenario-state.js +2 -95
- package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +1 -287
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/gui.md +3 -1
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +189 -33
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/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,
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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() -
|
|
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(
|
|
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
|
-
|
|
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
|
}
|