@rpgjs/server 5.0.0-alpha.23 → 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.
@@ -1,5 +1,5 @@
1
1
  import { MockConnection, RoomOnJoin } from '@signe/room';
2
- import { Hooks, RpgCommonMap, RpgShape, WorldMapsManager, WorldMapConfig } from '@rpgjs/common';
2
+ import { Hooks, RpgCommonMap, RpgShape, WorldMapsManager, WorldMapConfig } from '../../../common/src';
3
3
  import { RpgPlayer, RpgEvent } from '../Player/Player';
4
4
  import { BehaviorSubject } from 'rxjs';
5
5
  import { MapOptions } from '../decorators/map';
@@ -162,6 +162,10 @@ export declare class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoi
162
162
  private _shapes;
163
163
  /** Internal: Map of shape entity UUIDs to RpgShape instances */
164
164
  private _shapeEntities;
165
+ /** Internal: Subscription for the input processing loop */
166
+ private _inputLoopSubscription?;
167
+ /** Enable/disable automatic tick processing (useful for unit tests) */
168
+ private _autoTickEnabled;
165
169
  constructor();
166
170
  /**
167
171
  * Setup collision detection between players, events, and shapes
@@ -543,24 +547,43 @@ export declare class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoi
543
547
  /**
544
548
  * Main game loop that processes player inputs
545
549
  *
546
- * This private method runs continuously every 50ms to process pending inputs
547
- * for all players on the map. It ensures inputs are processed in order and
548
- * prevents concurrent processing for the same player.
550
+ * This private method subscribes to tick$ and processes pending inputs
551
+ * for all players on the map with a throttle of 50ms. It ensures inputs are
552
+ * processed in order and prevents concurrent processing for the same player.
549
553
  *
550
554
  * ## Architecture
551
555
  *
552
- * - Runs every 50ms for responsive input processing
556
+ * - Subscribes to tick$ with throttleTime(50ms) for responsive input processing
553
557
  * - Processes inputs for each player with pending inputs
554
558
  * - Uses a flag to prevent concurrent processing for the same player
555
559
  * - Calls `processInput()` to handle anti-cheat validation and movement
556
560
  *
557
561
  * @example
558
562
  * ```ts
559
- * // This method is called automatically in the constructor
563
+ * // This method is called automatically in the constructor if autoTick is enabled
560
564
  * // You typically don't call it directly
561
565
  * ```
562
566
  */
563
567
  private loop;
568
+ /**
569
+ * Enable or disable automatic tick processing
570
+ *
571
+ * When disabled, the input processing loop will not run automatically.
572
+ * This is useful for unit tests where you want manual control over when
573
+ * inputs are processed.
574
+ *
575
+ * @param enabled - Whether to enable automatic tick processing (default: true)
576
+ *
577
+ * @example
578
+ * ```ts
579
+ * // Disable auto tick for testing
580
+ * map.setAutoTick(false);
581
+ *
582
+ * // Manually trigger tick processing
583
+ * await map.processInput('player1');
584
+ * ```
585
+ */
586
+ setAutoTick(enabled: boolean): void;
564
587
  /**
565
588
  * Get a world manager by id
566
589
  *
@@ -1244,6 +1267,27 @@ export declare class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoi
1244
1267
  frequency?: number;
1245
1268
  direction?: 'x' | 'y' | 'both';
1246
1269
  }): void;
1270
+ /**
1271
+ * Clear all server resources and reset state
1272
+ *
1273
+ * This method should be called to clean up all server-side resources when
1274
+ * shutting down or resetting the map. It stops the input processing loop
1275
+ * and ensures that all subscriptions are properly cleaned up.
1276
+ *
1277
+ * ## Design
1278
+ *
1279
+ * This method is used primarily in testing environments to ensure clean
1280
+ * state between tests. It stops the tick subscription to prevent memory leaks.
1281
+ *
1282
+ * @example
1283
+ * ```ts
1284
+ * // In test cleanup
1285
+ * afterEach(() => {
1286
+ * map.clear();
1287
+ * });
1288
+ * ```
1289
+ */
1290
+ clear(): void;
1247
1291
  }
