From 9a2f2d4dc96adcd5469ac54d600a42f36869d484 Mon Sep 17 00:00:00 2001 From: "R3AL.CH1CK3N" Date: Tue, 21 Jul 2026 23:30:53 -0400 Subject: [PATCH] Initial MVP: drivable RX-7 vertical slice + server-authoritative systems - 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 --- .gitea/workflows/ci.yml | 41 ++++ .github/workflows/ci.yml | 40 ++++ .gitignore | 16 ++ Makefile | 26 +++ README.md | 88 +++++++ default.project.json | 55 +++++ docs/GDD.md | 52 +++++ docs/ROADMAP.md | 62 +++++ rokit.toml | 5 + selene.toml | 4 + src/client/init.client.luau | 391 ++++++++++++++++++++++++++++++++ src/server/AntiCheat.luau | 81 +++++++ src/server/CarManager.luau | 178 +++++++++++++++ src/server/DataManager.luau | 151 ++++++++++++ src/server/Economy.luau | 162 +++++++++++++ src/server/MapBuilder.luau | 142 ++++++++++++ src/server/RaceManager.luau | 107 +++++++++ src/server/init.server.luau | 116 ++++++++++ src/shared/Cars.luau | 104 +++++++++ src/shared/GameConfig.luau | 37 +++ src/shared/ProfileTemplate.luau | 51 +++++ src/shared/Remotes.luau | 63 +++++ stylua.toml | 6 + wally.toml | 8 + 24 files changed, 1986 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 default.project.json create mode 100644 docs/GDD.md create mode 100644 docs/ROADMAP.md create mode 100644 rokit.toml create mode 100644 selene.toml create mode 100644 src/client/init.client.luau create mode 100644 src/server/AntiCheat.luau create mode 100644 src/server/CarManager.luau create mode 100644 src/server/DataManager.luau create mode 100644 src/server/Economy.luau create mode 100644 src/server/MapBuilder.luau create mode 100644 src/server/RaceManager.luau create mode 100644 src/server/init.server.luau create mode 100644 src/shared/Cars.luau create mode 100644 src/shared/GameConfig.luau create mode 100644 src/shared/ProfileTemplate.luau create mode 100644 src/shared/Remotes.luau create mode 100644 stylua.toml create mode 100644 wally.toml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..84ba05d --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +# Gitea Actions (GitHub-compatible). Runs on every push and PR. +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rokit + run: | + curl -fsSL https://raw.githubusercontent.com/rojo-rbx/rokit/main/scripts/install.sh | bash + echo "$HOME/.rokit/bin" >> "$GITHUB_PATH" + + - name: Install toolchain (rojo, wally, selene, stylua) + run: rokit install --no-trust-check + + - name: Restore Wally packages + run: wally install + + - name: Format check (stylua) + run: stylua --check src/ + + - name: Lint (selene) + run: selene src/ + + - name: Build place (rojo) + run: rojo build --output build.rbxl + + - name: Upload build artifact + uses: actions/upload-artifact@v3 + with: + name: place-file + path: build.rbxl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ade7eb3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rokit + run: | + curl -fsSL https://raw.githubusercontent.com/rojo-rbx/rokit/main/scripts/install.sh | bash + echo "$HOME/.rokit/bin" >> "$GITHUB_PATH" + + - name: Install toolchain (rojo, wally, selene, stylua) + run: rokit install --no-trust-check + + - name: Restore Wally packages + run: wally install + + - name: Format check (stylua) + run: stylua --check src/ + + - name: Lint (selene) + run: selene src/ + + - name: Build place (rojo) + run: rojo build --output build.rbxl + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: place-file + path: build.rbxl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..978b28d --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Rojo / Wally build artifacts +/build/ +*.rbxl +*.rbxlx +*.rbxm +*.rbxmx + +# Wally installed packages (restored via `wally install`) +/Packages/ +/ServerPackages/ +/DevPackages/ +wally.lock + +# Rokit / OS +.DS_Store +*.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ba1c1dd --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +# Convenience targets. Requires Rokit tools on PATH ($HOME/.rokit/bin). +.PHONY: install check fmt lint build serve clean + +install: + rokit install + wally install + +fmt: + stylua src/ + +check: + stylua --check src/ + selene src/ + rojo build --output build.rbxl + +lint: + selene src/ + +build: + rojo build --output build.rbxl + +serve: + rojo serve + +clean: + rm -f build.rbxl diff --git a/README.md b/README.md new file mode 100644 index 0000000..c62ef29 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Fast & Furious: Tokyo Expressway — MVP + +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. + +**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. + +--- + +## Prerequisites + +Toolchain is managed by [Rokit](https://github.com/rojo-rbx/rokit). Once Rokit is +installed and on your `PATH`: + +```bash +rokit install # installs 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 | +| wally | Package manager (ProfileStore) | +| selene | Static analysis / linter | +| stylua | Code formatter | + +## Build & run + +```bash +# Build a standalone place file you can open in Studio: +rojo build --output build.rbxl + +# 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**. + +## Quality gates (run before every commit) + +```bash +stylua --check src/ # formatting +selene src/ # lint +rojo build --output build.rbxl # must compile +``` + +CI runs all three on every push (see `.github/` → mirrored to Gitea Actions). + +--- + +## Architecture (server-authoritative) + +Clients **request**, the server **decides**. Money, rep, and car ownership can +never be set by the client. + +``` +ReplicatedStorage/Shared GameConfig, Cars, ProfileTemplate, Remotes +ServerScriptService/Server init.server + managers (below) +ServerScriptService/ServerPackages ProfileStore (Wally) +StarterPlayerScripts/Client init.client (HUD + Garage UI) +``` + +| 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. | +| `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. + +## 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 +`unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/`. diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..bd279c7 --- /dev/null +++ b/default.project.json @@ -0,0 +1,55 @@ +{ + "name": "fnf-sim", + "tree": { + "$className": "DataModel", + + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "Shared": { + "$path": "src/shared" + } + }, + + "ServerScriptService": { + "$className": "ServerScriptService", + "Server": { + "$path": "src/server" + }, + "ServerPackages": { + "$path": "ServerPackages" + } + }, + + "StarterPlayer": { + "$className": "StarterPlayer", + "StarterPlayerScripts": { + "$className": "StarterPlayerScripts", + "Client": { + "$path": "src/client" + } + } + }, + + "Workspace": { + "$className": "Workspace", + "$properties": { + "Gravity": 196.2, + "StreamingEnabled": true + } + }, + + "Lighting": { + "$className": "Lighting", + "$properties": { + "ClockTime": 0.0, + "Brightness": 1.0, + "Ambient": [0.05, 0.05, 0.1], + "OutdoorAmbient": [0.1, 0.1, 0.18], + "FogEnd": 900.0, + "FogColor": [0.03, 0.03, 0.08], + "EnvironmentDiffuseScale": 0.4, + "GlobalShadows": true + } + } + } +} diff --git a/docs/GDD.md b/docs/GDD.md new file mode 100644 index 0000000..64ef9a0 --- /dev/null +++ b/docs/GDD.md @@ -0,0 +1,52 @@ +# 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. + +## The one-sentence pitch +"Start in the RX-7, own the expressway." + +## 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. + +Daily rewards + rising rep gates give a reason to come back tomorrow. + +## 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. + +## 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. + +## 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..1af504e --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,62 @@ +# 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. + +> 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*. + +--- + +## ✅ 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. + +## M1 — Make it feel good (the real work) +- [ ] 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. + +## 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. + +## 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. + +## 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. + +## 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`. + +## 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. + +--- + +## 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. +- Add a save key by adding it to `ProfileTemplate` (auto-reconciles); never remove + a key without a migration. +- Test in Studio after every server change (ProfileStore uses a mock store there). diff --git a/rokit.toml b/rokit.toml new file mode 100644 index 0000000..7deb364 --- /dev/null +++ b/rokit.toml @@ -0,0 +1,5 @@ +[tools] +rojo = "rojo-rbx/rojo@7.5.1" +wally = "UpliftGames/wally@0.3.2" +selene = "Kampfkarren/selene@0.28.0" +stylua = "JohnnyMorganz/stylua@2.1.0" diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..70b7489 --- /dev/null +++ b/selene.toml @@ -0,0 +1,4 @@ +std = "roblox" + +[lints] +empty_if = "allow" diff --git a/src/client/init.client.luau b/src/client/init.client.luau new file mode 100644 index 0000000..42f3a61 --- /dev/null +++ b/src/client/init.client.luau @@ -0,0 +1,391 @@ +--!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. +]] + +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Shared = ReplicatedStorage.Shared +local Remotes = require(Shared.Remotes) +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 + +-- UI helpers ---------------------------------------------------------------- +local NEON_PINK = Color3.fromRGB(255, 40, 120) +local NEON_BLUE = Color3.fromRGB(40, 160, 255) +local PANEL_BG = Color3.fromRGB(16, 16, 24) + +local function make(class: string, props: { [string]: any }, parent: Instance?): any + local inst = Instance.new(class) + for k, v in pairs(props) do + (inst :: any)[k] = v + end + if parent then + inst.Parent = parent + end + return inst +end + +local function corner(inst: Instance, radius: number) + make("UICorner", { CornerRadius = UDim.new(0, radius) }, inst) +end + +local screen = make("ScreenGui", { + Name = "FnF_UI", + ResetOnSpawn = false, + ZIndexBehavior = Enum.ZIndexBehavior.Sibling, +}, playerGui) + +-- ===== HUD (top-left): cash / rep / car ==================================== +local hud = make("Frame", { + Name = "HUD", + Size = UDim2.fromOffset(260, 96), + Position = UDim2.fromOffset(16, 16), + BackgroundColor3 = PANEL_BG, + BackgroundTransparency = 0.15, + BorderSizePixel = 0, +}, screen) +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), + 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, + 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 =================================================== +local bar = make("Frame", { + Name = "ActionBar", + Size = UDim2.fromOffset(430, 52), + Position = UDim2.new(0.5, -215, 1, -70), + BackgroundTransparency = 1, +}, screen) +make("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalAlignment = Enum.HorizontalAlignment.Center, + Padding = UDim.new(0, 10), + SortOrder = Enum.SortOrder.LayoutOrder, +}, bar) + +local function button(text: string, color: Color3, order: number): TextButton + local b = make("TextButton", { + Size = UDim2.fromOffset(100, 44), + BackgroundColor3 = PANEL_BG, + BackgroundTransparency = 0.1, + Font = Enum.Font.GothamBold, + TextColor3 = color, + TextSize = 15, + Text = text, + AutoButtonColor = true, + LayoutOrder = order, + BorderSizePixel = 0, + }, bar) + corner(b, 8) + make("UIStroke", { Color = color, Thickness = 1.2, Transparency = 0.4 }, b) + 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) + +-- ===== Toast (transient messages) ========================================== +local toast = make("TextLabel", { + Name = "Toast", + Size = UDim2.fromOffset(360, 34), + Position = UDim2.new(0.5, -180, 1, -120), + BackgroundColor3 = PANEL_BG, + BackgroundTransparency = 0.1, + Font = Enum.Font.GothamMedium, + TextColor3 = Color3.fromRGB(255, 255, 255), + TextSize = 15, + Text = "", + Visible = false, + BorderSizePixel = 0, +}, screen) +corner(toast, 8) + +local toastToken = 0 +local function showToast(msg: string) + toast.Text = msg + toast.Visible = true + toastToken += 1 + local myToken = toastToken + task.delay(2.6, 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), + 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) + +make("TextLabel", { + Size = UDim2.new(1, 0, 0, 40), + BackgroundTransparency = 1, + Font = Enum.Font.GothamBold, + TextColor3 = Color3.fromRGB(255, 255, 255), + TextSize = 20, + Text = "GARAGE & DEALERSHIP", +}, garage) + +local closeBtn = make("TextButton", { + Size = UDim2.fromOffset(32, 32), + Position = UDim2.new(1, -40, 0, 6), + BackgroundColor3 = NEON_PINK, + Font = Enum.Font.GothamBold, + TextColor3 = Color3.fromRGB(255, 255, 255), + TextSize = 18, + Text = "X", + BorderSizePixel = 0, +}, garage) +corner(closeBtn, 8) + +local list = make("ScrollingFrame", { + Size = UDim2.new(1, -24, 1, -56), + Position = UDim2.fromOffset(12, 46), + BackgroundTransparency = 1, + BorderSizePixel = 0, + ScrollBarThickness = 6, + CanvasSize = UDim2.new(0, 0, 0, 0), + AutomaticCanvasSize = Enum.AutomaticSize.Y, +}, garage) +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 + 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 + + local row = make("Frame", { + Size = UDim2.new(1, 0, 0, 72), + BackgroundColor3 = Color3.fromRGB(26, 26, 38), + BorderSizePixel = 0, + LayoutOrder = i, + }, list) + corner(row, 8) + + make("TextLabel", { + Size = UDim2.new(0.6, 0, 0, 22), + Position = UDim2.fromOffset(12, 8), + BackgroundTransparency = 1, + Font = Enum.Font.GothamBold, + TextColor3 = Color3.fromRGB(255, 255, 255), + TextXAlignment = Enum.TextXAlignment.Left, + TextSize = 16, + Text = string.format("T%d %s", car.Tier, car.Name), + }, row) + + make("TextLabel", { + Size = UDim2.new(0.6, 0, 0, 18), + Position = UDim2.fromOffset(12, 32), + BackgroundTransparency = 1, + Font = Enum.Font.Gotham, + TextColor3 = Color3.fromRGB(170, 170, 185), + TextXAlignment = Enum.TextXAlignment.Left, + TextSize = 12, + Text = string.format( + "%s | SPD %d ACC %d HND %d", + car.Model, + car.Stats.Speed, + car.Stats.Acceleration, + car.Stats.Handling + ), + }, row) + + -- Primary action button (Equip / Buy / locked) -------------------------- + local action = make("TextButton", { + Size = UDim2.fromOffset(120, 30), + Position = UDim2.new(1, -132, 0, 10), + Font = Enum.Font.GothamBold, + TextSize = 13, + 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 + 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.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") + 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() + end +end + +rDataUpdated.OnClientEvent:Connect(applyData) + +-- ===== Wire buttons ======================================================== +garageBtn.MouseButton1Click:Connect(function() + garage.Visible = not garage.Visible + if garage.Visible then + rebuildGarage() + end +end) +closeBtn.MouseButton1Click:Connect(function() + garage.Visible = false +end) +spawnBtn.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 +end) + +print("[FnF] Client UI ready.") diff --git a/src/server/AntiCheat.luau b/src/server/AntiCheat.luau new file mode 100644 index 0000000..8e98e7e --- /dev/null +++ b/src/server/AntiCheat.luau @@ -0,0 +1,81 @@ +--!strict +--[[ + AntiCheat.luau (SERVER) + MVP-level movement sanity check. Not a fortress — a speed bump that catches + obvious speed/teleport hacks. Because money & car ownership are ALREADY + server-authoritative (Economy validates everything), the main client attack + surface left is movement, so we watch that. + + Approach: sample each player's HRP position periodically; if they cover + ground implying a sustained speed above MaxPlausibleSpeed, snap them back + to their last valid position. Generous threshold to avoid false positives + from legit fast cars. +]] + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local GameConfig = require(ReplicatedStorage.Shared.GameConfig) + +local AntiCheat = {} + +type Track = { lastPos: Vector3, lastValid: CFrame } + +local tracked: { [Player]: Track } = {} +local SAMPLE_INTERVAL = 0.5 + +local function hrpOf(player: Player): BasePart? + local char = player.Character + return char and char:FindFirstChild("HumanoidRootPart") :: BasePart? +end + +function AntiCheat.init() + local function watch(player: Player) + player.CharacterAdded:Connect(function(char) + local hrp = char:WaitForChild("HumanoidRootPart", 10) :: BasePart? + if hrp then + tracked[player] = { lastPos = hrp.Position, lastValid = hrp.CFrame } + end + end) + end + for _, p in ipairs(Players:GetPlayers()) do + watch(p) + end + Players.PlayerAdded:Connect(watch) + Players.PlayerRemoving:Connect(function(p) + tracked[p] = nil + end) + + local accum = 0 + RunService.Heartbeat:Connect(function(dt) + accum += dt + if accum < SAMPLE_INTERVAL then + return + end + local interval = accum + accum = 0 + + for player, track in pairs(tracked) do + local hrp = hrpOf(player) + if not hrp then + continue + end + local pos = hrp.Position + -- Falling doesn't count (avoid flagging big drops). Only guard XZ plane. + local horizontal = Vector3.new(pos.X - track.lastPos.X, 0, pos.Z - track.lastPos.Z).Magnitude + local hSpeed = horizontal / interval + + if hSpeed > GameConfig.MaxPlausibleSpeed then + -- Snap back: likely a speed/teleport hack. + hrp.CFrame = track.lastValid + hrp.AssemblyLinearVelocity = Vector3.zero + else + track.lastValid = hrp.CFrame + end + track.lastPos = hrp.Position + end + end) +end + +return AntiCheat diff --git a/src/server/CarManager.luau b/src/server/CarManager.luau new file mode 100644 index 0000000..8f00b30 --- /dev/null +++ b/src/server/CarManager.luau @@ -0,0 +1,178 @@ +--!strict +--[[ + CarManager.luau (SERVER) + Spawns the player's equipped car as a simple, PROVEN drivable vehicle. + + Design choice (per "don't reinvent the wheel"): instead of a hand-rolled + physics chassis, the MVP car is a single driving Part + a VehicleSeat. + Roblox's VehicleSeat drives the assembly via its built-in Throttle/Steer — + 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") +local Workspace = game:GetService("Workspace") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Shared = ReplicatedStorage.Shared +local Cars = require(Shared.Cars) +local ProfileTemplate = require(Shared.ProfileTemplate) +local DataManager = require(script.Parent.DataManager) + +type ProfileData = ProfileTemplate.ProfileData +type Car = Cars.Car + +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. + 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 + +-- Build the placeholder car model. Returns the Model with a VehicleSeat. +local function buildCarModel(car: Car, maxSpeed: number, torque: number, turnSpeed: number): Model + local model = Instance.new("Model") + model.Name = "Car_" .. car.Id + + -- Body (the driving part) -------------------------------------------------- + local body = Instance.new("Part") + body.Name = "Body" + body.Size = Vector3.new(6, 2, 12) + body.Color = Color3.new(car.Color[1], car.Color[2], car.Color[3]) + body.Material = Enum.Material.SmoothPlastic + body.CanCollide = true + body.Parent = model + + -- A cabin block so it reads as a car, welded to the body ------------------- + local cabin = Instance.new("Part") + cabin.Name = "Cabin" + cabin.Size = Vector3.new(5, 1.6, 5) + cabin.Color = Color3.new(car.Color[1] * 0.7, car.Color[2] * 0.7, car.Color[3] * 0.7) + cabin.Material = Enum.Material.Glass + cabin.CanCollide = false + cabin.Parent = model + local cabinWeld = Instance.new("WeldConstraint") + cabinWeld.Part0 = body + cabinWeld.Part1 = cabin + cabinWeld.Parent = body + cabin.CFrame = body.CFrame * CFrame.new(0, 1.8, 0.5) + + -- VehicleSeat: Roblox's built-in, reliable driver ------------------------ + local seat = Instance.new("VehicleSeat") + seat.Name = "DriveSeat" + seat.Size = Vector3.new(4, 0.5, 3) + seat.MaxSpeed = maxSpeed + seat.Torque = torque + seat.TurnSpeed = turnSpeed + seat.HeadsUpDisplay = false + seat.CanCollide = false + seat.Color = Color3.new(0.1, 0.1, 0.1) + seat.Parent = model + local seatWeld = Instance.new("WeldConstraint") + seatWeld.Part0 = body + seatWeld.Part1 = seat + seatWeld.Parent = body + seat.CFrame = body.CFrame * CFrame.new(0, 1.6, -1) + + model.PrimaryPart = body + return model +end + +-- Find a reasonable spawn CFrame (a SpawnPad in the map, else origin) -------- +local function spawnCFrame(): CFrame + local map = Workspace:FindFirstChild("Map") + if map then + local pad = map:FindFirstChild("SpawnPad", true) + if pad and pad:IsA("BasePart") then + return pad.CFrame * CFrame.new(0, 5, 0) + end + end + return CFrame.new(0, 8, 0) +end + +--- Despawn the player's current car if any. +function CarManager.despawn(player: Player) + local existing = activeCars[player] + if existing then + existing:Destroy() + activeCars[player] = nil + end +end + +--- Spawn (or replace) the player's equipped car and seat them in it. +function CarManager.spawn(player: Player, carId: string?) + local data = DataManager.get(player) + if not data then + return + end + local id = carId or data.EquippedCarId + local car = Cars.Get(id) + if not car 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 + return + end + + CarManager.despawn(player) + + local maxSpeed, torque, turnSpeed = computeTuning(car, owned.Upgrades) + local model = buildCarModel(car, maxSpeed, torque, turnSpeed) + model:PivotTo(spawnCFrame()) + model.Parent = carsFolder + model:SetAttribute("OwnerUserId", player.UserId) + model:SetAttribute("CarId", id) + + -- Sit the player down. Give the assembly network ownership to the driver so + -- physics are smooth on their client (standard Roblox vehicle practice). + local character = player.Character + if character then + local seat = model:FindFirstChild("DriveSeat") :: VehicleSeat + local body = model.PrimaryPart + if body then + body:SetNetworkOwner(player) + end + if seat then + task.defer(function() + seat:Sit(character:FindFirstChildOfClass("Humanoid")) + end) + end + end + + activeCars[player] = model +end + +function CarManager.init() + carsFolder = Instance.new("Folder") + carsFolder.Name = "ActiveCars" + carsFolder.Parent = Workspace + + Players.PlayerRemoving:Connect(function(player) + CarManager.despawn(player) + end) +end + +return CarManager diff --git a/src/server/DataManager.luau b/src/server/DataManager.luau new file mode 100644 index 0000000..140a0a3 --- /dev/null +++ b/src/server/DataManager.luau @@ -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 diff --git a/src/server/Economy.luau b/src/server/Economy.luau new file mode 100644 index 0000000..ea3a73d --- /dev/null +++ b/src/server/Economy.luau @@ -0,0 +1,162 @@ +--!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 + 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/MapBuilder.luau b/src/server/MapBuilder.luau new file mode 100644 index 0000000..f5bce50 --- /dev/null +++ b/src/server/MapBuilder.luau @@ -0,0 +1,142 @@ +--!strict +--[[ + MapBuilder.luau (SERVER) + Builds the MVP "Tokyo expressway" vertical slice entirely from code so the + game is playable with ZERO manual Studio building. This is a straight, + banked-guardrail highway segment with a start pad, a finish line, neon-ish + side pylons, and invisible kill walls so you can't drive off the edge. + + Later you replace this with a proper modeled map; the rest of the game + (spawns, races) reads named parts so it keeps working. +]] + +local Workspace = game:GetService("Workspace") + +local MapBuilder = {} + +local ROAD_WIDTH = 60 +local SEGMENT_LENGTH = 400 +local NUM_SEGMENTS = 6 -- total length = 2400 studs of highway +local WALL_HEIGHT = 20 + +local function part(props: { [string]: any }): Part + local p = Instance.new("Part") + p.Anchored = true + p.TopSurface = Enum.SurfaceType.Smooth + p.BottomSurface = Enum.SurfaceType.Smooth + for k, v in pairs(props) do + (p :: any)[k] = v + end + return p +end + +function MapBuilder.build(): Folder + local existing = Workspace:FindFirstChild("Map") + if existing then + existing:Destroy() + end + + local map = Instance.new("Folder") + map.Name = "Map" + + local roadFolder = Instance.new("Folder") + roadFolder.Name = "Expressway" + roadFolder.Parent = map + + local totalLength = SEGMENT_LENGTH * NUM_SEGMENTS + + -- Road deck (segmented so you can later bank/curve individual pieces) ------ + for i = 0, NUM_SEGMENTS - 1 do + local z = -(i * SEGMENT_LENGTH) - SEGMENT_LENGTH / 2 + local road = part({ + Name = "Road_" .. i, + Size = Vector3.new(ROAD_WIDTH, 2, SEGMENT_LENGTH), + Position = Vector3.new(0, 0, z), + Color = Color3.fromRGB(28, 28, 34), + Material = Enum.Material.Asphalt, + }) + road.Parent = roadFolder + + -- Center lane line + local line = part({ + Name = "Lane_" .. i, + Size = Vector3.new(1, 2.1, SEGMENT_LENGTH), + Position = Vector3.new(0, 0.1, z), + Color = Color3.fromRGB(230, 200, 60), + Material = Enum.Material.Neon, + }) + line.Parent = roadFolder + end + + -- Guardrails + invisible kill walls on both sides ------------------------- + for _, side in ipairs({ -1, 1 }) do + local x = side * (ROAD_WIDTH / 2) + local rail = part({ + Name = "Guardrail_" .. (side == -1 and "L" or "R"), + Size = Vector3.new(1.5, 4, totalLength), + Position = Vector3.new(x, 2, -totalLength / 2), + Color = Color3.fromRGB(180, 180, 190), + Material = Enum.Material.Metal, + }) + rail.Parent = map + + -- Invisible wall: solid, tall, non-rendered — the "can't leave" barrier. + local wall = part({ + Name = "KillWall_" .. (side == -1 and "L" or "R"), + Size = Vector3.new(2, WALL_HEIGHT, totalLength), + Position = Vector3.new(x + side * 2, WALL_HEIGHT / 2, -totalLength / 2), + Transparency = 1, + CanCollide = true, + }) + wall.Parent = map + + -- Neon pylons for that cyberpunk Tokyo glow (cheap: one every 200 studs) + for i = 0, NUM_SEGMENTS * 2 do + local pylon = part({ + Name = "Pylon", + Size = Vector3.new(1, 14, 1), + Position = Vector3.new(x + side * 3, 7, -(i * 200)), + Color = side == -1 and Color3.fromRGB(255, 40, 120) or Color3.fromRGB(40, 160, 255), + Material = Enum.Material.Neon, + }) + pylon.Parent = map + end + end + + -- Spawn pad (where cars appear) at the start ------------------------------ + local pad = part({ + Name = "SpawnPad", + Size = Vector3.new(ROAD_WIDTH - 6, 1, 40), + Position = Vector3.new(0, 1.5, -20), + Color = Color3.fromRGB(20, 220, 120), + Material = Enum.Material.Neon, + Transparency = 0.4, + }) + pad.Parent = map + + -- Finish line at the far end ---------------------------------------------- + local finish = part({ + Name = "FinishLine", + Size = Vector3.new(ROAD_WIDTH, 1, 6), + Position = Vector3.new(0, 1.5, -totalLength + 30), + Color = Color3.fromRGB(240, 240, 240), + Material = Enum.Material.Neon, + Transparency = 0.3, + }) + finish.Parent = map + + -- A floor far below to catch anything that clips through (safety net) ------ + local floor = part({ + Name = "SafetyFloor", + Size = Vector3.new(ROAD_WIDTH * 4, 2, totalLength + 400), + Position = Vector3.new(0, -60, -totalLength / 2), + Transparency = 1, + CanCollide = true, + }) + floor.Parent = map + + map.Parent = Workspace + return map +end + +return MapBuilder diff --git a/src/server/RaceManager.luau b/src/server/RaceManager.luau new file mode 100644 index 0000000..23f9eb9 --- /dev/null +++ b/src/server/RaceManager.luau @@ -0,0 +1,107 @@ +--!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 new file mode 100644 index 0000000..ff09cce --- /dev/null +++ b/src/server/init.server.luau @@ -0,0 +1,116 @@ +--!strict +--[[ + init.server.luau (SERVER ENTRY POINT) + The one script Roblox runs on the server. It: + 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 + + Everything downstream is server-authoritative: clients REQUEST, server DECIDES. +]] + +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Shared = ReplicatedStorage.Shared +local Remotes = require(Shared.Remotes) + +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) + +-- 1. Remotes ---------------------------------------------------------------- +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) + +-- 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 ------------- +Players.PlayerAdded:Connect(function(player) + DataManager.onReady(player, function(data) + Economy.pushUpdate(player, data) + 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) + end) + 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) +end) + +rEquipCar.OnServerInvoke = function(player, carId) + if typeof(carId) ~= "string" then + return false + end + local data = DataManager.get(player) + if not data or not Economy.ownsCar(data, carId) then + return false + end + data.EquippedCarId = carId + Economy.pushUpdate(player, data) + 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.") diff --git a/src/shared/Cars.luau b/src/shared/Cars.luau new file mode 100644 index 0000000..639ac9e --- /dev/null +++ b/src/shared/Cars.luau @@ -0,0 +1,104 @@ +--!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. + + 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. +]] + +export type CarStats = { + Speed: number, + Acceleration: number, + Handling: number, +} + +export type Car = { + Id: string, + Name: string, + Model: string, -- real-world reference + Tier: number, -- 1 Junk .. 5 Exotic + Price: number, + RepRequired: number, + Stats: CarStats, + Color: { number }, -- {r,g,b} 0-1 for the placeholder body until real mesh exists + Description: string, + Starter: boolean?, -- true = granted free on first join +} + +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, + }, + { + Id = "hatch_junk", + 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.", + }, + { + 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.", + }, + { + 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.", + }, + { + 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.", + }, +} + +-- Fast lookup by Id (built once) -------------------------------------------- +Cars.ById = {} :: { [string]: Car } +for _, car in ipairs(Cars.List) do + Cars.ById[car.Id] = car +end + +function Cars.Get(id: string): Car? + return Cars.ById[id] +end + +return Cars diff --git a/src/shared/GameConfig.luau b/src/shared/GameConfig.luau new file mode 100644 index 0000000..cc19243 --- /dev/null +++ b/src/shared/GameConfig.luau @@ -0,0 +1,37 @@ +--!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. +]] + +local GameConfig = {} + +-- Economy ------------------------------------------------------------------- +GameConfig.StartingCash = 1500 -- enough to feel, not enough to skip the RX-7 +GameConfig.StartingRep = 0 +GameConfig.StartingCarId = "rx7_fc" -- the player's real 1993 Mazda RX-7 + +-- 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 + +-- 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) + +-- Autosave interval handled by ProfileStore internally; kept here for reference. +GameConfig.SessionTag = "fnf-sim-mvp" + +return GameConfig diff --git a/src/shared/ProfileTemplate.luau b/src/shared/ProfileTemplate.luau new file mode 100644 index 0000000..89e9550 --- /dev/null +++ b/src/shared/ProfileTemplate.luau @@ -0,0 +1,51 @@ +--!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. +]] + +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 +} + +local ProfileTemplate: ProfileData = { + Cash = GameConfig.StartingCash, + Rep = GameConfig.StartingRep, + OwnedCars = { + { + Id = GameConfig.StartingCarId, + Upgrades = { Engine = 0, Tires = 0, Nitro = 0 }, + }, + }, + EquippedCarId = GameConfig.StartingCarId, + LastDailyClaim = 0, + TotalRaces = 0, + Wins = 0, + Version = 1, +} + +return ProfileTemplate diff --git a/src/shared/Remotes.luau b/src/shared/Remotes.luau new file mode 100644 index 0000000..8ca8fa3 --- /dev/null +++ b/src/shared/Remotes.luau @@ -0,0 +1,63 @@ +--!strict +--[[ + 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). + + Add a remote = add one line to the DEFS table. No stringly-typed + FindFirstChild scattered across the codebase. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") + +local Remotes = {} + +-- name -> class. "Event" = RemoteEvent, "Function" = RemoteFunction ---------- +local DEFS: { [string]: string } = { + -- Server -> Client: pushes fresh profile snapshot for HUD/garage + 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 +} + +local FOLDER_NAME = "Remotes" + +-- Server-only: build the folder + instances. +function Remotes.init(): Folder + assert(RunService:IsServer(), "Remotes.init() must run on the server") + local existing = ReplicatedStorage:FindFirstChild(FOLDER_NAME) + if existing then + return existing :: Folder + end + local folder = Instance.new("Folder") + folder.Name = FOLDER_NAME + for name, kind in pairs(DEFS) do + local inst = Instance.new(kind == "Event" and "RemoteEvent" or "RemoteFunction") + inst.Name = name + inst.Parent = folder + end + folder.Parent = ReplicatedStorage + return folder +end + +-- Both realms: fetch a remote by name (waits, so client-safe). +function Remotes.get(name: string): Instance + assert(DEFS[name], "Unknown remote: " .. tostring(name)) + local folder = ReplicatedStorage:WaitForChild(FOLDER_NAME, 30) + assert(folder, "Remotes folder never appeared") + local inst = folder:WaitForChild(name, 30) + assert(inst, "Remote never appeared: " .. name) + return inst +end + +return Remotes diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..b1aeb38 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,6 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Tabs" +indent_width = 4 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" diff --git a/wally.toml b/wally.toml new file mode 100644 index 0000000..fde7178 --- /dev/null +++ b/wally.toml @@ -0,0 +1,8 @@ +[package] +name = "r3alchick3n/fnf-sim" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[server-dependencies] +ProfileStore = "lm-loleris/profilestore@1.0.3"