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.
UpdateandDeleteneed aWhere, 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) andRPGXDatabaseSwitched(reload afterrpgx_db_use) if you keep data in memory.