unreal-engine-mcp-server 0.5.0 → 0.5.2

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 (188) hide show
  1. package/.env.example +1 -1
  2. package/.github/release-drafter-config.yml +51 -0
  3. package/.github/workflows/greetings.yml +5 -1
  4. package/.github/workflows/labeler.yml +2 -1
  5. package/.github/workflows/publish-mcp.yml +2 -4
  6. package/.github/workflows/release-drafter.yml +3 -2
  7. package/.github/workflows/release.yml +3 -3
  8. package/CHANGELOG.md +109 -0
  9. package/CONTRIBUTING.md +1 -1
  10. package/GEMINI.md +115 -0
  11. package/Public/Plugin_setup_guide.mp4 +0 -0
  12. package/README.md +166 -200
  13. package/dist/automation/bridge.d.ts +1 -2
  14. package/dist/automation/bridge.js +24 -23
  15. package/dist/automation/connection-manager.d.ts +1 -0
  16. package/dist/automation/connection-manager.js +10 -0
  17. package/dist/automation/message-handler.js +5 -4
  18. package/dist/automation/request-tracker.d.ts +4 -0
  19. package/dist/automation/request-tracker.js +11 -3
  20. package/dist/config.d.ts +0 -1
  21. package/dist/config.js +0 -1
  22. package/dist/constants.d.ts +4 -0
  23. package/dist/constants.js +4 -0
  24. package/dist/graphql/loaders.d.ts +64 -0
  25. package/dist/graphql/loaders.js +117 -0
  26. package/dist/graphql/resolvers.d.ts +3 -3
  27. package/dist/graphql/resolvers.js +33 -30
  28. package/dist/graphql/server.js +3 -1
  29. package/dist/graphql/types.d.ts +2 -0
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.js +13 -2
  32. package/dist/server-setup.d.ts +0 -1
  33. package/dist/server-setup.js +0 -40
  34. package/dist/tools/actors.d.ts +58 -24
  35. package/dist/tools/actors.js +22 -6
  36. package/dist/tools/assets.d.ts +19 -71
  37. package/dist/tools/assets.js +28 -22
  38. package/dist/tools/base-tool.d.ts +4 -4
  39. package/dist/tools/base-tool.js +1 -1
  40. package/dist/tools/blueprint.d.ts +45 -61
  41. package/dist/tools/blueprint.js +43 -14
  42. package/dist/tools/consolidated-tool-definitions.js +2 -1
  43. package/dist/tools/consolidated-tool-handlers.js +96 -110
  44. package/dist/tools/dynamic-handler-registry.d.ts +11 -9
  45. package/dist/tools/dynamic-handler-registry.js +17 -95
  46. package/dist/tools/editor.d.ts +19 -193
  47. package/dist/tools/editor.js +11 -2
  48. package/dist/tools/environment.d.ts +8 -14
  49. package/dist/tools/foliage.d.ts +18 -143
  50. package/dist/tools/foliage.js +4 -2
  51. package/dist/tools/handlers/actor-handlers.d.ts +1 -1
  52. package/dist/tools/handlers/actor-handlers.js +14 -13
  53. package/dist/tools/handlers/asset-handlers.js +454 -454
  54. package/dist/tools/handlers/sequence-handlers.d.ts +1 -1
  55. package/dist/tools/handlers/sequence-handlers.js +24 -13
  56. package/dist/tools/introspection.d.ts +1 -1
  57. package/dist/tools/introspection.js +1 -1
  58. package/dist/tools/landscape.d.ts +16 -116
  59. package/dist/tools/landscape.js +7 -3
  60. package/dist/tools/level.d.ts +22 -103
  61. package/dist/tools/level.js +26 -18
  62. package/dist/tools/lighting.d.ts +54 -7
  63. package/dist/tools/lighting.js +9 -5
  64. package/dist/tools/materials.d.ts +1 -1
  65. package/dist/tools/materials.js +5 -1
  66. package/dist/tools/niagara.js +37 -2
  67. package/dist/tools/performance.d.ts +0 -1
  68. package/dist/tools/performance.js +0 -1
  69. package/dist/tools/physics.js +5 -1
  70. package/dist/tools/sequence.d.ts +24 -24
  71. package/dist/tools/sequence.js +13 -0
  72. package/dist/tools/ui.d.ts +0 -2
  73. package/dist/types/automation-responses.d.ts +115 -0
  74. package/dist/types/automation-responses.js +2 -0
  75. package/dist/types/responses.d.ts +249 -0
  76. package/dist/types/responses.js +2 -0
  77. package/dist/types/tool-interfaces.d.ts +135 -135
  78. package/dist/types/tool-types.d.ts +2 -0
  79. package/dist/unreal-bridge.js +4 -4
  80. package/dist/utils/command-validator.js +7 -5
  81. package/dist/utils/error-handler.d.ts +24 -2
  82. package/dist/utils/error-handler.js +58 -23
  83. package/dist/utils/normalize.d.ts +7 -4
  84. package/dist/utils/normalize.js +12 -10
  85. package/dist/utils/path-security.d.ts +2 -0
  86. package/dist/utils/path-security.js +24 -0
  87. package/dist/utils/response-factory.d.ts +4 -4
  88. package/dist/utils/response-factory.js +15 -21
  89. package/dist/utils/response-validator.js +88 -73
  90. package/dist/utils/unreal-command-queue.d.ts +2 -0
  91. package/dist/utils/unreal-command-queue.js +8 -1
  92. package/docs/Migration-Guide-v0.5.0.md +1 -9
  93. package/docs/handler-mapping.md +4 -2
  94. package/docs/testing-guide.md +2 -2
  95. package/package.json +12 -6
  96. package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridgeSubsystem.cpp +298 -33
  97. package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_AnimationHandlers.cpp +7 -8
  98. package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_BlueprintGraphHandlers.cpp +229 -319
  99. package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_BlueprintHandlers.cpp +98 -0
  100. package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_EffectHandlers.cpp +24 -0
  101. package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_EnvironmentHandlers.cpp +96 -0
  102. package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_LightingHandlers.cpp +52 -5
  103. package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_ProcessRequest.cpp +5 -268
  104. package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_SequenceHandlers.cpp +57 -2
  105. package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpConnectionManager.cpp +0 -1
  106. package/scripts/run-all-tests.mjs +25 -20
  107. package/server.json +3 -2
  108. package/src/automation/bridge.ts +27 -25
  109. package/src/automation/connection-manager.ts +18 -0
  110. package/src/automation/message-handler.ts +33 -8
  111. package/src/automation/request-tracker.ts +39 -7
  112. package/src/config.ts +1 -1
  113. package/src/constants.ts +7 -0
  114. package/src/graphql/loaders.ts +244 -0
  115. package/src/graphql/resolvers.ts +47 -49
  116. package/src/graphql/server.ts +3 -1
  117. package/src/graphql/types.ts +3 -0
  118. package/src/index.ts +15 -2
  119. package/src/resources/assets.ts +5 -4
  120. package/src/server/tool-registry.ts +3 -3
  121. package/src/server-setup.ts +3 -37
  122. package/src/tools/actors.ts +77 -44
  123. package/src/tools/animation.ts +1 -0
  124. package/src/tools/assets.ts +76 -65
  125. package/src/tools/base-tool.ts +3 -3
  126. package/src/tools/blueprint.ts +170 -104
  127. package/src/tools/consolidated-tool-definitions.ts +2 -1
  128. package/src/tools/consolidated-tool-handlers.ts +129 -150
  129. package/src/tools/dynamic-handler-registry.ts +22 -140
  130. package/src/tools/editor.ts +43 -29
  131. package/src/tools/environment.ts +21 -27
  132. package/src/tools/foliage.ts +28 -25
  133. package/src/tools/handlers/actor-handlers.ts +16 -17
  134. package/src/tools/handlers/asset-handlers.ts +484 -484
  135. package/src/tools/handlers/sequence-handlers.ts +85 -62
  136. package/src/tools/introspection.ts +7 -7
  137. package/src/tools/landscape.ts +34 -28
  138. package/src/tools/level.ts +100 -80
  139. package/src/tools/lighting.ts +25 -20
  140. package/src/tools/materials.ts +9 -3
  141. package/src/tools/niagara.ts +44 -2
  142. package/src/tools/performance.ts +1 -2
  143. package/src/tools/physics.ts +7 -1
  144. package/src/tools/sequence.ts +42 -26
  145. package/src/tools/ui.ts +1 -3
  146. package/src/types/automation-responses.ts +119 -0
  147. package/src/types/responses.ts +355 -0
  148. package/src/types/tool-interfaces.ts +135 -135
  149. package/src/types/tool-types.ts +4 -0
  150. package/src/unreal-bridge.ts +71 -26
  151. package/src/utils/command-validator.ts +47 -5
  152. package/src/utils/error-handler.ts +128 -45
  153. package/src/utils/normalize.test.ts +162 -0
  154. package/src/utils/normalize.ts +38 -16
  155. package/src/utils/path-security.ts +43 -0
  156. package/src/utils/response-factory.ts +29 -24
  157. package/src/utils/response-validator.ts +103 -87
  158. package/src/utils/safe-json.test.ts +90 -0
  159. package/src/utils/unreal-command-queue.ts +13 -1
  160. package/src/utils/validation.test.ts +184 -0
  161. package/tests/test-animation.mjs +358 -33
  162. package/tests/test-asset-graph.mjs +311 -0
  163. package/tests/test-audio.mjs +314 -116
  164. package/tests/test-behavior-tree.mjs +327 -144
  165. package/tests/test-blueprint-graph.mjs +343 -12
  166. package/tests/test-control-editor.mjs +85 -53
  167. package/tests/test-graphql.mjs +58 -8
  168. package/tests/test-input.mjs +349 -0
  169. package/tests/test-inspect.mjs +291 -61
  170. package/tests/test-landscape.mjs +304 -48
  171. package/tests/test-lighting.mjs +428 -0
  172. package/tests/test-manage-level.mjs +70 -51
  173. package/tests/test-performance.mjs +539 -0
  174. package/tests/test-sequence.mjs +82 -46
  175. package/tests/test-system.mjs +72 -33
  176. package/tests/test-wasm.mjs +98 -8
  177. package/vitest.config.ts +35 -0
  178. package/.github/release-drafter.yml +0 -148
  179. package/dist/prompts/index.d.ts +0 -21
  180. package/dist/prompts/index.js +0 -217
  181. package/dist/tools/blueprint/helpers.d.ts +0 -29
  182. package/dist/tools/blueprint/helpers.js +0 -182
  183. package/src/prompts/index.ts +0 -249
  184. package/src/tools/blueprint/helpers.ts +0 -189
  185. package/tests/test-blueprint-events.mjs +0 -35
  186. package/tests/test-extra-tools.mjs +0 -38
  187. package/tests/test-render.mjs +0 -33
  188. package/tests/test-search-assets.mjs +0 -66
