@adiholick/nvstudio-mcp 2.2.3 → 2.3.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.
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ const task_queue_1 = require("./task-queue");
12
12
  const fs_1 = __importDefault(require("fs"));
13
13
  const path_1 = __importDefault(require("path"));
14
14
  const luau_validator_1 = require("./luau-validator");
15
+ const roblox_docs_1 = require("./roblox-docs");
15
16
  const HISTORY_DIR = path_1.default.join(__dirname, '..', '.history');
16
17
  if (!fs_1.default.existsSync(HISTORY_DIR)) {
17
18
  fs_1.default.mkdirSync(HISTORY_DIR, { recursive: true });
@@ -377,9 +378,64 @@ const TOOLS = [
377
378
  required: ["material", "position", "size"],
378
379
  },
379
380
  },
381
+ {
382
+ name: "preview_asset",
383
+ description: "Pratinjau hierarki dan pemindaian keamanan aset Creator Store secara terisolasi sebelum dimasukkan ke dalam game. Mendeteksi skrip backdoor, require eksternal numerik, obfuscation, webhook discord, dan virus script tanpa risiko mengeksekusi kode berbahaya.",
384
+ inputSchema: {
385
+ type: "object",
386
+ properties: {
387
+ asset_id: {
388
+ type: "string",
389
+ description: "Roblox Asset ID angka (atau rbxassetid://) dari Creator Store.",
390
+ },
391
+ deep_scan: {
392
+ type: "boolean",
393
+ description: "Jika true (default), memindai dan menganalisis source code seluruh skrip di dalam model.",
394
+ },
395
+ placeId: {
396
+ type: "number",
397
+ description: "Target Place ID untuk multi-Studio routing.",
398
+ },
399
+ studioId: {
400
+ type: "string",
401
+ description: "Target Studio session ID.",
402
+ },
403
+ },
404
+ required: ["asset_id"],
405
+ },
406
+ },
407
+ {
408
+ name: "scan_security",
409
+ description: "Audit keamanan instance atau model yang sudah ada di dalam game (Workspace, ServerScriptService, dll.) untuk mendeteksi skrip backdoor, virus, webhook discord, dan kode berbahaya.",
410
+ inputSchema: {
411
+ type: "object",
412
+ properties: {
413
+ target: {
414
+ type: "string",
415
+ description: "Path instance atau folder yang ingin diaudit (default: 'Workspace').",
416
+ },
417
+ deep_scan: {
418
+ type: "boolean",
419
+ description: "Jika true (default), memindai isi source code skrip.",
420
+ },
421
+ clean_threats: {
422
+ type: "boolean",
423
+ description: "Jika true, secara otomatis menghapus skrip berbahaya yang terdeteksi (default: false).",
424
+ },
425
+ placeId: {
426
+ type: "number",
427
+ description: "Target Place ID untuk multi-Studio routing.",
428
+ },
429
+ studioId: {
430
+ type: "string",
431
+ description: "Target Studio session ID.",
432
+ },
433
+ },
434
+ },
435
+ },
380
436
  {
381
437
  name: "insert_asset",
382
- description: "Muat dan masukkan aset Roblox (Model/Package) berdasarkan Asset ID ke dalam Workspace atau target tertentu.",
438
+ description: "Muat dan masukkan aset Roblox (Model/Package) berdasarkan Asset ID ke dalam Workspace atau target tertentu dengan proteksi sanitasi skrip otomatis.",
383
439
  inputSchema: {
384
440
  type: "object",
385
441
  properties: {
@@ -391,6 +447,14 @@ const TOOLS = [
391
447
  type: "string",
392
448
  description: "Parent tujuan tempat menempatkan model (default: 'Workspace').",
393
449
  },
450
+ strip_scripts: {
451
+ type: "boolean",
452
+ description: "Jika true (default: true), secara otomatis menghapus semua skrip dari model sebelum dimasukkan ke game demi keamanan mutlak dari backdoor.",
453
+ },
454
+ scan_security: {
455
+ type: "boolean",
456
+ description: "Jika true (default: true), memindai potensi ancaman keamanan sebelum insert.",
457
+ },
394
458
  placeId: {
395
459
  type: "number",
396
460
  description: "Target Place ID untuk multi-Studio routing.",
@@ -475,7 +539,7 @@ const TOOLS = [
475
539
  },
476
540
  {
477
541
  name: "call_mcp_tool",
478
- description: "Legacy tool dispatcher untuk kompatibilitas mundur. Mendukung semua command: execute_luau, get_children, get_full_tree, get_script_source, update_script_source, patch_script_source, rollback_script, get_properties, set_properties, create_instance, delete_instance, search_instance, generate_terrain, insert_asset, search_asset, script_grep, get_logs.",
542
+ description: "Legacy tool dispatcher untuk kompatibilitas mundur. Mendukung semua command: execute_luau, get_children, get_full_tree, get_script_source, update_script_source, patch_script_source, rollback_script, get_properties, set_properties, create_instance, delete_instance, search_instance, generate_terrain, insert_asset, preview_asset, scan_security, batch_create_instances, batch_set_properties, search_asset, script_grep, get_roblox_docs, get_logs.",
479
543
  inputSchema: {
480
544
  type: "object",
481
545
  properties: {
@@ -503,6 +567,80 @@ const TOOLS = [
503
567
  required: ["command"],
504
568
  },
505
569
  },
570
+ {
571
+ name: "get_roblox_docs",
572
+ description: "Ambil dokumentasi resmi Roblox Engine API langsung dari Creator Docs (Classes, Services, Data types, Enums, dan Libraries). Mengembalikan format Markdown lengkap beserta signature fungsi, deskripsi parameter, properti, event, dan contoh kode untuk mencegah halusinasi kode Luau.",
573
+ inputSchema: {
574
+ type: "object",
575
+ properties: {
576
+ query: {
577
+ type: "string",
578
+ description: "Nama Class, Service, Data type, Enum, atau Library Roblox (contoh: 'TweenService', 'CanvasGroup', 'Vector3', 'EasingStyle', 'task').",
579
+ },
580
+ member: {
581
+ type: "string",
582
+ description: "Opsional: Nama method, property, event, atau enum item spesifik untuk menghemat token (contoh: 'Create', 'GroupTransparency', 'Completed').",
583
+ },
584
+ category: {
585
+ type: "string",
586
+ enum: ["class", "datatype", "enum", "library", "auto"],
587
+ description: "Opsional: Kategori dokumentasi (default: 'auto').",
588
+ },
589
+ },
590
+ required: ["query"],
591
+ },
592
+ },
593
+ {
594
+ name: "batch_create_instances",
595
+ description: "Pembangun instance massal dan deklaratif dalam satu kali pemanggilan. Mendukung mode hierarki bersarang (tree) atau daftar array datar (instances) lengkap dengan penyetelan properti langsung (UDim, Vector2, UDim2, Color3, Enum, dll.) untuk menghemat token dan latensi jaringan.",
596
+ inputSchema: {
597
+ type: "object",
598
+ properties: {
599
+ target: {
600
+ type: "string",
601
+ description: "Path root tujuan tempat menaruh instance (contoh: 'StarterGui.MyScreen', 'Workspace.Map').",
602
+ },
603
+ tree: {
604
+ type: "object",
605
+ description: "Objek hierarki bersarang { className, name, properties, children: [...] } untuk membangun struktur pohon secara rekursif.",
606
+ },
607
+ instances: {
608
+ type: "array",
609
+ description: "Daftar array datar [{ className, name, parent, properties }] jika tidak menggunakan mode tree.",
610
+ },
611
+ placeId: {
612
+ type: "number",
613
+ description: "Target Place ID untuk multi-Studio routing.",
614
+ },
615
+ studioId: {
616
+ type: "string",
617
+ description: "Target Studio session ID.",
618
+ },
619
+ },
620
+ },
621
+ },
622
+ {
623
+ name: "batch_set_properties",
624
+ description: "Ubah properti pada banyak instance sekaligus dalam satu kali request atomik.",
625
+ inputSchema: {
626
+ type: "object",
627
+ properties: {
628
+ updates: {
629
+ type: "array",
630
+ description: "Array objek pembaruan: [{ target: 'Path.To.Instance', properties: { PropName: value, ... } }].",
631
+ },
632
+ placeId: {
633
+ type: "number",
634
+ description: "Target Place ID untuk multi-Studio routing.",
635
+ },
636
+ studioId: {
637
+ type: "string",
638
+ description: "Target Studio session ID.",
639
+ },
640
+ },
641
+ required: ["updates"],
642
+ },
643
+ },
506
644
  ];
507
645
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
508
646
  return { tools: TOOLS };
@@ -752,11 +890,41 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
752
890
  });
753
891
  break;
754
892
  }
893
+ case "preview_asset": {
894
+ const assetId = String(args.asset_id ?? "");
895
+ if (!assetId)
896
+ throw new Error("Parameter 'asset_id' wajib diisi.");
897
+ const payload = JSON.stringify({
898
+ asset_id: assetId,
899
+ deep_scan: args.deep_scan !== false,
900
+ });
901
+ result = await dispatchTaskToStudio("preview_asset", "", payload, {
902
+ placeId: args.placeId,
903
+ studioId: args.studioId,
904
+ });
905
+ break;
906
+ }
907
+ case "scan_security": {
908
+ const payload = JSON.stringify({
909
+ deep_scan: args.deep_scan !== false,
910
+ clean_threats: args.clean_threats === true,
911
+ });
912
+ result = await dispatchTaskToStudio("scan_security", args.target || "Workspace", payload, {
913
+ placeId: args.placeId,
914
+ studioId: args.studioId,
915
+ });
916
+ break;
917
+ }
755
918
  case "insert_asset": {
756
919
  const assetId = String(args.asset_id ?? "");
757
920
  if (!assetId)
758
921
  throw new Error("Parameter 'asset_id' wajib diisi.");
759
- result = await dispatchTaskToStudio("insert_asset", args.target || "Workspace", assetId, {
922
+ const payload = JSON.stringify({
923
+ asset_id: assetId,
924
+ strip_scripts: args.strip_scripts !== false,
925
+ scan_security: args.scan_security !== false,
926
+ });
927
+ result = await dispatchTaskToStudio("insert_asset", args.target || "Workspace", payload, {
760
928
  placeId: args.placeId,
761
929
  studioId: args.studioId,
762
930
  });
@@ -802,6 +970,39 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
802
970
  }
803
971
  break;
804
972
  }
973
+ case "get_roblox_docs": {
974
+ const query = String(args.query ?? "").trim();
975
+ if (!query)
976
+ throw new Error("Parameter 'query' wajib diisi.");
977
+ const member = args.member ? String(args.member).trim() : undefined;
978
+ const category = args.category ? String(args.category).trim() : "auto";
979
+ result = await (0, roblox_docs_1.getRobloxDocs)(query, member, category);
980
+ break;
981
+ }
982
+ case "batch_create_instances": {
983
+ const payload = JSON.stringify({
984
+ target: args.target,
985
+ tree: args.tree,
986
+ instances: args.instances,
987
+ });
988
+ result = await dispatchTaskToStudio("batch_create_instances", args.target || "Workspace", payload, {
989
+ placeId: args.placeId,
990
+ studioId: args.studioId,
991
+ });
992
+ break;
993
+ }
994
+ case "batch_set_properties": {
995
+ if (!args.updates || !Array.isArray(args.updates))
996
+ throw new Error("Parameter 'updates' wajib berupa array.");
997
+ const payload = JSON.stringify({
998
+ updates: args.updates,
999
+ });
1000
+ result = await dispatchTaskToStudio("batch_set_properties", "", payload, {
1001
+ placeId: args.placeId,
1002
+ studioId: args.studioId,
1003
+ });
1004
+ break;
1005
+ }
805
1006
  case "call_mcp_tool": {
806
1007
  const { command, target, data, placeId, studioId } = args;
807
1008
  if (!command)
@@ -815,6 +1016,18 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
815
1016
  if (target)
816
1017
  backupScript(target, String(data));
817
1018
  }
1019
+ // Intercept get_roblox_docs di Node.js
1020
+ if (command === "get_roblox_docs") {
1021
+ const queryParams = typeof data === "string" ? (function () { try {
1022
+ return JSON.parse(data);
1023
+ }
1024
+ catch {
1025
+ return { query: data };
1026
+ } })() : (data || {});
1027
+ const q = String(queryParams.query || target || "").trim();
1028
+ result = await (0, roblox_docs_1.getRobloxDocs)(q, queryParams.member, queryParams.category);
1029
+ break;
1030
+ }
818
1031
  // Intercept search_asset di Node.js
819
1032
  if (command === "search_asset" && data) {
820
1033
  const queryParams = typeof data === "string" ? JSON.parse(data) : data;
@@ -1,15 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.validateLuauSyntax = validateLuauSyntax;
4
+ /**
5
+ * Heuristic pre-commit syntax validator for Luau.
6
+ * Validates bracket parity, string literals, multiline comments,
7
+ * and block keywords (function, do, statement if ... then, repeat ... until, end).
8
+ */
4
9
  function validateLuauSyntax(source) {
5
10
  const lines = source.split('\n');
6
- // Stack for blocks: function, do, if, for, while, repeat
7
- // We'll keep it simple: just count block openers vs block closers (end, until)
8
- // Note: This is a heuristic pre-commit check. It doesn't replace a full AST parser,
9
- // Bracket stacks
10
11
  let parenDepth = 0; // ()
11
12
  let braceDepth = 0; // {}
12
13
  let bracketDepth = 0; // []
14
+ let blockDepth = 0; // function, do, if ... then -> end
15
+ let repeatDepth = 0; // repeat -> until
13
16
  let inString = false;
14
17
  let stringChar = '';
15
18
  let inMultilineString = false;
@@ -18,10 +21,11 @@ function validateLuauSyntax(source) {
18
21
  for (let i = 0; i < lines.length; i++) {
19
22
  const line = lines[i];
20
23
  let j = 0;
24
+ const lineTokens = [];
21
25
  while (j < line.length) {
22
26
  const char = line[j];
23
27
  const nextChar = line[j + 1] || '';
24
- // Handle multiline comments
28
+ // Multiline comment start: --[[ or --[===[
25
29
  if (!inString && !inMultilineString && !inMultilineComment) {
26
30
  if (char === '-' && nextChar === '-') {
27
31
  const nextNextChar = line[j + 2] || '';
@@ -39,7 +43,7 @@ function validateLuauSyntax(source) {
39
43
  continue;
40
44
  }
41
45
  }
42
- // Single line comment, skip rest of line
46
+ // Single-line comment, ignore rest of line
43
47
  break;
44
48
  }
45
49
  }
@@ -60,7 +64,7 @@ function validateLuauSyntax(source) {
60
64
  j++;
61
65
  continue;
62
66
  }
63
- // Handle strings
67
+ // String literals
64
68
  if (!inString && !inMultilineString) {
65
69
  if (char === '"' || char === "'") {
66
70
  inString = true;
@@ -81,10 +85,6 @@ function validateLuauSyntax(source) {
81
85
  j = k + 1;
82
86
  continue;
83
87
  }
84
- // If it ended at end of line, we can't reliably detect it. Let's just avoid bracket counting
85
- if (line[k] === undefined) {
86
- break;
87
- }
88
88
  }
89
89
  }
90
90
  if (inString) {
@@ -115,36 +115,97 @@ function validateLuauSyntax(source) {
115
115
  j++;
116
116
  continue;
117
117
  }
118
- // At this point, we are outside strings and comments.
119
- // Check brackets
120
- if (!inString && !inMultilineString && !inMultilineComment) {
121
- if (char === '(')
122
- parenDepth++;
123
- else if (char === ')') {
124
- parenDepth--;
125
- if (parenDepth < 0)
126
- return { valid: false, error: 'Kelebihan tutup kurung ")"', line: i + 1 };
127
- }
128
- else if (char === '{')
129
- braceDepth++;
130
- else if (char === '}') {
131
- braceDepth--;
132
- if (braceDepth < 0)
133
- return { valid: false, error: 'Kelebihan tutup kurawal "}"', line: i + 1 };
134
- }
135
- else if (char === '[')
136
- bracketDepth++;
137
- else if (char === ']') {
138
- bracketDepth--;
139
- if (bracketDepth < 0)
140
- return { valid: false, error: 'Kelebihan tutup siku "]"', line: i + 1 };
118
+ // Outside strings & comments: collect bracket depth & tokens
119
+ if (char === '(') {
120
+ parenDepth++;
121
+ lineTokens.push('(');
122
+ }
123
+ else if (char === ')') {
124
+ parenDepth--;
125
+ lineTokens.push(')');
126
+ if (parenDepth < 0)
127
+ return { valid: false, error: 'Kelebihan tutup kurung ")"', line: i + 1 };
128
+ }
129
+ else if (char === '{') {
130
+ braceDepth++;
131
+ lineTokens.push('{');
132
+ }
133
+ else if (char === '}') {
134
+ braceDepth--;
135
+ lineTokens.push('}');
136
+ if (braceDepth < 0)
137
+ return { valid: false, error: 'Kelebihan tutup kurawal "}"', line: i + 1 };
138
+ }
139
+ else if (char === '[') {
140
+ bracketDepth++;
141
+ lineTokens.push('[');
142
+ }
143
+ else if (char === ']') {
144
+ bracketDepth--;
145
+ lineTokens.push(']');
146
+ if (bracketDepth < 0)
147
+ return { valid: false, error: 'Kelebihan tutup siku "]"', line: i + 1 };
148
+ }
149
+ else if (char === '=') {
150
+ lineTokens.push('=');
151
+ }
152
+ else if (char === ',') {
153
+ lineTokens.push(',');
154
+ }
155
+ else if (/[a-zA-Z_]/.test(char)) {
156
+ let start = j;
157
+ while (j < line.length && /[a-zA-Z0-9_]/.test(line[j])) {
158
+ j++;
141
159
  }
160
+ lineTokens.push(line.substring(start, j));
161
+ continue;
142
162
  }
143
163
  j++;
144
164
  }
145
165
  if (inString) {
146
166
  return { valid: false, error: 'String tidak ditutup (unclosed string literal)', line: i + 1 };
147
167
  }
168
+ // Process line tokens for block balance
169
+ let statementIfCount = 0;
170
+ for (let t = 0; t < lineTokens.length; t++) {
171
+ const tok = lineTokens[t];
172
+ const prevTok = t > 0 ? lineTokens[t - 1] : null;
173
+ if (tok === 'function') {
174
+ blockDepth++;
175
+ }
176
+ else if (tok === 'do') {
177
+ blockDepth++;
178
+ }
179
+ else if (tok === 'if') {
180
+ // Luau expression if is preceded by '=', 'return', '(', ',', '{', '[', 'and', 'or', 'not'
181
+ const isExpr = prevTok && ['=', 'return', '(', ',', '{', '[', 'and', 'or', 'not'].includes(prevTok);
182
+ if (!isExpr) {
183
+ statementIfCount++;
184
+ }
185
+ }
186
+ else if (tok === 'then') {
187
+ const isElseIf = lineTokens.includes('elseif');
188
+ if (!isElseIf) {
189
+ if (statementIfCount > 0) {
190
+ blockDepth++;
191
+ statementIfCount--;
192
+ }
193
+ }
194
+ }
195
+ else if (tok === 'repeat') {
196
+ repeatDepth++;
197
+ }
198
+ else if (tok === 'until') {
199
+ repeatDepth--;
200
+ if (repeatDepth < 0)
201
+ return { valid: false, error: 'Kelebihan kata kunci "until"', line: i + 1 };
202
+ }
203
+ else if (tok === 'end') {
204
+ blockDepth--;
205
+ if (blockDepth < 0)
206
+ return { valid: false, error: 'Kelebihan kata kunci "end"', line: i + 1 };
207
+ }
208
+ }
148
209
  }
149
210
  if (inMultilineString)
150
211
  return { valid: false, error: 'Multiline string tidak ditutup (unclosed [[ ]])' };
@@ -156,5 +217,9 @@ function validateLuauSyntax(source) {
156
217
  return { valid: false, error: 'Kekurangan tutup kurawal "}"' };
157
218
  if (bracketDepth > 0)
158
219
  return { valid: false, error: 'Kekurangan tutup siku "]"' };
220
+ if (repeatDepth > 0)
221
+ return { valid: false, error: 'Kekurangan kata kunci "until"' };
222
+ if (blockDepth > 0)
223
+ return { valid: false, error: 'Kekurangan kata kunci "end" (blok tidak ditutup)' };
159
224
  return { valid: true };
160
225
  }
@@ -18,6 +18,7 @@ const path_1 = __importDefault(require("path"));
18
18
  const child_process_1 = require("child_process");
19
19
  const task_queue_1 = require("./task-queue");
20
20
  const ws_gateway_1 = require("./ws-gateway");
21
+ const roblox_docs_1 = require("./roblox-docs");
21
22
  const { version: SERVER_VERSION } = require('../package.json');
22
23
  exports.isBridgeHosting = false;
23
24
  let resolveBridgeReady;
@@ -349,7 +350,17 @@ function addLog(io, type, message) {
349
350
  activityLogs.pop();
350
351
  if (io.engine && io.engine.clientsCount > 0) {
351
352
  io.emit('log', log);
352
- io.emit('stats', { totalTasks, successCount, errorCount, sessionStart });
353
+ io.emit('stats', {
354
+ totalTasks,
355
+ successCount,
356
+ errorCount,
357
+ sessionStart,
358
+ studioConnected: isStudioConnected(),
359
+ studioCount: getConnectedStudioCount(),
360
+ wsStudios: (0, ws_gateway_1.getWsPeerCount)(),
361
+ connectedStudios: getConnectedStudios(),
362
+ version: SERVER_VERSION
363
+ });
353
364
  }
354
365
  }
355
366
  /**
@@ -422,7 +433,12 @@ function startBridgeServer(port, isDaemon = false) {
422
433
  onStudioStatusChange: () => {
423
434
  const connected = isStudioConnected();
424
435
  const studioCount = getConnectedStudioCount();
425
- io.emit('studio-status', { connected, studioCount });
436
+ io.emit('studio-status', {
437
+ connected,
438
+ studioCount,
439
+ connectedStudios: getConnectedStudios(),
440
+ version: SERVER_VERSION
441
+ });
426
442
  broadcastStatus();
427
443
  if (!connected && (0, task_queue_1.hasPendingTasks)()) {
428
444
  checkPendingTasksOnStudioDisconnect();
@@ -464,10 +480,28 @@ function startBridgeServer(port, isDaemon = false) {
464
480
  studioConnected: isStudioConnected(),
465
481
  studioCount: getConnectedStudioCount(),
466
482
  wsStudios: (0, ws_gateway_1.getWsPeerCount)(),
467
- port
483
+ port,
484
+ version: SERVER_VERSION,
485
+ connectedStudios: getConnectedStudios(),
468
486
  },
469
487
  });
470
488
  });
489
+ // ── /api/docs (Endpoint pencarian dokumentasi engine untuk web dashboard) ──
490
+ app.get('/api/docs', async (req, res) => {
491
+ try {
492
+ const query = String(req.query.query || '').trim();
493
+ const member = req.query.member ? String(req.query.member).trim() : undefined;
494
+ const category = req.query.category ? String(req.query.category).trim() : 'auto';
495
+ if (!query) {
496
+ return res.status(400).json({ error: "Parameter 'query' wajib diisi." });
497
+ }
498
+ const result = await (0, roblox_docs_1.getRobloxDocs)(query, member, category);
499
+ res.json({ success: true, result });
500
+ }
501
+ catch (err) {
502
+ res.status(500).json({ error: err.message });
503
+ }
504
+ });
471
505
  // ── /api/stats ─────────────────────────────────────────────────────────────
472
506
  app.get('/api/stats', (req, res) => {
473
507
  const activeStudios = getConnectedStudioCount();
@@ -514,7 +548,13 @@ function startBridgeServer(port, isDaemon = false) {
514
548
  // Daftarkan studio jika belum ada
515
549
  if (!studioEventQueues.has(studioId)) {
516
550
  studioEventQueues.set(studioId, []);
517
- io.emit('studio-status', { connected: true, studioCount: Math.max(studioEventQueues.size, 1), studioId });
551
+ io.emit('studio-status', {
552
+ connected: true,
553
+ studioCount: Math.max(studioEventQueues.size, 1),
554
+ studioId,
555
+ connectedStudios: getConnectedStudios(),
556
+ version: SERVER_VERSION
557
+ });
518
558
  addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Place: ${placeId ?? 'Local'}, Total: ${studioEventQueues.size})`);
519
559
  if (!isDaemon && !dashboardOpened) {
520
560
  dashboardOpened = true;