@adiholick/nvstudio-mcp 2.2.2 → 2.2.3

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.
@@ -1,8 +1,8 @@
1
1
  -- ══════════════════════════════════════════════════════════════════════
2
- -- NVStudio MCP Plugin v2.1.8
3
- -- Arsitektur: Polling Stream / Local HTTP Bridge (Port 3055)
4
- -- Plugin auto-connect saat dimuat, polling /api/ping lalu /api/stream
5
- -- untuk mendapatkan events (status, request) dari server Node.js.
2
+ -- NVStudio MCP Plugin v2.2.2
3
+ -- Arsitektur: Dual-Mode WebSocket Duplex (Port 3055/ws) + Fallback Long-Polling
4
+ -- Plugin auto-connect saat dimuat via WebSocket native, dengan fallback otomatis
5
+ -- ke HTTP stream jika WebSocket tidak tersedia di environment Roblox Studio ini.
6
6
  -- ══════════════════════════════════════════════════════════════════════
7
7
 
8
8
  local HttpService = game:GetService("HttpService")
@@ -13,6 +13,8 @@ local BASE_URL = "http://localhost:3055"
13
13
  local STREAM_URL = BASE_URL .. "/api/stream" -- SSE long-poll (blocking GET)
14
14
  local RESPONSE_URL = BASE_URL .. "/api/response"
15
15
  local PING_URL = BASE_URL .. "/api/ping"
16
+ local WS_URL = "ws://localhost:3055/ws"
17
+ local WS_URL_IP = "ws://127.0.0.1:3055/ws"
16
18
 
17
19
  -- ID unik per sesi Studio
18
20
  local STUDIO_ID = HttpService:GenerateGUID(false)
@@ -34,18 +36,55 @@ local ctx = {
34
36
  getInstanceFromPath = nil -- akan di-bind di bawah
35
37
  }
36
38
  local MAX_LOGS = 50
37
- local isRunning = false -- apakah loop aktif
39
+ local isRunning = false -- apakah loop/koneksi aktif
38
40
  local isManualDisconnect = false
39
41
  local isClickDebounce = false
40
42
  local mcpAgentActive = false -- apakah Antigravity sedang konek ke MCP
41
43
  local serverReachable = false -- apakah server Node.js reachable
44
+ local activeWsWrapper = nil
45
+ local currentTransport = "none" -- "websocket" | "longpoll" | "none"
42
46
 
43
- -- Reconnect config
47
+ -- Reconnect config untuk Long-Polling
44
48
  local RECONNECT_DELAY_INITIAL = 1.5
45
49
  local RECONNECT_DELAY_MAX = 1.5
46
50
  local reconnectDelay = RECONNECT_DELAY_INITIAL
47
51
  local currentLoopId = nil
48
52
 
53
+ -- Task deduplication state (Bug G)
54
+ local runningTasks = {} -- taskId -> true
55
+ local completedTasks = {} -- taskId -> os.clock()
56
+
57
+ local function shouldProcessTask(taskId)
58
+ if not taskId or taskId == "" then return false end
59
+ if runningTasks[taskId] then
60
+ return false
61
+ end
62
+ local completedAt = completedTasks[taskId]
63
+ if completedAt and (os.clock() - completedAt < 60) then
64
+ return false
65
+ end
66
+ runningTasks[taskId] = true
67
+ return true
68
+ end
69
+
70
+ local function markTaskDone(taskId)
71
+ if not taskId then return end
72
+ runningTasks[taskId] = nil
73
+ completedTasks[taskId] = os.clock()
74
+
75
+ -- Bersihkan entri kedaluwarsa (>60 detik)
76
+ local now = os.clock()
77
+ for id, t in pairs(completedTasks) do
78
+ if now - t > 60 then
79
+ completedTasks[id] = nil
80
+ end
81
+ end
82
+ end
83
+
84
+ -- Forward declaration
85
+ local startConnectionManager = nil
86
+ local startLongPollingLoop = nil
87
+
49
88
  -- ── Skills (ModuleScript) ─────────────────────────────────────────────────────
50
89
  local skills = {}
