@rpgjs/server 5.0.0-alpha.32 → 5.0.0-alpha.35

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/rooms/map.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { Action, MockConnection, Request, Room, RoomMethods, RoomOnJoin } from "@signe/room";
2
2
  import { Hooks, IceMovement, ModulesToken, ProjectileMovement, ProjectileType, RpgCommonMap, Direction, RpgCommonPlayer, RpgShape, findModules } from "@rpgjs/common";
3
- import { WorldMapsManager, type WorldMapConfig } from "@rpgjs/common";
3
+ import { WorldMapsManager, type WeatherState, type WorldMapConfig } from "@rpgjs/common";
4
4
  import { RpgPlayer, RpgEvent } from "../Player/Player";
5
5
  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, throttleTime } from "rxjs";
9
+ import { finalize, lastValueFrom } 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";
@@ -15,6 +15,15 @@ import { EntityState } from "@rpgjs/physic";
15
15
  import { MapOptions } from "../decorators/map";
16
16
  import { BaseRoom } from "./BaseRoom";
17
17
  import { buildSaveSlotMeta, resolveSaveStorageStrategy } from "../services/save";
18
+ import { Log } from "../logs/log";
19
+
20
+ function isRpgLog(error: unknown): error is Log {
21
+ return error instanceof Log
22
+ || (typeof error === "object"
23
+ && error !== null
24
+ && "id" in error
25
+ && (error as any).name === "RpgLog");
26
+ }
18
27
 
19
28
  /**
20
29
  * Interface for input controls configuration
@@ -30,6 +39,8 @@ export interface Controls {
30
39
  minTimeBetweenInputs?: number;
31
40
  /** Whether to enable anti-cheat validation */
32
41
  enableAntiCheat?: boolean;
42
+ /** Maximum number of queued inputs processed per server tick */
43
+ maxInputsPerTick?: number;
33
44
  }
34
45
 
35
46
  /**
@@ -51,6 +62,9 @@ const MapUpdateSchema = z.object({
51
62
  height: z.number(),
52
63
  });
53
64
 
65
+ const SAFE_MAP_WIDTH = 1000;
66
+ const SAFE_MAP_HEIGHT = 1000;
67
+
54
68
  /**
55
69
  * Interface representing hook methods available for map events
56
70
  *
@@ -95,6 +109,10 @@ export type EventPosOption = {
95
109
  event: EventConstructor | (EventHooks & Record<string, any>)
96
110
  }
97
111
 
112
+ interface WeatherSetOptions {
113
+ sync?: boolean;
114
+ }
115
+
98
116
  @Room({
99
117
  path: "map-{id}"
100
118
  })
@@ -205,6 +223,7 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
205
223
  * with custom formulas when the map is loaded.
206
224
  */
207
225
  damageFormulas: any = {}
226
+ private _weatherState: WeatherState | null = null;
208
227
  /** Internal: Map of shapes by name */
209
228
  private _shapes: Map<string, RpgShape> = new Map();
210
229
  /** Internal: Map of shape entity UUIDs to RpgShape instances */
@@ -242,6 +261,31 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
242
261
  return BaseRoom.prototype.onStart.call(this)
243
262
  }
244
263
 
