@rpgjs/testing 5.0.0-alpha.25 → 5.0.0-alpha.27

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/src/index.ts CHANGED
@@ -1,24 +1,42 @@
1
1
  import { mergeConfig, Provider } from "@signe/di";
2
- import { provideRpg, startGame, provideClientModules, provideLoadMap, provideClientGlobalConfig, inject, WebSocketToken, AbstractWebsocket, RpgClientEngine, RpgClient, LoadMapToken } from "@rpgjs/client";
3
- import { createServer, provideServerModules, RpgServer, RpgPlayer } from "@rpgjs/server";
2
+ import {
3
+ provideRpg,
4
+ startGame,
5
+ provideClientModules,
6
+ provideLoadMap,
7
+ provideClientGlobalConfig,
8
+ inject,
9
+ WebSocketToken,
10
+ AbstractWebsocket,
11
+ RpgClientEngine,
12
+ RpgClient,
13
+ LoadMapToken,
14
+ } from "@rpgjs/client";
15
+ import {
16
+ createServer,
17
+ provideServerModules,
18
+ RpgServer,
19
+ RpgPlayer,
20
+ } from "@rpgjs/server";
4
21
  import { h, Container } from "canvasengine";
22
+ import { mockComponents } from "@canvasengine/testing";
5
23
  import { clearInject as clearClientInject } from "@rpgjs/client";
6
24
  import { clearInject as clearServerInject } from "@rpgjs/server";
7
- import { combineLatest, filter, take, race, timer, firstValueFrom } from "rxjs";
25
+ import { combineLatest, filter, take, firstValueFrom, Subject, map, throwError, race, timer, switchMap } from "rxjs";
8
26
 
9
27
  /**
10
28
  * Provides a default map loader for testing environments
11
- *
29
+ *
12
30
  * This function returns a `provideLoadMap` provider that creates mock maps
13
31
  * with default dimensions (1024x768) and a minimal component. It's automatically
14
32
  * used by `testing()` if no custom `provideLoadMap` is provided in `clientConfig.providers`.
15
- *
33
+ *
16
34
  * @returns A provider function that can be used with `provideLoadMap`
17
35
  * @example
18
36
  * ```ts
19
37
  * // Used automatically by testing()
20
38
  * const fixture = await testing([myModule])
21
- *
39
+ *
22
40
  * // Or use directly in clientConfig
23
41
  * const fixture = await testing([myModule], {
24
42
  * providers: [provideTestingLoadMap()]
@@ -26,25 +44,25 @@ import { combineLatest, filter, take, race, timer, firstValueFrom } from "rxjs";
26
44
  * ```
27
45
  */
28
46
  export function provideTestingLoadMap() {
29
- return provideLoadMap((id: string) => {
30
- return {
31
- id,
32
- data: {
33
- width: 1024,
34
- height: 768,
35
- hitboxes: [],
36
- params: {}
37
- },
38
- component: h(Container),
39
- width: 1024,
40
- height: 768,
41
- }
42
- })
47
+ return provideLoadMap((id: string) => {
48
+ return {
49
+ id,
50
+ data: {
51
+ width: 1024,
52
+ height: 768,
53
+ hitboxes: [],
54
+ params: {},
55
+ },
56
+ component: h(Container),
57
+ width: 1024,
58
+ height: 768,
59
+ };
60
+ });
43
61
  }
44
62
 
45
63
  /**
46
64
  * Normalizes modules input to extract server/client modules from createModule providers or direct module objects
47
- *
65
+ *
48
66
  * @param modules - Array of modules that can be either:
49
67
  * - Direct module objects: { server: RpgServer, client: RpgClient }
50
68
  * - Providers returned by createModule(): Provider[] with meta.server/client and useValue
@@ -53,86 +71,116 @@ export function provideTestingLoadMap() {
53
71
  * ```ts
54
72
  * // Direct modules
55
73
  * normalizeModules([{ server: serverModule, client: clientModule }])
56
- *
74
+ *
57
75
  * // createModule providers
58
76
  * const providers = createModule('MyModule', [{ server: serverModule, client: clientModule }])
59
77
  * normalizeModules(providers)
60
78
  * ```
61
79
  */
