RPG Xdocumentation About RPG X

Database

SQLite or MySQL, moving between them, every table, and saving from addons.

Everything RPG X saves goes to the server's own sv.db (SQLite) by default. Nothing to set up; it's wiped along with the server files (like a reinstall).

It can all move to an external MySQL / MariaDB (InnoDB) instead. That needs the mysqloo module on the server: garrysmod/lua/bin/gmsv_mysqloo_linux64.dll for this (64-bit Linux) server. It can't be shipped in the Workshop addon.

All database commands are server console only (or RCON), since they handle credentials:

Command Does
rpgx_db_mysql <host> <user> <password> <database> [port] Tests the connection, then saves it. Doesn't switch anything yet. Port defaults to 3306.
rpgx_db_migrate Copies every table from sv.db into MySQL, then switches to MySQL. Refuses if the MySQL tables already have data.
rpgx_db_migrate overwrite Copies anyway; players in both get the sv.db version, others in MySQL are kept.
rpgx_db_use sqlite / rpgx_db_use mysql Switches without copying. Online players are saved to the old one, then reloaded from the new one.
rpgx_db_status Where everything is stored, whether MySQL is connected, row counts in sv.db.

Typical move to MySQL:

rpgx_db_mysql db.example.com rpgx secretpassword rpgx
rpgx_db_migrate
  • The settings are kept in garrysmod/data/rpgx/database.json (server-side only, never sent to players). The password is stored there in plain text, so keep that folder private.
  • sv.db is left untouched by a migration, as a backup.
  • If MySQL is unreachable at startup, nothing loads or saves (so nothing gets overwritten) and it retries every 30 seconds.

Tables #

All created automatically, in both sv.db and MySQL. Foreign keys are ON DELETE CASCADE: deleting a player deletes their inventory and its items.

