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,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)
|
||||
Reference in New Issue
Block a user