roblox-opencode 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 (248) hide show
  1. package/README.md +122 -0
  2. package/commands/setup-game.md +108 -0
  3. package/commands/sync-check.md +53 -0
  4. package/core/roblox-core.md +93 -0
  5. package/dist/server.js +167 -0
  6. package/package.json +35 -0
  7. package/skills/roblox-analytics/SKILL.md +277 -0
  8. package/skills/roblox-analytics/references/event-batcher.luau +75 -0
  9. package/skills/roblox-animation-vfx/SKILL.md +1325 -0
  10. package/skills/roblox-architecture/SKILL.md +863 -0
  11. package/skills/roblox-architecture/references/combat-systems.md +1381 -0
  12. package/skills/roblox-code-review/SKILL.md +687 -0
  13. package/skills/roblox-data/SKILL.md +889 -0
  14. package/skills/roblox-data/references/inventory-systems.md +1729 -0
  15. package/skills/roblox-debug/SKILL.md +99 -0
  16. package/skills/roblox-gui/SKILL.md +1103 -0
  17. package/skills/roblox-gui-fusion/SKILL.md +150 -0
  18. package/skills/roblox-gui-fusion/references/inventory.luau +427 -0
  19. package/skills/roblox-gui-fusion/references/settings-menu.luau +579 -0
  20. package/skills/roblox-gui-fusion/references/shop.luau +411 -0
  21. package/skills/roblox-luau-mastery/SKILL.md +1519 -0
  22. package/skills/roblox-monetization/SKILL.md +1084 -0
  23. package/skills/roblox-monetization/references/process-receipt.luau +131 -0
  24. package/skills/roblox-networking/SKILL.md +669 -0
  25. package/skills/roblox-networking/references/remote-validator.luau +193 -0
  26. package/skills/roblox-publish-checklist/SKILL.md +128 -0
  27. package/skills/roblox-runtime/SKILL.md +753 -0
  28. package/skills/roblox-sharp-edges/SKILL.md +295 -0
  29. package/skills/roblox-sync/SKILL.md +126 -0
  30. package/skills/roblox-testing/SKILL.md +943 -0
  31. package/skills/roblox-tooling/SKILL.md +150 -0
  32. package/vendor/LICENSES/ProfileStore-LICENSE +201 -0
  33. package/vendor/LICENSES/RbxUtil-LICENSE +7 -0
  34. package/vendor/LICENSES/promise-LICENSE +21 -0
  35. package/vendor/LICENSES/t-LICENSE +21 -0
  36. package/vendor/LICENSES/testez-LICENSE +201 -0
  37. package/vendor/README.md +84 -0
  38. package/vendor/fusion/Animation/ExternalTime.luau +84 -0
  39. package/vendor/fusion/Animation/Spring.luau +322 -0
  40. package/vendor/fusion/Animation/Stopwatch.luau +128 -0
  41. package/vendor/fusion/Animation/Tween.luau +187 -0
  42. package/vendor/fusion/Animation/getTweenDuration.luau +27 -0
  43. package/vendor/fusion/Animation/getTweenRatio.luau +47 -0
  44. package/vendor/fusion/Animation/lerpType.luau +164 -0
  45. package/vendor/fusion/Animation/packType.luau +100 -0
  46. package/vendor/fusion/Animation/springCoefficients.luau +80 -0
  47. package/vendor/fusion/Animation/unpackType.luau +103 -0
  48. package/vendor/fusion/Colour/Oklab.luau +70 -0
  49. package/vendor/fusion/Colour/sRGB.luau +55 -0
  50. package/vendor/fusion/External.luau +168 -0
  51. package/vendor/fusion/ExternalDebug.luau +70 -0
  52. package/vendor/fusion/Graph/Observer.luau +114 -0
  53. package/vendor/fusion/Graph/castToGraph.luau +29 -0
  54. package/vendor/fusion/Graph/change.luau +81 -0
  55. package/vendor/fusion/Graph/depend.luau +33 -0
  56. package/vendor/fusion/Graph/evaluate.luau +56 -0
  57. package/vendor/fusion/Instances/Attribute.luau +58 -0
  58. package/vendor/fusion/Instances/AttributeChange.luau +47 -0
  59. package/vendor/fusion/Instances/AttributeOut.luau +63 -0
  60. package/vendor/fusion/Instances/Child.luau +21 -0
  61. package/vendor/fusion/Instances/Children.luau +148 -0
  62. package/vendor/fusion/Instances/Hydrate.luau +33 -0
  63. package/vendor/fusion/Instances/New.luau +53 -0
  64. package/vendor/fusion/Instances/OnChange.luau +50 -0
  65. package/vendor/fusion/Instances/OnEvent.luau +54 -0
  66. package/vendor/fusion/Instances/Out.luau +69 -0
  67. package/vendor/fusion/Instances/applyInstanceProps.luau +149 -0
  68. package/vendor/fusion/Instances/defaultProps.luau +194 -0
  69. package/vendor/fusion/LICENSE +21 -0
  70. package/vendor/fusion/Logging/formatError.luau +49 -0
  71. package/vendor/fusion/Logging/messages.luau +52 -0
  72. package/vendor/fusion/Logging/parseError.luau +25 -0
  73. package/vendor/fusion/Memory/checkLifetime.luau +134 -0
  74. package/vendor/fusion/Memory/deriveScope.luau +24 -0
  75. package/vendor/fusion/Memory/deriveScopeImpl.luau +45 -0
  76. package/vendor/fusion/Memory/doCleanup.luau +79 -0
  77. package/vendor/fusion/Memory/innerScope.luau +34 -0
  78. package/vendor/fusion/Memory/legacyCleanup.luau +18 -0
  79. package/vendor/fusion/Memory/needsDestruction.luau +17 -0
  80. package/vendor/fusion/Memory/poisonScope.luau +34 -0
  81. package/vendor/fusion/Memory/scopePool.luau +55 -0
  82. package/vendor/fusion/Memory/scoped.luau +27 -0
  83. package/vendor/fusion/Memory/whichLivesLonger.luau +75 -0
  84. package/vendor/fusion/RobloxExternal.luau +98 -0
  85. package/vendor/fusion/State/Computed.luau +139 -0
  86. package/vendor/fusion/State/For/Disassembly.luau +211 -0
  87. package/vendor/fusion/State/For/ForTypes.luau +30 -0
  88. package/vendor/fusion/State/For/init.luau +110 -0
  89. package/vendor/fusion/State/ForKeys.luau +94 -0
  90. package/vendor/fusion/State/ForPairs.luau +97 -0
  91. package/vendor/fusion/State/ForValues.luau +94 -0
  92. package/vendor/fusion/State/Value.luau +88 -0
  93. package/vendor/fusion/State/castToState.luau +26 -0
  94. package/vendor/fusion/State/peek.luau +31 -0
  95. package/vendor/fusion/State/updateAll.luau +1 -0
  96. package/vendor/fusion/Types.luau +314 -0
  97. package/vendor/fusion/Utility/Contextual.luau +91 -0
  98. package/vendor/fusion/Utility/Safe.luau +23 -0
  99. package/vendor/fusion/Utility/isSimilar.luau +29 -0
  100. package/vendor/fusion/Utility/merge.luau +35 -0
  101. package/vendor/fusion/Utility/nameOf.luau +35 -0
  102. package/vendor/fusion/Utility/never.luau +14 -0
  103. package/vendor/fusion/Utility/nicknames.luau +11 -0
  104. package/vendor/fusion/Utility/xtypeof.luau +27 -0
  105. package/vendor/fusion/init.luau +82 -0
  106. package/vendor/profilestore/init.luau +2243 -0
  107. package/vendor/promise/init.luau +1982 -0
  108. package/vendor/rbxutil/buffer-util/Buffer.test.luau +25 -0
  109. package/vendor/rbxutil/buffer-util/BufferReader.luau +228 -0
  110. package/vendor/rbxutil/buffer-util/BufferWriter.luau +269 -0
  111. package/vendor/rbxutil/buffer-util/DataTypeBuffer.luau +223 -0
  112. package/vendor/rbxutil/buffer-util/Types.luau +60 -0
  113. package/vendor/rbxutil/buffer-util/index.d.ts +153 -0
  114. package/vendor/rbxutil/buffer-util/init.luau +41 -0
  115. package/vendor/rbxutil/buffer-util/package.json +16 -0
  116. package/vendor/rbxutil/buffer-util/wally.toml +9 -0
  117. package/vendor/rbxutil/comm/Client/ClientComm.luau +232 -0
  118. package/vendor/rbxutil/comm/Client/ClientRemoteProperty.luau +156 -0
  119. package/vendor/rbxutil/comm/Client/ClientRemoteSignal.luau +109 -0
  120. package/vendor/rbxutil/comm/Client/init.luau +135 -0
  121. package/vendor/rbxutil/comm/Server/RemoteProperty.luau +295 -0
  122. package/vendor/rbxutil/comm/Server/RemoteSignal.luau +211 -0
  123. package/vendor/rbxutil/comm/Server/ServerComm.luau +211 -0
  124. package/vendor/rbxutil/comm/Server/init.luau +140 -0
  125. package/vendor/rbxutil/comm/Types.luau +18 -0
  126. package/vendor/rbxutil/comm/Util.luau +27 -0
  127. package/vendor/rbxutil/comm/init.luau +35 -0
  128. package/vendor/rbxutil/comm/wally.toml +13 -0
  129. package/vendor/rbxutil/component/init.luau +759 -0
  130. package/vendor/rbxutil/component/init.test.luau +311 -0
  131. package/vendor/rbxutil/component/wally.toml +14 -0
  132. package/vendor/rbxutil/concur/init.luau +542 -0
  133. package/vendor/rbxutil/concur/init.test.luau +364 -0
  134. package/vendor/rbxutil/concur/wally.toml +8 -0
  135. package/vendor/rbxutil/enum-list/init.luau +101 -0
  136. package/vendor/rbxutil/enum-list/init.test.luau +91 -0
  137. package/vendor/rbxutil/enum-list/wally.toml +8 -0
  138. package/vendor/rbxutil/find/index.d.ts +20 -0
  139. package/vendor/rbxutil/find/init.luau +44 -0
  140. package/vendor/rbxutil/find/package.json +17 -0
  141. package/vendor/rbxutil/find/wally.toml +8 -0
  142. package/vendor/rbxutil/input/Gamepad.luau +559 -0
  143. package/vendor/rbxutil/input/Keyboard.luau +124 -0
  144. package/vendor/rbxutil/input/Mouse.luau +278 -0
  145. package/vendor/rbxutil/input/PreferredInput.luau +91 -0
  146. package/vendor/rbxutil/input/Touch.luau +120 -0
  147. package/vendor/rbxutil/input/init.luau +33 -0
  148. package/vendor/rbxutil/input/wally.toml +12 -0
  149. package/vendor/rbxutil/loader/index.d.ts +15 -0
  150. package/vendor/rbxutil/loader/init.luau +137 -0
  151. package/vendor/rbxutil/loader/wally.toml +8 -0
  152. package/vendor/rbxutil/log/index.d.ts +38 -0
  153. package/vendor/rbxutil/log/init.luau +746 -0
  154. package/vendor/rbxutil/log/wally.toml +8 -0
  155. package/vendor/rbxutil/net/init.luau +190 -0
  156. package/vendor/rbxutil/net/wally.toml +8 -0
  157. package/vendor/rbxutil/option/index.d.ts +44 -0
  158. package/vendor/rbxutil/option/init.luau +489 -0
  159. package/vendor/rbxutil/option/init.test.luau +342 -0
  160. package/vendor/rbxutil/option/wally.toml +8 -0
  161. package/vendor/rbxutil/pid/index.d.ts +53 -0
  162. package/vendor/rbxutil/pid/init.luau +195 -0
  163. package/vendor/rbxutil/pid/package.json +16 -0
  164. package/vendor/rbxutil/pid/wally.toml +9 -0
  165. package/vendor/rbxutil/quaternion/index.d.ts +117 -0
  166. package/vendor/rbxutil/quaternion/init.luau +570 -0
  167. package/vendor/rbxutil/quaternion/package.json +16 -0
  168. package/vendor/rbxutil/quaternion/wally.toml +9 -0
  169. package/vendor/rbxutil/query/index.d.ts +43 -0
  170. package/vendor/rbxutil/query/init.luau +117 -0
  171. package/vendor/rbxutil/query/package.json +18 -0
  172. package/vendor/rbxutil/query/wally.toml +9 -0
  173. package/vendor/rbxutil/sequent/index.d.ts +28 -0
  174. package/vendor/rbxutil/sequent/init.luau +340 -0
  175. package/vendor/rbxutil/sequent/package.json +16 -0
  176. package/vendor/rbxutil/sequent/wally.toml +9 -0
  177. package/vendor/rbxutil/ser/init.luau +175 -0
  178. package/vendor/rbxutil/ser/init.test.luau +50 -0
  179. package/vendor/rbxutil/ser/wally.toml +11 -0
  180. package/vendor/rbxutil/shake/index.d.ts +36 -0
  181. package/vendor/rbxutil/shake/init.luau +532 -0
  182. package/vendor/rbxutil/shake/init.test.luau +267 -0
  183. package/vendor/rbxutil/shake/package.json +16 -0
  184. package/vendor/rbxutil/shake/wally.toml +9 -0
  185. package/vendor/rbxutil/signal/index.d.ts +100 -0
  186. package/vendor/rbxutil/signal/init.luau +432 -0
  187. package/vendor/rbxutil/signal/init.test.luau +190 -0
  188. package/vendor/rbxutil/signal/package.json +17 -0
  189. package/vendor/rbxutil/signal/wally.toml +9 -0
  190. package/vendor/rbxutil/silo/TableWatcher.luau +65 -0
  191. package/vendor/rbxutil/silo/Util.luau +55 -0
  192. package/vendor/rbxutil/silo/init.luau +338 -0
  193. package/vendor/rbxutil/silo/init.test.luau +215 -0
  194. package/vendor/rbxutil/silo/wally.toml +8 -0
  195. package/vendor/rbxutil/spring/index.d.ts +40 -0
  196. package/vendor/rbxutil/spring/init.luau +97 -0
  197. package/vendor/rbxutil/spring/package.json +17 -0
  198. package/vendor/rbxutil/spring/wally.toml +8 -0
  199. package/vendor/rbxutil/stream/index.d.ts +88 -0
  200. package/vendor/rbxutil/stream/init.luau +597 -0
  201. package/vendor/rbxutil/stream/package.json +18 -0
  202. package/vendor/rbxutil/stream/wally.toml +9 -0
  203. package/vendor/rbxutil/streamable/Streamable.luau +202 -0
  204. package/vendor/rbxutil/streamable/StreamableUtil.luau +80 -0
  205. package/vendor/rbxutil/streamable/init.luau +8 -0
  206. package/vendor/rbxutil/streamable/wally.toml +12 -0
  207. package/vendor/rbxutil/symbol/init.luau +56 -0
  208. package/vendor/rbxutil/symbol/init.test.luau +37 -0
  209. package/vendor/rbxutil/symbol/wally.toml +8 -0
  210. package/vendor/rbxutil/table-util/init.luau +938 -0
  211. package/vendor/rbxutil/table-util/init.test.luau +439 -0
  212. package/vendor/rbxutil/table-util/wally.toml +8 -0
  213. package/vendor/rbxutil/task-queue/index.d.ts +27 -0
  214. package/vendor/rbxutil/task-queue/init.luau +97 -0
  215. package/vendor/rbxutil/task-queue/wally.toml +8 -0
  216. package/vendor/rbxutil/timer/index.d.ts +81 -0
  217. package/vendor/rbxutil/timer/init.luau +249 -0
  218. package/vendor/rbxutil/timer/init.test.luau +73 -0
  219. package/vendor/rbxutil/timer/wally.toml +11 -0
  220. package/vendor/rbxutil/tree/index.d.ts +15 -0
  221. package/vendor/rbxutil/tree/init.luau +137 -0
  222. package/vendor/rbxutil/tree/wally.toml +8 -0
  223. package/vendor/rbxutil/trove/index.d.ts +46 -0
  224. package/vendor/rbxutil/trove/init.luau +787 -0
  225. package/vendor/rbxutil/trove/init.test.luau +203 -0
  226. package/vendor/rbxutil/trove/wally.toml +8 -0
  227. package/vendor/rbxutil/typed-remote/init.luau +196 -0
  228. package/vendor/rbxutil/typed-remote/wally.toml +8 -0
  229. package/vendor/rbxutil/wait-for/index.d.ts +17 -0
  230. package/vendor/rbxutil/wait-for/init.luau +257 -0
  231. package/vendor/rbxutil/wait-for/init.test.luau +182 -0
  232. package/vendor/rbxutil/wait-for/wally.toml +11 -0
  233. package/vendor/t/t.lua +1350 -0
  234. package/vendor/testez/Context.lua +26 -0
  235. package/vendor/testez/Expectation.lua +311 -0
  236. package/vendor/testez/ExpectationContext.lua +38 -0
  237. package/vendor/testez/LifecycleHooks.lua +89 -0
  238. package/vendor/testez/Reporters/TeamCityReporter.lua +102 -0
  239. package/vendor/testez/Reporters/TextReporter.lua +106 -0
  240. package/vendor/testez/Reporters/TextReporterQuiet.lua +97 -0
  241. package/vendor/testez/TestBootstrap.lua +147 -0
  242. package/vendor/testez/TestEnum.lua +28 -0
  243. package/vendor/testez/TestPlan.lua +304 -0
  244. package/vendor/testez/TestPlanner.lua +40 -0
  245. package/vendor/testez/TestResults.lua +112 -0
  246. package/vendor/testez/TestRunner.lua +188 -0
  247. package/vendor/testez/TestSession.lua +243 -0
  248. package/vendor/testez/init.lua +40 -0
