@rpgjs/server 5.0.0-alpha.13 → 5.0.0-alpha.15

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.
@@ -23,7 +23,7 @@ import {
23
23
  } from "./ParameterManager";
24
24
  import { WithItemFixture } from "./ItemFixture";
25
25
  import { IItemManager, WithItemManager } from "./ItemManager";
26
- import { lastValueFrom } from "rxjs";
26
+ import { combineLatest, lastValueFrom } from "rxjs";
27
27
  import { IEffectManager, WithEffectManager } from "./EffectManager";
28
28
  import { AGI, AGI_CURVE, DEX, DEX_CURVE, INT, INT_CURVE, MAXHP, MAXHP_CURVE, MAXSP, MAXSP_CURVE, STR, STR_CURVE } from "../presets";
29
29
  import { IElementManager, WithElementManager } from "./ElementManager";
@@ -98,6 +98,20 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
98
98
  context?: Context;
99
99
  conn: MockConnection | null = null;
100
100
  touchSide: boolean = false; // Protection against map change loops
101
+ /** Last processed client input timestamp for reconciliation */
102
+ lastProcessedInputTs: number = 0;
103
+ /** Last processed client input frame for reconciliation with server tick */
104
+ _lastFramePositions: {
105
+ frame: number;
106
+ position: {
107
+ x: number;
108
+ y: number;
109
+ direction: Direction;
110
+ };
111
+ serverTick?: number; // Server tick at which this position was computed
112
+ } | null = null;
113
+
114
+ frames: { x: number; y: number; ts: number }[] = [];
101
115
 
102
116
  @sync(RpgPlayer) events = signal<RpgEvent[]>([]);
103
117
 
@@ -118,6 +132,14 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
118
132
  (this as any).addParameter(DEX, DEX_CURVE);
119
133
  (this as any).addParameter(AGI, AGI_CURVE);
120
134
  (this as any).allRecovery();
135
+
136
+ combineLatest([this.x.observable, this.y.observable]).subscribe(([x, y]) => {
137
+ this.frames = [...this.frames, {
138
+ x: x,
139
+ y: y,
140
+ ts: Date.now(),
141
+ }]
142
+ })
121
143
  }
122
144
 
123
145
  _onInit() {
@@ -128,6 +150,11 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
128
150
  return inject<Hooks>(this.context as any, ModulesToken);
129
151
  }
130
152
 
153
+ applyFrames() {
154
+ this._frames.set(this.frames)
155
+ this.frames = []
156
+ }
157
+
131
158
  async execMethod(method: string, methodData: any[] = [], target?: any) {
132
159
  let ret: any;
133
160
  if (target) {
@@ -293,8 +320,15 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
293
320
 
294
321
  async teleport(positions: { x: number; y: number }) {
295
322
  if (!this.map) return false;
296
- // For movable objects like players, the position represents the center
297
- this.map.physic.updateHitbox(this.id, positions.x, positions.y);
323
+ if (this.map.physic) {
324
+ // Skip collision check for teleportation (allow teleporting through walls)
325
+ this.map.physic.updateHitbox(this.id, positions.x, positions.y, undefined, undefined, true);
326
+ }
327
+ else {
328
+ this.x.set(positions.x)
329
+ this.y.set(positions.y)
330
+ }
331
+ this._frames.set(this.frames)
298
332
  }
299
333
 
300
334
  getCurrentMap<T extends RpgMap = RpgMap>(): T | null {
package/src/RpgServer.ts CHANGED
@@ -753,4 +753,8 @@ export interface RpgServer {
753
753
  doChangeServer(store: IStoreState, matchMaker: RpgMatchMaker, player: RpgPlayer): Promise<boolean> | boolean
754
754
  }
755
755
  }
756
+
757
+ throttleSync?: number
758
+ throttleStorage?: number
759
+ sessionExpiryTime?: number
756
760
  }
package/src/rooms/map.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Action, MockConnection, Request, Room, RoomOnJoin } from "@signe/room";
2
- import { Hooks, IceMovement, ModulesToken, ProjectileMovement, ProjectileType, RpgCommonMap, ZoneData, Direction } from "@rpgjs/common";
2
+ import { Hooks, IceMovement, ModulesToken, ProjectileMovement, ProjectileType, RpgCommonMap, ZoneData, Direction, RpgCommonPlayer } from "@rpgjs/common";
3
3
  import { WorldMapsManager, type WorldMapConfig } from "@rpgjs/common";
