@rpgjs/testing 4.2.2 → 5.0.0-alpha.25

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,267 +1,617 @@
1
- import { HookClient, ModuleType, RpgPlugin } from '@rpgjs/common'
2
- import { entryPoint, RpgServerEngine, RpgMap, RpgWorld, RpgPlayer } from '@rpgjs/server'
3
- import { entryPoint as entryPointClient, RpgClientEngine } from '@rpgjs/client'
4
- import { ObjectFixtureList, Position } from '@rpgjs/types'
5
- import { MockSocketIo } from 'simple-room'
6
-
7
- const { serverIo, ClientIo } = MockSocketIo
8
-
9
- type ClientTesting = {
10
- client: RpgClientEngine,
11
- socket: any,
12
- playerId: string
13
- player: RpgPlayer
14
- }
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";
4
+ import { h, Container } from "canvasengine";
5
+ import { clearInject as clearClientInject } from "@rpgjs/client";
6
+ import { clearInject as clearServerInject } from "@rpgjs/server";
7
+ import { combineLatest, filter, take, race, timer, firstValueFrom } from "rxjs";
15
8
 
16
- type PositionMap = string | Position
17
-
18
- interface Testing {
19
- /**
20
- * Allows you to create a client and get fixtures to manipulate it during tests
21
- *
22
- * Returns:
23
- *
24
- * ```ts
25
- * {
26
- * client: RpgClientEngine,
27
- * socket: any,
28
- * playerId: string
29
- * player: RpgPlayer
30
- * }
31
- * ```
32
- *
33
- * @title Create Client
34
- * @method createClient()
35
- * @returns {Promise<ClientTesting>}
36
- * @memberof FixtureTesting
37
- */
38
- createClient(): Promise<ClientTesting>,
39
-
40
- /**
41
- * Create another client, add it to the map and send the information to the first client
42
- *
43
- * @title Add Other Client In Map
44
- * @method addOtherClientInMap(firstClient,mapId,position?)
45
- * @param {RpgClientEngine} firstClient
46
- * @param {string} mapId
47
- * @param {Position | string} [position]
48
- * @returns {Promise<ClientTesting>}
49
- * @since 3.2.0
50
- * @memberof FixtureTesting
51
- */
52
- addOtherClientInMap(firstClient: RpgClientEngine, mapId: string, position?: PositionMap): Promise<ClientTesting>
53
-
54
- /**
55
- * Get server
56
- *
57
- * @prop {RpgServerEngine} server
58
- * @memberof FixtureTesting
59
- */
60
- server: RpgServerEngine
61
-
62
- /**
63
- * Allows you to change the map. This function on the tests also allows to render with PIXI on the client side
64
- *
65
- * @title Change Map
66
- * @method changeMap(client,mapId,position?)
67
- * @param {RpgClientEngine} client
68
- * @param {string} mapId
69
- * @param {Position | string} [position]
70
- * @returns {Promise<void>}
71
- * @memberof FixtureTesting
72
- */
73
- changeMap(client: RpgClientEngine, mapId: string, position?: PositionMap): Promise<void>
9
+ /**
10
+ * Provides a default map loader for testing environments
11
+ *
12
+ * This function returns a `provideLoadMap` provider that creates mock maps
13
+ * with default dimensions (1024x768) and a minimal component. It's automatically
14
+ * used by `testing()` if no custom `provideLoadMap` is provided in `clientConfig.providers`.
15
+ *
16
+ * @returns A provider function that can be used with `provideLoadMap`
17
+ * @example
18
+ * ```ts
19
+ * // Used automatically by testing()
20
+ * const fixture = await testing([myModule])
21
+ *
22
+ * // Or use directly in clientConfig
23
+ * const fixture = await testing([myModule], {
24
+ * providers: [provideTestingLoadMap()]
25
+ * })
26
+ * ```
27
+ */
28
+ 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
+ })
74
43
  }
75
44
 
