From 0144e83f05619a6c2061cba748d22cafae5ddfd6 Mon Sep 17 00:00:00 2001 From: RedlHosting Date: Thu, 6 Aug 2026 03:28:58 +0000 Subject: [PATCH] Part 1: registration, login and character creation written from scratch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Всё, что здесь лежит, написал ИИ-агент на живом сервере под запись — без ESX, без QBCore, без скачанных с форума скриптов. - resources/justrp — свой ресурс на Lua: регистрация, вход, создание персонажа, спавн - resources/justrp/html — экраны NUI: кинематографичная загрузка, вход, редактор персонажа - api/api.py — внутренний HTTP-API к MariaDB, пароли через scrypt - server.cfg.example — пример конфига Секреты (пароль БД, внутренний ключ API) вынесены в переменные окружения и convar. Ключ Cfx.re в репозиторий не попадает. Co-Authored-By: Claude Opus 5 --- api/api.py | 340 +++++++ resources/justrp/client/main.lua | 343 +++++++ resources/justrp/fxmanifest.lua | 25 + resources/justrp/html/app.html | 1433 ++++++++++++++++++++++++++++ resources/justrp/html/loading.html | 598 ++++++++++++ resources/justrp/server/main.lua | 210 ++++ resources/redl-core/README.md | 3 - server.cfg.example | 26 + 8 files changed, 2975 insertions(+), 3 deletions(-) create mode 100644 api/api.py create mode 100644 resources/justrp/client/main.lua create mode 100644 resources/justrp/fxmanifest.lua create mode 100644 resources/justrp/html/app.html create mode 100644 resources/justrp/html/loading.html create mode 100644 resources/justrp/server/main.lua delete mode 100644 resources/redl-core/README.md create mode 100644 server.cfg.example diff --git a/api/api.py b/api/api.py new file mode 100644 index 0000000..19b76e1 --- /dev/null +++ b/api/api.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +"""JustRP Database API — internal HTTP service on port 8787""" +import json +import hashlib +import os +import socket +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs + +try: + import pymysql + import pymysql.cursors +except ImportError: + import subprocess + subprocess.run(["pip3", "install", "pymysql"], check=True) + import pymysql + import pymysql.cursors + +# Секреты берутся из окружения. Заведите их перед запуском, например в systemd-юните: +# Environment=JUSTRP_API_SECRET=... Environment=JUSTRP_DB_PASS=... +API_SECRET = os.environ.get("JUSTRP_API_SECRET", "change-me") +DB = { + "host": os.environ.get("JUSTRP_DB_HOST", "localhost"), + "user": os.environ.get("JUSTRP_DB_USER", "fivem"), + "password": os.environ.get("JUSTRP_DB_PASS", "change-me"), + "database": os.environ.get("JUSTRP_DB_NAME", "fivem"), + "charset": "utf8mb4", + "cursorclass": pymysql.cursors.DictCursor, +} + + +def db(): + return pymysql.connect(**DB) + + +def hash_pw(password: str, salt: str) -> str: + return hashlib.scrypt( + password.encode(), salt=salt.encode(), n=16384, r=8, p=1 + ).hex() + + +def new_salt() -> str: + return os.urandom(16).hex() + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass # silence default logging + + def send_json(self, code, data): + body = json.dumps(data).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def auth_check(self): + return self.headers.get("X-API-Key") == API_SECRET + + def read_body(self): + n = int(self.headers.get("Content-Length", 0)) + if n == 0: + return {} + raw = self.rfile.read(n) + try: + return json.loads(raw) + except Exception: + return {} + + def do_POST(self): + if not self.auth_check(): + return self.send_json(403, {"error": "forbidden"}) + path = urlparse(self.path).path + body = self.read_body() + + if path == "/auth/register": + self.handle_register(body) + elif path == "/auth/login": + self.handle_login(body) + elif path == "/character/create": + self.handle_char_create(body) + elif path == "/character/save": + self.handle_char_save(body) + elif path == "/account/ban": + self.handle_ban(body) + else: + self.send_json(404, {"error": "not found"}) + + def do_GET(self): + if not self.auth_check(): + return self.send_json(403, {"error": "forbidden"}) + parsed = urlparse(self.path) + path = parsed.path + qs = parse_qs(parsed.query) + + if path == "/characters": + self.handle_chars_list(qs) + elif path == "/character": + self.handle_char_get(qs) + elif path == "/account": + self.handle_account_get(qs) + else: + self.send_json(404, {"error": "not found"}) + + # --- Auth --- + + def handle_register(self, body): + license_ = body.get("license", "").strip() + username = body.get("username", "").strip() + password = body.get("password", "") + + if not license_ or not username or not password: + return self.send_json(400, {"success": False, "error": "Missing fields"}) + if len(username) < 3 or len(username) > 20: + return self.send_json(400, {"success": False, "error": "Username must be 3–20 characters"}) + if len(password) < 6: + return self.send_json(400, {"success": False, "error": "Password must be at least 6 characters"}) + + salt = new_salt() + pw_hash = hash_pw(password, salt) + stored = f"{salt}:{pw_hash}" + + try: + conn = db() + with conn.cursor() as cur: + cur.execute("SELECT id FROM accounts WHERE username = %s", (username,)) + if cur.fetchone(): + conn.close() + return self.send_json(409, {"success": False, "error": "Username already taken"}) + cur.execute("SELECT id FROM accounts WHERE license = %s", (license_,)) + if cur.fetchone(): + conn.close() + return self.send_json(409, {"success": False, "error": "A JustRP account already exists on this device"}) + cur.execute( + "INSERT INTO accounts (license, username, password_hash) VALUES (%s, %s, %s)", + (license_, username, stored), + ) + account_id = cur.lastrowid + conn.commit() + conn.close() + return self.send_json(200, {"success": True, "account_id": account_id, "username": username}) + except Exception as e: + return self.send_json(500, {"success": False, "error": str(e)}) + + def handle_login(self, body): + license_ = body.get("license", "").strip() + username = body.get("username", "").strip() + password = body.get("password", "") + + if not license_ or not username or not password: + return self.send_json(400, {"success": False, "error": "Missing fields"}) + + try: + conn = db() + with conn.cursor() as cur: + cur.execute( + "SELECT id, password_hash, banned, ban_reason FROM accounts WHERE username = %s", + (username,), + ) + row = cur.fetchone() + if not row: + conn.close() + return self.send_json(401, {"success": False, "error": "Invalid username or password"}) + if row["banned"]: + conn.close() + return self.send_json(403, {"success": False, "error": f"Banned: {row['ban_reason'] or 'No reason given'}"}) + + stored = row["password_hash"] + salt, expected = stored.split(":", 1) + actual = hash_pw(password, salt) + if actual != expected: + conn.close() + return self.send_json(401, {"success": False, "error": "Invalid username or password"}) + + cur.execute("UPDATE accounts SET last_login = NOW() WHERE id = %s", (row["id"],)) + conn.commit() + conn.close() + return self.send_json(200, {"success": True, "account_id": row["id"], "username": username}) + except Exception as e: + return self.send_json(500, {"success": False, "error": str(e)}) + + # --- Characters --- + + def handle_chars_list(self, qs): + account_id = int(qs.get("account_id", ["0"])[0]) + if not account_id: + return self.send_json(400, {"error": "missing account_id"}) + try: + conn = db() + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM characters WHERE account_id = %s ORDER BY slot", + (account_id,), + ) + rows = cur.fetchall() + conn.close() + return self.send_json(200, {"success": True, "characters": rows}) + except Exception as e: + return self.send_json(500, {"success": False, "error": str(e)}) + + def handle_char_get(self, qs): + char_id = int(qs.get("id", ["0"])[0]) + if not char_id: + return self.send_json(400, {"error": "missing id"}) + try: + conn = db() + with conn.cursor() as cur: + cur.execute("SELECT * FROM characters WHERE id = %s", (char_id,)) + row = cur.fetchone() + conn.close() + if not row: + return self.send_json(404, {"success": False, "error": "Character not found"}) + return self.send_json(200, {"success": True, "character": row}) + except Exception as e: + return self.send_json(500, {"success": False, "error": str(e)}) + + def handle_char_create(self, body): + required = ["account_id", "slot", "firstname", "lastname"] + for f in required: + if f not in body: + return self.send_json(400, {"success": False, "error": f"Missing field: {f}"}) + + fields = { + "account_id": body["account_id"], + "slot": body["slot"], + "firstname": body["firstname"][:32], + "lastname": body["lastname"][:32], + "backstory": body.get("backstory", ""), + "gender": body.get("gender", 0), + "face_blend": body.get("face_blend", 0.5), + "face_shape": body.get("face_shape", 0.5), + "skin_tone": body.get("skin_tone", 0.5), + "eye_color": body.get("eye_color", 0), + "hair_style": body.get("hair_style", 0), + "hair_color": body.get("hair_color", 0), + "body_weight": body.get("body_weight", 0.5), + "outfit_top": body.get("outfit_top", 0), + "outfit_pants": body.get("outfit_pants", 0), + "outfit_shoes": body.get("outfit_shoes", 0), + } + + try: + conn = db() + with conn.cursor() as cur: + cur.execute( + "SELECT COUNT(*) as cnt FROM characters WHERE account_id = %s", + (fields["account_id"],), + ) + if cur.fetchone()["cnt"] >= 3: + conn.close() + return self.send_json(400, {"success": False, "error": "Maximum 3 characters per account"}) + + cols = ", ".join(fields.keys()) + placeholders = ", ".join(["%s"] * len(fields)) + cur.execute( + f"INSERT INTO characters ({cols}) VALUES ({placeholders})", + list(fields.values()), + ) + char_id = cur.lastrowid + conn.commit() + conn.close() + return self.send_json(200, {"success": True, "character_id": char_id}) + except Exception as e: + return self.send_json(500, {"success": False, "error": str(e)}) + + def handle_char_save(self, body): + char_id = body.get("character_id") + if not char_id: + return self.send_json(400, {"success": False, "error": "Missing character_id"}) + + allowed = { + "last_x", "last_y", "last_z", "last_heading", + "cash", "bank", "playtime", + "face_blend", "face_shape", "skin_tone", "eye_color", + "hair_style", "hair_color", "body_weight", + "outfit_top", "outfit_pants", "outfit_shoes", + } + updates = {k: v for k, v in body.items() if k in allowed} + if not updates: + return self.send_json(200, {"success": True}) + + set_clause = ", ".join(f"{k} = %s" for k in updates) + values = list(updates.values()) + [char_id] + + try: + conn = db() + with conn.cursor() as cur: + cur.execute( + f"UPDATE characters SET {set_clause} WHERE id = %s", + values, + ) + conn.commit() + conn.close() + return self.send_json(200, {"success": True}) + except Exception as e: + return self.send_json(500, {"success": False, "error": str(e)}) + + def handle_account_get(self, qs): + license_ = qs.get("license", [""])[0] + if not license_: + return self.send_json(400, {"error": "missing license"}) + try: + conn = db() + with conn.cursor() as cur: + cur.execute( + "SELECT id, username, banned, ban_reason FROM accounts WHERE license = %s", + (license_,), + ) + row = cur.fetchone() + conn.close() + if not row: + return self.send_json(404, {"success": False, "error": "not found"}) + return self.send_json(200, {"success": True, "account": row}) + except Exception as e: + return self.send_json(500, {"success": False, "error": str(e)}) + + def handle_ban(self, body): + account_id = body.get("account_id") + reason = body.get("reason", "No reason given") + if not account_id: + return self.send_json(400, {"success": False, "error": "missing account_id"}) + try: + conn = db() + with conn.cursor() as cur: + cur.execute( + "UPDATE accounts SET banned = 1, ban_reason = %s WHERE id = %s", + (reason, account_id), + ) + conn.commit() + conn.close() + return self.send_json(200, {"success": True}) + except Exception as e: + return self.send_json(500, {"success": False, "error": str(e)}) + + +if __name__ == "__main__": + server = HTTPServer(("127.0.0.1", 8787), Handler) + print("[JustRP API] Listening on 127.0.0.1:8787", flush=True) + server.serve_forever() diff --git a/resources/justrp/client/main.lua b/resources/justrp/client/main.lua new file mode 100644 index 0000000..48efd40 --- /dev/null +++ b/resources/justrp/client/main.lua @@ -0,0 +1,343 @@ +-- JustRP — Client-side core +-- Manages NUI screens, cameras, spawning and position sync + +local inSession = false +local charLoaded = false +local camHandle = nil +local saveTimer = 0 + +-- ─── NUI helper ───────────────────────────────────────────────────────────── + +local function nuiMsg(action, data) + SendNUIMessage({ action = action, data = data or {} }) +end + +-- ─── On resource start (loading screen is already showing) ────────────────── + +AddEventHandler("onClientMapStart", function() + Wait(1500) -- give the game a moment to settle + TriggerServerEvent("justrp:clientReady") +end) + +-- ─── Session start from server ─────────────────────────────────────────────── + +RegisterNetEvent("justrp:startSession") +AddEventHandler("justrp:startSession", function() + -- Freeze world while auth is happening + SetEntityVisible(PlayerPedId(), false, false) + FreezeEntityPosition(PlayerPedId(), true) + DisplayHud(false) + DisplayRadar(false) + + -- Shut loading screen, then show auth UI + Wait(800) + ShutdownLoadingScreenNui() + Wait(300) + + SetNuiFocus(true, true) + nuiMsg("showAuth") + inSession = true +end) + +-- ─── Auth results ──────────────────────────────────────────────────────────── + +RegisterNetEvent("justrp:auth:ok") +AddEventHandler("justrp:auth:ok", function(accountId, username) + nuiMsg("authOk", { accountId = accountId, username = username }) + TriggerServerEvent("justrp:chars:load") +end) + +RegisterNetEvent("justrp:auth:fail") +AddEventHandler("justrp:auth:fail", function(err) + nuiMsg("authFail", { error = err }) +end) + +-- ─── Character list from server ────────────────────────────────────────────── + +RegisterNetEvent("justrp:chars:list") +AddEventHandler("justrp:chars:list", function(chars) + nuiMsg("showChars", { characters = chars }) +end) + +-- ─── Character creation result ─────────────────────────────────────────────── + +RegisterNetEvent("justrp:char:created") +AddEventHandler("justrp:char:created", function(charId) + -- Reload character list so the new one shows + TriggerServerEvent("justrp:chars:load") +end) + +RegisterNetEvent("justrp:char:createFail") +AddEventHandler("justrp:char:createFail", function(err) + nuiMsg("charCreateFail", { error = err }) +end) + +-- ─── Character selected → load appearance ──────────────────────────────────── + +RegisterNetEvent("justrp:char:loaded") +AddEventHandler("justrp:char:loaded", function(ch) + -- Apply appearance to player ped + local ped = PlayerPedId() + local model = ch.gender == 1 and GetHashKey("mp_f_freemode_01") or GetHashKey("mp_m_freemode_01") + + RequestModel(model) + while not HasModelLoaded(model) do Wait(10) end + SetPlayerModel(PlayerId(), model) + SetModelAsNoLongerNeeded(model) + + ped = PlayerPedId() + + -- Face blend + SetPedHeadBlendData( + ped, + math.floor(ch.face_blend * 45), math.floor(ch.face_blend * 45), + 0, + math.floor(ch.face_blend * 45), math.floor(ch.face_blend * 45), + 0, + ch.face_blend, ch.face_blend, 0.0, false + ) + + -- Skin overlay (skin tone) + SetPedMicroblendData(ped, ch.skin_tone, ch.skin_tone, ch.skin_tone) + + -- Eyes + SetPedEyeColor(ped, ch.eye_color) + + -- Hair + SetPedComponentVariation(ped, 2, ch.hair_style, 0, 2) + SetPedHairColor(ped, ch.hair_color, 0) + + -- Body shape + SetPedFaceFeature(ped, 16, (ch.body_weight - 0.5) * 2) -- body weight feature + + -- Outfit + SetPedComponentVariation(ped, 11, ch.outfit_top, 0, 2) + SetPedComponentVariation(ped, 4, ch.outfit_pants, 0, 2) + SetPedComponentVariation(ped, 6, ch.outfit_shoes, 0, 2) + + -- Tell NUI to show spawn screen + nuiMsg("showSpawn", { + name = ch.firstname .. " " .. ch.lastname, + }) +end) + +-- ─── Spawn ─────────────────────────────────────────────────────────────────── + +RegisterNetEvent("justrp:spawn:go") +AddEventHandler("justrp:spawn:go", function(pos) + local ped = PlayerPedId() + + -- Teleport to position and unfreeze + SetEntityCoords(ped, pos.x, pos.y, pos.z, false, false, false, true) + SetEntityHeading(ped, pos.h) + + -- Cinematic camera fly-down + local camX = pos.x + math.sin(math.rad(pos.h)) * 200 + local camY = pos.y + math.cos(math.rad(pos.h)) * 200 + camHandle = CreateCameraWithParams( + "DEFAULT_SCRIPTED_CAMERA", + camX, camY, pos.z + 180, + 0.0, 0.0, 0.0, + 60.0, false, 0 + ) + SetCamActive(camHandle, true) + RenderScriptCams(true, true, 1000, true, true) + + -- Animate camera flying down to player + local steps = 80 + Citizen.CreateThread(function() + for i = 1, steps do + local t = i / steps + local ease = 1 - math.pow(1 - t, 3) -- ease out cubic + + local cx = camX + (pos.x - camX) * ease + local cy = camY + (pos.y - camY) * ease + local cz = (pos.z + 180) + ((pos.z + 2) - (pos.z + 180)) * ease + + SetCamCoord(camHandle, cx, cy, cz) + PointCamAtCoord(camHandle, pos.x, pos.y, pos.z) + Wait(16) + end + + -- Transition back to player cam + RenderScriptCams(false, true, 1200, true, true) + Wait(1300) + DestroyCam(camHandle, false) + camHandle = nil + + -- Reveal player + SetEntityVisible(ped, true, false) + FreezeEntityPosition(ped, false) + DisplayHud(true) + DisplayRadar(true) + SetNuiFocus(false, false) + nuiMsg("hide") + charLoaded = true + end) +end) + +-- ─── NUI Callbacks ─────────────────────────────────────────────────────────── + +-- Auth +RegisterNUICallback("authRegister", function(data, cb) + TriggerServerEvent("justrp:auth:register", data.username, data.password) + cb("ok") +end) + +RegisterNUICallback("authLogin", function(data, cb) + TriggerServerEvent("justrp:auth:login", data.username, data.password) + cb("ok") +end) + +-- Character management +RegisterNUICallback("charSelect", function(data, cb) + TriggerServerEvent("justrp:char:select", tonumber(data.id)) + cb("ok") +end) + +RegisterNUICallback("charCreate", function(data, cb) + TriggerServerEvent("justrp:char:create", data) + cb("ok") +end) + +-- Spawn selection +RegisterNUICallback("spawnRequest", function(data, cb) + TriggerServerEvent("justrp:spawn:request", data.location) + cb("ok") +end) + +-- Character creation — camera rotation control +RegisterNUICallback("rotateCam", function(data, cb) + if camHandle then + -- data.delta is horizontal rotation amount + local heading = GetEntityHeading(PlayerPedId()) + SetEntityHeading(PlayerPedId(), heading - (data.delta or 0) * 0.4) + end + cb("ok") +end) + +-- ─── Character creation preview camera ─────────────────────────────────────── + +local charCamActive = false +local charCamAngle = 0.0 + +RegisterNUICallback("startCharCam", function(data, cb) + if charCamActive then + cb("ok") + return + end + charCamActive = true + local ped = PlayerPedId() + SetEntityVisible(ped, true, false) + + camHandle = CreateCamera("DEFAULT_SCRIPTED_CAMERA", false) + SetCamActive(camHandle, true) + RenderScriptCams(true, false, 0, true, true) + + Citizen.CreateThread(function() + while charCamActive do + local pedPos = GetEntityCoords(ped) + local angle = charCamAngle + local dist = 1.6 + local cx = pedPos.x + math.sin(math.rad(angle)) * dist + local cy = pedPos.y - math.cos(math.rad(angle)) * dist + local cz = pedPos.z + 0.7 + + SetCamCoord(camHandle, cx, cy, cz) + PointCamAtCoord(camHandle, pedPos.x, pedPos.y, pedPos.z + 0.7) + charCamAngle = charCamAngle + 0.15 -- slow auto-orbit + + Wait(16) + end + end) + + cb("ok") +end) + +RegisterNUICallback("stopCharCam", function(data, cb) + charCamActive = false + if camHandle then + RenderScriptCams(false, true, 600, true, true) + Wait(700) + DestroyCam(camHandle, false) + camHandle = nil + end + cb("ok") +end) + +RegisterNUICallback("charCamDrag", function(data, cb) + charCamAngle = charCamAngle + (data.delta or 0) * 0.5 + charCamActive = true -- stop auto-orbit briefly + cb("ok") +end) + +-- Apply live appearance updates during char creation +RegisterNUICallback("applyAppearance", function(data, cb) + local ped = PlayerPedId() + + if data.gender ~= nil then + local model = data.gender == 1 + and GetHashKey("mp_f_freemode_01") + or GetHashKey("mp_m_freemode_01") + if not HasModelLoaded(model) then + RequestModel(model) + while not HasModelLoaded(model) do Wait(10) end + end + SetPlayerModel(PlayerId(), model) + SetModelAsNoLongerNeeded(model) + ped = PlayerPedId() + end + + if data.face_blend ~= nil then + SetPedHeadBlendData( + ped, + math.floor(data.face_blend * 45), + math.floor(data.face_blend * 45), + 0, + math.floor(data.face_blend * 45), + math.floor(data.face_blend * 45), + 0, + data.face_blend, data.face_blend, 0.0, false + ) + end + if data.skin_tone ~= nil then + SetPedMicroblendData(ped, data.skin_tone, data.skin_tone, data.skin_tone) + end + if data.eye_color ~= nil then + SetPedEyeColor(ped, data.eye_color) + end + if data.hair_style ~= nil or data.hair_color ~= nil then + SetPedComponentVariation(ped, 2, data.hair_style or 0, 0, 2) + SetPedHairColor(ped, data.hair_color or 0, 0) + end + if data.body_weight ~= nil then + SetPedFaceFeature(ped, 16, (data.body_weight - 0.5) * 2) + end + if data.outfit_top ~= nil then + SetPedComponentVariation(ped, 11, data.outfit_top, 0, 2) + end + if data.outfit_pants ~= nil then + SetPedComponentVariation(ped, 4, data.outfit_pants, 0, 2) + end + if data.outfit_shoes ~= nil then + SetPedComponentVariation(ped, 6, data.outfit_shoes, 0, 2) + end + + cb("ok") +end) + +-- ─── Position auto-save ─────────────────────────────────────────────────────── + +Citizen.CreateThread(function() + while true do + Wait(60000) + if charLoaded then + local ped = PlayerPedId() + local pos = GetEntityCoords(ped) + local hdg = GetEntityHeading(ped) + TriggerServerEvent("justrp:savePosition", pos.x, pos.y, pos.z, hdg) + end + end +end) + +print("[JustRP] Client core loaded.") diff --git a/resources/justrp/fxmanifest.lua b/resources/justrp/fxmanifest.lua new file mode 100644 index 0000000..ee46c44 --- /dev/null +++ b/resources/justrp/fxmanifest.lua @@ -0,0 +1,25 @@ +fx_version 'cerulean' +game 'gta5' + +author 'JustRP' +description 'JustRP — Core authentication, character management and session control' +version '1.0.0' + +loadscreen 'html/loading.html' +loadscreen_manual_shutdown 'yes' +loadscreen_cursor_shown 'yes' + +ui_page 'html/app.html' + +client_scripts { + 'client/main.lua', +} + +server_scripts { + 'server/main.lua', +} + +files { + 'html/loading.html', + 'html/app.html', +} diff --git a/resources/justrp/html/app.html b/resources/justrp/html/app.html new file mode 100644 index 0000000..008d721 --- /dev/null +++ b/resources/justrp/html/app.html @@ -0,0 +1,1433 @@ + + + + + +JustRP + + + + + + + +
+
+ +
Los Santos Roleplay
+
+
+ A persistent roleplaying community set in Los Santos.

