The server itself: repo now mirrors the machine it runs on, plus install.sh
The repository carried two different servers side by side - the early
`justrp` prototype with its Python auth API, and a snapshot of the real
one under `server-code/`. Only the second one is a server anyone should
start from, so the prototype is gone and the real one moved to the root.
Taken from the live box, so the UI is the finished version (the tokens,
the county-records sheets and the variable fonts landed after the last
snapshot was pushed):
resources/[rp]/ rp_db, rp_core, rp_session, rp_loading, rp_ui,
rp_selftest, rp_dbtest
bin/ supervise.sh (keep-alive + console FIFO), rcon, init script
etc/schema.sql accounts, characters, transactions, inventory, vehicles
assets/fonts/ the bundled subsets
docs/SERVER.md how it is put together, resource by resource
install.sh turns a clean Ubuntu/Debian box into this server in one
command: recommended FXServer build, MariaDB with the schema, resources,
server.cfg + a generated database password, boot entry (systemd or
init.d), then it waits for the Cfx registration. --name/--port/--db-name
let a second server live on the same machine. Tested end to end on a
spare install root: build 25770 fetched, schema applied, boot entry
written, FXServer started and stopped exactly where a wrong licence key
should stop it.
No secrets travel with it: the licence key and the database password live
in data/secrets.cfg on the machine, and .gitignore now names it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
80c1d75d2f
commit
71856b15e9
@@ -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
|
||||
Reference in New Issue
Block a user