hoi4-agent-tools 1.0.0 → 1.2.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 (45) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +27 -16
  3. package/dist/hoi4_agent_tools/core/engine.js +12 -0
  4. package/dist/hoi4_agent_tools/event/algorithms.js +208 -0
  5. package/dist/hoi4_agent_tools/event/artifact-validation.js +572 -0
  6. package/dist/hoi4_agent_tools/event/compare.js +413 -0
  7. package/dist/hoi4_agent_tools/event/graph.js +1586 -0
  8. package/dist/hoi4_agent_tools/event/index.js +10 -0
  9. package/dist/hoi4_agent_tools/event/layout.js +218 -0
  10. package/dist/hoi4_agent_tools/event/limits.js +41 -0
  11. package/dist/hoi4_agent_tools/event/model.js +2 -0
  12. package/dist/hoi4_agent_tools/event/queries.js +643 -0
  13. package/dist/hoi4_agent_tools/event/render.js +443 -0
  14. package/dist/hoi4_agent_tools/event/scc.js +72 -0
  15. package/dist/hoi4_agent_tools/event/service.js +1147 -0
  16. package/dist/hoi4_agent_tools/event/source-analysis.js +1458 -0
  17. package/dist/hoi4_agent_tools/focus/compact.js +432 -0
  18. package/dist/hoi4_agent_tools/focus/compiler.js +1 -7
  19. package/dist/hoi4_agent_tools/focus/geometry.js +203 -16
  20. package/dist/hoi4_agent_tools/focus/index.js +1 -0
  21. package/dist/hoi4_agent_tools/focus/layout.js +1256 -92
  22. package/dist/hoi4_agent_tools/focus/limits.js +1 -1
  23. package/dist/hoi4_agent_tools/focus/lint.js +11 -3
  24. package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
  25. package/dist/hoi4_agent_tools/focus/planning.js +6 -1
  26. package/dist/hoi4_agent_tools/focus/render.js +14 -9
  27. package/dist/hoi4_agent_tools/mcp/resources/register.js +1 -1
  28. package/dist/hoi4_agent_tools/mcp/server/context-schemas.js +8 -0
  29. package/dist/hoi4_agent_tools/mcp/server/create.js +4 -1
  30. package/dist/hoi4_agent_tools/mcp/server/result.js +117 -52
  31. package/dist/hoi4_agent_tools/mcp/tools/event.js +459 -0
  32. package/dist/hoi4_agent_tools/mcp/tools/focus.js +93 -7
  33. package/dist/hoi4_agent_tools/mcp/tools/gui.js +12 -9
  34. package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -4
  35. package/dist/hoi4_agent_tools/mcp/tools/mods.js +1 -1
  36. package/dist/hoi4_agent_tools/schemas/event.js +95 -0
  37. package/dist/hoi4_agent_tools/schemas/focus.js +64 -8
  38. package/dist/hoi4_agent_tools/version.js +1 -1
  39. package/docs/README.md +1 -0
  40. package/docs/development.md +10 -0
  41. package/docs/events.md +113 -0
  42. package/docs/focus.md +17 -7
  43. package/docs/setup.md +3 -3
  44. package/package.json +9 -5
  45. package/server.json +3 -3
