@rpgjs/server 5.0.0-alpha.29 → 5.0.0-alpha.30

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.
Files changed (51) hide show
  1. package/dist/Gui/DialogGui.d.ts +1 -0
  2. package/dist/Gui/GameoverGui.d.ts +23 -0
  3. package/dist/Gui/Gui.d.ts +6 -0
  4. package/dist/Gui/MenuGui.d.ts +22 -3
  5. package/dist/Gui/NotificationGui.d.ts +1 -2
  6. package/dist/Gui/SaveLoadGui.d.ts +13 -0
  7. package/dist/Gui/ShopGui.d.ts +24 -3
  8. package/dist/Gui/TitleGui.d.ts +23 -0
  9. package/dist/Gui/index.d.ts +9 -1
  10. package/dist/Player/GuiManager.d.ts +86 -3
  11. package/dist/Player/Player.d.ts +24 -2
  12. package/dist/RpgServer.d.ts +7 -0
  13. package/dist/RpgServerEngine.d.ts +2 -1
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.js +1621 -336
  16. package/dist/index.js.map +1 -1
  17. package/dist/presets/index.d.ts +0 -9
  18. package/dist/rooms/BaseRoom.d.ts +37 -0
  19. package/dist/rooms/lobby.d.ts +6 -1
  20. package/dist/rooms/map.d.ts +22 -1
  21. package/dist/services/save.d.ts +43 -0
  22. package/dist/storage/index.d.ts +1 -0
  23. package/dist/storage/localStorage.d.ts +23 -0
  24. package/package.json +10 -10
  25. package/src/Gui/DialogGui.ts +12 -2
  26. package/src/Gui/GameoverGui.ts +39 -0
  27. package/src/Gui/Gui.ts +23 -1
  28. package/src/Gui/MenuGui.ts +155 -6
  29. package/src/Gui/NotificationGui.ts +1 -2
  30. package/src/Gui/SaveLoadGui.ts +60 -0
  31. package/src/Gui/ShopGui.ts +145 -16
  32. package/src/Gui/TitleGui.ts +39 -0
  33. package/src/Gui/index.ts +13 -2
  34. package/src/Player/BattleManager.ts +1 -1
  35. package/src/Player/ClassManager.ts +57 -2
  36. package/src/Player/GuiManager.ts +125 -14
  37. package/src/Player/ItemManager.ts +160 -41
  38. package/src/Player/ParameterManager.ts +1 -1
  39. package/src/Player/Player.ts +87 -12
  40. package/src/Player/SkillManager.ts +145 -66
  41. package/src/Player/StateManager.ts +70 -1
  42. package/src/Player/VariableManager.ts +10 -7
  43. package/src/RpgServer.ts +8 -0
  44. package/src/index.ts +5 -2
  45. package/src/presets/index.ts +1 -10
  46. package/src/rooms/BaseRoom.ts +112 -0
  47. package/src/rooms/lobby.ts +13 -6
  48. package/src/rooms/map.ts +31 -4
  49. package/src/services/save.ts +147 -0
  50. package/src/storage/index.ts +1 -0
  51. package/src/storage/localStorage.ts +76 -0
