From 80c1d75d2f37abd1eb1c959d9e2c89776e5e4b4a Mon Sep 17 00:00:00 2001 From: REDL Date: Thu, 6 Aug 2026 19:27:48 +0000 Subject: [PATCH] Part 1 final build: Los Santos RP written on camera by the agent rp_core (sessions, scrypt auth, self-written MySQL-wire driver rp_db), county-records NUI: residency-file auth, identity-record character intake, survey-grid spawn, procedural night-city loading screen. Secrets replaced with .example files. Co-Authored-By: Claude Fable 5 --- server-code/README.md | 64 ++ .../resources/[rp]/rp_core/client/state.lua | 24 + .../resources/[rp]/rp_core/fxmanifest.lua | 32 + .../[rp]/rp_core/lib/callbacks_client.lua | 46 + .../[rp]/rp_core/lib/callbacks_server.lua | 78 ++ .../resources/[rp]/rp_core/server/crypto.js | 62 ++ .../resources/[rp]/rp_core/server/player.lua | 264 +++++ .../[rp]/rp_core/shared/appearance.lua | 185 ++++ .../resources/[rp]/rp_core/shared/config.lua | 99 ++ .../resources/[rp]/rp_core/shared/util.lua | 111 ++ .../data/resources/[rp]/rp_db/fxmanifest.lua | 15 + .../data/resources/[rp]/rp_db/lib/db.lua | 87 ++ .../data/resources/[rp]/rp_db/server/mysql.js | 746 ++++++++++++++ .../resources/[rp]/rp_dbtest/fxmanifest.lua | 11 + .../resources/[rp]/rp_dbtest/server/test.lua | 122 +++ .../resources/[rp]/rp_loading/fxmanifest.lua | 26 + .../resources/[rp]/rp_loading/html/app.css | 302 ++++++ .../resources/[rp]/rp_loading/html/app.js | 567 +++++++++++ .../[rp]/rp_loading/html/fonts/archivo.woff2 | Bin 0 -> 34928 bytes .../[rp]/rp_loading/html/fonts/plex-400.woff2 | Bin 0 -> 14708 bytes .../[rp]/rp_loading/html/fonts/plex-500.woff2 | Bin 0 -> 14888 bytes .../resources/[rp]/rp_loading/html/index.html | 72 ++ .../resources/[rp]/rp_loading/html/tokens.css | 117 +++ .../resources/[rp]/rp_selftest/fxmanifest.lua | 17 + .../[rp]/rp_selftest/server/test.lua | 144 +++ .../resources/[rp]/rp_session/fxmanifest.lua | 19 + .../resources/[rp]/rp_session/server/main.lua | 438 ++++++++ .../resources/[rp]/rp_ui/client/camera.lua | 188 ++++ .../data/resources/[rp]/rp_ui/client/main.lua | 508 ++++++++++ .../data/resources/[rp]/rp_ui/client/ped.lua | 160 +++ .../data/resources/[rp]/rp_ui/fxmanifest.lua | 32 + .../data/resources/[rp]/rp_ui/html/app.css | 839 +++++++++++++++ .../data/resources/[rp]/rp_ui/html/app.js | 958 ++++++++++++++++++ .../[rp]/rp_ui/html/fonts/archivo.woff2 | Bin 0 -> 34928 bytes .../[rp]/rp_ui/html/fonts/plex-400.woff2 | Bin 0 -> 14708 bytes .../[rp]/rp_ui/html/fonts/plex-500.woff2 | Bin 0 -> 14888 bytes .../data/resources/[rp]/rp_ui/html/index.html | 241 +++++ .../data/resources/[rp]/rp_ui/html/tokens.css | 117 +++ server-code/etc/rpdata.env.example | 4 + server-code/etc/schema.sql | 105 ++ server-code/etc/secret.cfg.example | 1 + 41 files changed, 6801 insertions(+) create mode 100644 server-code/README.md create mode 100644 server-code/data/resources/[rp]/rp_core/client/state.lua create mode 100644 server-code/data/resources/[rp]/rp_core/fxmanifest.lua create mode 100644 server-code/data/resources/[rp]/rp_core/lib/callbacks_client.lua create mode 100644 server-code/data/resources/[rp]/rp_core/lib/callbacks_server.lua create mode 100644 server-code/data/resources/[rp]/rp_core/server/crypto.js create mode 100644 server-code/data/resources/[rp]/rp_core/server/player.lua create mode 100644 server-code/data/resources/[rp]/rp_core/shared/appearance.lua create mode 100644 server-code/data/resources/[rp]/rp_core/shared/config.lua create mode 100644 server-code/data/resources/[rp]/rp_core/shared/util.lua create mode 100644 server-code/data/resources/[rp]/rp_db/fxmanifest.lua create mode 100644 server-code/data/resources/[rp]/rp_db/lib/db.lua create mode 100644 server-code/data/resources/[rp]/rp_db/server/mysql.js create mode 100644 server-code/data/resources/[rp]/rp_dbtest/fxmanifest.lua create mode 100644 server-code/data/resources/[rp]/rp_dbtest/server/test.lua create mode 100644 server-code/data/resources/[rp]/rp_loading/fxmanifest.lua create mode 100644 server-code/data/resources/[rp]/rp_loading/html/app.css create mode 100644 server-code/data/resources/[rp]/rp_loading/html/app.js create mode 100644 server-code/data/resources/[rp]/rp_loading/html/fonts/archivo.woff2 create mode 100644 server-code/data/resources/[rp]/rp_loading/html/fonts/plex-400.woff2 create mode 100644 server-code/data/resources/[rp]/rp_loading/html/fonts/plex-500.woff2 create mode 100644 server-code/data/resources/[rp]/rp_loading/html/index.html create mode 100644 server-code/data/resources/[rp]/rp_loading/html/tokens.css create mode 100644 server-code/data/resources/[rp]/rp_selftest/fxmanifest.lua create mode 100644 server-code/data/resources/[rp]/rp_selftest/server/test.lua create mode 100644 server-code/data/resources/[rp]/rp_session/fxmanifest.lua create mode 100644 server-code/data/resources/[rp]/rp_session/server/main.lua create mode 100644 server-code/data/resources/[rp]/rp_ui/client/camera.lua create mode 100644 server-code/data/resources/[rp]/rp_ui/client/main.lua create mode 100644 server-code/data/resources/[rp]/rp_ui/client/ped.lua create mode 100644 server-code/data/resources/[rp]/rp_ui/fxmanifest.lua create mode 100644 server-code/data/resources/[rp]/rp_ui/html/app.css create mode 100644 server-code/data/resources/[rp]/rp_ui/html/app.js create mode 100644 server-code/data/resources/[rp]/rp_ui/html/fonts/archivo.woff2 create mode 100644 server-code/data/resources/[rp]/rp_ui/html/fonts/plex-400.woff2 create mode 100644 server-code/data/resources/[rp]/rp_ui/html/fonts/plex-500.woff2 create mode 100644 server-code/data/resources/[rp]/rp_ui/html/index.html create mode 100644 server-code/data/resources/[rp]/rp_ui/html/tokens.css create mode 100644 server-code/etc/rpdata.env.example create mode 100644 server-code/etc/schema.sql create mode 100644 server-code/etc/secret.cfg.example diff --git a/server-code/README.md b/server-code/README.md new file mode 100644 index 0000000..967cbe2 --- /dev/null +++ b/server-code/README.md @@ -0,0 +1,64 @@ +# Los Santos RP — server notes + +Everything here was written for this server. No ESX, no QBCore, no downloaded +resources: `cfx-server-data` is not used at all, and the bundled `chat` and +`monitor` resources are not started. + +## Layout + + /opt/fivem/server FXServer artefacts (build 25770, the recommended one) + /opt/fivem/data server-data root; run FXServer with this as cwd + server.cfg main config + secrets.cfg licence key + DB credentials, mode 600 + resources/[rp]/ our resources + /opt/fivem/bin supervise.sh (keep-alive), rcon (console pipe) + /opt/fivem/logs server.log, rotated at 200MB + /opt/fivem/etc/schema.sql the database schema + +## Resources + +| resource | what it is | +|---------------|------------| +| `rp_db` | MariaDB client speaking the MySQL wire protocol directly over a TCP socket — handshake, `mysql_native_password`, text result sets, connection pool, transactions. No npm packages. Lua front end in `lib/db.lua`. | +| `rp_core` | Config, shared validation, appearance model, scrypt password hashing, the player registry, and the client/server request bridge. | +| `rp_session` | Connection gate (bans, capacity), accounts, characters, spawn. Every decision is made here; the client can only ask. | +| `rp_loading` | Loading screen: procedural night-city flyover on canvas, progress bound to the game's real load events, ambience synthesised with the Web Audio API. | +| `rp_ui` | Sign in / register, character creation with a live ped preview, spawn picker. Scripted cameras live in `client/camera.lua`. | +| `rp_selftest` | Checks the account/character data path on every boot and proves it survived the restart. | +| `rp_dbtest` | Driver test suite. Not started by default; `ensure rp_dbtest` to run it. | + +## Operating it + + service fivem start|stop|restart|status + rcon # e.g. rcon refresh, rcon status + tail -f /opt/fivem/logs/server.log + +`supervise.sh` restarts FXServer if it dies, backing off from 5s up to 5min so +a server that cannot start (bad key, database down) does not hammer Cfx. It +also holds a FIFO open on the server's stdin — FXServer quits the moment stdin +reaches EOF, and that FIFO is what `rcon` writes to. + +## Starting on boot + +There is no systemd in this container. The platform runs a login shell as PID 1, +which sources `/etc/profile.d/redl-autostart.sh`, which starts everything marked +in `/etc/redl/enabled`. Both `mariadb` and `fivem` are marked, and `/etc/init.d/fivem` +waits for MariaDB to answer before starting the server. + +## Database + +MariaDB, database `rp`, application user `rp@127.0.0.1` with only +SELECT/INSERT/UPDATE/DELETE. Passwords are scrypt with a per-password salt and +the cost parameters stored in the hash. Tables: `accounts`, `auth_attempts`, +`characters`, `transactions`, `inventory`, `vehicles`. + +Every query goes through `DB.Query`/`DB.Insert`/... with `?` parameters. +`CLIENT_MULTI_STATEMENTS` is deliberately not negotiated, so even a failure of +the escaper could not turn a parameter into a second statement. + +## Not yet built + +The pre-spawn experience and the framework underneath it are done. Gameplay on +top of it — chat, HUD, inventory, jobs, vehicles, policing, medical — is not. +`characters`, `transactions`, `inventory` and `vehicles` already have their +tables and the money API in `rp_core/server/player.lua` is journalled and ready. diff --git a/server-code/data/resources/[rp]/rp_core/client/state.lua b/server-code/data/resources/[rp]/rp_core/client/state.lua new file mode 100644 index 0000000..fbd6801 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_core/client/state.lua @@ -0,0 +1,24 @@ +-- --------------------------------------------------------------------------- +-- Client mirror of this player's character state. +-- Read-only: the server pushes it, nothing here writes back. +-- --------------------------------------------------------------------------- + +Core = Core or {} + +local character = nil + +RegisterNetEvent('rp:core:character', function(data) + character = data + TriggerEvent('rp:core:characterChanged', character) +end) + +--- Current character, or nil while the player is still at the auth screens. +function Core.Character() + return character +end + +function Core.HasCharacter() + return character ~= nil +end + +exports('getCharacter', Core.Character) diff --git a/server-code/data/resources/[rp]/rp_core/fxmanifest.lua b/server-code/data/resources/[rp]/rp_core/fxmanifest.lua new file mode 100644 index 0000000..9f1c7e1 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_core/fxmanifest.lua @@ -0,0 +1,32 @@ +fx_version 'cerulean' +game 'gta5' + +name 'rp_core' +description 'Framework core: config, validation, player registry, request bridge' +author 'Los Santos RP' +version '1.0.0' + +shared_scripts { + 'shared/config.lua', + 'shared/util.lua', + 'shared/appearance.lua', +} + +server_scripts { + '@rp_db/lib/db.lua', + 'server/crypto.js', + 'server/player.lua', +} + +client_scripts { + 'client/state.lua', +} + +-- Consumed by other resources via '@rp_core/...' +files { + 'shared/config.lua', + 'shared/util.lua', + 'shared/appearance.lua', + 'lib/callbacks_client.lua', + 'lib/callbacks_server.lua', +} diff --git a/server-code/data/resources/[rp]/rp_core/lib/callbacks_client.lua b/server-code/data/resources/[rp]/rp_core/lib/callbacks_client.lua new file mode 100644 index 0000000..79fb920 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_core/lib/callbacks_client.lua @@ -0,0 +1,46 @@ +-- --------------------------------------------------------------------------- +-- Client half of the request/response bridge. +-- Include with: client_scripts { '@rp_core/lib/callbacks_client.lua', ... } +-- +-- Usage (inside a thread): +-- local result, err = Core.Callback('rp_session', 'auth:login', { ... }) +-- +-- Reply events are namespaced by the *calling* resource, so two resources +-- can never resolve each other's tokens. +-- --------------------------------------------------------------------------- + +Core = Core or {} + +local RES = GetCurrentResourceName() +local pending = {} +local seq = 0 + +RegisterNetEvent('rp:cb:res:' .. RES, function(token, result, err) + local p = pending[token] + if not p then return end -- already timed out + pending[token] = nil + p:resolve({ result = result, err = err }) +end) + +--- Blocking request to a server callback. Returns result, err. +--- Always returns; a lost reply surfaces as an error rather than a hang. +function Core.Callback(targetResource, name, payload, timeoutMs) + seq = seq + 1 + local token = seq + local p = promise.new() + pending[token] = p + + TriggerServerEvent('rp:cb:' .. targetResource, RES, name, token, payload) + + CreateThread(function() + Wait(timeoutMs or 15000) + local waiting = pending[token] + if waiting then + pending[token] = nil + waiting:resolve({ result = nil, err = 'no response from ' .. targetResource .. '/' .. name }) + end + end) + + local r = Citizen.Await(p) + return r.result, r.err +end diff --git a/server-code/data/resources/[rp]/rp_core/lib/callbacks_server.lua b/server-code/data/resources/[rp]/rp_core/lib/callbacks_server.lua new file mode 100644 index 0000000..c561774 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_core/lib/callbacks_server.lua @@ -0,0 +1,78 @@ +-- --------------------------------------------------------------------------- +-- Server half of the request/response bridge. +-- Include with: server_scripts { '@rp_core/lib/callbacks_server.lua', ... } +-- +-- Core.RegisterCallback('auth:login', function(src, payload) +-- return result, err -- err ~= nil is reported to the client +-- end) +-- +-- Handlers run in their own thread so they may block on the database. +-- Every handler is rate limited per player; a client that floods is ignored +-- rather than allowed to queue unbounded database work. +-- --------------------------------------------------------------------------- + +Core = Core or {} + +local RES = GetCurrentResourceName() +local handlers = {} +local buckets = {} -- [src] = { tokens = n, last = ms } + +local BUCKET_MAX = 15 -- burst +local BUCKET_RATE = 5 -- refilled per second + +local function allow(src) + local now = GetGameTimer() + local b = buckets[src] + if not b then + b = { tokens = BUCKET_MAX, last = now } + buckets[src] = b + end + local elapsed = (now - b.last) / 1000 + b.last = now + b.tokens = math.min(BUCKET_MAX, b.tokens + elapsed * BUCKET_RATE) + if b.tokens < 1 then return false end + b.tokens = b.tokens - 1 + return true +end + +AddEventHandler('playerDropped', function() + buckets[source] = nil +end) + +function Core.RegisterCallback(name, fn) + if handlers[name] then + print(('^3[rp_core]^7 callback %q registered twice in %s'):format(name, RES)) + end + handlers[name] = fn +end + +RegisterNetEvent('rp:cb:' .. RES, function(fromResource, name, token, payload) + local src = source + + -- A client controls fromResource/token, so they are only ever echoed back to + -- that same client. They are never used to look anything up on the server. + if type(fromResource) ~= 'string' or type(name) ~= 'string' then return end + + local reply = function(result, err) + TriggerClientEvent('rp:cb:res:' .. fromResource, src, token, result, err) + end + + if not allow(src) then + return reply(nil, 'slow down') + end + + local fn = handlers[name] + if not fn then + print(('^3[rp_core]^7 %s: unknown callback %q from %d'):format(RES, name, src)) + return reply(nil, 'unknown request') + end + + CreateThread(function() + local ok, result, err = pcall(fn, src, payload) + if not ok then + print(('^1[rp_core]^7 callback %q errored: %s'):format(name, tostring(result))) + return reply(nil, 'internal error') + end + reply(result, err) + end) +end) diff --git a/server-code/data/resources/[rp]/rp_core/server/crypto.js b/server-code/data/resources/[rp]/rp_core/server/crypto.js new file mode 100644 index 0000000..608c63d --- /dev/null +++ b/server-code/data/resources/[rp]/rp_core/server/crypto.js @@ -0,0 +1,62 @@ +// --------------------------------------------------------------------------- +// Password hashing for rp_auth. +// +// scrypt with per-password random salt. The cost parameters are stored inside +// the hash string, so they can be raised later without invalidating existing +// accounts - verify() always uses the parameters the hash was made with. +// +// Format: scrypt$N$r$p$$ +// --------------------------------------------------------------------------- + +const crypto = require('crypto'); + +const N = 16384; // CPU/memory cost +const R = 8; // block size +const P = 1; // parallelisation +const KEYLEN = 64; +const MAXMEM = 96 * 1024 * 1024; // scrypt needs ~128*N*r bytes = 16MB here + +function derive(password, salt, n, r, p, cb) { + crypto.scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: MAXMEM }, cb); +} + +global.exports('hashPassword', (password, cb) => { + if (typeof password !== 'string' || password.length === 0 || password.length > 200) { + return cb(null, 'invalid password'); + } + const salt = crypto.randomBytes(16); + derive(password, salt, N, R, P, (err, dk) => { + if (err) return cb(null, err.message); + cb(`scrypt$${N}$${R}$${P}$${salt.toString('base64')}$${dk.toString('base64')}`, null); + }); +}); + +global.exports('verifyPassword', (password, stored, cb) => { + if (typeof password !== 'string' || typeof stored !== 'string') return cb(false, null); + + const parts = stored.split('$'); + if (parts.length !== 6 || parts[0] !== 'scrypt') return cb(false, 'unrecognised hash format'); + + const n = parseInt(parts[1], 10); + const r = parseInt(parts[2], 10); + const p = parseInt(parts[3], 10); + if (!n || !r || !p) return cb(false, 'corrupt hash parameters'); + + let salt, expected; + try { + salt = Buffer.from(parts[4], 'base64'); + expected = Buffer.from(parts[5], 'base64'); + } catch (e) { + return cb(false, 'corrupt hash encoding'); + } + + derive(password, salt, n, r, p, (err, dk) => { + if (err) return cb(false, err.message); + // constant time: a wrong password must not be distinguishable by timing + const ok = dk.length === expected.length && crypto.timingSafeEqual(dk, expected); + cb(ok, null); + }); +}); + +// Opaque tokens (character session handles, drop ids, ...) +global.exports('randomToken', (bytes) => crypto.randomBytes(Math.min(Math.max(bytes || 16, 8), 64)).toString('hex')); diff --git a/server-code/data/resources/[rp]/rp_core/server/player.lua b/server-code/data/resources/[rp]/rp_core/server/player.lua new file mode 100644 index 0000000..832ea7f --- /dev/null +++ b/server-code/data/resources/[rp]/rp_core/server/player.lua @@ -0,0 +1,264 @@ +-- --------------------------------------------------------------------------- +-- Authoritative player registry. +-- +-- While a player is online their state lives in this table and nowhere else. +-- Balance changes are applied in memory *synchronously* (no yield between +-- read and write, so two concurrent handlers cannot both spend the same +-- money) and then mirrored to MariaDB asynchronously alongside a row in +-- `transactions`, which is the audit trail. +-- --------------------------------------------------------------------------- + +Core = Core or {} + +local players = {} -- [source] = player table +local byChar = {} -- [character id] = source + +-- --------------------------------------------------------------------------- +-- Lookup +-- --------------------------------------------------------------------------- + +function Core.Get(src) return players[src] end +function Core.GetByChar(charId) return players[byChar[charId] or -1] end +function Core.All() return players end + +local function requireChar(src) + local p = players[src] + if not p or not p.char then return nil end + return p +end + +-- --------------------------------------------------------------------------- +-- Lifecycle +-- --------------------------------------------------------------------------- + +--- Called by rp_session once credentials have been accepted. +function Core.AttachAccount(src, account) + players[src] = { + source = src, + license = account.license, + account = { id = account.id, username = account.username, role = account.role }, + char = nil, + spawned = false, + joinedAt = os.time(), + } + return players[src] +end + +--- Called once a character has been chosen. `row` is the DB row. +function Core.AttachCharacter(src, row) + local p = players[src] + if not p then return nil end + + p.char = { + id = row.id, + firstName = row.first_name, + lastName = row.last_name, + dob = row.dob, + gender = row.gender, + backstory = row.backstory, + cash = math.floor(tonumber(row.cash) or 0), + bank = math.floor(tonumber(row.bank) or 0), + health = tonumber(row.health) or 200, + armour = tonumber(row.armour) or 0, + job = row.job or 'unemployed', + jobGrade = tonumber(row.job_grade) or 0, + playtime = tonumber(row.playtime) or 0, + appearance = row.appearance and json.decode(row.appearance) or nil, + position = row.position and json.decode(row.position) or nil, + needs = row.needs and json.decode(row.needs) or { hunger = 100, thirst = 100, stress = 0 }, + } + p.sessionStart = os.time() + byChar[row.id] = src + + Core.PushCharacter(src) + Core.PublishState(src) + return p.char +end + +function Core.Detach(src) + local p = players[src] + if not p then return end + if p.char then byChar[p.char.id] = nil end + players[src] = nil +end + +-- --------------------------------------------------------------------------- +-- Synchronisation +-- --------------------------------------------------------------------------- + +--- Private state: only the owning client receives this. +function Core.PushCharacter(src) + local p = requireChar(src) + if not p then return end + TriggerClientEvent('rp:core:character', src, p.char) +end + +--- Public state: what every other client is allowed to know about this player. +function Core.PublishState(src) + local p = requireChar(src) + if not p then return end + local st = Player(src).state + st:set('rp:name', p.char.firstName .. ' ' .. p.char.lastName, true) + st:set('rp:job', p.char.job, true) + st:set('rp:charId', p.char.id, true) +end + +-- --------------------------------------------------------------------------- +-- Money +-- --------------------------------------------------------------------------- + +local VALID_ACCOUNTS = { cash = true, bank = true } + +local function journal(charId, kind, delta, balanceAfter, reason) + DB.TransactionAsync({ + { + query = ('UPDATE characters SET %s = ? WHERE id = ?'):format(kind), + values = { balanceAfter, charId }, + }, + { + query = 'INSERT INTO transactions (character_id, account_kind, delta, balance_after, reason) VALUES (?, ?, ?, ?, ?)', + values = { charId, kind, delta, balanceAfter, reason }, + }, + }, function(ok, err) + if not ok then + print(('^1[rp_core]^7 failed to journal %s %+d for character %d: %s') + :format(kind, delta, charId, tostring(err))) + end + end) +end + +--- Returns true on success. Amounts are always positive integers. +function Core.AddMoney(src, kind, amount, reason) + local p = requireChar(src) + if not p or not VALID_ACCOUNTS[kind] then return false end + amount = math.floor(tonumber(amount) or 0) + if amount <= 0 then return false end + + p.char[kind] = p.char[kind] + amount -- no yield: cannot interleave + local after = p.char[kind] + + Core.PushCharacter(src) + journal(p.char.id, kind, amount, after, reason or 'unspecified') + return true +end + +--- Returns false (and changes nothing) when the player cannot afford it. +function Core.RemoveMoney(src, kind, amount, reason) + local p = requireChar(src) + if not p or not VALID_ACCOUNTS[kind] then return false end + amount = math.floor(tonumber(amount) or 0) + if amount <= 0 then return false end + if p.char[kind] < amount then return false end + + p.char[kind] = p.char[kind] - amount + local after = p.char[kind] + + Core.PushCharacter(src) + journal(p.char.id, kind, -amount, after, reason or 'unspecified') + return true +end + +function Core.GetMoney(src, kind) + local p = requireChar(src) + if not p or not VALID_ACCOUNTS[kind] then return 0 end + return p.char[kind] +end + +-- --------------------------------------------------------------------------- +-- Persistence +-- --------------------------------------------------------------------------- + +--- Reads position and health from the server's own copy of the entity rather +--- than asking the client, so a modified client cannot lie about either. +local function snapshot(p) + local ped = GetPlayerPed(p.source) + if ped and ped ~= 0 and DoesEntityExist(ped) then + local c = GetEntityCoords(ped) + local h = GetEntityHeading(ped) + if c and c.x and not (c.x == 0.0 and c.y == 0.0) then + p.char.position = { x = c.x, y = c.y, z = c.z, h = h } + end + local hp = GetEntityHealth(ped) + if hp and hp > 0 then p.char.health = hp end + end + if p.sessionStart then + local now = os.time() + p.char.playtime = p.char.playtime + (now - p.sessionStart) + p.sessionStart = now + end +end + +function Core.Save(src, reason) + local p = requireChar(src) + if not p or not p.spawned then return end + snapshot(p) + local c = p.char + + DB.UpdateAsync([[ + UPDATE characters + SET cash = ?, bank = ?, health = ?, armour = ?, job = ?, job_grade = ?, + position = ?, needs = ?, appearance = ?, playtime = ?, last_played_at = NOW() + WHERE id = ? + ]], { + c.cash, c.bank, c.health, c.armour, c.job, c.jobGrade, + json.encode(c.position or {}), json.encode(c.needs or {}), + c.appearance and json.encode(c.appearance) or DB.NULL, + c.playtime, c.id, + }, function(_, err) + if err then + print(('^1[rp_core]^7 save failed for character %d (%s): %s'):format(c.id, tostring(reason), err)) + end + end) +end + +function Core.SaveAll(reason) + local n = 0 + for src in pairs(players) do + if players[src].char and players[src].spawned then + Core.Save(src, reason) + n = n + 1 + end + end + return n +end + +CreateThread(function() + local interval = (Config.Session.autosaveSeconds or 300) * 1000 + while true do + Wait(interval) + local n = Core.SaveAll('autosave') + if n > 0 then print(('^5[rp_core]^7 autosaved %d character(s)'):format(n)) end + end +end) + +AddEventHandler('playerDropped', function(reason) + local src = source + Core.Save(src, 'disconnect: ' .. tostring(reason)) + Core.Detach(src) +end) + +AddEventHandler('onResourceStop', function(res) + if res ~= GetCurrentResourceName() then return end + local n = Core.SaveAll('resource stop') + print(('^5[rp_core]^7 saving %d character(s) on shutdown'):format(n)) +end) + +-- --------------------------------------------------------------------------- +-- Exports for the other resources. None of these yield. +-- --------------------------------------------------------------------------- + +exports('getPlayer', function(src) return players[src] end) +exports('getCharacter', function(src) local p = players[src]; return p and p.char or nil end) +exports('attachAccount', Core.AttachAccount) +exports('attachCharacter',Core.AttachCharacter) +exports('detach', Core.Detach) +exports('addMoney', Core.AddMoney) +exports('removeMoney', Core.RemoveMoney) +exports('getMoney', Core.GetMoney) +exports('save', Core.Save) +exports('pushCharacter', Core.PushCharacter) +exports('publishState', Core.PublishState) +exports('setSpawned', function(src, v) + local p = players[src] + if p then p.spawned = v and true or false end +end) diff --git a/server-code/data/resources/[rp]/rp_core/shared/appearance.lua b/server-code/data/resources/[rp]/rp_core/shared/appearance.lua new file mode 100644 index 0000000..6c66beb --- /dev/null +++ b/server-code/data/resources/[rp]/rp_core/shared/appearance.lua @@ -0,0 +1,185 @@ +-- --------------------------------------------------------------------------- +-- Freemode ped appearance model. +-- +-- The client builds a ped from this table and the server stores it verbatim, +-- so it is sanitised here - once - and both sides use the same code. Anything +-- the client sends that is not described below is dropped. +-- --------------------------------------------------------------------------- + +Appearance = {} + +Appearance.MODELS = { + m = 'mp_m_freemode_01', + f = 'mp_f_freemode_01', +} + +-- SetPedHeadBlendData: 46 heritage faces per parent. +Appearance.PARENT_MAX = 45 + +-- SetPedFaceFeature indices, in the order the creator shows them. +Appearance.FEATURES = { + { id = 0, label = 'Nose width' }, + { id = 1, label = 'Nose height' }, + { id = 2, label = 'Nose length' }, + { id = 3, label = 'Nose bridge' }, + { id = 4, label = 'Nose tip' }, + { id = 5, label = 'Nose shift' }, + { id = 6, label = 'Brow height' }, + { id = 7, label = 'Brow depth' }, + { id = 8, label = 'Cheekbone height' }, + { id = 9, label = 'Cheekbone width' }, + { id = 10, label = 'Cheek width' }, + { id = 11, label = 'Eye opening' }, + { id = 12, label = 'Lip thickness' }, + { id = 13, label = 'Jaw width' }, + { id = 14, label = 'Jaw length' }, + { id = 15, label = 'Chin height' }, + { id = 16, label = 'Chin length' }, + { id = 17, label = 'Chin width' }, + { id = 18, label = 'Chin dimple' }, + { id = 19, label = 'Neck thickness' }, +} + +-- SetPedHeadOverlay slots that the creator exposes. +Appearance.OVERLAYS = { + { id = 2, key = 'eyebrows', label = 'Eyebrows', max = 33, tint = 'hair' }, + { id = 1, key = 'beard', label = 'Facial hair', max = 28, tint = 'hair' }, + { id = 0, key = 'blemishes', label = 'Blemishes', max = 23, tint = false }, + { id = 3, key = 'ageing', label = 'Ageing', max = 14, tint = false }, + { id = 6, key = 'complexion',label = 'Complexion', max = 11, tint = false }, + { id = 7, key = 'sundamage', label = 'Sun damage', max = 10, tint = false }, + { id = 9, key = 'freckles', label = 'Freckles', max = 17, tint = false }, + { id = 5, key = 'blush', label = 'Blush', max = 6, tint = 'makeup' }, + { id = 8, key = 'lipstick', label = 'Lipstick', max = 9, tint = 'makeup' }, + { id = 4, key = 'makeup', label = 'Make-up', max = 74, tint = 'makeup' }, +} + +-- Clothing components the creator exposes (0-11 exist; these are the useful ones). +Appearance.COMPONENTS = { + { id = 11, key = 'jacket', label = 'Top' }, + { id = 8, key = 'undershirt',label = 'Undershirt' }, + { id = 4, key = 'legs', label = 'Legs' }, + { id = 6, key = 'shoes', label = 'Shoes' }, + { id = 3, key = 'torso', label = 'Arms' }, + { id = 1, key = 'mask', label = 'Mask' }, + { id = 9, key = 'vest', label = 'Vest' }, + { id = 7, key = 'accessory', label = 'Accessory' }, + { id = 10, key = 'decal', label = 'Decal' }, + { id = 5, key = 'bag', label = 'Bag' }, +} + +Appearance.PROPS = { + { id = 0, key = 'hat', label = 'Hat' }, + { id = 1, key = 'glasses', label = 'Glasses' }, + { id = 2, key = 'ear', label = 'Earrings' }, +} + +local function num(v, lo, hi, fallback) + v = tonumber(v) + if not v or v ~= v then return fallback end -- also rejects NaN + if v < lo then return lo end + if v > hi then return hi end + return v +end + +local function int(v, lo, hi, fallback) + return math.floor(num(v, lo, hi, fallback) + 0.0) +end + +function Appearance.Default(gender) + local a = { + model = (gender == 'f') and 'f' or 'm', + parents = { father = 0, mother = 21, shapeMix = 0.5, skinMix = 0.5 }, + features = {}, + overlays = {}, + hair = { style = 0, colour = 0, highlight = 0 }, + eyeColour = 0, + components = {}, + props = {}, + } + for _, f in ipairs(Appearance.FEATURES) do a.features[tostring(f.id)] = 0.0 end + for _, o in ipairs(Appearance.OVERLAYS) do + a.overlays[o.key] = { index = -1, opacity = 1.0, colour = 0 } + end + for _, c in ipairs(Appearance.COMPONENTS) do + a.components[c.key] = { drawable = 0, texture = 0 } + end + for _, p in ipairs(Appearance.PROPS) do + a.props[p.key] = { drawable = -1, texture = 0 } + end + -- a plain default outfit rather than the naked base ped + a.components.jacket = { drawable = 15, texture = 0 } + a.components.undershirt = { drawable = 15, texture = 0 } + a.components.torso = { drawable = 15, texture = 0 } + a.components.legs = { drawable = 21, texture = 0 } + a.components.shoes = { drawable = 34, texture = 0 } + return a +end + +--- Returns a clean appearance table built only from known keys. +function Appearance.Sanitise(input) + local gender = (type(input) == 'table' and input.model == 'f') and 'f' or 'm' + local out = Appearance.Default(gender) + if type(input) ~= 'table' then return out end + + if type(input.parents) == 'table' then + out.parents.father = int(input.parents.father, 0, Appearance.PARENT_MAX, 0) + out.parents.mother = int(input.parents.mother, 0, Appearance.PARENT_MAX, 21) + out.parents.shapeMix = num(input.parents.shapeMix, 0.0, 1.0, 0.5) + out.parents.skinMix = num(input.parents.skinMix, 0.0, 1.0, 0.5) + end + + if type(input.features) == 'table' then + for _, f in ipairs(Appearance.FEATURES) do + local k = tostring(f.id) + out.features[k] = num(input.features[k], -1.0, 1.0, 0.0) + end + end + + if type(input.overlays) == 'table' then + for _, o in ipairs(Appearance.OVERLAYS) do + local given = input.overlays[o.key] + if type(given) == 'table' then + out.overlays[o.key] = { + index = int(given.index, -1, o.max, -1), + opacity = num(given.opacity, 0.0, 1.0, 1.0), + colour = int(given.colour, 0, 63, 0), + } + end + end + end + + if type(input.hair) == 'table' then + out.hair.style = int(input.hair.style, 0, 80, 0) + out.hair.colour = int(input.hair.colour, 0, 63, 0) + out.hair.highlight = int(input.hair.highlight, 0, 63, 0) + end + + out.eyeColour = int(input.eyeColour, 0, 31, 0) + + if type(input.components) == 'table' then + for _, c in ipairs(Appearance.COMPONENTS) do + local given = input.components[c.key] + if type(given) == 'table' then + out.components[c.key] = { + drawable = int(given.drawable, 0, 511, 0), + texture = int(given.texture, 0, 63, 0), + } + end + end + end + + if type(input.props) == 'table' then + for _, p in ipairs(Appearance.PROPS) do + local given = input.props[p.key] + if type(given) == 'table' then + out.props[p.key] = { + drawable = int(given.drawable, -1, 255, -1), + texture = int(given.texture, 0, 63, 0), + } + end + end + end + + return out +end diff --git a/server-code/data/resources/[rp]/rp_core/shared/config.lua b/server-code/data/resources/[rp]/rp_core/shared/config.lua new file mode 100644 index 0000000..8c40f9d --- /dev/null +++ b/server-code/data/resources/[rp]/rp_core/shared/config.lua @@ -0,0 +1,99 @@ +-- --------------------------------------------------------------------------- +-- Shared configuration. Loaded on both server and client. +-- --------------------------------------------------------------------------- + +Config = {} + +Config.ServerName = 'LOS SANTOS' +Config.ServerTag = 'ROLEPLAY' +Config.MaxChars = 3 + +-- Shown on the loading screen while the world streams in. +Config.Rules = { + { title = 'Stay in character', body = 'Your character does not know what you know. Breaking character in the world is the fastest way to lose the story.' }, + { title = 'Value your life', body = 'Act like the consequences are permanent. A gun in your face changes what you are willing to do.' }, + { title = 'No random deathmatch', body = 'Violence needs a reason the other person can understand. Escalate, do not detonate.' }, + { title = 'Do not power game', body = 'Give people a fair chance to react. Winning is not the point of a scene.' }, + { title = 'Leave the scene alive', body = 'If you die, your character forgets the thirty minutes before it. No revenge from the grave.' }, +} + +-- Spawn choices offered after character select. Coordinates are real map +-- locations; `cam` is where the establishing shot sits before it flies down. +Config.SpawnPoints = { + { + id = 'legion', + label = 'Legion Square', + area = 'Downtown', + blurb = 'Concrete, noise and opportunity. Everything starts here eventually.', + coords = vector4(195.12, -933.75, 30.69, 145.0), + cam = { pos = vector3(214.0, -880.0, 90.0), look = vector3(195.0, -933.0, 32.0) }, + map = { x = 0.512, y = 0.548 }, + }, + { + id = 'vespucci', + label = 'Vespucci Beach', + area = 'West coast', + blurb = 'Sand, boardwalk hustlers and the smell of two-stroke engines.', + coords = vector4(-1223.45, -1490.32, 4.38, 125.0), + cam = { pos = vector3(-1250.0, -1440.0, 60.0), look = vector3(-1223.0, -1490.0, 6.0) }, + map = { x = 0.318, y = 0.700 }, + }, + { + id = 'sandy', + label = 'Sandy Shores', + area = 'Blaine County', + blurb = 'Dust, trailers and people who would rather not be found.', + coords = vector4(1961.29, 3740.68, 32.34, 300.0), + cam = { pos = vector3(1995.0, 3700.0, 80.0), look = vector3(1961.0, 3740.0, 34.0) }, + map = { x = 0.760, y = 0.245 }, + }, + { + id = 'paleto', + label = 'Paleto Bay', + area = 'North coast', + blurb = 'A town small enough that everyone will know your name by Friday.', + coords = vector4(-108.71, 6467.32, 31.63, 225.0), + cam = { pos = vector3(-160.0, 6420.0, 80.0), look = vector3(-108.0, 6467.0, 33.0) }, + map = { x = 0.470, y = 0.075 }, + }, + { + id = 'mirror', + label = 'Mirror Park', + area = 'East Vinewood', + blurb = 'Quiet streets, loud neighbours, and rent you cannot quite afford.', + coords = vector4(1050.12, -720.45, 57.05, 90.0), + cam = { pos = vector3(1100.0, -680.0, 100.0), look = vector3(1050.0, -720.0, 58.0) }, + map = { x = 0.640, y = 0.505 }, + }, +} + +-- Establishing shots used behind the login / character screens. Each entry is +-- a slow dolly from `from` to `to` while looking at `look`. +Config.CinematicShots = { + { from = vector3(-1900.0, -600.0, 130.0), to = vector3(-1700.0, -500.0, 120.0), look = vector3(-1450.0, -400.0, 60.0) }, + { from = vector3(-75.0, -818.0, 320.0), to = vector3(30.0, -700.0, 290.0), look = vector3(-200.0, -1000.0, 60.0) }, + { from = vector3(1200.0, -1600.0, 160.0),to = vector3(1000.0, -1450.0, 150.0), look = vector3(500.0, -1200.0, 60.0) }, + { from = vector3(-1550.0, -1450.0, 90.0),to = vector3(-1400.0, -1550.0, 80.0), look = vector3(-1100.0, -1700.0, 20.0) }, + { from = vector3(730.0, 1200.0, 400.0), to = vector3(640.0, 1100.0, 380.0), look = vector3(300.0, 800.0, 200.0) }, +} + +-- Where a character stands while being edited or previewed. Flat, empty apron +-- at the airport: always streamed, no props to clip through, and dark enough +-- at the overridden clock time to light the ped cleanly. +Config.CreatorScene = { + ped = vector4(-1037.20, -2738.60, 20.17, 328.0), +} + +Config.Money = { + startingCash = 500, + startingBank = 2500, +} + +Config.Session = { + -- how long a client may sit at the auth screen before it is dropped + authTimeoutMs = 300000, + -- failed logins allowed per identity within the window + maxAttempts = 6, + attemptWindowS = 900, + autosaveSeconds = 300, +} diff --git a/server-code/data/resources/[rp]/rp_core/shared/util.lua b/server-code/data/resources/[rp]/rp_core/shared/util.lua new file mode 100644 index 0000000..858419d --- /dev/null +++ b/server-code/data/resources/[rp]/rp_core/shared/util.lua @@ -0,0 +1,111 @@ +-- --------------------------------------------------------------------------- +-- Small shared helpers. Kept deliberately thin. +-- --------------------------------------------------------------------------- + +Util = {} + +function Util.trim(s) + if type(s) ~= 'string' then return '' end + return (s:gsub('^%s+', ''):gsub('%s+$', '')) +end + +function Util.clamp(v, lo, hi) + if v < lo then return lo end + if v > hi then return hi end + return v +end + +function Util.round(v, places) + local m = 10 ^ (places or 0) + return math.floor(v * m + 0.5) / m +end + +--- Deep copy; cycles are not expected in config/state tables. +function Util.copy(t) + if type(t) ~= 'table' then return t end + local out = {} + for k, v in pairs(t) do out[k] = Util.copy(v) end + return out +end + +function Util.count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +--- Group separated money, e.g. 1234567 -> "1,234,567" +function Util.money(n) + local s = tostring(math.floor(math.abs(n or 0))) + local out = s:reverse():gsub('(%d%d%d)', '%1,'):reverse() + out = out:gsub('^,', '') + return (n or 0) < 0 and ('-' .. out) or out +end + +--- Names are stored capitalised regardless of how they were typed. +function Util.properName(s) + s = Util.trim(s):lower() + return (s:gsub("([^%s'-]+)", function(word) + return word:sub(1, 1):upper() .. word:sub(2) + end)) +end + +--- Validation shared by client (live feedback) and server (enforcement), so +--- the two can never disagree about what is acceptable. +Rules = {} + +Rules.username = { + min = 3, max = 20, + pattern = '^[%w_]+$', + hint = '3-20 characters, letters, numbers and underscore only', +} + +Rules.password = { + min = 8, max = 72, + hint = 'at least 8 characters, with a letter and a number', +} + +Rules.name = { + min = 2, max = 20, + pattern = "^[%a][%a'%-]*$", + hint = "letters, apostrophes and hyphens", +} + +function Rules.checkUsername(v) + v = Util.trim(v or '') + if #v < Rules.username.min then return false, 'Too short - ' .. Rules.username.hint end + if #v > Rules.username.max then return false, 'Too long - ' .. Rules.username.hint end + if not v:match(Rules.username.pattern) then return false, 'Only letters, numbers and underscore' end + return true +end + +function Rules.checkPassword(v) + v = v or '' + if #v < Rules.password.min then return false, 'At least 8 characters' end + if #v > Rules.password.max then return false, 'Too long' end + if not v:match('%a') then return false, 'Needs at least one letter' end + if not v:match('%d') then return false, 'Needs at least one number' end + return true +end + +function Rules.checkName(v) + v = Util.trim(v or '') + if #v < Rules.name.min then return false, 'Too short' end + if #v > Rules.name.max then return false, 'Too long' end + if not v:match(Rules.name.pattern) then return false, "Letters, ' and - only" end + return true +end + +--- Date of birth: accepts YYYY-MM-DD, must be a real date and an adult age. +function Rules.checkDob(v) + v = Util.trim(v or '') + local y, m, d = v:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$') + if not y then return false, 'Use YYYY-MM-DD' end + y, m, d = tonumber(y), tonumber(m), tonumber(d) + if m < 1 or m > 12 then return false, 'Month must be 01-12' end + local mdays = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if (y % 4 == 0 and y % 100 ~= 0) or y % 400 == 0 then mdays[2] = 29 end + if d < 1 or d > mdays[m] then return false, 'That day does not exist' end + if y < 1930 or y > 2010 then return false, 'Year must be between 1930 and 2010' end + return true +end diff --git a/server-code/data/resources/[rp]/rp_db/fxmanifest.lua b/server-code/data/resources/[rp]/rp_db/fxmanifest.lua new file mode 100644 index 0000000..ca5878c --- /dev/null +++ b/server-code/data/resources/[rp]/rp_db/fxmanifest.lua @@ -0,0 +1,15 @@ +fx_version 'cerulean' +game 'gta5' + +name 'rp_db' +description 'MariaDB client speaking the MySQL wire protocol directly - no external packages' +author 'Los Santos RP' +version '1.0.0' + +server_scripts { + 'server/mysql.js', +} + +files { + 'lib/db.lua', +} diff --git a/server-code/data/resources/[rp]/rp_db/lib/db.lua b/server-code/data/resources/[rp]/rp_db/lib/db.lua new file mode 100644 index 0000000..666bb28 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_db/lib/db.lua @@ -0,0 +1,87 @@ +-- --------------------------------------------------------------------------- +-- DB - Lua front end for rp_db. +-- +-- Loaded *into* the consuming resource with: +-- server_scripts { '@rp_db/lib/db.lua', 'server/whatever.lua' } +-- +-- so that Citizen.Await suspends the caller's own coroutine instead of trying +-- to yield across a cross-resource export call. +-- +-- Every function comes in two flavours: +-- DB.Query(sql, params) -> rows, err (blocking, inside a thread) +-- DB.QueryAsync(sql, params, cb) -> cb(rows, err) (callback, anywhere) +-- +-- Values are always passed as `?` parameters. Never build SQL by concatenation. +-- Use DB.NULL where you need a literal SQL NULL (Lua nil cannot survive a table). +-- --------------------------------------------------------------------------- + +DB = {} + +DB.NULL = 'RP_NULL' + +local backend = exports.rp_db + +local function async(method) + return function(sql, params, cb) + backend[method](backend, sql, params or {}, cb) + end +end + +local function blocking(method) + return function(sql, params) + local p = promise.new() + backend[method](backend, sql, params or {}, function(result, err) + -- wrapped: a promise resolved with nil would never wake the coroutine + p:resolve({ result = result, err = err }) + end) + local r = Citizen.Await(p) + if r.err then return nil, r.err end + return r.result + end +end + +--- Returns all rows as an array of tables (empty table when nothing matched). +DB.Query = blocking('query') +--- Returns the first row as a table, or nil. +DB.Single = blocking('single') +--- Returns the first column of the first row, or nil. +DB.Scalar = blocking('scalar') +--- Returns the AUTO_INCREMENT id produced by an INSERT. +DB.Insert = blocking('insert') +--- Returns the number of rows changed by an UPDATE/DELETE. +DB.Update = blocking('update') + +DB.QueryAsync = async('query') +DB.SingleAsync = async('single') +DB.ScalarAsync = async('scalar') +DB.InsertAsync = async('insert') +DB.UpdateAsync = async('update') + +--- All statements commit together or none of them do. +--- statements: { { query = 'UPDATE ..', values = { 1, 2 } }, ... } +function DB.Transaction(statements) + local p = promise.new() + backend:transaction(statements, function(ok, err) + p:resolve({ ok = ok, err = err }) + end) + local r = Citizen.Await(p) + if not r.ok then return false, r.err end + return true +end + +function DB.TransactionAsync(statements, cb) + backend:transaction(statements, cb) +end + +--- Blocks until the connection pool has at least one live connection. +--- Returns false if the database never came up within `timeout` ms. +function DB.WaitReady(timeout) + local waited, step = 0, 100 + timeout = timeout or 30000 + while not backend:isReady() do + if waited >= timeout then return false end + Citizen.Wait(step) + waited = waited + step + end + return true +end diff --git a/server-code/data/resources/[rp]/rp_db/server/mysql.js b/server-code/data/resources/[rp]/rp_db/server/mysql.js new file mode 100644 index 0000000..d1381e0 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_db/server/mysql.js @@ -0,0 +1,746 @@ +// --------------------------------------------------------------------------- +// rp_db - a MySQL/MariaDB client written for this server. +// +// Speaks the MySQL client/server protocol directly over a TCP socket: framing, +// handshake, mysql_native_password / caching_sha2_password (fast path), and +// the text result-set protocol. No external packages - only node built-ins. +// +// Deliberate choices: +// * CLIENT_MULTI_STATEMENTS is NOT negotiated, so a stacked-query injection +// ("'; DROP TABLE ..") is rejected by the server even if escaping failed. +// * CLIENT_LOCAL_FILES is NOT negotiated, so the server can never ask us to +// read a local file. +// * sql_mode is pinned at connect time, which also guarantees backslash +// escaping semantics that format() relies on. +// --------------------------------------------------------------------------- + +const net = require('net'); +const crypto = require('crypto'); + +// -- capability flags -------------------------------------------------------- +const CAP = { + LONG_PASSWORD: 0x00000001, + FOUND_ROWS: 0x00000002, + LONG_FLAG: 0x00000004, + CONNECT_WITH_DB: 0x00000008, + LOCAL_FILES: 0x00000080, + PROTOCOL_41: 0x00000200, + TRANSACTIONS: 0x00002000, + SECURE_CONNECTION: 0x00008000, + MULTI_STATEMENTS: 0x00010000, + MULTI_RESULTS: 0x00020000, + PLUGIN_AUTH: 0x00080000, + CONNECT_ATTRS: 0x00100000, + PLUGIN_AUTH_LENENC: 0x00200000, +}; + +const CLIENT_CAPS = + CAP.LONG_PASSWORD | CAP.LONG_FLAG | CAP.CONNECT_WITH_DB | CAP.PROTOCOL_41 | + CAP.TRANSACTIONS | CAP.SECURE_CONNECTION | CAP.PLUGIN_AUTH | CAP.PLUGIN_AUTH_LENENC; + +const COM = { QUIT: 0x01, QUERY: 0x03, PING: 0x0e }; + +// MySQL column type codes we care about when coercing text-protocol values. +const T_INT = new Set([1, 2, 3, 8, 9, 13]); // TINY SHORT LONG LONGLONG INT24 YEAR +const T_FLOAT = new Set([0, 4, 5, 246]); // DECIMAL FLOAT DOUBLE NEWDECIMAL + +// --------------------------------------------------------------------------- +// Buffer helpers +// --------------------------------------------------------------------------- + +class Reader { + constructor(buf) { this.b = buf; this.o = 0; } + u8() { return this.b[this.o++]; } + u16() { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; } + u32() { const v = this.b.readUInt32LE(this.o); this.o += 4; return v; } + skip(n) { this.o += n; return this; } + bytes(n) { const v = this.b.subarray(this.o, this.o + n); this.o += n; return v; } + nulStr() { + let i = this.b.indexOf(0, this.o); + if (i < 0) i = this.b.length; + const s = this.b.toString('utf8', this.o, i); + this.o = i + 1; + return s; + } + // length-encoded integer; returns null for the 0xfb NULL marker + lenenc() { + const f = this.b[this.o++]; + if (f < 0xfb) return f; + if (f === 0xfb) return null; + if (f === 0xfc) { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; } + if (f === 0xfd) { const v = this.b.readUIntLE(this.o, 3); this.o += 3; return v; } + const v = this.b.readBigUInt64LE(this.o); this.o += 8; + return Number(v); + } + lenencStr() { + const n = this.lenenc(); + if (n === null) return null; + return this.bytes(n).toString('utf8'); + } + remaining() { return this.b.length - this.o; } +} + +class Writer { + constructor() { this.parts = []; } + u8(v) { const b = Buffer.alloc(1); b[0] = v; this.parts.push(b); return this; } + u16(v) { const b = Buffer.alloc(2); b.writeUInt16LE(v); this.parts.push(b); return this; } + u32(v) { const b = Buffer.alloc(4); b.writeUInt32LE(v >>> 0); this.parts.push(b); return this; } + zeros(n) { this.parts.push(Buffer.alloc(n)); return this; } + raw(b) { this.parts.push(b); return this; } + nulStr(s) { this.parts.push(Buffer.from(s, 'utf8')); return this.u8(0); } + lenencBuf(b) { + if (b.length < 0xfb) this.u8(b.length); + else if (b.length < 0x10000) { this.u8(0xfc).u16(b.length); } + else { const t = Buffer.alloc(4); t.writeUIntLE(b.length, 0, 3); this.u8(0xfd).raw(t.subarray(0, 3)); } + return this.raw(b); + } + done() { return Buffer.concat(this.parts); } +} + +// --------------------------------------------------------------------------- +// Authentication +// --------------------------------------------------------------------------- + +const sha1 = (d) => crypto.createHash('sha1').update(d).digest(); +const sha256 = (d) => crypto.createHash('sha256').update(d).digest(); + +function xorBuf(a, b) { + const out = Buffer.alloc(a.length); + for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i % b.length]; + return out; +} + +// SHA1(pw) XOR SHA1( scramble + SHA1(SHA1(pw)) ) +function authNative(password, scramble) { + if (!password) return Buffer.alloc(0); + const s1 = sha1(Buffer.from(password, 'utf8')); + const s2 = sha1(s1); + return xorBuf(s1, sha1(Buffer.concat([scramble, s2]))); +} + +// SHA256(pw) XOR SHA256( SHA256(SHA256(pw)) + scramble ) [fast path only] +function authCachingSha2(password, scramble) { + if (!password) return Buffer.alloc(0); + const s1 = sha256(Buffer.from(password, 'utf8')); + const s2 = sha256(s1); + return xorBuf(s1, sha256(Buffer.concat([s2, scramble]))); +} + +function authResponse(plugin, password, scramble) { + switch (plugin) { + case 'mysql_native_password': return authNative(password, scramble); + case 'caching_sha2_password': return authCachingSha2(password, scramble); + case 'mysql_clear_password': return Buffer.concat([Buffer.from(password, 'utf8'), Buffer.alloc(1)]); + default: return null; // unsupported -> connection fails loudly + } +} + +// --------------------------------------------------------------------------- +// Value escaping. Every query goes through format(); values are never +// concatenated into SQL anywhere else in this codebase. +// --------------------------------------------------------------------------- + +const ESCAPE_MAP = { + '\0': '\\0', '\b': '\\b', '\t': '\\t', '\n': '\\n', + '\r': '\\r', '\x1a': '\\Z', '"': '\\"', "'": "\\'", '\\': '\\\\', +}; + +function escapeString(s) { + return "'" + s.replace(/[\0\b\t\n\r\x1a"'\\]/g, (c) => ESCAPE_MAP[c]) + "'"; +} + +function pad2(n) { return n < 10 ? '0' + n : '' + n; } + +function escapeValue(v) { + if (v === null || v === undefined) return 'NULL'; + switch (typeof v) { + case 'boolean': return v ? '1' : '0'; + case 'number': + if (!Number.isFinite(v)) throw new Error('cannot bind non-finite number'); + return String(v); + case 'bigint': return String(v); + case 'string': return escapeString(v); + case 'object': + if (v instanceof Date) { + return escapeString( + `${v.getFullYear()}-${pad2(v.getMonth() + 1)}-${pad2(v.getDate())} ` + + `${pad2(v.getHours())}:${pad2(v.getMinutes())}:${pad2(v.getSeconds())}` + ); + } + if (Buffer.isBuffer(v)) return 'x' + escapeString(v.toString('hex')); + if (Array.isArray(v)) return v.map(escapeValue).join(', '); + return escapeString(JSON.stringify(v)); // plain objects -> JSON columns + default: + throw new Error('cannot bind value of type ' + typeof v); + } +} + +// Replace `?` placeholders, skipping any that appear inside string/identifier +// literals or comments so that user data containing '?' can never shift binding. +function format(sql, params) { + if (!params || (Array.isArray(params) && params.length === 0)) return sql; + const list = Array.isArray(params) ? params : [params]; + let out = ''; + let pi = 0; + let i = 0; + const n = sql.length; + + while (i < n) { + const c = sql[i]; + + if (c === "'" || c === '"' || c === '`') { + const quote = c; + out += c; i++; + while (i < n) { + if (sql[i] === '\\' && quote !== '`') { out += sql[i] + (sql[i + 1] || ''); i += 2; continue; } + if (sql[i] === quote) { + if (sql[i + 1] === quote) { out += quote + quote; i += 2; continue; } // doubled quote + out += quote; i++; break; + } + out += sql[i]; i++; + } + continue; + } + if (c === '-' && sql[i + 1] === '-') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; } + if (c === '#') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; } + if (c === '/' && sql[i + 1] === '*') { + out += '/*'; i += 2; + while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) out += sql[i++]; + out += '*/'; i += 2; continue; + } + if (c === '?') { + if (pi >= list.length) throw new Error('not enough parameters for query'); + out += escapeValue(list[pi++]); + i++; continue; + } + out += c; i++; + } + + if (pi !== list.length) throw new Error(`parameter count mismatch (query takes ${pi}, got ${list.length})`); + return out; +} + +// --------------------------------------------------------------------------- +// Connection +// --------------------------------------------------------------------------- + +const MAX_PAYLOAD = 0xffffff; + +class Connection { + constructor(opts, id) { + this.opts = opts; + this.id = id; + this.socket = null; + this.buf = Buffer.alloc(0); + this.seq = 0; + this.state = 'offline'; + this.busy = false; + this.pinned = false; // reserved by a transaction; pump() must skip it + this.pending = null; // {resolve, reject, sql} + this.rs = null; // in-flight result set + this.multi = null; // reassembly buffer for >16MB payloads + this.serverCaps = 0; + this.onIdle = null; + this.retryDelay = 1000; + } + + log(msg) { console.log(`^5[rp_db]^7 conn#${this.id} ${msg}`); } + err(msg) { console.log(`^1[rp_db]^7 conn#${this.id} ${msg}`); } + + connect() { + if (this.state !== 'offline') return; + this.state = 'connecting'; + this.buf = Buffer.alloc(0); + this.seq = 0; + + const sock = net.createConnection({ host: this.opts.host, port: this.opts.port }); + this.socket = sock; + sock.setNoDelay(true); + + sock.on('data', (d) => { + this.buf = this.buf.length ? Buffer.concat([this.buf, d]) : d; + this.drainPackets(); + }); + sock.on('error', (e) => this.fail(e.message)); + sock.on('close', () => { if (this.state !== 'offline') this.fail('connection closed'); }); + } + + fail(reason) { + const wasReady = this.state === 'ready' || this.state === 'query'; + this.state = 'offline'; + if (this.socket) { this.socket.destroy(); this.socket = null; } + if (this.pending) { + const p = this.pending; this.pending = null; this.busy = false; + p.reject(new Error(reason)); + } + this.busy = false; + this.pinned = false; // a dropped connection must not stay reserved + this.rs = null; + if (wasReady) this.err(`lost connection: ${reason}`); + else this.err(`connect failed: ${reason}`); + // exponential backoff, capped + setTimeout(() => this.connect(), this.retryDelay); + this.retryDelay = Math.min(this.retryDelay * 2, 30000); + } + + // Split the stream into protocol packets, reassembling 16MB-split payloads. + drainPackets() { + for (;;) { + if (this.buf.length < 4) return; + const len = this.buf.readUIntLE(0, 3); + if (this.buf.length < 4 + len) return; + const seq = this.buf[3]; + const payload = this.buf.subarray(4, 4 + len); + this.buf = this.buf.subarray(4 + len); + this.seq = (seq + 1) & 0xff; + + if (len === MAX_PAYLOAD) { // more to come + this.multi = this.multi ? Buffer.concat([this.multi, payload]) : payload; + continue; + } + let full = payload; + if (this.multi) { full = Buffer.concat([this.multi, payload]); this.multi = null; } + + try { this.onPacket(full); } + catch (e) { this.err(`packet handling error: ${e.message}`); this.fail(e.message); return; } + } + } + + send(payload) { + if (!this.socket) return; + const chunks = []; + let off = 0; + do { + const slice = payload.subarray(off, off + MAX_PAYLOAD); + const head = Buffer.alloc(4); + head.writeUIntLE(slice.length, 0, 3); + head[3] = this.seq & 0xff; + this.seq = (this.seq + 1) & 0xff; + chunks.push(head, slice); + off += slice.length; + } while (off < payload.length); + this.socket.write(Buffer.concat(chunks)); + } + + sendCommand(cmd, text) { + this.seq = 0; // every command starts a new sequence + const body = text ? Buffer.from(text, 'utf8') : Buffer.alloc(0); + this.send(Buffer.concat([Buffer.from([cmd]), body])); + } + + onPacket(p) { + switch (this.state) { + case 'connecting': return this.onHandshake(p); + case 'auth': return this.onAuthResult(p); + case 'init': return this.onInitResult(p); + case 'query': return this.onQueryPacket(p); + default: + this.err(`unexpected packet in state ${this.state}`); + } + } + + // -- handshake ------------------------------------------------------------- + onHandshake(p) { + const r = new Reader(p); + const protocol = r.u8(); + if (protocol === 0xff) return this.fail(this.readError(p).message); + if (protocol !== 10) return this.fail(`unsupported protocol version ${protocol}`); + + this.serverVersion = r.nulStr(); + r.u32(); // connection id + const scramble1 = r.bytes(8); + r.skip(1); // filler + let caps = r.u16(); + let scramble2 = Buffer.alloc(0); + let plugin = 'mysql_native_password'; + + if (r.remaining() > 0) { + r.u8(); // charset + r.u16(); // status flags + caps |= r.u16() << 16; + const authDataLen = r.u8(); + r.skip(10); + if (caps & CAP.SECURE_CONNECTION) { + const n = Math.max(13, authDataLen - 8); + scramble2 = r.bytes(n); + // trailing NUL is part of the padding, not the scramble + if (scramble2.length && scramble2[scramble2.length - 1] === 0) { + scramble2 = scramble2.subarray(0, scramble2.length - 1); + } + } + if (caps & CAP.PLUGIN_AUTH) plugin = r.nulStr(); + } + + this.serverCaps = caps >>> 0; + this.scramble = Buffer.concat([scramble1, scramble2]); + + const resp = authResponse(plugin, this.opts.password, this.scramble); + if (resp === null) return this.fail(`server requested unsupported auth plugin '${plugin}'`); + + const w = new Writer(); + w.u32(CLIENT_CAPS).u32(MAX_PAYLOAD).u8(45 /* utf8mb4_general_ci */).zeros(23); + w.nulStr(this.opts.user); + w.lenencBuf(resp); + w.nulStr(this.opts.database); + w.nulStr(plugin); + + this.authPlugin = plugin; + this.state = 'auth'; + this.send(w.done()); + } + + onAuthResult(p) { + const marker = p[0]; + + if (marker === 0x00) return this.afterAuth(); + if (marker === 0xff) return this.fail(this.readError(p).message); + + if (marker === 0xfe) { // AuthSwitchRequest + const r = new Reader(p); r.u8(); + const plugin = r.nulStr(); + let scramble = r.bytes(r.remaining()); + if (scramble.length && scramble[scramble.length - 1] === 0) scramble = scramble.subarray(0, scramble.length - 1); + const resp = authResponse(plugin, this.opts.password, scramble); + if (resp === null) return this.fail(`server switched to unsupported auth plugin '${plugin}'`); + this.scramble = scramble; + this.authPlugin = plugin; + return this.send(resp); + } + + if (marker === 0x01) { // caching_sha2 extra auth data + const status = p[1]; + if (status === 0x03) return; // fast auth ok -> OK packet follows + if (status === 0x04) { + return this.fail( + 'caching_sha2_password requires a full authentication exchange (TLS or the ' + + 'server public key), which this driver does not implement. Give the database ' + + "user mysql_native_password, e.g. ALTER USER .. IDENTIFIED VIA mysql_native_password." + ); + } + return this.fail(`unexpected auth continuation 0x${status.toString(16)}`); + } + + this.fail(`unexpected auth response 0x${marker.toString(16)}`); + } + + // Pin session settings, then announce readiness. + afterAuth() { + this.retryDelay = 1000; + this.state = 'init'; + this.sendCommand( + COM.QUERY, + "SET NAMES utf8mb4, sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', " + + "time_zone='+00:00', autocommit=1" + ); + } + + onInitResult(p) { + if (p[0] === 0xff) return this.fail(this.readError(p).message); + this.state = 'ready'; + this.busy = false; + this.log(`ready (server ${this.serverVersion}, auth ${this.authPlugin})`); + if (this.onIdle) this.onIdle(this); + } + + // -- errors ---------------------------------------------------------------- + readError(p) { + const r = new Reader(p); + r.u8(); + const code = r.u16(); + let sqlState = ''; + if (p[3] === 0x23) { r.skip(1); sqlState = r.bytes(5).toString('ascii'); } + const message = p.subarray(r.o).toString('utf8'); + const e = new Error(`${message} (errno ${code}${sqlState ? ', sqlstate ' + sqlState : ''})`); + e.errno = code; e.sqlState = sqlState; + return e; + } + + readOk(p) { + const r = new Reader(p); + r.u8(); + const affectedRows = r.lenenc() || 0; + const insertId = r.lenenc() || 0; + return { affectedRows, insertId }; + } + + isEof(p) { return p[0] === 0xfe && p.length < 9; } + + // -- queries --------------------------------------------------------------- + query(sql) { + return new Promise((resolve, reject) => { + if (this.state !== 'ready') return reject(new Error('connection not ready')); + this.busy = true; + this.state = 'query'; + this.pending = { resolve, reject, sql }; + this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 }; + this.sendCommand(COM.QUERY, sql); + }); + } + + finish(value, error) { + const p = this.pending; + this.pending = null; + this.rs = null; + this.state = 'ready'; + this.busy = false; + if (p) { error ? p.reject(error) : p.resolve(value); } + if (this.onIdle) this.onIdle(this); + } + + onQueryPacket(p) { + const rs = this.rs; + + if (rs.stage === 'head') { + if (p[0] === 0x00) return this.finish(this.readOk(p)); + if (p[0] === 0xff) return this.finish(null, this.readError(p)); + if (p[0] === 0xfb) return this.finish(null, new Error('LOCAL INFILE requested but not enabled')); + const r = new Reader(p); + rs.colCount = r.lenenc(); + rs.stage = 'columns'; + return; + } + + if (rs.stage === 'columns') { + if (this.isEof(p)) { rs.stage = 'rows'; return; } + const r = new Reader(p); + r.lenencStr(); r.lenencStr(); r.lenencStr(); r.lenencStr(); // catalog, schema, table, org_table + const name = r.lenencStr(); + r.lenencStr(); // org_name + r.lenenc(); // length of fixed fields + r.u16(); // charset + r.u32(); // column length + const type = r.u8(); + rs.columns.push({ name, type }); + return; + } + + // stage === 'rows' + if (p[0] === 0xff) return this.finish(null, this.readError(p)); + if (this.isEof(p)) return this.finish(rs.rows); + + const r = new Reader(p); + const row = {}; + for (let i = 0; i < rs.colCount; i++) { + const col = rs.columns[i]; + const raw = r.lenencStr(); + row[col.name] = raw === null ? null : coerce(raw, col.type); + } + rs.rows.push(row); + } + + ping() { + if (this.state !== 'ready') return; + this.busy = true; + this.state = 'query'; + this.pending = { + resolve: () => {}, reject: (e) => this.err(`ping failed: ${e.message}`), sql: 'PING', + }; + this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 }; + this.sendCommand(COM.PING, ''); + } +} + +// Text protocol hands us strings; give Lua sensible types back. +function coerce(raw, type) { + if (T_INT.has(type)) { + const n = Number(raw); + return Number.isSafeInteger(n) ? n : raw; // keep huge BIGINTs exact as strings + } + if (T_FLOAT.has(type)) { + const n = Number(raw); + return Number.isNaN(n) ? raw : n; + } + return raw; // strings, dates, JSON, blobs +} + +// --------------------------------------------------------------------------- +// Pool +// --------------------------------------------------------------------------- + +class Pool { + constructor(opts) { + this.opts = opts; + this.queue = []; + this.conns = []; + for (let i = 0; i < opts.size; i++) { + const c = new Connection(opts, i + 1); + c.onIdle = () => this.pump(); + this.conns.push(c); + } + } + + start() { + this.conns.forEach((c, i) => setTimeout(() => c.connect(), i * 120)); + setInterval(() => { + for (const c of this.conns) if (c.state === 'ready' && !c.busy) c.ping(); + }, 30000); + } + + get ready() { return this.conns.some((c) => c.state === 'ready'); } + + free() { return this.conns.find((c) => c.state === 'ready' && !c.busy && !c.pinned); } + + pump() { + while (this.queue.length) { + const conn = this.free(); + if (!conn) return; + const job = this.queue.shift(); + clearTimeout(job.timer); + if (job.settled) continue; + job.settled = true; + conn.query(job.sql).then(job.resolve, job.reject); + } + } + + exec(sql) { + return new Promise((resolve, reject) => { + const job = { sql, resolve, reject, settled: false }; + // Each job owns its own deadline, so a query still fails loudly when the + // database is down and no connection ever becomes free to trigger pump(). + job.timer = setTimeout(() => { + if (job.settled) return; + job.settled = true; + const i = this.queue.indexOf(job); + if (i >= 0) this.queue.splice(i, 1); + reject(new Error('timed out waiting for a database connection')); + }, this.opts.queueTimeout); + this.queue.push(job); + this.pump(); + }); + } + + // Pins one connection for the whole transaction so no other query can + // interleave between START TRANSACTION and COMMIT. + async transaction(statements) { + const held = await this.acquire(); + try { + await held.query('START TRANSACTION'); + for (const st of statements) await held.query(st); + await held.query('COMMIT'); + return true; + } catch (e) { + try { await held.query('ROLLBACK'); } catch (_) { /* connection already gone */ } + throw e; + } finally { + held.release(); + } + } + + acquire() { + return new Promise((resolve, reject) => { + const started = Date.now(); + const attempt = () => { + const conn = this.free(); + if (conn) { + conn.pinned = true; + return resolve({ + query: (sql) => conn.query(sql), + release: () => { conn.pinned = false; this.pump(); }, + }); + } + if (Date.now() - started > this.opts.queueTimeout) { + return reject(new Error('timed out waiting for a database connection')); + } + setTimeout(attempt, 25); + }; + attempt(); + }); + } +} + +// --------------------------------------------------------------------------- +// FiveM bridge - exposes the pool to the Lua resources as exports. +// --------------------------------------------------------------------------- + +// Lua cannot put nil inside a table without creating a hole, so callers pass +// this sentinel when they need a real SQL NULL. rp_db exposes it as DB.NULL. +const NULL_SENTINEL = 'RP_NULL'; + +function unsentinel(v) { return v === NULL_SENTINEL ? null : v; } + +function normalizeParams(p) { + if (p === null || p === undefined) return []; + if (Array.isArray(p)) return p.map(unsentinel); + if (typeof p === 'object') { + const keys = Object.keys(p); + if (keys.length === 0) return []; // Lua {} arrives as an empty map + if (keys.every((k) => /^[0-9]+$/.test(k))) { // Lua array table + return keys.sort((a, b) => a - b).map((k) => unsentinel(p[k])); + } + return [p]; // a real object -> JSON column + } + return [unsentinel(p)]; +} + +const pool = new Pool({ + host: GetConvar('rp_db_host', '127.0.0.1'), + port: parseInt(GetConvar('rp_db_port', '3306'), 10), + user: GetConvar('rp_db_user', 'rp'), + password: GetConvar('rp_db_pass', ''), + database: GetConvar('rp_db_name', 'rp'), + size: parseInt(GetConvar('rp_db_pool', '4'), 10), + queueTimeout: 20000, +}); + +// Run a query and hand the result to a Lua callback as (result, err). +// The SQL *template* is logged on failure, never the formatted text, so bound +// values (password hashes and the like) stay out of the console. +function run(sql, params, cb, shape) { + const done = (result, err) => { + if (err) { + console.log(`^1[rp_db]^7 query failed: ${err}`); + console.log(`^1[rp_db]^7 in: ${String(sql).trim().slice(0, 200)}`); + } + if (typeof cb === 'function') { + try { cb(result, err || null); } + catch (e) { console.log(`^1[rp_db]^7 callback threw: ${e.message}`); } + } + }; + + let text; + try { text = format(sql, normalizeParams(params)); } + catch (e) { return done(null, e.message); } + + pool.exec(text).then( + (res) => { try { done(shape(res), null); } catch (e) { done(null, e.message); } }, + (e) => done(null, e.message) + ); +} + +const rowsOf = (res) => (Array.isArray(res) ? res : []); + +global.exports('query', (sql, params, cb) => run(sql, params, cb, (r) => r)); +global.exports('single', (sql, params, cb) => run(sql, params, cb, (r) => rowsOf(r)[0] || null)); +global.exports('scalar', (sql, params, cb) => run(sql, params, cb, (r) => { + const row = rowsOf(r)[0]; + if (!row) return null; + const k = Object.keys(row)[0]; + return k === undefined ? null : row[k]; +})); +global.exports('insert', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.insertId) || 0)); +global.exports('update', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.affectedRows) || 0)); + +// statements: array of {query = ..., values = {...}} tables from Lua +global.exports('transaction', (statements, cb) => { + let list; + try { + const arr = Array.isArray(statements) ? statements : Object.values(statements || {}); + list = arr.map((s) => format(s.query, normalizeParams(s.values))); + } catch (e) { + console.log(`^1[rp_db]^7 transaction rejected: ${e.message}`); + if (typeof cb === 'function') cb(false, e.message); + return; + } + pool.transaction(list).then( + () => { if (typeof cb === 'function') cb(true, null); }, + (e) => { + console.log(`^1[rp_db]^7 transaction rolled back: ${e.message}`); + if (typeof cb === 'function') cb(false, e.message); + } + ); +}); + +global.exports('isReady', () => pool.ready); +global.exports('nullSentinel', () => NULL_SENTINEL); + +pool.start(); diff --git a/server-code/data/resources/[rp]/rp_dbtest/fxmanifest.lua b/server-code/data/resources/[rp]/rp_dbtest/fxmanifest.lua new file mode 100644 index 0000000..1126f9e --- /dev/null +++ b/server-code/data/resources/[rp]/rp_dbtest/fxmanifest.lua @@ -0,0 +1,11 @@ +fx_version 'cerulean' +game 'gta5' + +name 'rp_dbtest' +description 'Self-test for the rp_db driver. Not started in production.' +version '1.0.0' + +server_scripts { + '@rp_db/lib/db.lua', + 'server/test.lua', +} diff --git a/server-code/data/resources/[rp]/rp_dbtest/server/test.lua b/server-code/data/resources/[rp]/rp_dbtest/server/test.lua new file mode 100644 index 0000000..3001bb8 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_dbtest/server/test.lua @@ -0,0 +1,122 @@ +-- Self-test for the hand-written MySQL driver. Run with: ensure rp_dbtest + +local passed, failed = 0, 0 + +local function check(name, ok, detail) + if ok then + passed = passed + 1 + print(('^2 PASS^7 %s'):format(name)) + else + failed = failed + 1 + print(('^1 FAIL^7 %s^1 %s^7'):format(name, detail or '')) + end +end + +CreateThread(function() + print('^5[dbtest]^7 waiting for the pool...') + if not DB.WaitReady(30000) then + print('^1[dbtest] database never became ready^7') + return + end + + -- 1. round trip through the text protocol ------------------------------- + local two = DB.Scalar('SELECT 1 + 1') + check('scalar arithmetic', two == 2, ('got %s (%s)'):format(tostring(two), type(two))) + + local row = DB.Single('SELECT ? AS s, ? AS n, ? AS f', { 'hello', 42, 1.5 }) + check('typed columns', row and row.s == 'hello' and row.n == 42 and row.f == 1.5, + row and json.encode(row) or 'nil') + + -- 2. hostile strings must survive verbatim ------------------------------ + DB.Update("DELETE FROM accounts WHERE username LIKE 'ztest%'") + + local nasty = [[O'Brien \ "quoted" ]] .. '\n\t' .. [[ 100% ` ; -- /* */ ?]] + local id = DB.Insert( + 'INSERT INTO accounts (username, password_hash) VALUES (?, ?)', + { 'ztest_a', nasty }) + check('insert returns id', type(id) == 'number' and id > 0, tostring(id)) + + local back = DB.Scalar('SELECT password_hash FROM accounts WHERE id = ?', { id }) + check('hostile string round trip', back == nasty, + ('stored %q'):format(tostring(back))) + + -- 3. a parameter can never become SQL ----------------------------------- + local inject = "'); DROP TABLE accounts; -- " + DB.Insert('INSERT INTO accounts (username, password_hash) VALUES (?, ?)', + { 'ztest_b', inject }) + local stillThere = DB.Scalar( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'accounts'") + check('injection is inert', stillThere == 1, 'accounts table missing!') + local storedInject = DB.Scalar("SELECT password_hash FROM accounts WHERE username = 'ztest_b'") + check('injection stored literally', storedInject == inject, tostring(storedInject)) + + -- 4. question marks inside literals must not shift binding --------------- + local q = DB.Single("SELECT 'what? really?' AS lit, ? AS bound", { 'bound-value' }) + check('literal ? not treated as placeholder', + q and q.lit == 'what? really?' and q.bound == 'bound-value', + q and json.encode(q) or 'nil') + + -- 5. NULL handling ------------------------------------------------------- + DB.Update('UPDATE accounts SET last_ip = ? WHERE id = ?', { DB.NULL, id }) + local isNull = DB.Scalar('SELECT last_ip IS NULL FROM accounts WHERE id = ?', { id }) + check('DB.NULL writes a real NULL', isNull == 1, tostring(isNull)) + local nilRow = DB.Single('SELECT last_ip FROM accounts WHERE id = ?', { id }) + check('NULL reads back as nil', nilRow ~= nil and nilRow.last_ip == nil, + nilRow and json.encode(nilRow) or 'nil') + + -- 6. unicode ------------------------------------------------------------- + local uni = 'Ünïcødé — 日本語 — 🚓' + DB.Update('UPDATE accounts SET password_hash = ? WHERE id = ?', { uni, id }) + check('utf8mb4 round trip', DB.Scalar('SELECT password_hash FROM accounts WHERE id = ?', { id }) == uni) + + -- 7. empty result sets --------------------------------------------------- + local none = DB.Query('SELECT * FROM accounts WHERE id = ?', { -1 }) + check('empty select returns empty table', type(none) == 'table' and #none == 0) + check('missing single returns nil', DB.Single('SELECT * FROM accounts WHERE id = ?', { -1 }) == nil) + + -- 8. transactions -------------------------------------------------------- + local ok = DB.Transaction({ + { query = 'UPDATE accounts SET role = ? WHERE id = ?', values = { 'admin', id } }, + { query = 'UPDATE accounts SET banned = ? WHERE id = ?', values = { 1, id } }, + }) + local after = DB.Single('SELECT role, banned FROM accounts WHERE id = ?', { id }) + check('transaction commits', ok and after.role == 'admin' and after.banned == 1, + json.encode(after or {})) + + local bad, err = DB.Transaction({ + { query = 'UPDATE accounts SET role = ? WHERE id = ?', values = { 'mod', id } }, + { query = 'UPDATE accounts SET nonexistent_column = 1 WHERE id = ?', values = { id } }, + }) + local rolled = DB.Scalar('SELECT role FROM accounts WHERE id = ?', { id }) + check('failed transaction rolls back', bad == false and rolled == 'admin', + ('role is now %s'):format(tostring(rolled))) + + -- 9. errors surface instead of failing silently -------------------------- + local res, qerr = DB.Query('SELECT * FROM table_that_does_not_exist') + check('bad query returns an error', res == nil and type(qerr) == 'string' and #qerr > 0, + tostring(qerr)) + + -- 10. wrong parameter count is caught, not guessed ----------------------- + local _, cerr = DB.Query('SELECT ?, ?', { 1 }) + check('parameter count mismatch reported', cerr ~= nil, tostring(cerr)) + + -- 11. a large payload exercises multi-packet framing in both directions -- + local big = string.rep('x', 700000) + check('700KB payload round trip', DB.Scalar('SELECT ? AS big', { big }) == big) + + -- 12. concurrency: many queries in flight across the pool ---------------- + local done, results = 0, {} + for i = 1, 40 do + DB.ScalarAsync('SELECT ?', { i }, function(v) done = done + 1; results[i] = v end) + end + local waited = 0 + while done < 40 and waited < 10000 do Wait(50); waited = waited + 50 end + local allGood = done == 40 + for i = 1, 40 do if results[i] ~= i then allGood = false end end + check('40 concurrent queries all answered correctly', allGood, ('done=%d'):format(done)) + + DB.Update("DELETE FROM accounts WHERE username LIKE 'ztest%'") + + print(('^5[dbtest]^7 ==== %d passed, %d failed ===='):format(passed, failed)) + if failed == 0 then print('^2[dbtest] driver OK^7') else print('^1[dbtest] DRIVER HAS PROBLEMS^7') end +end) diff --git a/server-code/data/resources/[rp]/rp_loading/fxmanifest.lua b/server-code/data/resources/[rp]/rp_loading/fxmanifest.lua new file mode 100644 index 0000000..6035030 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_loading/fxmanifest.lua @@ -0,0 +1,26 @@ +fx_version 'cerulean' +game 'gta5' + +name 'rp_loading' +description 'Loading screen: procedural night-city flyover, real load progress, synthesised ambience' +author 'Los Santos RP' +version '1.0.0' + +loadscreen 'html/index.html' + +-- The screen stays up after the game has finished loading so the client can +-- hand over to the in-game camera without a black frame in between. +loadscreen_manual_shutdown 'yes' + +-- Needed for the volume fader to be usable. +loadscreen_cursor 'yes' + +files { + 'html/index.html', + 'html/tokens.css', + 'html/app.css', + 'html/app.js', + 'html/fonts/archivo.woff2', + 'html/fonts/plex-400.woff2', + 'html/fonts/plex-500.woff2', +} diff --git a/server-code/data/resources/[rp]/rp_loading/html/app.css b/server-code/data/resources/[rp]/rp_loading/html/app.css new file mode 100644 index 0000000..6576a72 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_loading/html/app.css @@ -0,0 +1,302 @@ +/* --------------------------------------------------------------------------- + Loading screen. + Layout: the city runs full bleed; the record sits in a rail on the left, + held there by a gradient scrim rather than a panel, so the two never feel + like separate windows. + --------------------------------------------------------------------------- */ + +#sky, .grain, .scrim, .vignette { + position: fixed; + inset: 0; + pointer-events: none; +} + +#sky { width: 100%; height: 100%; display: block; } + +.scrim { + background: linear-gradient( + 90deg, + rgba(8, 9, 11, 0.97) 0%, + rgba(8, 9, 11, 0.93) 34%, + rgba(8, 9, 11, 0.72) 58%, + rgba(8, 9, 11, 0.15) 82%, + rgba(8, 9, 11, 0) 100% + ); +} + +.vignette { + background: radial-gradient(120% 90% at 62% 45%, transparent 40%, rgba(0, 0, 0, 0.55) 100%); +} + +/* film grain, drawn once as an SVG turbulence tile */ +.grain { + opacity: 0.16; + mix-blend-mode: overlay; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E"); + animation: grainshift 640ms steps(2) infinite; +} +@keyframes grainshift { + 0% { transform: translate(0, 0); } + 50% { transform: translate(-3px, 2px); } + 100% { transform: translate(2px, -2px); } +} + +/* --- rail ---------------------------------------------------------------- */ + +.rail { + position: relative; + z-index: 2; + width: var(--rail); + height: 100%; + padding: var(--gut-y) var(--gut-x); + display: grid; + grid-template-rows: auto 1fr auto auto; + gap: clamp(20px, 2.4vh, 34px); +} + +/* staged entrance; each element declares its own delay */ +.reveal { + opacity: 0; + transform: translateY(10px); + animation: rise 900ms var(--ease-out) forwards; + animation-delay: var(--d, 0ms); +} +@keyframes rise { + to { opacity: 1; transform: none; } +} + +/* --- file header --------------------------------------------------------- */ + +.file { + border-top: 1px solid var(--sodium-line); + padding-top: 14px; +} +.file .serial { + margin-top: 7px; + color: var(--paper-dim); +} +.file .serial span { color: var(--sodium); } + +/* --- wordmark ------------------------------------------------------------ */ + +.brand { align-self: end; } + +.wordmark { + font-size: clamp(46px, 5.2vw, 82px); + font-weight: 800; + line-height: 0.86; + letter-spacing: 0.3em; + text-indent: 0.3em; /* keeps the wide tracking optically centred */ + opacity: 0; + animation: settle 1500ms var(--ease-out) 180ms forwards; +} +/* the name tightens into place, like a stamp being pressed */ +@keyframes settle { + 0% { opacity: 0; letter-spacing: 0.3em; text-indent: 0.3em; filter: blur(6px); } + 60% { opacity: 1; } + 100% { opacity: 1; letter-spacing: -0.028em; text-indent: 0; filter: blur(0); } +} + +.tagrow { + display: flex; + align-items: center; + gap: 14px; + margin-top: 16px; +} +.tagline { + font-family: 'Plex Mono', monospace; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.42em; + color: var(--sodium); + text-transform: uppercase; +} +.dash { + flex: 1; + height: 1px; + background: linear-gradient(90deg, var(--sodium-line), transparent); +} + +/* --- notices ------------------------------------------------------------- */ + +.notices { align-self: center; } + +.notice-head { + display: flex; + justify-content: space-between; + align-items: baseline; + padding-bottom: 10px; + border-bottom: 1px solid var(--line); +} +.notice-index { color: var(--muted); } + +.notice { padding-top: 18px; min-height: 132px; } + +.notice-title { + font-size: 21px; + font-weight: 600; + letter-spacing: -0.015em; + margin-bottom: 8px; +} +.notice-body { + font-size: 14.5px; + color: var(--paper-dim); + max-width: 40ch; +} + +.notice.swap .notice-title, +.notice.swap .notice-body { animation: none; } + +.notice-title, .notice-body { + animation: noticein 620ms var(--ease-out) both; +} +.notice-body { animation-delay: 70ms; } +@keyframes noticein { + from { opacity: 0; transform: translateY(7px); } + to { opacity: 1; transform: none; } +} + +/* --- progress ------------------------------------------------------------ */ + +.load { align-self: end; } + +.loadhead { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 11px; + gap: 16px; +} +.step { + color: var(--paper-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.pct { + font-family: 'Plex Mono', monospace; + font-weight: 500; + font-size: 28px; + line-height: 1; + color: var(--paper); + font-variant-numeric: tabular-nums; +} +.pct i { + font-style: normal; + font-size: 13px; + color: var(--muted); + margin-left: 2px; +} + +.track { + position: relative; + height: 2px; + background: var(--line); + overflow: hidden; +} +.bar { + display: block; + height: 100%; + width: 0%; + background: var(--sodium); + transition: width 420ms var(--ease); +} +/* a light runs ahead of the fill so the bar reads as active even when the + game stalls on a long step */ +.bar::after { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 64px; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 159, 69, 0.85)); + animation: crawl 1900ms linear infinite; +} +@keyframes crawl { + 0% { transform: translateX(-64px); opacity: 0; } + 35% { opacity: 1; } + 100% { transform: translateX(0); opacity: 0; } +} + +/* --- volume fader -------------------------------------------------------- */ + +.fader { + display: flex; + align-items: center; + gap: 14px; + margin-top: 26px; + padding-top: 18px; + border-top: 1px solid var(--line-soft); +} + +.mute { + width: 30px; + height: 30px; + display: grid; + place-items: center; + background: transparent; + border: 1px solid var(--line); + color: var(--paper-dim); + cursor: pointer; + transition: color 180ms var(--ease), border-color 180ms var(--ease), transform 120ms var(--ease); +} +.mute:hover { color: var(--sodium); border-color: var(--sodium-line); } +.mute:active { transform: translateY(1px); } +.mute.off #wave { opacity: 0.18; } +.mute.off { color: var(--muted); } + +input[type='range'] { + flex: 1; + -webkit-appearance: none; + appearance: none; + height: 22px; + background: transparent; + cursor: pointer; +} +input[type='range']::-webkit-slider-runnable-track { + height: 2px; + background: var(--line); +} +input[type='range']::-webkit-slider-thumb { + -webkit-appearance: none; + width: 3px; + height: 15px; + margin-top: -6.5px; + background: var(--sodium); + border: 0; + border-radius: 0; + transition: height 160ms var(--ease), margin-top 160ms var(--ease); +} +input[type='range']:hover::-webkit-slider-thumb, +input[type='range']:focus-visible::-webkit-slider-thumb { + height: 21px; + margin-top: -9.5px; +} + +.volval { width: 3ch; text-align: right; color: var(--muted); } + +.audiohint { + margin-top: 10px; + color: var(--sodium); + animation: pulse 2.4s ease-in-out infinite; +} +@keyframes pulse { 50% { opacity: 0.45; } } + +.sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* Once the world is ready the rail steps aside and reveals the live camera. + The page background has to go transparent too, otherwise the fade reveals + nothing but our own backdrop colour. */ +body.handover, body.handover .rail { background: transparent !important; } +body.handover .scrim { transition: opacity 1400ms var(--ease); opacity: 0; } +body.handover #sky { transition: opacity 1400ms var(--ease); opacity: 0; } +body.handover .grain { transition: opacity 1400ms var(--ease); opacity: 0; } +body.handover .rail { transition: opacity 900ms var(--ease), transform 1200ms var(--ease); opacity: 0; transform: translateY(-12px); } diff --git a/server-code/data/resources/[rp]/rp_loading/html/app.js b/server-code/data/resources/[rp]/rp_loading/html/app.js new file mode 100644 index 0000000..5e9813b --- /dev/null +++ b/server-code/data/resources/[rp]/rp_loading/html/app.js @@ -0,0 +1,567 @@ +/* --------------------------------------------------------------------------- + Loading screen behaviour. + + Three jobs: + 1. draw the city - a procedural night skyline flown over on canvas + 2. report progress - bound to the load events the game actually emits + 3. play ambience - synthesised in the browser, nothing is downloaded + --------------------------------------------------------------------------- */ + +'use strict'; + +const $ = (id) => document.getElementById(id); + +/* ========================================================================= + 1. The city + ========================================================================= */ + +/* Small deterministic PRNG so the skyline is stable across a resize. */ +function rng(seed) { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +const SKY = { + canvas: $('sky'), + ctx: null, + layers: [], + w: 0, + h: 0, + dpr: 1, + t0: performance.now(), +}; + +/* Depth layers, far to near. Every layer is darker than the hazy sky behind + it, which is what makes a silhouette read at all; the far ones are lifted + towards the sky colour to stand in for atmosphere. `base` is the ground line + as a fraction of the viewport, so near towers rise right through the frame. + The difference in scroll rate is what reads as flight. */ +const LAYER_SPEC = [ + { depth: 0.00, speed: 4, minW: 28, maxW: 66, minH: 0.09, maxH: 0.21, base: 0.76, tone: '#20263a', lit: 0.16, win: 0.30, haze: 0.17 }, + { depth: 0.25, speed: 11, minW: 36, maxW: 90, minH: 0.13, maxH: 0.30, base: 0.83, tone: '#171c2b', lit: 0.20, win: 0.42, haze: 0.14 }, + { depth: 0.50, speed: 24, minW: 50, maxW: 122, minH: 0.19, maxH: 0.42, base: 0.91, tone: '#10141f', lit: 0.24, win: 0.58, haze: 0.10 }, + { depth: 0.75, speed: 50, minW: 74, maxW: 178, minH: 0.28, maxH: 0.62, base: 1.02, tone: '#090c14', lit: 0.26, win: 0.74, haze: 0.06 }, + { depth: 1.00, speed: 100, minW: 104, maxW: 262, minH: 0.44, maxH: 0.98, base: 1.20, tone: '#04060b', lit: 0.19, win: 0.90, haze: 0.00 }, +]; + +function buildLayer(spec, index, w, h) { + const cv = document.createElement('canvas'); + cv.width = Math.ceil(w * 2); + cv.height = h; + const c = cv.getContext('2d'); + const rand = rng(9173 + index * 7717); + + const base = h * spec.base; + const towers = []; + + let x = -60; + while (x < cv.width + 60) { + const bw = spec.minW + rand() * (spec.maxW - spec.minW); + const bh = h * (spec.minH + rand() * (spec.maxH - spec.minH)); + const top = base - bh; + + c.fillStyle = spec.tone; + c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), Math.ceil(h - top)); + + /* a hairline of sodium bounce along the roof edge */ + if (rand() < 0.55) { + c.fillStyle = `rgba(255,159,69,${0.05 + spec.glow * 0.16})`; + c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), 1); + } + + /* windows */ + const cell = 6 + spec.depth * 8; + const pad = Math.max(2, cell * 0.34); + for (let wy = top + pad * 2; wy < base - pad; wy += cell) { + for (let wx = x + pad; wx < x + bw - pad; wx += cell) { + if (rand() > spec.lit) continue; + const warm = rand(); + /* mostly sodium/tungsten, a few cold offices; distant windows are + dimmed so they read as depth rather than as static */ + const col = warm < 0.72 + ? `rgba(255,${150 + Math.floor(rand() * 50)},${70 + Math.floor(rand() * 40)},${(0.35 + rand() * 0.5) * spec.win})` + : `rgba(190,205,225,${(0.18 + rand() * 0.3) * spec.win})`; + c.fillStyle = col; + c.fillRect(Math.round(wx), Math.round(wy), Math.max(1, cell * 0.34), Math.max(1, cell * 0.42)); + } + } + + /* tall towers get an aviation light */ + if (bh > h * 0.5 && rand() < 0.5) { + towers.push({ x: x + bw / 2, y: top - 2, phase: rand() * Math.PI * 2 }); + } + + x += bw + 2 + rand() * 16; + } + + return { cv, spec, towers, base }; +} + +function resizeSky() { + const dpr = Math.min(window.devicePixelRatio || 1, 1.5); + SKY.dpr = dpr; + SKY.w = window.innerWidth; + SKY.h = window.innerHeight; + SKY.canvas.width = Math.floor(SKY.w * dpr); + SKY.canvas.height = Math.floor(SKY.h * dpr); + SKY.ctx = SKY.canvas.getContext('2d'); + SKY.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + SKY.layers = LAYER_SPEC.map((s, i) => buildLayer(s, i, SKY.w, SKY.h)); +} + +function drawSky(now) { + const { ctx, w, h } = SKY; + if (!ctx) return; + const t = (now - SKY.t0) / 1000; + + /* Sky: light pollution rather than stars. It has to stay brighter than the + buildings all the way down, otherwise there is no silhouette to see. */ + const g = ctx.createLinearGradient(0, 0, 0, h); + g.addColorStop(0.00, '#080b14'); + g.addColorStop(0.34, '#121627'); + g.addColorStop(0.60, '#23202f'); + g.addColorStop(0.78, '#412c27'); + g.addColorStop(0.90, '#6b4527'); + g.addColorStop(1.00, '#8a5726'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); + + /* the haze dome over downtown */ + const halo = ctx.createRadialGradient(w * 0.6, h * 0.9, 0, w * 0.6, h * 0.9, h * 0.85); + halo.addColorStop(0, 'rgba(255,159,69,0.34)'); + halo.addColorStop(0.45, 'rgba(255,140,60,0.12)'); + halo.addColorStop(1, 'rgba(255,140,60,0)'); + ctx.fillStyle = halo; + ctx.fillRect(0, 0, w, h); + + /* a slow descent: everything drifts down and grows a touch over time */ + const descend = Math.sin(t * 0.06) * 10 + t * 0.9; + + for (const layer of SKY.layers) { + const { cv, spec } = layer; + const span = cv.width / 2; + let ox = -((t * spec.speed) % span); + const oy = descend * (0.25 + spec.depth * 0.9); + + ctx.globalAlpha = 1; + ctx.drawImage(cv, ox, oy, cv.width, h); + + /* aviation lights blink on their own phase */ + for (const tw of layer.towers) { + const blink = Math.sin(t * 1.5 + tw.phase); + if (blink < 0.72) continue; + const px = tw.x + ox; + const py = tw.y + oy - h * 0.06; + ctx.fillStyle = `rgba(226,72,52,${(blink - 0.72) / 0.28})`; + ctx.beginPath(); + ctx.arc(px, py, 1.6, 0, Math.PI * 2); + ctx.fill(); + if (px > span) { + ctx.beginPath(); + ctx.arc(px - span, py, 1.6, 0, Math.PI * 2); + ctx.fill(); + } + } + + /* Aerial perspective: a warm veil laid over everything drawn so far, so + each nearer layer sits in front of progressively more atmosphere. */ + if (spec.haze > 0) { + ctx.fillStyle = `rgba(104,72,50,${spec.haze})`; + ctx.fillRect(0, 0, w, h); + } + } + ctx.globalAlpha = 1; +} + +/* ========================================================================= + 2. Progress, bound to the game's own load events + ========================================================================= */ + +const Progress = { + shown: 0, + target: 0, + step: 'Waiting for the game', + + set(fraction, step) { + if (typeof fraction === 'number' && isFinite(fraction)) { + /* never let it walk backwards - it reads as a fault */ + this.target = Math.max(this.target, Math.min(1, Math.max(0, fraction))); + } + if (step) this.step = step; + }, + + tick() { + /* ease towards the real figure so long steps still feel alive */ + this.shown += (this.target - this.shown) * 0.08; + const pct = Math.min(100, Math.round(this.shown * 100)); + $('bar').style.width = (this.shown * 100).toFixed(2) + '%'; + $('pct').firstChild.nodeValue = String(pct); + if ($('step').textContent !== this.step) $('step').textContent = this.step; + }, +}; + +/* Turns the game's internal init-function names into something a player can read. */ +const STEP_NAMES = { + MAP: 'Loading the map', + BEFORE_MAP_LOADED: 'Preparing the world', + AFTER_MAP_LOADED: 'Placing the world', + SESSION_INIT: 'Joining the session', + INIT_BEFORE_MAP_LOADED: 'Starting up', + INIT_AFTER_MAP_LOADED: 'Finishing up', + INIT_SESSION: 'Joining the session', +}; + +window.addEventListener('message', (event) => { + const d = event.data || {}; + switch (d.eventName) { + case 'loadProgress': + Progress.set(d.loadFraction); + break; + + case 'startInitFunction': + Progress.set(null, STEP_NAMES[d.type] || 'Starting up'); + break; + + case 'initFunctionInvoking': + if (typeof d.idx === 'number' && typeof d.count === 'number' && d.count > 0) { + Progress.set(null, `${STEP_NAMES[d.type] || 'Starting up'} ${d.idx}/${d.count}`); + } + break; + + case 'startDataFileEntries': + Progress.set(null, `Streaming ${d.count} asset packs`); + break; + + case 'performMapLoadFunction': + Progress.set(null, 'Building the map'); + break; + + case 'startWarning': + case 'onLogLine': + if (d.message) Progress.set(null, String(d.message).slice(0, 70)); + break; + + /* --- our own messages, sent from rp_ui once the client is running --- */ + case 'rp:rules': + if (Array.isArray(d.rules) && d.rules.length) { + Notices.load(d.rules); + } + break; + + case 'rp:server': + if (d.name) document.querySelector('.wordmark').textContent = d.name; + if (d.serial) $('serial').textContent = d.serial; + if (d.status) $('hostcount').textContent = d.status; + break; + + case 'rp:progress': + Progress.set(d.fraction, d.step); + break; + + /* the world is ready: dissolve into the live camera behind us */ + case 'rp:handover': + Progress.set(1, 'Ready'); + document.body.classList.add('handover'); + break; + } +}); + +/* ========================================================================= + 3. Posted notices + ========================================================================= */ + +const Notices = { + items: [ + { title: 'Stay in character', body: 'Your character does not know what you know. Breaking character in the world is the fastest way to lose the story.' }, + { title: 'Value your life', body: 'Act like the consequences are permanent. A gun in your face changes what you are willing to do.' }, + { title: 'No random deathmatch', body: 'Violence needs a reason the other person can understand. Escalate, do not detonate.' }, + { title: 'Do not power game', body: 'Give people a fair chance to react. Winning is not the point of a scene.' }, + { title: 'Leave the scene alive', body: 'If you die, your character forgets the thirty minutes before it. No revenge from the grave.' }, + ], + i: 0, + timer: null, + + load(items) { + this.items = items; + this.i = 0; + this.render(); + this.schedule(); + }, + + render() { + const item = this.items[this.i % this.items.length]; + const title = $('noticeTitle'); + const body = $('noticeBody'); + + /* restart the entry animation by reflowing the nodes */ + title.style.animation = 'none'; + body.style.animation = 'none'; + void title.offsetWidth; + title.style.animation = ''; + body.style.animation = ''; + + title.textContent = item.title; + body.textContent = item.body; + $('noticeIndex').textContent = `${(this.i % this.items.length) + 1} / ${this.items.length}`; + }, + + schedule() { + clearInterval(this.timer); + this.timer = setInterval(() => { + this.i += 1; + this.render(); + }, 7600); + }, +}; + +/* ========================================================================= + 4. Ambience - synthesised, never downloaded + ========================================================================= */ + +class Ambience { + constructor() { + this.ctx = null; + this.master = null; + this.volume = 0.45; + this.muted = false; + this.started = false; + } + + /* A short noise burst with exponential decay makes a serviceable hall. */ + makeImpulse(seconds, decay) { + const rate = this.ctx.sampleRate; + const len = Math.floor(rate * seconds); + const buf = this.ctx.createBuffer(2, len, rate); + for (let ch = 0; ch < 2; ch++) { + const data = buf.getChannelData(ch); + for (let i = 0; i < len; i++) { + data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay); + } + } + return buf; + } + + noiseBuffer(seconds) { + const rate = this.ctx.sampleRate; + const buf = this.ctx.createBuffer(1, rate * seconds, rate); + const d = buf.getChannelData(0); + let last = 0; + for (let i = 0; i < d.length; i++) { + /* brown-ish noise: closer to distant traffic than white hiss */ + const white = Math.random() * 2 - 1; + last = (last + 0.02 * white) / 1.02; + d[i] = last * 3.2; + } + return buf; + } + + start() { + if (this.started) return; + const Ctor = window.AudioContext || window.webkitAudioContext; + if (!Ctor) return; + this.ctx = new Ctor(); + this.started = true; + + const ctx = this.ctx; + this.master = ctx.createGain(); + this.master.gain.value = 0; + this.master.connect(ctx.destination); + + const verb = ctx.createConvolver(); + verb.buffer = this.makeImpulse(4.2, 2.6); + const verbGain = ctx.createGain(); + verbGain.gain.value = 0.55; + verb.connect(verbGain).connect(this.master); + + const dry = ctx.createGain(); + dry.gain.value = 0.75; + dry.connect(this.master); + + const bus = ctx.createGain(); + bus.connect(dry); + bus.connect(verb); + + /* --- the pad: a minor triad that never quite resolves --------------- */ + const lp = ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 420; + lp.Q.value = 0.7; + lp.connect(bus); + + const lfo = ctx.createOscillator(); + lfo.frequency.value = 0.031; + const lfoAmt = ctx.createGain(); + lfoAmt.gain.value = 210; + lfo.connect(lfoAmt).connect(lp.frequency); + lfo.start(); + + [55, 82.41, 110, 130.81, 164.81].forEach((freq, i) => { + const osc = ctx.createOscillator(); + osc.type = i % 2 ? 'sine' : 'triangle'; + osc.frequency.value = freq; + osc.detune.value = (i - 2) * 4; + + const g = ctx.createGain(); + g.gain.value = 0.16 / (1 + i * 0.35); + + /* each voice breathes on its own slow cycle */ + const breath = ctx.createOscillator(); + breath.frequency.value = 0.017 + i * 0.009; + const breathAmt = ctx.createGain(); + breathAmt.gain.value = g.gain.value * 0.6; + breath.connect(breathAmt).connect(g.gain); + breath.start(); + + osc.connect(g).connect(lp); + osc.start(); + }); + + /* --- distant traffic ------------------------------------------------ */ + const noise = ctx.createBufferSource(); + noise.buffer = this.noiseBuffer(8); + noise.loop = true; + const nf = ctx.createBiquadFilter(); + nf.type = 'bandpass'; + nf.frequency.value = 240; + nf.Q.value = 0.55; + const ng = ctx.createGain(); + ng.gain.value = 0.09; + noise.connect(nf).connect(ng).connect(bus); + noise.start(); + + const wind = ctx.createOscillator(); + wind.frequency.value = 0.043; + const windAmt = ctx.createGain(); + windAmt.gain.value = 130; + wind.connect(windAmt).connect(nf.frequency); + wind.start(); + + /* --- a siren, far away, every now and then -------------------------- */ + const siren = () => { + if (!this.ctx || this.ctx.state === 'closed') return; + const t = ctx.currentTime; + const o = ctx.createOscillator(); + o.type = 'triangle'; + const g = ctx.createGain(); + const pan = ctx.createStereoPanner ? ctx.createStereoPanner() : null; + g.gain.value = 0; + o.frequency.setValueAtTime(620, t); + o.frequency.linearRampToValueAtTime(880, t + 0.7); + o.frequency.linearRampToValueAtTime(620, t + 1.4); + g.gain.linearRampToValueAtTime(0.014, t + 0.6); + g.gain.linearRampToValueAtTime(0.0, t + 2.6); + if (pan) { + pan.pan.value = Math.random() * 1.6 - 0.8; + o.connect(g).connect(pan).connect(verb); + } else { + o.connect(g).connect(verb); + } + o.start(t); + o.stop(t + 2.8); + setTimeout(siren, 24000 + Math.random() * 40000); + }; + setTimeout(siren, 12000 + Math.random() * 12000); + + this.applyVolume(1.8); + } + + /* perceptual curve: a linear fader sounds wrong */ + applyVolume(rampSeconds) { + if (!this.master) return; + const target = this.muted ? 0 : Math.pow(this.volume, 2.2) * 0.9; + const now = this.ctx.currentTime; + this.master.gain.cancelScheduledValues(now); + this.master.gain.setValueAtTime(this.master.gain.value, now); + this.master.gain.linearRampToValueAtTime(target, now + (rampSeconds || 0.12)); + } + + setVolume(v) { + this.volume = Math.min(1, Math.max(0, v)); + if (this.volume > 0) this.muted = false; + this.applyVolume(); + } + + toggleMute() { + this.muted = !this.muted; + this.applyVolume(); + return this.muted; + } +} + +const ambience = new Ambience(); + +function wireAudio() { + const vol = $('vol'); + const volval = $('volval'); + const mute = $('mute'); + const hint = $('audiohint'); + + const saved = parseInt(window.localStorage.getItem('rp.volume') || '45', 10); + vol.value = String(isFinite(saved) ? saved : 45); + volval.textContent = vol.value; + ambience.volume = Number(vol.value) / 100; + + const tryStart = () => { + ambience.start(); + if (ambience.ctx && ambience.ctx.state === 'suspended') { + hint.hidden = false; + return false; + } + hint.hidden = true; + return true; + }; + + /* Autoplay may be blocked; the first input of any kind releases it. */ + if (!tryStart()) { + const release = () => { + if (ambience.ctx) ambience.ctx.resume(); + hint.hidden = true; + window.removeEventListener('pointerdown', release); + window.removeEventListener('keydown', release); + }; + window.addEventListener('pointerdown', release); + window.addEventListener('keydown', release); + } + + vol.addEventListener('input', () => { + volval.textContent = vol.value; + ambience.setVolume(Number(vol.value) / 100); + mute.classList.toggle('off', Number(vol.value) === 0); + window.localStorage.setItem('rp.volume', vol.value); + }); + + mute.addEventListener('click', () => { + const muted = ambience.toggleMute(); + mute.classList.toggle('off', muted); + }); +} + +/* ========================================================================= + boot + ========================================================================= */ + +function serial() { + const n = Math.floor(Math.random() * 9000 + 1000); + const m = Math.floor(Math.random() * 900 + 100); + return `${new Date().getFullYear()}-${n}${m}`; +} + +function frame(now) { + drawSky(now); + Progress.tick(); + requestAnimationFrame(frame); +} + +window.addEventListener('resize', resizeSky); + +document.addEventListener('DOMContentLoaded', () => { + $('serial').textContent = serial(); + resizeSky(); + Notices.render(); + Notices.schedule(); + wireAudio(); + requestAnimationFrame(frame); +}); diff --git a/server-code/data/resources/[rp]/rp_loading/html/fonts/archivo.woff2 b/server-code/data/resources/[rp]/rp_loading/html/fonts/archivo.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..716d4abd199a236dc4b9e112f63429b9b47ce911 GIT binary patch literal 34928 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 E05fT^n*aa+ literal 0 HcmV?d00001 diff --git a/server-code/data/resources/[rp]/rp_loading/html/fonts/plex-400.woff2 b/server-code/data/resources/[rp]/rp_loading/html/fonts/plex-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0804aaff92260b5359330f63e05b9ac25ccabeee GIT binary patch literal 14708 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 + + + +Los Santos Roleplay + + + + + + + + + + + +
+ +
+
City of Los Santos · Office of Records
+
+ FILE 0000-0000  /  RESIDENCY APPLICATION +
+
+ +
+

