hoi4-agent-tools 3.3.0 → 3.4.1

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 (89) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/hoi4_agent_tools/core/condition-evaluator.js +20 -8
  3. package/dist/hoi4_agent_tools/core/helper-expansion.js +3 -0
  4. package/dist/hoi4_agent_tools/core/index.js +1 -0
  5. package/dist/hoi4_agent_tools/core/job-worker-host.js +19 -1
  6. package/dist/hoi4_agent_tools/core/operation-result.js +4 -1
  7. package/dist/hoi4_agent_tools/event/comparison-selection.js +78 -0
  8. package/dist/hoi4_agent_tools/event/helper-expansion.js +48 -0
  9. package/dist/hoi4_agent_tools/event/operations.js +32 -10
  10. package/dist/hoi4_agent_tools/event/service.js +21 -2
  11. package/dist/hoi4_agent_tools/focus/compact.js +241 -27
  12. package/dist/hoi4_agent_tools/focus/layout.js +119 -8
  13. package/dist/hoi4_agent_tools/focus/render.js +62 -0
  14. package/dist/hoi4_agent_tools/focus/scenario.js +99 -0
  15. package/dist/hoi4_agent_tools/focus/tool-operations.js +30 -4
  16. package/dist/hoi4_agent_tools/gui/assets.js +54 -4
  17. package/dist/hoi4_agent_tools/gui/layout.js +935 -104
  18. package/dist/hoi4_agent_tools/gui/renderer.js +47 -17
  19. package/dist/hoi4_agent_tools/gui/shader.js +515 -0
  20. package/dist/hoi4_agent_tools/gui/source-graph.js +166 -6
  21. package/dist/hoi4_agent_tools/gui/studio.js +245 -19
  22. package/dist/hoi4_agent_tools/gui/tool-operations.js +9 -0
  23. package/dist/hoi4_agent_tools/gui/validators.js +41 -12
  24. package/dist/hoi4_agent_tools/map/operations.js +70 -1
  25. package/dist/hoi4_agent_tools/map/render.js +70 -1
  26. package/dist/hoi4_agent_tools/map/service.js +30 -6
  27. package/dist/hoi4_agent_tools/map/tool-operations.js +67 -1
  28. package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -1
  29. package/dist/hoi4_agent_tools/mcp/tools/focus.js +14 -4
  30. package/dist/hoi4_agent_tools/mcp/tools/gui.js +5 -1
  31. package/dist/hoi4_agent_tools/mcp/tools/map.js +12 -2
  32. package/dist/hoi4_agent_tools/mcp/tools/probability.js +5 -0
  33. package/dist/hoi4_agent_tools/mechanic/interpreter.js +91 -30
  34. package/dist/hoi4_agent_tools/mechanic/source.js +19 -4
  35. package/dist/hoi4_agent_tools/probability/evaluation.js +3 -0
  36. package/dist/hoi4_agent_tools/probability/operations.js +8 -0
  37. package/dist/hoi4_agent_tools/probability/service.js +33 -12
  38. package/dist/hoi4_agent_tools/probability/source-analysis.js +1 -0
  39. package/dist/hoi4_agent_tools/schemas/event.js +8 -0
  40. package/dist/hoi4_agent_tools/schemas/focus-requests.js +40 -1
  41. package/dist/hoi4_agent_tools/schemas/gui-requests.js +8 -0
  42. package/dist/hoi4_agent_tools/schemas/map-requests.js +22 -1
  43. package/dist/hoi4_agent_tools/schemas/technology.js +7 -0
  44. package/dist/hoi4_agent_tools/technology/graph.js +25 -10
  45. package/dist/hoi4_agent_tools/technology/operations.js +29 -1
  46. package/dist/hoi4_agent_tools/technology/render.js +287 -31
  47. package/dist/hoi4_agent_tools/technology/service.js +56 -8
  48. package/dist/hoi4_agent_tools/technology/source-analysis.js +166 -3
  49. package/dist/hoi4_agent_tools/version.js +1 -1
  50. package/docs/events.md +4 -2
  51. package/docs/focus.md +10 -0
  52. package/docs/gui.md +50 -3
  53. package/docs/jobs.md +1 -0
  54. package/docs/map.md +8 -0
  55. package/docs/mechanics.md +2 -1
  56. package/docs/probability.md +3 -1
  57. package/docs/technology.md +11 -2
  58. package/package.json +1 -1
  59. package/schemas/configuration.schema.json +1 -1
  60. package/schemas/continuous-focus-palette.schema.json +1 -1
  61. package/schemas/custom-weighted-pool.schema.json +1 -1
  62. package/schemas/decision-inspect-input.schema.json +1 -1
  63. package/schemas/event-feature-manifest.schema.json +1 -1
  64. package/schemas/focus-plan.schema.json +1 -1
  65. package/schemas/focus-planning-sidecar.schema.json +1 -1
  66. package/schemas/gui-animation-source.schema.json +1 -1
  67. package/schemas/gui-helper.schema.json +1 -1
  68. package/schemas/gui-scenario.schema.json +1 -1
  69. package/schemas/helper-expansion-request.schema.json +1 -1
  70. package/schemas/helper-expansion-summary.schema.json +1 -1
  71. package/schemas/impact-inspect-input.schema.json +1 -1
  72. package/schemas/map-operation.schema.json +1 -1
  73. package/schemas/mechanic-test-input.schema.json +1 -1
  74. package/schemas/operation-result.schema.json +1 -1
  75. package/schemas/package-check-input.schema.json +1 -1
  76. package/schemas/package-manifest.schema.json +1 -1
  77. package/schemas/probability-analysis-result.schema.json +1 -1
  78. package/schemas/probability-compare-input.schema.json +1 -1
  79. package/schemas/probability-evaluate-input.schema.json +1 -1
  80. package/schemas/probability-inspect-input.schema.json +1 -1
  81. package/schemas/probability-render-input.schema.json +1 -1
  82. package/schemas/probability-scenario-set.schema.json +1 -1
  83. package/schemas/probability-sequence-input.schema.json +1 -1
  84. package/schemas/probability-simulate-input.schema.json +1 -1
  85. package/schemas/probability-sweep-input.schema.json +1 -1
  86. package/schemas/scenario-suite.schema.json +1 -1
  87. package/schemas/scenario-test-input.schema.json +1 -1
  88. package/schemas/shared-scenario.schema.json +1 -1
  89. package/server.json +2 -2