76
- let server: RpgServerEngine
77
- let clients: RpgClientEngine[]
45
+ /**
46
+ * Normalizes modules input to extract server/client modules from createModule providers or direct module objects
47
+ *
48
+ * @param modules - Array of modules that can be either:
49
+ * - Direct module objects: { server: RpgServer, client: RpgClient }
50
+ * - Providers returned by createModule(): Provider[] with meta.server/client and useValue
51
+ * @returns Object with separate arrays for server and client modules
52
+ * @example
53
+ * ```ts
54
+ * // Direct modules
55
+ * normalizeModules([{ server: serverModule, client: clientModule }])
56
+ *
57
+ * // createModule providers
58
+ * const providers = createModule('MyModule', [{ server: serverModule, client: clientModule }])
59
+ * normalizeModules(providers)
60
+ * ```
61
+ */
62
+ function normalizeModules(modules: any[]): { serverModules: RpgServer[], clientModules: RpgClient[] } {
63
+ if (!modules || modules.length === 0) {
64
+ return { serverModules: [], clientModules: [] }
65
+ }
78
66
 
79
- function changeMap(client: RpgClientEngine, server: RpgServerEngine, mapId: string, position?: PositionMap): Promise<void> {
80
- return new Promise(async (resolve: any) => {
81
- let player = RpgWorld.getPlayer(client.playerId)
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
+ )
82
71
 
83
- const beforeLoading = () => {
84
- client.PIXI.utils.clearTextureCache()
85
- }
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 }
88
+ }
86
89
 
87
- const afterLoading = () => {
88
- client.nextFrame(0)
89
- RpgPlugin.off(HookClient.BeforeSceneLoading, beforeLoading)
90
- RpgPlugin.off(HookClient.AfterSceneLoading, afterLoading)
91
- resolve()
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
92
98
  }
93
99
 
94
- RpgPlugin.on(HookClient.BeforeSceneLoading, beforeLoading)
95
- RpgPlugin.on(HookClient.AfterSceneLoading, afterLoading)
100
+ const { useValue } = provider
96
101
 
97
- await player.changeMap(mapId, position)
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
+ }
98
117
  })
118
+
119
+ return { serverModules, clientModules }
99
120
  }
100
121
 
122
+ // Global storage for all created fixtures and clients (for clear() function)
123
+ const globalFixtures: Array<{
124
+ context: any;
125
+ clientEngine: RpgClientEngine;
126
+ websocket: AbstractWebsocket;
127
+ server?: any;
128
+ }> = []
129
+
101
130
  /**
102
- * Allows you to test modules
103
- *
104
- * @title Testing
105
- * @method testing(modules,optionsServer?,optionsClient?)
106
- * @param {ModuleType[]} modules
107
- * @param {object} [optionsServer]
108
- * @param {object} [optionsClient]
109
- * @returns {Promise<FixtureTesting>}
110
- * @example
131
+ * Testing utility function to set up server and client instances for unit testing
132
+ *
133
+ * This function creates a test environment with both server and client instances,
134
+ * allowing you to test player interactions, server hooks, and game mechanics.
111
135
  *
136
+ * @param modules - Array of modules that can be either:
137
+ * - Direct module objects: { server: RpgServer, client: RpgClient }
138
+ * - Providers returned by createModule(): Provider[] with meta.server/client and useValue
139
+ * @param clientConfig - Optional client configuration
140
+ * @param serverConfig - Optional server configuration
141
+ * @returns Testing fixture with createClient method
142
+ * @example
112
143
  * ```ts
113
- * import { testing } from '@rpgjs/testing';
114
- * import { beforeEach } from 'vitest';
115
- *
116
- * beforeEach(async () => {
117
- * const fixture = await testing([
118
- * {
119
- * server: RpgServerModule
120
- * },
121
- * ]);
122
- * const clientFixture = await fixture.createClient();
123
- * currentPlayer = clientFixture.player;
124
- * });
125
- *
126
- * @memberof Testing
144
+ * // Using direct modules
145
+ * const fixture = await testing([{
146
+ * server: serverModule,
147
+ * client: clientModule
148
+ * }])
149
+ *
150
+ * // Using createModule
151
+ * const myModule = createModule('MyModule', [{
152
+ * server: serverModule,
153
+ * client: clientModule
154
+ * }])
155
+ * const fixture = await testing(myModule)
156
+ * ```
127
157
  */
128
- export async function testing(modules: ModuleType[], optionsServer: any = {}, optionsClient: any = {}): Promise<Testing> {
129
- RpgPlugin.clear()
130
- const engine = await entryPoint(modules, {
131
- io: serverIo,
132
- standalone: true,
133
- disableAuth: true,
134
- ...optionsServer
158
+ 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
+ ]
135
172
  })