264
+ private isPositiveNumber(value: unknown): value is number {
265
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
266
+ }
267
+
268
+ private resolveTrustedMapDimensions(map: any): void {
269
+ const normalizedId = typeof map?.id === "string"
270
+ ? map.id.replace(/^map-/, "")
271
+ : "";
272
+ const worldMapInfo = normalizedId
273
+ ? this.worldMapsManager?.getMapInfo(normalizedId)
274
+ : null;
275
+
276
+ if (!this.isPositiveNumber(map?.width)) {
277
+ map.width = this.isPositiveNumber(worldMapInfo?.width)
278
+ ? worldMapInfo.width
279
+ : SAFE_MAP_WIDTH;
280
+ }
281
+
282
+ if (!this.isPositiveNumber(map?.height)) {
283
+ map.height = this.isPositiveNumber(worldMapInfo?.height)
284
+ ? worldMapInfo.height
285
+ : SAFE_MAP_HEIGHT;
286
+ }
287
+ }
288
+
245
289
  /**
246
290
  * Setup collision detection between players, events, and shapes
247
291
  *
@@ -288,8 +332,8 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
288
332
 
289
333
  // Helper function to check if entities have different z (height)
290
334
  const hasDifferentZ = (entityA: any, entityB: any): boolean => {
291
- const zA = entityA.owner.z();
292
- const zB = entityB.owner.z();
335
+ const zA = entityA.owner?.z();
336
+ const zB = entityB.owner?.z();
293
337
  return zA !== zB;
294
338
  };
295
339
 
@@ -450,14 +494,25 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
450
494
  if (packet && typeof packet === 'object') {
451
495
  obj.timestamp = Date.now();
452
496
 
453
- // Add ack info: last processed frame and authoritative position
497
+ // Add ack info: last processed frame and authoritative position.
498
+ // When the sync payload already contains this player's coordinates,
499
+ // prefer them to keep ack state aligned with the snapshot sent to the client.
454
500
  if (player) {
501
+ const value = packet.value && typeof packet.value === "object" ? packet.value : undefined;
502
+ const packetPlayers = value?.players && typeof value.players === "object" ? value.players : undefined;
503
+ const playerSnapshot = packetPlayers?.[player.id];
504
+ const bodyPos = this.getBodyPosition(player.id, "top-left");
505
+ const ackX =
506
+ typeof playerSnapshot?.x === "number" ? playerSnapshot.x : bodyPos?.x ?? player.x();
507
+ const ackY =
508
+ typeof playerSnapshot?.y === "number" ? playerSnapshot.y : bodyPos?.y ?? player.y();
455
509
  const lastFramePositions = player._lastFramePositions;
456
510
  obj.ack = {
457
- frame: lastFramePositions?.frame ?? player.pendingInputs.length,
458
- x: lastFramePositions?.position?.x ?? player.x(),
459
- y: lastFramePositions?.position?.y ?? player.y(),
460
- direction: lastFramePositions?.position?.direction ?? player.direction(),
511
+ frame: lastFramePositions?.frame ?? 0,
512
+ serverTick: this.getTick(),
513
+ x: Math.round(ackX),
514
+ y: Math.round(ackY),
515
+ direction: playerSnapshot?.direction ?? player.direction(),
461
516
  };
462
517
  }
463
518
  }
@@ -510,26 +565,54 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
510
565
  }
511
566
  player.context = context;
512
567
  player.conn = conn;
568
+ player.pendingInputs = [];
569
+ player.lastProcessedInputTs = 0;
570
+ player._lastFramePositions = null;
513
571
  player._onInit()
514
572
  this.dataIsReady$.pipe(
515
- finalize(async () => {
516
- // Check if we should stop all sounds before playing new ones
517
- if ((this as any).stopAllSoundsBeforeJoin) {
518
- player.stopAllSounds();
519
- }
573
+ finalize(() => {
574
+ // Avoid unhandled promise rejections from async hook execution.
575
+ void (async () => {
576
+ try {
577
+ const hitbox = typeof player.hitbox === 'function' ? player.hitbox() : player.hitbox;
578
+ const width = hitbox?.w ?? 32;
579
+ const height = hitbox?.h ?? 32;
580
+ const body = this.getBody(player.id) as any;
581
+ if (body) {
582
+ // Ensure physics callbacks target the current player instance
583
+ // after session transfer/map return.
584
+ body.owner = player;
585
+ }
586
+ // Keep physics body aligned with restored snapshot coordinates on map join.
587
+ this.updateHitbox(player.id, player.x(), player.y(), width, height);
520
588
 
521
- this.sounds.forEach(sound => player.playSound(sound, { loop: true }));
589
+ // Check if we should stop all sounds before playing new ones
590
+ if ((this as any).stopAllSoundsBeforeJoin) {
591
+ player.stopAllSounds();
592
+ }
522
593
 
523
- // Execute global map hooks (from RpgServer.map)
524
- await lastValueFrom(this.hooks.callHooks("server-map-onJoin", player, this));
594
+ this.sounds.forEach(sound => player.playSound(sound, { loop: true }));
595
+ player.emit("weatherState", this.getWeather());
525
596
 
526
- // // Execute map-specific hooks (from @MapData or MapOptions)
527
- if (typeof (this as any)._onJoin === 'function') {
528
- await (this as any)._onJoin(player);
529
- }
597
+ // Execute global map hooks (from RpgServer.map)
598
+ await lastValueFrom(this.hooks.callHooks("server-map-onJoin", player, this));
599
+
600
+ // // Execute map-specific hooks (from @MapData or MapOptions)
601
+ if (typeof (this as any)._onJoin === 'function') {
602
+ await (this as any)._onJoin(player);
603
+ }
530
604
 
531
- // Execute player hooks
532
- await lastValueFrom(this.hooks.callHooks("server-player-onJoinMap", player, this));
605
+ // Execute player hooks
606
+ await lastValueFrom(this.hooks.callHooks("server-player-onJoinMap", player, this));
607
+ }
608
+ catch (error) {
609
+ if (isRpgLog(error)) {
610
+ console.warn(`[RpgLog:${error.id}] ${error.message}`);
611
+ return;
612
+ }
613
+ console.error("[RPGJS] Error during map onJoin hooks:", error);
614
+ }
615
+ })();
533
616
  })
534
617
  ).subscribe();
535
618
  }
@@ -569,6 +652,8 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
569
652
  // Execute player hooks
570
653
  await lastValueFrom(this.hooks.callHooks("server-player-onLeaveMap", player, this));
571
654
  player.pendingInputs = [];
655
+ player.lastProcessedInputTs = 0;
656
+ player._lastFramePositions = null;
572
657
  }
573
658
 
574
659
  /**
@@ -696,21 +781,83 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
696
781
  */