LOS SANTOS

+
+ ROLEPLAY + + Connecting +
+
+ +
+
+ Posted notice + 1 / 5 +
+
+

Stay in character

+

Loading the city.

+
+
+ +
+
+ Waiting for the game + 0% +
+
+ +
+ + + + 45 +
+ +
+ +
+ + + + diff --git a/server-code/data/resources/[rp]/rp_loading/html/tokens.css b/server-code/data/resources/[rp]/rp_loading/html/tokens.css new file mode 100644 index 0000000..cd88d8c --- /dev/null +++ b/server-code/data/resources/[rp]/rp_loading/html/tokens.css @@ -0,0 +1,117 @@ +/* --------------------------------------------------------------------------- + Los Santos RP - design tokens. + + The interface is styled as a municipal record being opened on you: strict + grid, hairline rules, monospaced serials, and one accent taken from the only + light source the city has at night - sodium street lamps. + Shared verbatim by rp_loading and rp_ui so the four screens read as one + product. + --------------------------------------------------------------------------- */ + +@font-face { + font-family: 'Archivo'; + src: url('fonts/archivo.woff2') format('woff2-variations'); + font-weight: 100 900; + font-display: block; +} +@font-face { + font-family: 'Plex Mono'; + src: url('fonts/plex-400.woff2') format('woff2'); + font-weight: 400; + font-display: block; +} +@font-face { + font-family: 'Plex Mono'; + src: url('fonts/plex-500.woff2') format('woff2'); + font-weight: 500; + font-display: block; +} + +:root { + /* asphalt at night - cold, never pure black */ + --ink-900: #08090b; + --ink-800: #0d0f13; + --ink-700: #14171d; + --ink-600: #1d212a; + --ink-500: #272c37; + + /* document stock */ + --paper: #e8e4dc; + --paper-dim: #a9a69f; + --muted: #6e747f; + + /* the accent: sodium vapour, the colour of the city from the air */ + --sodium: #ff9f45; + --sodium-soft: rgba(255, 159, 69, 0.14); + --sodium-line: rgba(255, 159, 69, 0.42); + --sodium-deep: #c4711f; + + /* semantic only, never decorative */ + --stamp: #c4462f; + --good: #7f9f5a; + + --line: rgba(232, 228, 220, 0.13); + --line-soft: rgba(232, 228, 220, 0.07); + + --rail: clamp(400px, 31vw, 520px); + + --gut-x: clamp(32px, 3.4vw, 56px); + --gut-y: clamp(28px, 3vw, 48px); + + --ease: cubic-bezier(0.22, 0.61, 0.36, 1); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + overflow: hidden; + background: var(--ink-900); + color: var(--paper); + font-family: 'Archivo', system-ui, sans-serif; + font-weight: 400; + font-size: 15px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* Typographic roles --------------------------------------------------------- */ + +.eyebrow { + font-family: 'Plex Mono', monospace; + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--muted); +} + +.data { + font-family: 'Plex Mono', monospace; + font-size: 12px; + letter-spacing: 0.04em; + color: var(--paper-dim); + font-variant-numeric: tabular-nums; +} + +.rule { + height: 1px; + background: var(--line); + border: 0; +} + +/* Focus is always visible and always the accent. */ +:focus-visible { + outline: 2px solid var(--sodium); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/server-code/data/resources/[rp]/rp_selftest/fxmanifest.lua b/server-code/data/resources/[rp]/rp_selftest/fxmanifest.lua new file mode 100644 index 0000000..1fd7d7f --- /dev/null +++ b/server-code/data/resources/[rp]/rp_selftest/fxmanifest.lua @@ -0,0 +1,17 @@ +fx_version 'cerulean' +game 'gta5' + +name 'rp_selftest' +description 'Exercises the account/character data path and proves it survives a restart' +version '1.0.0' + +shared_scripts { + '@rp_core/shared/config.lua', + '@rp_core/shared/util.lua', + '@rp_core/shared/appearance.lua', +} + +server_scripts { + '@rp_db/lib/db.lua', + 'server/test.lua', +} diff --git a/server-code/data/resources/[rp]/rp_selftest/server/test.lua b/server-code/data/resources/[rp]/rp_selftest/server/test.lua new file mode 100644 index 0000000..e49c639 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_selftest/server/test.lua @@ -0,0 +1,144 @@ +-- --------------------------------------------------------------------------- +-- End-to-end check of the account and character data path. +-- +-- The first run creates a marker account. Every later run finds it already +-- there and verifies it came back byte-for-byte, which is what "persists +-- between restarts" actually means. Run `selftest_reset` to clear it. +-- --------------------------------------------------------------------------- + +local MARKER = 'zz_selftest' +local PASSWORD = 'correct horse 7' + +local passed, failed = 0, 0 + +local function check(name, ok, detail) + if ok then + passed = passed + 1 + print(('^2 PASS^7 %s'):format(name)) + else + failed = failed + 1 + print(('^1 FAIL^7 %s ^1%s^7'):format(name, detail or '')) + end +end + +local function hash(pw) + local p = promise.new() + exports.rp_core:hashPassword(pw, function(h, err) p:resolve({ h = h, err = err }) end) + local r = Citizen.Await(p) + return r.h, r.err +end + +local function verify(pw, stored) + local p = promise.new() + exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end) + return Citizen.Await(p).ok +end + +CreateThread(function() + if not DB.WaitReady(30000) then + print('^1[selftest] database never became ready^7') + return + end + + print('^5[selftest]^7 ---- account + character data path ----') + + -- 1. password hashing ---------------------------------------------------- + local h, herr = hash(PASSWORD) + check('scrypt hash produced', type(h) == 'string' and h:sub(1, 7) == 'scrypt$', tostring(herr)) + check('correct password verifies', verify(PASSWORD, h)) + check('wrong password rejected', not verify(PASSWORD .. 'x', h)) + check('empty password rejected', not verify('', h)) + + local h2 = hash(PASSWORD) + check('same password hashes differently (salted)', h ~= h2) + check('second hash also verifies', verify(PASSWORD, h2)) + check('corrupt hash rejected safely', not verify(PASSWORD, 'not-a-hash')) + + -- 2. the account row ------------------------------------------------------ + local existing = DB.Single('SELECT id, password_hash, created_at FROM accounts WHERE username = ?', { MARKER }) + local firstRun = existing == nil + local accountId + + if firstRun then + accountId = DB.Insert( + 'INSERT INTO accounts (username, password_hash, license) VALUES (?, ?, ?)', + { MARKER, h, 'selftest-license' }) + check('account created', type(accountId) == 'number' and accountId > 0) + print('^3[selftest]^7 marker account created - restart the server to prove persistence') + else + accountId = existing.id + check('account survived restart', true) + check('stored hash still verifies after restart', verify(PASSWORD, existing.password_hash)) + print(('^5[selftest]^7 marker account has existed since %s'):format(tostring(existing.created_at))) + end + + -- 3. the character row, including the appearance JSON --------------------- + local appearance = Appearance.Sanitise({ + model = 'f', + parents = { father = 12, mother = 33, shapeMix = 0.62, skinMix = 0.25 }, + hair = { style = 14, colour = 9, highlight = 2 }, + features = { ['0'] = 0.4, ['13'] = -0.75 }, + overlays = { eyebrows = { index = 5, opacity = 0.8, colour = 3 } }, + components = { jacket = { drawable = 42, texture = 3 } }, + }) + + local charId = DB.Scalar('SELECT id FROM characters WHERE account_id = ? AND deleted = 0 LIMIT 1', { accountId }) + + if not charId then + charId = DB.Insert([[ + INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory, appearance) + VALUES (?, 0, ?, ?, ?, 'f', ?, ?) + ]], { accountId, 'Zztest', 'Persistence', '1991-04-18', + 'Created by the self test.', json.encode(appearance) }) + check('character created', type(charId) == 'number' and charId > 0) + else + check('character survived restart', true) + end + + local row = DB.Single('SELECT * FROM characters WHERE id = ?', { charId }) + check('character reads back', row ~= nil) + + if row then + local back = json.decode(row.appearance) + check('appearance JSON round trip', json.encode(Appearance.Sanitise(back)) == json.encode(appearance), + 'appearance differs after a round trip') + check('float precision kept', math.abs(back.parents.shapeMix - 0.62) < 0.0001, + tostring(back.parents.shapeMix)) + check('negative feature kept', math.abs(back.features['13'] + 0.75) < 0.0001, + tostring(back.features['13'])) + check('starting balances applied', tonumber(row.cash) == Config.Money.startingCash + and tonumber(row.bank) == Config.Money.startingBank, + ('cash=%s bank=%s'):format(tostring(row.cash), tostring(row.bank))) + end + + -- 4. the constraints the session code relies on --------------------------- + local dupe, dupeErr = DB.Insert( + 'INSERT INTO accounts (username, password_hash) VALUES (?, ?)', { MARKER, h }) + check('duplicate username refused by the database', dupe == nil, tostring(dupe)) + + local dupeName = DB.Insert([[ + INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender) + VALUES (?, 1, 'Zztest', 'Persistence', '1991-04-18', 'f') + ]], { accountId }) + check('duplicate character name refused by the database', dupeName == nil) + + local orphan = DB.Insert([[ + INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender) + VALUES (999999, 0, 'Zzorphan', 'Nobody', '1991-04-18', 'm') + ]], {}) + check('character cannot reference a missing account', orphan == nil) + + print(('^5[selftest]^7 ==== %d passed, %d failed ===='):format(passed, failed)) + if failed == 0 then + print(firstRun and '^2[selftest] data path OK (first run)^7' or '^2[selftest] data path OK and persistent^7') + else + print('^1[selftest] PROBLEMS FOUND^7') + end +end) + +RegisterCommand('selftest_reset', function() + CreateThread(function() + DB.Update('DELETE FROM accounts WHERE username = ?', { MARKER }) -- characters cascade + print('^3[selftest]^7 marker account removed') + end) +end, true) diff --git a/server-code/data/resources/[rp]/rp_session/fxmanifest.lua b/server-code/data/resources/[rp]/rp_session/fxmanifest.lua new file mode 100644 index 0000000..4eec125 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_session/fxmanifest.lua @@ -0,0 +1,19 @@ +fx_version 'cerulean' +game 'gta5' + +name 'rp_session' +description 'Connection gate, accounts, characters and spawn' +author 'Los Santos RP' +version '1.0.0' + +shared_scripts { + '@rp_core/shared/config.lua', + '@rp_core/shared/util.lua', + '@rp_core/shared/appearance.lua', +} + +server_scripts { + '@rp_db/lib/db.lua', + '@rp_core/lib/callbacks_server.lua', + 'server/main.lua', +} diff --git a/server-code/data/resources/[rp]/rp_session/server/main.lua b/server-code/data/resources/[rp]/rp_session/server/main.lua new file mode 100644 index 0000000..6d913f5 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_session/server/main.lua @@ -0,0 +1,438 @@ +-- --------------------------------------------------------------------------- +-- rp_session - everything between "player opened the server" and "player is +-- standing in the world": ban gate, accounts, characters, spawn. +-- +-- The client drives the UI, but every decision is made here. The client may +-- only ever ask; it can never assert that it is logged in, that a character +-- belongs to it, or where it spawned. +-- --------------------------------------------------------------------------- + +local sessions = {} -- [src] = { license, ip, accountId, username, role, stage } + +-- This build of FXServer ships no `hardcap` resource (it only advertises the +-- name in /info.json), and the bundled `chat` and `monitor` resources are not +-- started, so capacity has to be enforced here - see the deferral below. +-- Kept as a guard in case a future artefact does ship one. +CreateThread(function() + if GetResourceState('hardcap') == 'started' then + StopResource('hardcap') + print('^5[rp_session]^7 stopped bundled hardcap; capacity is enforced by this resource') + end +end) + +local STAGE_AUTH = 'auth' +local STAGE_CHARS = 'characters' +local STAGE_SPAWN = 'spawn' +local STAGE_LIVE = 'live' + +-- --------------------------------------------------------------------------- +-- helpers +-- --------------------------------------------------------------------------- + +local function licenseOf(src) + for i = 0, GetNumPlayerIdentifiers(src) - 1 do + local id = GetPlayerIdentifier(src, i) + if id and id:sub(1, 8) == 'license:' then return id:sub(9) end + end + return nil +end + +local function hashPassword(pw) + local p = promise.new() + exports.rp_core:hashPassword(pw, function(hash, err) p:resolve({ hash = hash, err = err }) end) + local r = Citizen.Await(p) + return r.hash, r.err +end + +local function verifyPassword(pw, stored) + local p = promise.new() + exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end) + local r = Citizen.Await(p) + if r.err then print(('^1[rp_session]^7 password verify error: %s'):format(r.err)) end + return r.ok == true +end + +local function recordAttempt(identity, ip, success) + DB.InsertAsync('INSERT INTO auth_attempts (identity, ip, success) VALUES (?, ?, ?)', + { identity, ip or DB.NULL, success and 1 or 0 }) +end + +--- True when this identity has burned through its allowance recently. +local function throttled(identity) + local n = DB.Scalar( + 'SELECT COUNT(*) FROM auth_attempts WHERE identity = ? AND success = 0 AND at > (NOW() - INTERVAL ? SECOND)', + { identity, Config.Session.attemptWindowS }) + return (tonumber(n) or 0) >= Config.Session.maxAttempts +end + +local function characterRows(accountId) + return DB.Query([[ + SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance, + cash, bank, job, job_grade, playtime, last_played_at + FROM characters + WHERE account_id = ? AND deleted = 0 + ORDER BY slot ASC, id ASC + ]], { accountId }) or {} +end + +--- Trim a DB row down to what the character-select screen is allowed to see. +local function characterCard(row) + return { + id = row.id, + firstName = row.first_name, + lastName = row.last_name, + dob = tostring(row.dob):sub(1, 10), + gender = row.gender, + backstory = row.backstory, + cash = math.floor(tonumber(row.cash) or 0), + bank = math.floor(tonumber(row.bank) or 0), + job = row.job, + playtime = math.floor(tonumber(row.playtime) or 0), + lastPlayed= row.last_played_at and tostring(row.last_played_at) or nil, + appearance= row.appearance and json.decode(row.appearance) or nil, + } +end + +local function sessionOf(src, requiredStage) + local s = sessions[src] + if not s then return nil, 'no session' end + if requiredStage and s.stage ~= requiredStage then return nil, 'wrong stage' end + return s +end + +-- --------------------------------------------------------------------------- +-- connection gate +-- --------------------------------------------------------------------------- + +AddEventHandler('playerConnecting', function(_, _, deferrals) + local src = source + deferrals.defer() + Wait(0) + + local license = licenseOf(src) + if not license then + return deferrals.done('Could not read your FiveM licence identifier. Restart FiveM and try again.') + end + + -- Capacity. This replaces the stock `hardcap` resource, which is stopped in + -- server.cfg so that everything running here is our own code. + local maxClients = tonumber(GetConvar('sv_maxclients', '48')) or 48 + local online = #GetPlayers() -- the joining player is not counted yet + if online >= maxClients then + return deferrals.done( + ('The server is full (%d of %d). Try again in a few minutes.'):format(online, maxClients)) + end + + deferrals.update('Checking your record...') + + if not DB.WaitReady(15000) then + return deferrals.done('The server database is not available right now. Try again in a minute.') + end + + local ban = DB.Single([[ + SELECT id, ban_reason, ban_until + FROM accounts + WHERE license = ? AND banned = 1 + AND (ban_until IS NULL OR ban_until > NOW()) + LIMIT 1 + ]], { license }) + + if ban then + local until_ = ban.ban_until and (' until ' .. tostring(ban.ban_until) .. ' UTC') or ' permanently' + return deferrals.done(('You are banned%s.\n\nReason: %s') + :format(until_, ban.ban_reason or 'no reason recorded')) + end + + -- clear bans that have run out so the account is usable again + DB.UpdateAsync([[ + UPDATE accounts SET banned = 0, ban_reason = NULL, ban_until = NULL + WHERE license = ? AND banned = 1 AND ban_until IS NOT NULL AND ban_until <= NOW() + ]], { license }) + + sessions[src] = { license = license, ip = GetPlayerEndpoint(src), stage = STAGE_AUTH } + deferrals.done() +end) + +AddEventHandler('playerDropped', function() + sessions[source] = nil +end) + +-- --------------------------------------------------------------------------- +-- callbacks: authentication +-- --------------------------------------------------------------------------- + +Core.RegisterCallback('session:state', function(src) + local s = sessions[src] + if not s then return nil, 'no session' end + return { stage = s.stage, serverName = Config.ServerName, maxChars = Config.MaxChars } +end) + +Core.RegisterCallback('auth:register', function(src, data) + local s, e = sessionOf(src, STAGE_AUTH) + if not s then return nil, e end + if type(data) ~= 'table' then return nil, 'malformed request' end + + local username = Util.trim(data.username or '') + local password = data.password or '' + + local ok, why = Rules.checkUsername(username) + if not ok then return nil, why end + ok, why = Rules.checkPassword(password) + if not ok then return nil, why end + + if throttled(s.license) then + return nil, 'Too many attempts. Wait a few minutes and try again.' + end + + local taken = DB.Scalar('SELECT id FROM accounts WHERE username = ? LIMIT 1', { username }) + if taken then + recordAttempt(s.license, s.ip, false) + return nil, 'That username is already taken' + end + + local hash, herr = hashPassword(password) + if not hash then + print(('^1[rp_session]^7 hashing failed: %s'):format(tostring(herr))) + return nil, 'Could not create the account, try again' + end + + local id, ierr = DB.Insert( + 'INSERT INTO accounts (username, password_hash, license, last_login_at, last_ip) VALUES (?, ?, ?, NOW(), ?)', + { username, hash, s.license, s.ip or DB.NULL }) + + if not id then + -- unique key race: someone registered the same name a moment ago + print(('^1[rp_session]^7 register insert failed: %s'):format(tostring(ierr))) + return nil, 'That username is already taken' + end + + recordAttempt(s.license, s.ip, true) + + s.accountId = id + s.username = username + s.role = 'player' + s.stage = STAGE_CHARS + + exports.rp_core:attachAccount(src, { id = id, username = username, role = 'player', license = s.license }) + print(('^2[rp_session]^7 new account %q (id %d) from %s'):format(username, id, GetPlayerName(src))) + + return { username = username, characters = {}, maxChars = Config.MaxChars } +end) + +Core.RegisterCallback('auth:login', function(src, data) + local s, e = sessionOf(src, STAGE_AUTH) + if not s then return nil, e end + if type(data) ~= 'table' then return nil, 'malformed request' end + + local username = Util.trim(data.username or '') + local password = data.password or '' + if username == '' or password == '' then return nil, 'Enter your username and password' end + + if throttled(s.license) then + return nil, 'Too many failed attempts. Wait a few minutes and try again.' + end + + local acc = DB.Single( + 'SELECT id, username, password_hash, role, banned, ban_reason, ban_until FROM accounts WHERE username = ? LIMIT 1', + { username }) + + -- Always run a verification so a missing username and a wrong password take + -- the same amount of time and cannot be told apart from outside. + local stored = acc and acc.password_hash + or 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + local good = verifyPassword(password, stored) + + if not acc or not good then + recordAttempt(s.license, s.ip, false) + return nil, 'Incorrect username or password' + end + + if acc.banned == 1 then + -- re-checked against NOW() so an expired ban does not keep locking them out + local active = DB.Scalar( + 'SELECT 1 FROM accounts WHERE id = ? AND banned = 1 AND (ban_until IS NULL OR ban_until > NOW())', + { acc.id }) + if active == 1 then + return nil, ('This account is banned. Reason: %s'):format(acc.ban_reason or 'no reason recorded') + end + end + + recordAttempt(s.license, s.ip, true) + + DB.UpdateAsync('UPDATE accounts SET last_login_at = NOW(), last_ip = ?, license = ? WHERE id = ?', + { s.ip or DB.NULL, s.license, acc.id }) + + s.accountId = acc.id + s.username = acc.username + s.role = acc.role + s.stage = STAGE_CHARS + + exports.rp_core:attachAccount(src, { id = acc.id, username = acc.username, role = acc.role, license = s.license }) + + local cards = {} + for _, row in ipairs(characterRows(acc.id)) do cards[#cards + 1] = characterCard(row) end + + print(('^2[rp_session]^7 %s logged in as %q'):format(GetPlayerName(src), acc.username)) + return { username = acc.username, role = acc.role, characters = cards, maxChars = Config.MaxChars } +end) + +-- --------------------------------------------------------------------------- +-- callbacks: characters +-- --------------------------------------------------------------------------- + +Core.RegisterCallback('char:list', function(src) + local s, e = sessionOf(src, STAGE_CHARS) + if not s then return nil, e end + local cards = {} + for _, row in ipairs(characterRows(s.accountId)) do cards[#cards + 1] = characterCard(row) end + return { characters = cards, maxChars = Config.MaxChars } +end) + +Core.RegisterCallback('char:create', function(src, data) + local s, e = sessionOf(src, STAGE_CHARS) + if not s then return nil, e end + if type(data) ~= 'table' then return nil, 'malformed request' end + + local first = Util.properName(data.firstName or '') + local last = Util.properName(data.lastName or '') + + local ok, why = Rules.checkName(first) + if not ok then return nil, 'First name: ' .. why end + ok, why = Rules.checkName(last) + if not ok then return nil, 'Last name: ' .. why end + ok, why = Rules.checkDob(data.dob or '') + if not ok then return nil, 'Date of birth: ' .. why end + + local gender = (data.gender == 'f') and 'f' or 'm' + local backstory = Util.trim(data.backstory or '') + if #backstory > 2000 then return nil, 'Backstory is too long (2000 characters max)' end + + local existing = tonumber(DB.Scalar( + 'SELECT COUNT(*) FROM characters WHERE account_id = ? AND deleted = 0', { s.accountId })) or 0 + if existing >= Config.MaxChars then + return nil, ('You already have %d characters'):format(Config.MaxChars) + end + + local clash = DB.Scalar( + 'SELECT id FROM characters WHERE first_name = ? AND last_name = ? LIMIT 1', { first, last }) + if clash then return nil, 'Someone on this server already has that name' end + + local appearance = Appearance.Sanitise(data.appearance) + + local id, ierr = DB.Insert([[ + INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory, + appearance, cash, bank) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ]], { + s.accountId, existing, first, last, data.dob, gender, + backstory ~= '' and backstory or DB.NULL, + json.encode(appearance), Config.Money.startingCash, Config.Money.startingBank, + }) + + if not id then + print(('^1[rp_session]^7 character insert failed: %s'):format(tostring(ierr))) + return nil, 'Could not create that character' + end + + print(('^2[rp_session]^7 %s created character %s %s (id %d)'):format(s.username, first, last, id)) + + local row = DB.Single([[ + SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance, + cash, bank, job, job_grade, playtime, last_played_at + FROM characters WHERE id = ? + ]], { id }) + + return { character = characterCard(row) } +end) + +Core.RegisterCallback('char:delete', function(src, data) + local s, e = sessionOf(src, STAGE_CHARS) + if not s then return nil, e end + local id = tonumber(type(data) == 'table' and data.id or nil) + if not id then return nil, 'malformed request' end + + -- ownership is checked in the WHERE clause, so a forged id changes nothing + local n = DB.Update('UPDATE characters SET deleted = 1 WHERE id = ? AND account_id = ? AND deleted = 0', + { id, s.accountId }) + if (tonumber(n) or 0) == 0 then return nil, 'That character is not yours' end + + print(('^3[rp_session]^7 %s deleted character %d'):format(s.username, id)) + return { deleted = id } +end) + +Core.RegisterCallback('char:select', function(src, data) + local s, e = sessionOf(src, STAGE_CHARS) + if not s then return nil, e end + local id = tonumber(type(data) == 'table' and data.id or nil) + if not id then return nil, 'malformed request' end + + local row = DB.Single([[ + SELECT * FROM characters WHERE id = ? AND account_id = ? AND deleted = 0 + ]], { id, s.accountId }) + if not row then return nil, 'That character is not yours' end + + exports.rp_core:attachCharacter(src, row) + s.stage = STAGE_SPAWN + s.charId = id + + local last = row.position and json.decode(row.position) or nil + local spawns = {} + for _, sp in ipairs(Config.SpawnPoints) do + spawns[#spawns + 1] = { + id = sp.id, label = sp.label, area = sp.area, blurb = sp.blurb, map = sp.map, + coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }, + cam = { pos = { x = sp.cam.pos.x, y = sp.cam.pos.y, z = sp.cam.pos.z }, + look = { x = sp.cam.look.x, y = sp.cam.look.y, z = sp.cam.look.z } }, + } + end + + return { + character = characterCard(row), + appearance = row.appearance and json.decode(row.appearance) or nil, + spawns = spawns, + lastPosition = last, + } +end) + +-- --------------------------------------------------------------------------- +-- callbacks: spawn +-- --------------------------------------------------------------------------- + +Core.RegisterCallback('spawn:confirm', function(src, data) + local s, e = sessionOf(src, STAGE_SPAWN) + if not s then return nil, e end + + local choice = type(data) == 'table' and data.id or nil + local coords + + if choice == 'last' then + local p = exports.rp_core:getCharacter(src) + local last = p and p.position + if last and last.x then + coords = { x = last.x, y = last.y, z = last.z, h = last.h or 0.0 } + end + end + + if not coords then + for _, sp in ipairs(Config.SpawnPoints) do + if sp.id == choice then + coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w } + break + end + end + end + + if not coords then + local sp = Config.SpawnPoints[1] + coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w } + end + + s.stage = STAGE_LIVE + exports.rp_core:setSpawned(src, true) + + local char = exports.rp_core:getCharacter(src) + print(('^2[rp_session]^7 %s spawned as %s %s'):format(s.username, char.firstName, char.lastName)) + + TriggerClientEvent('rp:session:spawned', src, coords) + return { coords = coords, character = char } +end) diff --git a/server-code/data/resources/[rp]/rp_ui/client/camera.lua b/server-code/data/resources/[rp]/rp_ui/client/camera.lua new file mode 100644 index 0000000..8ff1fa5 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_ui/client/camera.lua @@ -0,0 +1,188 @@ +-- --------------------------------------------------------------------------- +-- Scripted cameras for the pre-spawn screens. +-- +-- One camera object is reused throughout. Everything is driven from a single +-- render thread that eases the live values towards target values, so a change +-- requested by the UI never snaps. +-- --------------------------------------------------------------------------- + +Cam = {} + +local cam = nil +local mode = 'off' -- 'off' | 'shots' | 'orbit' | 'fly' +local renderThread = false + +-- live and target orbit values +local orbit = { + angle = 180.0, targetAngle = 180.0, + radius = 1.6, targetRadius = 1.6, + height = 0.65, targetHeight = 0.65, + focus = nil, -- entity to orbit + pitch = 0.0, targetPitch = 0.0, +} + +local shots = { list = {}, index = 0, startedAt = 0, duration = 22000 } +local fly = { from = nil, to = nil, lookFrom = nil, lookTo = nil, startedAt = 0, duration = 3000, done = nil } + +local function ensureCam() + if cam and DoesCamExist(cam) then return cam end + cam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true) + SetCamActive(cam, true) + RenderScriptCams(true, false, 0, true, true) + return cam +end + +local function lerp(a, b, t) return a + (b - a) * t end + +local function vlerp(a, b, t) + return vector3(lerp(a.x, b.x, t), lerp(a.y, b.y, t), lerp(a.z, b.z, t)) +end + +-- smoothstep, so fly-throughs start and end at rest +local function ease(t) + t = math.max(0.0, math.min(1.0, t)) + return t * t * (3.0 - 2.0 * t) +end + +local function startRenderThread() + if renderThread then return end + renderThread = true + + CreateThread(function() + while mode ~= 'off' do + local c = ensureCam() + + if mode == 'shots' then + local shot = shots.list[shots.index] + if shot then + local t = (GetGameTimer() - shots.startedAt) / shots.duration + if t >= 1.0 then + shots.index = (shots.index % #shots.list) + 1 + shots.startedAt = GetGameTimer() + shot = shots.list[shots.index] + t = 0.0 + end + -- a slow linear dolly reads as a helicopter, not a spline + local pos = vlerp(shot.from, shot.to, t) + SetCamCoord(c, pos.x, pos.y, pos.z) + PointCamAtCoord(c, shot.look.x, shot.look.y, shot.look.z) + end + + elseif mode == 'orbit' then + orbit.angle = lerp(orbit.angle, orbit.targetAngle, 0.14) + orbit.radius = lerp(orbit.radius, orbit.targetRadius, 0.10) + orbit.height = lerp(orbit.height, orbit.targetHeight, 0.10) + orbit.pitch = lerp(orbit.pitch, orbit.targetPitch, 0.10) + + local ent = orbit.focus + if ent and DoesEntityExist(ent) then + local base = GetEntityCoords(ent) + local rad = math.rad(orbit.angle) + local px = base.x + math.sin(rad) * orbit.radius + local py = base.y + math.cos(rad) * orbit.radius + local pz = base.z + orbit.height + SetCamCoord(c, px, py, pz) + PointCamAtCoord(c, base.x, base.y, base.z + orbit.height - orbit.pitch) + end + + elseif mode == 'fly' then + local t = ease((GetGameTimer() - fly.startedAt) / fly.duration) + local pos = vlerp(fly.from, fly.to, t) + local look = vlerp(fly.lookFrom, fly.lookTo, t) + SetCamCoord(c, pos.x, pos.y, pos.z) + PointCamAtCoord(c, look.x, look.y, look.z) + if t >= 1.0 then + mode = 'idle' + if fly.done then + local fn = fly.done + fly.done = nil + fn() + end + end + end + + Wait(0) + end + renderThread = false + end) +end + +--- Slow establishing shots over the city, cycled forever. +function Cam.Shots(list) + shots.list = list + shots.index = 1 + shots.startedAt = GetGameTimer() + mode = 'shots' + ensureCam() + SetCamFov(cam, 42.0) + startRenderThread() +end + +--- Orbit an entity. `snap` places the camera immediately instead of easing in. +function Cam.Orbit(entity, radius, height, angle, snap) + orbit.focus = entity + orbit.targetRadius = radius or orbit.targetRadius + orbit.targetHeight = height or orbit.targetHeight + if angle then orbit.targetAngle = angle end + if snap then + orbit.radius = orbit.targetRadius + orbit.height = orbit.targetHeight + orbit.angle = orbit.targetAngle + end + mode = 'orbit' + ensureCam() + SetCamFov(cam, 34.0) + startRenderThread() +end + +--- Framing presets used by the creator's section tabs. +function Cam.OrbitFrame(radius, height, pitch) + orbit.targetRadius = radius + orbit.targetHeight = height + orbit.targetPitch = pitch or 0.0 +end + +function Cam.Nudge(deltaDegrees) + orbit.targetAngle = orbit.targetAngle + deltaDegrees +end + +function Cam.SetAngle(deg) + orbit.targetAngle = deg +end + +function Cam.Angle() return orbit.targetAngle end + +--- Fly from wherever we are to a point looking at a target, then call `done`. +function Cam.FlyTo(fromPos, fromLook, toPos, toLook, duration, done) + fly.from = fromPos + fly.to = toPos + fly.lookFrom = fromLook + fly.lookTo = toLook + fly.duration = duration or 3000 + fly.startedAt = GetGameTimer() + fly.done = done + mode = 'fly' + ensureCam() + startRenderThread() +end + +function Cam.SetFov(fov) + if cam and DoesCamExist(cam) then SetCamFov(cam, fov + 0.0) end +end + +function Cam.Position() + if cam and DoesCamExist(cam) then return GetCamCoord(cam) end + return GetEntityCoords(PlayerPedId()) +end + +--- Hand control back to the gameplay camera. With a duration the engine +--- interpolates between the two, which hides the cut entirely. +function Cam.Release(duration) + mode = 'off' + duration = duration or 0 + RenderScriptCams(false, duration > 0, duration, true, true) + if cam and DoesCamExist(cam) then + DestroyCam(cam, true) + end + cam = nil +end diff --git a/server-code/data/resources/[rp]/rp_ui/client/main.lua b/server-code/data/resources/[rp]/rp_ui/client/main.lua new file mode 100644 index 0000000..a61611d --- /dev/null +++ b/server-code/data/resources/[rp]/rp_ui/client/main.lua @@ -0,0 +1,508 @@ +-- --------------------------------------------------------------------------- +-- rp_ui - the pre-spawn experience. +-- +-- Owns the stage machine (auth -> characters -> creator -> spawn -> live), +-- the NUI bridge, and the transition into the world. The server decides +-- everything that matters; this file only asks and then animates the answer. +-- --------------------------------------------------------------------------- + +local stage = 'boot' +local account = nil +local characters = {} +local draft = nil -- appearance currently being edited +local previewPed = nil +local spawnData = nil -- payload from char:select +local chosenChar = nil + +local NEUTRAL = vector3(-1037.0, -2738.0, 20.17) -- flat, always-streamed tarmac + +-- --------------------------------------------------------------------------- +-- helpers +-- --------------------------------------------------------------------------- + +local function ui(payload) + SendNUIMessage(payload) +end + +local function focusUI(on) + SetNuiFocus(on, on) + SetNuiFocusKeepInput(false) +end + +local function holdPlayer() + local ped = PlayerPedId() + SetEntityVisible(ped, false, false) + SetEntityCollision(ped, false, false) + FreezeEntityPosition(ped, true) + SetEntityInvincible(ped, true) + SetPlayerControl(PlayerId(), false, 0) + SetPlayerInvincible(PlayerId(), true) + DisplayHud(false) + DisplayRadar(false) + ClearPedTasksImmediately(ped) +end + +local function releasePlayer() + local ped = PlayerPedId() + SetEntityVisible(ped, true, false) + SetEntityCollision(ped, true, true) + FreezeEntityPosition(ped, false) + SetEntityInvincible(ped, false) + SetPlayerControl(PlayerId(), true, 0) + SetPlayerInvincible(PlayerId(), false) + DisplayHud(true) + DisplayRadar(true) +end + +--- Dusk, so the sodium palette of the interface matches the world behind it. +local function setSceneMood(on) + if on then + NetworkOverrideClockTime(20, 40, 0) + SetWeatherTypeNowPersist('EXTRASUNNY') + else + NetworkClearClockTimeOverride() + ClearOverrideWeatherType() + SetWeatherTypeNow('EXTRASUNNY') + end +end + +--- Keep the world streamed around a point while no player ped is there. +local function focusOn(pos) + SetFocusPosAndVel(pos.x, pos.y, pos.z, 0.0, 0.0, 0.0) + RequestCollisionAtCoord(pos.x, pos.y, pos.z) +end + +local function destroyPreview() + if previewPed and DoesEntityExist(previewPed) then + DeleteEntity(previewPed) + end + previewPed = nil +end + +local function setStage(next, data) + stage = next + ui({ action = 'stage', stage = next, data = data }) +end + +-- --------------------------------------------------------------------------- +-- boot +-- --------------------------------------------------------------------------- + +CreateThread(function() + while not NetworkIsSessionStarted() do Wait(50) end + + holdPlayer() + setSceneMood(true) + + local first = Config.CinematicShots[1] + focusOn(first.from) + SetEntityCoords(PlayerPedId(), NEUTRAL.x, NEUTRAL.y, NEUTRAL.z, false, false, false, false) + + Cam.Shots(Config.CinematicShots) + + -- give the world a moment to stream in behind the loading screen + SendLoadingScreenMessage(json.encode({ + eventName = 'rp:server', + name = Config.ServerName, + status = 'Connected', + })) + SendLoadingScreenMessage(json.encode({ eventName = 'rp:rules', rules = Config.Rules })) + + local waited = 0 + while waited < 6000 do + SendLoadingScreenMessage(json.encode({ + eventName = 'rp:progress', + fraction = 0.85 + (waited / 6000) * 0.15, + step = 'Streaming the city', + })) + Wait(250) + waited = waited + 250 + end + + SendLoadingScreenMessage(json.encode({ eventName = 'rp:handover' })) + Wait(1500) + ShutdownLoadingScreenNui() + + focusUI(true) + setStage('auth', { + serverName = Config.ServerName, + serverTag = Config.ServerTag, + maxChars = Config.MaxChars, + }) +end) + +-- --------------------------------------------------------------------------- +-- authentication +-- --------------------------------------------------------------------------- + +RegisterNUICallback('auth:submit', function(data, cb) + CreateThread(function() + local mode = (data and data.mode == 'register') and 'auth:register' or 'auth:login' + local res, err = Core.Callback('rp_session', mode, { + username = data.username, + password = data.password, + }) + + if not res then return cb({ ok = false, error = err or 'Something went wrong' }) end + + account = { username = res.username, role = res.role } + characters = res.characters or {} + cb({ ok = true, account = account, characters = characters, maxChars = res.maxChars }) + end) +end) + +-- --------------------------------------------------------------------------- +-- character select +-- --------------------------------------------------------------------------- + +--- Stand the highlighted character up so the list is not just text. +RegisterNUICallback('char:preview', function(data, cb) + CreateThread(function() + destroyPreview() + + local appearance + for _, c in ipairs(characters) do + if c.id == data.id then appearance = c.appearance end + end + if not appearance then return cb({ ok = false }) end + + appearance = Appearance.Sanitise(appearance) + focusOn(Config.CreatorScene.ped) + previewPed = PedBuild.CreatePreview(appearance, Config.CreatorScene.ped, Config.CreatorScene.ped.w) + if previewPed then + PedBuild.Idle(previewPed) + Cam.Orbit(previewPed, 2.4, 0.25, Config.CreatorScene.ped.w + 180.0, true) + Cam.OrbitFrame(2.4, 0.25, 0.35) + end + cb({ ok = previewPed ~= nil }) + end) +end) + +RegisterNUICallback('char:delete', function(data, cb) + CreateThread(function() + local res, err = Core.Callback('rp_session', 'char:delete', { id = data.id }) + if not res then return cb({ ok = false, error = err }) end + + local list = Core.Callback('rp_session', 'char:list') + characters = (list and list.characters) or {} + destroyPreview() + Cam.Shots(Config.CinematicShots) + cb({ ok = true, characters = characters }) + end) +end) + +RegisterNUICallback('char:select', function(data, cb) + CreateThread(function() + local res, err = Core.Callback('rp_session', 'char:select', { id = data.id }) + if not res then return cb({ ok = false, error = err }) end + + spawnData = res + chosenChar = res.character + destroyPreview() + + -- become the character now, off-screen, so the fly-down reveals them + if res.appearance then + PedBuild.ApplyToPlayer(Appearance.Sanitise(res.appearance)) + holdPlayer() + end + + cb({ ok = true, spawns = res.spawns, lastPosition = res.lastPosition, character = res.character }) + end) +end) + +-- --------------------------------------------------------------------------- +-- character creation +-- --------------------------------------------------------------------------- + +--- Read the game's own colour tables so the swatches in the UI are the real +--- colours, not an approximation that drifts from what the ped ends up wearing. +local paletteCache = nil +local function palettes() + if paletteCache then return paletteCache end + local hair, makeup = {}, {} + for i = 0, 63 do + local ok, r, g, b = pcall(GetPedHairRgbColor, i) + hair[#hair + 1] = ok and { r = r, g = g, b = b } or { r = 40, g = 40, b = 40 } + local ok2, r2, g2, b2 = pcall(GetPedMakeupRgbColor, i) + makeup[#makeup + 1] = ok2 and { r = r2, g = g2, b = b2 } or { r = 60, g = 40, b = 40 } + end + paletteCache = { hair = hair, makeup = makeup } + return paletteCache +end + +--- The creator UI is built from this schema rather than a hardcoded copy, so +--- adding a slider is a one-line change in shared/appearance.lua. +local function pushDraft(cb) + local payload = { + ok = true, + appearance = draft, + limits = PedBuild.Limits(previewPed), + palettes = palettes(), + schema = { + features = Appearance.FEATURES, + overlays = Appearance.OVERLAYS, + components = Appearance.COMPONENTS, + props = Appearance.PROPS, + }, + } + if cb then cb(payload) else ui({ action = 'creator:sync', data = payload }) end +end + +local function rebuildPreview() + destroyPreview() + focusOn(Config.CreatorScene.ped) + previewPed = PedBuild.CreatePreview(draft, Config.CreatorScene.ped, Config.CreatorScene.ped.w) + if previewPed then + PedBuild.Idle(previewPed) + Cam.Orbit(previewPed, 1.5, 0.62, Config.CreatorScene.ped.w + 180.0, true) + end +end + +RegisterNUICallback('creator:start', function(data, cb) + CreateThread(function() + draft = Appearance.Default(data and data.gender or 'm') + rebuildPreview() + pushDraft(cb) + end) +end) + +RegisterNUICallback('creator:gender', function(data, cb) + CreateThread(function() + draft = Appearance.Default(data.gender == 'f' and 'f' or 'm') + rebuildPreview() + pushDraft(cb) + end) +end) + +RegisterNUICallback('creator:parent', function(data, cb) + if not draft or not previewPed then return cb({ ok = false }) end + local key = data.key + if key == 'father' or key == 'mother' then + draft.parents[key] = math.floor(tonumber(data.value) or 0) + elseif key == 'shapeMix' or key == 'skinMix' then + draft.parents[key] = tonumber(data.value) or 0.5 + end + SetPedHeadBlendData(previewPed, + draft.parents.father, draft.parents.mother, 0, + draft.parents.father, draft.parents.mother, 0, + draft.parents.shapeMix + 0.0, draft.parents.skinMix + 0.0, 0.0, false) + cb({ ok = true }) +end) + +RegisterNUICallback('creator:feature', function(data, cb) + if not draft or not previewPed then return cb({ ok = false }) end + local id = math.floor(tonumber(data.id) or 0) + local v = math.max(-1.0, math.min(1.0, tonumber(data.value) or 0.0)) + draft.features[tostring(id)] = v + SetPedFaceFeature(previewPed, id, v + 0.0) + cb({ ok = true }) +end) + +RegisterNUICallback('creator:overlay', function(data, cb) + if not draft or not previewPed then return cb({ ok = false }) end + local slot + for _, o in ipairs(Appearance.OVERLAYS) do if o.key == data.key then slot = o end end + if not slot then return cb({ ok = false }) end + + local ov = draft.overlays[data.key] + if data.index ~= nil then ov.index = math.floor(tonumber(data.index)) end + if data.opacity ~= nil then ov.opacity = tonumber(data.opacity) end + if data.colour ~= nil then ov.colour = math.floor(tonumber(data.colour)) end + + local index = (ov.index == nil or ov.index < 0) and 255 or ov.index + SetPedHeadOverlay(previewPed, slot.id, index, ov.opacity + 0.0) + if slot.tint and index ~= 255 then + SetPedHeadOverlayColor(previewPed, slot.id, slot.tint == 'hair' and 1 or 2, ov.colour, ov.colour) + end + cb({ ok = true }) +end) + +RegisterNUICallback('creator:hair', function(data, cb) + if not draft or not previewPed then return cb({ ok = false }) end + if data.style ~= nil then draft.hair.style = math.floor(tonumber(data.style)) end + if data.colour ~= nil then draft.hair.colour = math.floor(tonumber(data.colour)) end + if data.highlight ~= nil then draft.hair.highlight = math.floor(tonumber(data.highlight)) end + SetPedComponentVariation(previewPed, 2, draft.hair.style, 0, 0) + SetPedHairColor(previewPed, draft.hair.colour, draft.hair.highlight) + cb({ ok = true }) +end) + +RegisterNUICallback('creator:eyes', function(data, cb) + if not draft or not previewPed then return cb({ ok = false }) end + draft.eyeColour = math.floor(tonumber(data.value) or 0) + SetPedEyeColor(previewPed, draft.eyeColour) + cb({ ok = true }) +end) + +RegisterNUICallback('creator:component', function(data, cb) + if not draft or not previewPed then return cb({ ok = false }) end + local comp + for _, c in ipairs(Appearance.COMPONENTS) do if c.key == data.key then comp = c end end + if not comp then return cb({ ok = false }) end + + local cc = draft.components[data.key] + if data.drawable ~= nil then cc.drawable = math.floor(tonumber(data.drawable)) end + if data.texture ~= nil then cc.texture = math.floor(tonumber(data.texture)) end + + -- a new drawable usually has a different number of textures + local maxTex = math.max(0, GetNumberOfPedTextureVariations(previewPed, comp.id, cc.drawable) - 1) + if cc.texture > maxTex then cc.texture = 0 end + + SetPedComponentVariation(previewPed, comp.id, cc.drawable, cc.texture, 0) + cb({ ok = true, texture = cc.texture, maxTexture = maxTex }) +end) + +RegisterNUICallback('creator:prop', function(data, cb) + if not draft or not previewPed then return cb({ ok = false }) end + local prop + for _, p in ipairs(Appearance.PROPS) do if p.key == data.key then prop = p end end + if not prop then return cb({ ok = false }) end + + local pp = draft.props[data.key] + if data.drawable ~= nil then pp.drawable = math.floor(tonumber(data.drawable)) end + if data.texture ~= nil then pp.texture = math.floor(tonumber(data.texture)) end + + if pp.drawable < 0 then + ClearPedProp(previewPed, prop.id) + cb({ ok = true, texture = 0, maxTexture = 0 }) + else + local maxTex = math.max(0, GetNumberOfPedPropTextureVariations(previewPed, prop.id, pp.drawable) - 1) + if pp.texture > maxTex then pp.texture = 0 end + SetPedPropIndex(previewPed, prop.id, pp.drawable, pp.texture, true) + cb({ ok = true, texture = pp.texture, maxTexture = maxTex }) + end +end) + +--- Camera framing per creator section. +local FRAMES = { + heritage = { radius = 1.05, height = 0.66, pitch = 0.02 }, + face = { radius = 0.85, height = 0.68, pitch = 0.0 }, + hair = { radius = 1.05, height = 0.70, pitch = 0.0 }, + body = { radius = 2.60, height = 0.20, pitch = 0.45 }, + clothing = { radius = 2.30, height = 0.28, pitch = 0.40 }, + identity = { radius = 1.90, height = 0.45, pitch = 0.25 }, +} + +RegisterNUICallback('creator:frame', function(data, cb) + local f = FRAMES[data.section] or FRAMES.face + Cam.OrbitFrame(f.radius, f.height, f.pitch) + cb({ ok = true }) +end) + +RegisterNUICallback('creator:rotate', function(data, cb) + Cam.Nudge(tonumber(data.delta) or 0.0) + cb({ ok = true }) +end) + +RegisterNUICallback('creator:submit', function(data, cb) + CreateThread(function() + local res, err = Core.Callback('rp_session', 'char:create', { + firstName = data.firstName, + lastName = data.lastName, + dob = data.dob, + gender = draft and draft.model or 'm', + backstory = data.backstory, + appearance = draft, + }) + if not res then return cb({ ok = false, error = err }) end + + local list = Core.Callback('rp_session', 'char:list') + characters = (list and list.characters) or {} + destroyPreview() + draft = nil + cb({ ok = true, characters = characters }) + end) +end) + +RegisterNUICallback('creator:cancel', function(_, cb) + destroyPreview() + draft = nil + Cam.Shots(Config.CinematicShots) + cb({ ok = true }) +end) + +-- --------------------------------------------------------------------------- +-- spawn +-- --------------------------------------------------------------------------- + +RegisterNUICallback('spawn:preview', function(data, cb) + CreateThread(function() + if not spawnData then return cb({ ok = false }) end + for _, sp in ipairs(spawnData.spawns) do + if sp.id == data.id then + local from = Cam.Position() + focusOn(vector3(sp.coords.x, sp.coords.y, sp.coords.z)) + Cam.FlyTo( + from, vector3(sp.cam.look.x, sp.cam.look.y, sp.cam.look.z), + vector3(sp.cam.pos.x, sp.cam.pos.y, sp.cam.pos.z), + vector3(sp.coords.x, sp.coords.y, sp.coords.z), + 2200 + ) + break + end + end + cb({ ok = true }) + end) +end) + +RegisterNUICallback('spawn:confirm', function(data, cb) + CreateThread(function() + local res, err = Core.Callback('rp_session', 'spawn:confirm', { id = data.id }) + if not res then return cb({ ok = false, error = err }) end + + cb({ ok = true }) + focusUI(false) + ui({ action = 'stage', stage = 'flying' }) + + local c = res.coords + local ped = PlayerPedId() + + -- stream the ground before anyone stands on it + focusOn(vector3(c.x, c.y, c.z)) + RequestCollisionAtCoord(c.x, c.y, c.z) + local waited = 0 + while not HasCollisionLoadedAroundEntity(ped) and waited < 8000 do + RequestCollisionAtCoord(c.x, c.y, c.z) + Wait(50) + waited = waited + 50 + end + + SetEntityCoordsNoOffset(ped, c.x, c.y, c.z, false, false, false) + SetEntityHeading(ped, c.h or 0.0) + + local from = Cam.Position() + Cam.FlyTo( + from, vector3(c.x, c.y, c.z), + vector3(c.x - 2.4, c.y - 2.4, c.z + 1.4), vector3(c.x, c.y, c.z + 0.2), + 3400, + function() + SetEntityVisible(ped, true, false) + releasePlayer() + setSceneMood(false) + ClearFocus() + Cam.Release(1400) + ui({ action = 'stage', stage = 'live' }) + TriggerEvent('rp:ui:spawned', chosenChar) + end + ) + end) +end) + +-- --------------------------------------------------------------------------- +-- misc +-- --------------------------------------------------------------------------- + +RegisterNUICallback('ui:log', function(data, cb) + print(('^3[rp_ui]^7 %s'):format(tostring(data and data.message))) + cb({ ok = true }) +end) + +AddEventHandler('onResourceStop', function(res) + if res ~= GetCurrentResourceName() then return end + destroyPreview() + Cam.Release(0) + focusUI(false) + releasePlayer() + setSceneMood(false) + ClearFocus() +end) diff --git a/server-code/data/resources/[rp]/rp_ui/client/ped.lua b/server-code/data/resources/[rp]/rp_ui/client/ped.lua new file mode 100644 index 0000000..d745609 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_ui/client/ped.lua @@ -0,0 +1,160 @@ +-- --------------------------------------------------------------------------- +-- Building a freemode ped from an appearance table. +-- +-- The same code paints the creator preview and the live player, so what you +-- saw while editing is exactly what walks into the world. +-- --------------------------------------------------------------------------- + +PedBuild = {} + +local NO_OVERLAY = 255 -- the native's "none" value; our model stores -1 + +local function loadModel(name) + local hash = joaat(name) + if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end + RequestModel(hash) + local waited = 0 + while not HasModelLoaded(hash) and waited < 10000 do + Wait(10) + waited = waited + 10 + end + if not HasModelLoaded(hash) then return nil end + return hash +end + +PedBuild.LoadModel = loadModel + +--- Apply an appearance table to an existing freemode ped. +function PedBuild.Apply(ped, a) + if not ped or not DoesEntityExist(ped) or type(a) ~= 'table' then return end + + SetPedHeadBlendData( + ped, + a.parents.father, a.parents.mother, 0, + a.parents.father, a.parents.mother, 0, + a.parents.shapeMix + 0.0, a.parents.skinMix + 0.0, 0.0, + false + ) + + for _, f in ipairs(Appearance.FEATURES) do + local v = a.features[tostring(f.id)] + if v then SetPedFaceFeature(ped, f.id, v + 0.0) end + end + + for _, o in ipairs(Appearance.OVERLAYS) do + local ov = a.overlays[o.key] + if ov then + local index = (ov.index == nil or ov.index < 0) and NO_OVERLAY or ov.index + SetPedHeadOverlay(ped, o.id, index, ov.opacity + 0.0) + if o.tint and index ~= NO_OVERLAY then + -- tint type 1 = hair palette, 2 = make-up palette + SetPedHeadOverlayColor(ped, o.id, o.tint == 'hair' and 1 or 2, ov.colour, ov.colour) + end + end + end + + SetPedComponentVariation(ped, 2, a.hair.style, 0, 0) + SetPedHairColor(ped, a.hair.colour, a.hair.highlight) + SetPedEyeColor(ped, a.eyeColour) + + for _, c in ipairs(Appearance.COMPONENTS) do + local cc = a.components[c.key] + if cc then SetPedComponentVariation(ped, c.id, cc.drawable, cc.texture, 0) end + end + + for _, p in ipairs(Appearance.PROPS) do + local pp = a.props[p.key] + if pp then + if not pp.drawable or pp.drawable < 0 then + ClearPedProp(ped, p.id) + else + SetPedPropIndex(ped, p.id, pp.drawable, pp.texture, true) + end + end + end +end + +--- How many variations this particular model actually has, so the creator's +--- sliders stop at real values instead of guessed ones. +function PedBuild.Limits(ped) + local limits = { components = {}, props = {}, overlays = {}, hair = 0 } + if not ped or not DoesEntityExist(ped) then return limits end + + limits.hair = math.max(0, GetNumberOfPedDrawableVariations(ped, 2) - 1) + + for _, c in ipairs(Appearance.COMPONENTS) do + local drawables = math.max(0, GetNumberOfPedDrawableVariations(ped, c.id) - 1) + local current = GetPedDrawableVariation(ped, c.id) + limits.components[c.key] = { + drawable = drawables, + texture = math.max(0, GetNumberOfPedTextureVariations(ped, c.id, current) - 1), + } + end + + for _, p in ipairs(Appearance.PROPS) do + local drawables = math.max(-1, GetNumberOfPedPropDrawableVariations(ped, p.id) - 1) + local current = GetPedPropIndex(ped, p.id) + limits.props[p.key] = { + drawable = drawables, + texture = math.max(0, GetNumberOfPedPropTextureVariations(ped, p.id, current) - 1), + } + end + + for _, o in ipairs(Appearance.OVERLAYS) do + limits.overlays[o.key] = o.max + end + + return limits +end + +--- Create a standalone preview ped (never networked). +function PedBuild.CreatePreview(appearance, coords, heading) + local modelName = Appearance.MODELS[appearance.model] or Appearance.MODELS.m + local hash = loadModel(modelName) + if not hash then return nil end + + local ped = CreatePed(2, hash, coords.x, coords.y, coords.z, heading + 0.0, false, false) + SetModelAsNoLongerNeeded(hash) + + SetEntityInvincible(ped, true) + FreezeEntityPosition(ped, true) + SetBlockingOfNonTemporaryEvents(ped, true) + SetPedDefaultComponentVariation(ped) + SetPedCanRagdoll(ped, false) + SetEntityCollision(ped, false, false) + + PedBuild.Apply(ped, appearance) + return ped +end + +--- Turn the local player into this character. +function PedBuild.ApplyToPlayer(appearance) + local modelName = Appearance.MODELS[appearance.model] or Appearance.MODELS.m + local hash = loadModel(modelName) + if not hash then return false end + + SetPlayerModel(PlayerId(), hash) + SetModelAsNoLongerNeeded(hash) + + local ped = PlayerPedId() + SetPedDefaultComponentVariation(ped) + PedBuild.Apply(ped, appearance) + -- freemode peds start with no head blend applied until this settles a frame + Wait(50) + PedBuild.Apply(ped, appearance) + return true +end + +--- A calm idle so the preview does not stand like a mannequin. +function PedBuild.Idle(ped) + local dict = 'anim@heists@heist_corona@team_idles@female_a' + RequestAnimDict(dict) + local waited = 0 + while not HasAnimDictLoaded(dict) and waited < 3000 do + Wait(10) + waited = waited + 10 + end + if HasAnimDictLoaded(dict) then + TaskPlayAnim(ped, dict, 'idle', 2.0, 2.0, -1, 1, 0, false, false, false) + end +end diff --git a/server-code/data/resources/[rp]/rp_ui/fxmanifest.lua b/server-code/data/resources/[rp]/rp_ui/fxmanifest.lua new file mode 100644 index 0000000..0f190e0 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_ui/fxmanifest.lua @@ -0,0 +1,32 @@ +fx_version 'cerulean' +game 'gta5' + +name 'rp_ui' +description 'Pre-spawn interface: sign in, character creation and placement' +author 'Los Santos RP' +version '1.0.0' + +shared_scripts { + '@rp_core/shared/config.lua', + '@rp_core/shared/util.lua', + '@rp_core/shared/appearance.lua', +} + +client_scripts { + '@rp_core/lib/callbacks_client.lua', + 'client/camera.lua', + 'client/ped.lua', + 'client/main.lua', +} + +ui_page 'html/index.html' + +files { + 'html/index.html', + 'html/tokens.css', + 'html/app.css', + 'html/app.js', + 'html/fonts/archivo.woff2', + 'html/fonts/plex-400.woff2', + 'html/fonts/plex-500.woff2', +} diff --git a/server-code/data/resources/[rp]/rp_ui/html/app.css b/server-code/data/resources/[rp]/rp_ui/html/app.css new file mode 100644 index 0000000..62a7fc6 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_ui/html/app.css @@ -0,0 +1,839 @@ +/* --------------------------------------------------------------------------- + Pre-spawn interface. + + One spatial rule holds across every screen: the instrument sits in a rail on + the left, the world stays visible on the right. Nothing is ever a floating + centred card, because nothing here is a dialog - it is a file being worked + on while the city carries on behind it. + --------------------------------------------------------------------------- */ + +body { + background: transparent; + opacity: 1; + transition: opacity 420ms var(--ease); +} +body.hidden { opacity: 0; pointer-events: none; } + +/* Keeps interface text legible over whatever the camera happens to be on. */ +.edge { + position: fixed; + inset: 0; + pointer-events: none; + background: + linear-gradient(90deg, rgba(8,9,11,.55) 0%, rgba(8,9,11,0) 55%), + radial-gradient(130% 100% at 70% 50%, transparent 45%, rgba(0,0,0,.6) 100%); +} + +.grain { + position: fixed; + inset: 0; + pointer-events: none; + opacity: .1; + mix-blend-mode: overlay; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +/* ========================================================================= + chrome + ========================================================================= */ + +.topbar { + position: fixed; + top: 0; left: 0; right: 0; + height: 74px; + padding: 0 var(--gut-x); + display: flex; + align-items: center; + justify-content: space-between; + gap: 28px; + z-index: 40; + pointer-events: none; +} + +.mark { display: flex; align-items: baseline; gap: 11px; } +.mark-name { + font-weight: 800; + font-size: 17px; + letter-spacing: -.01em; +} +.mark-tag { + font-family: 'Plex Mono', monospace; + font-size: 9.5px; + letter-spacing: .34em; + color: var(--sodium); + text-transform: uppercase; +} + +.stages { display: flex; align-items: center; gap: 10px; } + +.stagechip { + display: flex; + align-items: center; + gap: 9px; + background: none; + border: 0; + padding: 6px 2px; + color: var(--muted); + font-family: inherit; + font-size: 12.5px; + letter-spacing: .01em; + cursor: default; + transition: color 300ms var(--ease); +} +.stagechip em { + font-family: 'Plex Mono', monospace; + font-style: normal; + font-size: 10px; + letter-spacing: .1em; + padding: 3px 5px; + border: 1px solid var(--line); + color: var(--muted); + transition: color 300ms var(--ease), border-color 300ms var(--ease), background 300ms var(--ease); +} +.stagechip.done { color: var(--paper-dim); } +.stagechip.done em { color: var(--paper-dim); border-color: var(--line); } +.stagechip.on { color: var(--paper); } +.stagechip.on em { + color: var(--ink-900); + background: var(--sodium); + border-color: var(--sodium); +} + +.stagelink { + width: 26px; + height: 1px; + background: var(--line); +} + +.filetag { color: var(--muted); } +.filetag span { color: var(--paper-dim); } + +/* ========================================================================= + screens + rail + ========================================================================= */ + +.screen { + position: fixed; + inset: 0; + z-index: 20; +} +.screen[hidden] { display: none; } + +.rail { + position: absolute; + top: 0; left: 0; bottom: 0; + width: var(--rail); + display: flex; + align-items: center; + pointer-events: auto; +} +.rail.wide { width: clamp(470px, 36vw, 640px); } + +.rail-scrim { + position: absolute; + top: 0; bottom: 0; left: 0; + width: 168%; + pointer-events: none; + background: linear-gradient( + 90deg, + rgba(8,9,11,.96) 0%, + rgba(8,9,11,.92) 42%, + rgba(8,9,11,.6) 72%, + rgba(8,9,11,0) 100% + ); +} + +.rail-inner { + position: relative; + width: 100%; + padding: 96px var(--gut-x) var(--gut-y); + max-height: 100vh; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: var(--ink-500) transparent; +} +.rail-inner::-webkit-scrollbar { width: 3px; } +.rail-inner::-webkit-scrollbar-thumb { background: var(--ink-500); } + +/* staged entrance, replayed each time a screen becomes current */ +.screen.is-on .rail-inner > * { animation: rise 720ms var(--ease-out) both; } +.screen.is-on .rail-inner > *:nth-child(1) { animation-delay: 40ms; } +.screen.is-on .rail-inner > *:nth-child(2) { animation-delay: 110ms; } +.screen.is-on .rail-inner > *:nth-child(3) { animation-delay: 175ms; } +.screen.is-on .rail-inner > *:nth-child(4) { animation-delay: 235ms; } +.screen.is-on .rail-inner > *:nth-child(5) { animation-delay: 290ms; } +.screen.is-on .rail-inner > *:nth-child(6) { animation-delay: 340ms; } +.screen.is-on .rail-inner > *:nth-child(7) { animation-delay: 385ms; } + +@keyframes rise { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: none; } +} + +/* ========================================================================= + type blocks + ========================================================================= */ + +.kicker { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 22px; +} +.kicker .hair { + flex: 1; + height: 1px; + background: linear-gradient(90deg, var(--line), transparent); +} + +.head { + font-size: clamp(30px, 2.9vw, 44px); + font-weight: 700; + line-height: 1.02; + letter-spacing: -.032em; + margin-bottom: 14px; +} + +.sub { + color: var(--paper-dim); + font-size: 14.5px; + max-width: 46ch; + margin-bottom: 30px; +} + +/* ========================================================================= + form + ========================================================================= */ + +.form { display: block; } + +.field { margin-bottom: 20px; } + +.field label { + display: block; + font-family: 'Plex Mono', monospace; + font-size: 10.5px; + font-weight: 500; + letter-spacing: .18em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 9px; + transition: color 220ms var(--ease); +} +.field:focus-within label { color: var(--sodium); } + +.field input { + width: 100%; + height: 46px; + padding: 0 14px; + background: rgba(20, 23, 29, .72); + border: 1px solid var(--line); + border-radius: 0; + color: var(--paper); + font-family: inherit; + font-size: 15px; + letter-spacing: .005em; + transition: border-color 220ms var(--ease), background 220ms var(--ease), box-shadow 220ms var(--ease); +} +.field input::placeholder { color: #565c66; } +.field input:hover { border-color: rgba(232,228,220,.22); } +.field input:focus { + outline: none; + border-color: var(--sodium); + background: rgba(29, 33, 42, .85); + box-shadow: inset 0 -2px 0 var(--sodium-soft); +} + +.withbtn { position: relative; display: flex; } +.withbtn input { padding-right: 62px; } +.reveal-btn { + position: absolute; + right: 1px; top: 1px; bottom: 1px; + padding: 0 13px; + background: none; + border: 0; + border-left: 1px solid var(--line); + color: var(--muted); + font-family: 'Plex Mono', monospace; + font-size: 10px; + letter-spacing: .12em; + text-transform: uppercase; + cursor: pointer; + transition: color 200ms var(--ease); +} +.reveal-btn:hover { color: var(--sodium); } + +.hint { + margin-top: 8px; + font-family: 'Plex Mono', monospace; + font-size: 11px; + line-height: 1.45; + letter-spacing: .01em; + color: var(--muted); + transition: color 220ms var(--ease), transform 220ms var(--ease); +} +.field.bad input { border-color: var(--stamp); } +.field.bad .hint { color: var(--stamp); } +.field.good input { border-color: rgba(127,159,90,.55); } +.field.good .hint { color: var(--good); } + +/* register-only row: grid trick so height animates without a fixed value */ +.collapse { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: grid-template-rows 460ms var(--ease-out), opacity 300ms var(--ease); +} +.collapse.open { grid-template-rows: 1fr; opacity: 1; } +.collapse-inner { overflow: hidden; min-height: 0; } + +.formerror { + margin-bottom: 16px; + padding: 11px 13px; + border-left: 2px solid var(--stamp); + background: rgba(196,70,47,.09); + color: #e8a598; + font-size: 13.5px; + animation: shake 380ms var(--ease); +} +.formerror[hidden] { display: none; } +@keyframes shake { + 0%,100% { transform: translateX(0); } + 22% { transform: translateX(-5px); } + 46% { transform: translateX(4px); } + 72% { transform: translateX(-2px); } +} + +.switch { + margin-top: 22px; + font-size: 13.5px; + color: var(--muted); +} + +/* ========================================================================= + buttons + ========================================================================= */ + +.btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + height: 50px; + padding: 0 26px; + border: 1px solid transparent; + border-radius: 0; + font-family: inherit; + font-size: 13px; + font-weight: 600; + letter-spacing: .07em; + text-transform: uppercase; + cursor: pointer; + overflow: hidden; + transition: transform 110ms var(--ease), background 240ms var(--ease), + color 240ms var(--ease), border-color 240ms var(--ease), opacity 240ms var(--ease); +} +.btn:active { transform: translateY(1px); } +.btn[disabled] { opacity: .34; cursor: not-allowed; } +.btn[disabled]:active { transform: none; } + +.btn.primary { + width: 100%; + background: var(--sodium); + color: #17120b; +} +.btn.primary:hover:not([disabled]) { background: #ffb063; } + +/* a light sweeps across on hover - the only ornament on the button */ +.btn.primary::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(105deg, transparent 30%, rgba(255,255,255,.42) 50%, transparent 70%); + transform: translateX(-130%); + transition: transform 720ms var(--ease); +} +.btn.primary:hover:not([disabled])::after { transform: translateX(130%); } + +.btn.ghost { + background: transparent; + border-color: var(--line); + color: var(--paper-dim); +} +.btn.ghost:hover:not([disabled]) { border-color: var(--sodium-line); color: var(--sodium); } + +.btn-spin { + width: 13px; height: 13px; + border: 1.5px solid rgba(23,18,11,.3); + border-top-color: #17120b; + border-radius: 50%; + display: none; + animation: spin 640ms linear infinite; +} +.btn.busy .btn-spin { display: block; } +.btn.busy .btn-label { opacity: .55; } +@keyframes spin { to { transform: rotate(360deg); } } + +.linkbtn { + background: none; + border: 0; + padding: 0; + color: var(--sodium); + font-family: inherit; + font-size: inherit; + cursor: pointer; + position: relative; +} +.linkbtn::after { + content: ''; + position: absolute; + left: 0; right: 0; bottom: -2px; + height: 1px; + background: var(--sodium); + transform: scaleX(0); + transform-origin: left; + transition: transform 280ms var(--ease-out); +} +.linkbtn:hover::after { transform: scaleX(1); } + +/* ========================================================================= + character list + ========================================================================= */ + +.charlist { list-style: none; margin-bottom: 26px; } + +.charcard { + position: relative; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 16px; + padding: 17px 18px; + margin-bottom: 10px; + background: rgba(20,23,29,.6); + border: 1px solid var(--line); + cursor: pointer; + transition: border-color 240ms var(--ease), background 240ms var(--ease), transform 240ms var(--ease); +} +.charcard:hover { border-color: rgba(232,228,220,.24); transform: translateX(3px); } +.charcard.on { + border-color: var(--sodium); + background: rgba(255,159,69,.07); +} +/* the accent bar only exists on the selected row */ +.charcard.on::before { + content: ''; + position: absolute; + left: -1px; top: -1px; bottom: -1px; + width: 2px; + background: var(--sodium); +} + +.charslot { + font-family: 'Plex Mono', monospace; + font-size: 10px; + letter-spacing: .1em; + color: var(--muted); + border: 1px solid var(--line); + padding: 4px 6px; +} +.charcard.on .charslot { color: var(--sodium); border-color: var(--sodium-line); } + +.charname { display: block; font-size: 17px; font-weight: 600; letter-spacing: -.015em; } +.charmeta { + display: block; + font-family: 'Plex Mono', monospace; + font-size: 11px; + color: var(--muted); + margin-top: 4px; + letter-spacing: .02em; +} +.charmoney { + font-family: 'Plex Mono', monospace; + font-size: 12.5px; + color: var(--paper-dim); + text-align: right; + font-variant-numeric: tabular-nums; +} + +.charcard.empty { + border-style: dashed; + background: transparent; + color: var(--muted); + grid-template-columns: auto 1fr; +} +.charcard.empty:hover { border-color: var(--sodium-line); color: var(--sodium); } +.charcard.empty .charname { font-size: 14px; font-weight: 500; color: inherit; } + +.charactions { display: grid; grid-template-columns: 1fr auto; gap: 10px; } + +/* ========================================================================= + creator + ========================================================================= */ + +.creator { padding-bottom: 26px; } + +.tabs { + display: flex; + flex-wrap: wrap; + gap: 2px; + margin-bottom: 22px; + border-bottom: 1px solid var(--line); +} +.tab { + position: relative; + background: none; + border: 0; + padding: 10px 13px; + color: var(--muted); + font-family: inherit; + font-size: 12.5px; + letter-spacing: .01em; + cursor: pointer; + transition: color 220ms var(--ease); +} +.tab:hover { color: var(--paper-dim); } +.tab.is-on { color: var(--paper); } +.tab.is-on::after { + content: ''; + position: absolute; + left: 0; right: 0; bottom: -1px; + height: 2px; + background: var(--sodium); + animation: tabin 320ms var(--ease-out); +} +@keyframes tabin { from { transform: scaleX(.2); } to { transform: scaleX(1); } } + +.panes { min-height: 300px; } +.pane { display: none; } +.pane.is-on { display: block; animation: panein 420ms var(--ease-out); } +@keyframes panein { + from { opacity: 0; transform: translateX(10px); } + to { opacity: 1; transform: none; } +} + +.ctrl { margin-bottom: 15px; } +.ctrl-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 6px; + gap: 12px; +} +.ctrl-label { + font-size: 13px; + color: var(--paper-dim); +} +.ctrl-val { + font-family: 'Plex Mono', monospace; + font-size: 11px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 20px; + background: transparent; + cursor: pointer; +} +.slider::-webkit-slider-runnable-track { height: 2px; background: var(--line); } +.slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 3px; + height: 14px; + margin-top: -6px; + background: var(--sodium); + border: 0; + transition: height 150ms var(--ease), margin-top 150ms var(--ease); +} +.slider:hover::-webkit-slider-thumb, +.slider:focus-visible::-webkit-slider-thumb { height: 20px; margin-top: -9px; } + +.stepper { display: flex; align-items: stretch; gap: 0; } +.stepper button { + width: 34px; + background: transparent; + border: 1px solid var(--line); + color: var(--paper-dim); + font-family: 'Plex Mono', monospace; + font-size: 14px; + cursor: pointer; + transition: color 180ms var(--ease), border-color 180ms var(--ease), background 180ms var(--ease); +} +.stepper button:hover { color: var(--sodium); border-color: var(--sodium-line); } +.stepper button:active { background: var(--sodium-soft); } +.stepper .stepval { + flex: 1; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-left: 0; border-right: 0; + font-family: 'Plex Mono', monospace; + font-size: 12px; + color: var(--paper); + font-variant-numeric: tabular-nums; +} + +/* 64 colours is a lot of surface; kept small so a palette never outweighs the + control it belongs to */ +.swatches { display: flex; flex-wrap: wrap; gap: 4px; } +.swatch { + width: 13px; height: 13px; + border: 1px solid var(--line); + cursor: pointer; + padding: 0; + transition: transform 160ms var(--ease), border-color 160ms var(--ease); +} +.swatch:hover { transform: scale(1.3); } +.swatch.on { border-color: var(--sodium); transform: scale(1.3); } + +.seg { display: flex; gap: 0; margin-bottom: 22px; } +.seg button { + flex: 1; + height: 42px; + background: transparent; + border: 1px solid var(--line); + color: var(--muted); + font-family: inherit; + font-size: 12.5px; + letter-spacing: .05em; + text-transform: uppercase; + cursor: pointer; + transition: color 220ms var(--ease), background 220ms var(--ease), border-color 220ms var(--ease); +} +.seg button + button { border-left: 0; } +.seg button.on { + color: var(--ink-900); + background: var(--sodium); + border-color: var(--sodium); +} +.seg button:not(.on):hover { color: var(--paper); } + +.field textarea { + width: 100%; + min-height: 132px; + padding: 13px 14px; + background: rgba(20,23,29,.72); + border: 1px solid var(--line); + color: var(--paper); + font-family: inherit; + font-size: 14.5px; + line-height: 1.55; + resize: vertical; + transition: border-color 220ms var(--ease), background 220ms var(--ease); +} +.field textarea:focus { + outline: none; + border-color: var(--sodium); + background: rgba(29,33,42,.85); +} +.counter { + margin-top: 6px; + text-align: right; + font-family: 'Plex Mono', monospace; + font-size: 10.5px; + color: var(--muted); +} + +.creator-foot { display: grid; grid-template-columns: auto 1fr; gap: 10px; margin-top: 24px; } + +/* drag surface over the world half */ +.turntable { + position: fixed; + top: 0; right: 0; bottom: 0; + left: clamp(470px, 36vw, 640px); + z-index: 15; + cursor: grab; +} +.turntable.dragging { cursor: grabbing; } + +.turnhint { + position: absolute; + left: 50%; + bottom: 46px; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 9px; + padding: 7px 13px; + border: 1px solid var(--line); + background: rgba(8,9,11,.55); + color: var(--paper-dim); + letter-spacing: .1em; + text-transform: uppercase; + font-size: 10px; + transition: opacity 420ms var(--ease); +} +.turntable.dragging .turnhint { opacity: 0; } +.turnicon { + width: 13px; height: 13px; + border: 1px solid currentColor; + border-radius: 50%; + border-left-color: transparent; + border-right-color: transparent; + animation: turnspin 2.6s linear infinite; +} +@keyframes turnspin { to { transform: rotate(360deg); } } + +/* ========================================================================= + spawn + ========================================================================= */ + +.spawnlist { list-style: none; margin-bottom: 24px; } + +.spawncard { + position: relative; + padding: 15px 17px; + margin-bottom: 8px; + border: 1px solid var(--line); + background: rgba(20,23,29,.55); + cursor: pointer; + transition: border-color 240ms var(--ease), background 240ms var(--ease), transform 240ms var(--ease); +} +.spawncard:hover { border-color: rgba(232,228,220,.24); transform: translateX(3px); } +.spawncard.on { border-color: var(--sodium); background: rgba(255,159,69,.07); } +.spawncard.on::before { + content: ''; + position: absolute; + left: -1px; top: -1px; bottom: -1px; + width: 2px; + background: var(--sodium); +} +.spawnhead { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; } +.spawnname { font-size: 15.5px; font-weight: 600; letter-spacing: -.01em; } +.spawnarea { + font-family: 'Plex Mono', monospace; + font-size: 10px; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--muted); +} +.spawnblurb { margin-top: 5px; font-size: 13px; color: var(--paper-dim); max-width: 42ch; } + +.chart { + position: fixed; + top: 50%; + right: clamp(40px, 7vw, 130px); + transform: translateY(-50%); + width: min(38vw, 44vh); + aspect-ratio: 1; + z-index: 18; + pointer-events: none; + opacity: 0; + animation: chartin 900ms var(--ease-out) 260ms forwards; +} +@keyframes chartin { + from { opacity: 0; transform: translateY(-50%) scale(.96); } + to { opacity: 1; transform: translateY(-50%) scale(1); } +} +.chart svg { width: 100%; height: 100%; display: block; } + +/* the coastline draws itself once, then stays */ +#coastline { + stroke-dasharray: 1400; + stroke-dashoffset: 1400; + animation: draw 2600ms var(--ease-out) 400ms forwards; +} +@keyframes draw { to { stroke-dashoffset: 0; } } + +.pins { position: absolute; inset: 0; } + +/* The marker rotates; the label must not, so they are separate elements + inside an unrotated anchor. */ +.pin { position: absolute; width: 0; height: 0; } + +.pin-mark { + position: absolute; + left: -6px; top: -6px; + width: 12px; height: 12px; + border: 1px solid var(--sodium-line); + background: rgba(255,159,69,.16); + transform: rotate(45deg); + transition: background 260ms var(--ease), border-color 260ms var(--ease), transform 260ms var(--ease); +} +.pin-label { + position: absolute; + left: 18px; + top: -7px; + white-space: nowrap; + font-family: 'Plex Mono', monospace; + font-size: 10px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--muted); + transition: color 260ms var(--ease); +} +.pin.on .pin-mark { + background: var(--sodium); + border-color: var(--sodium); + transform: rotate(45deg) scale(1.35); +} +.pin.on .pin-label { color: var(--paper); } +/* pins on the eastern side label leftwards so nothing runs off the sheet */ +.pin.flip .pin-label { left: auto; right: 18px; } +/* a single ping when selected, not a permanent pulse */ +.pin.on .pin-mark::before { + content: ''; + position: absolute; + inset: -6px; + border: 1px solid var(--sodium); + animation: ping 900ms var(--ease-out); +} + +/* survey chart furniture */ +.chart-frame { fill: none; stroke: rgba(232,228,220,.10); } +.chart-ticks { fill: none; stroke: rgba(255,159,69,.45); stroke-width: 2; } +.chart-label { + font-family: 'Plex Mono', monospace; + font-size: 19px; + letter-spacing: 3.4px; + fill: #6e747f; + text-transform: uppercase; +} +@keyframes ping { + from { opacity: .9; transform: scale(.6); } + to { opacity: 0; transform: scale(1.9); } +} + +/* ========================================================================= + the stamp + ========================================================================= */ + +.stamp { + position: fixed; + top: 46%; + left: 62%; + z-index: 60; + padding: 14px 30px; + border: 3px solid var(--stamp); + color: var(--stamp); + font-family: 'Plex Mono', monospace; + font-weight: 500; + font-size: 30px; + letter-spacing: .22em; + text-transform: uppercase; + opacity: 0; + pointer-events: none; + transform: translate(-50%, -50%) rotate(-19deg) scale(2.6); +} +.stamp::before { + content: ''; + position: absolute; + inset: 4px; + border: 1px solid var(--stamp); + opacity: .55; +} +.stamp.show { animation: stamp 1500ms var(--ease-out) forwards; } +@keyframes stamp { + 0% { opacity: 0; transform: translate(-50%,-50%) rotate(-19deg) scale(2.6); } + 38% { opacity: .95; transform: translate(-50%,-50%) rotate(-6deg) scale(.94); } + 48% { transform: translate(-50%,-50%) rotate(-8deg) scale(1.04); } + 58% { transform: translate(-50%,-50%) rotate(-7deg) scale(1); } + 80% { opacity: .95; } + 100% { opacity: 0; transform: translate(-50%,-50%) rotate(-7deg) scale(1); } +} diff --git a/server-code/data/resources/[rp]/rp_ui/html/app.js b/server-code/data/resources/[rp]/rp_ui/html/app.js new file mode 100644 index 0000000..336a290 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_ui/html/app.js @@ -0,0 +1,958 @@ +/* --------------------------------------------------------------------------- + Pre-spawn interface behaviour. + + The client Lua owns the truth about stages; this file renders whatever it is + told and asks for changes. Validation here is for live feedback only - the + server re-checks everything and its answer is the one that counts. + --------------------------------------------------------------------------- */ + +'use strict'; + +const $ = (id) => document.getElementById(id); +const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel)); + +async function post(name, data) { + try { + const res = await fetch(`https://rp_ui/${name}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=UTF-8' }, + body: JSON.stringify(data || {}), + }); + return await res.json(); + } catch (err) { + return { ok: false, error: 'The game stopped responding. Try again.' }; + } +} + +/* Fire-and-forget for high-frequency edits (slider drags). */ +function poke(name, data) { + fetch(`https://rp_ui/${name}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=UTF-8' }, + body: JSON.stringify(data || {}), + }).catch(() => {}); +} + +/* ========================================================================= + screen manager + ========================================================================= */ + +const SCREENS = ['auth', 'characters', 'creator', 'spawn']; +const CHIP_FOR = { auth: 'auth', characters: 'characters', creator: 'characters', spawn: 'spawn' }; +const CHIP_ORDER = ['auth', 'characters', 'spawn']; + +let current = null; + +function showScreen(name) { + SCREENS.forEach((s) => { + const el = $(`screen-${s}`); + if (!el) return; + if (s === name) { + el.hidden = false; + /* restart the entrance animation */ + el.classList.remove('is-on'); + void el.offsetWidth; + el.classList.add('is-on'); + } else { + el.hidden = true; + el.classList.remove('is-on'); + } + }); + current = name; + paintChips(CHIP_FOR[name]); + document.body.classList.remove('hidden'); +} + +function paintChips(activeKey) { + const idx = CHIP_ORDER.indexOf(activeKey); + $$('.stagechip').forEach((chip) => { + const i = CHIP_ORDER.indexOf(chip.dataset.stage); + chip.classList.toggle('on', i === idx); + chip.classList.toggle('done', i < idx); + }); +} + +function stamp(text) { + const el = $('stamp'); + $('stampText').textContent = text; + el.classList.remove('show'); + void el.offsetWidth; + el.classList.add('show'); +} + +function busy(btn, on) { + btn.classList.toggle('busy', on); + btn.disabled = on; +} + +function showError(el, message) { + if (!message) { + el.hidden = true; + return; + } + el.textContent = message; + el.hidden = false; + /* replay the shake even if the message is unchanged */ + el.style.animation = 'none'; + void el.offsetWidth; + el.style.animation = ''; +} + +/* ========================================================================= + validation (mirrors shared/util.lua; the server still decides) + ========================================================================= */ + +const check = { + username(v) { + v = (v || '').trim(); + if (!v) return [null, 'Pick a username.']; + if (v.length < 3) return [false, 'A bit longer - at least 3 characters.']; + if (v.length > 20) return [false, 'Too long - 20 characters at most.']; + if (!/^[A-Za-z0-9_]+$/.test(v)) return [false, 'Letters, numbers and underscore only.']; + return [true, 'That will do.']; + }, + password(v) { + v = v || ''; + if (!v) return [null, 'At least 8 characters, with a letter and a number.']; + if (v.length < 8) return [false, `${8 - v.length} more character${8 - v.length === 1 ? '' : 's'} needed.`]; + if (!/[A-Za-z]/.test(v)) return [false, 'Add at least one letter.']; + if (!/[0-9]/.test(v)) return [false, 'Add at least one number.']; + return [true, 'Strong enough.']; + }, + name(v) { + v = (v || '').trim(); + if (!v) return [null, 'Required.']; + if (v.length < 2) return [false, 'Too short.']; + if (v.length > 20) return [false, 'Too long.']; + if (!/^[A-Za-z][A-Za-z'-]*$/.test(v)) return [false, "Letters, ' and - only."]; + return [true, '']; + }, + dob(v) { + v = (v || '').trim(); + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v); + if (!v) return [null, 'YYYY-MM-DD']; + if (!m) return [false, 'Use YYYY-MM-DD.']; + const y = +m[1], mo = +m[2], d = +m[3]; + if (mo < 1 || mo > 12) return [false, 'Month must be 01-12.']; + const days = [31, (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (d < 1 || d > days[mo - 1]) return [false, 'That day does not exist.']; + if (y < 1930 || y > 2010) return [false, 'Year must be between 1930 and 2010.']; + return [true, '']; + }, +}; + +/* Paints a field's state. `state` is true / false / null (neutral). */ +function mark(fieldName, state, message) { + const field = document.querySelector(`.field[data-field="${fieldName}"]`); + if (!field) return; + field.classList.toggle('good', state === true); + field.classList.toggle('bad', state === false); + const hint = field.querySelector('.hint'); + if (hint && message !== undefined && message !== null) hint.textContent = message; +} + +/* ========================================================================= + 01 / auth + ========================================================================= */ + +const Auth = { + mode: 'register', + + init() { + $('switchMode').addEventListener('click', () => this.toggle()); + $('authForm').addEventListener('submit', (e) => { e.preventDefault(); this.submit(); }); + + $('revealPw').addEventListener('click', () => { + const input = $('password'); + const show = input.type === 'password'; + input.type = show ? 'text' : 'password'; + $('revealPw').textContent = show ? 'Hide' : 'Show'; + $('revealPw').setAttribute('aria-label', show ? 'Hide password' : 'Show password'); + }); + + $('username').addEventListener('input', () => { + const [ok, msg] = check.username($('username').value); + mark('username', ok, msg); + }); + $('password').addEventListener('input', () => { + const [ok, msg] = check.password($('password').value); + mark('password', ok, msg); + if (this.mode === 'register' && $('confirm').value) this.checkConfirm(); + }); + $('confirm').addEventListener('input', () => this.checkConfirm()); + }, + + checkConfirm() { + const a = $('password').value; + const b = $('confirm').value; + if (!b) return mark('confirm', null, 'Both entries must match.'); + mark('confirm', a === b, a === b ? 'They match.' : 'These do not match yet.'); + }, + + toggle() { + this.mode = this.mode === 'register' ? 'login' : 'register'; + const reg = this.mode === 'register'; + + $('authTitle').textContent = reg ? 'Open a file' : 'Return to your file'; + $('authSub').textContent = reg + ? 'Everything you do in this city is recorded against this file. Choose a name you will remember and a password you will not share.' + : 'Sign in and the city picks up where you left it.'; + $('authSubmit').querySelector('.btn-label').textContent = reg ? 'Open the file' : 'Sign in'; + $('switchText').textContent = reg ? 'Already have a file?' : 'No file yet?'; + $('switchMode').textContent = reg ? 'Sign in instead' : 'Open one now'; + + $('confirmWrap').classList.toggle('open', reg); + $('confirmWrap').setAttribute('aria-hidden', String(!reg)); + $('password').setAttribute('autocomplete', reg ? 'new-password' : 'current-password'); + + showError($('authError'), null); + if (!reg) mark('confirm', null, 'Both entries must match.'); + }, + + async submit() { + const username = $('username').value.trim(); + const password = $('password').value; + const reg = this.mode === 'register'; + + const [uOk, uMsg] = check.username(username); + if (uOk !== true) { mark('username', false, uMsg); $('username').focus(); return; } + + if (reg) { + const [pOk, pMsg] = check.password(password); + if (pOk !== true) { mark('password', false, pMsg); $('password').focus(); return; } + if ($('confirm').value !== password) { + mark('confirm', false, 'These do not match yet.'); + $('confirm').focus(); + return; + } + } else if (!password) { + mark('password', false, 'Enter your password.'); + $('password').focus(); + return; + } + + const btn = $('authSubmit'); + busy(btn, true); + showError($('authError'), null); + + const res = await post('auth:submit', { mode: this.mode, username, password }); + busy(btn, false); + + if (!res.ok) return showError($('authError'), res.error || 'That did not work.'); + + if (reg) stamp('Filed'); + Characters.load(res.characters || [], res.maxChars || 3); + showScreen('characters'); + }, +}; + +/* ========================================================================= + 02 / characters + ========================================================================= */ + +const Characters = { + list: [], + max: 3, + selected: null, + + init() { + $('charEnter').addEventListener('click', () => this.enter()); + $('charDelete').addEventListener('click', () => this.remove()); + }, + + load(list, max) { + this.list = list || []; + this.max = max || 3; + this.selected = null; + this.render(); + }, + + render() { + const ul = $('charList'); + ul.innerHTML = ''; + $('charCount').textContent = `${this.list.length} / ${this.max}`; + + this.list.forEach((c, i) => { + const li = document.createElement('li'); + li.className = 'charcard' + (this.selected === c.id ? ' on' : ''); + li.tabIndex = 0; + li.innerHTML = ` + 0${i + 1} + + + + + `; + li.querySelector('.charname').textContent = `${c.firstName} ${c.lastName}`; + li.querySelector('.charmeta').textContent = this.meta(c); + li.querySelector('.charmoney').textContent = `$${(c.cash + c.bank).toLocaleString('en-US')}`; + + const pick = () => this.select(c.id); + li.addEventListener('click', pick); + li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } }); + ul.appendChild(li); + }); + + if (this.list.length < this.max) { + const li = document.createElement('li'); + li.className = 'charcard empty'; + li.tabIndex = 0; + li.innerHTML = `+Create a new identity`; + const go = () => Creator.open(); + li.addEventListener('click', go); + li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }); + ul.appendChild(li); + } + + $('charEnter').disabled = this.selected === null; + $('charDelete').disabled = this.selected === null; + }, + + meta(c) { + const hours = Math.floor((c.playtime || 0) / 3600); + const played = hours > 0 ? `${hours}h played` : 'never played'; + const job = c.job && c.job !== 'unemployed' ? c.job : 'no job'; + return `${c.dob} · ${job} · ${played}`; + }, + + select(id) { + this.selected = id; + this.render(); + showError($('charError'), null); + poke('char:preview', { id }); + }, + + async enter() { + if (this.selected === null) return; + const btn = $('charEnter'); + busy(btn, true); + const res = await post('char:select', { id: this.selected }); + busy(btn, false); + if (!res.ok) return showError($('charError'), res.error || 'Could not load that character.'); + Spawn.load(res.spawns || [], res.lastPosition, res.character); + showScreen('spawn'); + }, + + async remove() { + if (this.selected === null) return; + const c = this.list.find((x) => x.id === this.selected); + if (!c) return; + const btn = $('charDelete'); + + /* two-step, in place: the button becomes the confirmation */ + if (btn.dataset.armed !== '1') { + btn.dataset.armed = '1'; + btn.textContent = `Delete ${c.firstName}?`; + setTimeout(() => { + if (btn.dataset.armed === '1') { btn.dataset.armed = '0'; btn.textContent = 'Delete'; } + }, 4000); + return; + } + + btn.dataset.armed = '0'; + btn.textContent = 'Delete'; + busy(btn, true); + const res = await post('char:delete', { id: this.selected }); + busy(btn, false); + if (!res.ok) return showError($('charError'), res.error || 'Could not delete that character.'); + this.load(res.characters || [], this.max); + }, +}; + +/* ========================================================================= + 03 / creator + ========================================================================= */ + +const SECTIONS = ['heritage', 'face', 'hair', 'body', 'clothing', 'identity']; + +const Creator = { + appearance: null, + limits: null, + schema: null, + palettes: null, + section: 'heritage', + + init() { + $$('.tab').forEach((t) => t.addEventListener('click', () => this.go(t.dataset.section))); + $('creatorCancel').addEventListener('click', () => this.cancel()); + $('creatorPrev').addEventListener('click', () => this.step(-1)); + $('creatorNext').addEventListener('click', () => this.step(1)); + this.initDrag(); + }, + + async open() { + const res = await post('creator:start', { gender: 'm' }); + if (!res.ok) return showError($('charError'), 'Could not start the editor.'); + this.absorb(res); + this.section = 'heritage'; + this.paintTabs(); + this.renderAll(); + showScreen('creator'); + poke('creator:frame', { section: 'heritage' }); + }, + + absorb(res) { + this.appearance = res.appearance; + this.limits = res.limits; + this.schema = res.schema || this.schema; + this.palettes = res.palettes || this.palettes; + }, + + go(section) { + this.section = section; + this.paintTabs(); + poke('creator:frame', { section }); + }, + + paintTabs() { + $$('.tab').forEach((t) => t.classList.toggle('is-on', t.dataset.section === this.section)); + $$('.pane').forEach((p) => p.classList.toggle('is-on', p.dataset.section === this.section)); + const i = SECTIONS.indexOf(this.section); + $('creatorPrev').disabled = i === 0; + $('creatorNext').querySelector('.btn-label').textContent = + i === SECTIONS.length - 1 ? 'Submit application' : 'Next'; + }, + + step(dir) { + const i = SECTIONS.indexOf(this.section); + if (dir > 0 && i === SECTIONS.length - 1) return this.submit(); + const next = SECTIONS[Math.min(SECTIONS.length - 1, Math.max(0, i + dir))]; + this.go(next); + }, + + /* --- control builders ------------------------------------------------- */ + + slider(label, value, min, max, step, format, onInput) { + const wrap = document.createElement('div'); + wrap.className = 'ctrl'; + wrap.innerHTML = ` +
+ + +
+ `; + wrap.querySelector('.ctrl-label').textContent = label; + const val = wrap.querySelector('.ctrl-val'); + const input = wrap.querySelector('input'); + input.min = min; input.max = max; input.step = step; input.value = value; + val.textContent = format(value); + input.addEventListener('input', () => { + const v = Number(input.value); + val.textContent = format(v); + onInput(v); + }); + return wrap; + }, + + stepper(label, value, min, max, onChange) { + const wrap = document.createElement('div'); + wrap.className = 'ctrl'; + wrap.innerHTML = ` +
+ + +
+
+ + + +
`; + wrap.querySelector('.ctrl-label').textContent = label; + const range = wrap.querySelector('.ctrl-val'); + const out = wrap.querySelector('.stepval'); + let v = value; + let lo = min; + let hi = max; + + const paint = () => { + out.textContent = v < 0 ? 'None' : String(v); + range.textContent = hi <= lo ? '—' : `${Math.max(0, lo)}–${hi}`; + }; + const set = (next) => { + if (next < lo) next = hi; // wraps, so browsing a long list is quick + if (next > hi) next = lo; + v = next; + paint(); + onChange(v); + }; + /* Changing a garment changes how many variants it has; the variant control + is re-bounded in place rather than rebuilt. */ + const setBounds = (newMax, newValue) => { + hi = typeof newMax === 'number' ? newMax : hi; + if (typeof newValue === 'number') v = newValue; + if (v > hi) v = lo; + paint(); + }; + wrap.querySelectorAll('button')[0].addEventListener('click', () => set(v - 1)); + wrap.querySelectorAll('button')[1].addEventListener('click', () => set(v + 1)); + paint(); + return { el: wrap, set, setBounds, get value() { return v; } }; + }, + + swatches(label, palette, value, onPick) { + const wrap = document.createElement('div'); + wrap.className = 'ctrl'; + wrap.innerHTML = ` +
+
`; + wrap.querySelector('.ctrl-label').textContent = label; + const val = wrap.querySelector('.ctrl-val'); + const row = wrap.querySelector('.swatches'); + val.textContent = String(value); + + (palette || []).forEach((c, i) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'swatch' + (i === value ? ' on' : ''); + b.style.background = `rgb(${c.r}, ${c.g}, ${c.b})`; + b.title = `Colour ${i}`; + b.addEventListener('click', () => { + row.querySelectorAll('.swatch').forEach((s) => s.classList.remove('on')); + b.classList.add('on'); + val.textContent = String(i); + onPick(i); + }); + row.appendChild(b); + }); + return wrap; + }, + + /* --- panes ------------------------------------------------------------ */ + + renderAll() { + this.renderHeritage(); + this.renderFace(); + this.renderHair(); + this.renderBody(); + this.renderClothing(); + this.renderIdentity(); + }, + + pane(section) { + const el = document.querySelector(`.pane[data-section="${section}"]`); + el.innerHTML = ''; + return el; + }, + + renderHeritage() { + const p = this.pane('heritage'); + const a = this.appearance; + + const seg = document.createElement('div'); + seg.className = 'seg'; + seg.innerHTML = ` + + `; + seg.querySelectorAll('button').forEach((b) => { + b.classList.toggle('on', b.dataset.g === a.model); + b.addEventListener('click', async () => { + if (b.dataset.g === this.appearance.model) return; + const res = await post('creator:gender', { gender: b.dataset.g }); + if (!res.ok) return; + this.absorb(res); + this.renderAll(); + this.paintTabs(); + }); + }); + p.appendChild(seg); + + p.appendChild(this.slider('Father', a.parents.father, 0, 45, 1, (v) => `#${v}`, (v) => { + a.parents.father = v; + poke('creator:parent', { key: 'father', value: v }); + })); + p.appendChild(this.slider('Mother', a.parents.mother, 0, 45, 1, (v) => `#${v}`, (v) => { + a.parents.mother = v; + poke('creator:parent', { key: 'mother', value: v }); + })); + p.appendChild(this.slider('Resemblance', a.parents.shapeMix, 0, 1, 0.01, + (v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`, + (v) => { a.parents.shapeMix = v; poke('creator:parent', { key: 'shapeMix', value: v }); })); + p.appendChild(this.slider('Skin tone', a.parents.skinMix, 0, 1, 0.01, + (v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`, + (v) => { a.parents.skinMix = v; poke('creator:parent', { key: 'skinMix', value: v }); })); + }, + + renderFace() { + const p = this.pane('face'); + const a = this.appearance; + + (this.schema.features || []).forEach((f) => { + const key = String(f.id); + const v = a.features[key] || 0; + p.appendChild(this.slider(f.label, v, -1, 1, 0.01, + (x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`), + (x) => { a.features[key] = x; poke('creator:feature', { id: f.id, value: x }); })); + }); + + p.appendChild(this.slider('Eye colour', a.eyeColour, 0, 31, 1, (v) => `#${v}`, (v) => { + a.eyeColour = v; + poke('creator:eyes', { value: v }); + })); + + this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint !== 'hair')); + }, + + renderHair() { + const p = this.pane('hair'); + const a = this.appearance; + const maxHair = (this.limits && this.limits.hair) || 0; + + const st = this.stepper('Hair style', a.hair.style, 0, maxHair, (v) => { + a.hair.style = v; + poke('creator:hair', { style: v }); + }); + p.appendChild(st.el); + + p.appendChild(this.swatches('Hair colour', this.palettes && this.palettes.hair, a.hair.colour, (i) => { + a.hair.colour = i; + poke('creator:hair', { colour: i }); + })); + p.appendChild(this.swatches('Highlights', this.palettes && this.palettes.hair, a.hair.highlight, (i) => { + a.hair.highlight = i; + poke('creator:hair', { highlight: i }); + })); + + /* Only the hair-tinted overlays belong here; skin and make-up sit with + the face, which keeps either pane from becoming a wall of controls. */ + this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint === 'hair')); + }, + + overlayControls(p, list) { + const a = this.appearance; + list.forEach((o) => { + const ov = a.overlays[o.key] || { index: -1, opacity: 1, colour: 0 }; + const s = this.stepper(o.label, ov.index, -1, o.max, (v) => { + ov.index = v; + poke('creator:overlay', { key: o.key, index: v }); + }); + p.appendChild(s.el); + + p.appendChild(this.slider(`${o.label} strength`, ov.opacity, 0, 1, 0.05, + (v) => `${Math.round(v * 100)}%`, + (v) => { ov.opacity = v; poke('creator:overlay', { key: o.key, opacity: v }); })); + + if (o.tint) { + const pal = o.tint === 'hair' ? this.palettes.hair : this.palettes.makeup; + p.appendChild(this.swatches(`${o.label} colour`, pal, ov.colour, (i) => { + ov.colour = i; + poke('creator:overlay', { key: o.key, colour: i }); + })); + } + }); + }, + + renderBody() { + const p = this.pane('body'); + const a = this.appearance; + const note = document.createElement('p'); + note.className = 'sub'; + note.textContent = 'Build is carried by the heritage blend and these two proportions.'; + p.appendChild(note); + + ['13', '19'].forEach((id) => { + const f = (this.schema.features || []).find((x) => String(x.id) === id); + if (!f) return; + p.appendChild(this.slider(f.label, a.features[id] || 0, -1, 1, 0.01, + (x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`), + (x) => { a.features[id] = x; poke('creator:feature', { id: Number(id), value: x }); })); + }); + }, + + renderClothing() { + const p = this.pane('clothing'); + const a = this.appearance; + + (this.schema.components || []).forEach((c) => { + const cc = a.components[c.key] || { drawable: 0, texture: 0 }; + const lim = (this.limits.components && this.limits.components[c.key]) || { drawable: 0, texture: 0 }; + + const tex = this.stepper(`${c.label} variant`, cc.texture, 0, lim.texture, (v) => { + cc.texture = v; + poke('creator:component', { key: c.key, texture: v }); + }); + + const draw = this.stepper(c.label, cc.drawable, 0, lim.drawable, async (v) => { + cc.drawable = v; + const res = await post('creator:component', { key: c.key, drawable: v }); + if (res && res.ok) { + cc.texture = res.texture; + tex.setBounds(res.maxTexture, res.texture); + } + }); + + p.appendChild(draw.el); + p.appendChild(tex.el); + }); + + (this.schema.props || []).forEach((pr) => { + const pp = a.props[pr.key] || { drawable: -1, texture: 0 }; + const lim = (this.limits.props && this.limits.props[pr.key]) || { drawable: -1, texture: 0 }; + const s = this.stepper(pr.label, pp.drawable, -1, Math.max(-1, lim.drawable), (v) => { + pp.drawable = v; + poke('creator:prop', { key: pr.key, drawable: v }); + }); + p.appendChild(s.el); + }); + }, + + renderIdentity() { + const p = this.pane('identity'); + p.innerHTML = ` +
+ + +

The name people will call you.

+
+
+ + +

Family name. Must be unique on this server.

+
+
+ + +

YYYY-MM-DD

+
+
+ + +

0 / 2000

+
`; + + $('firstName').addEventListener('input', () => { + const [ok, msg] = check.name($('firstName').value); + mark('firstName', ok, msg || 'The name people will call you.'); + }); + $('lastName').addEventListener('input', () => { + const [ok, msg] = check.name($('lastName').value); + mark('lastName', ok, msg || 'Family name. Must be unique on this server.'); + }); + $('dob').addEventListener('input', () => { + const [ok, msg] = check.dob($('dob').value); + mark('dob', ok, msg || 'YYYY-MM-DD'); + }); + $('backstory').addEventListener('input', () => { + $('storyCount').textContent = String($('backstory').value.length); + }); + }, + + /* --- drag to turn ----------------------------------------------------- */ + + initDrag() { + const tt = $('turntable'); + let dragging = false; + let lastX = 0; + let pending = 0; + let frame = null; + + const flush = () => { + frame = null; + if (pending !== 0) { + poke('creator:rotate', { delta: pending }); + pending = 0; + } + }; + + tt.addEventListener('pointerdown', (e) => { + dragging = true; + lastX = e.clientX; + tt.classList.add('dragging'); + tt.setPointerCapture(e.pointerId); + }); + tt.addEventListener('pointermove', (e) => { + if (!dragging) return; + pending += (e.clientX - lastX) * 0.42; + lastX = e.clientX; + if (!frame) frame = requestAnimationFrame(flush); + }); + const stop = (e) => { + if (!dragging) return; + dragging = false; + tt.classList.remove('dragging'); + try { tt.releasePointerCapture(e.pointerId); } catch (_) {} + }; + tt.addEventListener('pointerup', stop); + tt.addEventListener('pointercancel', stop); + }, + + async cancel() { + await post('creator:cancel', {}); + showScreen('characters'); + Characters.render(); + }, + + async submit() { + const first = $('firstName') ? $('firstName').value.trim() : ''; + const last = $('lastName') ? $('lastName').value.trim() : ''; + const dob = $('dob') ? $('dob').value.trim() : ''; + + if (this.section !== 'identity') this.go('identity'); + + const [fOk, fMsg] = check.name(first); + if (fOk !== true) { mark('firstName', false, fMsg || 'Required.'); $('firstName').focus(); return; } + const [lOk, lMsg] = check.name(last); + if (lOk !== true) { mark('lastName', false, lMsg || 'Required.'); $('lastName').focus(); return; } + const [dOk, dMsg] = check.dob(dob); + if (dOk !== true) { mark('dob', false, dMsg || 'Required.'); $('dob').focus(); return; } + + const btn = $('creatorNext'); + busy(btn, true); + showError($('creatorError'), null); + + const res = await post('creator:submit', { + firstName: first, + lastName: last, + dob, + backstory: $('backstory').value, + }); + busy(btn, false); + + if (!res.ok) return showError($('creatorError'), res.error || 'That application was refused.'); + + stamp('Approved'); + Characters.load(res.characters || [], Characters.max); + showScreen('characters'); + }, +}; + +/* ========================================================================= + 04 / spawn + ========================================================================= */ + +const Spawn = { + spawns: [], + selected: null, + last: null, + + init() { + $('spawnConfirm').addEventListener('click', () => this.confirm()); + }, + + load(spawns, lastPosition, character) { + this.spawns = spawns || []; + this.last = lastPosition || null; + this.selected = null; + if (character) { + $('spawnSub').textContent = + `${character.firstName} ${character.lastName}. Pick a district - you can move once you are on the ground.`; + } + this.render(); + }, + + render() { + const ul = $('spawnList'); + const pins = $('pins'); + ul.innerHTML = ''; + pins.innerHTML = ''; + + const entries = this.spawns.slice(); + if (this.last) { + entries.unshift({ + id: 'last', + label: 'Where you left off', + area: 'Last known position', + blurb: 'Pick up exactly where this character stopped.', + map: null, + }); + } + + entries.forEach((sp) => { + const li = document.createElement('li'); + li.className = 'spawncard' + (this.selected === sp.id ? ' on' : ''); + li.tabIndex = 0; + li.innerHTML = ` +
+ + +
+

`; + li.querySelector('.spawnname').textContent = sp.label; + li.querySelector('.spawnarea').textContent = sp.area; + li.querySelector('.spawnblurb').textContent = sp.blurb; + + const pick = () => this.select(sp.id); + li.addEventListener('click', pick); + li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } }); + ul.appendChild(li); + + if (sp.map) { + const pin = document.createElement('div'); + pin.className = 'pin' + (this.selected === sp.id ? ' on' : '') + (sp.map.x > 0.6 ? ' flip' : ''); + pin.style.left = `${sp.map.x * 100}%`; + pin.style.top = `${sp.map.y * 100}%`; + pin.innerHTML = ''; + pin.querySelector('.pin-label').textContent = sp.label; + pins.appendChild(pin); + } + }); + + $('spawnConfirm').disabled = this.selected === null; + }, + + select(id) { + this.selected = id; + this.render(); + showError($('spawnError'), null); + if (id !== 'last') poke('spawn:preview', { id }); + }, + + async confirm() { + if (this.selected === null) return; + const btn = $('spawnConfirm'); + busy(btn, true); + const res = await post('spawn:confirm', { id: this.selected }); + busy(btn, false); + if (!res.ok) return showError($('spawnError'), res.error || 'Could not place you there.'); + stamp('Issued'); + }, +}; + +/* ========================================================================= + messages from the client script + ========================================================================= */ + +window.addEventListener('message', (event) => { + const msg = event.data || {}; + + if (msg.action === 'stage') { + if (msg.stage === 'auth') { + const d = msg.data || {}; + if (d.serverName) $('markName').textContent = d.serverName; + if (d.serverTag) $('markTag').textContent = d.serverTag; + $('fileSerial').textContent = fileSerial(); + showScreen('auth'); + setTimeout(() => $('username').focus(), 520); + } else if (msg.stage === 'flying' || msg.stage === 'live') { + /* the camera is doing the talking now */ + SCREENS.forEach((s) => { const el = $(`screen-${s}`); if (el) el.hidden = true; }); + document.body.classList.add('hidden'); + } else { + showScreen(msg.stage); + } + } + + if (msg.action === 'creator:sync' && msg.data) { + Creator.absorb(msg.data); + Creator.renderAll(); + } +}); + +function fileSerial() { + const y = new Date().getFullYear(); + const n = String(Math.floor(Math.random() * 900000) + 100000); + return `${y}-${n}`; +} + +/* Escape backs out of the creator; nothing else steals keys from the game. */ +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && current === 'creator') Creator.cancel(); +}); + +document.addEventListener('DOMContentLoaded', () => { + Auth.init(); + Characters.init(); + Creator.init(); + Spawn.init(); + Auth.toggle(); /* start on "sign in"; the copy makes registering obvious */ +}); diff --git a/server-code/data/resources/[rp]/rp_ui/html/fonts/archivo.woff2 b/server-code/data/resources/[rp]/rp_ui/html/fonts/archivo.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..716d4abd199a236dc4b9e112f63429b9b47ce911 GIT binary patch literal 34928 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 E05fT^n*aa+ literal 0 HcmV?d00001 diff --git a/server-code/data/resources/[rp]/rp_ui/html/fonts/plex-400.woff2 b/server-code/data/resources/[rp]/rp_ui/html/fonts/plex-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0804aaff92260b5359330f63e05b9ac25ccabeee GIT binary patch literal 14708 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 + + + +Los Santos RP + + + + + + + + + +
+
+ LOS SANTOS + ROLEPLAY +
+ + + +
+ FILE 0000-000000 +
+
+ + + + + + + + + + + + + + + + + + + diff --git a/server-code/data/resources/[rp]/rp_ui/html/tokens.css b/server-code/data/resources/[rp]/rp_ui/html/tokens.css new file mode 100644 index 0000000..cd88d8c --- /dev/null +++ b/server-code/data/resources/[rp]/rp_ui/html/tokens.css @@ -0,0 +1,117 @@ +/* --------------------------------------------------------------------------- + Los Santos RP - design tokens. + + The interface is styled as a municipal record being opened on you: strict + grid, hairline rules, monospaced serials, and one accent taken from the only + light source the city has at night - sodium street lamps. + Shared verbatim by rp_loading and rp_ui so the four screens read as one + product. + --------------------------------------------------------------------------- */ + +@font-face { + font-family: 'Archivo'; + src: url('fonts/archivo.woff2') format('woff2-variations'); + font-weight: 100 900; + font-display: block; +} +@font-face { + font-family: 'Plex Mono'; + src: url('fonts/plex-400.woff2') format('woff2'); + font-weight: 400; + font-display: block; +} +@font-face { + font-family: 'Plex Mono'; + src: url('fonts/plex-500.woff2') format('woff2'); + font-weight: 500; + font-display: block; +} + +:root { + /* asphalt at night - cold, never pure black */ + --ink-900: #08090b; + --ink-800: #0d0f13; + --ink-700: #14171d; + --ink-600: #1d212a; + --ink-500: #272c37; + + /* document stock */ + --paper: #e8e4dc; + --paper-dim: #a9a69f; + --muted: #6e747f; + + /* the accent: sodium vapour, the colour of the city from the air */ + --sodium: #ff9f45; + --sodium-soft: rgba(255, 159, 69, 0.14); + --sodium-line: rgba(255, 159, 69, 0.42); + --sodium-deep: #c4711f; + + /* semantic only, never decorative */ + --stamp: #c4462f; + --good: #7f9f5a; + + --line: rgba(232, 228, 220, 0.13); + --line-soft: rgba(232, 228, 220, 0.07); + + --rail: clamp(400px, 31vw, 520px); + + --gut-x: clamp(32px, 3.4vw, 56px); + --gut-y: clamp(28px, 3vw, 48px); + + --ease: cubic-bezier(0.22, 0.61, 0.36, 1); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + overflow: hidden; + background: var(--ink-900); + color: var(--paper); + font-family: 'Archivo', system-ui, sans-serif; + font-weight: 400; + font-size: 15px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* Typographic roles --------------------------------------------------------- */ + +.eyebrow { + font-family: 'Plex Mono', monospace; + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--muted); +} + +.data { + font-family: 'Plex Mono', monospace; + font-size: 12px; + letter-spacing: 0.04em; + color: var(--paper-dim); + font-variant-numeric: tabular-nums; +} + +.rule { + height: 1px; + background: var(--line); + border: 0; +} + +/* Focus is always visible and always the accent. */ +:focus-visible { + outline: 2px solid var(--sodium); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/server-code/etc/rpdata.env.example b/server-code/etc/rpdata.env.example new file mode 100644 index 0000000..ae7ea6d --- /dev/null +++ b/server-code/etc/rpdata.env.example @@ -0,0 +1,4 @@ +RP_DB=/opt/fivem/datasvc/rp.db +RP_TOKEN=generate_your_own_random_token +RP_BIND=127.0.0.1 +RP_PORT=7788 diff --git a/server-code/etc/schema.sql b/server-code/etc/schema.sql new file mode 100644 index 0000000..31e5b8a --- /dev/null +++ b/server-code/etc/schema.sql @@ -0,0 +1,105 @@ +-- --------------------------------------------------------------------------- +-- Los Santos RP - authoritative schema +-- Everything the server considers truth lives here. Nothing gameplay-relevant +-- is stored client-side. +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS accounts ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(24) NOT NULL, + password_hash VARCHAR(255) NOT NULL, -- scrypt$N$r$p$salt_b64$hash_b64 + role ENUM('player','mod','admin','owner') NOT NULL DEFAULT 'player', + license VARCHAR(64) NULL, -- Cfx license: bound on first login + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login_at DATETIME NULL, + last_ip VARCHAR(45) NULL, + banned TINYINT(1) NOT NULL DEFAULT 0, + ban_reason VARCHAR(255) NULL, + ban_until DATETIME NULL, -- NULL + banned=1 => permanent + PRIMARY KEY (id), + UNIQUE KEY uq_accounts_username (username), + KEY idx_accounts_license (license) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Brute-force throttling. One row per (identity, window); pruned by the server. +CREATE TABLE IF NOT EXISTS auth_attempts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + identity VARCHAR(64) NOT NULL, -- license or username, lowercased + ip VARCHAR(45) NULL, + success TINYINT(1) NOT NULL DEFAULT 0, + at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_attempts_identity_at (identity, at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS characters ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + account_id INT UNSIGNED NOT NULL, + slot TINYINT UNSIGNED NOT NULL DEFAULT 0, + first_name VARCHAR(24) NOT NULL, + last_name VARCHAR(24) NOT NULL, + dob DATE NOT NULL, + gender ENUM('m','f') NOT NULL DEFAULT 'm', + backstory TEXT NULL, + appearance MEDIUMTEXT NULL, -- JSON: model, face blend, features, overlays, clothing + position VARCHAR(128) NULL, -- JSON: {x,y,z,h} + cash BIGINT NOT NULL DEFAULT 500, + bank BIGINT NOT NULL DEFAULT 2500, + health SMALLINT NOT NULL DEFAULT 200, + armour SMALLINT NOT NULL DEFAULT 0, + job VARCHAR(32) NOT NULL DEFAULT 'unemployed', + job_grade TINYINT UNSIGNED NOT NULL DEFAULT 0, + needs VARCHAR(128) NULL, -- JSON: {hunger,thirst,stress} + playtime INT UNSIGNED NOT NULL DEFAULT 0, -- seconds + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_played_at DATETIME NULL, + deleted TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uq_char_name (first_name, last_name), + KEY idx_char_account (account_id, deleted), + CONSTRAINT fk_char_account FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Every money movement is journalled; cash/bank on `characters` is the running total. +CREATE TABLE IF NOT EXISTS transactions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + character_id INT UNSIGNED NOT NULL, + account_kind ENUM('cash','bank') NOT NULL, + delta BIGINT NOT NULL, + balance_after BIGINT NOT NULL, + reason VARCHAR(64) NOT NULL, + at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_tx_character (character_id, at), + CONSTRAINT fk_tx_character FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS inventory ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + owner_kind ENUM('character','stash','ground','vehicle') NOT NULL, + owner_id VARCHAR(64) NOT NULL, -- character id, stash key, drop uuid, plate + slot SMALLINT UNSIGNED NOT NULL, + item VARCHAR(48) NOT NULL, + qty INT UNSIGNED NOT NULL, + meta VARCHAR(512) NULL, -- JSON: durability, serial, ... + PRIMARY KEY (id), + UNIQUE KEY uq_inv_slot (owner_kind, owner_id, slot), + KEY idx_inv_owner (owner_kind, owner_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS vehicles ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + character_id INT UNSIGNED NOT NULL, + plate VARCHAR(12) NOT NULL, + model VARCHAR(48) NOT NULL, + props MEDIUMTEXT NULL, -- JSON: colours, mods, damage + fuel FLOAT NOT NULL DEFAULT 100, + garage VARCHAR(32) NOT NULL DEFAULT 'legion', + stored TINYINT(1) NOT NULL DEFAULT 1, + impounded TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_veh_plate (plate), + KEY idx_veh_character (character_id), + CONSTRAINT fk_veh_character FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server-code/etc/secret.cfg.example b/server-code/etc/secret.cfg.example new file mode 100644 index 0000000..9ced822 --- /dev/null +++ b/server-code/etc/secret.cfg.example @@ -0,0 +1 @@ +sv_licenseKey "CFX_LICENSE_KEY=cfxk_your_key_here"