Part 1 final build: Los Santos RP written on camera by the agent
rp_core (sessions, scrypt auth, self-written MySQL-wire driver rp_db), county-records NUI: residency-file auth, identity-record character intake, survey-grid spawn, procedural night-city loading screen. Secrets replaced with .example files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <command> # 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.
|
||||||
@@ -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)
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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$<salt b64>$<derived key b64>
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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'));
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Los Santos Roleplay</title>
|
||||||
|
<link rel="stylesheet" href="tokens.css">
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- procedural night-city flyover; no external assets -->
|
||||||
|
<canvas id="sky" aria-hidden="true"></canvas>
|
||||||
|
<div class="grain" aria-hidden="true"></div>
|
||||||
|
<div class="scrim" aria-hidden="true"></div>
|
||||||
|
<div class="vignette" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<main class="rail">
|
||||||
|
|
||||||
|
<header class="file reveal" style="--d:0ms">
|
||||||
|
<div class="eyebrow">City of Los Santos · Office of Records</div>
|
||||||
|
<div class="serial data">
|
||||||
|
FILE <span id="serial">0000-0000</span> / RESIDENCY APPLICATION
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="brand">
|
||||||
|
<h1 class="wordmark" id="wordmark">LOS SANTOS</h1>
|
||||||
|
<div class="tagrow reveal" style="--d:520ms">
|
||||||
|
<span class="tagline">ROLEPLAY</span>
|
||||||
|
<span class="dash" aria-hidden="true"></span>
|
||||||
|
<span class="data" id="hostcount">Connecting</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="notices reveal" style="--d:760ms" aria-live="polite">
|
||||||
|
<div class="notice-head">
|
||||||
|
<span class="eyebrow">Posted notice</span>
|
||||||
|
<span class="notice-index data" id="noticeIndex">1 / 5</span>
|
||||||
|
</div>
|
||||||
|
<div class="notice" id="notice">
|
||||||
|
<h2 class="notice-title" id="noticeTitle">Stay in character</h2>
|
||||||
|
<p class="notice-body" id="noticeBody">Loading the city.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="load reveal" style="--d:940ms">
|
||||||
|
<div class="loadhead">
|
||||||
|
<span class="step data" id="step">Waiting for the game</span>
|
||||||
|
<span class="pct" id="pct">0<i>%</i></span>
|
||||||
|
</div>
|
||||||
|
<div class="track"><i class="bar" id="bar"></i></div>
|
||||||
|
|
||||||
|
<div class="fader">
|
||||||
|
<button class="mute" id="mute" type="button" aria-label="Mute ambience">
|
||||||
|
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
|
||||||
|
<path id="spk" d="M3 6h2.5L9 3v10L5.5 10H3z" fill="currentColor"/>
|
||||||
|
<path id="wave" d="M11 5.6a3.4 3.4 0 0 1 0 4.8M12.9 3.7a6 6 0 0 1 0 8.6"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<label class="sr-only" for="vol">Ambience volume</label>
|
||||||
|
<input id="vol" type="range" min="0" max="100" value="45" step="1">
|
||||||
|
<span class="volval data" id="volval">45</span>
|
||||||
|
</div>
|
||||||
|
<p class="audiohint data" id="audiohint" hidden>Click anywhere to start the ambience</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<span class="charslot">0${i + 1}</span>
|
||||||
|
<span>
|
||||||
|
<span class="charname"></span>
|
||||||
|
<span class="charmeta"></span>
|
||||||
|
</span>
|
||||||
|
<span class="charmoney"></span>`;
|
||||||
|
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 = `<span class="charslot">+</span><span class="charname">Create a new identity</span>`;
|
||||||
|
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 = `
|
||||||
|
<div class="ctrl-head">
|
||||||
|
<span class="ctrl-label"></span>
|
||||||
|
<span class="ctrl-val"></span>
|
||||||
|
</div>
|
||||||
|
<input class="slider" type="range">`;
|
||||||
|
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 = `
|
||||||
|
<div class="ctrl-head">
|
||||||
|
<span class="ctrl-label"></span>
|
||||||
|
<span class="ctrl-val"></span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper">
|
||||||
|
<button type="button" aria-label="Previous">−</button>
|
||||||
|
<span class="stepval"></span>
|
||||||
|
<button type="button" aria-label="Next">+</button>
|
||||||
|
</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="ctrl-head"><span class="ctrl-label"></span><span class="ctrl-val"></span></div>
|
||||||
|
<div class="swatches"></div>`;
|
||||||
|
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 = `
|
||||||
|
<button type="button" data-g="m">Male</button>
|
||||||
|
<button type="button" data-g="f">Female</button>`;
|
||||||
|
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 = `
|
||||||
|
<div class="field" data-field="firstName">
|
||||||
|
<label for="firstName">First name</label>
|
||||||
|
<input id="firstName" type="text" maxlength="20" spellcheck="false" placeholder="Danielle">
|
||||||
|
<p class="hint">The name people will call you.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field" data-field="lastName">
|
||||||
|
<label for="lastName">Last name</label>
|
||||||
|
<input id="lastName" type="text" maxlength="20" spellcheck="false" placeholder="Larue">
|
||||||
|
<p class="hint">Family name. Must be unique on this server.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field" data-field="dob">
|
||||||
|
<label for="dob">Date of birth</label>
|
||||||
|
<input id="dob" type="text" maxlength="10" spellcheck="false" placeholder="1994-07-12">
|
||||||
|
<p class="hint">YYYY-MM-DD</p>
|
||||||
|
</div>
|
||||||
|
<div class="field" data-field="backstory">
|
||||||
|
<label for="backstory">Backstory</label>
|
||||||
|
<textarea id="backstory" maxlength="2000"
|
||||||
|
placeholder="Where were they before Los Santos, and why did they leave?"></textarea>
|
||||||
|
<p class="counter"><span id="storyCount">0</span> / 2000</p>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
$('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 = `
|
||||||
|
<div class="spawnhead">
|
||||||
|
<span class="spawnname"></span>
|
||||||
|
<span class="spawnarea"></span>
|
||||||
|
</div>
|
||||||
|
<p class="spawnblurb"></p>`;
|
||||||
|
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 = '<i class="pin-mark"></i><span class="pin-label"></span>';
|
||||||
|
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 */
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,241 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Los Santos RP</title>
|
||||||
|
<link rel="stylesheet" href="tokens.css">
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
</head>
|
||||||
|
<body class="hidden">
|
||||||
|
|
||||||
|
<div class="edge" aria-hidden="true"></div>
|
||||||
|
<div class="grain" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<!-- ======================= persistent chrome ======================= -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="mark">
|
||||||
|
<span class="mark-name" id="markName">LOS SANTOS</span>
|
||||||
|
<span class="mark-tag" id="markTag">ROLEPLAY</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="stages" aria-label="Application progress">
|
||||||
|
<button class="stagechip" data-stage="auth" type="button" disabled>
|
||||||
|
<em>01</em><span>Credentials</span>
|
||||||
|
</button>
|
||||||
|
<i class="stagelink" aria-hidden="true"></i>
|
||||||
|
<button class="stagechip" data-stage="characters" type="button" disabled>
|
||||||
|
<em>02</em><span>Identity</span>
|
||||||
|
</button>
|
||||||
|
<i class="stagelink" aria-hidden="true"></i>
|
||||||
|
<button class="stagechip" data-stage="spawn" type="button" disabled>
|
||||||
|
<em>03</em><span>Placement</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="filetag data">
|
||||||
|
FILE <span id="fileSerial">0000-000000</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ======================= 02 / auth ======================= -->
|
||||||
|
<section class="screen" id="screen-auth" hidden>
|
||||||
|
<div class="rail">
|
||||||
|
<div class="rail-scrim" aria-hidden="true"></div>
|
||||||
|
<div class="rail-inner">
|
||||||
|
|
||||||
|
<div class="kicker">
|
||||||
|
<span class="eyebrow">Office of Records</span>
|
||||||
|
<span class="hair" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="head" id="authTitle">Open a file</h1>
|
||||||
|
<p class="sub" id="authSub">
|
||||||
|
Everything you do in this city is recorded against this file. Choose a name you will
|
||||||
|
remember and a password you will not share.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="form" id="authForm" novalidate>
|
||||||
|
<div class="field" data-field="username">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" type="text" autocomplete="username"
|
||||||
|
spellcheck="false" maxlength="20" placeholder="e.g. dlarue">
|
||||||
|
<p class="hint" id="hint-username">3–20 characters. Letters, numbers and underscore.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" data-field="password">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<div class="withbtn">
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password"
|
||||||
|
maxlength="72" placeholder="At least 8 characters">
|
||||||
|
<button class="reveal-btn" id="revealPw" type="button" aria-label="Show password">Show</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="hint-password">At least 8 characters, with a letter and a number.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- only present while registering; height animates, nothing reloads -->
|
||||||
|
<div class="collapse" id="confirmWrap" aria-hidden="true">
|
||||||
|
<div class="collapse-inner">
|
||||||
|
<div class="field" data-field="confirm">
|
||||||
|
<label for="confirm">Repeat password</label>
|
||||||
|
<input id="confirm" name="confirm" type="password" autocomplete="new-password"
|
||||||
|
maxlength="72" placeholder="Type it once more">
|
||||||
|
<p class="hint" id="hint-confirm">Both entries must match.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="formerror" id="authError" role="alert" aria-live="assertive" hidden></p>
|
||||||
|
|
||||||
|
<button class="btn primary" id="authSubmit" type="submit">
|
||||||
|
<span class="btn-label">Open the file</span>
|
||||||
|
<span class="btn-spin" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="switch">
|
||||||
|
<span id="switchText">Already have a file?</span>
|
||||||
|
<button class="linkbtn" id="switchMode" type="button">Sign in instead</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================= 03 / characters ======================= -->
|
||||||
|
<section class="screen" id="screen-characters" hidden>
|
||||||
|
<div class="rail wide">
|
||||||
|
<div class="rail-scrim" aria-hidden="true"></div>
|
||||||
|
<div class="rail-inner">
|
||||||
|
|
||||||
|
<div class="kicker">
|
||||||
|
<span class="eyebrow">Registered identities</span>
|
||||||
|
<span class="hair" aria-hidden="true"></span>
|
||||||
|
<span class="data" id="charCount">0 / 3</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="head">Who are you today?</h1>
|
||||||
|
|
||||||
|
<ul class="charlist" id="charList"></ul>
|
||||||
|
|
||||||
|
<div class="charactions">
|
||||||
|
<button class="btn primary" id="charEnter" type="button" disabled>
|
||||||
|
<span class="btn-label">Enter the city</span>
|
||||||
|
<span class="btn-spin" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<button class="btn ghost" id="charDelete" type="button" disabled>Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="formerror" id="charError" role="alert" aria-live="assertive" hidden></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================= 04 / creator ======================= -->
|
||||||
|
<section class="screen" id="screen-creator" hidden>
|
||||||
|
<div class="rail wide">
|
||||||
|
<div class="rail-scrim" aria-hidden="true"></div>
|
||||||
|
<div class="rail-inner creator">
|
||||||
|
|
||||||
|
<div class="kicker">
|
||||||
|
<span class="eyebrow">New identity</span>
|
||||||
|
<span class="hair" aria-hidden="true"></span>
|
||||||
|
<button class="linkbtn" id="creatorCancel" type="button">Discard</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="tabs" id="creatorTabs" aria-label="Editor sections">
|
||||||
|
<button class="tab is-on" data-section="heritage" type="button">Heritage</button>
|
||||||
|
<button class="tab" data-section="face" type="button">Face</button>
|
||||||
|
<button class="tab" data-section="hair" type="button">Hair</button>
|
||||||
|
<button class="tab" data-section="body" type="button">Body</button>
|
||||||
|
<button class="tab" data-section="clothing" type="button">Clothing</button>
|
||||||
|
<button class="tab" data-section="identity" type="button">Identity</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="panes" id="creatorPanes">
|
||||||
|
<div class="pane is-on" data-section="heritage"></div>
|
||||||
|
<div class="pane" data-section="face"></div>
|
||||||
|
<div class="pane" data-section="hair"></div>
|
||||||
|
<div class="pane" data-section="body"></div>
|
||||||
|
<div class="pane" data-section="clothing"></div>
|
||||||
|
<div class="pane" data-section="identity"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="creator-foot">
|
||||||
|
<button class="btn ghost" id="creatorPrev" type="button">Back</button>
|
||||||
|
<button class="btn primary" id="creatorNext" type="button">
|
||||||
|
<span class="btn-label">Next</span>
|
||||||
|
<span class="btn-spin" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="formerror" id="creatorError" role="alert" aria-live="assertive" hidden></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="turntable" id="turntable" title="Drag to turn">
|
||||||
|
<div class="turnhint data"><span class="turnicon" aria-hidden="true"></span>Drag to turn</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================= 05 / spawn ======================= -->
|
||||||
|
<section class="screen" id="screen-spawn" hidden>
|
||||||
|
<div class="rail">
|
||||||
|
<div class="rail-scrim" aria-hidden="true"></div>
|
||||||
|
<div class="rail-inner">
|
||||||
|
|
||||||
|
<div class="kicker">
|
||||||
|
<span class="eyebrow">Placement</span>
|
||||||
|
<span class="hair" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="head">Where does the day start?</h1>
|
||||||
|
<p class="sub" id="spawnSub">Pick a district. You can move once you are on the ground.</p>
|
||||||
|
|
||||||
|
<ul class="spawnlist" id="spawnList"></ul>
|
||||||
|
|
||||||
|
<button class="btn primary" id="spawnConfirm" type="button" disabled>
|
||||||
|
<span class="btn-label">Place me here</span>
|
||||||
|
<span class="btn-spin" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<p class="formerror" id="spawnError" role="alert" aria-live="assertive" hidden></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- survey chart: a simplified plan of the county, not a screenshot -->
|
||||||
|
<div class="chart" id="chart">
|
||||||
|
<svg viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M50 0 L0 0 0 50" fill="none" stroke="rgba(232,228,220,.055)" stroke-width="1"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1000" height="1000" fill="url(#grid)"/>
|
||||||
|
|
||||||
|
<!-- chart furniture: this is a survey sheet, not a screenshot -->
|
||||||
|
<rect class="chart-frame" x="34" y="34" width="932" height="932"/>
|
||||||
|
<path class="chart-ticks"
|
||||||
|
d="M34 96 L34 34 L96 34 M904 34 L966 34 L966 96
|
||||||
|
M966 904 L966 966 L904 966 M96 966 L34 966 L34 904"/>
|
||||||
|
<text class="chart-label" x="34" y="1000">Los Santos County</text>
|
||||||
|
|
||||||
|
<path id="landmass"
|
||||||
|
d="M318 62 L470 44 L604 74 L712 66 L806 128 L858 248 L872 372
|
||||||
|
L842 468 L868 556 L826 664 L742 742 L648 812 L560 872
|
||||||
|
L462 918 L392 902 L342 836 L296 742 L262 640 L240 528
|
||||||
|
L206 424 L196 320 L232 196 L272 108 Z"
|
||||||
|
fill="rgba(255,159,69,.04)" stroke="rgba(255,159,69,.30)" stroke-width="1.5"/>
|
||||||
|
<!-- the western coastline is the one edge worth drawing emphatically -->
|
||||||
|
<path id="coastline"
|
||||||
|
d="M262 640 L240 528 L206 424 L196 320 L232 196 L272 108 L318 62"
|
||||||
|
fill="none" stroke="rgba(255,159,69,.58)" stroke-width="2.5"/>
|
||||||
|
</svg>
|
||||||
|
<div class="pins" id="pins"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- the one bold moment: the city stamps your file -->
|
||||||
|
<div class="stamp" id="stamp" aria-hidden="true"><span id="stampText">FILED</span></div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
sv_licenseKey "CFX_LICENSE_KEY=cfxk_your_key_here"
|
||||||
Reference in New Issue
Block a user