@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.
@@ -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.5" 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.5`
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
@@ -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
- gui.ScreenInsets = Enum.ScreenInsets.None
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
- instance.Source = tostring(propValue)
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 topologyMarkerToken = PluginSession.prepareSharedTopology()
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.clearTopologyMarker(topologyMarkerToken)
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 topologyMarkerToken = PluginSession.prepareMultiplayerTopology(testId)
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.clearTopologyMarker(topologyMarkerToken)
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 MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
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 sessionInstanceId
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
- sessionInstanceId = existingSessionIdentity.Value
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
- sessionInstanceId = TopologyId.createInstanceId()
9885
- local sessionIdentity = Instance.new("StringValue")
10125
+ sessionIdentity = Instance.new("StringValue")
9886
10126
  sessionIdentity.Name = SESSION_IDENTITY_NAME
9887
- sessionIdentity.Value = sessionInstanceId
10127
+ sessionIdentity.Value = TopologyId.createInstanceId()
9888
10128
  sessionIdentity.Archivable = false
9889
10129
  sessionIdentity.Parent = CoreGui
9890
- createdSessionIdentity = true
9891
10130
  end
9892
- 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
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
- if inheritedInstanceId ~= nil then
9901
- return inheritedInstanceId
10141
+ local _result = runtimeTopology
10142
+ if _result ~= nil then
10143
+ _result = _result.instanceId
9902
10144
  end
9903
- if not isEditSession and getMarkerMode() == "shared" then
9904
- local sharedInstanceId = ReplicatedStorage:GetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE)
9905
- if type(sharedInstanceId) == "string" and sharedInstanceId ~= "" then
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 sessionInstanceId
10149
+ return _condition
9911
10150
  end
10151
+ local readPlayRecord
9912
10152
  local function getMultiplayerGroupId()
9913
- if getMarkerMode() ~= "multiplayer" then
9914
- 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
9915
10159
  end
9916
- local groupId = ReplicatedStorage:GetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE)
9917
- return if type(groupId) == "string" and groupId ~= "" then groupId else nil
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
- local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
9924
- if type(existing) == "string" and existing ~= "" then
9925
- 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
9926
10196
  end
9927
- local fresh = HttpService:GenerateGUID(false)
9928
10197
  pcall(function()
9929
- return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
10198
+ return target:SetAttribute(name, nil)
9930
10199
  end)
9931
- return `anon:{fresh}`
9932
10200
  end
9933
- local function setTopologyMarker(mode, instanceId, groupId)
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
- ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, nil)
9936
- ReplicatedStorage:SetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE, instanceId)
9937
- ReplicatedStorage:SetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE, groupId)
9938
- ReplicatedStorage:SetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE, token)
9939
- 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
9940
10426
  return token
9941
10427
  end
9942
- local function prepareSharedTopology()
9943
- 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
9944
10481
  end
9945
- local function prepareMultiplayerTopology(groupId)
9946
- return setTopologyMarker("multiplayer", nil, groupId)
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 clearTopologyMarker(token)
9949
- if ReplicatedStorage:GetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE) ~= token then
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
- if isEditSession then
9953
- prepareSharedTopology()
10535
+ local topology = envelope
10536
+ local instanceId = optionalString(topology.instanceId)
10537
+ if instanceId == nil then
9954
10538
  return nil
9955
10539
  end
9956
- ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, nil)
9957
- ReplicatedStorage:SetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE, nil)
9958
- ReplicatedStorage:SetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE, nil)
9959
- ReplicatedStorage:SetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE, nil)
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
- -- CoreGui survives plugin reloads without saving or replicating this identity.
10017
- -- Preserve active test markers on reload; fresh edits replace saved place markers.
10018
- if isEditSession then
10019
- if createdSessionIdentity or getMarkerMode() == nil then
10020
- prepareSharedTopology()
10658
+ local function resumePlayRecord()
10659
+ local record = readPlayRecord()
10660
+ if record == nil then
10661
+ return nil
10021
10662
  end
10022
- else
10023
- getInstanceId()
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
- prepareSharedTopology = prepareSharedTopology,
10034
- prepareMultiplayerTopology = prepareMultiplayerTopology,
10035
- clearTopologyMarker = clearTopologyMarker,
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="30">
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="31">
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="32">
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="33">
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="34">
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 _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
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(options)
10750
- local placeKeys = {}
10751
- if game.PlaceId ~= 0 then
10752
- addUnique(placeKeys, `place:{tostring(game.PlaceId)}`)
10753
- end
10754
- local existing = ServerStorage:GetAttribute("__MCPPlaceId")
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
- for _, placeKey in computePlaceKeys({
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
- -- Reading settings should not mint a place key. Client play DataModels have
10817
- -- their own ServerStorage; creating a key there would not match the
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="35">
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.1.5"
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="36">
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. Solo edit and
10989
- -- runtime peers share one process instanceId through PluginSession's topology
10990
- -- 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.
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="37">
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="38">
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="39">
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="40">
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 propertyValue == "true" then
13562
- return true
13563
- end
13564
- if propertyValue == "false" then
13565
- return false
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="45">
14460
+ <Item class="Folder" referent="46">
13805
14461
  <Properties>
13806
14462
  <string name="Name">include</string>
13807
14463
  </Properties>
13808
- <Item class="ModuleScript" referent="41">
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="42">
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="43">
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="46">
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="47">
173193
+ <Item class="Folder" referent="48">
172538
173194
  <Properties>
172539
173195
  <string name="Name">@rbxts</string>
172540
173196
  </Properties>
172541
- <Item class="ModuleScript" referent="44">
173197
+ <Item class="ModuleScript" referent="45">
172542
173198
  <Properties>
172543
173199
  <string name="Name">services</string>
172544
173200
  <string name="Source"><![CDATA[return setmetatable({}, {