136
- engine.start(null, false)
137
- server = engine
138
- clients = []
139
-
140
- const createClient = async function createClient() {
141
- const client = entryPointClient(modules, {
142
- io: new ClientIo(),
143
- standalone: true,
144
- ...optionsClient
145
- })
146
- await client.start({
147
- renderLoop: false
148
- })
149
- clients.push(client)
150
- client.renderer.transitionMode = 0
151
- const playerId = client.playerId
152
- return {
153
- client,
154
- socket: client.socket,
155
- playerId,
156
- player: RpgWorld.getPlayer(playerId)
157
- }
158
- }
159
-
160
- const _changeMap = function (client: RpgClientEngine, mapId: string, position?: PositionMap) {
161
- return changeMap(client, server, mapId, position)
162
- }
163
-
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
+
164
182
  return {
165
- createClient,
166
- async addOtherClientInMap(firstClient: RpgClientEngine, mapId: string, position?: PositionMap) {
167
- const clientFixture = await createClient()
168
- const client = clientFixture.client
169
- await _changeMap(client, mapId, position)
170
- await nextTick(firstClient)
171
- return clientFixture
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
+ }
302
+ }
172
303
  },
173
- server: engine,
174
- changeMap: _changeMap
304
+ /**
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
+ *
310
+ * @example
311
+ * ```ts
312
+ * const fixture = await testing([myModule])
313
+ *
314
+ * afterEach(() => {
315
+ * fixture.clear()
316
+ * })
317
+ * ```
318
+ */
319
+ clear() {
320
+ // Use the global clear function
321
+ clear()
322
+ }
175
323
  }
176
324
  }
177
325
 
178
326
  /**
179
- * Clear caches. Use it after the end of each test
327
+ * Clear all caches and reset test state
180
328
  *
329
+ * This function should be called after the end of each test to clean up
330
+ * all server and client instances, clear caches, and reset the DOM.
331
+ *
332
+ * ## Design
333
+ *
334
+ * Cleans up all created fixtures, client engines, server instances, and resets
335
+ * the DOM to a clean state. This ensures no state leaks between tests.
336
+ *
337
+ * @returns void
338
+ *
339
+ * @example
181
340
  * ```ts
182
341
  * import { clear } from '@rpgjs/testing'
183
- * import { afterEach } from 'vitest'
184
342
  *
185
343
  * afterEach(() => {
186
- * clear()
344
+ * clear()
187
345
  * })
188
346
  * ```
189
- *
190
- * @title Clear
191
- * @method clear()
192
- * @returns {void}
193
- * @memberof Testing
194
347
  */
