hoi4-agent-tools 3.2.0 → 3.4.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 (99) hide show
  1. package/CHANGELOG.md +19 -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 +18 -9
  5. package/dist/hoi4_agent_tools/core/helper-expansion.js +3 -0
  6. package/dist/hoi4_agent_tools/core/job-worker-host.js +3 -0
  7. package/dist/hoi4_agent_tools/core/operation-tasks.js +7 -0
  8. package/dist/hoi4_agent_tools/core/scenario-model.js +85 -0
  9. package/dist/hoi4_agent_tools/event/comparison-selection.js +78 -0
  10. package/dist/hoi4_agent_tools/event/helper-expansion.js +48 -0
  11. package/dist/hoi4_agent_tools/event/operations.js +16 -0
  12. package/dist/hoi4_agent_tools/event/service.js +21 -2
  13. package/dist/hoi4_agent_tools/focus/compact.js +241 -27
  14. package/dist/hoi4_agent_tools/focus/layout.js +119 -8
  15. package/dist/hoi4_agent_tools/focus/render.js +62 -0
  16. package/dist/hoi4_agent_tools/focus/scenario.js +99 -0
  17. package/dist/hoi4_agent_tools/focus/tool-operations.js +30 -4
  18. package/dist/hoi4_agent_tools/gui/assets.js +54 -4
  19. package/dist/hoi4_agent_tools/gui/layout.js +935 -104
  20. package/dist/hoi4_agent_tools/gui/renderer.js +47 -17
  21. package/dist/hoi4_agent_tools/gui/scenario-conditions.js +2 -23
  22. package/dist/hoi4_agent_tools/gui/shader.js +515 -0
  23. package/dist/hoi4_agent_tools/gui/source-graph.js +166 -6
  24. package/dist/hoi4_agent_tools/gui/studio.js +245 -19
  25. package/dist/hoi4_agent_tools/gui/tool-operations.js +9 -0
  26. package/dist/hoi4_agent_tools/gui/validators.js +38 -12
  27. package/dist/hoi4_agent_tools/map/operations.js +70 -1
  28. package/dist/hoi4_agent_tools/map/render.js +70 -1
  29. package/dist/hoi4_agent_tools/map/service.js +30 -6
  30. package/dist/hoi4_agent_tools/map/tool-operations.js +67 -1
  31. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  32. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +3 -0
  33. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +90 -0
  34. package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -1
  35. package/dist/hoi4_agent_tools/mcp/tools/focus.js +14 -4
  36. package/dist/hoi4_agent_tools/mcp/tools/gui.js +5 -1
  37. package/dist/hoi4_agent_tools/mcp/tools/map.js +12 -2
  38. package/dist/hoi4_agent_tools/mcp/tools/probability.js +5 -0
  39. package/dist/hoi4_agent_tools/mechanic/assertions.js +112 -0
  40. package/dist/hoi4_agent_tools/mechanic/interpreter.js +619 -0
  41. package/dist/hoi4_agent_tools/mechanic/service.js +145 -0
  42. package/dist/hoi4_agent_tools/mechanic/source.js +89 -0
  43. package/dist/hoi4_agent_tools/package-check/service.js +231 -0
  44. package/dist/hoi4_agent_tools/probability/evaluation.js +6 -1
  45. package/dist/hoi4_agent_tools/probability/operations.js +8 -0
  46. package/dist/hoi4_agent_tools/probability/service.js +33 -12
  47. package/dist/hoi4_agent_tools/probability/source-analysis.js +1 -0
  48. package/dist/hoi4_agent_tools/scenario-suite/service.js +341 -0
  49. package/dist/hoi4_agent_tools/schemas/event.js +8 -0
  50. package/dist/hoi4_agent_tools/schemas/focus-requests.js +40 -1
  51. package/dist/hoi4_agent_tools/schemas/gui-requests.js +8 -0
  52. package/dist/hoi4_agent_tools/schemas/map-requests.js +22 -1
  53. package/dist/hoi4_agent_tools/schemas/scenarios.js +264 -0
  54. package/dist/hoi4_agent_tools/schemas/technology.js +7 -0
  55. package/dist/hoi4_agent_tools/technology/graph.js +25 -10
  56. package/dist/hoi4_agent_tools/technology/render.js +286 -31
  57. package/dist/hoi4_agent_tools/technology/service.js +55 -8
  58. package/dist/hoi4_agent_tools/technology/source-analysis.js +166 -3
  59. package/dist/hoi4_agent_tools/version.js +1 -1
  60. package/docs/README.md +1 -0
  61. package/docs/events.md +3 -1
  62. package/docs/focus.md +10 -0
  63. package/docs/gui.md +49 -3
  64. package/docs/map.md +8 -0
  65. package/docs/mechanics.md +70 -0
  66. package/docs/probability.md +3 -1
  67. package/docs/technology.md +7 -2
  68. package/package.json +2 -1
  69. package/schemas/configuration.schema.json +1 -1
  70. package/schemas/continuous-focus-palette.schema.json +1 -1
  71. package/schemas/custom-weighted-pool.schema.json +1 -1
  72. package/schemas/decision-inspect-input.schema.json +1 -1
  73. package/schemas/event-feature-manifest.schema.json +1 -1
  74. package/schemas/focus-plan.schema.json +1 -1
  75. package/schemas/focus-planning-sidecar.schema.json +1 -1
  76. package/schemas/gui-animation-source.schema.json +1 -1
  77. package/schemas/gui-helper.schema.json +1 -1
  78. package/schemas/gui-scenario.schema.json +1 -1
  79. package/schemas/helper-expansion-request.schema.json +1 -1
  80. package/schemas/helper-expansion-summary.schema.json +1 -1
  81. package/schemas/impact-inspect-input.schema.json +1 -1
  82. package/schemas/map-operation.schema.json +1 -1
  83. package/schemas/mechanic-test-input.schema.json +650 -0
  84. package/schemas/operation-result.schema.json +1 -1
  85. package/schemas/package-check-input.schema.json +164 -0
  86. package/schemas/package-manifest.schema.json +142 -0
  87. package/schemas/probability-analysis-result.schema.json +1 -1
  88. package/schemas/probability-compare-input.schema.json +1 -1
  89. package/schemas/probability-evaluate-input.schema.json +1 -1
  90. package/schemas/probability-inspect-input.schema.json +1 -1
  91. package/schemas/probability-render-input.schema.json +1 -1
  92. package/schemas/probability-scenario-set.schema.json +1 -1
  93. package/schemas/probability-sequence-input.schema.json +1 -1
  94. package/schemas/probability-simulate-input.schema.json +1 -1
  95. package/schemas/probability-sweep-input.schema.json +1 -1
  96. package/schemas/scenario-suite.schema.json +1004 -0
  97. package/schemas/scenario-test-input.schema.json +1057 -0
  98. package/schemas/shared-scenario.schema.json +237 -0
  99. 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