@@ -46,6 +46,75 @@ export function WithStateManager<TBase extends PlayerCtor>(Base: TBase) {
46
46
  return class extends Base {
47
47
  _statesEfficiency = signal<any[]>([]);
48
48
 
49
+ private _getStateMap(required: boolean = true) {
50
+ // Use this.map directly to support both RpgMap and LobbyRoom
51
+ const map = (this as any).getCurrentMap?.() || (this as any).map;
52
+ if (required && (!map || !map.database)) {
53
+ throw new Error('Player must be on a map to resolve states');
54
+ }
55
+ return map;
56
+ }
57
+
58
+ private _resolveStateInput(
59
+ stateInput: StateClass | string,
60
+ databaseByIdOverride?: (id: string) => any
61
+ ) {
62
+ if (isString(stateInput)) {
63
+ return databaseByIdOverride
64
+ ? databaseByIdOverride(stateInput)
65
+ : (this as any).databaseById(stateInput);
66
+ }
67
+ return stateInput;
68
+ }
69
+
70
+ private _createStateInstance(stateClass: StateClass) {
71
+ return new (stateClass as StateClass)();
72
+ }
73
+
74
+ /**
75
+ * Create a state instance without side effects.
76
+ */
77
+ createStateInstance(stateInput: StateClass | string) {
78
+ const stateClass = this._resolveStateInput(stateInput);
79
+ const instance = this._createStateInstance(stateClass as StateClass);
80
+ return { stateClass, instance };
81
+ }
82
+
83
+ /**
84
+ * Resolve state snapshot entries into state instances without side effects.
85
+ */
86
+ resolveStatesSnapshot(snapshot: { states?: any[] }, mapOverride?: any) {
87
+ if (!snapshot || !Array.isArray(snapshot.states)) {
88
+ return snapshot;
89
+ }
90
+
91
+ const map = mapOverride ?? this._getStateMap(false);
92
+ if (!map || !map.database) {
93
+ return snapshot;
94
+ }
95
+
96
+ const databaseByIdOverride = (id: string) => {
97
+ const data = map.database()[id];
98
+ if (!data) {
99
+ throw new Error(
100
+ `The ID=${id} data is not found in the database. Add the data in the property "database"`
101
+ );
102
+ }
103
+ return data;
104
+ };
105
+
106
+ const states = snapshot.states.map((entry: any) => {
107
+ const stateId = isString(entry) ? entry : entry?.id;
108
+ if (!stateId) {
109
+ return entry;
110
+ }
111
+ const stateClass = this._resolveStateInput(stateId, databaseByIdOverride);
112
+ return this._createStateInstance(stateClass as StateClass);
113
+ });
114
+
115
+ return { ...snapshot, states };
116
+ }
117
+
49
118
  get statesDefense(): { rate: number; state: any }[] {
50
119
  return (this as any).getFeature("statesDefense", "state");
51
120
  }
@@ -94,7 +163,7 @@ export function WithStateManager<TBase extends PlayerCtor>(Base: TBase) {
94
163
  throw StateLog.addFailed(stateClass);
95
164
  }
96
165
  //const efficiency = this.findStateEfficiency(stateClass)
97
- const instance = new (stateClass as StateClass)();
166
+ const instance = this._createStateInstance(stateClass as StateClass);
98
167
  this.states().push(instance);
99
168
  this.applyStates(<any>this, instance);
100
169
  return instance;
@@ -1,4 +1,6 @@
1
1
  import { Constructor, PlayerCtor } from "@rpgjs/common";
2
+ import { signal } from "@signe/reactive";
3
+ import { type } from "@signe/sync";
2
4
 
3
5
  /**
4
6
  * Variable Manager Mixin
@@ -25,30 +27,31 @@ import { Constructor, PlayerCtor } from "@rpgjs/common";
25
27
  */
26
28
  export function WithVariableManager<TBase extends PlayerCtor>(Base: TBase) {
27
29
  return class extends Base {
28
- variables: Map<string, any> = new Map();
30
+ variables = type(signal<Record<string, any>>({}) as any, 'variables', { persist: true }, this as any);
29
31
 
30
32
  setVariable(key: string, val: any): void {
31
- this.variables.set(key, val);
33
+ this.variables()[key] = val;
32
34
  }
33
35
 
34
36
  getVariable<U = any>(key: string): U | undefined {
35
- return this.variables.get(key);
37
+ return this.variables()[key];
36
38
  }
37
39
 
38
40
  removeVariable(key: string): boolean {
39
- return this.variables.delete(key);
41
+ delete this.variables()[key];
42
+ return true;
40
43
  }
41
44
 
42
45
  hasVariable(key: string): boolean {
43
- return this.variables.has(key);
46
+ return key in this.variables();
44
47
  }
45
48
 
46
49
  getVariableKeys(): string[] {
47
- return Array.from(this.variables.keys());
50
+ return Object.keys(this.variables());
48
51
  }
49
52
 
50
53
  clearVariables(): void {
51
- this.variables.clear();
54
+ this.variables.set({});
52
55
  }
53
56
  } as unknown as TBase;
54
57
  }
package/src/RpgServer.ts CHANGED
@@ -178,6 +178,14 @@ export interface RpgPlayerHooks {
178
178
  */
179
179
  onConnected?: (player: RpgPlayer) => any
180
180
 
181
+ /**
182
+ * When the player starts the game from the lobby
183
+ *
184
+ * @prop { (player: RpgPlayer) => any } [onStart]
185
+ * @memberof RpgPlayerHooks
186
+ */
187
+ onStart?: (player: RpgPlayer) => any
188
+
181
189
  /**
182
190
  * When the player presses a key on the client side
183
191
  *
package/src/index.ts CHANGED
@@ -10,7 +10,10 @@ export * from "./rooms/map";
10
10
  export * from "./presets";
11
11
  export * from "@signe/reactive";
12
12
  export * from "./Gui";
13
- export { RpgShape, RpgModule } from "@rpgjs/common";
13
+ export * from "./services/save";
14
+ export * from "./storage";
15
+ export { RpgShape, RpgModule, MAXHP, MAXSP, ATK, PDEF, SDEF, STR, AGI, INT, DEX } from "@rpgjs/common";
14
16
  export * from "./decorators/event";
15
17
  export * from "./decorators/map";
16
- export * from "./Player/MoveManager";
18
+ export * from "./Player/MoveManager";
19
+ export * from "./presets";
@@ -1,14 +1,5 @@
1
1
  import { random } from "@rpgjs/common"
2
-
3
- export const MAXHP: string = 'maxHp'
4
- export const MAXSP: string = 'maxSp'
5
- export const ATK: string = 'atk'
6
- export const PDEF: string = 'pdef'
7
- export const SDEF: string = 'sdef'
8
- export const STR: string = 'str'
9
- export const AGI: string = 'agi'
10
- export const INT: string = 'int'
11
- export const DEX: string = 'dex'
2
+ import { DEX, AGI, ATK, PDEF, SDEF, STR, INT } from "@rpgjs/common"
12
3
 
13
4
  export const MAXHP_CURVE = {
14
5
  start: 741,
@@ -1,4 +1,11 @@
1
1
  import { signal } from "@signe/reactive";
2
+ import { inject } from "../core/inject";
3
+ import { context } from "../core/context";
4
+ import { Hooks, ModulesToken } from "@rpgjs/common";
5
+ import { Action } from "@signe/room";
6
+ import { RpgPlayer } from "../Player/Player";
7
+ import { resolveSaveStorageStrategy } from "../services/save";
8
+ import { lastValueFrom } from "rxjs";
2
9
 
3
10
  /**
4
11
  * Base class for rooms that need database functionality
@@ -20,6 +27,7 @@ import { signal } from "@signe/reactive";
20
27
  * ```
21
28
  */
22
29
  export abstract class BaseRoom {
30
+
23
31
  /**
24
32
  * Signal containing the room's database of items, classes, and other game data
25
33
  *
@@ -38,6 +46,11 @@ export abstract class BaseRoom {
38
46
  */
39
47
  database = signal({});
40
48
 
49
+
50
+ async onStart() {
51
+ await lastValueFrom(this.hooks.callHooks("server-databaseHooks-load", this))
52
+ }
53
+
41
54
  /**
42
55
  * Add data to the room's database
43
56
  *
@@ -117,4 +130,103 @@ export abstract class BaseRoom {
117
130
  this.database.set(database);
118
131
  return true;
119
132
  }
133
+
134
+ /**
135
+ * Get the hooks system for this map
136
+ *
137
+ * Returns the dependency-injected Hooks instance that allows you to trigger
138
+ * and listen to various game events.
139
+ *
140
+ * @returns The Hooks instance for this map
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * // Trigger a custom hook
145
+ * map.hooks.callHooks('custom-event', data).subscribe();
146
+ * ```
147
+ */
148
+ get hooks() {
149
+ return inject<Hooks>(ModulesToken, context);
150
+ }
151
+
152
+ /**
153
+ * Resolve complex snapshot entries (e.g. inventory items) before load.
154
+ */
155
+ async onSessionRestore({ userSnapshot, user }: { userSnapshot: any; user?: RpgPlayer }) {
156
+ if (!userSnapshot) {
157
+ return userSnapshot;
158
+ }
159
+
160
+ let resolvedSnapshot = userSnapshot;
161
+ if (user && typeof (user as any).resolveItemsSnapshot === 'function') {
162
+ resolvedSnapshot = (user as any).resolveItemsSnapshot(resolvedSnapshot, this);
163
+ }
164
+
165
+ if (user && typeof (user as any).resolveSkillsSnapshot === 'function') {
166
+ resolvedSnapshot = (user as any).resolveSkillsSnapshot(resolvedSnapshot, this);
167
+ }
168
+
169
+ if (user && typeof (user as any).resolveStatesSnapshot === 'function') {
170
+ resolvedSnapshot = (user as any).resolveStatesSnapshot(resolvedSnapshot, this);
171
+ }
172
+
173
+ if (user && typeof (user as any).resolveClassSnapshot === 'function') {
174
+ resolvedSnapshot = (user as any).resolveClassSnapshot(resolvedSnapshot, this);
175
+ }
176
+
177
+ if (user && typeof (user as any).resolveEquipmentsSnapshot === 'function') {
178
+ resolvedSnapshot = (user as any).resolveEquipmentsSnapshot(resolvedSnapshot, this);
179
+ }
180
+
181
+ return resolvedSnapshot;
182
+ }
183
+
184
+ @Action('save.list')
185
+ async listSaveSlots(player: RpgPlayer, value: { requestId: string }) {
186
+ const storage = resolveSaveStorageStrategy();
187
+ try {
188
+ const slots = await storage.list(player);
189
+ player.emit('save.list.result', { requestId: value?.requestId, slots });
190
+ return slots;
191
+ } catch (error: any) {
192
+ player.showNotification(error?.message || 'save.list failed');
193
+ return [];
194
+ }
195
+ }
196
+
197
+ @Action('save.save')
198
+ async saveSlot(player: RpgPlayer, value: { requestId: string; index: number; meta?: any }) {
199
+ const storage = resolveSaveStorageStrategy();
200
+ try {
201
+ if (typeof value?.index !== 'number') {
202
+ throw new Error('save.save requires an index');
203
+ }
204
+ const result = await player.save(value.index, value?.meta, { reason: "manual", source: "gui" });
205
+ if (!result) {
206
+ throw new Error('save.save is not allowed');
207
+ }
208
+ const slots = await storage.list(player);
209
+ player.emit('save.save.result', { requestId: value?.requestId, index: result.index, slots });
210
+ } catch (error: any) {
211
+ player.emit('save.error', { requestId: value?.requestId, message: error?.message || 'save.save failed' });
212
+ }
213
+ }
214
+
215
+ @Action('save.load')
216
+ async loadSlot(player: RpgPlayer, value: { requestId: string; index: number }) {
217
+ try {
218
+ if (typeof value?.index !== 'number') {
219
+ throw new Error('save.load requires an index');
220
+ }
221
+ const result = await player.load(value.index, { reason: "load", source: "gui" }, { changeMap: true });
222
+ player.emit('save.load.result', {
223
+ requestId: value?.requestId,
224
+ index: value.index,
225
+ ok: result.ok,
226
+ slot: result.slot
227
+ });
228
+ } catch (error: any) {
229
+ player.emit('save.error', { requestId: value?.requestId, message: error?.message || 'save.load failed' });
230
+ }
231
+ }
120
232
  }
@@ -1,11 +1,13 @@
1
1
  import { inject } from "@signe/di";
2
- import { MockConnection, Room } from "@signe/room";
2
+ import { Action, MockConnection, Room } from "@signe/room";
3
3
  import { Hooks, ModulesToken } from "@rpgjs/common";
4
4
  import { context } from "../core/context";
5
5
  import { users } from "@signe/sync";
6
6
  import { signal } from "@signe/reactive";
7
7
  import { RpgPlayer } from "../Player/Player";
8
8
  import { BaseRoom } from "./BaseRoom";
9
+ import { buildSaveSlotMeta, resolveSaveStorageStrategy } from "../services/save";
10
+ import { lastValueFrom } from "rxjs";
9
11
 
10
12
  @Room({
11
13
  path: "lobby-{id}",
@@ -22,13 +24,18 @@ export class LobbyRoom extends BaseRoom {
22
24
  }
23
25
  }
24
26
 
25
- onJoin(player: RpgPlayer, conn: MockConnection) {
27
+ async onJoin(player: RpgPlayer, conn: MockConnection) {
26
28
  player.map = this;
27
29
  player.context = context;
28
30
  player.conn = conn;
29
- const hooks = inject<Hooks>(context, ModulesToken);
30
- hooks
31
- .callHooks("server-player-onConnected", player)
32
- .subscribe();
31
+ this.hooks.callHooks("server-player-onConnected", player).subscribe();
32
+ }
33
+
34
+ @Action('gui.interaction')
35
+ async guiInteraction(player: RpgPlayer, value: { guiId: string, name: string, data: any }) {
36
+ const id = value.data.id
37
+ if (id === 'start') {
38
+ this.hooks.callHooks("server-player-onStart", player).subscribe();
39
+ }
33
40
  }
34
41
  }
package/src/rooms/map.ts CHANGED
@@ -14,6 +14,7 @@ import { z } from "zod";
14
14
  import { EntityState } from "@rpgjs/physic";
15
15
  import { MapOptions } from "../decorators/map";
16
16
  import { BaseRoom } from "./BaseRoom";
17
+ import { buildSaveSlotMeta, resolveSaveStorageStrategy } from "../services/save";
17
18
 
18
19
  /**
19
20
  * Interface for input controls configuration
@@ -237,6 +238,10 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
237
238
  }
238
239
  }
239
240
 
241
+ onStart() {
242
+ return BaseRoom.prototype.onStart.call(this)
243
+ }
244
+
240
245
  /**
241
246
  * Setup collision detection between players, events, and shapes
242
247
  *
@@ -581,7 +586,11 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
581
586
  * ```
582
587
  */
583
588
  get hooks() {
584
- return inject<Hooks>(context, ModulesToken);
589
+ return BaseRoom.prototype.hooks;
590
+ }
591
+
592
+ async onSessionRestore(payload: { userSnapshot: any; user?: RpgPlayer }) {
593
+ return await BaseRoom.prototype.onSessionRestore.call(this, payload);
585
594
  }
586
595
 
587
596
  /**
@@ -600,8 +609,11 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
600
609
  * ```
601
610
  */
602
611
  @Action('gui.interaction')
603
- guiInteraction(player: RpgPlayer, value) {
604
- this.hooks.callHooks("server-player-guiInteraction", player, value);
612
+ async guiInteraction(player: RpgPlayer, value: { guiId: string, name: string, data: any }) {
613
+ const gui = player.getGui(value.guiId)
614
+ if (gui) {
615
+ await gui.emit(value.name, value.data)
616
+ }
605
617
  player.syncChanges();
606
618
  }
607
619
 
@@ -699,6 +711,21 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
699
711
  }
700
712
  }
701
713
 
714
+ @Action('save.save')
715
+ async saveSlot(player: RpgPlayer, value: { requestId: string; index: number; meta?: any }) {
716
+ BaseRoom.prototype.saveSlot(player, value);
717
+ }
718
+
719
+ @Action('save.load')
720
+ async loadSlot(player: RpgPlayer, value: { requestId: string; index: number }) {
721
+ BaseRoom.prototype.loadSlot(player, value);
722
+ }
723
+
724
+ @Action('save.list')
725
+ async listSaveSlots(player: RpgPlayer, value: { requestId: string }) {
726
+ return await BaseRoom.prototype.listSaveSlots(player, value);
727
+ }
728
+
702
729
  /**
703
730
  * Update the map configuration and data
704
731
  *
@@ -2056,4 +2083,4 @@ export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
2056
2083
  }
2057
2084
  }
2058
2085
 
2059
- export interface RpgMap extends RoomMethods { }
2086
+ export interface RpgMap extends RoomMethods { }
@@ -0,0 +1,147 @@
1
+ import { SaveSlot, SaveSlotEntries, SaveSlotList, SaveSlotMeta } from "@rpgjs/common";
2
+ import { inject } from "../core/inject";
3
+ import { RpgPlayer } from "../Player/Player";
4
+
5
+ export const SaveStorageToken = "SaveStorageToken";
6
+
7
+ export type SaveSlotIndex = number | "auto";
8
+
9
+ export interface SaveRequestContext {
10
+ reason?: "manual" | "auto" | "load";
11
+ source?: string;
12
+ }
13
+
14
+ export interface AutoSaveStrategy {
15
+ canSave?: (player: RpgPlayer, context?: SaveRequestContext) => boolean;
16
+ canLoad?: (player: RpgPlayer, context?: SaveRequestContext) => boolean;
17
+ shouldAutoSave?: (player: RpgPlayer, context?: SaveRequestContext) => boolean;
18
+ getDefaultSlot?: (player: RpgPlayer, context?: SaveRequestContext) => number | null;
19
+ }
20
+
21
+ export interface SaveStorageStrategy {
22
+ list(player: RpgPlayer): Promise<SaveSlotList>;
23
+ get(player: RpgPlayer, index: number): Promise<SaveSlot | null>;
24
+ save(player: RpgPlayer, index: number, snapshot: string, meta: SaveSlotMeta): Promise<void>;
25
+ delete?(player: RpgPlayer, index: number): Promise<void>;
26
+ }
27
+
28
+ type PlayerSlots = Map<string, SaveSlotEntries>;
29
+
30
+ export class InMemorySaveStorageStrategy implements SaveStorageStrategy {
31
+ private slotsByPlayer: PlayerSlots = new Map();
32
+
33
+ async list(player: RpgPlayer): Promise<SaveSlotList> {
34
+ return this.stripSnapshots(this.getSlots(player));
35
+ }
36
+
37
+ async get(player: RpgPlayer, index: number): Promise<SaveSlot | null> {
38
+ const slots = this.getSlots(player);
39
+ const slot = slots[index];
40
+ return slot ?? null;
41
+ }
42
+
43
+ async save(player: RpgPlayer, index: number, snapshot: string, meta: SaveSlotMeta): Promise<void> {
44
+ const slots = this.getSlots(player);
45
+ const existing = slots[index];
46
+ slots[index] = {
47
+ ...(existing ?? {}),
48
+ ...meta,
49
+ snapshot,
50
+ };
51
+ }
52
+
53
+ async delete(player: RpgPlayer, index: number): Promise<void> {
54
+ const slots = this.getSlots(player);
55
+ slots[index] = null;
56
+ }
57
+
58
+ private getSlots(player: RpgPlayer): SaveSlotEntries {
59
+ const key = player.id ?? "unknown";
60
+ if (!this.slotsByPlayer.has(key)) {
61
+ this.slotsByPlayer.set(key, []);
62
+ }
63
+ return this.slotsByPlayer.get(key)!;
64
+ }
65
+
66
+ private stripSnapshots(slots: SaveSlotEntries): SaveSlotList {
67
+ return slots.map((slot) => {
68
+ if (!slot) return null;
69
+ const { snapshot, ...meta } = slot;
70
+ return meta;
71
+ });
72
+ }
73
+ }
74
+
75
+ let cachedSaveStorage: SaveStorageStrategy | null = null;
76
+ let cachedAutoSave: AutoSaveStrategy | null = null;
77
+
78
+ export const AutoSaveToken = "AutoSaveToken";
79
+
80
+ export function resolveSaveStorageStrategy(): SaveStorageStrategy {
81
+ if (cachedSaveStorage) return cachedSaveStorage;
82
+ try {
83
+ cachedSaveStorage = inject<SaveStorageStrategy>(SaveStorageToken);
84
+ } catch {
85
+ cachedSaveStorage = new InMemorySaveStorageStrategy();
86
+ }
87
+ return cachedSaveStorage;
88
+ }
89
+
90
+ export function resolveAutoSaveStrategy(): AutoSaveStrategy {
91
+ if (cachedAutoSave) return cachedAutoSave;
92
+ try {
93
+ cachedAutoSave = inject<AutoSaveStrategy>(AutoSaveToken);
94
+ } catch {
95
+ cachedAutoSave = null;
96
+ }
97
+ cachedAutoSave ||= {
98
+ canSave: () => true,
99
+ canLoad: () => true,
100
+ shouldAutoSave: () => false,
101
+ getDefaultSlot: () => 0,
102
+ };
103
+ return cachedAutoSave;
104
+ }
105
+
106
+ export function resolveSaveSlot(
107
+ slot: SaveSlotIndex | undefined,
108
+ policy: AutoSaveStrategy,
109
+ player: RpgPlayer,
110
+ context?: SaveRequestContext
111
+ ): number | null {
112
+ if (typeof slot === "number") return slot;
113
+ const resolver = policy.getDefaultSlot;
114
+ if (!resolver) return null;
115
+ return resolver(player, context);
116
+ }
117
+
118
+ export function shouldAutoSave(player: RpgPlayer, context?: SaveRequestContext): boolean {
119
+ const strategy = resolveAutoSaveStrategy();
120
+ if (!strategy.shouldAutoSave) return false;
121
+ return strategy.shouldAutoSave(player, context);
122
+ }
123
+
124
+ export function buildSaveSlotMeta(player: RpgPlayer, overrides: SaveSlotMeta = {}): SaveSlotMeta {
125
+ const mapId = player.getCurrentMap()?.id;
126
+ const base: SaveSlotMeta = {
127
+ level: typeof player.level === "number" ? player.level : undefined,
128
+ exp: typeof player.exp === "number" ? player.exp : undefined,
129
+ map: typeof mapId === "string" ? mapId : undefined,
130
+ date: new Date().toISOString(),
131
+ };
132
+ return { ...base, ...overrides };
133
+ }
134
+
135
+ export function provideSaveStorage(strategy: SaveStorageStrategy) {
136
+ return {
137
+ provide: SaveStorageToken,
138
+ useValue: strategy,
139
+ };
140
+ }
141
+
142
+ export function provideAutoSave(strategy: AutoSaveStrategy) {
143
+ return {
144
+ provide: AutoSaveToken,
145
+ useValue: strategy,
146
+ };
147
+ }
@@ -0,0 +1 @@
1
+ export * from "./localStorage";
@@ -0,0 +1,76 @@
1
+ import type { SaveSlot, SaveSlotEntries, SaveSlotList, SaveSlotMeta } from "@rpgjs/common";
2
+ import type { SaveStorageStrategy } from "../services/save";
3
+ import { RpgPlayer } from "../Player/Player";
4
+
5
+ export interface LocalStorageSaveStorageOptions {
6
+ key?: string;
7
+ }
8
+
9
+ /**
10
+ * Save storage strategy backed by browser localStorage.
11
+ *
12
+ * Intended for standalone mode where the server runs in the browser
13
+ * and localStorage is available.
14
+ */
15
+ export class LocalStorageSaveStorageStrategy implements SaveStorageStrategy {
16
+ private key: string;
17
+
18
+ constructor(options: LocalStorageSaveStorageOptions = {}) {
19
+ this.key = options.key ?? "rpgjs-save-slots";
20
+ }
21
+
22
+ async list(_player: RpgPlayer): Promise<SaveSlotList> {
23
+ return this.stripSnapshots(this.readSlots());
24
+ }
25
+
26
+ async get(_player: RpgPlayer, index: number): Promise<SaveSlot | null> {
27
+ const slots = this.readSlots();
28
+ return slots[index] ?? null;
29
+ }
30
+
31
+ async save(_player: RpgPlayer, index: number, snapshot: string, meta: SaveSlotMeta): Promise<void> {
32
+ const slots = this.readSlots();
33
+ const existing = slots[index];
34
+ slots[index] = {
35
+ ...(existing ?? {}),
36
+ ...meta,
37
+ snapshot,
38
+ };
39
+ this.writeSlots(slots);
40
+ }
41
+
42
+ async delete(_player: RpgPlayer, index: number): Promise<void> {
43
+ const slots = this.readSlots();
44
+ slots[index] = null;
45
+ this.writeSlots(slots);
46
+ }
47
+
48
+ private readSlots(): SaveSlotEntries {
49
+ if (typeof localStorage === "undefined") {
50
+ return [];
51
+ }
52
+ const raw = localStorage.getItem(this.key);
53
+ if (!raw) return [];
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ return Array.isArray(parsed) ? parsed : [];
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ private writeSlots(slots: SaveSlotEntries) {
63
+ if (typeof localStorage === "undefined") {
64
+ return;
65
+ }
66
+ localStorage.setItem(this.key, JSON.stringify(slots));
67
+ }
68
+
69
+ private stripSnapshots(slots: SaveSlotEntries): SaveSlotList {
70
+ return slots.map((slot) => {
71
+ if (!slot) return null;
72
+ const { snapshot, ...meta } = slot;
73
+ return meta;
74
+ });
75
+ }
76
+ }