@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/dist/Gui/ShopGui.d.ts +5 -1
- package/dist/Player/ItemManager.d.ts +8 -9
- package/dist/decorators/map.d.ts +22 -0
- package/dist/index.js +406 -336
- package/dist/index.js.map +1 -1
- package/dist/logs/log.d.ts +2 -3
- package/dist/rooms/map.d.ts +38 -3
- package/package.json +9 -9
- package/src/Gui/ShopGui.ts +4 -3
- package/src/Player/ClassManager.ts +7 -4
- package/src/Player/ItemManager.ts +21 -18
- package/src/Player/ParameterManager.ts +36 -2
- package/src/Player/Player.ts +30 -8
- package/src/decorators/map.ts +26 -1
- package/src/logs/log.ts +10 -3
- package/src/module.ts +1 -0
- package/src/rooms/map.ts +296 -50
- package/tests/item.spec.ts +19 -1
- package/tests/prediction-reconciliation.spec.ts +182 -0
- package/tests/world-maps.spec.ts +83 -1
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
|
|
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
|
|
292
|
-
const zB = entityB.owner
|
|
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 ??
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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(
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
-
|
|
594
|
+
this.sounds.forEach(sound => player.playSound(sound, { loop: true }));
|
|
595
|
+
player.emit("weatherState", this.getWeather());
|
|
525
596
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
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
|
|
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$.
|
|
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
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
*
|
package/tests/item.spec.ts
CHANGED
|
@@ -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
|
+
});
|