From 07a67204ca05fd54bc4c699c4f02df9c71fb2298 Mon Sep 17 00:00:00 2001 From: "R3AL.CH1CK3N" Date: Wed, 22 Jul 2026 00:11:11 -0400 Subject: [PATCH] Scope cut: drive-only MVP (remove racing + economy) 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 --- README.md | 62 ++++---- docs/GDD.md | 72 ++++----- docs/MESH_SWAP.md | 84 +++++++++++ docs/ROADMAP.md | 82 +++++------ src/client/init.client.luau | 252 ++++++++++---------------------- src/server/CarManager.luau | 32 +--- src/server/DataManager.luau | 20 +-- src/server/Economy.luau | 166 --------------------- src/server/RaceManager.luau | 107 -------------- src/server/init.server.luau | 90 ++++-------- src/shared/Cars.luau | 42 ++---- src/shared/GameConfig.luau | 59 ++++---- src/shared/ProfileTemplate.luau | 41 +----- src/shared/Remotes.luau | 19 +-- 14 files changed, 347 insertions(+), 781 deletions(-) create mode 100644 docs/MESH_SWAP.md delete mode 100644 src/server/Economy.luau delete mode 100644 src/server/RaceManager.luau diff --git a/README.md b/README.md index c62ef29..9147e6f 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ -# Fast & Furious: Tokyo Expressway — MVP +# Fast & Furious: Tokyo Expressway — MVP (drive-only) -A Roblox street-racing simulator. Start in a 1993 Mazda RX-7 (the owner's real car), -race the neon Tokyo expressway, earn cash + rep, buy and upgrade faster cars. +A Roblox game where you cruise a neon Tokyo expressway at night in cool cars — +starting with a real 1993 Mazda RX-7. **This MVP is deliberately tiny: just +driving.** No racing, money, or upgrades yet — the goal is to nail the driving +feel and be testable fast. See `docs/ROADMAP.md` for what comes next. -**Status:** Playable vertical slice. Drive → race → earn → save works end to end. -Built code-first — there is nothing to hand-build in Studio to run it. +**Status:** Playable. Spawn → drive → switch cars → respawn. CI green on a +self-hosted Gitea runner. Built code-first — nothing to hand-build in Studio. --- ## Prerequisites -Toolchain is managed by [Rokit](https://github.com/rojo-rbx/rokit). Once Rokit is -installed and on your `PATH`: +Toolchain is managed by [Rokit](https://github.com/rojo-rbx/rokit). With Rokit on +your `PATH`: ```bash -rokit install # installs rojo, wally, selene, stylua at pinned versions +rokit install # rojo, wally, selene, stylua at pinned versions wally install # restores ProfileStore into ServerPackages/ ``` -Installed tools (see `rokit.toml`): - | Tool | Purpose | |---|---| | rojo | Sync/build code ↔ Roblox place | @@ -30,17 +30,16 @@ Installed tools (see `rokit.toml`): ## Build & run ```bash -# Build a standalone place file you can open in Studio: -rojo build --output build.rbxl - +rojo build --output build.rbxl # standalone place file to open in Studio # OR live-sync into an open Studio place (install the Rojo Studio plugin): rojo serve ``` -Then in Studio press **Play**. You spawn on the expressway; use the bottom bar: -**GARAGE** (buy/equip/upgrade), **SPAWN CAR**, **RACE**, **DAILY**. +In Studio press **Play**. You spawn on the expressway in your car. Bottom bar: +**CARS** (switch car) and **RESPAWN** (re-spawn if you flip). The RX-7 only +appears for allowlisted accounts. -## Quality gates (run before every commit) +## Quality gates (CI enforces these on every push) ```bash stylua --check src/ # formatting @@ -48,41 +47,36 @@ selene src/ # lint rojo build --output build.rbxl # must compile ``` -CI runs all three on every push (see `.github/` → mirrored to Gitea Actions). +CI runs on a self-hosted **Gitea Actions** runner on UNRAID-1 (Ubuntu 24.04 job +container — rojo's binary needs glibc 2.39). Workflow: `.gitea/workflows/ci.yml`. --- ## Architecture (server-authoritative) -Clients **request**, the server **decides**. Money, rep, and car ownership can -never be set by the client. +Clients **request**, the server **decides**. The RX-7 allowlist and anti-cheat +are server-side. ``` ReplicatedStorage/Shared GameConfig, Cars, ProfileTemplate, Remotes ServerScriptService/Server init.server + managers (below) ServerScriptService/ServerPackages ProfileStore (Wally) -StarterPlayerScripts/Client init.client (HUD + Garage UI) +StarterPlayerScripts/Client init.client (HUD + car picker) ``` | Module | Responsibility | |---|---| -| `DataManager` | Wraps **ProfileStore** — session locking, reconcile, Studio mock. The data-safety foundation. | -| `Economy` | The only place cash/rep/ownership change. Buy, upgrade, daily, race payouts. | -| `CarManager` | Spawns the equipped car (VehicleSeat-driven placeholder) with upgrade-scaled tuning. | -| `RaceManager` | Solo time-trial; server validates the finish-line touch before paying out. | -| `MapBuilder` | Procedurally builds the expressway, spawn pad, finish line, neon pylons, kill walls. | +| `DataManager` | Wraps **ProfileStore** — session locking, reconcile, Studio mock. Remembers your last car. | +| `CarManager` | Spawns the equipped car (VehicleSeat-driven placeholder) and enforces the RX-7 allowlist. | +| `MapBuilder` | Procedurally builds the expressway, spawn pad, neon pylons, kill walls. | | `AntiCheat` | Movement sanity check (speed/teleport) — a speed bump, not a fortress. | -See `docs/GDD.md` for the game design doc and `docs/ROADMAP.md` for the realistic -solo-dev plan. +The RX-7 demo-lock: `GameConfig.RX7AllowedUserIds` holds the owner's + son's +Roblox UserIds; only they can select/drive the RX-7 (enforced in `CarManager` and +the `EquipCar` remote). ## Replacing the placeholder RX-7 with the real mesh -The car is currently a coloured block + `VehicleSeat` so the game is drivable today. -When you import the real RX-7 model: -1. Drop the mesh/model into `ReplicatedStorage` (or an assets folder). -2. Rewrite only `CarManager.buildCarModel` to clone it and weld a `VehicleSeat`. -3. Everything else (economy, upgrades, races, saving) keeps working unchanged. - -Reference photos of the real car live on the NAS at +One function changes: `CarManager.buildCarModel`. Full step-by-step in +`docs/MESH_SWAP.md`. Reference photos live on the NAS at `unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/`. diff --git a/docs/GDD.md b/docs/GDD.md index 64ef9a0..951c5a5 100644 --- a/docs/GDD.md +++ b/docs/GDD.md @@ -1,52 +1,38 @@ # Game Design Document — Fast & Furious: Tokyo Expressway (MVP) ## Vision -A progression-fantasy street racer. You start in a humble-but-iconic 1993 Mazda -RX-7 and claw your way up to hypercars on a neon-drenched Tokyo expressway at night. -Easy to pick up, satisfying to grind, fair to spend on. +A neon Tokyo expressway you can cruise at night in cool cars — starting with a +real 1993 Mazda RX-7. The long-term dream is a full street-racing simulator, but +the **MVP is deliberately tiny: just drive.** -## The one-sentence pitch -"Start in the RX-7, own the expressway." +## MVP scope (what's built) — JUST DRIVING +- Spawn on a neon Tokyo expressway at night. +- Drive a car around the highway (built on Roblox's reliable VehicleSeat). +- Switch between cars via a simple "CARS" picker. +- Respawn your car if you flip or fall off. +- The **RX-7 is demo-locked** to the owner + son (Roblox UserId allowlist, + enforced server-side). Everyone else drives the public cars. +- Your last car is remembered between sessions (ProfileStore). -## Core loop (the addictive 60 seconds) -1. **Spawn** on the expressway in your equipped car. -2. **Race** the time-trial down the highway to the finish line. -3. **Earn** cash + reputation for finishing. -4. **Spend** in the Garage: upgrade your current car or buy the next tier. -5. A faster car / better tune makes the next race feel better → back to step 1. +That's it. No racing, no money, no rep, no upgrades, no daily rewards. The point +of the MVP is to get the car feeling good on the road and be testable fast. -Daily rewards + rising rep gates give a reason to come back tomorrow. +## Why this cut +Driving feel is the hardest, most important part of a car game. Everything else +(racing, economy, progression) is worthless if the car isn't fun to drive. So the +MVP nails driving first; the rest is layered on later once it feels right. -## Pillars -- **Progression fantasy** — junker → hypercar, always a visible next goal. -- **Nighttime Tokyo vibe** — neon, speed, style. -- **Fair monetization** — spending accelerates the grind, never buys wins. -- **Server-authoritative** — no exploiting money or unlocking cars. +## Pillars (for the eventual full game) +- Progression fantasy — junker → hypercar (LATER). +- Nighttime Tokyo vibe — neon, speed, style (NOW: the map). +- Fair monetization (LATER). +- Server-authoritative — no exploiting (NOW: allowlist + anti-cheat foundations). -## MVP feature set (what's built) -- 5-tier car catalogue; **RX-7 is the free starter** (Tier 2). -- Drivable cars via VehicleSeat (proven, swap-in real mesh later). -- Procedural expressway map with kill-walls (can't leave the road). -- Garage + Dealership UI: view / buy / equip / upgrade. -- Upgrade system: Engine / Tires / Nitro, cost-scaled with diminishing returns. -- Solo time-trial race with server-validated payouts. -- Reputation gates on higher-tier cars. -- Daily login reward. -- Robust saving via ProfileStore (session-locked, reconciling). -- Basic movement anti-cheat. +## Deferred (see ROADMAP) +Racing, checkpoints, money/economy, reputation, upgrades, customization, daily +rewards, traffic AI, leaderboards, monetization, crews/PvP, full city map. -## Deliberately deferred (see ROADMAP) -Traffic AI, head-to-head/drag racing, car customization cosmetics, crews/PvP, -full city map, mobile-tuned optimization pass, monetization products. - -## Economy first-pass numbers (tune freely in `GameConfig`) -- Start: $1,500, 0 rep, own the RX-7. -- Race finish: +$650 (base+win) and +15 rep. -- Daily: +$300, +10 rep. -- Upgrades: base $500, ×1.6 per level, cap level 5 per part. -- Car tiers: Junk $800 → S15 $12k (50 rep) → R34 $45k (180 rep) → Hyper $250k (600 rep). - -## Win condition / retention -No hard "end" — the chase up the tiers plus daily rewards and (future) leaderboards -supply the long tail. First-session goal: earn enough to feel one upgrade land -inside the first 10 minutes. +## Cars in the picker +RX-7 (restricted), City Runabout (public default), Silvia S15, Skyline GT-R R34, +and a stylized hypercar. Stats (Speed/Accel/Handling, 0-100) only affect driving +feel — there are no prices or unlocks in the MVP. diff --git a/docs/MESH_SWAP.md b/docs/MESH_SWAP.md new file mode 100644 index 0000000..1399fed --- /dev/null +++ b/docs/MESH_SWAP.md @@ -0,0 +1,84 @@ +# Swapping the placeholder RX-7 for the real mesh (M1) + +Right now every car is a coloured block + a `VehicleSeat` so the game is drivable +today. This is the **one file** you touch to make the RX-7 a real model: +`src/server/CarManager.luau` → the `buildCarModel` function. + +Nothing else (saving, the allowlist, car switching, the map, anti-cheat) needs to +change. That's the whole point of isolating model-building in one function. + +## Reference photos +The real car's reference photos live on the NAS (too large to commit to git): + +``` +unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/ +``` + +A single downscaled reference is included at `assets/reference/rx7-reference.png` +for quick lookups. + +## Step 1 — Get a model into Roblox +Options, easiest first: +1. **Buy or find a free RX-7 FD model** on the Roblox Creator Marketplace/toolbox, + or +2. **Model it** in Blender from the reference photos and import the mesh + (`.fbx`/`.obj`) via Studio's 3D importer, or +3. Commission one. + +You want a single `Model` whose parts are welded together, with a clear "body" +part you can drive. Keep the part count low (mobile perf). + +## Step 2 — Store it where Rojo/Studio can clone it +Put the finished `Model` under `ReplicatedStorage` (e.g. a `CarModels` folder). +Two ways to manage it: +- **Simple:** keep it in the Studio place file as a real Instance (not synced by + Rojo). Fine for a single hero car. +- **Version-controlled:** export the model to a `.rbxm` and reference it from the + Rojo project so it lives in git. See Rojo's `$path` docs for `.rbxm` model files. + +## Step 3 — Rewrite `buildCarModel` to clone the real mesh +Replace the block-building body of `buildCarModel(car, maxSpeed, torque, turnSpeed)` +with something like: + +```lua +local CAR_MODELS = ReplicatedStorage:WaitForChild("CarModels") + +local function buildCarModel(car, maxSpeed, torque, turnSpeed) + -- Look up a real model by car id; fall back to the block builder if missing. + local template = CAR_MODELS:FindFirstChild(car.Id) + if not template then + return buildBlockCar(car, maxSpeed, torque, turnSpeed) -- keep the old code as a fallback + end + + local model = template:Clone() + + -- Ensure it has a VehicleSeat named "DriveSeat" welded to the driving body. + local seat = model:FindFirstChild("DriveSeat") + if not seat then + seat = Instance.new("VehicleSeat") + seat.Name = "DriveSeat" + -- weld seat to model.PrimaryPart, position it in the cabin... + seat.Parent = model + end + seat.MaxSpeed = maxSpeed + seat.Torque = torque + seat.TurnSpeed = turnSpeed + + assert(model.PrimaryPart, "Car model needs a PrimaryPart set") + return model +end +``` + +Keep the current block-builder renamed as `buildBlockCar` so any car without a +real mesh still works. That way you can import cars one at a time. + +## Step 4 (recommended, M1) — Move to a real chassis +The `VehicleSeat` is great for "it drives today" but for real suspension/grip, +adopt a proven **constraint chassis** (A-Chassis or a modern equivalent). At that +point `buildCarModel` clones the chassis rig and attaches the RX-7 body to it. +Still one function; still no changes elsewhere. + +## Verify +- `rojo build --output build.rbxl` compiles. +- In Studio: Play → you spawn in the real RX-7 → it drives → it saves. +- Non-allowlisted test account still can't select the RX-7 (server enforced). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1af504e..01ddda7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,62 +1,60 @@ # Roadmap — realistic solo-dev plan -This replaces the original doc's optimistic 12-week timeline. It assumes **one -part-time developer learning as they go**. Ship the vertical slice, then layer -polish. Each milestone is independently playable/testable. +Assumes **one part-time developer learning as they go**. The MVP is intentionally +tiny — *just driving* — so there's a fun, testable thing FAST. Everything else is +layered on only after the driving feels good. -> Rule of thumb: the original guide's "weeks" are really "milestones." Expect -> **4–8 months** of part-time work to a polished, launch-ready MVP. That's normal. -> The goal of this order is that you always have something that *runs*. +> Expect **months, not weeks** to a polished full game. That's normal. This order +> guarantees you always have something that runs. --- -## ✅ M0 — Vertical slice (DONE — this repo) -Drive → race → earn → buy/upgrade → **save**. Server-authoritative. Placeholder -RX-7 block car, procedural expressway. Proves the whole stack end to end. +## ✅ M0 — Drive-only MVP (DONE — this repo) +Spawn on the neon expressway, drive a car, switch cars, respawn. RX-7 demo-locked +to owner + son. Cars persist between sessions. CI green on a self-hosted runner. -## M1 — Make it feel good (the real work) +## M1 — Make driving feel GOOD (the real work, do this next) - [ ] Import the **real RX-7 mesh**; swap `CarManager.buildCarModel` only. -- [ ] Adopt a proven **constraint chassis** (A-Chassis or modern equivalent) for - real suspension/grip instead of the raw VehicleSeat. Do NOT hand-roll physics. -- [ ] Tune driving feel: acceleration curve, grip, camera. -- [ ] Sound: engine loop, boost, tire screech. -**Exit test:** the RX-7 is genuinely fun to just drive around. + See `docs/MESH_SWAP.md` for the exact steps. +- [ ] Adopt a proven **constraint chassis** (A-Chassis or a modern equivalent) + for real suspension/grip instead of the raw VehicleSeat. Do NOT hand-roll + physics. +- [ ] Tune acceleration curve, grip, and the chase camera. +- [ ] Sound: engine loop, tire screech, boost. +**Exit test:** the RX-7 is genuinely fun to just drive around. Ship nothing else +until this is true. -## M2 — Race depth -- [ ] Checkpoints + lap/segment timing (extend `RaceManager`). -- [ ] Personal best times saved to profile. -- [ ] A second race type (drag / point-to-point). -- [ ] Countdown + results screen. +## M2 — More cars + polish (still no racing) +- [ ] Import a few more real car meshes. +- [ ] Better map: more expressway sections, ramps, tunnels, scenery. +- [ ] Day/night, weather mood. -## M3 — Content & progression polish -- [ ] Flesh out the 25-car catalogue across tiers. -- [ ] Car customization cosmetics (rims, wraps, neon) — visual only for MVP. -- [ ] Daily missions (not just login): "win 3 races", "earn $5k". -- [ ] Tutorial for the first 10 minutes. +## M3 — Racing (the first "game" layer) +- [ ] Checkpoints + timing (a new RaceManager — the old one was removed in M0). +- [ ] Personal best times. +- [ ] Simple point-to-point and drag modes. -## M4 — Systems hardening -- [ ] Leaderboards (OrderedDataStore: top rep, fastest times). -- [ ] Anti-cheat pass 2 (validate race times against car stats). -- [ ] DataStore stress test / backup-version restore drill. -- [ ] Performance pass: StreamingEnabled tuning, part budget, mobile test. +## M4 — Progression & economy (only once racing is fun) +- [ ] Money from races, a garage, buying/unlocking cars, upgrades, reputation. + (These systems existed as scaffolding and were cut for the MVP; reintroduce + them deliberately when they have a purpose.) -## M5 — Monetization (only after it's fun) -- [ ] 2× Cash gamepass (the classic first product). -- [ ] VIP membership (daily bonus, cosmetic-only exclusives). -- [ ] Nitro/boost dev-products — **never** pay-to-win on ranked races. -- [ ] Receipt handling via ProfileStore `LastSavedData`. +## M5 — Systems hardening & monetization +- [ ] Leaderboards, anti-cheat pass 2, DataStore backup drill. +- [ ] Performance pass (StreamingEnabled, part budget, mobile). +- [ ] Fair gamepasses/products — never pay-to-win. ## M6 — Traffic AI (deferred hardest system) -- [ ] Start with **spline-following** dummy cars on fixed paths (cheap, no lag). - Only attempt true pathfinding AI if performance allows. -- [ ] Spawn/despawn by distance to keep part count sane. +- [ ] Spline-following dummy cars first (cheap). True AI only if perf allows. --- ## Standing engineering rules -- Every change must pass `stylua --check`, `selene`, and `rojo build` (CI enforces). -- Keep the client dumb: it requests, the server decides. No exceptions for money. -- Add new tunables to `GameConfig`, not scattered magic numbers. +- Every change must pass `stylua --check`, `selene`, and `rojo build` — CI on the + self-hosted Gitea runner enforces all three on every push. +- Keep the client dumb: it requests, the server decides. The RX-7 allowlist and + anti-cheat live server-side. +- Add tunables to `GameConfig`, not scattered magic numbers. - Add a save key by adding it to `ProfileTemplate` (auto-reconciles); never remove - a key without a migration. + a key without a migration (bump `ProfileStoreName` `_vN` in dev to wipe). - Test in Studio after every server change (ProfileStore uses a mock store there). diff --git a/src/client/init.client.luau b/src/client/init.client.luau index 97ce903..e011f32 100644 --- a/src/client/init.client.luau +++ b/src/client/init.client.luau @@ -1,10 +1,12 @@ --!strict --[[ - init.client.luau (CLIENT ENTRY POINT) - Builds the HUD (cash / rep / car), a Garage+Dealership panel, and a Daily - button — all in code so there's nothing to build in Studio. The client is - purely presentational: it shows server-provided data and sends requests. - It never decides money or ownership. + init.client.luua (CLIENT ENTRY POINT) + MVP scope: JUST DRIVING. Minimal UI: + - A small HUD showing the current car name. + - A "CARS" button opening a picker to switch cars (RX-7 shown only if you + can drive it — the server enforces this regardless). + - A "RESPAWN CAR" button to re-spawn your car if you flip or fall off. + The client is presentational; the server owns everything. ]] local Players = game:GetService("Players") @@ -17,17 +19,11 @@ local Cars = require(Shared.Cars) local player = Players.LocalPlayer local playerGui = player:WaitForChild("PlayerGui") --- Remotes ------------------------------------------------------------------- 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 --- Local cache of the latest server snapshot --------------------------------- -local latest: any = nil +local currentCarId = "" -- UI helpers ---------------------------------------------------------------- local NEON_PINK = Color3.fromRGB(255, 40, 120) @@ -55,10 +51,10 @@ local screen = make("ScreenGui", { ZIndexBehavior = Enum.ZIndexBehavior.Sibling, }, playerGui) --- ===== HUD (top-left): cash / rep / car ==================================== +-- HUD: current car ---------------------------------------------------------- local hud = make("Frame", { Name = "HUD", - Size = UDim2.fromOffset(260, 96), + Size = UDim2.fromOffset(260, 52), Position = UDim2.fromOffset(16, 16), BackgroundColor3 = PANEL_BG, BackgroundTransparency = 0.15, @@ -67,44 +63,22 @@ local hud = make("Frame", { corner(hud, 10) make("UIStroke", { Color = NEON_PINK, Thickness = 1.5, Transparency = 0.3 }, hud) -local cashLabel = make("TextLabel", { - Size = UDim2.new(1, -20, 0, 28), - Position = UDim2.fromOffset(12, 8), +local carLabel = make("TextLabel", { + Size = UDim2.new(1, -20, 1, 0), + Position = UDim2.fromOffset(12, 0), BackgroundTransparency = 1, Font = Enum.Font.GothamBold, - TextColor3 = Color3.fromRGB(120, 255, 170), - TextXAlignment = Enum.TextXAlignment.Left, - TextSize = 20, - Text = "$ --", -}, hud) - -local repLabel = make("TextLabel", { - Size = UDim2.new(1, -20, 0, 22), - Position = UDim2.fromOffset(12, 38), - BackgroundTransparency = 1, - Font = Enum.Font.GothamMedium, - TextColor3 = NEON_BLUE, + TextColor3 = Color3.fromRGB(230, 230, 240), TextXAlignment = Enum.TextXAlignment.Left, TextSize = 16, - Text = "REP --", -}, hud) - -local carLabel = make("TextLabel", { - Size = UDim2.new(1, -20, 0, 20), - Position = UDim2.fromOffset(12, 64), - BackgroundTransparency = 1, - Font = Enum.Font.Gotham, - TextColor3 = Color3.fromRGB(220, 220, 230), - TextXAlignment = Enum.TextXAlignment.Left, - TextSize = 14, Text = "Car: --", }, hud) --- ===== Bottom action bar =================================================== +-- Bottom action bar --------------------------------------------------------- local bar = make("Frame", { Name = "ActionBar", - Size = UDim2.fromOffset(430, 52), - Position = UDim2.new(0.5, -215, 1, -70), + Size = UDim2.fromOffset(230, 52), + Position = UDim2.new(0.5, -115, 1, -70), BackgroundTransparency = 1, }, screen) make("UIListLayout", { @@ -116,7 +90,7 @@ make("UIListLayout", { local function button(text: string, color: Color3, order: number): TextButton local b = make("TextButton", { - Size = UDim2.fromOffset(100, 44), + Size = UDim2.fromOffset(105, 44), BackgroundColor3 = PANEL_BG, BackgroundTransparency = 0.1, Font = Enum.Font.GothamBold, @@ -132,12 +106,10 @@ local function button(text: string, color: Color3, order: number): TextButton return b end -local garageBtn = button("GARAGE", NEON_BLUE, 1) -local spawnBtn = button("SPAWN CAR", Color3.fromRGB(120, 255, 170), 2) -local raceBtn = button("RACE", NEON_PINK, 3) -local dailyBtn = button("DAILY", Color3.fromRGB(255, 210, 80), 4) +local carsBtn = button("CARS", NEON_BLUE, 1) +local respawnBtn = button("RESPAWN", Color3.fromRGB(120, 255, 170), 2) --- ===== Toast (transient messages) ========================================== +-- Toast --------------------------------------------------------------------- local toast = make("TextLabel", { Name = "Toast", Size = UDim2.fromOffset(360, 34), @@ -159,25 +131,25 @@ local function showToast(msg: string) toast.Visible = true toastToken += 1 local myToken = toastToken - task.delay(2.6, function() + task.delay(2.4, function() if toastToken == myToken then toast.Visible = false end end) end --- ===== Garage / Dealership panel =========================================== -local garage = make("Frame", { - Name = "Garage", - Size = UDim2.fromOffset(560, 420), - Position = UDim2.new(0.5, -280, 0.5, -210), +-- Car picker panel ---------------------------------------------------------- +local picker = make("Frame", { + Name = "CarPicker", + Size = UDim2.fromOffset(520, 400), + Position = UDim2.new(0.5, -260, 0.5, -200), BackgroundColor3 = PANEL_BG, BackgroundTransparency = 0.05, Visible = false, BorderSizePixel = 0, }, screen) -corner(garage, 12) -make("UIStroke", { Color = NEON_BLUE, Thickness = 1.5, Transparency = 0.25 }, garage) +corner(picker, 12) +make("UIStroke", { Color = NEON_BLUE, Thickness = 1.5, Transparency = 0.25 }, picker) make("TextLabel", { Size = UDim2.new(1, 0, 0, 40), @@ -185,8 +157,8 @@ make("TextLabel", { Font = Enum.Font.GothamBold, TextColor3 = Color3.fromRGB(255, 255, 255), TextSize = 20, - Text = "GARAGE & DEALERSHIP", -}, garage) + Text = "PICK A CAR", +}, picker) local closeBtn = make("TextButton", { Size = UDim2.fromOffset(32, 32), @@ -197,7 +169,7 @@ local closeBtn = make("TextButton", { TextSize = 18, Text = "X", BorderSizePixel = 0, -}, garage) +}, picker) corner(closeBtn, 8) local list = make("ScrollingFrame", { @@ -208,36 +180,27 @@ local list = make("ScrollingFrame", { ScrollBarThickness = 6, CanvasSize = UDim2.new(0, 0, 0, 0), AutomaticCanvasSize = Enum.AutomaticSize.Y, -}, garage) +}, picker) make("UIListLayout", { Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder }, list) --- Build one row per car; content depends on owned/equipped/affordable -------- -local function rebuildGarage() - if not latest then - return - end +local function rebuildPicker() for _, child in ipairs(list:GetChildren()) do if child:IsA("Frame") then child:Destroy() end end - local ownedIds: { [string]: any } = {} - for _, o in ipairs(latest.OwnedCars) do - ownedIds[o.Id] = o - end - for i, car in ipairs(Cars.List) do - local owned = ownedIds[car.Id] - local equipped = latest.EquippedCarId == car.Id - - -- Hide restricted (allowlist-only) cars from anyone who doesn't own them. - if car.Restricted and not owned then + -- Hide the restricted RX-7 unless it's the car we're currently driving + -- (i.e. the server already granted it to us). The server enforces this + -- regardless of what the client shows. + if car.Restricted and currentCarId ~= car.Id then continue end + local isCurrent = currentCarId == car.Id local row = make("Frame", { - Size = UDim2.new(1, 0, 0, 72), + Size = UDim2.new(1, 0, 0, 64), BackgroundColor3 = Color3.fromRGB(26, 26, 38), BorderSizePixel = 0, LayoutOrder = i, @@ -245,7 +208,7 @@ local function rebuildGarage() corner(row, 8) make("TextLabel", { - Size = UDim2.new(0.6, 0, 0, 22), + Size = UDim2.new(0.62, 0, 0, 22), Position = UDim2.fromOffset(12, 8), BackgroundTransparency = 1, Font = Enum.Font.GothamBold, @@ -256,7 +219,7 @@ local function rebuildGarage() }, row) make("TextLabel", { - Size = UDim2.new(0.6, 0, 0, 18), + Size = UDim2.new(0.62, 0, 0, 16), Position = UDim2.fromOffset(12, 32), BackgroundTransparency = 1, Font = Enum.Font.Gotham, @@ -272,125 +235,58 @@ local function rebuildGarage() ), }, row) - -- Primary action button (Equip / Buy / locked) -------------------------- local action = make("TextButton", { - Size = UDim2.fromOffset(120, 30), - Position = UDim2.new(1, -132, 0, 10), + Size = UDim2.fromOffset(120, 34), + Position = UDim2.new(1, -132, 0.5, -17), Font = Enum.Font.GothamBold, - TextSize = 13, + TextSize = 14, BorderSizePixel = 0, }, row) corner(action, 6) - if owned then - if equipped then - action.Text = "EQUIPPED" - action.BackgroundColor3 = Color3.fromRGB(40, 60, 55) - action.TextColor3 = Color3.fromRGB(120, 255, 170) - else - action.Text = "EQUIP" - action.BackgroundColor3 = NEON_BLUE - action.TextColor3 = Color3.fromRGB(0, 0, 0) - action.MouseButton1Click:Connect(function() - local ok = rEquipCar:InvokeServer(car.Id) - if ok then - showToast("Equipped " .. car.Name) - end - end) - end + if isCurrent then + action.Text = "DRIVING" + action.BackgroundColor3 = Color3.fromRGB(40, 60, 55) + action.TextColor3 = Color3.fromRGB(120, 255, 170) else - local affordable = latest.Cash >= car.Price and latest.Rep >= car.RepRequired - action.Text = string.format("BUY $%d", car.Price) - action.BackgroundColor3 = affordable and Color3.fromRGB(120, 255, 170) or Color3.fromRGB(60, 40, 48) - action.TextColor3 = affordable and Color3.fromRGB(0, 0, 0) or Color3.fromRGB(200, 120, 130) + action.Text = "DRIVE" + action.BackgroundColor3 = NEON_BLUE + action.TextColor3 = Color3.fromRGB(0, 0, 0) action.MouseButton1Click:Connect(function() - local res = rBuyCar:InvokeServer(car.Id) - if res and res.ok then - showToast("Purchased " .. car.Name .. "!") - elseif res then - showToast(res.reason or "Cannot buy") - end - end) - end - - -- Upgrade button (only for owned cars) ---------------------------------- - if owned then - local upg = make("TextButton", { - Size = UDim2.fromOffset(120, 24), - Position = UDim2.new(1, -132, 0, 42), - Font = Enum.Font.GothamMedium, - TextSize = 12, - BackgroundColor3 = Color3.fromRGB(45, 45, 60), - TextColor3 = Color3.fromRGB(255, 210, 80), - BorderSizePixel = 0, - Text = string.format( - "UPGRADE (E%d/T%d/N%d)", - owned.Upgrades.Engine, - owned.Upgrades.Tires, - owned.Upgrades.Nitro - ), - }, row) - corner(upg, 6) - upg.MouseButton1Click:Connect(function() - -- MVP: cycle Engine -> Tires -> Nitro whichever is lowest. - local parts = { "Engine", "Tires", "Nitro" } - table.sort(parts, function(a, b) - return owned.Upgrades[a] < owned.Upgrades[b] - end) - local res = rUpgradeCar:InvokeServer(car.Id, parts[1]) - if res and res.ok then - showToast("Upgraded " .. parts[1] .. "!") - elseif res then - showToast(res.reason or "Cannot upgrade") + local ok = rEquipCar:InvokeServer(car.Id) + if ok then + showToast("Now driving " .. car.Name) + else + showToast("Can't drive that one") end end) end end end --- ===== Data sync =========================================================== -local function applyData(data: any) - latest = data - cashLabel.Text = string.format("$ %s", tostring(data.Cash)) - repLabel.Text = string.format("REP %s", tostring(data.Rep)) - local car = Cars.Get(data.EquippedCarId) - carLabel.Text = "Car: " .. (car and car.Name or data.EquippedCarId) - if garage.Visible then - rebuildGarage() +-- Data sync ----------------------------------------------------------------- +rDataUpdated.OnClientEvent:Connect(function(carId: string) + currentCarId = carId + local car = Cars.Get(carId) + carLabel.Text = "Car: " .. (car and car.Name or carId) + if picker.Visible then + rebuildPicker() end -end +end) -rDataUpdated.OnClientEvent:Connect(applyData) - --- ===== Wire buttons ======================================================== -garageBtn.MouseButton1Click:Connect(function() - garage.Visible = not garage.Visible - if garage.Visible then - rebuildGarage() +-- Buttons ------------------------------------------------------------------- +carsBtn.MouseButton1Click:Connect(function() + picker.Visible = not picker.Visible + if picker.Visible then + rebuildPicker() end end) closeBtn.MouseButton1Click:Connect(function() - garage.Visible = false + picker.Visible = false end) -spawnBtn.MouseButton1Click:Connect(function() +respawnBtn.MouseButton1Click:Connect(function() rSpawnCar:FireServer() - showToast("Spawning your car...") -end) -raceBtn.MouseButton1Click:Connect(function() - local res = rStartRace:InvokeServer("expressway_timetrial") - if res and res.ok then - showToast("Race started! Reach the finish line!") - elseif res then - showToast(res.reason or "Cannot start race") - end -end) -dailyBtn.MouseButton1Click:Connect(function() - local res = rClaimDaily:InvokeServer() - if res and res.ok then - showToast(string.format("Daily: +$%d, +%d rep!", res.cash or 0, res.rep or 0)) - elseif res then - showToast(res.reason or "Already claimed") - end + showToast("Respawning your car...") end) -print("[FnF] Client UI ready.") +print("[FnF] Client UI ready (drive-only MVP).") diff --git a/src/server/CarManager.luau b/src/server/CarManager.luau index 4e27c95..512f6cf 100644 --- a/src/server/CarManager.luau +++ b/src/server/CarManager.luau @@ -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 diff --git a/src/server/DataManager.luau b/src/server/DataManager.luau index a7bd26f..7b87f1c 100644 --- a/src/server/DataManager.luau +++ b/src/server/DataManager.luau @@ -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() diff --git a/src/server/Economy.luau b/src/server/Economy.luau deleted file mode 100644 index b6d4520..0000000 --- a/src/server/Economy.luau +++ /dev/null @@ -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 diff --git a/src/server/RaceManager.luau b/src/server/RaceManager.luau deleted file mode 100644 index 23f9eb9..0000000 --- a/src/server/RaceManager.luau +++ /dev/null @@ -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 diff --git a/src/server/init.server.luau b/src/server/init.server.luau index ff09cce..6ec7ed3 100644 --- a/src/server/init.server.luau +++ b/src/server/init.server.luau @@ -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.") diff --git a/src/shared/Cars.luau b/src/shared/Cars.luau index 8782769..e31da72 100644 --- a/src/shared/Cars.luau +++ b/src/shared/Cars.luau @@ -1,13 +1,11 @@ --!strict --[[ Cars.luau - The car catalogue. Table-driven so adding a car = adding a row. - MVP ships with the RX-7 starter + a few tier examples so the dealership - and progression have something to show. Expand freely later. + The car catalogue. MVP scope (just driving): a car is an identity + driving + feel + look. No prices, no rep gates, no upgrades. Add a car = add a row. - Stat scale: 0-100 (relative feel, not real units). Speed maps to top speed, - Acceleration to how fast it gets there, Handling to grip/turn-in. - Price is in cash. RepRequired gates purchase behind reputation. + Stat scale 0-100 (relative feel): Speed = top speed, Acceleration = how fast + it gets there, Handling = grip/turn-in. These drive the VehicleSeat tuning. ]] export type CarStats = { @@ -20,31 +18,24 @@ export type Car = { Id: string, Name: string, Model: string, -- real-world reference - Tier: number, -- 1 Junk .. 5 Exotic - Price: number, - RepRequired: number, + Tier: number, -- 1 Junk .. 5 Exotic (display/flavour only in MVP) Stats: CarStats, - Color: { number }, -- {r,g,b} 0-1 for the placeholder body until real mesh exists + Color: { number }, -- {r,g,b} 0-1 for the placeholder body until a real mesh exists Description: string, - Starter: boolean?, -- true = granted free on first join - Restricted: boolean?, -- true = allowlist-only; hidden from dealership for others + Restricted: boolean?, -- true = allowlist-only (the RX-7 demo lock) } local Cars = {} --- Ordered list (dealership browses this in order) --------------------------- Cars.List = { { Id = "rx7_fc", Name = "Chick3n's RX-7", Model = "1993 Mazda RX-7 (FD)", Tier = 2, - Price = 0, - RepRequired = 0, Stats = { Speed = 62, Acceleration = 58, Handling = 70 }, Color = { 0.85, 0.15, 0.12 }, -- classic RX-7 red until the real mesh is imported - Description = "The one that started it all. A rotary-hearted FD RX-7 — light, balanced, and iconic. Your daily driver and first weapon on the expressway.", - Starter = true, + Description = "The one that started it all. A rotary-hearted FD RX-7 — light, balanced, iconic.", Restricted = true, -- demo-locked to the owner + son during testing }, { @@ -52,48 +43,39 @@ Cars.List = { Name = "City Runabout", Model = "90s econobox hatchback", Tier = 1, - Price = 800, - RepRequired = 0, Stats = { Speed = 34, Acceleration = 40, Handling = 48 }, Color = { 0.5, 0.5, 0.55 }, - Description = "Cheap, cheerful, and slow. A backup beater for when you want to grind pocket change.", + Description = "Cheap, cheerful, and slow. The public default cruiser.", }, { Id = "silv_s15", Name = "Night Slider", Model = "Nissan Silvia S15", Tier = 3, - Price = 12000, - RepRequired = 50, Stats = { Speed = 74, Acceleration = 70, Handling = 78 }, Color = { 0.1, 0.35, 0.85 }, - Description = "A drift-bred coupe with serious mid-corner poise. The natural step up from the RX-7.", + Description = "A drift-bred coupe with serious mid-corner poise.", }, { Id = "gtr_r34", Name = "Godzilla", Model = "Nissan Skyline GT-R R34", Tier = 4, - Price = 45000, - RepRequired = 180, Stats = { Speed = 88, Acceleration = 86, Handling = 82 }, Color = { 0.15, 0.6, 0.95 }, - Description = "AWD, twin-turbo, legendary. When you show up in this, people notice.", + Description = "AWD, twin-turbo, legendary. People notice when you show up in this.", }, { Id = "hyper_x", Name = "Apex Phantom", Model = "Exotic hypercar (stylized)", Tier = 5, - Price = 250000, - RepRequired = 600, Stats = { Speed = 100, Acceleration = 98, Handling = 90 }, Color = { 0.05, 0.05, 0.06 }, - Description = "Endgame flex. Millions of yen of carbon fibre and neon. The top of the food chain.", + Description = "Top of the food chain. Carbon fibre and neon.", }, } --- Fast lookup by Id (built once) -------------------------------------------- Cars.ById = {} :: { [string]: Car } for _, car in ipairs(Cars.List) do Cars.ById[car.Id] = car diff --git a/src/shared/GameConfig.luau b/src/shared/GameConfig.luau index 3ac495f..c213bcc 100644 --- a/src/shared/GameConfig.luau +++ b/src/shared/GameConfig.luau @@ -1,63 +1,54 @@ --!strict --[[ GameConfig.luau - Central tunable constants. Anything you might want to balance later lives here, - NOT hard-coded in systems. Single source of truth for numbers. + Central tunable constants. MVP scope: JUST DRIVING on the expressway. + No racing, no economy, no rewards — pick a car and drive it. ]] local GameConfig = {} --- Economy ------------------------------------------------------------------- -GameConfig.StartingCash = 1500 -- enough to feel, not enough to skip the RX-7 -GameConfig.StartingRep = 0 - --- Starter cars --------------------------------------------------------------- +-- Starter / default cars ----------------------------------------------------- -- The RX-7 is a RESTRICTED demo car for the owner + son during testing. Only --- UserIds in the allowlist below ever receive / can buy / can spawn it. --- Everyone else starts in the public starter car instead. -GameConfig.PublicStarterCarId = "hatch_junk" -- default car for non-allowlisted players -GameConfig.RestrictedStarterCarId = "rx7_fc" -- the real 1993 Mazda RX-7 (demo-locked) +-- UserIds in the allowlist below can drive it. Everyone else gets the public +-- default and can drive any non-restricted car via the car picker. +GameConfig.PublicDefaultCarId = "hatch_junk" -- default equipped car for non-allowlisted players +GameConfig.RestrictedCarId = "rx7_fc" -- the real 1993 Mazda RX-7 (demo-locked) --- >>> FILL THESE IN: Roblox UserIds allowed to own the RX-7 (numbers, not names). +-- Roblox UserIds allowed to drive the restricted RX-7 (numbers, not names). -- Find yours at roblox.com/users//profile — the number in the URL. GameConfig.RX7AllowedUserIds = { [459891257] = true, -- R3AL.CH1CK3N (owner) [4731701178] = true, -- son } ---- True if this player is allowed to own the restricted RX-7. +--- True if this player is allowed to drive the restricted RX-7. function GameConfig.isRX7Allowed(userId: number): boolean return GameConfig.RX7AllowedUserIds[userId] == true end ---- The starter car id this specific player should receive on first join. -function GameConfig.starterCarFor(userId: number): string +--- The default car this player should be driving on first join. +function GameConfig.defaultCarFor(userId: number): string if GameConfig.isRX7Allowed(userId) then - return GameConfig.RestrictedStarterCarId + return GameConfig.RestrictedCarId end - return GameConfig.PublicStarterCarId + return GameConfig.PublicDefaultCarId end --- Race rewards (flat MVP model; tune per-race later) ------------------------- -GameConfig.RaceBaseReward = 250 -GameConfig.RaceWinBonus = 400 -GameConfig.RaceRepReward = 15 - --- Daily login ---------------------------------------------------------------- -GameConfig.DailyRewardCash = 300 -GameConfig.DailyRewardRep = 10 -GameConfig.DailyCooldownSeconds = 20 * 60 * 60 -- 20h so a "daily" is reliably claimable each day - --- Anti-cheat sanity bounds (server-side validation) -------------------------- --- Max believable straight-line speed in studs/sec for the FASTEST car + boosts. --- Anything sustained above this => flagged. Generous to avoid false positives in MVP. -GameConfig.MaxPlausibleSpeed = 400 +--- Can this player drive this car? Restricted cars need the allowlist. +--- (carId kept in the signature for call-site clarity / future per-car rules.) +function GameConfig.canDrive(userId: number, _carId: string, restricted: boolean?): boolean + if restricted then + return GameConfig.isRX7Allowed(userId) + end + return true +end -- DataStore ------------------------------------------------------------------ GameConfig.ProfileStoreName = "PlayerData_v1" -- bump the _vN to wipe/migrate in dev -GameConfig.UseMockInStudio = true -- ProfileStore mock store when testing in Studio (no live DataStore) +GameConfig.UseMockInStudio = true -- ProfileStore mock store when testing in Studio --- Autosave interval handled by ProfileStore internally; kept here for reference. -GameConfig.SessionTag = "fnf-sim-mvp" +-- Anti-cheat sanity bound (server-side): max believable horizontal speed +-- (studs/sec) for the fastest car. Sustained above this => snap back. +GameConfig.MaxPlausibleSpeed = 400 return GameConfig diff --git a/src/shared/ProfileTemplate.luau b/src/shared/ProfileTemplate.luau index fa4374a..bb67e7f 100644 --- a/src/shared/ProfileTemplate.luau +++ b/src/shared/ProfileTemplate.luau @@ -1,48 +1,19 @@ --!strict --[[ ProfileTemplate.luau - The canonical shape of a player's saved data. ProfileStore fills any - missing keys from this template automatically (safe forward-migration: - add a key here and existing players get the default on next load). - - NEVER remove/rename a key without a migration plan — that's how saves break. + Saved data shape. MVP scope (just driving): we only remember which car the + player last drove, so they resume in it. No money, no owned-car list, no rep. + ProfileStore reconciles missing keys from this template automatically. ]] -local GameConfig = require(script.Parent.GameConfig) - -export type UpgradeLevels = { - Engine: number, - Tires: number, - Nitro: number, -} - -export type OwnedCar = { - Id: string, - Upgrades: UpgradeLevels, -} - export type ProfileData = { - Cash: number, - Rep: number, - OwnedCars: { OwnedCar }, - EquippedCarId: string, - LastDailyClaim: number, -- os.time() of last claim; 0 = never - TotalRaces: number, - Wins: number, - Version: number, -- schema version for future migrations + EquippedCarId: string, -- last car the player was driving ("" = assign default on load) + Version: number, } local ProfileTemplate: ProfileData = { - Cash = GameConfig.StartingCash, - Rep = GameConfig.StartingRep, - -- Starter car is granted per-player by DataManager after load (depends on - -- UserId: allowlisted players get the RX-7, everyone else the public car). - OwnedCars = {}, EquippedCarId = "", - LastDailyClaim = 0, - TotalRaces = 0, - Wins = 0, - Version = 1, + Version = 2, } return ProfileTemplate diff --git a/src/shared/Remotes.luau b/src/shared/Remotes.luau index 8ca8fa3..1eca6e5 100644 --- a/src/shared/Remotes.luau +++ b/src/shared/Remotes.luau @@ -2,12 +2,10 @@ --[[ Remotes.luau Single source of truth for every RemoteEvent / RemoteFunction. - The SERVER calls Remotes.init() once at boot to create the instances under - ReplicatedStorage/Remotes. The CLIENT just requires and reads them (they're - guaranteed to exist because the server script runs first / it waits). + MVP scope (just driving): the only client->server actions are "give me a car" + and "switch which car I'm driving". Server creates these at boot; client reads. - Add a remote = add one line to the DEFS table. No stringly-typed - FindFirstChild scattered across the codebase. + Add a remote = add one line to DEFS. No stringly-typed FindFirstChild elsewhere. ]] local ReplicatedStorage = game:GetService("ReplicatedStorage") @@ -17,17 +15,12 @@ local Remotes = {} -- name -> class. "Event" = RemoteEvent, "Function" = RemoteFunction ---------- local DEFS: { [string]: string } = { - -- Server -> Client: pushes fresh profile snapshot for HUD/garage + -- Server -> Client: pushes the player's current car id (for HUD) DataUpdated = "Event", -- Client -> Server requests - SpawnCar = "Event", -- (carId?) spawn/replace the player's equipped car - EquipCar = "Function", -- (carId) -> boolean success - BuyCar = "Function", -- (carId) -> {ok:boolean, reason:string?} - UpgradeCar = "Function", -- (carId, part) -> {ok:boolean, reason:string?} - ClaimDaily = "Function", -- () -> {ok:boolean, cash:number?, rep:number?, reason:string?} - StartRace = "Function", -- (raceId) -> {ok:boolean, reason:string?} - FinishRace = "Event", -- (raceId, won:boolean) client reports; server validates + SpawnCar = "Event", -- () re-spawn the player's current car (e.g. after a flip) + EquipCar = "Function", -- (carId) -> boolean: switch to & spawn a different car } local FOLDER_NAME = "Remotes"