51
90
  for _, module in ipairs(script.Parent:GetChildren()) do
@@ -88,12 +127,17 @@ if uiModule then
88
127
  reconnectDelay = RECONNECT_DELAY_INITIAL
89
128
  ui:setStatus("waiting")
90
129
  ui:addLog("[System] Menghubungkan ulang ke Server MCP...", Color3.fromRGB(255, 220, 100))
91
- task.spawn(startConnectionLoop)
130
+ task.spawn(startConnectionManager)
92
131
  else
93
132
  isManualDisconnect = true
94
133
  isRunning = false
95
134
  serverReachable = false
96
135
  mcpAgentActive = false
136
+ if activeWsWrapper then
137
+ activeWsWrapper:Close()
138
+ activeWsWrapper = nil
139
+ end
140
+ currentTransport = "none"
97
141
  ui:setStatus("disconnected")
98
142
  ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
99
143
  end
@@ -200,31 +244,325 @@ local function processTask(taskData)
200
244
  return result
201
245
  end
202
246
 
203
- -- ── Kirim Respons ke Server ───────────────────────────────────────────────────
204
- local function sendResponse(taskId, resultData)
247
+ -- ── Helper: Sanitize result sebelum JSONEncode (Bug H) ─────────────────────────
248
+ local function sanitizeForJson(val, depth)
249
+ depth = depth or 0
250
+ if depth > 5 then return tostring(val) end
251
+ local t = typeof(val)
252
+ if t == "string" or t == "number" or t == "boolean" then
253
+ return val
254
+ elseif t == "Instance" then
255
+ return val:GetFullName()
256
+ elseif t == "Vector3" then
257
+ return { X = val.X, Y = val.Y, Z = val.Z }
258
+ elseif t == "CFrame" then
259
+ return { Position = { X = val.Position.X, Y = val.Position.Y, Z = val.Position.Z } }
260
+ elseif t == "Color3" then
261
+ return { R = val.R, G = val.G, B = val.B }
262
+ elseif t == "table" then
263
+ local sanitized = {}
264
+ for k, v in pairs(val) do
265
+ sanitized[tostring(k)] = sanitizeForJson(v, depth + 1)
266
+ end
267
+ return sanitized
268
+ else
269
+ return tostring(val)
270
+ end
271
+ end
272
+
273
+ -- ── Kirim Respons ke Server (HTTP Fallback) (Bug H) ───────────────────────────
274
+ local function sendResponse(taskId, resultData, attempt)
205
275
  task.spawn(function()
206
- local ok, err = pcall(function()
207
- local json = HttpService:JSONEncode(resultData)
208
- local success = pcall(function()
209
- HttpService:PostAsync(
210
- RESPONSE_URL .. "/" .. taskId,
211
- json,
212
- Enum.HttpContentType.ApplicationJson
213
- )
214
- end)
215
- if not success then
276
+ local payload = {
277
+ id = taskId,
278
+ taskId = taskId,
279
+ attempt = attempt,
280
+ status = resultData.status or "success",
281
+ result = sanitizeForJson(resultData.result),
282
+ error = resultData.error and tostring(resultData.error) or nil,
283
+ }
284
+
285
+ local encOk, json = pcall(function()
286
+ return HttpService:JSONEncode(payload)
287
+ end)
288
+
289
+ if not encOk then
290
+ payload.result = tostring(payload.result)
291
+ pcall(function() json = HttpService:JSONEncode(payload) end)
292
+ end
293
+
294
+ if not json then
295
+ json = string.format('{"id":"%s","taskId":"%s","status":"error","error":"Gagal encode payload JSON"}', taskId, taskId)
296
+ end
297
+
298
+ local postOk = pcall(function()
299
+ HttpService:PostAsync(
300
+ RESPONSE_URL .. "/" .. taskId,
301
+ json,
302
+ Enum.HttpContentType.ApplicationJson
303
+ )
304
+ end)
305
+
306
+ if not postOk then
307
+ -- Backoff sejenak sebelum fallback ke endpoint legacy (Bug H)
308
+ task.wait(0.2)
309
+ local fallbackOk = pcall(function()
216
310
  HttpService:PostAsync(RESPONSE_URL, json, Enum.HttpContentType.ApplicationJson)
311
+ end)
312
+ if not fallbackOk and ui then
313
+ ui:addLog("[System] Gagal kirim response HTTP ke server: " .. tostring(taskId), Color3.fromRGB(255, 100, 100))
217
314
  end
218
- end)
219
- if not ok and ui then
220
- ui:addLog("[System] Gagal kirim response: " .. tostring(err), Color3.fromRGB(255, 100, 100))
221
315
  end
222
316
  end)