@@ -27,114 +27,130 @@ function buildSummaryText(toolName: string, payload: unknown): string {
27
27
  return `${toolName} responded`;
28
28
  }
29
29
 
30
- // 1. Check for specific "data" or "result" wrapper
30
+ // Recursively flatten data/result wrappers into effective payload
31
31
  const effectivePayload: Record<string, any> = { ...(payload as object) };
32
- if (isRecord(effectivePayload.data)) {
33
- Object.assign(effectivePayload, effectivePayload.data);
34
- }
35
- if (isRecord(effectivePayload.result)) {
36
- Object.assign(effectivePayload, effectivePayload.result);
37
- }
38
32
 
39
- const parts: string[] = [];
33
+ const flattenWrappers = (obj: Record<string, any>, depth = 0): void => {
34
+ if (depth > 5) return; // Prevent infinite loops
35
+ if (isRecord(obj.data)) {
36
+ Object.assign(obj, obj.data);
37
+ delete obj.data;
38
+ flattenWrappers(obj, depth + 1);
39
+ }
40
+ if (isRecord(obj.result)) {
41
+ Object.assign(obj, obj.result);
42
+ delete obj.result;
43
+ flattenWrappers(obj, depth + 1);
44
+ }
45
+ };
46
+ flattenWrappers(effectivePayload);
40
47
 
