@chrrxs/robloxstudio-mcp-inspector 3.1.6 → 3.2.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 +1301 -250
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +782 -146
|
@@ -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
|
|
@@ -111,14 +120,22 @@ local function autoActivatePeer()
|
|
|
111
120
|
warn(`[robloxstudio-mcp] Runtime eval bridge install failed: {result.error}`)
|
|
112
121
|
end
|
|
113
122
|
end
|
|
123
|
+
if role == "server" then
|
|
124
|
+
-- Yields until this playtest server knows which edit session (or
|
|
125
|
+
-- multiplayer group) it belongs to, so it registers under that identity.
|
|
126
|
+
PluginSession.resolveRuntimeTopology()
|
|
127
|
+
end
|
|
114
128
|
if role == "edit" or role == "server" then
|
|
115
129
|
local activationOk, activationError = pcall(function()
|
|
116
130
|
local conn = State.getActiveConnection()
|
|
117
131
|
if not conn.isActive then
|
|
118
132
|
if role == "server" then
|
|
119
|
-
local _condition =
|
|
133
|
+
local _condition = PluginSession.getInheritedServerUrl()
|
|
120
134
|
if _condition == nil then
|
|
121
|
-
_condition =
|
|
135
|
+
_condition = ServerUrlSettings.readServerUrl()
|
|
136
|
+
if _condition == nil then
|
|
137
|
+
_condition = ClientBroker.DEFAULT_MCP_URL
|
|
138
|
+
end
|
|
122
139
|
end
|
|
123
140
|
local inheritedServerUrl = _condition
|
|
124
141
|
conn.serverUrl = ServerUrlSettings.normalizeServerUrl(inheritedServerUrl)
|
|
@@ -1942,9 +1959,9 @@ local function computeBridgeStamp()
|
|
|
1942
1959
|
for i = 1, #combined do
|
|
1943
1960
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1944
1961
|
end
|
|
1945
|
-
-- "3.
|
|
1962
|
+
-- "3.2.0" is replaced with the package version at package time
|
|
1946
1963
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1947
|
-
return `{tostring(h)}-3.
|
|
1964
|
+
return `{tostring(h)}-3.2.0`
|
|
1948
1965
|
end
|
|
1949
1966
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1950
1967
|
local function setSource(scriptInst, source)
|
|
@@ -2549,13 +2566,12 @@ return {
|
|
|
2549
2566
|
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
2550
2567
|
local Utils = TS.import(script, script.Parent.Parent, "Utils")
|
|
2551
2568
|
local PeerRole = TS.import(script, script.Parent.Parent, "PeerRole")
|
|
2569
|
+
local PluginSession = TS.import(script, script.Parent.Parent, "PluginSession")
|
|
2552
2570
|
local _binding = Utils
|
|
2553
2571
|
local getInstanceByPath = _binding.getInstanceByPath
|
|
2554
2572
|
local HttpService = game:GetService("HttpService")
|
|
2555
|
-
local ServerStorage = game:GetService("ServerStorage")
|
|
2556
2573
|
local LOG_PREFIX = "Breakpoint"
|
|
2557
2574
|
local REGISTRY_KEY_PREFIX = "MCP_BREAKPOINTS_V1_"
|
|
2558
|
-
local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
2559
2575
|
local pluginRef
|
|
2560
2576
|
local loadedRegistryKey
|
|
2561
2577
|
local loadedRegistryFromSettings = false
|
|
@@ -2566,20 +2582,6 @@ end
|
|
|
2566
2582
|
local function breakpointKey(scriptPath, line)
|
|
2567
2583
|
return `{scriptPath}:{line}`
|
|
2568
2584
|
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
2585
|
local function detectRole()
|
|
2584
2586
|
return PeerRole.detect()
|
|
2585
2587
|
end
|
|
@@ -2592,10 +2594,11 @@ local function requestedRole(requestData)
|
|
|
2592
2594
|
return if _condition then requestData.__mcp_target_role else detectRole()
|
|
2593
2595
|
end
|
|
2594
2596
|
local function registryScope(requestData)
|
|
2595
|
-
local placeKey = computePlaceKey()
|
|
2596
2597
|
local role = requestedRole(requestData)
|
|
2598
|
+
local legacyPlaceKey = PluginSession.getLegacyPlaceKey()
|
|
2597
2599
|
return {
|
|
2598
|
-
key = `{REGISTRY_KEY_PREFIX}{
|
|
2600
|
+
key = `{REGISTRY_KEY_PREFIX}{PluginSession.getPlaceKey()}:{role}`,
|
|
2601
|
+
legacyKey = if legacyPlaceKey ~= nil then `{REGISTRY_KEY_PREFIX}{legacyPlaceKey}:{role}` else nil,
|
|
2599
2602
|
}
|
|
2600
2603
|
end
|
|
2601
2604
|
local function readSetting(key)
|
|
@@ -2654,7 +2657,11 @@ local function loadRegistry(requestData)
|
|
|
2654
2657
|
return scope
|
|
2655
2658
|
end
|
|
2656
2659
|
loadedRegistryFromSettings = true
|
|
2657
|
-
local
|
|
2660
|
+
local _condition = readSetting(scope.key)
|
|
2661
|
+
if _condition == nil then
|
|
2662
|
+
_condition = (if scope.legacyKey ~= nil then readSetting(scope.legacyKey) else nil)
|
|
2663
|
+
end
|
|
2664
|
+
local stored = _condition
|
|
2658
2665
|
if stored == nil then
|
|
2659
2666
|
return scope
|
|
2660
2667
|
end
|
|
@@ -5474,6 +5481,18 @@ local function captureMicroProfiler(requestData)
|
|
|
5474
5481
|
local threadIds = session:FetchThreadIds() or {}
|
|
5475
5482
|
local frameMin = session:GetFrameIdMin()
|
|
5476
5483
|
local frameMax = session:GetFrameIdMax()
|
|
5484
|
+
-- Frame IDs start at 1. Studio cuts a frame only when it renders, so a capture
|
|
5485
|
+
-- without frames means nothing was drawn, not that the workload was idle.
|
|
5486
|
+
if frameMax <= 0 then
|
|
5487
|
+
session:Dispose()
|
|
5488
|
+
return {
|
|
5489
|
+
error = "micro_profiler_no_frames",
|
|
5490
|
+
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.",
|
|
5491
|
+
duration_ms = durationMs,
|
|
5492
|
+
buffer_bytes = buffer.len(snapshot),
|
|
5493
|
+
backend = backend,
|
|
5494
|
+
}
|
|
5495
|
+
end
|
|
5477
5496
|
local startFrame = math.max(frameMin, frameMax - frameWindow + 1)
|
|
5478
5497
|
local framesConsidered = if frameMax >= startFrame then frameMax - startFrame + 1 else 0
|
|
5479
5498
|
local groupCache = {}
|
|
@@ -9033,15 +9052,15 @@ local function startPlaytest(requestData)
|
|
|
9033
9052
|
}
|
|
9034
9053
|
end
|
|
9035
9054
|
testRunning = true
|
|
9036
|
-
local
|
|
9055
|
+
local preparedTest = PluginSession.prepareSoloTest()
|
|
9037
9056
|
task.spawn(function()
|
|
9038
9057
|
local ok, result = pcall(function()
|
|
9039
9058
|
if mode == "play" then
|
|
9040
|
-
return StudioTestService:ExecutePlayModeAsync(
|
|
9059
|
+
return StudioTestService:ExecutePlayModeAsync(preparedTest.testArgs)
|
|
9041
9060
|
end
|
|
9042
|
-
return StudioTestService:ExecuteRunModeAsync(
|
|
9061
|
+
return StudioTestService:ExecuteRunModeAsync(preparedTest.testArgs)
|
|
9043
9062
|
end)
|
|
9044
|
-
PluginSession.
|
|
9063
|
+
PluginSession.finishTest(preparedTest.token)
|
|
9045
9064
|
if not ok then
|
|
9046
9065
|
warn(`[robloxstudio-mcp] Playtest ended with error: {result}`)
|
|
9047
9066
|
end
|
|
@@ -9159,13 +9178,13 @@ local function multiplayerTestStart(requestData)
|
|
|
9159
9178
|
testArgs = testArgs,
|
|
9160
9179
|
startedAt = tick(),
|
|
9161
9180
|
}
|
|
9162
|
-
local
|
|
9181
|
+
local topologyToken = PluginSession.prepareMultiplayerTest(testId)
|
|
9163
9182
|
task.spawn(function()
|
|
9164
9183
|
multiplayerState.phase = "running"
|
|
9165
9184
|
local ok, result = pcall(function()
|
|
9166
9185
|
return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
|
|
9167
9186
|
end)
|
|
9168
|
-
PluginSession.
|
|
9187
|
+
PluginSession.finishTest(topologyToken)
|
|
9169
9188
|
multiplayerState.completedAt = tick()
|
|
9170
9189
|
multiplayerState.ok = ok
|
|
9171
9190
|
if ok then
|
|
@@ -9868,6 +9887,187 @@ return {
|
|
|
9868
9887
|
</Properties>
|
|
9869
9888
|
</Item>
|
|
9870
9889
|
<Item class="ModuleScript" referent="29">
|
|
9890
|
+
<Properties>
|
|
9891
|
+
<string name="Name">PlaytestTickets</string>
|
|
9892
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9893
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
9894
|
+
local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
|
|
9895
|
+
-- Hands topology from an edit DataModel to the runtime DataModels of its
|
|
9896
|
+
-- playtest through plugin settings, so nothing MCP-owned has to be saved into
|
|
9897
|
+
-- the place that Studio copies. Plugin settings are one store shared by every
|
|
9898
|
+
-- Studio window running this plugin, and a window can silently overwrite
|
|
9899
|
+
-- another window's write. Tickets are therefore re-published on a heartbeat and
|
|
9900
|
+
-- ignored once that heartbeat stops.
|
|
9901
|
+
local BOARD_KEY = "MCP_PLAYTEST_TICKETS_V1"
|
|
9902
|
+
local CLAIM_KEY_PREFIX = "MCP_PLAYTEST_CLAIM_V1_"
|
|
9903
|
+
local STALE_AFTER_MS = 5_000
|
|
9904
|
+
local function nowMs()
|
|
9905
|
+
return DateTime.now().UnixTimestampMillis
|
|
9906
|
+
end
|
|
9907
|
+
local function readSetting(store, key)
|
|
9908
|
+
local ok, value = pcall(function()
|
|
9909
|
+
return store:GetSetting(key)
|
|
9910
|
+
end)
|
|
9911
|
+
return if ok then value else nil
|
|
9912
|
+
end
|
|
9913
|
+
local function writeSetting(store, key, value)
|
|
9914
|
+
pcall(function()
|
|
9915
|
+
return store:SetSetting(key, value)
|
|
9916
|
+
end)
|
|
9917
|
+
end
|
|
9918
|
+
local function optionalString(value)
|
|
9919
|
+
local _value = value
|
|
9920
|
+
local _condition = type(_value) == "string"
|
|
9921
|
+
if _condition then
|
|
9922
|
+
_condition = value ~= ""
|
|
9923
|
+
end
|
|
9924
|
+
return if _condition then value else nil
|
|
9925
|
+
end
|
|
9926
|
+
local function decodeTicket(value)
|
|
9927
|
+
local _value = value
|
|
9928
|
+
if not (type(_value) == "table") then
|
|
9929
|
+
return nil
|
|
9930
|
+
end
|
|
9931
|
+
local data = value
|
|
9932
|
+
local id = data.id
|
|
9933
|
+
local placeId = data.placeId
|
|
9934
|
+
local placeKey = data.placeKey
|
|
9935
|
+
local dataModelName = data.dataModelName
|
|
9936
|
+
local postedAt = data.postedAt
|
|
9937
|
+
local heartbeatAt = data.heartbeatAt
|
|
9938
|
+
if not (type(id) == "string") or id == "" then
|
|
9939
|
+
return nil
|
|
9940
|
+
end
|
|
9941
|
+
if not (type(placeId) == "number") or not (type(placeKey) == "string") or not (type(dataModelName) == "string") then
|
|
9942
|
+
return nil
|
|
9943
|
+
end
|
|
9944
|
+
if not (type(postedAt) == "number") or not (type(heartbeatAt) == "number") then
|
|
9945
|
+
return nil
|
|
9946
|
+
end
|
|
9947
|
+
local instanceId = optionalString(data.instanceId)
|
|
9948
|
+
local groupId = optionalString(data.groupId)
|
|
9949
|
+
if instanceId == nil and groupId == nil then
|
|
9950
|
+
return nil
|
|
9951
|
+
end
|
|
9952
|
+
return {
|
|
9953
|
+
id = id,
|
|
9954
|
+
instanceId = instanceId,
|
|
9955
|
+
groupId = groupId,
|
|
9956
|
+
placeId = placeId,
|
|
9957
|
+
placeKey = placeKey,
|
|
9958
|
+
dataModelName = dataModelName,
|
|
9959
|
+
serverUrl = optionalString(data.serverUrl),
|
|
9960
|
+
postedAt = postedAt,
|
|
9961
|
+
heartbeatAt = heartbeatAt,
|
|
9962
|
+
}
|
|
9963
|
+
end
|
|
9964
|
+
local function isFresh(ticket, now)
|
|
9965
|
+
return math.abs(now - ticket.heartbeatAt) <= STALE_AFTER_MS
|
|
9966
|
+
end
|
|
9967
|
+
local function readBoard(store)
|
|
9968
|
+
local raw = readSetting(store, BOARD_KEY)
|
|
9969
|
+
if not (type(raw) == "string") then
|
|
9970
|
+
return {}
|
|
9971
|
+
end
|
|
9972
|
+
local ok, tickets = pcall(function()
|
|
9973
|
+
local decoded = HttpService:JSONDecode(raw)
|
|
9974
|
+
local valid = {}
|
|
9975
|
+
if not (type(decoded) == "table") then
|
|
9976
|
+
return valid
|
|
9977
|
+
end
|
|
9978
|
+
for _, entry in decoded do
|
|
9979
|
+
local ticket = decodeTicket(entry)
|
|
9980
|
+
if ticket ~= nil then
|
|
9981
|
+
table.insert(valid, ticket)
|
|
9982
|
+
end
|
|
9983
|
+
end
|
|
9984
|
+
return valid
|
|
9985
|
+
end)
|
|
9986
|
+
return if ok then tickets else {}
|
|
9987
|
+
end
|
|
9988
|
+
local function writeBoard(store, tickets)
|
|
9989
|
+
if tickets[1] == nil then
|
|
9990
|
+
writeSetting(store, BOARD_KEY, nil)
|
|
9991
|
+
return nil
|
|
9992
|
+
end
|
|
9993
|
+
local ok, encoded = pcall(function()
|
|
9994
|
+
return HttpService:JSONEncode(tickets)
|
|
9995
|
+
end)
|
|
9996
|
+
if ok and type(encoded) == "string" then
|
|
9997
|
+
writeSetting(store, BOARD_KEY, encoded)
|
|
9998
|
+
end
|
|
9999
|
+
end
|
|
10000
|
+
--* Publishes or refreshes a ticket and prunes tickets whose owners stopped refreshing them.
|
|
10001
|
+
local function publish(store, ticket)
|
|
10002
|
+
local now = nowMs()
|
|
10003
|
+
local board = {}
|
|
10004
|
+
for _, existing in readBoard(store) do
|
|
10005
|
+
if existing.id ~= ticket.id and isFresh(existing, now) then
|
|
10006
|
+
table.insert(board, existing)
|
|
10007
|
+
end
|
|
10008
|
+
end
|
|
10009
|
+
local _object = table.clone(ticket)
|
|
10010
|
+
setmetatable(_object, nil)
|
|
10011
|
+
_object.heartbeatAt = now
|
|
10012
|
+
table.insert(board, _object)
|
|
10013
|
+
writeBoard(store, board)
|
|
10014
|
+
end
|
|
10015
|
+
local function withdraw(store, ticketId)
|
|
10016
|
+
local now = nowMs()
|
|
10017
|
+
local board = {}
|
|
10018
|
+
local changed = false
|
|
10019
|
+
for _, existing in readBoard(store) do
|
|
10020
|
+
if existing.id == ticketId or not isFresh(existing, now) then
|
|
10021
|
+
changed = true
|
|
10022
|
+
else
|
|
10023
|
+
table.insert(board, existing)
|
|
10024
|
+
end
|
|
10025
|
+
end
|
|
10026
|
+
if changed then
|
|
10027
|
+
writeBoard(store, board)
|
|
10028
|
+
end
|
|
10029
|
+
end
|
|
10030
|
+
local function liveTickets(store)
|
|
10031
|
+
local now = nowMs()
|
|
10032
|
+
local _exp = readBoard(store)
|
|
10033
|
+
-- ▼ ReadonlyArray.filter ▼
|
|
10034
|
+
local _newValue = {}
|
|
10035
|
+
local _callback = function(ticket)
|
|
10036
|
+
return isFresh(ticket, now)
|
|
10037
|
+
end
|
|
10038
|
+
local _length = 0
|
|
10039
|
+
for _k, _v in _exp do
|
|
10040
|
+
if _callback(_v, _k - 1, _exp) == true then
|
|
10041
|
+
_length += 1
|
|
10042
|
+
_newValue[_length] = _v
|
|
10043
|
+
end
|
|
10044
|
+
end
|
|
10045
|
+
-- ▲ ReadonlyArray.filter ▲
|
|
10046
|
+
return _newValue
|
|
10047
|
+
end
|
|
10048
|
+
local function readClaim(store, ticketId)
|
|
10049
|
+
return optionalString(readSetting(store, CLAIM_KEY_PREFIX .. ticketId))
|
|
10050
|
+
end
|
|
10051
|
+
local function writeClaim(store, ticketId, claimant)
|
|
10052
|
+
writeSetting(store, CLAIM_KEY_PREFIX .. ticketId, claimant)
|
|
10053
|
+
end
|
|
10054
|
+
local function clearClaim(store, ticketId)
|
|
10055
|
+
writeSetting(store, CLAIM_KEY_PREFIX .. ticketId, nil)
|
|
10056
|
+
end
|
|
10057
|
+
return {
|
|
10058
|
+
nowMs = nowMs,
|
|
10059
|
+
decodeTicket = decodeTicket,
|
|
10060
|
+
publish = publish,
|
|
10061
|
+
withdraw = withdraw,
|
|
10062
|
+
liveTickets = liveTickets,
|
|
10063
|
+
readClaim = readClaim,
|
|
10064
|
+
writeClaim = writeClaim,
|
|
10065
|
+
clearClaim = clearClaim,
|
|
10066
|
+
}
|
|
10067
|
+
]]></string>
|
|
10068
|
+
</Properties>
|
|
10069
|
+
</Item>
|
|
10070
|
+
<Item class="ModuleScript" referent="30">
|
|
9871
10071
|
<Properties>
|
|
9872
10072
|
<string name="Name">PluginSession</string>
|
|
9873
10073
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9880,101 +10080,525 @@ local ServerStorage = _services.ServerStorage
|
|
|
9880
10080
|
local State = TS.import(script, script.Parent, "State")
|
|
9881
10081
|
local PeerRole = TS.import(script, script.Parent, "PeerRole")
|
|
9882
10082
|
local TopologyId = TS.import(script, script.Parent, "TopologyId")
|
|
10083
|
+
local PlaytestTickets = TS.import(script, script.Parent, "PlaytestTickets")
|
|
9883
10084
|
local CoreGui = game:GetService("CoreGui")
|
|
9884
|
-
local
|
|
10085
|
+
local StudioTestService = game:GetService("StudioTestService")
|
|
10086
|
+
local LEGACY_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
9885
10087
|
local TOPOLOGY_MODE_ATTRIBUTE = "__MCPTopologyMode"
|
|
9886
10088
|
local TOPOLOGY_INSTANCE_ID_ATTRIBUTE = "__MCPTopologyInstanceId"
|
|
9887
10089
|
local TOPOLOGY_GROUP_ID_ATTRIBUTE = "__MCPTopologyGroupId"
|
|
9888
10090
|
local TOPOLOGY_TOKEN_ATTRIBUTE = "__MCPTopologyToken"
|
|
10091
|
+
-- Older plugin versions saved these into places. Nothing MCP-owned belongs in a
|
|
10092
|
+
-- saved or published DataModel, so edit sessions remove them on load.
|
|
10093
|
+
local LEGACY_TOPOLOGY_ATTRIBUTES = { TOPOLOGY_MODE_ATTRIBUTE, TOPOLOGY_INSTANCE_ID_ATTRIBUTE, TOPOLOGY_GROUP_ID_ATTRIBUTE, TOPOLOGY_TOKEN_ATTRIBUTE }
|
|
9889
10094
|
local SESSION_IDENTITY_NAME = "__MCPSessionIdentity"
|
|
10095
|
+
-- Attributes on the CoreGui identity value: CoreGui survives plugin reloads but
|
|
10096
|
+
-- is never saved with the place or copied into playtest DataModels.
|
|
10097
|
+
local PLAY_RECORD_ATTRIBUTE = "PlayRecord"
|
|
10098
|
+
local RUNTIME_TOPOLOGY_ATTRIBUTE = "RuntimeTopology"
|
|
10099
|
+
-- MCP-started solo and Run playtests carry their topology in StudioTestService
|
|
10100
|
+
-- test args, which never touch the DataModel.
|
|
10101
|
+
local TEST_ARGS_TOPOLOGY_KEY = "__mcpTopology"
|
|
10102
|
+
local TICKET_HEARTBEAT_SEC = 1
|
|
10103
|
+
local UNCLAIMED_TICKET_LIFETIME_MS = 120_000
|
|
10104
|
+
local RESOLVE_POLL_SEC = 0.25
|
|
10105
|
+
-- A runtime claims a ticket only after it has been the single match for this
|
|
10106
|
+
-- long, so a second window's Play starting at the same moment is noticed.
|
|
10107
|
+
local TICKET_SETTLE_SEC = 1
|
|
10108
|
+
local NO_TICKET_TIMEOUT_SEC = 5
|
|
10109
|
+
local AMBIGUOUS_TICKET_TIMEOUT_SEC = 15
|
|
10110
|
+
-- A prepared MCP playtest that never started (its handler died) must not stop
|
|
10111
|
+
-- later Play-button runs from posting tickets.
|
|
10112
|
+
local ARMED_PLAY_TTL_SEC = 30
|
|
10113
|
+
--* The edit's current playtest, kept in CoreGui so a plugin reload can finish it.
|
|
9890
10114
|
local peerId = TopologyId.createPeerId()
|
|
9891
10115
|
local isEditSession = PeerRole.detect() == "edit"
|
|
9892
10116
|
local existingSessionIdentity = CoreGui:FindFirstChild(SESSION_IDENTITY_NAME)
|
|
9893
|
-
local
|
|
9894
|
-
local createdSessionIdentity = false
|
|
10117
|
+
local sessionIdentity
|
|
9895
10118
|
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
|
-
|
|
10119
|
+
sessionIdentity = existingSessionIdentity
|
|
9897
10120
|
else
|
|
9898
10121
|
local _result = existingSessionIdentity
|
|
9899
10122
|
if _result ~= nil then
|
|
9900
10123
|
_result:Destroy()
|
|
9901
10124
|
end
|
|
9902
|
-
|
|
9903
|
-
local sessionIdentity = Instance.new("StringValue")
|
|
10125
|
+
sessionIdentity = Instance.new("StringValue")
|
|
9904
10126
|
sessionIdentity.Name = SESSION_IDENTITY_NAME
|
|
9905
|
-
sessionIdentity.Value =
|
|
10127
|
+
sessionIdentity.Value = TopologyId.createInstanceId()
|
|
9906
10128
|
sessionIdentity.Archivable = false
|
|
9907
10129
|
sessionIdentity.Parent = CoreGui
|
|
9908
|
-
createdSessionIdentity = true
|
|
9909
10130
|
end
|
|
9910
|
-
local
|
|
10131
|
+
local sessionInstanceId = sessionIdentity.Value
|
|
10132
|
+
local settingsStore
|
|
10133
|
+
local serverUrlProvider
|
|
10134
|
+
local legacyPlaceKey
|
|
10135
|
+
local runtimeTopology
|
|
10136
|
+
local runtimeResolutionStarted = false
|
|
10137
|
+
local heartbeatTicketId
|
|
9911
10138
|
local cachedPlaceName
|
|
9912
10139
|
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
10140
|
local function getInstanceId()
|
|
9918
|
-
|
|
9919
|
-
|
|
10141
|
+
local _result = runtimeTopology
|
|
10142
|
+
if _result ~= nil then
|
|
10143
|
+
_result = _result.instanceId
|
|
9920
10144
|
end
|
|
9921
|
-
|
|
9922
|
-
|
|
9923
|
-
|
|
9924
|
-
inheritedInstanceId = sharedInstanceId
|
|
9925
|
-
return sharedInstanceId
|
|
9926
|
-
end
|
|
10145
|
+
local _condition = _result
|
|
10146
|
+
if _condition == nil then
|
|
10147
|
+
_condition = sessionInstanceId
|
|
9927
10148
|
end
|
|
9928
|
-
return
|
|
10149
|
+
return _condition
|
|
9929
10150
|
end
|
|
10151
|
+
local readPlayRecord
|
|
9930
10152
|
local function getMultiplayerGroupId()
|
|
9931
|
-
if
|
|
9932
|
-
|
|
10153
|
+
if not isEditSession then
|
|
10154
|
+
local _result = runtimeTopology
|
|
10155
|
+
if _result ~= nil then
|
|
10156
|
+
_result = _result.groupId
|
|
10157
|
+
end
|
|
10158
|
+
return _result
|
|
10159
|
+
end
|
|
10160
|
+
local _result = readPlayRecord()
|
|
10161
|
+
if _result ~= nil then
|
|
10162
|
+
_result = _result.ticket
|
|
10163
|
+
if _result ~= nil then
|
|
10164
|
+
_result = _result.groupId
|
|
10165
|
+
end
|
|
9933
10166
|
end
|
|
9934
|
-
|
|
9935
|
-
return if type(groupId) == "string" and groupId ~= "" then groupId else nil
|
|
10167
|
+
return _result
|
|
9936
10168
|
end
|
|
9937
10169
|
local function getPlaceKey()
|
|
10170
|
+
local _inherited = runtimeTopology
|
|
10171
|
+
if _inherited ~= nil then
|
|
10172
|
+
_inherited = _inherited.placeKey
|
|
10173
|
+
end
|
|
10174
|
+
local inherited = _inherited
|
|
10175
|
+
if inherited ~= nil then
|
|
10176
|
+
return inherited
|
|
10177
|
+
end
|
|
9938
10178
|
if game.PlaceId ~= 0 then
|
|
9939
10179
|
return `place:{tostring(game.PlaceId)}`
|
|
9940
10180
|
end
|
|
9941
|
-
|
|
9942
|
-
|
|
9943
|
-
|
|
10181
|
+
return `name:{game.Name}`
|
|
10182
|
+
end
|
|
10183
|
+
local function getLegacyPlaceKey()
|
|
10184
|
+
return legacyPlaceKey
|
|
10185
|
+
end
|
|
10186
|
+
local function getInheritedServerUrl()
|
|
10187
|
+
local _result = runtimeTopology
|
|
10188
|
+
if _result ~= nil then
|
|
10189
|
+
_result = _result.serverUrl
|
|
10190
|
+
end
|
|
10191
|
+
return _result
|
|
10192
|
+
end
|
|
10193
|
+
local function removeAttribute(target, name)
|
|
10194
|
+
if target:GetAttribute(name) == nil then
|
|
10195
|
+
return nil
|
|
9944
10196
|
end
|
|
9945
|
-
local fresh = HttpService:GenerateGUID(false)
|
|
9946
10197
|
pcall(function()
|
|
9947
|
-
return
|
|
10198
|
+
return target:SetAttribute(name, nil)
|
|
10199
|
+
end)
|
|
10200
|
+
end
|
|
10201
|
+
local function removeSavedSessionState()
|
|
10202
|
+
local savedPlaceId = ServerStorage:GetAttribute(LEGACY_PLACE_ID_ATTRIBUTE)
|
|
10203
|
+
if type(savedPlaceId) == "string" and savedPlaceId ~= "" then
|
|
10204
|
+
legacyPlaceKey = `anon:{savedPlaceId}`
|
|
10205
|
+
end
|
|
10206
|
+
removeAttribute(ServerStorage, LEGACY_PLACE_ID_ATTRIBUTE)
|
|
10207
|
+
for _, name in LEGACY_TOPOLOGY_ATTRIBUTES do
|
|
10208
|
+
removeAttribute(ReplicatedStorage, name)
|
|
10209
|
+
end
|
|
10210
|
+
end
|
|
10211
|
+
local function optionalString(value)
|
|
10212
|
+
local _value = value
|
|
10213
|
+
local _condition = type(_value) == "string"
|
|
10214
|
+
if _condition then
|
|
10215
|
+
_condition = value ~= ""
|
|
10216
|
+
end
|
|
10217
|
+
return if _condition then value else nil
|
|
10218
|
+
end
|
|
10219
|
+
function readPlayRecord()
|
|
10220
|
+
local raw = sessionIdentity:GetAttribute(PLAY_RECORD_ATTRIBUTE)
|
|
10221
|
+
if not (type(raw) == "string") then
|
|
10222
|
+
return nil
|
|
10223
|
+
end
|
|
10224
|
+
local ok, decoded = pcall(function()
|
|
10225
|
+
return HttpService:JSONDecode(raw)
|
|
10226
|
+
end)
|
|
10227
|
+
if not ok or not (type(decoded) == "table") then
|
|
10228
|
+
return nil
|
|
10229
|
+
end
|
|
10230
|
+
local data = decoded
|
|
10231
|
+
local phase = if data.phase == "armed" or data.phase == "active" then data.phase else nil
|
|
10232
|
+
if phase == nil then
|
|
10233
|
+
return nil
|
|
10234
|
+
end
|
|
10235
|
+
local armedAt = data.armedAt
|
|
10236
|
+
local ticketState = data.ticketState
|
|
10237
|
+
return {
|
|
10238
|
+
phase = phase,
|
|
10239
|
+
token = optionalString(data.token),
|
|
10240
|
+
armedAt = if type(armedAt) == "number" then armedAt else nil,
|
|
10241
|
+
ticket = PlaytestTickets.decodeTicket(data.ticket),
|
|
10242
|
+
ticketState = if ticketState == "posted" or ticketState == "claimed" or ticketState == "expired" then ticketState else nil,
|
|
10243
|
+
}
|
|
10244
|
+
end
|
|
10245
|
+
local function writePlayRecord(record)
|
|
10246
|
+
if record == nil then
|
|
10247
|
+
sessionIdentity:SetAttribute(PLAY_RECORD_ATTRIBUTE, nil)
|
|
10248
|
+
return nil
|
|
10249
|
+
end
|
|
10250
|
+
local ok, encoded = pcall(function()
|
|
10251
|
+
return HttpService:JSONEncode(record)
|
|
10252
|
+
end)
|
|
10253
|
+
if ok and type(encoded) == "string" then
|
|
10254
|
+
sessionIdentity:SetAttribute(PLAY_RECORD_ATTRIBUTE, encoded)
|
|
10255
|
+
end
|
|
10256
|
+
end
|
|
10257
|
+
local function createTicket(instanceId, groupId)
|
|
10258
|
+
local now = PlaytestTickets.nowMs()
|
|
10259
|
+
local _object = {
|
|
10260
|
+
id = HttpService:GenerateGUID(false),
|
|
10261
|
+
instanceId = instanceId,
|
|
10262
|
+
groupId = groupId,
|
|
10263
|
+
placeId = game.PlaceId,
|
|
10264
|
+
placeKey = getPlaceKey(),
|
|
10265
|
+
dataModelName = game.Name,
|
|
10266
|
+
}
|
|
10267
|
+
local _left = "serverUrl"
|
|
10268
|
+
local _result = serverUrlProvider
|
|
10269
|
+
if _result ~= nil then
|
|
10270
|
+
_result = _result()
|
|
10271
|
+
end
|
|
10272
|
+
_object[_left] = _result
|
|
10273
|
+
_object.postedAt = now
|
|
10274
|
+
_object.heartbeatAt = now
|
|
10275
|
+
return _object
|
|
10276
|
+
end
|
|
10277
|
+
local refreshTicket
|
|
10278
|
+
local function startTicketHeartbeat(ticketId)
|
|
10279
|
+
if heartbeatTicketId == ticketId then
|
|
10280
|
+
return nil
|
|
10281
|
+
end
|
|
10282
|
+
heartbeatTicketId = ticketId
|
|
10283
|
+
task.delay(TICKET_HEARTBEAT_SEC, function()
|
|
10284
|
+
return refreshTicket(ticketId)
|
|
9948
10285
|
end)
|
|
9949
|
-
return `anon:{fresh}`
|
|
9950
10286
|
end
|
|
9951
|
-
|
|
10287
|
+
function refreshTicket(ticketId)
|
|
10288
|
+
if heartbeatTicketId ~= ticketId then
|
|
10289
|
+
return nil
|
|
10290
|
+
end
|
|
10291
|
+
local store = settingsStore
|
|
10292
|
+
local record = readPlayRecord()
|
|
10293
|
+
local _ticket = record
|
|
10294
|
+
if _ticket ~= nil then
|
|
10295
|
+
_ticket = _ticket.ticket
|
|
10296
|
+
end
|
|
10297
|
+
local ticket = _ticket
|
|
10298
|
+
if store == nil or record == nil or ticket == nil or ticket.id ~= ticketId or record.ticketState ~= "posted" then
|
|
10299
|
+
heartbeatTicketId = nil
|
|
10300
|
+
return nil
|
|
10301
|
+
end
|
|
10302
|
+
if PlaytestTickets.readClaim(store, ticketId) ~= nil then
|
|
10303
|
+
-- Keep the claim itself until Play ends: the runtime verifies it.
|
|
10304
|
+
PlaytestTickets.withdraw(store, ticketId)
|
|
10305
|
+
record.ticketState = "claimed"
|
|
10306
|
+
writePlayRecord(record)
|
|
10307
|
+
heartbeatTicketId = nil
|
|
10308
|
+
return nil
|
|
10309
|
+
end
|
|
10310
|
+
if PlaytestTickets.nowMs() - ticket.postedAt > UNCLAIMED_TICKET_LIFETIME_MS then
|
|
10311
|
+
PlaytestTickets.withdraw(store, ticketId)
|
|
10312
|
+
record.ticketState = "expired"
|
|
10313
|
+
writePlayRecord(record)
|
|
10314
|
+
heartbeatTicketId = nil
|
|
10315
|
+
return nil
|
|
10316
|
+
end
|
|
10317
|
+
PlaytestTickets.publish(store, ticket)
|
|
10318
|
+
task.delay(TICKET_HEARTBEAT_SEC, function()
|
|
10319
|
+
return refreshTicket(ticketId)
|
|
10320
|
+
end)
|
|
10321
|
+
end
|
|
10322
|
+
local function retirePlayRecord(record)
|
|
10323
|
+
local store = settingsStore
|
|
10324
|
+
local ticket = record.ticket
|
|
10325
|
+
if store == nil or ticket == nil then
|
|
10326
|
+
return nil
|
|
10327
|
+
end
|
|
10328
|
+
PlaytestTickets.withdraw(store, ticket.id)
|
|
10329
|
+
PlaytestTickets.clearClaim(store, ticket.id)
|
|
10330
|
+
end
|
|
10331
|
+
local function beginPlay()
|
|
10332
|
+
local store = settingsStore
|
|
10333
|
+
if store == nil then
|
|
10334
|
+
return nil
|
|
10335
|
+
end
|
|
10336
|
+
local record = readPlayRecord()
|
|
10337
|
+
if record ~= nil then
|
|
10338
|
+
if record.phase == "active" then
|
|
10339
|
+
return nil
|
|
10340
|
+
end
|
|
10341
|
+
local _exp = os.clock()
|
|
10342
|
+
local _condition = record.armedAt
|
|
10343
|
+
if _condition == nil then
|
|
10344
|
+
_condition = 0
|
|
10345
|
+
end
|
|
10346
|
+
if _exp - _condition <= ARMED_PLAY_TTL_SEC then
|
|
10347
|
+
record.phase = "active"
|
|
10348
|
+
writePlayRecord(record)
|
|
10349
|
+
return nil
|
|
10350
|
+
end
|
|
10351
|
+
retirePlayRecord(record)
|
|
10352
|
+
end
|
|
10353
|
+
local ticket = createTicket(sessionInstanceId, nil)
|
|
10354
|
+
writePlayRecord({
|
|
10355
|
+
phase = "active",
|
|
10356
|
+
ticket = ticket,
|
|
10357
|
+
ticketState = "posted",
|
|
10358
|
+
})
|
|
10359
|
+
PlaytestTickets.publish(store, ticket)
|
|
10360
|
+
startTicketHeartbeat(ticket.id)
|
|
10361
|
+
end
|
|
10362
|
+
local function finishPlay()
|
|
10363
|
+
local record = readPlayRecord()
|
|
10364
|
+
-- An armed record belongs to an MCP playtest whose Play has not begun yet.
|
|
10365
|
+
if record == nil or record.phase ~= "active" then
|
|
10366
|
+
return nil
|
|
10367
|
+
end
|
|
10368
|
+
retirePlayRecord(record)
|
|
10369
|
+
writePlayRecord(nil)
|
|
10370
|
+
end
|
|
10371
|
+
local function armPlay(record)
|
|
10372
|
+
local existing = readPlayRecord()
|
|
10373
|
+
if existing ~= nil then
|
|
10374
|
+
retirePlayRecord(existing)
|
|
10375
|
+
end
|
|
10376
|
+
writePlayRecord(record)
|
|
10377
|
+
end
|
|
10378
|
+
local function prepareSoloTest()
|
|
10379
|
+
local token = HttpService:GenerateGUID(false)
|
|
10380
|
+
armPlay({
|
|
10381
|
+
phase = "armed",
|
|
10382
|
+
token = token,
|
|
10383
|
+
armedAt = os.clock(),
|
|
10384
|
+
})
|
|
10385
|
+
local _object = {
|
|
10386
|
+
token = token,
|
|
10387
|
+
}
|
|
10388
|
+
local _left = "testArgs"
|
|
10389
|
+
local _object_1 = {}
|
|
10390
|
+
local _left_1 = TEST_ARGS_TOPOLOGY_KEY
|
|
10391
|
+
local _object_2 = {
|
|
10392
|
+
instanceId = sessionInstanceId,
|
|
10393
|
+
placeKey = getPlaceKey(),
|
|
10394
|
+
}
|
|
10395
|
+
local _left_2 = "serverUrl"
|
|
10396
|
+
local _result = serverUrlProvider
|
|
10397
|
+
if _result ~= nil then
|
|
10398
|
+
_result = _result()
|
|
10399
|
+
end
|
|
10400
|
+
_object_2[_left_2] = _result
|
|
10401
|
+
_object_1[_left_1] = _object_2
|
|
10402
|
+
_object[_left] = _object_1
|
|
10403
|
+
return _object
|
|
10404
|
+
end
|
|
10405
|
+
--[[
|
|
10406
|
+
*
|
|
10407
|
+
* Multiplayer test args belong to the user's game code, so the group reaches
|
|
10408
|
+
* the test server through a ticket instead.
|
|
10409
|
+
|
|
10410
|
+
]]
|
|
10411
|
+
local function prepareMultiplayerTest(groupId)
|
|
9952
10412
|
local token = HttpService:GenerateGUID(false)
|
|
9953
|
-
|
|
9954
|
-
|
|
9955
|
-
|
|
9956
|
-
|
|
9957
|
-
|
|
10413
|
+
local ticket = createTicket(nil, groupId)
|
|
10414
|
+
armPlay({
|
|
10415
|
+
phase = "armed",
|
|
10416
|
+
token = token,
|
|
10417
|
+
armedAt = os.clock(),
|
|
10418
|
+
ticket = ticket,
|
|
10419
|
+
ticketState = "posted",
|
|
10420
|
+
})
|
|
10421
|
+
local store = settingsStore
|
|
10422
|
+
if store ~= nil then
|
|
10423
|
+
PlaytestTickets.publish(store, ticket)
|
|
10424
|
+
startTicketHeartbeat(ticket.id)
|
|
10425
|
+
end
|
|
9958
10426
|
return token
|
|
9959
10427
|
end
|
|
9960
|
-
local function
|
|
9961
|
-
|
|
10428
|
+
local function finishTest(token)
|
|
10429
|
+
local record = readPlayRecord()
|
|
10430
|
+
if record == nil or record.token ~= token then
|
|
10431
|
+
return nil
|
|
10432
|
+
end
|
|
10433
|
+
retirePlayRecord(record)
|
|
10434
|
+
writePlayRecord(nil)
|
|
10435
|
+
end
|
|
10436
|
+
local function onEditModeChanged()
|
|
10437
|
+
if StudioTestService.EditModeActive then
|
|
10438
|
+
finishPlay()
|
|
10439
|
+
else
|
|
10440
|
+
beginPlay()
|
|
10441
|
+
end
|
|
10442
|
+
end
|
|
10443
|
+
local function matchingTickets(store)
|
|
10444
|
+
local samePlace = {}
|
|
10445
|
+
local nameMatched = false
|
|
10446
|
+
for _, ticket in PlaytestTickets.liveTickets(store) do
|
|
10447
|
+
if ticket.placeId ~= game.PlaceId then
|
|
10448
|
+
continue
|
|
10449
|
+
end
|
|
10450
|
+
local claimant = PlaytestTickets.readClaim(store, ticket.id)
|
|
10451
|
+
if claimant ~= nil and claimant ~= peerId then
|
|
10452
|
+
continue
|
|
10453
|
+
end
|
|
10454
|
+
table.insert(samePlace, ticket)
|
|
10455
|
+
if ticket.dataModelName == game.Name then
|
|
10456
|
+
nameMatched = true
|
|
10457
|
+
end
|
|
10458
|
+
end
|
|
10459
|
+
-- Runtime DataModel names are expected to match their edit, but only narrow
|
|
10460
|
+
-- on the name when it actually distinguishes a candidate.
|
|
10461
|
+
local _result
|
|
10462
|
+
if nameMatched then
|
|
10463
|
+
-- ▼ ReadonlyArray.filter ▼
|
|
10464
|
+
local _newValue = {}
|
|
10465
|
+
local _callback = function(ticket)
|
|
10466
|
+
return ticket.dataModelName == game.Name
|
|
10467
|
+
end
|
|
10468
|
+
local _length = 0
|
|
10469
|
+
for _k, _v in samePlace do
|
|
10470
|
+
if _callback(_v, _k - 1, samePlace) == true then
|
|
10471
|
+
_length += 1
|
|
10472
|
+
_newValue[_length] = _v
|
|
10473
|
+
end
|
|
10474
|
+
end
|
|
10475
|
+
-- ▲ ReadonlyArray.filter ▲
|
|
10476
|
+
_result = _newValue
|
|
10477
|
+
else
|
|
10478
|
+
_result = samePlace
|
|
10479
|
+
end
|
|
10480
|
+
return _result
|
|
10481
|
+
end
|
|
10482
|
+
--[[
|
|
10483
|
+
*
|
|
10484
|
+
* Waits for exactly one matching ticket and claims it. Returns nothing when no
|
|
10485
|
+
* edit posts a ticket, or when several windows of the same place started Play
|
|
10486
|
+
* together: the runtime then keeps its own identity rather than guessing.
|
|
10487
|
+
|
|
10488
|
+
]]
|
|
10489
|
+
local function claimTicket(store)
|
|
10490
|
+
local startedAt = os.clock()
|
|
10491
|
+
local candidateSeen = false
|
|
10492
|
+
local settlingId
|
|
10493
|
+
local settlingSince = 0
|
|
10494
|
+
while true do
|
|
10495
|
+
local now = os.clock()
|
|
10496
|
+
local candidates = matchingTickets(store)
|
|
10497
|
+
local first = candidates[1]
|
|
10498
|
+
local only = if first ~= nil and candidates[2] == nil then first else nil
|
|
10499
|
+
if first ~= nil then
|
|
10500
|
+
candidateSeen = true
|
|
10501
|
+
end
|
|
10502
|
+
if only == nil then
|
|
10503
|
+
settlingId = nil
|
|
10504
|
+
elseif settlingId ~= only.id then
|
|
10505
|
+
settlingId = only.id
|
|
10506
|
+
settlingSince = now
|
|
10507
|
+
elseif now - settlingSince >= TICKET_SETTLE_SEC then
|
|
10508
|
+
PlaytestTickets.writeClaim(store, only.id, peerId)
|
|
10509
|
+
task.wait(RESOLVE_POLL_SEC)
|
|
10510
|
+
if PlaytestTickets.readClaim(store, only.id) == peerId then
|
|
10511
|
+
return only
|
|
10512
|
+
end
|
|
10513
|
+
settlingId = nil
|
|
10514
|
+
continue
|
|
10515
|
+
end
|
|
10516
|
+
local timeout = if candidateSeen then AMBIGUOUS_TICKET_TIMEOUT_SEC else NO_TICKET_TIMEOUT_SEC
|
|
10517
|
+
if now - startedAt >= timeout then
|
|
10518
|
+
return nil
|
|
10519
|
+
end
|
|
10520
|
+
task.wait(RESOLVE_POLL_SEC)
|
|
10521
|
+
end
|
|
10522
|
+
end
|
|
10523
|
+
local function topologyFromTestArgs()
|
|
10524
|
+
local ok, args = pcall(function()
|
|
10525
|
+
return StudioTestService:GetTestArgs()
|
|
10526
|
+
end)
|
|
10527
|
+
if not ok or not (type(args) == "table") then
|
|
10528
|
+
return nil
|
|
10529
|
+
end
|
|
10530
|
+
local testArgs = args
|
|
10531
|
+
local envelope = testArgs[TEST_ARGS_TOPOLOGY_KEY]
|
|
10532
|
+
if not (type(envelope) == "table") then
|
|
10533
|
+
return nil
|
|
10534
|
+
end
|
|
10535
|
+
local topology = envelope
|
|
10536
|
+
local instanceId = optionalString(topology.instanceId)
|
|
10537
|
+
if instanceId == nil then
|
|
10538
|
+
return nil
|
|
10539
|
+
end
|
|
10540
|
+
return {
|
|
10541
|
+
instanceId = instanceId,
|
|
10542
|
+
placeKey = optionalString(topology.placeKey),
|
|
10543
|
+
serverUrl = optionalString(topology.serverUrl),
|
|
10544
|
+
}
|
|
10545
|
+
end
|
|
10546
|
+
local function readRememberedRuntimeTopology()
|
|
10547
|
+
local raw = sessionIdentity:GetAttribute(RUNTIME_TOPOLOGY_ATTRIBUTE)
|
|
10548
|
+
if not (type(raw) == "string") then
|
|
10549
|
+
return nil
|
|
10550
|
+
end
|
|
10551
|
+
local ok, decoded = pcall(function()
|
|
10552
|
+
return HttpService:JSONDecode(raw)
|
|
10553
|
+
end)
|
|
10554
|
+
if not ok or not (type(decoded) == "table") then
|
|
10555
|
+
return nil
|
|
10556
|
+
end
|
|
10557
|
+
local data = decoded
|
|
10558
|
+
return {
|
|
10559
|
+
instanceId = optionalString(data.instanceId),
|
|
10560
|
+
groupId = optionalString(data.groupId),
|
|
10561
|
+
placeKey = optionalString(data.placeKey),
|
|
10562
|
+
serverUrl = optionalString(data.serverUrl),
|
|
10563
|
+
}
|
|
9962
10564
|
end
|
|
9963
|
-
local function
|
|
9964
|
-
|
|
10565
|
+
local function adoptRuntimeTopology(topology)
|
|
10566
|
+
runtimeTopology = topology
|
|
10567
|
+
local ok, encoded = pcall(function()
|
|
10568
|
+
return HttpService:JSONEncode(topology)
|
|
10569
|
+
end)
|
|
10570
|
+
if ok and type(encoded) == "string" then
|
|
10571
|
+
sessionIdentity:SetAttribute(RUNTIME_TOPOLOGY_ATTRIBUTE, encoded)
|
|
10572
|
+
end
|
|
9965
10573
|
end
|
|
9966
|
-
|
|
9967
|
-
|
|
10574
|
+
--[[
|
|
10575
|
+
*
|
|
10576
|
+
* Resolves which edit session this playtest server belongs to. Yields; call it
|
|
10577
|
+
* before the server peer registers with MCP.
|
|
10578
|
+
|
|
10579
|
+
]]
|
|
10580
|
+
local function resolveRuntimeTopology()
|
|
10581
|
+
if isEditSession or runtimeTopology ~= nil or runtimeResolutionStarted then
|
|
10582
|
+
return nil
|
|
10583
|
+
end
|
|
10584
|
+
if PeerRole.detect() ~= "server" then
|
|
9968
10585
|
return nil
|
|
9969
10586
|
end
|
|
9970
|
-
|
|
9971
|
-
|
|
10587
|
+
runtimeResolutionStarted = true
|
|
10588
|
+
local fromArgs = topologyFromTestArgs()
|
|
10589
|
+
if fromArgs ~= nil then
|
|
10590
|
+
adoptRuntimeTopology(fromArgs)
|
|
9972
10591
|
return nil
|
|
9973
10592
|
end
|
|
9974
|
-
|
|
9975
|
-
|
|
9976
|
-
|
|
9977
|
-
|
|
10593
|
+
local store = settingsStore
|
|
10594
|
+
local ticket = if store ~= nil then claimTicket(store) else nil
|
|
10595
|
+
-- Record a fallback too, so a plugin reload keeps this server's own identity.
|
|
10596
|
+
adoptRuntimeTopology(if ticket == nil then {} else {
|
|
10597
|
+
instanceId = ticket.instanceId,
|
|
10598
|
+
groupId = ticket.groupId,
|
|
10599
|
+
placeKey = ticket.placeKey,
|
|
10600
|
+
serverUrl = ticket.serverUrl,
|
|
10601
|
+
})
|
|
9978
10602
|
end
|
|
9979
10603
|
local function getRole()
|
|
9980
10604
|
return PeerRole.detect()
|
|
@@ -10031,32 +10655,69 @@ local function createReadyPayload(readyPeerId, role, instanceId, multiplayerGrou
|
|
|
10031
10655
|
timestamp = tick(),
|
|
10032
10656
|
}
|
|
10033
10657
|
end
|
|
10034
|
-
|
|
10035
|
-
|
|
10036
|
-
if
|
|
10037
|
-
|
|
10038
|
-
|
|
10658
|
+
local function resumePlayRecord()
|
|
10659
|
+
local record = readPlayRecord()
|
|
10660
|
+
if record == nil then
|
|
10661
|
+
return nil
|
|
10662
|
+
end
|
|
10663
|
+
if StudioTestService.EditModeActive then
|
|
10664
|
+
-- The Play ended while the plugin was reloading.
|
|
10665
|
+
if record.phase == "active" then
|
|
10666
|
+
finishPlay()
|
|
10667
|
+
end
|
|
10668
|
+
elseif record.phase == "armed" then
|
|
10669
|
+
beginPlay()
|
|
10670
|
+
end
|
|
10671
|
+
local current = readPlayRecord()
|
|
10672
|
+
local _ticket = current
|
|
10673
|
+
if _ticket ~= nil then
|
|
10674
|
+
_ticket = _ticket.ticket
|
|
10675
|
+
end
|
|
10676
|
+
local ticket = _ticket
|
|
10677
|
+
local _result = current
|
|
10678
|
+
if _result ~= nil then
|
|
10679
|
+
_result = _result.ticketState
|
|
10680
|
+
end
|
|
10681
|
+
local _condition = _result == "posted"
|
|
10682
|
+
if _condition then
|
|
10683
|
+
_condition = ticket ~= nil
|
|
10684
|
+
end
|
|
10685
|
+
if _condition then
|
|
10686
|
+
startTicketHeartbeat(ticket.id)
|
|
10039
10687
|
end
|
|
10040
|
-
|
|
10041
|
-
|
|
10688
|
+
end
|
|
10689
|
+
local function init(store, options)
|
|
10690
|
+
settingsStore = store
|
|
10691
|
+
serverUrlProvider = options.getServerUrl
|
|
10692
|
+
if not isEditSession then
|
|
10693
|
+
runtimeTopology = readRememberedRuntimeTopology()
|
|
10694
|
+
return nil
|
|
10695
|
+
end
|
|
10696
|
+
removeSavedSessionState()
|
|
10697
|
+
StudioTestService:GetPropertyChangedSignal("EditModeActive"):Connect(onEditModeChanged)
|
|
10698
|
+
resumePlayRecord()
|
|
10042
10699
|
end
|
|
10043
10700
|
return {
|
|
10044
10701
|
peerId = peerId,
|
|
10702
|
+
init = init,
|
|
10703
|
+
resolveRuntimeTopology = resolveRuntimeTopology,
|
|
10045
10704
|
getInstanceId = getInstanceId,
|
|
10046
10705
|
getMultiplayerGroupId = getMultiplayerGroupId,
|
|
10047
10706
|
getPlaceKey = getPlaceKey,
|
|
10707
|
+
getLegacyPlaceKey = getLegacyPlaceKey,
|
|
10708
|
+
getInheritedServerUrl = getInheritedServerUrl,
|
|
10048
10709
|
getRole = getRole,
|
|
10049
10710
|
getPlaceName = getPlaceName,
|
|
10050
10711
|
invalidatePlaceName = invalidatePlaceName,
|
|
10051
|
-
|
|
10052
|
-
|
|
10053
|
-
|
|
10712
|
+
prepareSoloTest = prepareSoloTest,
|
|
10713
|
+
finishTest = finishTest,
|
|
10714
|
+
prepareMultiplayerTest = prepareMultiplayerTest,
|
|
10054
10715
|
createReadyPayload = createReadyPayload,
|
|
10055
10716
|
}
|
|
10056
10717
|
]]></string>
|
|
10057
10718
|
</Properties>
|
|
10058
10719
|
</Item>
|
|
10059
|
-
<Item class="ModuleScript" referent="
|
|
10720
|
+
<Item class="ModuleScript" referent="31">
|
|
10060
10721
|
<Properties>
|
|
10061
10722
|
<string name="Name">Recording</string>
|
|
10062
10723
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10086,7 +10747,7 @@ return {
|
|
|
10086
10747
|
]]></string>
|
|
10087
10748
|
</Properties>
|
|
10088
10749
|
</Item>
|
|
10089
|
-
<Item class="ModuleScript" referent="
|
|
10750
|
+
<Item class="ModuleScript" referent="32">
|
|
10090
10751
|
<Properties>
|
|
10091
10752
|
<string name="Name">RenderMonitor</string>
|
|
10092
10753
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10154,7 +10815,7 @@ return {
|
|
|
10154
10815
|
]]></string>
|
|
10155
10816
|
</Properties>
|
|
10156
10817
|
</Item>
|
|
10157
|
-
<Item class="ModuleScript" referent="
|
|
10818
|
+
<Item class="ModuleScript" referent="33">
|
|
10158
10819
|
<Properties>
|
|
10159
10820
|
<string name="Name">RuntimeLogBuffer</string>
|
|
10160
10821
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10390,7 +11051,7 @@ return {
|
|
|
10390
11051
|
]]></string>
|
|
10391
11052
|
</Properties>
|
|
10392
11053
|
</Item>
|
|
10393
|
-
<Item class="ModuleScript" referent="
|
|
11054
|
+
<Item class="ModuleScript" referent="34">
|
|
10394
11055
|
<Properties>
|
|
10395
11056
|
<string name="Name">ScriptSearch</string>
|
|
10396
11057
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10717,14 +11378,12 @@ return {
|
|
|
10717
11378
|
]]></string>
|
|
10718
11379
|
</Properties>
|
|
10719
11380
|
</Item>
|
|
10720
|
-
<Item class="ModuleScript" referent="
|
|
11381
|
+
<Item class="ModuleScript" referent="35">
|
|
10721
11382
|
<Properties>
|
|
10722
11383
|
<string name="Name">ServerUrlSettings</string>
|
|
10723
11384
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10724
11385
|
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
10725
|
-
local
|
|
10726
|
-
local HttpService = _services.HttpService
|
|
10727
|
-
local ServerStorage = _services.ServerStorage
|
|
11386
|
+
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
10728
11387
|
local SETTING_KEY_PREFIX = "MCP_LAST_SUCCESSFUL_SERVER_URL_"
|
|
10729
11388
|
local GLOBAL_SETTING_KEY = "MCP_LAST_SUCCESSFUL_SERVER_URL_GLOBAL_V1"
|
|
10730
11389
|
local pluginRef
|
|
@@ -10764,30 +11423,12 @@ local function addUnique(values, value)
|
|
|
10764
11423
|
table.insert(_values_1, _value_1)
|
|
10765
11424
|
end
|
|
10766
11425
|
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
|
|
11426
|
+
local function computePlaceKeys()
|
|
11427
|
+
local placeKeys = { PluginSession.getPlaceKey() }
|
|
11428
|
+
-- Unpublished places used to be keyed by a saved __MCPPlaceId attribute.
|
|
11429
|
+
local legacyPlaceKey = PluginSession.getLegacyPlaceKey()
|
|
11430
|
+
if legacyPlaceKey ~= nil then
|
|
11431
|
+
addUnique(placeKeys, legacyPlaceKey)
|
|
10791
11432
|
end
|
|
10792
11433
|
return placeKeys
|
|
10793
11434
|
end
|
|
@@ -10821,19 +11462,14 @@ local function rememberServerUrl(serverUrl)
|
|
|
10821
11462
|
return nil
|
|
10822
11463
|
end
|
|
10823
11464
|
writeSettingString(GLOBAL_SETTING_KEY, normalized)
|
|
10824
|
-
|
|
10825
|
-
createAnonymous = true,
|
|
10826
|
-
}) do
|
|
10827
|
-
writeSettingString(settingKey(placeKey), normalized)
|
|
10828
|
-
end
|
|
11465
|
+
writeSettingString(settingKey(PluginSession.getPlaceKey()), normalized)
|
|
10829
11466
|
end
|
|
10830
11467
|
local function readServerUrl()
|
|
10831
11468
|
if not pluginRef then
|
|
10832
11469
|
return nil
|
|
10833
11470
|
end
|
|
10834
|
-
--
|
|
10835
|
-
--
|
|
10836
|
-
-- edit/server place-scoped setting.
|
|
11471
|
+
-- The legacy key is read only, so a place remembered by an older plugin
|
|
11472
|
+
-- version keeps its server URL until the current key is written.
|
|
10837
11473
|
for _, placeKey in computePlaceKeys() do
|
|
10838
11474
|
local remembered = readSettingString(settingKey(placeKey))
|
|
10839
11475
|
if remembered ~= nil then
|
|
@@ -10856,11 +11492,11 @@ return {
|
|
|
10856
11492
|
]]></string>
|
|
10857
11493
|
</Properties>
|
|
10858
11494
|
</Item>
|
|
10859
|
-
<Item class="ModuleScript" referent="
|
|
11495
|
+
<Item class="ModuleScript" referent="36">
|
|
10860
11496
|
<Properties>
|
|
10861
11497
|
<string name="Name">State</string>
|
|
10862
11498
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10863
|
-
local CURRENT_VERSION = "3.
|
|
11499
|
+
local CURRENT_VERSION = "3.2.0"
|
|
10864
11500
|
local PLUGIN_VARIANT = "inspector"
|
|
10865
11501
|
local BASE_PORT = 58741
|
|
10866
11502
|
local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
|
|
@@ -10998,14 +11634,14 @@ return {
|
|
|
10998
11634
|
]]></string>
|
|
10999
11635
|
</Properties>
|
|
11000
11636
|
</Item>
|
|
11001
|
-
<Item class="ModuleScript" referent="
|
|
11637
|
+
<Item class="ModuleScript" referent="37">
|
|
11002
11638
|
<Properties>
|
|
11003
11639
|
<string name="Name">StopPlayMonitor</string>
|
|
11004
11640
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
11005
11641
|
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
11006
|
-
-- Cross-DataModel stop_playtest signaling via plugin settings.
|
|
11007
|
-
--
|
|
11008
|
-
--
|
|
11642
|
+
-- Cross-DataModel stop_playtest signaling via plugin settings. A solo play
|
|
11643
|
+
-- server inherits its edit's instanceId from PluginSession (test args or a
|
|
11644
|
+
-- plugin-settings ticket), so only the intended Studio process consumes it.
|
|
11009
11645
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
11010
11646
|
local HttpService = _services.HttpService
|
|
11011
11647
|
local RunService = _services.RunService
|
|
@@ -11284,7 +11920,7 @@ return {
|
|
|
11284
11920
|
]]></string>
|
|
11285
11921
|
</Properties>
|
|
11286
11922
|
</Item>
|
|
11287
|
-
<Item class="ModuleScript" referent="
|
|
11923
|
+
<Item class="ModuleScript" referent="38">
|
|
11288
11924
|
<Properties>
|
|
11289
11925
|
<string name="Name">StudioWebSocket</string>
|
|
11290
11926
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -12408,7 +13044,7 @@ return {
|
|
|
12408
13044
|
]]></string>
|
|
12409
13045
|
</Properties>
|
|
12410
13046
|
</Item>
|
|
12411
|
-
<Item class="ModuleScript" referent="
|
|
13047
|
+
<Item class="ModuleScript" referent="39">
|
|
12412
13048
|
<Properties>
|
|
12413
13049
|
<string name="Name">TopologyId</string>
|
|
12414
13050
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -12453,7 +13089,7 @@ return {
|
|
|
12453
13089
|
]]></string>
|
|
12454
13090
|
</Properties>
|
|
12455
13091
|
</Item>
|
|
12456
|
-
<Item class="ModuleScript" referent="
|
|
13092
|
+
<Item class="ModuleScript" referent="40">
|
|
12457
13093
|
<Properties>
|
|
12458
13094
|
<string name="Name">UI</string>
|
|
12459
13095
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -12978,7 +13614,7 @@ return {
|
|
|
12978
13614
|
]]></string>
|
|
12979
13615
|
</Properties>
|
|
12980
13616
|
</Item>
|
|
12981
|
-
<Item class="ModuleScript" referent="
|
|
13617
|
+
<Item class="ModuleScript" referent="41">
|
|
12982
13618
|
<Properties>
|
|
12983
13619
|
<string name="Name">Utils</string>
|
|
12984
13620
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -13821,11 +14457,11 @@ return {
|
|
|
13821
14457
|
</Properties>
|
|
13822
14458
|
</Item>
|
|
13823
14459
|
</Item>
|
|
13824
|
-
<Item class="Folder" referent="
|
|
14460
|
+
<Item class="Folder" referent="46">
|
|
13825
14461
|
<Properties>
|
|
13826
14462
|
<string name="Name">include</string>
|
|
13827
14463
|
</Properties>
|
|
13828
|
-
<Item class="ModuleScript" referent="
|
|
14464
|
+
<Item class="ModuleScript" referent="42">
|
|
13829
14465
|
<Properties>
|
|
13830
14466
|
<string name="Name">LibMP</string>
|
|
13831
14467
|
<string name="Source"><![CDATA[-- =============================================================================
|
|
@@ -170209,7 +170845,7 @@ return LibMP
|
|
|
170209
170845
|
]]></string>
|
|
170210
170846
|
</Properties>
|
|
170211
170847
|
</Item>
|
|
170212
|
-
<Item class="ModuleScript" referent="
|
|
170848
|
+
<Item class="ModuleScript" referent="43">
|
|
170213
170849
|
<Properties>
|
|
170214
170850
|
<string name="Name">Promise</string>
|
|
170215
170851
|
<string name="Source"><![CDATA[--[[
|
|
@@ -172283,7 +172919,7 @@ return Promise
|
|
|
172283
172919
|
]]></string>
|
|
172284
172920
|
</Properties>
|
|
172285
172921
|
</Item>
|
|
172286
|
-
<Item class="ModuleScript" referent="
|
|
172922
|
+
<Item class="ModuleScript" referent="44">
|
|
172287
172923
|
<Properties>
|
|
172288
172924
|
<string name="Name">RuntimeLib</string>
|
|
172289
172925
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -172550,15 +173186,15 @@ return TS
|
|
|
172550
173186
|
</Properties>
|
|
172551
173187
|
</Item>
|
|
172552
173188
|
</Item>
|
|
172553
|
-
<Item class="Folder" referent="
|
|
173189
|
+
<Item class="Folder" referent="47">
|
|
172554
173190
|
<Properties>
|
|
172555
173191
|
<string name="Name">node_modules</string>
|
|
172556
173192
|
</Properties>
|
|
172557
|
-
<Item class="Folder" referent="
|
|
173193
|
+
<Item class="Folder" referent="48">
|
|
172558
173194
|
<Properties>
|
|
172559
173195
|
<string name="Name">@rbxts</string>
|
|
172560
173196
|
</Properties>
|
|
172561
|
-
<Item class="ModuleScript" referent="
|
|
173197
|
+
<Item class="ModuleScript" referent="45">
|
|
172562
173198
|
<Properties>
|
|
172563
173199
|
<string name="Name">services</string>
|
|
172564
173200
|
<string name="Source"><![CDATA[return setmetatable({}, {
|