@antha/multiplayer-core 0.0.8 → 0.1.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.
@@ -75,6 +75,13 @@ export type MultiplayerRoomControllerParams<Message extends JsonCompatibleValue>
75
75
  */
76
76
  gameId: string;
77
77
  } & PartialWithUndefined<{
78
+ /** Reuse an existing local client id when establishing a multiplayer connection. */
79
+ clientId: ClientId;
80
+ /**
81
+ * Prepare a newly connected room before it replaces the current room. Throwing keeps the
82
+ * current room connected and discards the candidate connection.
83
+ */
84
+ prepareConnection: (connection: Readonly<MultiplayerRoomConnection<Message>>) => MaybePromise<void>;
78
85
  /**
79
86
  * This is fired when a WebRTC peer attempts to connect to the host client (this will only be
80
87
  * fired if your client is the host). Return `true` to accept the connection. Return `false` to
@@ -388,8 +395,8 @@ export declare class MultiplayerRoomController<Message extends JsonCompatibleVal
388
395
  getAllClientIds(): ClientId[];
389
396
  constructor(params: MultiplayerRoomControllerParams<Message>);
390
397
  /**
391
- * Start multiplayer mode. This initializes
392
- * {@link MultiplayerRoomController.multiplayerApiClient}.
398
+ * Initialize multiplayer API access. This does not open a room or start host pings; those begin
399
+ * when {@link MultiplayerRoomController.joinOrCreateRoom} establishes a room connection.
393
400
  */
394
401
  initMultiplayer(params: Readonly<MultiplayerInitParams>): Promise<void>;
395
402
  /**
@@ -145,8 +145,8 @@ export class MultiplayerRoomController extends ListenTarget {
145
145
  this.params = params;
146
146
  }
147
147
  /**
148
- * Start multiplayer mode. This initializes
149
- * {@link MultiplayerRoomController.multiplayerApiClient}.
148
+ * Initialize multiplayer API access. This does not open a room or start host pings; those begin
149
+ * when {@link MultiplayerRoomController.joinOrCreateRoom} establishes a room connection.
150
150
  */
151
151
  async initMultiplayer(params) {
152
152
  if (this.currentConnection) {
@@ -233,7 +233,7 @@ export class MultiplayerRoomController extends ListenTarget {
233
233
  this.updateConnectionState({
234
234
  room: MultiplayerConnectionState.Connecting,
235
235
  });
236
- const currentConnection = new WebrtcMultiplayerController(this.params.gameId, this.multiplayerApiClient, this.multiplayerParams.stunServerUrls || [], room, undefined, this.params.acceptConnection
236
+ const currentConnection = new WebrtcMultiplayerController(this.params.gameId, this.multiplayerApiClient, this.multiplayerParams.stunServerUrls || [], room, this.params.clientId, this.params.acceptConnection
237
237
  ? (data) => {
238
238
  return this.params.acceptConnection?.(data.connectingClientId, this) ?? true;
239
239
  }
@@ -259,6 +259,7 @@ export class MultiplayerRoomController extends ListenTarget {
259
259
  };
260
260
  });
261
261
  if (connectionResult.connected) {
262
+ await this.params.prepareConnection?.(currentConnection);
262
263
  this.currentConnection = currentConnection;
263
264
  previousConnection?.destroy();
264
265
  makeWritable(this).roomId = room.roomId;
@@ -34,6 +34,7 @@ export function createMultiplayerRoomHandler(options) {
34
34
  },
35
35
  /** Get the client-friendly room listing for a game. */
36
36
  getRoomsForFetching(gameId) {
37
+ updateRoomsForFetching(gameId, state);
37
38
  return state.roomsForFetching[gameId] || {};
38
39
  },
39
40
  /** Force an immediate update of the rooms-for-fetching cache for a game. */
@@ -177,20 +178,36 @@ function processQueueItem(state, { message, transport, gameId, }) {
177
178
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
178
179
  }
179
180
  else if (message.type === MultiplayerWebSocketMessageType.HostPing) {
180
- if (room && room.hostClient.clientSecret === message.clientSecret) {
181
- room.clientCount = message.clientCount;
182
- room.roomName = message.roomName;
183
- room.roomPassword = message.roomPassword;
184
- room.lastHostPingTimestamp = Date.now();
185
- updateRoomsForFetching(gameId, state);
186
- }
187
- else {
181
+ const existingRoom = state.rooms[gameId]?.[message.roomId];
182
+ if (existingRoom && existingRoom.hostClient.clientSecret !== message.clientSecret) {
183
+ /** A client that isn't the room's host is trying to ping it. */
188
184
  transport.send({
189
185
  messageId: message.messageId,
190
186
  type: MultiplayerWebSocketMessageType.Error,
191
187
  errorMessage: 'Invalid room to ping.',
192
188
  });
193
189
  }
190
+ else {
191
+ /** Refresh the room, re-establishing it if needed. */
192
+ const pingedRoom = existingRoom ?? {
193
+ clientsAwaitingAnswer: {},
194
+ hostClient: currentClient,
195
+ roomId: message.roomId,
196
+ roomName: message.roomName,
197
+ roomPassword: message.roomPassword,
198
+ clientCount: message.clientCount,
199
+ lastHostPingTimestamp: Date.now(),
200
+ };
201
+ pingedRoom.hostClient = currentClient;
202
+ pingedRoom.clientCount = message.clientCount;
203
+ pingedRoom.roomName = message.roomName;
204
+ pingedRoom.roomPassword = message.roomPassword;
205
+ pingedRoom.lastHostPingTimestamp = Date.now();
206
+ getOrSet(state.rooms, gameId, () => {
207
+ return {};
208
+ })[pingedRoom.roomId] = pingedRoom;
209
+ updateRoomsForFetching(gameId, state);
210
+ }
194
211
  }
195
212
  else {
196
213
  transport.send({
@@ -78,8 +78,8 @@ export type WebrtcEvents<MessageData extends JsonCompatibleValue> = WebrtcMessag
78
78
  */
79
79
  export declare class WebrtcController<MessageData extends JsonCompatibleValue> extends ListenTarget<WebrtcEvents<MessageData>> {
80
80
  readonly clientId: ClientId;
81
- private dataChannel;
82
- private connection;
81
+ protected dataChannel: undefined | Readonly<RTCDataChannel>;
82
+ protected connection: undefined | Readonly<RTCPeerConnection>;
83
83
  /** Indicates whether the WebRTC connection is live or not. */
84
84
  readonly isConnected: boolean;
85
85
  constructor(clientId: ClientId);
@@ -98,7 +98,9 @@ export declare class WebrtcController<MessageData extends JsonCompatibleValue> e
98
98
  */
99
99
  sendMessage(data: Readonly<MessageData>): void;
100
100
  destroy(): void;
101
- private handleDataChannel;
102
- private createConnection;
101
+ /** Attach connection and message handling to a WebRTC data channel. */
102
+ protected handleDataChannel(dataChannel: Readonly<RTCDataChannel>): void;
103
+ /** Create a peer connection and wait for ICE candidate gathering to finish. */
104
+ protected createConnection(stunServerUrls: ReadonlyArray<string>): Promise<void>;
103
105
  }
104
106
  export {};
@@ -120,6 +120,7 @@ export class WebrtcController extends ListenTarget {
120
120
  this.connection?.close();
121
121
  super.destroy();
122
122
  }
123
+ /** Attach connection and message handling to a WebRTC data channel. */
123
124
  handleDataChannel(dataChannel) {
124
125
  this.dataChannel?.close();
125
126
  this.dataChannel = dataChannel;
@@ -144,6 +145,7 @@ export class WebrtcController extends ListenTarget {
144
145
  }));
145
146
  });
146
147
  }
148
+ /** Create a peer connection and wait for ICE candidate gathering to finish. */
147
149
  createConnection(stunServerUrls) {
148
150
  if (this.connection) {
149
151
  throw new Error('Connection already created!');
@@ -1,10 +1,11 @@
1
- import { type JsonCompatibleValue, type MaybePromise, type PartialWithUndefined } from '@augment-vir/common';
2
- import { type RequireExactlyOne } from 'type-fest';
1
+ import { type JsonCompatibleValue, type MaybePromise, type PartialWithUndefined, PromiseQueue, type RequireExactlyOne } from '@augment-vir/common';
2
+ import { type ClientWebSocket } from '@rest-vir/api';
3
3
  import { ListenTarget } from 'typed-event-target';
4
- import { type MultiplayerConnectClientMessage } from '../multiplayer-api/multiplayer-api.js';
4
+ import { type MultiplayerConnectClientMessage, multiplayerConnectWebSocket } from '../multiplayer-api/multiplayer-api.js';
5
5
  import { type MultiplayerApiClient } from '../multiplayer-api/multiplayer-client.js';
6
6
  import { type ClientId } from '../multiplayer-id.js';
7
7
  import { MultiplayerWebSocketMessageType } from './web-rtc-communication.js';
8
+ import { WebrtcController } from './webrtc-controller.js';
8
9
  declare const WebrtcMultiplayerMessageEvent_base: (new (eventInitDict: {
9
10
  bubbles?: boolean;
10
11
  cancelable?: boolean;
@@ -169,8 +170,8 @@ export type ShouldAllowConnectionCheck<Controller> = (data: {
169
170
  * @category Internal
170
171
  */
171
172
  export declare class WebrtcMultiplayerController<MessageData extends JsonCompatibleValue = any> extends ListenTarget<WebrtcMultiplayerMessageEvent<MessageData> | WebrtcMultiplayerConnectionUpdateEvent> {
172
- private readonly gameId;
173
- private readonly multiplayerApiClient;
173
+ protected readonly gameId: string;
174
+ protected readonly multiplayerApiClient: Readonly<MultiplayerApiClient>;
174
175
  readonly stunServerUrls: ReadonlyArray<string>;
175
176
  readonly multiplayerRoom: Readonly<RoomInput>;
176
177
  /** The randomized client id for this controller and client. */
@@ -182,17 +183,20 @@ export declare class WebrtcMultiplayerController<MessageData extends JsonCompati
182
183
  *
183
184
  * @default accept all connections
184
185
  */
185
- private readonly shouldAllowConnectionCheck;
186
+ protected readonly shouldAllowConnectionCheck: ShouldAllowConnectionCheck<WebrtcMultiplayerController<MessageData>>;
186
187
  readonly hostClientId: ClientId | undefined;
187
188
  /**
188
189
  * Connections between multiple WebRTC peers.
189
190
  *
190
191
  * A connection with the current client's id is the init connection.
191
192
  */
192
- private connections;
193
- private webSocket;
194
- private readonly clientSecret;
193
+ protected connections: Record<ClientId, WebrtcController<MessageData>>;
194
+ protected webSocket: ClientWebSocket<typeof multiplayerConnectWebSocket> | undefined;
195
+ protected readonly clientSecret: string;
195
196
  readonly isDestroyed: boolean;
197
+ protected hostPingTimeoutId: ReturnType<typeof setTimeout> | undefined;
198
+ protected reconnectPromise: Promise<void> | undefined;
199
+ protected isCleaningUpConnection: boolean;
196
200
  constructor(gameId: string, multiplayerApiClient: Readonly<MultiplayerApiClient>, stunServerUrls: ReadonlyArray<string>, multiplayerRoom: Readonly<RoomInput>,
197
201
  /** The randomized client id for this controller and client. */
198
202
  clientId?: ClientId,
@@ -255,9 +259,97 @@ export declare class WebrtcMultiplayerController<MessageData extends JsonCompati
255
259
  * if the WebRTC connections already exist).
256
260
  */
257
261
  initConnection(): Promise<boolean>;
258
- private sendHostPing;
259
- private connectionQueue;
260
- private setupWebSocket;
261
- private createNewConnection;
262
+ /** Send the current room client count to the multiplayer server and schedule the next update. */
263
+ protected sendHostPing(): void;
264
+ /** Reconnect this room member after it loses its connection to the host. */
265
+ protected reconnectAfterHostLoss(): void;
266
+ protected connectionQueue: PromiseQueue;
267
+ /** Create or reuse the WebSocket used for multiplayer room signaling. */
268
+ protected setupWebSocket(): Promise<ClientWebSocket<Readonly<{
269
+ path: "/connect";
270
+ clientMessage: import("object-shape-tester").Shape<import("@sinclair/typebox").TUnion<(import("@sinclair/typebox").TUnsafe<{
271
+ clientId: ClientId;
272
+ roomId: import("../multiplayer-id.js").RoomId;
273
+ roomName: string;
274
+ } & {
275
+ messageId: import("../multiplayer-id.js").SocketMessageId;
276
+ } & {
277
+ type: MultiplayerWebSocketMessageType.Answer;
278
+ data: {
279
+ type: MultiplayerWebSocketMessageType.Answer;
280
+ sdp: string;
281
+ } | {
282
+ rejected: true;
283
+ };
284
+ }> | import("@sinclair/typebox").TUnsafe<{
285
+ clientId: ClientId;
286
+ roomId: import("../multiplayer-id.js").RoomId;
287
+ roomName: string;
288
+ } & {
289
+ messageId: import("../multiplayer-id.js").SocketMessageId;
290
+ } & {
291
+ type: MultiplayerWebSocketMessageType.HostPing;
292
+ clientSecret: string;
293
+ clientCount: number;
294
+ roomPassword: string;
295
+ }> | import("@sinclair/typebox").TUnsafe<{
296
+ messageId: import("../multiplayer-id.js").SocketMessageId;
297
+ } & {
298
+ clientId: ClientId;
299
+ roomId: import("../multiplayer-id.js").RoomId;
300
+ roomName: string;
301
+ } & {
302
+ type: MultiplayerWebSocketMessageType.Offer;
303
+ data: {
304
+ type: MultiplayerWebSocketMessageType.Offer;
305
+ sdp: string;
306
+ };
307
+ } & {
308
+ clientSecret: string;
309
+ roomPassword: string;
310
+ }>)[]>>;
311
+ hostMessage: import("object-shape-tester").Shape<import("@sinclair/typebox").TUnion<(import("@sinclair/typebox").TUnsafe<{
312
+ clientId: ClientId;
313
+ roomId: import("../multiplayer-id.js").RoomId;
314
+ roomName: string;
315
+ } & {
316
+ messageId: import("../multiplayer-id.js").SocketMessageId;
317
+ } & {
318
+ type: MultiplayerWebSocketMessageType.Answer;
319
+ data: {
320
+ type: MultiplayerWebSocketMessageType.Answer;
321
+ sdp: string;
322
+ } | {
323
+ rejected: true;
324
+ };
325
+ }> | import("@sinclair/typebox").TUnsafe<{
326
+ clientId: ClientId;
327
+ roomId: import("../multiplayer-id.js").RoomId;
328
+ roomName: string;
329
+ } & {
330
+ messageId: import("../multiplayer-id.js").SocketMessageId;
331
+ } & {
332
+ type: MultiplayerWebSocketMessageType.Offer;
333
+ data: {
334
+ type: MultiplayerWebSocketMessageType.Offer;
335
+ sdp: string;
336
+ };
337
+ }> | import("@sinclair/typebox").TUnsafe<{
338
+ messageId: import("../multiplayer-id.js").SocketMessageId;
339
+ } & {
340
+ type: MultiplayerWebSocketMessageType.OfferResult;
341
+ hostClientId: ClientId;
342
+ }> | import("@sinclair/typebox").TUnsafe<{
343
+ messageId: import("../multiplayer-id.js").SocketMessageId;
344
+ } & {
345
+ type: MultiplayerWebSocketMessageType.Error;
346
+ errorMessage: string;
347
+ }>)[]>>;
348
+ searchParams: {
349
+ readonly gameId: import("object-shape-tester").Shape<import("@sinclair/typebox").TTuple<[import("@sinclair/typebox").TString]>>;
350
+ };
351
+ }>>>;
352
+ /** Create and track a WebRTC connection for the given client. */
353
+ protected createNewConnection(clientId: ClientId): WebrtcController<MessageData>;
262
354
  }
263
355
  export {};
@@ -1,5 +1,5 @@
1
1
  import { assert, waitUntil } from '@augment-vir/assert';
2
- import { PromiseQueue, ensureErrorAndPrependMessage, extractErrorMessage, filterMap, getObjectTypedValues, log, makeWritable, mergeDefinedProperties, randomString, removeDuplicates, stringify, } from '@augment-vir/common';
2
+ import { PromiseQueue, ensureErrorAndPrependMessage, extractErrorMessage, filterMap, getObjectTypedValues, log, makeWritable, mergeDefinedProperties, randomString, removeDuplicates, retry, stringify, } from '@augment-vir/common';
3
3
  import { ListenTarget, defineTypedCustomEvent } from 'typed-event-target';
4
4
  import { multiplayerConnectWebSocket, } from '../multiplayer-api/multiplayer-api.js';
5
5
  import { createMultiplayerId } from '../multiplayer-id.js';
@@ -65,6 +65,9 @@ export class WebrtcMultiplayerController extends ListenTarget {
65
65
  webSocket;
66
66
  clientSecret = randomString(32);
67
67
  isDestroyed = false;
68
+ hostPingTimeoutId;
69
+ reconnectPromise;
70
+ isCleaningUpConnection = false;
68
71
  constructor(gameId, multiplayerApiClient, stunServerUrls, multiplayerRoom,
69
72
  /** The randomized client id for this controller and client. */
70
73
  clientId = createMultiplayerId.client(),
@@ -144,6 +147,8 @@ export class WebrtcMultiplayerController extends ListenTarget {
144
147
  /** Destroy this controller and clean everything up. */
145
148
  destroy() {
146
149
  makeWritable(this).isDestroyed = true;
150
+ globalThis.clearTimeout(this.hostPingTimeoutId);
151
+ this.hostPingTimeoutId = undefined;
147
152
  Object.values(this.connections).forEach((connection) => connection.destroy());
148
153
  void this.webSocket?.close();
149
154
  this.connectionQueue.destroy();
@@ -197,31 +202,50 @@ export class WebrtcMultiplayerController extends ListenTarget {
197
202
  // connections already exist
198
203
  return false;
199
204
  }
200
- const newConnection = this.createNewConnection(this.clientId);
201
- const newOffer = await newConnection.createOffer(this.stunServerUrls);
202
- const webSocket = await this.setupWebSocket();
203
- const reply = await webSocket.sendAndWaitForReply({
204
- message: {
205
- messageId: createMultiplayerId.socketMessage(),
206
- type: MultiplayerWebSocketMessageType.Offer,
207
- clientId: this.clientId,
208
- clientSecret: this.clientSecret,
209
- data: newOffer,
210
- ...this.multiplayerRoom,
211
- },
212
- replyCheck(message) {
213
- return message.type === MultiplayerWebSocketMessageType.OfferResult;
214
- },
215
- });
216
- assert.strictEquals(reply.type, MultiplayerWebSocketMessageType.OfferResult);
217
- /**
218
- * `hostClientId` will be set by the already attached listener. We just need to wait until
219
- * it does, because we need to know who the host is before calling `sendHostPing`.
220
- */
221
- await waitUntil.isDefined(() => this.hostClientId);
222
- this.sendHostPing();
223
- return true;
205
+ try {
206
+ const newConnection = this.createNewConnection(this.clientId);
207
+ const newOffer = await newConnection.createOffer(this.stunServerUrls);
208
+ const webSocket = await this.setupWebSocket();
209
+ const reply = await webSocket.sendAndWaitForReply({
210
+ message: {
211
+ messageId: createMultiplayerId.socketMessage(),
212
+ type: MultiplayerWebSocketMessageType.Offer,
213
+ clientId: this.clientId,
214
+ clientSecret: this.clientSecret,
215
+ data: newOffer,
216
+ ...this.multiplayerRoom,
217
+ },
218
+ replyCheck(message) {
219
+ return message.type === MultiplayerWebSocketMessageType.OfferResult;
220
+ },
221
+ });
222
+ assert.strictEquals(reply.type, MultiplayerWebSocketMessageType.OfferResult);
223
+ /**
224
+ * `hostClientId` will be set by the already attached listener. We just need to wait
225
+ * until it does, because we need to know who the host is before calling
226
+ * `sendHostPing`.
227
+ */
228
+ await waitUntil.isDefined(() => this.hostClientId);
229
+ this.sendHostPing();
230
+ return true;
231
+ }
232
+ catch (error) {
233
+ this.isCleaningUpConnection = true;
234
+ try {
235
+ this.connections[this.clientId]?.destroy();
236
+ delete this.connections[this.clientId];
237
+ globalThis.clearTimeout(this.hostPingTimeoutId);
238
+ this.hostPingTimeoutId = undefined;
239
+ await this.webSocket?.close();
240
+ this.webSocket = undefined;
241
+ }
242
+ finally {
243
+ this.isCleaningUpConnection = false;
244
+ }
245
+ throw error;
246
+ }
224
247
  }
248
+ /** Send the current room client count to the multiplayer server and schedule the next update. */
225
249
  sendHostPing() {
226
250
  if (this.isHost() && this.webSocket) {
227
251
  this.webSocket.send({
@@ -232,10 +256,29 @@ export class WebrtcMultiplayerController extends ListenTarget {
232
256
  clientSecret: this.clientSecret,
233
257
  ...this.multiplayerRoom,
234
258
  });
235
- setTimeout(() => this.sendHostPing(), 1000);
259
+ this.hostPingTimeoutId = setTimeout(() => this.sendHostPing(), 1000);
236
260
  }
237
261
  }
262
+ /** Reconnect this room member after it loses its connection to the host. */
263
+ reconnectAfterHostLoss() {
264
+ if (this.isDestroyed || this.reconnectPromise || this.isCleaningUpConnection) {
265
+ return;
266
+ }
267
+ this.reconnectPromise = retry(3, () => this.initConnection(), {
268
+ interval: {
269
+ seconds: 1,
270
+ },
271
+ })
272
+ .then(() => undefined)
273
+ .catch((error) => {
274
+ log.warning(ensureErrorAndPrependMessage(error, 'Failed to reconnect to the multiplayer room.'));
275
+ })
276
+ .finally(() => {
277
+ this.reconnectPromise = undefined;
278
+ });
279
+ }
238
280
  connectionQueue = new PromiseQueue();
281
+ /** Create or reuse the WebSocket used for multiplayer room signaling. */
239
282
  async setupWebSocket() {
240
283
  if (this.webSocket &&
241
284
  (this.webSocket.readyState === WebSocket.OPEN ||
@@ -342,12 +385,15 @@ export class WebrtcMultiplayerController extends ListenTarget {
342
385
  },
343
386
  close: () => {
344
387
  this.webSocket = undefined;
388
+ globalThis.clearTimeout(this.hostPingTimeoutId);
389
+ this.hostPingTimeoutId = undefined;
345
390
  },
346
391
  },
347
392
  });
348
393
  this.webSocket = webSocket;
349
394
  return webSocket;
350
395
  }
396
+ /** Create and track a WebRTC connection for the given client. */
351
397
  createNewConnection(clientId) {
352
398
  const newController = new WebrtcController(clientId);
353
399
  this.connections[clientId] = newController;
@@ -382,7 +428,7 @@ export class WebrtcMultiplayerController extends ListenTarget {
382
428
  * If this member client has lost connection to its host, we've got to get it
383
429
  * back!
384
430
  */
385
- void this.initConnection();
431
+ this.reconnectAfterHostLoss();
386
432
  }
387
433
  }
388
434
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antha/multiplayer-core",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "description": "Core functionalities for Antha multiplayer mods.",
5
5
  "keywords": [
6
6
  "vir",
@@ -36,21 +36,20 @@
36
36
  "test:docs": "virmator docs check"
37
37
  },
38
38
  "dependencies": {
39
- "@augment-vir/assert": "^31.73.1",
40
- "@augment-vir/common": "^31.73.1",
41
- "@rest-vir/api": "^2.2.0",
42
- "@sinclair/typebox": "^0.34.49",
43
- "date-vir": "^8.5.0",
44
- "object-shape-tester": "^6.14.0",
45
- "type-fest": "^5.7.0",
46
- "typed-event-target": "^4.3.1",
47
- "url-vir": "^2.1.9"
39
+ "@augment-vir/assert": "^32.2.2",
40
+ "@augment-vir/common": "^32.2.2",
41
+ "@rest-vir/api": "^2.3.1",
42
+ "@sinclair/typebox": "^0.34.52",
43
+ "date-vir": "^9.0.0",
44
+ "object-shape-tester": "^6.14.1",
45
+ "typed-event-target": "^4.3.3",
46
+ "url-vir": "^2.2.1"
48
47
  },
49
48
  "devDependencies": {
50
- "@augment-vir/test": "^31.73.1",
51
- "@web/dev-server-esbuild": "^1.0.5",
52
- "@web/test-runner": "^0.20.2",
53
- "@web/test-runner-playwright": "^0.11.1",
49
+ "@augment-vir/test": "^32.2.2",
50
+ "@web/dev-server-esbuild": "^2.0.0",
51
+ "@web/test-runner": "^1.0.0",
52
+ "@web/test-runner-playwright": "^1.0.0",
54
53
  "istanbul-smart-text-reporter": "^1.1.5"
55
54
  },
56
55
  "engines": {