+ }
@@ -151,6 +151,12 @@ export function validateEventCompareRequest(value, context) {
151
151
  path: ['after'],
152
152
  message: 'after and proposedSources are mutually exclusive',
153
153
  });
154
+ if (value.maxChainNodes !== undefined && value.selector === undefined)
155
+ context.addIssue({
156
+ code: 'custom',
157
+ path: ['maxChainNodes'],
158
+ message: 'maxChainNodes requires selector',
159
+ });
154
160
  }
155
161
  export const eventCompareRequestSchema = z
156
162
  .object({
@@ -158,6 +164,8 @@ export const eventCompareRequestSchema = z
158
164
  before: eventGraphReferenceSchema.optional(),
159
165
  after: eventGraphReferenceSchema.optional(),
160
166
  proposedSources: z.array(eventProposedSourceSchema).min(1).max(64).optional(),
167
+ selector: eventSelectorSchema.optional(),
168
+ maxChainNodes: z.number().int().min(1).max(5_000).optional(),
161
169
  render: z.boolean().optional(),
162
170
  maxRenderNodes: z.number().int().min(1).max(240).optional(),
163
171
  refresh: z.boolean().optional(),
@@ -17,6 +17,10 @@ export const focusInspectRequestSchema = z
17
17
  previous: compactFocusLayoutSchema.optional(),
18
18
  laneSpacing: z.number().int().min(1).max(100).optional(),
19
19
  nodeSpacing: z.number().int().min(1).max(100).optional(),
20
+ scenario: z
21
+ .object({ completedFocusIds: z.array(z.string().min(1).max(256)).max(10_000) })
22
+ .strict()
23
+ .optional(),
20
24
  })
