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