roblox-opencode 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -122
- package/commands/setup-game.md +108 -108
- package/commands/sync-check.md +53 -53
- package/core/roblox-core.md +93 -93
- package/dist/server.js +189 -167
- package/package.json +35 -35
- package/skills/roblox-analytics/SKILL.md +277 -277
- package/skills/roblox-analytics/references/event-batcher.luau +75 -75
- package/skills/roblox-animation-vfx/SKILL.md +1325 -1325
- package/skills/roblox-architecture/SKILL.md +877 -863
- package/skills/roblox-architecture/references/combat-systems.md +1381 -1381
- package/skills/roblox-code-review/SKILL.md +686 -686
- package/skills/roblox-data/SKILL.md +889 -889
- package/skills/roblox-data/references/inventory-systems.md +1729 -1729
- package/skills/roblox-debug/SKILL.md +98 -98
- package/skills/roblox-gui/SKILL.md +1103 -1103
- package/skills/roblox-gui-fusion/SKILL.md +150 -150
- package/skills/roblox-gui-fusion/references/inventory.luau +427 -427
- package/skills/roblox-gui-fusion/references/settings-menu.luau +579 -579
- package/skills/roblox-gui-fusion/references/shop.luau +411 -411
- package/skills/roblox-luau-mastery/SKILL.md +1618 -1519
- package/skills/roblox-monetization/SKILL.md +1084 -1084
- package/skills/roblox-monetization/references/process-receipt.luau +131 -131
- package/skills/roblox-networking/SKILL.md +669 -669
- package/skills/roblox-networking/references/remote-validator.luau +193 -193
- package/skills/roblox-publish-checklist/SKILL.md +127 -127
- package/skills/roblox-runtime/SKILL.md +753 -753
- package/skills/roblox-sharp-edges/SKILL.md +294 -294
- package/skills/roblox-sync/SKILL.md +126 -126
- package/skills/roblox-testing/SKILL.md +943 -943
- package/skills/roblox-tooling/SKILL.md +149 -149
- package/vendor/LICENSES/ProfileStore-LICENSE +201 -201
- package/vendor/LICENSES/RbxUtil-LICENSE +7 -7
- package/vendor/LICENSES/promise-LICENSE +20 -20
- package/vendor/LICENSES/t-LICENSE +21 -21
- package/vendor/LICENSES/testez-LICENSE +200 -200
- package/vendor/README.md +83 -83
- package/vendor/fusion/Animation/ExternalTime.luau +83 -83
- package/vendor/fusion/Animation/Spring.luau +321 -321
- package/vendor/fusion/Animation/Stopwatch.luau +127 -127
- package/vendor/fusion/Animation/Tween.luau +187 -187
- package/vendor/fusion/Animation/getTweenDuration.luau +27 -27
- package/vendor/fusion/Animation/getTweenRatio.luau +47 -47
- package/vendor/fusion/Animation/lerpType.luau +163 -163
- package/vendor/fusion/Animation/packType.luau +99 -99
- package/vendor/fusion/Animation/springCoefficients.luau +80 -80
- package/vendor/fusion/Animation/unpackType.luau +102 -102
- package/vendor/fusion/Colour/Oklab.luau +70 -70
- package/vendor/fusion/Colour/sRGB.luau +54 -54
- package/vendor/fusion/External.luau +167 -167
- package/vendor/fusion/ExternalDebug.luau +69 -69
- package/vendor/fusion/Graph/Observer.luau +113 -113
- package/vendor/fusion/Graph/castToGraph.luau +28 -28
- package/vendor/fusion/Graph/change.luau +80 -80
- package/vendor/fusion/Graph/depend.luau +32 -32
- package/vendor/fusion/Graph/evaluate.luau +55 -55
- package/vendor/fusion/Instances/Attribute.luau +57 -57
- package/vendor/fusion/Instances/AttributeChange.luau +46 -46
- package/vendor/fusion/Instances/AttributeOut.luau +63 -63
- package/vendor/fusion/Instances/Child.luau +21 -21
- package/vendor/fusion/Instances/Children.luau +147 -147
- package/vendor/fusion/Instances/Hydrate.luau +32 -32
- package/vendor/fusion/Instances/New.luau +52 -52
- package/vendor/fusion/Instances/OnChange.luau +49 -49
- package/vendor/fusion/Instances/OnEvent.luau +53 -53
- package/vendor/fusion/Instances/Out.luau +69 -69
- package/vendor/fusion/Instances/applyInstanceProps.luau +148 -148
- package/vendor/fusion/Instances/defaultProps.luau +194 -194
- package/vendor/fusion/LICENSE +21 -21
- package/vendor/fusion/Logging/formatError.luau +48 -48
- package/vendor/fusion/Logging/messages.luau +51 -51
- package/vendor/fusion/Logging/parseError.luau +24 -24
- package/vendor/fusion/Memory/checkLifetime.luau +133 -133
- package/vendor/fusion/Memory/deriveScope.luau +23 -23
- package/vendor/fusion/Memory/deriveScopeImpl.luau +44 -44
- package/vendor/fusion/Memory/doCleanup.luau +78 -78
- package/vendor/fusion/Memory/innerScope.luau +33 -33
- package/vendor/fusion/Memory/legacyCleanup.luau +17 -17
- package/vendor/fusion/Memory/needsDestruction.luau +16 -16
- package/vendor/fusion/Memory/poisonScope.luau +33 -33
- package/vendor/fusion/Memory/scopePool.luau +54 -54
- package/vendor/fusion/Memory/scoped.luau +26 -26
- package/vendor/fusion/Memory/whichLivesLonger.luau +74 -74
- package/vendor/fusion/RobloxExternal.luau +97 -97
- package/vendor/fusion/State/Computed.luau +138 -138
- package/vendor/fusion/State/For/Disassembly.luau +210 -210
- package/vendor/fusion/State/For/ForTypes.luau +30 -30
- package/vendor/fusion/State/For/init.luau +109 -109
- package/vendor/fusion/State/ForKeys.luau +93 -93
- package/vendor/fusion/State/ForPairs.luau +96 -96
- package/vendor/fusion/State/ForValues.luau +93 -93
- package/vendor/fusion/State/Value.luau +87 -87
- package/vendor/fusion/State/castToState.luau +25 -25
- package/vendor/fusion/State/peek.luau +30 -30
- package/vendor/fusion/Types.luau +314 -314
- package/vendor/fusion/Utility/Contextual.luau +90 -90
- package/vendor/fusion/Utility/Safe.luau +22 -22
- package/vendor/fusion/Utility/isSimilar.luau +29 -29
- package/vendor/fusion/Utility/merge.luau +35 -35
- package/vendor/fusion/Utility/nameOf.luau +34 -34
- package/vendor/fusion/Utility/never.luau +13 -13
- package/vendor/fusion/Utility/nicknames.luau +10 -10
- package/vendor/fusion/Utility/xtypeof.luau +26 -26
- package/vendor/fusion/init.luau +82 -82
- package/vendor/profilestore/init.luau +2242 -2242
- package/vendor/promise/init.luau +1982 -1982
- package/vendor/rbxutil/buffer-util/Buffer.test.luau +25 -25
- package/vendor/rbxutil/buffer-util/BufferReader.luau +228 -228
- package/vendor/rbxutil/buffer-util/BufferWriter.luau +269 -269
- package/vendor/rbxutil/buffer-util/DataTypeBuffer.luau +223 -223
- package/vendor/rbxutil/buffer-util/Types.luau +60 -60
- package/vendor/rbxutil/buffer-util/index.d.ts +153 -153
- package/vendor/rbxutil/buffer-util/init.luau +41 -41
- package/vendor/rbxutil/buffer-util/package.json +16 -16
- package/vendor/rbxutil/buffer-util/wally.toml +9 -9
- package/vendor/rbxutil/comm/Client/ClientComm.luau +232 -232
- package/vendor/rbxutil/comm/Client/ClientRemoteProperty.luau +156 -156
- package/vendor/rbxutil/comm/Client/ClientRemoteSignal.luau +109 -109
- package/vendor/rbxutil/comm/Client/init.luau +135 -135
- package/vendor/rbxutil/comm/Server/RemoteProperty.luau +295 -295
- package/vendor/rbxutil/comm/Server/RemoteSignal.luau +211 -211
- package/vendor/rbxutil/comm/Server/ServerComm.luau +211 -211
- package/vendor/rbxutil/comm/Server/init.luau +140 -140
- package/vendor/rbxutil/comm/Types.luau +18 -18
- package/vendor/rbxutil/comm/Util.luau +27 -27
- package/vendor/rbxutil/comm/init.luau +35 -35
- package/vendor/rbxutil/comm/wally.toml +13 -13
- package/vendor/rbxutil/component/init.luau +759 -759
- package/vendor/rbxutil/component/init.test.luau +311 -311
- package/vendor/rbxutil/component/wally.toml +14 -14
- package/vendor/rbxutil/concur/init.luau +542 -542
- package/vendor/rbxutil/concur/init.test.luau +364 -364
- package/vendor/rbxutil/concur/wally.toml +8 -8
- package/vendor/rbxutil/enum-list/init.luau +101 -101
- package/vendor/rbxutil/enum-list/init.test.luau +91 -91
- package/vendor/rbxutil/enum-list/wally.toml +8 -8
- package/vendor/rbxutil/find/index.d.ts +20 -20
- package/vendor/rbxutil/find/init.luau +44 -44
- package/vendor/rbxutil/find/package.json +17 -17
- package/vendor/rbxutil/find/wally.toml +8 -8
- package/vendor/rbxutil/input/Gamepad.luau +559 -559
- package/vendor/rbxutil/input/Keyboard.luau +124 -124
- package/vendor/rbxutil/input/Mouse.luau +278 -278
- package/vendor/rbxutil/input/PreferredInput.luau +91 -91
- package/vendor/rbxutil/input/Touch.luau +120 -120
- package/vendor/rbxutil/input/init.luau +33 -33
- package/vendor/rbxutil/input/wally.toml +12 -12
- package/vendor/rbxutil/loader/index.d.ts +15 -15
- package/vendor/rbxutil/loader/init.luau +137 -137
- package/vendor/rbxutil/loader/wally.toml +8 -8
- package/vendor/rbxutil/log/index.d.ts +38 -38
- package/vendor/rbxutil/log/init.luau +746 -746
- package/vendor/rbxutil/log/wally.toml +8 -8
- package/vendor/rbxutil/net/init.luau +190 -190
- package/vendor/rbxutil/net/wally.toml +8 -8
- package/vendor/rbxutil/option/index.d.ts +44 -44
- package/vendor/rbxutil/option/init.luau +489 -489
- package/vendor/rbxutil/option/init.test.luau +342 -342
- package/vendor/rbxutil/option/wally.toml +8 -8
- package/vendor/rbxutil/pid/index.d.ts +53 -53
- package/vendor/rbxutil/pid/init.luau +195 -195
- package/vendor/rbxutil/pid/package.json +16 -16
- package/vendor/rbxutil/pid/wally.toml +9 -9
- package/vendor/rbxutil/quaternion/index.d.ts +117 -117
- package/vendor/rbxutil/quaternion/init.luau +570 -570
- package/vendor/rbxutil/quaternion/package.json +16 -16
- package/vendor/rbxutil/quaternion/wally.toml +9 -9
- package/vendor/rbxutil/query/index.d.ts +43 -43
- package/vendor/rbxutil/query/init.luau +117 -117
- package/vendor/rbxutil/query/package.json +18 -18
- package/vendor/rbxutil/query/wally.toml +9 -9
- package/vendor/rbxutil/sequent/index.d.ts +28 -28
- package/vendor/rbxutil/sequent/init.luau +340 -340
- package/vendor/rbxutil/sequent/package.json +16 -16
- package/vendor/rbxutil/sequent/wally.toml +9 -9
- package/vendor/rbxutil/ser/init.luau +175 -175
- package/vendor/rbxutil/ser/init.test.luau +50 -50
- package/vendor/rbxutil/ser/wally.toml +11 -11
- package/vendor/rbxutil/shake/index.d.ts +36 -36
- package/vendor/rbxutil/shake/init.luau +532 -532
- package/vendor/rbxutil/shake/init.test.luau +267 -267
- package/vendor/rbxutil/shake/package.json +16 -16
- package/vendor/rbxutil/shake/wally.toml +9 -9
- package/vendor/rbxutil/signal/index.d.ts +100 -100
- package/vendor/rbxutil/signal/init.luau +432 -432
- package/vendor/rbxutil/signal/init.test.luau +190 -190
- package/vendor/rbxutil/signal/package.json +17 -17
- package/vendor/rbxutil/signal/wally.toml +9 -9
- package/vendor/rbxutil/silo/TableWatcher.luau +65 -65
- package/vendor/rbxutil/silo/Util.luau +55 -55
- package/vendor/rbxutil/silo/init.luau +338 -338
- package/vendor/rbxutil/silo/init.test.luau +215 -215
- package/vendor/rbxutil/silo/wally.toml +8 -8
- package/vendor/rbxutil/spring/index.d.ts +40 -40
- package/vendor/rbxutil/spring/init.luau +97 -97
- package/vendor/rbxutil/spring/package.json +17 -17
- package/vendor/rbxutil/spring/wally.toml +8 -8
- package/vendor/rbxutil/stream/index.d.ts +88 -88
- package/vendor/rbxutil/stream/init.luau +597 -597
- package/vendor/rbxutil/stream/package.json +18 -18
- package/vendor/rbxutil/stream/wally.toml +9 -9
- package/vendor/rbxutil/streamable/Streamable.luau +202 -202
- package/vendor/rbxutil/streamable/StreamableUtil.luau +80 -80
- package/vendor/rbxutil/streamable/init.luau +8 -8
- package/vendor/rbxutil/streamable/wally.toml +12 -12
- package/vendor/rbxutil/symbol/init.luau +56 -56
- package/vendor/rbxutil/symbol/init.test.luau +37 -37
- package/vendor/rbxutil/symbol/wally.toml +8 -8
- package/vendor/rbxutil/table-util/init.luau +938 -938
- package/vendor/rbxutil/table-util/init.test.luau +439 -439
- package/vendor/rbxutil/task-queue/index.d.ts +27 -27
- package/vendor/rbxutil/task-queue/init.luau +97 -97
- package/vendor/rbxutil/task-queue/wally.toml +8 -8
- package/vendor/rbxutil/timer/index.d.ts +81 -81
- package/vendor/rbxutil/timer/init.luau +249 -249
- package/vendor/rbxutil/timer/init.test.luau +73 -73
- package/vendor/rbxutil/timer/wally.toml +11 -11
- package/vendor/rbxutil/tree/index.d.ts +15 -15
- package/vendor/rbxutil/tree/init.luau +137 -137
- package/vendor/rbxutil/tree/wally.toml +8 -8
- package/vendor/rbxutil/trove/index.d.ts +46 -46
- package/vendor/rbxutil/trove/init.luau +787 -787
- package/vendor/rbxutil/trove/init.test.luau +203 -203
- package/vendor/rbxutil/trove/wally.toml +8 -8
- package/vendor/rbxutil/typed-remote/init.luau +196 -196
- package/vendor/rbxutil/typed-remote/wally.toml +8 -8
- package/vendor/rbxutil/wait-for/index.d.ts +17 -17
- package/vendor/rbxutil/wait-for/init.luau +257 -257
- package/vendor/rbxutil/wait-for/init.test.luau +182 -182
- package/vendor/rbxutil/wait-for/wally.toml +11 -11
- package/vendor/t/t.lua +1350 -1350
- package/vendor/testez/Context.lua +26 -26
- package/vendor/testez/Expectation.lua +311 -311
- package/vendor/testez/ExpectationContext.lua +38 -38
- package/vendor/testez/LifecycleHooks.lua +89 -89
- package/vendor/testez/Reporters/TeamCityReporter.lua +101 -101
- package/vendor/testez/Reporters/TextReporter.lua +105 -105
- package/vendor/testez/Reporters/TextReporterQuiet.lua +96 -96
- package/vendor/testez/TestBootstrap.lua +146 -146
- package/vendor/testez/TestEnum.lua +27 -27
- package/vendor/testez/TestPlan.lua +304 -304
- package/vendor/testez/TestPlanner.lua +39 -39
- package/vendor/testez/TestResults.lua +111 -111
- package/vendor/testez/TestRunner.lua +188 -188
- package/vendor/testez/TestSession.lua +243 -243
- package/vendor/testez/init.lua +39 -39
|
@@ -1,1729 +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
|
-
```
|
|
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
|
+
```
|