hoi4-agent-tools 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +4 -0
  3. package/dist/hoi4_agent_tools/core/analysis-job-operations.js +11 -1
  4. package/dist/hoi4_agent_tools/core/condition-evaluator.js +5 -1
  5. package/dist/hoi4_agent_tools/core/job-worker-host.js +3 -0
  6. package/dist/hoi4_agent_tools/core/operation-tasks.js +7 -0
  7. package/dist/hoi4_agent_tools/core/scenario-model.js +85 -0
  8. package/dist/hoi4_agent_tools/gui/scenario-conditions.js +2 -23
  9. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  10. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +3 -0
  11. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +90 -0
  12. package/dist/hoi4_agent_tools/mechanic/assertions.js +112 -0
  13. package/dist/hoi4_agent_tools/mechanic/interpreter.js +619 -0
  14. package/dist/hoi4_agent_tools/mechanic/service.js +145 -0
  15. package/dist/hoi4_agent_tools/mechanic/source.js +89 -0
  16. package/dist/hoi4_agent_tools/package-check/service.js +231 -0
  17. package/dist/hoi4_agent_tools/probability/evaluation.js +3 -1
  18. package/dist/hoi4_agent_tools/scenario-suite/service.js +341 -0
  19. package/dist/hoi4_agent_tools/schemas/scenarios.js +264 -0
  20. package/dist/hoi4_agent_tools/version.js +1 -1
  21. package/docs/README.md +1 -0
  22. package/docs/mechanics.md +70 -0
  23. package/package.json +2 -1
  24. package/schemas/configuration.schema.json +1 -1
  25. package/schemas/continuous-focus-palette.schema.json +1 -1
  26. package/schemas/custom-weighted-pool.schema.json +1 -1
  27. package/schemas/decision-inspect-input.schema.json +1 -1
  28. package/schemas/event-feature-manifest.schema.json +1 -1
  29. package/schemas/focus-plan.schema.json +1 -1
  30. package/schemas/focus-planning-sidecar.schema.json +1 -1
  31. package/schemas/gui-animation-source.schema.json +1 -1
  32. package/schemas/gui-helper.schema.json +1 -1
  33. package/schemas/gui-scenario.schema.json +1 -1
  34. package/schemas/helper-expansion-request.schema.json +1 -1
  35. package/schemas/helper-expansion-summary.schema.json +1 -1
  36. package/schemas/impact-inspect-input.schema.json +1 -1
  37. package/schemas/map-operation.schema.json +1 -1
  38. package/schemas/mechanic-test-input.schema.json +650 -0
  39. package/schemas/operation-result.schema.json +1 -1
  40. package/schemas/package-check-input.schema.json +164 -0
  41. package/schemas/package-manifest.schema.json +142 -0
  42. package/schemas/probability-analysis-result.schema.json +1 -1
  43. package/schemas/probability-compare-input.schema.json +1 -1
  44. package/schemas/probability-evaluate-input.schema.json +1 -1
  45. package/schemas/probability-inspect-input.schema.json +1 -1
  46. package/schemas/probability-render-input.schema.json +1 -1
  47. package/schemas/probability-scenario-set.schema.json +1 -1
  48. package/schemas/probability-sequence-input.schema.json +1 -1
  49. package/schemas/probability-simulate-input.schema.json +1 -1
  50. package/schemas/probability-sweep-input.schema.json +1 -1
  51. package/schemas/scenario-suite.schema.json +1004 -0
  52. package/schemas/scenario-test-input.schema.json +1057 -0
  53. package/schemas/shared-scenario.schema.json +237 -0
  54. package/server.json +2 -2
