@rpgjs/testing 4.3.0 → 5.0.0-alpha.26

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