21
25
  .strict()
22
26
  .superRefine((value, context) => {
@@ -34,7 +38,7 @@ export const focusInspectRequestSchema = z
34
38
  message: 'treeId is only valid in national mode',
35
39
  });
36
40
  if (mode === 'continuous') {
37
- for (const field of ['previous', 'laneSpacing', 'nodeSpacing']) {
41
+ for (const field of ['previous', 'laneSpacing', 'nodeSpacing', 'scenario']) {
38
42
  if (value[field] !== undefined)
39
43
  context.addIssue({
40
44
  code: 'custom',
@@ -49,6 +53,12 @@ export const focusInspectRequestSchema = z
49
53
  path: ['previous'],
50
54
  message: 'previous requires treeId so it applies to exactly one tree',
51
55
  });
56
+ if (value.scenario !== undefined && value.treeId === undefined)
57
+ context.addIssue({
58
+ code: 'custom',
59
+ path: ['scenario'],
60
+ message: 'scenario requires treeId so it applies to exactly one tree',
61
+ });
52
62
  });
53
63
  export const focusRenderRequestSchema = z
54
64
  .object({
@@ -64,6 +74,7 @@ export const focusRenderRequestSchema = z
64
74
  .min(FOCUS_RENDER_MIN_OUTPUT_SCALE)
65
75
  .max(FOCUS_RENDER_MAX_OUTPUT_SCALE)
66
76
  .optional(),
77
+ cropFocusIds: z.array(z.string().min(1).max(256)).max(16).optional(),
67
78
  columns: z.number().int().min(1).max(12).optional(),
68
79
  padding: z.number().int().min(0).max(1000).optional(),
69
80
  })
@@ -80,6 +91,7 @@ export const focusRenderRequestSchema = z
80
91
  ['horizontalSpacing', value.horizontalSpacing],
81
92
  ['verticalSpacing', value.verticalSpacing],
82
93
  ['reviewScale', value.reviewScale],
94
+ ['cropFocusIds', value.cropFocusIds],
83
95
  ];
84
96
  for (const [field, fieldValue] of invalid) {
85
97
  if (fieldValue !== undefined)
@@ -103,6 +115,25 @@ export const focusRewriteRequestSchema = z
103
115
  relativePath: workspaceRelativePathSchema,
104
116
  treeId: z.string().min(1).max(256).optional(),
105
117
  layoutMode: z.enum(['authored', 'compact']).default('authored'),
118
+ compactFocusIds: z.array(z.string().min(1).max(256)).min(1).max(10_000).optional(),
119
+ pinnedFocusIds: z.array(z.string().min(1).max(256)).max(10_000).optional(),
120
+ symmetryGroups: z
121
+ .array(z
122
+ .object({
123
+ centerFocusId: z.string().min(1).max(256),
124
+ pairs: z
125
+ .array(z
126
+ .object({
127
+ leftFocusId: z.string().min(1).max(256),
128
+ rightFocusId: z.string().min(1).max(256),
129
+ })
130
+ .strict())
131
+ .min(1)
132
+ .max(10_000),
133
+ })
134
+ .strict())
135
+ .max(64)
136
+ .optional(),
106
137
  plan: compactFocusPlanSchema.optional(),
107
138
  createIfMissing: z.boolean().default(false),
108
139
  horizontalSpacing: z.number().int().min(80).max(1000).optional(),
@@ -117,6 +148,14 @@ export const focusRewriteRequestSchema = z
117
148
  })
