{"id":"hurreman/roblox-data","name":"roblox-data","scope":"hurreman","platform":"roblox","description":"Player saves for hurreman's Roblox games on ProfileStore: session lifecycle with a one-time legacy import, validated currencies, versioned save sections, and throttled delta snapshots to each client","version":"0.1.0","latest":"0.1.0","versions":["0.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"e16918e5b267479c8af9134c6739e08b4371bf09f522d0aa97f3f2fcf63ee34d","likes":0,"downloads":0,"install":"forest install hurreman/roblox-data","url":"https://forest.dev/p/roblox/hurreman/roblox-data","files":"https://api.forest.dev/ai/package/roblox/hurreman/roblox-data/files","readme":"# roblox-data\n\nPlayer saves for hurreman's Roblox games, on ProfileStore:\n- **Sessions:** one per player from join to leave, with a one-time import from a game's old DataStore.\n- **Currencies:** validated whole-number balances.\n- **Versioned sections:** pieces of the save that change shape over time.\n- **Sync:** throttled delta snapshots from server to client.\n\nIt is built from three sources:\n- the generic half of Slime Nebula's `DataLoader`, `DataManager` and schema modules;\n- Magic Neighborhood's `SyncService` and client `Store`;\n- an importer for the hand-written session-lock saves of Magic Neighborhood and Steal an Egg.\n\n```toml\n# wally.toml\n[dependencies]\nData = \"hurreman/roblox-data@0.1.0\"\n```\n\nModules load on first access: `Data.Profiles`, `Data.Currency`, `Data.Sync` (server), `Data.Store` (client), and `Data.Schema` and `Data.Tables` (either side).\n\n## Profiles\n\n```lua\nlocal Data = require(ReplicatedStorage.Packages.Data)\n\nlocal profiles = Data.Profiles.new({\n\tProfileStore = require(ServerPackages.ProfileStore), -- the Wally package or a vendored copy\n\tname = { studio = \"Test_v3\", live = \"Live_v3\" },\n\ttemplate = require(script.Template),\n\t-- key = function(player) return \"Player_\" .. player.UserId end   (the default)\n\tmigrate = function(data, player, info) end, -- before Reconcile: sees exactly what was saved;\n\t                                            -- info.fresh: a brand-new player, info.imported: just imported\n\tonLoaded = function(player, profile) end,   -- the game's join work\n\tonRelease = function(player, profile, reason) end, -- last writes, right before the final save:\n\t                                            -- Release, server shutdown or another server (\"Manual\" | \"Shutdown\" | \"External\")\n\t-- mock = true                               -- ProfileStore's Mock store: no real DataStore\n\t-- trace = false                             -- no [JoinFlow] prints / [JoinCritical] errors\n})\nprofiles:Start()\n\nprofiles:Await(player, 5) -- yields: the profile, or nil on timeout or leave\nprofiles:Data(player)     -- profile.Data, or nil while not loaded\nprofiles:Reset(player)    -- the save back to the template; OnReset listeners run\n```\n\n`Load` runs in this order:\n1. StartSessionAsync, cancelled if the player leaves while it waits.\n2. AddUserId.\n3. The legacy import, if configured.\n4. `migrate`.\n5. Reconcile against the template.\n6. `onLoaded`, guarded so one broken hook can't leave `Await` hanging.\n\n`onRelease` runs from ProfileStore's `OnLastSave`, once per session: on `Release`, and also when the server closes (ProfileStore ends every session itself then) or another server takes the session.\n\nA session that ends any way other than `Release` (another server took it, or ProfileStore gave up) kicks the player, so nobody keeps playing on data that won't save. Sessions ProfileStore ends itself while the server closes (`ProfileStore.IsClosing`) don't count.\n\n### Importing an old save (Magic Neighborhood, Steal an Egg)\n\n```lua\nlegacy = {\n\tstore = { studio = \"PlayerData_v1_Studio\", live = \"PlayerData_v1\" },\n\tkey = function(player) return \"u_\" .. player.UserId end,\n\tconvert = function(record, player) return { cash = record.cash, receipts = record.receipts, ... } end,\n\t-- lockField = \"_lock\", staleAfter = 300, flag = \"LegacyImported\", attempts = 3, retryDelay = 2\n},\n```\n\nThe import runs the first time a profile lacks the flag:\n- The old record is **read only, never written**. It goes through `convert` and becomes `profile.Data` (Reconcile fills in the template afterwards, so `migrate` sees only what was saved), and the flag is set to `os.time()`.\n- A player with no old record is simply flagged.\n- If the old record's `_lock.time` is younger than `staleAfter`, an old server still holds that save. The player is asked to rejoin in a minute, and nothing is imported.\n- If the old store can't be read after its retries, the player is kicked and nothing is flagged. Playing on a fresh profile would let a later import overwrite that progress.\n- Without DataStore access (`ProfileStore.DataStoreState` isn't `\"Access\"`: Studio with API access off), ProfileStore plays a mock profile that won't save, so nothing is read or flagged; the import runs on a later join that has access.\n\nTo switch a live game over, use Roblox's \"restart servers\" when publishing. Old servers then release their locks as they close, and joiners import on the new servers.\n\n## Currency\n\n```lua\nlocal wallet = Data.Currency.new({\n\tcurrencies = { SlimeBucks = { max = 1e9 }, Nebulite = {}, Gems = { field = \"PremiumGems\" } },\n\tdata = function(player) return profiles:Data(player) end,\n\tchanged = function(player, currency, balance, delta, reason) end, -- client update, telemetry\n})\nwallet:Add(player, \"SlimeBucks\", 50, \"quest\")\nwallet:Spend(player, \"SlimeBucks\", 20, \"shop\") -- false if they can't afford it\n```\n\nAmounts are floored to whole numbers. NaN and ±inf are refused, so they never reach a save, and balances are clamped to `[0, max]`.\n\n## Schema (versioned sections)\n\n```lua\nlocal BoothSchema = Data.Schema.new({\n\tversion = 3,\n\tdefault = function() return { slots = {} } end,\n\tsteps = { [1] = function(raw) ... return raw end, [2] = function(raw) ... return raw end },\n\tnormalize = function(data, issues, catalog) ... return data end,\n})\nlocal booth, issues = BoothSchema:Load(profile.Data.Booth, catalog)\n```\n\n`Load` behaves like this:\n- A missing or non-table save becomes the default.\n- A save newer than `version` (a rollback) is reset and reported.\n- Anything else is stepped up one version at a time, then normalized.\n\n## Sync and Store\n\n```lua\n-- server\nlocal sync = Data.Sync.new({\n\tsnapshot = function(player) return { cash = ..., bag = ... } end,\n\tsend = function(player, changes, full, removed) net:SendTo(player, \"DataSync\", changes, full, removed) end,\n})\nsync:Start()\nnet:OnServerEvent(\"DataHello\", function(player) sync:Hello(player) end)\n-- after any change: sync:MarkDirty(player)\n\n-- client\nlocal store = Data.Store.new()\nstore:Connect(net:Event(\"DataSync\"), net:Event(\"DataHello\"))\nstore:Subscribe(function(state) ... end) -- a React hook is a useState over this\n```\n\nWhat gets sent:\n- The first send, and the first after a hello, is the full snapshot.\n- After that, only top-level keys whose value changed are sent; sub-tables are deep-compared against a copy and sent whole.\n- Removed keys are listed in `removed`.\n\nA snapshot that errors leaves that player dirty and is retried every `retryAfter` seconds, without stopping the loop for anyone else.\n\n## Moving the games over\n\n- **Slime Nebula:**\n  - `DataLoader` becomes `Profiles.new` with:\n    - `name = { studio = \"Test_v3\", live = \"Live_v3\" }` and the vendored `Libraries/ProfileStore` (or the Wally one);\n    - the join work as `onLoaded`;\n    - the leave work (quest serialization, playtime flush) as `onRelease`.\n  - `DataManager.Profiles[player]` becomes `profiles:Get(player)`, and `AwaitProfile` becomes `profiles:Await`.\n  - `MergeTemplateIntoProfileData` becomes `Data.Tables.WithTemplate`, and `OnProfileReset` becomes `profiles:OnReset`.\n  - The currency functions become a `Currency` wallet. Its `changed` hook fires `CurrencyUpdated` and the progress event.\n  - `KitchenBoothSchema` and `FridgeStorageSchema` can move onto `Data.Schema` as they're touched.\n- **Magic Neighborhood and Steal an Egg:**\n  - `DataService` becomes `Profiles.new` plus `legacy` as above. Keep `receipts` in the converted data, and roblox-commerce's `legacyIdsField = \"receipts\"` keeps old receipts deduped.\n  - `SyncService` and `Store` become `Data.Sync` and `Data.Store`, with the game's snapshot. Toasts stay in the game: they go over roblox-net.\n  - This is the riskiest migration. Dry-run it on the Studio store first, then follow the game's `docs/live-verification.md`.\n\n## Developing\n\n```bash\nlune run tests/run    # specs on hurreman/luau-spec (DevPackages, else ../luau-spec); fakes in tests/fixtures\n```\n\nThe Lune specs use a fake ProfileStore and DataStoreService. They cover:\n- sessions, kicks, Await, Reset and migrate;\n- every legacy import outcome: imported, none, busy and failed;\n- currency validation, schema steps and resets;\n- sync deltas, hello, errors and the client store.\n\nIn Studio, `Profiles` was run against Slime Nebula's real vendored ProfileStore on its Mock store. These worked: load, Reconcile, AddUserId, onLoaded, Await, Release without a kick, a reload keeping the saved values and onRelease's write, and Reset.\n\nThe legacy import has not been run against a real DataStore.\n","readmeTruncated":false}