@chrrxs/robloxstudio-mcp 3.1.6 → 3.2.1
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 +1423 -299
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +864 -161
|
@@ -17,6 +17,7 @@ local ensureRuntimeBridgeInstalled = _EvalBridges.ensureRuntimeBridgeInstalled
|
|
|
17
17
|
local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
|
|
18
18
|
local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
|
|
19
19
|
local StudioWebSocket = TS.import(script, script, "modules", "StudioWebSocket")
|
|
20
|
+
local PluginSession = TS.import(script, script, "modules", "PluginSession")
|
|
20
21
|
local BreakpointHandlers = TS.import(script, script, "modules", "handlers", "BreakpointHandlers")
|
|
21
22
|
local RenderMonitor = TS.import(script, script, "modules", "RenderMonitor")
|
|
22
23
|
-- Track render-loop liveness so input/screenshot tools can report "window
|
|
@@ -33,6 +34,14 @@ RuntimeLogBuffer.install()
|
|
|
33
34
|
StopPlayMonitor.init(plugin)
|
|
34
35
|
BreakpointHandlers.init(plugin)
|
|
35
36
|
ServerUrlSettings.init(plugin)
|
|
37
|
+
-- Before anything reads place keys: an edit session removes MCP attributes
|
|
38
|
+
-- that older plugin versions saved into the place, and hands its topology to
|
|
39
|
+
-- playtest DataModels through plugin settings instead.
|
|
40
|
+
PluginSession.init(plugin, {
|
|
41
|
+
getServerUrl = function()
|
|
42
|
+
return State.getActiveConnection().serverUrl
|
|
43
|
+
end,
|
|
44
|
+
})
|
|
36
45
|
local startupRole = ClientBroker.forkRole()
|
|
37
46
|
local function applyRememberedServerUrl()
|
|
38
47
|
if startupRole == "client" then
|
|
@@ -55,8 +64,8 @@ applyRememberedServerUrl()
|
|
|
55
64
|
-- down their VM. Close the persistent WebStreamClient while BindToClose still
|
|
56
65
|
-- gives this server peer a live execution context; otherwise each play cycle
|
|
57
66
|
-- can retain one native WebSocket until Studio itself is restarted. The
|
|
58
|
-
-- transport
|
|
59
|
-
--
|
|
67
|
+
-- transport unregisters the server peer on that socket and waits at most two
|
|
68
|
+
-- seconds for the server to close it, so no HTTP request can hold up shutdown.
|
|
60
69
|
if startupRole == "server" then
|
|
61
70
|
game:BindToClose(StudioWebSocket.suspendForShutdown)
|
|
62
71
|
end
|
|
@@ -95,8 +104,20 @@ plugin.Unloading:Connect(function()
|
|
|
95
104
|
Communication.deactivateAll()
|
|
96
105
|
end)
|
|
97
106
|
UI.updateUIState()
|
|
98
|
-
|
|
107
|
+
-- Every DataModel shares Studio's five plugin HTTP request slots, and a
|
|
108
|
+
-- request has no time limit. Check for updates once, from the edit DataModel,
|
|
109
|
+
-- instead of again in every play server and client.
|
|
110
|
+
if startupRole == "edit" then
|
|
111
|
+
Communication.checkForUpdates()
|
|
112
|
+
end
|
|
99
113
|
task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton)
|
|
114
|
+
--* Runs one startup step so that its failure cannot skip the steps after it.
|
|
115
|
+
local function runStartupStep(description, step)
|
|
116
|
+
local ok, err = pcall(step)
|
|
117
|
+
if not ok then
|
|
118
|
+
warn(`[robloxstudio-mcp] {description} failed: {err}`)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
100
121
|
-- Auto-activate per Peer. Runtime plugin VMs can load before their first
|
|
101
122
|
-- Heartbeat; task.delay() would then wait behind the very multiplayer startup
|
|
102
123
|
-- that needs this Peer to register. Start runtime initialization immediately,
|
|
@@ -111,14 +132,25 @@ local function autoActivatePeer()
|
|
|
111
132
|
warn(`[robloxstudio-mcp] Runtime eval bridge install failed: {result.error}`)
|
|
112
133
|
end
|
|
113
134
|
end
|
|
135
|
+
if role == "server" then
|
|
136
|
+
-- Yields until this playtest server knows which edit session (or
|
|
137
|
+
-- multiplayer group) it belongs to, so it registers under that identity.
|
|
138
|
+
-- On failure the server still registers, under its own identity.
|
|
139
|
+
runStartupStep("Playtest topology resolution", function()
|
|
140
|
+
return PluginSession.resolveRuntimeTopology()
|
|
141
|
+
end)
|
|
142
|
+
end
|
|
114
143
|
if role == "edit" or role == "server" then
|
|
115
|
-
|
|
144
|
+
runStartupStep(`Automatic {role} Peer activation`, function()
|
|
116
145
|
local conn = State.getActiveConnection()
|
|
117
146
|
if not conn.isActive then
|
|
118
147
|
if role == "server" then
|
|
119
|
-
local _condition =
|
|
148
|
+
local _condition = PluginSession.getInheritedServerUrl()
|
|
120
149
|
if _condition == nil then
|
|
121
|
-
_condition =
|
|
150
|
+
_condition = ServerUrlSettings.readServerUrl()
|
|
151
|
+
if _condition == nil then
|
|
152
|
+
_condition = ClientBroker.DEFAULT_MCP_URL
|
|
153
|
+
end
|
|
122
154
|
end
|
|
123
155
|
local inheritedServerUrl = _condition
|
|
124
156
|
conn.serverUrl = ServerUrlSettings.normalizeServerUrl(inheritedServerUrl)
|
|
@@ -138,19 +170,20 @@ local function autoActivatePeer()
|
|
|
138
170
|
Communication.activatePlugin()
|
|
139
171
|
end
|
|
140
172
|
end)
|
|
141
|
-
if not activationOk then
|
|
142
|
-
warn(`[robloxstudio-mcp] Automatic {role} Peer activation failed: {activationError}`)
|
|
143
|
-
end
|
|
144
173
|
end
|
|
145
174
|
if role == "server" then
|
|
146
|
-
|
|
175
|
+
runStartupStep("Client broker setup", function()
|
|
176
|
+
return ClientBroker.setupServerBroker()
|
|
177
|
+
end)
|
|
147
178
|
-- The play-server DM is the only one where StudioTestService:EndTest is
|
|
148
179
|
-- legal, so the stop-play monitor lives here. It consumes tokenized
|
|
149
180
|
-- stop requests from plugin settings and acknowledges EndTest results.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
181
|
+
runStartupStep("Stop-play monitor startup", function()
|
|
182
|
+
return StopPlayMonitor.startMonitor({
|
|
183
|
+
beforeEndTest = StudioWebSocket.suspendForShutdown,
|
|
184
|
+
afterEndTestFailure = StudioWebSocket.resumeAfterShutdownFailure,
|
|
185
|
+
})
|
|
186
|
+
end)
|
|
154
187
|
elseif role == "client" then
|
|
155
188
|
ClientBroker.setupClientBroker()
|
|
156
189
|
end
|
|
@@ -1942,9 +1975,9 @@ local function computeBridgeStamp()
|
|
|
1942
1975
|
for i = 1, #combined do
|
|
1943
1976
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1944
1977
|
end
|
|
1945
|
-
-- "3.1
|
|
1978
|
+
-- "3.2.1" is replaced with the package version at package time
|
|
1946
1979
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1947
|
-
return `{tostring(h)}-3.1
|
|
1980
|
+
return `{tostring(h)}-3.2.1`
|
|
1948
1981
|
end
|
|
1949
1982
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1950
1983
|
local function setSource(scriptInst, source)
|
|
@@ -2549,13 +2582,12 @@ return {
|
|
|
2549
2582
|
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
2550
2583
|
local Utils = TS.import(script, script.Parent.Parent, "Utils")
|
|
2551
2584
|
local PeerRole = TS.import(script, script.Parent.Parent, "PeerRole")
|
|
2585
|
+
local PluginSession = TS.import(script, script.Parent.Parent, "PluginSession")
|
|
2552
2586
|
local _binding = Utils
|
|
2553
2587
|
local getInstanceByPath = _binding.getInstanceByPath
|
|
2554
2588
|
local HttpService = game:GetService("HttpService")
|
|
2555
|
-
local ServerStorage = game:GetService("ServerStorage")
|
|
2556
2589
|
local LOG_PREFIX = "Breakpoint"
|
|
2557
2590
|
local REGISTRY_KEY_PREFIX = "MCP_BREAKPOINTS_V1_"
|
|
2558
|
-
local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
2559
2591
|
local pluginRef
|
|
2560
2592
|
local loadedRegistryKey
|
|
2561
2593
|
local loadedRegistryFromSettings = false
|
|
@@ -2566,20 +2598,6 @@ end
|
|
|
2566
2598
|
local function breakpointKey(scriptPath, line)
|
|
2567
2599
|
return `{scriptPath}:{line}`
|
|
2568
2600
|
end
|
|
2569
|
-
local function computePlaceKey()
|
|
2570
|
-
if game.PlaceId ~= 0 then
|
|
2571
|
-
return `place:{tostring(game.PlaceId)}`
|
|
2572
|
-
end
|
|
2573
|
-
local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
|
|
2574
|
-
if type(existing) == "string" and existing ~= "" then
|
|
2575
|
-
return `anon:{existing}`
|
|
2576
|
-
end
|
|
2577
|
-
local fresh = HttpService:GenerateGUID(false)
|
|
2578
|
-
pcall(function()
|
|
2579
|
-
return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
|
|
2580
|
-
end)
|
|
2581
|
-
return `anon:{fresh}`
|
|
2582
|
-
end
|
|
2583
2601
|
local function detectRole()
|
|
2584
2602
|
return PeerRole.detect()
|
|
2585
2603
|
end
|
|
@@ -2592,10 +2610,11 @@ local function requestedRole(requestData)
|
|
|
2592
2610
|
return if _condition then requestData.__mcp_target_role else detectRole()
|
|
2593
2611
|
end
|
|
2594
2612
|
local function registryScope(requestData)
|
|
2595
|
-
local placeKey = computePlaceKey()
|
|
2596
2613
|
local role = requestedRole(requestData)
|
|
2614
|
+
local legacyPlaceKey = PluginSession.getLegacyPlaceKey()
|
|
2597
2615
|
return {
|
|
2598
|
-
key = `{REGISTRY_KEY_PREFIX}{
|
|
2616
|
+
key = `{REGISTRY_KEY_PREFIX}{PluginSession.getPlaceKey()}:{role}`,
|
|
2617
|
+
legacyKey = if legacyPlaceKey ~= nil then `{REGISTRY_KEY_PREFIX}{legacyPlaceKey}:{role}` else nil,
|
|
2599
2618
|
}
|
|
2600
2619
|
end
|
|
2601
2620
|
local function readSetting(key)
|
|
@@ -2654,7 +2673,11 @@ local function loadRegistry(requestData)
|
|
|
2654
2673
|
return scope
|
|
2655
2674
|
end
|
|
2656
2675
|
loadedRegistryFromSettings = true
|
|
2657
|
-
local
|
|
2676
|
+
local _condition = readSetting(scope.key)
|
|
2677
|
+
if _condition == nil then
|
|
2678
|
+
_condition = (if scope.legacyKey ~= nil then readSetting(scope.legacyKey) else nil)
|
|
2679
|
+
end
|
|
2680
|
+
local stored = _condition
|
|
2658
2681
|
if stored == nil then
|
|
2659
2682
|
return scope
|
|
2660
2683
|
end
|
|
@@ -5474,6 +5497,18 @@ local function captureMicroProfiler(requestData)
|
|
|
5474
5497
|
local threadIds = session:FetchThreadIds() or {}
|
|
5475
5498
|
local frameMin = session:GetFrameIdMin()
|
|
5476
5499
|
local frameMax = session:GetFrameIdMax()
|
|
5500
|
+
-- Frame IDs start at 1. Studio cuts a frame only when it renders, so a capture
|
|
5501
|
+
-- without frames means nothing was drawn, not that the workload was idle.
|
|
5502
|
+
if frameMax <= 0 then
|
|
5503
|
+
session:Dispose()
|
|
5504
|
+
return {
|
|
5505
|
+
error = "micro_profiler_no_frames",
|
|
5506
|
+
message = "Studio rendered no frames during the capture, so MicroProfiler recorded no data. Studio draws frames only while its window can be displayed, which stops when the display turns off (for example after the power-saving timeout) or Studio is minimized. Wake the display or restore Studio, then retry.",
|
|
5507
|
+
duration_ms = durationMs,
|
|
5508
|
+
buffer_bytes = buffer.len(snapshot),
|
|
5509
|
+
backend = backend,
|
|
5510
|
+
}
|
|
5511
|
+
end
|
|
5477
5512
|
local startFrame = math.max(frameMin, frameMax - frameWindow + 1)
|
|
5478
5513
|
local framesConsidered = if frameMax >= startFrame then frameMax - startFrame + 1 else 0
|
|
5479
5514
|
local groupCache = {}
|
|
@@ -9033,15 +9068,15 @@ local function startPlaytest(requestData)
|
|
|
9033
9068
|
}
|
|
9034
9069
|
end
|
|
9035
9070
|
testRunning = true
|
|
9036
|
-
local
|
|
9071
|
+
local preparedTest = PluginSession.prepareSoloTest()
|
|
9037
9072
|
task.spawn(function()
|
|
9038
9073
|
local ok, result = pcall(function()
|
|
9039
9074
|
if mode == "play" then
|
|
9040
|
-
return StudioTestService:ExecutePlayModeAsync(
|
|
9075
|
+
return StudioTestService:ExecutePlayModeAsync(preparedTest.testArgs)
|
|
9041
9076
|
end
|
|
9042
|
-
return StudioTestService:ExecuteRunModeAsync(
|
|
9077
|
+
return StudioTestService:ExecuteRunModeAsync(preparedTest.testArgs)
|
|
9043
9078
|
end)
|
|
9044
|
-
PluginSession.
|
|
9079
|
+
PluginSession.finishTest(preparedTest.token)
|
|
9045
9080
|
if not ok then
|
|
9046
9081
|
warn(`[robloxstudio-mcp] Playtest ended with error: {result}`)
|
|
9047
9082
|
end
|
|
@@ -9159,13 +9194,13 @@ local function multiplayerTestStart(requestData)
|
|
|
9159
9194
|
testArgs = testArgs,
|
|
9160
9195
|
startedAt = tick(),
|
|
9161
9196
|
}
|
|
9162
|
-
local
|
|
9197
|
+
local topologyToken = PluginSession.prepareMultiplayerTest(testId)
|
|
9163
9198
|
task.spawn(function()
|
|
9164
9199
|
multiplayerState.phase = "running"
|
|
9165
9200
|
local ok, result = pcall(function()
|
|
9166
9201
|
return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
|
|
9167
9202
|
end)
|
|
9168
|
-
PluginSession.
|
|
9203
|
+
PluginSession.finishTest(topologyToken)
|
|
9169
9204
|
multiplayerState.completedAt = tick()
|
|
9170
9205
|
multiplayerState.ok = ok
|
|
9171
9206
|
if ok then
|
|
@@ -9868,6 +9903,187 @@ return {
|
|
|
9868
9903
|
</Properties>
|
|
9869
9904
|
</Item>
|
|
9870
9905
|
<Item class="ModuleScript" referent="29">
|
|
9906
|
+
<Properties>
|
|
9907
|
+
<string name="Name">PlaytestTickets</string>
|
|
9908
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9909
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
9910
|
+
local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
|
|
9911
|
+
-- Hands topology from an edit DataModel to the runtime DataModels of its
|
|
9912
|
+
-- playtest through plugin settings, so nothing MCP-owned has to be saved into
|
|
9913
|
+
-- the place that Studio copies. Plugin settings are one store shared by every
|
|
9914
|
+
-- Studio window running this plugin, and a window can silently overwrite
|
|
9915
|
+
-- another window's write. Tickets are therefore re-published on a heartbeat and
|
|
9916
|
+
-- ignored once that heartbeat stops.
|
|
9917
|
+
local BOARD_KEY = "MCP_PLAYTEST_TICKETS_V1"
|
|
9918
|
+
local CLAIM_KEY_PREFIX = "MCP_PLAYTEST_CLAIM_V1_"
|
|
9919
|
+
local STALE_AFTER_MS = 5_000
|
|
9920
|
+
local function nowMs()
|
|
9921
|
+
return DateTime.now().UnixTimestampMillis
|
|
9922
|
+
end
|
|
9923
|
+
local function readSetting(store, key)
|
|
9924
|
+
local ok, value = pcall(function()
|
|
9925
|
+
return store:GetSetting(key)
|
|
9926
|
+
end)
|
|
9927
|
+
return if ok then value else nil
|
|
9928
|
+
end
|
|
9929
|
+
local function writeSetting(store, key, value)
|
|
9930
|
+
pcall(function()
|
|
9931
|
+
return store:SetSetting(key, value)
|
|
9932
|
+
end)
|
|
9933
|
+
end
|
|
9934
|
+
local function optionalString(value)
|
|
9935
|
+
local _value = value
|
|
9936
|
+
local _condition = type(_value) == "string"
|
|
9937
|
+
if _condition then
|
|
9938
|
+
_condition = value ~= ""
|
|
9939
|
+
end
|
|
9940
|
+
return if _condition then value else nil
|
|
9941
|
+
end
|
|
9942
|
+
local function decodeTicket(value)
|
|
9943
|
+
local _value = value
|
|
9944
|
+
if not (type(_value) == "table") then
|
|
9945
|
+
return nil
|
|
9946
|
+
end
|
|
9947
|
+
local data = value
|
|
9948
|
+
local id = data.id
|
|
9949
|
+
local placeId = data.placeId
|
|
9950
|
+
local placeKey = data.placeKey
|
|
9951
|
+
local dataModelName = data.dataModelName
|
|
9952
|
+
local postedAt = data.postedAt
|
|
9953
|
+
local heartbeatAt = data.heartbeatAt
|
|
9954
|
+
if not (type(id) == "string") or id == "" then
|
|
9955
|
+
return nil
|
|
9956
|
+
end
|
|
9957
|
+
if not (type(placeId) == "number") or not (type(placeKey) == "string") or not (type(dataModelName) == "string") then
|
|
9958
|
+
return nil
|
|
9959
|
+
end
|
|
9960
|
+
if not (type(postedAt) == "number") or not (type(heartbeatAt) == "number") then
|
|
9961
|
+
return nil
|
|
9962
|
+
end
|
|
9963
|
+
local instanceId = optionalString(data.instanceId)
|
|
9964
|
+
local groupId = optionalString(data.groupId)
|
|
9965
|
+
if instanceId == nil and groupId == nil then
|
|
9966
|
+
return nil
|
|
9967
|
+
end
|
|
9968
|
+
return {
|
|
9969
|
+
id = id,
|
|
9970
|
+
instanceId = instanceId,
|
|
9971
|
+
groupId = groupId,
|
|
9972
|
+
placeId = placeId,
|
|
9973
|
+
placeKey = placeKey,
|
|
9974
|
+
dataModelName = dataModelName,
|
|
9975
|
+
serverUrl = optionalString(data.serverUrl),
|
|
9976
|
+
postedAt = postedAt,
|
|
9977
|
+
heartbeatAt = heartbeatAt,
|
|
9978
|
+
}
|
|
9979
|
+
end
|
|
9980
|
+
local function isFresh(ticket, now)
|
|
9981
|
+
return math.abs(now - ticket.heartbeatAt) <= STALE_AFTER_MS
|
|
9982
|
+
end
|
|
9983
|
+
local function readBoard(store)
|
|
9984
|
+
local raw = readSetting(store, BOARD_KEY)
|
|
9985
|
+
if not (type(raw) == "string") then
|
|
9986
|
+
return {}
|
|
9987
|
+
end
|
|
9988
|
+
local ok, tickets = pcall(function()
|
|
9989
|
+
local decoded = HttpService:JSONDecode(raw)
|
|
9990
|
+
local valid = {}
|
|
9991
|
+
if not (type(decoded) == "table") then
|
|
9992
|
+
return valid
|
|
9993
|
+
end
|
|
9994
|
+
for _, entry in decoded do
|
|
9995
|
+
local ticket = decodeTicket(entry)
|
|
9996
|
+
if ticket ~= nil then
|
|
9997
|
+
table.insert(valid, ticket)
|
|
9998
|
+
end
|
|
9999
|
+
end
|
|
10000
|
+
return valid
|
|
10001
|
+
end)
|
|
10002
|
+
return if ok then tickets else {}
|
|
10003
|
+
end
|
|
10004
|
+
local function writeBoard(store, tickets)
|
|
10005
|
+
if tickets[1] == nil then
|
|
10006
|
+
writeSetting(store, BOARD_KEY, nil)
|
|
10007
|
+
return nil
|
|
10008
|
+
end
|
|
10009
|
+
local ok, encoded = pcall(function()
|
|
10010
|
+
return HttpService:JSONEncode(tickets)
|
|
10011
|
+
end)
|
|
10012
|
+
if ok and type(encoded) == "string" then
|
|
10013
|
+
writeSetting(store, BOARD_KEY, encoded)
|
|
10014
|
+
end
|
|
10015
|
+
end
|
|
10016
|
+
--* Publishes or refreshes a ticket and prunes tickets whose owners stopped refreshing them.
|
|
10017
|
+
local function publish(store, ticket)
|
|
10018
|
+
local now = nowMs()
|
|
10019
|
+
local board = {}
|
|
10020
|
+
for _, existing in readBoard(store) do
|
|
10021
|
+
if existing.id ~= ticket.id and isFresh(existing, now) then
|
|
10022
|
+
table.insert(board, existing)
|
|
10023
|
+
end
|
|
10024
|
+
end
|
|
10025
|
+
local _object = table.clone(ticket)
|
|
10026
|
+
setmetatable(_object, nil)
|
|
10027
|
+
_object.heartbeatAt = now
|
|
10028
|
+
table.insert(board, _object)
|
|
10029
|
+
writeBoard(store, board)
|
|
10030
|
+
end
|
|
10031
|
+
local function withdraw(store, ticketId)
|
|
10032
|
+
local now = nowMs()
|
|
10033
|
+
local board = {}
|
|
10034
|
+
local changed = false
|
|
10035
|
+
for _, existing in readBoard(store) do
|
|
10036
|
+
if existing.id == ticketId or not isFresh(existing, now) then
|
|
10037
|
+
changed = true
|
|
10038
|
+
else
|
|
10039
|
+
table.insert(board, existing)
|
|
10040
|
+
end
|
|
10041
|
+
end
|
|
10042
|
+
if changed then
|
|
10043
|
+
writeBoard(store, board)
|
|
10044
|
+
end
|
|
10045
|
+
end
|
|
10046
|
+
local function liveTickets(store)
|
|
10047
|
+
local now = nowMs()
|
|
10048
|
+
local _exp = readBoard(store)
|
|
10049
|
+
-- ▼ ReadonlyArray.filter ▼
|
|
10050
|
+
local _newValue = {}
|
|
10051
|
+
local _callback = function(ticket)
|
|
10052
|
+
return isFresh(ticket, now)
|
|
10053
|
+
end
|
|
10054
|
+
local _length = 0
|
|
10055
|
+
for _k, _v in _exp do
|
|
10056
|
+
if _callback(_v, _k - 1, _exp) == true then
|
|
10057
|
+
_length += 1
|
|
10058
|
+
_newValue[_length] = _v
|
|
10059
|
+
end
|
|
10060
|
+
end
|
|
10061
|
+
-- ▲ ReadonlyArray.filter ▲
|
|
10062
|
+
return _newValue
|
|
10063
|
+
end
|
|
10064
|
+
local function readClaim(store, ticketId)
|
|
10065
|
+
return optionalString(readSetting(store, CLAIM_KEY_PREFIX .. ticketId))
|
|
10066
|
+
end
|
|
10067
|
+
local function writeClaim(store, ticketId, claimant)
|
|
10068
|
+
writeSetting(store, CLAIM_KEY_PREFIX .. ticketId, claimant)
|
|
10069
|
+
end
|
|
10070
|
+
local function clearClaim(store, ticketId)
|
|
10071
|
+
writeSetting(store, CLAIM_KEY_PREFIX .. ticketId, nil)
|
|
10072
|
+
end
|
|
10073
|
+
return {
|
|
10074
|
+
nowMs = nowMs,
|
|
10075
|
+
decodeTicket = decodeTicket,
|
|
10076
|
+
publish = publish,
|
|
10077
|
+
withdraw = withdraw,
|
|
10078
|
+
liveTickets = liveTickets,
|
|
10079
|
+
readClaim = readClaim,
|
|
10080
|
+
writeClaim = writeClaim,
|
|
10081
|
+
clearClaim = clearClaim,
|
|
10082
|
+
}
|
|
10083
|
+
]]></string>
|
|
10084
|
+
</Properties>
|
|
10085
|
+
</Item>
|
|
10086
|
+
<Item class="ModuleScript" referent="30">
|
|
9871
10087
|
<Properties>
|
|
9872
10088
|
<string name="Name">PluginSession</string>
|
|
9873
10089
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9880,101 +10096,542 @@ local ServerStorage = _services.ServerStorage
|
|
|
9880
10096
|
local State = TS.import(script, script.Parent, "State")
|
|
9881
10097
|
local PeerRole = TS.import(script, script.Parent, "PeerRole")
|
|
9882
10098
|
local TopologyId = TS.import(script, script.Parent, "TopologyId")
|
|
10099
|
+
local PlaytestTickets = TS.import(script, script.Parent, "PlaytestTickets")
|
|
9883
10100
|
local CoreGui = game:GetService("CoreGui")
|
|
9884
|
-
local
|
|
10101
|
+
local StudioTestService = game:GetService("StudioTestService")
|
|
10102
|
+
local LEGACY_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
9885
10103
|
local TOPOLOGY_MODE_ATTRIBUTE = "__MCPTopologyMode"
|
|
9886
10104
|
local TOPOLOGY_INSTANCE_ID_ATTRIBUTE = "__MCPTopologyInstanceId"
|
|
9887
10105
|
local TOPOLOGY_GROUP_ID_ATTRIBUTE = "__MCPTopologyGroupId"
|
|
9888
10106
|
local TOPOLOGY_TOKEN_ATTRIBUTE = "__MCPTopologyToken"
|
|
10107
|
+
-- Older plugin versions saved these into places. Nothing MCP-owned belongs in a
|
|
10108
|
+
-- saved or published DataModel, so edit sessions remove them on load.
|
|
10109
|
+
local LEGACY_TOPOLOGY_ATTRIBUTES = { TOPOLOGY_MODE_ATTRIBUTE, TOPOLOGY_INSTANCE_ID_ATTRIBUTE, TOPOLOGY_GROUP_ID_ATTRIBUTE, TOPOLOGY_TOKEN_ATTRIBUTE }
|
|
9889
10110
|
local SESSION_IDENTITY_NAME = "__MCPSessionIdentity"
|
|
10111
|
+
-- JSON session state lives in StringValue children of the CoreGui identity
|
|
10112
|
+
-- value: CoreGui survives plugin reloads but is never saved with the place or
|
|
10113
|
+
-- copied into playtest DataModels. Not attributes: with NextGenerationReplication,
|
|
10114
|
+
-- playtest DataModels reject attribute strings longer than 50 characters.
|
|
10115
|
+
local PLAY_RECORD_NAME = "PlayRecord"
|
|
10116
|
+
local RUNTIME_TOPOLOGY_NAME = "RuntimeTopology"
|
|
10117
|
+
-- MCP-started solo and Run playtests carry their topology in StudioTestService
|
|
10118
|
+
-- test args, which never touch the DataModel.
|
|
10119
|
+
local TEST_ARGS_TOPOLOGY_KEY = "__mcpTopology"
|
|
10120
|
+
local TICKET_HEARTBEAT_SEC = 1
|
|
10121
|
+
local UNCLAIMED_TICKET_LIFETIME_MS = 120_000
|
|
10122
|
+
local RESOLVE_POLL_SEC = 0.25
|
|
10123
|
+
-- A runtime claims a ticket only after it has been the single match for this
|
|
10124
|
+
-- long, so a second window's Play starting at the same moment is noticed.
|
|
10125
|
+
local TICKET_SETTLE_SEC = 1
|
|
10126
|
+
local NO_TICKET_TIMEOUT_SEC = 5
|
|
10127
|
+
local AMBIGUOUS_TICKET_TIMEOUT_SEC = 15
|
|
10128
|
+
-- A prepared MCP playtest that never started (its handler died) must not stop
|
|
10129
|
+
-- later Play-button runs from posting tickets.
|
|
10130
|
+
local ARMED_PLAY_TTL_SEC = 30
|
|
10131
|
+
--* The edit's current playtest, kept in CoreGui so a plugin reload can finish it.
|
|
9890
10132
|
local peerId = TopologyId.createPeerId()
|
|
9891
10133
|
local isEditSession = PeerRole.detect() == "edit"
|
|
9892
10134
|
local existingSessionIdentity = CoreGui:FindFirstChild(SESSION_IDENTITY_NAME)
|
|
9893
|
-
local
|
|
9894
|
-
local createdSessionIdentity = false
|
|
10135
|
+
local sessionIdentity
|
|
9895
10136
|
if existingSessionIdentity ~= nil and existingSessionIdentity:IsA("StringValue") and not existingSessionIdentity.Archivable and (string.match(existingSessionIdentity.Value, "^instance:[0-9a-z][0-9a-z][0-9a-z]%-[0-9a-z][0-9a-z][0-9a-z]$")) ~= nil then
|
|
9896
|
-
|
|
10137
|
+
sessionIdentity = existingSessionIdentity
|
|
9897
10138
|
else
|
|
9898
10139
|
local _result = existingSessionIdentity
|
|
9899
10140
|
if _result ~= nil then
|
|
9900
10141
|
_result:Destroy()
|
|
9901
10142
|
end
|
|
9902
|
-
|
|
9903
|
-
local sessionIdentity = Instance.new("StringValue")
|
|
10143
|
+
sessionIdentity = Instance.new("StringValue")
|
|
9904
10144
|
sessionIdentity.Name = SESSION_IDENTITY_NAME
|
|
9905
|
-
sessionIdentity.Value =
|
|
10145
|
+
sessionIdentity.Value = TopologyId.createInstanceId()
|
|
9906
10146
|
sessionIdentity.Archivable = false
|
|
9907
10147
|
sessionIdentity.Parent = CoreGui
|
|
9908
|
-
createdSessionIdentity = true
|
|
9909
10148
|
end
|
|
9910
|
-
local
|
|
10149
|
+
local sessionInstanceId = sessionIdentity.Value
|
|
10150
|
+
local settingsStore
|
|
10151
|
+
local serverUrlProvider
|
|
10152
|
+
local legacyPlaceKey
|
|
10153
|
+
local runtimeTopology
|
|
10154
|
+
local runtimeResolutionStarted = false
|
|
10155
|
+
local heartbeatTicketId
|
|
9911
10156
|
local cachedPlaceName
|
|
9912
10157
|
local cachedPlaceNamePlaceId
|
|
9913
|
-
local function getMarkerMode()
|
|
9914
|
-
local mode = ReplicatedStorage:GetAttribute(TOPOLOGY_MODE_ATTRIBUTE)
|
|
9915
|
-
return if mode == "shared" or mode == "multiplayer" then mode else nil
|
|
9916
|
-
end
|
|
9917
10158
|
local function getInstanceId()
|
|
9918
|
-
|
|
9919
|
-
|
|
10159
|
+
local _result = runtimeTopology
|
|
10160
|
+
if _result ~= nil then
|
|
10161
|
+
_result = _result.instanceId
|
|
9920
10162
|
end
|
|
9921
|
-
|
|
9922
|
-
|
|
9923
|
-
|
|
9924
|
-
inheritedInstanceId = sharedInstanceId
|
|
9925
|
-
return sharedInstanceId
|
|
9926
|
-
end
|
|
10163
|
+
local _condition = _result
|
|
10164
|
+
if _condition == nil then
|
|
10165
|
+
_condition = sessionInstanceId
|
|
9927
10166
|
end
|
|
9928
|
-
return
|
|
10167
|
+
return _condition
|
|
9929
10168
|
end
|
|
10169
|
+
local readPlayRecord
|
|
9930
10170
|
local function getMultiplayerGroupId()
|
|
9931
|
-
if
|
|
9932
|
-
|
|
10171
|
+
if not isEditSession then
|
|
10172
|
+
local _result = runtimeTopology
|
|
10173
|
+
if _result ~= nil then
|
|
10174
|
+
_result = _result.groupId
|
|
10175
|
+
end
|
|
10176
|
+
return _result
|
|
10177
|
+
end
|
|
10178
|
+
local _result = readPlayRecord()
|
|
10179
|
+
if _result ~= nil then
|
|
10180
|
+
_result = _result.ticket
|
|
10181
|
+
if _result ~= nil then
|
|
10182
|
+
_result = _result.groupId
|
|
10183
|
+
end
|
|
9933
10184
|
end
|
|
9934
|
-
|
|
9935
|
-
return if type(groupId) == "string" and groupId ~= "" then groupId else nil
|
|
10185
|
+
return _result
|
|
9936
10186
|
end
|
|
9937
10187
|
local function getPlaceKey()
|
|
10188
|
+
local _inherited = runtimeTopology
|
|
10189
|
+
if _inherited ~= nil then
|
|
10190
|
+
_inherited = _inherited.placeKey
|
|
10191
|
+
end
|
|
10192
|
+
local inherited = _inherited
|
|
10193
|
+
if inherited ~= nil then
|
|
10194
|
+
return inherited
|
|
10195
|
+
end
|
|
9938
10196
|
if game.PlaceId ~= 0 then
|
|
9939
10197
|
return `place:{tostring(game.PlaceId)}`
|
|
9940
10198
|
end
|
|
9941
|
-
|
|
9942
|
-
|
|
9943
|
-
|
|
10199
|
+
return `name:{game.Name}`
|
|
10200
|
+
end
|
|
10201
|
+
local function getLegacyPlaceKey()
|
|
10202
|
+
return legacyPlaceKey
|
|
10203
|
+
end
|
|
10204
|
+
local function getInheritedServerUrl()
|
|
10205
|
+
local _result = runtimeTopology
|
|
10206
|
+
if _result ~= nil then
|
|
10207
|
+
_result = _result.serverUrl
|
|
10208
|
+
end
|
|
10209
|
+
return _result
|
|
10210
|
+
end
|
|
10211
|
+
local function removeAttribute(target, name)
|
|
10212
|
+
if target:GetAttribute(name) == nil then
|
|
10213
|
+
return nil
|
|
9944
10214
|
end
|
|
9945
|
-
local fresh = HttpService:GenerateGUID(false)
|
|
9946
10215
|
pcall(function()
|
|
9947
|
-
return
|
|
10216
|
+
return target:SetAttribute(name, nil)
|
|
10217
|
+
end)
|
|
10218
|
+
end
|
|
10219
|
+
local function removeSavedSessionState()
|
|
10220
|
+
local savedPlaceId = ServerStorage:GetAttribute(LEGACY_PLACE_ID_ATTRIBUTE)
|
|
10221
|
+
if type(savedPlaceId) == "string" and savedPlaceId ~= "" then
|
|
10222
|
+
legacyPlaceKey = `anon:{savedPlaceId}`
|
|
10223
|
+
end
|
|
10224
|
+
removeAttribute(ServerStorage, LEGACY_PLACE_ID_ATTRIBUTE)
|
|
10225
|
+
for _, name in LEGACY_TOPOLOGY_ATTRIBUTES do
|
|
10226
|
+
removeAttribute(ReplicatedStorage, name)
|
|
10227
|
+
end
|
|
10228
|
+
end
|
|
10229
|
+
local function optionalString(value)
|
|
10230
|
+
local _value = value
|
|
10231
|
+
local _condition = type(_value) == "string"
|
|
10232
|
+
if _condition then
|
|
10233
|
+
_condition = value ~= ""
|
|
10234
|
+
end
|
|
10235
|
+
return if _condition then value else nil
|
|
10236
|
+
end
|
|
10237
|
+
local function readSessionState(name)
|
|
10238
|
+
local holder = sessionIdentity:FindFirstChild(name)
|
|
10239
|
+
if holder == nil or not holder:IsA("StringValue") then
|
|
10240
|
+
return nil
|
|
10241
|
+
end
|
|
10242
|
+
local ok, decoded = pcall(function()
|
|
10243
|
+
return HttpService:JSONDecode(holder.Value)
|
|
9948
10244
|
end)
|
|
9949
|
-
|
|
10245
|
+
if not ok or not (type(decoded) == "table") then
|
|
10246
|
+
return nil
|
|
10247
|
+
end
|
|
10248
|
+
return decoded
|
|
9950
10249
|
end
|
|
9951
|
-
|
|
10250
|
+
--* Best effort: saved session state only lets a plugin reload resume where it left off.
|
|
10251
|
+
local function writeSessionState(name, state)
|
|
10252
|
+
local ok, err = pcall(function()
|
|
10253
|
+
local existing = sessionIdentity:FindFirstChild(name)
|
|
10254
|
+
if state == nil then
|
|
10255
|
+
local _result = existing
|
|
10256
|
+
if _result ~= nil then
|
|
10257
|
+
_result:Destroy()
|
|
10258
|
+
end
|
|
10259
|
+
return nil
|
|
10260
|
+
end
|
|
10261
|
+
local encoded = HttpService:JSONEncode(state)
|
|
10262
|
+
if existing ~= nil and existing:IsA("StringValue") then
|
|
10263
|
+
existing.Value = encoded
|
|
10264
|
+
return nil
|
|
10265
|
+
end
|
|
10266
|
+
local _result = existing
|
|
10267
|
+
if _result ~= nil then
|
|
10268
|
+
_result:Destroy()
|
|
10269
|
+
end
|
|
10270
|
+
local holder = Instance.new("StringValue")
|
|
10271
|
+
holder.Name = name
|
|
10272
|
+
holder.Value = encoded
|
|
10273
|
+
holder.Archivable = false
|
|
10274
|
+
holder.Parent = sessionIdentity
|
|
10275
|
+
end)
|
|
10276
|
+
if not ok then
|
|
10277
|
+
warn(`[robloxstudio-mcp] Could not save {name} session state: {err}`)
|
|
10278
|
+
end
|
|
10279
|
+
end
|
|
10280
|
+
function readPlayRecord()
|
|
10281
|
+
local data = readSessionState(PLAY_RECORD_NAME)
|
|
10282
|
+
if data == nil then
|
|
10283
|
+
return nil
|
|
10284
|
+
end
|
|
10285
|
+
local phase = if data.phase == "armed" or data.phase == "active" then data.phase else nil
|
|
10286
|
+
if phase == nil then
|
|
10287
|
+
return nil
|
|
10288
|
+
end
|
|
10289
|
+
local armedAt = data.armedAt
|
|
10290
|
+
local ticketState = data.ticketState
|
|
10291
|
+
return {
|
|
10292
|
+
phase = phase,
|
|
10293
|
+
token = optionalString(data.token),
|
|
10294
|
+
armedAt = if type(armedAt) == "number" then armedAt else nil,
|
|
10295
|
+
ticket = PlaytestTickets.decodeTicket(data.ticket),
|
|
10296
|
+
ticketState = if ticketState == "posted" or ticketState == "claimed" or ticketState == "expired" then ticketState else nil,
|
|
10297
|
+
}
|
|
10298
|
+
end
|
|
10299
|
+
local function writePlayRecord(record)
|
|
10300
|
+
writeSessionState(PLAY_RECORD_NAME, record)
|
|
10301
|
+
end
|
|
10302
|
+
local function createTicket(instanceId, groupId)
|
|
10303
|
+
local now = PlaytestTickets.nowMs()
|
|
10304
|
+
local _object = {
|
|
10305
|
+
id = HttpService:GenerateGUID(false),
|
|
10306
|
+
instanceId = instanceId,
|
|
10307
|
+
groupId = groupId,
|
|
10308
|
+
placeId = game.PlaceId,
|
|
10309
|
+
placeKey = getPlaceKey(),
|
|
10310
|
+
dataModelName = game.Name,
|
|
10311
|
+
}
|
|
10312
|
+
local _left = "serverUrl"
|
|
10313
|
+
local _result = serverUrlProvider
|
|
10314
|
+
if _result ~= nil then
|
|
10315
|
+
_result = _result()
|
|
10316
|
+
end
|
|
10317
|
+
_object[_left] = _result
|
|
10318
|
+
_object.postedAt = now
|
|
10319
|
+
_object.heartbeatAt = now
|
|
10320
|
+
return _object
|
|
10321
|
+
end
|
|
10322
|
+
local refreshTicket
|
|
10323
|
+
local function startTicketHeartbeat(ticketId)
|
|
10324
|
+
if heartbeatTicketId == ticketId then
|
|
10325
|
+
return nil
|
|
10326
|
+
end
|
|
10327
|
+
heartbeatTicketId = ticketId
|
|
10328
|
+
task.delay(TICKET_HEARTBEAT_SEC, function()
|
|
10329
|
+
return refreshTicket(ticketId)
|
|
10330
|
+
end)
|
|
10331
|
+
end
|
|
10332
|
+
function refreshTicket(ticketId)
|
|
10333
|
+
if heartbeatTicketId ~= ticketId then
|
|
10334
|
+
return nil
|
|
10335
|
+
end
|
|
10336
|
+
local store = settingsStore
|
|
10337
|
+
local record = readPlayRecord()
|
|
10338
|
+
local _ticket = record
|
|
10339
|
+
if _ticket ~= nil then
|
|
10340
|
+
_ticket = _ticket.ticket
|
|
10341
|
+
end
|
|
10342
|
+
local ticket = _ticket
|
|
10343
|
+
if store == nil or record == nil or ticket == nil or ticket.id ~= ticketId or record.ticketState ~= "posted" then
|
|
10344
|
+
heartbeatTicketId = nil
|
|
10345
|
+
return nil
|
|
10346
|
+
end
|
|
10347
|
+
if PlaytestTickets.readClaim(store, ticketId) ~= nil then
|
|
10348
|
+
-- Keep the claim itself until Play ends: the runtime verifies it.
|
|
10349
|
+
PlaytestTickets.withdraw(store, ticketId)
|
|
10350
|
+
record.ticketState = "claimed"
|
|
10351
|
+
writePlayRecord(record)
|
|
10352
|
+
heartbeatTicketId = nil
|
|
10353
|
+
return nil
|
|
10354
|
+
end
|
|
10355
|
+
if PlaytestTickets.nowMs() - ticket.postedAt > UNCLAIMED_TICKET_LIFETIME_MS then
|
|
10356
|
+
PlaytestTickets.withdraw(store, ticketId)
|
|
10357
|
+
record.ticketState = "expired"
|
|
10358
|
+
writePlayRecord(record)
|
|
10359
|
+
heartbeatTicketId = nil
|
|
10360
|
+
return nil
|
|
10361
|
+
end
|
|
10362
|
+
PlaytestTickets.publish(store, ticket)
|
|
10363
|
+
task.delay(TICKET_HEARTBEAT_SEC, function()
|
|
10364
|
+
return refreshTicket(ticketId)
|
|
10365
|
+
end)
|
|
10366
|
+
end
|
|
10367
|
+
local function retirePlayRecord(record)
|
|
10368
|
+
local store = settingsStore
|
|
10369
|
+
local ticket = record.ticket
|
|
10370
|
+
if store == nil or ticket == nil then
|
|
10371
|
+
return nil
|
|
10372
|
+
end
|
|
10373
|
+
PlaytestTickets.withdraw(store, ticket.id)
|
|
10374
|
+
PlaytestTickets.clearClaim(store, ticket.id)
|
|
10375
|
+
end
|
|
10376
|
+
local function beginPlay()
|
|
10377
|
+
local store = settingsStore
|
|
10378
|
+
if store == nil then
|
|
10379
|
+
return nil
|
|
10380
|
+
end
|
|
10381
|
+
local record = readPlayRecord()
|
|
10382
|
+
if record ~= nil then
|
|
10383
|
+
if record.phase == "active" then
|
|
10384
|
+
return nil
|
|
10385
|
+
end
|
|
10386
|
+
local _exp = os.clock()
|
|
10387
|
+
local _condition = record.armedAt
|
|
10388
|
+
if _condition == nil then
|
|
10389
|
+
_condition = 0
|
|
10390
|
+
end
|
|
10391
|
+
if _exp - _condition <= ARMED_PLAY_TTL_SEC then
|
|
10392
|
+
record.phase = "active"
|
|
10393
|
+
writePlayRecord(record)
|
|
10394
|
+
return nil
|
|
10395
|
+
end
|
|
10396
|
+
retirePlayRecord(record)
|
|
10397
|
+
end
|
|
10398
|
+
local ticket = createTicket(sessionInstanceId, nil)
|
|
10399
|
+
writePlayRecord({
|
|
10400
|
+
phase = "active",
|
|
10401
|
+
ticket = ticket,
|
|
10402
|
+
ticketState = "posted",
|
|
10403
|
+
})
|
|
10404
|
+
PlaytestTickets.publish(store, ticket)
|
|
10405
|
+
startTicketHeartbeat(ticket.id)
|
|
10406
|
+
end
|
|
10407
|
+
local function finishPlay()
|
|
10408
|
+
local record = readPlayRecord()
|
|
10409
|
+
-- An armed record belongs to an MCP playtest whose Play has not begun yet.
|
|
10410
|
+
if record == nil or record.phase ~= "active" then
|
|
10411
|
+
return nil
|
|
10412
|
+
end
|
|
10413
|
+
retirePlayRecord(record)
|
|
10414
|
+
writePlayRecord(nil)
|
|
10415
|
+
end
|
|
10416
|
+
local function armPlay(record)
|
|
10417
|
+
local existing = readPlayRecord()
|
|
10418
|
+
if existing ~= nil then
|
|
10419
|
+
retirePlayRecord(existing)
|
|
10420
|
+
end
|
|
10421
|
+
writePlayRecord(record)
|
|
10422
|
+
end
|
|
10423
|
+
local function prepareSoloTest()
|
|
10424
|
+
local token = HttpService:GenerateGUID(false)
|
|
10425
|
+
armPlay({
|
|
10426
|
+
phase = "armed",
|
|
10427
|
+
token = token,
|
|
10428
|
+
armedAt = os.clock(),
|
|
10429
|
+
})
|
|
10430
|
+
local _object = {
|
|
10431
|
+
token = token,
|
|
10432
|
+
}
|
|
10433
|
+
local _left = "testArgs"
|
|
10434
|
+
local _object_1 = {}
|
|
10435
|
+
local _left_1 = TEST_ARGS_TOPOLOGY_KEY
|
|
10436
|
+
local _object_2 = {
|
|
10437
|
+
instanceId = sessionInstanceId,
|
|
10438
|
+
placeKey = getPlaceKey(),
|
|
10439
|
+
}
|
|
10440
|
+
local _left_2 = "serverUrl"
|
|
10441
|
+
local _result = serverUrlProvider
|
|
10442
|
+
if _result ~= nil then
|
|
10443
|
+
_result = _result()
|
|
10444
|
+
end
|
|
10445
|
+
_object_2[_left_2] = _result
|
|
10446
|
+
_object_1[_left_1] = _object_2
|
|
10447
|
+
_object[_left] = _object_1
|
|
10448
|
+
return _object
|
|
10449
|
+
end
|
|
10450
|
+
--[[
|
|
10451
|
+
*
|
|
10452
|
+
* Multiplayer test args belong to the user's game code, so the group reaches
|
|
10453
|
+
* the test server through a ticket instead.
|
|
10454
|
+
|
|
10455
|
+
]]
|
|
10456
|
+
local function prepareMultiplayerTest(groupId)
|
|
9952
10457
|
local token = HttpService:GenerateGUID(false)
|
|
9953
|
-
|
|
9954
|
-
|
|
9955
|
-
|
|
9956
|
-
|
|
9957
|
-
|
|
10458
|
+
local ticket = createTicket(nil, groupId)
|
|
10459
|
+
armPlay({
|
|
10460
|
+
phase = "armed",
|
|
10461
|
+
token = token,
|
|
10462
|
+
armedAt = os.clock(),
|
|
10463
|
+
ticket = ticket,
|
|
10464
|
+
ticketState = "posted",
|
|
10465
|
+
})
|
|
10466
|
+
local store = settingsStore
|
|
10467
|
+
if store ~= nil then
|
|
10468
|
+
PlaytestTickets.publish(store, ticket)
|
|
10469
|
+
startTicketHeartbeat(ticket.id)
|
|
10470
|
+
end
|
|
9958
10471
|
return token
|
|
9959
10472
|
end
|
|
9960
|
-
local function
|
|
9961
|
-
|
|
10473
|
+
local function finishTest(token)
|
|
10474
|
+
local record = readPlayRecord()
|
|
10475
|
+
if record == nil or record.token ~= token then
|
|
10476
|
+
return nil
|
|
10477
|
+
end
|
|
10478
|
+
retirePlayRecord(record)
|
|
10479
|
+
writePlayRecord(nil)
|
|
9962
10480
|
end
|
|
9963
|
-
local function
|
|
9964
|
-
|
|
10481
|
+
local function onEditModeChanged()
|
|
10482
|
+
if StudioTestService.EditModeActive then
|
|
10483
|
+
finishPlay()
|
|
10484
|
+
else
|
|
10485
|
+
beginPlay()
|
|
10486
|
+
end
|
|
9965
10487
|
end
|
|
9966
|
-
local function
|
|
9967
|
-
|
|
10488
|
+
local function matchingTickets(store)
|
|
10489
|
+
local samePlace = {}
|
|
10490
|
+
local nameMatched = false
|
|
10491
|
+
for _, ticket in PlaytestTickets.liveTickets(store) do
|
|
10492
|
+
if ticket.placeId ~= game.PlaceId then
|
|
10493
|
+
continue
|
|
10494
|
+
end
|
|
10495
|
+
local claimant = PlaytestTickets.readClaim(store, ticket.id)
|
|
10496
|
+
if claimant ~= nil and claimant ~= peerId then
|
|
10497
|
+
continue
|
|
10498
|
+
end
|
|
10499
|
+
table.insert(samePlace, ticket)
|
|
10500
|
+
if ticket.dataModelName == game.Name then
|
|
10501
|
+
nameMatched = true
|
|
10502
|
+
end
|
|
10503
|
+
end
|
|
10504
|
+
-- Runtime DataModel names are expected to match their edit, but only narrow
|
|
10505
|
+
-- on the name when it actually distinguishes a candidate.
|
|
10506
|
+
local _result
|
|
10507
|
+
if nameMatched then
|
|
10508
|
+
-- ▼ ReadonlyArray.filter ▼
|
|
10509
|
+
local _newValue = {}
|
|
10510
|
+
local _callback = function(ticket)
|
|
10511
|
+
return ticket.dataModelName == game.Name
|
|
10512
|
+
end
|
|
10513
|
+
local _length = 0
|
|
10514
|
+
for _k, _v in samePlace do
|
|
10515
|
+
if _callback(_v, _k - 1, samePlace) == true then
|
|
10516
|
+
_length += 1
|
|
10517
|
+
_newValue[_length] = _v
|
|
10518
|
+
end
|
|
10519
|
+
end
|
|
10520
|
+
-- ▲ ReadonlyArray.filter ▲
|
|
10521
|
+
_result = _newValue
|
|
10522
|
+
else
|
|
10523
|
+
_result = samePlace
|
|
10524
|
+
end
|
|
10525
|
+
return _result
|
|
10526
|
+
end
|
|
10527
|
+
--[[
|
|
10528
|
+
*
|
|
10529
|
+
* Waits for exactly one matching ticket and claims it. Returns nothing when no
|
|
10530
|
+
* edit posts a ticket, or when several windows of the same place started Play
|
|
10531
|
+
* together: the runtime then keeps its own identity rather than guessing.
|
|
10532
|
+
|
|
10533
|
+
]]
|
|
10534
|
+
local function claimTicket(store)
|
|
10535
|
+
local startedAt = os.clock()
|
|
10536
|
+
local candidateSeen = false
|
|
10537
|
+
local settlingId
|
|
10538
|
+
local settlingSince = 0
|
|
10539
|
+
while true do
|
|
10540
|
+
local now = os.clock()
|
|
10541
|
+
local candidates = matchingTickets(store)
|
|
10542
|
+
local first = candidates[1]
|
|
10543
|
+
local only = if first ~= nil and candidates[2] == nil then first else nil
|
|
10544
|
+
if first ~= nil then
|
|
10545
|
+
candidateSeen = true
|
|
10546
|
+
end
|
|
10547
|
+
if only == nil then
|
|
10548
|
+
settlingId = nil
|
|
10549
|
+
elseif settlingId ~= only.id then
|
|
10550
|
+
settlingId = only.id
|
|
10551
|
+
settlingSince = now
|
|
10552
|
+
elseif now - settlingSince >= TICKET_SETTLE_SEC then
|
|
10553
|
+
PlaytestTickets.writeClaim(store, only.id, peerId)
|
|
10554
|
+
task.wait(RESOLVE_POLL_SEC)
|
|
10555
|
+
if PlaytestTickets.readClaim(store, only.id) == peerId then
|
|
10556
|
+
return only
|
|
10557
|
+
end
|
|
10558
|
+
settlingId = nil
|
|
10559
|
+
continue
|
|
10560
|
+
end
|
|
10561
|
+
local timeout = if candidateSeen then AMBIGUOUS_TICKET_TIMEOUT_SEC else NO_TICKET_TIMEOUT_SEC
|
|
10562
|
+
if now - startedAt >= timeout then
|
|
10563
|
+
return nil
|
|
10564
|
+
end
|
|
10565
|
+
task.wait(RESOLVE_POLL_SEC)
|
|
10566
|
+
end
|
|
10567
|
+
end
|
|
10568
|
+
local function topologyFromTestArgs()
|
|
10569
|
+
local ok, args = pcall(function()
|
|
10570
|
+
return StudioTestService:GetTestArgs()
|
|
10571
|
+
end)
|
|
10572
|
+
if not ok or not (type(args) == "table") then
|
|
9968
10573
|
return nil
|
|
9969
10574
|
end
|
|
9970
|
-
|
|
9971
|
-
|
|
10575
|
+
local testArgs = args
|
|
10576
|
+
local envelope = testArgs[TEST_ARGS_TOPOLOGY_KEY]
|
|
10577
|
+
if not (type(envelope) == "table") then
|
|
9972
10578
|
return nil
|
|
9973
10579
|
end
|
|
9974
|
-
|
|
9975
|
-
|
|
9976
|
-
|
|
9977
|
-
|
|
10580
|
+
local topology = envelope
|
|
10581
|
+
local instanceId = optionalString(topology.instanceId)
|
|
10582
|
+
if instanceId == nil then
|
|
10583
|
+
return nil
|
|
10584
|
+
end
|
|
10585
|
+
return {
|
|
10586
|
+
instanceId = instanceId,
|
|
10587
|
+
placeKey = optionalString(topology.placeKey),
|
|
10588
|
+
serverUrl = optionalString(topology.serverUrl),
|
|
10589
|
+
}
|
|
10590
|
+
end
|
|
10591
|
+
local function readRememberedRuntimeTopology()
|
|
10592
|
+
local data = readSessionState(RUNTIME_TOPOLOGY_NAME)
|
|
10593
|
+
if data == nil then
|
|
10594
|
+
return nil
|
|
10595
|
+
end
|
|
10596
|
+
return {
|
|
10597
|
+
instanceId = optionalString(data.instanceId),
|
|
10598
|
+
groupId = optionalString(data.groupId),
|
|
10599
|
+
placeKey = optionalString(data.placeKey),
|
|
10600
|
+
serverUrl = optionalString(data.serverUrl),
|
|
10601
|
+
}
|
|
10602
|
+
end
|
|
10603
|
+
local function adoptRuntimeTopology(topology)
|
|
10604
|
+
runtimeTopology = topology
|
|
10605
|
+
writeSessionState(RUNTIME_TOPOLOGY_NAME, topology)
|
|
10606
|
+
end
|
|
10607
|
+
--[[
|
|
10608
|
+
*
|
|
10609
|
+
* Resolves which edit session this playtest server belongs to. Yields; call it
|
|
10610
|
+
* before the server peer registers with MCP.
|
|
10611
|
+
|
|
10612
|
+
]]
|
|
10613
|
+
local function resolveRuntimeTopology()
|
|
10614
|
+
if isEditSession or runtimeTopology ~= nil or runtimeResolutionStarted then
|
|
10615
|
+
return nil
|
|
10616
|
+
end
|
|
10617
|
+
if PeerRole.detect() ~= "server" then
|
|
10618
|
+
return nil
|
|
10619
|
+
end
|
|
10620
|
+
runtimeResolutionStarted = true
|
|
10621
|
+
local fromArgs = topologyFromTestArgs()
|
|
10622
|
+
if fromArgs ~= nil then
|
|
10623
|
+
adoptRuntimeTopology(fromArgs)
|
|
10624
|
+
return nil
|
|
10625
|
+
end
|
|
10626
|
+
local store = settingsStore
|
|
10627
|
+
local ticket = if store ~= nil then claimTicket(store) else nil
|
|
10628
|
+
-- Record a fallback too, so a plugin reload keeps this server's own identity.
|
|
10629
|
+
adoptRuntimeTopology(if ticket == nil then {} else {
|
|
10630
|
+
instanceId = ticket.instanceId,
|
|
10631
|
+
groupId = ticket.groupId,
|
|
10632
|
+
placeKey = ticket.placeKey,
|
|
10633
|
+
serverUrl = ticket.serverUrl,
|
|
10634
|
+
})
|
|
9978
10635
|
end
|
|
9979
10636
|
local function getRole()
|
|
9980
10637
|
return PeerRole.detect()
|
|
@@ -10031,32 +10688,69 @@ local function createReadyPayload(readyPeerId, role, instanceId, multiplayerGrou
|
|
|
10031
10688
|
timestamp = tick(),
|
|
10032
10689
|
}
|
|
10033
10690
|
end
|
|
10034
|
-
|
|
10035
|
-
|
|
10036
|
-
if
|
|
10037
|
-
|
|
10038
|
-
prepareSharedTopology()
|
|
10691
|
+
local function resumePlayRecord()
|
|
10692
|
+
local record = readPlayRecord()
|
|
10693
|
+
if record == nil then
|
|
10694
|
+
return nil
|
|
10039
10695
|
end
|
|
10040
|
-
|
|
10041
|
-
|
|
10696
|
+
if StudioTestService.EditModeActive then
|
|
10697
|
+
-- The Play ended while the plugin was reloading.
|
|
10698
|
+
if record.phase == "active" then
|
|
10699
|
+
finishPlay()
|
|
10700
|
+
end
|
|
10701
|
+
elseif record.phase == "armed" then
|
|
10702
|
+
beginPlay()
|
|
10703
|
+
end
|
|
10704
|
+
local current = readPlayRecord()
|
|
10705
|
+
local _ticket = current
|
|
10706
|
+
if _ticket ~= nil then
|
|
10707
|
+
_ticket = _ticket.ticket
|
|
10708
|
+
end
|
|
10709
|
+
local ticket = _ticket
|
|
10710
|
+
local _result = current
|
|
10711
|
+
if _result ~= nil then
|
|
10712
|
+
_result = _result.ticketState
|
|
10713
|
+
end
|
|
10714
|
+
local _condition = _result == "posted"
|
|
10715
|
+
if _condition then
|
|
10716
|
+
_condition = ticket ~= nil
|
|
10717
|
+
end
|
|
10718
|
+
if _condition then
|
|
10719
|
+
startTicketHeartbeat(ticket.id)
|
|
10720
|
+
end
|
|
10721
|
+
end
|
|
10722
|
+
local function init(store, options)
|
|
10723
|
+
settingsStore = store
|
|
10724
|
+
serverUrlProvider = options.getServerUrl
|
|
10725
|
+
if not isEditSession then
|
|
10726
|
+
runtimeTopology = readRememberedRuntimeTopology()
|
|
10727
|
+
return nil
|
|
10728
|
+
end
|
|
10729
|
+
removeSavedSessionState()
|
|
10730
|
+
StudioTestService:GetPropertyChangedSignal("EditModeActive"):Connect(onEditModeChanged)
|
|
10731
|
+
resumePlayRecord()
|
|
10042
10732
|
end
|
|
10043
10733
|
return {
|
|
10044
10734
|
peerId = peerId,
|
|
10735
|
+
init = init,
|
|
10736
|
+
resolveRuntimeTopology = resolveRuntimeTopology,
|
|
10045
10737
|
getInstanceId = getInstanceId,
|
|
10046
10738
|
getMultiplayerGroupId = getMultiplayerGroupId,
|
|
10047
10739
|
getPlaceKey = getPlaceKey,
|
|
10740
|
+
getLegacyPlaceKey = getLegacyPlaceKey,
|
|
10741
|
+
getInheritedServerUrl = getInheritedServerUrl,
|
|
10048
10742
|
getRole = getRole,
|
|
10049
10743
|
getPlaceName = getPlaceName,
|
|
10050
10744
|
invalidatePlaceName = invalidatePlaceName,
|
|
10051
|
-
|
|
10052
|
-
|
|
10053
|
-
|
|
10745
|
+
prepareSoloTest = prepareSoloTest,
|
|
10746
|
+
finishTest = finishTest,
|
|
10747
|
+
prepareMultiplayerTest = prepareMultiplayerTest,
|
|
10054
10748
|
createReadyPayload = createReadyPayload,
|
|
10055
10749
|
}
|
|
10056
10750
|
]]></string>
|
|
10057
10751
|
</Properties>
|
|
10058
10752
|
</Item>
|
|
10059
|
-
<Item class="ModuleScript" referent="
|
|
10753
|
+
<Item class="ModuleScript" referent="31">
|
|
10060
10754
|
<Properties>
|
|
10061
10755
|
<string name="Name">Recording</string>
|
|
10062
10756
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10086,7 +10780,7 @@ return {
|
|
|
10086
10780
|
]]></string>
|
|
10087
10781
|
</Properties>
|
|
10088
10782
|
</Item>
|
|
10089
|
-
<Item class="ModuleScript" referent="
|
|
10783
|
+
<Item class="ModuleScript" referent="32">
|
|
10090
10784
|
<Properties>
|
|
10091
10785
|
<string name="Name">RenderMonitor</string>
|
|
10092
10786
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10154,7 +10848,7 @@ return {
|
|
|
10154
10848
|
]]></string>
|
|
10155
10849
|
</Properties>
|
|
10156
10850
|
</Item>
|
|
10157
|
-
<Item class="ModuleScript" referent="
|
|
10851
|
+
<Item class="ModuleScript" referent="33">
|
|
10158
10852
|
<Properties>
|
|
10159
10853
|
<string name="Name">RuntimeLogBuffer</string>
|
|
10160
10854
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10390,7 +11084,7 @@ return {
|
|
|
10390
11084
|
]]></string>
|
|
10391
11085
|
</Properties>
|
|
10392
11086
|
</Item>
|
|
10393
|
-
<Item class="ModuleScript" referent="
|
|
11087
|
+
<Item class="ModuleScript" referent="34">
|
|
10394
11088
|
<Properties>
|
|
10395
11089
|
<string name="Name">ScriptSearch</string>
|
|
10396
11090
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10717,14 +11411,12 @@ return {
|
|
|
10717
11411
|
]]></string>
|
|
10718
11412
|
</Properties>
|
|
10719
11413
|
</Item>
|
|
10720
|
-
<Item class="ModuleScript" referent="
|
|
11414
|
+
<Item class="ModuleScript" referent="35">
|
|
10721
11415
|
<Properties>
|
|
10722
11416
|
<string name="Name">ServerUrlSettings</string>
|
|
10723
11417
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10724
11418
|
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
10725
|
-
local
|
|
10726
|
-
local HttpService = _services.HttpService
|
|
10727
|
-
local ServerStorage = _services.ServerStorage
|
|
11419
|
+
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
10728
11420
|
local SETTING_KEY_PREFIX = "MCP_LAST_SUCCESSFUL_SERVER_URL_"
|
|
10729
11421
|
local GLOBAL_SETTING_KEY = "MCP_LAST_SUCCESSFUL_SERVER_URL_GLOBAL_V1"
|
|
10730
11422
|
local pluginRef
|
|
@@ -10764,30 +11456,12 @@ local function addUnique(values, value)
|
|
|
10764
11456
|
table.insert(_values_1, _value_1)
|
|
10765
11457
|
end
|
|
10766
11458
|
end
|
|
10767
|
-
local function computePlaceKeys(
|
|
10768
|
-
local placeKeys = {}
|
|
10769
|
-
|
|
10770
|
-
|
|
10771
|
-
|
|
10772
|
-
|
|
10773
|
-
if type(existing) == "string" and existing ~= "" then
|
|
10774
|
-
addUnique(placeKeys, `anon:{existing}`)
|
|
10775
|
-
else
|
|
10776
|
-
local _condition = game.PlaceId == 0
|
|
10777
|
-
if _condition then
|
|
10778
|
-
local _result = options
|
|
10779
|
-
if _result ~= nil then
|
|
10780
|
-
_result = _result.createAnonymous
|
|
10781
|
-
end
|
|
10782
|
-
_condition = _result == true
|
|
10783
|
-
end
|
|
10784
|
-
if _condition then
|
|
10785
|
-
local fresh = HttpService:GenerateGUID(false)
|
|
10786
|
-
pcall(function()
|
|
10787
|
-
return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
|
|
10788
|
-
end)
|
|
10789
|
-
addUnique(placeKeys, `anon:{fresh}`)
|
|
10790
|
-
end
|
|
11459
|
+
local function computePlaceKeys()
|
|
11460
|
+
local placeKeys = { PluginSession.getPlaceKey() }
|
|
11461
|
+
-- Unpublished places used to be keyed by a saved __MCPPlaceId attribute.
|
|
11462
|
+
local legacyPlaceKey = PluginSession.getLegacyPlaceKey()
|
|
11463
|
+
if legacyPlaceKey ~= nil then
|
|
11464
|
+
addUnique(placeKeys, legacyPlaceKey)
|
|
10791
11465
|
end
|
|
10792
11466
|
return placeKeys
|
|
10793
11467
|
end
|
|
@@ -10821,19 +11495,14 @@ local function rememberServerUrl(serverUrl)
|
|
|
10821
11495
|
return nil
|
|
10822
11496
|
end
|
|
10823
11497
|
writeSettingString(GLOBAL_SETTING_KEY, normalized)
|
|
10824
|
-
|
|
10825
|
-
createAnonymous = true,
|
|
10826
|
-
}) do
|
|
10827
|
-
writeSettingString(settingKey(placeKey), normalized)
|
|
10828
|
-
end
|
|
11498
|
+
writeSettingString(settingKey(PluginSession.getPlaceKey()), normalized)
|
|
10829
11499
|
end
|
|
10830
11500
|
local function readServerUrl()
|
|
10831
11501
|
if not pluginRef then
|
|
10832
11502
|
return nil
|
|
10833
11503
|
end
|
|
10834
|
-
--
|
|
10835
|
-
--
|
|
10836
|
-
-- edit/server place-scoped setting.
|
|
11504
|
+
-- The legacy key is read only, so a place remembered by an older plugin
|
|
11505
|
+
-- version keeps its server URL until the current key is written.
|
|
10837
11506
|
for _, placeKey in computePlaceKeys() do
|
|
10838
11507
|
local remembered = readSettingString(settingKey(placeKey))
|
|
10839
11508
|
if remembered ~= nil then
|
|
@@ -10856,11 +11525,11 @@ return {
|
|
|
10856
11525
|
]]></string>
|
|
10857
11526
|
</Properties>
|
|
10858
11527
|
</Item>
|
|
10859
|
-
<Item class="ModuleScript" referent="
|
|
11528
|
+
<Item class="ModuleScript" referent="36">
|
|
10860
11529
|
<Properties>
|
|
10861
11530
|
<string name="Name">State</string>
|
|
10862
11531
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10863
|
-
local CURRENT_VERSION = "3.1
|
|
11532
|
+
local CURRENT_VERSION = "3.2.1"
|
|
10864
11533
|
local PLUGIN_VARIANT = "main"
|
|
10865
11534
|
local BASE_PORT = 58741
|
|
10866
11535
|
local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
|
|
@@ -10998,14 +11667,14 @@ return {
|
|
|
10998
11667
|
]]></string>
|
|
10999
11668
|
</Properties>
|
|
11000
11669
|
</Item>
|
|
11001
|
-
<Item class="ModuleScript" referent="
|
|
11670
|
+
<Item class="ModuleScript" referent="37">
|
|
11002
11671
|
<Properties>
|
|
11003
11672
|
<string name="Name">StopPlayMonitor</string>
|
|
11004
11673
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
11005
11674
|
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
11006
|
-
-- Cross-DataModel stop_playtest signaling via plugin settings.
|
|
11007
|
-
--
|
|
11008
|
-
--
|
|
11675
|
+
-- Cross-DataModel stop_playtest signaling via plugin settings. A solo play
|
|
11676
|
+
-- server inherits its edit's instanceId from PluginSession (test args or a
|
|
11677
|
+
-- plugin-settings ticket), so only the intended Studio process consumes it.
|
|
11009
11678
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
11010
11679
|
local HttpService = _services.HttpService
|
|
11011
11680
|
local RunService = _services.RunService
|
|
@@ -11284,7 +11953,7 @@ return {
|
|
|
11284
11953
|
]]></string>
|
|
11285
11954
|
</Properties>
|
|
11286
11955
|
</Item>
|
|
11287
|
-
<Item class="ModuleScript" referent="
|
|
11956
|
+
<Item class="ModuleScript" referent="38">
|
|
11288
11957
|
<Properties>
|
|
11289
11958
|
<string name="Name">StudioWebSocket</string>
|
|
11290
11959
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -11307,6 +11976,10 @@ local MAX_PENDING_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
|
11307
11976
|
local RESERVED_ERROR_BYTES = 4096
|
|
11308
11977
|
-- The bridge admits 128 UTF-16 code units; each can require three UTF-8 bytes.
|
|
11309
11978
|
local MAX_REQUEST_ID_BYTES = 128 * 3
|
|
11979
|
+
-- Upper bound on BindToClose waiting for the server to close the socket after
|
|
11980
|
+
-- the unregister frame; it normally closes within milliseconds.
|
|
11981
|
+
local SHUTDOWN_UNREGISTER_WAIT_SECONDS = 2
|
|
11982
|
+
local SHUTDOWN_UNREGISTER_POLL_SECONDS = 0.05
|
|
11310
11983
|
local options
|
|
11311
11984
|
local active = false
|
|
11312
11985
|
local shutdownSuspended = false
|
|
@@ -12349,8 +13022,13 @@ local function refresh()
|
|
|
12349
13022
|
refreshRequested = true
|
|
12350
13023
|
flushRefresh()
|
|
12351
13024
|
end
|
|
12352
|
-
-- EndTest may tear down this VM without an Unloading callback
|
|
12353
|
-
--
|
|
13025
|
+
-- EndTest may tear down this VM without an Unloading callback, and BindToClose
|
|
13026
|
+
-- waits for this function. It unregisters on the socket itself: Studio runs at
|
|
13027
|
+
-- most five plugin HTTP requests at a time across the whole process, with no
|
|
13028
|
+
-- time limit, so a /disconnect queued behind five that never finish held
|
|
13029
|
+
-- shutdown until Studio's 30 s deadline. Closing right after Send can drop the
|
|
13030
|
+
-- frame, so it waits briefly for the server to close the socket first. A
|
|
13031
|
+
-- failed EndTest can resume and registers again.
|
|
12354
13032
|
local function suspendForShutdown()
|
|
12355
13033
|
local currentOptions = options
|
|
12356
13034
|
if not active or currentOptions == nil then
|
|
@@ -12362,9 +13040,34 @@ local function suspendForShutdown()
|
|
|
12362
13040
|
cancelTransportWork()
|
|
12363
13041
|
refreshRequested = false
|
|
12364
13042
|
reconnectAttempt = 0
|
|
13043
|
+
local client = socketClient
|
|
13044
|
+
local unregistered = false
|
|
13045
|
+
if client ~= nil and socketOpen then
|
|
13046
|
+
local serverClosed = false
|
|
13047
|
+
local closedConnection = client.Closed:Connect(function()
|
|
13048
|
+
serverClosed = true
|
|
13049
|
+
end)
|
|
13050
|
+
local sent = pcall(function()
|
|
13051
|
+
return client:Send(HttpService:JSONEncode({
|
|
13052
|
+
kind = "disconnect",
|
|
13053
|
+
}))
|
|
13054
|
+
end)
|
|
13055
|
+
unregistered = sent
|
|
13056
|
+
local deadline = os.clock() + SHUTDOWN_UNREGISTER_WAIT_SECONDS
|
|
13057
|
+
while sent and not serverClosed and os.clock() < deadline do
|
|
13058
|
+
task.wait(SHUTDOWN_UNREGISTER_POLL_SECONDS)
|
|
13059
|
+
end
|
|
13060
|
+
closedConnection:Disconnect()
|
|
13061
|
+
end
|
|
12365
13062
|
closeCurrentSocket()
|
|
12366
13063
|
cachedReady = nil
|
|
12367
|
-
|
|
13064
|
+
-- Without an open socket, unregister over HTTP on another thread rather
|
|
13065
|
+
-- than wait; the server also drops a peer whose socket stays closed.
|
|
13066
|
+
if not unregistered then
|
|
13067
|
+
task.spawn(function()
|
|
13068
|
+
return disconnectSession(currentOptions)
|
|
13069
|
+
end)
|
|
13070
|
+
end
|
|
12368
13071
|
end
|
|
12369
13072
|
local function resumeAfterShutdownFailure()
|
|
12370
13073
|
if not shutdownSuspended or options == nil then
|
|
@@ -12408,7 +13111,7 @@ return {
|
|
|
12408
13111
|
]]></string>
|
|
12409
13112
|
</Properties>
|
|
12410
13113
|
</Item>
|
|
12411
|
-
<Item class="ModuleScript" referent="
|
|
13114
|
+
<Item class="ModuleScript" referent="39">
|
|
12412
13115
|
<Properties>
|
|
12413
13116
|
<string name="Name">TopologyId</string>
|
|
12414
13117
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -12453,7 +13156,7 @@ return {
|
|
|
12453
13156
|
]]></string>
|
|
12454
13157
|
</Properties>
|
|
12455
13158
|
</Item>
|
|
12456
|
-
<Item class="ModuleScript" referent="
|
|
13159
|
+
<Item class="ModuleScript" referent="40">
|
|
12457
13160
|
<Properties>
|
|
12458
13161
|
<string name="Name">UI</string>
|
|
12459
13162
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -12978,7 +13681,7 @@ return {
|
|
|
12978
13681
|
]]></string>
|
|
12979
13682
|
</Properties>
|
|
12980
13683
|
</Item>
|
|
12981
|
-
<Item class="ModuleScript" referent="
|
|
13684
|
+
<Item class="ModuleScript" referent="41">
|
|
12982
13685
|
<Properties>
|
|
12983
13686
|
<string name="Name">Utils</string>
|
|
12984
13687
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -13821,11 +14524,11 @@ return {
|
|
|
13821
14524
|
</Properties>
|
|
13822
14525
|
</Item>
|
|
13823
14526
|
</Item>
|
|
13824
|
-
<Item class="Folder" referent="
|
|
14527
|
+
<Item class="Folder" referent="46">
|
|
13825
14528
|
<Properties>
|
|
13826
14529
|
<string name="Name">include</string>
|
|
13827
14530
|
</Properties>
|
|
13828
|
-
<Item class="ModuleScript" referent="
|
|
14531
|
+
<Item class="ModuleScript" referent="42">
|
|
13829
14532
|
<Properties>
|
|
13830
14533
|
<string name="Name">LibMP</string>
|
|
13831
14534
|
<string name="Source"><![CDATA[-- =============================================================================
|
|
@@ -170209,7 +170912,7 @@ return LibMP
|
|
|
170209
170912
|
]]></string>
|
|
170210
170913
|
</Properties>
|
|
170211
170914
|
</Item>
|
|
170212
|
-
<Item class="ModuleScript" referent="
|
|
170915
|
+
<Item class="ModuleScript" referent="43">
|
|
170213
170916
|
<Properties>
|
|
170214
170917
|
<string name="Name">Promise</string>
|
|
170215
170918
|
<string name="Source"><![CDATA[--[[
|
|
@@ -172283,7 +172986,7 @@ return Promise
|
|
|
172283
172986
|
]]></string>
|
|
172284
172987
|
</Properties>
|
|
172285
172988
|
</Item>
|
|
172286
|
-
<Item class="ModuleScript" referent="
|
|
172989
|
+
<Item class="ModuleScript" referent="44">
|
|
172287
172990
|
<Properties>
|
|
172288
172991
|
<string name="Name">RuntimeLib</string>
|
|
172289
172992
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -172550,15 +173253,15 @@ return TS
|
|
|
172550
173253
|
</Properties>
|
|
172551
173254
|
</Item>
|
|
172552
173255
|
</Item>
|
|
172553
|
-
<Item class="Folder" referent="
|
|
173256
|
+
<Item class="Folder" referent="47">
|
|
172554
173257
|
<Properties>
|
|
172555
173258
|
<string name="Name">node_modules</string>
|
|
172556
173259
|
</Properties>
|
|
172557
|
-
<Item class="Folder" referent="
|
|
173260
|
+
<Item class="Folder" referent="48">
|
|
172558
173261
|
<Properties>
|
|
172559
173262
|
<string name="Name">@rbxts</string>
|
|
172560
173263
|
</Properties>
|
|
172561
|
-
<Item class="ModuleScript" referent="
|
|
173264
|
+
<Item class="ModuleScript" referent="45">
|
|
172562
173265
|
<Properties>
|
|
172563
173266
|
<string name="Name">services</string>
|
|
172564
173267
|
<string name="Source"><![CDATA[return setmetatable({}, {
|