697
782
  @Action('move')
698
783
  async onInput(player: RpgPlayer, input: any) {
699
- if (typeof input?.frame === 'number') {
700
- // Check if we already have this frame to avoid duplicates
701
- const existingInput = player.pendingInputs.find(pending => pending.frame === input.frame);
702
- if (existingInput) {
703
- return; // Skip duplicate frame
784
+ const lastAckedFrame = player._lastFramePositions?.frame ?? 0;
785
+ const now = Date.now();
786
+ const candidates: Array<{
787
+ input: any;
788
+ frame: number;
789
+ tick?: number;
790
+ timestamp: number;
791
+ clientState?: { x: number; y: number; direction?: Direction };
792
+ }> = [];
793
+
794
+ const enqueueCandidate = (entry: any) => {
795
+ if (typeof entry?.frame !== "number") {
796
+ return;
797
+ }
798
+ if (!entry?.input) {
799
+ return;
704
800
  }
801
+ const candidate: {
802
+ input: any;
803
+ frame: number;
804
+ tick?: number;
805
+ timestamp: number;
806
+ clientState?: { x: number; y: number; direction?: Direction };
807
+ } = {
808
+ input: entry.input,
809
+ frame: entry.frame,
810
+ tick: typeof entry.tick === "number" ? entry.tick : undefined,
811
+ timestamp: typeof entry.timestamp === "number" ? entry.timestamp : now,
812
+ };
813
+ if (typeof entry.x === "number" && typeof entry.y === "number") {
814
+ candidate.clientState = {
815
+ x: entry.x,
816
+ y: entry.y,
817
+ direction: entry.direction,
818
+ };
819
+ }
820
+ candidates.push(candidate);
821
+ };
705
822
 
706
- player.pendingInputs.push({
707
- input: input.input,
708
- frame: input.frame,
709
- timestamp: input.timestamp || Date.now(),
710
- });
823
+ for (const trajectoryEntry of Array.isArray(input?.trajectory) ? input.trajectory : []) {
824
+ enqueueCandidate(trajectoryEntry);
825
+ }
826
+
827
+ enqueueCandidate(input);
828
+
829
+ if (candidates.length === 0) {
830
+ return;
831
+ }
832
+
833
+ candidates.sort((a, b) => a.frame - b.frame);
834
+ const existingFrames = new Set<number>(
835
+ player.pendingInputs
836
+ .map((pending: any) => pending?.frame)
837
+ .filter((frame: any): frame is number => typeof frame === "number"),
838
+ );
839
+
840
+ for (const candidate of candidates) {
841
+ if (candidate.frame <= lastAckedFrame) {
842
+ continue;
843
+ }
844
+ if (existingFrames.has(candidate.frame)) {
845
+ continue;
846
+ }
847
+ player.pendingInputs.push(candidate);
848
+ existingFrames.add(candidate.frame);
711
849
  }
712
850
  }
713
851
 
852
+ @Action("ping")
853
+ onPing(player: RpgPlayer, payload: { clientTime?: number; clientFrame?: number }) {
854
+ player.emit("pong", {
855
+ serverTick: this.getTick(),
856
+ clientTime: typeof payload?.clientTime === "number" ? payload.clientTime : Date.now(),
857
+ clientFrame: typeof payload?.clientFrame === "number" ? payload.clientFrame : 0,
858
+ });
859
+ }
860
+
714
861
  @Action('save.save')
715
862
  async saveSlot(player: RpgPlayer, value: { requestId: string; index: number; meta?: any }) {
716
863
  BaseRoom.prototype.saveSlot(player, value);
@@ -772,10 +919,17 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
772
919
  await lastValueFrom(this.hooks.callHooks("server-worldMaps-load", this))
773
920
  await lastValueFrom(this.hooks.callHooks("server-databaseHooks-load", this))
774
921
 
922
+ this.resolveTrustedMapDimensions(map)
923
+ this.data.set(map)
924
+
775
925
  map.events = map.events ?? []
926
+ let initialWeather: WeatherState | null | undefined = this.globalConfig?.weather;
776
927
 
777
928
  if (map.id) {
778
929
  const mapFound = this.maps.find(m => m.id === map.id)
930
+ if (typeof mapFound?.weather !== "undefined") {
931
+ initialWeather = mapFound.weather;
932
+ }
779
933
  if (mapFound?.events) {
780
934
  map.events = [
781
935
  ...mapFound.events,
@@ -807,6 +961,12 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
807
961
  }
808
962
  }
809
963
 
964
+ if (typeof initialWeather !== "undefined") {
965
+ this.setWeather(initialWeather);
966
+ } else {
967
+ this.clearWeather();
968
+ }
969
+
810
970
  await lastValueFrom(this.hooks.callHooks("server-map-onBeforeUpdate", map, this))
811
971
 
812
972
  this.loadPhysic()
@@ -898,10 +1058,11 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
898
1058
  /**
899
1059
  * Process pending inputs for a player with anti-cheat validation
900
1060
  *
901
- * This method processes all pending inputs for a player while performing
1061
+ * This method processes pending inputs for a player while performing
902
1062
  * anti-cheat validation to prevent time manipulation and frame skipping.
903
1063
  * It validates the time deltas between inputs and ensures they are within
904
- * acceptable ranges.
1064
+ * acceptable ranges. To preserve movement itinerary under network bursts,
1065
+ * the number of inputs processed per call is capped.
905
1066
  *
906
1067
  * ## Architecture
907
1068
  *
@@ -953,20 +1114,22 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
953
1114
  maxTimeDelta: 1000, // 1 second max between inputs
954
1115
  maxFrameDelta: 10, // Max 10 frames skipped
955
1116
  minTimeBetweenInputs: 16, // ~60fps minimum
956
- enableAntiCheat: false
1117
+ enableAntiCheat: false,
1118
+ maxInputsPerTick: 1,
957
1119
  };
958
1120
 
959
1121
  const config = { ...defaultControls, ...controls };
960
1122
  let lastProcessedTime = player.lastProcessedInputTs || 0;
961
- let lastProcessedFrame = 0;
1123
+ let lastProcessedFrame = player._lastFramePositions?.frame ?? 0;
962
1124
 
963
1125
  // Sort inputs by frame number to ensure proper order
964
1126
  player.pendingInputs.sort((a, b) => (a.frame || 0) - (b.frame || 0));
965
1127
 
966
1128
  let hasProcessedInputs = false;
1129
+ let processedThisTick = 0;
967
1130
 
968
- // Process all pending inputs
969
- while (player.pendingInputs.length > 0) {
1131
+ // Process pending inputs progressively to preserve itinerary under latency.
1132
+ while (player.pendingInputs.length > 0 && processedThisTick < config.maxInputsPerTick) {
970
1133
  const input = player.pendingInputs.shift();
971
1134
 
972
1135
  if (!input || typeof input.frame !== 'number') {
@@ -1009,6 +1172,26 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
1009
1172
  processedInputs.push(input.input);
1010
1173
  hasProcessedInputs = true;
1011
1174
  lastProcessedTime = input.timestamp || Date.now();
1175
+ processedThisTick += 1;
1176
+
1177
+ const bodyPos = this.getBodyPosition(player.id, "top-left");
1178
+ const ackX =
1179
+ typeof input.clientState?.x === "number"
1180
+ ? input.clientState.x
1181
+ : bodyPos?.x ?? player.x();
1182
+ const ackY =
1183
+ typeof input.clientState?.y === "number"
1184
+ ? input.clientState.y
1185
+ : bodyPos?.y ?? player.y();
1186
+ player._lastFramePositions = {
1187
+ frame: input.frame,
1188
+ position: {
1189
+ x: Math.round(ackX),
1190
+ y: Math.round(ackY),
1191
+ direction: input.clientState?.direction ?? player.direction(),
1192
+ },
1193
+ serverTick: this.getTick(),
1194
+ };
1012
1195
  }
1013
1196
 
1014
1197
  // Update tracking variables
@@ -1060,19 +1243,17 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
1060
1243
  this._inputLoopSubscription.unsubscribe();
1061
1244
  }
1062
1245
 
1063
- this._inputLoopSubscription = this.tick$.pipe(
1064
- throttleTime(50) // Throttle to 50ms for input processing
1065
- ).subscribe(async ({ timestamp }) => {
1246
+ this._inputLoopSubscription = this.tick$.subscribe(() => {
1066
1247
  for (const player of this.getPlayers()) {
1067
- if (player.pendingInputs.length > 0) {
1068
- const anyPlayer = player as any;
1069
- if (!anyPlayer._isProcessingInputs) {
1070
- anyPlayer._isProcessingInputs = true;
1071
- await this.processInput(player.id).finally(() => {
1072
- anyPlayer._isProcessingInputs = false;
1073
- });
1074
- }
1248
+ const anyPlayer = player as any;
1249
+ const shouldProcess = player.pendingInputs.length > 0 || (player.lastProcessedInputTs || 0) > 0;
1250
+ if (!shouldProcess || anyPlayer._isProcessingInputs) {
1251
+ continue;
1075
1252
  }
1253
+ anyPlayer._isProcessingInputs = true;
1254
+ void this.processInput(player.id).finally(() => {
1255
+ anyPlayer._isProcessingInputs = false;
1256
+ });
1076
1257
  }
1077
1258
  });
1078
1259
  }
@@ -1616,6 +1797,71 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
1616
1797
  })
1617
1798
  }
1618
1799
 
1800
+ private cloneWeatherState(weather: WeatherState | null): WeatherState | null {
1801
+ if (!weather) {
1802
+ return null;
1803
+ }
1804
+ return {
1805
+ ...weather,
1806
+ params: weather.params ? { ...weather.params } : undefined,
1807
+ };
1808
+ }
1809
+
1810
+ /**
1811
+ * Get the current map weather state.
1812
+ */
1813
+ getWeather(): WeatherState | null {
1814
+ return this.cloneWeatherState(this._weatherState);
1815
+ }
1816
+
1817
+ /**
1818
+ * Set the full weather state for this map.
1819
+ *
1820
+ * When `sync` is true (default), all connected clients receive the new weather.
1821
+ */
1822
+ setWeather(next: WeatherState | null, options: WeatherSetOptions = {}): WeatherState | null {
1823
+ const sync = options.sync !== false;
1824
+ if (next && !next.effect) {
1825
+ throw new Error("setWeather: 'effect' is required when weather is not null.");
1826
+ }
1827
+ this._weatherState = this.cloneWeatherState(next);
1828
+ if (sync) {
1829
+ this.$broadcast({
1830
+ type: "weatherState",
1831
+ value: this._weatherState,
1832
+ });
1833
+ }
1834
+ return this.getWeather();
1835
+ }
1836
+
1837
+ /**
1838
+ * Patch the current weather state.
1839
+ *
1840
+ * Nested `params` values are merged.
1841
+ */
1842
+ patchWeather(patch: Partial<WeatherState>, options: WeatherSetOptions = {}): WeatherState | null {
1843
+ const current = this._weatherState ?? null;
1844
+ if (!current && !patch.effect) {
1845
+ throw new Error("patchWeather: 'effect' is required when no weather is currently set.");
1846
+ }
1847
+ const next: WeatherState = {
1848
+ ...(current ?? {}),
1849
+ ...patch,
1850
+ params: {
1851
+ ...(current?.params ?? {}),
1852
+ ...(patch.params ?? {}),
1853
+ },
1854
+ } as WeatherState;
1855
+ return this.setWeather(next, options);
1856
+ }
1857
+
1858
+ /**
1859
+ * Clear weather for this map.
1860
+ */
1861
+ clearWeather(options: WeatherSetOptions = {}): void {
1862
+ this.setWeather(null, options);
1863
+ }
1864
+
1619
1865
  /**
1620
1866
  * Configure runtime synchronized properties on the map
1621
1867
  *
@@ -385,6 +385,24 @@ describe("Item Management - Equipment", () => {
385
385
  expect((item as any).equipped).toBe(true);
386
386
  });
387
387
 
388
+ test("should auto add and equip item", () => {
389
+ player.equip("TestSword", "auto");
390
+ const item = player.getItem("TestSword");
391
+ expect(item).toBeDefined();
392
+ expect(item?.quantity()).toBe(1);
393
+ expect((item as any).equipped).toBe(true);
394
+ expect(player.equipments().some((eq) => eq.id() === "TestSword")).toBe(
395
+ true
396
+ );
397
+ });
398
+
399
+ test("should not add duplicate when auto equipping existing item", () => {
400
+ player.addItem("TestSword", 2);
401
+ player.equip("TestSword", "auto");
402
+ const item = player.getItem("TestSword");
403
+ expect(item?.quantity()).toBe(2);
404
+ });
405
+
388
406
  test("should throw error when equipping non-existent item", () => {
389
407
  expect(() => {
390
408
  player.equip("TestSword", true);
@@ -588,4 +606,4 @@ describe("Item Management - Edge Cases", () => {
588
606
  expect(player.getItem("TestSword")?.quantity()).toBe(1);
589
607
  expect(player.getItem("TestArmor")?.quantity()).toBe(2);
590
608
  });
591
- });
609
+ });