223
317
  end
224
318
 
225
- -- ── Proses satu event dari SSE stream ────────────────────────────────────────
319
+ -- ── WebSocket API Abstraction ────────────────────────────────────────────────
320
+ local function detectWebSocketCapability()
321
+ -- 1. Check HttpService:CreateWebStreamClient (Roblox Studio native API)
322
+ local hasWebStream = false
323
+ pcall(function()
324
+ hasWebStream = (typeof(HttpService.CreateWebStreamClient) == "function")
325
+ and (Enum.WebStreamClientType and Enum.WebStreamClientType.WebSocket ~= nil)
326
+ end)
327
+ if hasWebStream then
328
+ return "WebStreamClient"
329
+ end
330
+
331
+ -- 2. Check global WebSocket API jika tersedia
332
+ local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket")
333
+ if not wsGlobal then
334
+ pcall(function() wsGlobal = WebSocket end)
335
+ end
336
+ if wsGlobal and typeof(wsGlobal.connect) == "function" then
337
+ return "WebSocketGlobal"
338
+ end
339
+
340
+ return nil
341
+ end
342
+
343
+ local function createWsClient(url)
344
+ local cap = detectWebSocketCapability()
345
+ if not cap then
346
+ return nil, "WebSocket API not available"
347
+ end
348
+
349
+ if cap == "WebStreamClient" then
350
+ local ok, client = pcall(function()
351
+ return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
352
+ Url = url
353
+ })
354
+ end)
355
+ if not ok or not client then
356
+ return nil, tostring(client or "CreateWebStreamClient failed")
357
+ end
358
+
359
+ local wrapper = {
360
+ raw = client,
361
+ cap = "WebStreamClient",
362
+ closed = false,
363
+ connections = {},
364
+ }
365
+
366
+ function wrapper:Send(data)
367
+ if self.closed then return false, "Closed" end
368
+ return pcall(function()
369
+ self.raw:Send(data)
370
+ end)
371
+ end
372
+
373
+ function wrapper:Close()
374
+ if self.closed then return end
375
+ self.closed = true
376
+ pcall(function() self.raw:Close() end)
377
+ for _, conn in ipairs(self.connections) do
378
+ pcall(function() conn:Disconnect() end)
379
+ end
380
+ table.clear(self.connections)
381
+ end
382
+
383
+ function wrapper:BindEvents(onMessage, onClose)
384
+ if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
385
+ local ok, conn = pcall(function()
386
+ return self.raw.MessageReceived:Connect(onMessage)
387
+ end)
388
+ if ok and conn then table.insert(self.connections, conn) end
389
+ end
390
+
391
+ if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
392
+ local ok, conn = pcall(function()
393
+ return self.raw.Closed:Connect(onClose)
394
+ end)
395
+ if ok and conn then table.insert(self.connections, conn) end
396
+ end
397
+ end
398
+
399
+ return wrapper, nil
400
+
401
+ elseif cap == "WebSocketGlobal" then
402
+ local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket") or WebSocket
403
+ local ok, client = pcall(function()
404
+ return wsGlobal.connect(url)
405
+ end)
406
+ if not ok or not client then
407
+ return nil, tostring(client or "WebSocket.connect failed")
408
+ end
409
+
410
+ local wrapper = {
411
+ raw = client,
412
+ cap = "WebSocketGlobal",
413
+ closed = false,
414
+ connections = {},
415
+ }
416
+
417
+ function wrapper:Send(data)
418
+ if self.closed then return false, "Closed" end
419
+ return pcall(function()
420
+ if typeof(self.raw.Send) == "function" then
421
+ self.raw:Send(data)
422
+ elseif typeof(self.raw.send) == "function" then
423
+ self.raw:send(data)
424
+ end
425
+ end)
426
+ end
427
+
428
+ function wrapper:Close()
429
+ if self.closed then return end
430
+ self.closed = true
431
+ pcall(function()
432
+ if typeof(self.raw.Close) == "function" then
433
+ self.raw:Close()
434
+ elseif typeof(self.raw.close) == "function" then
435
+ self.raw:close()
436
+ end
437
+ end)
438
+ for _, conn in ipairs(self.connections) do
439
+ pcall(function() conn:Disconnect() end)
440
+ end
441
+ table.clear(self.connections)
442
+ end
443
+
444
+ function wrapper:BindEvents(onMessage, onClose)
445
+ if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
446
+ local ok, conn = pcall(function() return self.raw.MessageReceived:Connect(onMessage) end)
447
+ if ok and conn then table.insert(self.connections, conn) end
448
+ elseif self.raw.OnMessage then
449
+ if typeof(self.raw.OnMessage) == "RBXScriptSignal" or (typeof(self.raw.OnMessage) == "userdata" and self.raw.OnMessage.Connect) then
450
+ local ok, conn = pcall(function() return self.raw.OnMessage:Connect(onMessage) end)
451
+ if ok and conn then table.insert(self.connections, conn) end
452
+ else
453
+ pcall(function() self.raw.OnMessage = onMessage end)
454
+ end
455
+ end
456
+
457
+ if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
458
+ local ok, conn = pcall(function() return self.raw.Closed:Connect(onClose) end)
459
+ if ok and conn then table.insert(self.connections, conn) end
460
+ elseif self.raw.OnClose then
461
+ if typeof(self.raw.OnClose) == "RBXScriptSignal" or (typeof(self.raw.OnClose) == "userdata" and self.raw.OnClose.Connect) then
462
+ local ok, conn = pcall(function() return self.raw.OnClose:Connect(onClose) end)
463
+ if ok and conn then table.insert(self.connections, conn) end
464
+ else
465
+ pcall(function() self.raw.OnClose = onClose end)
466
+ end
467
+ end
468
+ end
469
+
470
+ return wrapper, nil
471
+ end
472
+
473
+ return nil, "Unsupported"
474
+ end
475
+
476
+ -- ── WebSocket Message Dispatcher ─────────────────────────────────────────────
477
+ local function handleWsMessage(wrapper, rawMessage)
478
+ if type(rawMessage) ~= "string" or rawMessage == "" then return end
479
+ local ok, decoded = pcall(function() return HttpService:JSONDecode(rawMessage) end)
480
+ if not ok or type(decoded) ~= "table" then return end
481
+
482
+ local kind = decoded.kind
483
+
484
+ -- Heartbeat ping -> balas pong
485
+ if kind == "ping" then
486
+ pcall(function()
487
+ wrapper:Send(HttpService:JSONEncode({ kind = "pong", ts = os.time() }))
488
+ end)
489
+ return
490
+ end
491
+
492
+ if kind == "pong" then
493
+ return
494
+ end
495
+
496
+ -- Update status koneksi MCP / Studio
497
+ if kind == "status" then
498
+ local newActive = decoded.mcpConnected == true
499
+ local changed = (mcpAgentActive ~= newActive)
500
+ mcpAgentActive = newActive
501
+ if ui then
502
+ if mcpAgentActive then
503
+ ui:setStatus("connected")
504
+ else
505
+ ui:setStatus("server_connected")
506
+ end
507
+ if newActive and changed then
508
+ ui:addLog("[System] ✅ IDE Agent (Antigravity) aktif ⚡", Color3.fromRGB(100, 255, 100))
509
+ elseif not newActive and changed then
510
+ ui:addLog("[System] ⚠️ IDE Agent terputus.", Color3.fromRGB(255, 150, 50))
511
+ end
512
+ end
513
+ return
514
+ end
515
+
516
+ -- Eksekusi perintah AI
517
+ if kind == "request" then
518
+ local taskId = decoded.taskId or decoded.requestId
519
+ local attempt = decoded.attempt
520
+ local command = decoded.command
521
+ local target = decoded.target
522
+ local data = decoded.data
523
+
524
+ if not taskId or not command then return end
525
+ if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
526
+
527
+ task.spawn(function()
528
+ local resultData = processTask({
529
+ id = taskId,
530
+ command = command,
531
+ target = target or "",
532
+ data = data,
533
+ })
534
+ markTaskDone(taskId) -- Bug G: Tandai selesai
535
+ if resultData then
536
+ -- Push respons instan kembali via WebSocket (Zero-Delay)
537
+ local responseEnvelope = {
538
+ kind = "response",
539
+ taskId = taskId,
540
+ id = taskId,
541
+ attempt = attempt,
542
+ status = resultData.status,
543
+ result = sanitizeForJson(resultData.result),
544
+ error = resultData.error and tostring(resultData.error) or nil,
545
+ }
546
+ local encOk, encoded = pcall(function()
547
+ return HttpService:JSONEncode(responseEnvelope)
548
+ end)
549
+ local sent = false
550
+ if encOk then
551
+ local sendOk, _ = wrapper:Send(encoded)
552
+ sent = (sendOk == true)
553
+ end
554
+ if not sent then
555
+ -- Jika serialisasi atau pengiriman via WS gagal, fallback ke HTTP POST
556
+ sendResponse(taskId, resultData, attempt)
557
+ end
558
+ end
559
+ end)
560
+ return
561
+ end
562
+ end
563
+
564
+ -- ── Proses satu event dari SSE stream (Long-Polling fallback) ────────────────
226
565
  local function handleSSELine(line)
