Files
tokyo-wangan-racing/src/server/DataManager.luau
T
Hermes 681c3462c6 Rename: Tokyo Wangan Racing (was Fast & Furious: Tokyo Expressway)
- 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
2026-07-30 20:03:52 -04:00

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