@quenty/studio-bridge 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +36 -8
  3. package/dist/src/bridge/bridge-connection.d.ts.map +1 -1
  4. package/dist/src/bridge/bridge-connection.js +1 -0
  5. package/dist/src/bridge/bridge-connection.js.map +1 -1
  6. package/dist/src/bridge/bridge-session.d.ts +11 -16
  7. package/dist/src/bridge/bridge-session.d.ts.map +1 -1
  8. package/dist/src/bridge/bridge-session.js +37 -29
  9. package/dist/src/bridge/bridge-session.js.map +1 -1
  10. package/dist/src/bridge/bridge-session.test.js +35 -0
  11. package/dist/src/bridge/bridge-session.test.js.map +1 -1
  12. package/dist/src/bridge/internal/bridge-host.d.ts +2 -0
  13. package/dist/src/bridge/internal/bridge-host.d.ts.map +1 -1
  14. package/dist/src/bridge/internal/bridge-host.js +1 -0
  15. package/dist/src/bridge/internal/bridge-host.js.map +1 -1
  16. package/dist/src/bridge/internal/host-protocol.d.ts.map +1 -1
  17. package/dist/src/bridge/internal/host-protocol.js +23 -10
  18. package/dist/src/bridge/internal/host-protocol.js.map +1 -1
  19. package/dist/src/bridge/types.d.ts +5 -2
  20. package/dist/src/bridge/types.d.ts.map +1 -1
  21. package/dist/src/bridge/types.js.map +1 -1
  22. package/dist/src/commands/console/exec/exec.d.ts +13 -3
  23. package/dist/src/commands/console/exec/exec.d.ts.map +1 -1
  24. package/dist/src/commands/console/exec/exec.js +82 -7
  25. package/dist/src/commands/console/exec/exec.js.map +1 -1
  26. package/dist/src/commands/console/exec/exec.test.js +63 -1
  27. package/dist/src/commands/console/exec/exec.test.js.map +1 -1
  28. package/dist/src/commands/framework/action-loader.test.js +1 -1
  29. package/dist/src/commands/framework/action-loader.test.js.map +1 -1
  30. package/dist/src/commands/playtest/player/playtest-players.d.ts.map +1 -1
  31. package/dist/src/commands/playtest/player/playtest-players.js +15 -93
  32. package/dist/src/commands/playtest/player/playtest-players.js.map +1 -1
  33. package/dist/src/commands/playtest/player/playtest-players.test.js +34 -21
  34. package/dist/src/commands/playtest/player/playtest-players.test.js.map +1 -1
  35. package/dist/src/commands/playtest/playtest-sessions.d.ts +19 -3
  36. package/dist/src/commands/playtest/playtest-sessions.d.ts.map +1 -1
  37. package/dist/src/commands/playtest/playtest-sessions.js +54 -74
  38. package/dist/src/commands/playtest/playtest-sessions.js.map +1 -1
  39. package/dist/src/commands/playtest/playtest-sessions.test.js +74 -8
  40. package/dist/src/commands/playtest/playtest-sessions.test.js.map +1 -1
  41. package/dist/src/commands/playtest/run/run.d.ts +9 -7
  42. package/dist/src/commands/playtest/run/run.d.ts.map +1 -1
  43. package/dist/src/commands/playtest/run/run.js +33 -38
  44. package/dist/src/commands/playtest/run/run.js.map +1 -1
  45. package/dist/src/commands/process/close/close.d.ts +4 -0
  46. package/dist/src/commands/process/close/close.d.ts.map +1 -1
  47. package/dist/src/commands/process/close/close.js +54 -4
  48. package/dist/src/commands/process/close/close.js.map +1 -1
  49. package/dist/src/commands/process/close/close.test.js +36 -3
  50. package/dist/src/commands/process/close/close.test.js.map +1 -1
  51. package/dist/src/commands/process/info/info.d.ts +15 -2
  52. package/dist/src/commands/process/info/info.d.ts.map +1 -1
  53. package/dist/src/commands/process/info/info.js +44 -4
  54. package/dist/src/commands/process/info/info.js.map +1 -1
  55. package/dist/src/commands/process/info/info.test.js +48 -0
  56. package/dist/src/commands/process/info/info.test.js.map +1 -1
  57. package/dist/src/commands/process/launch/launch.d.ts.map +1 -1
  58. package/dist/src/commands/process/launch/launch.js +22 -3
  59. package/dist/src/commands/process/launch/launch.js.map +1 -1
  60. package/dist/src/commands/process/launch/launch.test.js +27 -5
  61. package/dist/src/commands/process/launch/launch.test.js.map +1 -1
  62. package/dist/src/commands/process/list/list.d.ts +49 -6
  63. package/dist/src/commands/process/list/list.d.ts.map +1 -1
  64. package/dist/src/commands/process/list/list.js +185 -18
  65. package/dist/src/commands/process/list/list.js.map +1 -1
  66. package/dist/src/commands/process/list/list.test.js +92 -1
  67. package/dist/src/commands/process/list/list.test.js.map +1 -1
  68. package/dist/src/commands/process/run/run.d.ts +1 -1
  69. package/dist/src/commands/process/run/run.d.ts.map +1 -1
  70. package/dist/src/commands/process/run/run.js +3 -3
  71. package/dist/src/commands/process/run/run.js.map +1 -1
  72. package/dist/src/docker/docker-delegator.js +1 -1
  73. package/dist/src/docker/docker-delegator.js.map +1 -1
  74. package/dist/src/docker/docker-delegator.test.js +1 -1
  75. package/dist/src/docker/docker-delegator.test.js.map +1 -1
  76. package/dist/src/plugin/playtest-bridge.d.ts +36 -0
  77. package/dist/src/plugin/playtest-bridge.d.ts.map +1 -0
  78. package/dist/src/plugin/playtest-bridge.js +65 -0
  79. package/dist/src/plugin/playtest-bridge.js.map +1 -0
  80. package/dist/src/plugin/playtest-bridge.test.d.ts +2 -0
  81. package/dist/src/plugin/playtest-bridge.test.d.ts.map +1 -0
  82. package/dist/src/plugin/playtest-bridge.test.js +35 -0
  83. package/dist/src/plugin/playtest-bridge.test.js.map +1 -0
  84. package/dist/src/process/studio-launch-registry.d.ts +14 -0
  85. package/dist/src/process/studio-launch-registry.d.ts.map +1 -0
  86. package/dist/src/process/studio-launch-registry.js +37 -0
  87. package/dist/src/process/studio-launch-registry.js.map +1 -0
  88. package/dist/src/process/studio-launch-registry.test.d.ts +2 -0
  89. package/dist/src/process/studio-launch-registry.test.d.ts.map +1 -0
  90. package/dist/src/process/studio-launch-registry.test.js +38 -0
  91. package/dist/src/process/studio-launch-registry.test.js.map +1 -0
  92. package/dist/src/process/studio-process-closer.d.ts +3 -1
  93. package/dist/src/process/studio-process-closer.d.ts.map +1 -1
  94. package/dist/src/process/studio-process-closer.js +29 -11
  95. package/dist/src/process/studio-process-closer.js.map +1 -1
  96. package/dist/src/process/studio-process-identity.d.ts +10 -0
  97. package/dist/src/process/studio-process-identity.d.ts.map +1 -0
  98. package/dist/src/process/studio-process-identity.js +63 -0
  99. package/dist/src/process/studio-process-identity.js.map +1 -0
  100. package/dist/src/process/studio-process-identity.test.d.ts +2 -0
  101. package/dist/src/process/studio-process-identity.test.d.ts.map +1 -0
  102. package/dist/src/process/studio-process-identity.test.js +30 -0
  103. package/dist/src/process/studio-process-identity.test.js.map +1 -0
  104. package/dist/src/roblox-mcp/index.d.ts +3 -0
  105. package/dist/src/roblox-mcp/index.d.ts.map +1 -0
  106. package/dist/src/roblox-mcp/index.js +3 -0
  107. package/dist/src/roblox-mcp/index.js.map +1 -0
  108. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts +60 -0
  109. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts.map +1 -0
  110. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js +171 -0
  111. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js.map +1 -0
  112. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts +2 -0
  113. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts.map +1 -0
  114. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js +104 -0
  115. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js.map +1 -0
  116. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts +2 -0
  117. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts.map +1 -0
  118. package/dist/src/roblox-mcp/mcp/repair-list-result.js +22 -0
  119. package/dist/src/roblox-mcp/mcp/repair-list-result.js.map +1 -0
  120. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts +2 -0
  121. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts.map +1 -0
  122. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js +42 -0
  123. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js.map +1 -0
  124. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts +34 -0
  125. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts.map +1 -0
  126. package/dist/src/roblox-mcp/roblox-studio-mcp.js +190 -0
  127. package/dist/src/roblox-mcp/roblox-studio-mcp.js.map +1 -0
  128. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts +2 -0
  129. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts.map +1 -0
  130. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js +123 -0
  131. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js.map +1 -0
  132. package/dist/src/roblox-mcp/studio/studio-session.d.ts +39 -0
  133. package/dist/src/roblox-mcp/studio/studio-session.d.ts.map +1 -0
  134. package/dist/src/roblox-mcp/studio/studio-session.js +175 -0
  135. package/dist/src/roblox-mcp/studio/studio-session.js.map +1 -0
  136. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts +2 -0
  137. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts.map +1 -0
  138. package/dist/src/roblox-mcp/studio/studio-session.test.js +213 -0
  139. package/dist/src/roblox-mcp/studio/studio-session.test.js.map +1 -0
  140. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts +19 -0
  141. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts.map +1 -0
  142. package/dist/src/roblox-mcp/studio-mcp-locator.js +121 -0
  143. package/dist/src/roblox-mcp/studio-mcp-locator.js.map +1 -0
  144. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts +2 -0
  145. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts.map +1 -0
  146. package/dist/src/roblox-mcp/studio-mcp-locator.test.js +107 -0
  147. package/dist/src/roblox-mcp/studio-mcp-locator.test.js.map +1 -0
  148. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts +23 -0
  149. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts.map +1 -0
  150. package/dist/src/roblox-mcp/studio-mcp-snapshot.js +65 -0
  151. package/dist/src/roblox-mcp/studio-mcp-snapshot.js.map +1 -0
  152. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts +2 -0
  153. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts.map +1 -0
  154. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js +81 -0
  155. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js.map +1 -0
  156. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts +27 -0
  157. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts.map +1 -0
  158. package/dist/src/roblox-mcp/test/fake-mcp-server.js +55 -0
  159. package/dist/src/roblox-mcp/test/fake-mcp-server.js.map +1 -0
  160. package/dist/src/server/web-socket-protocol.d.ts +50 -15
  161. package/dist/src/server/web-socket-protocol.d.ts.map +1 -1
  162. package/dist/src/server/web-socket-protocol.js +63 -13
  163. package/dist/src/server/web-socket-protocol.js.map +1 -1
  164. package/dist/tsconfig.tsbuildinfo +1 -1
  165. package/docs/playtest-bridge.md +21 -0
  166. package/package.json +5 -5
  167. package/src/bridge/bridge-connection.ts +1 -0
  168. package/src/bridge/bridge-session.test.ts +51 -0
  169. package/src/bridge/bridge-session.ts +66 -47
  170. package/src/bridge/internal/bridge-host.ts +3 -0
  171. package/src/bridge/internal/host-protocol.ts +22 -9
  172. package/src/bridge/types.ts +5 -0
  173. package/src/commands/console/exec/exec.test.ts +94 -0
  174. package/src/commands/console/exec/exec.ts +130 -16
  175. package/src/commands/console/exec/execute.lua +147 -5
  176. package/src/commands/console/exec/execute.spec.lua +22 -2
  177. package/src/commands/framework/action-loader.test.ts +1 -1
  178. package/src/commands/playtest/player/playtest-players.lua +127 -0
  179. package/src/commands/playtest/player/playtest-players.spec.lua +48 -0
  180. package/src/commands/playtest/player/playtest-players.test.ts +40 -21
  181. package/src/commands/playtest/player/playtest-players.ts +30 -110
  182. package/src/commands/playtest/playtest-sessions.test.ts +116 -14
  183. package/src/commands/playtest/playtest-sessions.ts +77 -88
  184. package/src/commands/playtest/run/run.ts +44 -58
  185. package/src/commands/playtest/start/start-playtest.lua +105 -6
  186. package/src/commands/process/close/close.test.ts +52 -3
  187. package/src/commands/process/close/close.ts +78 -4
  188. package/src/commands/process/info/info.test.ts +76 -0
  189. package/src/commands/process/info/info.ts +74 -6
  190. package/src/commands/process/launch/launch.test.ts +33 -5
  191. package/src/commands/process/launch/launch.ts +41 -3
  192. package/src/commands/process/list/list.test.ts +130 -1
  193. package/src/commands/process/list/list.ts +290 -22
  194. package/src/commands/process/run/run.ts +4 -4
  195. package/src/docker/docker-delegator.test.ts +1 -1
  196. package/src/docker/docker-delegator.ts +1 -1
  197. package/src/plugin/playtest-bridge.test.ts +53 -0
  198. package/src/plugin/playtest-bridge.ts +98 -0
  199. package/src/process/studio-launch-registry.test.ts +56 -0
  200. package/src/process/studio-launch-registry.ts +63 -0
  201. package/src/process/studio-process-closer.ts +36 -11
  202. package/src/process/studio-process-identity.test.ts +45 -0
  203. package/src/process/studio-process-identity.ts +90 -0
  204. package/src/roblox-mcp/index.ts +17 -0
  205. package/src/roblox-mcp/mcp/mcp-stdio-client.test.ts +148 -0
  206. package/src/roblox-mcp/mcp/mcp-stdio-client.ts +270 -0
  207. package/src/roblox-mcp/mcp/repair-list-result.test.ts +52 -0
  208. package/src/roblox-mcp/mcp/repair-list-result.ts +23 -0
  209. package/src/roblox-mcp/roblox-studio-mcp.test.ts +168 -0
  210. package/src/roblox-mcp/roblox-studio-mcp.ts +330 -0
  211. package/src/roblox-mcp/studio/studio-session.test.ts +284 -0
  212. package/src/roblox-mcp/studio/studio-session.ts +279 -0
  213. package/src/roblox-mcp/studio-mcp-locator.test.ts +186 -0
  214. package/src/roblox-mcp/studio-mcp-locator.ts +180 -0
  215. package/src/roblox-mcp/studio-mcp-snapshot.test.ts +113 -0
  216. package/src/roblox-mcp/studio-mcp-snapshot.ts +104 -0
  217. package/src/roblox-mcp/test/fake-mcp-server.ts +74 -0
  218. package/src/server/web-socket-protocol.ts +119 -29
  219. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.client.lua +32 -0
  220. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.server.lua +40 -0
  221. package/templates/studio-bridge-plugin/src/Shared/ActionRouter.lua +25 -3
  222. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.lua +47 -0
  223. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.spec.lua +57 -0
  224. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeClient.lua +357 -0
  225. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.lua +29 -3
  226. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.spec.lua +35 -2
  227. package/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua +16 -292
  228. package/src/commands/playtest/run/playtest-runner.luau +0 -264
  229. package/src/commands/playtest/run/playtest-runner.spec.lua +0 -71
  230. package/src/commands/playtest/run/run-playtest-script.lua +0 -139
  231. package/src/commands/playtest/run/run-playtest-script.spec.lua +0 -43