227
- -- Format SSE: "data: {...json...}"
228
566
  local payload = line
229
567
  if line:sub(1, 6) == "event:" then return end
230
568
  if line:sub(1, 5) == "data:" then
@@ -237,12 +575,10 @@ local function handleSSELine(line)
237
575
 
238
576
  local kind = decoded.kind
239
577
 
240
- -- heartbeat: server masih hidup
241
578
  if kind == "heartbeat" then
242
579
  return
243
580
  end
244
581
 
245
- -- status: update mcpConnected
246
582
  if kind == "status" then
247
583
  local newActive = decoded.mcpConnected == true
248
584
  local changed = (mcpAgentActive ~= newActive)
@@ -263,14 +599,15 @@ local function handleSSELine(line)
263
599
  return
264
600
  end
265
601
 
266
- -- request: ada task dari AI agent
267
602
  if kind == "request" then
268
- local taskId = decoded.requestId
603
+ local taskId = decoded.requestId or decoded.taskId
604
+ local attempt = decoded.attempt
269
605
  local command = decoded.command
270
606
  local target = decoded.target
271
607
  local data = decoded.data
272
608
 
273
609
  if not taskId or not command then return end
610
+ if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
274
611
 
275
612
  task.spawn(function()
276
613
  local resultData = processTask({
@@ -279,39 +616,34 @@ local function handleSSELine(line)
279
616
  target = target or "",
280
617
  data = data,
281
618
  })
619
+ markTaskDone(taskId) -- Bug G: Tandai selesai
282
620
  if resultData then
283
- sendResponse(taskId, resultData)
621
+ sendResponse(taskId, resultData, attempt)
284
622
  end
285
623
  end)
