Initial commit

This commit is contained in:
pavetr
2026-03-15 14:54:49 +03:00
commit 64f8029c06
4027 changed files with 254888 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
/*---------------------------------------------------------------------------
Disjoint-set forest implementation
Inspired by the book Introduction To Algorithms (third edition)
by FPtje Atheos
Running time per operation (Union/FindSet): O(a(n)) where a is the inverse of the Ackermann function.
---------------------------------------------------------------------------*/
local ipairs = ipairs
local setmetatable = setmetatable
local string = string
local table = table
local tostring = tostring
module("disjoint")
local metatable
-- Make a singleton set. Parent parameter is optional, must be a disjoint-set as well.
function MakeSet(x, parent)
local set = {}
set.value = x
set.rank = 0
set.parent = parent or set
setmetatable(set, metatable)
return set
end
local function Link(x, y)
if x == y then return x end
-- Union by rank
if x.rank > y.rank then
y.parent = x
return x
end
x.parent = y
if x.rank == y.rank then
y.rank = y.rank + 1
end
return y
end
-- Apply the union operation between two sets.
function Union(x, y)
return Link(FindSet(x), FindSet(y))
end
function FindSet(x)
local parent = x
local listParents
-- Go up the tree to find the parent
while parent ~= parent.parent do
parent = parent.parent
listParents = listParents or {}
table.insert(listParents, parent)
end
-- Path compression, update all parents to refer to the top parent
if listParents then
for _, v in ipairs(listParents) do
v.parent = parent
end
end
return parent
end
function Disconnect(x)
x.parent = x
return x
end
metatable = {
__tostring = function(self)
return string.format("Disjoint-Set [value: %s][Rank: %s][Parent: %s]", tostring(self.value), self.rank, tostring(self.parent.value))
end,
__metatable = true, -- restrict access to metatable
__add = Union
}

View File

