Initial MVP: drivable RX-7 vertical slice + server-authoritative systems
CI / quality (push) Failing after 5m53s
CI / quality (push) Failing after 5m53s
- Rojo + Wally project; ProfileStore (loleris) for safe saves - Shared: GameConfig, Cars (RX-7 free starter), ProfileTemplate, Remotes - Server: DataManager, Economy, CarManager, RaceManager, MapBuilder, AntiCheat - Client: neon HUD + Garage/Dealership UI (buy/equip/upgrade), race/daily - Procedural Tokyo expressway (spawn pad, finish line, neon pylons, kill walls) - CI workflow (Gitea/GitHub), Makefile, selene+stylua clean, rojo builds - Docs: README, GDD, realistic solo ROADMAP
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
--!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
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user