1248
1292
  export interface RpgMap {
1249
1293
  $send: (conn: MockConnection, data: any) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpgjs/server",
3
- "version": "5.0.0-alpha.23",
3
+ "version": "5.0.0-alpha.25",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "publishConfig": {
@@ -11,23 +11,25 @@
11
11
  "license": "MIT",
12
12
  "description": "",
13
13
  "dependencies": {
14
- "@rpgjs/common": "5.0.0-alpha.23",
15
- "@rpgjs/physic": "5.0.0-alpha.23",
14
+ "@rpgjs/common": "5.0.0-alpha.25",
15
+ "@rpgjs/physic": "5.0.0-alpha.25",
16
+ "@rpgjs/testing": "5.0.0-alpha.25",
16
17
  "@rpgjs/database": "^4.3.0",
17
- "@signe/di": "^2.5.2",
18
- "@signe/reactive": "^2.5.2",
19
- "@signe/room": "^2.5.2",
20
- "@signe/sync": "^2.5.2",
18
+ "@signe/di": "^2.6.0",
19
+ "@signe/reactive": "^2.6.0",
20
+ "@signe/room": "^2.6.0",
21
+ "@signe/sync": "^2.6.0",
21
22
  "rxjs": "^7.8.2",
22
23
  "zod": "^4.1.13"
23
24
  },
24
25
  "devDependencies": {
25
- "vite": "^7.2.6",
26
+ "vite": "^7.2.7",
26
27
  "vite-plugin-dts": "^4.5.4"
27
28
  },
28
29
  "type": "module",
29
30
  "scripts": {
30
31
  "dev": "vite build --watch",
31
- "build": "vite build"
32
+ "build": "vite build",
33
+ "test": "vitest"
32
34
  }
33
35
  }
@@ -278,6 +278,7 @@ export function WithItemManager<TBase extends PlayerCtor>(Base: TBase) {
278
278
  // Create new item instance
279
279
  instance = new Item(data);
280
280
  instance.id.set(itemId);
281
+ instance.quantity.set(nb);
281
282
 
282
283
  // Attach hooks from class instance or object
283
284
  if (itemInstance) {
@@ -294,6 +295,7 @@ export function WithItemManager<TBase extends PlayerCtor>(Base: TBase) {
294
295
 
295
296
  // Call onAdd hook - use stored instance if available
296
297
  const hookTarget = (instance as any)._itemInstance || instance;
298
+ // Only call onAdd if it exists and is a function
297
299
  (this as any)["execMethod"]("onAdd", [this], hookTarget);
298
300
  return instance;
299
301
  }
@@ -315,7 +317,9 @@ export function WithItemManager<TBase extends PlayerCtor>(Base: TBase) {
315
317
  }
316
318
  // Call onRemove hook - use stored instance if available
317
319
  const hookTarget = (item as any)._itemInstance || item;
318
- this["execMethod"]("onRemove", [this], hookTarget);
320
+ if (hookTarget && typeof hookTarget.onRemove === 'function') {
321
+ this["execMethod"]("onRemove", [this], hookTarget);
322
+ }
319
323
  return this.items()[itemIndex];
320
324
  }
321
325
 
@@ -580,6 +580,9 @@ export function WithParameterManager<TBase extends PlayerCtor>(Base: TBase) {
580
580
  this.hpSignal.set(val)
581
581
  }
582
582
 
583
+ get hp(): number {
584
+ return this.hpSignal()
585
+ }
583
586
 
584
587
  /**
585
588
  * Changes the skill points
@@ -601,7 +604,9 @@ export function WithParameterManager<TBase extends PlayerCtor>(Base: TBase) {
601
604
  this.spSignal.set(val)
602
605
  }
603
606
 
604
-
607
+ get sp(): number {
608
+ return this.spSignal()
609
+ }
605
610
 
606
611
  /**
607
612
  * Changing the player's experience.
@@ -185,7 +185,9 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
185
185
  async execMethod(method: string, methodData: any[] = [], target?: any) {
186
186
  let ret: any;
187
187
  if (target) {
188
- ret = await target[method](...methodData);
188
+ if (typeof target[method] === 'function') {
189
+ ret = await target[method](...methodData);
190
+ }
189
191
  }
190
192
  else {
191
193
  ret = await lastValueFrom(this.hooks
@@ -268,13 +270,13 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
268
270
  const worldPositionX = (map.worldX ?? 0) + this.x();
269
271
  const worldPositionY = (map.worldY ?? 0) + this.y();
270
272
 
271
- const changeMap = async (adjacentCoords: {x: number, y: number}, positionCalculator: (nextMapInfo: any) => {x: number, y: number}) => {
273
+ const changeMap = async (directionNumber: number, positionCalculator: (nextMapInfo: any) => {x: number, y: number}) => {
272
274
  if (this.touchSide) {
273
275
  return false;
274
276
  }
275
277
  this.touchSide = true;
276
278
 
277
- const [nextMap] = worldMaps.getAdjacentMaps(map, adjacentCoords);
279
+ const [nextMap] = worldMaps.getAdjacentMaps(map, directionNumber);
278
280
  if (!nextMap) {
279
281
  this.touchSide = false;
280
282
  return false;
@@ -299,40 +301,28 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
299
301
  };
300
302
  // Check left border
301
303
  if (nextPosition.x < marginLeftRight && direction === Direction.Left) {
302
- ret = await changeMap({
303
- x: (map.worldX ?? 0) - 1,
304
- y: worldPositionY
305
- }, nextMapInfo => ({
304
+ ret = await changeMap(2, nextMapInfo => ({
306
305
  x: nextMapInfo.width - (this.hitbox().w) - marginLeftRight,
307
306
  y: (map.worldY ?? 0) - (nextMapInfo.y ?? 0) + nextPosition.y
308
307
  }));
309
308
  }
310
309
  // Check right border
311
310
  else if (nextPosition.x > map.widthPx - this.hitbox().w - marginLeftRight && direction === Direction.Right) {
312
- ret = await changeMap({
313
- x: (map.worldX ?? 0) + map.widthPx + 1,
314
- y: worldPositionY
315
- }, nextMapInfo => ({
311
+ ret = await changeMap(3, nextMapInfo => ({
316
312
  x: marginLeftRight,
317
313
  y: (map.worldY ?? 0) - (nextMapInfo.y ?? 0) + nextPosition.y
318
314
  }));
319
315
  }
320
316
  // Check top border
321
317
  else if (nextPosition.y < marginTopDown && direction === Direction.Up) {
322
- ret = await changeMap({
323
- x: worldPositionX,
324
- y: (map.worldY ?? 0) - 1
325
- }, nextMapInfo => ({
318
+ ret = await changeMap(0, nextMapInfo => ({
326
319
  x: (map.worldX ?? 0) - (nextMapInfo.x ?? 0) + nextPosition.x,
327
320
  y: nextMapInfo.height - this.hitbox().h - marginTopDown
328
321
  }));
329
322
  }
330
323
  // Check bottom border
331
324
  else if (nextPosition.y > map.heightPx - this.hitbox().h - marginTopDown && direction === Direction.Down) {
332
- ret = await changeMap({
333
- x: worldPositionX,
334
- y: (map.worldY ?? 0) + map.heightPx + 1
335
- }, nextMapInfo => ({
325
+ ret = await changeMap(1, nextMapInfo => ({
336
326
  x: (map.worldX ?? 0) - (nextMapInfo.x ?? 0) + nextPosition.x,
337
327
  y: marginTopDown
338
328
  }));
package/src/rooms/map.ts CHANGED
@@ -6,7 +6,7 @@ import { generateShortUUID, sync, type, users } from "@signe/sync";
6
6
  import { signal } from "@signe/reactive";
7
7
  import { inject } from "@signe/di";
8
8
  import { context } from "../core/context";;
9
- import { finalize, lastValueFrom } from "rxjs";
9
+ import { finalize, lastValueFrom, throttleTime } from "rxjs";
10
10
  import { Subject } from "rxjs";
11
11
  import { BehaviorSubject } from "rxjs";
12
12
  import { COEFFICIENT_ELEMENTS, DAMAGE_CRITICAL, DAMAGE_PHYSIC, DAMAGE_SKILL } from "../presets";
@@ -207,6 +207,10 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
207
207
  private _shapes: Map<string, RpgShape> = new Map();
208
208
  /** Internal: Map of shape entity UUIDs to RpgShape instances */
209
209
  private _shapeEntities: Map<string, RpgShape> = new Map();
210
+ /** Internal: Subscription for the input processing loop */
211
+ private _inputLoopSubscription?: any;
212
+ /** Enable/disable automatic tick processing (useful for unit tests) */
213
+ private _autoTickEnabled: boolean = true;
210
214
 
211
215
  constructor() {
212
216
  super();
@@ -215,7 +219,9 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
215
219
  this.throttleStorage = this.isStandalone ? 0 : 1000;
216
220
  this.sessionExpiryTime = 1000 * 60 * 5; //5 minutes
217
221
  this.setupCollisionDetection();
218
- this.loop();
222
+ if (this._autoTickEnabled) {
223
+ this.loop();
224
+ }
219
225
  }
220
226
 
221
227
  /**
@@ -1052,25 +1058,31 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
1052
1058
  /**
1053
1059
  * Main game loop that processes player inputs
1054
1060
  *
1055
- * This private method runs continuously every 50ms to process pending inputs
1056
- * for all players on the map. It ensures inputs are processed in order and
1057
- * prevents concurrent processing for the same player.
1061
+ * This private method subscribes to tick$ and processes pending inputs
1062
+ * for all players on the map with a throttle of 50ms. It ensures inputs are
1063
+ * processed in order and prevents concurrent processing for the same player.
1058
1064
  *
1059
1065
  * ## Architecture
1060
1066
  *
1061
- * - Runs every 50ms for responsive input processing
1067
+ * - Subscribes to tick$ with throttleTime(50ms) for responsive input processing
1062
1068
  * - Processes inputs for each player with pending inputs
1063
1069
  * - Uses a flag to prevent concurrent processing for the same player
1064
1070
  * - Calls `processInput()` to handle anti-cheat validation and movement
1065
1071
  *
1066
1072
  * @example
1067
1073
  * ```ts
1068
- * // This method is called automatically in the constructor
1074
+ * // This method is called automatically in the constructor if autoTick is enabled
1069
1075
  * // You typically don't call it directly
1070
1076
  * ```
1071
1077
  */
1072
1078
  private loop() {
1073
- setInterval(async () => {
1079
+ if (this._inputLoopSubscription) {
1080
+ this._inputLoopSubscription.unsubscribe();
1081
+ }
1082
+
1083
+ this._inputLoopSubscription = this.tick$.pipe(
1084
+ throttleTime(50) // Throttle to 50ms for input processing
1085
+ ).subscribe(async ({ timestamp }) => {
1074
1086
  for (const player of this.getPlayers()) {
1075
1087
  if (player.pendingInputs.length > 0) {
1076
1088
  const anyPlayer = player as any;
@@ -1082,7 +1094,35 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
1082
1094
  }
1083
1095
  }
1084
1096
  }
1085
- }, 50); // Increased frequency from 100ms to 50ms for better responsiveness
1097
+ });
1098
+ }
1099
+
1100
+ /**
1101
+ * Enable or disable automatic tick processing
1102
+ *
1103
+ * When disabled, the input processing loop will not run automatically.
1104
+ * This is useful for unit tests where you want manual control over when
1105
+ * inputs are processed.
1106
+ *
1107
+ * @param enabled - Whether to enable automatic tick processing (default: true)
1108
+ *
1109
+ * @example
1110
+ * ```ts
1111
+ * // Disable auto tick for testing
1112
+ * map.setAutoTick(false);
1113
+ *
1114
+ * // Manually trigger tick processing
1115
+ * await map.processInput('player1');
1116
+ * ```
1117
+ */
1118
+ setAutoTick(enabled: boolean): void {
1119
+ this._autoTickEnabled = enabled;
1120
+ if (enabled && !this._inputLoopSubscription) {
1121
+ this.loop();
1122
+ } else if (!enabled && this._inputLoopSubscription) {
1123
+ this._inputLoopSubscription.unsubscribe();
1124
+ this._inputLoopSubscription = undefined;
1125
+ }
1086
1126
  }
1087
1127
 
1088
1128
  /**
@@ -2036,6 +2076,38 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
2036
2076
  },
2037
2077
  });
2038
2078
  }
2079
+
2080
+ /**
2081
+ * Clear all server resources and reset state
2082
+ *
2083
+ * This method should be called to clean up all server-side resources when
2084
+ * shutting down or resetting the map. It stops the input processing loop
2085
+ * and ensures that all subscriptions are properly cleaned up.
2086
+ *
2087
+ * ## Design
2088
+ *
2089
+ * This method is used primarily in testing environments to ensure clean
2090
+ * state between tests. It stops the tick subscription to prevent memory leaks.
2091
+ *
2092
+ * @example
2093
+ * ```ts
2094
+ * // In test cleanup
2095
+ * afterEach(() => {
2096
+ * map.clear();
2097
+ * });
2098
+ * ```
2099
+ */
2100
+ clear(): void {
2101
+ try {
2102
+ // Stop input processing loop
2103
+ if (this._inputLoopSubscription) {
2104
+ this._inputLoopSubscription.unsubscribe();
2105
+ this._inputLoopSubscription = undefined;
2106
+ }
2107
+ } catch (error) {
2108
+ console.warn('Error during map cleanup:', error);
2109
+ }
2110
+ }
2039
2111
  }
