@rpgjs/server 5.0.0-alpha.14 → 5.0.0-alpha.16

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,18 @@ 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
+ const entity = this.map.physic.getEntityByUUID(this.id);
326
+ if (entity) {
327
+ this.map.physic.teleport(entity, { x: positions.x, y: positions.y });
328
+ }
329
+ }
330
+ else {
331
+ this.x.set(positions.x)
332
+ this.y.set(positions.y)
333
+ }
334
+ this._frames.set(this.frames)
298
335
  }
299
336
 
300
337
  getCurrentMap<T extends RpgMap = RpgMap>(): T | null {
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";
@@ -12,6 +12,22 @@ import { BehaviorSubject } from "rxjs";
12
12
  import { COEFFICIENT_ELEMENTS, DAMAGE_CRITICAL, DAMAGE_PHYSIC, DAMAGE_SKILL } from "../presets";
13
13
  import { z } from "zod";
14
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
+
15
31
  /**
16
32
  * Zod schema for validating map update request body
17
33
  *
@@ -89,31 +105,57 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
89
105
  constructor() {
90
106
  super();
91
107
  this.hooks.callHooks("server-map-onStart", this).subscribe();
92
- this.throttleSync = this.isStandalone ? 0 : 100;
108
+ this.throttleSync = this.isStandalone ? 0 : 50; // Reduced from 100ms to 50ms for better responsiveness
93
109
  this.throttleStorage = this.isStandalone ? 0 : 1000;
110
+ this.sessionExpiryTime = 1000 * 60 * 5; //5 minutes
111
+ this.loop();
94
112
  }
95
113
 
96
- get isStandalone() {
97
- return typeof window !== 'undefined'
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
+ };
98
149
  }
99
150
 
100
151
  onJoin(player: RpgPlayer, conn: MockConnection) {
101
152
  player.map = this;
102
153
  player.context = context;
103
154
  player.conn = conn;
104
- this.physic.addMovableHitbox(player, player.x(), player.y(), player.hitbox().w, player.hitbox().h, {}, {
105
- enabled: true,
106
- friction: 0.8,
107
- minVelocity: 0.5
108
- });
109
- this.physic.registerMovementEvents(player.id, () => {
110
- player.animationName.set('walk')
111
- }, () => {
112
- player.animationName.set('stand')
113
- })
114
155
  player._onInit()
115
156
  this.dataIsReady$.pipe(
116
157
  finalize(() => {
158
+ player.applyFrames()
117
159
  this.hooks
118
160
  .callHooks("server-player-onJoinMap", player, this)
119
161
  .subscribe();
@@ -122,10 +164,10 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
122
164
  }
123
165
 
124
166
  onLeave(player: RpgPlayer, conn: MockConnection) {
125
- this.physic.removeHitbox(player.id)
126
167
  this.hooks
127
168
  .callHooks("server-player-onLeaveMap", player, this)
128
169
  .subscribe();
170
+ player.pendingInputs = [];
129
171
  }
130
172
 
131
173
  get hooks() {
@@ -166,7 +208,8 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
166
208
 
167
209
  @Action('action')
168
210
  onAction(player: RpgPlayer, action: any) {
169
- 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);
170
213
  const events: (RpgEvent | undefined)[] = collisions.map(id => this.getEvent(id))
171
214
  if (events.length > 0) {
172
215
  events.forEach(event => {
@@ -178,7 +221,19 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
178
221
 
179
222
  @Action('move')
180
223
  async onInput(player: RpgPlayer, input: any) {
181
- 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
+ }
182
237
  }
183
238
 
184
239
  @Request({
@@ -191,11 +246,11 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
191
246
  this.globalConfig = map.config
192
247
  this.damageFormulas = map.damageFormulas || {};
193
248
  this.damageFormulas = {
194
- damageSkill: DAMAGE_SKILL,
195
- damagePhysic: DAMAGE_PHYSIC,
196
- damageCritical: DAMAGE_CRITICAL,
197
- coefficientElements: COEFFICIENT_ELEMENTS,
198
- ...this.damageFormulas
249
+ damageSkill: DAMAGE_SKILL,
250
+ damagePhysic: DAMAGE_PHYSIC,
251
+ damageCritical: DAMAGE_CRITICAL,
252
+ coefficientElements: COEFFICIENT_ELEMENTS,
253
+ ...this.damageFormulas
199
254
  }
200
255
  await lastValueFrom(this.hooks.callHooks("server-maps-load", this))
201
256
  await lastValueFrom(this.hooks.callHooks("server-worldMaps-load", this))
@@ -203,13 +258,13 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
203
258
  map.events = map.events ?? []
204
259
 
205
260
  if (map.id) {
206
- const mapFound = this.maps.find(m => m.id === map.id)
207
- if (mapFound?.events) {
208
- map.events = [
209
- ...mapFound.events,
210
- ...map.events
211
- ]
212
- }
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
+ }
213
268
  }
214
269
 
215
270
  await lastValueFrom(this.hooks.callHooks("server-map-onBeforeUpdate", map, this))
@@ -247,7 +302,7 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
247
302
  const parts = urlObj.pathname.split('/');
248
303
  // ['', 'world', ':id', 'update'] → index 2
249
304
  worldId = parts[2] ?? '';
250
- } catch {}
305
+ } catch { }
251
306
  const payload = await request.json();
252
307
 
253
308
  // Normalize input to array of WorldMapConfig
@@ -272,6 +327,160 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
272
327
  return { ok: true } as any;
273
328
  }
274
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
+
275
484
  /**
276
485
  * Get a world manager by id (if multiple supported in future)
277
486
  */
@@ -359,7 +568,7 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
359
568
  if (!eventObj.event) {
360
569
  // @ts-ignore
361
570
  eventObj = {
362
- event: eventObj
571
+ event: eventObj
363
572
  }
364
573
  }
365
574
 
@@ -381,7 +590,7 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
381
590
  // Check if event is a constructor function (class)
382
591
  if (typeof event === 'function') {
383
592
  eventInstance = new event();
384
- }
593
+ }
385
594
  // Handle event as an object with hooks
386
595
  else {
387
596
  // Create a new instance extending RpgPlayer with the hooks from the event object
@@ -397,7 +606,7 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
397
606
 
398
607
  constructor() {
399
608
  super();
400
-
609
+
401
610
  // Copy hooks from the event object
402
611
  const hookObj = event as EventHooks;
403
612
  if (hookObj.onInit) this.onInit = hookObj.onInit.bind(this);
@@ -419,8 +628,9 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
419
628
 
420
629
  eventInstance.x.set(x);
421
630
  eventInstance.y.set(y);
631
+ eventInstance.applyFrames()
422
632
  if (event.name) eventInstance.name.set(event.name);
423
-
633
+
424
634
  this.events()[id] = eventInstance;
425
635
 
426
636
  await eventInstance.execMethod('onInit')
@@ -434,6 +644,10 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
434
644
  return this.players()[playerId]
435
645
  }
436
646
 
647
+ getPlayers(): RpgPlayer[] {
648
+ return Object.values(this.players())
649
+ }
650
+
437
651
  getEvents(): RpgEvent[] {
438
652
  return Object.values(this.events())
439
653
  }
@@ -555,7 +769,7 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
555
769
  }
556
770
 
557
771
  export interface RpgMap {
558
- $send: (conn: MockConnection, data: any) => void;
772
+ $send: (conn: MockConnection, data: any) => void;
559
773
  $broadcast: (data: any) => void;
560
774
  $sessionTransfer: (userOrPublicId: any | string, targetRoomId: string) => void;
561
775
  }