4
4
  import { RpgPlayer, RpgEvent } from "../Player/Player";
5
5
  import { generateShortUUID, sync, type, users } from "@signe/sync";
@@ -10,6 +10,42 @@ 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";
13
+ import { z } from "zod";
14
+
15
+ /**
16
+ * Interface for input controls configuration
17
+ *
18
+ * Defines the structure for input validation and anti-cheat controls
19
+ */
20
+ export interface Controls {
21
+ /** Maximum allowed time delta between inputs in milliseconds */
22
+ maxTimeDelta?: number;
23
+ /** Maximum allowed frame delta between inputs */
24
+ maxFrameDelta?: number;
25
+ /** Minimum time between inputs in milliseconds */
26
+ minTimeBetweenInputs?: number;
27
+ /** Whether to enable anti-cheat validation */
28
+ enableAntiCheat?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Zod schema for validating map update request body
33
+ *
34
+ * This schema ensures that the required fields are present and properly typed
35
+ * when updating a map configuration.
36
+ */
37
+ const MapUpdateSchema = z.object({
38
+ /** Configuration object for the map (optional) */
39
+ config: z.any().optional(),
40
+ /** Damage formulas configuration (optional) */
41
+ damageFormulas: z.any().optional(),
42
+ /** Unique identifier for the map (required) */
43
+ id: z.string(),
44
+ /** Width of the map in pixels (required) */
45
+ width: z.number(),
46
+ /** Height of the map in pixels (required) */
47
+ height: z.number(),
48
+ });
13
49
 
14
50
  /**
15
51
  * Interface representing hook methods available for map events
@@ -55,8 +91,7 @@ export type EventPosOption = {
55
91
  }
56
92
 
57
93
  @Room({
58
- path: "map-{id}",
59
- throttleSync: 0
94
+ path: "map-{id}"
60
95
  })
61
96
  export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
62
97
  @users(RpgPlayer) players = signal({});
@@ -67,24 +102,60 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
67
102
  globalConfig: any = {}
68
103
  damageFormulas: any = {}
69
104
 
70
- // @ts-expect-error: signature differs from RoomOnJoin for backward compat with engine
105
+ constructor() {
106
+ super();
107
+ this.hooks.callHooks("server-map-onStart", this).subscribe();
108
+ this.throttleSync = this.isStandalone ? 0 : 50; // Reduced from 100ms to 50ms for better responsiveness
109
+ this.throttleStorage = this.isStandalone ? 0 : 1000;
110
+ this.sessionExpiryTime = 1000 * 60 * 5; //5 minutes
111
+ this.loop();
112
+ }
113
+
114
+ // autoload by @signe/room
115
+ interceptorPacket(player: RpgPlayer, packet: any, conn: MockConnection) {
116
+ let obj: any = {}
117
+
118
+ if (!player) {
119
+ return null
120
+ }
121
+
122
+ // Add timestamp to sync packets for client-side prediction reconciliation
123
+ if (packet && typeof packet === 'object') {
124
+ obj.timestamp = Date.now();
125
+
126
+ // Add ack info: last processed frame and authoritative position
127
+ if (player) {
128
+ const lastFramePositions = player._lastFramePositions;
129
+ obj.ack = {
130
+ frame: lastFramePositions?.frame ?? player.pendingInputs.length,
131
+ x: lastFramePositions?.position?.x ?? player.x(),
132
+ y: lastFramePositions?.position?.y ?? player.y(),
133
+ direction: lastFramePositions?.position?.direction ?? player.direction(),
134
+ };
135
+ }
136
+ }
137
+
138
+ if (typeof packet.value == 'string') {
139
+ return packet
140
+ }
141
+
142
+ return {
143
+ ...packet,
144
+ value: {
145
+ ...packet.value,
146
+ ...obj
147
+ }
148
+ };
149
+ }
150
+
71
151
  onJoin(player: RpgPlayer, conn: MockConnection) {
72
152
  player.map = this;
73
153
  player.context = context;
74
154
  player.conn = conn;
75
- this.physic.addMovableHitbox(player, player.x(), player.y(), player.hitbox().w, player.hitbox().h, {}, {
76
- enabled: true,
77
- friction: 0.8,
78
- minVelocity: 0.5
79
- });
80
- this.physic.registerMovementEvents(player.id, () => {
81
- player.animationName.set('walk')
82
- }, () => {
83
- player.animationName.set('stand')
84
- })
85
155
  player._onInit()
86
156
  this.dataIsReady$.pipe(
87
157
  finalize(() => {
158
+ player.applyFrames()
88
159
  this.hooks
89
160
  .callHooks("server-player-onJoinMap", player, this)
90
161
  .subscribe();
@@ -92,6 +163,13 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
92
163
  ).subscribe();
93
164
  }
94
165
 
166
+ onLeave(player: RpgPlayer, conn: MockConnection) {
167
+ this.hooks
168
+ .callHooks("server-player-onLeaveMap", player, this)
169
+ .subscribe();
170
+ player.pendingInputs = [];
171
+ }
172
+
95
173
  get hooks() {
96
174
  return inject<Hooks>(context, ModulesToken);
97
175
  }
@@ -130,7 +208,8 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
130
208
 
131
209
  @Action('action')
132
210
  onAction(player: RpgPlayer, action: any) {
133
- const collisions = this.physic.getCollisions(player.id)
211
+ // Get collisions using the helper method from RpgCommonMap
212
+ const collisions = (this as any).getCollisions(player.id);
134
213
  const events: (RpgEvent | undefined)[] = collisions.map(id => this.getEvent(id))
135
214
  if (events.length > 0) {
136
215
  events.forEach(event => {
@@ -142,24 +221,36 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
142
221
 
143
222
  @Action('move')
144
223
  async onInput(player: RpgPlayer, input: any) {
145
- await this.movePlayer(player, input.input)
224
+ if (typeof input?.frame === 'number') {
225
+ // Check if we already have this frame to avoid duplicates
226
+ const existingInput = player.pendingInputs.find(pending => pending.frame === input.frame);
227
+ if (existingInput) {
228
+ return; // Skip duplicate frame
229
+ }
230
+
231
+ player.pendingInputs.push({
232
+ input: input.input,
233
+ frame: input.frame,
234
+ timestamp: input.timestamp || Date.now(),
235
+ });
236
+ }
146
237
  }
147
238
 
148
239
  @Request({
149
240
  path: "/map/update",
150
- method: "POST",
151
- })
241
+ method: "POST"
242
+ }, MapUpdateSchema as any)
152
243
  async updateMap(request: Request) {
153
244
  const map = await request.json()
154
245
  this.data.set(map)
155
246
  this.globalConfig = map.config
156
247
  this.damageFormulas = map.damageFormulas || {};
157
248
  this.damageFormulas = {
158
- damageSkill: DAMAGE_SKILL,
159
- damagePhysic: DAMAGE_PHYSIC,
160
- damageCritical: DAMAGE_CRITICAL,
161
- coefficientElements: COEFFICIENT_ELEMENTS,
162
- ...this.damageFormulas
249
+ damageSkill: DAMAGE_SKILL,
250
+ damagePhysic: DAMAGE_PHYSIC,
251
+ damageCritical: DAMAGE_CRITICAL,
252
+ coefficientElements: COEFFICIENT_ELEMENTS,
253
+ ...this.damageFormulas
163
254
  }
164
255
  await lastValueFrom(this.hooks.callHooks("server-maps-load", this))
165
256
  await lastValueFrom(this.hooks.callHooks("server-worldMaps-load", this))
@@ -167,13 +258,13 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
167
258
  map.events = map.events ?? []
168
259
 
169
260
  if (map.id) {
170
- const mapFound = this.maps.find(m => m.id === map.id)
171
- if (mapFound?.events) {
172
- map.events = [
173
- ...mapFound.events,
174
- ...map.events
175
- ]
176
- }
261
+ const mapFound = this.maps.find(m => m.id === map.id)
262
+ if (mapFound?.events) {
263
+ map.events = [
264
+ ...mapFound.events,
265
+ ...map.events
266
+ ]
267
+ }
177
268
  }
178
269
 
179
270
  await lastValueFrom(this.hooks.callHooks("server-map-onBeforeUpdate", map, this))
@@ -211,7 +302,7 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
211
302
  const parts = urlObj.pathname.split('/');
212
303
  // ['', 'world', ':id', 'update'] → index 2
213
304
  worldId = parts[2] ?? '';
214
- } catch {}
305
+ } catch { }
215
306
  const payload = await request.json();
216
307
 
217
308
  // Normalize input to array of WorldMapConfig
@@ -236,6 +327,160 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
236
327
  return { ok: true } as any;
237
328
  }
238
329
 
330
+ /**
331
+ * Process pending inputs for a player with anti-cheat validation
332
+ *
333
+ * This method processes all pending inputs for a player while performing
334
+ * anti-cheat validation to prevent time manipulation and frame skipping.
335
+ * It validates the time deltas between inputs and ensures they are within
336
+ * acceptable ranges. After processing, it saves the last frame position
337
+ * for use in packet interception.
338
+ *
339
+ * @param playerId - The ID of the player to process inputs for
340
+ * @param controls - Optional anti-cheat configuration
341
+ * @returns Promise containing the player and processed input strings
342
+ *
343
+ * @example
344
+ * ```ts
345
+ * // Process inputs with default anti-cheat settings
346
+ * const result = await map.processInput('player1');
347
+ * console.log('Processed inputs:', result.inputs);
348
+ *
349
+ * // Process inputs with custom anti-cheat configuration
350
+ * const result = await map.processInput('player1', {
351
+ * maxTimeDelta: 100,
352
+ * maxFrameDelta: 5,
353
+ * minTimeBetweenInputs: 16,
354
+ * enableAntiCheat: true
355
+ * });
356
+ * ```
357
+ */
358
+ async processInput(playerId: string, controls?: Controls): Promise<{
359
+ player: RpgPlayer,
360
+ inputs: string[]
361
+ }> {
362
+ const player = this.getPlayer(playerId);
363
+ if (!player) {
364
+ throw new Error(`Player ${playerId} not found`);
365
+ }
366
+
367
+ if (!player.isConnected()) {
368
+ player.pendingInputs = [];
369
+ return {
370
+ player,
371
+ inputs: []
372
+ }
373
+ }
374
+
375
+ const processedInputs: string[] = [];
376
+ const defaultControls: Required<Controls> = {
377
+ maxTimeDelta: 1000, // 1 second max between inputs
378
+ maxFrameDelta: 10, // Max 10 frames skipped
379
+ minTimeBetweenInputs: 16, // ~60fps minimum
380
+ enableAntiCheat: false
381
+ };
382
+
383
+ const config = { ...defaultControls, ...controls };
384
+ let lastProcessedTime = player.lastProcessedInputTs || 0;
385
+ let lastProcessedFrame = 0;
386
+
387
+ // Sort inputs by frame number to ensure proper order
388
+ player.pendingInputs.sort((a, b) => (a.frame || 0) - (b.frame || 0));
389
+
390
+ let hasProcessedInputs = false;
391
+
392
+ // Process all pending inputs
393
+ while (player.pendingInputs.length > 0) {
394
+ const input = player.pendingInputs.shift();
395
+
396
+ if (!input || typeof input.frame !== 'number') {
397
+ continue;
398
+ }
399
+
400
+ // Anti-cheat validation
401
+ if (config.enableAntiCheat) {
402
+ // Check frame delta
403
+ if (input.frame > lastProcessedFrame + config.maxFrameDelta) {
404
+ // Reset to last valid frame
405
+ input.frame = lastProcessedFrame + 1;
406
+ }
407
+
408
+ // Check time delta if timestamp is available
409
+ if (input.timestamp && lastProcessedTime > 0) {
410
+ const timeDelta = input.timestamp - lastProcessedTime;
411
+ if (timeDelta > config.maxTimeDelta) {
412
+ input.timestamp = lastProcessedTime + config.minTimeBetweenInputs;
413
+ }
414
+ }
415
+
416
+ // Check minimum time between inputs
417
+ if (input.timestamp && lastProcessedTime > 0) {
418
+ const timeDelta = input.timestamp - lastProcessedTime;
419
+ if (timeDelta < config.minTimeBetweenInputs) {
420
+ continue;
421
+ }
422
+ }
423
+ }
424
+
425
+ // Skip if frame is too old (more than 10 frames behind)
426
+ if (input.frame < lastProcessedFrame - 10) {
427
+ continue;
428
+ }
429
+
430
+ // Process the input - update velocity based on the latest input
431
+ if (input.input) {
432
+ await this.movePlayer(player, input.input);
433
+ processedInputs.push(input.input);
434
+ hasProcessedInputs = true;
435
+ lastProcessedTime = input.timestamp || Date.now();
436
+ }
437
+
438
+ // Update tracking variables
439
+ lastProcessedFrame = input.frame;
440
+ }
441
+
442
+ // Step physics once after processing all inputs for deterministic physics processing
443
+ // This matches the pattern used in the physic example where stepOneTick()
444
+ // is called in the game loop, not after each individual input. We process all inputs
445
+ // first to determine the final velocity, then step once.
446
+ // By processing all inputs before stepping, we avoid multiple steps that cause sliding
447
+ if (hasProcessedInputs) {
448
+ this.forceSingleTick();
449
+ player.lastProcessedInputTs = lastProcessedTime;
450
+ } else {
451
+ const idleTimeout = Math.max(config.minTimeBetweenInputs * 4, 50);
452
+ const lastTs = player.lastProcessedInputTs || 0;
453
+ if (lastTs > 0 && Date.now() - lastTs > idleTimeout) {
454
+ (this as any).stopMovement(player);
455
+ player.lastProcessedInputTs = 0;
456
+ }
457
+ }
458
+
459
+ // Apply frames after all physics steps have been processed
460
+ player.applyFrames()
461
+
462
+ return {
463
+ player,
464
+ inputs: processedInputs
465
+ };
466
+ }
467
+
468
+ private loop() {
469
+ setInterval(async () => {
470
+ for (const player of this.getPlayers()) {
471
+ if (player.pendingInputs.length > 0) {
472
+ const anyPlayer = player as RpgPlayer;
473
+ if (!anyPlayer._isProcessingInputs) {
474
+ anyPlayer._isProcessingInputs = true;
475
+ await this.processInput(player.id).finally(() => {
476
+ anyPlayer._isProcessingInputs = false;
477
+ });
478
+ }
479
+ }
480
+ }
481
+ }, 50); // Increased frequency from 100ms to 50ms for better responsiveness
482
+ }
483
+
239
484
  /**
240
485
  * Get a world manager by id (if multiple supported in future)
241
486
  */
@@ -323,7 +568,7 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
323
568
  if (!eventObj.event) {
324
569
  // @ts-ignore
325
570
  eventObj = {
326
- event: eventObj
571
+ event: eventObj
327
572
  }
328
573
  }
329
574
 
@@ -345,7 +590,7 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
345
590
  // Check if event is a constructor function (class)
346
591
  if (typeof event === 'function') {
347
592
  eventInstance = new event();
348
- }
593
+ }
349
594
  // Handle event as an object with hooks