@@ -21,6 +21,9 @@ export function probabilityAnalysisData(result) {
21
21
  cacheKey: result.metadata.cacheKey,
22
22
  scenarios: result.scenarios.length,
23
23
  candidates: result.scenarios.reduce((sum, scenario) => sum + scenario.candidates.length, 0),
24
+ eligibleCandidates: result.scenarios.reduce((sum, scenario) => sum + scenario.candidates.filter(({ eligibility }) => eligibility === 'true').length, 0),
25
+ excludedCandidates: result.scenarios.reduce((sum, scenario) => sum + scenario.candidates.filter(({ eligibility }) => eligibility === 'false').length, 0),
26
+ unresolvedCandidates: result.scenarios.reduce((sum, scenario) => sum + scenario.candidates.filter(({ eligibility }) => eligibility === 'unresolved').length, 0),
24
27
  ...(result.scenarios.some(({ scopePools }) => (scopePools?.length ?? 0) > 0)
25
28
  ? {
26
29
  scopePools: result.scenarios.reduce((sum, scenario) => sum + (scenario.scopePools?.length ?? 0), 0),
@@ -87,6 +90,8 @@ export function normalizeProbabilityRenderRequest(input) {
87
90
  }
88
91
  export async function inspectProbabilities(analyzer, input, context) {
89
92
  const inspected = await analyzer.inspect(runtime(context, input.refresh), input.adapter, input.source, input.candidatePool, input.customPoolManifest);
93
+ const sourceRequiredInputPaths = (inspected.surface?.requiredInputs ?? []).filter((path) => path !== 'focus.external_factors_complete' && path !== 'technology.external_factors_complete');
94
+ const adapterRequiredInputPaths = (inspected.surface?.requiredInputs ?? []).filter((path) => path === 'focus.external_factors_complete' || path === 'technology.external_factors_complete');
90
95
  const result = emptyServiceResult(context.workspaceId, {
91
96
  adapters: inspected.adapters.length,
92
97
  ...(inspected.surface === undefined
@@ -120,6 +125,9 @@ export async function inspectProbabilities(analyzer, input, context) {
120
125
  })) ?? [],
121
126
  candidateExamples: inspected.discovery?.exampleCandidateIds ?? [],
122
127
  requiredInputs: inspected.surface?.requiredInputs.length ?? 0,
128
+ requiredInputPaths: sourceRequiredInputPaths.slice(0, 32),
129
+ requiredInputPathsTruncated: sourceRequiredInputPaths.length > 32,
130
+ adapterRequiredInputPaths,
123
131
  unresolved: inspected.surface?.unsupported.length ?? 0,
124
132
  });
125
133
  result.code =
@@ -9,7 +9,7 @@ import { PACKAGE_VERSION } from '../version.js';
9
9
  import { probabilityAnalysisResultSchema } from '../schemas/probability.js';
10
10
  import { probabilityAdapter, probabilityAdapters } from './adapters.js';
11
11
  import { compareProbabilityResults } from './compare.js';
12
- import { evaluateExactCandidates, evaluateSurfaceScenarios } from './evaluation.js';
12
+ import { evaluateExactCandidates, evaluateSurfaceScenarios, } from './evaluation.js';
13
13
  import { VERIFIED_GAME_CHECKSUM, VERIFIED_GAME_RAW_VERSION, VERIFIED_GAME_VERSION, } from './model.js';
14
14
  import { renderProbabilityResult } from './render.js';
15
15
  import { analyzeSequence, evaluateCustomPoolPoint } from './sequence.js';
@@ -17,6 +17,28 @@ import { simulateSurface } from './simulation.js';
17
17
  import { Rational } from './rational.js';
18
18
  import { discoverWeightedSurface, inventoryWeightedSurfaces, } from './source-analysis.js';
19
19
  import { sweepSurface } from './sweep.js';
20
+ function missingScenarioInputs(unresolved) {
21
+ return [
22
+ ...new Set(unresolved.flatMap(({ code, path, details }) => {
23
+ if (typeof details?.scenarioInput === 'string')
24
+ return [details.scenarioInput];
25
+ if (code === 'VALUE_UNRESOLVED' && path !== undefined && !/^(?:@|constant:)/u.test(path))
26
+ return [path];
27
+ if (path !== undefined &&
28
+ (code === 'FOCUS_EXTERNAL_FACTORS_UNDECLARED' ||
29
+ code === 'TECHNOLOGY_EXTERNAL_FACTORS_UNDECLARED'))
30
+ return [path];
31
+ return [];
32
+ })),
33
+ ].sort(compareCodeUnits);
34
+ }
35
+ function blockingInspectionUnresolved(candidate) {
36
+ if (candidate.eligibility === 'false')
37
+ return [];
38
+ if (candidate.eligibility === 'unresolved')
39
+ return [...candidate.eligibilityUnresolved, ...candidate.externalUnresolved];
40
+ return [...candidate.valueUnresolved, ...candidate.externalUnresolved];
41
+ }
20
42
  const states = new WeakMap();
21
43
  const ANALYSIS_CACHE_ENTRIES = 16;
22
44
  function resolveSourceBackedAdapter(snapshot, source, candidatePool, requested) {
@@ -1063,9 +1085,7 @@ export class ProbabilityAnalyzer {
1063
1085
  }
1064
1086
  const definitions = ClausewitzEvaluationDefinitions.build(snapshot);
1065
1087
  const inspectedCandidates = evaluateExactCandidates(surface, { id: 'inspection', state: {} }, definitions);
1066
- const requiredInputs = [
1067
- ...new Set(inspectedCandidates.flatMap(({ unresolved }) => unresolved.flatMap(({ path }) => (path === undefined ? [] : [path])))),
1068
- ].sort();
1088
+ const requiredInputs = missingScenarioInputs(inspectedCandidates.flatMap(blockingInspectionUnresolved));
1069
1089
  const report = {
1070
1090
  schemaVersion: 'probability-inspection.v2',
1071
1091
  workspaceId: context.workspaceId,
@@ -1079,9 +1099,8 @@ export class ProbabilityAnalyzer {
1079
1099
  candidateCount: surface.candidates.length,
1080
1100
  candidates: surface.candidates.map((candidate, index) => {
1081
1101
  const evaluated = inspectedCandidates[index];
1082
- const candidateRequiredInputs = [
1083
- ...new Set(evaluated.unresolved.flatMap(({ path }) => (path === undefined ? [] : [path]))),
1084
- ].sort();
1102
+ const blockingUnresolved = blockingInspectionUnresolved(evaluated);
1103
+ const candidateRequiredInputs = missingScenarioInputs(blockingUnresolved);
1085
1104
  return {
1086
1105
  id: candidate.id,
1087
1106
  sourceKind: candidate.sourceKind,
@@ -1092,11 +1111,13 @@ export class ProbabilityAnalyzer {
1092
1111
  referencedProvenance: evaluated.provenance.filter(({ path, symbol }) => !candidate.provenance.some((source) => source.path === path && source.symbol === symbol)),
1093
1112
  analysisSupport: candidateRequiredInputs.length > 0
1094
1113
  ? 'requires_scenario'
1095
- : surface.adapter.selectionRule === 'score_only'
1096
- ? 'score_only'
1097
- : surface.adapter.completePoolRequired && !surface.poolComplete
1098
- ? 'requires_complete_pool'
1099
- : 'exact',
1114
+ : blockingUnresolved.length > 0
1115
+ ? 'unresolved_source'
1116
+ : surface.adapter.selectionRule === 'score_only'
1117
+ ? 'score_only'
1118
+ : surface.adapter.completePoolRequired && !surface.poolComplete
1119
+ ? 'requires_complete_pool'
1120
+ : 'exact',
1100
1121
  };
1101
1122
  }),
1102
1123
  requiredInputs,
@@ -421,6 +421,7 @@ export function inventoryWeightedSurfaces(snapshot, source, candidatePool = [],
421
421
  ? candidates.length
422
422
  : candidates.filter(({ id }) => wanted.has(id)).length,
423
423
  exampleCandidateIds: [...new Set(candidates.map(({ id }) => id))].slice(0, 10),
424
+ candidateCatalog: candidates.map(({ id, provenance }) => ({ id, provenance })),
424
425
  sourcePaths: [
425
426
  ...new Set(candidates.flatMap(({ provenance }) => provenance.map(({ path }) => path))),
426
427
  ].sort(compareCodeUnits),
@@ -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,
@@ -132,6 +132,13 @@ export const technologyRenderRequestSchema = z
132
132
  categoryId: technologyIdSchema.optional(),
133
133
  targetId: technologyIdSchema.optional(),
134
134
  maxNodes: z.number().int().min(1).max(2_000).optional(),
135
+ scenario: z
136
+ .object({
137
+ year: z.number().int().min(1).max(9999),
138
+ researchedTechnologyIds: z.array(technologyIdSchema).max(25_000),
139
+ })
140
+ .strict()
141
+ .optional(),
135
142
  includeHtml: z.boolean().optional(),
136
143
  refresh: z.boolean().optional(),
137
144
  })
@@ -11,6 +11,7 @@ const MAX_EXTERNAL_REFERENCES = 1_000_000;
11
11
  const MAX_ISSUES = 100_000;
12
12
  const MAX_HELPER_DEPTH = 64;
13
13
  const MAX_HELPER_PROJECTIONS = 500_000;
14
+ const MAX_FULL_HELPER_CALLS = 4_096;
14
15
  const confidenceRank = {
15
16
  confirmed: 0,
16
17
  high: 1,
@@ -188,6 +189,20 @@ function activeYearMarkers(values) {
188
189
  left.year - right.year ||
189
190
  compareCodeUnits(left.name, right.name));
190
191
  }
192
+ function activeBackgrounds(values) {
193
+ const groups = new Map();
194
+ for (const background of values) {
195
+ const key = `${background.folderId}\0${background.name}`;
196
+ const group = groups.get(key) ?? [];
197
+ group.push(background);
198
+ groups.set(key, group);
199
+ }
200
+ return [...groups.values()]
201
+ .map((group) => group.sort((left, right) => right.loadOrder - left.loadOrder ||
202
+ compareCodeUnits(left.sourcePath, right.sourcePath) ||
203
+ left.location.start.offset - right.location.start.offset)[0])
204
+ .sort((left, right) => compareCodeUnits(left.folderId, right.folderId) || compareCodeUnits(left.name, right.name));
205
+ }
191
206
  function enrichPlacements(placements, edges, gridboxes, technologies, unlocks, itemLayouts, signal) {
192
207
  const technologyById = new Map(technologies.map((technology) => [technology.id, technology]));
193
208
  const unlockKindsByTechnology = new Map();
@@ -238,11 +253,8 @@ function enrichPlacements(placements, edges, gridboxes, technologies, unlocks, i
238
253
  return placements.map((placement) => {
239
254
  const roots = rootsFor(placement.technologyId, placement.folderId);
240
255
  const branchRootId = roots.length === 1 ? roots[0] : undefined;
241
- const expectedGridbox = branchRootId === undefined ? undefined : `${branchRootId}_tree`;
242
- const candidates = expectedGridbox === undefined
243
- ? []
244
- : gridboxes.filter(({ name, folderId }) => name === expectedGridbox &&
245
- (folderId === undefined || folderId === placement.folderId));
256
+ const expectedGridbox = `${branchRootId ?? placement.technologyId}_tree`;
257
+ const candidates = gridboxes.filter(({ name, folderId }) => name === expectedGridbox && (folderId === undefined || folderId === placement.folderId));
246
258
  const gridbox = candidates[0];
247
259
  const technology = technologyById.get(placement.technologyId);
248
260
  const folderLayouts = layoutsByFolder.get(placement.folderId);
@@ -1155,6 +1167,7 @@ function* technologyGraphAnalysis(snapshot, options) {
1155
1167
  const gridboxes = activeGridboxes(fragments.flatMap(({ gridboxes }) => gridboxes));
1156
1168
  const itemLayouts = activeItemLayouts(fragments.flatMap(({ itemLayouts }) => itemLayouts));
1157
1169
  const yearMarkers = activeYearMarkers(fragments.flatMap(({ yearMarkers }) => yearMarkers));
1170
+ const backgrounds = activeBackgrounds(fragments.flatMap(({ backgrounds }) => backgrounds ?? []));
1158
1171
  const categories = activeCategories(fragments.flatMap(({ categories }) => categories));
1159
1172
  const selectedDoctrines = activeDefinitions(fragments.flatMap(({ doctrineDefinitions }) => doctrineDefinitions));
1160
1173
  const doctrineDefinitions = localiseDoctrines(selectedDoctrines.active, snapshot.index, assets);
@@ -1168,10 +1181,11 @@ function* technologyGraphAnalysis(snapshot, options) {
1168
1181
  const placements = enrichPlacements(basePlacements, edges, gridboxes, technologies, unlocks, itemLayouts, options.signal);
1169
1182
  const unresolved = fragments.flatMap(({ unresolved }) => unresolved);
1170
1183
  const helperCalls = fragments.flatMap(({ helperCalls }) => helperCalls);
1171
- const walk = options.analysisMode === 'focused'
1184
+ const analysisMode = options.analysisMode ?? (helperCalls.length > MAX_FULL_HELPER_CALLS ? 'focused' : 'full');
1185
+ const walk = analysisMode === 'focused'
1172
1186
  ? undefined
1173
1187
  : yield technologyHelperWalk(helperCalls, snapshot.revision);
1174
- const externalReferences = expandHelperReferences(fragments.flatMap(({ externalReferences }) => externalReferences), helperCalls, unresolved, options.analysisMode ?? 'full', walk, options.signal);
1188
+ const externalReferences = expandHelperReferences(fragments.flatMap(({ externalReferences }) => externalReferences), helperCalls, unresolved, analysisMode, walk, options.signal);
1175
1189
  const issues = diagnose(technologies, folders, placements, gridboxes, edges, categories, doctrineDefinitions, unlocks, externalReferences, selectedTechnologies.duplicates.map((group) => group.map(({ iconSprite, effectSignatureFields, ...technology }) => ({
1176
1190
  ...technology,
1177
1191
  icon: iconStatus(iconSprite, snapshot.index, assets),
@@ -1197,14 +1211,14 @@ function* technologyGraphAnalysis(snapshot, options) {
1197
1211
  const graph = {
1198
1212
  schemaVersion: TECHNOLOGY_GRAPH_SCHEMA_VERSION,
1199
1213
  parserVersion: TECHNOLOGY_PARSER_VERSION,
1200
- analysisMode: options.analysisMode ?? 'full',
1214
+ analysisMode,
1201
1215
  workspaceId: snapshot.workspaceId,
1202
1216
  workspaceIdentity: options.workspaceIdentity,
1203
1217
  revision,
1204
1218
  complete: snapshot.complete &&
1205
1219
  unresolved.every(({ kind }) => kind !== 'partial_source') &&
1206
1220
  !unresolved.some(({ blockers }) => blockers.some(({ code }) => code === 'TECH_HELPER_DEPTH_BLOCKED' || code === 'TECH_HELPER_PROJECTION_LIMIT')) &&
1207
- !(options.analysisMode === 'focused' && helperCalls.length > 0),
1221
+ !(analysisMode === 'focused' && helperCalls.length > 0),
1208
1222
  analysisBoundary: {
1209
1223
  staticAnalysis: true,
1210
1224
  language: 'l_english',
@@ -1215,7 +1229,7 @@ function* technologyGraphAnalysis(snapshot, options) {
1215
1229
  'meta-generated or variable technology identifiers',
1216
1230
  'exact AI research choice and runtime research time',
1217
1231
  'dynamic scripted localisation without supplied runtime values',
1218
- ...(options.analysisMode === 'focused' && helperCalls.length > 0
1232
+ ...(analysisMode === 'focused' && helperCalls.length > 0
1219
1233
  ? ['large-workspace scripted-effect helper projections are deferred']
1220
1234
  : []),
1221
1235
  ],
@@ -1235,6 +1249,7 @@ function* technologyGraphAnalysis(snapshot, options) {
1235
1249
  gridboxes,
1236
1250
  itemLayouts,
1237
1251
  yearMarkers,
1252
+ backgrounds,
1238
1253
  edges,
1239
1254
  categories,
1240
1255
  doctrineDefinitions,
@@ -58,6 +58,34 @@ export function technologyValidation(graph) {
58
58
  ],
59
59
  };
60
60
  }
61
+ export function technologyRenderValidation(graph, render) {
62
+ const omitted = render.omittedNodeCount;
63
+ const unresolvedSprites = render.unresolvedIconSprites.length;
64
+ const sourcePlacementPassed = render.view !== 'folder' || render.sourceAccurate;
65
+ const analysisBoundaryPassed = graph.complete || (graph.analysisMode === 'focused' && graph.skippedSourceCount === 0);
66
+ const renderPassed = sourcePlacementPassed && omitted === 0 && unresolvedSprites === 0;
67
+ return {
68
+ passed: renderPassed && analysisBoundaryPassed,
69
+ checks: [
70
+ {
71
+ id: 'technology-render',
72
+ passed: renderPassed,
73
+ message: renderPassed
74
+ ? 'The requested technology view rendered with complete node and sprite coverage'
75
+ : `${sourcePlacementPassed ? 0 : 1} source-placement failure(s), ${omitted} omitted node(s), and ${unresolvedSprites} unresolved sprite(s) affect the requested technology view`,
76
+ },
77
+ {
78
+ id: 'technology-analysis-boundary',
79
+ passed: analysisBoundaryPassed,
80
+ message: graph.complete
81
+ ? 'The supporting technology analysis is complete'
82
+ : analysisBoundaryPassed
83
+ ? 'Whole-workspace helper projections were deferred; the requested render retains direct source evidence'
84
+ : `${graph.skippedSourceCount} technology source(s) were skipped; linked evidence records the partial analysis boundary`,
85
+ },
86
+ ],
87
+ };
88
+ }
61
89
  export async function inspectTechnologies(viewer, input) {
62
90
  const output = await viewer.analyze(input);
63
91
  const result = emptyServiceResult(input.workspaceId, {
@@ -93,7 +121,7 @@ export async function renderTechnologies(viewer, input) {
93
121
  result.code = output.graph.complete ? 'TECH_RENDERED' : 'TECH_RENDERED_PARTIAL';
94
122
  setInlineFilesScanned(result, output.graph.filesScanned);
95
123
  result.artifacts = output.artifacts.map(publicArtifactLink);
96
- result.validation = technologyValidation(output.graph);
124
+ result.validation = technologyRenderValidation(output.graph, output.render);
97
125
  return result;
98
126
  }
99
127
  export async function compareTechnologies(viewer, input) {