@@ -5,20 +5,23 @@ import {
5
5
  removePlaytestPlayersAsync,
6
6
  } from './playtest-players.js';
7
7
 
8
+ type Reply = { players?: unknown[]; remaining?: string[] };
9
+
8
10
  function createSession(
9
11
  sessionId: string,
10
12
  context: string,
11
- returnValues: unknown[][],
13
+ replies: Reply[],
12
14
  playerName?: string
13
15
  ) {
14
- const scripts: string[] = [];
16
+ const requests: Array<Record<string, unknown>> = [];
15
17
  return {
16
- scripts,
18
+ requests,
17
19
  info: { sessionId, instanceId: 'place-1a2b', context, playerName },
18
20
  context,
19
- execAsync: vi.fn(async (script: string) => {
20
- scripts.push(script);
21
- return { success: true, output: [], returnValues: returnValues.shift() };
21
+ playtestPlayersAsync: vi.fn(async (request: Record<string, unknown>) => {
22
+ requests.push(request);
23
+ const reply = replies.shift() ?? {};
24
+ return { players: reply.players ?? [], remaining: reply.remaining ?? [] };
22
25
  }),
23
26
  };
24
27
  }
@@ -39,7 +42,7 @@ const player = (name: string, userId: number) => ({
39
42
  describe('listPlaytestPlayersAsync', () => {
40
43
  it('pairs each player with its client session', async () => {
41
44
  const server = createSession('place-1a2b-server', 'server', [
42
- [[player('Player1', -1), player('Player2', -2)]],
45
+ { players: [player('Player1', -1), player('Player2', -2)] },
43
46
  ]);
44
47
  const client = createSession(
45
48
  'place-1a2b-client-player1',
@@ -53,6 +56,7 @@ describe('listPlaytestPlayersAsync', () => {
53
56
  server as any
54
57
  );
55
58
 
59
+ expect(server.requests).toEqual([{ op: 'list' }]);
56
60
  expect(players).toEqual([
57
61
  { ...player('Player1', -1), sessionId: 'place-1a2b-client-player1' },
58
62
  player('Player2', -2),
@@ -63,7 +67,7 @@ describe('listPlaytestPlayersAsync', () => {
63
67
  describe('addPlaytestPlayersAsync', () => {
64
68
  it('adds players from the server and reports the ones that joined', async () => {
65
69
  const server = createSession('place-1a2b-server', 'server', [
66
- [[player('Player3', -3)]],
70
+ { players: [player('Player3', -3)] },
67
71
  ]);
68
72
  const client = createSession(
69
73
  'place-1a2b-client-player3',
@@ -78,7 +82,7 @@ describe('addPlaytestPlayersAsync', () => {
78
82
  1
79
83
  );
80
84
 
81
- expect(server.scripts[0]).toContain('AddPlayers(1)');
85
+ expect(server.requests).toEqual([{ op: 'add', count: 1, timeoutSec: 60 }]);
82
86
  expect(players).toEqual([
83
87
  { ...player('Player3', -3), sessionId: 'place-1a2b-client-player3' },
84
88
  ]);
@@ -88,14 +92,13 @@ describe('addPlaytestPlayersAsync', () => {
88
92
  describe('removePlaytestPlayersAsync', () => {
89
93
  it('has a connected client leave and kicks the rest', async () => {
90
94
  const server = createSession('place-1a2b-server', 'server', [
91
- [[player('Player1', -1), player('Player2', -2)]],
92
- [],
93
- [[]],
95
+ { players: [player('Player1', -1), player('Player2', -2)] },
96
+ { remaining: [] },
94
97
  ]);
95
98
  const client = createSession(
96
99
  'place-1a2b-client-player1',
97
100
  'client',
98
- [[]],
101
+ [{}],
99
102
  'Player1'
100
103
  );
101
104
 
@@ -109,16 +112,19 @@ describe('removePlaytestPlayersAsync', () => {
109
112
  { name: 'Player1', how: 'left' },
110
113
  { name: 'Player2', how: 'kicked' },
111
114
  ]);
112
- expect(client.scripts[0]).toContain('LeaveTest');
113
- expect(server.scripts[1]).toContain('"Player2"');
114
- expect(server.scripts[1]).not.toContain('"Player1"');
115
+ expect(client.requests).toEqual([{ op: 'leave' }]);
116
+ expect(server.requests[1]).toEqual({
117
+ op: 'remove',
118
+ kick: ['Player2'],
119
+ names: ['Player1', 'Player2'],
120
+ timeoutSec: 60,
121
+ });
115
122
  });
116
123
 
117
124
  it('removes a player named twice only once', async () => {
118
125
  const server = createSession('place-1a2b-server', 'server', [
119
- [[player('Player2', -2)]],
120
- [],
121
- [[]],
126
+ { players: [player('Player2', -2)] },
127
+ { remaining: [] },
122
128
  ]);
123
129
 
124
130
  const removed = await removePlaytestPlayersAsync(
@@ -130,9 +136,22 @@ describe('removePlaytestPlayersAsync', () => {
130
136
  expect(removed).toEqual([{ name: 'Player2', how: 'kicked' }]);
131
137
  });
132
138
 
139
+ it('reports players still there at the timeout', async () => {
140
+ const server = createSession('place-1a2b-server', 'server', [
141
+ { players: [player('Player2', -2)] },
142
+ { remaining: ['Player2'] },
143
+ ]);
144
+
145
+ await expect(
146
+ removePlaytestPlayersAsync(createConnection([server]), server as any, [
147
+ 'Player2',
148
+ ])
149
+ ).rejects.toThrow('Players still in the playtest after 60s: Player2');
150
+ });
151
+
133
152
  it('refuses an unknown name before removing anyone', async () => {
134
153
  const server = createSession('place-1a2b-server', 'server', [
135
- [[player('Player1', -1)]],
154
+ { players: [player('Player1', -1)] },
136
155
  ]);
137
156
 
138
157
  await expect(
@@ -141,6 +160,6 @@ describe('removePlaytestPlayersAsync', () => {
141
160
  'Nobody',
142
161
  ])
143
162
  ).rejects.toThrow("No player named 'Nobody' (players: Player1)");
144
- expect(server.scripts).toHaveLength(1);
163
+ expect(server.requests).toHaveLength(1);
145
164
  });
146
165
  });
@@ -17,7 +17,7 @@ import {
17
17
 
18
18
  const PLAYER_CHANGE_TIMEOUT_SEC = 60;
19
19
  const CLIENT_SESSION_TIMEOUT_MS = 30_000;
20
- const SCRIPT_GRACE_MS = 10_000;
20
+ const REPLY_GRACE_MS = 10_000;
21
21
 
22
22
  export interface PlaytestPlayer {
23
23
  name: string;
@@ -27,103 +27,12 @@ export interface PlaytestPlayer {
27
27
  sessionId?: string;
28
28
  }
29
29
 
30
- const PLAYER_INFO_FUNCTION = [
31
- 'local function playerInfo(player)',
32
- '\treturn { name = player.Name, userId = player.UserId, displayName = player.DisplayName }',
33
- 'end',
34
- ].join('\n');
30
+ const REQUEST_TIMEOUT_MS = PLAYER_CHANGE_TIMEOUT_SEC * 1000 + REPLY_GRACE_MS;
35
31
 
36
- const LIST_PLAYERS_SCRIPT = [
37
- PLAYER_INFO_FUNCTION,
38
- 'local players = {}',
39
- 'for _, player in game:GetService("Players"):GetPlayers() do',
40
- '\ttable.insert(players, playerInfo(player))',
41
- 'end',
42
- 'return players',
43
- ].join('\n');
44
-
45
- function addPlayersScript(count: number): string {
46
- return [
47
- PLAYER_INFO_FUNCTION,
48
- 'local Players = game:GetService("Players")',
49
- 'local before = {}',
50
- 'for _, player in Players:GetPlayers() do',
51
- '\tbefore[player] = true',
52
- 'end',
53
- `game:GetService("StudioTestService"):AddPlayers(${count})`,
54
- `local deadline = os.clock() + ${PLAYER_CHANGE_TIMEOUT_SEC}`,
55
- 'local added = {}',
56
- 'repeat',
57
- '\ttask.wait(0.25)',
58
- '\tadded = {}',
59
- '\tfor _, player in Players:GetPlayers() do',
60
- '\t\tif not before[player] then',
61
- '\t\t\ttable.insert(added, playerInfo(player))',
62
- '\t\tend',
63
- '\tend',
64
- `until #added >= ${count} or os.clock() > deadline`,
65
- 'return added',
66
- ].join('\n');
67
- }
68
-
69
- function kickPlayersScript(names: string[]): string {
70
- return [
71
- `local names = ${luaStringList(names)}`,
72
- 'local Players = game:GetService("Players")',
73
- 'for _, name in names do',
74
- '\tlocal player = Players:FindFirstChild(name)',
75
- '\tif player then',
76
- '\t\tplayer:Kick("Removed by studio-bridge")',
77
- '\tend',
78
- 'end',
79
- ].join('\n');
80
- }
81
-
82
- function waitForPlayersGoneScript(names: string[]): string {
83
- return [
84
- `local names = ${luaStringList(names)}`,
85
- 'local Players = game:GetService("Players")',
86
- `local deadline = os.clock() + ${PLAYER_CHANGE_TIMEOUT_SEC}`,
87
- 'local remaining',
88
- 'repeat',
89
- '\tremaining = {}',
90
- '\tfor _, name in names do',
91
- '\t\tif Players:FindFirstChild(name) then',
92
- '\t\t\ttable.insert(remaining, name)',
93
- '\t\tend',
94
- '\tend',
95
- '\tif #remaining > 0 then',
96
- '\t\ttask.wait(0.25)',
97
- '\tend',
98
- 'until #remaining == 0 or os.clock() > deadline',
99
- 'return remaining',
100
- ].join('\n');
101
- }
102
-
103
- const LEAVE_TEST_SCRIPT =
104
- 'task.defer(function() game:GetService("StudioTestService"):LeaveTest() end)';
105
-
106
- function luaStringList(values: string[]): string {
107
- return `{ ${values.map((value) => JSON.stringify(value)).join(', ')} }`;
108
- }
109
-
110
- async function runScriptAsync(
111
- session: BridgeSession,
112
- script: string
113
- ): Promise<unknown> {
114
- const result = await session.execAsync(
115
- script,
116
- PLAYER_CHANGE_TIMEOUT_SEC * 1000 + SCRIPT_GRACE_MS
117
- );
118
- if (!result.success) {
119
- throw new Error(result.error ?? 'Player script failed');
120
- }
121
- return result.returnValues?.[0];
122
- }
123
-
124
- function toPlayers(value: unknown): PlaytestPlayer[] {
125
- if (!Array.isArray(value)) return [];
126
- return value.map((entry) => ({
32
+ function toPlayers(
33
+ players: Array<{ name: string; userId: number; displayName: string }>
34
+ ): PlaytestPlayer[] {
35
+ return players.map((entry) => ({
127
36
  name: String(entry.name),
128
37
  userId: Number(entry.userId),
129
38
  displayName: String(entry.displayName),
@@ -162,10 +71,15 @@ export async function listPlaytestPlayersAsync(
162
71
  connection: BridgeConnection,
163
72
  serverSession: BridgeSession
164
73
  ): Promise<PlaytestPlayer[]> {
165
- const players = toPlayers(
166
- await runScriptAsync(serverSession, LIST_PLAYERS_SCRIPT)
74
+ const { players } = await serverSession.playtestPlayersAsync(
75
+ { op: 'list' },
76
+ REQUEST_TIMEOUT_MS
77
+ );
78
+ return withSessions(
79
+ connection,
80
+ serverSession.info.instanceId,
81
+ toPlayers(players)
167
82
  );
168
- return withSessions(connection, serverSession.info.instanceId, players);
169
83
  }
170
84
 
171
85
  /**
@@ -178,7 +92,12 @@ export async function addPlaytestPlayersAsync(
178
92
  count: number
179
93
  ): Promise<PlaytestPlayer[]> {
180
94
  const added = toPlayers(
181
- await runScriptAsync(serverSession, addPlayersScript(count))
95
+ (
96
+ await serverSession.playtestPlayersAsync(
97
+ { op: 'add', count, timeoutSec: PLAYER_CHANGE_TIMEOUT_SEC },
98
+ REQUEST_TIMEOUT_MS
99
+ )
100
+ ).players
182
101
  );
183
102
  const { instanceId } = serverSession.info;
184
103
 
@@ -230,22 +149,23 @@ export async function removePlaytestPlayersAsync(
230
149
  ? connection.getSession(player.sessionId)
231
150
  : undefined;
232
151
  if (client) {
233
- await runScriptAsync(client, LEAVE_TEST_SCRIPT);
152
+ await client.playtestPlayersAsync({ op: 'leave' }, REQUEST_TIMEOUT_MS);
234
153
  removed.push({ name: player.name, how: 'left' });
235
154
  } else {
236
155
  toKick.push(player.name);
237
156
  removed.push({ name: player.name, how: 'kicked' });
238
157
  }
239
158
  }
240
- if (toKick.length > 0) {
241
- await runScriptAsync(serverSession, kickPlayersScript(toKick));
242
- }
243
-
244
- const remaining = await runScriptAsync(
245
- serverSession,
246
- waitForPlayersGoneScript(targets.map((p) => p.name))
159
+ const { remaining } = await serverSession.playtestPlayersAsync(
160
+ {
161
+ op: 'remove',
162
+ kick: toKick,
163
+ names: targets.map((p) => p.name),
164
+ timeoutSec: PLAYER_CHANGE_TIMEOUT_SEC,
165
+ },
166
+ REQUEST_TIMEOUT_MS
247
167
  );
248
- if (Array.isArray(remaining) && remaining.length > 0) {
168
+ if (remaining.length > 0) {
249
169
  throw new Error(
250
170
  `Players still in the playtest after ${PLAYER_CHANGE_TIMEOUT_SEC}s: ${remaining.join(
251
171
  ', '
@@ -12,6 +12,18 @@ vi.mock('../../process/studio-settings.js', () => ({
12
12
  settings.loadUserPluginsOnPlaytest,
13
13
  }));
14
14
 
15
+ vi.mock('../../plugin/playtest-bridge.js', () => ({
16
+ buildPlaytestBridgeAsync: async (
17
+ scripts?: string[],
18
+ options?: { withClient?: boolean }
19
+ ) => ({
20
+ entrySource: 'entry',
21
+ ...(options?.withClient ? { clientEntrySource: 'client entry' } : {}),
22
+ modules: {},
23
+ precompiled: scripts ?? [],
24
+ }),
25
+ }));
26
+
15
27
  import { ContextNotFoundError } from '../../bridge/index.js';
16
28
  import {
17
29
  explainMissingPlaytestContextAsync,
@@ -63,24 +75,12 @@ describe('startPlaytestAsync multiplayer checks', () => {
63
75
  });
64
76
 
65
77
  describe('with "Load user plugins on playtest" off', () => {
66
- it('refuses to start a playtest whose sessions could never connect', async () => {
67
- settings.loadUserPluginsOnPlaytest = false;
68
- const editSession = createEditSession('current');
69
-
70
- await expect(
71
- startPlaytestAsync({} as any, editSession, 'play')
72
- ).rejects.toThrow(
73
- `Studio's "Load user plugins on playtest" setting is off`
74
- );
75
- expect(editSession.runPlaytestAsync).not.toHaveBeenCalled();
76
- });
77
-
78
- it('names the setting when no server session is connected', async () => {
78
+ it('does not blame the setting for a missing server session, which is planted', async () => {
79
79
  settings.loadUserPluginsOnPlaytest = false;
80
80
  const connection = { listSessions: () => [] } as any;
81
81
 
82
82
  await expect(resolvePlaytestServerSessionAsync(connection)).rejects.toThrow(
83
- `No playtest is connected. Studio's "Load user plugins on playtest" setting is off`
83
+ "No playtest is running. Start one with 'studio-bridge playtest start'."
84
84
  );
85
85
  });
86
86
 
@@ -141,3 +141,105 @@ describe('explainMissingPlaytestContextAsync', () => {
141
141
  );
142
142
  });
143
143
  });
144
+
145
+ describe('planting the server session', () => {
146
+ function createConnection() {
147
+ return {
148
+ port: 38741,
149
+ isRemote: false,
150
+ listSessions: () => [],
151
+ getSession: () => undefined,
152
+ on: vi.fn(),
153
+ off: vi.fn(),
154
+ } as any;
155
+ }
156
+
157
+ // The start fails at once, so the test sees what it was asked to start
158
+ function createFailingEditSession(pluginVersion: string) {
159
+ const editSession = createEditSession(pluginVersion);
160
+ editSession.runPlaytestAsync.mockResolvedValue({
161
+ playtestId: 'p',
162
+ success: false,
163
+ error: 'refused',
164
+ });
165
+ return editSession;
166
+ }
167
+
168
+ it('plants a bridge with the scripts to run when the setting is off', async () => {
169
+ settings.loadUserPluginsOnPlaytest = false;
170
+ const editSession = createFailingEditSession('old');
171
+
172
+ await expect(
173
+ startPlaytestAsync(createConnection(), editSession, 'run', undefined, {
174
+ scripts: ['return 1'],
175
+ })
176
+ ).rejects.toThrow('Playtest failed to start: refused');
177
+
178
+ const bridge = editSession.runPlaytestAsync.mock.calls[0][5];
179
+ expect(bridge.precompiled).toEqual(['return 1']);
180
+ });
181
+
182
+ it('plants a client bridge too for a play mode playtest when the setting is off', async () => {
183
+ settings.loadUserPluginsOnPlaytest = false;
184
+ const editSession = createFailingEditSession('old');
185
+
186
+ await expect(
187
+ startPlaytestAsync(createConnection(), editSession, 'play')
188
+ ).rejects.toThrow('Playtest failed to start: refused');
189
+
190
+ expect(
191
+ editSession.runPlaytestAsync.mock.calls[0][5].clientEntrySource
192
+ ).toContain('client entry');
193
+ });
194
+
195
+ it('plants no client bridge in run mode, or when the setting is unknown', async () => {
196
+ settings.loadUserPluginsOnPlaytest = false;
197
+ const runMode = createFailingEditSession('old');
198
+ await expect(
199
+ startPlaytestAsync(createConnection(), runMode, 'run')
200
+ ).rejects.toThrow('refused');
201
+ expect(
202
+ runMode.runPlaytestAsync.mock.calls[0][5].clientEntrySource
203
+ ).toBeUndefined();
204
+
205
+ settings.loadUserPluginsOnPlaytest = undefined;
206
+ const unknown = createFailingEditSession('current');
207
+ await expect(
208
+ startPlaytestAsync(createConnection(), unknown, 'play')
209
+ ).rejects.toThrow('refused');
210
+ expect(
211
+ unknown.runPlaytestAsync.mock.calls[0][5].clientEntrySource
212
+ ).toBeUndefined();
213
+ });
214
+
215
+ it('plants a bridge when the setting is unknown', async () => {
216
+ const editSession = createFailingEditSession('current');
217
+
218
+ await expect(
219
+ startPlaytestAsync(createConnection(), editSession, 'run')
220
+ ).rejects.toThrow('refused');
221
+
222
+ expect(editSession.runPlaytestAsync.mock.calls[0][5]).toBeDefined();
223
+ });
224
+
225
+ it('leaves the server to the plugin when the setting is on', async () => {
226
+ settings.loadUserPluginsOnPlaytest = true;
227
+ const editSession = createFailingEditSession('current');
228
+
229
+ await expect(
230
+ startPlaytestAsync(createConnection(), editSession, 'run')
231
+ ).rejects.toThrow('refused');
232
+
233
+ expect(editSession.runPlaytestAsync.mock.calls[0][5]).toBeUndefined();
234
+ });
235
+
236
+ it('needs the current plugin when plugin sessions will connect', async () => {
237
+ settings.loadUserPluginsOnPlaytest = true;
238
+ const editSession = createFailingEditSession('old');
239
+
240
+ await expect(
241
+ startPlaytestAsync(createConnection(), editSession, 'run')
242
+ ).rejects.toThrow("Run 'studio-bridge plugin install' first");
243
+ expect(editSession.runPlaytestAsync).not.toHaveBeenCalled();
244
+ });
245
+ });