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:
2026-08-06 19:27:48 +00:00
co-authored by Claude Fable 5
parent cb0649016c
commit 80c1d75d2f
41 changed files with 6801 additions and 0 deletions
@@ -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