+ Every character, every decision, every story — yours to write. +
+
+ +
+
+
+
Login
+
Register
+
+ +
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ + +
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+
+ + +
+ +
+
+
+ + +
+
Signed in as
+
+

Choose Your Character

+

Select a character to continue or create a new one

+
+
+ +
+
+ + +
+
+
Click · drag to rotate  ·  Scroll to zoom
+
+ +
+
+

New Character

+

Customise your appearance

+
+
+
+
+
+
+
+ +
+ + +
+
Identity
+
+
♂ Male
+
♀ Female
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
Face
+
+ + +
+
+ + +
+
+ + +
+
+
Eye Colour
+
+
+ + +
+
Body
+
+ + +
+
+
Hair Style
+
+
+
Hair Colour
+
+
+ + +
+
Top
+
+
+
Pants
+
+
+
Shoes
+
+
+ +
+ + + +
+
+
+
+
+ + +
+
+

Choose Spawn Location

+

Where does your story begin today?

+
+ +
+
+ 🏙 +
Legion Square
+
Downtown Los Santos. Busy streets, city life.
+
+
+ 🌊 +
Vespucci Beach
+
Laid-back beach district. Casual start.
+
+
+ 🏜 +
Sandy Shores
+
Rural desert town. Off the beaten path.
+
+
+ 🌲 +
Paleto Bay
+
Quiet northern town. Far from the chaos.
+
+
+ +
LSIA Airport
+
Just arrived in the city. Fresh start.
+
+
+ + +
+ + + + diff --git a/resources/justrp/html/loading.html b/resources/justrp/html/loading.html new file mode 100644 index 0000000..318dd77 --- /dev/null +++ b/resources/justrp/html/loading.html @@ -0,0 +1,598 @@ + + + + + +JustRP — Loading + + + + + + + +
+ +
+
JUSTRP
+
+
Los Santos Roleplay
+
+ +
+

