@chrrxs/robloxstudio-mcp 3.1.5 → 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 +2265 -637
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +810 -154
|
@@ -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
|
|
@@ -3700,7 +3707,9 @@ local function showMarkers(captureId)
|
|
|
3700
3707
|
local gui = Instance.new("ScreenGui")
|
|
3701
3708
|
gui.Name = MARKER_GUI_NAME
|
|
3702
3709
|
gui.IgnoreGuiInset = true
|
|
3703
|
-
|
|
3710
|
+
-- Camera.ViewportSize reports the device safe area, so markers must bound it too.
|
|
3711
|
+
gui.ScreenInsets = Enum.ScreenInsets.DeviceSafeInsets
|
|
3712
|
+
gui.SafeAreaCompatibility = Enum.SafeAreaCompatibility.None
|
|
3704
3713
|
gui.DisplayOrder = 2147483647
|
|
3705
3714
|
gui.ResetOnSpawn = false
|
|
3706
3715
|
gui.ZIndexBehavior = Enum.ZIndexBehavior.Global
|
|
@@ -5472,6 +5481,18 @@ local function captureMicroProfiler(requestData)
|
|
|
5472
5481
|
local threadIds = session:FetchThreadIds() or {}
|
|
5473
5482
|
local frameMin = session:GetFrameIdMin()
|
|
5474
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
|
|
5475
5496
|
local startFrame = math.max(frameMin, frameMax - frameWindow + 1)
|
|
5476
5497
|
local framesConsidered = if frameMax >= startFrame then frameMax - startFrame + 1 else 0
|
|
5477
5498
|
local groupCache = {}
|
|
@@ -6596,6 +6617,7 @@ local Recording = TS.import(script, script.Parent.Parent, "Recording")
|
|
|
6596
6617
|
local _binding = Utils
|
|
6597
6618
|
local getInstanceByPath = _binding.getInstanceByPath
|
|
6598
6619
|
local convertPropertyValue = _binding.convertPropertyValue
|
|
6620
|
+
local applyScriptSource = _binding.applyScriptSource
|
|
6599
6621
|
local _binding_1 = Recording
|
|
6600
6622
|
local beginRecording = _binding_1.beginRecording
|
|
6601
6623
|
local finishRecording = _binding_1.finishRecording
|
|
@@ -6623,7 +6645,12 @@ local function setProperties(requestData)
|
|
|
6623
6645
|
for propName, propValue in pairs(properties) do
|
|
6624
6646
|
local success, err = pcall(function()
|
|
6625
6647
|
if propName == "Parent" or propName == "PrimaryPart" then
|
|
6626
|
-
if type(propValue) == "string" then
|
|
6648
|
+
if not (type(propValue) == "string") then
|
|
6649
|
+
error(`{propName} must be an instance path string (or an empty string to clear)`)
|
|
6650
|
+
end
|
|
6651
|
+
if propValue == "" then
|
|
6652
|
+
inst[propName] = nil
|
|
6653
|
+
else
|
|
6627
6654
|
local refInstance = getInstanceByPath(propValue)
|
|
6628
6655
|
if not refInstance then
|
|
6629
6656
|
error(`{propName} reference not found: {propValue}`)
|
|
@@ -6633,7 +6660,17 @@ local function setProperties(requestData)
|
|
|
6633
6660
|
elseif propName == "Name" then
|
|
6634
6661
|
instance.Name = tostring(propValue)
|
|
6635
6662
|
elseif propName == "Source" and instance:IsA("LuaSourceContainer") then
|
|
6636
|
-
|
|
6663
|
+
if not (type(propValue) == "string") then
|
|
6664
|
+
error("Source must be a string")
|
|
6665
|
+
end
|
|
6666
|
+
local result = applyScriptSource(instance, propValue)
|
|
6667
|
+
if not result.success then
|
|
6668
|
+
local _condition = result.error
|
|
6669
|
+
if _condition == nil then
|
|
6670
|
+
_condition = "Script source update failed"
|
|
6671
|
+
end
|
|
6672
|
+
error(_condition)
|
|
6673
|
+
end
|
|
6637
6674
|
else
|
|
6638
6675
|
local converted = convertPropertyValue(instance, propName, propValue)
|
|
6639
6676
|
inst[propName] = if converted ~= nil then converted else propValue
|
|
@@ -9015,15 +9052,15 @@ local function startPlaytest(requestData)
|
|
|
9015
9052
|
}
|
|
9016
9053
|
end
|
|
9017
9054
|
testRunning = true
|
|
9018
|
-
local
|
|
9055
|
+
local preparedTest = PluginSession.prepareSoloTest()
|
|
9019
9056
|
task.spawn(function()
|
|
9020
9057
|
local ok, result = pcall(function()
|
|
9021
9058
|
if mode == "play" then
|
|
9022
|
-
return StudioTestService:ExecutePlayModeAsync(
|
|
9059
|
+
return StudioTestService:ExecutePlayModeAsync(preparedTest.testArgs)
|
|
9023
9060
|
end
|
|
9024
|
-
return StudioTestService:ExecuteRunModeAsync(
|
|
9061
|
+
return StudioTestService:ExecuteRunModeAsync(preparedTest.testArgs)
|
|
9025
9062
|
end)
|
|
9026
|
-
PluginSession.
|
|
9063
|
+
PluginSession.finishTest(preparedTest.token)
|
|
9027
9064
|
if not ok then
|
|
9028
9065
|
warn(`[robloxstudio-mcp] Playtest ended with error: {result}`)
|
|
9029
9066
|
end
|
|
@@ -9141,13 +9178,13 @@ local function multiplayerTestStart(requestData)
|
|
|
9141
9178
|
testArgs = testArgs,
|
|
9142
9179
|
startedAt = tick(),
|
|
9143
9180
|
}
|
|
9144
|
-
local
|
|
9181
|
+
local topologyToken = PluginSession.prepareMultiplayerTest(testId)
|
|
9145
9182
|
task.spawn(function()
|
|
9146
9183
|
multiplayerState.phase = "running"
|
|
9147
9184
|
local ok, result = pcall(function()
|
|
9148
9185
|
return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
|
|
9149
9186
|
end)
|
|
9150
|
-
PluginSession.
|
|
9187
|
+
PluginSession.finishTest(topologyToken)
|
|
9151
9188
|
multiplayerState.completedAt = tick()
|
|
9152
9189
|
multiplayerState.ok = ok
|
|
9153
9190
|
if ok then
|
|
@@ -9850,6 +9887,187 @@ return {
|
|
|
9850
9887
|
</Properties>
|
|
9851
9888
|
</Item>
|
|
9852
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">
|
|
9853
10071
|
<Properties>
|
|
9854
10072
|
<string name="Name">PluginSession</string>
|
|
9855
10073
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9862,101 +10080,525 @@ local ServerStorage = _services.ServerStorage
|
|
|
9862
10080
|
local State = TS.import(script, script.Parent, "State")
|
|
9863
10081
|
local PeerRole = TS.import(script, script.Parent, "PeerRole")
|
|
9864
10082
|
local TopologyId = TS.import(script, script.Parent, "TopologyId")
|
|
10083
|
+
local PlaytestTickets = TS.import(script, script.Parent, "PlaytestTickets")
|
|
9865
10084
|
local CoreGui = game:GetService("CoreGui")
|
|
9866
|
-
local
|
|
10085
|
+
local StudioTestService = game:GetService("StudioTestService")
|
|
10086
|
+
local LEGACY_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
9867
10087
|
local TOPOLOGY_MODE_ATTRIBUTE = "__MCPTopologyMode"
|
|
9868
10088
|
local TOPOLOGY_INSTANCE_ID_ATTRIBUTE = "__MCPTopologyInstanceId"
|
|
9869
10089
|
local TOPOLOGY_GROUP_ID_ATTRIBUTE = "__MCPTopologyGroupId"
|
|
9870
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 }
|
|
9871
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.
|
|
9872
10114
|
local peerId = TopologyId.createPeerId()
|
|
9873
10115
|
local isEditSession = PeerRole.detect() == "edit"
|
|
9874
10116
|
local existingSessionIdentity = CoreGui:FindFirstChild(SESSION_IDENTITY_NAME)
|
|
9875
|
-
local
|
|
9876
|
-
local createdSessionIdentity = false
|
|
10117
|
+
local sessionIdentity
|
|
9877
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
|
|
9878
|
-
|
|
10119
|
+
sessionIdentity = existingSessionIdentity
|
|
9879
10120
|
else
|
|
9880
10121
|
local _result = existingSessionIdentity
|
|
9881
10122
|
if _result ~= nil then
|
|
9882
10123
|
_result:Destroy()
|
|
9883
10124
|
end
|
|
9884
|
-
|
|
9885
|
-
local sessionIdentity = Instance.new("StringValue")
|
|
10125
|
+
sessionIdentity = Instance.new("StringValue")
|
|
9886
10126
|
sessionIdentity.Name = SESSION_IDENTITY_NAME
|
|
9887
|
-
sessionIdentity.Value =
|
|
10127
|
+
sessionIdentity.Value = TopologyId.createInstanceId()
|
|
9888
10128
|
sessionIdentity.Archivable = false
|
|
9889
10129
|
sessionIdentity.Parent = CoreGui
|
|
9890
|
-
createdSessionIdentity = true
|
|
9891
10130
|
end
|
|
9892
|
-
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
|
|
9893
10138
|
local cachedPlaceName
|
|
9894
10139
|
local cachedPlaceNamePlaceId
|
|
9895
|
-
local function getMarkerMode()
|
|
9896
|
-
local mode = ReplicatedStorage:GetAttribute(TOPOLOGY_MODE_ATTRIBUTE)
|
|
9897
|
-
return if mode == "shared" or mode == "multiplayer" then mode else nil
|
|
9898
|
-
end
|
|
9899
10140
|
local function getInstanceId()
|
|
9900
|
-
|
|
9901
|
-
|
|
10141
|
+
local _result = runtimeTopology
|
|
10142
|
+
if _result ~= nil then
|
|
10143
|
+
_result = _result.instanceId
|
|
9902
10144
|
end
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
|
|
9906
|
-
inheritedInstanceId = sharedInstanceId
|
|
9907
|
-
return sharedInstanceId
|
|
9908
|
-
end
|
|
10145
|
+
local _condition = _result
|
|
10146
|
+
if _condition == nil then
|
|
10147
|
+
_condition = sessionInstanceId
|
|
9909
10148
|
end
|
|
9910
|
-
return
|
|
10149
|
+
return _condition
|
|
9911
10150
|
end
|
|
10151
|
+
local readPlayRecord
|
|
9912
10152
|
local function getMultiplayerGroupId()
|
|
9913
|
-
if
|
|
9914
|
-
|
|
10153
|
+
if not isEditSession then
|
|
10154
|
+
local _result = runtimeTopology
|
|
10155
|
+
if _result ~= nil then
|
|
10156
|
+
_result = _result.groupId
|
|
10157
|
+
end
|
|
10158
|
+
return _result
|
|
9915
10159
|
end
|
|
9916
|
-
local
|
|
9917
|
-
|
|
10160
|
+
local _result = readPlayRecord()
|
|
10161
|
+
if _result ~= nil then
|
|
10162
|
+
_result = _result.ticket
|
|
10163
|
+
if _result ~= nil then
|
|
10164
|
+
_result = _result.groupId
|
|
10165
|
+
end
|
|
10166
|
+
end
|
|
10167
|
+
return _result
|
|
9918
10168
|
end
|
|
9919
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
|
|
9920
10178
|
if game.PlaceId ~= 0 then
|
|
9921
10179
|
return `place:{tostring(game.PlaceId)}`
|
|
9922
10180
|
end
|
|
9923
|
-
|
|
9924
|
-
|
|
9925
|
-
|
|
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
|
|
9926
10196
|
end
|
|
9927
|
-
local fresh = HttpService:GenerateGUID(false)
|
|
9928
10197
|
pcall(function()
|
|
9929
|
-
return
|
|
10198
|
+
return target:SetAttribute(name, nil)
|
|
9930
10199
|
end)
|
|
9931
|
-
return `anon:{fresh}`
|
|
9932
10200
|
end
|
|
9933
|
-
local function
|
|
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)
|
|
10285
|
+
end)
|
|
10286
|
+
end
|
|
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)
|
|
9934
10412
|
local token = HttpService:GenerateGUID(false)
|
|
9935
|
-
|
|
9936
|
-
|
|
9937
|
-
|
|
9938
|
-
|
|
9939
|
-
|
|
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
|
|
9940
10426
|
return token
|
|
9941
10427
|
end
|
|
9942
|
-
local function
|
|
9943
|
-
|
|
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
|
|
9944
10481
|
end
|
|
9945
|
-
|
|
9946
|
-
|
|
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
|
|
9947
10522
|
end
|
|
9948
|
-
local function
|
|
9949
|
-
|
|
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
|
|
9950
10533
|
return nil
|
|
9951
10534
|
end
|
|
9952
|
-
|
|
9953
|
-
|
|
10535
|
+
local topology = envelope
|
|
10536
|
+
local instanceId = optionalString(topology.instanceId)
|
|
10537
|
+
if instanceId == nil then
|
|
9954
10538
|
return nil
|
|
9955
10539
|
end
|
|
9956
|
-
|
|
9957
|
-
|
|
9958
|
-
|
|
9959
|
-
|
|
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
|
+
}
|
|
10564
|
+
end
|
|
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
|
|
10573
|
+
end
|
|
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
|
|
10585
|
+
return nil
|
|
10586
|
+
end
|
|
10587
|
+
runtimeResolutionStarted = true
|
|
10588
|
+
local fromArgs = topologyFromTestArgs()
|
|
10589
|
+
if fromArgs ~= nil then
|
|
10590
|
+
adoptRuntimeTopology(fromArgs)
|
|
10591
|
+
return nil
|
|
10592
|
+
end
|
|
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
|
+
})
|
|
9960
10602
|
end
|
|
9961
10603
|
local function getRole()
|
|
9962
10604
|
return PeerRole.detect()
|
|
@@ -10013,32 +10655,69 @@ local function createReadyPayload(readyPeerId, role, instanceId, multiplayerGrou
|
|
|
10013
10655
|
timestamp = tick(),
|
|
10014
10656
|
}
|
|
10015
10657
|
end
|
|
10016
|
-
|
|
10017
|
-
|
|
10018
|
-
if
|
|
10019
|
-
|
|
10020
|
-
prepareSharedTopology()
|
|
10658
|
+
local function resumePlayRecord()
|
|
10659
|
+
local record = readPlayRecord()
|
|
10660
|
+
if record == nil then
|
|
10661
|
+
return nil
|
|
10021
10662
|
end
|
|
10022
|
-
|
|
10023
|
-
|
|
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)
|
|
10687
|
+
end
|
|
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()
|
|
10024
10699
|
end
|
|
10025
10700
|
return {
|
|
10026
10701
|
peerId = peerId,
|
|
10702
|
+
init = init,
|
|
10703
|
+
resolveRuntimeTopology = resolveRuntimeTopology,
|
|
10027
10704
|
getInstanceId = getInstanceId,
|
|
10028
10705
|
getMultiplayerGroupId = getMultiplayerGroupId,
|
|
10029
10706
|
getPlaceKey = getPlaceKey,
|
|
10707
|
+
getLegacyPlaceKey = getLegacyPlaceKey,
|
|
10708
|
+
getInheritedServerUrl = getInheritedServerUrl,
|
|
10030
10709
|
getRole = getRole,
|
|
10031
10710
|
getPlaceName = getPlaceName,
|
|
10032
10711
|
invalidatePlaceName = invalidatePlaceName,
|
|
10033
|
-
|
|
10034
|
-
|
|
10035
|
-
|
|
10712
|
+
prepareSoloTest = prepareSoloTest,
|
|
10713
|
+
finishTest = finishTest,
|
|
10714
|
+
prepareMultiplayerTest = prepareMultiplayerTest,
|
|
10036
10715
|
createReadyPayload = createReadyPayload,
|
|
10037
10716
|
}
|
|
10038
10717
|
]]></string>
|
|
10039
10718
|
</Properties>
|
|
10040
10719
|
</Item>
|
|
10041
|
-
<Item class="ModuleScript" referent="
|
|
10720
|
+
<Item class="ModuleScript" referent="31">
|
|
10042
10721
|
<Properties>
|
|
10043
10722
|
<string name="Name">Recording</string>
|
|
10044
10723
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10068,7 +10747,7 @@ return {
|
|
|
10068
10747
|
]]></string>
|
|
10069
10748
|
</Properties>
|
|
10070
10749
|
</Item>
|
|
10071
|
-
<Item class="ModuleScript" referent="
|
|
10750
|
+
<Item class="ModuleScript" referent="32">
|
|
10072
10751
|
<Properties>
|
|
10073
10752
|
<string name="Name">RenderMonitor</string>
|
|
10074
10753
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10136,7 +10815,7 @@ return {
|
|
|
10136
10815
|
]]></string>
|
|
10137
10816
|
</Properties>
|
|
10138
10817
|
</Item>
|
|
10139
|
-
<Item class="ModuleScript" referent="
|
|
10818
|
+
<Item class="ModuleScript" referent="33">
|
|
10140
10819
|
<Properties>
|
|
10141
10820
|
<string name="Name">RuntimeLogBuffer</string>
|
|
10142
10821
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10372,7 +11051,7 @@ return {
|
|
|
10372
11051
|
]]></string>
|
|
10373
11052
|
</Properties>
|
|
10374
11053
|
</Item>
|
|
10375
|
-
<Item class="ModuleScript" referent="
|
|
11054
|
+
<Item class="ModuleScript" referent="34">
|
|
10376
11055
|
<Properties>
|
|
10377
11056
|
<string name="Name">ScriptSearch</string>
|
|
10378
11057
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10699,14 +11378,12 @@ return {
|
|
|
10699
11378
|
]]></string>
|
|
10700
11379
|
</Properties>
|
|
10701
11380
|
</Item>
|
|
10702
|
-
<Item class="ModuleScript" referent="
|
|
11381
|
+
<Item class="ModuleScript" referent="35">
|
|
10703
11382
|
<Properties>
|
|
10704
11383
|
<string name="Name">ServerUrlSettings</string>
|
|
10705
11384
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10706
11385
|
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
10707
|
-
local
|
|
10708
|
-
local HttpService = _services.HttpService
|
|
10709
|
-
local ServerStorage = _services.ServerStorage
|
|
11386
|
+
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
10710
11387
|
local SETTING_KEY_PREFIX = "MCP_LAST_SUCCESSFUL_SERVER_URL_"
|
|
10711
11388
|
local GLOBAL_SETTING_KEY = "MCP_LAST_SUCCESSFUL_SERVER_URL_GLOBAL_V1"
|
|
10712
11389
|
local pluginRef
|
|
@@ -10746,30 +11423,12 @@ local function addUnique(values, value)
|
|
|
10746
11423
|
table.insert(_values_1, _value_1)
|
|
10747
11424
|
end
|
|
10748
11425
|
end
|
|
10749
|
-
local function computePlaceKeys(
|
|
10750
|
-
local placeKeys = {}
|
|
10751
|
-
|
|
10752
|
-
|
|
10753
|
-
|
|
10754
|
-
|
|
10755
|
-
if type(existing) == "string" and existing ~= "" then
|
|
10756
|
-
addUnique(placeKeys, `anon:{existing}`)
|
|
10757
|
-
else
|
|
10758
|
-
local _condition = game.PlaceId == 0
|
|
10759
|
-
if _condition then
|
|
10760
|
-
local _result = options
|
|
10761
|
-
if _result ~= nil then
|
|
10762
|
-
_result = _result.createAnonymous
|
|
10763
|
-
end
|
|
10764
|
-
_condition = _result == true
|
|
10765
|
-
end
|
|
10766
|
-
if _condition then
|
|
10767
|
-
local fresh = HttpService:GenerateGUID(false)
|
|
10768
|
-
pcall(function()
|
|
10769
|
-
return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
|
|
10770
|
-
end)
|
|
10771
|
-
addUnique(placeKeys, `anon:{fresh}`)
|
|
10772
|
-
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)
|
|
10773
11432
|
end
|
|
10774
11433
|
return placeKeys
|
|
10775
11434
|
end
|
|
@@ -10803,19 +11462,14 @@ local function rememberServerUrl(serverUrl)
|
|
|
10803
11462
|
return nil
|
|
10804
11463
|
end
|
|
10805
11464
|
writeSettingString(GLOBAL_SETTING_KEY, normalized)
|
|
10806
|
-
|
|
10807
|
-
createAnonymous = true,
|
|
10808
|
-
}) do
|
|
10809
|
-
writeSettingString(settingKey(placeKey), normalized)
|
|
10810
|
-
end
|
|
11465
|
+
writeSettingString(settingKey(PluginSession.getPlaceKey()), normalized)
|
|
10811
11466
|
end
|
|
10812
11467
|
local function readServerUrl()
|
|
10813
11468
|
if not pluginRef then
|
|
10814
11469
|
return nil
|
|
10815
11470
|
end
|
|
10816
|
-
--
|
|
10817
|
-
--
|
|
10818
|
-
-- 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.
|
|
10819
11473
|
for _, placeKey in computePlaceKeys() do
|
|
10820
11474
|
local remembered = readSettingString(settingKey(placeKey))
|
|
10821
11475
|
if remembered ~= nil then
|
|
@@ -10838,11 +11492,11 @@ return {
|
|
|
10838
11492
|
]]></string>
|
|
10839
11493
|
</Properties>
|
|
10840
11494
|
</Item>
|
|
10841
|
-
<Item class="ModuleScript" referent="
|
|
11495
|
+
<Item class="ModuleScript" referent="36">
|
|
10842
11496
|
<Properties>
|
|
10843
11497
|
<string name="Name">State</string>
|
|
10844
11498
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10845
|
-
local CURRENT_VERSION = "3.
|
|
11499
|
+
local CURRENT_VERSION = "3.2.0"
|
|
10846
11500
|
local PLUGIN_VARIANT = "main"
|
|
10847
11501
|
local BASE_PORT = 58741
|
|
10848
11502
|
local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
|
|
@@ -10980,14 +11634,14 @@ return {
|
|
|
10980
11634
|
]]></string>
|
|
10981
11635
|
</Properties>
|
|
10982
11636
|
</Item>
|
|
10983
|
-
<Item class="ModuleScript" referent="
|
|
11637
|
+
<Item class="ModuleScript" referent="37">
|
|
10984
11638
|
<Properties>
|
|
10985
11639
|
<string name="Name">StopPlayMonitor</string>
|
|
10986
11640
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10987
11641
|
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
10988
|
-
-- Cross-DataModel stop_playtest signaling via plugin settings.
|
|
10989
|
-
--
|
|
10990
|
-
--
|
|
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.
|
|
10991
11645
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
10992
11646
|
local HttpService = _services.HttpService
|
|
10993
11647
|
local RunService = _services.RunService
|
|
@@ -11266,7 +11920,7 @@ return {
|
|
|
11266
11920
|
]]></string>
|
|
11267
11921
|
</Properties>
|
|
11268
11922
|
</Item>
|
|
11269
|
-
<Item class="ModuleScript" referent="
|
|
11923
|
+
<Item class="ModuleScript" referent="38">
|
|
11270
11924
|
<Properties>
|
|
11271
11925
|
<string name="Name">StudioWebSocket</string>
|
|
11272
11926
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -12390,7 +13044,7 @@ return {
|
|
|
12390
13044
|
]]></string>
|
|
12391
13045
|
</Properties>
|
|
12392
13046
|
</Item>
|
|
12393
|
-
<Item class="ModuleScript" referent="
|
|
13047
|
+
<Item class="ModuleScript" referent="39">
|
|
12394
13048
|
<Properties>
|
|
12395
13049
|
<string name="Name">TopologyId</string>
|
|
12396
13050
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -12435,7 +13089,7 @@ return {
|
|
|
12435
13089
|
]]></string>
|
|
12436
13090
|
</Properties>
|
|
12437
13091
|
</Item>
|
|
12438
|
-
<Item class="ModuleScript" referent="
|
|
13092
|
+
<Item class="ModuleScript" referent="40">
|
|
12439
13093
|
<Properties>
|
|
12440
13094
|
<string name="Name">UI</string>
|
|
12441
13095
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -12960,7 +13614,7 @@ return {
|
|
|
12960
13614
|
]]></string>
|
|
12961
13615
|
</Properties>
|
|
12962
13616
|
</Item>
|
|
12963
|
-
<Item class="ModuleScript" referent="
|
|
13617
|
+
<Item class="ModuleScript" referent="41">
|
|
12964
13618
|
<Properties>
|
|
12965
13619
|
<string name="Name">Utils</string>
|
|
12966
13620
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -13558,11 +14212,13 @@ local function convertPropertyValue(instance, propertyName, propertyValue)
|
|
|
13558
14212
|
if propertyName == "BrickColor" then
|
|
13559
14213
|
return BrickColor.new(propertyValue)
|
|
13560
14214
|
end
|
|
13561
|
-
if
|
|
13562
|
-
|
|
13563
|
-
|
|
13564
|
-
|
|
13565
|
-
|
|
14215
|
+
if success and typeof(currentVal) == "boolean" then
|
|
14216
|
+
if propertyValue == "true" then
|
|
14217
|
+
return true
|
|
14218
|
+
end
|
|
14219
|
+
if propertyValue == "false" then
|
|
14220
|
+
return false
|
|
14221
|
+
end
|
|
13566
14222
|
end
|
|
13567
14223
|
end
|
|
13568
14224
|
return propertyValue
|
|
@@ -13801,11 +14457,11 @@ return {
|
|
|
13801
14457
|
</Properties>
|
|
13802
14458
|
</Item>
|
|
13803
14459
|
</Item>
|
|
13804
|
-
<Item class="Folder" referent="
|
|
14460
|
+
<Item class="Folder" referent="46">
|
|
13805
14461
|
<Properties>
|
|
13806
14462
|
<string name="Name">include</string>
|
|
13807
14463
|
</Properties>
|
|
13808
|
-
<Item class="ModuleScript" referent="
|
|
14464
|
+
<Item class="ModuleScript" referent="42">
|
|
13809
14465
|
<Properties>
|
|
13810
14466
|
<string name="Name">LibMP</string>
|
|
13811
14467
|
<string name="Source"><![CDATA[-- =============================================================================
|
|
@@ -170189,7 +170845,7 @@ return LibMP
|
|
|
170189
170845
|
]]></string>
|
|
170190
170846
|
</Properties>
|
|
170191
170847
|
</Item>
|
|
170192
|
-
<Item class="ModuleScript" referent="
|
|
170848
|
+
<Item class="ModuleScript" referent="43">
|
|
170193
170849
|
<Properties>
|
|
170194
170850
|
<string name="Name">Promise</string>
|
|
170195
170851
|
<string name="Source"><![CDATA[--[[
|
|
@@ -172263,7 +172919,7 @@ return Promise
|
|
|
172263
172919
|
]]></string>
|
|
172264
172920
|
</Properties>
|
|
172265
172921
|
</Item>
|
|
172266
|
-
<Item class="ModuleScript" referent="
|
|
172922
|
+
<Item class="ModuleScript" referent="44">
|
|
172267
172923
|
<Properties>
|
|
172268
172924
|
<string name="Name">RuntimeLib</string>
|
|
172269
172925
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -172530,15 +173186,15 @@ return TS
|
|
|
172530
173186
|
</Properties>
|
|
172531
173187
|
</Item>
|
|
172532
173188
|
</Item>
|
|
172533
|
-
<Item class="Folder" referent="
|
|
173189
|
+
<Item class="Folder" referent="47">
|
|
172534
173190
|
<Properties>
|
|
172535
173191
|
<string name="Name">node_modules</string>
|
|
172536
173192
|
</Properties>
|
|
172537
|
-
<Item class="Folder" referent="
|
|
173193
|
+
<Item class="Folder" referent="48">
|
|
172538
173194
|
<Properties>
|
|
172539
173195
|
<string name="Name">@rbxts</string>
|
|
172540
173196
|
</Properties>
|
|
172541
|
-
<Item class="ModuleScript" referent="
|
|
173197
|
+
<Item class="ModuleScript" referent="45">
|
|
172542
173198
|
<Properties>
|
|
172543
173199
|
<string name="Name">services</string>
|
|
172544
173200
|
<string name="Source"><![CDATA[return setmetatable({}, {
|