@@ -0,0 +1,341 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { z } from 'zod/v4';
3
+ import { analysisSourceEvidence } from '../core/analysis-evidence.js';
4
+ import { publicArtifactLink } from '../core/artifacts.js';
5
+ import { canonicalJson, hashCanonical, sha256Bytes } from '../core/canonical.js';
6
+ import { registerAnalysisJobs } from '../core/analysis-job-operations.js';
7
+ import { JobOperations } from '../core/job-executor.js';
8
+ import { setInlineFilesScanned } from '../core/operation-result.js';
9
+ import { emptyServiceResult, ServiceError } from '../core/result.js';
10
+ import { registerEventJobs } from '../event/job-operations.js';
11
+ import { registerFocusJobs } from '../focus/job-operations.js';
12
+ import { registerGuiJobs } from '../gui/job-operations.js';
13
+ import { registerMapJobs } from '../map/job-operations.js';
14
+ import { runMechanicCase } from '../mechanic/service.js';
15
+ import { PackageAnalyzer } from '../package-check/service.js';
16
+ import { registerProbabilityJobs } from '../probability/job-operations.js';
17
+ import { scenarioSuiteSchema, } from '../schemas/scenarios.js';
18
+ import { registerTechnologyJobs } from '../technology/job-operations.js';
19
+ import { PACKAGE_VERSION } from '../version.js';
20
+ const MAX_SUITE_BYTES = 8 * 1024 * 1024;
21
+ const continuationSchema = z
22
+ .object({
23
+ version: z.literal(1),
24
+ workspaceIdentity: z.string().regex(/^[a-f0-9]{64}$/u),
25
+ principal: z.string(),
26
+ suiteHash: z.string().regex(/^[a-f0-9]{64}$/u),
27
+ sourceRevision: z.string().regex(/^[a-f0-9]{64}$/u),
28
+ nextIndex: z.number().int().nonnegative(),
29
+ completedHash: z.string().regex(/^[a-f0-9]{64}$/u),
30
+ failed: z.number().int().nonnegative(),
31
+ unresolved: z.number().int().nonnegative(),
32
+ })
33
+ .strict();
34
+ function issueToken(engine, payload) {
35
+ const state = engine.resolver.serverState();
36
+ if (state === undefined)
37
+ throw new ServiceError('SCENARIO_CONTINUATION_UNAVAILABLE', 'Persistent server state is required for suite continuation');
38
+ const encoded = Buffer.from(canonicalJson(payload)).toString('base64url');
39
+ return `${encoded}.${state.authenticateJournal(payload)}`;
40
+ }
41
+ function readToken(engine, token) {
42
+ const state = engine.resolver.serverState();
43
+ if (state === undefined)
44
+ throw new ServiceError('SCENARIO_CONTINUATION_UNAVAILABLE', 'Persistent server state is required for suite continuation');
45
+ const [encoded, tag, extra] = token.split('.');
46
+ if (encoded === undefined || tag === undefined || extra !== undefined)
47
+ throw new ServiceError('SCENARIO_CONTINUATION_INVALID', 'Malformed continuation');
48
+ let payload;
49
+ try {
50
+ payload = continuationSchema.parse(JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')));
51
+ }
52
+ catch {
53
+ throw new ServiceError('SCENARIO_CONTINUATION_INVALID', 'Malformed continuation');
54
+ }
55
+ if (!state.verifyJournal(payload, tag))
56
+ throw new ServiceError('SCENARIO_CONTINUATION_INVALID', 'Continuation authentication failed');
57
+ return payload;
58
+ }
59
+ function atPath(object, path) {
60
+ let cursor = object;
61
+ for (const key of path) {
62
+ if (cursor === null || typeof cursor !== 'object' || !Object.hasOwn(cursor, key))
63
+ return undefined;
64
+ cursor = cursor[key];
65
+ }
66
+ return cursor;
67
+ }
68
+ function caseAssertions(assertions, output) {
69
+ return assertions.map((assertion) => {
70
+ const observed = atPath(output, assertion.path);
71
+ return {
72
+ id: assertion.id,
73
+ status: observed === undefined
74
+ ? 'unresolved'
75
+ : observed === assertion.expected
76
+ ? 'passed'
77
+ : 'failed',
78
+ ...(observed === undefined ? {} : { observed }),
79
+ };
80
+ });
81
+ }
82
+ function typedOperations(engine) {
83
+ const operations = new JobOperations();
84
+ registerAnalysisJobs(operations, engine);
85
+ registerEventJobs(operations, engine);
86
+ registerTechnologyJobs(operations, engine);
87
+ registerProbabilityJobs(operations, engine);
88
+ registerFocusJobs(operations, engine);
89
+ registerGuiJobs(operations, engine);
90
+ registerMapJobs(operations, engine);
91
+ return operations;
92
+ }
93
+ /**
94
+ * Runs a versioned suite in bounded pages through fixed read-only core
95
+ * operations. The continuation is signed and pins all selection inputs.
96
+ */
97
+ export class ScenarioAnalyzer {
98
+ engine;
99
+ constructor(engine) {
100
+ this.engine = engine;
101
+ }
102
+ async test(input) {
103
+ const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
104
+ if (input.refresh)
105
+ this.engine.invalidate(input.workspaceId);
106
+ const snapshot = await this.engine.scan(input.workspaceId, {}, input.principal, input.signal);
107
+ if (input.expectedRevision !== undefined && input.expectedRevision !== snapshot.revision)
108
+ throw new ServiceError('SCENARIO_SOURCE_STALE', 'Scenario source revision changed');
109
+ let suite;
110
+ let suiteFileHash;
111
+ if (input.suitePath !== undefined) {
112
+ const resolved = await this.engine.resolver.resolvePath(input.workspaceId, input.suitePath, 'read', ['mod'], input.principal);
113
+ const details = await stat(resolved.path);
114
+ if (!details.isFile() || details.size > MAX_SUITE_BYTES)
115
+ throw new ServiceError('SCENARIO_SUITE_LIMIT', 'Suite file exceeds the bounded reader');
116
+ const bytes = await readFile(resolved.path);
117
+ suiteFileHash = sha256Bytes(bytes);
118
+ suite = scenarioSuiteSchema.parse(JSON.parse(bytes.toString('utf8')));
119
+ }
120
+ else
121
+ suite = scenarioSuiteSchema.parse(input.suite);
122
+ const suiteHash = hashCanonical({ suite, suiteFileHash });
123
+ const resumed = input.continuation === undefined ? undefined : readToken(this.engine, input.continuation);
124
+ if (resumed !== undefined &&
125
+ (resumed.workspaceIdentity !== workspace.workspaceIdentity ||
126
+ resumed.principal !== (input.principal ?? '') ||
127
+ resumed.suiteHash !== suiteHash ||
128
+ resumed.sourceRevision !== snapshot.revision ||
129
+ resumed.nextIndex >= suite.cases.length ||
130
+ resumed.failed + resumed.unresolved > resumed.nextIndex))
131
+ throw new ServiceError('SCENARIO_CONTINUATION_STALE', 'Continuation does not match the authorized suite and source revision');
132
+ const start = resumed?.nextIndex ?? 0;
133
+ const end = Math.min(suite.cases.length, start + input.maxCases);
134
+ const operations = typedOperations(this.engine);
135
+ const packageAnalyzer = new PackageAnalyzer(this.engine);
136
+ const caseResults = [];
137
+ let completedHash = resumed?.completedHash ??
138
+ hashCanonical({
139
+ suiteHash,
140
+ sourceRevision: snapshot.revision,
141
+ workspaceIdentity: workspace.workspaceIdentity,
142
+ });
143
+ const evidence = analysisSourceEvidence(snapshot);
144
+ for (let index = start; index < end; index += 1) {
145
+ input.signal?.throwIfAborted();
146
+ const item = suite.cases[index];
147
+ const missing = item.sourceSelectors.filter(({ kind, id }) => snapshot.index.find(kind, id) === undefined);
148
+ let output;
149
+ let executionStatus;
150
+ let reason;
151
+ if (missing.length > 0) {
152
+ executionStatus = snapshot.complete ? 'failed' : 'unresolved';
153
+ reason = 'Declared source selector is absent from the active source index';
154
+ output = { missing };
155
+ }
156
+ else {
157
+ try {
158
+ if (item.domain === 'mechanic') {
159
+ const mechanic = runMechanicCase(snapshot, item.test);
160
+ output = mechanic;
161
+ executionStatus =
162
+ mechanic.status === 'passed'
163
+ ? 'completed'
164
+ : mechanic.status === 'failed'
165
+ ? 'failed'
166
+ : 'unresolved';
167
+ }
168
+ else if (item.domain === 'package') {
169
+ const checked = await packageAnalyzer.check({
170
+ workspaceId: input.workspaceId,
171
+ manifest: item.manifest,
172
+ expectedRevision: snapshot.revision,
173
+ ...(input.principal === undefined ? {} : { principal: input.principal }),
174
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
175
+ refresh: false,
176
+ });
177
+ output = checked;
178
+ executionStatus = checked.data.complete ? 'completed' : 'failed';
179
+ }
180
+ else {
181
+ const operation = operations.get(item.tool);
182
+ if (operation.mutation)
183
+ throw new ServiceError('SCENARIO_TOOL_MUTATION', 'Suite operation is not read-only');
184
+ const context = {
185
+ engine: this.engine,
186
+ workspaceId: input.workspaceId,
187
+ principal: input.principal,
188
+ signal: input.signal ?? new AbortController().signal,
189
+ checkpoint: undefined,
190
+ progress: () => Promise.resolve(),
191
+ saveCheckpoint: () => Promise.reject(new ServiceError('SCENARIO_CHECKPOINT_UNAVAILABLE', 'Nested jobs cannot publish a suite checkpoint')),
192
+ stageResult: () => Promise.resolve(),
193
+ };
194
+ const wire = await operation.run({
195
+ ...item.arguments,
196
+ workspaceId: input.workspaceId,
197
+ }, context);
198
+ output = atPath(wire, ['structuredContent']) ?? wire;
199
+ const status = atPath(output, ['status']);
200
+ const passed = atPath(output, ['validation', 'passed']);
201
+ executionStatus =
202
+ status === 'error' || status === 'blocked' || passed === false
203
+ ? 'failed'
204
+ : 'completed';
205
+ }
206
+ }
207
+ catch (error) {
208
+ if (input.signal?.aborted)
209
+ throw error;
210
+ executionStatus = 'unresolved';
211
+ reason =
212
+ error instanceof ServiceError
213
+ ? error.code
214
+ : error instanceof Error
215
+ ? error.message
216
+ : 'Case execution failed';
217
+ output = {};
218
+ }
219
+ }
220
+ const assertions = caseAssertions(item.assertions, output);
221
+ const status = assertions.some(({ status }) => status === 'failed')
222
+ ? 'failed'
223
+ : assertions.some(({ status }) => status === 'unresolved') ||
224
+ executionStatus === 'unresolved'
225
+ ? 'unresolved'
226
+ : executionStatus === 'failed'
227
+ ? 'failed'
228
+ : 'completed';
229
+ const caseReport = {
230
+ schemaVersion: 'scenario-case.v1',
231
+ suiteId: suite.id,
232
+ caseId: item.id,
233
+ index,
234
+ domain: item.domain,
235
+ sourceRevision: snapshot.revision,
236
+ status,
237
+ assertions,
238
+ ...(reason === undefined ? {} : { reason }),
239
+ output,
240
+ };
241
+ const caseHash = hashCanonical(caseReport);
242
+ const artifact = await this.engine.artifacts.putChunked(workspace, `scenario-case-${caseHash.slice(0, 24)}.json`, 'application/json', `${canonicalJson(caseReport)}\n`, {
243
+ kind: 'scenario-case',
244
+ toolVersion: PACKAGE_VERSION,
245
+ schemaVersion: 'scenario-case.v1',
246
+ sourceHashes: evidence.sourceHashes,
247
+ metadata: { beforeRevision: snapshot.revision, suiteHash, caseId: item.id },
248
+ }, 'Content-addressed suite case result', input.signal);
249
+ completedHash = hashCanonical({ prior: completedHash, caseId: item.id, caseHash });
250
+ caseResults.push({
251
+ id: item.id,
252
+ index,
253
+ status,
254
+ caseHash,
255
+ artifact: publicArtifactLink(artifact),
256
+ ...(item.views.includes('summary') ? { assertions } : {}),
257
+ ...(item.views.includes('artifacts')
258
+ ? { outputArtifacts: atPath(output, ['artifacts']) }
259
+ : {}),
260
+ ...(item.views.includes('diagnostics')
261
+ ? { diagnostics: atPath(output, ['diagnostics']) }
262
+ : {}),
263
+ });
264
+ const observed = await this.engine.scan(input.workspaceId, {}, input.principal, input.signal);
265
+ if (observed.revision !== snapshot.revision)
266
+ throw new ServiceError('SCENARIO_SOURCE_STALE', 'Source changed during suite execution');
267
+ }
268
+ const failed = (resumed?.failed ?? 0) + caseResults.filter(({ status }) => status === 'failed').length;
269
+ const unresolved = (resumed?.unresolved ?? 0) +
270
+ caseResults.filter(({ status }) => status === 'unresolved').length;
271
+ const continuation = end < suite.cases.length
272
+ ? issueToken(this.engine, {
273
+ version: 1,
274
+ workspaceIdentity: workspace.workspaceIdentity,
275
+ principal: input.principal ?? '',
276
+ suiteHash,
277
+ sourceRevision: snapshot.revision,
278
+ nextIndex: end,
279
+ completedHash,
280
+ failed,
281
+ unresolved,
282
+ })
283
+ : undefined;
284
+ const report = {
285
+ schemaVersion: 'scenario-suite-batch.v1',
286
+ suiteId: suite.id,
287
+ suiteHash,
288
+ sourceRevision: snapshot.revision,
289
+ start,
290
+ end,
291
+ total: suite.cases.length,
292
+ completedHash,
293
+ failed,
294
+ unresolved,
295
+ cases: caseResults,
296
+ ...(continuation === undefined ? {} : { continuation }),
297
+ };
298
+ const reportHash = hashCanonical(report);
299
+ const artifact = await this.engine.artifacts.putChunked(workspace, `scenario-batch-${reportHash.slice(0, 24)}.json`, 'application/json', `${canonicalJson(report)}\n`, {
300
+ kind: 'scenario-suite-batch',
301
+ toolVersion: PACKAGE_VERSION,
302
+ schemaVersion: 'scenario-suite-batch.v1',
303
+ sourceHashes: evidence.sourceHashes,
304
+ metadata: { beforeRevision: snapshot.revision, suiteHash, start, end },
305
+ }, 'Revision-pinned suite batch and continuation evidence', input.signal);
306
+ const result = emptyServiceResult(input.workspaceId, {
307
+ sourceRevision: snapshot.revision,
308
+ suiteHash,
309
+ reportHash,
310
+ start,
311
+ end,
312
+ total: suite.cases.length,
313
+ completed: end,
314
+ failed,
315
+ unresolved,
316
+ pending: suite.cases.length - end,
317
+ ...(continuation === undefined ? {} : { continuation }),
318
+ });
319
+ result.code =
320
+ continuation !== undefined
321
+ ? 'SCENARIO_SUITE_PENDING'
322
+ : failed > 0 || unresolved > 0
323
+ ? 'SCENARIO_SUITE_INCOMPLETE'
324
+ : 'SCENARIO_SUITE_PASSED';
325
+ result.artifacts = [publicArtifactLink(artifact)];
326
+ setInlineFilesScanned(result, snapshot.files.map(({ displayPath }) => displayPath));
327
+ result.validation = {
328
+ passed: continuation === undefined && failed === 0 && unresolved === 0,
329
+ checks: [
330
+ {
331
+ id: 'suite-cases',
332
+ passed: continuation === undefined && failed === 0 && unresolved === 0,
333
+ message: continuation === undefined
334
+ ? 'All requested cases were reported in this suite'
335
+ : 'Additional cases are pending under the authenticated continuation',
336
+ },
337
+ ],
338
+ };
339
+ return result;
340
+ }
341
+ }
@@ -0,0 +1,264 @@
1
+ import { z } from 'zod/v4';
2
+ import { declaredConditionScenarioSchema } from './analysis.js';
3
+ import { workspaceIdSchema, workspaceRelativePathSchema } from './common.js';
4
+ const identifier = z.string().min(1).max(512);
5
+ const manifestToken = z.string().regex(/^[A-Za-z0-9_.:-]{1,512}$/u);
6
+ const sourceSelector = z
7
+ .object({
8
+ kind: z.enum(['scripted_effect', 'event_option', 'decision', 'focus_reward']),
9
+ id: identifier,
10
+ option: identifier.optional(),
11
+ })
12
+ .strict();
13
+ export const sharedScenarioSchema = declaredConditionScenarioSchema.safeExtend({
14
+ schemaVersion: z.literal('1.0'),
15
+ globalFlags: z.array(identifier).max(10_000).optional(),
16
+ scopeCatalogs: z
17
+ .record(identifier, z
18
+ .object({
19
+ complete: z.boolean(),
20
+ members: z.array(identifier).max(2_048),
21
+ })
22
+ .strict())
23
+ .optional(),
24
+ });
25
+ const assertionPath = z
26
+ .object({
27
+ scope: identifier.default('ROOT'),
28
+ key: identifier,
29
+ })
30
+ .strict();
31
+ const scalar = z.union([z.string().max(4_096), z.number(), z.boolean(), z.null()]);
32
+ export const mechanicAssertionSchema = z.discriminatedUnion('kind', [
33
+ z
34
+ .object({ id: identifier, kind: z.literal('equals'), path: assertionPath, expected: scalar })
35
+ .strict(),
36
+ z
37
+ .object({
38
+ id: identifier,
39
+ kind: z.literal('conservation'),
40
+ paths: z.array(assertionPath).min(2).max(64),
41
+ tolerance: z.number().nonnegative().default(0),
42
+ })
43
+ .strict(),
44
+ z
45
+ .object({
46
+ id: identifier,
47
+ kind: z.literal('array_alignment'),
48
+ paths: z.array(assertionPath).min(2).max(32),
49
+ })
50
+ .strict(),
51
+ z
52
+ .object({
53
+ id: identifier,
54
+ kind: z.literal('affordability'),
55
+ balance: assertionPath,
56
+ cost: z.number().nonnegative(),
57
+ })
58
+ .strict(),
59
+ z
60
+ .object({
61
+ id: identifier,
62
+ kind: z.literal('single_payment'),
63
+ balance: assertionPath,
64
+ cost: z.number().nonnegative(),
65
+ })
66
+ .strict(),
67
+ z
68
+ .object({
69
+ id: identifier,
70
+ kind: z.literal('repeated_setup'),
71
+ firstStep: z.number().int().nonnegative(),
72
+ secondStep: z.number().int().nonnegative(),
73
+ })
74
+ .strict(),
75
+ z
76
+ .object({
77
+ id: identifier,
78
+ kind: z.literal('exclusive_flags'),
79
+ scope: identifier.default('ROOT'),
80
+ flags: z.array(identifier).min(2).max(64),
81
+ })
82
+ .strict(),
83
+ z
84
+ .object({
85
+ id: identifier,
86
+ kind: z.literal('cleanup'),
87
+ scope: identifier.default('ROOT'),
88
+ flags: z.array(identifier).max(64).default([]),
89
+ targets: z.array(identifier).max(64).default([]),
90
+ })
91
+ .strict(),
92
+ z
93
+ .object({ id: identifier, kind: z.literal('end_state'), path: assertionPath, expected: scalar })
94
+ .strict(),
95
+ ]);
96
+ export const mechanicCaseSchema = z
97
+ .object({
98
+ id: identifier,
99
+ scenario: sharedScenarioSchema,
100
+ steps: z
101
+ .array(z.discriminatedUnion('kind', [
102
+ z
103
+ .object({
104
+ kind: z.literal('effect'),
105
+ source: sourceSelector,
106
+ repeat: z.number().int().min(1).max(8).default(1),
107
+ })
108
+ .strict(),
109
+ z
110
+ .object({ kind: z.literal('advance_days'), days: z.number().int().min(0).max(36_500) })
111
+ .strict(),
112
+ ]))
113
+ .min(1)
114
+ .max(128),
115
+ assertions: z.array(mechanicAssertionSchema).min(1).max(256),
116
+ })
117
+ .strict();
118
+ export const mechanicTestRequestSchema = z
119
+ .object({
120
+ workspaceId: workspaceIdSchema,
121
+ test: mechanicCaseSchema,
122
+ expectedRevision: z
123
+ .string()
124
+ .regex(/^[a-f0-9]{64}$/u)
125
+ .optional(),
126
+ refresh: z.boolean().default(false),
127
+ })
128
+ .strict();
129
+ const manifestItem = z.object({ kind: manifestToken, id: manifestToken }).strict();
130
+ export const packageManifestSchema = z
131
+ .object({
132
+ schemaVersion: z.literal('1.0'),
133
+ id: manifestToken,
134
+ definitions: z.array(manifestItem).max(2_048).default([]),
135
+ calls: z
136
+ .array(z.object({ from: manifestItem, to: manifestItem }).strict())
137
+ .max(2_048)
138
+ .default([]),
139
+ registrations: z
140
+ .array(z.object({ path: workspaceRelativePathSchema, token: manifestToken }).strict())
141
+ .max(2_048)
142
+ .default([]),
143
+ localisation: z.array(manifestToken).max(2_048).default([]),
144
+ assets: z.array(workspaceRelativePathSchema).max(2_048).default([]),
145
+ requiredCases: z
146
+ .array(z.object({ suite: workspaceRelativePathSchema, caseId: manifestToken }).strict())
147
+ .max(512)
148
+ .default([]),
149
+ })
150
+ .strict();
151
+ export const packageCheckRequestSchema = z
152
+ .object({
153
+ workspaceId: workspaceIdSchema,
154
+ manifest: packageManifestSchema,
155
+ expectedRevision: z
156
+ .string()
157
+ .regex(/^[a-f0-9]{64}$/u)
158
+ .optional(),
159
+ refresh: z.boolean().default(false),
160
+ })
161
+ .strict();
162
+ const readOnlySuiteTool = z.enum([
163
+ 'hoi4.impact_inspect',
164
+ 'hoi4.decision_inspect',
165
+ 'hoi4.event_inspect',
166
+ 'hoi4.event_render',
167
+ 'hoi4.tech_inspect',
168
+ 'hoi4.tech_render',
169
+ 'hoi4.probability_inspect',
170
+ 'hoi4.probability_evaluate',
171
+ 'hoi4.probability_render',
172
+ 'hoi4.focus_inspect',
173
+ 'hoi4.focus_render',
174
+ 'hoi4.gui_inspect',
175
+ 'hoi4.gui_render',
176
+ 'hoi4.map_inspect',
177
+ 'hoi4.map_render',
178
+ ]);
179
+ const suiteCaseMetadata = {
180
+ sourceSelectors: z.array(manifestItem).max(64).default([]),
181
+ assertions: z
182
+ .array(z
183
+ .object({
184
+ id: identifier,
185
+ path: z.array(identifier).min(1).max(16),
186
+ expected: scalar,
187
+ })
188
+ .strict())
189
+ .max(64)
190
+ .default([]),
191
+ views: z
192
+ .array(z.enum(['summary', 'artifacts', 'diagnostics']))
193
+ .max(3)
194
+ .default(['summary']),
195
+ };
196
+ export const scenarioSuiteSchema = z
197
+ .object({
198
+ schemaVersion: z.literal('1.0'),
199
+ id: identifier,
200
+ cases: z
201
+ .array(z.discriminatedUnion('domain', [
202
+ z
203
+ .object({
204
+ domain: z.literal('mechanic'),
205
+ id: identifier,
206
+ test: mechanicCaseSchema,
207
+ ...suiteCaseMetadata,
208
+ })
209
+ .strict(),
210
+ z
211
+ .object({
212
+ domain: z.literal('package'),
213
+ id: identifier,
214
+ manifest: packageManifestSchema,
215
+ ...suiteCaseMetadata,
216
+ })
217
+ .strict(),
218
+ z
219
+ .object({
220
+ domain: z.literal('tool'),
221
+ id: identifier,
222
+ tool: readOnlySuiteTool,
223
+ arguments: z.record(z.string().max(256), z.json()),
224
+ ...suiteCaseMetadata,
225
+ })
226
+ .strict(),
227
+ ]))
228
+ .min(1)
229
+ .max(10_000),
230
+ })
231
+ .strict()
232
+ .superRefine((suite, context) => {
233
+ const ids = new Set();
234
+ for (const [index, item] of suite.cases.entries()) {
235
+ if (ids.has(item.id))
236
+ context.addIssue({
237
+ code: 'custom',
238
+ path: ['cases', index, 'id'],
239
+ message: 'Suite case ids must be unique',
240
+ });
241
+ ids.add(item.id);
242
+ }
243
+ });
244
+ export const scenarioTestRequestSchema = z
245
+ .object({
246
+ workspaceId: workspaceIdSchema,
247
+ suite: scenarioSuiteSchema.optional(),
248
+ suitePath: workspaceRelativePathSchema.optional(),
249
+ continuation: z.string().max(4_096).optional(),
250
+ maxCases: z.number().int().min(1).max(256).default(32),
251
+ expectedRevision: z
252
+ .string()
253
+ .regex(/^[a-f0-9]{64}$/u)
254
+ .optional(),
255
+ refresh: z.boolean().default(false),
256
+ })
257
+ .strict()
258
+ .superRefine((input, context) => {
259
+ if ((input.suite === undefined) === (input.suitePath === undefined))
260
+ context.addIssue({
261
+ code: 'custom',
262
+ message: 'Specify exactly one inline suite or suitePath',
263
+ });
264
+ });
@@ -1,6 +1,6 @@
1
1
  import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
2
2
  export const PACKAGE_NAME = 'hoi4-agent-tools';
3
- export const PACKAGE_VERSION = '3.2.0';
3
+ export const PACKAGE_VERSION = '3.3.0';
4
4
  export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
5
5
  export const CONFIG_VERSION = 1;
6
6
  export const TRANSACTION_VERSION = 1;
package/docs/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  - [Setup](setup.md): install the server, configure HOI4 paths, and connect an MCP client.
4
4
  - [Event chains](events.md): inspect, trace, lint, render, and compare event chains.
5
5
  - [Cross-system impact and decisions](analysis.md): trace source consumers and evaluate decision or mission scenarios.
6
+ - [Mechanic tests and scenario suites](mechanics.md): interpret declared effect steps, check package links, and run resumable cases.
6
7
  - [Technology trees](technology.md): inspect, trace, lint, render, and compare technologies and doctrines.
7
8
  - [Bounded helper expansion](helper-expansion.md): resume source-linked event and technology helper paths with exact coverage boundaries.
8
9
  - [Persistent jobs](jobs.md): run, inspect, cancel, and recover long operations.