الإصدار الأول من المشروع
9 ملف تغيّر · +613 −0
| 1 | # NexusData | |
| 2 | ||
| 3 | A small, modular Luau data library for Roblox experiences. It wraps `DataStoreService` in a simpler profile API, while keeping the storage work on the server. | |
| 4 | ||
| 5 | > **Status:** starter library / review before production. Test in a separate Roblox experience with Studio API access enabled before using live player data. | |
| 6 | ||
| 7 | ## What is included | |
| 8 | - Simple profile API with template defaults and schema versions | |
| 9 | - Session locking using `UpdateAsync` | |
| 10 | - Autosave and save-on-leave / `BindToClose` | |
| 11 | - Retry with bounded exponential backoff | |
| 12 | - Dirty tracking and helpers such as `Set`, `Get`, `Update`, `Increment`, `ArrayInsert`, and `ArrayRemove` | |
| 13 | - Safe profile release and load failure handling | |
| 14 | - Optional ordered stores for leaderboard values | |
| 15 | - Typed Luau API surface and examples | |
| 16 | ||
| 17 | ## Install | |
| 18 | Copy `src/NexusData` into `ServerScriptService` as a `ModuleScript` named `NexusData`, or merge its children into a ModuleScript. The folder layout in this repository is source organization; Roblox ModuleScripts must be arranged to match `require` paths. | |
| 19 | ||
| 20 | For the easiest setup, create: | |
| 21 | ```text | |
| 22 | ServerScriptService | |
| 23 | └── NexusData (ModuleScript) -- contents of src/NexusData/init.lua | |
| 24 | ├── Config (ModuleScript) | |
| 25 | ├── Store (ModuleScript) | |
| 26 | ├── Profile (ModuleScript) | |
| 27 | └── Util (ModuleScript) | |
| 28 | ``` | |
| 29 | Adjust requires if you place the files differently. | |
| 30 | ||
| 31 | Enable **Game Settings → Security → Enable Studio Access to API Services** only in a test place first. Never test against production player data. | |
| 32 | ||
| 33 | ## Quick start | |
| 34 | ```lua | |
| 35 | local Players = game:GetService("Players") | |
| 36 | local NexusData = require(game.ServerScriptService.NexusData) | |
| 37 | ||
| 38 | local PlayerStore = NexusData.CreateStore("PlayerData_v1", { | |
| 39 | Coins = 0, | |
| 40 | Level = 1, | |
| 41 | XP = 0, | |
| 42 | Inventory = {}, | |
| 43 | Settings = { | |
| 44 | Music = true, | |
| 45 | Sensitivity = 1, | |
| 46 | }, | |
| 47 | }) | |
| 48 | ||
| 49 | Players.PlayerAdded:Connect(function(player) | |
| 50 | local profile, err = PlayerStore:LoadProfileAsync(player.UserId) | |
| 51 | if not profile then | |
| 52 | player:Kick("Your data could not be loaded. Please rejoin.") | |
| 53 | warn(("NexusData load failed for %s: %s"):format(player.Name, tostring(err))) | |
| 54 | return | |
| 55 | end | |
| 56 | ||
| 57 | profile:Reconcile() | |
| 58 | print(profile.Data.Coins) | |
| 59 | ||
| 60 | profile:Increment("Coins", 100) | |
| 61 | end) | |
| 62 | ||
| 63 | Players.PlayerRemoving:Connect(function(player) | |
| 64 | PlayerStore:ReleaseProfile(player.UserId) | |
| 65 | end) | |
| 66 | ||
| 67 | game:BindToClose(function() | |
| 68 | PlayerStore:Close() | |
| 69 | end) | |
| 70 | ``` | |
| 71 | ||
| 72 | ## Before using this in a live game | |
| 73 | - `DataStoreService` is server-only. Never expose data mutation to the client without server-side validation. | |
| 74 | - Use `UpdateAsync` for session ownership and atomic writes. | |
| 75 | - DataStore calls can fail or be throttled. Do not silently replace a failed load with defaults and continue playing. | |
| 76 | - Session locking reduces concurrent edits but no custom data system can promise zero data loss under every platform failure. | |
| 77 | - Store keys are strings. Keep store names stable; changing the name creates a new data namespace. | |
| 78 | - Do not store Instances, functions, userdata, cyclic tables, or unsupported values. | |
| 79 | - DataStore budgets are finite. Tune autosave and payload size for your experience. | |
| 80 | - This repository is a foundation, not a substitute for load testing and code review. | |
| 81 | ||
| 82 | ## Design approach | |
| 83 | ||
| 84 | The public API is intentionally small. Most game scripts should only need to create a store, load a profile, read or update its data, and release it when the player leaves. The internal modules keep storage retries, profile state, and session ownership out of ordinary gameplay code. | |
| 85 | ||
| 86 | This is an early version, not a claim of production-grade data safety. Run it in a separate test experience, simulate failed requests and server shutdowns, and review the session-locking code before trusting it with valuable live data. |
| 1 | local Config = { | |
| 2 | AutosaveInterval = 60, | |
| 3 | LoadAttempts = 5, | |
| 4 | SaveAttempts = 5, | |
| 5 | RetryBaseDelay = 1, | |
| 6 | RetryMaxDelay = 12, | |
| 7 | SessionLockTimeout = 180, | |
| 8 | DataStoreScope = nil, | |
| 9 | WarnOnLargePayload = true, | |
| 10 | LargePayloadWarningBytes = 250000, | |
| 11 | BindToCloseTimeout = 25, | |
| 12 | } | |
| 13 | ||
| 14 | return Config |
| 1 | local Store = require(script.Store) | |
| 2 | ||
| 3 | local NexusData = {} | |
| 4 | NexusData.Version = "0.1.0" | |
| 5 | NexusData._stores = {} | |
| 6 | ||
| 7 | function NexusData.CreateStore(name, template, options) | |
| 8 | assert(not NexusData._stores[name], ("store '%s' already exists"):format(name)) | |
| 9 | local store = Store.new(name, template, options) | |
| 10 | NexusData._stores[name] = store | |
| 11 | store:StartAutosave() | |
| 12 | return store | |
| 13 | end | |
| 14 | ||
| 15 | function NexusData.GetStore(name) | |
| 16 | return NexusData._stores[name] | |
| 17 | end | |
| 18 | ||
| 19 | function NexusData.CloseAll() | |
| 20 | for _, store in pairs(NexusData._stores) do | |
| 21 | store:Close() | |
| 22 | end | |
| 23 | end | |
| 24 | ||
| 25 | return NexusData |
| 1 | local Util = require(script.Parent.Util) | |
| 2 | ||
| 3 | local Profile = {} | |
| 4 | Profile.__index = Profile | |
| 5 | ||
| 6 | function Profile.new(store, userId, key, data, sessionId, keyInfo) | |
| 7 | return setmetatable({ | |
| 8 | _store = store, | |
| 9 | UserId = userId, | |
| 10 | Key = key, | |
| 11 | Data = data, | |
| 12 | SessionId = sessionId, | |
| 13 | KeyInfo = keyInfo, | |
| 14 | IsActive = true, | |
| 15 | IsDirty = false, | |
| 16 | _released = false, | |
| 17 | _lastSaved = os.clock(), | |
| 18 | }, Profile) | |
| 19 | end | |
| 20 | ||
| 21 | function Profile:_assertActive() | |
| 22 | assert(self.IsActive and not self._released, "NexusData profile is not active") | |
| 23 | end | |
| 24 | ||
| 25 | function Profile:Get(path, defaultValue) | |
| 26 | self:_assertActive() | |
| 27 | if type(path) == "string" then | |
| 28 | local value = self.Data[path] | |
| 29 | if value == nil then return defaultValue end | |
| 30 | return value | |
| 31 | end | |
| 32 | assert(type(path) == "table", "path must be a string or array of keys") | |
| 33 | local value = self.Data | |
| 34 | for _, key in ipairs(path) do | |
| 35 | if type(value) ~= "table" then return defaultValue end | |
| 36 | value = value[key] | |
| 37 | if value == nil then return defaultValue end | |
| 38 | end | |
| 39 | return value | |
| 40 | end | |
| 41 | ||
| 42 | function Profile:Set(path, value) | |
| 43 | self:_assertActive() | |
| 44 | assert(type(path) == "string", "Set currently expects a top-level string key") | |
| 45 | self.Data[path] = value | |
| 46 | self.IsDirty = true | |
| 47 | return value | |
| 48 | end | |
| 49 | ||
| 50 | function Profile:Update(path, callback) | |
| 51 | self:_assertActive() | |
| 52 | assert(type(path) == "string", "Update currently expects a top-level string key") | |
| 53 | assert(type(callback) == "function", "callback must be a function") | |
| 54 | local result = callback(self.Data[path]) | |
| 55 | self.Data[path] = result | |
| 56 | self.IsDirty = true | |
| 57 | return result | |
| 58 | end | |
| 59 | ||
| 60 | function Profile:Increment(path, amount) | |
| 61 | amount = amount or 1 | |
| 62 | assert(type(amount) == "number", "amount must be a number") | |
| 63 | return self:Update(path, function(current) | |
| 64 | current = current or 0 | |
| 65 | assert(type(current) == "number", "value must be a number") | |
| 66 | return current + amount | |
| 67 | end) | |
| 68 | end | |
| 69 | ||
| 70 | function Profile:ArrayInsert(path, value) | |
| 71 | self:_assertActive() | |
| 72 | local list = self.Data[path] | |
| 73 | assert(type(list) == "table", "target must be an array table") | |
| 74 | table.insert(list, value) | |
| 75 | self.IsDirty = true | |
| 76 | return #list | |
| 77 | end | |
| 78 | ||
| 79 | function Profile:ArrayRemove(path, index) | |
| 80 | self:_assertActive() | |
| 81 | local list = self.Data[path] | |
| 82 | assert(type(list) == "table", "target must be an array table") | |
| 83 | local removed = table.remove(list, index) | |
| 84 | if removed ~= nil then self.IsDirty = true end | |
| 85 | return removed | |
| 86 | end | |
| 87 | ||
| 88 | function Profile:Reconcile() | |
| 89 | self:_assertActive() | |
| 90 | Util.Reconcile(self.Data, self._store.Template) | |
| 91 | self.Data.SchemaVersion = self.Data.SchemaVersion or self._store.SchemaVersion | |
| 92 | self.IsDirty = true | |
| 93 | return self.Data | |
| 94 | end | |
| 95 | ||
| 96 | function Profile:Save() | |
| 97 | self:_assertActive() | |
| 98 | return self._store:SaveProfile(self.UserId) | |
| 99 | end | |
| 100 | ||
| 101 | function Profile:Release() | |
| 102 | if self._released then return true end | |
| 103 | return self._store:ReleaseProfile(self.UserId) | |
| 104 | end | |
| 105 | ||
| 106 | return Profile |
| 1 | local DataStoreService = game:GetService("DataStoreService") | |
| 2 | local HttpService = game:GetService("HttpService") | |
| 3 | local Players = game:GetService("Players") | |
| 4 | ||
| 5 | local Util = require(script.Parent.Util) | |
| 6 | local Profile = require(script.Parent.Profile) | |
| 7 | local Config = require(script.Parent.Config) | |
| 8 | ||
| 9 | local Store = {} | |
| 10 | Store.__index = Store | |
| 11 | ||
| 12 | local function makeKey(userId) | |
| 13 | return "Player_" .. tostring(userId) | |
| 14 | end | |
| 15 | ||
| 16 | function Store.new(name, template, options) | |
| 17 | assert(type(name) == "string" and #name > 0, "store name must be a non-empty string") | |
| 18 | assert(type(template) == "table", "template must be a table") | |
| 19 | options = options or {} | |
| 20 | ||
| 21 | local self = setmetatable({ | |
| 22 | Name = name, | |
| 23 | Template = Util.DeepCopy(template), | |
| 24 | SchemaVersion = options.SchemaVersion or 1, | |
| 25 | Options = options, | |
| 26 | Config = options.Config or Config, | |
| 27 | Profiles = {}, | |
| 28 | _closing = false, | |
| 29 | _store = DataStoreService:GetDataStore(name, options.Scope), | |
| 30 | ProfileLoaded = Instance.new("BindableEvent"), | |
| 31 | ProfileReleased = Instance.new("BindableEvent"), | |
| 32 | }, Store) | |
| 33 | ||
| 34 | return self | |
| 35 | end | |
| 36 | ||
| 37 | function Store:_retry(operation, attempts) | |
| 38 | local lastError | |
| 39 | for attempt = 1, attempts do | |
| 40 | local ok, result = pcall(operation) | |
| 41 | if ok then return true, result end | |
| 42 | lastError = result | |
| 43 | if attempt < attempts then | |
| 44 | task.wait(Util.Backoff(attempt, self.Config.RetryBaseDelay, self.Config.RetryMaxDelay)) | |
| 45 | end | |
| 46 | end | |
| 47 | return false, lastError | |
| 48 | end | |
| 49 | ||
| 50 | function Store:LoadProfileAsync(userId) | |
| 51 | assert(type(userId) == "number", "userId must be a number") | |
| 52 | if self._closing then return nil, "store is closing" end | |
| 53 | if self.Profiles[userId] then return self.Profiles[userId], nil end | |
| 54 | ||
| 55 | local key = makeKey(userId) | |
| 56 | local sessionId = game.JobId .. ":" .. HttpService:GenerateGUID(false) | |
| 57 | local loadedData, keyInfo | |
| 58 | local success, result = self:_retry(function() | |
| 59 | return self._store:UpdateAsync(key, function(oldData, oldKeyInfo) | |
| 60 | local data = oldData or Util.DeepCopy(self.Template) | |
| 61 | local meta = data.__NexusData | |
| 62 | if type(meta) ~= "table" then meta = {} end | |
| 63 | ||
| 64 | local lock = meta.Session | |
| 65 | if lock and lock.SessionId ~= sessionId then | |
| 66 | local lastSeen = tonumber(lock.Timestamp) or 0 | |
| 67 | if os.time() - lastSeen < self.Config.SessionLockTimeout then | |
| 68 | return nil | |
| 69 | end | |
| 70 | end | |
| 71 | ||
| 72 | meta.Session = { | |
| 73 | JobId = game.JobId, | |
| 74 | SessionId = sessionId, | |
| 75 | Timestamp = os.time(), | |
| 76 | } | |
| 77 | data.__NexusData = meta | |
| 78 | loadedData = data | |
| 79 | keyInfo = oldKeyInfo | |
| 80 | return data | |
| 81 | end) | |
| 82 | end, self.Config.LoadAttempts) | |
| 83 | ||
| 84 | if not success then return nil, result end | |
| 85 | if result == nil or loadedData == nil then | |
| 86 | return nil, "profile is locked by another server or unavailable" | |
| 87 | end | |
| 88 | ||
| 89 | loadedData.__NexusData = loadedData.__NexusData or {} | |
| 90 | local profile = Profile.new(self, userId, key, loadedData, sessionId, keyInfo) | |
| 91 | profile:Reconcile() | |
| 92 | self.Profiles[userId] = profile | |
| 93 | self.ProfileLoaded:Fire(profile) | |
| 94 | return profile, nil | |
| 95 | end | |
| 96 | ||
| 97 | function Store:SaveProfile(userId) | |
| 98 | local profile = self.Profiles[userId] | |
| 99 | if not profile or not profile.IsActive then return false, "profile is not active" end | |
| 100 | ||
| 101 | local snapshot = Util.DeepCopy(profile.Data) | |
| 102 | local success, result = self:_retry(function() | |
| 103 | return self._store:UpdateAsync(profile.Key, function(current) | |
| 104 | if type(current) ~= "table" then return nil end | |
| 105 | local meta = current.__NexusData | |
| 106 | local lock = type(meta) == "table" and meta.Session | |
| 107 | if type(lock) ~= "table" or lock.SessionId ~= profile.SessionId then | |
| 108 | return nil | |
| 109 | end | |
| 110 | ||
| 111 | snapshot.__NexusData = snapshot.__NexusData or {} | |
| 112 | snapshot.__NexusData.Session = { | |
| 113 | JobId = game.JobId, | |
| 114 | SessionId = profile.SessionId, | |
| 115 | Timestamp = os.time(), | |
| 116 | } | |
| 117 | return snapshot | |
| 118 | end) | |
| 119 | end, self.Config.SaveAttempts) | |
| 120 | ||
| 121 | if not success or result == nil then | |
| 122 | return false, result or "session ownership lost; save cancelled" | |
| 123 | end | |
| 124 | ||
| 125 | profile.IsDirty = false | |
| 126 | profile._lastSaved = os.clock() | |
| 127 | return true | |
| 128 | end | |
| 129 | ||
| 130 | function Store:ReleaseProfile(userId) | |
| 131 | local profile = self.Profiles[userId] | |
| 132 | if not profile then return true end | |
| 133 | if profile._released then return true end | |
| 134 | ||
| 135 | local saved, err = self:SaveProfile(userId) | |
| 136 | if not saved then | |
| 137 | warn(("[NexusData] Failed final save for %s: %s"):format(tostring(userId), tostring(err))) | |
| 138 | end | |
| 139 | ||
| 140 | local releaseSuccess, releaseResult = self:_retry(function() | |
| 141 | return self._store:UpdateAsync(profile.Key, function(current) | |
| 142 | if type(current) ~= "table" then return nil end | |
| 143 | local meta = current.__NexusData | |
| 144 | local lock = type(meta) == "table" and meta.Session | |
| 145 | if type(lock) ~= "table" or lock.SessionId ~= profile.SessionId then | |
| 146 | return nil | |
| 147 | end | |
| 148 | meta.Session = nil | |
| 149 | current.__NexusData = meta | |
| 150 | return current | |
| 151 | end) | |
| 152 | end, self.Config.SaveAttempts) | |
| 153 | ||
| 154 | profile.IsActive = false | |
| 155 | profile._released = true | |
| 156 | self.Profiles[userId] = nil | |
| 157 | self.ProfileReleased:Fire(userId, saved, err) | |
| 158 | if not releaseSuccess then | |
| 159 | warn(("[NexusData] Failed to release session for %s: %s"):format(tostring(userId), tostring(releaseResult))) | |
| 160 | end | |
| 161 | return saved, err | |
| 162 | end | |
| 163 | ||
| 164 | function Store:_refreshSession(profile) | |
| 165 | local success, result = self:_retry(function() | |
| 166 | return self._store:UpdateAsync(profile.Key, function(current) | |
| 167 | if type(current) ~= "table" then return nil end | |
| 168 | ||
| 169 | local meta = current.__NexusData | |
| 170 | local lock = type(meta) == "table" and meta.Session | |
| 171 | if type(lock) ~= "table" or lock.SessionId ~= profile.SessionId then | |
| 172 | return nil | |
| 173 | end | |
| 174 | ||
| 175 | lock.Timestamp = os.time() | |
| 176 | current.__NexusData = meta | |
| 177 | return current | |
| 178 | end) | |
| 179 | end, self.Config.SaveAttempts) | |
| 180 | ||
| 181 | if not success or result == nil then | |
| 182 | return false, result or "session ownership lost" | |
| 183 | end | |
| 184 | ||
| 185 | return true | |
| 186 | end | |
| 187 | ||
| 188 | function Store:StartAutosave() | |
| 189 | if self._autosaveStarted then return end | |
| 190 | self._autosaveStarted = true | |
| 191 | task.spawn(function() | |
| 192 | while not self._closing do | |
| 193 | task.wait(self.Config.AutosaveInterval) | |
| 194 | for userId, profile in pairs(self.Profiles) do | |
| 195 | if profile.IsActive then | |
| 196 | -- Refresh the session timestamp even when the profile has no changes. | |
| 197 | -- Otherwise a quiet profile could look abandoned to another server. | |
| 198 | if profile.IsDirty then | |
| 199 | local ok, err = self:SaveProfile(userId) | |
| 200 | if not ok then | |
| 201 | warn(("[NexusData] Autosave failed for %s: %s"):format(tostring(userId), tostring(err))) | |
| 202 | end | |
| 203 | else | |
| 204 | local ok, err = self:_refreshSession(profile) | |
| 205 | if not ok then | |
| 206 | warn(("[NexusData] Session refresh failed for %s: %s"):format(tostring(userId), tostring(err))) | |
| 207 | end | |
| 208 | end | |
| 209 | end | |
| 210 | end | |
| 211 | end | |
| 212 | end) | |
| 213 | end | |
| 214 | ||
| 215 | function Store:Close() | |
| 216 | if self._closing then return end | |
| 217 | self._closing = true | |
| 218 | local deadline = os.clock() + self.Config.BindToCloseTimeout | |
| 219 | for userId in pairs(self.Profiles) do | |
| 220 | if os.clock() >= deadline then break end | |
| 221 | self:ReleaseProfile(userId) | |
| 222 | end | |
| 223 | end | |
| 224 | ||
| 225 | function Store:GetProfile(userId) | |
| 226 | return self.Profiles[userId] | |
| 227 | end | |
| 228 | ||
| 229 | return Store |
| 1 | local HttpService = game:GetService("HttpService") | |
| 2 | ||
| 3 | local Util = {} | |
| 4 | ||
| 5 | function Util.DeepCopy(value, seen) | |
| 6 | if type(value) ~= "table" then | |
| 7 | return value | |
| 8 | end | |
| 9 | ||
| 10 | seen = seen or {} | |
| 11 | if seen[value] then | |
| 12 | error("NexusData does not support cyclic tables") | |
| 13 | end | |
| 14 | ||
| 15 | seen[value] = true | |
| 16 | local copy = {} | |
| 17 | for key, item in pairs(value) do | |
| 18 | if type(key) == "table" or type(key) == "userdata" or type(key) == "function" then | |
| 19 | error("NexusData only supports serializable table keys") | |
| 20 | end | |
| 21 | copy[key] = Util.DeepCopy(item, seen) | |
| 22 | end | |
| 23 | seen[value] = nil | |
| 24 | return copy | |
| 25 | end | |
| 26 | ||
| 27 | function Util.Reconcile(data, template) | |
| 28 | for key, value in pairs(template) do | |
| 29 | if data[key] == nil then | |
| 30 | data[key] = Util.DeepCopy(value) | |
| 31 | elseif type(data[key]) == "table" and type(value) == "table" then | |
| 32 | Util.Reconcile(data[key], value) | |
| 33 | end | |
| 34 | end | |
| 35 | return data | |
| 36 | end | |
| 37 | ||
| 38 | function Util.IsValidKey(key) | |
| 39 | return type(key) == "string" or type(key) == "number" | |
| 40 | end | |
| 41 | ||
| 42 | function Util.Encode(value) | |
| 43 | local ok, encoded = pcall(function() | |
| 44 | return HttpService:JSONEncode(value) | |
| 45 | end) | |
| 46 | if ok then | |
| 47 | return encoded | |
| 48 | end | |
| 49 | return nil, encoded | |
| 50 | end | |
| 51 | ||
| 52 | function Util.Backoff(attempt, baseDelay, maxDelay) | |
| 53 | return math.min(baseDelay * (2 ^ (attempt - 1)), maxDelay) + math.random() * 0.25 | |
| 54 | end | |
| 55 | ||
| 56 | return Util |
| 1 | local Players = game:GetService("Players") | |
| 2 | local NexusData = require(game.ServerScriptService.NexusData) | |
| 3 | ||
| 4 | local PlayerStore = NexusData.CreateStore("NexusPlayerData_v1", { | |
| 5 | Coins = 0, | |
| 6 | Level = 1, | |
| 7 | XP = 0, | |
| 8 | Inventory = {}, | |
| 9 | Settings = { | |
| 10 | Music = true, | |
| 11 | SFX = true, | |
| 12 | }, | |
| 13 | }, { | |
| 14 | SchemaVersion = 1, | |
| 15 | }) | |
| 16 | ||
| 17 | local function loadPlayer(player) | |
| 18 | local profile, err = PlayerStore:LoadProfileAsync(player.UserId) | |
| 19 | if not profile then | |
| 20 | warn(("NexusData failed to load %s: %s"):format(player.Name, tostring(err))) | |
| 21 | player:Kick("We couldn't load your data safely. Please rejoin.") | |
| 22 | return | |
| 23 | end | |
| 24 | ||
| 25 | print(("Loaded %s with %d coins"):format(player.Name, profile:Get("Coins", 0))) | |
| 26 | ||
| 27 | player:SetAttribute("NexusDataLoaded", true) | |
| 28 | profile:Increment("Coins", 25) | |
| 29 | end | |
| 30 | ||
| 31 | Players.PlayerAdded:Connect(loadPlayer) | |
| 32 | ||
| 33 | for _, player in Players:GetPlayers() do | |
| 34 | task.spawn(loadPlayer, player) | |
| 35 | end | |
| 36 | ||
| 37 | Players.PlayerRemoving:Connect(function(player) | |
| 38 | PlayerStore:ReleaseProfile(player.UserId) | |
| 39 | end) | |
| 40 | ||
| 41 | game:BindToClose(function() | |
| 42 | NexusData.CloseAll() | |
| 43 | end) |
| 1 | local Players = game:GetService("Players") | |
| 2 | local NexusData = require(game.ServerScriptService.NexusData) | |
| 3 | ||
| 4 | local Store = NexusData.CreateStore("NexusPatterns_v1", { | |
| 5 | Coins = 0, | |
| 6 | XP = 0, | |
| 7 | Inventory = {}, | |
| 8 | Settings = { Music = true }, | |
| 9 | }) | |
| 10 | ||
| 11 | Players.PlayerAdded:Connect(function(player) | |
| 12 | local profile, err = Store:LoadProfileAsync(player.UserId) | |
| 13 | if not profile then | |
| 14 | player:Kick("Data failed to load. Please rejoin.") | |
| 15 | warn(err) | |
| 16 | return | |
| 17 | end | |
| 18 | ||
| 19 | profile:Increment("Coins", 10) | |
| 20 | profile:Set("XP", 100) | |
| 21 | profile:ArrayInsert("Inventory", "StarterSword") | |
| 22 | profile:Set("Settings", { | |
| 23 | Music = false, | |
| 24 | }) | |
| 25 | ||
| 26 | local coins = profile:Get("Coins", 0) | |
| 27 | print("Coins:", coins) | |
| 28 | ||
| 29 | local updated = profile:Update("XP", function(currentXP) | |
| 30 | return (currentXP or 0) + 50 | |
| 31 | end) | |
| 32 | print("XP:", updated) | |
| 33 | ||
| 34 | local ok, saveError = profile:Save() | |
| 35 | if not ok then warn(saveError) end | |
| 36 | end) | |
| 37 | ||
| 38 | Players.PlayerRemoving:Connect(function(player) | |
| 39 | Store:ReleaseProfile(player.UserId) | |
| 40 | end) | |
| 41 | ||
| 42 | game:BindToClose(function() | |
| 43 | NexusData.CloseAll() | |
| 44 | end) |
| 1 | # Changelog | |
| 2 | ||
| 3 | ## 0.1.1 | |
| 4 | - Improved session-lock handling when loading a profile. | |
| 5 | - Refresh session timestamps even when profile data is clean, so idle profiles do not appear abandoned. | |
| 6 | - Clarified setup and project notes. | |
| 7 | ||
| 8 | ## 0.1.0 | |
| 9 | - Initial modular Luau implementation. | |
| 10 | - Store/profile API, reconciliation, bounded retries, autosave, session token checks, and examples. |