@chrrxs/robloxstudio-mcp 3.0.5 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1316 -875
- package/package.json +2 -2
- package/studio-plugin/MCPPlugin.rbxmx +627 -367
package/dist/index.js
CHANGED
|
@@ -1951,59 +1951,83 @@ var RoutingFailure = class extends Error {
|
|
|
1951
1951
|
this.routingError = routingError;
|
|
1952
1952
|
}
|
|
1953
1953
|
};
|
|
1954
|
-
function
|
|
1954
|
+
function toPublicPeer(peer) {
|
|
1955
1955
|
return {
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1956
|
+
peerId: peer.peerId,
|
|
1957
|
+
instanceId: peer.instanceId,
|
|
1958
|
+
multiplayerGroupId: peer.multiplayerGroupId,
|
|
1959
|
+
role: peer.role,
|
|
1960
|
+
placeId: peer.placeId,
|
|
1961
|
+
placeName: peer.placeName,
|
|
1962
|
+
placeKey: peer.placeKey,
|
|
1963
|
+
dataModelName: peer.dataModelName,
|
|
1964
|
+
isRunning: peer.isRunning,
|
|
1965
|
+
pluginVersion: peer.pluginVersion,
|
|
1966
|
+
pluginVariant: peer.pluginVariant,
|
|
1967
|
+
serverVersion: peer.serverVersion,
|
|
1968
|
+
lastActivity: peer.lastActivity,
|
|
1969
|
+
connectedAt: peer.connectedAt
|
|
1967
1970
|
};
|
|
1968
1971
|
}
|
|
1969
|
-
var
|
|
1970
|
-
var DUPLICATE_TAKEOVER_MS = 3e3;
|
|
1971
|
-
var INSTANCE_ALIAS_TTL_MS = 5 * 60 * 1e3;
|
|
1972
|
+
var STALE_PEER_MS = 3e4;
|
|
1972
1973
|
var ACCEPTED_REQUEST_TOMBSTONE_TTL_MS = 6e4;
|
|
1973
1974
|
var MAX_ACCEPTED_REQUEST_TOMBSTONES = 4096;
|
|
1974
1975
|
var CANCELLATION_TOMBSTONE_TTL_MS = 6e4;
|
|
1975
1976
|
var MAX_CANCELLATION_TOMBSTONES = 4096;
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1977
|
+
var MAX_OUTSTANDING_REQUESTS_PER_DELIVERY_OWNER = 4;
|
|
1978
|
+
function roleOrder(role) {
|
|
1979
|
+
if (role === "edit")
|
|
1980
|
+
return 0;
|
|
1981
|
+
if (role === "server")
|
|
1982
|
+
return 1;
|
|
1983
|
+
const client = /^client-(\d+)$/.exec(role);
|
|
1984
|
+
return client ? 2 + Number(client[1]) : Number.MAX_SAFE_INTEGER;
|
|
1985
|
+
}
|
|
1986
|
+
function isRuntimeRole(role) {
|
|
1987
|
+
return role === "server" || /^client-\d+$/.test(role);
|
|
1988
|
+
}
|
|
1989
|
+
function connectedRuntimeInstanceId(peer) {
|
|
1990
|
+
return `${peer.instanceId}-${peer.role}`;
|
|
1991
|
+
}
|
|
1992
|
+
function peerIdsByRole(peers) {
|
|
1993
|
+
return Object.fromEntries([...peers].sort((left, right) => roleOrder(left.role) - roleOrder(right.role) || left.peerId.localeCompare(right.peerId)).map((peer) => [peer.role, peer.peerId]));
|
|
1994
|
+
}
|
|
1995
|
+
function preferredPeer(peers) {
|
|
1996
|
+
return peers.reduce((preferred, candidate) => {
|
|
1997
|
+
const difference = roleOrder(candidate.role) - roleOrder(preferred.role);
|
|
1998
|
+
if (difference !== 0)
|
|
1999
|
+
return difference < 0 ? candidate : preferred;
|
|
2000
|
+
return candidate.connectedAt < preferred.connectedAt ? candidate : preferred;
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
function copyGroup(group) {
|
|
2004
|
+
return { ...group, instanceIds: [...group.instanceIds] };
|
|
1980
2005
|
}
|
|
1981
2006
|
var BridgeService = class {
|
|
1982
2007
|
pendingRequests = /* @__PURE__ */ new Map();
|
|
1983
2008
|
acceptedRequestIds = /* @__PURE__ */ new Map();
|
|
1984
2009
|
pendingCancellations = /* @__PURE__ */ new Map();
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
instanceRegisteredListeners = /* @__PURE__ */ new Set();
|
|
2010
|
+
peersById = /* @__PURE__ */ new Map();
|
|
2011
|
+
multiplayerGroupsById = /* @__PURE__ */ new Map();
|
|
2012
|
+
peerRegisteredListeners = /* @__PURE__ */ new Set();
|
|
1989
2013
|
requestAvailableListeners = /* @__PURE__ */ new Set();
|
|
1990
|
-
|
|
1991
|
-
|
|
2014
|
+
peerClosedListeners = /* @__PURE__ */ new Set();
|
|
2015
|
+
deliveryOwnersByTransportPeer = /* @__PURE__ */ new Map();
|
|
1992
2016
|
requestTimeout = 3e4;
|
|
1993
|
-
|
|
1994
|
-
this.
|
|
1995
|
-
for (const
|
|
2017
|
+
onPeerRegistered(listener) {
|
|
2018
|
+
this.peerRegisteredListeners.add(listener);
|
|
2019
|
+
for (const peer of this.getPublicPeers()) {
|
|
1996
2020
|
try {
|
|
1997
|
-
listener(
|
|
2021
|
+
listener(peer);
|
|
1998
2022
|
} catch {
|
|
1999
2023
|
}
|
|
2000
2024
|
}
|
|
2001
|
-
return () => this.
|
|
2025
|
+
return () => this.peerRegisteredListeners.delete(listener);
|
|
2002
2026
|
}
|
|
2003
|
-
|
|
2004
|
-
for (const listener of this.
|
|
2027
|
+
notifyPeerRegistered(peer) {
|
|
2028
|
+
for (const listener of this.peerRegisteredListeners) {
|
|
2005
2029
|
try {
|
|
2006
|
-
listener(
|
|
2030
|
+
listener(peer);
|
|
2007
2031
|
} catch {
|
|
2008
2032
|
}
|
|
2009
2033
|
}
|
|
@@ -2012,16 +2036,16 @@ var BridgeService = class {
|
|
|
2012
2036
|
this.requestAvailableListeners.add(listener);
|
|
2013
2037
|
return () => this.requestAvailableListeners.delete(listener);
|
|
2014
2038
|
}
|
|
2015
|
-
|
|
2016
|
-
this.
|
|
2017
|
-
return () => this.
|
|
2039
|
+
onPeerClosed(listener) {
|
|
2040
|
+
this.peerClosedListeners.add(listener);
|
|
2041
|
+
return () => this.peerClosedListeners.delete(listener);
|
|
2018
2042
|
}
|
|
2019
|
-
setDeliveryActive(
|
|
2020
|
-
let owners = this.
|
|
2043
|
+
setDeliveryActive(transportPeerId, owner, active) {
|
|
2044
|
+
let owners = this.deliveryOwnersByTransportPeer.get(transportPeerId);
|
|
2021
2045
|
if (active) {
|
|
2022
2046
|
if (!owners) {
|
|
2023
2047
|
owners = /* @__PURE__ */ new Set();
|
|
2024
|
-
this.
|
|
2048
|
+
this.deliveryOwnersByTransportPeer.set(transportPeerId, owners);
|
|
2025
2049
|
}
|
|
2026
2050
|
owners.add(owner);
|
|
2027
2051
|
return;
|
|
@@ -2030,422 +2054,534 @@ var BridgeService = class {
|
|
|
2030
2054
|
return;
|
|
2031
2055
|
owners.delete(owner);
|
|
2032
2056
|
if (owners.size === 0)
|
|
2033
|
-
this.
|
|
2057
|
+
this.deliveryOwnersByTransportPeer.delete(transportPeerId);
|
|
2034
2058
|
}
|
|
2035
|
-
notifyRequestAvailable(
|
|
2059
|
+
notifyRequestAvailable(transportPeerId) {
|
|
2036
2060
|
for (const listener of this.requestAvailableListeners) {
|
|
2037
2061
|
try {
|
|
2038
|
-
listener(
|
|
2062
|
+
listener(transportPeerId);
|
|
2039
2063
|
} catch {
|
|
2040
2064
|
}
|
|
2041
2065
|
}
|
|
2042
2066
|
}
|
|
2043
2067
|
notifyRequestCancelled(request, reason) {
|
|
2044
|
-
const
|
|
2045
|
-
if (!
|
|
2068
|
+
const transportPeerId = request.lastDeliveryTransportPeerId;
|
|
2069
|
+
if (!transportPeerId || this.pendingCancellations.has(request.id))
|
|
2046
2070
|
return;
|
|
2047
2071
|
const now = Date.now();
|
|
2048
2072
|
this.prunePendingCancellations(now);
|
|
2049
2073
|
this.pendingCancellations.set(request.id, {
|
|
2050
2074
|
requestId: request.id,
|
|
2051
2075
|
reason,
|
|
2052
|
-
|
|
2076
|
+
transportPeerId,
|
|
2053
2077
|
createdAt: now
|
|
2054
2078
|
});
|
|
2055
2079
|
this.prunePendingCancellations(now);
|
|
2056
|
-
this.notifyRequestAvailable(
|
|
2080
|
+
this.notifyRequestAvailable(transportPeerId);
|
|
2057
2081
|
}
|
|
2058
|
-
|
|
2059
|
-
const
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
physicalSessionIds.add(instance.physicalSessionId);
|
|
2063
|
-
}
|
|
2082
|
+
groupIdForInstance(instanceId) {
|
|
2083
|
+
for (const group of this.getMultiplayerGroups()) {
|
|
2084
|
+
if (group.instanceIds.includes(instanceId))
|
|
2085
|
+
return group.id;
|
|
2064
2086
|
}
|
|
2065
|
-
return
|
|
2087
|
+
return void 0;
|
|
2066
2088
|
}
|
|
2067
|
-
|
|
2068
|
-
return
|
|
2089
|
+
peerScopeKey(peer) {
|
|
2090
|
+
return peer.multiplayerGroupId ? `group:${peer.multiplayerGroupId}` : `instance:${peer.instanceId}`;
|
|
2069
2091
|
}
|
|
2070
|
-
|
|
2071
|
-
if (
|
|
2072
|
-
return;
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
lastSeen: Date.now()
|
|
2076
|
-
});
|
|
2092
|
+
registrationScopePeers(instanceId, multiplayerGroupId) {
|
|
2093
|
+
if (multiplayerGroupId) {
|
|
2094
|
+
return this.getPeers().filter((peer) => peer.multiplayerGroupId === multiplayerGroupId);
|
|
2095
|
+
}
|
|
2096
|
+
return this.getPeers().filter((peer) => peer.instanceId === instanceId && peer.multiplayerGroupId === void 0);
|
|
2077
2097
|
}
|
|
2078
|
-
|
|
2079
|
-
const
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2098
|
+
detachInstanceFromOtherGroups(instanceId, retainedGroupId) {
|
|
2099
|
+
for (const group of this.multiplayerGroupsById.values()) {
|
|
2100
|
+
if (group.id === retainedGroupId || !group.instanceIds.includes(instanceId))
|
|
2101
|
+
continue;
|
|
2102
|
+
group.instanceIds = group.instanceIds.filter((id) => id !== instanceId);
|
|
2103
|
+
if (group.controllerInstanceId === instanceId)
|
|
2104
|
+
group.controllerInstanceId = void 0;
|
|
2105
|
+
if (group.instanceIds.length === 0)
|
|
2106
|
+
this.multiplayerGroupsById.delete(group.id);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
groupAttachmentConflict(instanceId, groupId) {
|
|
2110
|
+
const incomingRoles = new Set(this.getPeers().filter((peer) => peer.instanceId === instanceId).map((peer) => peer.role));
|
|
2111
|
+
return this.getPeers().find((peer) => peer.instanceId !== instanceId && peer.multiplayerGroupId === groupId && incomingRoles.has(peer.role));
|
|
2112
|
+
}
|
|
2113
|
+
attachInstanceToGroup(instanceId, groupId) {
|
|
2114
|
+
const conflict = this.groupAttachmentConflict(instanceId, groupId);
|
|
2115
|
+
if (conflict) {
|
|
2116
|
+
throw new Error(`Cannot attach Instance "${instanceId}" to Multiplayer Group "${groupId}": role "${conflict.role}" is already owned by Peer "${conflict.peerId}".`);
|
|
2117
|
+
}
|
|
2118
|
+
this.detachInstanceFromOtherGroups(instanceId, groupId);
|
|
2119
|
+
let group = this.multiplayerGroupsById.get(groupId);
|
|
2120
|
+
if (!group) {
|
|
2121
|
+
group = { id: groupId, instanceIds: [], createdAt: Date.now() };
|
|
2122
|
+
this.multiplayerGroupsById.set(groupId, group);
|
|
2123
|
+
}
|
|
2124
|
+
if (!group.instanceIds.includes(instanceId))
|
|
2125
|
+
group.instanceIds.push(instanceId);
|
|
2126
|
+
for (const peer of this.peersById.values()) {
|
|
2127
|
+
if (peer.instanceId === instanceId)
|
|
2128
|
+
peer.multiplayerGroupId = groupId;
|
|
2129
|
+
}
|
|
2130
|
+
return group;
|
|
2131
|
+
}
|
|
2132
|
+
createMultiplayerGroup(groupId, controllerInstanceId) {
|
|
2133
|
+
const group = this.attachInstanceToGroup(controllerInstanceId, groupId);
|
|
2134
|
+
group.controllerInstanceId = controllerInstanceId;
|
|
2135
|
+
return copyGroup(group);
|
|
2136
|
+
}
|
|
2137
|
+
async createMultiplayerGroupEverywhere(groupId, controllerInstanceId) {
|
|
2138
|
+
return this.createMultiplayerGroup(groupId, controllerInstanceId);
|
|
2139
|
+
}
|
|
2140
|
+
removeMultiplayerGroup(groupId) {
|
|
2141
|
+
const group = this.multiplayerGroupsById.get(groupId);
|
|
2142
|
+
if (!group)
|
|
2143
|
+
return void 0;
|
|
2144
|
+
this.multiplayerGroupsById.delete(groupId);
|
|
2145
|
+
for (const peer of this.peersById.values()) {
|
|
2146
|
+
if (peer.multiplayerGroupId === groupId)
|
|
2147
|
+
peer.multiplayerGroupId = void 0;
|
|
2148
|
+
}
|
|
2149
|
+
return copyGroup(group);
|
|
2150
|
+
}
|
|
2151
|
+
async removeMultiplayerGroupEverywhere(groupId) {
|
|
2152
|
+
return this.removeMultiplayerGroup(groupId);
|
|
2153
|
+
}
|
|
2154
|
+
connectedInstanceIdCollision(peerId, instanceId, role, multiplayerGroupId) {
|
|
2155
|
+
const peers = this.getPeers().filter((peer) => peer.peerId !== peerId);
|
|
2156
|
+
const canonicalCollision = peers.find((peer) => peer.multiplayerGroupId !== void 0 && isRuntimeRole(peer.role) && connectedRuntimeInstanceId(peer) === instanceId);
|
|
2157
|
+
if (canonicalCollision)
|
|
2158
|
+
return canonicalCollision;
|
|
2159
|
+
if (multiplayerGroupId === void 0 || !isRuntimeRole(role))
|
|
2160
|
+
return void 0;
|
|
2161
|
+
const runtimeAlias = `${instanceId}-${role}`;
|
|
2162
|
+
return peers.find((peer) => peer.instanceId === runtimeAlias);
|
|
2084
2163
|
}
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2164
|
+
registerPeer(input) {
|
|
2165
|
+
const prior = this.peersById.get(input.peerId);
|
|
2166
|
+
if (prior && (prior.instanceId !== input.instanceId || prior.transportPeerId !== input.transportPeerId)) {
|
|
2167
|
+
return {
|
|
2168
|
+
ok: false,
|
|
2169
|
+
error: {
|
|
2170
|
+
code: "peer_identity_mismatch",
|
|
2171
|
+
message: `Peer "${input.peerId}" is already registered to Instance "${prior.instanceId}" through transport Peer "${prior.transportPeerId}".`,
|
|
2172
|
+
existing: toPublicPeer(prior)
|
|
2173
|
+
}
|
|
2174
|
+
};
|
|
2092
2175
|
}
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2176
|
+
const multiplayerGroupId = input.multiplayerGroupId ?? this.groupIdForInstance(input.instanceId) ?? prior?.multiplayerGroupId;
|
|
2177
|
+
const attachmentConflict = multiplayerGroupId ? this.groupAttachmentConflict(input.instanceId, multiplayerGroupId) : void 0;
|
|
2178
|
+
if (attachmentConflict) {
|
|
2179
|
+
return {
|
|
2180
|
+
ok: false,
|
|
2181
|
+
error: {
|
|
2182
|
+
code: "duplicate_scope_role",
|
|
2183
|
+
message: `Multiplayer Group "${multiplayerGroupId}" already has a Peer registered as "${attachmentConflict.role}".`,
|
|
2184
|
+
existing: toPublicPeer(attachmentConflict)
|
|
2185
|
+
}
|
|
2186
|
+
};
|
|
2100
2187
|
}
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
const
|
|
2111
|
-
if (
|
|
2112
|
-
return;
|
|
2113
|
-
ids.add(published);
|
|
2114
|
-
placeIds.add(Math.trunc(placeId));
|
|
2115
|
-
};
|
|
2116
|
-
const placeMatch = resolvedInstanceId.match(/^place:(\d+)$/) ?? instanceId.match(/^place:(\d+)$/);
|
|
2117
|
-
if (placeMatch)
|
|
2118
|
-
addPlaceId(Number(placeMatch[1]));
|
|
2119
|
-
for (const inst of this.getInstances()) {
|
|
2120
|
-
if (ids.has(inst.instanceId))
|
|
2121
|
-
addPlaceId(inst.placeId);
|
|
2122
|
-
}
|
|
2123
|
-
return this.getInstances().filter((inst) => ids.has(inst.instanceId) || inst.placeId > 0 && placeIds.has(Math.trunc(inst.placeId)));
|
|
2124
|
-
}
|
|
2125
|
-
resolveInstanceId(instanceId) {
|
|
2126
|
-
return this.resolveInstanceAlias(instanceId);
|
|
2127
|
-
}
|
|
2128
|
-
registerInstance(input) {
|
|
2129
|
-
const { pluginSessionId, role } = input;
|
|
2130
|
-
const rawInstanceId = input.instanceId;
|
|
2131
|
-
const instanceId = this.canonicalInstanceId(rawInstanceId, input.placeId);
|
|
2132
|
-
const prior = this.instances.get(pluginSessionId);
|
|
2133
|
-
let assignedRole = role;
|
|
2134
|
-
const pluginVersion = input.pluginVersion ?? "";
|
|
2135
|
-
const pluginVariant = input.pluginVariant ?? "unknown";
|
|
2136
|
-
const serverVersion = input.serverVersion ?? "";
|
|
2137
|
-
this.rememberInstanceAlias(rawInstanceId, instanceId);
|
|
2138
|
-
if (prior && prior.instanceId !== instanceId) {
|
|
2139
|
-
this.rememberInstanceAlias(prior.instanceId, instanceId);
|
|
2140
|
-
this.migratePendingRequests(prior.instanceId, instanceId);
|
|
2141
|
-
}
|
|
2142
|
-
if (role === "client") {
|
|
2143
|
-
if (prior && prior.role.match(/^client-\d+$/)) {
|
|
2188
|
+
const scopePeers = this.registrationScopePeers(input.instanceId, multiplayerGroupId).filter((peer) => peer.peerId !== input.peerId);
|
|
2189
|
+
let assignedRole = input.role;
|
|
2190
|
+
if (input.role === "client") {
|
|
2191
|
+
const used = /* @__PURE__ */ new Set();
|
|
2192
|
+
for (const peer of scopePeers) {
|
|
2193
|
+
const match = /^client-(\d+)$/.exec(peer.role);
|
|
2194
|
+
if (match)
|
|
2195
|
+
used.add(Number(match[1]));
|
|
2196
|
+
}
|
|
2197
|
+
const priorOrdinal = prior ? /^client-(\d+)$/.exec(prior.role) : null;
|
|
2198
|
+
if (prior && priorOrdinal && !used.has(Number(priorOrdinal[1]))) {
|
|
2144
2199
|
assignedRole = prior.role;
|
|
2145
2200
|
} else {
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
const match = inst.role.match(/^client-(\d+)$/);
|
|
2151
|
-
if (match)
|
|
2152
|
-
used.add(Number(match[1]));
|
|
2153
|
-
}
|
|
2154
|
-
let idx = 1;
|
|
2155
|
-
while (used.has(idx))
|
|
2156
|
-
idx++;
|
|
2157
|
-
assignedRole = `client-${idx}`;
|
|
2201
|
+
let ordinal = 1;
|
|
2202
|
+
while (used.has(ordinal))
|
|
2203
|
+
ordinal += 1;
|
|
2204
|
+
assignedRole = `client-${ordinal}`;
|
|
2158
2205
|
}
|
|
2159
2206
|
}
|
|
2160
|
-
const
|
|
2207
|
+
const instanceIdCollision = this.connectedInstanceIdCollision(input.peerId, input.instanceId, assignedRole, multiplayerGroupId);
|
|
2208
|
+
if (instanceIdCollision) {
|
|
2209
|
+
return {
|
|
2210
|
+
ok: false,
|
|
2211
|
+
error: {
|
|
2212
|
+
code: "instance_id_alias_collision",
|
|
2213
|
+
message: `Instance "${input.instanceId}" would make a grouped runtime Instance ID ambiguous.`,
|
|
2214
|
+
existing: toPublicPeer(instanceIdCollision)
|
|
2215
|
+
}
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
const existing = scopePeers.find((peer) => peer.role === assignedRole);
|
|
2161
2219
|
if (existing) {
|
|
2162
|
-
const
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
existing: toPublic(existing)
|
|
2172
|
-
}
|
|
2173
|
-
};
|
|
2174
|
-
}
|
|
2220
|
+
const scopeDescription = multiplayerGroupId ? `Multiplayer Group "${multiplayerGroupId}"` : `Instance "${input.instanceId}"`;
|
|
2221
|
+
return {
|
|
2222
|
+
ok: false,
|
|
2223
|
+
error: {
|
|
2224
|
+
code: "duplicate_scope_role",
|
|
2225
|
+
message: `${scopeDescription} already has a Peer registered as "${assignedRole}".`,
|
|
2226
|
+
existing: toPublicPeer(existing)
|
|
2227
|
+
}
|
|
2228
|
+
};
|
|
2175
2229
|
}
|
|
2230
|
+
const now = Date.now();
|
|
2176
2231
|
const registered = {
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
instanceId,
|
|
2232
|
+
peerId: input.peerId,
|
|
2233
|
+
transportPeerId: input.transportPeerId,
|
|
2234
|
+
instanceId: input.instanceId,
|
|
2235
|
+
multiplayerGroupId,
|
|
2180
2236
|
role: assignedRole,
|
|
2181
2237
|
placeId: input.placeId ?? 0,
|
|
2182
2238
|
placeName: input.placeName ?? "",
|
|
2239
|
+
placeKey: input.placeKey,
|
|
2183
2240
|
dataModelName: input.dataModelName ?? "",
|
|
2184
2241
|
isRunning: input.isRunning ?? false,
|
|
2185
|
-
pluginVersion,
|
|
2186
|
-
pluginVariant,
|
|
2187
|
-
serverVersion,
|
|
2188
|
-
lastActivity:
|
|
2189
|
-
connectedAt: prior?.connectedAt ??
|
|
2242
|
+
pluginVersion: input.pluginVersion ?? "",
|
|
2243
|
+
pluginVariant: input.pluginVariant ?? "unknown",
|
|
2244
|
+
serverVersion: input.serverVersion ?? "",
|
|
2245
|
+
lastActivity: now,
|
|
2246
|
+
connectedAt: prior?.connectedAt ?? now
|
|
2247
|
+
};
|
|
2248
|
+
this.peersById.set(input.peerId, registered);
|
|
2249
|
+
if (multiplayerGroupId)
|
|
2250
|
+
this.attachInstanceToGroup(input.instanceId, multiplayerGroupId);
|
|
2251
|
+
this.notifyPeerRegistered(toPublicPeer(registered));
|
|
2252
|
+
this.notifyRequestAvailable(registered.transportPeerId);
|
|
2253
|
+
return {
|
|
2254
|
+
ok: true,
|
|
2255
|
+
assignedRole,
|
|
2256
|
+
peerId: registered.peerId,
|
|
2257
|
+
instanceId: registered.instanceId,
|
|
2258
|
+
multiplayerGroupId: registered.multiplayerGroupId
|
|
2190
2259
|
};
|
|
2191
|
-
this.instances.set(pluginSessionId, registered);
|
|
2192
|
-
this.notifyInstanceRegistered(toPublic(registered));
|
|
2193
|
-
this.notifyRequestAvailable(registered.physicalSessionId);
|
|
2194
|
-
return { ok: true, assignedRole, instanceId };
|
|
2195
2260
|
}
|
|
2196
|
-
|
|
2197
|
-
|
|
2261
|
+
unregisterPeer(peerId) {
|
|
2262
|
+
this.unregisterPeerInternal(peerId, /* @__PURE__ */ new Set());
|
|
2263
|
+
}
|
|
2264
|
+
unregisterPeerInternal(peerId, visited, removedPeers) {
|
|
2265
|
+
if (visited.has(peerId))
|
|
2266
|
+
return;
|
|
2267
|
+
visited.add(peerId);
|
|
2268
|
+
const removed = this.peersById.get(peerId);
|
|
2198
2269
|
if (!removed)
|
|
2199
2270
|
return;
|
|
2200
|
-
|
|
2201
|
-
this.
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
};
|
|
2206
|
-
for (const listener of this.sessionClosedListeners) {
|
|
2271
|
+
removedPeers?.push(removed);
|
|
2272
|
+
const dependentPeerIds = removed.transportPeerId === peerId ? this.getPeers().filter((peer) => peer.peerId !== peerId && peer.transportPeerId === peerId).map((peer) => peer.peerId) : [];
|
|
2273
|
+
this.peersById.delete(peerId);
|
|
2274
|
+
const session = { peerId, transportPeerId: removed.transportPeerId };
|
|
2275
|
+
for (const listener of this.peerClosedListeners) {
|
|
2207
2276
|
try {
|
|
2208
2277
|
listener(session);
|
|
2209
2278
|
} catch {
|
|
2210
2279
|
}
|
|
2211
2280
|
}
|
|
2212
|
-
if (removed.
|
|
2213
|
-
this.
|
|
2281
|
+
if (removed.transportPeerId === peerId) {
|
|
2282
|
+
this.deliveryOwnersByTransportPeer.delete(peerId);
|
|
2214
2283
|
}
|
|
2215
|
-
for (const
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
const
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2284
|
+
for (const request of Array.from(this.pendingRequests.values())) {
|
|
2285
|
+
if (request.targetPeerId !== peerId)
|
|
2286
|
+
continue;
|
|
2287
|
+
const deliveryTransportPeerId = request.lastDeliveryTransportPeerId;
|
|
2288
|
+
this.removePendingRequest(request);
|
|
2289
|
+
request.reject(new Error(`Target Peer "${peerId}" disconnected`));
|
|
2290
|
+
if (deliveryTransportPeerId)
|
|
2291
|
+
this.notifyRequestAvailable(deliveryTransportPeerId);
|
|
2292
|
+
}
|
|
2293
|
+
for (const dependentPeerId of dependentPeerIds) {
|
|
2294
|
+
this.unregisterPeerInternal(dependentPeerId, visited, removedPeers);
|
|
2223
2295
|
}
|
|
2296
|
+
this.removeInstanceFromGroupsWhenDisconnected(removed.instanceId);
|
|
2297
|
+
}
|
|
2298
|
+
removeInstanceFromGroupsWhenDisconnected(instanceId) {
|
|
2299
|
+
if (this.getPeers().some((peer) => peer.instanceId === instanceId))
|
|
2300
|
+
return;
|
|
2301
|
+
this.detachInstanceFromOtherGroups(instanceId);
|
|
2224
2302
|
}
|
|
2225
2303
|
unregisterInstanceId(instanceId) {
|
|
2226
|
-
const matching = this.
|
|
2227
|
-
const
|
|
2228
|
-
|
|
2229
|
-
|
|
2304
|
+
const matching = this.getPeers().filter((peer) => peer.instanceId === instanceId);
|
|
2305
|
+
const departingRuntimeGroupIds = new Set(matching.flatMap((peer) => peer.multiplayerGroupId !== void 0 && isRuntimeRole(peer.role) ? [peer.multiplayerGroupId] : []));
|
|
2306
|
+
const removedPeers = [];
|
|
2307
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2308
|
+
for (const peer of matching) {
|
|
2309
|
+
this.unregisterPeerInternal(peer.peerId, visited, removedPeers);
|
|
2230
2310
|
}
|
|
2231
|
-
|
|
2311
|
+
this.detachInstanceFromOtherGroups(instanceId);
|
|
2312
|
+
for (const groupId of departingRuntimeGroupIds) {
|
|
2313
|
+
const hasRuntimePeer = this.getPeers().some((peer) => peer.multiplayerGroupId === groupId && isRuntimeRole(peer.role));
|
|
2314
|
+
if (!hasRuntimePeer)
|
|
2315
|
+
this.removeMultiplayerGroup(groupId);
|
|
2316
|
+
}
|
|
2317
|
+
return removedPeers.map(toPublicPeer);
|
|
2232
2318
|
}
|
|
2233
2319
|
async unregisterInstanceIdEverywhere(instanceId) {
|
|
2234
2320
|
return this.unregisterInstanceId(instanceId);
|
|
2235
2321
|
}
|
|
2322
|
+
getPeers() {
|
|
2323
|
+
return Array.from(this.peersById.values());
|
|
2324
|
+
}
|
|
2325
|
+
getPublicPeers() {
|
|
2326
|
+
return this.getPeers().map(toPublicPeer);
|
|
2327
|
+
}
|
|
2328
|
+
getPeerById(peerId) {
|
|
2329
|
+
return this.getPeers().find((peer) => peer.peerId === peerId);
|
|
2330
|
+
}
|
|
2236
2331
|
getInstances() {
|
|
2237
|
-
|
|
2332
|
+
const peersByInstance = /* @__PURE__ */ new Map();
|
|
2333
|
+
for (const peer of this.getPeers()) {
|
|
2334
|
+
const peers = peersByInstance.get(peer.instanceId);
|
|
2335
|
+
if (peers)
|
|
2336
|
+
peers.push(peer);
|
|
2337
|
+
else
|
|
2338
|
+
peersByInstance.set(peer.instanceId, [peer]);
|
|
2339
|
+
}
|
|
2340
|
+
return Array.from(peersByInstance, ([id, peers]) => {
|
|
2341
|
+
const preferred = preferredPeer(peers);
|
|
2342
|
+
return {
|
|
2343
|
+
id,
|
|
2344
|
+
multiplayerGroupId: this.groupIdForInstance(id) ?? preferred.multiplayerGroupId,
|
|
2345
|
+
placeId: preferred.placeId,
|
|
2346
|
+
placeName: preferred.placeName,
|
|
2347
|
+
peers
|
|
2348
|
+
};
|
|
2349
|
+
});
|
|
2238
2350
|
}
|
|
2239
2351
|
getPublicInstances() {
|
|
2240
|
-
return this.getInstances().map(
|
|
2352
|
+
return this.getInstances().map((instance) => ({
|
|
2353
|
+
id: instance.id,
|
|
2354
|
+
multiplayerGroupId: instance.multiplayerGroupId,
|
|
2355
|
+
placeId: instance.placeId,
|
|
2356
|
+
placeName: instance.placeName,
|
|
2357
|
+
peers: instance.peers.map(toPublicPeer)
|
|
2358
|
+
}));
|
|
2359
|
+
}
|
|
2360
|
+
getConnectedInstances() {
|
|
2361
|
+
return this.getInstances().flatMap((instance) => {
|
|
2362
|
+
const peers = instance.multiplayerGroupId === void 0 ? instance.peers : instance.peers.filter((peer) => !isRuntimeRole(peer.role));
|
|
2363
|
+
if (peers.length === 0)
|
|
2364
|
+
return [];
|
|
2365
|
+
return [{
|
|
2366
|
+
id: instance.id,
|
|
2367
|
+
multiplayerGroupId: instance.multiplayerGroupId,
|
|
2368
|
+
placeId: instance.placeId,
|
|
2369
|
+
placeName: instance.placeName,
|
|
2370
|
+
peers: peerIdsByRole(peers)
|
|
2371
|
+
}];
|
|
2372
|
+
});
|
|
2373
|
+
}
|
|
2374
|
+
getConnectedMultiplayerGroups() {
|
|
2375
|
+
const peers = this.getPeers();
|
|
2376
|
+
return this.getMultiplayerGroups().map((group) => ({
|
|
2377
|
+
id: group.id,
|
|
2378
|
+
controllerInstanceId: group.controllerInstanceId,
|
|
2379
|
+
instances: Object.fromEntries(peers.filter((peer) => peer.multiplayerGroupId === group.id && isRuntimeRole(peer.role)).sort((left, right) => roleOrder(left.role) - roleOrder(right.role) || left.peerId.localeCompare(right.peerId)).map((peer) => [connectedRuntimeInstanceId(peer), peer.peerId]))
|
|
2380
|
+
}));
|
|
2241
2381
|
}
|
|
2242
|
-
|
|
2243
|
-
return this.
|
|
2382
|
+
getMultiplayerGroups() {
|
|
2383
|
+
return Array.from(this.multiplayerGroupsById.values(), copyGroup);
|
|
2384
|
+
}
|
|
2385
|
+
getPublicMultiplayerGroups() {
|
|
2386
|
+
return this.getMultiplayerGroups().map(copyGroup);
|
|
2387
|
+
}
|
|
2388
|
+
getTopologySnapshot() {
|
|
2389
|
+
return {
|
|
2390
|
+
peers: this.getPeers(),
|
|
2391
|
+
instances: this.getInstances(),
|
|
2392
|
+
multiplayerGroups: this.getMultiplayerGroups()
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
resolveConnectedInstanceId(instanceId) {
|
|
2396
|
+
const exact = this.getInstances().find((instance) => instance.id === instanceId);
|
|
2397
|
+
const groupedRuntime = this.getPeers().find((peer) => peer.multiplayerGroupId !== void 0 && isRuntimeRole(peer.role) && connectedRuntimeInstanceId(peer) === instanceId);
|
|
2398
|
+
if (exact && groupedRuntime && exact.id !== groupedRuntime.instanceId)
|
|
2399
|
+
return void 0;
|
|
2400
|
+
return exact?.id ?? groupedRuntime?.instanceId;
|
|
2401
|
+
}
|
|
2402
|
+
getInstanceIdsInScope(instanceId) {
|
|
2403
|
+
const resolvedInstanceId = this.resolveConnectedInstanceId(instanceId);
|
|
2404
|
+
if (resolvedInstanceId === void 0)
|
|
2405
|
+
return [];
|
|
2406
|
+
const groupId = this.groupIdForInstance(resolvedInstanceId);
|
|
2407
|
+
if (groupId) {
|
|
2408
|
+
return [...this.getMultiplayerGroups().find((group) => group.id === groupId)?.instanceIds ?? []];
|
|
2409
|
+
}
|
|
2410
|
+
return [resolvedInstanceId];
|
|
2411
|
+
}
|
|
2412
|
+
getPeersInScope(instanceId) {
|
|
2413
|
+
const instanceIds = new Set(this.getInstanceIdsInScope(instanceId));
|
|
2414
|
+
return this.getPeers().filter((peer) => instanceIds.has(peer.instanceId));
|
|
2244
2415
|
}
|
|
2245
2416
|
getPendingRequestCount() {
|
|
2246
2417
|
return this.pendingRequests.size;
|
|
2247
2418
|
}
|
|
2248
|
-
|
|
2249
|
-
const
|
|
2250
|
-
if (!
|
|
2419
|
+
updatePeerActivity(peerId) {
|
|
2420
|
+
const peer = this.getPeerById(peerId);
|
|
2421
|
+
if (!peer)
|
|
2251
2422
|
return;
|
|
2252
2423
|
const now = Date.now();
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
candidate.
|
|
2424
|
+
if (peer.transportPeerId === peerId) {
|
|
2425
|
+
for (const candidate of this.getPeers()) {
|
|
2426
|
+
if (candidate.transportPeerId === peerId)
|
|
2427
|
+
candidate.lastActivity = now;
|
|
2256
2428
|
}
|
|
2429
|
+
return;
|
|
2257
2430
|
}
|
|
2431
|
+
peer.lastActivity = now;
|
|
2258
2432
|
}
|
|
2259
|
-
|
|
2260
|
-
const
|
|
2261
|
-
if (!
|
|
2433
|
+
updatePeerMetadata(peerId, metadata) {
|
|
2434
|
+
const peer = this.getPeerById(peerId);
|
|
2435
|
+
if (!peer)
|
|
2262
2436
|
return;
|
|
2263
|
-
const priorInstanceId = inst.instanceId;
|
|
2264
2437
|
if (metadata.placeId !== void 0)
|
|
2265
|
-
|
|
2438
|
+
peer.placeId = metadata.placeId;
|
|
2266
2439
|
if (metadata.placeName !== void 0)
|
|
2267
|
-
|
|
2440
|
+
peer.placeName = metadata.placeName;
|
|
2441
|
+
if (metadata.placeKey !== void 0)
|
|
2442
|
+
peer.placeKey = metadata.placeKey;
|
|
2268
2443
|
if (metadata.dataModelName !== void 0)
|
|
2269
|
-
|
|
2444
|
+
peer.dataModelName = metadata.dataModelName;
|
|
2270
2445
|
if (metadata.isRunning !== void 0)
|
|
2271
|
-
|
|
2272
|
-
const canonicalInstanceId = this.canonicalInstanceId(inst.instanceId, inst.placeId);
|
|
2273
|
-
if (canonicalInstanceId !== inst.instanceId) {
|
|
2274
|
-
const duplicate = Array.from(this.instances.values()).find((other) => other.pluginSessionId !== pluginSessionId && other.instanceId === canonicalInstanceId && other.role === inst.role);
|
|
2275
|
-
if (!duplicate) {
|
|
2276
|
-
this.rememberInstanceAlias(priorInstanceId, canonicalInstanceId);
|
|
2277
|
-
this.migratePendingRequests(priorInstanceId, canonicalInstanceId);
|
|
2278
|
-
inst.instanceId = canonicalInstanceId;
|
|
2279
|
-
this.notifyRequestAvailable(inst.physicalSessionId);
|
|
2280
|
-
}
|
|
2281
|
-
}
|
|
2446
|
+
peer.isRunning = metadata.isRunning;
|
|
2282
2447
|
}
|
|
2283
|
-
|
|
2448
|
+
cleanupStalePeers() {
|
|
2284
2449
|
const now = Date.now();
|
|
2285
|
-
for (const
|
|
2286
|
-
const deliveryActive = (this.
|
|
2287
|
-
if (!deliveryActive && now -
|
|
2288
|
-
this.
|
|
2450
|
+
for (const peer of this.getPeers()) {
|
|
2451
|
+
const deliveryActive = (this.deliveryOwnersByTransportPeer.get(peer.transportPeerId)?.size ?? 0) > 0;
|
|
2452
|
+
if (!deliveryActive && now - peer.lastActivity > STALE_PEER_MS) {
|
|
2453
|
+
this.unregisterPeer(peer.peerId);
|
|
2289
2454
|
}
|
|
2290
2455
|
}
|
|
2291
|
-
this.cleanupStaleAliases(now);
|
|
2292
2456
|
}
|
|
2293
|
-
|
|
2294
|
-
const
|
|
2295
|
-
const
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
return;
|
|
2301
|
-
ids.add(published);
|
|
2302
|
-
placeIds.add(Math.trunc(placeId));
|
|
2457
|
+
routingErrorData() {
|
|
2458
|
+
const instances = this.getConnectedInstances();
|
|
2459
|
+
const multiplayerGroups = this.getConnectedMultiplayerGroups();
|
|
2460
|
+
return {
|
|
2461
|
+
instances,
|
|
2462
|
+
multiplayerGroups,
|
|
2463
|
+
count: instances.length + multiplayerGroups.length
|
|
2303
2464
|
};
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2465
|
+
}
|
|
2466
|
+
resolvedTarget(peer) {
|
|
2467
|
+
return {
|
|
2468
|
+
targetPeerId: peer.peerId,
|
|
2469
|
+
targetInstanceId: peer.instanceId,
|
|
2470
|
+
targetRole: peer.role
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
resolveWithinScope(peers, target, errorData, selectedInstanceId) {
|
|
2474
|
+
if (target === "all") {
|
|
2475
|
+
return { ok: true, mode: "fanout", targets: peers.map((peer) => this.resolvedTarget(peer)) };
|
|
2311
2476
|
}
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2477
|
+
if (target) {
|
|
2478
|
+
const exact = peers.find((peer) => peer.role === target);
|
|
2479
|
+
if (!exact) {
|
|
2480
|
+
return {
|
|
2481
|
+
ok: false,
|
|
2482
|
+
error: {
|
|
2483
|
+
code: "target_role_not_present_on_instance",
|
|
2484
|
+
message: `${selectedInstanceId ? `Instance "${selectedInstanceId}" scope` : "The connected scope"} has no role "${target}". Available roles: ${peers.map((peer) => peer.role).join(", ")}.`,
|
|
2485
|
+
data: errorData
|
|
2486
|
+
}
|
|
2487
|
+
};
|
|
2315
2488
|
}
|
|
2489
|
+
return { ok: true, mode: "single", ...this.resolvedTarget(exact) };
|
|
2316
2490
|
}
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2491
|
+
const edit = peers.find((peer) => peer.role === "edit");
|
|
2492
|
+
if (edit)
|
|
2493
|
+
return { ok: true, mode: "single", ...this.resolvedTarget(edit) };
|
|
2494
|
+
if (peers.length === 1) {
|
|
2495
|
+
return { ok: true, mode: "single", ...this.resolvedTarget(peers[0]) };
|
|
2320
2496
|
}
|
|
2321
|
-
return
|
|
2497
|
+
return {
|
|
2498
|
+
ok: false,
|
|
2499
|
+
error: {
|
|
2500
|
+
code: "target_role_required",
|
|
2501
|
+
message: `${selectedInstanceId ? `Instance "${selectedInstanceId}" scope` : "The connected scope"} has multiple roles connected: ${peers.map((peer) => peer.role).join(", ")}. Pass target=<role>.`,
|
|
2502
|
+
data: errorData
|
|
2503
|
+
}
|
|
2504
|
+
};
|
|
2322
2505
|
}
|
|
2323
|
-
// Resolves (instance_id, target-role) MCP arguments to a concrete
|
|
2324
|
-
// routing decision: either a single (instanceId, role) tuple or a fanout
|
|
2325
|
-
// list. Returns an error result with the full instance list embedded so
|
|
2326
|
-
// the caller (tool layer) can surface it without a second round-trip.
|
|
2327
2506
|
resolveTarget(input) {
|
|
2328
|
-
const
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
const isFanout = target === "all";
|
|
2333
|
-
const role = target && target !== "all" ? target : void 0;
|
|
2334
|
-
if (instance_id !== void 0) {
|
|
2335
|
-
const matchingInstances = this.matchingInstancesForInstanceId(instance_id);
|
|
2336
|
-
if (matchingInstances.length === 0) {
|
|
2507
|
+
const errorData = this.routingErrorData();
|
|
2508
|
+
if (input.instance_id !== void 0) {
|
|
2509
|
+
const peers2 = this.getPeersInScope(input.instance_id);
|
|
2510
|
+
if (peers2.length === 0) {
|
|
2337
2511
|
return {
|
|
2338
2512
|
ok: false,
|
|
2339
2513
|
error: {
|
|
2340
2514
|
code: "unrecognized_instance_id",
|
|
2341
|
-
message: `instance_id "${instance_id}" is not connected. Pass
|
|
2515
|
+
message: `instance_id "${input.instance_id}" is not connected. Pass a connected top-level or grouped role-suffixed Instance ID.`,
|
|
2342
2516
|
data: errorData
|
|
2343
2517
|
}
|
|
2344
2518
|
};
|
|
2345
2519
|
}
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
targets: matchingInstances.map((i) => ({
|
|
2351
|
-
targetInstanceId: i.instanceId,
|
|
2352
|
-
targetRole: i.role
|
|
2353
|
-
}))
|
|
2354
|
-
};
|
|
2355
|
-
}
|
|
2356
|
-
if (role) {
|
|
2357
|
-
const exact = matchingInstances.find((i) => i.role === role);
|
|
2358
|
-
if (!exact) {
|
|
2359
|
-
return {
|
|
2360
|
-
ok: false,
|
|
2361
|
-
error: {
|
|
2362
|
-
code: "target_role_not_present_on_instance",
|
|
2363
|
-
message: `instance "${instance_id}" has no role "${role}". Available roles: ${matchingInstances.map((i) => i.role).join(", ")}.`,
|
|
2364
|
-
data: errorData
|
|
2365
|
-
}
|
|
2366
|
-
};
|
|
2367
|
-
}
|
|
2368
|
-
return { ok: true, mode: "single", targetInstanceId: exact.instanceId, targetRole: role };
|
|
2369
|
-
}
|
|
2370
|
-
if (matchingInstances.length === 1) {
|
|
2371
|
-
return {
|
|
2372
|
-
ok: true,
|
|
2373
|
-
mode: "single",
|
|
2374
|
-
targetInstanceId: matchingInstances[0].instanceId,
|
|
2375
|
-
targetRole: matchingInstances[0].role
|
|
2376
|
-
};
|
|
2377
|
-
}
|
|
2378
|
-
const edit = matchingInstances.find((i) => i.role === "edit");
|
|
2379
|
-
if (edit) {
|
|
2380
|
-
return { ok: true, mode: "single", targetInstanceId: edit.instanceId, targetRole: "edit" };
|
|
2381
|
-
}
|
|
2520
|
+
return this.resolveWithinScope(peers2, input.target, errorData, input.instance_id);
|
|
2521
|
+
}
|
|
2522
|
+
const scopeKeys = new Set(this.getPeers().map((peer) => this.peerScopeKey(peer)));
|
|
2523
|
+
if (scopeKeys.size === 0) {
|
|
2382
2524
|
return {
|
|
2383
2525
|
ok: false,
|
|
2384
2526
|
error: {
|
|
2385
|
-
code: "
|
|
2386
|
-
message:
|
|
2527
|
+
code: "unrecognized_instance_id",
|
|
2528
|
+
message: "No Studio Peer is connected.",
|
|
2387
2529
|
data: errorData
|
|
2388
2530
|
}
|
|
2389
2531
|
};
|
|
2390
2532
|
}
|
|
2391
|
-
|
|
2392
|
-
|
|
2533
|
+
if (scopeKeys.size > 1) {
|
|
2534
|
+
const code = input.target ? "ambiguous_target" : "multiple_instances_connected";
|
|
2393
2535
|
return {
|
|
2394
2536
|
ok: false,
|
|
2395
2537
|
error: {
|
|
2396
|
-
code
|
|
2397
|
-
message: "
|
|
2538
|
+
code,
|
|
2539
|
+
message: input.target ? `target=${input.target} is ambiguous because multiple Studio routing scopes are connected. Pass instance_id to choose a scope.` : "Multiple Studio routing scopes are connected. Pass instance_id to disambiguate.",
|
|
2398
2540
|
data: errorData
|
|
2399
2541
|
}
|
|
2400
2542
|
};
|
|
2401
2543
|
}
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
return { ok: false, error: { code: errorCode2, message: msg, data: errorData } };
|
|
2406
|
-
}
|
|
2407
|
-
const onlyInstanceId = distinctInstanceIds.values().next().value;
|
|
2408
|
-
return this.resolveTarget({ instance_id: onlyInstanceId, target });
|
|
2544
|
+
const onlyScope = scopeKeys.values().next().value;
|
|
2545
|
+
const peers = this.getPeers().filter((peer) => this.peerScopeKey(peer) === onlyScope);
|
|
2546
|
+
return this.resolveWithinScope(peers, input.target, errorData);
|
|
2409
2547
|
}
|
|
2410
|
-
|
|
2548
|
+
sendRequest(endpoint, data, targetPeerId, timeoutMs = this.requestTimeout, signal) {
|
|
2411
2549
|
const requestId = randomUUID();
|
|
2412
2550
|
const effectiveTimeoutMs = Math.max(1, timeoutMs);
|
|
2413
2551
|
if (signal?.aborted)
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
}
|
|
2448
|
-
});
|
|
2552
|
+
return Promise.reject(new Error("Request aborted"));
|
|
2553
|
+
const { promise, resolve: resolve5, reject } = Promise.withResolvers();
|
|
2554
|
+
const cancelPending = (reason, error) => {
|
|
2555
|
+
const pending = this.pendingRequests.get(requestId);
|
|
2556
|
+
if (!pending || !this.removePendingRequest(pending))
|
|
2557
|
+
return;
|
|
2558
|
+
this.notifyRequestCancelled(pending, reason);
|
|
2559
|
+
pending.reject(error);
|
|
2560
|
+
};
|
|
2561
|
+
const timeoutId = setTimeout(() => cancelPending("timeout", new Error("Request timeout")), effectiveTimeoutMs);
|
|
2562
|
+
const abortListener = () => cancelPending("aborted", new Error("Request aborted"));
|
|
2563
|
+
const request = {
|
|
2564
|
+
id: requestId,
|
|
2565
|
+
endpoint,
|
|
2566
|
+
data,
|
|
2567
|
+
targetPeerId,
|
|
2568
|
+
timestamp: Date.now(),
|
|
2569
|
+
resolve: resolve5,
|
|
2570
|
+
reject,
|
|
2571
|
+
timeoutId,
|
|
2572
|
+
timeoutMs: effectiveTimeoutMs,
|
|
2573
|
+
abortSignal: signal,
|
|
2574
|
+
abortListener
|
|
2575
|
+
};
|
|
2576
|
+
this.pendingRequests.set(requestId, request);
|
|
2577
|
+
signal?.addEventListener("abort", abortListener, { once: true });
|
|
2578
|
+
if (signal?.aborted)
|
|
2579
|
+
abortListener();
|
|
2580
|
+
const target = this.getPeerById(targetPeerId);
|
|
2581
|
+
if (this.pendingRequests.has(requestId) && target) {
|
|
2582
|
+
this.notifyRequestAvailable(target.transportPeerId);
|
|
2583
|
+
}
|
|
2584
|
+
return promise;
|
|
2449
2585
|
}
|
|
2450
2586
|
removePendingRequest(request) {
|
|
2451
2587
|
if (this.pendingRequests.get(request.id) !== request)
|
|
@@ -2457,43 +2593,44 @@ var BridgeService = class {
|
|
|
2457
2593
|
this.pendingRequests.delete(request.id);
|
|
2458
2594
|
return true;
|
|
2459
2595
|
}
|
|
2460
|
-
|
|
2596
|
+
claimNextRequestForTransport(transportPeerId, claimOwner) {
|
|
2597
|
+
let outstandingCount = 0;
|
|
2598
|
+
for (const request of this.pendingRequests.values()) {
|
|
2599
|
+
if (request.claimOwner === claimOwner)
|
|
2600
|
+
outstandingCount++;
|
|
2601
|
+
}
|
|
2602
|
+
if (outstandingCount >= MAX_OUTSTANDING_REQUESTS_PER_DELIVERY_OWNER)
|
|
2603
|
+
return null;
|
|
2461
2604
|
let oldestRequest;
|
|
2462
|
-
let logicalSessionId = "";
|
|
2463
2605
|
for (const request of this.pendingRequests.values()) {
|
|
2464
2606
|
if (request.claimOwner !== void 0)
|
|
2465
2607
|
continue;
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
if (candidate.physicalSessionId === physicalSessionId && candidate.instanceId === request.targetInstanceId && candidate.role === request.targetRole) {
|
|
2469
|
-
matchingSessionId = candidate.pluginSessionId;
|
|
2470
|
-
break;
|
|
2471
|
-
}
|
|
2472
|
-
}
|
|
2473
|
-
if (!matchingSessionId)
|
|
2608
|
+
const peer2 = this.getPeerById(request.targetPeerId);
|
|
2609
|
+
if (!peer2 || peer2.transportPeerId !== transportPeerId)
|
|
2474
2610
|
continue;
|
|
2475
|
-
if (!oldestRequest || request.timestamp < oldestRequest.timestamp)
|
|
2611
|
+
if (!oldestRequest || request.timestamp < oldestRequest.timestamp)
|
|
2476
2612
|
oldestRequest = request;
|
|
2477
|
-
logicalSessionId = matchingSessionId;
|
|
2478
|
-
}
|
|
2479
2613
|
}
|
|
2480
2614
|
if (!oldestRequest)
|
|
2481
2615
|
return null;
|
|
2616
|
+
const peer = this.getPeerById(oldestRequest.targetPeerId);
|
|
2617
|
+
if (!peer)
|
|
2618
|
+
return null;
|
|
2482
2619
|
oldestRequest.claimOwner = claimOwner;
|
|
2483
|
-
oldestRequest.
|
|
2620
|
+
oldestRequest.lastDeliveryTransportPeerId = transportPeerId;
|
|
2484
2621
|
return {
|
|
2485
2622
|
requestId: oldestRequest.id,
|
|
2486
|
-
|
|
2487
|
-
target:
|
|
2623
|
+
peerId: oldestRequest.targetPeerId,
|
|
2624
|
+
target: peer.role,
|
|
2488
2625
|
endpoint: oldestRequest.endpoint,
|
|
2489
2626
|
data: oldestRequest.data,
|
|
2490
2627
|
remainingMs: Math.max(1, oldestRequest.timeoutMs - (Date.now() - oldestRequest.timestamp))
|
|
2491
2628
|
};
|
|
2492
2629
|
}
|
|
2493
|
-
|
|
2630
|
+
claimNextCancellationForTransport(transportPeerId, claimOwner) {
|
|
2494
2631
|
this.prunePendingCancellations(Date.now());
|
|
2495
2632
|
for (const cancellation of this.pendingCancellations.values()) {
|
|
2496
|
-
if (cancellation.
|
|
2633
|
+
if (cancellation.transportPeerId !== transportPeerId || cancellation.claimOwner !== void 0) {
|
|
2497
2634
|
continue;
|
|
2498
2635
|
}
|
|
2499
2636
|
cancellation.claimOwner = claimOwner;
|
|
@@ -2502,24 +2639,23 @@ var BridgeService = class {
|
|
|
2502
2639
|
return null;
|
|
2503
2640
|
}
|
|
2504
2641
|
releaseDeliveryClaims(claimOwner) {
|
|
2505
|
-
const
|
|
2642
|
+
const transportPeerIds = /* @__PURE__ */ new Set();
|
|
2506
2643
|
for (const request of this.pendingRequests.values()) {
|
|
2507
2644
|
if (request.claimOwner !== claimOwner)
|
|
2508
2645
|
continue;
|
|
2509
2646
|
request.claimOwner = void 0;
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2647
|
+
const peer = this.getPeerById(request.targetPeerId);
|
|
2648
|
+
if (peer)
|
|
2649
|
+
transportPeerIds.add(peer.transportPeerId);
|
|
2513
2650
|
}
|
|
2514
2651
|
for (const cancellation of this.pendingCancellations.values()) {
|
|
2515
2652
|
if (cancellation.claimOwner !== claimOwner)
|
|
2516
2653
|
continue;
|
|
2517
2654
|
cancellation.claimOwner = void 0;
|
|
2518
|
-
|
|
2519
|
-
}
|
|
2520
|
-
for (const physicalSessionId of physicalSessionIds) {
|
|
2521
|
-
this.notifyRequestAvailable(physicalSessionId);
|
|
2655
|
+
transportPeerIds.add(cancellation.transportPeerId);
|
|
2522
2656
|
}
|
|
2657
|
+
for (const transportPeerId of transportPeerIds)
|
|
2658
|
+
this.notifyRequestAvailable(transportPeerId);
|
|
2523
2659
|
}
|
|
2524
2660
|
resolveRequest(requestId, response) {
|
|
2525
2661
|
return this.settleRequest(requestId, (request) => request.resolve(response));
|
|
@@ -2531,13 +2667,15 @@ var BridgeService = class {
|
|
|
2531
2667
|
const now = Date.now();
|
|
2532
2668
|
this.pruneAcceptedRequestIds(now);
|
|
2533
2669
|
const request = this.pendingRequests.get(requestId);
|
|
2534
|
-
if (!request)
|
|
2670
|
+
if (!request)
|
|
2535
2671
|
return this.acceptedRequestIds.has(requestId) ? "already_settled" : "unknown";
|
|
2536
|
-
|
|
2672
|
+
const deliveryTransportPeerId = request.lastDeliveryTransportPeerId;
|
|
2537
2673
|
this.removePendingRequest(request);
|
|
2538
2674
|
this.acceptedRequestIds.set(requestId, now);
|
|
2539
2675
|
this.pruneAcceptedRequestIds(now);
|
|
2540
2676
|
settle(request);
|
|
2677
|
+
if (deliveryTransportPeerId)
|
|
2678
|
+
this.notifyRequestAvailable(deliveryTransportPeerId);
|
|
2541
2679
|
return "accepted";
|
|
2542
2680
|
}
|
|
2543
2681
|
pruneAcceptedRequestIds(now) {
|
|
@@ -2829,7 +2967,7 @@ Tool descriptions explain selection. Input schemas explain arguments. This guide
|
|
|
2829
2967
|
## Connection and paths
|
|
2830
2968
|
|
|
2831
2969
|
- Use canonical DataModel paths returned by the tools. Paths usually start with game.
|
|
2832
|
-
- Call get_connected_instances when more than one
|
|
2970
|
+
- Call get_connected_instances when more than one Studio process may be connected. Pass either a top-level Instance ID or a multiplayer group's role-suffixed Instance ID as instance_id on later calls.
|
|
2833
2971
|
- Use get_place_info for the active place identity and settings.
|
|
2834
2972
|
|
|
2835
2973
|
## Discovery and edit work
|
|
@@ -2859,7 +2997,7 @@ Start solo_playtest or multiplayer_playtest before targeting a live server or cl
|
|
|
2859
2997
|
|
|
2860
2998
|
execute_luau runs through the Studio plugin. eval_server_runtime and eval_client_runtime run inside a live game VM and share that VM's require cache with game scripts. Use the eval tools when module state or the runtime Script or LocalScript environment matters.
|
|
2861
2999
|
|
|
2862
|
-
Read output with get_runtime_logs. Reuse
|
|
3000
|
+
Read output with get_runtime_logs. Reuse nextCursor as cursor for one Instance, or nextCursorByInstance as cursor_by_instance for a Multiplayer Group, instead of requesting the full process streams again.
|
|
2863
3001
|
|
|
2864
3002
|
## Simulation and input
|
|
2865
3003
|
|
|
@@ -5388,7 +5526,7 @@ var INTERNAL_RESULT_KEYS = /* @__PURE__ */ new Set([
|
|
|
5388
5526
|
"diagnostics",
|
|
5389
5527
|
"internal",
|
|
5390
5528
|
"lastActivity",
|
|
5391
|
-
"
|
|
5529
|
+
"transportPeerId",
|
|
5392
5530
|
"pluginVariant",
|
|
5393
5531
|
"pluginVersion",
|
|
5394
5532
|
"requestId",
|
|
@@ -5492,12 +5630,18 @@ function publicRoutingError(error) {
|
|
|
5492
5630
|
return {
|
|
5493
5631
|
error: error.routingError.code,
|
|
5494
5632
|
message: error.routingError.message,
|
|
5633
|
+
count: error.routingError.data.count,
|
|
5495
5634
|
instances: error.routingError.data.instances.map((instance) => ({
|
|
5496
|
-
instance_id: instance.
|
|
5497
|
-
|
|
5635
|
+
instance_id: instance.id,
|
|
5636
|
+
multiplayer_group_id: instance.multiplayerGroupId,
|
|
5498
5637
|
place_id: instance.placeId,
|
|
5499
5638
|
place_name: instance.placeName,
|
|
5500
|
-
|
|
5639
|
+
peers: instance.peers
|
|
5640
|
+
})),
|
|
5641
|
+
multiplayer_groups: error.routingError.data.multiplayerGroups.map((group) => ({
|
|
5642
|
+
multiplayer_group_id: group.id,
|
|
5643
|
+
controller_instance_id: group.controllerInstanceId,
|
|
5644
|
+
instances: group.instances
|
|
5501
5645
|
}))
|
|
5502
5646
|
};
|
|
5503
5647
|
}
|
|
@@ -5563,7 +5707,7 @@ function serverInstructions(definitions) {
|
|
|
5563
5707
|
const has = (...toolNames) => toolNames.every((name) => names.has(name));
|
|
5564
5708
|
const instructions = ["Use canonical DataModel paths returned by tools."];
|
|
5565
5709
|
if (has("get_connected_instances")) {
|
|
5566
|
-
instructions.push("When more than one
|
|
5710
|
+
instructions.push("When more than one Studio process scope is connected, call get_connected_instances and pass either a top-level instance id or a multiplayer group's role-suffixed instance id as instance_id.");
|
|
5567
5711
|
}
|
|
5568
5712
|
if (has("search_objects", "get_project_structure", "grep_scripts", "execute_luau")) {
|
|
5569
5713
|
instructions.push("Use search_objects, get_project_structure, or grep_scripts for standard discovery. Use execute_luau for custom traversal or bulk edits.");
|
|
@@ -5677,34 +5821,34 @@ var SseStudioTransport = class {
|
|
|
5677
5821
|
queue;
|
|
5678
5822
|
streams = /* @__PURE__ */ new Map();
|
|
5679
5823
|
unsubscribeRequestAvailable;
|
|
5680
|
-
|
|
5824
|
+
unsubscribePeerClosed;
|
|
5681
5825
|
nextGeneration = 0;
|
|
5682
5826
|
constructor(queue) {
|
|
5683
5827
|
this.queue = queue;
|
|
5684
|
-
this.unsubscribeRequestAvailable = queue.onRequestAvailable((
|
|
5685
|
-
const stream = this.streams.get(
|
|
5828
|
+
this.unsubscribeRequestAvailable = queue.onRequestAvailable((transportPeerId) => {
|
|
5829
|
+
const stream = this.streams.get(transportPeerId);
|
|
5686
5830
|
if (stream)
|
|
5687
5831
|
this.pump(stream);
|
|
5688
5832
|
});
|
|
5689
|
-
this.
|
|
5690
|
-
if (route.
|
|
5691
|
-
this.
|
|
5833
|
+
this.unsubscribePeerClosed = queue.onPeerClosed((route) => {
|
|
5834
|
+
if (route.peerId === route.transportPeerId) {
|
|
5835
|
+
this.closeTransport(route.transportPeerId);
|
|
5692
5836
|
}
|
|
5693
5837
|
});
|
|
5694
5838
|
}
|
|
5695
5839
|
get activeStreamCount() {
|
|
5696
5840
|
return this.streams.size;
|
|
5697
5841
|
}
|
|
5698
|
-
canOpen(
|
|
5699
|
-
return this.streams.has(
|
|
5842
|
+
canOpen(transportPeerId) {
|
|
5843
|
+
return this.streams.has(transportPeerId) || this.streams.size < MAX_ACTIVE_EVENT_STREAMS;
|
|
5700
5844
|
}
|
|
5701
|
-
open(
|
|
5702
|
-
if (!this.canOpen(
|
|
5845
|
+
open(transportPeerId, sink, status) {
|
|
5846
|
+
if (!this.canOpen(transportPeerId))
|
|
5703
5847
|
return void 0;
|
|
5704
5848
|
this.nextGeneration += 1;
|
|
5705
|
-
const claimOwner = `sse:${
|
|
5849
|
+
const claimOwner = `sse:${transportPeerId}:${this.nextGeneration}`;
|
|
5706
5850
|
const stream = {
|
|
5707
|
-
|
|
5851
|
+
transportPeerId,
|
|
5708
5852
|
claimOwner,
|
|
5709
5853
|
sink,
|
|
5710
5854
|
status,
|
|
@@ -5719,18 +5863,18 @@ var SseStudioTransport = class {
|
|
|
5719
5863
|
this.pump(stream);
|
|
5720
5864
|
}
|
|
5721
5865
|
};
|
|
5722
|
-
this.queue.setDeliveryActive(
|
|
5723
|
-
this.queue.
|
|
5724
|
-
const replaced = this.streams.get(
|
|
5866
|
+
this.queue.setDeliveryActive(transportPeerId, claimOwner, true);
|
|
5867
|
+
this.queue.updatePeerActivity(transportPeerId);
|
|
5868
|
+
const replaced = this.streams.get(transportPeerId);
|
|
5725
5869
|
if (replaced)
|
|
5726
5870
|
this.closeStream(replaced, true);
|
|
5727
|
-
this.streams.set(
|
|
5871
|
+
this.streams.set(transportPeerId, stream);
|
|
5728
5872
|
sink.on("close", stream.onClose);
|
|
5729
5873
|
sink.on("error", stream.onClose);
|
|
5730
5874
|
sink.on("drain", stream.onDrain);
|
|
5731
5875
|
stream.heartbeatTimer = setInterval(() => {
|
|
5732
5876
|
if (!stream.closed && !stream.blocked) {
|
|
5733
|
-
this.queue.
|
|
5877
|
+
this.queue.updatePeerActivity(transportPeerId);
|
|
5734
5878
|
stream.statusPending = true;
|
|
5735
5879
|
this.pump(stream);
|
|
5736
5880
|
if (!stream.blocked) {
|
|
@@ -5741,13 +5885,13 @@ var SseStudioTransport = class {
|
|
|
5741
5885
|
stream.heartbeatTimer.unref();
|
|
5742
5886
|
this.pump(stream);
|
|
5743
5887
|
return {
|
|
5744
|
-
|
|
5888
|
+
transportPeerId,
|
|
5745
5889
|
close: () => this.closeStream(stream, true)
|
|
5746
5890
|
};
|
|
5747
5891
|
}
|
|
5748
|
-
refreshStatus(
|
|
5749
|
-
if (
|
|
5750
|
-
const stream = this.streams.get(
|
|
5892
|
+
refreshStatus(transportPeerId) {
|
|
5893
|
+
if (transportPeerId !== void 0) {
|
|
5894
|
+
const stream = this.streams.get(transportPeerId);
|
|
5751
5895
|
if (stream) {
|
|
5752
5896
|
stream.lastStatusJson = void 0;
|
|
5753
5897
|
stream.statusPending = true;
|
|
@@ -5761,8 +5905,8 @@ var SseStudioTransport = class {
|
|
|
5761
5905
|
this.pump(stream);
|
|
5762
5906
|
}
|
|
5763
5907
|
}
|
|
5764
|
-
|
|
5765
|
-
const stream = this.streams.get(
|
|
5908
|
+
closeTransport(transportPeerId) {
|
|
5909
|
+
const stream = this.streams.get(transportPeerId);
|
|
5766
5910
|
if (stream)
|
|
5767
5911
|
this.closeStream(stream, true);
|
|
5768
5912
|
}
|
|
@@ -5771,10 +5915,10 @@ var SseStudioTransport = class {
|
|
|
5771
5915
|
this.closeStream(stream, true);
|
|
5772
5916
|
}
|
|
5773
5917
|
this.unsubscribeRequestAvailable();
|
|
5774
|
-
this.
|
|
5918
|
+
this.unsubscribePeerClosed();
|
|
5775
5919
|
}
|
|
5776
5920
|
pump(stream) {
|
|
5777
|
-
if (stream.closed || stream.blocked || this.streams.get(stream.
|
|
5921
|
+
if (stream.closed || stream.blocked || this.streams.get(stream.transportPeerId) !== stream)
|
|
5778
5922
|
return;
|
|
5779
5923
|
if (stream.statusPending) {
|
|
5780
5924
|
stream.statusPending = false;
|
|
@@ -5793,20 +5937,20 @@ var SseStudioTransport = class {
|
|
|
5793
5937
|
}
|
|
5794
5938
|
}
|
|
5795
5939
|
while (!stream.closed && !stream.blocked) {
|
|
5796
|
-
const cancellation = this.queue.
|
|
5940
|
+
const cancellation = this.queue.claimNextCancellationForTransport(stream.transportPeerId, stream.claimOwner);
|
|
5797
5941
|
if (!cancellation)
|
|
5798
5942
|
break;
|
|
5799
5943
|
if (!this.write(stream, { kind: "cancel", ...cancellation }))
|
|
5800
5944
|
return;
|
|
5801
5945
|
}
|
|
5802
5946
|
while (!stream.closed && !stream.blocked) {
|
|
5803
|
-
const request = this.queue.
|
|
5947
|
+
const request = this.queue.claimNextRequestForTransport(stream.transportPeerId, stream.claimOwner);
|
|
5804
5948
|
if (!request)
|
|
5805
5949
|
return;
|
|
5806
5950
|
const event = {
|
|
5807
5951
|
kind: "request",
|
|
5808
5952
|
requestId: request.requestId,
|
|
5809
|
-
|
|
5953
|
+
peerId: request.peerId,
|
|
5810
5954
|
target: request.target,
|
|
5811
5955
|
endpoint: request.endpoint,
|
|
5812
5956
|
data: request.data === void 0 ? null : request.data,
|
|
@@ -5839,11 +5983,11 @@ var SseStudioTransport = class {
|
|
|
5839
5983
|
stream.sink.removeListener("close", stream.onClose);
|
|
5840
5984
|
stream.sink.removeListener("error", stream.onClose);
|
|
5841
5985
|
stream.sink.removeListener("drain", stream.onDrain);
|
|
5842
|
-
if (this.streams.get(stream.
|
|
5843
|
-
this.streams.delete(stream.
|
|
5986
|
+
if (this.streams.get(stream.transportPeerId) === stream) {
|
|
5987
|
+
this.streams.delete(stream.transportPeerId);
|
|
5844
5988
|
}
|
|
5845
|
-
this.queue.
|
|
5846
|
-
this.queue.setDeliveryActive(stream.
|
|
5989
|
+
this.queue.updatePeerActivity(stream.transportPeerId);
|
|
5990
|
+
this.queue.setDeliveryActive(stream.transportPeerId, stream.claimOwner, false);
|
|
5847
5991
|
this.queue.releaseDeliveryClaims(stream.claimOwner);
|
|
5848
5992
|
if (endSink) {
|
|
5849
5993
|
try {
|
|
@@ -5855,6 +5999,32 @@ var SseStudioTransport = class {
|
|
|
5855
5999
|
};
|
|
5856
6000
|
|
|
5857
6001
|
// ../core/dist/http-server.js
|
|
6002
|
+
function toPassivePeer(peer) {
|
|
6003
|
+
return {
|
|
6004
|
+
instanceId: peer.instanceId,
|
|
6005
|
+
multiplayerGroupId: peer.multiplayerGroupId,
|
|
6006
|
+
role: peer.role,
|
|
6007
|
+
placeId: peer.placeId,
|
|
6008
|
+
placeName: peer.placeName,
|
|
6009
|
+
placeKey: peer.placeKey,
|
|
6010
|
+
dataModelName: peer.dataModelName,
|
|
6011
|
+
isRunning: peer.isRunning,
|
|
6012
|
+
pluginVersion: peer.pluginVersion,
|
|
6013
|
+
pluginVariant: peer.pluginVariant,
|
|
6014
|
+
serverVersion: peer.serverVersion,
|
|
6015
|
+
lastActivity: peer.lastActivity,
|
|
6016
|
+
connectedAt: peer.connectedAt
|
|
6017
|
+
};
|
|
6018
|
+
}
|
|
6019
|
+
function toPassiveInstance(instance) {
|
|
6020
|
+
return {
|
|
6021
|
+
id: instance.id,
|
|
6022
|
+
multiplayerGroupId: instance.multiplayerGroupId,
|
|
6023
|
+
placeId: instance.placeId,
|
|
6024
|
+
placeName: instance.placeName,
|
|
6025
|
+
peers: instance.peers.map(toPassivePeer)
|
|
6026
|
+
};
|
|
6027
|
+
}
|
|
5858
6028
|
function parseLineRange(lineRange) {
|
|
5859
6029
|
const validLine = (line) => line === void 0 || line >= 1;
|
|
5860
6030
|
if (typeof lineRange === "string") {
|
|
@@ -5946,7 +6116,7 @@ var TOOL_HANDLERS = {
|
|
|
5946
6116
|
manage_instance: (tools, body) => tools.manageInstance(body),
|
|
5947
6117
|
solo_playtest: (tools, body) => tools.soloPlaytest(body.action, body.mode, body.timeout, body.instance_id),
|
|
5948
6118
|
multiplayer_playtest: (tools, body) => tools.multiplayerPlaytest(body.action, body.numPlayers, body.target, body.testArgs, body.value, body.timeout, body.instance_id),
|
|
5949
|
-
get_runtime_logs: (tools, body) => tools.getRuntimeLogs(body.
|
|
6119
|
+
get_runtime_logs: (tools, body, context) => tools.getRuntimeLogs(body.instance_id, body.multiplayer_group_id, body.cursor, body.cursor_by_instance, body.tail, body.filter, context?.signal),
|
|
5950
6120
|
capture_script_profiler: (tools, body) => tools.captureScriptProfiler(body.target, {
|
|
5951
6121
|
duration_ms: body.duration_ms,
|
|
5952
6122
|
frequency: body.frequency,
|
|
@@ -6010,7 +6180,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6010
6180
|
let lastMCPActivity = 0;
|
|
6011
6181
|
let mcpServerStartTime = 0;
|
|
6012
6182
|
const proxyInstances = /* @__PURE__ */ new Set();
|
|
6013
|
-
const
|
|
6183
|
+
const rejectedVersionPeers = /* @__PURE__ */ new Set();
|
|
6014
6184
|
const eventTransport = new SseStudioTransport(bridge);
|
|
6015
6185
|
const eventStreamHandles = /* @__PURE__ */ new Set();
|
|
6016
6186
|
const setMCPServerActive = (active) => {
|
|
@@ -6037,20 +6207,20 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6037
6207
|
return false;
|
|
6038
6208
|
return Date.now() - lastMCPActivity < 3e4;
|
|
6039
6209
|
};
|
|
6040
|
-
const eventStatus = (
|
|
6041
|
-
const
|
|
6042
|
-
const
|
|
6210
|
+
const eventStatus = (transportPeerId) => {
|
|
6211
|
+
const peer = bridge.getPeerById(transportPeerId);
|
|
6212
|
+
const knownPeer = peer?.transportPeerId === transportPeerId;
|
|
6043
6213
|
return {
|
|
6044
6214
|
kind: "status",
|
|
6045
|
-
|
|
6215
|
+
knownPeer,
|
|
6046
6216
|
mcpConnected: isMCPServerActive(),
|
|
6047
6217
|
serverVersion: serverConfig?.version,
|
|
6048
|
-
pluginVersion:
|
|
6049
|
-
pluginVariant:
|
|
6218
|
+
pluginVersion: peer?.pluginVersion,
|
|
6219
|
+
pluginVariant: peer?.pluginVariant
|
|
6050
6220
|
};
|
|
6051
6221
|
};
|
|
6052
6222
|
const isPluginConnected = () => {
|
|
6053
|
-
return bridge.
|
|
6223
|
+
return bridge.getPeers().length > 0;
|
|
6054
6224
|
};
|
|
6055
6225
|
const allowedOrigins = new Set(security?.allowedOrigins ?? []);
|
|
6056
6226
|
app.use((req, res, next) => {
|
|
@@ -6077,7 +6247,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6077
6247
|
next();
|
|
6078
6248
|
});
|
|
6079
6249
|
const authToken = security?.authToken;
|
|
6080
|
-
const authRequired = (path6) => path6 === "/mcp" || path6.startsWith("/mcp/") || path6 === "/proxy" || path6 === "/
|
|
6250
|
+
const authRequired = (path6) => path6 === "/mcp" || path6.startsWith("/mcp/") || path6 === "/proxy" || path6 === "/topology" || path6 === "/unregister-instance-id" || path6 === "/create-multiplayer-group" || path6 === "/remove-multiplayer-group";
|
|
6081
6251
|
app.use((req, res, next) => {
|
|
6082
6252
|
if (!authToken || !authRequired(req.path)) {
|
|
6083
6253
|
next();
|
|
@@ -6098,8 +6268,9 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6098
6268
|
app.use(express.json({ limit: "50mb" }));
|
|
6099
6269
|
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
|
6100
6270
|
app.get("/health", (req, res) => {
|
|
6101
|
-
const
|
|
6102
|
-
const
|
|
6271
|
+
const peers = bridge.getPublicPeers().map(toPassivePeer);
|
|
6272
|
+
const instances = bridge.getPublicInstances().map(toPassiveInstance);
|
|
6273
|
+
const multiplayerGroups = bridge.getPublicMultiplayerGroups();
|
|
6103
6274
|
res.json({
|
|
6104
6275
|
status: "ok",
|
|
6105
6276
|
service: "robloxstudio-mcp",
|
|
@@ -6115,9 +6286,12 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6115
6286
|
processIdentity: studioLifecycleCapabilities?.processIdentity
|
|
6116
6287
|
}
|
|
6117
6288
|
} : {},
|
|
6118
|
-
pluginConnected:
|
|
6289
|
+
pluginConnected: peers.length > 0,
|
|
6119
6290
|
instanceCount: instances.length,
|
|
6120
|
-
|
|
6291
|
+
peerCount: peers.length,
|
|
6292
|
+
instances,
|
|
6293
|
+
peers,
|
|
6294
|
+
multiplayerGroups,
|
|
6121
6295
|
mcpServerActive: isMCPServerActive(),
|
|
6122
6296
|
uptime: mcpServerActive ? Date.now() - mcpServerStartTime : 0,
|
|
6123
6297
|
pendingRequests: bridge.getPendingRequestCount(),
|
|
@@ -6127,25 +6301,34 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6127
6301
|
});
|
|
6128
6302
|
});
|
|
6129
6303
|
app.post("/ready", (req, res) => {
|
|
6130
|
-
const {
|
|
6304
|
+
const { peerId, transportPeerId, instanceId, multiplayerGroupId, role, placeId, placeName, placeKey, dataModelName, isRunning, pluginVersion, pluginVariant, timestamp } = req.body;
|
|
6131
6305
|
const requestContext = {
|
|
6132
|
-
|
|
6306
|
+
peerId: typeof peerId === "string" ? peerId : void 0,
|
|
6307
|
+
transportPeerId: typeof transportPeerId === "string" ? transportPeerId : void 0,
|
|
6133
6308
|
instanceId: typeof instanceId === "string" ? instanceId : void 0,
|
|
6309
|
+
multiplayerGroupId: typeof multiplayerGroupId === "string" ? multiplayerGroupId : void 0,
|
|
6134
6310
|
role: typeof role === "string" ? role : void 0,
|
|
6135
6311
|
placeId: typeof placeId === "number" ? placeId : void 0,
|
|
6136
6312
|
placeName: typeof placeName === "string" ? placeName : void 0,
|
|
6313
|
+
placeKey: typeof placeKey === "string" ? placeKey : void 0,
|
|
6137
6314
|
dataModelName: typeof dataModelName === "string" ? dataModelName : void 0,
|
|
6138
6315
|
isRunning: typeof isRunning === "boolean" ? isRunning : void 0,
|
|
6139
6316
|
pluginVersion: typeof pluginVersion === "string" ? pluginVersion : void 0,
|
|
6140
|
-
pluginVariant: typeof pluginVariant === "string" ? pluginVariant : void 0
|
|
6317
|
+
pluginVariant: typeof pluginVariant === "string" ? pluginVariant : void 0,
|
|
6318
|
+
timestamp: typeof timestamp === "number" ? timestamp : void 0
|
|
6141
6319
|
};
|
|
6142
6320
|
const missingFields = [
|
|
6143
|
-
typeof
|
|
6144
|
-
typeof
|
|
6321
|
+
typeof peerId !== "string" || peerId === "" ? "peerId" : void 0,
|
|
6322
|
+
typeof transportPeerId !== "string" || transportPeerId === "" ? "transportPeerId" : void 0,
|
|
6145
6323
|
typeof instanceId !== "string" || instanceId === "" ? "instanceId" : void 0,
|
|
6146
6324
|
typeof role !== "string" || role === "" ? "role" : void 0,
|
|
6325
|
+
typeof placeId !== "number" || !Number.isFinite(placeId) ? "placeId" : void 0,
|
|
6326
|
+
typeof placeName !== "string" ? "placeName" : void 0,
|
|
6327
|
+
typeof dataModelName !== "string" ? "dataModelName" : void 0,
|
|
6328
|
+
typeof isRunning !== "boolean" ? "isRunning" : void 0,
|
|
6147
6329
|
typeof pluginVersion !== "string" || pluginVersion === "" ? "pluginVersion" : void 0,
|
|
6148
|
-
typeof pluginVariant !== "string" || pluginVariant === "" ? "pluginVariant" : void 0
|
|
6330
|
+
typeof pluginVariant !== "string" || pluginVariant === "" ? "pluginVariant" : void 0,
|
|
6331
|
+
typeof timestamp !== "number" || !Number.isFinite(timestamp) ? "timestamp" : void 0
|
|
6149
6332
|
].filter((field) => !!field);
|
|
6150
6333
|
if (missingFields.length > 0) {
|
|
6151
6334
|
res.status(400).json({
|
|
@@ -6157,6 +6340,15 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6157
6340
|
});
|
|
6158
6341
|
return;
|
|
6159
6342
|
}
|
|
6343
|
+
if (multiplayerGroupId !== void 0 && (typeof multiplayerGroupId !== "string" || multiplayerGroupId === "")) {
|
|
6344
|
+
res.status(400).json({
|
|
6345
|
+
success: false,
|
|
6346
|
+
error: "invalid_multiplayer_group_id",
|
|
6347
|
+
message: "multiplayerGroupId must be a non-empty string when provided.",
|
|
6348
|
+
request: requestContext
|
|
6349
|
+
});
|
|
6350
|
+
return;
|
|
6351
|
+
}
|
|
6160
6352
|
const serverVersion = serverConfig?.version;
|
|
6161
6353
|
if (!serverVersion) {
|
|
6162
6354
|
res.status(503).json({
|
|
@@ -6168,10 +6360,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6168
6360
|
return;
|
|
6169
6361
|
}
|
|
6170
6362
|
if (pluginVersion !== serverVersion) {
|
|
6171
|
-
if (!
|
|
6172
|
-
if (
|
|
6173
|
-
|
|
6174
|
-
|
|
6363
|
+
if (!rejectedVersionPeers.has(peerId)) {
|
|
6364
|
+
if (rejectedVersionPeers.size >= 256)
|
|
6365
|
+
rejectedVersionPeers.clear();
|
|
6366
|
+
rejectedVersionPeers.add(peerId);
|
|
6175
6367
|
console.error(`[plugin-version-rejected] Studio plugin v${pluginVersion} (${pluginVariant}) does not match MCP server v${serverVersion} for ${instanceId}/${role}`);
|
|
6176
6368
|
}
|
|
6177
6369
|
res.status(426).json({
|
|
@@ -6185,24 +6377,25 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6185
6377
|
return;
|
|
6186
6378
|
}
|
|
6187
6379
|
const isClientRole = role === "client" || /^client-[1-9]\d*$/.test(role);
|
|
6188
|
-
const
|
|
6189
|
-
if (
|
|
6380
|
+
const isProxiedPeer = transportPeerId !== peerId;
|
|
6381
|
+
if (isProxiedPeer && !isClientRole || !isProxiedPeer && isClientRole || !isClientRole && role !== "edit" && role !== "server") {
|
|
6190
6382
|
res.status(400).json({
|
|
6191
6383
|
success: false,
|
|
6192
|
-
error: "
|
|
6193
|
-
message: "
|
|
6384
|
+
error: "invalid_peer_topology",
|
|
6385
|
+
message: "Transport Peers must use the edit or server role; client Peers must use a distinct server transport Peer.",
|
|
6194
6386
|
request: requestContext
|
|
6195
6387
|
});
|
|
6196
6388
|
return;
|
|
6197
6389
|
}
|
|
6198
|
-
if (
|
|
6199
|
-
const
|
|
6200
|
-
const
|
|
6201
|
-
|
|
6390
|
+
if (isProxiedPeer) {
|
|
6391
|
+
const transportOwner = bridge.getPeerById(transportPeerId);
|
|
6392
|
+
const sameInstance = transportOwner?.instanceId === instanceId;
|
|
6393
|
+
const sameMultiplayerGroup = typeof multiplayerGroupId === "string" && transportOwner?.multiplayerGroupId === multiplayerGroupId;
|
|
6394
|
+
if (!transportOwner || transportOwner.peerId !== transportPeerId || transportOwner.transportPeerId !== transportPeerId || transportOwner.role !== "server" || !sameInstance && !sameMultiplayerGroup) {
|
|
6202
6395
|
res.status(409).json({
|
|
6203
6396
|
success: false,
|
|
6204
|
-
error: "
|
|
6205
|
-
message: "A
|
|
6397
|
+
error: "transport_peer_unavailable",
|
|
6398
|
+
message: "A client Peer requires a registered server transport Peer in the same Instance or explicit MultiplayerGroup.",
|
|
6206
6399
|
request: requestContext
|
|
6207
6400
|
});
|
|
6208
6401
|
return;
|
|
@@ -6210,15 +6403,17 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6210
6403
|
}
|
|
6211
6404
|
let result;
|
|
6212
6405
|
try {
|
|
6213
|
-
result = bridge.
|
|
6214
|
-
|
|
6215
|
-
|
|
6406
|
+
result = bridge.registerPeer({
|
|
6407
|
+
peerId,
|
|
6408
|
+
transportPeerId,
|
|
6216
6409
|
instanceId,
|
|
6410
|
+
multiplayerGroupId,
|
|
6217
6411
|
role,
|
|
6218
|
-
placeId
|
|
6219
|
-
placeName
|
|
6220
|
-
|
|
6221
|
-
|
|
6412
|
+
placeId,
|
|
6413
|
+
placeName,
|
|
6414
|
+
placeKey: typeof placeKey === "string" ? placeKey : void 0,
|
|
6415
|
+
dataModelName,
|
|
6416
|
+
isRunning,
|
|
6222
6417
|
pluginVersion,
|
|
6223
6418
|
pluginVariant,
|
|
6224
6419
|
serverVersion
|
|
@@ -6242,75 +6437,98 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6242
6437
|
});
|
|
6243
6438
|
return;
|
|
6244
6439
|
}
|
|
6245
|
-
eventTransport.refreshStatus(
|
|
6440
|
+
eventTransport.refreshStatus(transportPeerId);
|
|
6246
6441
|
res.json({
|
|
6247
6442
|
success: true,
|
|
6248
6443
|
assignedRole: result.assignedRole,
|
|
6444
|
+
peerId: result.peerId,
|
|
6249
6445
|
instanceId: result.instanceId,
|
|
6446
|
+
multiplayerGroupId: result.multiplayerGroupId,
|
|
6250
6447
|
serverVersion
|
|
6251
6448
|
});
|
|
6252
6449
|
});
|
|
6253
6450
|
app.post("/disconnect", (req, res) => {
|
|
6254
|
-
const {
|
|
6255
|
-
if (
|
|
6256
|
-
bridge.
|
|
6451
|
+
const { peerId } = req.body;
|
|
6452
|
+
if (typeof peerId === "string" && peerId !== "") {
|
|
6453
|
+
bridge.unregisterPeer(peerId);
|
|
6257
6454
|
}
|
|
6258
6455
|
res.json({ success: true });
|
|
6259
6456
|
});
|
|
6260
|
-
app.post("/unregister-instance-id", (req, res) => {
|
|
6457
|
+
app.post("/unregister-instance-id", async (req, res) => {
|
|
6261
6458
|
const { instanceId } = req.body;
|
|
6262
6459
|
if (typeof instanceId !== "string" || instanceId.length === 0) {
|
|
6263
6460
|
res.status(400).json({ error: "instanceId is required" });
|
|
6264
6461
|
return;
|
|
6265
6462
|
}
|
|
6266
|
-
const removed = bridge.
|
|
6463
|
+
const removed = await bridge.unregisterInstanceIdEverywhere(instanceId);
|
|
6464
|
+
res.json({ success: true, removed });
|
|
6465
|
+
});
|
|
6466
|
+
app.post("/create-multiplayer-group", async (req, res) => {
|
|
6467
|
+
const { groupId, controllerInstanceId } = req.body;
|
|
6468
|
+
if (typeof groupId !== "string" || groupId.length === 0 || typeof controllerInstanceId !== "string" || controllerInstanceId.length === 0) {
|
|
6469
|
+
res.status(400).json({ error: "groupId and controllerInstanceId are required" });
|
|
6470
|
+
return;
|
|
6471
|
+
}
|
|
6472
|
+
const group = await bridge.createMultiplayerGroupEverywhere(groupId, controllerInstanceId);
|
|
6473
|
+
res.json({ success: true, group });
|
|
6474
|
+
});
|
|
6475
|
+
app.post("/remove-multiplayer-group", async (req, res) => {
|
|
6476
|
+
const { groupId } = req.body;
|
|
6477
|
+
if (typeof groupId !== "string" || groupId.length === 0) {
|
|
6478
|
+
res.status(400).json({ error: "groupId is required" });
|
|
6479
|
+
return;
|
|
6480
|
+
}
|
|
6481
|
+
const removed = await bridge.removeMultiplayerGroupEverywhere(groupId);
|
|
6267
6482
|
res.json({ success: true, removed });
|
|
6268
6483
|
});
|
|
6269
6484
|
app.get("/status", (req, res) => {
|
|
6270
|
-
const
|
|
6271
|
-
const
|
|
6485
|
+
const peers = bridge.getPublicPeers().map(toPassivePeer);
|
|
6486
|
+
const instances = bridge.getPublicInstances().map(toPassiveInstance);
|
|
6487
|
+
const multiplayerGroups = bridge.getPublicMultiplayerGroups();
|
|
6272
6488
|
res.json({
|
|
6273
|
-
pluginConnected:
|
|
6489
|
+
pluginConnected: peers.length > 0,
|
|
6274
6490
|
instanceCount: instances.length,
|
|
6275
|
-
|
|
6491
|
+
peerCount: peers.length,
|
|
6492
|
+
instances,
|
|
6493
|
+
peers,
|
|
6494
|
+
multiplayerGroups,
|
|
6276
6495
|
serverVersion: serverConfig?.version,
|
|
6277
6496
|
mcpServerActive: isMCPServerActive(),
|
|
6278
6497
|
lastMCPActivity,
|
|
6279
6498
|
uptime: mcpServerActive ? Date.now() - mcpServerStartTime : 0
|
|
6280
6499
|
});
|
|
6281
6500
|
});
|
|
6282
|
-
app.get("/
|
|
6283
|
-
const instances = bridge.getInstances();
|
|
6501
|
+
app.get("/topology", (req, res) => {
|
|
6284
6502
|
res.json({
|
|
6285
|
-
|
|
6503
|
+
...bridge.getTopologySnapshot(),
|
|
6286
6504
|
serverVersion: serverConfig?.version
|
|
6287
6505
|
});
|
|
6288
6506
|
});
|
|
6289
6507
|
app.get("/events", (req, res) => {
|
|
6290
|
-
const
|
|
6291
|
-
if (!
|
|
6508
|
+
const peerId = typeof req.query.peerId === "string" ? req.query.peerId : void 0;
|
|
6509
|
+
if (!peerId) {
|
|
6292
6510
|
res.status(400).json({
|
|
6293
|
-
error: "
|
|
6294
|
-
message: "
|
|
6511
|
+
error: "missing_peer_id",
|
|
6512
|
+
message: "peerId is required"
|
|
6295
6513
|
});
|
|
6296
6514
|
return;
|
|
6297
6515
|
}
|
|
6298
|
-
const
|
|
6299
|
-
if (!
|
|
6516
|
+
const peer = bridge.getPeerById(peerId);
|
|
6517
|
+
if (!peer) {
|
|
6300
6518
|
res.status(404).json({
|
|
6301
|
-
error: "
|
|
6302
|
-
|
|
6519
|
+
error: "unknown_peer",
|
|
6520
|
+
knownPeer: false
|
|
6303
6521
|
});
|
|
6304
6522
|
return;
|
|
6305
6523
|
}
|
|
6306
|
-
if (
|
|
6524
|
+
if (peer.transportPeerId !== peerId) {
|
|
6307
6525
|
res.status(409).json({
|
|
6308
|
-
error: "
|
|
6309
|
-
|
|
6526
|
+
error: "peer_has_no_event_stream",
|
|
6527
|
+
transportPeerId: peer.transportPeerId
|
|
6310
6528
|
});
|
|
6311
6529
|
return;
|
|
6312
6530
|
}
|
|
6313
|
-
if (!eventTransport.canOpen(
|
|
6531
|
+
if (!eventTransport.canOpen(peerId)) {
|
|
6314
6532
|
res.setHeader("Retry-After", "1");
|
|
6315
6533
|
res.status(503).json({
|
|
6316
6534
|
error: "event_stream_capacity_reached",
|
|
@@ -6318,14 +6536,14 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6318
6536
|
});
|
|
6319
6537
|
return;
|
|
6320
6538
|
}
|
|
6321
|
-
bridge.
|
|
6539
|
+
bridge.updatePeerActivity(peerId);
|
|
6322
6540
|
res.status(200);
|
|
6323
6541
|
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
6324
6542
|
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
6325
6543
|
res.setHeader("Connection", "keep-alive");
|
|
6326
6544
|
res.setHeader("X-Accel-Buffering", "no");
|
|
6327
6545
|
res.flushHeaders();
|
|
6328
|
-
const handle = eventTransport.open(
|
|
6546
|
+
const handle = eventTransport.open(peerId, res, () => eventStatus(peerId));
|
|
6329
6547
|
if (!handle) {
|
|
6330
6548
|
res.end();
|
|
6331
6549
|
return;
|
|
@@ -6350,9 +6568,9 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6350
6568
|
res.json({ success: true, disposition });
|
|
6351
6569
|
});
|
|
6352
6570
|
app.post("/proxy", async (req, res) => {
|
|
6353
|
-
const { endpoint, data,
|
|
6354
|
-
if (!endpoint || !
|
|
6355
|
-
res.status(400).json({ error: "endpoint
|
|
6571
|
+
const { endpoint, data, targetPeerId, proxyInstanceId, timeoutMs } = req.body;
|
|
6572
|
+
if (!endpoint || !targetPeerId) {
|
|
6573
|
+
res.status(400).json({ error: "endpoint and targetPeerId are required" });
|
|
6356
6574
|
return;
|
|
6357
6575
|
}
|
|
6358
6576
|
if (timeoutMs !== void 0 && (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 3e5)) {
|
|
@@ -6367,7 +6585,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6367
6585
|
req.once("aborted", abort);
|
|
6368
6586
|
res.once("close", abort);
|
|
6369
6587
|
try {
|
|
6370
|
-
const response = await bridge.sendRequest(endpoint, data,
|
|
6588
|
+
const response = await bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, controller.signal);
|
|
6371
6589
|
res.json({ response });
|
|
6372
6590
|
} catch (error) {
|
|
6373
6591
|
if (!res.headersSent && !res.destroyed) {
|
|
@@ -6470,10 +6688,9 @@ var StudioHttpClient = class {
|
|
|
6470
6688
|
constructor(bridge) {
|
|
6471
6689
|
this.bridge = bridge;
|
|
6472
6690
|
}
|
|
6473
|
-
async request(endpoint, data,
|
|
6691
|
+
async request(endpoint, data, targetPeerId, timeoutMs, signal) {
|
|
6474
6692
|
try {
|
|
6475
|
-
|
|
6476
|
-
return response;
|
|
6693
|
+
return await this.bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, signal);
|
|
6477
6694
|
} catch (error) {
|
|
6478
6695
|
if (error instanceof Error && error.message === "Request timeout") {
|
|
6479
6696
|
throw new Error("Studio plugin connection timeout. Make sure the Roblox Studio plugin is running and activated.");
|
|
@@ -9289,6 +9506,7 @@ var MAX_DEVICE_MATRIX_ENTRIES = 6;
|
|
|
9289
9506
|
var MAX_NETWORK_PACKET_LOSS_PERCENT = 0.5;
|
|
9290
9507
|
var GREP_SCRIPTS_TIMEOUT_MS = 12e4;
|
|
9291
9508
|
var MAX_GREP_PATTERN_UTF8_BYTES = 4096;
|
|
9509
|
+
var RUNTIME_LOG_PEER_TIMEOUT_MS = 5e3;
|
|
9292
9510
|
var STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL = "Studio Assistant Source Image";
|
|
9293
9511
|
var CREATOR_STORE_SEARCH_TYPES = /* @__PURE__ */ new Set([
|
|
9294
9512
|
"Audio",
|
|
@@ -10026,9 +10244,9 @@ var RobloxStudioTools = class {
|
|
|
10026
10244
|
this.openCloudClient = new OpenCloudClient();
|
|
10027
10245
|
this.cookieClient = new RobloxCookieClient();
|
|
10028
10246
|
this.instanceManager = new StudioInstanceManager();
|
|
10029
|
-
this.bridge.
|
|
10247
|
+
this.bridge.onPeerRegistered((peer) => {
|
|
10030
10248
|
const instanceManager = this.instanceManager;
|
|
10031
|
-
const association = this.managedConnectionAssociations.then(() => this._associateManagedEditConnection(
|
|
10249
|
+
const association = this.managedConnectionAssociations.then(() => this._associateManagedEditConnection(peer, instanceManager));
|
|
10032
10250
|
this.managedConnectionAssociations = association.catch((error) => {
|
|
10033
10251
|
console.warn(`[robloxstudio-mcp] managed Studio connection association failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
10034
10252
|
});
|
|
@@ -10093,93 +10311,109 @@ var RobloxStudioTools = class {
|
|
|
10093
10311
|
return { content: [{ type: "text", text: result.content }] };
|
|
10094
10312
|
}
|
|
10095
10313
|
_parseTextResult(result) {
|
|
10096
|
-
|
|
10097
|
-
|
|
10314
|
+
if (result === null || typeof result !== "object" || !("content" in result) || !Array.isArray(result.content)) {
|
|
10315
|
+
return {};
|
|
10316
|
+
}
|
|
10317
|
+
const first = result.content[0];
|
|
10318
|
+
if (first === null || typeof first !== "object" || !("text" in first) || typeof first.text !== "string") {
|
|
10098
10319
|
return {};
|
|
10320
|
+
}
|
|
10099
10321
|
try {
|
|
10100
|
-
const parsed = JSON.parse(text);
|
|
10101
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
10322
|
+
const parsed = JSON.parse(first.text);
|
|
10323
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
|
|
10102
10324
|
} catch {
|
|
10103
10325
|
return {};
|
|
10104
10326
|
}
|
|
10105
10327
|
}
|
|
10106
|
-
_briefRoles(instanceId
|
|
10107
|
-
const roles =
|
|
10328
|
+
_briefRoles(instanceId) {
|
|
10329
|
+
const roles = this._rolesForScope(instanceId);
|
|
10108
10330
|
return {
|
|
10109
10331
|
roles,
|
|
10110
10332
|
runtimeRoles: roles.filter((role) => role === "server" || /^client-\d+$/.test(role))
|
|
10111
10333
|
};
|
|
10112
10334
|
}
|
|
10113
|
-
|
|
10114
|
-
|
|
10115
|
-
|
|
10116
|
-
|
|
10117
|
-
|
|
10335
|
+
_routingErrorData() {
|
|
10336
|
+
const instances = this.bridge.getConnectedInstances();
|
|
10337
|
+
const multiplayerGroups = this.bridge.getConnectedMultiplayerGroups();
|
|
10338
|
+
return {
|
|
10339
|
+
instances,
|
|
10340
|
+
multiplayerGroups,
|
|
10341
|
+
count: instances.length + multiplayerGroups.length
|
|
10342
|
+
};
|
|
10343
|
+
}
|
|
10344
|
+
_peerForRoleInScope(instanceId, role) {
|
|
10345
|
+
return this.bridge.getPeersInScope(instanceId).find((peer) => peer.role === role);
|
|
10346
|
+
}
|
|
10347
|
+
_requestPeer(endpoint, data, targetPeerId, timeoutMs, signal) {
|
|
10348
|
+
return this.client.request(endpoint, data, targetPeerId, timeoutMs, signal);
|
|
10349
|
+
}
|
|
10350
|
+
_request(endpoint, data, instanceId, role, timeoutMs, signal) {
|
|
10351
|
+
const peer = this._peerForRoleInScope(instanceId, role);
|
|
10352
|
+
if (!peer) {
|
|
10353
|
+
throw new RoutingFailure({
|
|
10354
|
+
code: "target_role_not_present_on_instance",
|
|
10355
|
+
message: `Routing scope for instance "${instanceId}" has no role "${role}".`,
|
|
10356
|
+
data: this._routingErrorData()
|
|
10357
|
+
});
|
|
10358
|
+
}
|
|
10359
|
+
return this._requestPeer(endpoint, data, peer.peerId, timeoutMs, signal);
|
|
10360
|
+
}
|
|
10361
|
+
// Resolve an optional Studio process plus role to one exact Peer and dispatch.
|
|
10118
10362
|
async _callSingle(endpoint, data, target, instance_id, timeoutMs, signal) {
|
|
10119
|
-
const
|
|
10120
|
-
if (!
|
|
10121
|
-
throw new RoutingFailure(
|
|
10122
|
-
if (
|
|
10363
|
+
const resolved = this.bridge.resolveTarget({ instance_id, target });
|
|
10364
|
+
if (!resolved.ok)
|
|
10365
|
+
throw new RoutingFailure(resolved.error);
|
|
10366
|
+
if (resolved.mode !== "single") {
|
|
10123
10367
|
throw new RoutingFailure({
|
|
10124
10368
|
code: "target_role_not_present_on_instance",
|
|
10125
10369
|
message: "This tool does not support target=all. Pick a specific role or omit target.",
|
|
10126
|
-
data:
|
|
10127
|
-
instances: this.bridge.getPublicInstances(),
|
|
10128
|
-
count: this.bridge.getInstances().length
|
|
10129
|
-
}
|
|
10370
|
+
data: this._routingErrorData()
|
|
10130
10371
|
});
|
|
10131
10372
|
}
|
|
10132
|
-
|
|
10133
|
-
return this.client.request(endpoint, data, r.targetInstanceId, r.targetRole, timeoutMs);
|
|
10134
|
-
}
|
|
10135
|
-
return this.client.request(endpoint, data, r.targetInstanceId, r.targetRole, timeoutMs, signal);
|
|
10373
|
+
return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal);
|
|
10136
10374
|
}
|
|
10137
|
-
//
|
|
10138
|
-
//
|
|
10139
|
-
// running client (where the live viewport + input pipeline are) without the
|
|
10140
|
-
// caller having to pass target. Throws RoutingFailure with the standard
|
|
10141
|
-
// instance list if the place is ambiguous (multiple connected, no instance_id).
|
|
10375
|
+
// Prefer the first client role in the selected process/group scope for live
|
|
10376
|
+
// viewport and input operations; otherwise retain the default Peer's Instance.
|
|
10142
10377
|
_resolveRuntime(instance_id) {
|
|
10143
|
-
const
|
|
10144
|
-
if (!
|
|
10145
|
-
throw new RoutingFailure(
|
|
10146
|
-
|
|
10147
|
-
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10378
|
+
const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
|
|
10379
|
+
if (!resolved.ok)
|
|
10380
|
+
throw new RoutingFailure(resolved.error);
|
|
10381
|
+
if (resolved.mode !== "single") {
|
|
10382
|
+
throw new RoutingFailure({
|
|
10383
|
+
code: "target_role_not_present_on_instance",
|
|
10384
|
+
message: "A single runtime target is required.",
|
|
10385
|
+
data: this._routingErrorData()
|
|
10386
|
+
});
|
|
10387
|
+
}
|
|
10388
|
+
const client = this.bridge.getPeersInScope(resolved.targetInstanceId).filter((peer) => /^client-\d+$/.test(peer.role)).sort((a, b) => a.role.localeCompare(b.role) || a.peerId.localeCompare(b.peerId))[0];
|
|
10389
|
+
return {
|
|
10390
|
+
instanceId: client?.instanceId ?? resolved.targetInstanceId,
|
|
10391
|
+
clientRole: client?.role
|
|
10392
|
+
};
|
|
10151
10393
|
}
|
|
10152
10394
|
_resolveInstanceIdOnly(instance_id) {
|
|
10153
|
-
const instances = this.bridge.getInstances();
|
|
10154
|
-
const publicList = this.bridge.getPublicInstances();
|
|
10155
|
-
const errorData = { instances: publicList, count: publicList.length };
|
|
10156
10395
|
if (instance_id !== void 0) {
|
|
10157
|
-
const resolvedInstanceId = this.bridge.
|
|
10158
|
-
if (
|
|
10396
|
+
const resolvedInstanceId = this.bridge.resolveConnectedInstanceId(instance_id);
|
|
10397
|
+
if (resolvedInstanceId === void 0) {
|
|
10159
10398
|
throw new RoutingFailure({
|
|
10160
10399
|
code: "unrecognized_instance_id",
|
|
10161
|
-
message: `instance_id "${instance_id}" is not connected. Pass
|
|
10162
|
-
data:
|
|
10400
|
+
message: `instance_id "${instance_id}" is not connected. Pass a connected top-level or grouped role-suffixed Instance ID.`,
|
|
10401
|
+
data: this._routingErrorData()
|
|
10163
10402
|
});
|
|
10164
10403
|
}
|
|
10165
10404
|
return resolvedInstanceId;
|
|
10166
10405
|
}
|
|
10167
|
-
const
|
|
10168
|
-
if (
|
|
10169
|
-
throw new RoutingFailure(
|
|
10170
|
-
|
|
10171
|
-
message: "No Studio plugin is connected.",
|
|
10172
|
-
data: errorData
|
|
10173
|
-
});
|
|
10174
|
-
}
|
|
10175
|
-
if (distinct.length > 1) {
|
|
10406
|
+
const resolved = this.bridge.resolveTarget({ target: void 0 });
|
|
10407
|
+
if (!resolved.ok)
|
|
10408
|
+
throw new RoutingFailure(resolved.error);
|
|
10409
|
+
if (resolved.mode !== "single") {
|
|
10176
10410
|
throw new RoutingFailure({
|
|
10177
10411
|
code: "multiple_instances_connected",
|
|
10178
|
-
message: "Multiple Studio
|
|
10179
|
-
data:
|
|
10412
|
+
message: "Multiple Studio process scopes are connected. Pass instance_id to disambiguate.",
|
|
10413
|
+
data: this._routingErrorData()
|
|
10180
10414
|
});
|
|
10181
10415
|
}
|
|
10182
|
-
return
|
|
10416
|
+
return resolved.targetInstanceId;
|
|
10183
10417
|
}
|
|
10184
10418
|
_resolveSingleTarget(target, instance_id) {
|
|
10185
10419
|
const resolved = this.bridge.resolveTarget({ instance_id, target });
|
|
@@ -10189,27 +10423,27 @@ var RobloxStudioTools = class {
|
|
|
10189
10423
|
throw new RoutingFailure({
|
|
10190
10424
|
code: "target_role_not_present_on_instance",
|
|
10191
10425
|
message: "Pick a specific target role for this tool.",
|
|
10192
|
-
data:
|
|
10193
|
-
instances: this.bridge.getPublicInstances(),
|
|
10194
|
-
count: this.bridge.getInstances().length
|
|
10195
|
-
}
|
|
10426
|
+
data: this._routingErrorData()
|
|
10196
10427
|
});
|
|
10197
10428
|
}
|
|
10198
|
-
return {
|
|
10199
|
-
|
|
10200
|
-
|
|
10201
|
-
|
|
10429
|
+
return {
|
|
10430
|
+
targetPeerId: resolved.targetPeerId,
|
|
10431
|
+
instanceId: resolved.targetInstanceId,
|
|
10432
|
+
role: resolved.targetRole
|
|
10433
|
+
};
|
|
10202
10434
|
}
|
|
10203
|
-
|
|
10204
|
-
|
|
10205
|
-
return this.bridge.getInstances().filter((i) => instanceIds.has(i.instanceId)).map((i) => i.role);
|
|
10435
|
+
_rolesForScope(instanceId) {
|
|
10436
|
+
return this.bridge.getPeersInScope(instanceId).map((peer) => peer.role);
|
|
10206
10437
|
}
|
|
10207
|
-
|
|
10208
|
-
return this.
|
|
10438
|
+
_clientRolesForScope(instanceId) {
|
|
10439
|
+
return this._rolesForScope(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
|
|
10209
10440
|
}
|
|
10210
|
-
|
|
10211
|
-
|
|
10212
|
-
|
|
10441
|
+
_runtimeTargetsForScope(instanceId) {
|
|
10442
|
+
return this.bridge.getPeersInScope(instanceId).filter((peer) => peer.role === "server" || /^client-\d+$/.test(peer.role)).map((peer) => ({
|
|
10443
|
+
targetPeerId: peer.peerId,
|
|
10444
|
+
instanceId: peer.instanceId,
|
|
10445
|
+
role: peer.role
|
|
10446
|
+
}));
|
|
10213
10447
|
}
|
|
10214
10448
|
_compactSimulationResetResult(result) {
|
|
10215
10449
|
const compact = {};
|
|
@@ -10236,15 +10470,12 @@ var RobloxStudioTools = class {
|
|
|
10236
10470
|
const selectedTarget = target ?? "edit";
|
|
10237
10471
|
if (selectedTarget === "all-clients") {
|
|
10238
10472
|
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
10239
|
-
const roles = this.
|
|
10473
|
+
const roles = this._clientRolesForScope(instanceId);
|
|
10240
10474
|
if (roles.length === 0) {
|
|
10241
10475
|
throw new RoutingFailure({
|
|
10242
10476
|
code: "target_role_not_present_on_instance",
|
|
10243
10477
|
message: `instance "${instanceId}" has no connected playtest client roles. Start a playtest first.`,
|
|
10244
|
-
data:
|
|
10245
|
-
instances: this.bridge.getPublicInstances(),
|
|
10246
|
-
count: this.bridge.getInstances().length
|
|
10247
|
-
}
|
|
10478
|
+
data: this._routingErrorData()
|
|
10248
10479
|
});
|
|
10249
10480
|
}
|
|
10250
10481
|
return { instanceId, selectedTarget, roles };
|
|
@@ -10265,8 +10496,8 @@ var RobloxStudioTools = class {
|
|
|
10265
10496
|
throw new Error(`${toolName} target must be "edit", "client-N", "all-clients", or "edit-and-clients" (got: ${selectedTarget})`);
|
|
10266
10497
|
}
|
|
10267
10498
|
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
10268
|
-
const connectedRoles = this.
|
|
10269
|
-
const clientRoles = this.
|
|
10499
|
+
const connectedRoles = this._rolesForScope(instanceId);
|
|
10500
|
+
const clientRoles = this._clientRolesForScope(instanceId);
|
|
10270
10501
|
const warnings = [];
|
|
10271
10502
|
let roles;
|
|
10272
10503
|
if (selectedTarget === "edit") {
|
|
@@ -10274,10 +10505,7 @@ var RobloxStudioTools = class {
|
|
|
10274
10505
|
throw new RoutingFailure({
|
|
10275
10506
|
code: "target_role_not_present_on_instance",
|
|
10276
10507
|
message: `instance "${instanceId}" has no role "edit". Available roles: ${connectedRoles.join(", ") || "none"}.`,
|
|
10277
|
-
data:
|
|
10278
|
-
instances: this.bridge.getPublicInstances(),
|
|
10279
|
-
count: this.bridge.getInstances().length
|
|
10280
|
-
}
|
|
10508
|
+
data: this._routingErrorData()
|
|
10281
10509
|
});
|
|
10282
10510
|
}
|
|
10283
10511
|
roles = ["edit"];
|
|
@@ -10299,10 +10527,7 @@ var RobloxStudioTools = class {
|
|
|
10299
10527
|
throw new RoutingFailure({
|
|
10300
10528
|
code: "target_role_not_present_on_instance",
|
|
10301
10529
|
message: `instance "${instanceId}" has no role "${selectedTarget}". Available client roles: ${clientRoles.join(", ") || "none"}.`,
|
|
10302
|
-
data:
|
|
10303
|
-
instances: this.bridge.getPublicInstances(),
|
|
10304
|
-
count: this.bridge.getInstances().length
|
|
10305
|
-
}
|
|
10530
|
+
data: this._routingErrorData()
|
|
10306
10531
|
});
|
|
10307
10532
|
}
|
|
10308
10533
|
roles = [selectedTarget];
|
|
@@ -10330,12 +10555,12 @@ var RobloxStudioTools = class {
|
|
|
10330
10555
|
}
|
|
10331
10556
|
async _executeNetworkStateOperation(instanceId, role, operation) {
|
|
10332
10557
|
const code = buildNetworkStateLuau(operation);
|
|
10333
|
-
const response = await this.
|
|
10558
|
+
const response = await this._request("/api/execute-luau", { code }, instanceId, role);
|
|
10334
10559
|
return this._parseExecuteLuauJsonResponse(response, `network simulation ${operation}`);
|
|
10335
10560
|
}
|
|
10336
10561
|
async _executeDeviceSimulatorOperation(instanceId, role, operation, options) {
|
|
10337
10562
|
const code = buildDeviceSimulatorLuau(operation, options);
|
|
10338
|
-
const response = await this.
|
|
10563
|
+
const response = await this._request("/api/execute-luau", { code }, instanceId, role);
|
|
10339
10564
|
return this._parseExecuteLuauJsonResponse(response, `device simulator ${operation}`);
|
|
10340
10565
|
}
|
|
10341
10566
|
_settingsFromDeviceSimulatorState(state) {
|
|
@@ -10376,11 +10601,11 @@ var RobloxStudioTools = class {
|
|
|
10376
10601
|
throw new Error(`capture_device_matrix cannot safely restore active custom device "${s.activeDeviceId}". Switch the simulator to default or a built-in preset first, or pass restoreAfter=false only if you intentionally accept changing the simulator state.`);
|
|
10377
10602
|
}
|
|
10378
10603
|
}
|
|
10379
|
-
async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30
|
|
10604
|
+
async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
|
|
10380
10605
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
10381
10606
|
while (Date.now() < deadline) {
|
|
10382
|
-
const roles =
|
|
10383
|
-
const clientRoles =
|
|
10607
|
+
const roles = this._rolesForScope(instanceId);
|
|
10608
|
+
const clientRoles = this._clientRolesForScope(instanceId);
|
|
10384
10609
|
const hasServer = !opts.server || roles.includes("server");
|
|
10385
10610
|
const hasClients = opts.clientCount === void 0 || clientRoles.length >= opts.clientCount;
|
|
10386
10611
|
const absent = opts.absentRole === void 0 || !roles.includes(opts.absentRole);
|
|
@@ -10392,7 +10617,7 @@ var RobloxStudioTools = class {
|
|
|
10392
10617
|
}
|
|
10393
10618
|
return {
|
|
10394
10619
|
ok: false,
|
|
10395
|
-
roles:
|
|
10620
|
+
roles: this._rolesForScope(instanceId),
|
|
10396
10621
|
timedOut: true
|
|
10397
10622
|
};
|
|
10398
10623
|
}
|
|
@@ -10400,8 +10625,8 @@ var RobloxStudioTools = class {
|
|
|
10400
10625
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
10401
10626
|
let exactSince;
|
|
10402
10627
|
while (Date.now() < deadline) {
|
|
10403
|
-
const roles2 = this.
|
|
10404
|
-
const clientCount2 = this.
|
|
10628
|
+
const roles2 = this._rolesForScope(instanceId);
|
|
10629
|
+
const clientCount2 = this._clientRolesForScope(instanceId).length;
|
|
10405
10630
|
if (clientCount2 > expectedClientCount) {
|
|
10406
10631
|
return { ok: false, roles: roles2, timedOut: false, extraClients: true, clientCount: clientCount2 };
|
|
10407
10632
|
}
|
|
@@ -10415,17 +10640,16 @@ var RobloxStudioTools = class {
|
|
|
10415
10640
|
}
|
|
10416
10641
|
await sleep(250);
|
|
10417
10642
|
}
|
|
10418
|
-
const roles = this.
|
|
10419
|
-
const clientCount = this.
|
|
10643
|
+
const roles = this._rolesForScope(instanceId);
|
|
10644
|
+
const clientCount = this._clientRolesForScope(instanceId).length;
|
|
10420
10645
|
return { ok: false, roles, timedOut: true, extraClients: clientCount > expectedClientCount, clientCount };
|
|
10421
10646
|
}
|
|
10422
|
-
async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60
|
|
10647
|
+
async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60) {
|
|
10423
10648
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
10424
10649
|
while (Date.now() < deadline) {
|
|
10425
|
-
const
|
|
10426
|
-
const
|
|
10427
|
-
const
|
|
10428
|
-
const freshRoles = new Set(instances.filter((i) => i.connectedAt >= connectedAfter).map((i) => i.role));
|
|
10650
|
+
const peers = this.bridge.getPeersInScope(instanceId);
|
|
10651
|
+
const roles = peers.map((peer) => peer.role);
|
|
10652
|
+
const freshRoles = new Set(peers.filter((peer) => peer.connectedAt >= connectedAfter).map((peer) => peer.role));
|
|
10429
10653
|
if (requiredRoles.every((role) => freshRoles.has(role))) {
|
|
10430
10654
|
return { ok: true, roles, timedOut: false };
|
|
10431
10655
|
}
|
|
@@ -10433,7 +10657,7 @@ var RobloxStudioTools = class {
|
|
|
10433
10657
|
}
|
|
10434
10658
|
return {
|
|
10435
10659
|
ok: false,
|
|
10436
|
-
roles:
|
|
10660
|
+
roles: this._rolesForScope(instanceId),
|
|
10437
10661
|
timedOut: true
|
|
10438
10662
|
};
|
|
10439
10663
|
}
|
|
@@ -10788,7 +11012,7 @@ var RobloxStudioTools = class {
|
|
|
10788
11012
|
async setNetworkProfile(profile, target, overrides, instance_id) {
|
|
10789
11013
|
const values = normalizeNetworkProfile(profile, overrides);
|
|
10790
11014
|
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
10791
|
-
const clientRoles = this.
|
|
11015
|
+
const clientRoles = this._clientRolesForScope(instanceId);
|
|
10792
11016
|
const selectedTarget = target ?? "client-1";
|
|
10793
11017
|
let targetRoles;
|
|
10794
11018
|
if (selectedTarget === "all-clients") {
|
|
@@ -10798,10 +11022,7 @@ var RobloxStudioTools = class {
|
|
|
10798
11022
|
throw new RoutingFailure({
|
|
10799
11023
|
code: "target_role_not_present_on_instance",
|
|
10800
11024
|
message: `instance "${instanceId}" has no role "${selectedTarget}". Available client roles: ${clientRoles.join(", ") || "none"}.`,
|
|
10801
|
-
data:
|
|
10802
|
-
instances: this.bridge.getPublicInstances(),
|
|
10803
|
-
count: this.bridge.getInstances().length
|
|
10804
|
-
}
|
|
11025
|
+
data: this._routingErrorData()
|
|
10805
11026
|
});
|
|
10806
11027
|
}
|
|
10807
11028
|
targetRoles = [selectedTarget];
|
|
@@ -10812,15 +11033,12 @@ var RobloxStudioTools = class {
|
|
|
10812
11033
|
throw new RoutingFailure({
|
|
10813
11034
|
code: "target_role_not_present_on_instance",
|
|
10814
11035
|
message: `instance "${instanceId}" has no connected playtest client roles. Start a playtest first.`,
|
|
10815
|
-
data:
|
|
10816
|
-
instances: this.bridge.getPublicInstances(),
|
|
10817
|
-
count: this.bridge.getInstances().length
|
|
10818
|
-
}
|
|
11036
|
+
data: this._routingErrorData()
|
|
10819
11037
|
});
|
|
10820
11038
|
}
|
|
10821
11039
|
const code = buildNetworkProfileLuau(profile, values);
|
|
10822
11040
|
const responses = await Promise.allSettled(targetRoles.map(async (role) => {
|
|
10823
|
-
const response = await this.
|
|
11041
|
+
const response = await this._request("/api/execute-luau", { code }, instanceId, role);
|
|
10824
11042
|
const result = this._parseExecuteLuauJsonResponse(response, "set_network_profile");
|
|
10825
11043
|
return { role, result };
|
|
10826
11044
|
}));
|
|
@@ -11150,98 +11368,237 @@ var RobloxStudioTools = class {
|
|
|
11150
11368
|
]
|
|
11151
11369
|
};
|
|
11152
11370
|
}
|
|
11153
|
-
async getRuntimeLogs(
|
|
11154
|
-
|
|
11155
|
-
|
|
11156
|
-
if (since !== void 0)
|
|
11157
|
-
data.since = since;
|
|
11158
|
-
if (tail !== void 0)
|
|
11159
|
-
data.tail = tail;
|
|
11160
|
-
if (filter !== void 0)
|
|
11161
|
-
data.filter = filter;
|
|
11162
|
-
const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
|
|
11163
|
-
if (!resolved.ok)
|
|
11164
|
-
throw new RoutingFailure(resolved.error);
|
|
11165
|
-
if (resolved.mode === "single") {
|
|
11166
|
-
const originPeerReliable2 = await this._isMultiplayerTestRunning(resolved.targetInstanceId);
|
|
11167
|
-
const response = await this.client.request("/api/get-runtime-logs", data, resolved.targetInstanceId, resolved.targetRole);
|
|
11168
|
-
response.capturedBy = resolved.targetRole;
|
|
11169
|
-
delete response.peer;
|
|
11170
|
-
response.originPeerReliable = originPeerReliable2;
|
|
11171
|
-
response.peerAttribution = originPeerReliable2 ? "guaranteed_multiplayer" : "unavailable_shared_logservice";
|
|
11172
|
-
if (originPeerReliable2)
|
|
11173
|
-
response.peer = resolved.targetRole;
|
|
11174
|
-
if (Array.isArray(response.entries)) {
|
|
11175
|
-
for (const e of response.entries) {
|
|
11176
|
-
e.capturedBy = resolved.targetRole;
|
|
11177
|
-
delete e.peer;
|
|
11178
|
-
if (originPeerReliable2)
|
|
11179
|
-
e.peer = resolved.targetRole;
|
|
11180
|
-
}
|
|
11181
|
-
}
|
|
11182
|
-
return {
|
|
11183
|
-
content: [{ type: "text", text: JSON.stringify(response) }]
|
|
11184
|
-
};
|
|
11371
|
+
async getRuntimeLogs(instance_id, multiplayer_group_id, cursor, cursor_by_instance, tail, filter, signal) {
|
|
11372
|
+
if (instance_id !== void 0 && multiplayer_group_id !== void 0) {
|
|
11373
|
+
throw new Error("get_runtime_logs accepts only one of instance_id or multiplayer_group_id.");
|
|
11185
11374
|
}
|
|
11186
|
-
|
|
11187
|
-
|
|
11188
|
-
|
|
11189
|
-
|
|
11190
|
-
|
|
11191
|
-
}
|
|
11192
|
-
const
|
|
11193
|
-
const
|
|
11194
|
-
|
|
11195
|
-
let
|
|
11196
|
-
|
|
11197
|
-
|
|
11198
|
-
|
|
11199
|
-
|
|
11200
|
-
|
|
11201
|
-
|
|
11202
|
-
|
|
11203
|
-
|
|
11375
|
+
if (cursor !== void 0 && cursor_by_instance !== void 0) {
|
|
11376
|
+
throw new Error("get_runtime_logs accepts only one of cursor or cursor_by_instance.");
|
|
11377
|
+
}
|
|
11378
|
+
if (tail !== void 0 && (!Number.isInteger(tail) || tail < 0)) {
|
|
11379
|
+
throw new Error("get_runtime_logs tail must be a non-negative integer.");
|
|
11380
|
+
}
|
|
11381
|
+
const instances = this.bridge.getInstances();
|
|
11382
|
+
const groups = this.bridge.getMultiplayerGroups();
|
|
11383
|
+
let selectedGroup = multiplayer_group_id === void 0 ? void 0 : groups.find((group) => group.id === multiplayer_group_id);
|
|
11384
|
+
let selectedInstanceId = instance_id === void 0 ? void 0 : this._resolveInstanceIdOnly(instance_id);
|
|
11385
|
+
if (multiplayer_group_id !== void 0 && !selectedGroup) {
|
|
11386
|
+
throw new RoutingFailure({
|
|
11387
|
+
code: "unrecognized_instance_id",
|
|
11388
|
+
message: `multiplayer_group_id "${multiplayer_group_id}" is not connected.`,
|
|
11389
|
+
data: this._routingErrorData()
|
|
11390
|
+
});
|
|
11391
|
+
}
|
|
11392
|
+
if (selectedInstanceId !== void 0 && !instances.some((instance) => instance.id === selectedInstanceId)) {
|
|
11393
|
+
throw new RoutingFailure({
|
|
11394
|
+
code: "unrecognized_instance_id",
|
|
11395
|
+
message: `instance_id "${selectedInstanceId}" is not connected. Pass a connected top-level or grouped role-suffixed Instance ID.`,
|
|
11396
|
+
data: this._routingErrorData()
|
|
11397
|
+
});
|
|
11398
|
+
}
|
|
11399
|
+
if (selectedGroup === void 0 && selectedInstanceId === void 0) {
|
|
11400
|
+
const groupedInstanceIds = new Set(groups.flatMap((group) => group.instanceIds));
|
|
11401
|
+
const standaloneInstanceIds = instances.map((instance) => instance.id).filter((id) => !groupedInstanceIds.has(id));
|
|
11402
|
+
const scopeCount = groups.length + standaloneInstanceIds.length;
|
|
11403
|
+
if (scopeCount === 0) {
|
|
11404
|
+
throw new RoutingFailure({
|
|
11405
|
+
code: "unrecognized_instance_id",
|
|
11406
|
+
message: "No Studio plugin is connected.",
|
|
11407
|
+
data: this._routingErrorData()
|
|
11408
|
+
});
|
|
11409
|
+
}
|
|
11410
|
+
if (scopeCount > 1) {
|
|
11411
|
+
throw new RoutingFailure({
|
|
11412
|
+
code: "multiple_instances_connected",
|
|
11413
|
+
message: "Multiple Studio process scopes are connected. Pass instance_id or multiplayer_group_id.",
|
|
11414
|
+
data: this._routingErrorData()
|
|
11415
|
+
});
|
|
11204
11416
|
}
|
|
11205
|
-
if (
|
|
11206
|
-
|
|
11207
|
-
|
|
11208
|
-
|
|
11209
|
-
const entry = { ...e };
|
|
11210
|
-
delete entry.peer;
|
|
11211
|
-
merged.push({ ...entry, capturedBy });
|
|
11417
|
+
if (groups.length === 1) {
|
|
11418
|
+
selectedGroup = groups[0];
|
|
11419
|
+
} else {
|
|
11420
|
+
selectedInstanceId = standaloneInstanceIds[0];
|
|
11212
11421
|
}
|
|
11213
11422
|
}
|
|
11214
|
-
|
|
11215
|
-
|
|
11216
|
-
const deduped = [];
|
|
11217
|
-
for (const e of merged) {
|
|
11218
|
-
const isDup = deduped.some((d) => d.message === e.message && d.level === e.level && Math.abs(d.ts - e.ts) <= DEDUP_WINDOW && d.capturedBy !== e.capturedBy);
|
|
11219
|
-
if (!isDup)
|
|
11220
|
-
deduped.push(e);
|
|
11423
|
+
if (selectedGroup !== void 0 && cursor !== void 0) {
|
|
11424
|
+
throw new Error("Use cursor_by_instance when reading a multiplayer group.");
|
|
11221
11425
|
}
|
|
11222
|
-
|
|
11223
|
-
|
|
11224
|
-
final = deduped.slice(deduped.length - tail);
|
|
11426
|
+
if (selectedGroup === void 0 && cursor_by_instance !== void 0) {
|
|
11427
|
+
throw new Error("Use cursor when reading one Instance.");
|
|
11225
11428
|
}
|
|
11226
|
-
const
|
|
11227
|
-
|
|
11228
|
-
|
|
11229
|
-
|
|
11230
|
-
|
|
11231
|
-
|
|
11232
|
-
|
|
11429
|
+
const decodeCursor = (value, instanceId) => {
|
|
11430
|
+
if (value === void 0)
|
|
11431
|
+
return {};
|
|
11432
|
+
let decoded;
|
|
11433
|
+
try {
|
|
11434
|
+
decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
11435
|
+
} catch {
|
|
11436
|
+
throw new Error(`get_runtime_logs received an invalid cursor for Instance "${instanceId}".`);
|
|
11437
|
+
}
|
|
11438
|
+
if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) {
|
|
11439
|
+
throw new Error(`get_runtime_logs received an invalid cursor for Instance "${instanceId}".`);
|
|
11440
|
+
}
|
|
11441
|
+
const payload = decoded;
|
|
11442
|
+
if (payload.version !== 1 || payload.instanceId !== instanceId || typeof payload.peers !== "object" || payload.peers === null || Array.isArray(payload.peers)) {
|
|
11443
|
+
throw new Error(`get_runtime_logs cursor does not belong to Instance "${instanceId}".`);
|
|
11444
|
+
}
|
|
11445
|
+
const peers = payload.peers;
|
|
11446
|
+
const parsed = {};
|
|
11447
|
+
for (const [peerId, nextSince] of Object.entries(peers)) {
|
|
11448
|
+
if (typeof nextSince !== "number" || !Number.isInteger(nextSince) || nextSince < 0) {
|
|
11449
|
+
throw new Error(`get_runtime_logs received an invalid cursor for Instance "${instanceId}".`);
|
|
11450
|
+
}
|
|
11451
|
+
parsed[peerId] = nextSince;
|
|
11452
|
+
}
|
|
11453
|
+
return parsed;
|
|
11454
|
+
};
|
|
11455
|
+
const encodeCursor = (instanceId, peers) => {
|
|
11456
|
+
const orderedPeers = {};
|
|
11457
|
+
for (const peerId of Object.keys(peers).sort())
|
|
11458
|
+
orderedPeers[peerId] = peers[peerId];
|
|
11459
|
+
const payload = {
|
|
11460
|
+
version: 1,
|
|
11461
|
+
instanceId,
|
|
11462
|
+
peers: orderedPeers
|
|
11463
|
+
};
|
|
11464
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
11465
|
+
};
|
|
11466
|
+
const roleRank = (role) => {
|
|
11467
|
+
if (role === "edit")
|
|
11468
|
+
return 0;
|
|
11469
|
+
if (role === "server")
|
|
11470
|
+
return 1;
|
|
11471
|
+
const client = /^client-(\d+)$/.exec(role);
|
|
11472
|
+
return client ? 2 + Number(client[1]) : Number.MAX_SAFE_INTEGER;
|
|
11473
|
+
};
|
|
11474
|
+
const entryTimestamp = (entry) => {
|
|
11475
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
11476
|
+
return 0;
|
|
11477
|
+
const record = entry;
|
|
11478
|
+
return typeof record.ts === "number" ? record.ts : 0;
|
|
11479
|
+
};
|
|
11480
|
+
const publicEntry = (entry) => {
|
|
11481
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
11482
|
+
return entry;
|
|
11483
|
+
const record = entry;
|
|
11484
|
+
const copy = { ...record };
|
|
11485
|
+
delete copy.seq;
|
|
11486
|
+
return copy;
|
|
11487
|
+
};
|
|
11488
|
+
const readInstance = async (instanceId, instanceCursor) => {
|
|
11489
|
+
const peers = this.bridge.getPeers().filter((peer) => peer.instanceId === instanceId).sort((a, b) => roleRank(a.role) - roleRank(b.role) || a.peerId.localeCompare(b.peerId));
|
|
11490
|
+
const priorByPeer = decodeCursor(instanceCursor, instanceId);
|
|
11491
|
+
const nextByPeer = {};
|
|
11492
|
+
for (const peer of peers) {
|
|
11493
|
+
const prior = priorByPeer[peer.peerId];
|
|
11494
|
+
if (prior !== void 0)
|
|
11495
|
+
nextByPeer[peer.peerId] = prior;
|
|
11496
|
+
}
|
|
11497
|
+
if (peers.length === 0) {
|
|
11498
|
+
return {
|
|
11499
|
+
instanceId,
|
|
11500
|
+
error: "No connected Peer exists for this Instance.",
|
|
11501
|
+
nextCursor: encodeCursor(instanceId, nextByPeer),
|
|
11502
|
+
peerErrors: []
|
|
11503
|
+
};
|
|
11504
|
+
}
|
|
11505
|
+
const reads = await Promise.all(peers.map(async (peer) => {
|
|
11506
|
+
const data = {};
|
|
11507
|
+
const peerSince = priorByPeer[peer.peerId];
|
|
11508
|
+
if (peerSince !== void 0)
|
|
11509
|
+
data.since = peerSince;
|
|
11510
|
+
if (tail !== void 0)
|
|
11511
|
+
data.tail = tail;
|
|
11512
|
+
if (filter !== void 0)
|
|
11513
|
+
data.filter = filter;
|
|
11514
|
+
try {
|
|
11515
|
+
const responseValue = await this.client.request("/api/get-runtime-logs", data, peer.peerId, RUNTIME_LOG_PEER_TIMEOUT_MS, signal);
|
|
11516
|
+
if (typeof responseValue !== "object" || responseValue === null || Array.isArray(responseValue)) {
|
|
11517
|
+
return {
|
|
11518
|
+
peerId: peer.peerId,
|
|
11519
|
+
role: peer.role,
|
|
11520
|
+
error: "Studio returned an invalid runtime log response."
|
|
11521
|
+
};
|
|
11522
|
+
}
|
|
11523
|
+
const response = responseValue;
|
|
11524
|
+
if (typeof response.error === "string") {
|
|
11525
|
+
return { peerId: peer.peerId, role: peer.role, error: response.error };
|
|
11526
|
+
}
|
|
11527
|
+
if (!Array.isArray(response.entries) || typeof response.totalDropped !== "number" || typeof response.nextSince !== "number") {
|
|
11528
|
+
return {
|
|
11529
|
+
peerId: peer.peerId,
|
|
11530
|
+
role: peer.role,
|
|
11531
|
+
error: "Studio returned an invalid runtime log response."
|
|
11532
|
+
};
|
|
11533
|
+
}
|
|
11534
|
+
return {
|
|
11535
|
+
peerId: peer.peerId,
|
|
11536
|
+
role: peer.role,
|
|
11537
|
+
entries: response.entries,
|
|
11538
|
+
totalDropped: response.totalDropped,
|
|
11539
|
+
nextSince: response.nextSince
|
|
11540
|
+
};
|
|
11541
|
+
} catch (error) {
|
|
11542
|
+
if (signal?.aborted)
|
|
11543
|
+
throw error;
|
|
11544
|
+
return { peerId: peer.peerId, role: peer.role, error: errorMessage(error) };
|
|
11545
|
+
}
|
|
11546
|
+
}));
|
|
11547
|
+
const successful = [];
|
|
11548
|
+
const peerErrors = [];
|
|
11549
|
+
for (const read of reads) {
|
|
11550
|
+
if ("error" in read) {
|
|
11551
|
+
peerErrors.push(read);
|
|
11552
|
+
} else {
|
|
11553
|
+
successful.push(read);
|
|
11554
|
+
nextByPeer[read.peerId] = read.nextSince;
|
|
11555
|
+
}
|
|
11556
|
+
}
|
|
11557
|
+
const nextCursor = encodeCursor(instanceId, nextByPeer);
|
|
11558
|
+
if (successful.length === 0) {
|
|
11559
|
+
return {
|
|
11560
|
+
instanceId,
|
|
11561
|
+
error: "Every connected Peer failed to read its runtime log buffer.",
|
|
11562
|
+
nextCursor,
|
|
11563
|
+
peerErrors
|
|
11564
|
+
};
|
|
11565
|
+
}
|
|
11566
|
+
let insertionOrder = 0;
|
|
11567
|
+
const merged = successful.flatMap((read) => read.entries.map((entry) => ({
|
|
11568
|
+
entry: publicEntry(entry),
|
|
11569
|
+
timestamp: entryTimestamp(entry),
|
|
11570
|
+
insertionOrder: insertionOrder++
|
|
11571
|
+
})));
|
|
11572
|
+
merged.sort((a, b) => a.timestamp - b.timestamp || a.insertionOrder - b.insertionOrder);
|
|
11573
|
+
const allEntries = merged.map((item) => item.entry);
|
|
11574
|
+
const entries = tail === void 0 ? allEntries : tail === 0 ? [] : allEntries.slice(-tail);
|
|
11575
|
+
const totalDropped = successful.reduce((total, read) => total + read.totalDropped, 0);
|
|
11576
|
+
return {
|
|
11577
|
+
instanceId,
|
|
11578
|
+
entries,
|
|
11579
|
+
totalDropped,
|
|
11580
|
+
nextCursor,
|
|
11581
|
+
...peerErrors.length > 0 ? { peerErrors } : {}
|
|
11582
|
+
};
|
|
11233
11583
|
};
|
|
11234
|
-
if (
|
|
11235
|
-
|
|
11584
|
+
if (selectedGroup) {
|
|
11585
|
+
const connectedIds = new Set(instances.map((instance) => instance.id));
|
|
11586
|
+
const instanceIds = selectedGroup.instanceIds.filter((id) => connectedIds.has(id));
|
|
11587
|
+
const results = await Promise.all(instanceIds.map((instanceId) => readInstance(instanceId, cursor_by_instance?.[instanceId])));
|
|
11588
|
+
const nextCursorByInstance = {};
|
|
11589
|
+
for (const result2 of results)
|
|
11590
|
+
nextCursorByInstance[result2.instanceId] = result2.nextCursor;
|
|
11591
|
+
return this._textResult({
|
|
11592
|
+
multiplayerGroupId: selectedGroup.id,
|
|
11593
|
+
instances: results,
|
|
11594
|
+
nextCursorByInstance
|
|
11595
|
+
});
|
|
11236
11596
|
}
|
|
11237
|
-
|
|
11238
|
-
|
|
11239
|
-
|
|
11240
|
-
body.perPeerErrors = perCaptureErrors;
|
|
11597
|
+
const result = await readInstance(selectedInstanceId, cursor);
|
|
11598
|
+
if ("error" in result) {
|
|
11599
|
+
throw new Error(`get_runtime_logs failed for Instance "${result.instanceId}": ${result.error}`);
|
|
11241
11600
|
}
|
|
11242
|
-
return
|
|
11243
|
-
content: [{ type: "text", text: JSON.stringify(body) }]
|
|
11244
|
-
};
|
|
11601
|
+
return this._textResult(result);
|
|
11245
11602
|
}
|
|
11246
11603
|
async captureScriptProfiler(target, request = {}, instance_id) {
|
|
11247
11604
|
const targetRole = target ?? "server";
|
|
@@ -11261,15 +11618,12 @@ var RobloxStudioTools = class {
|
|
|
11261
11618
|
throw new RoutingFailure({
|
|
11262
11619
|
code: "target_role_not_present_on_instance",
|
|
11263
11620
|
message: 'capture_script_profiler profiles one runtime peer at a time. Pick target="server" or a specific "client-N".',
|
|
11264
|
-
data:
|
|
11265
|
-
instances: this.bridge.getPublicInstances(),
|
|
11266
|
-
count: this.bridge.getInstances().length
|
|
11267
|
-
}
|
|
11621
|
+
data: this._routingErrorData()
|
|
11268
11622
|
});
|
|
11269
11623
|
}
|
|
11270
11624
|
data.__mcp_instance_id = resolved.targetInstanceId;
|
|
11271
11625
|
data.__mcp_target_role = resolved.targetRole;
|
|
11272
|
-
const response = await this.
|
|
11626
|
+
const response = await this._requestPeer("/api/capture-script-profiler", data, resolved.targetPeerId);
|
|
11273
11627
|
const body = response !== null && typeof response === "object" && !Array.isArray(response) ? { ...response, target: resolved.targetRole } : response;
|
|
11274
11628
|
if (body !== null && typeof body === "object" && !Array.isArray(body)) {
|
|
11275
11629
|
const mutable = body;
|
|
@@ -11324,15 +11678,12 @@ var RobloxStudioTools = class {
|
|
|
11324
11678
|
throw new RoutingFailure({
|
|
11325
11679
|
code: "target_role_not_present_on_instance",
|
|
11326
11680
|
message: 'capture_micro_profiler profiles one runtime peer at a time. Pick target="server" or a specific "client-N".',
|
|
11327
|
-
data:
|
|
11328
|
-
instances: this.bridge.getPublicInstances(),
|
|
11329
|
-
count: this.bridge.getInstances().length
|
|
11330
|
-
}
|
|
11681
|
+
data: this._routingErrorData()
|
|
11331
11682
|
});
|
|
11332
11683
|
}
|
|
11333
11684
|
data.__mcp_instance_id = resolved.targetInstanceId;
|
|
11334
11685
|
data.__mcp_target_role = resolved.targetRole;
|
|
11335
|
-
const response = await this.
|
|
11686
|
+
const response = await this._requestPeer("/api/capture-micro-profiler", data, resolved.targetPeerId);
|
|
11336
11687
|
const body = response !== null && typeof response === "object" && !Array.isArray(response) ? { ...response, target: resolved.targetRole } : response;
|
|
11337
11688
|
if (body !== null && typeof body === "object" && !Array.isArray(body)) {
|
|
11338
11689
|
const mutable = body;
|
|
@@ -11381,15 +11732,11 @@ var RobloxStudioTools = class {
|
|
|
11381
11732
|
throw new RoutingFailure({
|
|
11382
11733
|
code: "target_role_not_present_on_instance",
|
|
11383
11734
|
message: "This tool does not support target=all. Pick a specific role or omit target.",
|
|
11384
|
-
data:
|
|
11385
|
-
instances: this.bridge.getPublicInstances(),
|
|
11386
|
-
count: this.bridge.getInstances().length
|
|
11387
|
-
}
|
|
11735
|
+
data: this._routingErrorData()
|
|
11388
11736
|
});
|
|
11389
11737
|
}
|
|
11390
|
-
data.__mcp_instance_id = resolved.targetInstanceId;
|
|
11391
11738
|
data.__mcp_target_role = resolved.targetRole;
|
|
11392
|
-
const response = await this.
|
|
11739
|
+
const response = await this._requestPeer("/api/breakpoints", data, resolved.targetPeerId);
|
|
11393
11740
|
const body = response !== null && typeof response === "object" && !Array.isArray(response) ? { ...response, target: resolved.targetRole } : response;
|
|
11394
11741
|
return { content: [{ type: "text", text: JSON.stringify(body) }] };
|
|
11395
11742
|
}
|
|
@@ -11412,12 +11759,8 @@ var RobloxStudioTools = class {
|
|
|
11412
11759
|
}
|
|
11413
11760
|
return value;
|
|
11414
11761
|
}
|
|
11415
|
-
_publicInstanceKey(
|
|
11416
|
-
return `${
|
|
11417
|
-
}
|
|
11418
|
-
async _isLatestPublishedPlaceOpen(placeId) {
|
|
11419
|
-
const publishedInstanceId2 = `place:${placeId}`;
|
|
11420
|
-
return this.bridge.getPublicInstances().some((instance) => instance.placeId === placeId || instance.instanceId === publishedInstanceId2) || (await this.instanceManager.list()).some((record) => record.closedAt === void 0 && record.source === "published_place" && record.placeId === placeId);
|
|
11762
|
+
_publicInstanceKey(peer) {
|
|
11763
|
+
return `${peer.peerId}:${peer.instanceId}:${peer.connectedAt}`;
|
|
11421
11764
|
}
|
|
11422
11765
|
_matchesManagedLaunch(record, instance) {
|
|
11423
11766
|
if (record.source === "published_place") {
|
|
@@ -11455,7 +11798,7 @@ var RobloxStudioTools = class {
|
|
|
11455
11798
|
if (record.state === "failed" || record.state === "exited" || record.closedAt !== void 0) {
|
|
11456
11799
|
return void 0;
|
|
11457
11800
|
}
|
|
11458
|
-
const candidates = this.bridge.
|
|
11801
|
+
const candidates = this.bridge.getPublicPeers().filter((peer) => peer.role === "edit").filter((instance) => !beforeKeys.has(this._publicInstanceKey(instance))).filter((instance) => instance.connectedAt >= record.launchedAt - 1e3).filter((instance) => this._matchesManagedLaunch(record, instance)).sort((a, b) => b.connectedAt - a.connectedAt);
|
|
11459
11802
|
if (candidates[0])
|
|
11460
11803
|
return candidates[0];
|
|
11461
11804
|
await sleep(500);
|
|
@@ -11463,7 +11806,7 @@ var RobloxStudioTools = class {
|
|
|
11463
11806
|
return void 0;
|
|
11464
11807
|
}
|
|
11465
11808
|
_managedStatus(record) {
|
|
11466
|
-
const connected = record.instanceId ? this.bridge.
|
|
11809
|
+
const connected = record.instanceId ? this.bridge.getPublicPeers().filter((peer) => peer.instanceId === record.instanceId) : [];
|
|
11467
11810
|
return {
|
|
11468
11811
|
launch_id: record.recordId,
|
|
11469
11812
|
instance_id: record.instanceId,
|
|
@@ -11555,7 +11898,7 @@ var RobloxStudioTools = class {
|
|
|
11555
11898
|
}
|
|
11556
11899
|
if (instance_id) {
|
|
11557
11900
|
const record2 = await this.instanceManager.get(instance_id);
|
|
11558
|
-
const connected2 = this.bridge.
|
|
11901
|
+
const connected2 = this.bridge.getPublicPeers().filter((peer) => peer.instanceId === instance_id);
|
|
11559
11902
|
if (!record2 && connected2.length === 0) {
|
|
11560
11903
|
return this._textResult({ error: "Instance is not connected or managed.", instance_id });
|
|
11561
11904
|
}
|
|
@@ -11573,10 +11916,10 @@ var RobloxStudioTools = class {
|
|
|
11573
11916
|
return this._textResult({
|
|
11574
11917
|
managed: (await this.instanceManager.list()).filter((record2) => record2.closedAt === void 0).map((record2) => this._managedStatus(record2)),
|
|
11575
11918
|
connected: this.bridge.getPublicInstances().map((instance) => ({
|
|
11576
|
-
instance_id: instance.
|
|
11577
|
-
role: instance.role,
|
|
11919
|
+
instance_id: instance.id,
|
|
11578
11920
|
place_id: instance.placeId,
|
|
11579
|
-
place_name: instance.placeName
|
|
11921
|
+
place_name: instance.placeName,
|
|
11922
|
+
roles: instance.peers.map((peer) => peer.role).sort()
|
|
11580
11923
|
}))
|
|
11581
11924
|
});
|
|
11582
11925
|
}
|
|
@@ -11613,8 +11956,8 @@ var RobloxStudioTools = class {
|
|
|
11613
11956
|
message: managedClose.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
|
|
11614
11957
|
});
|
|
11615
11958
|
}
|
|
11616
|
-
const connected2 = this.bridge.
|
|
11617
|
-
const edit = connected2.find((
|
|
11959
|
+
const connected2 = this.bridge.getPublicPeers().filter((peer) => peer.instanceId === instance_id);
|
|
11960
|
+
const edit = connected2.find((peer) => peer.role === "edit");
|
|
11618
11961
|
if (!edit) {
|
|
11619
11962
|
return this._textResult({
|
|
11620
11963
|
error: "Instance is not connected or managed.",
|
|
@@ -11679,12 +12022,6 @@ var RobloxStudioTools = class {
|
|
|
11679
12022
|
}
|
|
11680
12023
|
const processEnvironment = parseStudioProcessEnvironmentPatch(request.process_environment);
|
|
11681
12024
|
const studioWorkingDirectory = parseStudioWorkingDirectory(request.studio_working_directory);
|
|
11682
|
-
if (launchSource === "published_place" && placeId !== void 0 && await this._isLatestPublishedPlaceOpen(placeId)) {
|
|
11683
|
-
return this._textResult({
|
|
11684
|
-
error: "Place is already open.",
|
|
11685
|
-
message: `place_id ${placeId} is already connected. Use the existing instance or launch a specific place_revision.`
|
|
11686
|
-
});
|
|
11687
|
-
}
|
|
11688
12025
|
const universeId = launchSource === "published_place" || launchSource === "place_revision" ? await this._deriveUniverseId(placeId) : void 0;
|
|
11689
12026
|
if (request.require_process_identity !== void 0 && typeof request.require_process_identity !== "boolean") {
|
|
11690
12027
|
throw new Error("require_process_identity must be a boolean when provided.");
|
|
@@ -11692,7 +12029,7 @@ var RobloxStudioTools = class {
|
|
|
11692
12029
|
const requireProcessIdentity = request.require_process_identity === true;
|
|
11693
12030
|
const waitForConnection = !requireProcessIdentity && request.wait_for_connection !== false;
|
|
11694
12031
|
const timeoutMs = this._optionalPositiveInteger(request.timeout_ms, "timeout_ms") ?? 12e4;
|
|
11695
|
-
const beforeKeys = new Set(this.bridge.
|
|
12032
|
+
const beforeKeys = new Set(this.bridge.getPublicPeers().map((peer) => this._publicInstanceKey(peer)));
|
|
11696
12033
|
const record = await this.instanceManager.launch({
|
|
11697
12034
|
source: launchSource,
|
|
11698
12035
|
localPlaceFile,
|
|
@@ -11739,7 +12076,7 @@ var RobloxStudioTools = class {
|
|
|
11739
12076
|
}
|
|
11740
12077
|
if (action === "status") {
|
|
11741
12078
|
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
11742
|
-
const { roles, runtimeRoles } = this._briefRoles(instanceId
|
|
12079
|
+
const { roles, runtimeRoles } = this._briefRoles(instanceId);
|
|
11743
12080
|
return this._textResult({
|
|
11744
12081
|
success: true,
|
|
11745
12082
|
action,
|
|
@@ -11802,22 +12139,19 @@ var RobloxStudioTools = class {
|
|
|
11802
12139
|
throw new RoutingFailure({
|
|
11803
12140
|
code: "target_role_not_present_on_instance",
|
|
11804
12141
|
message: "This tool does not support target=all. Pick a specific role or omit target.",
|
|
11805
|
-
data:
|
|
11806
|
-
instances: this.bridge.getPublicInstances(),
|
|
11807
|
-
count: this.bridge.getInstances().length
|
|
11808
|
-
}
|
|
12142
|
+
data: this._routingErrorData()
|
|
11809
12143
|
});
|
|
11810
12144
|
}
|
|
11811
|
-
const existingRuntime = this.
|
|
12145
|
+
const existingRuntime = this._runtimeTargetsForScope(resolved.targetInstanceId);
|
|
11812
12146
|
if (existingRuntime.length > 0) {
|
|
11813
|
-
const roles = this.
|
|
12147
|
+
const roles = this._rolesForScope(resolved.targetInstanceId);
|
|
11814
12148
|
return {
|
|
11815
12149
|
content: [{
|
|
11816
12150
|
type: "text",
|
|
11817
12151
|
text: JSON.stringify({
|
|
11818
12152
|
success: false,
|
|
11819
12153
|
error: "Playtest already running.",
|
|
11820
|
-
message: "A playtest is already running for this Studio
|
|
12154
|
+
message: "A playtest is already running for this Studio process scope. Stop the current playtest before starting another.",
|
|
11821
12155
|
runtimeReady: true,
|
|
11822
12156
|
timedOut: false,
|
|
11823
12157
|
roles,
|
|
@@ -11826,11 +12160,11 @@ var RobloxStudioTools = class {
|
|
|
11826
12160
|
}]
|
|
11827
12161
|
};
|
|
11828
12162
|
}
|
|
11829
|
-
const response = await this.
|
|
12163
|
+
const response = await this._requestPeer("/api/start-playtest", data, resolved.targetPeerId);
|
|
11830
12164
|
let wait;
|
|
11831
12165
|
if (response?.success === true) {
|
|
11832
12166
|
const requiredRoles = mode === "play" ? ["server", "client-1"] : ["server"];
|
|
11833
|
-
wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles, timeout
|
|
12167
|
+
wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles, timeout);
|
|
11834
12168
|
}
|
|
11835
12169
|
const body = wait ? {
|
|
11836
12170
|
...response,
|
|
@@ -11852,7 +12186,7 @@ var RobloxStudioTools = class {
|
|
|
11852
12186
|
let response;
|
|
11853
12187
|
let stopRequestError;
|
|
11854
12188
|
try {
|
|
11855
|
-
response = await this.
|
|
12189
|
+
response = await this._request("/api/stop-playtest", {}, instanceId, "edit");
|
|
11856
12190
|
} catch (error) {
|
|
11857
12191
|
stopRequestError = errorMessage(error);
|
|
11858
12192
|
response = {
|
|
@@ -11863,11 +12197,11 @@ var RobloxStudioTools = class {
|
|
|
11863
12197
|
}
|
|
11864
12198
|
let wait;
|
|
11865
12199
|
if (response?.success === true) {
|
|
11866
|
-
wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, timeout
|
|
11867
|
-
} else if (this.
|
|
12200
|
+
wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, timeout);
|
|
12201
|
+
} else if (this._runtimeTargetsForScope(instanceId).length > 0) {
|
|
11868
12202
|
wait = {
|
|
11869
12203
|
ok: false,
|
|
11870
|
-
roles: this.
|
|
12204
|
+
roles: this._rolesForScope(instanceId),
|
|
11871
12205
|
timedOut: false
|
|
11872
12206
|
};
|
|
11873
12207
|
}
|
|
@@ -11898,9 +12232,11 @@ var RobloxStudioTools = class {
|
|
|
11898
12232
|
};
|
|
11899
12233
|
}
|
|
11900
12234
|
async _buildMultiplayerState(instanceId) {
|
|
11901
|
-
const peers = this.bridge.
|
|
12235
|
+
const peers = this.bridge.getPublicPeers().filter((peer) => this.bridge.getInstanceIdsInScope(instanceId).includes(peer.instanceId)).sort((a, b) => a.role.localeCompare(b.role));
|
|
12236
|
+
const multiplayerGroup = this.bridge.getMultiplayerGroups().find((group) => group.instanceIds.includes(instanceId));
|
|
11902
12237
|
const body = {
|
|
11903
12238
|
instanceId,
|
|
12239
|
+
multiplayerGroupId: multiplayerGroup?.id,
|
|
11904
12240
|
peers,
|
|
11905
12241
|
peerCount: peers.length
|
|
11906
12242
|
};
|
|
@@ -11910,7 +12246,7 @@ var RobloxStudioTools = class {
|
|
|
11910
12246
|
let serverState;
|
|
11911
12247
|
if (edit) {
|
|
11912
12248
|
try {
|
|
11913
|
-
editState = await this.
|
|
12249
|
+
editState = await this._request("/api/multiplayer-test-state", {}, instanceId, "edit");
|
|
11914
12250
|
body.edit = editState;
|
|
11915
12251
|
} catch (err2) {
|
|
11916
12252
|
body.edit = { error: err2 instanceof Error ? err2.message : String(err2) };
|
|
@@ -11918,7 +12254,7 @@ var RobloxStudioTools = class {
|
|
|
11918
12254
|
}
|
|
11919
12255
|
if (server) {
|
|
11920
12256
|
try {
|
|
11921
|
-
serverState = await this.
|
|
12257
|
+
serverState = await this._request("/api/multiplayer-test-state", {}, instanceId, "server");
|
|
11922
12258
|
body.server = serverState;
|
|
11923
12259
|
} catch (err2) {
|
|
11924
12260
|
body.server = { error: err2 instanceof Error ? err2.message : String(err2) };
|
|
@@ -11935,16 +12271,16 @@ var RobloxStudioTools = class {
|
|
|
11935
12271
|
body.error = session?.error;
|
|
11936
12272
|
body.players = serverState?.players ?? [];
|
|
11937
12273
|
body.playerCount = serverState?.playerCount ?? 0;
|
|
11938
|
-
body.clientRoles = this.
|
|
12274
|
+
body.clientRoles = this._clientRolesForScope(instanceId);
|
|
11939
12275
|
return body;
|
|
11940
12276
|
}
|
|
11941
12277
|
async _waitForMultiplayerEditDone(instanceId, timeoutSec = 30) {
|
|
11942
12278
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
11943
12279
|
while (Date.now() < deadline) {
|
|
11944
|
-
if (!this.
|
|
12280
|
+
if (!this._rolesForScope(instanceId).includes("edit"))
|
|
11945
12281
|
return false;
|
|
11946
12282
|
try {
|
|
11947
|
-
const editState = await this.
|
|
12283
|
+
const editState = await this._request("/api/multiplayer-test-state", {}, instanceId, "edit");
|
|
11948
12284
|
const phase = editState?.session?.phase;
|
|
11949
12285
|
if (phase === "completed" || phase === "failed")
|
|
11950
12286
|
return true;
|
|
@@ -11955,19 +12291,7 @@ var RobloxStudioTools = class {
|
|
|
11955
12291
|
return false;
|
|
11956
12292
|
}
|
|
11957
12293
|
async _isMultiplayerTestRunning(instanceId) {
|
|
11958
|
-
|
|
11959
|
-
const hasServer = roles.includes("server");
|
|
11960
|
-
const clientCount = roles.filter((role) => role.startsWith("client-")).length;
|
|
11961
|
-
if (roles.includes("edit")) {
|
|
11962
|
-
try {
|
|
11963
|
-
const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
|
|
11964
|
-
const phase = editState?.session?.phase;
|
|
11965
|
-
if (phase === "starting" || phase === "running")
|
|
11966
|
-
return true;
|
|
11967
|
-
} catch {
|
|
11968
|
-
}
|
|
11969
|
-
}
|
|
11970
|
-
return hasServer && clientCount >= 2;
|
|
12294
|
+
return this.bridge.getMultiplayerGroups().some((group) => group.instanceIds.includes(instanceId));
|
|
11971
12295
|
}
|
|
11972
12296
|
async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30, connectedAfter) {
|
|
11973
12297
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
@@ -11976,8 +12300,8 @@ var RobloxStudioTools = class {
|
|
|
11976
12300
|
const exact = await this._waitForExactClientCount(instanceId, clientCount, 0.25, 0);
|
|
11977
12301
|
if (exact.ok || exact.extraClients) {
|
|
11978
12302
|
if (exact.ok && connectedAfter !== void 0) {
|
|
11979
|
-
const
|
|
11980
|
-
const freshRoles = new Set(
|
|
12303
|
+
const peers = this.bridge.getPeersInScope(instanceId);
|
|
12304
|
+
const freshRoles = new Set(peers.filter((peer) => peer.connectedAt >= connectedAfter).map((peer) => peer.role));
|
|
11981
12305
|
const freshClientCount = [...freshRoles].filter((role) => /^client-\d+$/.test(role)).length;
|
|
11982
12306
|
if (!freshRoles.has("server") || freshClientCount !== clientCount) {
|
|
11983
12307
|
await sleep(250);
|
|
@@ -11988,19 +12312,19 @@ var RobloxStudioTools = class {
|
|
|
11988
12312
|
}
|
|
11989
12313
|
try {
|
|
11990
12314
|
const remainingMs = Math.max(1, Math.min(1e3, deadline - Date.now()));
|
|
11991
|
-
const editState = await this.
|
|
12315
|
+
const editState = await this._request("/api/multiplayer-test-state", {}, instanceId, "edit", remainingMs);
|
|
11992
12316
|
const session = editState?.session;
|
|
11993
12317
|
if (typeof session?.phase === "string") {
|
|
11994
12318
|
lastPhase = session.phase;
|
|
11995
12319
|
}
|
|
11996
12320
|
if (session?.phase === "failed") {
|
|
11997
|
-
return { ok: false, roles: this.
|
|
12321
|
+
return { ok: false, roles: this._rolesForScope(instanceId), timedOut: false, phase: session.phase, error: session.error };
|
|
11998
12322
|
}
|
|
11999
12323
|
} catch {
|
|
12000
12324
|
}
|
|
12001
12325
|
await sleep(250);
|
|
12002
12326
|
}
|
|
12003
|
-
return { ok: false, roles: this.
|
|
12327
|
+
return { ok: false, roles: this._rolesForScope(instanceId), timedOut: true, phase: lastPhase };
|
|
12004
12328
|
}
|
|
12005
12329
|
async multiplayerPlaytest(action, numPlayers, target, testArgs, value, timeout, instance_id) {
|
|
12006
12330
|
if (action !== "start" && action !== "status" && action !== "add_players" && action !== "leave_client" && action !== "end") {
|
|
@@ -12008,9 +12332,11 @@ var RobloxStudioTools = class {
|
|
|
12008
12332
|
}
|
|
12009
12333
|
const briefState = async (instanceId) => {
|
|
12010
12334
|
const state = await this._buildMultiplayerState(this._resolveInstanceIdOnly(instanceId));
|
|
12335
|
+
const roles = Array.isArray(state.peers) ? state.peers.flatMap((peer) => peer !== null && typeof peer === "object" && "role" in peer && typeof peer.role === "string" ? [peer.role] : []) : [];
|
|
12011
12336
|
return {
|
|
12012
12337
|
phase: state.phase,
|
|
12013
|
-
|
|
12338
|
+
multiplayerGroupId: typeof state.multiplayerGroupId === "string" ? state.multiplayerGroupId : void 0,
|
|
12339
|
+
roles,
|
|
12014
12340
|
playerCount: typeof state.playerCount === "number" ? state.playerCount : void 0,
|
|
12015
12341
|
error: typeof state.error === "string" ? state.error : void 0
|
|
12016
12342
|
};
|
|
@@ -12024,25 +12350,32 @@ var RobloxStudioTools = class {
|
|
|
12024
12350
|
}
|
|
12025
12351
|
if (action === "start") {
|
|
12026
12352
|
const body2 = this._parseTextResult(await this.multiplayerTestStart(numPlayers, testArgs, timeout, instance_id));
|
|
12027
|
-
const
|
|
12353
|
+
const stateValue = body2.state;
|
|
12354
|
+
const state = stateValue !== null && typeof stateValue === "object" && !Array.isArray(stateValue) ? { ...stateValue } : {};
|
|
12355
|
+
const waitValue = body2.wait;
|
|
12356
|
+
const wait = waitValue !== null && typeof waitValue === "object" && !Array.isArray(waitValue) ? { ...waitValue } : {};
|
|
12028
12357
|
const launched = body2.success === true && body2.ready === true;
|
|
12358
|
+
const multiplayerGroupId2 = typeof body2.multiplayerGroupId === "string" ? body2.multiplayerGroupId : typeof body2.testId === "string" ? body2.testId : void 0;
|
|
12029
12359
|
return this._textResult(launched ? {
|
|
12030
12360
|
success: true,
|
|
12031
12361
|
action,
|
|
12032
12362
|
message: "Multiplayer playtest started.",
|
|
12363
|
+
multiplayerGroupId: multiplayerGroupId2,
|
|
12033
12364
|
roles: Array.isArray(body2.roles) ? body2.roles : void 0,
|
|
12034
12365
|
playerCount: typeof state.playerCount === "number" ? state.playerCount : void 0
|
|
12035
12366
|
} : {
|
|
12036
12367
|
success: false,
|
|
12037
12368
|
action,
|
|
12038
|
-
error: body2.error ??
|
|
12369
|
+
error: body2.error ?? wait.error ?? "multiplayer_start_not_detected",
|
|
12039
12370
|
message: body2.success === true ? "Multiplayer playtest start was requested, but MCP did not detect the required server/client peers before timeout." : body2.message ?? "Multiplayer playtest did not start.",
|
|
12371
|
+
multiplayerGroupId: multiplayerGroupId2,
|
|
12040
12372
|
roles: Array.isArray(body2.roles) ? body2.roles : void 0
|
|
12041
12373
|
});
|
|
12042
12374
|
}
|
|
12043
12375
|
if (action === "add_players") {
|
|
12044
12376
|
const body2 = this._parseTextResult(await this.multiplayerTestAddPlayers(numPlayers, timeout, instance_id));
|
|
12045
|
-
const
|
|
12377
|
+
const stateValue = body2.state;
|
|
12378
|
+
const state = stateValue !== null && typeof stateValue === "object" && !Array.isArray(stateValue) ? { ...stateValue } : {};
|
|
12046
12379
|
const success = body2.success === true && body2.ready === true;
|
|
12047
12380
|
return this._textResult(success ? {
|
|
12048
12381
|
success: true,
|
|
@@ -12074,14 +12407,17 @@ var RobloxStudioTools = class {
|
|
|
12074
12407
|
});
|
|
12075
12408
|
}
|
|
12076
12409
|
const body = this._parseTextResult(await this.multiplayerTestEnd(value, timeout, instance_id));
|
|
12410
|
+
const multiplayerGroupId = typeof body.multiplayerGroupId === "string" ? body.multiplayerGroupId : void 0;
|
|
12077
12411
|
return this._textResult(body.success === true && body.ended === true ? {
|
|
12078
12412
|
success: true,
|
|
12079
12413
|
action,
|
|
12414
|
+
multiplayerGroupId,
|
|
12080
12415
|
message: body.alreadyEnded === true ? "Multiplayer playtest already ended." : body.teardownConfirmed === false ? 'Multiplayer playtest end requested; teardown still in progress. Use multiplayer_playtest action="status" to confirm.' : "Multiplayer playtest ended.",
|
|
12081
12416
|
teardownConfirmed: body.teardownConfirmed === true
|
|
12082
12417
|
} : {
|
|
12083
12418
|
success: false,
|
|
12084
12419
|
action,
|
|
12420
|
+
multiplayerGroupId,
|
|
12085
12421
|
error: body.error ?? "end_failed",
|
|
12086
12422
|
message: body.message ?? "Multiplayer playtest did not end.",
|
|
12087
12423
|
roles: Array.isArray(body.roles) ? body.roles : void 0,
|
|
@@ -12093,13 +12429,13 @@ var RobloxStudioTools = class {
|
|
|
12093
12429
|
throw new Error("numPlayers must be an integer from 1 to 8");
|
|
12094
12430
|
}
|
|
12095
12431
|
const editTarget = this._resolveSingleTarget("edit", instance_id);
|
|
12096
|
-
const existingRuntime = this.
|
|
12432
|
+
const existingRuntime = this._runtimeTargetsForScope(editTarget.instanceId);
|
|
12097
12433
|
if (existingRuntime.length > 0) {
|
|
12098
|
-
const roles = this.
|
|
12434
|
+
const roles = this._rolesForScope(editTarget.instanceId);
|
|
12099
12435
|
return this._textResult({
|
|
12100
12436
|
success: false,
|
|
12101
12437
|
error: "Multiplayer playtest already running.",
|
|
12102
|
-
message: "A Studio runtime is already connected for this
|
|
12438
|
+
message: "A Studio runtime is already connected for this process scope. End the existing playtest before starting another multiplayer playtest.",
|
|
12103
12439
|
ready: true,
|
|
12104
12440
|
timedOut: false,
|
|
12105
12441
|
roles,
|
|
@@ -12107,23 +12443,35 @@ var RobloxStudioTools = class {
|
|
|
12107
12443
|
});
|
|
12108
12444
|
}
|
|
12109
12445
|
const startedAt = Date.now();
|
|
12110
|
-
const response = await this.
|
|
12111
|
-
|
|
12112
|
-
|
|
12446
|
+
const response = await this._requestPeer("/api/multiplayer-test-start", { numPlayers, testArgs: testArgs ?? {} }, editTarget.targetPeerId);
|
|
12447
|
+
const groupId = typeof response?.testId === "string" ? response.testId : void 0;
|
|
12448
|
+
if (response?.error || response?.success !== true || groupId === void 0) {
|
|
12449
|
+
if (groupId !== void 0)
|
|
12450
|
+
await this.bridge.removeMultiplayerGroupEverywhere(groupId);
|
|
12451
|
+
return this._textResult({
|
|
12452
|
+
...response,
|
|
12453
|
+
error: response?.error ?? "Multiplayer start did not return a testId."
|
|
12454
|
+
});
|
|
12113
12455
|
}
|
|
12456
|
+
await this.bridge.createMultiplayerGroupEverywhere(groupId, editTarget.instanceId);
|
|
12114
12457
|
const wait = await this._waitForMultiplayerStart(editTarget.instanceId, numPlayers, timeout ?? 60, startedAt);
|
|
12115
12458
|
const launched = wait.ok;
|
|
12116
12459
|
const state = await this._buildMultiplayerState(editTarget.instanceId);
|
|
12117
|
-
const success =
|
|
12460
|
+
const success = wait.ok;
|
|
12461
|
+
const runtimeStillConnected = this._runtimeTargetsForScope(editTarget.instanceId).length > 0;
|
|
12462
|
+
const definitelyFailed = state.phase === "failed" && !runtimeStillConnected;
|
|
12463
|
+
if (definitelyFailed)
|
|
12464
|
+
await this.bridge.removeMultiplayerGroupEverywhere(groupId);
|
|
12118
12465
|
return {
|
|
12119
12466
|
content: [{
|
|
12120
12467
|
type: "text",
|
|
12121
12468
|
text: JSON.stringify({
|
|
12122
12469
|
...response,
|
|
12470
|
+
multiplayerGroupId: groupId,
|
|
12123
12471
|
success,
|
|
12124
12472
|
ready: wait.ok,
|
|
12125
12473
|
launched,
|
|
12126
|
-
startRequested:
|
|
12474
|
+
startRequested: true,
|
|
12127
12475
|
timedOut: wait.timedOut,
|
|
12128
12476
|
wait,
|
|
12129
12477
|
roles: wait.roles,
|
|
@@ -12145,13 +12493,15 @@ var RobloxStudioTools = class {
|
|
|
12145
12493
|
throw new Error("numPlayers must be an integer from 1 to 8");
|
|
12146
12494
|
}
|
|
12147
12495
|
const serverTarget = this._resolveSingleTarget("server", instance_id);
|
|
12148
|
-
const
|
|
12149
|
-
const
|
|
12496
|
+
const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(serverTarget.instanceId));
|
|
12497
|
+
const scopeInstanceId = group?.controllerInstanceId ?? serverTarget.instanceId;
|
|
12498
|
+
const before = this._clientRolesForScope(scopeInstanceId).length;
|
|
12499
|
+
const response = await this._requestPeer("/api/multiplayer-test-add-players", { numPlayers, timeout: timeout ?? 10 }, serverTarget.targetPeerId);
|
|
12150
12500
|
if (response?.error) {
|
|
12151
12501
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
12152
12502
|
}
|
|
12153
|
-
const wait = await this._waitForExactClientCount(
|
|
12154
|
-
const state = await this._buildMultiplayerState(
|
|
12503
|
+
const wait = await this._waitForExactClientCount(scopeInstanceId, before + numPlayers, timeout ?? 30);
|
|
12504
|
+
const state = await this._buildMultiplayerState(scopeInstanceId);
|
|
12155
12505
|
return {
|
|
12156
12506
|
content: [{
|
|
12157
12507
|
type: "text",
|
|
@@ -12171,12 +12521,14 @@ var RobloxStudioTools = class {
|
|
|
12171
12521
|
throw new Error(`multiplayer_test_leave_client requires target=client-N (got: ${target})`);
|
|
12172
12522
|
}
|
|
12173
12523
|
const clientTarget = this._resolveSingleTarget(target, instance_id);
|
|
12174
|
-
const
|
|
12524
|
+
const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(clientTarget.instanceId));
|
|
12525
|
+
const scopeInstanceId = group?.controllerInstanceId ?? clientTarget.instanceId;
|
|
12526
|
+
const response = await this._requestPeer("/api/multiplayer-test-leave-client", {}, clientTarget.targetPeerId);
|
|
12175
12527
|
if (response?.error) {
|
|
12176
12528
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
12177
12529
|
}
|
|
12178
|
-
const wait = await this._waitForRuntimeRoles(
|
|
12179
|
-
const state = await this._buildMultiplayerState(
|
|
12530
|
+
const wait = await this._waitForRuntimeRoles(scopeInstanceId, { absentRole: clientTarget.role }, timeout ?? 30);
|
|
12531
|
+
const state = await this._buildMultiplayerState(scopeInstanceId);
|
|
12180
12532
|
return {
|
|
12181
12533
|
content: [{
|
|
12182
12534
|
type: "text",
|
|
@@ -12194,29 +12546,39 @@ var RobloxStudioTools = class {
|
|
|
12194
12546
|
let serverTarget;
|
|
12195
12547
|
try {
|
|
12196
12548
|
serverTarget = this._resolveSingleTarget("server", instance_id);
|
|
12197
|
-
} catch (
|
|
12549
|
+
} catch (error) {
|
|
12198
12550
|
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
12199
|
-
const
|
|
12551
|
+
const group2 = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(instanceId));
|
|
12552
|
+
const hasRuntime = this._rolesForScope(instanceId).some((role) => role === "server" || /^client-\d+$/.test(role));
|
|
12200
12553
|
if (!hasRuntime) {
|
|
12554
|
+
if (group2)
|
|
12555
|
+
await this.bridge.removeMultiplayerGroupEverywhere(group2.id);
|
|
12201
12556
|
return this._textResult({
|
|
12202
12557
|
success: true,
|
|
12558
|
+
multiplayerGroupId: group2?.id,
|
|
12203
12559
|
ended: true,
|
|
12204
12560
|
alreadyEnded: true,
|
|
12205
12561
|
teardownConfirmed: true,
|
|
12206
12562
|
message: "No active multiplayer test to end (already ended)."
|
|
12207
12563
|
});
|
|
12208
12564
|
}
|
|
12209
|
-
throw
|
|
12565
|
+
throw error;
|
|
12210
12566
|
}
|
|
12211
|
-
const
|
|
12567
|
+
const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(serverTarget.instanceId));
|
|
12568
|
+
const scopeInstanceId = group?.controllerInstanceId ?? serverTarget.instanceId;
|
|
12569
|
+
const response = await this._requestPeer("/api/multiplayer-test-end", { value: value ?? "ended_by_mcp" }, serverTarget.targetPeerId);
|
|
12212
12570
|
if (response?.error) {
|
|
12213
|
-
return this._textResult(
|
|
12571
|
+
return this._textResult({
|
|
12572
|
+
...response,
|
|
12573
|
+
multiplayerGroupId: group?.id
|
|
12574
|
+
});
|
|
12214
12575
|
}
|
|
12215
|
-
const editDone = await this._waitForMultiplayerEditDone(
|
|
12216
|
-
const wait = await this._waitForRuntimeRoles(
|
|
12217
|
-
const state = await this._buildMultiplayerState(
|
|
12218
|
-
|
|
12576
|
+
const editDone = await this._waitForMultiplayerEditDone(scopeInstanceId, timeout ?? 30);
|
|
12577
|
+
const wait = await this._waitForRuntimeRoles(scopeInstanceId, { noRuntime: true }, timeout ?? 30);
|
|
12578
|
+
const state = await this._buildMultiplayerState(scopeInstanceId);
|
|
12579
|
+
const result = this._textResult({
|
|
12219
12580
|
...response,
|
|
12581
|
+
multiplayerGroupId: group?.id,
|
|
12220
12582
|
ended: response.success === true,
|
|
12221
12583
|
teardownConfirmed: wait.ok,
|
|
12222
12584
|
editDone,
|
|
@@ -12224,24 +12586,15 @@ var RobloxStudioTools = class {
|
|
|
12224
12586
|
roles: wait.roles,
|
|
12225
12587
|
state
|
|
12226
12588
|
});
|
|
12589
|
+
if (wait.ok && group)
|
|
12590
|
+
await this.bridge.removeMultiplayerGroupEverywhere(group.id);
|
|
12591
|
+
return result;
|
|
12227
12592
|
}
|
|
12228
12593
|
async getConnectedInstances() {
|
|
12229
|
-
|
|
12230
|
-
|
|
12231
|
-
|
|
12232
|
-
|
|
12233
|
-
place = {
|
|
12234
|
-
id: peer.instanceId,
|
|
12235
|
-
name: peer.placeName || peer.dataModelName,
|
|
12236
|
-
roles: []
|
|
12237
|
-
};
|
|
12238
|
-
places.set(peer.instanceId, place);
|
|
12239
|
-
}
|
|
12240
|
-
if (!place.roles.includes(peer.role)) {
|
|
12241
|
-
place.roles.push(peer.role);
|
|
12242
|
-
}
|
|
12243
|
-
}
|
|
12244
|
-
return this._textResult({ instances: [...places.values()] });
|
|
12594
|
+
return this._textResult({
|
|
12595
|
+
instances: this.bridge.getConnectedInstances(),
|
|
12596
|
+
multiplayerGroups: this.bridge.getConnectedMultiplayerGroups()
|
|
12597
|
+
});
|
|
12245
12598
|
}
|
|
12246
12599
|
// === Asset Tools ===
|
|
12247
12600
|
async searchAssets(assetType, query, maxResults, sortBy, robloxCreatedOnly) {
|
|
@@ -12825,13 +13178,13 @@ var RobloxStudioTools = class {
|
|
|
12825
13178
|
if (!resolved.ok)
|
|
12826
13179
|
throw new RoutingFailure(resolved.error);
|
|
12827
13180
|
if (resolved.mode === "single") {
|
|
12828
|
-
const response = await this.
|
|
13181
|
+
const response = await this._requestPeer("/api/get-memory-breakdown", data, resolved.targetPeerId);
|
|
12829
13182
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
12830
13183
|
}
|
|
12831
13184
|
const targets = resolved.targets;
|
|
12832
13185
|
const responses = await Promise.allSettled(targets.map(async (t) => ({
|
|
12833
13186
|
peer: t.targetRole,
|
|
12834
|
-
result: await this.
|
|
13187
|
+
result: await this._requestPeer("/api/get-memory-breakdown", data, t.targetPeerId)
|
|
12835
13188
|
})));
|
|
12836
13189
|
const body = {};
|
|
12837
13190
|
for (let i = 0; i < responses.length; i++) {
|
|
@@ -12858,13 +13211,13 @@ var RobloxStudioTools = class {
|
|
|
12858
13211
|
if (!resolved.ok)
|
|
12859
13212
|
throw new RoutingFailure(resolved.error);
|
|
12860
13213
|
if (resolved.mode === "single") {
|
|
12861
|
-
const response = await this.
|
|
13214
|
+
const response = await this._requestPeer("/api/get-scene-analysis", data, resolved.targetPeerId);
|
|
12862
13215
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
12863
13216
|
}
|
|
12864
13217
|
const targets = resolved.targets;
|
|
12865
13218
|
const responses = await Promise.allSettled(targets.map(async (t) => ({
|
|
12866
13219
|
peer: t.targetRole,
|
|
12867
|
-
result: await this.
|
|
13220
|
+
result: await this._requestPeer("/api/get-scene-analysis", data, t.targetPeerId)
|
|
12868
13221
|
})));
|
|
12869
13222
|
const body = {};
|
|
12870
13223
|
for (let i = 0; i < responses.length; i++) {
|
|
@@ -13085,12 +13438,17 @@ var RobloxStudioTools = class {
|
|
|
13085
13438
|
// ../core/dist/proxy-bridge-service.js
|
|
13086
13439
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
13087
13440
|
var PROXY_RESPONSE_GRACE_MS = 5e3;
|
|
13441
|
+
function peerPublicationChanged(previous, current) {
|
|
13442
|
+
return previous === void 0 || previous.transportPeerId !== current.transportPeerId || previous.instanceId !== current.instanceId || previous.multiplayerGroupId !== current.multiplayerGroupId || previous.role !== current.role || previous.placeId !== current.placeId || previous.placeName !== current.placeName || previous.placeKey !== current.placeKey || previous.dataModelName !== current.dataModelName || previous.isRunning !== current.isRunning || previous.pluginVersion !== current.pluginVersion || previous.pluginVariant !== current.pluginVariant || previous.serverVersion !== current.serverVersion;
|
|
13443
|
+
}
|
|
13088
13444
|
var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
13089
13445
|
primaryBaseUrl;
|
|
13090
13446
|
authToken;
|
|
13091
13447
|
proxyInstanceId;
|
|
13092
13448
|
proxyRequestTimeout = 3e4;
|
|
13449
|
+
cachedPeers = [];
|
|
13093
13450
|
cachedInstances = [];
|
|
13451
|
+
cachedMultiplayerGroups = [];
|
|
13094
13452
|
initialRefresh;
|
|
13095
13453
|
refreshTimer;
|
|
13096
13454
|
static REFRESH_INTERVAL_MS = 1e3;
|
|
@@ -13099,8 +13457,8 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
13099
13457
|
this.primaryBaseUrl = primaryBaseUrl;
|
|
13100
13458
|
this.authToken = authToken;
|
|
13101
13459
|
this.proxyInstanceId = randomUUID4();
|
|
13102
|
-
this.initialRefresh = this.
|
|
13103
|
-
this.refreshTimer = setInterval(() => this.
|
|
13460
|
+
this.initialRefresh = this.refreshTopology();
|
|
13461
|
+
this.refreshTimer = setInterval(() => this.refreshTopology(), _ProxyBridgeService.REFRESH_INTERVAL_MS);
|
|
13104
13462
|
}
|
|
13105
13463
|
waitForInitialRefresh() {
|
|
13106
13464
|
return this.initialRefresh;
|
|
@@ -13111,29 +13469,99 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
13111
13469
|
headers["X-MCP-Auth"] = this.authToken;
|
|
13112
13470
|
return headers;
|
|
13113
13471
|
}
|
|
13114
|
-
async
|
|
13472
|
+
async refreshTopology() {
|
|
13115
13473
|
try {
|
|
13116
|
-
const res = await fetch(`${this.primaryBaseUrl}/
|
|
13474
|
+
const res = await fetch(`${this.primaryBaseUrl}/topology`, {
|
|
13117
13475
|
headers: this.authHeaders()
|
|
13118
13476
|
});
|
|
13119
13477
|
if (!res.ok)
|
|
13120
13478
|
return;
|
|
13121
13479
|
const body = await res.json();
|
|
13122
|
-
if (Array.isArray(body.instances)) {
|
|
13123
|
-
|
|
13124
|
-
|
|
13125
|
-
|
|
13126
|
-
|
|
13127
|
-
|
|
13128
|
-
|
|
13480
|
+
if (!Array.isArray(body.peers) || !Array.isArray(body.instances) || !Array.isArray(body.multiplayerGroups)) {
|
|
13481
|
+
return;
|
|
13482
|
+
}
|
|
13483
|
+
const previousPeers = new Map(this.cachedPeers.map((peer) => [peer.peerId, peer]));
|
|
13484
|
+
this.cachedPeers = body.peers;
|
|
13485
|
+
this.cachedInstances = body.instances;
|
|
13486
|
+
this.cachedMultiplayerGroups = body.multiplayerGroups;
|
|
13487
|
+
for (const peer of body.peers) {
|
|
13488
|
+
if (peerPublicationChanged(previousPeers.get(peer.peerId), peer)) {
|
|
13489
|
+
this.notifyPeerRegistered(toPublicPeer(peer));
|
|
13129
13490
|
}
|
|
13130
13491
|
}
|
|
13131
13492
|
} catch {
|
|
13132
13493
|
}
|
|
13133
13494
|
}
|
|
13495
|
+
getPeers() {
|
|
13496
|
+
return this.cachedPeers;
|
|
13497
|
+
}
|
|
13134
13498
|
getInstances() {
|
|
13135
13499
|
return this.cachedInstances;
|
|
13136
13500
|
}
|
|
13501
|
+
getMultiplayerGroups() {
|
|
13502
|
+
return this.cachedMultiplayerGroups;
|
|
13503
|
+
}
|
|
13504
|
+
getTopologySnapshot() {
|
|
13505
|
+
return {
|
|
13506
|
+
peers: this.cachedPeers,
|
|
13507
|
+
instances: this.cachedInstances,
|
|
13508
|
+
multiplayerGroups: this.cachedMultiplayerGroups
|
|
13509
|
+
};
|
|
13510
|
+
}
|
|
13511
|
+
async createMultiplayerGroupEverywhere(groupId, controllerInstanceId) {
|
|
13512
|
+
const response = await fetch(`${this.primaryBaseUrl}/create-multiplayer-group`, {
|
|
13513
|
+
method: "POST",
|
|
13514
|
+
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
13515
|
+
body: JSON.stringify({ groupId, controllerInstanceId })
|
|
13516
|
+
});
|
|
13517
|
+
if (!response.ok) {
|
|
13518
|
+
const body = await response.text().catch(() => "");
|
|
13519
|
+
throw new Error(`Proxy Multiplayer Group creation failed (${response.status}): ${body || response.statusText}`);
|
|
13520
|
+
}
|
|
13521
|
+
const result = await response.json();
|
|
13522
|
+
if (!result.group || result.group.id !== groupId) {
|
|
13523
|
+
throw new Error("Proxy Multiplayer Group creation returned an invalid Group.");
|
|
13524
|
+
}
|
|
13525
|
+
const group = {
|
|
13526
|
+
...result.group,
|
|
13527
|
+
instanceIds: [...result.group.instanceIds]
|
|
13528
|
+
};
|
|
13529
|
+
this.cachedPeers = this.cachedPeers.map((peer) => peer.instanceId === controllerInstanceId ? { ...peer, multiplayerGroupId: group.id } : peer);
|
|
13530
|
+
this.cachedInstances = this.cachedInstances.map((instance) => instance.id === controllerInstanceId ? {
|
|
13531
|
+
...instance,
|
|
13532
|
+
multiplayerGroupId: group.id,
|
|
13533
|
+
peers: instance.peers.map((peer) => ({ ...peer, multiplayerGroupId: group.id }))
|
|
13534
|
+
} : instance);
|
|
13535
|
+
this.cachedMultiplayerGroups = [
|
|
13536
|
+
...this.cachedMultiplayerGroups.filter((candidate) => candidate.id !== group.id),
|
|
13537
|
+
group
|
|
13538
|
+
];
|
|
13539
|
+
return group;
|
|
13540
|
+
}
|
|
13541
|
+
async removeMultiplayerGroupEverywhere(groupId) {
|
|
13542
|
+
const response = await fetch(`${this.primaryBaseUrl}/remove-multiplayer-group`, {
|
|
13543
|
+
method: "POST",
|
|
13544
|
+
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
13545
|
+
body: JSON.stringify({ groupId })
|
|
13546
|
+
});
|
|
13547
|
+
if (!response.ok) {
|
|
13548
|
+
const body = await response.text().catch(() => "");
|
|
13549
|
+
throw new Error(`Proxy Multiplayer Group removal failed (${response.status}): ${body || response.statusText}`);
|
|
13550
|
+
}
|
|
13551
|
+
const result = await response.json();
|
|
13552
|
+
const removed = result.removed;
|
|
13553
|
+
if (removed !== void 0 && removed.id !== groupId) {
|
|
13554
|
+
throw new Error("Proxy Multiplayer Group removal returned an invalid Group.");
|
|
13555
|
+
}
|
|
13556
|
+
this.cachedPeers = this.cachedPeers.map((peer) => peer.multiplayerGroupId === groupId ? { ...peer, multiplayerGroupId: void 0 } : peer);
|
|
13557
|
+
this.cachedInstances = this.cachedInstances.map((instance) => instance.multiplayerGroupId === groupId ? {
|
|
13558
|
+
...instance,
|
|
13559
|
+
multiplayerGroupId: void 0,
|
|
13560
|
+
peers: instance.peers.map((peer) => ({ ...peer, multiplayerGroupId: void 0 }))
|
|
13561
|
+
} : instance);
|
|
13562
|
+
this.cachedMultiplayerGroups = this.cachedMultiplayerGroups.filter((candidate) => candidate.id !== groupId);
|
|
13563
|
+
return removed === void 0 ? void 0 : { ...removed, instanceIds: [...removed.instanceIds] };
|
|
13564
|
+
}
|
|
13137
13565
|
async unregisterInstanceIdEverywhere(instanceId) {
|
|
13138
13566
|
const response = await fetch(`${this.primaryBaseUrl}/unregister-instance-id`, {
|
|
13139
13567
|
method: "POST",
|
|
@@ -13146,10 +13574,17 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
13146
13574
|
}
|
|
13147
13575
|
const result = await response.json();
|
|
13148
13576
|
const removed = Array.isArray(result.removed) ? result.removed : [];
|
|
13149
|
-
const
|
|
13150
|
-
|
|
13151
|
-
|
|
13152
|
-
|
|
13577
|
+
const removedPeerIds = new Set(removed.map((peer) => peer.peerId));
|
|
13578
|
+
const removedInstanceIds = /* @__PURE__ */ new Set([
|
|
13579
|
+
instanceId,
|
|
13580
|
+
...removed.map((peer) => peer.instanceId)
|
|
13581
|
+
]);
|
|
13582
|
+
this.cachedPeers = this.cachedPeers.filter((peer) => !removedPeerIds.has(peer.peerId));
|
|
13583
|
+
this.cachedInstances = this.cachedInstances.filter((instance) => !removedInstanceIds.has(instance.id));
|
|
13584
|
+
this.cachedMultiplayerGroups = this.cachedMultiplayerGroups.map((group) => ({
|
|
13585
|
+
...group,
|
|
13586
|
+
instanceIds: group.instanceIds.filter((id) => !removedInstanceIds.has(id))
|
|
13587
|
+
})).filter((group) => group.instanceIds.length > 0);
|
|
13153
13588
|
return removed;
|
|
13154
13589
|
}
|
|
13155
13590
|
/** Called when this proxy is being discarded (e.g. promotion to primary
|
|
@@ -13160,7 +13595,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
13160
13595
|
this.refreshTimer = void 0;
|
|
13161
13596
|
}
|
|
13162
13597
|
}
|
|
13163
|
-
async sendRequest(endpoint, data,
|
|
13598
|
+
async sendRequest(endpoint, data, targetPeerId, timeoutMs = this.proxyRequestTimeout, signal) {
|
|
13164
13599
|
if (signal?.aborted)
|
|
13165
13600
|
throw new Error("Request aborted");
|
|
13166
13601
|
const controller = new AbortController();
|
|
@@ -13181,8 +13616,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
13181
13616
|
body: JSON.stringify({
|
|
13182
13617
|
endpoint,
|
|
13183
13618
|
data,
|
|
13184
|
-
|
|
13185
|
-
targetRole,
|
|
13619
|
+
targetPeerId,
|
|
13186
13620
|
proxyInstanceId: this.proxyInstanceId,
|
|
13187
13621
|
timeoutMs: effectiveTimeoutMs
|
|
13188
13622
|
}),
|
|
@@ -13337,7 +13771,7 @@ var RobloxStudioMCPServer = class {
|
|
|
13337
13771
|
}, 5e3);
|
|
13338
13772
|
const cleanupInterval = setInterval(() => {
|
|
13339
13773
|
this.bridge.cleanupOldRequests();
|
|
13340
|
-
this.bridge.
|
|
13774
|
+
this.bridge.cleanupStalePeers();
|
|
13341
13775
|
}, 5e3);
|
|
13342
13776
|
const shutdown = async () => {
|
|
13343
13777
|
console.error("Shutting down MCP server...");
|
|
@@ -13379,7 +13813,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13379
13813
|
properties: {
|
|
13380
13814
|
instance_id: {
|
|
13381
13815
|
type: "string",
|
|
13382
|
-
description: "
|
|
13816
|
+
description: "Studio process ID when ambiguous."
|
|
13383
13817
|
}
|
|
13384
13818
|
}
|
|
13385
13819
|
}
|
|
@@ -13406,7 +13840,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13406
13840
|
},
|
|
13407
13841
|
instance_id: {
|
|
13408
13842
|
type: "string",
|
|
13409
|
-
description: "
|
|
13843
|
+
description: "Studio process ID when ambiguous."
|
|
13410
13844
|
}
|
|
13411
13845
|
},
|
|
13412
13846
|
required: ["query"]
|
|
@@ -13430,7 +13864,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13430
13864
|
},
|
|
13431
13865
|
instance_id: {
|
|
13432
13866
|
type: "string",
|
|
13433
|
-
description: "
|
|
13867
|
+
description: "Studio process ID when ambiguous."
|
|
13434
13868
|
}
|
|
13435
13869
|
},
|
|
13436
13870
|
required: ["instancePath"]
|
|
@@ -13458,7 +13892,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13458
13892
|
},
|
|
13459
13893
|
instance_id: {
|
|
13460
13894
|
type: "string",
|
|
13461
|
-
description: "
|
|
13895
|
+
description: "Studio process ID when ambiguous."
|
|
13462
13896
|
}
|
|
13463
13897
|
}
|
|
13464
13898
|
}
|
|
@@ -13480,7 +13914,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13480
13914
|
},
|
|
13481
13915
|
instance_id: {
|
|
13482
13916
|
type: "string",
|
|
13483
|
-
description: "
|
|
13917
|
+
description: "Studio process ID when ambiguous."
|
|
13484
13918
|
}
|
|
13485
13919
|
},
|
|
13486
13920
|
required: ["instancePath", "properties"]
|
|
@@ -13505,7 +13939,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13505
13939
|
},
|
|
13506
13940
|
instance_id: {
|
|
13507
13941
|
type: "string",
|
|
13508
|
-
description: "
|
|
13942
|
+
description: "Studio process ID when ambiguous."
|
|
13509
13943
|
}
|
|
13510
13944
|
},
|
|
13511
13945
|
required: ["instancePath"]
|
|
@@ -13528,7 +13962,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13528
13962
|
},
|
|
13529
13963
|
instance_id: {
|
|
13530
13964
|
type: "string",
|
|
13531
|
-
description: "
|
|
13965
|
+
description: "Studio process ID when ambiguous."
|
|
13532
13966
|
}
|
|
13533
13967
|
},
|
|
13534
13968
|
required: ["instancePath", "source"]
|
|
@@ -13559,7 +13993,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13559
13993
|
},
|
|
13560
13994
|
instance_id: {
|
|
13561
13995
|
type: "string",
|
|
13562
|
-
description: "
|
|
13996
|
+
description: "Studio process ID when ambiguous."
|
|
13563
13997
|
}
|
|
13564
13998
|
},
|
|
13565
13999
|
required: ["instancePath", "old_string", "new_string"]
|
|
@@ -13586,7 +14020,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13586
14020
|
},
|
|
13587
14021
|
instance_id: {
|
|
13588
14022
|
type: "string",
|
|
13589
|
-
description: "
|
|
14023
|
+
description: "Studio process ID when ambiguous."
|
|
13590
14024
|
}
|
|
13591
14025
|
},
|
|
13592
14026
|
required: ["instancePath", "newContent"]
|
|
@@ -13609,7 +14043,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13609
14043
|
},
|
|
13610
14044
|
instance_id: {
|
|
13611
14045
|
type: "string",
|
|
13612
|
-
description: "
|
|
14046
|
+
description: "Studio process ID when ambiguous."
|
|
13613
14047
|
}
|
|
13614
14048
|
},
|
|
13615
14049
|
required: ["instancePath", "line_range"]
|
|
@@ -13628,7 +14062,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13628
14062
|
},
|
|
13629
14063
|
instance_id: {
|
|
13630
14064
|
type: "string",
|
|
13631
|
-
description: "
|
|
14065
|
+
description: "Studio process ID when ambiguous."
|
|
13632
14066
|
}
|
|
13633
14067
|
},
|
|
13634
14068
|
required: ["instancePath"]
|
|
@@ -13682,7 +14116,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13682
14116
|
},
|
|
13683
14117
|
instance_id: {
|
|
13684
14118
|
type: "string",
|
|
13685
|
-
description: "
|
|
14119
|
+
description: "Studio process ID when ambiguous."
|
|
13686
14120
|
}
|
|
13687
14121
|
},
|
|
13688
14122
|
required: ["action"]
|
|
@@ -13705,7 +14139,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13705
14139
|
},
|
|
13706
14140
|
instance_id: {
|
|
13707
14141
|
type: "string",
|
|
13708
|
-
description: "
|
|
14142
|
+
description: "Studio process ID when ambiguous."
|
|
13709
14143
|
}
|
|
13710
14144
|
},
|
|
13711
14145
|
required: ["code"]
|
|
@@ -13724,7 +14158,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13724
14158
|
},
|
|
13725
14159
|
instance_id: {
|
|
13726
14160
|
type: "string",
|
|
13727
|
-
description: "
|
|
14161
|
+
description: "Studio process ID when ambiguous."
|
|
13728
14162
|
}
|
|
13729
14163
|
},
|
|
13730
14164
|
required: ["code"]
|
|
@@ -13747,7 +14181,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13747
14181
|
},
|
|
13748
14182
|
instance_id: {
|
|
13749
14183
|
type: "string",
|
|
13750
|
-
description: "
|
|
14184
|
+
description: "Studio process ID when ambiguous."
|
|
13751
14185
|
}
|
|
13752
14186
|
},
|
|
13753
14187
|
required: ["code"]
|
|
@@ -13807,7 +14241,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13807
14241
|
},
|
|
13808
14242
|
instance_id: {
|
|
13809
14243
|
type: "string",
|
|
13810
|
-
description: "
|
|
14244
|
+
description: "Studio process ID when ambiguous."
|
|
13811
14245
|
}
|
|
13812
14246
|
},
|
|
13813
14247
|
required: ["pattern"]
|
|
@@ -13898,7 +14332,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13898
14332
|
},
|
|
13899
14333
|
instance_id: {
|
|
13900
14334
|
type: "string",
|
|
13901
|
-
description: "
|
|
14335
|
+
description: "Studio process ID for close or status; excludes launch_id."
|
|
13902
14336
|
},
|
|
13903
14337
|
launch_id: {
|
|
13904
14338
|
type: "string",
|
|
@@ -13932,7 +14366,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13932
14366
|
},
|
|
13933
14367
|
instance_id: {
|
|
13934
14368
|
type: "string",
|
|
13935
|
-
description: "
|
|
14369
|
+
description: "Studio process ID when ambiguous."
|
|
13936
14370
|
}
|
|
13937
14371
|
},
|
|
13938
14372
|
required: ["action"]
|
|
@@ -13995,7 +14429,7 @@ var TOOL_DEFINITIONS = [
|
|
|
13995
14429
|
},
|
|
13996
14430
|
instance_id: {
|
|
13997
14431
|
type: "string",
|
|
13998
|
-
description: "
|
|
14432
|
+
description: "Studio process ID when ambiguous."
|
|
13999
14433
|
}
|
|
14000
14434
|
},
|
|
14001
14435
|
required: ["profile"]
|
|
@@ -14019,7 +14453,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14019
14453
|
},
|
|
14020
14454
|
instance_id: {
|
|
14021
14455
|
type: "string",
|
|
14022
|
-
description: "
|
|
14456
|
+
description: "Studio process ID when ambiguous."
|
|
14023
14457
|
}
|
|
14024
14458
|
}
|
|
14025
14459
|
}
|
|
@@ -14045,7 +14479,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14045
14479
|
},
|
|
14046
14480
|
instance_id: {
|
|
14047
14481
|
type: "string",
|
|
14048
|
-
description: "
|
|
14482
|
+
description: "Studio process ID when ambiguous."
|
|
14049
14483
|
}
|
|
14050
14484
|
}
|
|
14051
14485
|
}
|
|
@@ -14071,7 +14505,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14071
14505
|
},
|
|
14072
14506
|
instance_id: {
|
|
14073
14507
|
type: "string",
|
|
14074
|
-
description: "
|
|
14508
|
+
description: "Studio process ID when ambiguous."
|
|
14075
14509
|
}
|
|
14076
14510
|
}
|
|
14077
14511
|
}
|
|
@@ -14125,7 +14559,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14125
14559
|
},
|
|
14126
14560
|
instance_id: {
|
|
14127
14561
|
type: "string",
|
|
14128
|
-
description: "
|
|
14562
|
+
description: "Studio process ID when ambiguous."
|
|
14129
14563
|
}
|
|
14130
14564
|
}
|
|
14131
14565
|
}
|
|
@@ -14207,7 +14641,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14207
14641
|
},
|
|
14208
14642
|
instance_id: {
|
|
14209
14643
|
type: "string",
|
|
14210
|
-
description: "
|
|
14644
|
+
description: "Studio process ID when ambiguous."
|
|
14211
14645
|
}
|
|
14212
14646
|
},
|
|
14213
14647
|
required: ["entries"]
|
|
@@ -14245,7 +14679,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14245
14679
|
},
|
|
14246
14680
|
instance_id: {
|
|
14247
14681
|
type: "string",
|
|
14248
|
-
description: "
|
|
14682
|
+
description: "Studio process ID when ambiguous."
|
|
14249
14683
|
}
|
|
14250
14684
|
},
|
|
14251
14685
|
required: ["action"]
|
|
@@ -14254,17 +14688,28 @@ var TOOL_DEFINITIONS = [
|
|
|
14254
14688
|
{
|
|
14255
14689
|
name: "get_runtime_logs",
|
|
14256
14690
|
category: "read",
|
|
14257
|
-
description: "Use to read
|
|
14691
|
+
description: "Use to read merged logs for one Instance or separate Instance logs for a MultiplayerGroup.",
|
|
14258
14692
|
inputSchema: {
|
|
14259
14693
|
type: "object",
|
|
14260
14694
|
properties: {
|
|
14261
|
-
|
|
14695
|
+
instance_id: {
|
|
14262
14696
|
type: "string",
|
|
14263
|
-
description: "
|
|
14697
|
+
description: "Exact Studio process ID; excludes multiplayer_group_id."
|
|
14264
14698
|
},
|
|
14265
|
-
|
|
14266
|
-
type: "
|
|
14267
|
-
description: "
|
|
14699
|
+
multiplayer_group_id: {
|
|
14700
|
+
type: "string",
|
|
14701
|
+
description: "Multiplayer group ID; excludes instance_id."
|
|
14702
|
+
},
|
|
14703
|
+
cursor: {
|
|
14704
|
+
type: "string",
|
|
14705
|
+
description: "Opaque cursor returned by the previous read of one Instance. It remains correct when Peers are added or reloaded."
|
|
14706
|
+
},
|
|
14707
|
+
cursor_by_instance: {
|
|
14708
|
+
type: "object",
|
|
14709
|
+
description: "For a multiplayer group, opaque cursors keyed by Instance ID from nextCursorByInstance.",
|
|
14710
|
+
additionalProperties: {
|
|
14711
|
+
type: "string"
|
|
14712
|
+
}
|
|
14268
14713
|
},
|
|
14269
14714
|
tail: {
|
|
14270
14715
|
type: "number",
|
|
@@ -14273,10 +14718,6 @@ var TOOL_DEFINITIONS = [
|
|
|
14273
14718
|
filter: {
|
|
14274
14719
|
type: "string",
|
|
14275
14720
|
description: "Literal message substring applied before tail."
|
|
14276
|
-
},
|
|
14277
|
-
instance_id: {
|
|
14278
|
-
type: "string",
|
|
14279
|
-
description: "Connected place ID; required with multiple places."
|
|
14280
14721
|
}
|
|
14281
14722
|
}
|
|
14282
14723
|
}
|
|
@@ -14338,7 +14779,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14338
14779
|
},
|
|
14339
14780
|
instance_id: {
|
|
14340
14781
|
type: "string",
|
|
14341
|
-
description: "
|
|
14782
|
+
description: "Studio process ID when ambiguous."
|
|
14342
14783
|
}
|
|
14343
14784
|
}
|
|
14344
14785
|
}
|
|
@@ -14465,7 +14906,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14465
14906
|
},
|
|
14466
14907
|
instance_id: {
|
|
14467
14908
|
type: "string",
|
|
14468
|
-
description: "
|
|
14909
|
+
description: "Studio process ID when ambiguous."
|
|
14469
14910
|
}
|
|
14470
14911
|
}
|
|
14471
14912
|
}
|
|
@@ -14516,7 +14957,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14516
14957
|
},
|
|
14517
14958
|
instance_id: {
|
|
14518
14959
|
type: "string",
|
|
14519
|
-
description: "
|
|
14960
|
+
description: "Studio process ID when ambiguous."
|
|
14520
14961
|
}
|
|
14521
14962
|
},
|
|
14522
14963
|
required: ["action"]
|
|
@@ -14526,7 +14967,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14526
14967
|
{
|
|
14527
14968
|
name: "get_connected_instances",
|
|
14528
14969
|
category: "read",
|
|
14529
|
-
description: "Use to discover
|
|
14970
|
+
description: "Use to discover standalone Studio Instances and role-suffixed temporary Instances nested within multiplayer groups.",
|
|
14530
14971
|
inputSchema: {
|
|
14531
14972
|
type: "object",
|
|
14532
14973
|
properties: {}
|
|
@@ -14630,7 +15071,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14630
15071
|
},
|
|
14631
15072
|
instance_id: {
|
|
14632
15073
|
type: "string",
|
|
14633
|
-
description: "
|
|
15074
|
+
description: "Studio process ID when ambiguous."
|
|
14634
15075
|
}
|
|
14635
15076
|
},
|
|
14636
15077
|
required: ["assetId"]
|
|
@@ -14707,7 +15148,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14707
15148
|
},
|
|
14708
15149
|
instance_id: {
|
|
14709
15150
|
type: "string",
|
|
14710
|
-
description: "
|
|
15151
|
+
description: "Studio process ID when ambiguous."
|
|
14711
15152
|
}
|
|
14712
15153
|
}
|
|
14713
15154
|
}
|
|
@@ -14747,7 +15188,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14747
15188
|
},
|
|
14748
15189
|
instance_id: {
|
|
14749
15190
|
type: "string",
|
|
14750
|
-
description: "
|
|
15191
|
+
description: "Studio process ID when ambiguous."
|
|
14751
15192
|
}
|
|
14752
15193
|
},
|
|
14753
15194
|
required: ["assetId"]
|
|
@@ -14807,7 +15248,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14807
15248
|
},
|
|
14808
15249
|
instance_id: {
|
|
14809
15250
|
type: "string",
|
|
14810
|
-
description: "
|
|
15251
|
+
description: "Studio process ID when ambiguous."
|
|
14811
15252
|
}
|
|
14812
15253
|
}
|
|
14813
15254
|
}
|
|
@@ -14844,7 +15285,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14844
15285
|
},
|
|
14845
15286
|
instance_id: {
|
|
14846
15287
|
type: "string",
|
|
14847
|
-
description: "
|
|
15288
|
+
description: "Studio process ID when ambiguous."
|
|
14848
15289
|
}
|
|
14849
15290
|
},
|
|
14850
15291
|
required: ["action", "x", "y"]
|
|
@@ -14880,7 +15321,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14880
15321
|
},
|
|
14881
15322
|
instance_id: {
|
|
14882
15323
|
type: "string",
|
|
14883
|
-
description: "
|
|
15324
|
+
description: "Studio process ID when ambiguous."
|
|
14884
15325
|
}
|
|
14885
15326
|
}
|
|
14886
15327
|
}
|
|
@@ -14904,7 +15345,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14904
15345
|
},
|
|
14905
15346
|
instance_id: {
|
|
14906
15347
|
type: "string",
|
|
14907
|
-
description: "
|
|
15348
|
+
description: "Studio process ID when ambiguous."
|
|
14908
15349
|
}
|
|
14909
15350
|
}
|
|
14910
15351
|
}
|
|
@@ -14937,7 +15378,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14937
15378
|
},
|
|
14938
15379
|
instance_id: {
|
|
14939
15380
|
type: "string",
|
|
14940
|
-
description: "
|
|
15381
|
+
description: "Studio process ID when ambiguous."
|
|
14941
15382
|
}
|
|
14942
15383
|
}
|
|
14943
15384
|
}
|
|
@@ -14966,7 +15407,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14966
15407
|
},
|
|
14967
15408
|
instance_id: {
|
|
14968
15409
|
type: "string",
|
|
14969
|
-
description: "
|
|
15410
|
+
description: "Studio process ID when ambiguous."
|
|
14970
15411
|
}
|
|
14971
15412
|
},
|
|
14972
15413
|
required: ["instance_paths", "output_path"]
|
|
@@ -15004,7 +15445,7 @@ var TOOL_DEFINITIONS = [
|
|
|
15004
15445
|
},
|
|
15005
15446
|
instance_id: {
|
|
15006
15447
|
type: "string",
|
|
15007
|
-
description: "
|
|
15448
|
+
description: "Studio process ID when ambiguous."
|
|
15008
15449
|
}
|
|
15009
15450
|
},
|
|
15010
15451
|
required: ["source", "parent_path"]
|
|
@@ -15053,7 +15494,7 @@ var TOOL_DEFINITIONS = [
|
|
|
15053
15494
|
},
|
|
15054
15495
|
instance_id: {
|
|
15055
15496
|
type: "string",
|
|
15056
|
-
description: "
|
|
15497
|
+
description: "Studio process ID when ambiguous."
|
|
15057
15498
|
}
|
|
15058
15499
|
},
|
|
15059
15500
|
required: ["pattern", "replacement"]
|