Server Rules

+
    +
  1. Respect every player
  2. +
  3. No metagaming or powergaming
  4. +
  5. No random deathmatch (RDM)
  6. +
  7. No vehicle deathmatch (VDM)
  8. +
  9. Value your character's life
  10. +
  11. Stay in character at all times
  12. +
  13. Admin decisions are final
  14. +
+
+ + + + + + diff --git a/resources/justrp/server/main.lua b/resources/justrp/server/main.lua new file mode 100644 index 0000000..b6454ee --- /dev/null +++ b/resources/justrp/server/main.lua @@ -0,0 +1,210 @@ +-- JustRP — Server-side core +-- All DB operations go through the internal Python API on :8787 + +local API_BASE = "http://127.0.0.1:8787" +-- Должен совпадать с JUSTRP_API_SECRET у Python-API. Задаётся в server.cfg: +-- set justrp_api_key "ваш-ключ" +local API_KEY = GetConvar("justrp_api_key", "change-me") + +-- Per-player session state +local Sessions = {} + +-- ─── HTTP helper ──────────────────────────────────────────────────────────── + +local function apiRequest(method, path, data, cb) + PerformHttpRequest( + API_BASE .. path, + function(status, body, _headers) + local ok, result = pcall(json.decode, body or "{}") + if ok then + cb(status, result) + else + cb(status, { success = false, error = "parse error" }) + end + end, + method, + data and json.encode(data) or "", + { + ["Content-Type"] = "application/json", + ["X-API-Key"] = API_KEY, + } + ) +end + +-- ─── Player connecting ─────────────────────────────────────────────────────── + +AddEventHandler("playerConnecting", function(name, setKickReason, deferrals) + local src = source + local license = GetPlayerIdentifierByType(src, "license") or + GetPlayerIdentifierByType(src, "license2") or + "license:" .. tostring(src) + deferrals.defer() + Wait(0) + deferrals.update("Checking account…") + + apiRequest("GET", "/account?license=" .. license, nil, function(status, res) + if status == 200 and res.success then + if res.account.banned == 1 then + deferrals.done("You are banned from JustRP.\nReason: " .. (res.account.ban_reason or "No reason given")) + else + deferrals.done() + end + else + -- No account yet — let them through, they'll register in-game + deferrals.done() + end + end) +end) + +-- ─── Client ready → start session ─────────────────────────────────────────── + +RegisterNetEvent("justrp:clientReady") +AddEventHandler("justrp:clientReady", function() + local src = source + local license = GetPlayerIdentifierByType(src, "license") or + GetPlayerIdentifierByType(src, "license2") or + "license:" .. tostring(src) + Sessions[src] = { license = license, accountId = nil, charId = nil } + TriggerClientEvent("justrp:startSession", src) +end) + +-- ─── Auth events ──────────────────────────────────────────────────────────── + +RegisterNetEvent("justrp:auth:register") +AddEventHandler("justrp:auth:register", function(username, password) + local src = source + local ses = Sessions[src] + if not ses then return end + + apiRequest("POST", "/auth/register", { + license = ses.license, + username = username, + password = password, + }, function(status, res) + if res.success then + ses.accountId = res.account_id + TriggerClientEvent("justrp:auth:ok", src, res.account_id, res.username) + else + TriggerClientEvent("justrp:auth:fail", src, res.error or "Unknown error") + end + end) +end) + +RegisterNetEvent("justrp:auth:login") +AddEventHandler("justrp:auth:login", function(username, password) + local src = source + local ses = Sessions[src] + if not ses then return end + + apiRequest("POST", "/auth/login", { + license = ses.license, + username = username, + password = password, + }, function(status, res) + if res.success then + ses.accountId = res.account_id + TriggerClientEvent("justrp:auth:ok", src, res.account_id, res.username) + else + TriggerClientEvent("justrp:auth:fail", src, res.error or "Unknown error") + end + end) +end) + +-- ─── Character events ──────────────────────────────────────────────────────── + +RegisterNetEvent("justrp:chars:load") +AddEventHandler("justrp:chars:load", function() + local src = source + local ses = Sessions[src] + if not ses or not ses.accountId then + TriggerClientEvent("justrp:chars:list", src, {}) + return + end + + apiRequest("GET", "/characters?account_id=" .. ses.accountId, nil, function(status, res) + if res.success then + TriggerClientEvent("justrp:chars:list", src, res.characters or {}) + else + TriggerClientEvent("justrp:chars:list", src, {}) + end + end) +end) + +RegisterNetEvent("justrp:char:create") +AddEventHandler("justrp:char:create", function(data) + local src = source + local ses = Sessions[src] + if not ses or not ses.accountId then return end + + data.account_id = ses.accountId + apiRequest("POST", "/character/create", data, function(status, res) + if res.success then + ses.charId = res.character_id + TriggerClientEvent("justrp:char:created", src, res.character_id) + else + TriggerClientEvent("justrp:char:createFail", src, res.error or "Unknown error") + end + end) +end) + +RegisterNetEvent("justrp:char:select") +AddEventHandler("justrp:char:select", function(charId) + local src = source + local ses = Sessions[src] + if not ses or not ses.accountId then return end + + apiRequest("GET", "/character?id=" .. tostring(charId), nil, function(status, res) + if res.success and res.character then + local ch = res.character + if ch.account_id ~= ses.accountId then + return -- character doesn't belong to this account + end + ses.charId = charId + TriggerClientEvent("justrp:char:loaded", src, ch) + end + end) +end) + +-- ─── Spawn ─────────────────────────────────────────────────────────────────── + +RegisterNetEvent("justrp:spawn:request") +AddEventHandler("justrp:spawn:request", function(spawnKey) + local src = source + local ses = Sessions[src] + if not ses or not ses.charId then return end + + local spawns = { + legion = { x = -166.7, y = -928.9, z = 31.4, h = 120.0 }, + vespucci = { x = -1380.0, y = -1520.0, z = 4.9, h = 75.0 }, + sandy = { x = 1843.4, y = 3683.0, z = 34.3, h = 200.0 }, + paleto = { x = -165.8, y = 6330.0, z = 31.5, h = 270.0 }, + airport = { x = -1037.0, y = -2738.0, z = 20.0, h = 330.0 }, + } + local pos = spawns[spawnKey] or spawns["legion"] + TriggerClientEvent("justrp:spawn:go", src, pos) +end) + +-- ─── Auto-save position every 60s ──────────────────────────────────────────── + +RegisterNetEvent("justrp:savePosition") +AddEventHandler("justrp:savePosition", function(x, y, z, h) + local src = source + local ses = Sessions[src] + if not ses or not ses.charId then return end + apiRequest("POST", "/character/save", { + character_id = ses.charId, + last_x = x, + last_y = y, + last_z = z, + last_heading = h, + }, function() end) +end) + +-- ─── Disconnect ────────────────────────────────────────────────────────────── + +AddEventHandler("playerDropped", function() + local src = source + Sessions[src] = nil +end) + +print("[JustRP] Server core loaded.") diff --git a/resources/redl-core/README.md b/resources/redl-core/README.md deleted file mode 100644 index 821c650..0000000 --- a/resources/redl-core/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Part 1 - -Code for part 1 lands here after filming. diff --git a/server.cfg.example b/server.cfg.example new file mode 100644 index 0000000..c82b021 --- /dev/null +++ b/server.cfg.example @@ -0,0 +1,26 @@ +# JustRP — пример server.cfg +# Скопируйте в server.cfg и подставьте свои значения. + +endpoint_add_tcp "0.0.0.0:30120" +endpoint_add_udp "0.0.0.0:30120" + +sv_hostname "JustRP | Los Santos Roleplay" +sv_maxclients 64 +sv_scriptHookAllowed 0 +onesync on + +# Ключ сервера с https://keymaster.fivem.net — НИКОГДА не публикуйте его +sv_licenseKey "СЮДА_ВАШ_КЛЮЧ" + +# Должен совпадать с JUSTRP_API_SECRET у Python-API +set justrp_api_key "СЮДА_СВОЙ_КЛЮЧ" + +ensure mapmanager +ensure chat +ensure spawnmanager +ensure sessionmanager +ensure fivem-map-hipster +ensure basic-gamemode +ensure hardcap + +ensure justrp