lumisjs 1.0.0

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/SECURITY.md +160 -0
  4. package/index.js +90 -0
  5. package/package.json +68 -0
  6. package/src/cache/CacheManager.js +393 -0
  7. package/src/cache/MemoryAdapter.js +259 -0
  8. package/src/cache/MultiLevelCache.js +362 -0
  9. package/src/cache/RedisAdapter.js +329 -0
  10. package/src/cache/SQLiteAdapter.js +280 -0
  11. package/src/cache/index.js +15 -0
  12. package/src/client/Client.js +554 -0
  13. package/src/client/ShardingManager.js +274 -0
  14. package/src/config/ConfigValidator.js +172 -0
  15. package/src/config/index.js +7 -0
  16. package/src/dashboard/DashboardManager.js +362 -0
  17. package/src/datagen/DataGenerator.js +47 -0
  18. package/src/datagen/apis/GraphqlMocker.js +16 -0
  19. package/src/datagen/apis/OpenApiMocker.js +18 -0
  20. package/src/datagen/cli.js +130 -0
  21. package/src/datagen/formatters/csv.js +45 -0
  22. package/src/datagen/formatters/index.js +15 -0
  23. package/src/datagen/formatters/json.js +33 -0
  24. package/src/datagen/formatters/mongo.js +22 -0
  25. package/src/datagen/formatters/sql.js +32 -0
  26. package/src/datagen/index.js +17 -0
  27. package/src/datagen/plugin/PluginManager.js +43 -0
  28. package/src/datagen/plugins/example-plugin.js +21 -0
  29. package/src/datagen/schema/SchemaGenerator.js +172 -0
  30. package/src/datagen/schema/loadSchema.js +14 -0
  31. package/src/datagen/utils/seed.js +26 -0
  32. package/src/di/ServiceContainer.js +229 -0
  33. package/src/errors/ErrorCodes.js +110 -0
  34. package/src/errors/LumisError.js +85 -0
  35. package/src/game/EconomyManager.js +161 -0
  36. package/src/game/GameSessionManager.js +76 -0
  37. package/src/game/GuildManager.js +197 -0
  38. package/src/game/InventoryManager.js +140 -0
  39. package/src/game/LevelingSystem.js +194 -0
  40. package/src/game/MusicManager.js +587 -0
  41. package/src/health/HealthChecker.js +170 -0
  42. package/src/health/index.js +7 -0
  43. package/src/performance/PerformanceMonitor.js +244 -0
  44. package/src/performance/index.js +7 -0
  45. package/src/security/InputSanitizer.js +185 -0
  46. package/src/security/SecurityMiddleware.js +231 -0
  47. package/src/security/index.js +9 -0
  48. package/src/shutdown/GracefulShutdown.js +159 -0
  49. package/src/shutdown/index.js +7 -0
  50. package/src/utils/StructuredLogger.js +118 -0
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const { ErrorCodes, ErrorMessages } = require('./ErrorCodes');
4
+
5
+ class LumisError extends Error {
6
+
7
+ constructor(code, ...args) {
8
+ const message = LumisError._getMessage(code, args);
9
+ super(message);
10
+
11
+ this.name = 'LumisError';
12
+ this.code = code;
13
+
14
+ if (Error.captureStackTrace) {
15
+ Error.captureStackTrace(this, this.constructor);
16
+ }
17
+ }
18
+
19
+ static _getMessage(code, args) {
20
+ const msg = ErrorMessages[code];
21
+ if (!msg) return `Unknown error: ${code}`;
22
+ if (typeof msg === 'function') return msg(...args);
23
+ return msg;
24
+ }
25
+ }
26
+
27
+ class APIError extends LumisError {
28
+
29
+ constructor(path, error, method, status) {
30
+ super(ErrorCodes.API_ERROR, status, error.message || 'Unknown');
31
+
32
+ this.name = 'APIError';
33
+ this.method = method;
34
+ this.path = path;
35
+ this.httpStatus = status;
36
+ this.requestBody = null;
37
+
38
+ this.discordCode = error.code || 0;
39
+
40
+ this.discordMessage = error.message || 'Unknown';
41
+
42
+ this.errors = error.errors || {};
43
+ }
44
+
45
+ toJSON() {
46
+ return {
47
+ name: this.name,
48
+ code: this.code,
49
+ discordCode: this.discordCode,
50
+ message: this.discordMessage,
51
+ method: this.method,
52
+ path: this.path,
53
+ httpStatus: this.httpStatus,
54
+ errors: this.errors,
55
+ };
56
+ }
57
+ }
58
+
59
+ class WebSocketError extends LumisError {
60
+
61
+ constructor(code, closeCode, ...args) {
62
+ super(code, ...args);
63
+ this.name = 'WebSocketError';
64
+ this.closeCode = closeCode || null;
65
+ }
66
+ }
67
+
68
+ class RateLimitError extends LumisError {
69
+
70
+ constructor(path, retryAfter, global = false) {
71
+ super(ErrorCodes.RATE_LIMITED, retryAfter);
72
+
73
+ this.name = 'RateLimitError';
74
+ this.path = path;
75
+ this.retryAfter = retryAfter;
76
+ this.global = global;
77
+ }
78
+ }
79
+
80
+ module.exports = {
81
+ LumisError,
82
+ APIError,
83
+ WebSocketError,
84
+ RateLimitError,
85
+ };
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ class EconomyManager {
4
+ /**
5
+ * @param {import('../client/Client')} client
6
+ * @param {object} [options]
7
+ * @param {string} [options.currencyName='Coins']
8
+ * @param {string} [options.currencySymbol='💰']
9
+ * @param {number} [options.defaultBalance=0]
10
+ * @param {number} [options.maxBalance=Infinity]
11
+ */
12
+ constructor(client, options = {}) {
13
+ this.client = client;
14
+ this.currencyName = options.currencyName || 'Coins';
15
+ this.currencySymbol = options.currencySymbol || '💰';
16
+ this.defaultBalance = options.defaultBalance ?? 0;
17
+ this.maxBalance = options.maxBalance ?? Infinity;
18
+ }
19
+
20
+ _key(userId) {
21
+ return `eco_balance_${userId}`;
22
+ }
23
+
24
+ /**
25
+ * Get a user's balance.
26
+ * @param {string} userId
27
+ * @returns {Promise<number>}
28
+ */
29
+ async getBalance(userId) {
30
+ if (!userId) throw new TypeError('userId is required');
31
+ const val = await this.client.cache.get(this._key(userId));
32
+ if (val === null || val === undefined) return this.defaultBalance;
33
+ return Number(val);
34
+ }
35
+
36
+ /**
37
+ * Add to a user's balance.
38
+ * @param {string} userId
39
+ * @param {number} amount
40
+ * @returns {Promise<number>} New balance
41
+ */
42
+ async addBalance(userId, amount) {
43
+ if (!userId) throw new TypeError('userId is required');
44
+ if (typeof amount !== 'number' || isNaN(amount) || amount <= 0) {
45
+ throw new RangeError('amount must be a positive number');
46
+ }
47
+
48
+ const current = await this.getBalance(userId);
49
+ const newBalance = Math.min(current + amount, this.maxBalance);
50
+ await this.client.cache.set(this._key(userId), newBalance);
51
+ return newBalance;
52
+ }
53
+
54
+ /**
55
+ * Remove from a user's balance.
56
+ * @param {string} userId
57
+ * @param {number} amount
58
+ * @returns {Promise<number|false>} New balance, or false if insufficient funds
59
+ */
60
+ async removeBalance(userId, amount) {
61
+ if (!userId) throw new TypeError('userId is required');
62
+ if (typeof amount !== 'number' || isNaN(amount) || amount <= 0) {
63
+ throw new RangeError('amount must be a positive number');
64
+ }
65
+
66
+ const current = await this.getBalance(userId);
67
+ if (current < amount) return false;
68
+
69
+ const newBalance = current - amount;
70
+ await this.client.cache.set(this._key(userId), newBalance);
71
+ return newBalance;
72
+ }
73
+
74
+ /**
75
+ * Set a user's balance directly.
76
+ * @param {string} userId
77
+ * @param {number} amount
78
+ * @returns {Promise<number>}
79
+ */
80
+ async setBalance(userId, amount) {
81
+ if (!userId) throw new TypeError('userId is required');
82
+ if (typeof amount !== 'number' || isNaN(amount) || amount < 0) {
83
+ throw new RangeError('amount must be a non-negative number');
84
+ }
85
+
86
+ const clamped = Math.min(amount, this.maxBalance);
87
+ await this.client.cache.set(this._key(userId), clamped);
88
+ return clamped;
89
+ }
90
+
91
+ /**
92
+ * Transfer balance from one user to another.
93
+ * @param {string} fromUserId
94
+ * @param {string} toUserId
95
+ * @param {number} amount
96
+ * @returns {Promise<{ from: number, to: number }|false>}
97
+ */
98
+ async transfer(fromUserId, toUserId, amount) {
99
+ if (!fromUserId || !toUserId) throw new TypeError('Both user IDs are required');
100
+ if (fromUserId === toUserId) throw new Error('Cannot transfer to the same user');
101
+ if (typeof amount !== 'number' || isNaN(amount) || amount <= 0) {
102
+ throw new RangeError('amount must be a positive number');
103
+ }
104
+
105
+ const fromBalance = await this.getBalance(fromUserId);
106
+ if (fromBalance < amount) return false;
107
+
108
+ // Sequential to avoid race where both reads happen before writes
109
+ const newFrom = await this.removeBalance(fromUserId, amount);
110
+ const newTo = await this.addBalance(toUserId, amount);
111
+
112
+ return { from: newFrom, to: newTo };
113
+ }
114
+
115
+ /**
116
+ * Get a leaderboard sorted by balance.
117
+ * Works across all cache adapters via the keys() API.
118
+ * @param {number} [limit=10]
119
+ * @returns {Promise<Array<{ userId: string, balance: number }>>}
120
+ */
121
+ async getLeaderboard(limit = 10) {
122
+ if (typeof limit !== 'number' || limit < 1) {
123
+ throw new RangeError('limit must be a positive number');
124
+ }
125
+
126
+ const prefix = 'eco_balance_';
127
+ const users = [];
128
+
129
+ try {
130
+ const allKeys = await this.client.cache.keys();
131
+ const ecoKeys = allKeys.filter((k) => k.startsWith(prefix));
132
+
133
+ for (const key of ecoKeys) {
134
+ const val = await this.client.cache.get(key);
135
+ if (val !== undefined && val !== null) {
136
+ users.push({
137
+ userId: key.slice(prefix.length),
138
+ balance: Number(val),
139
+ });
140
+ }
141
+ }
142
+ } catch (err) {
143
+ this.client.logger?.warn(`EconomyManager.getLeaderboard failed: ${err.message}`);
144
+ }
145
+
146
+ return users.sort((a, b) => b.balance - a.balance).slice(0, limit);
147
+ }
148
+
149
+ /**
150
+ * Reset a user's balance to the default.
151
+ * @param {string} userId
152
+ * @returns {Promise<number>}
153
+ */
154
+ async resetBalance(userId) {
155
+ if (!userId) throw new TypeError('userId is required');
156
+ await this.client.cache.set(this._key(userId), this.defaultBalance);
157
+ return this.defaultBalance;
158
+ }
159
+ }
160
+
161
+ module.exports = EconomyManager;
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ const Collection = require('../utils/Collection');
4
+ const { EventEmitter } = require('node:events');
5
+
6
+ class GameSessionManager extends EventEmitter {
7
+ constructor() {
8
+ super();
9
+
10
+ this.sessions = new Collection();
11
+ }
12
+
13
+ startSession(sessionId, initialData = {}, timeoutMs = 300000) {
14
+ if (this.sessions.has(sessionId)) {
15
+ this.endSession(sessionId, 'force_restart');
16
+ }
17
+
18
+ const session = {
19
+ id: sessionId,
20
+ data: initialData,
21
+ startTime: Date.now(),
22
+ lastActivityTime: Date.now(),
23
+ timeoutMs,
24
+ _timer: null
25
+ };
26
+
27
+ this.sessions.set(sessionId, session);
28
+ this._resetTimer(session);
29
+
30
+ this.emit('sessionStart', session);
31
+ return session;
32
+ }
33
+
34
+ getSession(sessionId) {
35
+ return this.sessions.get(sessionId) || null;
36
+ }
37
+
38
+ updateSession(sessionId, newData) {
39
+ const session = this.sessions.get(sessionId);
40
+ if (!session) return null;
41
+
42
+ Object.assign(session.data, newData);
43
+ session.lastActivityTime = Date.now();
44
+
45
+ this._resetTimer(session);
46
+ this.emit('sessionUpdate', session);
47
+ return session;
48
+ }
49
+
50
+ endSession(sessionId, reason = 'finished') {
51
+ const session = this.sessions.get(sessionId);
52
+ if (!session) return false;
53
+
54
+ if (session._timer) {
55
+ clearTimeout(session._timer);
56
+ }
57
+
58
+ this.sessions.delete(sessionId);
59
+ this.emit('sessionEnd', session, reason);
60
+ return true;
61
+ }
62
+
63
+ _resetTimer(session) {
64
+ if (session._timer) {
65
+ clearTimeout(session._timer);
66
+ }
67
+
68
+ if (session.timeoutMs > 0) {
69
+ session._timer = setTimeout(() => {
70
+ this.endSession(session.id, 'timeout');
71
+ }, session.timeoutMs).unref();
72
+ }
73
+ }
74
+ }
75
+
76
+ module.exports = GameSessionManager;
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ const { LumisError } = require('../errors/LumisError');
4
+ const { ErrorCodes } = require('../errors/ErrorCodes');
5
+
6
+ class GuildManager {
7
+ /**
8
+ * @param {import('../client/Client')} client
9
+ */
10
+ constructor(client) {
11
+ this.client = client;
12
+ }
13
+
14
+ _nameHistoryKey(userId, guildId) {
15
+ return `name_history_${guildId}_${userId}`;
16
+ }
17
+
18
+ /**
19
+ * Parse JSON from cache safely.
20
+ * @private
21
+ */
22
+ _parse(raw, fallback) {
23
+ if (!raw) return fallback;
24
+ if (typeof raw === 'object') return raw;
25
+ try {
26
+ return JSON.parse(raw);
27
+ } catch {
28
+ return fallback;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Register a user in a guild (set nickname + manage roles).
34
+ * @param {object} member
35
+ * @param {string} name
36
+ * @param {string|number} age
37
+ * @param {object} [options]
38
+ * @param {string} [options.nameFormat='{name} | {age}']
39
+ * @param {string[]} [options.rolesToAdd]
40
+ * @param {string[]} [options.rolesToRemove]
41
+ * @returns {Promise<boolean>}
42
+ */
43
+ async registerUser(member, name, age, options = {}) {
44
+ if (!member?.id || !member?.guildId) {
45
+ throw new LumisError(ErrorCodes.INVALID_STRUCTURE, 'member');
46
+ }
47
+ if (!name || typeof name !== 'string') {
48
+ throw new TypeError('name must be a non-empty string');
49
+ }
50
+
51
+ const nameFormat = options.nameFormat || '{name} | {age}';
52
+ const formattedName = nameFormat
53
+ .replace('{name}', name.trim())
54
+ .replace('{age}', String(age).trim());
55
+
56
+ // Truncate to Discord's 32-char nickname limit
57
+ const nick = formattedName.slice(0, 32);
58
+
59
+ try {
60
+ await this.client.rest.patch(`/guilds/${member.guildId}/members/${member.id}`, {
61
+ body: { nick },
62
+ });
63
+
64
+ if (Array.isArray(options.rolesToAdd)) {
65
+ await Promise.all(
66
+ options.rolesToAdd.map((roleId) =>
67
+ this.client.rest.put(
68
+ `/guilds/${member.guildId}/members/${member.id}/roles/${roleId}`
69
+ )
70
+ )
71
+ );
72
+ }
73
+
74
+ if (Array.isArray(options.rolesToRemove)) {
75
+ await Promise.all(
76
+ options.rolesToRemove.map((roleId) =>
77
+ this.client.rest.delete(
78
+ `/guilds/${member.guildId}/members/${member.id}/roles/${roleId}`
79
+ )
80
+ )
81
+ );
82
+ }
83
+
84
+ await this.addNameHistory(member.id, member.guildId, nick, 'Register');
85
+ return true;
86
+ } catch (error) {
87
+ this.client.logger?.error(`Registration failed (User: ${member.id}):`, error);
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Record a name change in history.
94
+ * @param {string} userId
95
+ * @param {string} guildId
96
+ * @param {string} name
97
+ * @param {string} [reason='Name Change']
98
+ */
99
+ async addNameHistory(userId, guildId, name, reason = 'Name Change') {
100
+ const key = this._nameHistoryKey(userId, guildId);
101
+ const raw = await this.client.cache.get(key);
102
+ const arr = this._parse(raw, []);
103
+
104
+ arr.push({ name, reason, date: Date.now() });
105
+
106
+ // Keep last 50 entries to prevent unbounded growth
107
+ if (arr.length > 50) arr.splice(0, arr.length - 50);
108
+
109
+ await this.client.cache.set(key, JSON.stringify(arr));
110
+ }
111
+
112
+ /**
113
+ * Get a user's name history (newest first).
114
+ * @param {string} userId
115
+ * @param {string} guildId
116
+ * @returns {Promise<Array<{ name: string, reason: string, date: number }>>}
117
+ */
118
+ async getNameHistory(userId, guildId) {
119
+ const key = this._nameHistoryKey(userId, guildId);
120
+ const raw = await this.client.cache.get(key);
121
+ const arr = this._parse(raw, []);
122
+ return arr.sort((a, b) => b.date - a.date);
123
+ }
124
+
125
+ /**
126
+ * Jail a user by replacing all their roles with a jail role.
127
+ * Saves their current roles for restoration.
128
+ * @param {object} member
129
+ * @param {string} jailRoleId
130
+ * @returns {Promise<boolean>}
131
+ */
132
+ async jailUser(member, jailRoleId) {
133
+ if (!member?.id || !member?.guildId) return false;
134
+ if (!jailRoleId) throw new TypeError('jailRoleId is required');
135
+
136
+ const currentRoles = Array.isArray(member.roles) ? member.roles : [];
137
+ const cacheKey = `jail_roles_${member.guildId}_${member.id}`;
138
+
139
+ await this.client.cache.set(cacheKey, JSON.stringify(currentRoles));
140
+
141
+ try {
142
+ await this.client.rest.patch(`/guilds/${member.guildId}/members/${member.id}`, {
143
+ body: { roles: [jailRoleId] },
144
+ });
145
+ return true;
146
+ } catch (error) {
147
+ this.client.logger?.error('Jail failed:', error);
148
+ // Rollback cache write if API call failed
149
+ await this.client.cache.delete(cacheKey);
150
+ return false;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Unjail a user by restoring their saved roles.
156
+ * @param {object} member
157
+ * @returns {Promise<boolean>}
158
+ */
159
+ async unjailUser(member) {
160
+ if (!member?.id || !member?.guildId) return false;
161
+
162
+ const cacheKey = `jail_roles_${member.guildId}_${member.id}`;
163
+ const raw = await this.client.cache.get(cacheKey);
164
+
165
+ if (!raw) {
166
+ this.client.logger?.warn(`Unjail: No saved roles found for user ${member.id}.`);
167
+ return false;
168
+ }
169
+
170
+ const rolesToRestore = this._parse(raw, []);
171
+
172
+ try {
173
+ await this.client.rest.patch(`/guilds/${member.guildId}/members/${member.id}`, {
174
+ body: { roles: rolesToRestore },
175
+ });
176
+ await this.client.cache.delete(cacheKey);
177
+ return true;
178
+ } catch (error) {
179
+ this.client.logger?.error('Unjail failed:', error);
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Check if a user is currently jailed.
186
+ * @param {string} userId
187
+ * @param {string} guildId
188
+ * @returns {Promise<boolean>}
189
+ */
190
+ async isJailed(userId, guildId) {
191
+ const cacheKey = `jail_roles_${guildId}_${userId}`;
192
+ const raw = await this.client.cache.get(cacheKey);
193
+ return raw !== undefined && raw !== null;
194
+ }
195
+ }
196
+
197
+ module.exports = GuildManager;
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ class InventoryManager {
4
+ /**
5
+ * @param {import('../client/Client')} client
6
+ */
7
+ constructor(client) {
8
+ this.client = client;
9
+ }
10
+
11
+ _key(userId) {
12
+ return `inv_items_${userId}`;
13
+ }
14
+
15
+ /**
16
+ * Parse the stored inventory safely.
17
+ * @private
18
+ */
19
+ _parse(raw) {
20
+ if (!raw) return {};
21
+ if (typeof raw === 'object') return raw;
22
+ try {
23
+ return JSON.parse(raw);
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Get a user's full inventory.
31
+ * @param {string} userId
32
+ * @returns {Promise<Record<string, number>>}
33
+ */
34
+ async getInventory(userId) {
35
+ if (!userId) throw new TypeError('userId is required');
36
+ const val = await this.client.cache.get(this._key(userId));
37
+ return this._parse(val);
38
+ }
39
+
40
+ /**
41
+ * Add an item to a user's inventory.
42
+ * @param {string} userId
43
+ * @param {string} itemName
44
+ * @param {number} [amount=1]
45
+ * @returns {Promise<Record<string, number>>} Updated inventory
46
+ */
47
+ async addItem(userId, itemName, amount = 1) {
48
+ if (!userId) throw new TypeError('userId is required');
49
+ if (!itemName || typeof itemName !== 'string') throw new TypeError('itemName must be a non-empty string');
50
+ if (typeof amount !== 'number' || amount <= 0 || !Number.isInteger(amount)) {
51
+ throw new RangeError('amount must be a positive integer');
52
+ }
53
+
54
+ const inv = await this.getInventory(userId);
55
+ inv[itemName] = (inv[itemName] ?? 0) + amount;
56
+
57
+ await this.client.cache.set(this._key(userId), JSON.stringify(inv));
58
+ return inv;
59
+ }
60
+
61
+ /**
62
+ * Remove an item from a user's inventory.
63
+ * @param {string} userId
64
+ * @param {string} itemName
65
+ * @param {number} [amount=1]
66
+ * @returns {Promise<Record<string, number>|false>} Updated inventory or false if not enough
67
+ */
68
+ async removeItem(userId, itemName, amount = 1) {
69
+ if (!userId) throw new TypeError('userId is required');
70
+ if (!itemName || typeof itemName !== 'string') throw new TypeError('itemName must be a non-empty string');
71
+ if (typeof amount !== 'number' || amount <= 0 || !Number.isInteger(amount)) {
72
+ throw new RangeError('amount must be a positive integer');
73
+ }
74
+
75
+ const inv = await this.getInventory(userId);
76
+ const current = inv[itemName] ?? 0;
77
+
78
+ if (current < amount) return false;
79
+
80
+ inv[itemName] = current - amount;
81
+ if (inv[itemName] <= 0) {
82
+ delete inv[itemName];
83
+ }
84
+
85
+ await this.client.cache.set(this._key(userId), JSON.stringify(inv));
86
+ return inv;
87
+ }
88
+
89
+ /**
90
+ * Check how many of an item a user has.
91
+ * @param {string} userId
92
+ * @param {string} itemName
93
+ * @returns {Promise<number>}
94
+ */
95
+ async hasItem(userId, itemName) {
96
+ if (!userId) throw new TypeError('userId is required');
97
+ const inv = await this.getInventory(userId);
98
+ return inv[itemName] ?? 0;
99
+ }
100
+
101
+ /**
102
+ * Transfer an item from one user to another.
103
+ * @param {string} fromUserId
104
+ * @param {string} toUserId
105
+ * @param {string} itemName
106
+ * @param {number} [amount=1]
107
+ * @returns {Promise<boolean>}
108
+ */
109
+ async transferItem(fromUserId, toUserId, itemName, amount = 1) {
110
+ if (!fromUserId || !toUserId) throw new TypeError('Both user IDs are required');
111
+ if (fromUserId === toUserId) throw new Error('Cannot transfer item to the same user');
112
+
113
+ const removed = await this.removeItem(fromUserId, itemName, amount);
114
+ if (removed === false) return false;
115
+
116
+ await this.addItem(toUserId, itemName, amount);
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Get all items in a user's inventory as an array.
122
+ * @param {string} userId
123
+ * @returns {Promise<Array<{ name: string, amount: number }>>}
124
+ */
125
+ async getItemList(userId) {
126
+ const inv = await this.getInventory(userId);
127
+ return Object.entries(inv).map(([name, amount]) => ({ name, amount }));
128
+ }
129
+
130
+ /**
131
+ * Clear a user's entire inventory.
132
+ * @param {string} userId
133
+ */
134
+ async clearInventory(userId) {
135
+ if (!userId) throw new TypeError('userId is required');
136
+ await this.client.cache.delete(this._key(userId));
137
+ }
138
+ }
139
+
140
+ module.exports = InventoryManager;