286
624
  return
287
625
  end
288
626
  end
289
627
 
290
- -- ── Loop Utama: Ping → Stream → Parse SSE lines ───────────────────────────────
291
- -- Roblox HttpService:GetAsync() tidak mendukung streaming, jadi kita
292
- -- menggunakan strategi: GET /api/stream dengan waktu hold singkat (5 detik).
293
- -- Server mengirim semua events yang tertunda lalu tutup koneksi.
294
- -- Plugin reconnect langsung untuk sesi berikutnya.
295
- function startConnectionLoop()
296
- if isRunning then return end
297
- isRunning = true
298
- isManualDisconnect = false
628
+ -- ── Fallback Loop: Long-Polling (/api/stream) ─────────────────────────────────
629
+ function startLongPollingLoop()
630
+ if not isRunning or isManualDisconnect then return end
631
+ currentTransport = "longpoll"
299
632
  reconnectDelay = RECONNECT_DELAY_INITIAL
300
633
 
301
634
  local loopId = HttpService:GenerateGUID(false)
302
635
  currentLoopId = loopId
303
636
 
304
637
  if ui then
305
- ui:addLog("[System] NVStudio MCP v2.1.8 — Menghubungkan ke server...", Color3.fromRGB(200, 200, 200))
638
+ ui:addLog("[System] Mode Long-Polling aktif (Port 3055)...", Color3.fromRGB(200, 200, 200))
306
639
  end
