{"id":"luminalabsgames/novadata","name":"novadata","scope":"luminalabsgames","platform":"roblox","description":"The starter player data layer: ProfileStore sessions replicated with Replica, receipts, messages, migrations and resets","version":"0.1.0","latest":"0.1.0","versions":["0.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{"luminalabsgames/replicaclient":{"version":"^1.0.1","alias":"ReplicaClient"},"luminalabsgames/replicaserver":{"version":"^1.0.3","alias":"ReplicaServer"}},"integrity":"6908e5b9fb0cff7670f9f3b4753b1b7ecc5068d57d32e685310bd63045c34f37","likes":0,"downloads":0,"install":"forest install luminalabsgames/novadata","url":"https://forest.dev/p/roblox/luminalabsgames/novadata","files":"https://api.forest.dev/ai/package/roblox/luminalabsgames/novadata/files","readme":"# NovaData\n\nThe player data layer every LuminaLabs game copied in as `src/server/Data`, now a package. It runs\nProfileStore sessions, replicated to clients with Replica, and handles developer product receipts,\ncross-server messages, migrations, versioned resets and wipe marks. It does not need Nova, so it\nworks in a Shard game too.\n\n```toml\n# wally.toml\n[dependencies]\nNovaData = \"luminalabsgames/novadata@0.1.0\"\nReplicaShared = \"luminalabsgames/replicashared@1.0.0\" # see \"Requirements\"\n\n[server-dependencies]\nProfileStore = \"lm-loleris/profilestore@1.0.3\"\n```\n\n## Requirements\n\n- **ProfileStore** must be the game's own server dependency. NovaData finds it at\n  `ServerScriptService.ServerPackages.ProfileStore`, or you can pass `profileStore = require(...)`.\n- **ReplicaShared** must be listed in the game's own `wally.toml`. ReplicaServer and ReplicaClient\n  1.0.x require it at `ReplicatedStorage.Packages.ReplicaShared`, and Wally only creates that link\n  for packages the game lists itself. Every LuminaLabs game already lists it.\n\n## Setup\n\n```lua\n-- ServerScriptService/Boot.server.luau\nNova.boot({\n\tmodules = script.Parent.Services,\n\tbefore = function()\n\t\tNovaData.start({\n\t\t\ttemplate = require(script.Parent.DataTemplate), -- every new save starts as this\n\t\t\tproductionPlaceId = 1234567890,\n\t\t})\n\tend,\n})\n\n-- StarterPlayerScripts/Boot.client.luau\nNova.boot({ modules = script.Parent.Controllers, after = NovaData.request })\n```\n\n`start` does not yield; saves load on their own threads. The client calls `request` once, after\nevery controller's observers are connected.\n\n## Server\n\n```lua\nlocal profile = NovaData.awaitProfile(player) -- yields; nil if they left or it failed\nlocal profile = NovaData.getProfile(player)   -- nil until loaded, never yields\n\nprofile:Get(\"Cash\")                            -- a key name\nprofile:Get(\"Experience\")                      -- a name unique in the template: Stats.Experience\nprofile:Get(\"Stats.Level\")                     -- a dotted path\nprofile:Get({ \"Inventory\", itemId })           -- a list path, for keys the template does not have\nprofile:Set(\"Cash\", 100)\nprofile:Increment(\"Cash\", 25)                  -- never below 0; Decrement too\nif profile:Spend(\"Cash\", 50) then ... end      -- takes it only if they have it\nprofile:Insert({ \"Inventory\" }, item)          -- returns the index\nprofile:Remove({ \"Inventory\" }, 3)             -- returns what was removed\nprofile:GetPlayer(), profile:GetUserId(), profile:GetProfile(), profile:GetReplica(), profile:IsActive()\n\nNovaData.observeProfiles(function(player, profile)\n\t-- each loaded save, now and later\n\treturn function() end -- runs as they leave, before the session closes\nend)\n```\n\nThe profile methods keep the names from the Shard-era template, so service code ports as it is.\n\n- **Always write through these methods.** The save and the replica share one table. A write\n  straight into a table that `Get` returned is saved but never reaches the client.\n- **Names are looked up in the template, not the live save.** A top-level key always wins. A\n  nested name works when only one table in the template has it. A misspelt or ambiguous name\n  errors and says what it could be. (The template version searched the live save and returned\n  whichever match it found first.)\n\n| Function | |\n| --- | --- |\n| `onProduct(productId, fn(profile, receipt))` | What a developer product grants. Errors or returning `false` mean it did not land, and Roblox retries. Each purchase is granted once and acknowledged only after it is saved. The purchase history holds 1,000 entries |\n| `onMessage(kind, fn(profile, message))` | Handles ProfileStore messages like `{ \"Gift\", \"Cash\", 25000 }`. Return `true` once it has landed. Unknown kinds are kept, not lost. Register at require time |\n| `sendMessage(userId, message)` | Queues a message for any save, online or offline, on any server |\n| `markForWipe(player)` | Empties the save back to the template the next time it opens (purchase history kept). Send the player away afterwards |\n| `isMock()`, `keyFor(userId)`, `store()` | Whether this server uses the mock store, the DataStore key, and the ProfileStore store for offline reads |\n\n**When a save opens,** these steps run before any service or client can see it:\n\n1. A save marked for a wipe is emptied.\n2. A save from before `reset.version` is rebuilt from the template. It keeps `reset.keep` and the\n   purchase history.\n3. `Reconcile` fills in any template keys the save is missing.\n4. `migrations` run in order on a copy, starting from the save's `DataVersion`. If one throws, the\n   player is kicked and their save stays exactly as it was. Brand-new, wiped and reset saves skip\n   migrations. Migrations must not yield.\n\n### Options\n\n| Option | Default | |\n| --- | --- | --- |\n| `template` | required | The table every new save starts as |\n| `productionPlaceId` | `0` | Live servers in this place save under `productionSuffix`. Everything else, Studio included, uses `developmentSuffix` |\n| `developmentSuffix`, `productionSuffix` | `\"_DEVELOPMENT\"`, `\"_PRODUCTION\"` | Change the development suffix to give every tester a fresh save |\n| `mock` | `true` | In Studio, use the mock store, which starts empty every playtest |\n| `replicateTo` | `\"owner\"` | `\"everyone\"` when other players' data is shown (for example a nametag level). The template replicated every save to every client |\n| `migrations` | `{}` | A list of `function(data, player)` |\n| `versionKey` | `\"DataVersion\"` | Where the migration count is stored. Keep it out of the template |\n| `reset` | none | `{ version = \"2026-11-01\", keep = { \"Passes\" } }` |\n| `receipts` | `true` | Set to false to handle `ProcessReceipt` yourself |\n| `spentKey` | none | For example `\"RobuxSpent\"`: adds each receipt's Robux, counted once per purchase |\n| `purchaseHistory` | `1000` | How many purchase ids each save remembers |\n| `storeName`, `token` | `\"PlayerStore\"`, `\"PlayerData\"` | |\n| `loadFailedMessage`, `sessionEndedMessage` | | Kick messages |\n\n## Client\n\n```lua\nNovaData.observe(\"Cash\", function(cash) ... end) -- once the data arrives, then on every change at or under the path\nNovaData.read(\"Stats.Level\")                      -- nil until it arrives\nNovaData.ready(10)                                -- yields until it arrives; false on a timeout\nNovaData.readFor(otherPlayer, \"Stats.Level\")      -- only with replicateTo = \"everyone\"\n```\n\nThese replace the block every NeedohEscape controller repeated: `ReplicaClient.OnNew(\"PlayerData\")`,\na check that the replica belonged to the local player, then an `OnSet` per key. Paths resolve the\nsame way as on the server. Writes happen on the server only, so send a packet and let a service do\nthe write.\n\n## What changed from the Shard-era template\n\n- **Fixed:** a player who left while their save loaded still got a replica and a profile entry.\n- **Fixed:** `GetUserId` returned a field that was never set, so the by-UserId cleanup never ran.\n- **Fixed:** `GetProfileAsync` busy-waited every frame. `awaitProfile` waits on the load itself.\n- **Changed:** replication goes to the owner only, unless `replicateTo = \"everyone\"`.\n- **Folded in from CarSteal:** message handlers registered by kind, wipe marks, versioned resets,\n  the 1,000-entry purchase history, and grants that error instead of consuming a receipt.\n- **New:** migrations, which run on a copy and leave a save untouched if one fails.\n","readmeTruncated":false}