Table Columns
rpgx_player steamid (SteamID64), name (last known), first_seen, last_seen (unix time) One row per player. Everything saved per player references it.
rpgx_player_rank steamid → rpgx_player, rank_id (admin / owner), migrated (copied into ULX) Only players above user have a row.
rpgx_inventory id, steamid → rpgx_player, slots (NULL = rpgx_inventory_slots), selected (hotbar slot) One per player for now.
rpgx_inventory_item id, inventory_id → rpgx_inventory, slot, class, clip1, clip2, equipped (in hand) 1:n. class is the name the weapon is spawned by (weapon_pistol), since items live in addons, not the database.
rpgx_enemy_spawn id, map, x, y, z, ceiling, hostile, invincible, static, hive, level_min, level_max, respawn, number, created One per spawn point. map is a plain map name (gm_construct), not a key.
rpgx_enemy_spawn_npc spawn_id → rpgx_enemy_spawn, npc 1:n, which enemies can spawn at a point. npc is the spawn-menu name the NPC is spawned by (npc_zombie).
rpgx_npc_role spawn_id → rpgx_enemy_spawn, role (shop / questgiver) Points whose NPC has a role.
rpgx_shop_item spawn_id → rpgx_enemy_spawn, item (weapon class) 1:n, what a shop hands out.
rpgx_quest id, spawn_id → rpgx_enemy_spawn, number, name, repeatable, min_level, reward (weapon class or NULL) 1:n, a giver's quests in order.
rpgx_quest_requirement quest_id → rpgx_quest, required_id → rpgx_quest Quests that have to be finished first.
rpgx_quest_task id, quest_id → rpgx_quest, number, type, data (the type's options, JSON) 1:n, a quest's tasks in order.
rpgx_quest_task_requirement task_id → rpgx_quest_task, required_id → rpgx_quest_task Tasks that have to be done first.
rpgx_player_quest steamid → rpgx_player, quest_id → rpgx_quest, status (active / done), completions, tracked (the one on the HUD) A player's quests.
rpgx_player_task steamid → rpgx_player, task_id → rpgx_quest_task, progress (JSON) A player's progress in the tasks of active quests.
rpgx_player_resource steamid → rpgx_player, resource, spec, progress (spent towards the next training point) Per player and resource.
rpgx_player_training_point id, steamid → rpgx_player, resource, remaining (online seconds left) 1:n, training points.
rpgx_weapon class, resource, cost, damage, bullets, spread (NULL = unchanged), buffs (1 = its rpgx_weapon_buff rows replace SWEP.HitBuffs, none too) One per changed gun.
rpgx_weapon_buff class → rpgx_weapon, buff (buff id), chance (percent) 1:n, what a hit with the gun may apply.
rpgx_weapon_field class → rpgx_weapon, field (Primary.Damage, RPM...), kind (number / bool / string), value 1:n, a gun's changed SWEP values.

Ids are 32-character strings made by the server, so a save never has to wait for the database to hand one back, and an item keeps the same id for its whole life. Inventories saved in the first version (one JSON row per player) are converted automatically on startup.

Saving things from code (addons too) #

Everything that's saved goes through RPGX.Database (server side, available once the gamemode has loaded). Nobody writes SQL: you register tables and build queries, and it turns them into whatever the server uses, sv.db or MySQL, so rpgx_db_migrate covers your data too.

Tables. Register them once, parents before the tables that reference them:

local DB = RPGX.Database

DB:RegisterTable( "myaddon_bank", {
	columns = {
		{ "id", "id" },
		{ "steamid", "string" },
		{ "balance", "int" },
		{ "note", "text", null = true },
	},
	primary = { "id" },
	foreign = { steamid = "rpgx_player(steamid)" },   -- ON DELETE CASCADE
	index   = { "steamid" },
} )

Types: id (use DB:NewId() for values), string, text, int, number, bool. Columns are NOT NULL unless marked null = true; default = value gives a number or bool default.

Columns added to a spec later are added to the existing table on the next start (sv.db and MySQL). They need null = true or a default, since the rows already there need a value.

Queries are built by chaining, then :Run( callback ):

-- Select: callback( rows, err ). Rows come back typed: numbers, true/false, strings.
DB:Select( "myaddon_bank" ):Where( "steamid", steamid ):Run( function( rows, err ) end )

DB:Select( "rpgx_inventory_item" )
	:Columns( "class", "slot", "rpgx_player.name", { "rpgx_inventory.id", "inventory" } )
	:Join( "rpgx_inventory", "rpgx_inventory.id", "rpgx_inventory_item.inventory_id" )
	:LeftJoin( "rpgx_player", "rpgx_player.steamid", "rpgx_inventory.steamid" )
	:Where( "slot", "<=", 10 ):Where( "rpgx_player.name", "LIKE", "Maff%" )
	:OrderBy( "slot", "DESC" ):Limit( 20 ):Offset( 0 )
	:Run( function( rows, err ) end )

DB:Select( "myaddon_bank" ):Where( "balance", ">", 0 ):Count():Run( function( count, err ) end )
DB:Select( "myaddon_bank" ):Where( "id", id ):First( function( row, err ) end )   -- row or nil

-- Writes: callback( ok, err )
DB:Insert( "myaddon_bank" ):Values( { id = DB:NewId(), steamid = steamid, balance = 0 } ):Run( callback )
DB:Update( "myaddon_bank" ):Set( { balance = 50, note = DB.NULL } ):Where( "id", id ):Run( callback )
DB:Delete( "myaddon_bank" ):WhereIn( "id", { a, b } ):Run( callback )
DB:Upsert( "myaddon_bank" ):Values( row ):Keep( "created" ):Run( callback )   -- insert, or update if the key exists

-- Several writes, all or nothing: callback( ok, err )
DB:Transaction( function( tx )
	GAMEMODE:PlayerRecord( tx, steamid, name )   -- make sure rpgx_player has them
	tx:Insert( "myaddon_bank" ):Values( { id = DB:NewId(), steamid = steamid, balance = 100 } )
	tx:Update( "myaddon_bank" ):Set( { balance = 0 } ):Where( "id", oldId )
end, callback )
Method Does
:Where( column, value ) / :Where( column, op, value ) op: = != < <= > >= LIKE IN. Several are ANDed.
:WhereIn( column, list ), :WhereNull( column ), :WhereNotNull( column ) DB.NULL works as a value too.
:Columns( ... ) "column", "table.column" or { "table.column", "alias" }. Default: every column of the starting table.
:Join( table, a, b ) / :LeftJoin( ... ) Joins on a = b. A bare column name must belong to exactly one table in the query.
:OrderBy( column, "ASC"/"DESC" ), :Limit( n ), :Offset( n ), :Count(), :First( cb ) Select only.
:Values( row ) / :Set( values ) Only the columns present are written; DB.NULL writes a NULL.
:Keys( ... ), :Keep( ... ) Upsert: what counts as "exists" (default: primary key), and columns only written on insert.
  • Every table and column name is checked against the registered tables; a typo is a Lua error right away, and values are always sent as parameters, so nothing can inject SQL.
  • Update and Delete need a Where, so a whole table can't be wiped by accident.
  • DB:IsReady() is false while MySQL is unreachable or a migration runs; queries then fail with an error instead of running. Save later rather than lose data.
  • Hook RPGXDatabaseFlush (save everything now, wait = block) and RPGXDatabaseSwitched (reload after rpgx_db_use) if you keep data in memory.