--[[ Author: [Spyhawk] License: ISC Website: http://www.etlegacy.com Mod: compatible with Legacy mod only Description: Skill Rating (aka TrueSkill) data management Use in conjunction with g_skillRating cvar ]]-- --[[ TODO: * 0.1: handle basic rating update functionality * 0.2: handle disconnected then reconnected clients * 0.3: handle clients that left * 0.4: handle map bias parameter * 0.5: add useful commands ]]-- -- Lua module version local version = "0.2" -- load sqlite driver (or mysql..) local luasql = require "luasql.sqlite3" local env -- environment object local con -- database connection local cur -- cursor -- check feature local g_skillRating = tonumber(et.trap_Cvar_Get("g_skillRating")) --[[ Functions ]]-- -- database helper function -- returns database rows matching sql_statement function rows(connection, sql_statement) local cursor = assert(connection:execute (sql_statement)) return function () return cursor:fetch() end end -- con:prepare with bind_names should be used to prevent SQL injections -- but it isn't currently implemented in LuaSQL function validateGUID(clientNum, guid) -- allow only alphanumeric characters in guid if(string.match(guid, "%W")) then -- Invalid characters detected. We should probably drop this client et.G_Print("^1[Skill Rating]:^7 User with ID " .. clientNum .. " has an invalid GUID: " .. guid .. "\n") et.trap_SendServerCommand (clientNum, "cpm \"^2Your Skill Rating won't be saved because you have an invalid GUID!\n\"") return false end return true end -- saves SR values of a player with id 'clientNum' into database function saveSR(clientNum) local name = et.Info_ValueForKey(et.trap_GetUserinfo(clientNum), "name") local guid = et.Info_ValueForKey(et.trap_GetUserinfo(clientNum), "cl_guid") if not validateGUID(clientNum, guid) then return end cur = assert(con:execute(string.format("SELECT * FROM users WHERE guid='%s' LIMIT 1", guid))) local player = cur:fetch({}, 'a') if not player then -- should not happen et.G_Print("^1[Skill Rating]:^7 User not found in database!\n") -- cur:close() return else -- save data cur = assert(con:execute(string.format([[UPDATE users SET last_seen='%s', mu='%f', sigma='%f' WHERE guid='%s']], os.date("%Y-%m-%d %H:%M:%S"), et.gentity_get(clientNum, "sess.mu"), et.gentity_get(clientNum, "sess.sigma"), guid ))) end -- cur:close() end --[[ Callbacks ]]-- -- called when game initializes function et_InitGame(levelTime, randomSeed, restart) -- register name of this module et.RegisterModname("Skill Rating " .. version) -- check status if tonumber(et.trap_Cvar_Get("g_skillRating")) < 1 then return end -- create environement object env = assert(luasql.sqlite3()) -- connect to database con = assert(env:connect("rating.sqlite")) -- drop database -- cur = assert(con:execute("DROP TABLE users")) cur = assert(con:execute("DROP TABLE IF EXISTS match")) -- create database cur = assert(con:execute[[ CREATE TABLE IF NOT EXISTS users( guid VARCHAR(64), last_seen VARCHAR(64), mu REAL, sigma REAL, UNIQUE (guid) ) ]]) cur = assert(con:execute[[ CREATE TABLE match( guid VARCHAR(64), time_axis INT, time_allies INT, UNIQUE (guid) ) ]]) --cur:close() end -- called when game shuts down function et_ShutdownGame(restart) -- check status if g_skillRating < 1 then return end -- clean up cur:close() con:close() env:close() end -- called every server frame function et_RunFrame(levelTime) -- check status if g_skillRating < 1 then return end -- check gamestate changes gamestate = tonumber(et.trap_Cvar_Get("gamestate")) if oldgamestate ~= gamestate then oldgamestate = tonumber(et.trap_Cvar_Get("gamestate")) -- GS_WARMUP -- if gamestate == 1 then -- et.G_Print("^1[Skill Rating]:^7 GS_WARMUP\n") -- end -- GS_PLAYING -- if gamestate == 0 then -- et.G_Print("^1[Skill Rating]:^7 GS_PLAYING\n") -- end -- GS_INTERMISSION if gamestate == 3 then -- et.G_Print("^1[Skill Rating]:^7 GS_INTERMISSION\n") local clientNum = 0 local maxclients = tonumber(et.trap_Cvar_Get("sv_maxclients")) -- iterate through clients while clientNum < maxclients do local cs = et.trap_GetConfigstring(tonumber(et.CS_PLAYERS) + clientNum) -- save new ratings if cs ~= nil and cs ~= "" then saveSR(clientNum) end clientNum = clientNum + 1 end end end end -- called for every ClientConnect function et_ClientConnect(clientNum, firstTime, isBot) -- check status if g_skillRating < 1 then return end end -- called for every ClientDisconnect function et_ClientDisconnect(clientNum) -- check status if g_skillRating < 1 then return end local guid = et.Info_ValueForKey(et.trap_GetUserinfo(clientNum), "cl_guid") local name = et.Info_ValueForKey(et.trap_GetUserinfo(clientNum), "name") if not validateGUID(clientNum, guid) then return end cur = assert(con:execute(string.format("SELECT * FROM users WHERE guid='%s' LIMIT 1", guid))) local user = cur:fetch({}, 'a') if not user then -- should not happen et.G_Print("^1[Skill Rating]:^7 User not found in database!\n") -- cur:close() return else cur = assert(con:execute(string.format([[UPDATE users SET last_seen='%s' WHERE guid='%s']], os.date("%Y-%m-%d %H:%M:%S"), guid ))) cur = assert(con:execute(string.format([[UPDATE match SET time_axis='%i', time_allies='%i' WHERE guid='%s']], et.gentity_get(clientNum, "sess.time_axis"), et.gentity_get(clientNum, "sess.time_allies"), guid ))) -- cur:close() end -- cur:close() end -- called for every ClientBegin function et_ClientBegin(clientNum) -- check status if g_skillRating < 1 then return end local guid = et.Info_ValueForKey(et.trap_GetUserinfo(clientNum), "cl_guid") local name = et.Info_ValueForKey(et.trap_GetUserinfo(clientNum), "name") if not validateGUID(clientNum, guid) then return end cur = assert(con:execute(string.format("SELECT * FROM users WHERE guid='%s'", guid))) local user = cur:fetch({}, 'a') if not user then -- first time this user is seen et.trap_SendServerCommand(clientNum, "cpm \"^2[Skill Rating]:^7 Welcome, " .. name .. "^7! You are playing on an Skill Rating enabled server\n\"") -- use default values cur = assert(con:execute(string.format("INSERT INTO users VALUES ('%s', '%s', '%f', '%f')", guid, os.date("%Y-%m-%d %H:%M:%S"), 25, 25/3 ))) cur = assert(con:execute(string.format("INSERT INTO match VALUES ('%s', '%i', '%i')", guid, 0, 0 ))) -- cur:close() else -- load current rating et.gentity_set(clientNum, "sess.mu", tonumber(user.mu)) et.gentity_set(clientNum, "sess.sigma", tonumber(user.sigma)) -- create copy for delta rating et.gentity_set(clientNum, "sess.oldmu", tonumber(user.mu)) et.gentity_set(clientNum, "sess.oldsigma", tonumber(user.sigma)) et.trap_SendServerCommand(clientNum, string.format("cpm \"^2[Skill Rating]:^7 Welcome back, %s^7! Your rating is ^3%.2f\n\"", name, math.max(user.mu - 3 * user.sigma, 0)) ) -- load current play time, if reconnecting to the same match cur = assert(con:execute(string.format("SELECT * FROM match WHERE guid='%s'", guid))) local player = cur:fetch({}, 'a') if player then et.gentity_set(clientNum, "sess.time_axis", tonumber(player.time_axis)) et.gentity_set(clientNum, "sess.time_allies", tonumber(player.time_allies)) end end -- cur:close() end -- called for every client command -- return 1 if intercepted, 0 if passthrough function et_ClientCommand(clientNum, cmd) -- check status if g_skillRating < 1 then return 0 end -- local cmd = et.trap_Argv(0) cmd = string.lower(cmd) -- display current rating if cmd == "!sr" then local mu = et.gentity_get(clientNum, "sess.mu") local sigma = et.gentity_get(clientNum, "sess.sigma") et.trap_SendServerCommand(clientNum, string.format("cpm \"^2[Skill Rating]:^7 Your rating is ^3%.2f\n\"", math.max(mu - 3 * sigma, 0)) ) return 1 end return 0 end -- called for every console command -- return 1 if intercepted, 0 if passthrough function et_ConsoleCommand() -- check status if g_skillRating < 1 then return 0 end local cmd = et.trap_Argv(0) cmd = string.lower(cmd) -- drop users if cmd == "!srdbdrop" then -- FIXME: LuaSQL: database table is locked cur = assert(con:execute("DROP TABLE users")) et.G_Print("^2[Skill Rating]:^7 Dropped users table\n") -- cur:close() return 1 end -- drop match players time if cmd == "!srmatchdrop" then -- FIXME: LuaSQL: database table is locked cur = assert(con:execute("DROP TABLE match")) et.G_Print("^2[Skill Rating]:^7 Dropped match table\n") -- cur:close() return 1 end -- list all users if cmd == "!srdblist" then cur = assert(con:execute("SELECT COUNT(*) FROM users")) et.G_Print("^2[Skill Rating]:^3 " .. tonumber(cur:fetch(row, 'a')) .. "^7 users in database\n") local guid, lastseen, mu, sigma for guid, lastseen, mu, sigma in rows(con, "SELECT * FROM users") do et.G_Print(string.format("\tGUID %s\tLast seen: %s mu: ^1%.2f^: sigma: ^4%.2f^: Rating: ^3%.2f\n", guid, lastseen, mu, sigma, math.max(mu - 3 * sigma, 0)) ) end -- cur:close() return 1 end -- list all match players if cmd == "!srmatchlist" then cur = assert(con:execute("SELECT COUNT(*) FROM match")) et.G_Print("^2[Skill Rating]:^3 " .. tonumber(cur:fetch(row, 'a')) .. "^7 players in match\n") local guid, time_axis, time_allies for guid, time_axis, time_allies in rows(con, "SELECT * FROM match") do et.G_Print(string.format("\tGUID %s\tTime Axis: %i\tTime Allies: %i\n", guid, time_axis, time_allies) ) end -- cur:close() return 1 end return 0 end