307
640
 
308
641
  local consecutiveErrors = 0
309
642
 
310
- while isRunning and not isManualDisconnect and currentLoopId == loopId do
311
- -- Stream / polling snapshot dari server
643
+ while isRunning and not isManualDisconnect and currentLoopId == loopId and currentTransport == "longpoll" do
312
644
  local streamOk, streamRaw = pcall(function()
313
645
  return HttpService:GetAsync(
314
- STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID),
646
+ STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID) .. "&placeId=" .. tostring(game.PlaceId),
315
647
  false
316
648
  )
317
649
  end)
@@ -324,28 +656,26 @@ function startConnectionLoop()
324
656
  serverReachable = true
325
657
  if ui then
326
658
  ui:setStatus(mcpAgentActive and "connected" or "server_connected")
327
- ui:addLog("[System] ✅ Server MCP terhubung & siap.", Color3.fromRGB(100, 255, 150))
659
+ ui:addLog("[System] ✅ Server MCP terhubung via Long-Polling.", Color3.fromRGB(100, 255, 150))
328
660
  end
329
661
  end
330
662
 
331
- -- Parse baris-baris SSE
332
663
  for line in (streamRaw .. "\n"):gmatch("([^\n]*)\n") do
333
664
  line = line:gsub("\r", "")
334
665
  if line ~= "" then
335
666
  handleSSELine(line)
336
667
  end
337
668
  end
338
- task.wait(0.05) -- Instant loop: server menahan request (hanging GET) saat idle
669
+ task.wait()
339
670
  else
340
671
  consecutiveErrors = consecutiveErrors + 1
341
- -- Toleransi 2x gagal sebelum menandai server unreachable
342
672
  if consecutiveErrors >= 2 then
343
673
  if serverReachable then
344
674
  serverReachable = false
345
675
  mcpAgentActive = false
346
676
  if ui then
347
677
  ui:setStatus("waiting")
348
- ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau. Mencoba menghubungkan ulang...", Color3.fromRGB(255, 150, 50))
678
+ ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau. Menghubungkan ulang...", Color3.fromRGB(255, 150, 50))
349
679
  end
350
680
  end
351
681
  end
@@ -355,22 +685,123 @@ function startConnectionLoop()
355
685
  end
356
686
  end
357
687
 