@@ -0,0 +1,1729 @@
1
+ # Roblox Inventory Systems Reference
2
+
3
+ ---
4
+
5
+ ## 1. Overview
6
+
7
+ Inventory systems manage the lifecycle of items a player owns, equips, trades, and consumes. Load and initialize the inventory system when:
8
+
9
+ - **Building inventory UI** -- The player opens their backpack, equipment screen, or any item management interface.
10
+ - **Items and equipment** -- The player picks up loot, equips gear, or consumes items during gameplay.
11
+ - **Trading** -- Two players negotiate and swap items in a secure, server-validated transaction.
12
+ - **Shops and stores** -- The player buys from or sells to NPC vendors.
13
+
14
+ The core principle is **server authority**: the server owns all inventory state, and the client is only a rendering layer. Every mutation flows through the server, which validates ownership, capacity, and legality before committing changes.
15
+
16
+ ---
17
+
18
+ ## 2. Item Data Architecture
19
+
20
+ ### Item Definitions (Shared Schema)
21
+
22
+ Item definitions are static, read-only templates that describe what an item *is*. Store them in a shared `ModuleScript` inside `ReplicatedStorage` so both server and client can reference them without duplication.
23
+
24
+ ```luau
25
+ -- ReplicatedStorage/Shared/ItemDefinitions.luau
26
+ local ItemDefinitions = {
27
+ [1001] = {
28
+ Id = 1001,
29
+ Name = "Iron Sword",
30
+ Description = "A sturdy blade forged from iron.",
31
+ Rarity = "Common",
32
+ Category = "Weapon",
33
+ Stats = { Attack = 10, Speed = 1.2 },
34
+ Icon = "rbxassetid://123456789",
35
+ Stackable = false,
36
+ MaxStack = 1,
37
+ },
38
+ [1002] = {
39
+ Id = 1002,
40
+ Name = "Health Potion",
41
+ Description = "Restores 50 HP.",
42
+ Rarity = "Common",
43
+ Category = "Consumable",
44
+ Stats = { HealAmount = 50 },
45
+ Icon = "rbxassetid://123456790",
46
+ Stackable = true,
47
+ MaxStack = 99,
48
+ },
49
+ [2001] = {
50
+ Id = 2001,
51
+ Name = "Dragon Helmet",
52
+ Description = "Helm forged from dragon scales.",
53
+ Rarity = "Legendary",
54
+ Category = "Helmet",
55
+ Stats = { Defense = 45, FireResist = 20 },
56
+ Icon = "rbxassetid://123456791",
57
+ Stackable = false,
58
+ MaxStack = 1,
59
+ },
60
+ [2002] = {
61
+ Id = 2002,
62
+ Name = "Leather Boots",
63
+ Description = "Light boots that increase movement speed.",
64
+ Rarity = "Uncommon",
65
+ Category = "Boots",
66
+ Stats = { Defense = 5, Speed = 1.5 },
67
+ Icon = "rbxassetid://123456792",
68
+ Stackable = false,
69
+ MaxStack = 1,
70
+ },
71
+ }
72
+
73
+ return ItemDefinitions
74
+ ```
75
+
76
+ ### Item Instances (Player-Specific Data)
77
+
78
+ An item instance is a player's copy of an item. It references the definition by `itemId` and stores instance-specific data like quantity, durability, or enchantments. Never duplicate the full definition into instance data.
79
+
80
+ ```luau
81
+ -- Example item instance stored in a player's inventory slot
82
+ {
83
+ itemId = 1001, -- references ItemDefinitions[1001]
84
+ quantity = 1, -- always 1 for non-stackable
85
+ metadata = {
86
+ durability = 85, -- instance-specific
87
+ enchantments = { "Sharpness II" },
88
+ uuid = "a1b2c3d4", -- unique instance identifier for trading/logging
89
+ },
90
+ }
91
+ ```
92
+
93
+ Key rules:
94
+ - `itemId` is the only link to the definition. All display data (name, icon, stats) comes from `ItemDefinitions[itemId]`.
95
+ - `metadata` holds mutable, instance-specific state.
96
+ - Generate a `uuid` for non-stackable items so individual copies are distinguishable (critical for trading and logging).
97
+
98
+ ---
99
+
100
+ ## 3. Inventory Storage
101
+
102
+ ### Server-Side Inventory Table
103
+
104
+ The server maintains each player's inventory as a Luau table. Two common layouts exist and can be combined.
105
+
106
+ **Slot-based (fixed slots):**
107
+
108
+ Used for equipment where each slot has a specific purpose. Slots are keyed by name.
109
+
110
+ ```luau
111
+ local equipment = {
112
+ Weapon = nil, -- one item instance or nil
113
+ Helmet = nil,
114
+ Armor = nil,
115
+ Boots = nil,
116
+ Accessory1 = nil,
117
+ Accessory2 = nil,
118
+ }
119
+ ```
120
+
121
+ **List-based (dynamic backpack):**
122
+
123
+ Used for general inventory where items fill sequential slots up to a capacity limit.
124
+
125
+ ```luau
126
+ local backpack = {
127
+ -- [slotIndex] = itemInstance
128
+ [1] = { itemId = 1002, quantity = 10, metadata = {} },
129
+ [2] = { itemId = 1001, quantity = 1, metadata = { durability = 100, uuid = "x1y2" } },
130
+ -- slots 3..maxSlots are nil (empty)
131
+ }
132
+ ```
133
+
134
+ ### Capacity and Overflow
135
+
136
+ ```luau
137
+ local MAX_BACKPACK_SLOTS = 30
138
+
139
+ local function getUsedSlotCount(backpack: { [number]: any }): number
140
+ local count = 0
141
+ for _ in backpack do
142
+ count += 1
143
+ end
144
+ return count
145
+ end
146
+
147
+ local function hasSpace(backpack: { [number]: any }): boolean
148
+ return getUsedSlotCount(backpack) < MAX_BACKPACK_SLOTS
149
+ end
150
+ ```
151
+
152
+ Overflow handling strategies:
153
+ - **Reject** -- Refuse the action and notify the player ("Inventory full").
154
+ - **Mailbox / overflow stash** -- Store overflow items in a secondary collection the player can retrieve later.
155
+ - **Drop to ground** -- Spawn the item in the world near the player (risky if other players can loot it).
156
+
157
+ Always check capacity *before* committing an add operation. Never silently discard items.
158
+
159
+ ---
160
+
161
+ ## 4. Equipment System
162
+
163
+ ### Equip Slots
164
+
165
+ ```luau
166
+ local EQUIP_SLOTS = {
167
+ "Weapon",
168
+ "Helmet",
169
+ "Armor",
170
+ "Boots",
171
+ "Accessory1",
172
+ "Accessory2",
173
+ }
174
+
175
+ -- Map item categories to valid equip slots
176
+ local CATEGORY_TO_SLOT: { [string]: { string } } = {
177
+ Weapon = { "Weapon" },
178
+ Helmet = { "Helmet" },
179
+ Armor = { "Armor" },
180
+ Boots = { "Boots" },
181
+ Accessory = { "Accessory1", "Accessory2" },
182
+ }
183
+ ```
184
+
185
+ ### Equip Flow
186
+
187
+ 1. **Validate ownership** -- Confirm the item exists in the player's backpack.
188
+ 2. **Validate slot compatibility** -- Ensure the item's category matches the target slot.
189
+ 3. **Unequip current** -- If the target slot is occupied, move the current item back to backpack (requires space check).
190
+ 4. **Remove from backpack** -- Take the item out of the backpack slot.
191
+ 5. **Place in equip slot** -- Assign the item instance to the equipment table.
192
+ 6. **Apply stat bonuses** -- Recalculate the player's stats.
193
+ 7. **Update visuals** -- Attach the Tool or Accessory to the character model.
194
+
195
+ ### Unequip Flow
196
+
197
+ Reverse of equip: validate backpack has space, remove from equip slot, add to backpack, remove stat bonuses, remove visual.
198
+
199
+ ### Code Example
200
+
201
+ ```luau
202
+ -- Inside InventoryManager (see full module in Section 11)
203
+
204
+ local function getItemDef(itemId: number)
205
+ return ItemDefinitions[itemId]
206
+ end
207
+
208
+ local function findBackpackSlot(playerData, itemId: number, uuid: string?): number?
209
+ for slot, item in playerData.backpack do
210
+ if item.itemId == itemId then
211
+ if uuid == nil or (item.metadata and item.metadata.uuid == uuid) then
212
+ return slot
213
+ end
214
+ end
215
+ end
216
+ return nil
217
+ end
218
+
219
+ local function findEmptyBackpackSlot(playerData): number?
220
+ for i = 1, MAX_BACKPACK_SLOTS do
221
+ if playerData.backpack[i] == nil then
222
+ return i
223
+ end
224
+ end
225
+ return nil
226
+ end
227
+
228
+ function InventoryManager.equip(player: Player, backpackSlot: number, equipSlot: string): (boolean, string?)
229
+ local playerData = InventoryManager._getPlayerData(player)
230
+ if not playerData then
231
+ return false, "Player data not found"
232
+ end
233
+
234
+ local item = playerData.backpack[backpackSlot]
235
+ if not item then
236
+ return false, "No item in that backpack slot"
237
+ end
238
+
239
+ local def = getItemDef(item.itemId)
240
+ if not def then
241
+ return false, "Unknown item"
242
+ end
243
+
244
+ -- Validate slot compatibility
245
+ local validSlots = CATEGORY_TO_SLOT[def.Category]
246
+ if not validSlots or not table.find(validSlots, equipSlot) then
247
+ return false, "Item cannot be equipped in that slot"
248
+ end
249
+
250
+ -- If slot is occupied, ensure we have room to unequip
251
+ local currentEquip = playerData.equipment[equipSlot]
252
+ if currentEquip then
253
+ local emptySlot = findEmptyBackpackSlot(playerData)
254
+ if not emptySlot then
255
+ return false, "Inventory full, cannot unequip current item"
256
+ end
257
+ -- Move current equipment to backpack
258
+ playerData.backpack[emptySlot] = currentEquip
259
+ end
260
+
261
+ -- Move item from backpack to equipment
262
+ playerData.equipment[equipSlot] = item
263
+ playerData.backpack[backpackSlot] = nil
264
+
265
+ -- Recalculate stats
266
+ InventoryManager._recalculateStats(player)
267
+
268
+ -- Update character visuals
269
+ InventoryManager._updateVisuals(player, equipSlot, item)
270
+
271
+ return true, nil
272
+ end
273
+ ```
274
+
275
+ ### Visual Updates
276
+
277
+ ```luau
278
+ function InventoryManager._updateVisuals(player: Player, equipSlot: string, item: any?)
279
+ local character = player.Character
280
+ if not character then return end
281
+
282
+ if equipSlot == "Weapon" then
283
+ -- Remove existing tool
284
+ for _, tool in character:GetChildren() do
285
+ if tool:IsA("Tool") and tool:GetAttribute("EquipSlot") == "Weapon" then
286
+ tool:Destroy()
287
+ end
288
+ end
289
+ -- Add new tool if item provided
290
+ if item then
291
+ local def = getItemDef(item.itemId)
292
+ local toolTemplate = ServerStorage.Tools:FindFirstChild(def.Name)
293
+ if toolTemplate then
294
+ local tool = toolTemplate:Clone()
295
+ tool:SetAttribute("EquipSlot", "Weapon")
296
+ tool.Parent = character
297
+ end
298
+ end
299
+ elseif equipSlot == "Helmet" or equipSlot == "Armor" or equipSlot == "Boots" then
300
+ -- Handle Accessories attached to the Humanoid
301
+ local humanoid = character:FindFirstChildOfClass("Humanoid")
302
+ if not humanoid then return end
303
+
304
+ -- Remove old accessory for this slot
305
+ for _, acc in character:GetChildren() do
306
+ if acc:IsA("Accessory") and acc:GetAttribute("EquipSlot") == equipSlot then
307
+ acc:Destroy()
308
+ end
309
+ end
310
+ -- Add new accessory
311
+ if item then
312
+ local def = getItemDef(item.itemId)
313
+ local accTemplate = ServerStorage.Accessories:FindFirstChild(def.Name)
314
+ if accTemplate then
315
+ local acc = accTemplate:Clone()
316
+ acc:SetAttribute("EquipSlot", equipSlot)
317
+ humanoid:AddAccessory(acc)
318
+ end
319
+ end
320
+ end
321
+ end
322
+ ```
323
+
324
+ ---
325
+
326
+ ## 5. Loot / Drop Systems
327
+
328
+ ### Rarity Weights
329
+
330
+ Standard rarity tiers with drop weights:
331
+
332
+ | Rarity | Weight | Approximate Chance |
333
+ |-----------|--------|--------------------|
334
+ | Common | 60 | 60% |
335
+ | Uncommon | 25 | 25% |
336
+ | Rare | 10 | 10% |
337
+ | Epic | 4 | 4% |
338
+ | Legendary | 1 | 1% |
339
+
340
+ ### Drop Tables
341
+
342
+ Each enemy or chest defines a drop table listing which items can drop and at what rarity:
343
+
344
+ ```luau
345
+ local DropTables = {
346
+ Goblin = {
347
+ { itemId = 1001, rarity = "Common" },
348
+ { itemId = 1002, rarity = "Common" },
349
+ { itemId = 1003, rarity = "Uncommon" },
350
+ { itemId = 2001, rarity = "Legendary" },
351
+ },
352
+ TreasureChest = {
353
+ { itemId = 1003, rarity = "Uncommon" },
354
+ { itemId = 1004, rarity = "Rare" },
355
+ { itemId = 2001, rarity = "Epic" },
356
+ { itemId = 2002, rarity = "Legendary" },
357
+ },
358
+ }
359
+ ```
360
+
361
+ ### Weighted Random Selection Algorithm
362
+
363
+ ```luau
364
+ local RARITY_WEIGHTS: { [string]: number } = {
365
+ Common = 60,
366
+ Uncommon = 25,
367
+ Rare = 10,
368
+ Epic = 4,
369
+ Legendary = 1,
370
+ }
371
+
372
+ local function weightedRandomPick(dropTable: { { itemId: number, rarity: string } }): number?
373
+ -- Build weighted entries
374
+ local entries = {}
375
+ local totalWeight = 0
376
+ for _, entry in dropTable do
377
+ local weight = RARITY_WEIGHTS[entry.rarity] or 0
378
+ if weight > 0 then
379
+ totalWeight += weight
380
+ table.insert(entries, { itemId = entry.itemId, weight = weight })
381
+ end
382
+ end
383
+
384
+ if totalWeight == 0 then
385
+ return nil
386
+ end
387
+
388
+ -- Roll
389
+ local roll = math.random() * totalWeight
390
+ local cumulative = 0
391
+ for _, entry in entries do
392
+ cumulative += entry.weight
393
+ if roll <= cumulative then
394
+ return entry.itemId
395
+ end
396
+ end
397
+
398
+ -- Fallback (should not reach here)
399
+ return entries[#entries].itemId
400
+ end
401
+ ```
402
+
403
+ ### Pity System (Guaranteed Drop After N Attempts)
404
+
405
+ Prevents extreme bad luck by guaranteeing a rare+ drop after a threshold of attempts with no rare+ drop.
406
+
407
+ ```luau
408
+ local PITY_THRESHOLD = 50 -- guarantee rare+ after 50 kills with none
409
+
410
+ local pityCounters: { [number]: number } = {} -- [playerId] = count since last rare+
411
+
412
+ local function rollWithPity(player: Player, dropTable): number?
413
+ local userId = player.UserId
414
+ pityCounters[userId] = pityCounters[userId] or 0
415
+
416
+ local itemId = weightedRandomPick(dropTable)
417
+ if not itemId then return nil end
418
+
419
+ local def = getItemDef(itemId)
420
+ local isRarePlus = def and (
421
+ def.Rarity == "Rare" or def.Rarity == "Epic" or def.Rarity == "Legendary"
422
+ )
423
+
424
+ if isRarePlus then
425
+ pityCounters[userId] = 0
426
+ return itemId
427
+ end
428
+
429
+ pityCounters[userId] += 1
430
+
431
+ if pityCounters[userId] >= PITY_THRESHOLD then
432
+ -- Force a rare+ drop
433
+ local rareItems = {}
434
+ for _, entry in dropTable do
435
+ local r = entry.rarity
436
+ if r == "Rare" or r == "Epic" or r == "Legendary" then
437
+ table.insert(rareItems, entry.itemId)
438
+ end
439
+ end
440
+ if #rareItems > 0 then
441
+ pityCounters[userId] = 0
442
+ return rareItems[math.random(1, #rareItems)]
443
+ end
444
+ end
445
+
446
+ return itemId
447
+ end
448
+ ```
449
+
450
+ ---
451
+
452
+ ## 6. Trading System
453
+
454
+ ### Trade Flow
455
+
456
+ 1. **Player A sends trade request** to Player B (via RemoteEvent).
457
+ 2. **Player B accepts** the request, opening the trade window for both.
458
+ 3. **Both players add/remove items** to their offer. Each change is sent to the server and validated.
459
+ 4. **Both players confirm** ("Ready" / "Lock in").
460
+ 5. **Server validates** both players still own all offered items, both have inventory space for incoming items, and neither set of items has changed since confirmation.
461
+ 6. **Atomic swap** -- server removes all offered items from both players and grants the received items. If any step fails, the entire trade is rolled back.
462
+ 7. **Trade logged** with timestamp, both player IDs, and item details for dispute resolution.
463
+
464
+ ### Trade Logging
465
+
466
+ ```luau
467
+ local function logTrade(
468
+ playerA: Player,
469
+ playerB: Player,
470
+ itemsFromA: { any },
471
+ itemsFromB: { any }
472
+ )
473
+ local entry = {
474
+ timestamp = os.time(),
475
+ playerA = { userId = playerA.UserId, name = playerA.Name },
476
+ playerB = { userId = playerB.UserId, name = playerB.Name },
477
+ fromA = itemsFromA,
478
+ fromB = itemsFromB,
479
+ }
480
+ -- Persist to a DataStore or external logging service
481
+ local success, err = pcall(function()
482
+ local TradeLogStore = DataStoreService:GetDataStore("TradeLogs")
483
+ local key = `trade_{entry.timestamp}_{playerA.UserId}_{playerB.UserId}`
484
+ TradeLogStore:SetAsync(key, entry)
485
+ end)
486
+ if not success then
487
+ warn("[TradeLog] Failed to log trade:", err)
488
+ end
489
+ end
490
+ ```
491
+
492
+ ### Code Example -- Trade Execution
493
+
494
+ ```luau
495
+ function InventoryManager.executeTrade(
496
+ playerA: Player,
497
+ playerB: Player,
498
+ offerA: { { backpackSlot: number } },
499
+ offerB: { { backpackSlot: number } }
500
+ ): (boolean, string?)
501
+ local dataA = InventoryManager._getPlayerData(playerA)
502
+ local dataB = InventoryManager._getPlayerData(playerB)
503
+ if not dataA or not dataB then
504
+ return false, "Player data not found"
505
+ end
506
+
507
+ -- 1. Validate all offered items still exist
508
+ local itemsFromA = {}
509
+ for _, offer in offerA do
510
+ local item = dataA.backpack[offer.backpackSlot]
511
+ if not item then
512
+ return false, "Player A missing offered item"
513
+ end
514
+ table.insert(itemsFromA, { slot = offer.backpackSlot, item = item })
515
+ end
516
+
517
+ local itemsFromB = {}
518
+ for _, offer in offerB do
519
+ local item = dataB.backpack[offer.backpackSlot]
520
+ if not item then
521
+ return false, "Player B missing offered item"
522
+ end
523
+ table.insert(itemsFromB, { slot = offer.backpackSlot, item = item })
524
+ end
525
+
526
+ -- 2. Validate both have space for incoming items
527
+ local freeA = 0
528
+ local freeB = 0
529
+ for i = 1, MAX_BACKPACK_SLOTS do
530
+ if dataA.backpack[i] == nil then freeA += 1 end
531
+ if dataB.backpack[i] == nil then freeB += 1 end
532
+ end
533
+
534
+ -- After removing offered items, they gain slots back
535
+ local netSpaceA = freeA + #itemsFromA - #itemsFromB
536
+ local netSpaceB = freeB + #itemsFromB - #itemsFromA
537
+ if netSpaceA < 0 then
538
+ return false, "Player A does not have enough inventory space"
539
+ end
540
+ if netSpaceB < 0 then
541
+ return false, "Player B does not have enough inventory space"
542
+ end
543
+
544
+ -- 3. Atomic swap -- remove all first, then grant all
545
+ -- Remove from A
546
+ for _, entry in itemsFromA do
547
+ dataA.backpack[entry.slot] = nil
548
+ end
549
+ -- Remove from B
550
+ for _, entry in itemsFromB do
551
+ dataB.backpack[entry.slot] = nil
552
+ end
553
+
554
+ -- Grant B's items to A
555
+ for _, entry in itemsFromB do
556
+ local emptySlot = findEmptyBackpackSlot(dataA)
557
+ if not emptySlot then
558
+ -- Rollback: this should not happen given the space check above
559
+ warn("[Trade] Critical: space check passed but no slot found. Rolling back.")
560
+ -- Restore all items (rollback logic)
561
+ for _, e in itemsFromA do dataA.backpack[e.slot] = e.item end
562
+ for _, e in itemsFromB do dataB.backpack[e.slot] = e.item end
563
+ return false, "Trade failed unexpectedly"
564
+ end
565
+ dataA.backpack[emptySlot] = entry.item
566
+ end
567
+
568
+ -- Grant A's items to B
569
+ for _, entry in itemsFromA do
570
+ local emptySlot = findEmptyBackpackSlot(dataB)
571
+ if not emptySlot then
572
+ warn("[Trade] Critical: space check passed but no slot found. Rolling back.")
573
+ for _, e in itemsFromA do dataA.backpack[e.slot] = e.item end
574
+ for _, e in itemsFromB do dataB.backpack[e.slot] = e.item end
575
+ return false, "Trade failed unexpectedly"
576
+ end
577
+ dataB.backpack[emptySlot] = entry.item
578
+ end
579
+
580
+ -- 4. Log the trade
581
+ logTrade(playerA, playerB, itemsFromA, itemsFromB)
582
+
583
+ return true, nil
584
+ end
585
+ ```
586
+
587
+ ---
588
+
589
+ ## 7. Shop / Store System
590
+
591
+ ### NPC Shop Definition
592
+
593
+ ```luau
594
+ local ShopDefinitions = {
595
+ WeaponSmith = {
596
+ name = "Grog's Weapons",
597
+ items = {
598
+ { itemId = 1001, buyPrice = 100, sellPrice = 40 },
599
+ { itemId = 1005, buyPrice = 500, sellPrice = 200 },
600
+ { itemId = 1006, buyPrice = 2000, sellPrice = 800 },
601
+ },
602
+ },
603
+ Alchemist = {
604
+ name = "Elara's Potions",
605
+ items = {
606
+ { itemId = 1002, buyPrice = 25, sellPrice = 10 },
607
+ { itemId = 1007, buyPrice = 75, sellPrice = 30 },
608
+ },
609
+ },
610
+ }
611
+ ```
612
+
613
+ ### Purchase Flow (Atomic)
614
+
615
+ 1. Validate the shop and item exist.
616
+ 2. Validate the player has enough currency.
617
+ 3. Validate the player has inventory space.
618
+ 4. Deduct currency.
619
+ 5. Grant item.
620
+
621
+ If any step fails, nothing changes. Steps 4 and 5 must both succeed or both be reverted.
622
+
623
+ ```luau
624
+ function InventoryManager.buyFromShop(
625
+ player: Player,
626
+ shopId: string,
627
+ itemIndex: number,
628
+ quantity: number
629
+ ): (boolean, string?)
630
+ local shop = ShopDefinitions[shopId]
631
+ if not shop then
632
+ return false, "Shop not found"
633
+ end
634
+
635
+ local listing = shop.items[itemIndex]
636
+ if not listing then
637
+ return false, "Item not in shop"
638
+ end
639
+
640
+ local totalCost = listing.buyPrice * quantity
641
+ local playerData = InventoryManager._getPlayerData(player)
642
+ if not playerData then
643
+ return false, "Player data not found"
644
+ end
645
+
646
+ -- Validate currency
647
+ if playerData.currency < totalCost then
648
+ return false, "Not enough currency"
649
+ end
650
+
651
+ -- Validate space / stackability
652
+ local def = getItemDef(listing.itemId)
653
+ if not def then
654
+ return false, "Unknown item"
655
+ end
656
+
657
+ if def.Stackable then
658
+ -- Find existing stack or need a new slot
659
+ local existingSlot = nil
660
+ for slot, item in playerData.backpack do
661
+ if item.itemId == listing.itemId then
662
+ if item.quantity + quantity <= def.MaxStack then
663
+ existingSlot = slot
664
+ break
665
+ end
666
+ end
667
+ end
668
+ if existingSlot then
669
+ playerData.currency -= totalCost
670
+ playerData.backpack[existingSlot].quantity += quantity
671
+ return true, nil
672
+ end
673
+ end
674
+
675
+ -- Need a new slot (non-stackable or no existing stack with room)
676
+ local emptySlot = findEmptyBackpackSlot(playerData)
677
+ if not emptySlot then
678
+ return false, "Inventory full"
679
+ end
680
+
681
+ -- Commit (atomic: currency deduction + item grant)
682
+ playerData.currency -= totalCost
683
+ playerData.backpack[emptySlot] = {
684
+ itemId = listing.itemId,
685
+ quantity = quantity,
686
+ metadata = {},
687
+ }
688
+
689
+ return true, nil
690
+ end
691
+ ```
692
+
693
+ ### Sell Flow
694
+
695
+ ```luau
696
+ function InventoryManager.sellToShop(
697
+ player: Player,
698
+ shopId: string,
699
+ backpackSlot: number,
700
+ quantity: number
701
+ ): (boolean, string?)
702
+ local shop = ShopDefinitions[shopId]
703
+ if not shop then
704
+ return false, "Shop not found"
705
+ end
706
+
707
+ local playerData = InventoryManager._getPlayerData(player)
708
+ if not playerData then
709
+ return false, "Player data not found"
710
+ end
711
+
712
+ local item = playerData.backpack[backpackSlot]
713
+ if not item then
714
+ return false, "No item in that slot"
715
+ end
716
+
717
+ -- Find sell price from shop listing
718
+ local sellPrice = nil
719
+ for _, listing in shop.items do
720
+ if listing.itemId == item.itemId then
721
+ sellPrice = listing.sellPrice
722
+ break
723
+ end
724
+ end
725
+ if not sellPrice then
726
+ return false, "This shop does not buy that item"
727
+ end
728
+
729
+ if item.quantity < quantity then
730
+ return false, "Not enough of that item"
731
+ end
732
+
733
+ -- Commit
734
+ local totalValue = sellPrice * quantity
735
+ item.quantity -= quantity
736
+ if item.quantity <= 0 then
737
+ playerData.backpack[backpackSlot] = nil
738
+ end
739
+ playerData.currency += totalValue
740
+
741
+ return true, nil
742
+ end
743
+ ```
744
+
745
+ ---
746
+
747
+ ## 8. DataStore Integration
748
+
749
+ ### Serialization
750
+
751
+ Inventory data must be serialized into DataStore-safe format. Rules:
752
+
753
+ - No Instance references (no `tool`, `accessory`, or any Roblox object).
754
+ - Convert everything to plain tables of numbers, strings, and booleans.
755
+ - Prefer item ID references over storing full definition data. Definitions live in code; only instance data goes to the DataStore.
756
+
757
+ ```luau
758
+ local function serializeInventory(playerData): { [string]: any }
759
+ local serialized = {
760
+ currency = playerData.currency,
761
+ backpack = {},
762
+ equipment = {},
763
+ }
764
+
765
+ for slot, item in playerData.backpack do
766
+ serialized.backpack[tostring(slot)] = {
767
+ itemId = item.itemId,
768
+ qty = item.quantity,
769
+ meta = item.metadata or {},
770
+ }
771
+ end
772
+
773
+ for slotName, item in playerData.equipment do
774
+ if item then
775
+ serialized.equipment[slotName] = {
776
+ itemId = item.itemId,
777
+ qty = item.quantity,
778
+ meta = item.metadata or {},
779
+ }
780
+ end
781
+ end
782
+
783
+ return serialized
784
+ end
785
+
786
+ local function deserializeInventory(saved: { [string]: any }): { [string]: any }
787
+ local playerData = {
788
+ currency = saved.currency or 0,
789
+ backpack = {},
790
+ equipment = {
791
+ Weapon = nil,
792
+ Helmet = nil,
793
+ Armor = nil,
794
+ Boots = nil,
795
+ Accessory1 = nil,
796
+ Accessory2 = nil,
797
+ },
798
+ }
799
+
800
+ if saved.backpack then
801
+ for slotStr, data in saved.backpack do
802
+ local slot = tonumber(slotStr)
803
+ if slot then
804
+ playerData.backpack[slot] = {
805
+ itemId = data.itemId,
806
+ quantity = data.qty or 1,
807
+ metadata = data.meta or {},
808
+ }
809
+ end
810
+ end
811
+ end
812
+
813
+ if saved.equipment then
814
+ for slotName, data in saved.equipment do
815
+ playerData.equipment[slotName] = {
816
+ itemId = data.itemId,
817
+ quantity = data.qty or 1,
818
+ metadata = data.meta or {},
819
+ }
820
+ end
821
+ end
822
+
823
+ return playerData
824
+ end
825
+ ```
826
+
827
+ ### Handling Large Inventories
828
+
829
+ - **DataStore limit**: 4MB per key (4,194,304 bytes).
830
+ - Each item slot serializes to roughly 50-200 bytes depending on metadata. A 500-slot inventory with moderate metadata is well under 1MB.
831
+ - If inventories grow very large, split across multiple DataStore keys (e.g., `inv_backpack`, `inv_equipment`, `inv_overflow`).
832
+ - Use `HttpService:JSONEncode()` to estimate payload size before saving:
833
+
834
+ ```luau
835
+ local HttpService = game:GetService("HttpService")
836
+
837
+ local function estimateSize(data: any): number
838
+ local json = HttpService:JSONEncode(data)
839
+ return #json
840
+ end
841
+ ```
842
+
843
+ ### Save and Load
844
+
845
+ ```luau
846
+ local DataStoreService = game:GetService("DataStoreService")
847
+ local InventoryStore = DataStoreService:GetDataStore("PlayerInventory_v1")
848
+
849
+ local function savePlayerInventory(player: Player)
850
+ local playerData = InventoryManager._getPlayerData(player)
851
+ if not playerData then return end
852
+
853
+ local serialized = serializeInventory(playerData)
854
+ local key = `player_{player.UserId}`
855
+
856
+ local success, err = pcall(function()
857
+ InventoryStore:SetAsync(key, serialized)
858
+ end)
859
+
860
+ if not success then
861
+ warn(`[Inventory] Failed to save for {player.Name}: {err}`)
862
+ end
863
+ end
864
+
865
+ local function loadPlayerInventory(player: Player): { [string]: any }
866
+ local key = `player_{player.UserId}`
867
+ local success, saved = pcall(function()
868
+ return InventoryStore:GetAsync(key)
869
+ end)
870
+
871
+ if success and saved then
872
+ return deserializeInventory(saved)
873
+ else
874
+ -- Return default new-player inventory
875
+ return {
876
+ currency = 0,
877
+ backpack = {},
878
+ equipment = {
879
+ Weapon = nil,
880
+ Helmet = nil,
881
+ Armor = nil,
882
+ Boots = nil,
883
+ Accessory1 = nil,
884
+ Accessory2 = nil,
885
+ },
886
+ }
887
+ end
888
+ end
889
+ ```
890
+
891
+ ---
892
+
893
+ ## 9. Best Practices
894
+
895
+ - **Server owns all inventory state.** The client renders what the server tells it. Every add, remove, equip, trade, and purchase is a server operation.
896
+ - **Validate every operation.** Check ownership, quantities, capacity, and item existence before every mutation. Never trust client-supplied item data.
897
+ - **Log item transactions.** Record every significant inventory change (trades, purchases, loot drops, item deletions) with timestamps and player IDs. This is essential for player support and detecting exploits.
898
+ - **Handle edge cases explicitly:**
899
+ - Full inventory on loot: notify the player, use overflow storage, or drop on ground.
900
+ - Disconnect during trade: cancel the trade, return all items. Never leave items in limbo.
901
+ - Disconnect during save: use `PlayerRemoving` plus `game:BindToClose()` to ensure saves complete.
902
+ - Duplicate item IDs: use UUIDs for non-stackable items so copies are distinguishable.
903
+ - **Item versioning for balance changes.** When you change item stats (nerf/buff), update `ItemDefinitions` in code. Since instances only store `itemId`, all players automatically see new stats on next session. If you need to preserve old versions, add a `version` field to instance metadata.
904
+ - **Use `UpdateAsync` over `SetAsync` for saves when data contention is possible.** `UpdateAsync` provides atomic read-modify-write to avoid overwriting concurrent changes.
905
+ - **Debounce saves.** Do not save on every inventory change. Save periodically (every 60-120 seconds) and on `PlayerRemoving`.
906
+
907
+ ---
908
+
909
+ ## 10. Anti-Patterns
910
+
911
+ **Client-side inventory (exploitable):**
912
+ Never store the "real" inventory on the client. An exploiter can modify LocalScripts and RemoteEvents to give themselves any item. The client should only have a *read-only mirror* for UI rendering.
913
+
914
+ **Trusting client item data:**
915
+ Never accept full item definitions from the client (e.g., "I have an item with 9999 Attack"). The client sends an action ("equip slot 3") and the server looks up what is actually in slot 3.
916
+
917
+ **Not validating trades server-side:**
918
+ If the server does not verify both players own the items they are offering at the moment of execution, a player can offer an item, then drop it, and still "trade" it -- duplicating the item.
919
+
920
+ **No overflow handling:**
921
+ If loot is granted without checking capacity, items either vanish silently (data loss) or the system errors. Always check before granting, and handle the full case gracefully.
922
+
923
+ **Storing full item definitions in DataStore:**
924
+ Storing name, description, stats, and icon per instance wastes space and causes data drift when definitions change. Store only `itemId` and instance-specific metadata.
925
+
926
+ **Using string keys for backpack slots in runtime:**
927
+ Keep numeric keys at runtime for fast iteration. Convert to string keys only during serialization (DataStore requires string keys for dictionary-style tables).
928
+
929
+ **No save retry / no BindToClose:**
930
+ If the save on `PlayerRemoving` fails and there is no `BindToClose` fallback, data is lost when the server shuts down.
931
+
932
+ ---
933
+
934
+ ## 11. Complete InventoryManager Module
935
+
936
+ A full, self-contained `InventoryManager` module combining all systems above.
937
+
938
+ ```luau
939
+ -- ServerScriptService/Modules/InventoryManager.luau
940
+ local DataStoreService = game:GetService("DataStoreService")
941
+ local HttpService = game:GetService("HttpService")
942
+ local Players = game:GetService("Players")
943
+ local ServerStorage = game:GetService("ServerStorage")
944
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
945
+
946
+ local ItemDefinitions = require(ReplicatedStorage.Shared.ItemDefinitions)
947
+
948
+ local InventoryStore = DataStoreService:GetDataStore("PlayerInventory_v1")
949
+
950
+ --------------------------------------------------------------------------------
951
+ -- Constants
952
+ --------------------------------------------------------------------------------
953
+
954
+ local MAX_BACKPACK_SLOTS = 30
955
+
956
+ local EQUIP_SLOTS = { "Weapon", "Helmet", "Armor", "Boots", "Accessory1", "Accessory2" }
957
+
958
+ local CATEGORY_TO_SLOT: { [string]: { string } } = {
959
+ Weapon = { "Weapon" },
960
+ Helmet = { "Helmet" },
961
+ Armor = { "Armor" },
962
+ Boots = { "Boots" },
963
+ Accessory = { "Accessory1", "Accessory2" },
964
+ }
965
+
966
+ local RARITY_WEIGHTS: { [string]: number } = {
967
+ Common = 60,
968
+ Uncommon = 25,
969
+ Rare = 10,
970
+ Epic = 4,
971
+ Legendary = 1,
972
+ }
973
+
974
+ local PITY_THRESHOLD = 50
975
+
976
+ --------------------------------------------------------------------------------
977
+ -- Module
978
+ --------------------------------------------------------------------------------
979
+
980
+ local InventoryManager = {}
981
+
982
+ -- Private state: [userId] = playerData
983
+ local playerDataCache: { [number]: any } = {}
984
+ local pityCounters: { [number]: number } = {}
985
+
986
+ --------------------------------------------------------------------------------
987
+ -- Internal Helpers
988
+ --------------------------------------------------------------------------------
989
+
990
+ local function getItemDef(itemId: number)
991
+ return ItemDefinitions[itemId]
992
+ end
993
+
994
+ function InventoryManager._getPlayerData(player: Player)
995
+ return playerDataCache[player.UserId]
996
+ end
997
+
998
+ local function findEmptyBackpackSlot(playerData): number?
999
+ for i = 1, MAX_BACKPACK_SLOTS do
1000
+ if playerData.backpack[i] == nil then
1001
+ return i
1002
+ end
1003
+ end
1004
+ return nil
1005
+ end
1006
+
1007
+ local function findStackableSlot(playerData, itemId: number, addQty: number): number?
1008
+ local def = getItemDef(itemId)
1009
+ if not def or not def.Stackable then
1010
+ return nil
1011
+ end
1012
+ for slot, item in playerData.backpack do
1013
+ if item.itemId == itemId and item.quantity + addQty <= def.MaxStack then
1014
+ return slot
1015
+ end
1016
+ end
1017
+ return nil
1018
+ end
1019
+
1020
+ local function generateUUID(): string
1021
+ return HttpService:GenerateGUID(false)
1022
+ end
1023
+
1024
+ --------------------------------------------------------------------------------
1025
+ -- Serialization
1026
+ --------------------------------------------------------------------------------
1027
+
1028
+ local function serializeInventory(playerData): { [string]: any }
1029
+ local serialized = {
1030
+ currency = playerData.currency,
1031
+ backpack = {},
1032
+ equipment = {},
1033
+ }
1034
+ for slot, item in playerData.backpack do
1035
+ serialized.backpack[tostring(slot)] = {
1036
+ itemId = item.itemId,
1037
+ qty = item.quantity,
1038
+ meta = item.metadata or {},
1039
+ }
1040
+ end
1041
+ for slotName, item in playerData.equipment do
1042
+ if item then
1043
+ serialized.equipment[slotName] = {
1044
+ itemId = item.itemId,
1045
+ qty = item.quantity,
1046
+ meta = item.metadata or {},
1047
+ }
1048
+ end
1049
+ end
1050
+ return serialized
1051
+ end
1052
+
1053
+ local function deserializeInventory(saved: { [string]: any }?): { [string]: any }
1054
+ if not saved then
1055
+ return {
1056
+ currency = 0,
1057
+ backpack = {},
1058
+ equipment = {
1059
+ Weapon = nil, Helmet = nil, Armor = nil,
1060
+ Boots = nil, Accessory1 = nil, Accessory2 = nil,
1061
+ },
1062
+ }
1063
+ end
1064
+
1065
+ local playerData = {
1066
+ currency = saved.currency or 0,
1067
+ backpack = {},
1068
+ equipment = {
1069
+ Weapon = nil, Helmet = nil, Armor = nil,
1070
+ Boots = nil, Accessory1 = nil, Accessory2 = nil,
1071
+ },
1072
+ }
1073
+
1074
+ if saved.backpack then
1075
+ for slotStr, data in saved.backpack do
1076
+ local slot = tonumber(slotStr)
1077
+ if slot then
1078
+ playerData.backpack[slot] = {
1079
+ itemId = data.itemId,
1080
+ quantity = data.qty or 1,
1081
+ metadata = data.meta or {},
1082
+ }
1083
+ end
1084
+ end
1085
+ end
1086
+
1087
+ if saved.equipment then
1088
+ for slotName, data in saved.equipment do
1089
+ playerData.equipment[slotName] = {
1090
+ itemId = data.itemId,
1091
+ quantity = data.qty or 1,
1092
+ metadata = data.meta or {},
1093
+ }
1094
+ end
1095
+ end
1096
+
1097
+ return playerData
1098
+ end
1099
+
1100
+ --------------------------------------------------------------------------------
1101
+ -- Save / Load
1102
+ --------------------------------------------------------------------------------
1103
+
1104
+ function InventoryManager.save(player: Player)
1105
+ local playerData = playerDataCache[player.UserId]
1106
+ if not playerData then return end
1107
+
1108
+ local serialized = serializeInventory(playerData)
1109
+ local key = `player_{player.UserId}`
1110
+
1111
+ local success, err = pcall(function()
1112
+ InventoryStore:SetAsync(key, serialized)
1113
+ end)
1114
+ if not success then
1115
+ warn(`[InventoryManager] Save failed for {player.Name}: {err}`)
1116
+ end
1117
+ end
1118
+
1119
+ function InventoryManager.load(player: Player)
1120
+ local key = `player_{player.UserId}`
1121
+ local success, saved = pcall(function()
1122
+ return InventoryStore:GetAsync(key)
1123
+ end)
1124
+
1125
+ local playerData
1126
+ if success then
1127
+ playerData = deserializeInventory(saved)
1128
+ else
1129
+ warn(`[InventoryManager] Load failed for {player.Name}, using defaults`)
1130
+ playerData = deserializeInventory(nil)
1131
+ end
1132
+
1133
+ playerDataCache[player.UserId] = playerData
1134
+ pityCounters[player.UserId] = 0
1135
+ end
1136
+
1137
+ function InventoryManager.unload(player: Player)
1138
+ InventoryManager.save(player)
1139
+ playerDataCache[player.UserId] = nil
1140
+ pityCounters[player.UserId] = nil
1141
+ end
1142
+
1143
+ --------------------------------------------------------------------------------
1144
+ -- Add / Remove Items
1145
+ --------------------------------------------------------------------------------
1146
+
1147
+ function InventoryManager.addItem(
1148
+ player: Player,
1149
+ itemId: number,
1150
+ quantity: number?,
1151
+ metadata: { [string]: any }?
1152
+ ): (boolean, string?)
1153
+ local playerData = InventoryManager._getPlayerData(player)
1154
+ if not playerData then
1155
+ return false, "Player data not loaded"
1156
+ end
1157
+
1158
+ local def = getItemDef(itemId)
1159
+ if not def then
1160
+ return false, "Unknown item ID"
1161
+ end
1162
+
1163
+ local qty = quantity or 1
1164
+
1165
+ -- Try stacking first
1166
+ if def.Stackable then
1167
+ local stackSlot = findStackableSlot(playerData, itemId, qty)
1168
+ if stackSlot then
1169
+ playerData.backpack[stackSlot].quantity += qty
1170
+ return true, nil
1171
+ end
1172
+ end
1173
+
1174
+ -- Need a new slot
1175
+ local emptySlot = findEmptyBackpackSlot(playerData)
1176
+ if not emptySlot then
1177
+ return false, "Inventory full"
1178
+ end
1179
+
1180
+ local meta = metadata or {}
1181
+ if not def.Stackable and not meta.uuid then
1182
+ meta.uuid = generateUUID()
1183
+ end
1184
+
1185
+ playerData.backpack[emptySlot] = {
1186
+ itemId = itemId,
1187
+ quantity = qty,
1188
+ metadata = meta,
1189
+ }
1190
+
1191
+ return true, nil
1192
+ end
1193
+
1194
+ function InventoryManager.removeItem(
1195
+ player: Player,
1196
+ backpackSlot: number,
1197
+ quantity: number?
1198
+ ): (boolean, string?)
1199
+ local playerData = InventoryManager._getPlayerData(player)
1200
+ if not playerData then
1201
+ return false, "Player data not loaded"
1202
+ end
1203
+
1204
+ local item = playerData.backpack[backpackSlot]
1205
+ if not item then
1206
+ return false, "No item in that slot"
1207
+ end
1208
+
1209
+ local qty = quantity or item.quantity
1210
+ if item.quantity < qty then
1211
+ return false, "Not enough quantity"
1212
+ end
1213
+
1214
+ item.quantity -= qty
1215
+ if item.quantity <= 0 then
1216
+ playerData.backpack[backpackSlot] = nil
1217
+ end
1218
+
1219
+ return true, nil
1220
+ end
1221
+
1222
+ --------------------------------------------------------------------------------
1223
+ -- Equip / Unequip
1224
+ --------------------------------------------------------------------------------
1225
+
1226
+ function InventoryManager._recalculateStats(player: Player)
1227
+ local playerData = InventoryManager._getPlayerData(player)
1228
+ if not playerData then return end
1229
+
1230
+ local totalStats: { [string]: number } = {}
1231
+
1232
+ for _, slotName in EQUIP_SLOTS do
1233
+ local item = playerData.equipment[slotName]
1234
+ if item then
1235
+ local def = getItemDef(item.itemId)
1236
+ if def and def.Stats then
1237
+ for stat, value in def.Stats do
1238
+ totalStats[stat] = (totalStats[stat] or 0) + value
1239
+ end
1240
+ end
1241
+ end
1242
+ end
1243
+
1244
+ -- Apply stats to player (implementation depends on your stat system)
1245
+ -- Example: store on leaderstats or a custom Attributes system
1246
+ for stat, value in totalStats do
1247
+ player:SetAttribute(`EquipStat_{stat}`, value)
1248
+ end
1249
+
1250
+ playerData.calculatedStats = totalStats
1251
+ end
1252
+
1253
+ function InventoryManager._updateVisuals(player: Player, equipSlot: string, item: any?)
1254
+ local character = player.Character
1255
+ if not character then return end
1256
+
1257
+ if equipSlot == "Weapon" then
1258
+ for _, tool in character:GetChildren() do
1259
+ if tool:IsA("Tool") and tool:GetAttribute("EquipSlot") == "Weapon" then
1260
+ tool:Destroy()
1261
+ end
1262
+ end
1263
+ if item then
1264
+ local def = getItemDef(item.itemId)
1265
+ if def then
1266
+ local toolTemplate = ServerStorage:FindFirstChild("Tools")
1267
+ and ServerStorage.Tools:FindFirstChild(def.Name)
1268
+ if toolTemplate then
1269
+ local tool = toolTemplate:Clone()
1270
+ tool:SetAttribute("EquipSlot", "Weapon")
1271
+ tool.Parent = character
1272
+ end
1273
+ end
1274
+ end
1275
+ else
1276
+ local humanoid = character:FindFirstChildOfClass("Humanoid")
1277
+ if not humanoid then return end
1278
+
1279
+ for _, acc in character:GetChildren() do
1280
+ if acc:IsA("Accessory") and acc:GetAttribute("EquipSlot") == equipSlot then
1281
+ acc:Destroy()
1282
+ end
1283
+ end
1284
+ if item then
1285
+ local def = getItemDef(item.itemId)
1286
+ if def then
1287
+ local accTemplate = ServerStorage:FindFirstChild("Accessories")
1288
+ and ServerStorage.Accessories:FindFirstChild(def.Name)
1289
+ if accTemplate then
1290
+ local acc = accTemplate:Clone()
1291
+ acc:SetAttribute("EquipSlot", equipSlot)
1292
+ humanoid:AddAccessory(acc)
1293
+ end
1294
+ end
1295
+ end
1296
+ end
1297
+ end
1298
+
1299
+ function InventoryManager.equip(
1300
+ player: Player,
1301
+ backpackSlot: number,
1302
+ equipSlot: string
1303
+ ): (boolean, string?)
1304
+ local playerData = InventoryManager._getPlayerData(player)
1305
+ if not playerData then
1306
+ return false, "Player data not loaded"
1307
+ end
1308
+
1309
+ if not table.find(EQUIP_SLOTS, equipSlot) then
1310
+ return false, "Invalid equip slot"
1311
+ end
1312
+
1313
+ local item = playerData.backpack[backpackSlot]
1314
+ if not item then
1315
+ return false, "No item in that backpack slot"
1316
+ end
1317
+
1318
+ local def = getItemDef(item.itemId)
1319
+ if not def then
1320
+ return false, "Unknown item"
1321
+ end
1322
+
1323
+ local validSlots = CATEGORY_TO_SLOT[def.Category]
1324
+ if not validSlots or not table.find(validSlots, equipSlot) then
1325
+ return false, "Item cannot go in that slot"
1326
+ end
1327
+
1328
+ -- Handle currently equipped item
1329
+ local currentEquip = playerData.equipment[equipSlot]
1330
+ if currentEquip then
1331
+ local emptySlot = findEmptyBackpackSlot(playerData)
1332
+ if not emptySlot then
1333
+ return false, "Inventory full, cannot unequip current item"
1334
+ end
1335
+ playerData.backpack[emptySlot] = currentEquip
1336
+ InventoryManager._updateVisuals(player, equipSlot, nil)
1337
+ end
1338
+
1339
+ playerData.equipment[equipSlot] = item
1340
+ playerData.backpack[backpackSlot] = nil
1341
+
1342
+ InventoryManager._recalculateStats(player)
1343
+ InventoryManager._updateVisuals(player, equipSlot, item)
1344
+
1345
+ return true, nil
1346
+ end
1347
+
1348
+ function InventoryManager.unequip(player: Player, equipSlot: string): (boolean, string?)
1349
+ local playerData = InventoryManager._getPlayerData(player)
1350
+ if not playerData then
1351
+ return false, "Player data not loaded"
1352
+ end
1353
+
1354
+ local item = playerData.equipment[equipSlot]
1355
+ if not item then
1356
+ return false, "Nothing equipped in that slot"
1357
+ end
1358
+
1359
+ local emptySlot = findEmptyBackpackSlot(playerData)
1360
+ if not emptySlot then
1361
+ return false, "Inventory full"
1362
+ end
1363
+
1364
+ playerData.backpack[emptySlot] = item
1365
+ playerData.equipment[equipSlot] = nil
1366
+
1367
+ InventoryManager._recalculateStats(player)
1368
+ InventoryManager._updateVisuals(player, equipSlot, nil)
1369
+
1370
+ return true, nil
1371
+ end
1372
+
1373
+ --------------------------------------------------------------------------------
1374
+ -- Loot / Drops
1375
+ --------------------------------------------------------------------------------
1376
+
1377
+ local function weightedRandomPick(dropTable: { { itemId: number, rarity: string } }): number?
1378
+ local entries = {}
1379
+ local totalWeight = 0
1380
+ for _, entry in dropTable do
1381
+ local weight = RARITY_WEIGHTS[entry.rarity] or 0
1382
+ if weight > 0 then
1383
+ totalWeight += weight
1384
+ table.insert(entries, { itemId = entry.itemId, weight = weight })
1385
+ end
1386
+ end
1387
+
1388
+ if totalWeight == 0 then
1389
+ return nil
1390
+ end
1391
+
1392
+ local roll = math.random() * totalWeight
1393
+ local cumulative = 0
1394
+ for _, entry in entries do
1395
+ cumulative += entry.weight
1396
+ if roll <= cumulative then
1397
+ return entry.itemId
1398
+ end
1399
+ end
1400
+
1401
+ return entries[#entries].itemId
1402
+ end
1403
+
1404
+ function InventoryManager.rollLoot(
1405
+ player: Player,
1406
+ dropTable: { { itemId: number, rarity: string } }
1407
+ ): (boolean, number?, string?)
1408
+ local userId = player.UserId
1409
+ pityCounters[userId] = pityCounters[userId] or 0
1410
+
1411
+ local itemId = weightedRandomPick(dropTable)
1412
+ if not itemId then
1413
+ return false, nil, "Empty drop table"
1414
+ end
1415
+
1416
+ local def = getItemDef(itemId)
1417
+ local isRarePlus = def and (
1418
+ def.Rarity == "Rare" or def.Rarity == "Epic" or def.Rarity == "Legendary"
1419
+ )
1420
+
1421
+ if isRarePlus then
1422
+ pityCounters[userId] = 0
1423
+ else
1424
+ pityCounters[userId] += 1
1425
+ if pityCounters[userId] >= PITY_THRESHOLD then
1426
+ local rareItems = {}
1427
+ for _, entry in dropTable do
1428
+ local r = entry.rarity
1429
+ if r == "Rare" or r == "Epic" or r == "Legendary" then
1430
+ table.insert(rareItems, entry.itemId)
1431
+ end
1432
+ end
1433
+ if #rareItems > 0 then
1434
+ pityCounters[userId] = 0
1435
+ itemId = rareItems[math.random(1, #rareItems)]
1436
+ end
1437
+ end
1438
+ end
1439
+
1440
+ local success, err = InventoryManager.addItem(player, itemId)
1441
+ if not success then
1442
+ return false, itemId, err
1443
+ end
1444
+
1445
+ return true, itemId, nil
1446
+ end
1447
+
1448
+ --------------------------------------------------------------------------------
1449
+ -- Trading
1450
+ --------------------------------------------------------------------------------
1451
+
1452
+ function InventoryManager.executeTrade(
1453
+ playerA: Player,
1454
+ playerB: Player,
1455
+ slotsFromA: { number },
1456
+ slotsFromB: { number }
1457
+ ): (boolean, string?)
1458
+ local dataA = InventoryManager._getPlayerData(playerA)
1459
+ local dataB = InventoryManager._getPlayerData(playerB)
1460
+ if not dataA or not dataB then
1461
+ return false, "Player data not found"
1462
+ end
1463
+
1464
+ -- Snapshot items for validation and rollback
1465
+ local itemsA = {}
1466
+ for _, slot in slotsFromA do
1467
+ local item = dataA.backpack[slot]
1468
+ if not item then
1469
+ return false, `Player A missing item in slot {slot}`
1470
+ end
1471
+ table.insert(itemsA, { slot = slot, item = item })
1472
+ end
1473
+
1474
+ local itemsB = {}
1475
+ for _, slot in slotsFromB do
1476
+ local item = dataB.backpack[slot]
1477
+ if not item then
1478
+ return false, `Player B missing item in slot {slot}`
1479
+ end
1480
+ table.insert(itemsB, { slot = slot, item = item })
1481
+ end
1482
+
1483
+ -- Space check
1484
+ local freeA, freeB = 0, 0
1485
+ for i = 1, MAX_BACKPACK_SLOTS do
1486
+ if dataA.backpack[i] == nil then freeA += 1 end
1487
+ if dataB.backpack[i] == nil then freeB += 1 end
1488
+ end
1489
+
1490
+ if (freeA + #itemsA - #itemsB) < 0 then
1491
+ return false, "Player A lacks inventory space"
1492
+ end
1493
+ if (freeB + #itemsB - #itemsA) < 0 then
1494
+ return false, "Player B lacks inventory space"
1495
+ end
1496
+
1497
+ -- Remove all offered items
1498
+ for _, entry in itemsA do
1499
+ dataA.backpack[entry.slot] = nil
1500
+ end
1501
+ for _, entry in itemsB do
1502
+ dataB.backpack[entry.slot] = nil
1503
+ end
1504
+
1505
+ -- Grant B's items to A
1506
+ for _, entry in itemsB do
1507
+ local emptySlot = findEmptyBackpackSlot(dataA)
1508
+ if not emptySlot then
1509
+ -- Rollback
1510
+ for _, e in itemsA do dataA.backpack[e.slot] = e.item end
1511
+ for _, e in itemsB do dataB.backpack[e.slot] = e.item end
1512
+ return false, "Trade failed: space error during swap"
1513
+ end
1514
+ dataA.backpack[emptySlot] = entry.item
1515
+ end
1516
+
1517
+ -- Grant A's items to B
1518
+ for _, entry in itemsA do
1519
+ local emptySlot = findEmptyBackpackSlot(dataB)
1520
+ if not emptySlot then
1521
+ -- Rollback
1522
+ for _, e in itemsA do dataA.backpack[e.slot] = e.item end
1523
+ for _, e in itemsB do dataB.backpack[e.slot] = e.item end
1524
+ return false, "Trade failed: space error during swap"
1525
+ end
1526
+ dataB.backpack[emptySlot] = entry.item
1527
+ end
1528
+
1529
+ -- Log trade
1530
+ pcall(function()
1531
+ local TradeLogStore = DataStoreService:GetDataStore("TradeLogs")
1532
+ local key = `trade_{os.time()}_{playerA.UserId}_{playerB.UserId}`
1533
+ TradeLogStore:SetAsync(key, {
1534
+ timestamp = os.time(),
1535
+ playerA = playerA.UserId,
1536
+ playerB = playerB.UserId,
1537
+ fromA = slotsFromA,
1538
+ fromB = slotsFromB,
1539
+ })
1540
+ end)
1541
+
1542
+ return true, nil
1543
+ end
1544
+
1545
+ --------------------------------------------------------------------------------
1546
+ -- Shop
1547
+ --------------------------------------------------------------------------------
1548
+
1549
+ local ShopDefinitions = {}
1550
+
1551
+ function InventoryManager.registerShop(shopId: string, shopData: any)
1552
+ ShopDefinitions[shopId] = shopData
1553
+ end
1554
+
1555
+ function InventoryManager.buyFromShop(
1556
+ player: Player,
1557
+ shopId: string,
1558
+ itemIndex: number,
1559
+ quantity: number?
1560
+ ): (boolean, string?)
1561
+ local shop = ShopDefinitions[shopId]
1562
+ if not shop then
1563
+ return false, "Shop not found"
1564
+ end
1565
+
1566
+ local listing = shop.items[itemIndex]
1567
+ if not listing then
1568
+ return false, "Item not in shop"
1569
+ end
1570
+
1571
+ local qty = quantity or 1
1572
+ local totalCost = listing.buyPrice * qty
1573
+ local playerData = InventoryManager._getPlayerData(player)
1574
+ if not playerData then
1575
+ return false, "Player data not loaded"
1576
+ end
1577
+
1578
+ if playerData.currency < totalCost then
1579
+ return false, "Not enough currency"
1580
+ end
1581
+
1582
+ local def = getItemDef(listing.itemId)
1583
+ if not def then
1584
+ return false, "Unknown item"
1585
+ end
1586
+
1587
+ -- Try stacking
1588
+ if def.Stackable then
1589
+ local stackSlot = findStackableSlot(playerData, listing.itemId, qty)
1590
+ if stackSlot then
1591
+ playerData.currency -= totalCost
1592
+ playerData.backpack[stackSlot].quantity += qty
1593
+ return true, nil
1594
+ end
1595
+ end
1596
+
1597
+ local emptySlot = findEmptyBackpackSlot(playerData)
1598
+ if not emptySlot then
1599
+ return false, "Inventory full"
1600
+ end
1601
+
1602
+ playerData.currency -= totalCost
1603
+ playerData.backpack[emptySlot] = {
1604
+ itemId = listing.itemId,
1605
+ quantity = qty,
1606
+ metadata = {},
1607
+ }
1608
+
1609
+ return true, nil
1610
+ end
1611
+
1612
+ function InventoryManager.sellToShop(
1613
+ player: Player,
1614
+ shopId: string,
1615
+ backpackSlot: number,
1616
+ quantity: number?
1617
+ ): (boolean, string?)
1618
+ local shop = ShopDefinitions[shopId]
1619
+ if not shop then
1620
+ return false, "Shop not found"
1621
+ end
1622
+
1623
+ local playerData = InventoryManager._getPlayerData(player)
1624
+ if not playerData then
1625
+ return false, "Player data not loaded"
1626
+ end
1627
+
1628
+ local item = playerData.backpack[backpackSlot]
1629
+ if not item then
1630
+ return false, "No item in that slot"
1631
+ end
1632
+
1633
+ local sellPrice = nil
1634
+ for _, listing in shop.items do
1635
+ if listing.itemId == item.itemId then
1636
+ sellPrice = listing.sellPrice
1637
+ break
1638
+ end
1639
+ end
1640
+ if not sellPrice then
1641
+ return false, "Shop does not buy that item"
1642
+ end
1643
+
1644
+ local qty = quantity or item.quantity
1645
+ if item.quantity < qty then
1646
+ return false, "Not enough quantity"
1647
+ end
1648
+
1649
+ local totalValue = sellPrice * qty
1650
+ item.quantity -= qty
1651
+ if item.quantity <= 0 then
1652
+ playerData.backpack[backpackSlot] = nil
1653
+ end
1654
+ playerData.currency += totalValue
1655
+
1656
+ return true, nil
1657
+ end
1658
+
1659
+ --------------------------------------------------------------------------------
1660
+ -- Lifecycle Hooks (call from a server Script)
1661
+ --------------------------------------------------------------------------------
1662
+
1663
+ function InventoryManager.init()
1664
+ Players.PlayerAdded:Connect(function(player)
1665
+ InventoryManager.load(player)
1666
+ end)
1667
+
1668
+ Players.PlayerRemoving:Connect(function(player)
1669
+ InventoryManager.unload(player)
1670
+ end)
1671
+
1672
+ game:BindToClose(function()
1673
+ for _, player in Players:GetPlayers() do
1674
+ InventoryManager.save(player)
1675
+ end
1676
+ end)
1677
+ end
1678
+
1679
+ return InventoryManager
1680
+ ```
1681
+
1682
+ ### Usage from a Server Script
1683
+
1684
+ ```luau
1685
+ -- ServerScriptService/InventoryServer.server.luau
1686
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
1687
+ local InventoryManager = require(script.Parent.Modules.InventoryManager)
1688
+
1689
+ InventoryManager.init()
1690
+
1691
+ -- Register shops
1692
+ InventoryManager.registerShop("WeaponSmith", {
1693
+ name = "Grog's Weapons",
1694
+ items = {
1695
+ { itemId = 1001, buyPrice = 100, sellPrice = 40 },
1696
+ },
1697
+ })
1698
+
1699
+ -- Wire up RemoteEvents
1700
+ local Remotes = ReplicatedStorage:WaitForChild("Remotes")
1701
+
1702
+ Remotes.EquipItem.OnServerEvent:Connect(function(player, backpackSlot, equipSlot)
1703
+ if typeof(backpackSlot) ~= "number" or typeof(equipSlot) ~= "string" then
1704
+ return
1705
+ end
1706
+ local success, err = InventoryManager.equip(player, backpackSlot, equipSlot)
1707
+ Remotes.EquipResult:FireClient(player, success, err)
1708
+ end)
1709
+
1710
+ Remotes.UnequipItem.OnServerEvent:Connect(function(player, equipSlot)
1711
+ if typeof(equipSlot) ~= "string" then return end
1712
+ local success, err = InventoryManager.unequip(player, equipSlot)
1713
+ Remotes.UnequipResult:FireClient(player, success, err)
1714
+ end)
1715
+
1716
+ Remotes.BuyItem.OnServerEvent:Connect(function(player, shopId, itemIndex, quantity)
1717
+ if typeof(shopId) ~= "string" or typeof(itemIndex) ~= "number" then return end
1718
+ local qty = if typeof(quantity) == "number" then quantity else 1
1719
+ local success, err = InventoryManager.buyFromShop(player, shopId, itemIndex, qty)
1720
+ Remotes.BuyResult:FireClient(player, success, err)
1721
+ end)
1722
+
1723
+ Remotes.SellItem.OnServerEvent:Connect(function(player, shopId, backpackSlot, quantity)
1724
+ if typeof(shopId) ~= "string" or typeof(backpackSlot) ~= "number" then return end
1725
+ local qty = if typeof(quantity) == "number" then quantity else 1
1726
+ local success, err = InventoryManager.sellToShop(player, shopId, backpackSlot, qty)
1727
+ Remotes.SellResult:FireClient(player, success, err)
1728
+ end)
1729
+ ```