Per user direction, the MVP is now simply driving cars on the expressway. - Removed RaceManager and Economy modules entirely - Slimmed Remotes to DataUpdated / SpawnCar / EquipCar - ProfileTemplate now just remembers the last driven car (v2) - Cars: dropped price/rep/upgrade fields; kept identity + driving stats - CarManager: stat-based tuning only; RX-7 allowlist via GameConfig.canDrive - DataManager: assign default car on join; downgrade RX-7 if de-allowlisted - Client: replaced garage/economy UI with a simple car picker + respawn - Docs: GDD/ROADMAP/README rewritten for drive-only; added docs/MESH_SWAP.md - Gates: stylua clean, selene 0/0, rojo builds
This commit is contained in:
@@ -9,8 +9,6 @@
|
||||
the most reliable, least-buggy way to get a car moving on day one. When you
|
||||
later import the real RX-7 mesh + a constraint chassis (A-Chassis style),
|
||||
you swap out `buildCarModel` and keep everything else.
|
||||
|
||||
Upgrades scale MaxSpeed / Torque / TurnSpeed so the upgrade economy is real.
|
||||
]]
|
||||
|
||||
local Players = game:GetService("Players")
|
||||
@@ -20,10 +18,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local Shared = ReplicatedStorage.Shared
|
||||
local Cars = require(Shared.Cars)
|
||||
local GameConfig = require(Shared.GameConfig)
|
||||
local ProfileTemplate = require(Shared.ProfileTemplate)
|
||||
local DataManager = require(script.Parent.DataManager)
|
||||
|
||||
type ProfileData = ProfileTemplate.ProfileData
|
||||
type Car = Cars.Car
|
||||
|
||||
local CarManager = {}
|
||||
@@ -31,17 +27,11 @@ local CarManager = {}
|
||||
local activeCars: { [Player]: Model } = {}
|
||||
local carsFolder: Folder
|
||||
|
||||
-- Convert a car's 0-100 stats + upgrade levels into VehicleSeat tuning --------
|
||||
local function computeTuning(car: Car, upgrades: { Engine: number, Tires: number, Nitro: number })
|
||||
local engineLvl = upgrades.Engine or 0
|
||||
local tiresLvl = upgrades.Tires or 0
|
||||
-- Base values derived from stats; upgrades add a percentage on top.
|
||||
-- Convert a car's 0-100 stats into VehicleSeat tuning ------------------------
|
||||
local function computeTuning(car: Car)
|
||||
local maxSpeed = 60 + car.Stats.Speed * 1.6 -- studs/sec-ish target
|
||||
maxSpeed *= (1 + engineLvl * 0.08) -- +8% top speed per engine level
|
||||
local torque = 8000 + car.Stats.Acceleration * 220
|
||||
torque *= (1 + engineLvl * 0.10)
|
||||
local turnSpeed = 20 + car.Stats.Handling * 0.35
|
||||
turnSpeed *= (1 + tiresLvl * 0.06) -- tires help turn-in
|
||||
return maxSpeed, torque, turnSpeed
|
||||
end
|
||||
|
||||
@@ -126,26 +116,14 @@ function CarManager.spawn(player: Player, carId: string?)
|
||||
if not car then
|
||||
return
|
||||
end
|
||||
-- Defense in depth: never spawn the restricted RX-7 for a non-allowlisted user,
|
||||
-- even if their save somehow contained it.
|
||||
if car.Id == GameConfig.RestrictedStarterCarId and not GameConfig.isRX7Allowed(player.UserId) then
|
||||
return
|
||||
end
|
||||
-- Only spawn cars the player owns (anti-cheat: no spawning unowned exotics).
|
||||
local owned
|
||||
for _, o in ipairs(data.OwnedCars) do
|
||||
if o.Id == id then
|
||||
owned = o
|
||||
break
|
||||
end
|
||||
end
|
||||
if not owned then
|
||||
-- Restricted car (RX-7) can only be driven by allowlisted UserIds.
|
||||
if not GameConfig.canDrive(player.UserId, car.Id, car.Restricted) then
|
||||
return
|
||||
end
|
||||
|
||||
CarManager.despawn(player)
|
||||
|
||||
local maxSpeed, torque, turnSpeed = computeTuning(car, owned.Upgrades)
|
||||
local maxSpeed, torque, turnSpeed = computeTuning(car)
|
||||
local model = buildCarModel(car, maxSpeed, torque, turnSpeed)
|
||||
model:PivotTo(spawnCFrame())
|
||||
model.Parent = carsFolder
|
||||
|
||||
@@ -71,20 +71,14 @@ local function onPlayerAdded(player: Player)
|
||||
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).
|
||||
-- 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.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
|
||||
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()
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
--!strict
|
||||
--[[
|
||||
Economy.luau (SERVER)
|
||||
The ONLY place cash/rep changes. Everything is server-authoritative: clients
|
||||
can request actions but never set their own money. After any change we push a
|
||||
fresh snapshot to the owning client via the DataUpdated remote.
|
||||
|
||||
Also owns: car buying, upgrades, and daily rewards — because those are all
|
||||
economic transactions that must be validated server-side.
|
||||
]]
|
||||
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Shared = ReplicatedStorage.Shared
|
||||
local GameConfig = require(Shared.GameConfig)
|
||||
local Cars = require(Shared.Cars)
|
||||
local ProfileTemplate = require(Shared.ProfileTemplate)
|
||||
|
||||
local DataManager = require(script.Parent.DataManager)
|
||||
|
||||
type ProfileData = ProfileTemplate.ProfileData
|
||||
type Result = { ok: boolean, reason: string? }
|
||||
|
||||
local Economy = {}
|
||||
|
||||
-- Upgrade tuning: cost scales, effect has diminishing returns -----------------
|
||||
local MAX_UPGRADE_LEVEL = 5
|
||||
local UPGRADE_BASE_COST = 500
|
||||
local UPGRADE_COST_GROWTH = 1.6 -- each level costs 1.6x the previous
|
||||
|
||||
local function upgradeCost(currentLevel: number): number
|
||||
return math.floor(UPGRADE_BASE_COST * (UPGRADE_COST_GROWTH ^ currentLevel))
|
||||
end
|
||||
Economy.upgradeCost = upgradeCost
|
||||
Economy.MAX_UPGRADE_LEVEL = MAX_UPGRADE_LEVEL
|
||||
|
||||
-- Push a snapshot to the client (thin: just the data table) ------------------
|
||||
local dataUpdatedRemote: RemoteEvent? = nil
|
||||
function Economy._setRemote(remote: RemoteEvent)
|
||||
dataUpdatedRemote = remote
|
||||
end
|
||||
|
||||
local function pushUpdate(player: Player, data: ProfileData)
|
||||
if dataUpdatedRemote then
|
||||
dataUpdatedRemote:FireClient(player, data)
|
||||
end
|
||||
end
|
||||
Economy.pushUpdate = pushUpdate
|
||||
|
||||
-- Core mutators --------------------------------------------------------------
|
||||
function Economy.addCash(player: Player, amount: number): boolean
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return false
|
||||
end
|
||||
data.Cash = math.max(0, data.Cash + amount)
|
||||
pushUpdate(player, data)
|
||||
return true
|
||||
end
|
||||
|
||||
function Economy.addRep(player: Player, amount: number): boolean
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return false
|
||||
end
|
||||
data.Rep = math.max(0, data.Rep + amount)
|
||||
pushUpdate(player, data)
|
||||
return true
|
||||
end
|
||||
|
||||
local function ownsCar(data: ProfileData, carId: string): boolean
|
||||
for _, owned in ipairs(data.OwnedCars) do
|
||||
if owned.Id == carId then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
Economy.ownsCar = ownsCar
|
||||
|
||||
-- Buying a car ---------------------------------------------------------------
|
||||
function Economy.buyCar(player: Player, carId: string): Result
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return { ok = false, reason = "Data not loaded" }
|
||||
end
|
||||
local car = Cars.Get(carId)
|
||||
if not car then
|
||||
return { ok = false, reason = "Unknown car" }
|
||||
end
|
||||
-- Restricted demo car: the RX-7 can only ever be owned by allowlisted UserIds.
|
||||
if car.Id == GameConfig.RestrictedStarterCarId and not GameConfig.isRX7Allowed(player.UserId) then
|
||||
return { ok = false, reason = "This car is not available" }
|
||||
end
|
||||
if ownsCar(data, carId) then
|
||||
return { ok = false, reason = "You already own this car" }
|
||||
end
|
||||
if data.Rep < car.RepRequired then
|
||||
return { ok = false, reason = string.format("Requires %d rep", car.RepRequired) }
|
||||
end
|
||||
if data.Cash < car.Price then
|
||||
return { ok = false, reason = "Not enough cash" }
|
||||
end
|
||||
|
||||
data.Cash -= car.Price
|
||||
table.insert(data.OwnedCars, { Id = carId, Upgrades = { Engine = 0, Tires = 0, Nitro = 0 } })
|
||||
pushUpdate(player, data)
|
||||
return { ok = true }
|
||||
end
|
||||
|
||||
-- Upgrading a car part -------------------------------------------------------
|
||||
local VALID_PARTS = { Engine = true, Tires = true, Nitro = true }
|
||||
|
||||
function Economy.upgradeCar(player: Player, carId: string, part: string): Result
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return { ok = false, reason = "Data not loaded" }
|
||||
end
|
||||
if not VALID_PARTS[part] then
|
||||
return { ok = false, reason = "Invalid part" }
|
||||
end
|
||||
local owned
|
||||
for _, o in ipairs(data.OwnedCars) do
|
||||
if o.Id == carId then
|
||||
owned = o
|
||||
break
|
||||
end
|
||||
end
|
||||
if not owned then
|
||||
return { ok = false, reason = "You don't own this car" }
|
||||
end
|
||||
local level = owned.Upgrades[part] or 0
|
||||
if level >= MAX_UPGRADE_LEVEL then
|
||||
return { ok = false, reason = "Already maxed" }
|
||||
end
|
||||
local cost = upgradeCost(level)
|
||||
if data.Cash < cost then
|
||||
return { ok = false, reason = "Not enough cash" }
|
||||
end
|
||||
|
||||
data.Cash -= cost
|
||||
owned.Upgrades[part] = level + 1
|
||||
pushUpdate(player, data)
|
||||
return { ok = true }
|
||||
end
|
||||
|
||||
-- Daily reward ---------------------------------------------------------------
|
||||
function Economy.claimDaily(player: Player): Result & { cash: number?, rep: number? }
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return { ok = false, reason = "Data not loaded" }
|
||||
end
|
||||
local now = os.time()
|
||||
if now - data.LastDailyClaim < GameConfig.DailyCooldownSeconds then
|
||||
local remaining = GameConfig.DailyCooldownSeconds - (now - data.LastDailyClaim)
|
||||
local hours = math.ceil(remaining / 3600)
|
||||
return { ok = false, reason = string.format("Come back in ~%dh", hours) }
|
||||
end
|
||||
data.LastDailyClaim = now
|
||||
data.Cash += GameConfig.DailyRewardCash
|
||||
data.Rep += GameConfig.DailyRewardRep
|
||||
pushUpdate(player, data)
|
||||
return { ok = true, cash = GameConfig.DailyRewardCash, rep = GameConfig.DailyRewardRep }
|
||||
end
|
||||
|
||||
return Economy
|
||||
@@ -1,107 +0,0 @@
|
||||
--!strict
|
||||
--[[
|
||||
RaceManager.luau (SERVER)
|
||||
MVP race: a solo time-trial down the expressway. Player starts a race, drives
|
||||
to the FinishLine, and the SERVER validates the finish (touch of the real
|
||||
finish part) before paying out. This keeps rewards server-authoritative — the
|
||||
client can SAY it won, but it only gets paid if the server saw the touch.
|
||||
|
||||
This is intentionally the simplest thing that proves the race->reward->save
|
||||
loop. Head-to-head, checkpoints, and drag mode come later.
|
||||
]]
|
||||
|
||||
local Players = game:GetService("Players")
|
||||
local Workspace = game:GetService("Workspace")
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Shared = ReplicatedStorage.Shared
|
||||
local GameConfig = require(Shared.GameConfig)
|
||||
local Economy = require(script.Parent.Economy)
|
||||
local DataManager = require(script.Parent.DataManager)
|
||||
|
||||
local RaceManager = {}
|
||||
|
||||
type ActiveRace = {
|
||||
startTime: number,
|
||||
connection: RBXScriptConnection,
|
||||
}
|
||||
|
||||
local active: { [Player]: ActiveRace } = {}
|
||||
local RACE_TIMEOUT = 180 -- auto-cancel a race after 3 min
|
||||
|
||||
local function finishFor(player: Player, won: boolean)
|
||||
local race = active[player]
|
||||
if not race then
|
||||
return
|
||||
end
|
||||
race.connection:Disconnect()
|
||||
active[player] = nil
|
||||
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return
|
||||
end
|
||||
data.TotalRaces += 1
|
||||
|
||||
local payout = GameConfig.RaceBaseReward
|
||||
if won then
|
||||
payout += GameConfig.RaceWinBonus
|
||||
data.Wins += 1
|
||||
end
|
||||
Economy.addCash(player, payout)
|
||||
Economy.addRep(player, GameConfig.RaceRepReward)
|
||||
-- addCash/addRep already push updates; TotalRaces/Wins ride along on next push.
|
||||
Economy.pushUpdate(player, data)
|
||||
end
|
||||
|
||||
--- Start a race for the player. Returns a Result.
|
||||
function RaceManager.start(player: Player, _raceId: string?): { ok: boolean, reason: string? }
|
||||
if active[player] then
|
||||
return { ok = false, reason = "Already racing" }
|
||||
end
|
||||
local map = Workspace:FindFirstChild("Map")
|
||||
local finish = map and map:FindFirstChild("FinishLine")
|
||||
if not (finish and finish:IsA("BasePart")) then
|
||||
return { ok = false, reason = "No finish line in map" }
|
||||
end
|
||||
|
||||
local conn
|
||||
conn = finish.Touched:Connect(function(hit)
|
||||
-- Was this the racing player's car/character that touched the line?
|
||||
local character = player.Character
|
||||
local carModel = hit:FindFirstAncestorOfClass("Model")
|
||||
local isOwnCar = carModel and carModel:GetAttribute("OwnerUserId") == player.UserId
|
||||
local isSelf = character and hit:IsDescendantOf(character)
|
||||
if isOwnCar or isSelf then
|
||||
finishFor(player, true)
|
||||
end
|
||||
end)
|
||||
|
||||
active[player] = { startTime = os.clock(), connection = conn }
|
||||
|
||||
-- Timeout guard
|
||||
task.delay(RACE_TIMEOUT, function()
|
||||
if active[player] and os.clock() - active[player].startTime >= RACE_TIMEOUT then
|
||||
-- cancel with no reward (won = false but we just clean up)
|
||||
local race = active[player]
|
||||
if race then
|
||||
race.connection:Disconnect()
|
||||
active[player] = nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
return { ok = true }
|
||||
end
|
||||
|
||||
function RaceManager.init()
|
||||
Players.PlayerRemoving:Connect(function(player)
|
||||
local race = active[player]
|
||||
if race then
|
||||
race.connection:Disconnect()
|
||||
active[player] = nil
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return RaceManager
|
||||
+31
-59
@@ -1,13 +1,14 @@
|
||||
--!strict
|
||||
--[[
|
||||
init.server.luau (SERVER ENTRY POINT)
|
||||
The one script Roblox runs on the server. It:
|
||||
MVP scope: JUST DRIVING. The server:
|
||||
1. Creates the remotes
|
||||
2. Boots every manager in dependency order
|
||||
3. Builds the map + lighting mood
|
||||
4. Wires all remote handlers to server-authoritative logic
|
||||
2. Boots DataManager (safe saves), CarManager, AntiCheat
|
||||
3. Builds the expressway map + lighting mood
|
||||
4. Gives each player their car and lets them switch cars
|
||||
|
||||
Everything downstream is server-authoritative: clients REQUEST, server DECIDES.
|
||||
Server-authoritative: the client REQUESTS a car, the server DECIDES (and
|
||||
enforces the RX-7 allowlist).
|
||||
]]
|
||||
|
||||
local Players = game:GetService("Players")
|
||||
@@ -15,11 +16,11 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Shared = ReplicatedStorage.Shared
|
||||
local Remotes = require(Shared.Remotes)
|
||||
local Cars = require(Shared.Cars)
|
||||
local GameConfig = require(Shared.GameConfig)
|
||||
|
||||
local DataManager = require(script.DataManager)
|
||||
local Economy = require(script.Economy)
|
||||
local CarManager = require(script.CarManager)
|
||||
local RaceManager = require(script.RaceManager)
|
||||
local MapBuilder = require(script.MapBuilder)
|
||||
local AntiCheat = require(script.AntiCheat)
|
||||
|
||||
@@ -28,29 +29,28 @@ Remotes.init()
|
||||
local rDataUpdated = Remotes.get("DataUpdated") :: RemoteEvent
|
||||
local rSpawnCar = Remotes.get("SpawnCar") :: RemoteEvent
|
||||
local rEquipCar = Remotes.get("EquipCar") :: RemoteFunction
|
||||
local rBuyCar = Remotes.get("BuyCar") :: RemoteFunction
|
||||
local rUpgradeCar = Remotes.get("UpgradeCar") :: RemoteFunction
|
||||
local rClaimDaily = Remotes.get("ClaimDaily") :: RemoteFunction
|
||||
local rStartRace = Remotes.get("StartRace") :: RemoteFunction
|
||||
|
||||
Economy._setRemote(rDataUpdated)
|
||||
local function pushCar(player: Player)
|
||||
local data = DataManager.get(player)
|
||||
if data then
|
||||
rDataUpdated:FireClient(player, data.EquippedCarId)
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. Boot managers ----------------------------------------------------------
|
||||
DataManager.init()
|
||||
CarManager.init()
|
||||
RaceManager.init()
|
||||
AntiCheat.init()
|
||||
|
||||
-- 3. Build the world --------------------------------------------------------
|
||||
MapBuilder.build()
|
||||
|
||||
-- 4. Player lifecycle: push initial data + auto-spawn their car -------------
|
||||
-- 4. Player lifecycle: push car id + auto-spawn their car -------------------
|
||||
Players.PlayerAdded:Connect(function(player)
|
||||
DataManager.onReady(player, function(data)
|
||||
Economy.pushUpdate(player, data)
|
||||
DataManager.onReady(player, function()
|
||||
pushCar(player)
|
||||
end)
|
||||
player.CharacterAdded:Connect(function()
|
||||
-- When the character (re)spawns, give them their equipped car after a beat.
|
||||
DataManager.onReady(player, function(data)
|
||||
task.wait(1)
|
||||
CarManager.spawn(player, data.EquippedCarId)
|
||||
@@ -58,59 +58,31 @@ Players.PlayerAdded:Connect(function(player)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- 5. Remote handlers (all validated server-side) ----------------------------
|
||||
rSpawnCar.OnServerEvent:Connect(function(player, carId)
|
||||
if carId ~= nil and typeof(carId) ~= "string" then
|
||||
return
|
||||
end
|
||||
CarManager.spawn(player, carId)
|
||||
-- 5. Remote handlers (validated server-side) --------------------------------
|
||||
rSpawnCar.OnServerEvent:Connect(function(player)
|
||||
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
|
||||
end)
|
||||
|
||||
rEquipCar.OnServerInvoke = function(player, carId)
|
||||
if typeof(carId) ~= "string" then
|
||||
return false
|
||||
end
|
||||
local car = Cars.Get(carId)
|
||||
if not car then
|
||||
return false
|
||||
end
|
||||
-- Enforce the RX-7 allowlist server-side.
|
||||
if not GameConfig.canDrive(player.UserId, car.Id, car.Restricted) then
|
||||
return false
|
||||
end
|
||||
local data = DataManager.get(player)
|
||||
if not data or not Economy.ownsCar(data, carId) then
|
||||
if not data then
|
||||
return false
|
||||
end
|
||||
data.EquippedCarId = carId
|
||||
Economy.pushUpdate(player, data)
|
||||
pushCar(player)
|
||||
CarManager.spawn(player, carId)
|
||||
return true
|
||||
end
|
||||
|
||||
rBuyCar.OnServerInvoke = function(player, carId)
|
||||
if typeof(carId) ~= "string" then
|
||||
return { ok = false, reason = "Bad request" }
|
||||
end
|
||||
return Economy.buyCar(player, carId)
|
||||
end
|
||||
|
||||
rUpgradeCar.OnServerInvoke = function(player, carId, part)
|
||||
if typeof(carId) ~= "string" or typeof(part) ~= "string" then
|
||||
return { ok = false, reason = "Bad request" }
|
||||
end
|
||||
local result = Economy.upgradeCar(player, carId, part)
|
||||
-- Re-spawn so upgraded tuning takes effect immediately if it's the active car.
|
||||
if result.ok then
|
||||
local data = DataManager.get(player)
|
||||
if data and data.EquippedCarId == carId then
|
||||
CarManager.spawn(player, carId)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
rClaimDaily.OnServerInvoke = function(player)
|
||||
return Economy.claimDaily(player)
|
||||
end
|
||||
|
||||
rStartRace.OnServerInvoke = function(player, raceId)
|
||||
if raceId ~= nil and typeof(raceId) ~= "string" then
|
||||
return { ok = false, reason = "Bad request" }
|
||||
end
|
||||
return RaceManager.start(player, raceId)
|
||||
end
|
||||
|
||||
print("[FnF] Server booted: managers online, map built, remotes wired.")
|
||||
print("[FnF] Server booted (drive-only MVP): map built, car system online.")
|
||||
|
||||
Reference in New Issue
Block a user