358
- isRunning = false
359
- serverReachable = false
360
- mcpAgentActive = false
361
- if ui then ui:setStatus("disconnected") end
688
+ if currentTransport == "longpoll" and (not isRunning or isManualDisconnect) then
689
+ isRunning = false
690
+ serverReachable = false
691
+ mcpAgentActive = false
692
+ if ui then ui:setStatus("disconnected") end
693
+ end
694
+ end
695
+
696
+ -- ── Connection Manager: WebSocket Utama + Fallback Long-Polling ──────────────
697
+ function startConnectionManager()
698
+ if isRunning then return end
699
+ isRunning = true
700
+ isManualDisconnect = false
701
+
702
+ local cap = detectWebSocketCapability()
703
+ if cap then
704
+ task.spawn(function()
705
+ local wsFailures = 0
706
+ local backoff = 0.5
707
+
708
+ while isRunning and not isManualDisconnect and wsFailures < 3 do
709
+ currentTransport = "websocket"
710
+ if ui then
711
+ ui:setStatus("waiting")
712
+ ui:addLog("[System] Menghubungkan via WebSocket (Port 3055)...", Color3.fromRGB(200, 200, 255))
713
+ end
714
+
715
+ local wrapper, err = createWsClient(WS_URL)
716
+ if not wrapper then
717
+ -- Coba fallback ke IP 127.0.0.1
718
+ wrapper, err = createWsClient(WS_URL_IP)
719
+ end
720
+
721
+ if wrapper then
722
+ activeWsWrapper = wrapper
723
+ serverReachable = true
724
+ if ui then
725
+ ui:setStatus(mcpAgentActive and "connected" or "server_connected")
726
+ ui:addLog("[System] ⚡ WebSocket Terhubung! (Ultra Low-Latency <1ms)", Color3.fromRGB(100, 255, 150))
727
+ end
728
+
729
+ -- Kirim pesan registrasi awal
730
+ local regOk = wrapper:Send(HttpService:JSONEncode({
731
+ kind = "register",
732
+ studioId = STUDIO_ID,
733
+ placeId = game.PlaceId,
734
+ pluginsVersion = 2,
735
+ }))
736
+
737
+ if regOk then
738
+ wsFailures = 0
739
+ backoff = 0.5
740
+
741
+ local closedSignal = Instance.new("BindableEvent")
742
+ wrapper:BindEvents(
743
+ function(msg)
744
+ handleWsMessage(wrapper, msg)
745
+ end,
746
+ function()
747
+ closedSignal:Fire()
748
+ end
749
+ )
750
+
751
+ closedSignal.Event:Wait()
752
+ closedSignal:Destroy()
753
+ end
754
+
755
+ if activeWsWrapper == wrapper then
756
+ activeWsWrapper = nil
757
+ end
758
+ wrapper:Close()
759
+ end
760
+
761
+ if isManualDisconnect or not isRunning then break end
762
+
763
+ wsFailures = wsFailures + 1
764
+ if wsFailures < 3 then
765
+ if ui then
766
+ ui:setStatus("waiting")
767
+ ui:addLog("[System] ⚠️ WebSocket terputus. Reconnect dalam " .. string.format("%.1f", backoff) .. "s...", Color3.fromRGB(255, 180, 50))
768
+ end
769
+ task.wait(backoff)
770
+ backoff = math.min(backoff * 2, 5.0)
771
+ end
772
+ end
773
+
774
+ -- Jika WS gagal 3x berturut-turut dan plugin masih aktif -> Beralih ke Long-Polling!
775
+ if isRunning and not isManualDisconnect then
776
+ if ui then
777
+ ui:addLog("[System] ℹ️ Mengalihkan ke Mode Fallback Long-Polling...", Color3.fromRGB(255, 200, 100))
778
+ end
779
+ startLongPollingLoop()
780
+ end
781
+ end)
782
+ else
783
+ -- WebSocket tidak didukung di environment Studio ini -> langsung Long-Polling
784
+ if ui then
785
+ ui:addLog("[System] ℹ️ WebSocket tidak terdeteksi. Menggunakan Long-Polling.", Color3.fromRGB(200, 200, 200))
786
+ end
787
+ startLongPollingLoop()
788
+ end
362
789
  end
363
790
 
364
791
  -- ── Plugin Unloading ──────────────────────────────────────────────────────────
365
792
  plugin.Unloading:Connect(function()
366
793
  isManualDisconnect = true
367
794
  isRunning = false
795
+ if activeWsWrapper then
796
+ activeWsWrapper:Close()
797
+ activeWsWrapper = nil
798
+ end
368
799
  end)
369
800
 
370
801
  -- ══════════════════════════════════════════════════════════════════════
371
- -- STARTUP: Langsung mulai connection loop
802
+ -- STARTUP: Langsung mulai connection manager
372
803
  -- ══════════════════════════════════════════════════════════════════════
373
804
  task.spawn(function()
374
805
  task.wait(0.5) -- Tunggu UI siap
375
- startConnectionLoop()
806
+ startConnectionManager()
376
807
  end)