@rpgjs/server 5.0.0-beta.3 → 5.0.0-beta.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpgjs/server",
3
- "version": "5.0.0-beta.3",
3
+ "version": "5.0.0-beta.5",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {
@@ -22,9 +22,9 @@
22
22
  "description": "",
23
23
  "dependencies": {
24
24
  "@canvasengine/tiled": "2.0.0-beta.58",
25
- "@rpgjs/common": "5.0.0-beta.3",
26
- "@rpgjs/physic": "5.0.0-beta.3",
27
- "@rpgjs/testing": "5.0.0-beta.3",
25
+ "@rpgjs/common": "5.0.0-beta.5",
26
+ "@rpgjs/physic": "5.0.0-beta.5",
27
+ "@rpgjs/testing": "5.0.0-beta.5",
28
28
  "@rpgjs/database": "^4.3.0",
29
29
  "@signe/di": "^2.9.0",
30
30
  "@signe/reactive": "^2.9.0",
@@ -22,6 +22,16 @@ export interface MenuGuiOptions {
22
22
  saveAutoSlotLabel?: string
23
23
  }
24
24
 
25
+ function readReactiveValue(value: any, context?: any) {
26
+ return typeof value === 'function' ? value.call(context) : value
27
+ }
28
+
29
+ function readField(source: any, key: string, fallback?: any) {
30
+ const value = source?.[key]
31
+ const resolved = readReactiveValue(value, source)
32
+ return resolved ?? fallback
33
+ }
34
+
25
35
  export class MenuGui extends Gui {
26
36
  private menuOptions: MenuGuiOptions = {}
27
37
 
@@ -61,7 +71,7 @@ export class MenuGui extends Gui {
61
71
  const player = this.player as any
62
72
  const databaseById = player.databaseById?.bind(player)
63
73
  const equippedIds = new Set(
64
- (player.equipments?.() || []).map((it) => it?.id?.() ?? it?.id ?? it?.name)
74
+ (player.equipments?.() || []).map((it) => readField(it, 'id', readField(it, 'name')))
65
75
  )
66
76
 
67
77
  const buildStats = () => {
@@ -76,19 +86,19 @@ export class MenuGui extends Gui {
76
86
  ]
77
87
  const stats: Record<string, number> = {}
78
88
  statKeys.forEach((key) => {
79
- stats[key] = params[key] ?? 0
89
+ stats[key] = readReactiveValue(params[key]) ?? 0
80
90
  })
81
- stats.pdef = player.pdef ?? params.pdef ?? 0
82
- stats.sdef = player.sdef ?? params.sdef ?? 0
83
- stats.atk = player.atk ?? params.atk ?? 0
91
+ stats.pdef = readReactiveValue(player.pdef ?? params.pdef) ?? 0
92
+ stats.sdef = readReactiveValue(player.sdef ?? params.sdef) ?? 0
93
+ stats.atk = readReactiveValue(player.atk ?? params.atk) ?? 0
84
94
  return stats
85
95
  }
86
96
 
87
97
  const items = (player.items?.() || []).map((item) => {
88
- const id = item.id()
98
+ const id = readField(item, 'id')
89
99
  const data = databaseById ? databaseById(id) : {}
90
- const type = data?._type ?? 'item'
91
- const consumable = data?.consumable
100
+ const type = readField(data, '_type', 'item')
101
+ const consumable = readField(data, 'consumable')
92
102
  const isConsumable = consumable !== undefined ? consumable : type === 'item'
93
103
  const usable = isConsumable === false
94
104
  ? false
@@ -97,13 +107,13 @@ export class MenuGui extends Gui {
97
107
  : true
98
108
  return {
99
109
  id,
100
- name: item.name(),
101
- description: item.description(),
102
- quantity: item.quantity(),
103
- icon: data?.icon ?? (item as any)?.icon,
104
- atk: item.atk(),
105
- pdef: item.pdef(),
106
- sdef: item.sdef(),
110
+ name: readField(item, 'name'),
111
+ description: readField(item, 'description'),
112
+ quantity: readField(item, 'quantity'),
113
+ icon: readField(data, 'icon', readField(item, 'icon')),
114
+ atk: readField(item, 'atk'),
115
+ pdef: readField(item, 'pdef'),
116
+ sdef: readField(item, 'sdef'),
107
117
  consumable: isConsumable,
108
118
  type,
109
119
  usable,
@@ -112,14 +122,14 @@ export class MenuGui extends Gui {
112
122
  })
113
123
  const menuEquips = items.filter((item) => item.type === 'weapon' || item.type === 'armor')
114
124
  const skills = (player.skills?.() || []).map((skill) => ({
115
- id: skill?.id() ?? skill?.name(),
116
- name: skill?.name() ?? skill?.id() ?? 'Skill',
117
- description: skill?.description() ?? '',
118
- spCost: skill?.spCost() ?? 0
125
+ id: readField(skill, 'id', readField(skill, 'name')),
126
+ name: readField(skill, 'name', readField(skill, 'id', 'Skill')),
127
+ description: readField(skill, 'description', ''),
128
+ spCost: readField(skill, 'spCost', 0)
119
129
  }))
120
130
  const saveLoad = this.buildSaveLoad(options)
121
131
 
122
- return { menus, items, equips: menuEquips, skills, saveLoad, playerStats: buildStats(), expForNextlevel: player.expForNextlevel }
132
+ return { menus, items, equips: menuEquips, skills, saveLoad, playerStats: buildStats(), expForNextlevel: readReactiveValue(player.expForNextlevel) }
123
133
  }
124
134
 
125
135
  private refreshMenu(clientActionId?: string) {
@@ -753,7 +753,10 @@ export function WithParameterManager<TBase extends PlayerCtor>(Base: TBase) {
753
753
  for (let i = this._level() ; i <= val; i++) {
754
754
  for (let skill of currentClass.skillsToLearn as any[]) {
755
755
  if (skill.level == i) {
756
- this['learnSkill'](skill.skill)
756
+ this['learnSkill'](skill.skill, {
757
+ source: skill.source ?? 'level',
758
+ level: i
759
+ })
757
760
  }
758
761
  }
759
762
  }
@@ -778,6 +778,8 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
778
778
  graphic,
779
779
  nbTimes: finalNbTimes,
780
780
  object: this.id,
781
+ restoreAnimationName: this.animationName(),
782
+ restoreGraphics: [...this.graphics()],
781
783
  },
782
784
  });
783
785
  }
@@ -12,7 +12,20 @@ import { Effect } from "./EffectManager";
12
12
  /**
13
13
  * Type for skill class constructor
14
14
  */
15
- type SkillClass = { new (...args: any[]): any };
15
+ export type SkillClass = { new (...args: any[]): any };
16
+
17
+ export type SkillChangeAction = "learn" | "forget";
18
+
19
+ export interface SkillChangeOptions {
20
+ source?: "manual" | "level" | "class" | "studio" | string;
21
+ level?: number;
22
+ }
23
+
24
+ export interface SkillChangePayload extends SkillChangeOptions {
25
+ action: SkillChangeAction;
26
+ skill: SkillClass | SkillObject | string;
27
+ skillId: string;
28
+ }
16
29
 
17
30
  /**
18
31
  * Interface defining the hooks that can be implemented on skill classes or objects
@@ -430,7 +443,10 @@ export function WithSkillManager<TBase extends PlayerCtor>(Base: TBase): TBase {
430
443
  * });
431
444
  * ```
432
445
  */
433
- learnSkill(skillInput: SkillClass | SkillObject | string): any {
446
+ learnSkill(
447
+ skillInput: SkillClass | SkillObject | string,
448
+ options: SkillChangeOptions = {},
449
+ ): any {
434
450
  const map = this._getSkillMap();
435
451
  const { skillId, skillData, skillInstance } = this._resolveSkillInput(skillInput, map);
436
452
 
@@ -446,6 +462,15 @@ export function WithSkillManager<TBase extends PlayerCtor>(Base: TBase): TBase {
446
462
  // Call onLearn hook
447
463
  const hookTarget = (instance as any)._skillInstance || instance;
448
464
  this["execMethod"]("onLearn", [this], hookTarget);
465
+ this["execMethod"]("onSkillChange", [
466
+ {
467
+ action: "learn",
468
+ skill: skillData,
469
+ skillId,
470
+ source: options.source ?? "manual",
471
+ level: options.level,
472
+ },
473
+ ]);
449
474
 
450
475
  return skillData;
451
476
  }
@@ -466,7 +491,10 @@ export function WithSkillManager<TBase extends PlayerCtor>(Base: TBase): TBase {
466
491
  * player.forgetSkill(FireSkill);
467
492
  * ```
468
493
  */
469
- forgetSkill(skillInput: SkillClass | SkillObject | string): any {
494
+ forgetSkill(
495
+ skillInput: SkillClass | SkillObject | string,
496
+ options: SkillChangeOptions = {},
497
+ ): any {
470
498
  const index = this._getSkillIndex(skillInput);
471
499
 
472
500
  if (index === -1) {
@@ -494,6 +522,15 @@ export function WithSkillManager<TBase extends PlayerCtor>(Base: TBase): TBase {
494
522
  (skillEntry as any)?._skillData ||
495
523
  skillData;
496
524
  this["execMethod"]("onForget", [this], hookTarget);
525
+ this["execMethod"]("onSkillChange", [
526
+ {
527
+ action: "forget",
528
+ skill: skillData,
529
+ skillId: skillData?.id ?? String(skillInput),
530
+ source: options.source ?? "manual",
531
+ level: options.level,
532
+ },
533
+ ]);
497
534
 
498
535
  return skillData;
499
536
  }
@@ -607,7 +644,7 @@ export interface ISkillManager {
607
644
  * @returns The learned skill data
608
645
  * @throws SkillLog.alreadyLearned if the player already knows the skill
609
646
  */
610
- learnSkill(skillInput: SkillClass | SkillObject | string): any;
647
+ learnSkill(skillInput: SkillClass | SkillObject | string, options?: SkillChangeOptions): any;
611
648
 
612
649
  /**
613
650
  * Forget a skill
@@ -616,7 +653,7 @@ export interface ISkillManager {
616
653
  * @returns The forgotten skill data
617
654
  * @throws SkillLog.notLearned if trying to forget a skill not learned
618
655
  */
619
- forgetSkill(skillInput: SkillClass | SkillObject | string): any;
656
+ forgetSkill(skillInput: SkillClass | SkillObject | string, options?: SkillChangeOptions): any;
620
657
 
621
658
  /**
622
659
  * Use a skill
package/src/RpgServer.ts CHANGED
@@ -4,6 +4,7 @@ import { type RpgMap } from "./rooms/map"
4
4
  import { RpgServerEngine } from "./RpgServerEngine"
5
5
  import { WorldMapConfig, RpgShape, type MapPhysicsInitContext, type MapPhysicsEntityContext } from "@rpgjs/common"
6
6
  import { RpgEvent } from "./Player/Player"
7
+ import type { SkillChangePayload } from "./Player/SkillManager"
7
8
 
8
9
  type RpgClassMap<T> = new () => T
9
10
  type RpgClassEvent<T> = RpgEvent
@@ -210,6 +211,14 @@ export interface RpgPlayerHooks {
210
211
  */
211
212
  onLevelUp?: (player: RpgPlayer, nbLevel: number) => any
212
213
 
214
+ /**
215
+ * When a player learns or forgets a skill
216
+ *
217
+ * @prop { (player: RpgPlayer, payload: SkillChangePayload) => any } [onSkillChange]
218
+ * @memberof RpgPlayerHooks
219
+ */
220
+ onSkillChange?: (player: RpgPlayer, payload: SkillChangePayload) => any
221
+
213
222
  /**
214
223
  * When the player's HP drops to 0
215
224
  *
@@ -36,7 +36,12 @@ export class LobbyRoom extends BaseRoom {
36
36
  const id = value.data.id
37
37
  if (id === 'start') {
38
38
  player.initializeDefaultStats();
39
- this.hooks.callHooks("server-player-onStart", player).subscribe();
39
+ try {
40
+ await lastValueFrom(this.hooks.callHooks("server-player-onStart", player));
41
+ }
42
+ catch (error) {
43
+ console.error("[RPGJS] Error during player onStart hooks:", error);
44
+ }
40
45
  }
41
46
  }
42
47
  }
package/src/rooms/map.ts CHANGED
@@ -1091,6 +1091,13 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
1091
1091
  */
1092
1092
  @Action('move')
1093
1093
  async onInput(player: RpgPlayer, input: any) {
1094
+ if (typeof player.canMove === "function" && !player.canMove()) {
1095
+ player.pendingInputs = [];
1096
+ player.lastProcessedInputTs = 0;
1097
+ (this as any).stopMovement(player);
1098
+ return;
1099
+ }
1100
+
1094
1101
  const lastAckedFrame = player._lastFramePositions?.frame ?? 0;
1095
1102
  const now = Date.now();
1096
1103
  const candidates: Array<{
@@ -1525,6 +1532,16 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
1525
1532
  }
1526
1533
  }
1527
1534
 
1535
+ if (typeof player.canMove === "function" && !player.canMove()) {
1536
+ player.pendingInputs = [];
1537
+ player.lastProcessedInputTs = 0;
1538
+ (this as any).stopMovement(player);
1539
+ return {
1540
+ player,
1541
+ inputs: []
1542
+ }
1543
+ }
1544
+
1528
1545
  const processedInputs: string[] = [];
1529
1546
  const defaultControls: Required<Controls> = {
1530
1547
  maxTimeDelta: 1000, // 1 second max between inputs
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { MenuGui, signal } from "../src";
3
+
4
+ describe("GUI", () => {
5
+ test("main menu sends cloneable data when inventory data contains signals", () => {
6
+ const inventoryItem = {
7
+ id: signal("sword"),
8
+ name: signal("Bronze Sword"),
9
+ description: signal("A starter weapon"),
10
+ quantity: signal(1),
11
+ atk: signal(5),
12
+ pdef: signal(0),
13
+ sdef: signal(0),
14
+ icon: signal("inventory-icon"),
15
+ };
16
+ const skill = {
17
+ id: signal("fire"),
18
+ name: signal("Fire"),
19
+ description: signal("Small fire spell"),
20
+ spCost: signal(3),
21
+ };
22
+ const sent: any[] = [];
23
+ const player: any = {
24
+ canMove: signal(true),
25
+ items: signal([inventoryItem]),
26
+ equipments: signal([inventoryItem]),
27
+ skills: signal([skill]),
28
+ param: { str: 4, dex: 3, int: 2, agi: 1, maxHp: 20, maxSp: 10 },
29
+ pdef: 1,
30
+ sdef: 2,
31
+ atk: 5,
32
+ expForNextlevel: signal(150),
33
+ databaseById() {
34
+ return {
35
+ _type: signal("weapon"),
36
+ icon: signal("db-icon"),
37
+ consumable: signal(false),
38
+ };
39
+ },
40
+ emit(type: string, value: any) {
41
+ sent.push(structuredClone({ type, value }));
42
+ },
43
+ };
44
+
45
+ const gui = new MenuGui(player);
46
+ const pending = gui.open();
47
+
48
+ expect(sent[0]).toMatchObject({
49
+ type: "gui.open",
50
+ value: {
51
+ guiId: "rpg-main-menu",
52
+ data: {
53
+ expForNextlevel: 150,
54
+ items: [
55
+ {
56
+ id: "sword",
57
+ icon: "db-icon",
58
+ type: "weapon",
59
+ equipped: true,
60
+ },
61
+ ],
62
+ skills: [
63
+ {
64
+ id: "fire",
65
+ name: "Fire",
66
+ spCost: 3,
67
+ },
68
+ ],
69
+ },
70
+ },
71
+ });
72
+
73
+ gui.close();
74
+ return pending;
75
+ });
76
+ });
@@ -92,6 +92,7 @@ const FreeSkill = {
92
92
 
93
93
  let player: RpgPlayer;
94
94
  let fixture: TestingFixture;
95
+ const onSkillChangeSpy = vi.fn();
95
96
 
96
97
  // Define server module with skills in database
97
98
  const serverModule = defineModule({
@@ -113,6 +114,7 @@ const serverModule = defineModule({
113
114
  async onConnected(player) {
114
115
  await player.changeMap("test-map", { x: 100, y: 100 });
115
116
  },
117
+ onSkillChange: onSkillChangeSpy,
116
118
  },
117
119
  });
118
120
 
@@ -122,6 +124,7 @@ const clientModule = defineModule({
122
124
  });
123
125
 
124
126
  beforeEach(async () => {
127
+ onSkillChangeSpy.mockClear();
125
128
  const myModule = createModule("TestModule", [
126
129
  {
127
130
  server: serverModule,
@@ -523,6 +526,51 @@ describe("Skill Management - Hooks", () => {
523
526
  expect(onForgetSpy).toHaveBeenCalledWith(player);
524
527
  });
525
528
 
529
+ test("should call player onSkillChange hook when learning skill", () => {
530
+ const skill = player.learnSkill("fire");
531
+
532
+ expect(skill).toBe(FireSkill);
533
+ expect(onSkillChangeSpy).toHaveBeenCalledWith(
534
+ player,
535
+ expect.objectContaining({
536
+ action: "learn",
537
+ skill: FireSkill,
538
+ skillId: "fire",
539
+ source: "manual",
540
+ }),
541
+ );
542
+ });
543
+
544
+ test("should call player onSkillChange hook when forgetting skill", () => {
545
+ player.learnSkill("fire");
546
+ onSkillChangeSpy.mockClear();
547
+
548
+ player.forgetSkill("fire");
549
+
550
+ expect(onSkillChangeSpy).toHaveBeenCalledWith(
551
+ player,
552
+ expect.objectContaining({
553
+ action: "forget",
554
+ skillId: "fire",
555
+ source: "manual",
556
+ }),
557
+ );
558
+ });
559
+
560
+ test("should pass source and level to onSkillChange hook", () => {
561
+ player.learnSkill("fire", { source: "level", level: 3 });
562
+
563
+ expect(onSkillChangeSpy).toHaveBeenCalledWith(
564
+ player,
565
+ expect.objectContaining({
566
+ action: "learn",
567
+ skillId: "fire",
568
+ source: "level",
569
+ level: 3,
570
+ }),
571
+ );
572
+ });
573
+
526
574
  test("should call onUse hook when using skill successfully", () => {
527
575
  const onUseSpy = vi.fn();
528
576
  const customSkill: SkillObject = {