@chrrxs/robloxstudio-mcp 2.16.0 → 2.16.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.
- package/dist/index.js +210 -37
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +556 -141
- package/studio-plugin/MCPPlugin.rbxmx +556 -141
- package/studio-plugin/src/modules/ClientBroker.ts +32 -6
- package/studio-plugin/src/modules/Communication.ts +81 -41
- package/studio-plugin/src/modules/HttpDiagnostics.ts +50 -0
- package/studio-plugin/src/modules/ServerUrlSettings.ts +61 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +184 -45
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +18 -13
- package/studio-plugin/src/server/index.server.ts +15 -4
|
@@ -7,6 +7,7 @@ import InputHandlers from "./handlers/InputHandlers";
|
|
|
7
7
|
import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
|
|
8
8
|
import LuauExec from "./LuauExec";
|
|
9
9
|
import State from "./State";
|
|
10
|
+
import HttpDiagnostics from "./HttpDiagnostics";
|
|
10
11
|
|
|
11
12
|
interface StudioTestServiceMultiplayer extends StudioTestService {
|
|
12
13
|
CanLeaveTest(): boolean;
|
|
@@ -64,7 +65,8 @@ function resolvePlaceName(): string {
|
|
|
64
65
|
// is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
|
|
65
66
|
// signaling, which works regardless of MCP server state.)
|
|
66
67
|
|
|
67
|
-
const
|
|
68
|
+
const DEFAULT_MCP_URL = "http://localhost:58741";
|
|
69
|
+
let mcpUrl = DEFAULT_MCP_URL;
|
|
68
70
|
const BROKER_NAME = "__MCPClientBroker";
|
|
69
71
|
const BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner";
|
|
70
72
|
|
|
@@ -152,7 +154,7 @@ function forkRole(): "edit" | "server" | "client" {
|
|
|
152
154
|
function postJson(endpoint: string, body: Record<string, unknown>) {
|
|
153
155
|
return pcall(() =>
|
|
154
156
|
HttpService.RequestAsync({
|
|
155
|
-
Url: `${
|
|
157
|
+
Url: `${mcpUrl}${endpoint}`,
|
|
156
158
|
Method: "POST",
|
|
157
159
|
Headers: { "Content-Type": "application/json" },
|
|
158
160
|
Body: HttpService.JSONEncode(body),
|
|
@@ -160,6 +162,20 @@ function postJson(endpoint: string, body: Record<string, unknown>) {
|
|
|
160
162
|
);
|
|
161
163
|
}
|
|
162
164
|
|
|
165
|
+
function formatPostJsonFailure(endpoint: string, ok: boolean, res: unknown): string {
|
|
166
|
+
return HttpDiagnostics.formatRequestFailure(`${mcpUrl}${endpoint}`, ok, res);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function setServerUrl(serverUrl: string | undefined): void {
|
|
170
|
+
if (serverUrl !== undefined && serverUrl !== "") {
|
|
171
|
+
mcpUrl = serverUrl;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getServerUrl(): string {
|
|
176
|
+
return mcpUrl;
|
|
177
|
+
}
|
|
178
|
+
|
|
163
179
|
function handleExecuteLuau(data: Record<string, unknown> | undefined) {
|
|
164
180
|
const code = data && (data.code as string | undefined);
|
|
165
181
|
if (typeIs(code, "string") === false || code === "") {
|
|
@@ -277,13 +293,14 @@ function setupClientBroker() {
|
|
|
277
293
|
}
|
|
278
294
|
|
|
279
295
|
const proxyByPlayer = new Map<Player, ProxyEntry>();
|
|
296
|
+
const proxyRegisterFailuresByPlayer = new Set<Player>();
|
|
280
297
|
let serverBrokerStarted = false;
|
|
281
298
|
|
|
282
299
|
function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
|
|
283
300
|
while (player.Parent !== undefined && proxyByPlayer.has(player)) {
|
|
284
301
|
const [ok, res] = pcall(() =>
|
|
285
302
|
HttpService.RequestAsync({
|
|
286
|
-
Url: `${
|
|
303
|
+
Url: `${mcpUrl}/poll?pluginSessionId=${proxyId}`,
|
|
287
304
|
Method: "GET",
|
|
288
305
|
Headers: { "Content-Type": "application/json" },
|
|
289
306
|
}),
|
|
@@ -341,18 +358,23 @@ function registerProxy(player: Player, rf: RemoteFunction) {
|
|
|
341
358
|
pluginVariant: State.PLUGIN_VARIANT,
|
|
342
359
|
});
|
|
343
360
|
if (!ok || !res || !res.Success) {
|
|
344
|
-
|
|
361
|
+
proxyRegisterFailuresByPlayer.add(player);
|
|
362
|
+
warn(`[robloxstudio-mcp] proxy register failed for ${player.Name}: ${formatPostJsonFailure("/ready", ok, res)}`);
|
|
345
363
|
return;
|
|
346
364
|
}
|
|
347
365
|
const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
|
|
348
366
|
const assigned = body.assignedRole ?? "client";
|
|
349
367
|
proxyByPlayer.set(player, { pluginSessionId: proxyId, role: assigned });
|
|
368
|
+
if (proxyRegisterFailuresByPlayer.has(player)) {
|
|
369
|
+
proxyRegisterFailuresByPlayer.delete(player);
|
|
370
|
+
print(`[robloxstudio-mcp] proxy registered for ${player.Name} as ${assigned} via ${mcpUrl}`);
|
|
371
|
+
}
|
|
350
372
|
task.spawn(pollProxy, proxyId, player, rf);
|
|
351
373
|
}
|
|
352
374
|
|
|
353
375
|
// (Removed: startEditProxyLoop. The play-server DM no longer registers an
|
|
354
376
|
// "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
|
|
355
|
-
// plugin:SetSetting
|
|
377
|
+
// plugin:SetSetting request consumed by StopPlayMonitor in the play-server DM,
|
|
356
378
|
// which doesn't depend on MCP server state or peer registration at all.)
|
|
357
379
|
|
|
358
380
|
function setupServerBroker() {
|
|
@@ -377,6 +399,7 @@ function setupServerBroker() {
|
|
|
377
399
|
const entry = proxyByPlayer.get(p);
|
|
378
400
|
if (entry) {
|
|
379
401
|
proxyByPlayer.delete(p);
|
|
402
|
+
proxyRegisterFailuresByPlayer.delete(p);
|
|
380
403
|
postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
|
|
381
404
|
}
|
|
382
405
|
});
|
|
@@ -389,7 +412,10 @@ function setupServerBroker() {
|
|
|
389
412
|
}
|
|
390
413
|
|
|
391
414
|
export = {
|
|
392
|
-
MCP_URL,
|
|
415
|
+
MCP_URL: DEFAULT_MCP_URL,
|
|
416
|
+
DEFAULT_MCP_URL,
|
|
417
|
+
getServerUrl,
|
|
418
|
+
setServerUrl,
|
|
393
419
|
forkRole,
|
|
394
420
|
setupClientBroker,
|
|
395
421
|
setupServerBroker,
|
|
@@ -18,6 +18,8 @@ import SerializationHandlers from "./handlers/SerializationHandlers";
|
|
|
18
18
|
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
19
19
|
import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
|
|
20
20
|
import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
|
|
21
|
+
import ServerUrlSettings from "./ServerUrlSettings";
|
|
22
|
+
import HttpDiagnostics from "./HttpDiagnostics";
|
|
21
23
|
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
22
24
|
|
|
23
25
|
// Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
@@ -45,20 +47,24 @@ function computeInstanceId(): string {
|
|
|
45
47
|
return `anon:${fresh}`;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
const instanceId = computeInstanceId();
|
|
49
50
|
let assignedRole: string | undefined;
|
|
50
51
|
let duplicateInstanceRole = false;
|
|
51
52
|
let hasVersionMismatch = false;
|
|
52
53
|
let lastVersionMismatchWarningKey: string | undefined;
|
|
54
|
+
let lastReadyInstanceId: string | undefined;
|
|
55
|
+
const readyFailureLogKeys = new Set<string>();
|
|
53
56
|
|
|
54
57
|
// Cache the published place name from MarketplaceService:GetProductInfo so
|
|
55
58
|
// /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
56
59
|
// from game.Name (the DataModel name, often "Place1" in edit). We only fetch
|
|
57
60
|
// once per plugin load; the published name doesn't change mid-session.
|
|
58
61
|
let cachedPlaceName: string | undefined;
|
|
62
|
+
let cachedPlaceNamePlaceId: number | undefined;
|
|
59
63
|
|
|
60
64
|
function resolvePlaceName(): string {
|
|
61
|
-
if (cachedPlaceName !== undefined) return cachedPlaceName;
|
|
65
|
+
if (cachedPlaceName !== undefined && cachedPlaceNamePlaceId === game.PlaceId) return cachedPlaceName;
|
|
66
|
+
cachedPlaceName = undefined;
|
|
67
|
+
cachedPlaceNamePlaceId = game.PlaceId;
|
|
62
68
|
if (game.PlaceId === 0) {
|
|
63
69
|
cachedPlaceName = game.Name;
|
|
64
70
|
return cachedPlaceName;
|
|
@@ -206,21 +212,31 @@ function getConnectionStatus(connIndex: number): string {
|
|
|
206
212
|
// restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
|
|
207
213
|
let lastReadyPostAt = 0;
|
|
208
214
|
|
|
209
|
-
// game.Name
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
// get_connected_instances doesn't show a stale dataModelName forever. Set
|
|
213
|
-
// up once per plugin load — the connection passed in is whichever was
|
|
214
|
-
// active when activatePlugin was first called.
|
|
215
|
+
// game.Name and game.PlaceId can both settle after plugin load. PlaceId also
|
|
216
|
+
// changes when an unpublished file is published while MCP is already active.
|
|
217
|
+
// Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
|
|
215
218
|
let nameChangeConn: RBXScriptConnection | undefined;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
219
|
+
let placeIdChangeConn: RBXScriptConnection | undefined;
|
|
220
|
+
function ensureIdentityWatcher(conn: Connection): void {
|
|
221
|
+
if (!nameChangeConn) {
|
|
222
|
+
const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("Name"));
|
|
223
|
+
if (okSig && signal) {
|
|
224
|
+
nameChangeConn = signal.Connect(() => {
|
|
225
|
+
// sendReady has its own 2s throttle, so rapid burst changes coalesce.
|
|
226
|
+
sendReady(conn);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!placeIdChangeConn) {
|
|
231
|
+
const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("PlaceId"));
|
|
232
|
+
if (okSig && signal) {
|
|
233
|
+
placeIdChangeConn = signal.Connect(() => {
|
|
234
|
+
cachedPlaceName = undefined;
|
|
235
|
+
cachedPlaceNamePlaceId = undefined;
|
|
236
|
+
sendReady(conn);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
224
240
|
}
|
|
225
241
|
|
|
226
242
|
function sendReady(conn: Connection): void {
|
|
@@ -228,6 +244,7 @@ function sendReady(conn: Connection): void {
|
|
|
228
244
|
const now = tick();
|
|
229
245
|
if (now - lastReadyPostAt < 2) return; // throttle to ≤1 /ready every 2s
|
|
230
246
|
lastReadyPostAt = now;
|
|
247
|
+
const instanceId = computeInstanceId();
|
|
231
248
|
task.spawn(() => {
|
|
232
249
|
const [readyOk, readyResult] = pcall(() => {
|
|
233
250
|
return HttpService.RequestAsync({
|
|
@@ -249,30 +266,47 @@ function sendReady(conn: Connection): void {
|
|
|
249
266
|
}),
|
|
250
267
|
});
|
|
251
268
|
});
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (State.getActiveTabIndex() === 0) {
|
|
259
|
-
ui.statusLabel.Text = "Duplicate instance";
|
|
260
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
261
|
-
ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role";
|
|
262
|
-
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
263
|
-
}
|
|
264
|
-
warn(
|
|
265
|
-
`[MCPPlugin] Another Studio is already connected as (${instanceId}, ${detectRole()}). Close the other Studio window or this one.`,
|
|
266
|
-
);
|
|
269
|
+
const readyUrl = `${conn.serverUrl}/ready`;
|
|
270
|
+
const readyRole = detectRole();
|
|
271
|
+
const readyLogKey = `${conn.serverUrl}|${instanceId}|${readyRole}`;
|
|
272
|
+
if (!readyOk) {
|
|
273
|
+
readyFailureLogKeys.add(readyLogKey);
|
|
274
|
+
warn(`[robloxstudio-mcp] /ready failed for ${instanceId}/${readyRole}: ${HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`);
|
|
267
275
|
return;
|
|
268
276
|
}
|
|
269
|
-
if (readyResult.Success) {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (
|
|
274
|
-
|
|
277
|
+
if (!readyResult.Success) {
|
|
278
|
+
const reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult);
|
|
279
|
+
readyFailureLogKeys.add(readyLogKey);
|
|
280
|
+
// 409 = duplicate_instance_role. Surface in UI and stop polling.
|
|
281
|
+
if (readyResult.StatusCode === 409) {
|
|
282
|
+
duplicateInstanceRole = true;
|
|
283
|
+
conn.isActive = false;
|
|
284
|
+
const ui = UI.getElements();
|
|
285
|
+
if (State.getActiveTabIndex() === 0) {
|
|
286
|
+
ui.statusLabel.Text = "Duplicate instance";
|
|
287
|
+
ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
288
|
+
ui.detailStatusLabel.Text = reason;
|
|
289
|
+
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
290
|
+
}
|
|
291
|
+
warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
|
|
292
|
+
return;
|
|
275
293
|
}
|
|
294
|
+
warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const [parseOk, readyData] = pcall(
|
|
298
|
+
() => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
|
|
299
|
+
);
|
|
300
|
+
if (parseOk && readyData.assignedRole) {
|
|
301
|
+
assignedRole = readyData.assignedRole;
|
|
302
|
+
}
|
|
303
|
+
lastReadyInstanceId = parseOk && typeIs(readyData.instanceId, "string") && readyData.instanceId !== ""
|
|
304
|
+
? readyData.instanceId
|
|
305
|
+
: instanceId;
|
|
306
|
+
const connectedRole = assignedRole ?? detectRole();
|
|
307
|
+
if (readyFailureLogKeys.has(readyLogKey)) {
|
|
308
|
+
readyFailureLogKeys.delete(readyLogKey);
|
|
309
|
+
print(`[robloxstudio-mcp] /ready connected for ${instanceId}/${connectedRole} via ${conn.serverUrl}`);
|
|
276
310
|
}
|
|
277
311
|
});
|
|
278
312
|
}
|
|
@@ -313,7 +347,7 @@ function pollForRequests(connIndex: number) {
|
|
|
313
347
|
const warningKey = `${State.CURRENT_VERSION}:${serverVersion}`;
|
|
314
348
|
if (lastVersionMismatchWarningKey !== warningKey) {
|
|
315
349
|
lastVersionMismatchWarningKey = warningKey;
|
|
316
|
-
warn(`[
|
|
350
|
+
warn(`[robloxstudio-mcp] Version mismatch: Studio plugin v${State.CURRENT_VERSION} / MCP v${serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`);
|
|
317
351
|
}
|
|
318
352
|
UI.showBanner("version-mismatch", `Plugin v${State.CURRENT_VERSION} / MCP v${serverVersion} mismatch`);
|
|
319
353
|
} else if (hasVersionMismatch) {
|
|
@@ -470,11 +504,18 @@ function activatePlugin(connIndex?: number) {
|
|
|
470
504
|
UI.updateTabLabel(idx);
|
|
471
505
|
UI.updateUIState();
|
|
472
506
|
}
|
|
507
|
+
ServerUrlSettings.rememberServerUrl(conn.serverUrl);
|
|
473
508
|
UI.updateTabDot(idx);
|
|
474
509
|
|
|
475
510
|
if (!conn.heartbeatConnection) {
|
|
476
511
|
conn.heartbeatConnection = RunService.Heartbeat.Connect(() => {
|
|
477
512
|
const now = tick();
|
|
513
|
+
const currentInstanceId = computeInstanceId();
|
|
514
|
+
if (lastReadyInstanceId !== undefined && currentInstanceId !== lastReadyInstanceId) {
|
|
515
|
+
cachedPlaceName = undefined;
|
|
516
|
+
cachedPlaceNamePlaceId = undefined;
|
|
517
|
+
sendReady(conn);
|
|
518
|
+
}
|
|
478
519
|
const currentInterval = conn.consecutiveFailures > 5 ? conn.currentRetryDelay : conn.pollInterval;
|
|
479
520
|
if (now - conn.lastPoll > currentInterval) {
|
|
480
521
|
conn.lastPoll = now;
|
|
@@ -493,9 +534,8 @@ function activatePlugin(connIndex?: number) {
|
|
|
493
534
|
task.spawn(cleanupLegacyEditBridges);
|
|
494
535
|
}
|
|
495
536
|
|
|
496
|
-
// Watch
|
|
497
|
-
|
|
498
|
-
ensureNameChangeWatcher(conn);
|
|
537
|
+
// Watch identity fields so stale name or anon instance ids are refreshed.
|
|
538
|
+
ensureIdentityWatcher(conn);
|
|
499
539
|
}
|
|
500
540
|
|
|
501
541
|
function deactivatePlugin(connIndex?: number) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { HttpService } from "@rbxts/services";
|
|
2
|
+
|
|
3
|
+
interface FailureBody {
|
|
4
|
+
error?: string;
|
|
5
|
+
message?: string;
|
|
6
|
+
missingFields?: unknown;
|
|
7
|
+
request?: unknown;
|
|
8
|
+
existing?: unknown;
|
|
9
|
+
details?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function encodeForLog(value: unknown): string {
|
|
13
|
+
const [ok, encoded] = pcall(() => HttpService.JSONEncode(value));
|
|
14
|
+
return ok ? encoded : tostring(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatBody(body: string): string {
|
|
18
|
+
if (body === "") return "";
|
|
19
|
+
const [ok, decoded] = pcall(() => HttpService.JSONDecode(body));
|
|
20
|
+
if (ok && typeIs(decoded, "table")) {
|
|
21
|
+
const data = decoded as FailureBody;
|
|
22
|
+
const parts: string[] = [];
|
|
23
|
+
if (typeIs(data.error, "string") && data.error !== "") parts.push(`error=${data.error}`);
|
|
24
|
+
if (typeIs(data.message, "string") && data.message !== "") parts.push(`message=${data.message}`);
|
|
25
|
+
if (data.missingFields !== undefined) parts.push(`missingFields=${encodeForLog(data.missingFields)}`);
|
|
26
|
+
if (data.request !== undefined) parts.push(`request=${encodeForLog(data.request)}`);
|
|
27
|
+
if (data.existing !== undefined) parts.push(`existing=${encodeForLog(data.existing)}`);
|
|
28
|
+
if (data.details !== undefined) parts.push(`details=${encodeForLog(data.details)}`);
|
|
29
|
+
if (parts.size() > 0) return parts.join(" ");
|
|
30
|
+
}
|
|
31
|
+
return `body=${body}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatRequestFailure(url: string, ok: boolean, res: unknown): string {
|
|
35
|
+
if (!ok) {
|
|
36
|
+
return `RequestAsync threw for ${url}: ${tostring(res)}`;
|
|
37
|
+
}
|
|
38
|
+
if (res === undefined) {
|
|
39
|
+
return `RequestAsync returned no response for ${url}`;
|
|
40
|
+
}
|
|
41
|
+
const response = res as RequestAsyncResponse;
|
|
42
|
+
const statusMessage = response.StatusMessage !== "" ? ` ${response.StatusMessage}` : "";
|
|
43
|
+
const body = formatBody(response.Body);
|
|
44
|
+
const suffix = body !== "" ? `: ${body}` : "";
|
|
45
|
+
return `HTTP ${response.StatusCode}${statusMessage} from ${url}${suffix}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export = {
|
|
49
|
+
formatRequestFailure,
|
|
50
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { HttpService, ServerStorage } from "@rbxts/services";
|
|
2
|
+
|
|
3
|
+
const SETTING_KEY_PREFIX = "MCP_SERVER_URL_";
|
|
4
|
+
|
|
5
|
+
let pluginRef: Plugin | undefined;
|
|
6
|
+
|
|
7
|
+
function init(p: Plugin): void {
|
|
8
|
+
pluginRef = p;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function addUnique(values: string[], value: string): void {
|
|
12
|
+
if (!values.includes(value)) {
|
|
13
|
+
values.push(value);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function computeInstanceIds(): string[] {
|
|
18
|
+
const ids: string[] = [];
|
|
19
|
+
if (game.PlaceId !== 0) {
|
|
20
|
+
addUnique(ids, `place:${tostring(game.PlaceId)}`);
|
|
21
|
+
}
|
|
22
|
+
const existing = ServerStorage.GetAttribute("__MCPPlaceId");
|
|
23
|
+
if (typeIs(existing, "string") && existing !== "") {
|
|
24
|
+
addUnique(ids, `anon:${existing as string}`);
|
|
25
|
+
} else if (game.PlaceId === 0) {
|
|
26
|
+
const fresh = HttpService.GenerateGUID(false);
|
|
27
|
+
pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
|
|
28
|
+
addUnique(ids, `anon:${fresh}`);
|
|
29
|
+
}
|
|
30
|
+
return ids;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function settingKey(instanceId: string): string {
|
|
34
|
+
return SETTING_KEY_PREFIX + instanceId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rememberServerUrl(serverUrl: string): void {
|
|
38
|
+
if (!pluginRef || serverUrl === "") return;
|
|
39
|
+
for (const instanceId of computeInstanceIds()) {
|
|
40
|
+
const key = settingKey(instanceId);
|
|
41
|
+
pcall(() => pluginRef!.SetSetting(key, serverUrl));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readServerUrl(): string | undefined {
|
|
46
|
+
if (!pluginRef) return undefined;
|
|
47
|
+
for (const instanceId of computeInstanceIds()) {
|
|
48
|
+
const key = settingKey(instanceId);
|
|
49
|
+
const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
|
|
50
|
+
if (ok && typeIs(value, "string") && value !== "") {
|
|
51
|
+
return value as string;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export = {
|
|
58
|
+
init,
|
|
59
|
+
rememberServerUrl,
|
|
60
|
+
readServerUrl,
|
|
61
|
+
};
|