- Title in README + GDD, UI/log prefixes FnF -> Wangan - Rojo project name -> TokyoWanganRacing - Fix undefined `H` in CarManager seat CFrame (selene error) -> Chassis.BODY_SIZE.Y
162 lines
5.0 KiB
Luau
162 lines
5.0 KiB
Luau
--!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("[Wangan] Your data could not be loaded. Please rejoin.")
|
|
return
|
|
end
|
|
|
|
profile:AddUserId(player.UserId) -- GDPR-friendly association
|
|
profile:Reconcile() -- fill missing template keys
|
|
|
|
-- Assign the player's default car if they don't have one yet, and validate:
|
|
-- if their saved car is the restricted RX-7 but they're no longer allowlisted,
|
|
-- fall back to the public default so they can still drive.
|
|
local data = profile.Data
|
|
if data.EquippedCarId == "" then
|
|
data.EquippedCarId = GameConfig.defaultCarFor(player.UserId)
|
|
elseif data.EquippedCarId == GameConfig.RestrictedCarId and not GameConfig.isRX7Allowed(player.UserId) then
|
|
data.EquippedCarId = GameConfig.PublicDefaultCarId
|
|
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("[Wangan] 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
|