2040
2112
 
2041
2113
  export interface RpgMap {
@@ -0,0 +1,72 @@
1
+ import { beforeEach, test, expect, afterEach } from 'vitest'
2
+ import { testing } from '@rpgjs/testing'
3
+ import { defineModule, createModule } from '@rpgjs/common'
4
+ import { RpgPlayer, RpgServer } from '../src'
5
+ import { RpgClient } from '../../client/src'
6
+
7
+ // Define server module with two maps
8
+ const serverModule = defineModule<RpgServer>({
9
+ maps: [
10
+ {
11
+ id: 'map1',
12
+ file: '',
13
+ },
14
+ {
15
+ id: 'map2',
16
+ file: '',
17
+ }
18
+ ],
19
+ player: {
20
+ async onConnected(player) {
21
+ // Start player on map1
22
+ await player.changeMap('map1', { x: 100, y: 100 })
23
+ },
24
+ onJoinMap(player) {
25
+ console.log('onJoinMap', player.getCurrentMap()?.id)
26
+ }
27
+ }
28
+ })
29
+
30
+ // Define client module
31
+ const clientModule = defineModule<RpgClient>({
32
+ // Client-side logic
33
+ })
34
+
35
+ let player: RpgPlayer
36
+ let client: any
37
+ let fixture: any
38
+
39
+ beforeEach(async () => {
40
+ const myModule = createModule('TestModule', [{
41
+ server: serverModule,
42
+ client: clientModule
43
+ }])
44
+
45
+ fixture = await testing(myModule)
46
+ client = await fixture.createClient()
47
+ player = client.player
48
+ })
49
+
50
+ afterEach(() => {
51
+ fixture.clear()
52
+ })
53
+
54
+ test('Player can change map', async () => {
55
+ player = await client.waitForMapChange('map1')
56
+
57
+ const initialMap = player.getCurrentMap()
58
+ expect(initialMap).toBeDefined()
59
+ expect(initialMap?.id).toBe('map1')
60
+
61
+ const result = await player.changeMap('map2', { x: 200, y: 200 })
62
+ expect(result).toBe(true)
63
+
64
+ player = await client.waitForMapChange('map2')
65
+
66
+ const newMap = player.getCurrentMap()
67
+ expect(newMap).toBeDefined()
68
+ expect(newMap?.id).toBe('map2')
69
+
70
+ expect(player.x()).toBe(200 - player.hitbox().h / 2)
71
+ expect(player.y()).toBe(200 - player.hitbox().w / 2)
72
+ })