195
348
  export function clear(): void {
196
- server?.world.clear()
197
- clients?.forEach(client => client.reset())
198
- RpgMap.buffer.clear()
199
- RpgPlugin.clear()
200
- serverIo.clear()
201
- serverIo.events.clear()
202
- window.document.body.innerHTML = `<div id="rpg"></div>`
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
379
+ try {
380
+ clearServerInject()
381
+ } 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>`
388
+ }
203
389
  }
204
390
 
205
391
  /**
206
- * Allows you to make a tick:
207
- * 1. on server
208
- * 2. server sends data to client
392
+ * Manually trigger a game tick for processing inputs and physics
393
+ *
394
+ * This function allows you to manually advance the game by one tick.
395
+ * It performs the following operations:
396
+ * 1. On server: processes pending inputs and advances physics
397
+ * 2. Server sends data to client
209
398
  * 3. Client retrieves data and performs inputs (move, etc.) and server reconciliation
210
399
  * 4. A tick is performed on the client
211
- * 5. A tick is performed on VueJS
212
- *
213
- * @title Next Tick
214
- * @method nextTick(client,timestamp?)
215
- * @param {RpgClientEngine} client
216
- * @param {number} [timestamp=0] A predefined timestamp
217
- * @returns {Promise<ObjectFixtureList>}
218
- * @memberof Testing
400
+ * 5. A tick is performed on VueJS (if Vue is used)
401
+ *
402
+ * @param client - The RpgClientEngine instance
403
+ * @param timestamp - Optional timestamp to use for the tick (default: Date.now())
404
+ * @returns Promise that resolves when the tick is complete
405
+ *
406
+ * @example
407
+ * ```ts
408
+ * import { nextTick } from '@rpgjs/testing'
409
+ *
410
+ * const client = await fixture.createClient()
411
+ *
412
+ * // Manually advance the game by one tick
413
+ * await nextTick(client.client, Date.now())
414
+ * ```
219
415
  */
220
- export async function nextTick(client: RpgClientEngine, timestamp = 0): Promise<ObjectFixtureList> {
221
- server.nextTick(timestamp)
222
- await server.send()
223
- return new Promise((resolve: any) => {
224
- client.objects.subscribe(async (objects) => {
225
- await client.processInput()
226
- client.nextFrame(timestamp)
227
- await client.vueInstance.$nextTick()
228
- resolve(objects)
229
- })
230
- })
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
439
+ }
440
+
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
+ }
452
+
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
+ }
459
+ }
460
+
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))
465
+
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
+ }
471
+
472
+ // 6. Trigger VueJS tick if Vue is used (handled by CanvasEngine internally)
473
+ // CanvasEngine handles this automatically through its tick system
231
474
  }
232
475
 
233
476
  /**
234
- * @title Wait a moment
235
- * @method waitUntil(promise)
236
- * @param {Promise<any>} promise
237
- * @returns {Promise<any>}
238
- * @since 4.0.0
239
- * @memberof Testing
240
- * @example
477
+ * Wait for synchronization to complete on the client
478
+ *
479
+ * This function waits for the client to receive and process synchronization data
480
+ * from the server. It monitors the `playersReceived$` and `eventsReceived$` observables
481
+ * in the RpgClientEngine to determine when synchronization is complete.
482
+ *
483
+ * ## Design
241
484
  *
485
+ * - Uses `combineLatest` to wait for both `playersReceived$` and `eventsReceived$` to be `true`
486
+ * - Filters to only proceed when both are `true`
487
+ * - Includes a timeout to prevent waiting indefinitely
488
+ * - Resets the observables to `false` before waiting to ensure we catch the next sync
489
+ *
490
+ * @param client - The RpgClientEngine instance
491
+ * @param timeout - Maximum time to wait in milliseconds (default: 1000ms)
492
+ * @returns Promise that resolves when synchronization is complete
493
+ * @throws Error if timeout is exceeded
494
+ *
495
+ * @example
242
496
  * ```ts
243
- * await waitUntil(
244
- * player.moveRoutes([Move.right()])
245
- * )
497
+ * import { waitForSync } from '@rpgjs/testing'
498
+ *
499
+ * const client = await fixture.createClient()
500
+ *
501
+ * // Wait for sync to complete
502
+ * await waitForSync(client.client)
503
+ *
504
+ * // Now you can safely test client-side state
505
+ * expect(client.client.sceneMap.players()).toBeDefined()
246
506
  * ```
247
507
  */
248
- export function waitUntil(promise: Promise<any>): Promise<any> {
249
- let tick = 0
250
- let finish = false
251
- return new Promise((resolve: any, reject: any) => {
252
- promise.then(() => {
253
- finish = true
254
- resolve()
255
- }).catch(reject)
256
- const timeout = () => {
257
- setTimeout(() => {
258
- if (!finish) {
259
- tick++
260
- server.nextTick(tick)
261
- timeout()
262
- }
263
- }, 50)
264
- }
265
- timeout()
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)
266
549
  })
550
+
551
+ // Race between sync completion and timeout
552
+ await Promise.race([
553
+ firstValueFrom(syncComplete$),
554
+ timeoutPromise
555
+ ])
556
+ }
557
+
558
+ /**
559
+ * Wait for complete synchronization cycle (server sync + client receive)
560
+ *
561
+ * This function performs a complete synchronization cycle:
562
+ * 1. Triggers a game tick using `nextTick()` which calls `syncChanges()` on all players
563
+ * 2. Waits for the client to receive and process the synchronization data
564
+ *
565
+ * This is useful when you need to ensure that server-side changes are fully
566
+ * synchronized to the client before testing client-side state.
567
+ *
568
+ * ## Design
569
+ *
570
+ * - Calls `nextTick()` to trigger server-side sync
571
+ * - Waits for client to receive sync data using `waitForSync()`
572
+ * - Ensures complete synchronization cycle is finished
573
+ *
574
+ * @param player - The RpgPlayer instance (optional, will sync all players if not provided)
575
+ * @param client - The RpgClientEngine instance
576
+ * @param timeout - Maximum time to wait in milliseconds (default: 1000ms)
577
+ * @returns Promise that resolves when synchronization is complete
578
+ * @throws Error if timeout is exceeded
579
+ *
580
+ * @example
581
+ * ```ts
582
+ * import { waitForSyncComplete } from '@rpgjs/testing'
583
+ *
584
+ * const client = await fixture.createClient()
585
+ * const player = client.player
586
+ *
587
+ * // Make a server-side change
588
+ * player.addItem('potion', 5)
589
+ *
590
+ * // Wait for sync to complete
591
+ * await waitForSyncComplete(player, client.client)
592
+ *
593
+ * // Now you can safely test client-side state
594
+ * const clientPlayer = client.client.sceneMap.players()[player.id]
595
+ * expect(clientPlayer.items()).toBeDefined()
596
+ * ```
597
+ */
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)
267
617
  }