41
- // 2. Identify "List" responses (Arrays) - Prioritize showing content
42
- const listKeys = ['actors', 'levels', 'assets', 'folders', 'blueprints', 'components', 'pawnClasses', 'foliageTypes', 'nodes', 'tracks', 'bindings', 'keys'];
43
- for (const key of listKeys) {
44
- if (Array.isArray(effectivePayload[key])) {
45
- const arr = effectivePayload[key] as any[];
46
- const names = arr.map(i => isRecord(i) ? (i.name || i.path || i.id || i.assetName || i.objectPath || i.packageName || i.nodeName || '<?>') : String(i));
47
- const count = arr.length;
48
- const preview = names.slice(0, 100).join(', '); // Show up to 100
49
- const suffix = count > 100 ? `, ... (+${count - 100} more)` : '';
50
- parts.push(`${key}: ${preview}${suffix} (Total: ${count})`);
48
+ const parts: string[] = [];
49
+ const addedKeys = new Set<string>();
50
+
51
+ // Keys to skip (internal/redundant)
52
+ const skipKeys = new Set(['requestId', 'type', 'data', 'result', 'warnings']);
53
+
54
+ // Helper to format a value for display
55
+ const formatValue = (val: unknown): string => {
56
+ if (val === null || val === undefined) return '';
57
+ if (typeof val === 'string') return val.length > 150 ? val.slice(0, 150) + '...' : val;
58
+ if (typeof val === 'number' || typeof val === 'boolean') return String(val);
59
+
60
+ // Handle arrays - show items with names/paths
61
+ if (Array.isArray(val)) {
62
+ if (val.length === 0) return '[] (0)';
63
+ const items = val.slice(0, 30).map(v => {
64
+ if (isRecord(v)) {
65
+ // Try common identifier fields
66
+ return v.name || v.path || v.id || v.nodeId || v.nodeName || v.className ||
67
+ v.displayName || v.type || v.assetPath || v.objectPath ||
68
+ JSON.stringify(v).slice(0, 50);
69
+ }
70
+ return String(v);
71
+ });
72
+ const suffix = val.length > 30 ? `, ... (+${val.length - 30} more)` : '';
73
+ return `[${items.join(', ')}${suffix}] (${val.length})`;
51
74
  }
52
- }
53
75
 
54
- // 3. Identify "Entity" operations (Single significant object)
55
- if (typeof effectivePayload.actor === 'string' || isRecord(effectivePayload.actor)) {
56
- const a = effectivePayload.actor;
57
- const name = isRecord(a) ? (a.name || a.path) : a;
58
- const loc = isRecord(effectivePayload.location) ? ` at [${effectivePayload.location.x},${effectivePayload.location.y},${effectivePayload.location.z}]` : '';
59
- parts.push(`Actor: ${name}${loc}`);
60
- }
76
+ // Handle transform-like objects (location/rotation/scale)
77
+ if (isRecord(val)) {
78
+ const keys = Object.keys(val);
79
+ // Check if it looks like a 3D vector/transform
80
+ if (keys.some(k => ['x', 'y', 'z', 'pitch', 'yaw', 'roll'].includes(k))) {
81
+ const x = val.x ?? val.pitch ?? 0;
82
+ const y = val.y ?? val.yaw ?? 0;
83
+ const z = val.z ?? val.roll ?? 0;
84
+ return `[${x}, ${y}, ${z}]`;
85
+ }
86
+ // Generic object - show key=value pairs
87
+ const entries = Object.entries(val).slice(0, 8);
88
+ const formatted = entries.map(([k, v]) => {
89
+ const vStr = typeof v === 'object' ? JSON.stringify(v).slice(0, 40) : String(v);
90
+ return `${k}=${vStr}`;
91
+ });
92
+ return `{ ${formatted.join(', ')}${keys.length > 8 ? ' ...' : ''} }`;
93
+ }
61
94
 
62
- if (typeof effectivePayload.asset === 'string' || isRecord(effectivePayload.asset)) {
63
- const a = effectivePayload.asset;
64
- const path = isRecord(a) ? (a.path || a.name) : a;
65
- parts.push(`Asset: ${path}`);
95
+ return String(val);
96
+ };
97
+
98
+ // Process all keys in priority order
99
+ // 1. First add 'success' and 'error' at the start
100
+ for (const key of ['success', 'error']) {
101
+ if (effectivePayload[key] !== undefined && !addedKeys.has(key)) {
102
+ const formatted = formatValue(effectivePayload[key]);
103
+ if (formatted) {
104
+ parts.push(`${key}: ${formatted}`);
105
+ addedKeys.add(key);
106
+ }
107
+ }
66
108
  }
67
109
 
68
- if (typeof effectivePayload.blueprint === 'string' || isRecord(effectivePayload.blueprint)) {
69
- const bp = effectivePayload.blueprint;
70
- const name = isRecord(bp) ? (bp.name || bp.path || effectivePayload.blueprintPath) : bp;
71
- parts.push(`Blueprint: ${name}`);
72
- }
110
+ // 2. Then add ALL other keys dynamically
111
+ let hasArrays = false;
112
+ for (const [key, val] of Object.entries(effectivePayload)) {
113
+ if (addedKeys.has(key)) continue;
114
+ if (skipKeys.has(key)) continue;
115
+ if (val === undefined || val === null) continue;
116
+ if (typeof val === 'string' && val.trim() === '') continue;
73
117
 
74
- if (typeof effectivePayload.sequence === 'string' || isRecord(effectivePayload.sequence)) {
75
- const seq = effectivePayload.sequence;
76
- const name = isRecord(seq) ? (seq.name || seq.path) : seq;
77
- parts.push(`Sequence: ${name}`);
78
- }
118
+ // Skip 'message' for now - handle later to avoid duplication
119
+ if (key === 'message') continue;
79
120
 
80
- // 4. Generic Key-Value Summary (Contextual)
81
- // Added: sequencePath, graphName, nodeName, variableName, memberName, scriptName, etc.
82
- const usefulKeys = [
83
- 'success', 'error', 'message', 'assets', 'folders', 'count', 'totalCount',
84
- 'saved', 'valid', 'issues', 'class', 'skeleton', 'parent',
85
- 'package', 'dependencies', 'graph', 'tags', 'metadata', 'properties'
86
- ];
87
-
88
- for (const key of usefulKeys) {
89
- if (effectivePayload[key] !== undefined && effectivePayload[key] !== null) {
90
- const val = effectivePayload[key];
91
- // Special handling for objects like metadata
92
- if (typeof val === 'object') {
93
- if (key === 'metadata' || key === 'properties' || key === 'tags') {
94
- const entries = Object.entries(val as object);
95
- // Format as "Key=Value", skip generic types if possible, or just show raw
96
- const formatted = entries.map(([k, v]) => `${k}=${v}`);
97
- const limit = 50; // Show more items as requested
98
- parts.push(`${key}: { ${formatted.slice(0, limit).join(', ')}${formatted.length > limit ? '...' : ''} }`);
99
- continue;
100
- }
101
- // Try to find a name if it's an object
102
- // Skip complex objects unless handled above
103
- continue;
104
- }
121
+ // Track if we have arrays (to skip duplicate count/totalCount later)
122
+ if (Array.isArray(val) && val.length > 0) hasArrays = true;
105
123
 
106
- const strVal = String(val);
107
- // Avoid traversing huge strings
108
- if (strVal.length > 100) continue;
124
+ // Skip count/totalCount if we already have arrays showing counts
125
+ if ((key === 'count' || key === 'totalCount') && hasArrays) continue;
109
126
 
110
- if (!parts.some(p => p.includes(strVal))) {
111
- parts.push(`${key}: ${strVal}`);
112
- }
127
+ const formatted = formatValue(val);
128
+ if (formatted) {
129
+ parts.push(`${key}: ${formatted}`);
130
+ addedKeys.add(key);
113
131
  }
114
132
  }
115
133
 
116
- // 5. Add standard status messages LAST
117
- const success = typeof payload.success === 'boolean' ? payload.success : undefined;
118
- const message = typeof payload.message === 'string' ? normalizeText(payload.message) : '';
119
- const error = typeof payload.error === 'string' ? normalizeText(payload.error) : '';
134
+ // 3. Handle message last - but skip if it duplicates existing info
135
+ const message = typeof effectivePayload.message === 'string' ? normalizeText(effectivePayload.message) : '';
136
+ if (message && message.toLowerCase() !== 'success') {
137
+ // Skip if message duplicates count info
138
+ const isDuplicateInfo = /^(found|listed|retrieved|got|loaded|created|deleted|saved|spawned)\s+\d+/i.test(message) ||
139
+ /Folders:\s*\[/.test(message) ||
140
+ /\d+\s+(assets?|folders?|items?|actors?|components?)\s+(and|in|at)/i.test(message);
120
141
 
121
- if (parts.length > 0) {
122
- if (message && message.toLowerCase() !== 'success') {
142
+ // Also skip if message content is already represented in parts
143
+ const messageInParts = parts.some(p => p.toLowerCase().includes(message.toLowerCase().slice(0, 30)));
144
+
145
+ if (!isDuplicateInfo && !messageInParts) {
123
146
  parts.push(message);
124
147
  }
125
- } else {
126
- // No data parts, rely on message/error
127
- if (message) parts.push(message);
128
- if (error) parts.push(`Error: ${error}`);
129
- if (parts.length === 0 && success !== undefined) {
130
- parts.push(success ? 'Success' : 'Failed');
131
- }
132
148
  }
133
149
 
134
- // 6. Warnings
135
- const warnings = Array.isArray(payload.warnings) ? payload.warnings : [];
150
+ // 4. Warnings at end
151
+ const warnings = Array.isArray(effectivePayload.warnings) ? effectivePayload.warnings : [];
136
152
  if (warnings.length > 0) {
137
- parts.push(`Warnings: ${warnings.length}`);
153
+ parts.push(`Warnings: ${warnings.map((w: any) => typeof w === 'string' ? w : JSON.stringify(w)).join('; ')}`);
138
154
  }
139
155
 
140
156
  return parts.length > 0 ? parts.join(' | ') : `${toolName} responded`;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Unit tests for safe-json utility
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import { cleanObject } from './safe-json.js';
6
+
7
+ describe('cleanObject', () => {
8
+ it('removes undefined values', () => {
9
+ const input = { a: 1, b: undefined, c: 3 };
10
+ const result = cleanObject(input);
11
+ expect(result).toEqual({ a: 1, c: 3 });
12
+ expect('b' in result).toBe(false);
13
+ });
14
+
15
+ it('preserves null values (only removes undefined)', () => {
16
+ const input = { a: 1, b: null, c: 3 };
17
+ const result = cleanObject(input);
18
+ // cleanObject preserves null, only removes undefined
19
+ expect(result).toEqual({ a: 1, b: null, c: 3 });
20
+ });
21
+
22
+ it('handles nested objects', () => {
23
+ const input = { a: 1, nested: { b: 2, c: undefined } };
24
+ const result = cleanObject(input);
25
+ expect(result.nested).toEqual({ b: 2 });
26
+ });
27
+
28
+ it('handles arrays with undefined (preserves null)', () => {
29
+ const input = { arr: [1, undefined, 3, null] };
30
+ const result = cleanObject(input);
31
+ // Arrays preserve their structure, undefined becomes undefined in array
32
+ expect(result.arr).toEqual([1, undefined, 3, null]);
33
+ });
34
+
35
+ it('preserves falsy values that are not null/undefined', () => {
36
+ const input = { a: 0, b: '', c: false };
37
+ const result = cleanObject(input);
38
+ expect(result).toEqual({ a: 0, b: '', c: false });
39
+ });
40
+
41
+ it('handles empty objects', () => {
42
+ expect(cleanObject({})).toEqual({});
43
+ });
44
+
45
+ it('handles primitive inputs', () => {
46
+ expect(cleanObject('string')).toBe('string');
47
+ expect(cleanObject(42)).toBe(42);
48
+ expect(cleanObject(true)).toBe(true);
49
+ });
50
+
51
+ it('respects max depth limit', () => {
52
+ // Create deeply nested object
53
+ let deep: any = { value: 'deep' };
54
+ for (let i = 0; i < 15; i++) {
55
+ deep = { nested: deep };
56
+ }
57
+
58
+ // Should not throw when hitting depth limit
59
+ expect(() => cleanObject(deep, 10)).not.toThrow();
60
+ });
61
+
62
+ it('handles circular reference prevention at max depth', () => {
63
+ const obj: any = { a: 1 };
64
+ obj.self = obj; // circular reference
65
+
66
+ // Should not throw - depth limiting should prevent infinite recursion
67
+ expect(() => cleanObject(obj, 5)).not.toThrow();
68
+ });
69
+
70
+ it('handles Date objects (converts to empty object)', () => {
71
+ const date = new Date('2024-01-01');
72
+ const input = { created: date };
73
+ const result = cleanObject(input);
74
+ // Date objects are processed as objects using Object.keys, which returns []
75
+ expect(result.created).toEqual({});
76
+ });
77
+
78
+ it('handles empty arrays', () => {
79
+ const input = { arr: [] };
80
+ const result = cleanObject(input);
81
+ expect(result.arr).toEqual([]);
82
+ });
83
+
84
+ it('handles NaN values', () => {
85
+ const input = { a: 1, b: NaN };
86
+ const result = cleanObject(input);
87
+ // NaN handling depends on implementation
88
+ expect(result.a).toBe(1);
89
+ });
90
+ });
@@ -14,6 +14,7 @@ export class UnrealCommandQueue {
14
14
  private isProcessing = false;
15
15
  private lastCommandTime = 0;
16
16
  private lastStatCommandTime = 0;
17
+ private processorInterval?: ReturnType<typeof setInterval>;
17
18
 
18
19
  // Config
19
20
  private readonly MIN_COMMAND_DELAY = 100;
@@ -139,13 +140,24 @@ export class UnrealCommandQueue {
139
140
  }
140
141
 
141
142
  private startProcessor(): void {
142
- setInterval(() => {
143
+ this.processorInterval = setInterval(() => {
143
144
  if (!this.isProcessing && this.queue.length > 0) {
144
145
  this.processQueue();
145
146
  }
146
147
  }, 1000);
147
148
  }
148
149
 
150
+ /**
151
+ * Stop the command queue processor and clean up the interval.
152
+ * Should be called during shutdown to allow clean process exit.
153
+ */
154
+ stopProcessor(): void {
155
+ if (this.processorInterval) {
156
+ clearInterval(this.processorInterval);
157
+ this.processorInterval = undefined;
158
+ }
159
+ }
160
+
149
161
  private delay(ms: number): Promise<void> {
150
162
  return new Promise(resolve => setTimeout(resolve, ms));
151
163
  }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Unit tests for validation utility functions
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import {
6
+ sanitizeAssetName,
7
+ sanitizePath,
8
+ validatePathLength,
9
+ validateAssetParams,
10
+ validateNumber,
11
+ ensureVector3,
12
+ ensureRotation
13
+ } from './validation.js';
14
+
15
+ describe('sanitizeAssetName', () => {
16
+ it('removes invalid characters', () => {
17
+ // ! is replaced with _, then trailing _ is stripped
18
+ expect(sanitizeAssetName('My Asset!')).toBe('My_Asset');
19
+ });
20
+
21
+ it('removes leading/trailing whitespace', () => {
22
+ expect(sanitizeAssetName(' MyAsset ')).toBe('MyAsset');
23
+ });
24
+
25
+ it('preserves valid names', () => {
26
+ expect(sanitizeAssetName('ValidName_123')).toBe('ValidName_123');
27
+ });
28
+
29
+ it('replaces spaces with underscores', () => {
30
+ expect(sanitizeAssetName('My Cool Asset')).toBe('My_Cool_Asset');
31
+ });
32
+
33
+ it('handles empty strings', () => {
34
+ const result = sanitizeAssetName('');
35
+ expect(result.length).toBeGreaterThan(0);
36
+ });
37
+
38
+ it('removes consecutive underscores', () => {
39
+ const result = sanitizeAssetName('My__Asset');
40
+ expect(result).not.toContain('__');
41
+ });
42
+ });
43
+
44
+ describe('sanitizePath', () => {
45
+ it('normalizes forward slashes', () => {
46
+ const result = sanitizePath('/Game/MyAsset');
47
+ expect(result).toContain('/');
48
+ expect(result).not.toContain('\\\\');
49
+ });
50
+
51
+ it('removes double slashes', () => {
52
+ const result = sanitizePath('/Game//MyAsset');
53
+ expect(result).not.toContain('//');
54
+ });
55
+
56
+ it('handles backslashes', () => {
57
+ const result = sanitizePath('\\Game\\MyAsset');
58
+ expect(result).toContain('/');
59
+ });
60
+
61
+ it('sanitizes path segments with dots', () => {
62
+ const result = sanitizePath('/Game/../MyAsset');
63
+ // Each segment is sanitized, .. becomes a segment that gets sanitized
64
+ expect(result).toContain('/');
65
+ });
66
+ });
67
+
68
+ describe('validatePathLength', () => {
69
+ it('accepts valid short paths', () => {
70
+ const result = validatePathLength('/Game/MyAsset');
71
+ expect(result.valid).toBe(true);
72
+ });
73
+
74
+ it('accepts empty paths (length 0 < 260)', () => {
75
+ const result = validatePathLength('');
76
+ // Empty string has length 0, which is < 260, so it's valid
77
+ expect(result.valid).toBe(true);
78
+ });
79
+
80
+ it('rejects excessively long paths', () => {
81
+ const longPath = '/Game/' + 'a'.repeat(300);
82
+ const result = validatePathLength(longPath);
83
+ expect(result.valid).toBe(false);
84
+ expect(result.error).toBeDefined();
85
+ });
86
+ });
87
+
88
+ describe('validateAssetParams', () => {
89
+ it('validates correct create params', () => {
90
+ const result = validateAssetParams({
91
+ action: 'create',
92
+ name: 'MyAsset',
93
+ path: '/Game/Assets'
94
+ });
95
+ expect(result.valid).toBe(true);
96
+ });
97
+
98
+ it('handles names that need sanitization', () => {
99
+ const result = validateAssetParams({
100
+ name: '',
101
+ savePath: '/Game'
102
+ });
103
+ // Empty name gets sanitized to 'Asset', so this should be valid
104
+ expect(result.valid).toBe(true);
105
+ });
106
+
107
+ it('validates path params', () => {
108
+ const result = validateAssetParams({
109
+ name: 'Target',
110
+ savePath: '/Game/Source'
111
+ });
112
+ expect(result.valid).toBe(true);
113
+ });
114
+ });
115
+
116
+ describe('validateNumber', () => {
117
+ it('accepts valid finite numbers', () => {
118
+ expect(() => validateNumber(42, 'test')).not.toThrow();
119
+ });
120
+
121
+ it('rejects NaN', () => {
122
+ expect(() => validateNumber(NaN, 'test')).toThrow('expected a finite number');
123
+ });
124
+
125
+ it('rejects Infinity', () => {
126
+ expect(() => validateNumber(Infinity, 'test')).toThrow();
127
+ });
128
+
129
+ it('respects minimum constraint', () => {
130
+ expect(() => validateNumber(5, 'test', { min: 10 })).toThrow('must be >=');
131
+ });
132
+
133
+ it('respects maximum constraint', () => {
134
+ expect(() => validateNumber(15, 'test', { max: 10 })).toThrow('must be <=');
135
+ });
136
+
137
+ it('allows zero by default', () => {
138
+ expect(() => validateNumber(0, 'test')).not.toThrow();
139
+ });
140
+
141
+ it('rejects zero when allowZero is false', () => {
142
+ expect(() => validateNumber(0, 'test', { allowZero: false })).toThrow('zero is not allowed');
143
+ });
144
+ });
145
+
146
+ describe('ensureVector3', () => {
147
+ it('accepts object format with x, y, z', () => {
148
+ const result = ensureVector3({ x: 1, y: 2, z: 3 }, 'location');
149
+ expect(result).toEqual([1, 2, 3]);
150
+ });
151
+
152
+ it('accepts array format', () => {
153
+ const result = ensureVector3([1, 2, 3], 'location');
154
+ expect(result).toEqual([1, 2, 3]);
155
+ });
156
+
157
+ it('throws on invalid object', () => {
158
+ expect(() => ensureVector3({ x: 1 }, 'location')).toThrow();
159
+ });
160
+
161
+ it('throws on wrong array length', () => {
162
+ expect(() => ensureVector3([1, 2], 'location')).toThrow();
163
+ });
164
+
165
+ it('throws on non-number values', () => {
166
+ expect(() => ensureVector3({ x: 'a', y: 2, z: 3 }, 'location')).toThrow();
167
+ });
168
+ });
169
+
170
+ describe('ensureRotation', () => {
171
+ it('accepts object format with pitch, yaw, roll', () => {
172
+ const result = ensureRotation({ pitch: 0, yaw: 90, roll: 0 }, 'rotation');
173
+ expect(result).toEqual([0, 90, 0]);
174
+ });
175
+
176
+ it('accepts array format', () => {
177
+ const result = ensureRotation([0, 90, 0], 'rotation');
178
+ expect(result).toEqual([0, 90, 0]);
179
+ });
180
+
181
+ it('throws on invalid input', () => {
182
+ expect(() => ensureRotation('invalid', 'rotation')).toThrow();
183
+ });
184
+ });