@chrrxs/robloxstudio-mcp 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.
@@ -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 = ServerUrlSettings.readServerUrl()
133
+ local _condition = PluginSession.getInheritedServerUrl()
120
134
  if _condition == nil then
121
- _condition = ClientBroker.DEFAULT_MCP_URL
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.1.6" is replaced with the package version at package time
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.1.6`
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}{placeKey}:{role}`,
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 stored = readSetting(scope.key)
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 topologyMarkerToken = PluginSession.prepareSharedTopology()
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.clearTopologyMarker(topologyMarkerToken)
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 topologyMarkerToken = PluginSession.prepareMultiplayerTopology(testId)
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.clearTopologyMarker(topologyMarkerToken)
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 MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
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 sessionInstanceId
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
- sessionInstanceId = existingSessionIdentity.Value
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
- sessionInstanceId = TopologyId.createInstanceId()
9903
- local sessionIdentity = Instance.new("StringValue")
10125
+ sessionIdentity = Instance.new("StringValue")
9904
10126
  sessionIdentity.Name = SESSION_IDENTITY_NAME
9905
- sessionIdentity.Value = sessionInstanceId
10127
+ sessionIdentity.Value = TopologyId.createInstanceId()
9906
10128
  sessionIdentity.Archivable = false
9907
10129
  sessionIdentity.Parent = CoreGui
9908
- createdSessionIdentity = true
9909
10130
  end
9910
- local inheritedInstanceId
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
- if inheritedInstanceId ~= nil then
9919
- return inheritedInstanceId
10141
+ local _result = runtimeTopology
10142
+ if _result ~= nil then
10143
+ _result = _result.instanceId
9920
10144
  end
9921
- if not isEditSession and getMarkerMode() == "shared" then
9922
- local sharedInstanceId = ReplicatedStorage:GetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE)
9923
- if type(sharedInstanceId) == "string" and sharedInstanceId ~= "" then
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 sessionInstanceId
10149
+ return _condition
9929
10150
  end
10151
+ local readPlayRecord
9930
10152
  local function getMultiplayerGroupId()
9931
- if getMarkerMode() ~= "multiplayer" then
9932
- return nil
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
- local groupId = ReplicatedStorage:GetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE)
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
- local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
9942
- if type(existing) == "string" and existing ~= "" then
9943
- return `anon:{existing}`
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 ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
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
- local function setTopologyMarker(mode, instanceId, groupId)
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
- ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, nil)
9954
- ReplicatedStorage:SetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE, instanceId)
9955
- ReplicatedStorage:SetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE, groupId)
9956
- ReplicatedStorage:SetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE, token)
9957
- ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, mode)
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 prepareSharedTopology()
9961
- return setTopologyMarker("shared", sessionInstanceId, nil)
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 prepareMultiplayerTopology(groupId)
9964
- return setTopologyMarker("multiplayer", nil, groupId)
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
- local function clearTopologyMarker(token)
9967
- if ReplicatedStorage:GetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE) ~= token then
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
- if isEditSession then
9971
- prepareSharedTopology()
10587
+ runtimeResolutionStarted = true
10588
+ local fromArgs = topologyFromTestArgs()
10589
+ if fromArgs ~= nil then
10590
+ adoptRuntimeTopology(fromArgs)
9972
10591
  return nil
9973
10592
  end
9974
- ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, nil)
9975
- ReplicatedStorage:SetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE, nil)
9976
- ReplicatedStorage:SetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE, nil)
9977
- ReplicatedStorage:SetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE, nil)
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
- -- CoreGui survives plugin reloads without saving or replicating this identity.
10035
- -- Preserve active test markers on reload; fresh edits replace saved place markers.
10036
- if isEditSession then
10037
- if createdSessionIdentity or getMarkerMode() == nil then
10038
- prepareSharedTopology()
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
- else
10041
- getInstanceId()
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
- prepareSharedTopology = prepareSharedTopology,
10052
- prepareMultiplayerTopology = prepareMultiplayerTopology,
10053
- clearTopologyMarker = clearTopologyMarker,
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="30">
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="31">
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="32">
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="33">
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="34">
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 _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
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(options)
10768
- local placeKeys = {}
10769
- if game.PlaceId ~= 0 then
10770
- addUnique(placeKeys, `place:{tostring(game.PlaceId)}`)
10771
- end
10772
- local existing = ServerStorage:GetAttribute("__MCPPlaceId")
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
- for _, placeKey in computePlaceKeys({
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
- -- Reading settings should not mint a place key. Client play DataModels have
10835
- -- their own ServerStorage; creating a key there would not match the
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="35">
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.1.6"
11499
+ local CURRENT_VERSION = "3.2.0"
10864
11500
  local PLUGIN_VARIANT = "main"
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="36">
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. Solo edit and
11007
- -- runtime peers share one process instanceId through PluginSession's topology
11008
- -- marker, so only the intended Studio process can consume the request.
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="37">
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="38">
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="39">
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="40">
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="45">
14460
+ <Item class="Folder" referent="46">
13825
14461
  <Properties>
13826
14462
  <string name="Name">include</string>
13827
14463
  </Properties>
13828
- <Item class="ModuleScript" referent="41">
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="42">
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="43">
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="46">
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="47">
173193
+ <Item class="Folder" referent="48">
172558
173194
  <Properties>
172559
173195
  <string name="Name">@rbxts</string>
172560
173196
  </Properties>
172561
- <Item class="ModuleScript" referent="44">
173197
+ <Item class="ModuleScript" referent="45">
172562
173198
  <Properties>
172563
173199
  <string name="Name">services</string>
172564
173200
  <string name="Source"><![CDATA[return setmetatable({}, {