118
149
  .strict()
119
150
  .superRefine((value, context) => {
151
+ if (value.layoutMode !== 'compact')
152
+ for (const field of ['compactFocusIds', 'pinnedFocusIds', 'symmetryGroups'])
153
+ if (value[field] !== undefined)
154
+ context.addIssue({
155
+ code: 'custom',
156
+ path: [field],
157
+ message: `${field} requires compact layout mode`,
158
+ });
120
159
  const mode = value.mode ?? 'national';
121
160
  const schema = mode === 'continuous' ? continuousFocusPaletteSchema : focusTreePlanSchema;
122
161
  if (value.plan !== undefined && !schema.safeParse(value.plan).success)
@@ -82,6 +82,14 @@ export const guiRenderRequestSchema = z
82
82
  relatedScenarios: z.array(compactGuiScenarioSchema).max(32).optional(),
83
83
  generatedScenarios: compactGeneratedScenarioOptionsSchema.optional(),
84
84
  comparisonScenario: compactGuiScenarioSchema.optional(),
85
+ sourceBaseline: z
86
+ .object({
87
+ relativePath: workspaceRelativePathSchema,
88
+ source: z.string().max(SOURCE_MAX_BYTES),
89
+ })
90
+ .strict()
91
+ .optional()
92
+ .describe('Previous mod-owned .gui source for a matched source comparison using the requested scenario.'),
85
93
  })
86
94
  .strict()
87
95
  .superRefine(({ scenario, states, resolutions, relatedScenarios, generatedScenarios }, context) => {
@@ -63,6 +63,7 @@ export const mapInspectRequestSchema = z
63
63
  ]))
64
64
  .max(100)
65
65
  .default([]),
66
+ lookupOnly: z.boolean().default(false),
66
67
  includeOverview: z.boolean().default(true),
67
68
  allocationRequests: z.array(mapAllocationRequestSchema).max(100).default([]),
68
69
  })
@@ -73,8 +74,28 @@ export const mapRenderRequestSchema = z
73
74
  layer: mapLayerSchema.optional(),
74
75
  overlays: z.array(mapOverlaySchema).max(12).optional(),
75
76
  scale: z.number().int().min(1).max(16).optional(),
77
+ tile: z
78
+ .object({
79
+ x: z.number().int().min(0),
80
+ y: z.number().int().min(0),
81
+ width: z.number().int().min(1).max(2_048),
82
+ height: z.number().int().min(1).max(2_048),
83
+ })
84
+ .strict()
85
+ .optional(),
86
+ area: z
87
+ .object({
88
+ provinceIds: z.array(z.number().int().nonnegative()).max(256).optional(),
89
+ stateIds: z.array(z.number().int().nonnegative()).max(64).optional(),
90
+ regionIds: z.array(z.number().int().nonnegative()).max(64).optional(),
91
+ padding: z.number().int().min(0).max(256).optional(),
92
+ })
93
+ .strict()
94
+ .refine(({ provinceIds, stateIds, regionIds }) => (provinceIds?.length ?? 0) + (stateIds?.length ?? 0) + (regionIds?.length ?? 0) > 0, 'Map area requires at least one province, state, or region ID')
95
+ .optional(),
76
96
  })
77
- .strict();
97
+ .strict()
98
+ .refine(({ tile, area }) => tile === undefined || area === undefined, 'Choose tile or area, not both');
78
99
  export const mapRewriteRequestSchema = z
79
100
  .object({
80
101
  workspaceId: workspaceIdSchema,