@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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { testing, type TestingFixture } from "@rpgjs/testing";
|
|
3
|
+
import { createModule, defineModule, Direction } from "@rpgjs/common";
|
|
4
|
+
import { RpgPlayer, RpgServer } from "../src";
|
|
5
|
+
|
|
6
|
+
const serverModule = defineModule<RpgServer>({
|
|
7
|
+
maps: [
|
|
8
|
+
{
|
|
9
|
+
id: "test-map",
|
|
10
|
+
file: "",
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
player: {
|
|
14
|
+
async onConnected(player) {
|
|
15
|
+
await player.changeMap("test-map", { x: 100, y: 100 });
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
let fixture: TestingFixture;
|
|
21
|
+
let client: any;
|
|
22
|
+
let player: RpgPlayer;
|
|
23
|
+
let serverMap: any;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
const module = createModule("PredictionServerModule", [
|
|
27
|
+
{
|
|
28
|
+
server: serverModule,
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
31
|
+
fixture = await testing(module);
|
|
32
|
+
client = await fixture.createClient();
|
|
33
|
+
player = await client.waitForMapChange("test-map");
|
|
34
|
+
serverMap = fixture.server.subRoom as any;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await fixture.clear();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("Prediction + Reconciliation Server Protocol", () => {
|
|
42
|
+
test("should reply pong with server tick", () => {
|
|
43
|
+
const emitSpy = vi.spyOn(player, "emit");
|
|
44
|
+
const payload = {
|
|
45
|
+
clientTime: 1234,
|
|
46
|
+
clientFrame: 42,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
serverMap.onPing(player, payload);
|
|
50
|
+
|
|
51
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
52
|
+
"pong",
|
|
53
|
+
expect.objectContaining({
|
|
54
|
+
clientTime: payload.clientTime,
|
|
55
|
+
clientFrame: payload.clientFrame,
|
|
56
|
+
serverTick: serverMap.getTick(),
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("should include authoritative ack metadata in sync interceptor", async () => {
|
|
62
|
+
const frame = 7;
|
|
63
|
+
await serverMap.onInput(player, {
|
|
64
|
+
input: Direction.Right,
|
|
65
|
+
frame,
|
|
66
|
+
tick: 0,
|
|
67
|
+
timestamp: Date.now(),
|
|
68
|
+
});
|
|
69
|
+
await serverMap.processInput(player.id);
|
|
70
|
+
|
|
71
|
+
const intercepted = serverMap.interceptorPacket(
|
|
72
|
+
player,
|
|
73
|
+
{ type: "sync", value: {} },
|
|
74
|
+
player.conn,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(intercepted?.value?.ack).toEqual(
|
|
78
|
+
expect.objectContaining({
|
|
79
|
+
frame,
|
|
80
|
+
serverTick: expect.any(Number),
|
|
81
|
+
x: expect.any(Number),
|
|
82
|
+
y: expect.any(Number),
|
|
83
|
+
direction: expect.any(String),
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("should align ack position with the synced local player payload when available", () => {
|
|
89
|
+
player._lastFramePositions = {
|
|
90
|
+
frame: 21,
|
|
91
|
+
position: {
|
|
92
|
+
x: 0,
|
|
93
|
+
y: 0,
|
|
94
|
+
direction: Direction.Down,
|
|
95
|
+
},
|
|
96
|
+
serverTick: 1,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const intercepted = serverMap.interceptorPacket(
|
|
100
|
+
player,
|
|
101
|
+
{
|
|
102
|
+
type: "sync",
|
|
103
|
+
value: {
|
|
104
|
+
players: {
|
|
105
|
+
[player.id]: {
|
|
106
|
+
x: 321,
|
|
107
|
+
y: 45,
|
|
108
|
+
direction: Direction.Left,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
player.conn,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(intercepted?.value?.ack).toEqual(
|
|
117
|
+
expect.objectContaining({
|
|
118
|
+
frame: 21,
|
|
119
|
+
x: 321,
|
|
120
|
+
y: 45,
|
|
121
|
+
direction: Direction.Left,
|
|
122
|
+
serverTick: serverMap.getTick(),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("should queue trajectory frames and replay them progressively on server ticks", async () => {
|
|
128
|
+
const baseTs = Date.now();
|
|
129
|
+
await serverMap.onInput(player, {
|
|
130
|
+
input: Direction.Right,
|
|
131
|
+
frame: 3,
|
|
132
|
+
tick: 3,
|
|
133
|
+
timestamp: baseTs + 32,
|
|
134
|
+
trajectory: [
|
|
135
|
+
{
|
|
136
|
+
input: Direction.Right,
|
|
137
|
+
frame: 1,
|
|
138
|
+
tick: 1,
|
|
139
|
+
timestamp: baseTs,
|
|
140
|
+
x: 101,
|
|
141
|
+
y: 100,
|
|
142
|
+
direction: Direction.Right,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
input: Direction.Right,
|
|
146
|
+
frame: 2,
|
|
147
|
+
tick: 2,
|
|
148
|
+
timestamp: baseTs + 16,
|
|
149
|
+
x: 102,
|
|
150
|
+
y: 100,
|
|
151
|
+
direction: Direction.Right,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
input: Direction.Right,
|
|
155
|
+
frame: 3,
|
|
156
|
+
tick: 3,
|
|
157
|
+
timestamp: baseTs + 32,
|
|
158
|
+
x: 103,
|
|
159
|
+
y: 100,
|
|
160
|
+
direction: Direction.Right,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(player.pendingInputs.map((entry: any) => entry.frame)).toEqual([1, 2, 3]);
|
|
166
|
+
|
|
167
|
+
await serverMap.processInput(player.id);
|
|
168
|
+
expect(player._lastFramePositions?.frame).toBe(1);
|
|
169
|
+
expect(player._lastFramePositions?.position?.x).toBe(101);
|
|
170
|
+
expect(player.pendingInputs.map((entry: any) => entry.frame)).toEqual([2, 3]);
|
|
171
|
+
|
|
172
|
+
await serverMap.processInput(player.id);
|
|
173
|
+
expect(player._lastFramePositions?.frame).toBe(2);
|
|
174
|
+
expect(player._lastFramePositions?.position?.x).toBe(102);
|
|
175
|
+
expect(player.pendingInputs.map((entry: any) => entry.frame)).toEqual([3]);
|
|
176
|
+
|
|
177
|
+
await serverMap.processInput(player.id);
|
|
178
|
+
expect(player._lastFramePositions?.frame).toBe(3);
|
|
179
|
+
expect(player._lastFramePositions?.position?.x).toBe(103);
|
|
180
|
+
expect(player.pendingInputs).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
});
|
package/tests/world-maps.spec.ts
CHANGED
|
@@ -410,6 +410,47 @@ describe('Map WorldMapsManager Integration', () => {
|
|
|
410
410
|
expect(worldX2).toBe(1074)
|
|
411
411
|
expect(worldY2).toBe(100)
|
|
412
412
|
})
|
|
413
|
+
|
|
414
|
+
test('should keep movement sync after returning to initial map', async () => {
|
|
415
|
+
player = await client.waitForMapChange('map1')
|
|
416
|
+
|
|
417
|
+
await player.changeMap('map2', { x: 50, y: 100 })
|
|
418
|
+
player = await client.waitForMapChange('map2')
|
|
419
|
+
expect(player.getCurrentMap()?.id).toBe('map2')
|
|
420
|
+
|
|
421
|
+
await player.changeMap('map1', { x: 100, y: 100 })
|
|
422
|
+
player = await client.waitForMapChange('map1')
|
|
423
|
+
const map = player.getCurrentMap()
|
|
424
|
+
expect(map?.id).toBe('map1')
|
|
425
|
+
|
|
426
|
+
const beforeX = player.x()
|
|
427
|
+
await map?.movePlayer(player as any, Direction.Right)
|
|
428
|
+
map?.nextTick(16)
|
|
429
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
430
|
+
|
|
431
|
+
expect(player.x()).toBeGreaterThan(beforeX)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
test('should keep restored player position after loadPhysic rebuild', async () => {
|
|
435
|
+
player = await client.waitForMapChange('map1')
|
|
436
|
+
const map = player.getCurrentMap()
|
|
437
|
+
expect(map).toBeDefined()
|
|
438
|
+
|
|
439
|
+
await player.teleport({ x: 0, y: 0 })
|
|
440
|
+
map?.loadPhysic()
|
|
441
|
+
|
|
442
|
+
// Simulate a late position restore (e.g. session transfer snapshot hydration).
|
|
443
|
+
player.x.set(100)
|
|
444
|
+
player.y.set(100)
|
|
445
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
446
|
+
|
|
447
|
+
const topLeft = map?.getBodyPosition(player.id, 'top-left')
|
|
448
|
+
expect(topLeft).toBeDefined()
|
|
449
|
+
expect(Math.round(topLeft!.x)).toBe(100)
|
|
450
|
+
expect(Math.round(topLeft!.y)).toBe(100)
|
|
451
|
+
expect(player.x()).toBe(100)
|
|
452
|
+
expect(player.y()).toBe(100)
|
|
453
|
+
})
|
|
413
454
|
})
|
|
414
455
|
|
|
415
456
|
/**
|
|
@@ -588,6 +629,47 @@ describe('Automatic Map Change on Border Touch', () => {
|
|
|
588
629
|
}
|
|
589
630
|
})
|
|
590
631
|
|
|
632
|
+
test('should not immediately bounce back after returning from adjacent map', async () => {
|
|
633
|
+
player = await client.waitForMapChange('map1')
|
|
634
|
+
await player.autoChangeMap({ x: 513, y: 384 }, Direction.Right)
|
|
635
|
+
|
|
636
|
+
const hitbox = player.hitbox()
|
|
637
|
+
const marginTopDown = 16 // tileHeight / 2
|
|
638
|
+
const topBorderY = marginTopDown - 1
|
|
639
|
+
|
|
640
|
+
player.changeDirection(Direction.Up)
|
|
641
|
+
await player.teleport({ x: 512, y: topBorderY })
|
|
642
|
+
const movedUp = await player.autoChangeMap({ x: 512, y: topBorderY - 1 }, Direction.Up)
|
|
643
|
+
expect(movedUp).toBe(true)
|
|
644
|
+
|
|
645
|
+
player = await client.waitForMapChange('map4')
|
|
646
|
+
expect(player.getCurrentMap()?.id).toBe('map4')
|
|
647
|
+
|
|
648
|
+
const map4 = player.getCurrentMap()
|
|
649
|
+
const bottomBorderY = (map4?.heightPx ?? 768) - hitbox.h - marginTopDown + 1
|
|
650
|
+
|
|
651
|
+
player.changeDirection(Direction.Down)
|
|
652
|
+
await player.teleport({ x: 512, y: bottomBorderY })
|
|
653
|
+
// First downward move after return should be blocked to avoid ping-pong map swaps.
|
|
654
|
+
const firstDownAttempt = await player.autoChangeMap({ x: 512, y: bottomBorderY + 1 }, Direction.Down)
|
|
655
|
+
expect(firstDownAttempt).toBe(false)
|
|
656
|
+
|
|
657
|
+
// Move away from border to unlock transitions, then touch border again.
|
|
658
|
+
await player.teleport({ x: 512, y: 384 })
|
|
659
|
+
player.changeDirection(Direction.Up)
|
|
660
|
+
await player.autoChangeMap({ x: 512, y: 383 }, Direction.Up)
|
|
661
|
+
|
|
662
|
+
const mapAfterUnlock = player.getCurrentMap()
|
|
663
|
+
const downBorderY = (mapAfterUnlock?.heightPx ?? 768) - hitbox.h - marginTopDown + 1
|
|
664
|
+
await player.teleport({ x: 512, y: downBorderY })
|
|
665
|
+
player.changeDirection(Direction.Down)
|
|
666
|
+
const movedDown = await player.autoChangeMap({ x: 512, y: downBorderY + 1 }, Direction.Down)
|
|
667
|
+
expect(movedDown).toBe(true)
|
|
668
|
+
|
|
669
|
+
player = await client.waitForMapChange('map1')
|
|
670
|
+
expect(player.getCurrentMap()?.id).toBe('map1')
|
|
671
|
+
})
|
|
672
|
+
|
|
591
673
|
test('should not change map when player is not at border', async () => {
|
|
592
674
|
player = await client.waitForMapChange('map1')
|
|
593
675
|
const initialMapId = player.getCurrentMap()?.id
|
|
@@ -811,4 +893,4 @@ describe('Automatic Map Change with Non-Adjacent Maps', () => {
|
|
|
811
893
|
expect(map2Info).toBeDefined()
|
|
812
894
|
expect(map2Info?.worldX).toBe(1025) // Gap exists
|
|
813
895
|
})
|
|
814
|
-
})
|
|
896
|
+
})
|