@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/dist/index.d.ts +20 -62
- package/dist/index.js +163 -102
- package/dist/index.js.map +1 -1
- package/dist/setup.js +3 -0
- package/dist/setup.js.map +1 -1
- package/package.json +4 -3
- package/src/index.ts +552 -426
- package/src/setup.ts +5 -0
package/src/index.ts
CHANGED
|
@@ -1,24 +1,42 @@
|
|
|
1
1
|
import { mergeConfig, Provider } from "@signe/di";
|
|
2
|
-
import {
|
|
3
|
-
|
|
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,
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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[]): {
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* This
|
|
308
|
-
*
|
|
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
|
|
313
|
-
*
|
|
314
|
-
* afterEach(() => {
|
|
315
|
-
* fixture.clear()
|
|
316
|
-
* })
|
|
305
|
+
* const client = await fixture.createClient()
|
|
306
|
+
* await client.waitForMapChange('map1')
|
|
317
307
|
* ```
|
|
318
308
|
*/
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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(
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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(
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
+
}
|