62
- function normalizeModules(modules: any[]): { serverModules: RpgServer[], clientModules: RpgClient[] } {
63
- if (!modules || modules.length === 0) {
64
- return { serverModules: [], clientModules: [] }
80
+ function normalizeModules(modules: any[]): {
81
+ serverModules: RpgServer[];
82
+ clientModules: RpgClient[];
83
+ } {
84
+ if (!modules || modules.length === 0) {
85
+ return { serverModules: [], clientModules: [] };
86
+ }
87
+
88
+ // Check if first item is a provider (has meta and useValue properties)
89
+ const isProviderArray = modules.some(
90
+ (item: any) =>
91
+ item && typeof item === "object" && "meta" in item && "useValue" in item
92
+ );
93
+
94
+ const serverModules: RpgServer[] = [];
95
+ const clientModules: RpgClient[] = [];
96
+
97
+ if (!isProviderArray) {
98
+ // Direct module objects, extract server and client separately
99
+ modules.forEach((module: any) => {
100
+ if (module && typeof module === "object") {
101
+ if (module.server) {
102
+ serverModules.push(module.server);
103
+ }
104
+ if (module.client) {
105
+ clientModules.push(module.client);
106
+ }
107
+ }
108
+ });
109
+ return { serverModules, clientModules };
110
+ }
111
+
112
+ // Extract modules from createModule providers
113
+ // createModule returns providers where useValue contains the original { server, client } object
114
+ // We need to group providers by their useValue to reconstruct the original modules
115
+ const seenUseValues = new Set<any>();
116
+
117
+ modules.forEach((provider: any) => {
118
+ if (
119
+ !provider ||
120
+ typeof provider !== "object" ||
121
+ !("meta" in provider) ||
122
+ !("useValue" in provider)
123
+ ) {
124
+ return;
65
125
  }
66
126
 
67
- // Check if first item is a provider (has meta and useValue properties)
68
- const isProviderArray = modules.some((item: any) =>
69
- item && typeof item === 'object' && 'meta' in item && 'useValue' in item
70
- )
127
+ const { useValue } = provider;
71
128
 
72
- const serverModules: RpgServer[] = []
73
- const clientModules: RpgClient[] = []
74
-
75
- if (!isProviderArray) {
76
- // Direct module objects, extract server and client separately
77
- modules.forEach((module: any) => {
78
- if (module && typeof module === 'object') {
79
- if (module.server) {
80
- serverModules.push(module.server)
81
- }
82
- if (module.client) {
83
- clientModules.push(module.client)
84
- }
85
- }
86
- })
87
- return { serverModules, clientModules }
129
+ // Skip if we've already processed this useValue (same module, different provider for server/client)
130
+ if (seenUseValues.has(useValue)) {
131
+ return;
88
132
  }
89
133
 
90
- // Extract modules from createModule providers
91
- // createModule returns providers where useValue contains the original { server, client } object
92
- // We need to group providers by their useValue to reconstruct the original modules
93
- const seenUseValues = new Set<any>()
94
-
95
- modules.forEach((provider: any) => {
96
- if (!provider || typeof provider !== 'object' || !('meta' in provider) || !('useValue' in provider)) {
97
- return
98
- }
99
-
100
- const { useValue } = provider
101
-
102
- // Skip if we've already processed this useValue (same module, different provider for server/client)
103
- if (seenUseValues.has(useValue)) {
104
- return
105
- }
106
-
107
- // Check if useValue has server or client properties (it's a module object)
108
- if (useValue && typeof useValue === 'object' && ('server' in useValue || 'client' in useValue)) {
109
- seenUseValues.add(useValue)
110
- if (useValue.server) {
111
- serverModules.push(useValue.server)
112
- }
113
- if (useValue.client) {
114
- clientModules.push(useValue.client)
115
- }
116
- }
117
- })
134
+ // Check if useValue has server or client properties (it's a module object)
135
+ if (
136
+ useValue &&
137
+ typeof useValue === "object" &&
138
+ ("server" in useValue || "client" in useValue)
139
+ ) {
140
+ seenUseValues.add(useValue);
141
+ if (useValue.server) {
142
+ serverModules.push(useValue.server);
143
+ }
144
+ if (useValue.client) {
145
+ clientModules.push(useValue.client);
146
+ }
147
+ }
148
+ });
118
149
 
119
- return { serverModules, clientModules }
150
+ return { serverModules, clientModules };
120
151
  }
121
152
 
122
153
  // Global storage for all created fixtures and clients (for clear() function)
123
154
  const globalFixtures: Array<{
124
- context: any;
125
- clientEngine: RpgClientEngine;
126
- websocket: AbstractWebsocket;
127
- server?: any;
128
- }> = []
155
+ context: any;
156
+ clientEngine: RpgClientEngine;
157
+ websocket: AbstractWebsocket;
158
+ server?: any;
159
+ }> = [];
160
+
161
+ export interface TestingFixture {
162
+ createClient(): Promise<{
163
+ socket: AbstractWebsocket;
164
+ client: RpgClientEngine;
165
+ playerId: string;
166
+ player: RpgPlayer;
167
+ waitForMapChange(expectedMapId: string, timeout?: number): Promise<RpgPlayer>;
168
+ }>;
169
+ server: RpgServer;
170
+ clear(): Promise<void>;
171
+ applySyncToClient(): Promise<void>;
172
+ nextTick(timestamp?: number): Promise<void>;
173
+ nextTickTimes(times: number, timestamp?: number): Promise<void>;
174
+ wait(ms: number): Promise<void>;
175
+ waitUntil(promise: Promise<any>): Promise<void>;
176
+ }
129
177
 
130
178
  /**
131
179
  * Testing utility function to set up server and client instances for unit testing
132
- *
180
+ *
133
181
  * This function creates a test environment with both server and client instances,
134
182
  * allowing you to test player interactions, server hooks, and game mechanics.
135
- *
183
+ *
136
184
  * @param modules - Array of modules that can be either:
137
185
  * - Direct module objects: { server: RpgServer, client: RpgClient }
138
186
  * - Providers returned by createModule(): Provider[] with meta.server/client and useValue
@@ -146,7 +194,7 @@ const globalFixtures: Array<{
146
194
  * server: serverModule,
147
195
  * client: clientModule
148
196
  * }])
149
- *
197
+ *
150
198
  * // Using createModule
151
199
  * const myModule = createModule('MyModule', [{
152
200
  * server: serverModule,
@@ -156,241 +204,307 @@ const globalFixtures: Array<{
156
204
  * ```
157
205
  */
158
206
  export async function testing(
159
- modules: ({ server?: RpgServer, client?: RpgClient } | Provider)[] = [],
160
- clientConfig: any = {},
161
- serverConfig: any = {}
162
- ) {
163
- // Normalize modules to extract server/client from providers if needed
164
- const { serverModules, clientModules } = normalizeModules(modules as any[])
165
-
166
- const serverClass = createServer({
167
- ...serverConfig,
168
- providers: [
169
- provideServerModules(serverModules),
170
- ...(serverConfig.providers || [])
171
- ]
172
- })
173
-
174
- // Store created instances for cleanup
175
- const createdClients: Array<{
176
- context: any;
177
- clientEngine: RpgClientEngine;
178
- websocket: AbstractWebsocket;
179
- server?: any;
180
- }> = []
181
-
182
- return {
183
- async createClient() {
184
- // Check if LoadMapToken is already provided in clientConfig.providers
185
- // (provideLoadMap returns an array with LoadMapToken)
186
- const hasLoadMap = clientConfig.providers?.some((provider: any) => {
187
- if (Array.isArray(provider)) {
188
- return provider.some((p: any) => p?.provide === LoadMapToken)
189
- }
190
- return provider?.provide === LoadMapToken
191
- }) || false
192
-
193
- const context = await startGame(
194
- mergeConfig({
195
- ...clientConfig,
196
- providers: [
197
- provideClientGlobalConfig({}),
198
- ...(hasLoadMap ? [] : [provideTestingLoadMap()]), // Add only if not already provided
199
- provideClientModules(clientModules),
200
- ...(clientConfig.providers || [])
201
- ]
202
- }, {
203
- providers: [provideRpg(serverClass)],
204
- })
205
- )
206
- const websocket = inject<AbstractWebsocket>(WebSocketToken) as any
207
- const clientEngine = inject<RpgClientEngine>(RpgClientEngine)
208
- const playerId = clientEngine.playerId
209
- if (!playerId) {
210
- throw new Error('Player ID is not available')
211
- }
212
- const playerIdString: string = playerId
213
-
214
- // Get server instance
215
- const server = websocket.getServer()
216
-
217
- // Disable auto tick for unit tests (manual control via nextTick)
218
- // The map will be available after the player connects, so we disable it asynchronously
219
- setTimeout(() => {
220
- try {
221
- const serverMap = server?.subRoom as any
222
- if (serverMap && typeof serverMap.setAutoTick === 'function') {
223
- serverMap.setAutoTick(false)
224
- }
225
- } catch (error) {
226
- // Ignore errors if map is not ready yet
227
- }
228
- }, 0)
229
-
230
- // Store instance for cleanup (both locally and globally)
231
- const clientInstance = {
232
- context,
233
- clientEngine,
234
- websocket,
235
- server
236
- }
237
- createdClients.push(clientInstance)
238
- globalFixtures.push(clientInstance)
239
-
240
- return {
241
- get server() {
242
- return websocket.getServer()
243
- },
244
- socket: websocket.getSocket(),
245
- client: clientEngine,
246
- playerId: playerIdString,
247
- get player(): RpgPlayer {
248
- return this.server.subRoom.players()[playerIdString] as RpgPlayer
249
- },
250
- /**
251
- * Wait for player to be on a specific map
252
- *
253
- * This utility function polls the player's current map until it matches
254
- * the expected map ID, or throws an error if the timeout is exceeded.
255
- *
256
- * @param expectedMapId - The expected map ID (without 'map-' prefix, e.g. 'map1')
257
- * @param timeout - Maximum time to wait in milliseconds (default: 5000)
258
- * @returns Promise that resolves when player is on the expected map
259
- * @throws Error if timeout is exceeded
260
- * @example
261
- * ```ts
262
- * const client = await fixture.createClient()
263
- * await client.waitForMapChange('map1')
264
- * ```
265
- */
266
- async waitForMapChange(expectedMapId: string, timeout = 5000): Promise<RpgPlayer> {
267
- const startTime = Date.now()
268
-
269
- while (Date.now() - startTime < timeout) {
270
- const currentMap = this.player.getCurrentMap()
271
- if (currentMap?.id === expectedMapId) {
272
- return this.player
273
- }
274
- await new Promise(resolve => setTimeout(resolve, 50)) // Wait 50ms before next check
275
- }
276
-
277
- const currentMap = this.player.getCurrentMap()
278
- throw new Error(
279
- `Timeout: Player did not reach map ${expectedMapId} within ${timeout}ms. ` +
280
- `Current map: ${currentMap?.id || 'null'}`
281
- )
282
- },
283
- /**
284
- * Manually trigger a game tick for processing inputs and physics
285
- *
286
- * This method is a convenience wrapper around the exported nextTick() function.
287
- *
288
- * @param timestamp - Optional timestamp to use for the tick (default: Date.now())
289
- * @returns Promise that resolves when the tick is complete
290
- *
291
- * @example
292
- * ```ts
293
- * const client = await fixture.createClient()
294
- *
295
- * // Manually advance the game by one tick
296
- * await client.nextTick()
297
- * ```
298
- */
299
- async nextTick(timestamp?: number): Promise<void> {
300
- return nextTick(this.client, timestamp)
301
- }
207
+ modules: ({ server?: RpgServer; client?: RpgClient } | Provider)[] = [],
208
+ clientConfig: any = {},
209
+ serverConfig: any = {}
210
+ ): Promise<TestingFixture> {
211
+ // Normalize modules to extract server/client from providers if needed
212
+ const { serverModules, clientModules } = normalizeModules(modules as any[]);
213
+
214
+ // Subject to emit map change events when onJoinMap is triggered
215
+ const mapChangeSubject = new Subject<{ mapId: string; player: RpgPlayer }>();
216
+
217
+ const serverClass = createServer({
218
+ ...serverConfig,
219
+ providers: [
220
+ provideServerModules([
221
+ ...serverModules,
222
+ {
223
+ player: {
224
+ onJoinMap(player: RpgPlayer, map: any) {
225
+ // Emit map change event to RxJS Subject
226
+ const mapId = map?.id;
227
+ if (mapId) {
228
+ mapChangeSubject.next({ mapId, player });
229
+ }
302
230
  }
231
+ }
232
+ },
233
+ ]),
234
+ ...(serverConfig.providers || []),
235
+ ],
236
+ });
237
+
238
+ // Check if LoadMapToken is already provided in clientConfig.providers
239
+ // (provideLoadMap returns an array with LoadMapToken)
240
+ const hasLoadMap =
241
+ clientConfig.providers?.some((provider: any) => {
242
+ if (Array.isArray(provider)) {
243
+ return provider.some((p: any) => p?.provide === LoadMapToken);
244
+ }
245
+ return provider?.provide === LoadMapToken;
246
+ }) || false;
247
+
248
+ await startGame(
249
+ mergeConfig(
250
+ {
251
+ ...clientConfig,
252
+ providers: [
253
+ provideClientGlobalConfig({
254
+ // TODO
255
+ // bootstrapCanvasOptions: {
256
+ // components: mockComponents,
257
+ // autoRegister: false,
258
+ // },
259
+ }),
260
+ ...(hasLoadMap ? [] : [provideTestingLoadMap()]), // Add only if not already provided
261
+ provideClientModules(clientModules),
262
+ ...(clientConfig.providers || []),
263
+ ],
264
+ },
265
+ {
266
+ providers: [provideRpg(serverClass, { env: { TEST: "true" } })],
267
+ }
268
+ )
269
+ );
270
+ const websocket = inject<AbstractWebsocket>(WebSocketToken) as any;
271
+ const clientEngine = inject<RpgClientEngine>(RpgClientEngine);
272
+
273
+ return {
274
+ async createClient() {
275
+ const clientObj = {
276
+ socket: websocket.getSocket(),
277
+ client: clientEngine,
278
+ get playerId() {
279
+ return Object.keys(websocket.getServer().subRoom.players())[0];
280
+ },
281
+ get player(): RpgPlayer {
282
+ return websocket.getServer().subRoom.players()[clientObj.playerId] as RpgPlayer;
303
283
  },
304
284
  /**
305
- * Clear all server, client instances and reset the DOM
306
- *
307
- * This method should be called in afterEach to clean up test state.
308
- * It destroys all created client instances, clears the server, and resets the DOM.
309
- *
285
+ * Wait for player to be on a specific map
286
+ *
287
+ * This utility function waits for the `onJoinMap` hook to be triggered
288
+ * when the player joins the expected map, or throws an error if the timeout is exceeded.
289
+ *
290
+ * ## Design
291
+ *
292
+ * Uses RxJS to listen for map change events emitted by `onJoinMap`. The function:
293
+ * 1. Checks if the player is already on the expected map
294
+ * 2. Subscribes to the `mapChangeSubject` observable
295
+ * 3. Filters events to match the expected map ID
296
+ * 4. Uses `race` operator with a timer to implement timeout handling
297
+ * 5. Resolves with the player when the map change event is received
298
+ *
299
+ * @param expectedMapId - The expected map ID (without 'map-' prefix, e.g. 'map1')
300
+ * @param timeout - Maximum time to wait in milliseconds (default: 5000)
301
+ * @returns Promise that resolves when player is on the expected map
302
+ * @throws Error if timeout is exceeded
310
303
  * @example
311
304
  * ```ts
312
- * const fixture = await testing([myModule])
313
- *
314
- * afterEach(() => {
315
- * fixture.clear()
316
- * })
305
+ * const client = await fixture.createClient()
306
+ * await client.waitForMapChange('map1')
317
307
  * ```
318
308
  */
319
- clear() {
320
- // Use the global clear function
321
- clear()
322
- }
323
- }
309
+ async waitForMapChange(
310
+ expectedMapId: string,
311
+ timeout = 5000
312
+ ): Promise<RpgPlayer> {
313
+ // Check if already on the expected map
314
+ const currentMap = clientObj.player.getCurrentMap();
315
+ if (currentMap?.id === expectedMapId) {
316
+ return clientObj.player;
317
+ }
318
+
319
+ // Create observable that filters map changes for the expected map ID
320
+ const mapChange$ = mapChangeSubject.pipe(
321
+ filter((event) => event.mapId === expectedMapId),
322
+ take(1),
323
+ map((event) => event.player)
324
+ );
325
+
326
+ // Create timeout observable that throws an error
327
+ const timeout$ = timer(timeout).pipe(
328
+ take(1),
329
+ switchMap(() => {
330
+ const currentMap = clientObj.player.getCurrentMap();
331
+ return throwError(() => new Error(
332
+ `Timeout: Player did not reach map ${expectedMapId} within ${timeout}ms. ` +
333
+ `Current map: ${currentMap?.id || "null"}`
334
+ ));
335
+ })
336
+ );
337
+
338
+ // Race between map change and timeout
339
+ try {
340
+ const result = await firstValueFrom(race([mapChange$, timeout$]));
341
+ return result as RpgPlayer;
342
+ } catch (error) {
343
+ if (error instanceof Error) {
344
+ throw error;
345
+ }
346
+ const currentMap = clientObj.player.getCurrentMap();
347
+ throw new Error(
348
+ `Timeout: Player did not reach map ${expectedMapId} within ${timeout}ms. ` +
349
+ `Current map: ${currentMap?.id || "null"}`
350
+ );
351
+ }
352
+ },
353
+
354
+ };
355
+ return clientObj;
356
+ },
357
+ get server() {
358
+ return websocket.getServer();
359
+ },
360
+ /**
361
+ * Clear all server, client instances and reset the DOM
362
+ *
363
+ * This method should be called in afterEach to clean up test state.
364
+ * It destroys all created client instances, clears the server, and resets the DOM.
365
+ *
366
+ * @example
367
+ * ```ts
368
+ * const fixture = await testing([myModule])
369
+ *
370
+ * afterEach(() => {
371
+ * fixture.clear()
372
+ * })
373
+ * ```
374
+ */
375
+ clear() {
376
+ return clear();
377
+ },
378
+ async applySyncToClient() {
379
+ websocket.getServer().subRoom.applySyncToClient();
380
+ await waitForSync(clientEngine);
381
+ },
382
+ /**
383
+ * Manually trigger a game tick for processing inputs and physics
384
+ *
385
+ * This method is a convenience wrapper around the exported nextTick() function.
386
+ * It uses the clientEngine from the fixture, so no client parameter is needed.
387
+ *
388
+ * @param timestamp - Optional timestamp to use for the tick (default: Date.now())
389
+ * @returns Promise that resolves when the tick is complete
390
+ *
391
+ * @example
392
+ * ```ts
393
+ * const fixture = await testing([myModule])
394
+ *
395
+ * // Manually advance the game by one tick
396
+ * await fixture.nextTick()
397
+ * ```
398
+ */
399
+ async nextTick(timestamp?: number): Promise<void> {
400
+ return nextTick(clientEngine, timestamp);
401
+ },
402
+ async nextTickTimes(times: number, timestamp?: number): Promise<void> {
403
+ for (let i = 0; i < times; i++) {
404
+ await nextTick(clientEngine, timestamp);
405
+ }
406
+ },
407
+ async wait(ms: number): Promise<void> {
408
+ return new Promise((resolve) => setTimeout(resolve, ms));
409
+ },
410
+ async waitUntil<T = any>(promise: Promise<T>): Promise<T> {
411
+ let tick = 0
412
+ let finish = false
413
+ return new Promise<T>((resolve: (value: T) => void, reject: (reason?: any) => void) => {
414
+ promise.then((value: T) => {
415
+ finish = true
416
+ resolve(value)
417
+ }).catch(reject)
418
+ const timeout = () => {
419
+ setTimeout(() => {
420
+ if (!finish) {
421
+ tick++
422
+ nextTick(clientEngine, Date.now() + tick * 16)
423
+ timeout()
424
+ }
425
+ }, 0)
426
+ }
427
+ timeout()
428
+ })
429
+ },
430
+ };
324
431
  }
325
432
 
326
433
  /**
327
434
  * Clear all caches and reset test state
328
- *
435
+ *
329
436
  * This function should be called after the end of each test to clean up
330
437
  * all server and client instances, clear caches, and reset the DOM.
331
- *
438
+ *
332
439
  * ## Design
333
- *
440
+ *
334
441
  * Cleans up all created fixtures, client engines, server instances, and resets
335
442
  * the DOM to a clean state. This ensures no state leaks between tests.
336
- *
443
+ *
337
444
  * @returns void
338
- *
445
+ *
339
446
  * @example
340
447
  * ```ts
341
448
  * import { clear } from '@rpgjs/testing'
342
- *
449
+ *
343
450
  * afterEach(() => {
344
451
  * clear()
345
452
  * })
346
453
  * ```
347
454
  */
348
- export function clear(): void {
349
- // Clean up all created client and server instances from all fixtures
350
- for (const client of globalFixtures) {
351
- try {
352
- // Clear client engine
353
- if (client.clientEngine && typeof (client.clientEngine as any).clear === 'function') {
354
- (client.clientEngine as any).clear()
355
- }
356
-
357
- // Clear server map (subRoom)
358
- const serverMap = client.server?.subRoom as any
359
- if (serverMap && typeof serverMap.clear === 'function') {
360
- serverMap.clear()
361
- }
362
- } catch (error) {
363
- // Silently ignore cleanup errors
364
- console.warn('Error during cleanup:', error)
365
- }
366
- }
367
-
368
- // Clear the global fixtures array
369
- globalFixtures.length = 0
370
-
371
- // Clear client context injection
372
- try {
373
- clearClientInject()
374
- } catch (error) {
375
- console.warn('Error clearing client inject:', error)
376
- }
377
-
378
- // Clear server context injection
455
+ export async function clear(): Promise<void> {
456
+
457
+ // Wait for the next tick to ensure all promises are resolved
458
+ await new Promise((resolve) => setTimeout(resolve, 0));
459
+
460
+ // Clean up all created client and server instances from all fixtures
461
+ for (const client of globalFixtures) {
379
462
  try {
380
- clearServerInject()
463
+ // Clear client engine
464
+ if (
465
+ client.clientEngine &&
466
+ typeof (client.clientEngine as any).clear === "function"
467
+ ) {
468
+ (client.clientEngine as any).clear();
469
+ }
470
+
471
+ // Clear server map (subRoom)
472
+ const serverMap = client.server?.subRoom as any;
473
+ if (serverMap && typeof serverMap.clear === "function") {
474
+ serverMap.clear();
475
+ }
381
476
  } catch (error) {
382
- console.warn('Error clearing server inject:', error)
383
- }
384
-
385
- // Reset DOM
386
- if (typeof window !== 'undefined' && window.document) {
387
- window.document.body.innerHTML = `<div id="rpg"></div>`
477
+ // Silently ignore cleanup errors
478
+ console.warn("Error during cleanup:", error);
388
479
  }
480
+ }
481
+
482
+ // Clear the global fixtures array
483
+ globalFixtures.length = 0;
484
+
485
+ // Clear client context injection
486
+ try {
487
+ clearClientInject();
488
+ } catch (error) {
489
+ console.warn("Error clearing client inject:", error);
490
+ }
491
+
492
+ // Clear server context injection
493
+ try {
494
+ clearServerInject();
495
+ } catch (error) {
496
+ console.warn("Error clearing server inject:", error);
497
+ }
498
+
499
+ // Reset DOM
500
+ if (typeof window !== "undefined" && window.document) {
501
+ window.document.body.innerHTML = `<div id="rpg"></div>`;
502
+ }
389
503
  }
390
504
 
391
505
  /**
392
506
  * Manually trigger a game tick for processing inputs and physics
393
- *
507
+ *
394
508
  * This function allows you to manually advance the game by one tick.
395
509
  * It performs the following operations:
396
510
  * 1. On server: processes pending inputs and advances physics
@@ -398,220 +512,232 @@ export function clear(): void {
398
512
  * 3. Client retrieves data and performs inputs (move, etc.) and server reconciliation
399
513
  * 4. A tick is performed on the client
400
514
  * 5. A tick is performed on VueJS (if Vue is used)
401
- *
515
+ *
402
516
  * @param client - The RpgClientEngine instance
403
517
  * @param timestamp - Optional timestamp to use for the tick (default: Date.now())
404
518
  * @returns Promise that resolves when the tick is complete
405
- *
519
+ *
406
520
  * @example
407
521
  * ```ts
408
522
  * import { nextTick } from '@rpgjs/testing'
409
- *
523
+ *
410
524
  * const client = await fixture.createClient()
411
- *
525
+ *
412
526
  * // Manually advance the game by one tick
413
527
  * await nextTick(client.client, Date.now())
414
528
  * ```
415
529
  */
416
- export async function nextTick(client: RpgClientEngine, timestamp?: number): Promise<void> {
417
- if (!client) {
418
- throw new Error('nextTick: client parameter is required')
419
- }
420
-
421
- const tickTimestamp = timestamp ?? Date.now()
422
- const delta = 16 // 16ms for 60fps
423
-
424
- // Get server instance from client context
425
- const websocket = (client as any).webSocket
426
- if (!websocket) {
427
- throw new Error('nextTick: websocket not found in client')
428
- }
429
-
430
- const server = websocket.getServer()
431
- if (!server) {
432
- throw new Error('nextTick: server not found')
433
- }
434
-
435
- // Get server map (subRoom)
436
- const serverMap = server.subRoom as any
437
- if (!serverMap) {
438
- return
530
+ export async function nextTick(
531
+ client: RpgClientEngine,
532
+ timestamp?: number
533
+ ): Promise<void> {
534
+ if (!client) {
535
+ throw new Error("nextTick: client parameter is required");
536
+ }
537
+
538
+ const tickTimestamp = timestamp ?? Date.now();
539
+ const delta = 16; // 16ms for 60fps
540
+
541
+ // Get server instance from client context
542
+ const websocket = (client as any).webSocket;
543
+ if (!websocket) {
544
+ throw new Error("nextTick: websocket not found in client");
545
+ }
546
+
547
+ const server = websocket.getServer();
548
+ if (!server) {
549
+ throw new Error("nextTick: server not found");
550
+ }
551
+
552
+ // Get server map (subRoom)
553
+ const serverMap = server.subRoom as any;
554
+ if (!serverMap) {
555
+ return;
556
+ }
557
+
558
+ // 1. On server: Process inputs for all players
559
+ for (const player of serverMap.getPlayers()) {
560
+ if (player.pendingInputs && player.pendingInputs.length > 0) {
561
+ await serverMap.processInput(player.id);
439
562
  }
563
+ }
440
564
 
441
- // 1. On server: Process inputs for all players
442
- for (const player of serverMap.getPlayers()) {
443
- if (player.pendingInputs && player.pendingInputs.length > 0) {
444
- await serverMap.processInput(player.id)
445
- }
446
- }
447
-
448
- // 2. Run physics tick on server map
449
- if (typeof serverMap.runFixedTicks === 'function') {
450
- serverMap.runFixedTicks(delta)
451
- }
565
+ serverMap.nextTick(delta);
452
566
 
453
- // 3. Server sends data to client - trigger sync for all players
454
- // The sync is triggered by calling syncChanges() on each player
455
- for (const player of serverMap.getPlayers()) {
456
- if (player && typeof (player as any).syncChanges === 'function') {
457
- (player as any).syncChanges()
458
- }
567
+ // 3. Server sends data to client - trigger sync for all players
568
+ // The sync is triggered by calling syncChanges() on each player
569
+ for (const player of serverMap.getPlayers()) {
570
+ if (player && typeof (player as any).syncChanges === "function") {
571
+ (player as any).syncChanges();
459
572
  }
573
+ }
460
574
 
461
- // 4. Client retrieves data and performs reconciliation
462
- // The sync data will be received by the client through the websocket
463
- // We need to wait a bit for the sync data to be processed
464
- await new Promise(resolve => setTimeout(resolve, 0))
575
+ // 4. Client retrieves data and performs reconciliation
576
+ // The sync data will be received by the client through the websocket
577
+ // We need to wait a bit for the sync data to be processed
578
+ await new Promise((resolve) => setTimeout(resolve, 0));
465
579
 
466
- // 5. Run physics tick on client map (performs client-side prediction)
467
- const sceneMap = (client as any).sceneMap
468
- if (sceneMap && typeof sceneMap.stepPredictionTick === 'function') {
469
- sceneMap.stepPredictionTick()
470
- }
580
+ // 5. Run physics tick on client map (performs client-side prediction)
581
+ const sceneMap = (client as any).sceneMap;
582
+ if (sceneMap && typeof sceneMap.stepPredictionTick === "function") {
583
+ sceneMap.stepPredictionTick();
584
+ }
471
585
 
472
- // 6. Trigger VueJS tick if Vue is used (handled by CanvasEngine internally)
473
- // CanvasEngine handles this automatically through its tick system
586
+ // 6. Trigger VueJS tick if Vue is used (handled by CanvasEngine internally)
587
+ // CanvasEngine handles this automatically through its tick system
474
588
  }
475
589
 
476
590
  /**
477
591
  * Wait for synchronization to complete on the client
478
- *
592
+ *
479
593
  * This function waits for the client to receive and process synchronization data
480
594
  * from the server. It monitors the `playersReceived$` and `eventsReceived$` observables
481
595
  * in the RpgClientEngine to determine when synchronization is complete.
482
- *
596
+ *
483
597
  * ## Design
484
- *
598
+ *
485
599
  * - Uses `combineLatest` to wait for both `playersReceived$` and `eventsReceived$` to be `true`
486
600
  * - Filters to only proceed when both are `true`
487
601
  * - Includes a timeout to prevent waiting indefinitely
488
602
  * - Resets the observables to `false` before waiting to ensure we catch the next sync
489
- *
603
+ *
490
604
  * @param client - The RpgClientEngine instance
491
605
  * @param timeout - Maximum time to wait in milliseconds (default: 1000ms)
492
606
  * @returns Promise that resolves when synchronization is complete
493
607
  * @throws Error if timeout is exceeded
494
- *
608
+ *
495
609
  * @example
496
610
  * ```ts
497
611
  * import { waitForSync } from '@rpgjs/testing'
498
- *
612
+ *
499
613
  * const client = await fixture.createClient()
500
- *
614
+ *
501
615
  * // Wait for sync to complete
502
616
  * await waitForSync(client.client)
503
- *
617
+ *
504
618
  * // Now you can safely test client-side state
505
619
  * expect(client.client.sceneMap.players()).toBeDefined()
506
620
  * ```
507
621
  */
508
- export async function waitForSync(client: RpgClientEngine, timeout: number = 1000): Promise<void> {
509
- if (!client) {
510
- throw new Error('waitForSync: client parameter is required')
511
- }
512
-
513
- // Access private observables via type assertion
514
- const playersReceived$ = (client as any).playersReceived$ as any
515
- const eventsReceived$ = (client as any).eventsReceived$ as any
516
-
517
- if (!playersReceived$ || !eventsReceived$) {
518
- throw new Error('waitForSync: playersReceived$ or eventsReceived$ not found in client')
519
- }
520
-
521
- // Check if observables are already true - if so, sync has already arrived, don't reset
522
- const playersAlreadyTrue = playersReceived$.getValue ? playersReceived$.getValue() === true : false;
523
- const eventsAlreadyTrue = eventsReceived$.getValue ? eventsReceived$.getValue() === true : false;
524
-
525
- // If both observables are already true, sync has already completed - return immediately
526
- if (playersAlreadyTrue && eventsAlreadyTrue) {
527
- return;
528
- }
529
-
530
- // Reset observables to false to ensure we catch the next sync
531
- // Note: This is only needed when waitForSync is called standalone.
532
- // When called from waitForSyncComplete, observables are already reset before nextTick
533
- playersReceived$.next(false)
534
- eventsReceived$.next(false)
535
-
536
- // Wait for both observables to be true
537
- const syncComplete$ = combineLatest([
538
- playersReceived$.pipe(filter(received => received === true)),
539
- eventsReceived$.pipe(filter(received => received === true))
540
- ]).pipe(
541
- take(1)
542
- )
543
-
544
- // Create a timeout promise
545
- const timeoutPromise = new Promise<never>((_, reject) => {
546
- setTimeout(() => {
547
- reject(new Error(`waitForSync: Timeout after ${timeout}ms. Synchronization did not complete.`))
548
- }, timeout)
549
- })
550
-
551
- // Race between sync completion and timeout
552
- await Promise.race([
553
- firstValueFrom(syncComplete$),
554
- timeoutPromise
555
- ])
622
+ export async function waitForSync(
623
+ client: RpgClientEngine,
624
+ timeout: number = 1000
625
+ ): Promise<void> {
626
+ if (!client) {
627
+ throw new Error("waitForSync: client parameter is required");
628
+ }
629
+
630
+ // Access private observables via type assertion
631
+ const playersReceived$ = (client as any).playersReceived$ as any;
632
+ const eventsReceived$ = (client as any).eventsReceived$ as any;
633
+
634
+ if (!playersReceived$ || !eventsReceived$) {
635
+ throw new Error(
636
+ "waitForSync: playersReceived$ or eventsReceived$ not found in client"
637
+ );
638
+ }
639
+
640
+ // Check if observables are already true - if so, sync has already arrived, don't reset
641
+ const playersAlreadyTrue = playersReceived$.getValue
642
+ ? playersReceived$.getValue() === true
643
+ : false;
644
+ const eventsAlreadyTrue = eventsReceived$.getValue
645
+ ? eventsReceived$.getValue() === true
646
+ : false;
647
+
648
+ // If both observables are already true, sync has already completed - return immediately
649
+ if (playersAlreadyTrue && eventsAlreadyTrue) {
650
+ return;
651
+ }
652
+
653
+ // Reset observables to false to ensure we catch the next sync
654
+ // Note: This is only needed when waitForSync is called standalone.
655
+ // When called from waitForSyncComplete, observables are already reset before nextTick
656
+ playersReceived$.next(false);
657
+ eventsReceived$.next(false);
658
+
659
+ // Wait for both observables to be true
660
+ const syncComplete$ = combineLatest([
661
+ playersReceived$.pipe(filter((received) => received === true)),
662
+ eventsReceived$.pipe(filter((received) => received === true)),
663
+ ]).pipe(take(1));
664
+
665
+ // Create a timeout promise
666
+ const timeoutPromise = new Promise<never>((_, reject) => {
667
+ setTimeout(() => {
668
+ reject(
669
+ new Error(
670
+ `waitForSync: Timeout after ${timeout}ms. Synchronization did not complete.`
671
+ )
672
+ );
673
+ }, timeout);
674
+ });
675
+
676
+ // Race between sync completion and timeout
677
+ await Promise.race([firstValueFrom(syncComplete$), timeoutPromise]);
556
678
  }
557
679
 
558
680
  /**
559
681
  * Wait for complete synchronization cycle (server sync + client receive)
560
- *
682
+ *
561
683
  * This function performs a complete synchronization cycle:
562
684
  * 1. Triggers a game tick using `nextTick()` which calls `syncChanges()` on all players
563
685
  * 2. Waits for the client to receive and process the synchronization data
564
- *
686
+ *
565
687
  * This is useful when you need to ensure that server-side changes are fully
566
688
  * synchronized to the client before testing client-side state.
567
- *
689
+ *
568
690
  * ## Design
569
- *
691
+ *
570
692
  * - Calls `nextTick()` to trigger server-side sync
571
693
  * - Waits for client to receive sync data using `waitForSync()`
572
694
  * - Ensures complete synchronization cycle is finished
573
- *
695
+ *
574
696
  * @param player - The RpgPlayer instance (optional, will sync all players if not provided)
575
697
  * @param client - The RpgClientEngine instance
576
698
  * @param timeout - Maximum time to wait in milliseconds (default: 1000ms)
577
699
  * @returns Promise that resolves when synchronization is complete
578
700
  * @throws Error if timeout is exceeded
579
- *
701
+ *
580
702
  * @example
581
703
  * ```ts
582
704
  * import { waitForSyncComplete } from '@rpgjs/testing'
583
- *
705
+ *
584
706
  * const client = await fixture.createClient()
585
707
  * const player = client.player
586
- *
708
+ *
587
709
  * // Make a server-side change
588
710
  * player.addItem('potion', 5)
589
- *
711
+ *
590
712
  * // Wait for sync to complete
591
713
  * await waitForSyncComplete(player, client.client)
592
- *
714
+ *
593
715
  * // Now you can safely test client-side state
594
716
  * const clientPlayer = client.client.sceneMap.players()[player.id]
595
717
  * expect(clientPlayer.items()).toBeDefined()
596
718
  * ```
597
719
  */
598
- export async function waitForSyncComplete(player: RpgPlayer | null, client: RpgClientEngine, timeout: number = 1000): Promise<void> {
599
- if (!client) {
600
- throw new Error('waitForSyncComplete: client parameter is required')
601
- }
602
-
603
- // Reset observables BEFORE calling nextTick to ensure we catch the sync that will be sent
604
- // This prevents race condition where sync arrives before we start waiting
605
- const playersReceived$ = (client as any).playersReceived$ as any
606
- const eventsReceived$ = (client as any).eventsReceived$ as any
607
- if (playersReceived$ && eventsReceived$) {
608
- playersReceived$.next(false)
609
- eventsReceived$.next(false)
610
- }
611
-
612
- // Trigger sync by calling nextTick (which calls syncChanges on all players)
613
- await nextTick(client)
614
-
615
- // Wait for client to receive and process the sync
616
- await waitForSync(client, timeout)
617
- }
720
+ export async function waitForSyncComplete(
721
+ player: RpgPlayer | null,
722
+ client: RpgClientEngine,
723
+ timeout: number = 1000
724
+ ): Promise<void> {
725
+ if (!client) {
726
+ throw new Error("waitForSyncComplete: client parameter is required");
727
+ }
728
+
729
+ // Reset observables BEFORE calling nextTick to ensure we catch the sync that will be sent
730
+ // This prevents race condition where sync arrives before we start waiting
731
+ const playersReceived$ = (client as any).playersReceived$ as any;
732
+ const eventsReceived$ = (client as any).eventsReceived$ as any;
733
+ if (playersReceived$ && eventsReceived$) {
734
+ playersReceived$.next(false);
735
+ eventsReceived$.next(false);
736
+ }
737
+
738
+ // Trigger sync by calling nextTick (which calls syncChanges on all players)
739
+ await nextTick(client);
740
+
741
+ // Wait for client to receive and process the sync
742
+ await waitForSync(client, timeout);
743
+ }