@@ -0,0 +1,337 @@
/*---------------------------------------------------------------------------
Functional library
by FPtje Atheos
---------------------------------------------------------------------------*/
/*---------------------------------------------------------------------------
Function currying
Take a function with n parameters.
Currying is the procedure of storing k < n parameters "in the function"
in such a way that the remaining function can be called with n - k parameters
Example:
DebugPrint = fp{print, "[DEBUG]"}
DebugPrint("TEST")
> [DEBUG] TEST
---------------------------------------------------------------------------*/
function fp(tbl)
local func = tbl[1]
return function(...)
local fnArgs = {}
local arg = {...}
local tblN = table.maxn(tbl)
for i = 2, tblN do fnArgs[i - 1] = tbl[i] end
for i = 1, table.maxn(arg) do fnArgs[tblN + i - 1] = arg[i] end
return func(unpack(fnArgs, 1, table.maxn(fnArgs)))
end
end
local unpack = unpack
local table = table
local pairs = pairs
local ipairs = ipairs
local error = error
local math = math
local select = select
local type = type
local _G = _G
local fp = fp
module("fn")
/*---------------------------------------------------------------------------
Parameter manipulation
---------------------------------------------------------------------------*/
Id = function(...) return ... end
Flip = function(f)
if not f then error("not a function") end
return function(b, a, ...)
return f(a, b, ...)
end
end
-- Definition from http://lua-users.org/wiki/CurriedLua
ReverseArgs = function(...)
--reverse args by building a function to do it, similar to the unpack() example
local function reverse_h(acc, v, ...)
if select('#', ...) == 0 then
return v, acc()
else
return reverse_h(function () return v, acc() end, ...)
end
end
-- initial acc is the end of the list
return reverse_h(function () return end, ...)
end
/*---------------------------------------------------------------------------
Misc functions
---------------------------------------------------------------------------*/
-- function composition
do
local function comp_h(a, b, ...)
if b == nil then return a end
b = comp_h(b, ...)
return function(...)
return a(b(...))
end
end
Compose = function(funcs, ...)
if type(funcs) == "table" then
return comp_h(unpack(funcs))
else
return comp_h(funcs, ...)
end
end
end
_G.fc = Compose
-- Definition from http://lua-users.org/wiki/CurriedLua
Curry = function(func, num_args)
if not num_args then error("Missing argument #2: num_args") end
if not func then error("Function does not exist!", 2) end
-- helper
local function curry_h(argtrace, n)
if n == 0 then
-- reverse argument list and call function
return func(ReverseArgs(argtrace()))
else
-- "push" argument (by building a wrapper function) and decrement n
return function(x)
return curry_h(function() return x, argtrace() end, n - 1)
end
end
end
-- no sense currying for 1 arg or less
if num_args > 1 then
return curry_h(function() return end, num_args)
else
return func
end
end
-- Thanks Lexic!
Partial = function(func, ...)
local args = {...}
return function(...)
return func(unpack(table.Add( args, {...})))
end
end
Apply = function(f, ...) return f(...) end
Const = function(a, b) return a end
Until = function(cmp, fn, val)
if cmp(val) then
return val
end
return Until(cmp, fn, fn(val))
end
Seq = function(f, x) f(x) return x end
GetGlobalVar = function(key) return _G[key] end
/*---------------------------------------------------------------------------
Mathematical operators and functions
---------------------------------------------------------------------------*/
Add = function(a, b) return a + b end
Sub = function(a, b) return a - b end
Mul = function(a, b) return a * b end
Div = function(a, b) return a / b end
Mod = function(a, b) return a % b end
Neg = function(a) return -a end
Eq = function(a, b) return a == b end
Neq = function(a, b) return a ~= b end
Gt = function(a, b) return a > b end
Lt = function(a, b) return a < b end
Gte = function(a, b) return a >= b end
Lte = function(a, b) return a <= b end
Succ = Compose{Add, 1}
Pred = Compose{Flip(Sub), 1}
Even = Compose{fp{Eq, 0}, fp{Flip(Mod), 2}}
Odd = Compose{Not, Even}
/*---------------------------------------------------------------------------
Functional logical operators and conditions
---------------------------------------------------------------------------*/
FAnd = function(fns)
return function(...)
local val
for _, f in pairs(fns) do
val = {f(...)}
if not val[1] then return unpack(val) end
end
if val then return unpack(val) end
end
end
FOr = function(fns)
return function(...)
local val
for _, f in pairs(fns) do
val = {f(...)}
if val[1] then return unpack(val) end
end
return false, unpack(val, 2)
end
end
Not = function(x) return not x end
If = function(f, Then, Else)
return function(x)
if f(x) then
return Then
else
return Else
end
end
end
/*---------------------------------------------------------------------------
List operations
---------------------------------------------------------------------------*/
Map = function(f, xs)
for k, v in pairs(xs) do
xs[k] = f(v)
end
return xs
end
Append = function(xs, ys)
return table.Add(xs, ys)
end
Filter = function(f, xs)
local res = {}
for k,v in pairs(xs) do
if f(v) then res[k] = v end
end
return res
end
ForEach = function(f, xs)
for k,v in pairs(xs) do
local val = f(k, v)
if val ~= nil then return val end
end
end
Head = function(xs)
return table.GetFirstValue(xs)
end
Last = function(xs)
return xs[#xs] or table.GetLastValue(xs)
end
Tail = function(xs)
table.remove(xs, 1)
return xs
end
Init = function(xs)
xs[#xs] = nil
return xs
end
GetValue = function(i, xs)
return xs[i]
end
Null = function(xs)
for k, v in pairs(xs) do
return false
end
return true
end
Length = function(xs)
return #xs
end
Index = function(xs, i)
return xs[i]
end
Reverse = function(xs)
local res = {}
for i = #xs, 1, -1 do
res[#xs - i + 1] = xs[i]
end
return res
end
/*---------------------------------------------------------------------------
Folds
---------------------------------------------------------------------------*/
Foldr = function(func, val, xs)
for i = #xs, 1, -1 do
val = func(xs[i], val)
end
return val
end
Foldl = function(func, val, xs)
for k, v in ipairs(xs) do
val = func(val, v)
end
return val
end
And = function(xs)
for k, v in pairs(xs) do
if v ~= true then return false end
end
return true
end
Or = function(xs)
for k, v in pairs(xs) do
if v == true then return true end
end
return false
end
Any = function(func, xs)
for k, v in pairs(xs) do
if func(v) == true then return true end
end
return false
end
All = function(func, xs)
for k, v in pairs(xs) do
if func(v) ~= true then return false end
end
return true
end
Sum = _G.fp{Foldr, Add, 0}
Product = _G.fp{Foldr, Mul, 1}
Concat = _G.fp{Foldr, Append, {}}
Maximum = _G.fp{Foldl, math.Max, -math.huge}
Minimum = _G.fp{Foldl, math.Min, math.huge}
Snd = _G.fp{select, 2}
Thrd = _G.fp{select, 3}

View File

@@ -0,0 +1,210 @@
module("DarkRP", package.seeall)
MetaName = "DarkRP"
-- Variables that maintain the existing stubs and hooks
local stubs = {}
local hookStubs = {}
-- Contains the functions that the hooks call by default
hooks = {}
-- Delay the calling of methods until the functions are implemented
local delayedCalls = {}
local returnsLayout, isreturns
local parameterLayout, isparameters
local isdeprecated
local checkStub
local hookLayout
local realm -- State variable to manage the realm of the stubs
--[[---------------------------------------------------------------------------
Methods that check whether certain fields are valid
---------------------------------------------------------------------------]]
isreturns = function(tbl)
if not istable(tbl) then return false end
for _, v in pairs(tbl) do
if not checkStub(v, returnsLayout) then return false end
end
return true
end
isparameters = function(tbl)
if not istable(tbl) then return false end
for _, v in pairs(tbl) do
if not checkStub(v, parameterLayout) then return false end
end
return true
end
isdeprecated = function(val)
return val == nil or isstring(val)
end
--[[---------------------------------------------------------------------------
The layouts of stubs
---------------------------------------------------------------------------]]
local stubLayout = {
name = isstring,
description = isstring,
deprecated = isdeprecated,
parameters = isparameters, -- the parameters of a method
returns = isreturns, -- the return values of a method
metatable = istable -- DarkRP, Player, Entity, Vector, ...
}
hookLayout = {
name = isstring,
description = isstring,
deprecated = isdeprecated,
parameters = isreturns, -- doesn't have the 'optional' field
returns = isreturns,
}
returnsLayout = {
name = isstring,
description = isstring,
type = isstring
}
parameterLayout = {
name = isstring,
description = isstring,
type = isstring,
optional = isbool
}
--[[---------------------------------------------------------------------------
Check the validity of a stub
---------------------------------------------------------------------------]]
checkStub = function(tbl, stub)
if not istable(tbl) then return false, "table" end
for name, check in pairs(stub) do
if not check(tbl[name]) then
return false, name
end
end
return true
end
--[[---------------------------------------------------------------------------
When a stub is called, the calling of the method is delayed
---------------------------------------------------------------------------]]
local function notImplemented(name, args, thisFunc)
if stubs[name] and stubs[name].metatable[name] ~= thisFunc then -- when calling the not implemented function after the function was implemented
return stubs[name].metatable[name](unpack(args))
end
table.insert(delayedCalls, {name = name, args = args})
return nil -- no return value because the method is not implemented
end
--[[---------------------------------------------------------------------------
Generate a stub
---------------------------------------------------------------------------]]
function stub(tbl)
local isStub, field = checkStub(tbl, stubLayout)
if not isStub then
error("Invalid DarkRP method stub! Field \"" .. field .. "\" is invalid!", 2)
end
tbl.realm = tbl.realm or realm
stubs[tbl.name] = tbl
local function retNotImpl(...)
return notImplemented(tbl.name, {...}, retNotImpl)
end
return retNotImpl
end
--[[---------------------------------------------------------------------------
Generate a hook stub
---------------------------------------------------------------------------]]
function hookStub(tbl)
local isStub, field = checkStub(tbl, hookLayout)
if not isStub then
error("Invalid DarkRP hook! Field \"" .. field .. "\" is invalid!", 2)
end
tbl.realm = tbl.realm or realm
hookStubs[tbl.name] = tbl
end
--[[---------------------------------------------------------------------------
Retrieve the stubs
---------------------------------------------------------------------------]]
function getStubs()
return table.Copy(stubs)
end
--[[---------------------------------------------------------------------------
Retrieve the hooks
---------------------------------------------------------------------------]]
function getHooks()
return table.Copy(hookStubs)
end
--[[---------------------------------------------------------------------------
Call the cached methods
---------------------------------------------------------------------------]]
function finish()
local calls = table.Copy(delayedCalls) -- Loop through a copy, so the notImplemented function doesn't get called again
for _, tbl in ipairs(calls) do
local name = tbl.name
if not stubs[name] then ErrorNoHalt("Calling non-existing stub \"" .. name .. "\"") continue end
stubs[name].metatable[name](unpack(tbl.args))
end
delayedCalls = {}
end
--[[---------------------------------------------------------------------------
Load the interface files
---------------------------------------------------------------------------]]
local function loadInterfaces()
local root = GM.FolderName .. "/gamemode/modules"
local _, folders = file.Find(root .. "/*", "LUA")
ENTITY = FindMetaTable("Entity")
PLAYER = FindMetaTable("Player")
VECTOR = FindMetaTable("Vector")
for _, folder in SortedPairs(folders, true) do
local interfacefile = string.format("%s/%s/%s_interface.lua", root, folder, "%s")
local client = string.format(interfacefile, "cl")
local shared = string.format(interfacefile, "sh")
local server = string.format(interfacefile, "sv")
if file.Exists(shared, "LUA") then
if SERVER then AddCSLuaFile(shared) end
realm = "Shared"
include(shared)
end
if SERVER and file.Exists(client, "LUA") then
AddCSLuaFile(client)
end
if SERVER and file.Exists(server, "LUA") then
realm = "Server"
include(server)
end
if CLIENT and file.Exists(client, "LUA") then
realm = "Client"
include(client)
end
end
ENTITY, PLAYER, VECTOR = nil, nil, nil
end
loadInterfaces()

View File

@@ -0,0 +1,152 @@
-- Modification loader.
-- Dependencies:
-- - fn
-- - simplerr
--[[---------------------------------------------------------------------------
Disabled defaults
---------------------------------------------------------------------------]]
DarkRP.disabledDefaults = {}
DarkRP.disabledDefaults["modules"] = {
["afk"] = true,
["chatsounds"] = false,
["events"] = false,
["fpp"] = false,
["hitmenu"] = false,
["hud"] = false,
["hungermod"] = true,
["playerscale"] = false,
["sleep"] = false,
}
DarkRP.disabledDefaults["agendas"] = {}
DarkRP.disabledDefaults["ammo"] = {}
DarkRP.disabledDefaults["demotegroups"] = {}
DarkRP.disabledDefaults["doorgroups"] = {}
DarkRP.disabledDefaults["entities"] = {}
DarkRP.disabledDefaults["food"] = {}
DarkRP.disabledDefaults["groupchat"] = {}
DarkRP.disabledDefaults["hitmen"] = {}
DarkRP.disabledDefaults["jobs"] = {}
DarkRP.disabledDefaults["shipments"] = {}
DarkRP.disabledDefaults["vehicles"] = {}
DarkRP.disabledDefaults["workarounds"] = {}
-- The client cannot use simplerr.runLuaFile because of restrictions in GMod.
local doInclude = CLIENT and include or fc{simplerr.wrapError, simplerr.wrapLog, simplerr.runFile}
if file.Exists("darkrp_config/disabled_defaults.lua", "LUA") then
if SERVER then AddCSLuaFile("darkrp_config/disabled_defaults.lua") end
doInclude("darkrp_config/disabled_defaults.lua")
end
--[[---------------------------------------------------------------------------
Config
---------------------------------------------------------------------------]]
local configFiles = {
"darkrp_config/settings.lua",
"darkrp_config/licenseweapons.lua",
}
for _, File in ipairs(configFiles) do
if not file.Exists(File, "LUA") then continue end
if SERVER then AddCSLuaFile(File) end
doInclude(File)
end
if SERVER and file.Exists("darkrp_config/mysql.lua", "LUA") then doInclude("darkrp_config/mysql.lua") end
--[[---------------------------------------------------------------------------
Modules
---------------------------------------------------------------------------]]
local function loadModules()
local fol = "darkrp_modules/"
local _, folders = file.Find(fol .. "*", "LUA")
for _, folder in SortedPairs(folders, true) do
if folder == "." or folder == ".." or GAMEMODE.Config.DisabledCustomModules[folder] then continue end
-- Sound but incomplete way of detecting the error of putting addons in the darkrpmod folder
if file.Exists(fol .. folder .. "/addon.txt", "LUA") or file.Exists(fol .. folder .. "/addon.json", "LUA") then
DarkRP.errorNoHalt("Addon detected in the darkrp_modules folder.", 2, {
"This addon is not supposed to be in the darkrp_modules folder.",
"It is supposed to be in garrysmod/addons/ instead.",
"Whether a mod is to be installed in darkrp_modules or addons is the author's decision.",
"Please read the readme of the addons you're installing next time."
},
"<darkrpmod addon>/lua/darkrp_modules/" .. folder, -1)
continue
end
for _, File in SortedPairs(file.Find(fol .. folder .. "/sh_*.lua", "LUA"), true) do
if SERVER then
AddCSLuaFile(fol .. folder .. "/" .. File)
end
if File == "sh_interface.lua" then continue end
doInclude(fol .. folder .. "/" .. File)
end
if SERVER then
for _, File in SortedPairs(file.Find(fol .. folder .. "/sv_*.lua", "LUA"), true) do
if File == "sv_interface.lua" then continue end
doInclude(fol .. folder .. "/" .. File)
end
end
for _, File in SortedPairs(file.Find(fol .. folder .. "/cl_*.lua", "LUA"), true) do
if File == "cl_interface.lua" then continue end
if SERVER then
AddCSLuaFile(fol .. folder .. "/" .. File)
else
doInclude(fol .. folder .. "/" .. File)
end
end
end
end
local function loadLanguages()
local fol = "darkrp_language/"
local files, _ = file.Find(fol .. "*", "LUA")
for _, File in ipairs(files) do
if SERVER then AddCSLuaFile(fol .. File) end
doInclude(fol .. File)
end
end
local customFiles = {
"darkrp_customthings/jobs.lua",
"darkrp_customthings/shipments.lua",
"darkrp_customthings/entities.lua",
"darkrp_customthings/vehicles.lua",
"darkrp_customthings/food.lua",
"darkrp_customthings/ammo.lua",
"darkrp_customthings/groupchats.lua",
"darkrp_customthings/categories.lua",
"darkrp_customthings/agendas.lua", -- has to be run after jobs.lua
"darkrp_customthings/doorgroups.lua", -- has to be run after jobs.lua
"darkrp_customthings/demotegroups.lua", -- has to be run after jobs.lua
}
local function loadCustomDarkRPItems()
for _, File in ipairs(customFiles) do
if not file.Exists(File, "LUA") then continue end
if File == "darkrp_customthings/food.lua" and DarkRP.disabledDefaults["modules"]["hungermod"] then continue end
if SERVER then AddCSLuaFile(File) end
doInclude(File)
end
end
function GM:DarkRPFinishedLoading()
-- GAMEMODE gets set after the last statement in the gamemode files is run. That is not the case in this hook
GAMEMODE = GAMEMODE or GM
loadLanguages()
loadModules()
loadCustomDarkRPItems()
hook.Call("loadCustomDarkRPItems", self)
hook.Call("postLoadCustomDarkRPItems", self)
end

View File

@@ -0,0 +1,519 @@
--[[
MySQLite - Abstraction mechanism for SQLite and MySQL
Why use this?
- Easy to use interface for MySQL
- No need to modify code when switching between SQLite and MySQL
- Queued queries: execute a bunch of queries in order an run the callback when all queries are done
License: LGPL V2.1 (read here: https://www.gnu.org/licenses/lgpl-2.1.html)
Supported MySQL modules:
- MySQLOO
- tmysql4
Note: When both MySQLOO and tmysql4 modules are installed, MySQLOO is used by default.
/*---------------------------------------------------------------------------
Documentation
---------------------------------------------------------------------------*/
MySQLite.initialize([config :: table]) :: No value
Initialize MySQLite. Loads the config from either the config parameter OR the MySQLite_config global.
This loads the module (if necessary) and connects to the MySQL database (if set up).
The config must have this layout:
{
EnableMySQL :: Bool - set to true to use MySQL, false for SQLite
Host :: String - database hostname
Username :: String - database username
Password :: String - database password (keep away from clients!)
Database_name :: String - name of the database
Database_port :: Number - connection port (3306 by default)
Preferred_module :: String - Preferred module, case sensitive, must be either "mysqloo" or "tmysql4"
MultiStatements :: Bool - Only available in tmysql4: allow multiple SQL statements per query
}
----------------------------- Utility functions -----------------------------
MySQLite.isMySQL() :: Bool
Returns whether MySQLite is set up to use MySQL. True for MySQL, false for SQLite.
Use this when the query syntax between SQLite and MySQL differs (example: AUTOINCREMENT vs AUTO_INCREMENT)
MySQLite.SQLStr(str :: String) :: String
Escapes the string and puts it in quotes.
It uses the escaping method of the module that is currently being used.
MySQLite.tableExists(tbl :: String, callback :: function, errorCallback :: function)
Checks whether table tbl exists.
callback format: function(res :: Bool)
res is a boolean indicating whether the table exists.
The errorCallback format is the same as in MySQLite.query.
----------------------------- Running queries -----------------------------
MySQLite.query(sqlText :: String, callback :: function, errorCallback :: function) :: No value
Runs a query. Calls the callback parameter when finished, calls errorCallback when an error occurs.
callback format:
function(result :: table, lastInsert :: number)
Result is the table with results (nil when there are no results or when the result list is empty)
lastInsert is the row number of the last inserted value (use with AUTOINCREMENT)
Note: lastInsert is NOT supported when using SQLite.
errorCallback format:
function(error :: String, query :: String) :: Bool
error is the error given by the database module.
query is the query that triggered the error.
Return true to suppress the error!
MySQLite.queryValue(sqlText :: String, callback :: function, errorCallback :: function) :: No value
Runs a query and returns the first value it comes across.
callback format:
function(result :: any)
where the result is either a string or a number, depending on the requested database field.
The errorCallback format is the same as in MySQLite.query.
MySQLite.prepare(sqlText :: String, sqlParams :: Table, callback :: function, errorCallback :: function)
Calls a prepared statement, faster for queries that are ran multiple times. Params do not need to be escaped.
This does not work with SQLite, and will instead call a regular query
callback format:
function(result :: table, lastInsert :: number, rowsChanged :: number)
Result is the table with results (nil when there are no results or when the result list is empty)
lastInsert is the row number of the last inserted value (use with AUTOINCREMENT)
rowsChanged is the amount of rows that were changed with the query
Note: lastInsert is NOT supported when using SQLite.
The errorCallback format is the same as in MySQLite.query.
Example:
MySQLite.prepare("UPDATE `darkrp_player` SET `rpname` = ? WHERE `rpname` = ?", { "players_old_name", "players_new_name"}, success_callback, error_callback)
----------------------------- Transactions -----------------------------
MySQLite.begin() :: No value
Starts a transaction. Use in combination with MySQLite.queueQuery and MySQLite.commit.
MySQLite.queueQuery(sqlText :: String, callback :: function, errorCallback :: function) :: No value
Queues a query in the transaction. Note: a transaction must be started with MySQLite.begin() for this to work.
The callback will be called when this specific query has been executed successfully.
The errorCallback function will be called when an error occurs in this specific query.
See MySQLite.query for the callback and errorCallback format.
MySQLite.commit(onFinished)
Commits a transaction and calls onFinished when EVERY queued query has finished.
onFinished is NOT called when an error occurs in one of the queued queries.
onFinished is called without arguments.
----------------------------- Hooks -----------------------------
DatabaseInitialized
Called when a successful connection to the database has been made.
]]
local debug = debug
local error = error
local ErrorNoHalt = ErrorNoHalt
local hook = hook
local pairs = pairs
local require = require
local sql = sql
local string = string
local table = table
local timer = timer
local tostring = tostring
local mysqlOO
local TMySQL
local _G = _G
local ipairs = ipairs
local type = type
local unpack = unpack
local multistatements
local MySQLite_config = MySQLite_config or RP_MySQLConfig or FPP_MySQLConfig
local moduleLoaded
local function loadMySQLModule()
if moduleLoaded or not MySQLite_config or not MySQLite_config.EnableMySQL then return end
local moo, tmsql = util.IsBinaryModuleInstalled("mysqloo"), util.IsBinaryModuleInstalled("tmysql4")
if not moo and not tmsql then
error("Could not find a suitable MySQL module. Please either:\n - Install tmysql. It can be obtained from https://github.com/SuperiorServers/gm_tmysql4\n - Install MySQLOO. It can be obtained from https://github.com/FredyH/MySQLOO\nDue to this error, MySQL is disabled. This means that SQLite is used instead to store data.")
end
moduleLoaded = true
require(
moo and tmsql and MySQLite_config.Preferred_module or
moo and "mysqloo" or
tmsql and "tmysql4"
)
multistatements = CLIENT_MULTI_STATEMENTS
mysqlOO = mysqloo
TMySQL = tmysql
if MySQLite_config.Preferred_module == "tmysql4" then
if not tmsql then
ErrorNoHalt("The preferred module for MySQL is selected to be tmysql4. However, tmysql4 does not appear to be installed. Please either:\n - Install tmysql. It can be obtained from https://github.com/SuperiorServers/gm_tmysql4\n - Select MySQLOO as the preferred module for MySQL. MySQLOO appears to be installed.")
return
end
if not tmysql.Version or tmysql.Version < 4.1 then
MsgC(Color(255, 0, 0), "Using older tmysql version, please consider updating!\n")
MsgC(Color(255, 0, 0), "Newer Version: https://github.com/SuperiorServers/gm_tmysql4\n")
end
-- Turns tmysql.Connect into tmysql.Initialize if they're using an older version.
TMySQL.Connect = tmysql.Version and tmysql.Version >= 4.1 and TMySQL.Connect or TMySQL.initialize
TMySQL.SetOption = tmysql.Version and tmysql.Version >= 4.1 and TMySQL.SetOption or TMySQL.Option
else
if not moo then
ErrorNoHalt("The preferred module for MySQL is selected to be MySQLOO. However, MySQLOO does not appear to be installed. Please either:\n - Install MySQLOO. It can be obtained from https://github.com/FredyH/MySQLOO\n - Select tmysql4 as the preferred module for MySQL. tmysql4 appears to be installed.")
end
end
end
loadMySQLModule()
module("MySQLite")
-- Helper function to return the first value found when iterating over a table.
-- Replaces the now deprecated table.GetFirstValue
local function arbitraryTableValue(tbl)
for _, v in pairs(tbl) do return v end
end
function initialize(config)
MySQLite_config = config or MySQLite_config
if not MySQLite_config then
ErrorNoHalt("Warning: No MySQL config!")
end
loadMySQLModule()
if MySQLite_config.EnableMySQL then
connectToMySQL(MySQLite_config.Host, MySQLite_config.Username, MySQLite_config.Password, MySQLite_config.Database_name, MySQLite_config.Database_port)
else
timer.Simple(0, function()
_G.GAMEMODE.DatabaseInitialized = _G.GAMEMODE.DatabaseInitialized or function() end
hook.Call("DatabaseInitialized", _G.GAMEMODE)
end)
end
end
local CONNECTED_TO_MYSQL = false
local msOOConnect
databaseObject = nil
local queuedQueries
local cachedQueries
function isMySQL()
return CONNECTED_TO_MYSQL
end
function begin()
if not CONNECTED_TO_MYSQL then
sql.Begin()
else
if queuedQueries then
debug.Trace()
error("Transaction ongoing!")
end
queuedQueries = {}
end
end
function commit(onFinished)
if not CONNECTED_TO_MYSQL then
sql.Commit()
if onFinished then onFinished() end
return
end
if not queuedQueries then
error("No queued queries! Call begin() first!")
end
if #queuedQueries == 0 then
queuedQueries = nil
if onFinished then onFinished() end
return
end
-- Copy the table so other scripts can create their own queue
local queue = table.Copy(queuedQueries)
queuedQueries = nil
-- Handle queued queries in order
local queuePos = 0
local call
-- Recursion invariant: queuePos > 0 and queue[queuePos] <= #queue
call = function(...)
queuePos = queuePos + 1
if queue[queuePos].callback then
queue[queuePos].callback(...)
end
-- Base case, end of the queue
if queuePos + 1 > #queue then
if onFinished then onFinished() end -- All queries have finished
return
end
-- Recursion
local nextQuery = queue[queuePos + 1]
query(nextQuery.query, call, nextQuery.onError)
end
query(queue[1].query, call, queue[1].onError)
end
function queueQuery(sqlText, callback, errorCallback)
if CONNECTED_TO_MYSQL then
table.insert(queuedQueries, {query = sqlText, callback = callback, onError = errorCallback})
return
end
-- SQLite is instantaneous, simply running the query is equal to queueing it
query(sqlText, callback, errorCallback)
end
local function msOOQuery(sqlText, callback, errorCallback, queryValue)
local queryObject = databaseObject:query(sqlText)
local data
queryObject.onData = function(Q, D)
data = data or {}
data[#data + 1] = D
end
queryObject.onError = function(Q, E)
if databaseObject:status() == mysqlOO.DATABASE_NOT_CONNECTED then
table.insert(cachedQueries, {sqlText, callback, queryValue})
-- Immediately try reconnecting
msOOConnect(MySQLite_config.Host, MySQLite_config.Username, MySQLite_config.Password, MySQLite_config.Database_name, MySQLite_config.Database_port)
return
end
local supp = errorCallback and errorCallback(E, sqlText)
if not supp then error(E .. " (" .. sqlText .. ")") end
end
queryObject.onSuccess = function()
local res = queryValue and data and data[1] and arbitraryTableValue(data[1]) or not queryValue and data or nil
if callback then callback(res, queryObject:lastInsert()) end
end
queryObject:start()
end
local preparedStatements = {}
local paramTypes = {}
paramTypes["number"] = function(queryObj, paramIndex, paramValue) return queryObj:setNumber(paramIndex, paramValue) end
paramTypes["string"] = function(queryObj, paramIndex, paramValue) return queryObj:setString(paramIndex, paramValue) end
paramTypes["boolean"] = function(queryObj, paramIndex, paramValue) return queryObj:setBoolean(paramIndex, paramValue) end
local function msOOPrepare(sqlText, sqlParams, callback, errorCallback)
local queryObject
if preparedStatements[sqlText] then
queryObject = preparedStatements[sqlText]
else
queryObject = databaseObject:prepare(sqlText)
preparedStatements[sqlText] = queryObject
end
for i, param in ipairs(sqlParams) do
local paramType = type(param)
if paramTypes[paramType] then
paramTypes[paramType](queryObject, i, param)
else
queryObject:setString(i, param)
end
end
queryObject.onError = function(_, E)
local supp = errorCallback and errorCallback(E, sqlText)
if not supp then error(E .. " (" .. sqlText .. ")") end
end
queryObject.onSuccess = function(_, data)
if callback then callback(data, queryObject:lastInsert(), queryObject:affectedRows()) end
end
queryObject:start()
end
local function tmsqlPrepare(sqlText, sqlParams, callback, errorCallback)
local queryObject
if preparedStatements[sqlText] then
queryObject = preparedStatements[sqlText]
else
queryObject = databaseObject:Prepare(sqlText)
preparedStatements[sqlText] = queryObject
end
for i, param in ipairs(sqlParams) do
param = SQLStr(param, true)
end
local varcount = queryObject:GetArgCount()
sqlParams[varcount + 1] = function(results)
if results[1].error ~= nil then
local supp = errorCallback and errorCallback(E, results[1].error)
if not supp then error(E .. " (" .. results[1].error .. ")") end
end
if callback then callback(data, results[1].lastid, results[1].affected) end
end
queryObject:Run(unpack(sqlParams, 1, varcount + 2))
end
local function tmsqlQuery(sqlText, callback, errorCallback, queryValue)
local call = function(res)
res = res[1] -- For now only support one result set
if not res.status then
local supp = errorCallback and errorCallback(res.error, sqlText)
if not supp then error(res.error .. " (" .. sqlText .. ")") end
return
end
if not res.data or #res.data == 0 then res.data = nil end -- compatibility with other backends
if queryValue and callback then return callback(res.data and res.data[1] and arbitraryTableValue(res.data[1]) or nil) end
if callback then callback(res.data, res.lastid) end
end
databaseObject:Query(sqlText, call)
end
local function SQLiteQuery(sqlText, callback, errorCallback, queryValue)
sql.m_strError = "" -- reset last error
local lastError = sql.LastError()
local Result = queryValue and sql.QueryValue(sqlText) or sql.Query(sqlText)
if sql.LastError() and sql.LastError() ~= lastError then
local err = sql.LastError()
local supp = errorCallback and errorCallback(err, sqlText)
if supp == false then error(err .. " (" .. sqlText .. ")", 2) end
return
end
if callback then callback(Result) end
return Result
end
-- SQLite doesn't support preparedStatements, so convert it to a regular query.
local function SQLitePrepare(sqlText, sqlParams, callback, errorCallback)
for _, param in ipairs(sqlParams) do
sqlText = sqlText:gsub("%?", sql.SQLStr(param), 1)
end
return SQLiteQuery(sqlText, callback, errorCallback, false)
end
function query(sqlText, callback, errorCallback)
local qFunc = (CONNECTED_TO_MYSQL and ((mysqlOO and msOOQuery) or (TMySQL and tmsqlQuery))) or SQLiteQuery
return qFunc(sqlText, callback, errorCallback, false)
end
function queryValue(sqlText, callback, errorCallback)
local qFunc = (CONNECTED_TO_MYSQL and ((mysqlOO and msOOQuery) or (TMySQL and tmsqlQuery))) or SQLiteQuery
return qFunc(sqlText, callback, errorCallback, true)
end
function prepare(sqlText, sqlParams, callback, errorCallback)
local qFunc = (CONNECTED_TO_MYSQL and ((mysqlOO and msOOPrepare) or (TMySQL and tmsqlPrepare))) or SQLitePrepare
return qFunc(sqlText, sqlParams, callback, errorCallback, false)
end
local function onConnected()
CONNECTED_TO_MYSQL = true
-- Run the queries that were called before the connection was made
for k, v in pairs(cachedQueries or {}) do
cachedQueries[k] = nil
if v[3] then
queryValue(v[1], v[2])
else
query(v[1], v[2])
end
end
cachedQueries = {}
local GM = _G.GAMEMODE or _G.GM
hook.Call("DatabaseInitialized", GM.DatabaseInitialized and GM or nil)
end
msOOConnect = function(host, username, password, database_name, database_port)
databaseObject = mysqlOO.connect(host, username, password, database_name, database_port)
if timer.Exists("darkrp_check_mysql_status") then timer.Remove("darkrp_check_mysql_status") end
databaseObject.onConnectionFailed = function(_, msg)
timer.Simple(5, function()
msOOConnect(MySQLite_config.Host, MySQLite_config.Username, MySQLite_config.Password, MySQLite_config.Database_name, MySQLite_config.Database_port)
end)
error("Connection failed! " .. tostring(msg) .. "\nTrying again in 5 seconds.")
end
databaseObject.onConnected = onConnected
databaseObject:connect()
end
local function tmsqlConnect(host, username, password, database_name, database_port)
local db, err = TMySQL.Connect(host, username, password, database_name, database_port, nil, MySQLite_config.MultiStatements and multistatements or nil)
if err then error("Connection failed! " .. err .. "\n") end
databaseObject = db
onConnected()
if (TMySQL.Version and TMySQL.Version >= 4.1) then
hook.Add("Think", "MySQLite:tmysqlPoll", function()
db:Poll()
end)
end
end
function connectToMySQL(host, username, password, database_name, database_port)
database_port = database_port or 3306
local func = mysqlOO and msOOConnect or TMySQL and tmsqlConnect or function() end
func(host, username, password, database_name, database_port)
end
function SQLStr(sqlStr)
local escape =
not CONNECTED_TO_MYSQL and sql.SQLStr or
mysqlOO and function(str) return "\"" .. databaseObject:escape(tostring(str)) .. "\"" end or
TMySQL and function(str) return "\"" .. databaseObject:Escape(tostring(str)) .. "\"" end
return escape(sqlStr)
end
function tableExists(tbl, callback, errorCallback)
if not CONNECTED_TO_MYSQL then
local exists = sql.TableExists(tbl)
callback(exists)
return exists
end
queryValue(string.format("SHOW TABLES LIKE %s", SQLStr(tbl)), function(v)
callback(v ~= nil)
end, errorCallback)
end

View File

@@ -0,0 +1,362 @@
--[[
CAMI - Common Admin Mod Interface.
Copyright 2020 CAMI Contributors
Makes admin mods intercompatible and provides an abstract privilege interface
for third party addons.
Follows the specification on this page:
https://github.com/glua/CAMI/blob/master/README.md
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]
-- Version number in YearMonthDay format.
local version = 20211019
if CAMI and CAMI.Version >= version then return end
CAMI = CAMI or {}
CAMI.Version = version
--- @class CAMI_USERGROUP
--- defines the charactaristics of a usergroup
--- @field Name string @The name of the usergroup
--- @field Inherits string @The name of the usergroup this usergroup inherits from
--- @field CAMI_Source string @The source specified by the admin mod which registered this usergroup (if any, converted to a string)
--- @class CAMI_PRIVILEGE
--- defines the charactaristics of a privilege
--- @field Name string @The name of the privilege
--- @field MinAccess "'user'" | "'admin'" | "'superadmin'" @Default group that should have this privilege
--- @field Description string | nil @Optional text describing the purpose of the privilege
local CAMI_PRIVILEGE = {}
--- Optional function to check if a player has access to this privilege
--- (and optionally execute it on another player)
---
--- ⚠ **Warning**: This function may not be called by all admin mods
--- @param actor GPlayer @The player
--- @param target GPlayer | nil @Optional - the target
--- @return boolean @If they can or not
--- @return string | nil @Optional reason
function CAMI_PRIVILEGE:HasAccess(actor, target)
end
--- Contains the registered CAMI_USERGROUP usergroup structures.
--- Indexed by usergroup name.
--- @type CAMI_USERGROUP[]
local usergroups = CAMI.GetUsergroups and CAMI.GetUsergroups() or {
user = {
Name = "user",
Inherits = "user",
CAMI_Source = "Garry's Mod",
},
admin = {
Name = "admin",
Inherits = "user",
CAMI_Source = "Garry's Mod",
},
superadmin = {
Name = "superadmin",
Inherits = "admin",
CAMI_Source = "Garry's Mod",
}
}
--- Contains the registered CAMI_PRIVILEGE privilege structures.
--- Indexed by privilege name.
--- @type CAMI_PRIVILEGE[]
local privileges = CAMI.GetPrivileges and CAMI.GetPrivileges() or {}
--- Registers a usergroup with CAMI.
---
--- Use the source parameter to make sure CAMI.RegisterUsergroup function and
--- the CAMI.OnUsergroupRegistered hook don't cause an infinite loop
--- @param usergroup CAMI_USERGROUP @The structure for the usergroup you want to register
--- @param source any @Identifier for your own admin mod. Can be anything.
--- @return CAMI_USERGROUP @The usergroup given as an argument
function CAMI.RegisterUsergroup(usergroup, source)
if source then
usergroup.CAMI_Source = tostring(source)
end
usergroups[usergroup.Name] = usergroup
hook.Call("CAMI.OnUsergroupRegistered", nil, usergroup, source)
return usergroup
end
--- Unregisters a usergroup from CAMI. This will call a hook that will notify
--- all other admin mods of the removal.
---
--- ⚠ **Warning**: Call only when the usergroup is to be permanently removed.
---
--- Use the source parameter to make sure CAMI.UnregisterUsergroup function and
--- the CAMI.OnUsergroupUnregistered hook don't cause an infinite loop
--- @param usergroupName string @The name of the usergroup.
--- @param source any @Identifier for your own admin mod. Can be anything.
--- @return boolean @Whether the unregistering succeeded.
function CAMI.UnregisterUsergroup(usergroupName, source)
if not usergroups[usergroupName] then return false end
local usergroup = usergroups[usergroupName]
usergroups[usergroupName] = nil
hook.Call("CAMI.OnUsergroupUnregistered", nil, usergroup, source)
return true
end
--- Retrieves all registered usergroups.
--- @return CAMI_USERGROUP[] @Usergroups indexed by their names.
function CAMI.GetUsergroups()
return usergroups
end
--- Receives information about a usergroup.
--- @param usergroupName string
--- @return CAMI_USERGROUP | nil @Returns nil when the usergroup does not exist.
function CAMI.GetUsergroup(usergroupName)
return usergroups[usergroupName]
end
--- Checks to see if potentialAncestor is an ancestor of usergroupName.
--- All usergroups are ancestors of themselves.
---
--- Examples:
--- * `user` is an ancestor of `admin` and also `superadmin`
--- * `admin` is an ancestor of `superadmin`, but not `user`
--- @param usergroupName string @The usergroup to query
--- @param potentialAncestor string @The ancestor to query
--- @return boolean @Whether usergroupName inherits potentialAncestor.
function CAMI.UsergroupInherits(usergroupName, potentialAncestor)
repeat
if usergroupName == potentialAncestor then return true end
usergroupName = usergroups[usergroupName] and
usergroups[usergroupName].Inherits or
usergroupName
until not usergroups[usergroupName] or
usergroups[usergroupName].Inherits == usergroupName
-- One can only be sure the usergroup inherits from user if the
-- usergroup isn't registered.
return usergroupName == potentialAncestor or potentialAncestor == "user"
end
--- Find the base group a usergroup inherits from.
---
--- This function traverses down the inheritence chain, so for example if you have
--- `user` -> `group1` -> `group2`
--- this function will return `user` if you pass it `group2`.
---
--- ℹ **NOTE**: All usergroups must eventually inherit either user, admin or superadmin.
--- @param usergroupName string @The name of the usergroup
--- @return "'user'" | "'admin'" | "'superadmin'" @The name of the root usergroup
function CAMI.InheritanceRoot(usergroupName)
if not usergroups[usergroupName] then return end
local inherits = usergroups[usergroupName].Inherits
while inherits ~= usergroups[usergroupName].Inherits do
usergroupName = usergroups[usergroupName].Inherits
end
return usergroupName
end
--- Registers an addon privilege with CAMI.
---
--- ⚠ **Warning**: This should only be used by addons. Admin mods must *NOT*
--- register their privileges using this function.
--- @param privilege CAMI_PRIVILEGE
--- @return CAMI_PRIVILEGE @The privilege given as argument.
function CAMI.RegisterPrivilege(privilege)
privileges[privilege.Name] = privilege
hook.Call("CAMI.OnPrivilegeRegistered", nil, privilege)
return privilege
end
--- Unregisters a privilege from CAMI.
--- This will call a hook that will notify any admin mods of the removal.
---
--- ⚠ **Warning**: Call only when the privilege is to be permanently removed.
--- @param privilegeName string @The name of the privilege.
--- @return boolean @Whether the unregistering succeeded.
function CAMI.UnregisterPrivilege(privilegeName)
if not privileges[privilegeName] then return false end
local privilege = privileges[privilegeName]
privileges[privilegeName] = nil
hook.Call("CAMI.OnPrivilegeUnregistered", nil, privilege)
return true
end
--- Retrieves all registered privileges.
--- @return CAMI_PRIVILEGE[] @All privileges indexed by their names.
function CAMI.GetPrivileges()
return privileges
end
--- Receives information about a privilege.
--- @param privilegeName string
--- @return CAMI_PRIVILEGE | nil
function CAMI.GetPrivilege(privilegeName)
return privileges[privilegeName]
end
-- Default access handler
local defaultAccessHandler = {["CAMI.PlayerHasAccess"] =
function(_, actorPly, privilegeName, callback, targetPly, extraInfoTbl)
-- The server always has access in the fallback
if not IsValid(actorPly) then return callback(true, "Fallback.") end
local priv = privileges[privilegeName]
local fallback = extraInfoTbl and (
not extraInfoTbl.Fallback and actorPly:IsAdmin() or
extraInfoTbl.Fallback == "user" and true or
extraInfoTbl.Fallback == "admin" and actorPly:IsAdmin() or
extraInfoTbl.Fallback == "superadmin" and actorPly:IsSuperAdmin())
if not priv then return callback(fallback, "Fallback.") end
local hasAccess =
priv.MinAccess == "user" or
priv.MinAccess == "admin" and actorPly:IsAdmin() or
priv.MinAccess == "superadmin" and actorPly:IsSuperAdmin()
if hasAccess and priv.HasAccess then
hasAccess = priv:HasAccess(actorPly, targetPly)
end
callback(hasAccess, "Fallback.")
end,
["CAMI.SteamIDHasAccess"] =
function(_, _, _, callback)
callback(false, "No information available.")
end
}
--- @class CAMI_ACCESS_EXTRA_INFO
--- @field Fallback "'user'" | "'admin'" | "'superadmin'" @Fallback status for if the privilege doesn't exist. Defaults to `admin`.
--- @field IgnoreImmunity boolean @Ignore any immunity mechanisms an admin mod might have.
--- @field CommandArguments table @Extra arguments that were given to the privilege command.
--- Checks if a player has access to a privilege
--- (and optionally can execute it on targetPly)
---
--- This function is designed to be asynchronous but will be invoked
--- synchronously if no callback is passed.
---
--- ⚠ **Warning**: If the currently installed admin mod does not support
--- synchronous queries, this function will throw an error!
--- @param actorPly GPlayer @The player to query
--- @param privilegeName string @The privilege to query
--- @param callback fun(hasAccess: boolean, reason: string|nil) @Callback to receive the answer, or nil for synchronous
--- @param targetPly GPlayer | nil @Optional - target for if the privilege effects another player (eg kick/ban)
--- @param extraInfoTbl CAMI_ACCESS_EXTRA_INFO | nil @Table of extra information for the admin mod
--- @return boolean | nil @Synchronous only - if the player has the privilege
--- @return string | nil @Synchronous only - optional reason from admin mod
function CAMI.PlayerHasAccess(actorPly, privilegeName, callback, targetPly,
extraInfoTbl)
local hasAccess, reason = nil, nil
local callback_ = callback or function(hA, r) hasAccess, reason = hA, r end
hook.Call("CAMI.PlayerHasAccess", defaultAccessHandler, actorPly,
privilegeName, callback_, targetPly, extraInfoTbl)
if callback ~= nil then return end
if hasAccess == nil then
local err = [[The function CAMI.PlayerHasAccess was used to find out
whether Player %s has privilege "%s", but an admin mod did not give an
immediate answer!]]
error(string.format(err,
actorPly:IsPlayer() and actorPly:Nick() or tostring(actorPly),
privilegeName))
end
return hasAccess, reason
end
--- Get all the players on the server with a certain privilege
--- (and optionally who can execute it on targetPly)
---
--- ℹ **NOTE**: This is an asynchronous function!
--- @param privilegeName string @The privilege to query
--- @param callback fun(players: GPlayer[]) @Callback to receive the answer
--- @param targetPly GPlayer | nil @Optional - target for if the privilege effects another player (eg kick/ban)
--- @param extraInfoTbl CAMI_ACCESS_EXTRA_INFO | nil @Table of extra information for the admin mod
function CAMI.GetPlayersWithAccess(privilegeName, callback, targetPly,
extraInfoTbl)
local allowedPlys = {}
local allPlys = player.GetAll()
local countdown = #allPlys
local function onResult(ply, hasAccess, _)
countdown = countdown - 1
if hasAccess then table.insert(allowedPlys, ply) end
if countdown == 0 then callback(allowedPlys) end
end
for _, ply in ipairs(allPlys) do
CAMI.PlayerHasAccess(ply, privilegeName,
function(...) onResult(ply, ...) end,
targetPly, extraInfoTbl)
end
end
--- @class CAMI_STEAM_ACCESS_EXTRA_INFO
--- @field IgnoreImmunity boolean @Ignore any immunity mechanisms an admin mod might have.
--- @field CommandArguments table @Extra arguments that were given to the privilege command.
--- Checks if a (potentially offline) SteamID has access to a privilege
--- (and optionally if they can execute it on a target SteamID)
---
--- ℹ **NOTE**: This is an asynchronous function!
--- @param actorSteam string | nil @The SteamID to query
--- @param privilegeName string @The privilege to query
--- @param callback fun(hasAccess: boolean, reason: string|nil) @Callback to receive the answer
--- @param targetSteam string | nil @Optional - target SteamID for if the privilege effects another player (eg kick/ban)
--- @param extraInfoTbl CAMI_STEAM_ACCESS_EXTRA_INFO | nil @Table of extra information for the admin mod
function CAMI.SteamIDHasAccess(actorSteam, privilegeName, callback,
targetSteam, extraInfoTbl)
hook.Call("CAMI.SteamIDHasAccess", defaultAccessHandler, actorSteam,
privilegeName, callback, targetSteam, extraInfoTbl)
end
--- Signify that your admin mod has changed the usergroup of a player. This
--- function communicates to other admin mods what it thinks the usergroup
--- of a player should be.
---
--- Listen to the hook to receive the usergroup changes of other admin mods.
--- @param ply GPlayer @The player for which the usergroup is changed
--- @param old string @The previous usergroup of the player.
--- @param new string @The new usergroup of the player.
--- @param source any @Identifier for your own admin mod. Can be anything.
function CAMI.SignalUserGroupChanged(ply, old, new, source)
hook.Call("CAMI.PlayerUsergroupChanged", nil, ply, old, new, source)
end
--- Signify that your admin mod has changed the usergroup of a disconnected
--- player. This communicates to other admin mods what it thinks the usergroup
--- of a player should be.
---
--- Listen to the hook to receive the usergroup changes of other admin mods.
--- @param steamId string @The steam ID of the player for which the usergroup is changed
--- @param old string @The previous usergroup of the player.
--- @param new string @The new usergroup of the player.
--- @param source any @Identifier for your own admin mod. Can be anything.
function CAMI.SignalSteamIDUserGroupChanged(steamId, old, new, source)
hook.Call("CAMI.SteamIDUsergroupChanged", nil, steamId, old, new, source)
end

View File

@@ -0,0 +1,557 @@
local CompileFile = CompileFile
local CompileString = CompileString
local debug = debug
local error = error
local file = file
local hook = hook
local include = include
local isfunction = isfunction
local isstring = isstring
local math = math
local os = os
local string = string
local table = table
local tonumber = tonumber
local unpack = unpack
local xpcall = xpcall
-- Template for syntax errors
-- The [ERROR] start of it cannot be removed, because that would make the
-- error mechanism remove all square brackets. Only Garry can make that bullshit up.
local synErrTranslation = [=[[ERROR] Lua is unable to understand file "%s" because its author made a mistake around line number %i.
The best help I can give you is this:
%s
Hints:
%s
------- End of Simplerr error -------
]=] -- The end is a special string by which simplerr errors are internally recognised
-- Template for runtime errors
local runErrTranslation = [=[[ERROR] A runtime error has occurred in "%s" on line %i.
The best help I can give you is this:
%s
Hints:
%s
The responsibility for the error above lies with (the authors of) one (or more) of these files:
%s
------- End of Simplerr error -------
]=]
-- Structure that contains syntax errors and their translations. Catches only the most common errors.
-- Order is important: the structure with the first match is taken.
local synErrs = {
{
match = "'=' expected near '(.*)'",
text = "Right before the '%s', Lua expected to read an '='-sign, but it didn't.",
format = function(m) return m[1] end,
hints = {
"Did you simply forget the '='-sign?",
"Did you forget a comma?",
"Is this supposed to be a local variable?"
}
},
{
match = "'.' expected [(]to close '([{[(])' at line ([0-9-]+)[)] near '(.*)'",
text = "There is an opening '%s' bracket at line %i, but this bracket is never closed or not closed in time. It was expected to be closed before the '%s' at line %i.",
format = function(m, l) return m[1], m[2], m[3], l end,
hints = {
"Did you forget a comma?",
"All open brackets ({, (, [) must have a matching closing bracket. Are you sure it's there?",
"Brackets must be opened and closed in the right order. This will work: ({}), but this won't: ({)}."
}
},
{
match = "'end' expected [(]to close '(.*)' at line ([0-9-]+)[)] near '(.*)'",
text = "An '%s' was started on line %i, but it was never ended or not ended in time. It was expected to be ended before the '%s' at line %i",
format = function(m, l) return m[1], m[2], m[3], l end,
hints = {
"For every if/for/do/while/function there must be an 'end' that closes it."
}
},
{
match = "unfinished string near '(.*)'",
text = "The string '%s' at line %i is opened, but not closed.",
format = function(m, l) return m[1], l end,
hints = {
"A string is a different word for literal text.",
"Strings must be in single or double quotation marks (e.g. 'example', \"example\")",
"A third option for strings is for them to be in double square brackets.",
"Whatever you use (quotations or square brackets), you must not forget that strings are enclosed within a pair of quotation marks/square brackets."
}
},
{
match = "unfinished long string near '(.*)'",
text = "Lua expected to see the end of a multiline string somewhere before the '%s' at line %i.",
format = function(m, l) return m[1], l end,
hints = {
"A string is a different word for literal text.",
"Multiline strings are strings that span over multiple lines.",
"Multiline strings must be enclosed by double square brackets.",
"Whatever you use (quotations or square brackets), you must not forget that strings are enclosed within a pair of quotation marks/square brackets.",
"If you used brackets, the source of the mistake may be somewhere above the reported line."
}
},
{
match = "unfinished long comment near '(.*)'",
text = "Lua expected to see the end of a multiline comment somewhere before the '%s' at line %i.",
format = function(m, l) return m[1], l end,
hints = {
"A comment is text ignored by Lua.",
"Multiline comments are ones that span multiple lines.",
"Multiline comments must be enclosed by either /* and */ or double square brackets.",
"Whatever you use (/**/ or square brackets), you must not forget that once you start a comment, you must end it.",
"The source of the mistake may be somewhere above the reported line."
}
},
-- Generic error messages
{
match = "function arguments expected near '(.*)'",
text = "A function is being called right before '%s', but its arguments are not given.",
format = function(m) return m[1] end,
hints = {
"Did you write 'something:otherthing'? Try changing it to 'something:otherthing()'"
}
},
{
match = "unexpected symbol near '(.*)'",
text = "Right before the '%s', Lua encountered something it could not make sense of.",
format = function(m) return m[1] end,
hints = {"Did you forget something here? (Perhaps a closing bracket)", "Is it a typo?"}
},
{
match = "'(.*)' expected near '(.*)'",
text = "Right before the '%s', Lua expected to read a '%s', but it didn't.",
format = function(m) return m[2], m[1] end,
hints = {"Did you forget a keyword?", "Did you forget a comma?"}
},
{
match = "malformed number near '(.*)'",
text = "Lua attempted to read '%s' as a number, but failed to do so.",
format = function(m) return m[1] end,
hints = {
"Numbers starting with '0x' are hexidecimal.",
"Lua can get confused when doing '<number>..\"some text\"'. Try inserting a space between the number and the '..'."
}
},
}
-- Similar structure for runtime errors. Catches only the most common errors.
-- Order is important: the structure with the first match is taken
local runErrs = {
{
match = "table index is nil",
text = "A table is being indexed by something that does not exist (table index is nil).", -- Requires improvement
format = function() end,
hints = {
"The thing between square brackets does not exist (is nil)."
}
},
{
match = "table index is NaN",
text = "A table is being indexed by something that is not really a number (table index is NaN).",
format = function() end,
hints = {
"Did you divide zero by zero thinking it would be funny?"
}
},
{
match = "attempt to index global '(.*)' [(]a nil value[)]",
text = "'%s' is being indexed like it is a table, but in reality it does not exist (is nil).",
format = function(m) return m[1] end,
hints = {
"You either have 'something.somethingElse', 'something[somethingElse]' or 'something:somethingElse(more)'. The 'something' here does not exist."
}
},
{
match = "attempt to index global '(.*)' [(]a (.*) value[)]",
text = "'%s' is being indexed like it is a table, but in reality it is a %s value.",
format = function(m) return m[1], m[2] end,
hints = {
"You either have 'something.somethingElse' or 'something:somethingElse(more)'. The 'something' here is not a table."
}
},
{
match = "attempt to index a nil value",
text = "Something is being indexed like it is a table, but in reality does not exist (is nil).",
format = function() end,
hints = {
"You either have 'something.somethingElse', 'something[somethingElse]' or 'something:somethingElse(more)'. The 'something' here does not exist."
}
},
{
match = "attempt to index a (.*) value",
text = "Something is being indexed like it is a table, but in reality it is a %s value.",
format = function(m) return m[1] end,
hints = {
"You either have 'something.somethingElse', 'something[somethingElse]' or 'something:somethingElse(more)'. The 'something' here is not a table."
}
},
{
match = "attempt to call global '(.*)' [(]a nil value[)]",
text = "'%s' is being called like it is a function, but in reality does not exist (is nil).",
format = function(m) return m[1] end,
hints = {
"You are doing something(<otherstuff>). The 'something' here does not exist."
}
},
{
match = "attempt to call a nil value",
text = "Something is being called like it is a function, but in reality it does not exist (is nil).",
format = function() end,
hints = {
"You are doing something(<otherstuff>). The 'something' here does not exist."
}
},
{
match = "attempt to call global '(.*)' [(]a (.*) value[)]",
text = "'%s' is being called like it is a function, but in reality it is a %s.",
format = function(m) return m[1], m[2] end,
hints = {
"You are doing something(<otherstuff>). The 'something' here is not a function."
}
},
{
match = "attempt to call a (.*) value",
text = "Something is being called like it is a function, but in reality it is a %s.",
format = function(m) return m[1] end,
hints = {
"You are doing something(<otherstuff>). The 'something' here is not a function."
}
},
{
match = "attempt to call field '(.*)' [(]a nil value[)]",
text = "'%s' is being called like it is a function, but in reality it does not exist (is nil).",
format = function(m) return m[1] end,
hints = {
"You are doing either stuff.something(<otherstuff>) or stuff:something(<otherstuff>). The 'something' here does not exist."
}
},
{
match = "attempt to call field '(.*)' [(]a (.*) value[)]",
text = "'%s' is being called like it is a function, but in reality it is a %s.",
format = function(m) return m[1], m[2] end,
hints = {
"You are doing either stuff.something(<otherstuff>) or stuff:something(<otherstuff>). The 'something' here is not a function."
}
},
{
match = "attempt to concatenate global '(.*)' [(]a nil value[)]",
text = "'%s' is being concatenated to something else, but '%s' does not exist (is nil).",
format = function(m) return m[1], m[1] end,
hints = {
"Concatenation looks like this: something .. otherThing. Either something or otherThing does not exist."
}
},
{
match = "attempt to concatenate global '(.*)' [(]a (.*) value[)]",
text = "'%s' is being concatenated to something else, but %s values cannot be concatenated.",
format = function(m) return m[1], m[2] end,
hints = {
"Concatenation looks like this: something .. otherThing. Either something or otherThing is neither string nor number."
}
},
{
match = "attempt to concatenate a nil value",
text = "Two (or more) things are being concatenated and one of them does not exist (is nil).",
format = function() end,
hints = {
"Concatenation looks like this: something .. otherThing. Either something or otherThing does not exist."
}
},
{
match = "attempt to concatenate a (.*) value",
text = "Two (or more) things are being concatenated and one of them is neither string nor number, but a %s.",
format = function(m) return m[1] end,
hints = {
"Concatenation looks like this: something .. otherThing. Either something or otherThing is neither string nor number."
}
},
{
match = "stack overflow",
text = "The stack of function calls has overflowed",
format = function() end,
hints = {
"Most likely infinite recursion.",
"Do you have a function calling itself?"
}
},
{
match = "attempt to compare two (.*) values",
text = "A comparison is being made between two %s values. They cannot be compared.",
format = function(m) return m[1] end,
hints = {
"This error usually occurs when two incompatible things are being compared.",
"'comparison' in this context means one of <, >, <=, >= (smaller than, greater than, etc.)"
}
},
{
match = "attempt to compare (.*) with (.*)",
text = "A comparison is being made between a %s and a %s. This is not possible.",
format = function(m) return m[1], m[2] end,
hints = {
"This error usually occurs when two incompatible things are being compared.",
"'Comparison' in this context means one of <, >, <=, >= (smaller than, greater than, etc.)"
}
},
{
match = "attempt to perform arithmetic on a (.*) value",
text = "Arithmetic operations are being performed on a %s. This is not possible.",
format = function(m) return m[1] end,
hints = {
"'Arithmetic' in this context means adding, multiplying, dividing, etc."
}
},
{
match = "attempt to get length of global '(.*)' [(]a nil value[)]",
text = "The length of '%s' is requested as if it is a table, but in reality it does not exist (is nil).",
format = function(m) return m[1] end,
hints = {
"You are doing #something. The 'something' here is does not exist."
}
},
{
match = "attempt to get length of global '(.*)' [(]a (.*) value[)]",
text = "The length of '%s' is requested as if it is a table, but in reality it is a %s.",
format = function(m) return m[1], m[2] end,
hints = {
"You are doing #something. The 'something' here is not a table."
}
},
{
match = "attempt to get length of a nil value",
text = "The length of something is requested as if it is a table, but in reality it does not exist (is nil).",
format = function(m) return m[1] end,
hints = {
"You are doing #something. The 'something' here is does not exist."
}
},
{
match = "attempt to get length of a (.*) value",
text = "The length of something is requested as if it is a table, but in reality it is a %s.",
format = function(m) return m[1] end,
hints = {
"You are doing #something. The 'something' here is not a table."
}
},
}
module("simplerr")
-- Get a nicely formatted stack trace. Start is where to start numbering
-- stackMod allows the caller to modify the stack before it is numbered
local function getStack(i, start, stackMod)
i = i or 1
start = start or 1
local stack = {}
-- Invariant: stack level (i + count) >= 2 and <= last stack item
for count = 1, math.huge do -- user visible count
local info = debug.getinfo(i + count, "Sln")
if not info then break end
local line = info.currentline or "unknown"
if line == -1 and info.name then
table.insert(stack, string.format("function '%s'", info.name))
else
table.insert(stack, string.format("%s on line %s", info.short_src, line))
end
end
-- Allow modification of the stack
if stackMod then stack = stackMod(stack) end
-- add the numbering
for count = 1, #stack do
local stackLevel = start + count - 1
stack[count] = string.format("\t%i. %s", stackLevel, stack[count])
end
return table.concat(stack, "\n")
end
-- Translate a runtime error to simplerr format.
-- Decorate with e.g. wrapError to have it actually throw the error.
function runError(msg, stackNr, hints, path, line, stack)
stackNr = stackNr or 1
hints = hints or {"No hints, sorry."}
hints = "\t- " .. table.concat(hints, "\n\t- ")
if not path and not line then
local info = debug.getinfo(stackNr + 1, "Sln") or debug.getinfo(stackNr, "Sln") or debug.getinfo(stackNr - 1, "Sln")
path = info.short_src
line = info.currentline
end
return false, string.format(runErrTranslation, path, line, msg, hints, stack or getStack(stackNr + 1))
end
-- Translate the message of an error
local function translateMsg(msg, path, line, errs)
local res
local hints = {"No hints, sorry."}
for i = 1, #errs do
local trans = errs[i]
if not string.find(msg, trans.match) then continue end
-- translate <eof>
msg = string.Replace(msg, "<eof>", "end of the file")
res = string.format(trans.text, trans.format({string.match(msg, trans.match)}, line, path))
hints = trans.hints
break
end
return res or msg, "\t- " .. table.concat(hints, "\n\t- ")
end
-- Translate an error into a language understandable by non-programmers
local function translateError(path, line, err, translation, errs, stack)
-- Using .* instead of path because path may be wrong when error is called
local msg, hints = translateMsg(string.match(err, ".*:[0-9-]+: (.*)"), path, line, errs)
local res = string.format(translation, path, line, msg, hints, stack)
return res
end
-- Trims the [C] functions at the beginning of the stack
local function trimStart(stack)
while true do
if string.StartWith(stack[1], "function ") then
table.remove(stack, 1)
else
break
end
end
return stack
end
-- safeCall uses xpcall, which has the downside that both xpcall and
-- the safeCall function itself end up in the stack trace.
-- This function removes them from the stack trace
local function removeXpcall(stack)
for i = #stack - 1, 1, -1 do
if stack[i] == "function 'xpcall'" and string.find(stack[i + 1], "simplerr") then
table.remove(stack, i)
table.remove(stack, i) -- also remove the simplerr safeCall call
return stack
end
end
return stack
end
-- Combines the two above functions
local function stackModAggregate(stack)
stack = trimStart(stack)
return removeXpcall(stack)
end
-- Used as the error handler in safeCall
local function errorHandler(err, func)
-- Investigate the stack. Not using err matching because calls to error can give a different path and line
local stack = getStack(func and 1 or 2, 1, stackModAggregate) -- add called func to stack
-- Fetch the path and line number from the top of the stack
local firstLine = string.sub(stack, 1, string.match(stack, "()\n") - 1)
local path, line = string.match(firstLine, "\t[0-9-]+%. (.*) on line ([0-9-]+)")
line = tonumber(line)
return {err, path, line, stack}
end
-- Call a function and catch immediate runtime errors
function safeCall(f, ...)
-- Use xpcall so fetching of debug info is in the stack of the error rather than after it is unwound
local res = {xpcall(f, errorHandler, ...)}
local succ, errInfo = res[1], res[2]
if succ then return unpack(res) end
-- This will only happen if the error is "not enough memory" or "error in error handling".
-- The former tends to crash the game and the latter will mean it'll probably error in the next line.
-- But we will try anyway.
-- Note: stack trace will be less accurate.
if isstring(errInfo) then errInfo = errorHandler(errInfo, f) end
-- Skip translation if the error is already a simplerr error
-- This prevents nested simplerr errors when runError is called by a file loaded by runFile
local mustTranslate = not string.find(errInfo[1], "------- End of Simplerr error -------")
return false, mustTranslate and translateError(errInfo[2], errInfo[3], errInfo[1], runErrTranslation, runErrs, errInfo[4]) or errInfo[1]
end
-- Run a file or explain its syntax errors in layman's terms
-- Returns bool succeed, [string error]
-- Do NOT use this on clientside files.
-- Clientside files sent by the server cannot be read using file.Read unless you're the host of a listen server
function runFile(path)
if not file.Exists(path, "LUA") then error(string.format("Could not run file '%s' (file not found)", path)) end
local contents = file.Read(path, "LUA")
-- Files can make a comment containing #NoSimplerr# to disable simplerr (and thus enable autorefresh)
if string.find(contents, "#NoSimplerr#") then include(path) return true end
-- Catch syntax errors with CompileString
local err = CompileString(contents, path, false)
-- CompileString returns the following string whenever a file is empty: Invalid script - or too short.
-- It also prints: Not running script <path> - it's too short.
-- If so, do nothing.
if err == "Invalid script - or too short." then return true end
-- No syntax errors, check for immediate runtime errors using CompileFile
-- Using the function CompileString returned leads to relative path trouble
if isfunction(err) then return safeCall(CompileFile(path), path) end
-- Fetch the line number from the error
local line = string.match(err, ".*:([0-9-]+): .*")
line = tonumber(line)
return false, translateError(path, line, err, synErrTranslation, synErrs)
end
-- Error wrapper: decorator for runFile and safeCall that throws an error on failure.
-- Breaks execution. Must be the last decorator.
function wrapError(succ, err, ...)
if succ then return succ, err, ... end
error(err)
end
-- Hook wrapper: Calls a hook on error
function wrapHook(succ, err, ...)
if not succ then hook.Call("onSimplerrError", nil, err) end
return succ, err, ...
end
-- Logging wrapper: decorator for runFile and safeCall that logs failures.
local log = {}
function wrapLog(succ, err, ...)
if succ then return succ, err, ... end
local data = {
err = err,
time = os.time()
}
table.insert(log, data)
return succ, err, ...
end
-- Retrieve the log
function getLog() return log end
-- Clear the log
function clearLog() log = {} end

View File

@@ -0,0 +1,364 @@
--[[
tablecheck
WIP
Author: FPtje Falco
Purpose:
Allow validating tables by creating schemas of tables. Inspired by Joi (https://github.com/hapijs/joi)
Requires fn library (https://github.com/FPtje/GModFunctional),
Example:
```lua
local schema = tc.checkTable{
name = tc.addHint(isstring, "The name must be a string!"),
id = tc.addHint(isnumber, "The id must be a number!"),
gender = tc.addHint(tc.oneOf{"male", "female", "carp"}, "Gender missing or not recognised!", {"Perhaps you are a carp?"}),
}
local correct, err, hints = schema({name = "Dick", id = 3, gender = "carp"})
print(correct) -- true
local correct, err, hints = schema({name = "Dick", id = 3, gender = "crap"})
print(correct) -- false
print(err) -- Gender missing or not recognised!
PrintTable(hints) -- {"Perhaps you are a carp?"}
```
For further examples, including nesting and combining of schemas, please see the `unitTests` function for now.
--]]
module("tc", package.seeall)
-- Helpers for quick access to metatables
angle = FindMetaTable("Angle")
convar = FindMetaTable("ConVar")
effectdata = FindMetaTable("CEffectData")
entity = FindMetaTable("Entity")
file = FindMetaTable("File")
imaterial = FindMetaTable("IMaterial")
irestore = FindMetaTable("IRestore")
isave = FindMetaTable("ISave")
itexture = FindMetaTable("ITexture")
lualocomotion = FindMetaTable("CLuaLocomotion")
movedata = FindMetaTable("CMoveData")
navarea = FindMetaTable("CNavArea")
navladder = FindMetaTable("CNavLadder")
nextbot = FindMetaTable("NextBot")
npc = FindMetaTable("NPC")
pathfollower = FindMetaTable("PathFollower")
physobj = FindMetaTable("PhysObj")
player = FindMetaTable("Player")
recipientfilter = FindMetaTable("CRecipientFilter")
soundpatch = FindMetaTable("CSoundPatch")
takedamageinfo = FindMetaTable("CTakeDamageInfo")
usercmd = FindMetaTable("CUserCmd")
vector = FindMetaTable("Vector")
vehicle = FindMetaTable("Vehicle")
vmatrix = FindMetaTable("VMatrix")
weapon = FindMetaTable("Weapon")
-- Assert function, asserts a property and returns the error if false.
-- Allows f to override err and hints by simply returning them
addHint = function(f, err, hints) return function(...)
local res = {f(...)}
res[2] = err
res[3] = hints
return unpack(res)
end end
--[[ Validates a table against a schema
Capable of nesting
--]]
function checkTable(schema)
return function(tbl)
if not istable(tbl) then
return false, "Not a table!"
end
for k, v in pairs(schema or {}) do
local correct, err, hints = tbl[v] ~= nil
if isfunction(v) then correct, err, hints, replace, replaceWith = v(tbl[k], tbl) end
if not correct then
err = err or string.format("Element '%s' is corrupt!", k)
return correct, err, hints
end
-- Update the value
if correct and replace == true and replaceWith then
tbl[k] = replaceWith
end
end
return true
end
end
-- Returns whether a value is nil
isnil = fn.Curry(fn.Eq, 2)(nil)
-- Returns whether a value is a color
iscolor = IsColor
-- Returns true on the client
client = function() return CLIENT end
-- returns true on the server
server = function() return SERVER end
-- Optional value, when filled in it must meet the conditions
optional = function(...) return fn.FOr{isnil, ...} end
-- Default value, implies optional. Only works in combination with tc.checkTable
-- Note that the tc.addHint is to be the second parameter of default.
-- tc.addHint(tc.default(x)) does NOT work, default(x, tc.addHint(...)) does.
-- example: tc.checkTable{test = tc.default(3, tc.addHint(isnumber, "must be a number"))}
-- example: tc.checkTable{test = tc.default(3)}
default = function(def, f)
return function(val, ...)
if val == nil then
-- second return value is the default value. Expects parent function to actually change the value
return true, nil, nil, true, def
end
-- Return in if statement rather than "return f and f(val) or true" to allow multiple return values
if f then return f(val, ...) else return true end
end
end
-- A table of which each element must meet condition f
-- i.e. "this must be a table of xxx"
-- example: tc.tableOf(isnumber) demands that the table contains only numbers
tableOf = function(f) return function(tbl, parentTbl)
if not istable(tbl) then return false end
for _, v in pairs(tbl) do
local res = {f(v, parentTbl)}
if not res[1] then
return unpack(res)
end
end
return true
end end
-- Checks whether a value is amongst a given set of values
-- exapmle: tc.oneOf{"jobs", "entities", "shipments", "weapons", "vehicles", "ammo"}
oneOf = function(f) return fp{table.HasValue, f} end
-- A table that is non-empty, also useful for wrapping around tableOf
-- example: tc.nonEmpty(tc.tableOf(isnumber))
-- example: tc.nonEmpty() -- just checks that the table is non-empty
nonEmpty = function(f) return function(tbl, parentTbl)
if not istable(tbl) or table.IsEmpty(tbl) then return false end
if not f then return true end
return f(tbl, parentTbl)
end end
-- Number check: minimum
min = function(n) return fn.FAnd{isnumber, fp{fn.Lte, n}} end
-- Number check: maximum
max = function(n) return fn.FAnd{isnumber, fp{fn.Gte, n}} end
-- Number check: positive
positive = min(0)
-- Number check: negative
negative = max(0)
-- Whether the input matches regex
-- Note: uses string.match, so it doesn't support full regex.
-- May also allow numbers, since string.match also accepts numbers.
-- Note, also matches on substrings. Use ^pattern$ for a full match.
regex = function(pattern, startpos) return function(val)
return (isstring(val) or isnumber(val)) and tobool(string.match(val, pattern, startpos))
end end
-- Requires that the value only contains alphanumeric characters
alphanum = regex("^[a-zA-Z0-9]+$")
-- Test cases. Also serve as nice examples
function unitTests()
local id = 0
-- unit test helper functions
local function checkCorrect(correct, err, hints)
id = id + 1
if correct ~= true then
print(id, "Incorrect value that should be correct!", correct, err, hints)
if hints then PrintTable(hints) end
return
end
print(id, "Correct")
end
local function checkIncorrect(correct, err, hints)
id = id + 1
if correct then
print(id, "Correct value that should be incorrect!", correct, err, hints)
if hints then PrintTable(hints) end
return
end
print(id, "Correct")
end
--[[
Simple value schema. Checks whether the input is a number.
]]
local simpleSchema = tc.addHint(isnumber, "Must be a number!")
-- This is how a schema is to be used. Just call it with the value you want to check.
-- In further unit tests, the schema function is immediately called inside the checkCorrect/checIncorrect call for brevity
local correct, err, hints = simpleSchema(3)
checkCorrect(correct, err, hints)
--[[
Simple table schema
]]
local simpleTableSchema = tc.checkTable{
name = tc.addHint(isstring, "The name must be a string!"),
id = tc.addHint(isnumber, "The id must be a number!"),
gender = tc.addHint(tc.oneOf{"male", "female", "carp"}, "Gender missing or not recognised!", {"Perhaps you are a carp?"}),
nilthing = tc.addHint(tc.isnil, "nilthing must be nil"),
nonEmpty = tc.addHint(tc.nonEmpty(tc.tableOf(isnumber)), "nonEmpty not table of numbers"),
optnum = tc.addHint(tc.optional(isnumber), "optnum given, but not a number"),
strnum = tc.addHint(fn.FOr{isstring, isnumber}, "strnum must either be a string or a number"),
minmax = tc.addHint(fn.FAnd{tc.min(5), tc.max(10)}),
pos = tc.addHint(tc.optional(tc.positive)),
regx = tc.addHint(tc.optional(tc.regex("[a-z]+"))),
letters = tc.addHint(tc.optional(tc.alphanum)),
}
checkCorrect(simpleTableSchema({name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 5, regx = "asdf", letters = "asdfj", pos = 3}))
-- Counterexamples, should throw errors
local badTables = {
{},
{name = 1, id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 7},
{name = "Dick", id = "3", gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 7},
{name = "Dick", id = 3, gender = "other", nonEmpty = {1,2,3}, strnum = "str", minmax = 7},
{name = "Dick", id = 3, gender = "carp", nonEmpty = {}, strnum = "str", minmax = 7},
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = {}, minmax = 7},
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", optnum = "nope", minmax = 7},
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 4},
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 11},
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str"},
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 7, regx = "666"},
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 7, regx = "asdf", letters = ">:D"},
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 7, regx = "asdf", letters = ">:D", pos = -1},
}
for _, tbl in pairs(badTables) do
checkIncorrect(simpleTableSchema(tbl))
end
--[[
Table Schema with no explicit keys
]]
local nokeysSchema = tc.checkTable{
tc.addHint(isstring, "The first value must be a string."),
tc.addHint(isnumber, "The second value must be a number!"),
}
checkCorrect(nokeysSchema({"string", 3}))
--[[
Nested table schema
]]
local nestedSchema = tc.checkTable{
nested = tc.checkTable{
val = tc.addHint(isnumber, "'val' must be a number!")
}
}
checkCorrect(nestedSchema({nested = {val = 3}}))
checkIncorrect(nestedSchema({}))
--[[
Combining schemas using the fn library
]]
local andSchema = fn.FAnd{
tc.checkTable{
num = tc.addHint(isnumber, "num is not a number")
},
tc.checkTable{
str = tc.addHint(isstring, "str is not a string")
}
}
checkCorrect(andSchema({num = 1, str = "string!"}))
checkIncorrect(andSchema({num = 1}))
checkIncorrect(andSchema({str = "string!"}))
local orSchema = fn.FOr{
tc.checkTable{
num = tc.addHint(isnumber, "num is not a number")
},
tc.checkTable{
str = tc.addHint(isstring, "str is not a string")
}
}
checkCorrect(orSchema({num = 1}))
checkCorrect(orSchema({str = "string!"}))
--[[
Default value with a check
]]
local withDefaultSchema = tc.checkTable{
value = tc.default(10, tc.addHint(isnumber, "must be a number!"))
}
checkCorrect(withDefaultSchema({value = 30}))
checkIncorrect(withDefaultSchema({value = "string"}))
local empty = {}
checkCorrect(withDefaultSchema(empty))
if empty.value ~= 10 then
print("Default did NOT set the value to 10!")
else
print("Default test OK!")
end
--[[
Default value with no checks
]]
local withDefaultNoCheck = tc.checkTable{
value = tc.default(10)
}
checkCorrect(withDefaultNoCheck({}))
checkCorrect(withDefaultNoCheck({value = "string"}))
--[[
Creating your own checker function that returns an error message
When both the function and the tc.addHint define error messages, there's a conflict
]]
local function customCheck(val)
return false, "function error message", {"function hint"}
end
local customCheckSchema = tc.checkTable{
value = tc.addHint(customCheck, "added error message", {"added hint"})
}
checkIncorrect(customCheckSchema{value = 1})
checkIncorrect(customCheckSchema{})
_, err, hints = customCheckSchema{value = 2}
if err ~= "added error message" or hints[1] ~= "added hint" then
print("Wrong conflict solution", err, hints[1])
else
print("Conflict solution OK!")
end
print("finished")
end