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,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