--!strict --[[ DataManager.luau (SERVER) Wraps loleris's ProfileStore (battle-tested) so the rest of the game never touches DataStores directly. This is deliberately built FIRST — data safety is the foundation everything else stands on. Public API: DataManager.init() DataManager.get(player) -> ProfileData? (nil if still loading / kicked) DataManager.onReady(player, fn) -> runs fn(data) now or when loaded DataManager.forEachLoaded(fn) Guarantees: - Session locking (no data loss from double-load across servers) - Reconcile fills new template keys onto old saves (forward migration) - Studio uses ProfileStore.Mock so you can test without a live DataStore ]] local Players = game:GetService("Players") local RunService = game:GetService("RunService") local ServerScriptService = game:GetService("ServerScriptService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local ProfileStore = require(ServerScriptService.ServerPackages.ProfileStore) local Shared = ReplicatedStorage.Shared local GameConfig = require(Shared.GameConfig) local ProfileTemplate = require(Shared.ProfileTemplate) type ProfileData = ProfileTemplate.ProfileData local DataManager = {} -- Profiles keyed by player. Value is the ProfileStore profile object. local profiles: { [Player]: any } = {} -- Callbacks queued before a profile finished loading. local readyQueue: { [Player]: { (ProfileData) -> () } } = {} local store: any = nil local function keyFor(player: Player): string return "Player_" .. tostring(player.UserId) end local function flushReady(player: Player, data: ProfileData) local queue = readyQueue[player] if not queue then return end readyQueue[player] = nil for _, fn in ipairs(queue) do task.spawn(fn, data) end end local function onPlayerAdded(player: Player) local profile = store:StartSessionAsync(keyFor(player), { Cancel = function() -- Give up starting the session if the player already left. return player.Parent ~= Players end, }) if profile == nil then -- Another server holds the lock, or player left mid-load. Kick to be safe. player:Kick("[FnF] Your data could not be loaded. Please rejoin.") return end profile:AddUserId(player.UserId) -- GDPR-friendly association profile:Reconcile() -- fill missing template keys -- Grant the correct starter car for THIS player (RX-7 only for allowlisted -- UserIds; everyone else gets the public starter). Idempotent: only grants -- if the player has no cars yet (i.e. genuinely first join). local data = profile.Data if #data.OwnedCars == 0 then local starterId = GameConfig.starterCarFor(player.UserId) table.insert(data.OwnedCars, { Id = starterId, Upgrades = { Engine = 0, Tires = 0, Nitro = 0 }, }) data.EquippedCarId = starterId elseif data.EquippedCarId == "" then -- Safety: if somehow they own cars but have nothing equipped, equip the first. data.EquippedCarId = data.OwnedCars[1].Id end profile.OnSessionEnd:Connect(function() -- The session was lost (another server stole it, or we released it). profiles[player] = nil if player.Parent == Players then player:Kick("[FnF] Your session ended (data loaded elsewhere). Please rejoin.") end end) if player.Parent ~= Players then -- Player left during the yield above; release immediately. profile:EndSession() return end profiles[player] = profile flushReady(player, profile.Data) end local function onPlayerRemoving(player: Player) local profile = profiles[player] if profile then profile:EndSession() -- saves + releases the lock end profiles[player] = nil readyQueue[player] = nil end --- Returns the live, mutable data table (changes autosave). nil while loading. function DataManager.get(player: Player): ProfileData? local profile = profiles[player] return profile and profile.Data or nil end --- Run fn with the player's data now (if loaded) or as soon as it loads. function DataManager.onReady(player: Player, fn: (ProfileData) -> ()) local profile = profiles[player] if profile then task.spawn(fn, profile.Data) return end readyQueue[player] = readyQueue[player] or {} table.insert(readyQueue[player], fn) end --- Iterate every currently-loaded player's data. function DataManager.forEachLoaded(fn: (Player, ProfileData) -> ()) for player, profile in pairs(profiles) do fn(player, profile.Data) end end function DataManager.init() assert(RunService:IsServer(), "DataManager is server-only") local base = ProfileStore.New(GameConfig.ProfileStoreName, ProfileTemplate) -- In Studio, use the in-memory Mock store so testing never touches live data. if GameConfig.UseMockInStudio and RunService:IsStudio() then store = base.Mock else store = base end for _, player in ipairs(Players:GetPlayers()) do task.spawn(onPlayerAdded, player) end Players.PlayerAdded:Connect(onPlayerAdded) Players.PlayerRemoving:Connect(onPlayerRemoving) -- Ensure clean release on shutdown (BindToClose is handled by ProfileStore too, -- but ending sessions explicitly is tidy for the last frame). game:BindToClose(function() for _, player in ipairs(Players:GetPlayers()) do onPlayerRemoving(player) end end) end return DataManager