Initial MVP: drivable RX-7 vertical slice + server-authoritative systems
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:
2026-07-21 23:30:53 -04:00
commit 9a2f2d4dc9
24 changed files with 1986 additions and 0 deletions
+151
View File
@@ -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