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 0000000..716d4ab Binary files /dev/null and b/server-code/data/resources/[rp]/rp_loading/html/fonts/archivo.woff2 differ 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 0000000..0804aaf Binary files /dev/null and b/server-code/data/resources/[rp]/rp_loading/html/fonts/plex-400.woff2 differ diff --git a/server-code/data/resources/[rp]/rp_loading/html/fonts/plex-500.woff2 b/server-code/data/resources/[rp]/rp_loading/html/fonts/plex-500.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/server-code/data/resources/[rp]/rp_loading/html/fonts/plex-500.woff2 differ diff --git a/server-code/data/resources/[rp]/rp_loading/html/index.html b/server-code/data/resources/[rp]/rp_loading/html/index.html new file mode 100644 index 0000000..289cd0e --- /dev/null +++ b/server-code/data/resources/[rp]/rp_loading/html/index.html @@ -0,0 +1,72 @@ + + + + +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 0000000..716d4ab Binary files /dev/null and b/server-code/data/resources/[rp]/rp_ui/html/fonts/archivo.woff2 differ 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 0000000..0804aaf Binary files /dev/null and b/server-code/data/resources/[rp]/rp_ui/html/fonts/plex-400.woff2 differ diff --git a/server-code/data/resources/[rp]/rp_ui/html/fonts/plex-500.woff2 b/server-code/data/resources/[rp]/rp_ui/html/fonts/plex-500.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/server-code/data/resources/[rp]/rp_ui/html/fonts/plex-500.woff2 differ diff --git a/server-code/data/resources/[rp]/rp_ui/html/index.html b/server-code/data/resources/[rp]/rp_ui/html/index.html new file mode 100644 index 0000000..81f5543 --- /dev/null +++ b/server-code/data/resources/[rp]/rp_ui/html/index.html @@ -0,0 +1,241 @@ + + + + +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"