350
595
  else {
351
596
  // Create a new instance extending RpgPlayer with the hooks from the event object
@@ -361,7 +606,7 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
361
606
 
362
607
  constructor() {
363
608
  super();
364
-
609
+
365
610
  // Copy hooks from the event object
366
611
  const hookObj = event as EventHooks;
367
612
  if (hookObj.onInit) this.onInit = hookObj.onInit.bind(this);
@@ -383,8 +628,9 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
383
628
 
384
629
  eventInstance.x.set(x);
385
630
  eventInstance.y.set(y);
631
+ eventInstance.applyFrames()
386
632
  if (event.name) eventInstance.name.set(event.name);
387
-
633
+
388
634
  this.events()[id] = eventInstance;
389
635
 
390
636
  await eventInstance.execMethod('onInit')
@@ -398,6 +644,10 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
398
644
  return this.players()[playerId]
399
645
  }
400
646
 
647
+ getPlayers(): RpgPlayer[] {
648
+ return Object.values(this.players())
649
+ }
650
+
401
651
  getEvents(): RpgEvent[] {
402
652
  return Object.values(this.events())
403
653
  }
@@ -519,7 +769,7 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
519
769
  }
520
770
 
521
771
  export interface RpgMap {
522
- $send: (conn: MockConnection, data: any) => void;
772
+ $send: (conn: MockConnection, data: any) => void;
523
773
  $broadcast: (data: any) => void;
524
774
  $sessionTransfer: (userOrPublicId: any | string, targetRoomId: string) => void;
525
775
  }