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:
Claude Opus 5
2026-08-12 23:23:33 +00:00
co-authored by Claude Opus 5
parent 80c1d75d2f
commit 71856b15e9
66 changed files with 2030 additions and 3814 deletions
+508
View File
@@ -0,0 +1,508 @@
-- ---------------------------------------------------------------------------
-- rp_ui - the pre-spawn experience.
--
-- Owns the stage machine (auth -> characters -> creator -> spawn -> live),
-- the NUI bridge, and the transition into the world. The server decides
-- everything that matters; this file only asks and then animates the answer.
-- ---------------------------------------------------------------------------
local stage = 'boot'
local account = nil
local characters = {}
local draft = nil -- appearance currently being edited
local previewPed = nil
local spawnData = nil -- payload from char:select
local chosenChar = nil
local NEUTRAL = vector3(-1037.0, -2738.0, 20.17) -- flat, always-streamed tarmac
-- ---------------------------------------------------------------------------
-- helpers
-- ---------------------------------------------------------------------------
local function ui(payload)
SendNUIMessage(payload)
end
local function focusUI(on)
SetNuiFocus(on, on)
SetNuiFocusKeepInput(false)
end
local function holdPlayer()
local ped = PlayerPedId()
SetEntityVisible(ped, false, false)
SetEntityCollision(ped, false, false)
FreezeEntityPosition(ped, true)
SetEntityInvincible(ped, true)
SetPlayerControl(PlayerId(), false, 0)
SetPlayerInvincible(PlayerId(), true)
DisplayHud(false)
DisplayRadar(false)
ClearPedTasksImmediately(ped)
end
local function releasePlayer()
local ped = PlayerPedId()
SetEntityVisible(ped, true, false)
SetEntityCollision(ped, true, true)
FreezeEntityPosition(ped, false)
SetEntityInvincible(ped, false)
SetPlayerControl(PlayerId(), true, 0)
SetPlayerInvincible(PlayerId(), false)
DisplayHud(true)
DisplayRadar(true)
end
--- Dusk, so the sodium palette of the interface matches the world behind it.
local function setSceneMood(on)
if on then
NetworkOverrideClockTime(20, 40, 0)
SetWeatherTypeNowPersist('EXTRASUNNY')
else
NetworkClearClockTimeOverride()
ClearOverrideWeatherType()
SetWeatherTypeNow('EXTRASUNNY')
end
end
--- Keep the world streamed around a point while no player ped is there.
local function focusOn(pos)
SetFocusPosAndVel(pos.x, pos.y, pos.z, 0.0, 0.0, 0.0)
RequestCollisionAtCoord(pos.x, pos.y, pos.z)
end
local function destroyPreview()
if previewPed and DoesEntityExist(previewPed) then
DeleteEntity(previewPed)
end
previewPed = nil
end
local function setStage(next, data)
stage = next
ui({ action = 'stage', stage = next, data = data })
end
-- ---------------------------------------------------------------------------
-- boot
-- ---------------------------------------------------------------------------
CreateThread(function()
while not NetworkIsSessionStarted() do Wait(50) end
holdPlayer()
setSceneMood(true)
local first = Config.CinematicShots[1]
focusOn(first.from)
SetEntityCoords(PlayerPedId(), NEUTRAL.x, NEUTRAL.y, NEUTRAL.z, false, false, false, false)
Cam.Shots(Config.CinematicShots)
-- give the world a moment to stream in behind the loading screen
SendLoadingScreenMessage(json.encode({
eventName = 'rp:server',
name = Config.ServerName,
status = 'Connected',
}))
SendLoadingScreenMessage(json.encode({ eventName = 'rp:rules', rules = Config.Rules }))
local waited = 0
while waited < 6000 do
SendLoadingScreenMessage(json.encode({
eventName = 'rp:progress',
fraction = 0.85 + (waited / 6000) * 0.15,
step = 'Streaming the city',
}))
Wait(250)
waited = waited + 250
end
SendLoadingScreenMessage(json.encode({ eventName = 'rp:handover' }))
Wait(1500)
ShutdownLoadingScreenNui()
focusUI(true)
setStage('auth', {
serverName = Config.ServerName,
serverTag = Config.ServerTag,
maxChars = Config.MaxChars,
})
end)
-- ---------------------------------------------------------------------------
-- authentication
-- ---------------------------------------------------------------------------
RegisterNUICallback('auth:submit', function(data, cb)
CreateThread(function()
local mode = (data and data.mode == 'register') and 'auth:register' or 'auth:login'
local res, err = Core.Callback('rp_session', mode, {
username = data.username,
password = data.password,
})
if not res then return cb({ ok = false, error = err or 'Something went wrong' }) end
account = { username = res.username, role = res.role }
characters = res.characters or {}
cb({ ok = true, account = account, characters = characters, maxChars = res.maxChars })
end)
end)
-- ---------------------------------------------------------------------------
-- character select
-- ---------------------------------------------------------------------------
--- Stand the highlighted character up so the list is not just text.
RegisterNUICallback('char:preview', function(data, cb)
CreateThread(function()
destroyPreview()
local appearance
for _, c in ipairs(characters) do
if c.id == data.id then appearance = c.appearance end
end
if not appearance then return cb({ ok = false }) end
appearance = Appearance.Sanitise(appearance)
focusOn(Config.CreatorScene.ped)
previewPed = PedBuild.CreatePreview(appearance, Config.CreatorScene.ped, Config.CreatorScene.ped.w)
if previewPed then
PedBuild.Idle(previewPed)
Cam.Orbit(previewPed, 2.4, 0.25, Config.CreatorScene.ped.w + 180.0, true)
Cam.OrbitFrame(2.4, 0.25, 0.35)
end
cb({ ok = previewPed ~= nil })
end)
end)
RegisterNUICallback('char:delete', function(data, cb)
CreateThread(function()
local res, err = Core.Callback('rp_session', 'char:delete', { id = data.id })
if not res then return cb({ ok = false, error = err }) end
local list = Core.Callback('rp_session', 'char:list')
characters = (list and list.characters) or {}
destroyPreview()
Cam.Shots(Config.CinematicShots)
cb({ ok = true, characters = characters })
end)
end)
RegisterNUICallback('char:select', function(data, cb)
CreateThread(function()
local res, err = Core.Callback('rp_session', 'char:select', { id = data.id })
if not res then return cb({ ok = false, error = err }) end
spawnData = res
chosenChar = res.character
destroyPreview()
-- become the character now, off-screen, so the fly-down reveals them
if res.appearance then
PedBuild.ApplyToPlayer(Appearance.Sanitise(res.appearance))
holdPlayer()
end
cb({ ok = true, spawns = res.spawns, lastPosition = res.lastPosition, character = res.character })
end)
end)
-- ---------------------------------------------------------------------------
-- character creation
-- ---------------------------------------------------------------------------
--- Read the game's own colour tables so the swatches in the UI are the real
--- colours, not an approximation that drifts from what the ped ends up wearing.
local paletteCache = nil
local function palettes()
if paletteCache then return paletteCache end
local hair, makeup = {}, {}
for i = 0, 63 do
local ok, r, g, b = pcall(GetPedHairRgbColor, i)
hair[#hair + 1] = ok and { r = r, g = g, b = b } or { r = 40, g = 40, b = 40 }
local ok2, r2, g2, b2 = pcall(GetPedMakeupRgbColor, i)
makeup[#makeup + 1] = ok2 and { r = r2, g = g2, b = b2 } or { r = 60, g = 40, b = 40 }
end
paletteCache = { hair = hair, makeup = makeup }
return paletteCache
end
--- The creator UI is built from this schema rather than a hardcoded copy, so
--- adding a slider is a one-line change in shared/appearance.lua.
local function pushDraft(cb)
local payload = {
ok = true,
appearance = draft,
limits = PedBuild.Limits(previewPed),
palettes = palettes(),
schema = {
features = Appearance.FEATURES,
overlays = Appearance.OVERLAYS,
components = Appearance.COMPONENTS,
props = Appearance.PROPS,
},
}
if cb then cb(payload) else ui({ action = 'creator:sync', data = payload }) end
end
local function rebuildPreview()
destroyPreview()
focusOn(Config.CreatorScene.ped)
previewPed = PedBuild.CreatePreview(draft, Config.CreatorScene.ped, Config.CreatorScene.ped.w)
if previewPed then
PedBuild.Idle(previewPed)
Cam.Orbit(previewPed, 1.5, 0.62, Config.CreatorScene.ped.w + 180.0, true)
end
end
RegisterNUICallback('creator:start', function(data, cb)
CreateThread(function()
draft = Appearance.Default(data and data.gender or 'm')
rebuildPreview()
pushDraft(cb)
end)
end)
RegisterNUICallback('creator:gender', function(data, cb)
CreateThread(function()
draft = Appearance.Default(data.gender == 'f' and 'f' or 'm')
rebuildPreview()
pushDraft(cb)
end)
end)
RegisterNUICallback('creator:parent', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
local key = data.key
if key == 'father' or key == 'mother' then
draft.parents[key] = math.floor(tonumber(data.value) or 0)
elseif key == 'shapeMix' or key == 'skinMix' then
draft.parents[key] = tonumber(data.value) or 0.5
end
SetPedHeadBlendData(previewPed,
draft.parents.father, draft.parents.mother, 0,
draft.parents.father, draft.parents.mother, 0,
draft.parents.shapeMix + 0.0, draft.parents.skinMix + 0.0, 0.0, false)
cb({ ok = true })
end)
RegisterNUICallback('creator:feature', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
local id = math.floor(tonumber(data.id) or 0)
local v = math.max(-1.0, math.min(1.0, tonumber(data.value) or 0.0))
draft.features[tostring(id)] = v
SetPedFaceFeature(previewPed, id, v + 0.0)
cb({ ok = true })
end)
RegisterNUICallback('creator:overlay', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
local slot
for _, o in ipairs(Appearance.OVERLAYS) do if o.key == data.key then slot = o end end
if not slot then return cb({ ok = false }) end
local ov = draft.overlays[data.key]
if data.index ~= nil then ov.index = math.floor(tonumber(data.index)) end
if data.opacity ~= nil then ov.opacity = tonumber(data.opacity) end
if data.colour ~= nil then ov.colour = math.floor(tonumber(data.colour)) end
local index = (ov.index == nil or ov.index < 0) and 255 or ov.index
SetPedHeadOverlay(previewPed, slot.id, index, ov.opacity + 0.0)
if slot.tint and index ~= 255 then
SetPedHeadOverlayColor(previewPed, slot.id, slot.tint == 'hair' and 1 or 2, ov.colour, ov.colour)
end
cb({ ok = true })
end)
RegisterNUICallback('creator:hair', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
if data.style ~= nil then draft.hair.style = math.floor(tonumber(data.style)) end
if data.colour ~= nil then draft.hair.colour = math.floor(tonumber(data.colour)) end
if data.highlight ~= nil then draft.hair.highlight = math.floor(tonumber(data.highlight)) end
SetPedComponentVariation(previewPed, 2, draft.hair.style, 0, 0)
SetPedHairColor(previewPed, draft.hair.colour, draft.hair.highlight)
cb({ ok = true })
end)
RegisterNUICallback('creator:eyes', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
draft.eyeColour = math.floor(tonumber(data.value) or 0)
SetPedEyeColor(previewPed, draft.eyeColour)
cb({ ok = true })
end)
RegisterNUICallback('creator:component', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
local comp
for _, c in ipairs(Appearance.COMPONENTS) do if c.key == data.key then comp = c end end
if not comp then return cb({ ok = false }) end
local cc = draft.components[data.key]
if data.drawable ~= nil then cc.drawable = math.floor(tonumber(data.drawable)) end
if data.texture ~= nil then cc.texture = math.floor(tonumber(data.texture)) end
-- a new drawable usually has a different number of textures
local maxTex = math.max(0, GetNumberOfPedTextureVariations(previewPed, comp.id, cc.drawable) - 1)
if cc.texture > maxTex then cc.texture = 0 end
SetPedComponentVariation(previewPed, comp.id, cc.drawable, cc.texture, 0)
cb({ ok = true, texture = cc.texture, maxTexture = maxTex })
end)
RegisterNUICallback('creator:prop', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
local prop
for _, p in ipairs(Appearance.PROPS) do if p.key == data.key then prop = p end end
if not prop then return cb({ ok = false }) end
local pp = draft.props[data.key]
if data.drawable ~= nil then pp.drawable = math.floor(tonumber(data.drawable)) end
if data.texture ~= nil then pp.texture = math.floor(tonumber(data.texture)) end
if pp.drawable < 0 then
ClearPedProp(previewPed, prop.id)
cb({ ok = true, texture = 0, maxTexture = 0 })
else
local maxTex = math.max(0, GetNumberOfPedPropTextureVariations(previewPed, prop.id, pp.drawable) - 1)
if pp.texture > maxTex then pp.texture = 0 end
SetPedPropIndex(previewPed, prop.id, pp.drawable, pp.texture, true)
cb({ ok = true, texture = pp.texture, maxTexture = maxTex })
end
end)
--- Camera framing per creator section.
local FRAMES = {
heritage = { radius = 1.05, height = 0.66, pitch = 0.02 },
face = { radius = 0.85, height = 0.68, pitch = 0.0 },
hair = { radius = 1.05, height = 0.70, pitch = 0.0 },
body = { radius = 2.60, height = 0.20, pitch = 0.45 },
clothing = { radius = 2.30, height = 0.28, pitch = 0.40 },
identity = { radius = 1.90, height = 0.45, pitch = 0.25 },
}
RegisterNUICallback('creator:frame', function(data, cb)
local f = FRAMES[data.section] or FRAMES.face
Cam.OrbitFrame(f.radius, f.height, f.pitch)
cb({ ok = true })
end)
RegisterNUICallback('creator:rotate', function(data, cb)
Cam.Nudge(tonumber(data.delta) or 0.0)
cb({ ok = true })
end)
RegisterNUICallback('creator:submit', function(data, cb)
CreateThread(function()
local res, err = Core.Callback('rp_session', 'char:create', {
firstName = data.firstName,
lastName = data.lastName,
dob = data.dob,
gender = draft and draft.model or 'm',
backstory = data.backstory,
appearance = draft,
})
if not res then return cb({ ok = false, error = err }) end
local list = Core.Callback('rp_session', 'char:list')
characters = (list and list.characters) or {}
destroyPreview()
draft = nil
cb({ ok = true, characters = characters })
end)
end)
RegisterNUICallback('creator:cancel', function(_, cb)
destroyPreview()
draft = nil
Cam.Shots(Config.CinematicShots)
cb({ ok = true })
end)
-- ---------------------------------------------------------------------------
-- spawn
-- ---------------------------------------------------------------------------
RegisterNUICallback('spawn:preview', function(data, cb)
CreateThread(function()
if not spawnData then return cb({ ok = false }) end
for _, sp in ipairs(spawnData.spawns) do
if sp.id == data.id then
local from = Cam.Position()
focusOn(vector3(sp.coords.x, sp.coords.y, sp.coords.z))
Cam.FlyTo(
from, vector3(sp.cam.look.x, sp.cam.look.y, sp.cam.look.z),
vector3(sp.cam.pos.x, sp.cam.pos.y, sp.cam.pos.z),
vector3(sp.coords.x, sp.coords.y, sp.coords.z),
2200
)
break
end
end
cb({ ok = true })
end)
end)
RegisterNUICallback('spawn:confirm', function(data, cb)
CreateThread(function()
local res, err = Core.Callback('rp_session', 'spawn:confirm', { id = data.id })
if not res then return cb({ ok = false, error = err }) end
cb({ ok = true })
focusUI(false)
ui({ action = 'stage', stage = 'flying' })
local c = res.coords
local ped = PlayerPedId()
-- stream the ground before anyone stands on it
focusOn(vector3(c.x, c.y, c.z))
RequestCollisionAtCoord(c.x, c.y, c.z)
local waited = 0
while not HasCollisionLoadedAroundEntity(ped) and waited < 8000 do
RequestCollisionAtCoord(c.x, c.y, c.z)
Wait(50)
waited = waited + 50
end
SetEntityCoordsNoOffset(ped, c.x, c.y, c.z, false, false, false)
SetEntityHeading(ped, c.h or 0.0)
local from = Cam.Position()
Cam.FlyTo(
from, vector3(c.x, c.y, c.z),
vector3(c.x - 2.4, c.y - 2.4, c.z + 1.4), vector3(c.x, c.y, c.z + 0.2),
3400,
function()
SetEntityVisible(ped, true, false)
releasePlayer()
setSceneMood(false)
ClearFocus()
Cam.Release(1400)
ui({ action = 'stage', stage = 'live' })
TriggerEvent('rp:ui:spawned', chosenChar)
end
)
end)
end)
-- ---------------------------------------------------------------------------
-- misc
-- ---------------------------------------------------------------------------
RegisterNUICallback('ui:log', function(data, cb)
print(('^3[rp_ui]^7 %s'):format(tostring(data and data.message)))
cb({ ok = true })
end)
AddEventHandler('onResourceStop', function(res)
if res ~= GetCurrentResourceName() then return end
destroyPreview()
Cam.Release(0)
focusUI(false)
releasePlayer()
setSceneMood(false)
ClearFocus()
end)