@@ -0,0 +1,432 @@
1
+ import { compareCodeUnits } from '../core/canonical.js';
2
+ import { ServiceError } from '../core/result.js';
3
+ import { focusPlanHash, } from './model.js';
4
+ import { FOCUS_LAYOUT_WORK_MAX } from './limits.js';
5
+ import { FocusLayoutWorkBudget, layoutFocusTree, layoutFocusTreeAsync } from './layout.js';
6
+ const COMPACT_SAME_ROW_SPACING = 2;
7
+ const COMPACT_LANE_SPACING = 8;
8
+ const SMALL_COMPACT_SCALES = [0.2, 0.3, 0.4, 0.55, 0.7, 0.9];
9
+ const LARGE_COMPACT_SCALES = [0.55, 0.6, 0.7, 0.8];
10
+ const COMPACT_LAYOUT_WORK_MAX = FOCUS_LAYOUT_WORK_MAX * 10;
11
+ const COMPACT_HARD_DIAGNOSTICS = new Set([
12
+ 'FOCUS_LAYOUT_CONNECTOR_CROSSING_UNSATISFIED',
13
+ 'FOCUS_LAYOUT_COORDINATE_CONFLICT',
14
+ 'FOCUS_LAYOUT_LANE_BOUNDS_UNSATISFIED',
15
+ 'FOCUS_LAYOUT_MUTUAL_EXCLUSION_SPACING_UNSATISFIED',
16
+ 'FOCUS_LAYOUT_PARENT_ORDER_UNSATISFIED',
17
+ 'FOCUS_LAYOUT_SAME_ROW_SPACING_UNSATISFIED',
18
+ 'FOCUS_LAYOUT_VISIBLE_OVERLAP',
19
+ ]);
20
+ function compactLimits(focusCount, connectorCount) {
21
+ const graphScale = Math.sqrt(Math.max(1, focusCount));
22
+ const maximumHorizontalSpan = Math.max(14, Math.ceil(graphScale * 3));
23
+ const maximumVerticalSpan = Math.max(4, Math.ceil(Math.log2(Math.max(2, focusCount))));
24
+ return {
25
+ maximumColumns: Math.max(16, Math.ceil(graphScale * 5)),
26
+ maximumHorizontalSpan,
27
+ maximumVerticalSpan,
28
+ maximumManhattanSpan: maximumHorizontalSpan + maximumVerticalSpan,
29
+ maximumLongConnectors: Math.ceil(connectorCount * 0.05),
30
+ maximumNodeIntersections: Math.ceil(connectorCount * 0.12),
31
+ maximumSiblingDeviation: Math.max(2, Math.ceil(graphScale * 2)),
32
+ maximumTotalSiblingDeviation: Math.max(4, Math.ceil(focusCount * 0.5)),
33
+ };
34
+ }
35
+ function requiredMetrics(layout) {
36
+ if (layout.metrics !== undefined)
37
+ return layout.metrics;
38
+ throw new ServiceError('FOCUS_COMPACT_METRICS_REQUIRED', 'Compact focus reflow requires current layout quality metrics', { treeId: layout.treeId });
39
+ }
40
+ function absoluteCompactRegressions(layout) {
41
+ const metrics = requiredMetrics(layout);
42
+ const limits = compactLimits(layout.nodes.length, metrics.connectors.count);
43
+ return [
44
+ ...(layout.diagnostics.some(({ code }) => COMPACT_HARD_DIAGNOSTICS.has(code))
45
+ ? ['hardLayoutDiagnostics']
46
+ : []),
47
+ ...(metrics.connectors.crossingCount > 0 ? ['connectorCrossingCount'] : []),
48
+ ...(metrics.connectors.nodeIntersectionCount > limits.maximumNodeIntersections
49
+ ? ['connectorNodeIntersectionBudget']
50
+ : []),
51
+ ...(metrics.spacing.tooCloseSameRowPairCount > 0 ? ['sameRowSpacing'] : []),
52
+ ...(metrics.symmetry.maximumSiblingDeviation > 1 ? ['siblingMirrorDeviation'] : []),
53
+ ...(metrics.symmetry.totalSiblingDeviation > metrics.symmetry.siblingCohortCount
54
+ ? ['totalSiblingMirrorDeviation']
55
+ : []),
56
+ ...(metrics.symmetry.maximumSiblingAnchorDeviation > limits.maximumSiblingDeviation
57
+ ? ['maximumSiblingDeviationBudget']
58
+ : []),
59
+ ...(metrics.symmetry.totalSiblingAnchorDeviation > limits.maximumTotalSiblingDeviation
60
+ ? ['totalSiblingDeviationBudget']
61
+ : []),
62
+ ...(metrics.symmetry.boundingCenterOffsetTwice > 1 ? ['boundingCenter'] : []),
63
+ ...(metrics.bounds.columnCount > limits.maximumColumns ? ['columnBudget'] : []),
64
+ ...(metrics.connectors.maximumHorizontalSpan > limits.maximumHorizontalSpan
65
+ ? ['maximumHorizontalConnectorBudget']
66
+ : []),
67
+ ...(metrics.connectors.maximumVerticalSpan > limits.maximumVerticalSpan
68
+ ? ['maximumVerticalConnectorBudget']
69
+ : []),
70
+ ...(metrics.connectors.maximumManhattanSpan > limits.maximumManhattanSpan
71
+ ? ['maximumManhattanConnectorBudget']
72
+ : []),
73
+ ...(metrics.connectors.longConnectorCount > limits.maximumLongConnectors
74
+ ? ['longConnectorBudget']
75
+ : []),
76
+ ];
77
+ }
78
+ function relativeCompactRegressions(current, proposed) {
79
+ const before = requiredMetrics(current);
80
+ const after = requiredMetrics(proposed);
81
+ return [
82
+ ...(after.connectors.crossingCount > before.connectors.crossingCount
83
+ ? ['connectorCrossingCount']
84
+ : []),
85
+ ...(after.connectors.nodeIntersectionCount > before.connectors.nodeIntersectionCount
86
+ ? ['connectorNodeIntersectionCount']
87
+ : []),
88
+ ...(after.connectors.maximumHorizontalSpan > before.connectors.maximumHorizontalSpan
89
+ ? ['maximumHorizontalConnectorSpan']
90
+ : []),
91
+ ...(after.connectors.maximumVerticalSpan > before.connectors.maximumVerticalSpan
92
+ ? ['maximumVerticalConnectorSpan']
93
+ : []),
94
+ ...(after.connectors.maximumManhattanSpan > before.connectors.maximumManhattanSpan
95
+ ? ['maximumManhattanConnectorSpan']
96
+ : []),
97
+ ...(after.connectors.longConnectorCount > before.connectors.longConnectorCount
98
+ ? ['longConnectorCount']
99
+ : []),
100
+ ...(after.connectors.totalHorizontalSpan > before.connectors.totalHorizontalSpan
101
+ ? ['totalHorizontalConnectorSpan']
102
+ : []),
103
+ ...(after.connectors.totalManhattanSpan > before.connectors.totalManhattanSpan
104
+ ? ['totalManhattanConnectorSpan']
105
+ : []),
106
+ ...(after.bounds.columnCount > before.bounds.columnCount ? ['columnCount'] : []),
107
+ ...(after.bounds.rowCount > before.bounds.rowCount ? ['rowCount'] : []),
108
+ ];
109
+ }
110
+ function compactShape(plan, layout) {
111
+ return (plan.focuses.every(({ position }) => position.mode === 'auto') &&
112
+ plan.laneGroups.every(({ minimumX, maximumX }) => minimumX === undefined && maximumX === undefined) &&
113
+ absoluteCompactRegressions(layout).length === 0);
114
+ }
115
+ function prerequisiteIds(plan, focusId) {
116
+ const focus = plan.focuses.find(({ id }) => id === focusId);
117
+ return (focus?.prerequisites.groups
118
+ .flatMap(({ focusIds }) => focusIds)
119
+ .sort((left, right) => compareCodeUnits(left, right)) ?? []);
120
+ }
121
+ function focusLaneId(plan, focus) {
122
+ if (focus.laneId !== undefined)
123
+ return focus.laneId;
124
+ if (focus.branchId !== undefined) {
125
+ const branch = plan.branchGroups.find(({ id }) => id === focus.branchId);
126
+ return branch?.laneId ?? focus.branchId;
127
+ }
128
+ return 'default';
129
+ }
130
+ function compactLaneBases(plan) {
131
+ const configured = [...plan.laneGroups].sort((left, right) => left.order - right.order || compareCodeUnits(left.id, right.id));
132
+ const configuredIds = new Set(configured.map(({ id }) => id));
133
+ const discovered = [...new Set(plan.focuses.map((focus) => focusLaneId(plan, focus)))]
134
+ .filter((id) => !configuredIds.has(id))
135
+ .sort((left, right) => compareCodeUnits(left, right));
136
+ const laneIds = [...configured.map(({ id }) => id), ...discovered];
137
+ return new Map(laneIds.map((id, index) => [
138
+ id,
139
+ Math.round((index - (laneIds.length - 1) / 2) * COMPACT_LANE_SPACING),
140
+ ]));
141
+ }
142
+ function compactStructuralAnchorX(plan, focus, parents, preferredX, laneBases) {
143
+ const parentCoordinates = parents
144
+ .flatMap((focusId) => {
145
+ const coordinate = preferredX.get(focusId);
146
+ return coordinate === undefined ? [] : [coordinate];
147
+ })
148
+ .sort((left, right) => left - right);
149
+ if (parentCoordinates.length > 1 && focus.convergence) {
150
+ return Math.floor(parentCoordinates.reduce((total, coordinate) => total + coordinate, 0) /
151
+ parentCoordinates.length);
152
+ }
153
+ if (parentCoordinates.length > 0) {
154
+ const middle = Math.floor(parentCoordinates.length / 2);
155
+ if (parentCoordinates.length % 2 === 1)
156
+ return parentCoordinates[middle] ?? 0;
157
+ return Math.floor(((parentCoordinates[middle - 1] ?? 0) + (parentCoordinates[middle] ?? 0)) / 2);
158
+ }
159
+ return laneBases.get(focusLaneId(plan, focus)) ?? 0;
160
+ }
161
+ function mirrorSiblingCohorts(plan, preferredX, preferredY) {
162
+ const cohorts = new Map();
163
+ const laneBases = compactLaneBases(plan);
164
+ const childCounts = new Map();
165
+ for (const focus of plan.focuses) {
166
+ const parents = prerequisiteIds(plan, focus.id);
167
+ for (const parentId of parents)
168
+ childCounts.set(parentId, (childCounts.get(parentId) ?? 0) + 1);
169
+ const key = JSON.stringify([
170
+ parents.length === 0 ? focusLaneId(plan, focus) : null,
171
+ parents,
172
+ preferredY.get(focus.id) ?? 0,
173
+ ]);
174
+ const cohort = cohorts.get(key) ?? [];
175
+ cohort.push(focus.id);
176
+ cohorts.set(key, cohort);
177
+ }
178
+ for (const cohort of cohorts.values()) {
179
+ if (cohort.length < 2)
180
+ continue;
181
+ cohort.sort((left, right) => (preferredX.get(left) ?? 0) - (preferredX.get(right) ?? 0) || compareCodeUnits(left, right));
182
+ const coordinates = cohort.map((id) => preferredX.get(id) ?? 0);
183
+ const pairCount = Math.floor(coordinates.length / 2);
184
+ const focus = plan.focuses.find(({ id }) => id === cohort[0]);
185
+ if (focus === undefined)
186
+ continue;
187
+ // A leaf fan can be mirrored directly around its structural parent. For
188
+ // rooted subtrees, moving only the entry focuses would push the long
189
+ // connectors into their descendants; keep the subtree envelope's center
190
+ // until the bounded gateway refinement can move the common parent.
191
+ const leafFan = cohort.every((focusId) => (childCounts.get(focusId) ?? 0) === 0);
192
+ const mirroredAt = (centerTwice) => {
193
+ const mirrored = [...coordinates];
194
+ for (let index = 0; index < pairCount; index += 1) {
195
+ const opposite = coordinates.length - 1 - index;
196
+ const left = Math.round(((coordinates[index] ?? 0) + centerTwice - (coordinates[opposite] ?? 0)) / 2);
197
+ mirrored[index] = left;
198
+ mirrored[opposite] = centerTwice - left;
199
+ }
200
+ if (coordinates.length % 2 === 1)
201
+ mirrored[pairCount] = Math.round(centerTwice / 2);
202
+ let maximumLeft = centerTwice / 2 - (coordinates.length % 2 === 1 ? 2 : 1);
203
+ for (let index = pairCount - 1; index >= 0; index -= 1) {
204
+ const opposite = coordinates.length - 1 - index;
205
+ const left = Math.min(mirrored[index] ?? maximumLeft, maximumLeft);
206
+ mirrored[index] = left;
207
+ mirrored[opposite] = centerTwice - left;
208
+ maximumLeft = left - COMPACT_SAME_ROW_SPACING;
209
+ }
210
+ return mirrored;
211
+ };
212
+ const selfCenterTwice = (coordinates[0] ?? 0) + (coordinates.at(-1) ?? 0);
213
+ const anchorCenterTwice = 2 *
214
+ compactStructuralAnchorX(plan, focus, prerequisiteIds(plan, focus.id), preferredX, laneBases);
215
+ let mirrored = mirroredAt(leafFan ? anchorCenterTwice : selfCenterTwice);
216
+ if (leafFan) {
217
+ const cohortIds = new Set(cohort);
218
+ const y = preferredY.get(cohort[0] ?? '');
219
+ const otherCoordinates = plan.focuses.flatMap((candidate) => !cohortIds.has(candidate.id) && preferredY.get(candidate.id) === y
220
+ ? [preferredX.get(candidate.id) ?? 0]
221
+ : []);
222
+ if (mirrored.some((coordinate) => otherCoordinates.some((otherCoordinate) => Math.abs(coordinate - otherCoordinate) < COMPACT_SAME_ROW_SPACING))) {
223
+ mirrored = mirroredAt(selfCenterTwice);
224
+ }
225
+ }
226
+ for (const [index, focusId] of cohort.entries())
227
+ preferredX.set(focusId, mirrored[index] ?? 0);
228
+ }
229
+ }
230
+ function compactCandidate(plan, layout, scale, compressRows) {
231
+ const minimumX = Math.min(...layout.nodes.map(({ x }) => x));
232
+ const maximumX = Math.max(...layout.nodes.map(({ x }) => x));
233
+ const minimumY = Math.min(...layout.nodes.map(({ y }) => y));
234
+ const centerX = (minimumX + maximumX) / 2;
235
+ const preferredX = new Map(layout.nodes.map((node) => [node.id, Math.round((node.x - centerX) * scale)]));
236
+ const sourceRows = [...new Set(layout.nodes.map(({ y }) => y))].sort((left, right) => left - right);
237
+ const compactRows = new Map(sourceRows.map((row, index) => [row, index]));
238
+ const preferredY = new Map(layout.nodes.map((node) => [
239
+ node.id,
240
+ compressRows ? (compactRows.get(node.y) ?? node.y - minimumY) : node.y - minimumY,
241
+ ]));
242
+ const rows = new Map();
243
+ for (const node of layout.nodes) {
244
+ const row = rows.get(node.y) ?? [];
245
+ row.push(node);
246
+ rows.set(node.y, row);
247
+ }
248
+ for (const row of rows.values()) {
249
+ row.sort((left, right) => left.x - right.x || compareCodeUnits(left.id, right.id));
250
+ const placed = [];
251
+ for (const [index, node] of row.entries()) {
252
+ const requested = preferredX.get(node.id) ?? 0;
253
+ placed.push(index === 0
254
+ ? requested
255
+ : Math.max(requested, (placed[index - 1] ?? 0) + COMPACT_SAME_ROW_SPACING));
256
+ }
257
+ const drift = Math.round(row.reduce((total, node, index) => total + (preferredX.get(node.id) ?? 0) - (placed[index] ?? 0), 0) / row.length);
258
+ for (const [index, node] of row.entries())
259
+ preferredX.set(node.id, (placed[index] ?? 0) + drift);
260
+ }
261
+ mirrorSiblingCohorts(plan, preferredX, preferredY);
262
+ const compacted = structuredClone(plan);
263
+ compacted.laneGroups = compacted.laneGroups.map(({ id, label, order }) => ({
264
+ id,
265
+ label,
266
+ order,
267
+ }));
268
+ for (const focus of compacted.focuses) {
269
+ focus.position = {
270
+ mode: 'auto',
271
+ pinned: false,
272
+ preferredX: preferredX.get(focus.id) ?? 0,
273
+ preferredY: preferredY.get(focus.id) ?? 0,
274
+ };
275
+ }
276
+ compacted.provenance.importedPlanHash = focusPlanHash(compacted);
277
+ return compacted;
278
+ }
279
+ function compactScore(layout) {
280
+ const metrics = requiredMetrics(layout);
281
+ return [
282
+ metrics.connectors.longConnectorCount,
283
+ metrics.connectors.maximumHorizontalSpan,
284
+ metrics.bounds.columnCount,
285
+ metrics.connectors.nodeIntersectionCount,
286
+ metrics.connectors.totalHorizontalSpan,
287
+ metrics.connectors.maximumManhattanSpan,
288
+ metrics.bounds.rowCount,
289
+ metrics.connectors.totalVerticalSpan,
290
+ layout.layoutHash,
291
+ ];
292
+ }
293
+ function betterScore(candidate, current) {
294
+ for (let index = 0; index < candidate.length; index += 1) {
295
+ const left = candidate[index];
296
+ const right = current[index];
297
+ if (left === right)
298
+ continue;
299
+ if (typeof left === 'number' && typeof right === 'number')
300
+ return left < right;
301
+ return String(left) < String(right);
302
+ }
303
+ return false;
304
+ }
305
+ function compactScales(focusCount) {
306
+ return focusCount <= 50
307
+ ? SMALL_COMPACT_SCALES
308
+ : focusCount <= 500
309
+ ? LARGE_COMPACT_SCALES
310
+ : focusCount <= 2_000
311
+ ? [0.55, 0.65, 0.75]
312
+ : [0.65];
313
+ }
314
+ /**
315
+ * Produces a gameplay-neutral compact-reflow plan. Several deterministic
316
+ * compression candidates are measured with the same layout engine; only a
317
+ * centered, crossing-free, evenly spaced, branch-balanced candidate that
318
+ * stays within absolute and source-relative connector budgets can win.
319
+ */
320
+ export function compactFocusTreePlan(plan) {
321
+ const currentLayout = layoutFocusTree(plan);
322
+ if (compactShape(plan, currentLayout)) {
323
+ const stable = structuredClone(plan);
324
+ stable.provenance.importedPlanHash = focusPlanHash(stable);
325
+ return stable;
326
+ }
327
+ const scales = compactScales(plan.focuses.length);
328
+ let selected;
329
+ let fallback;
330
+ for (const scale of scales) {
331
+ for (const compressRows of plan.focuses.length <= 500 ? [false, true] : [false]) {
332
+ const candidate = compactCandidate(plan, currentLayout, scale, compressRows);
333
+ let layout;
334
+ try {
335
+ layout = layoutFocusTree(candidate);
336
+ }
337
+ catch (error) {
338
+ if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED')
339
+ continue;
340
+ throw error;
341
+ }
342
+ if (fallback === undefined ||
343
+ betterScore(compactScore(layout), compactScore(fallback.layout)))
344
+ fallback = { plan: candidate, layout };
345
+ if (absoluteCompactRegressions(layout).length > 0 ||
346
+ relativeCompactRegressions(currentLayout, layout).length > 0)
347
+ continue;
348
+ if (selected === undefined ||
349
+ betterScore(compactScore(layout), compactScore(selected.layout)))
350
+ selected = { plan: candidate, layout };
351
+ }
352
+ }
353
+ if (selected !== undefined)
354
+ return selected.plan;
355
+ if (fallback !== undefined) {
356
+ assertCompactLayoutQuality(currentLayout, fallback.layout);
357
+ return fallback.plan;
358
+ }
359
+ throw new ServiceError('FOCUS_COMPACT_LAYOUT_REQUIRED', 'Compact focus planning did not produce a measurable layout candidate', { treeId: plan.id });
360
+ }
361
+ /**
362
+ * Cooperative compact planning for protocol handlers. Candidate layouts share
363
+ * one work budget, and the selected layout is returned for validation/rendering.
364
+ */
365
+ export async function compactFocusTreePlanAsync(plan, options = {}) {
366
+ const workBudget = new FocusLayoutWorkBudget(options.maximumWork ?? COMPACT_LAYOUT_WORK_MAX);
367
+ const layoutOptions = {
368
+ workBudget,
369
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
370
+ };
371
+ const currentLayout = await layoutFocusTreeAsync(plan, layoutOptions);
372
+ if (compactShape(plan, currentLayout)) {
373
+ const stable = structuredClone(plan);
374
+ stable.provenance.importedPlanHash = focusPlanHash(stable);
375
+ return { plan: stable, currentLayout, proposedLayout: currentLayout };
376
+ }
377
+ let selected;
378
+ let fallback;
379
+ let exhausted;
380
+ candidateSearch: for (const scale of compactScales(plan.focuses.length)) {
381
+ for (const compressRows of plan.focuses.length <= 500 ? [false, true] : [false]) {
382
+ options.signal?.throwIfAborted();
383
+ const candidate = compactCandidate(plan, currentLayout, scale, compressRows);
384
+ let layout;
385
+ try {
386
+ layout = await layoutFocusTreeAsync(candidate, layoutOptions);
387
+ }
388
+ catch (error) {
389
+ if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED') {
390
+ exhausted = error;
391
+ break candidateSearch;
392
+ }
393
+ throw error;
394
+ }
395
+ if (fallback === undefined ||
396
+ betterScore(compactScore(layout), compactScore(fallback.layout)))
397
+ fallback = { plan: candidate, layout };
398
+ if (absoluteCompactRegressions(layout).length > 0 ||
399
+ relativeCompactRegressions(currentLayout, layout).length > 0)
400
+ continue;
401
+ if (selected === undefined ||
402
+ betterScore(compactScore(layout), compactScore(selected.layout)))
403
+ selected = { plan: candidate, layout };
404
+ }
405
+ }
406
+ if (selected !== undefined) {
407
+ return { plan: selected.plan, currentLayout, proposedLayout: selected.layout };
408
+ }
409
+ if (exhausted !== undefined)
410
+ throw exhausted;
411
+ if (fallback !== undefined) {
412
+ assertCompactLayoutQuality(currentLayout, fallback.layout);
413
+ return { plan: fallback.plan, currentLayout, proposedLayout: fallback.layout };
414
+ }
415
+ throw new ServiceError('FOCUS_COMPACT_LAYOUT_REQUIRED', 'Compact focus planning did not produce a measurable layout candidate', { treeId: plan.id });
416
+ }
417
+ /** Refuses compact rewrites that are not clean or trade readability for a smaller canvas. */
418
+ export function assertCompactLayoutQuality(current, proposed) {
419
+ const regressions = [
420
+ ...absoluteCompactRegressions(proposed),
421
+ ...(current === undefined ? [] : relativeCompactRegressions(current, proposed)),
422
+ ].filter((value, index, all) => all.indexOf(value) === index);
423
+ if (regressions.length === 0)
424
+ return;
425
+ throw new ServiceError('FOCUS_COMPACT_QUALITY_BLOCKED', 'Compact focus reflow failed the absolute or relative layout-quality gate', {
426
+ treeId: proposed.treeId,
427
+ regressions,
428
+ limits: compactLimits(proposed.nodes.length, requiredMetrics(proposed).connectors.count),
429
+ ...(current === undefined ? {} : { before: requiredMetrics(current) }),
430
+ proposed: requiredMetrics(proposed),
431
+ });
432
+ }
@@ -90,13 +90,7 @@ function positionLines(focus, layout) {
90
90
  if (focus.position.mode === 'fixed') {
91
91
  return [`x = ${focus.position.x}`, `y = ${focus.position.y}`];
92
92
  }
93
- const placed = layoutNodeMap(layout ?? {
94
- treeId: '',
95
- nodes: [],
96
- decisions: [],
97
- diagnostics: [],
98
- layoutHash: '',
99
- }).get(focus.id);
93
+ const placed = layout === undefined ? undefined : layoutNodeMap(layout).get(focus.id);
100
94
  if (placed === undefined) {
101
95
  throw new ServiceError('FOCUS_LAYOUT_REQUIRED', `Automatic focus ${focus.id} has no layout position`, {
102
96
  focusId: focus.id,
@@ -1,30 +1,217 @@
1
1
  // Shared visual contract for the renderer and constraint solver. Coordinates
2
- // remain ordinary HOI4 focus-grid units; the pixel conversion proves whether
3
- // two rendered node rectangles intersect.
2
+ // remain ordinary HOI4 focus-grid units; connector geometry is converted to
3
+ // the same deterministic pixel-space cubic used in SVG review artifacts.
4
4
  export const FOCUS_HORIZONTAL_GRID_PIXELS = 176;
5
5
  export const FOCUS_VERTICAL_GRID_PIXELS = 116;
6
6
  export const FOCUS_NODE_WIDTH_PIXELS = 144;
7
7
  export const FOCUS_NODE_HEIGHT_PIXELS = 76;
8
+ export const FOCUS_CONNECTOR_FLATTEN_SEGMENTS = 24;
9
+ const GEOMETRY_EPSILON = 1e-7;
10
+ function resolvedGeometry(options) {
11
+ return {
12
+ horizontalSpacing: options.horizontalSpacing ?? FOCUS_HORIZONTAL_GRID_PIXELS,
13
+ verticalSpacing: options.verticalSpacing ?? FOCUS_VERTICAL_GRID_PIXELS,
14
+ nodeWidth: options.nodeWidth ?? FOCUS_NODE_WIDTH_PIXELS,
15
+ nodeHeight: options.nodeHeight ?? FOCUS_NODE_HEIGHT_PIXELS,
16
+ originX: options.originX ?? 0,
17
+ originY: options.originY ?? 0,
18
+ };
19
+ }
8
20
  export function focusNodesVisiblyOverlap(left, right) {
9
21
  return (Math.abs(left.x - right.x) * FOCUS_HORIZONTAL_GRID_PIXELS < FOCUS_NODE_WIDTH_PIXELS &&
10
22
  Math.abs(left.y - right.y) * FOCUS_VERTICAL_GRID_PIXELS < FOCUS_NODE_HEIGHT_PIXELS);
11
23
  }
24
+ export function focusNodeRectangle(node, options = {}) {
25
+ const geometry = resolvedGeometry(options);
26
+ const minimumX = geometry.originX + node.x * geometry.horizontalSpacing;
27
+ const minimumY = geometry.originY + node.y * geometry.verticalSpacing;
28
+ return {
29
+ minimumX,
30
+ minimumY,
31
+ maximumX: minimumX + geometry.nodeWidth,
32
+ maximumY: minimumY + geometry.nodeHeight,
33
+ };
34
+ }
35
+ /** The exact cubic rendered for a prerequisite connector. */
36
+ export function focusConnectorCurve(parent, child, options = {}) {
37
+ const geometry = resolvedGeometry(options);
38
+ const parentRectangle = focusNodeRectangle(parent, geometry);
39
+ const childRectangle = focusNodeRectangle(child, geometry);
40
+ const start = {
41
+ x: (parentRectangle.minimumX + parentRectangle.maximumX) / 2,
42
+ y: parentRectangle.maximumY,
43
+ };
44
+ const end = {
45
+ x: (childRectangle.minimumX + childRectangle.maximumX) / 2,
46
+ y: childRectangle.minimumY,
47
+ };
48
+ const middleY = (start.y + end.y) / 2;
49
+ return {
50
+ start,
51
+ firstControl: { x: start.x, y: middleY },
52
+ secondControl: { x: end.x, y: middleY },
53
+ end,
54
+ };
55
+ }
56
+ /** Serialize the shared connector primitive without introducing renderer-only rounding. */
57
+ export function focusConnectorSvgPath(curve) {
58
+ return `M ${curve.start.x} ${curve.start.y} C ${curve.firstControl.x} ${curve.firstControl.y}, ${curve.secondControl.x} ${curve.secondControl.y}, ${curve.end.x} ${curve.end.y}`;
59
+ }
60
+ function cubicCoordinate(start, firstControl, secondControl, end, progress) {
61
+ const remaining = 1 - progress;
62
+ return (remaining * remaining * remaining * start +
63
+ 3 * remaining * remaining * progress * firstControl +
64
+ 3 * remaining * progress * progress * secondControl +
65
+ progress * progress * progress * end);
66
+ }
67
+ /** Fixed-size flattening keeps intersection work deterministic and bounded. */
68
+ export function flattenFocusConnectorCurve(curve) {
69
+ return Array.from({ length: FOCUS_CONNECTOR_FLATTEN_SEGMENTS + 1 }, (_unused, index) => {
70
+ const progress = index / FOCUS_CONNECTOR_FLATTEN_SEGMENTS;
71
+ return {
72
+ x: cubicCoordinate(curve.start.x, curve.firstControl.x, curve.secondControl.x, curve.end.x, progress),
73
+ y: cubicCoordinate(curve.start.y, curve.firstControl.y, curve.secondControl.y, curve.end.y, progress),
74
+ };
75
+ });
76
+ }
77
+ function pointBounds(points) {
78
+ let minimumX = Infinity;
79
+ let minimumY = Infinity;
80
+ let maximumX = -Infinity;
81
+ let maximumY = -Infinity;
82
+ for (const point of points) {
83
+ minimumX = Math.min(minimumX, point.x);
84
+ minimumY = Math.min(minimumY, point.y);
85
+ maximumX = Math.max(maximumX, point.x);
86
+ maximumY = Math.max(maximumY, point.y);
87
+ }
88
+ return { minimumX, minimumY, maximumX, maximumY };
89
+ }
90
+ function rectanglesOverlap(left, right) {
91
+ return !(left.maximumX < right.minimumX - GEOMETRY_EPSILON ||
92
+ right.maximumX < left.minimumX - GEOMETRY_EPSILON ||
93
+ left.maximumY < right.minimumY - GEOMETRY_EPSILON ||
94
+ right.maximumY < left.minimumY - GEOMETRY_EPSILON);
95
+ }
12
96
  function orientation(first, second, third) {
13
- return Math.sign((second.y - first.y) * (third.x - second.x) - (second.x - first.x) * (third.y - second.y));
14
- }
15
- /**
16
- * Returns true only for a proper interior intersection. Connector pairs that
17
- * merely touch, overlap collinearly, or share a focus endpoint are handled by
18
- * their callers and do not count as a crossing.
19
- */
20
- export function focusConnectorSegmentsProperlyCross(firstStart, firstEnd, secondStart, secondEnd) {
21
- const values = [
22
- orientation(firstStart, firstEnd, secondStart),
23
- orientation(firstStart, firstEnd, secondEnd),
24
- orientation(secondStart, secondEnd, firstStart),
25
- orientation(secondStart, secondEnd, firstEnd),
97
+ const crossProduct = (second.x - first.x) * (third.y - first.y) - (second.y - first.y) * (third.x - first.x);
98
+ return Math.abs(crossProduct) <= GEOMETRY_EPSILON ? 0 : Math.sign(crossProduct);
99
+ }
100
+ function pointOnSegment(point, start, end) {
101
+ return (orientation(start, end, point) === 0 &&
102
+ point.x >= Math.min(start.x, end.x) - GEOMETRY_EPSILON &&
103
+ point.x <= Math.max(start.x, end.x) + GEOMETRY_EPSILON &&
104
+ point.y >= Math.min(start.y, end.y) - GEOMETRY_EPSILON &&
105
+ point.y <= Math.max(start.y, end.y) + GEOMETRY_EPSILON);
106
+ }
107
+ function segmentsIntersect(firstStart, firstEnd, secondStart, secondEnd) {
108
+ if (!rectanglesOverlap({
109
+ minimumX: Math.min(firstStart.x, firstEnd.x),
110
+ minimumY: Math.min(firstStart.y, firstEnd.y),
111
+ maximumX: Math.max(firstStart.x, firstEnd.x),
112
+ maximumY: Math.max(firstStart.y, firstEnd.y),
113
+ }, {
114
+ minimumX: Math.min(secondStart.x, secondEnd.x),
115
+ minimumY: Math.min(secondStart.y, secondEnd.y),
116
+ maximumX: Math.max(secondStart.x, secondEnd.x),
117
+ maximumY: Math.max(secondStart.y, secondEnd.y),
118
+ }))
119
+ return false;
120
+ const firstStartOrientation = orientation(firstStart, firstEnd, secondStart);
121
+ const firstEndOrientation = orientation(firstStart, firstEnd, secondEnd);
122
+ const secondStartOrientation = orientation(secondStart, secondEnd, firstStart);
123
+ const secondEndOrientation = orientation(secondStart, secondEnd, firstEnd);
124
+ if (firstStartOrientation * firstEndOrientation < 0 &&
125
+ secondStartOrientation * secondEndOrientation < 0)
126
+ return true;
127
+ return ((firstStartOrientation === 0 && pointOnSegment(secondStart, firstStart, firstEnd)) ||
128
+ (firstEndOrientation === 0 && pointOnSegment(secondEnd, firstStart, firstEnd)) ||
129
+ (secondStartOrientation === 0 && pointOnSegment(firstStart, secondStart, secondEnd)) ||
130
+ (secondEndOrientation === 0 && pointOnSegment(firstEnd, secondStart, secondEnd)));
131
+ }
132
+ function polylineSegments(points) {
133
+ const segments = [];
134
+ for (let index = 1; index < points.length; index += 1) {
135
+ const start = points[index - 1];
136
+ const end = points[index];
137
+ if (start === undefined || end === undefined)
138
+ continue;
139
+ segments.push({
140
+ start,
141
+ end,
142
+ minimumY: Math.min(start.y, end.y),
143
+ maximumY: Math.max(start.y, end.y),
144
+ });
145
+ }
146
+ return segments.sort((left, right) => left.minimumY - right.minimumY ||
147
+ left.maximumY - right.maximumY ||
148
+ left.start.x - right.start.x ||
149
+ left.end.x - right.end.x);
150
+ }
151
+ export function focusConnectorPolylinesIntersect(firstPoints, secondPoints) {
152
+ if (!rectanglesOverlap(pointBounds(firstPoints), pointBounds(secondPoints)))
153
+ return false;
154
+ const firstSegments = polylineSegments(firstPoints);
155
+ const secondSegments = polylineSegments(secondPoints);
156
+ let secondStartIndex = 0;
157
+ for (const first of firstSegments) {
158
+ while (secondStartIndex < secondSegments.length &&
159
+ (secondSegments[secondStartIndex]?.maximumY ?? Infinity) < first.minimumY - GEOMETRY_EPSILON)
160
+ secondStartIndex += 1;
161
+ for (let secondIndex = secondStartIndex; secondIndex < secondSegments.length; secondIndex += 1) {
162
+ const second = secondSegments[secondIndex];
163
+ if (second === undefined)
164
+ continue;
165
+ if (second.minimumY > first.maximumY + GEOMETRY_EPSILON)
166
+ break;
167
+ if (segmentsIntersect(first.start, first.end, second.start, second.end))
168
+ return true;
169
+ }
170
+ }
171
+ return false;
172
+ }
173
+ export function focusConnectorCurvesIntersect(first, second) {
174
+ return focusConnectorPolylinesIntersect(flattenFocusConnectorCurve(first), flattenFocusConnectorCurve(second));
175
+ }
176
+ export function focusConnectorsVisiblyIntersect(firstParent, firstChild, secondParent, secondChild, options = {}) {
177
+ return focusConnectorCurvesIntersect(focusConnectorCurve(firstParent, firstChild, options), focusConnectorCurve(secondParent, secondChild, options));
178
+ }
179
+ function pointInsideRectangle(point, rectangle) {
180
+ return (point.x >= rectangle.minimumX - GEOMETRY_EPSILON &&
181
+ point.x <= rectangle.maximumX + GEOMETRY_EPSILON &&
182
+ point.y >= rectangle.minimumY - GEOMETRY_EPSILON &&
183
+ point.y <= rectangle.maximumY + GEOMETRY_EPSILON);
184
+ }
185
+ export function focusConnectorPolylineIntersectsRectangle(points, rectangle) {
186
+ if (!rectanglesOverlap(pointBounds(points), rectangle))
187
+ return false;
188
+ const topLeft = { x: rectangle.minimumX, y: rectangle.minimumY };
189
+ const topRight = { x: rectangle.maximumX, y: rectangle.minimumY };
190
+ const bottomRight = { x: rectangle.maximumX, y: rectangle.maximumY };
191
+ const bottomLeft = { x: rectangle.minimumX, y: rectangle.maximumY };
192
+ const sides = [
193
+ [topLeft, topRight],
194
+ [topRight, bottomRight],
195
+ [bottomRight, bottomLeft],
196
+ [bottomLeft, topLeft],
26
197
  ];
27
- return values.every((value) => value !== 0) && values[0] !== values[1] && values[2] !== values[3];
198
+ for (let index = 1; index < points.length; index += 1) {
199
+ const start = points[index - 1];
200
+ const end = points[index];
201
+ if (start === undefined || end === undefined)
202
+ continue;
203
+ if (pointInsideRectangle(start, rectangle) || pointInsideRectangle(end, rectangle))
204
+ return true;
205
+ if (sides.some(([sideStart, sideEnd]) => segmentsIntersect(start, end, sideStart, sideEnd)))
206
+ return true;
207
+ }
208
+ return false;
209
+ }
210
+ export function focusConnectorCurveIntersectsRectangle(curve, rectangle) {
211
+ return focusConnectorPolylineIntersectsRectangle(flattenFocusConnectorCurve(curve), rectangle);
212
+ }
213
+ export function focusConnectorIntersectsNode(parent, child, node, options = {}) {
214
+ return focusConnectorCurveIntersectsRectangle(focusConnectorCurve(parent, child, options), focusNodeRectangle(node, options));
28
215
  }
29
216
  export function focusNodeOrigin(node, minimumX, minimumY, padding, horizontalSpacing = FOCUS_HORIZONTAL_GRID_PIXELS, verticalSpacing = FOCUS_VERTICAL_GRID_PIXELS) {
30
217
  return {