Core librarySafe server code

Safe server code

Requests that check what a player sends, run one at a time, and know where the player is.

On this page

A player's game can be modified, so anything it sends the server can be made up: a price, a position, a hundred requests at once. tr_core gives every request the same checks, so the server only acts on what it has checked itself.

Requests from players

Every request from a player's game goes through TR.callback.register (or TR.net.on for one that needs no answer), with a schema for what it may send and a rateLimit for how often.

server/main.lua
TR.callback.register('tr_bank:withdraw', function(src, amount)
    if not TR.guard.isNear(src, Config.bank, 3.0) then
        return false, 'too_far'
    end
    if not TR.player.removeMoney(src, 'bank', amount) then
        return false, 'no_money'
    end

    TR.player.addMoney(src, 'cash', amount)
    return true
end, {
    schema = { { type = 'integer', min = 1, max = 100000 } },
    rateLimit = { max = 2, window = 1000 },
})
client/main.lua
local ok, why = TR.callback.await('tr_bank:withdraw', 500)
if not ok then
    TR.notify.send({ description = TR.locale.t(why), type = 'error' })
end

A request that doesn't match its schema never reaches your function: the player gets nil, 'invalid_args' and is flagged (see Flags). One over the rate limit gets nil, 'rate_limited'.

The schema has one entry per argument. Types: any, nil, boolean, number, integer, string, table, array, vector3 and vector4, with min / max, pattern, enum and optional. A short form works too: 'string?' is an optional string.

  • Numbers and vector parts must be real numbers: NaN and infinity are refused.
  • A string without its own max takes at most 4096 characters. Give a bigger max where a player may send more.

One at a time

A function that waits for something (the database, or the player's game) can be called again by the same player before the first call is done. Both calls pass the checks before either has saved anything: two purchases, one payment.

Give such a request exclusive = true. While it runs for a player, their next call gets nil, 'in_progress'.

server/main.lua
TR.callback.register('tr_houses:buy', function(src, id)
    local sql = 'SELECT owner FROM houses WHERE id = ?'
    if TR.db.scalar(sql, { id }) then return false, 'taken' end
    if not TR.player.removeMoney(src, 'bank', Config.price) then
        return false, 'no_money'
    end

    TR.db.update('UPDATE houses SET owner = ? WHERE id = ?', {
        TR.player.getIdentifier(src), id,
    })
    return true
end, {
    schema = { { type = 'integer', min = 1 } },
    rateLimit = { max = 1, window = 2000 },
    exclusive = true,
})

Add an in_progress line to your locales, for the message the player sees.

It costs nothing on a function that doesn't wait, so when in doubt, add it. When the same work can be started another way too (from a laptop app, say), take the same lock there: it is named after the callback.

server/apps.lua
local ok, why = TR.guard.exclusive(src, 'tr_houses:buy', buy, src, id)

TR.guard.lock(src, name) is the same lock by hand: it returns a function that lets go of it, or nil while the player holds it.

Asking a player's game

The server can ask a player's game for something only it knows, with TR.callback.await(name, src, ...). The answer comes from that game, so check it like a request: awaitChecked takes a schema for the answer.

server/main.lua
local fuel, why = TR.callback.awaitChecked('tr_example:fuel', src, {
    schema = { 'number' },
}, netId)
-- why: 'invalid_answer', 'timeout' or 'offline'
if not fuel then return end

An answer that doesn't match gives nil, 'invalid_answer' and flags the player (flag = false turns that off). A player who leaves while the server waits gives nil, 'offline' at once; timeout (ms) sets how long to wait.

Where the player is

TR.guard.isNear(src, coords, distance) checks the player's real position on the server, not one their game sent.

Instanced places (a warehouse interior, an apartment) put each copy in its own routing bucket, at the same coordinates. Coordinates don't know about buckets, so give the place's bucket, and a player in another copy isn't near it:

server/main.lua
if not TR.guard.isNear(src, interior.center, 20.0, interior.bucket) then
    return false, 'not_inside'
end

Near an entity (a car, a prop), use TR.guard.isNearEntity(src, entity, distance). It checks the entity's bucket itself.

Flags

When a player's game sends something it never should, flag them:

server/main.lua
TR.guard.flag(src, 'sold more than they had', {
    item = item, count = count,
})

Every flag fires the tr:playerFlagged event, for an anticheat or ban script to act on:

server/anticheat.lua
AddEventHandler('tr:playerFlagged', function(src, resource, reason, info)
end)

The console and the security Discord channel get a player's first flag in a minute, then one summary of the rest, so a player sending bad data on purpose can't flood them.

Still stuck? Open a ticket on Discord.