diff --git a/src/MHServerEmu.Core/Helpers/FileHelper.cs b/src/MHServerEmu.Core/Helpers/FileHelper.cs
index 3c92b572..a21f7dcb 100644
--- a/src/MHServerEmu.Core/Helpers/FileHelper.cs
+++ b/src/MHServerEmu.Core/Helpers/FileHelper.cs
@@ -11,6 +11,14 @@ namespace MHServerEmu.Core.Helpers
public static readonly string ServerRoot = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
public static readonly string DataDirectory = Path.Combine(ServerRoot, "Data");
+ ///
+ /// Returns a path relative to server root directory.
+ ///
+ public static string GetRelativePath(string filePath)
+ {
+ return Path.GetRelativePath(ServerRoot, filePath);
+ }
+
///
/// Deserializes a from a JSON file located at the specified path.
///
diff --git a/src/MHServerEmu.DatabaseAccess/Data/Account.db b/src/MHServerEmu.DatabaseAccess/Data/Account.db
deleted file mode 100644
index 7c1a76f3..00000000
Binary files a/src/MHServerEmu.DatabaseAccess/Data/Account.db and /dev/null differ
diff --git a/src/MHServerEmu.DatabaseAccess/Data/SQLite/InitializeDatabase.sql b/src/MHServerEmu.DatabaseAccess/Data/SQLite/InitializeDatabase.sql
new file mode 100644
index 00000000..c35da365
--- /dev/null
+++ b/src/MHServerEmu.DatabaseAccess/Data/SQLite/InitializeDatabase.sql
@@ -0,0 +1,72 @@
+-- Initialize a new database file using the current schema version
+
+PRAGMA user_version = 1;
+
+CREATE TABLE "Account" (
+ "Id" INTEGER NOT NULL UNIQUE,
+ "Email" TEXT NOT NULL UNIQUE,
+ "PlayerName" TEXT NOT NULL UNIQUE,
+ "PasswordHash" BLOB NOT NULL,
+ "Salt" BLOB NOT NULL,
+ "UserLevel" INTEGER NOT NULL,
+ "IsBanned" INTEGER NOT NULL,
+ "IsArchived" INTEGER NOT NULL,
+ "IsPasswordExpired" INTEGER NOT NULL,
+ PRIMARY KEY("Id")
+);
+
+CREATE TABLE "Player" (
+ "DbGuid" INTEGER NOT NULL UNIQUE,
+ "ArchiveData" BLOB,
+ "StartTarget" INTEGER,
+ "StartTargetRegionOverride" INTEGER,
+ "AOIVolume" INTEGER,
+ FOREIGN KEY("DbGuid") REFERENCES "Account"("Id") ON DELETE CASCADE,
+ PRIMARY KEY("DbGuid")
+);
+
+CREATE TABLE "Avatar" (
+ "DbGuid" INTEGER NOT NULL UNIQUE,
+ "ContainerDbGuid" INTEGER,
+ "InventoryProtoGuid" INTEGER,
+ "Slot" INTEGER,
+ "EntityProtoGuid" INTEGER,
+ "ArchiveData" BLOB,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
+ PRIMARY KEY("DbGuid")
+);
+
+CREATE TABLE "TeamUp" (
+ "DbGuid" INTEGER NOT NULL UNIQUE,
+ "ContainerDbGuid" INTEGER,
+ "InventoryProtoGuid" INTEGER,
+ "Slot" INTEGER,
+ "EntityProtoGuid" INTEGER,
+ "ArchiveData" BLOB,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
+ PRIMARY KEY("DbGuid")
+);
+
+CREATE TABLE "Item" (
+ "DbGuid" INTEGER NOT NULL UNIQUE,
+ "ContainerDbGuid" INTEGER,
+ "InventoryProtoGuid" INTEGER,
+ "Slot" INTEGER,
+ "EntityProtoGuid" INTEGER,
+ "ArchiveData" BLOB,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "Avatar"("DbGuid") ON DELETE CASCADE,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "TeamUp"("DbGuid") ON DELETE CASCADE,
+ PRIMARY KEY("DbGuid")
+);
+
+CREATE TABLE "ControlledEntity" (
+ "DbGuid" INTEGER NOT NULL UNIQUE,
+ "ContainerDbGuid" INTEGER,
+ "InventoryProtoGuid" INTEGER,
+ "Slot" INTEGER,
+ "EntityProtoGuid" INTEGER,
+ "ArchiveData" BLOB,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "Avatar"("DbGuid") ON DELETE CASCADE,
+ PRIMARY KEY("DbGuid")
+);
\ No newline at end of file
diff --git a/src/MHServerEmu.DatabaseAccess/Data/SQLite/Migrations/0.sql b/src/MHServerEmu.DatabaseAccess/Data/SQLite/Migrations/0.sql
new file mode 100644
index 00000000..57864f82
--- /dev/null
+++ b/src/MHServerEmu.DatabaseAccess/Data/SQLite/Migrations/0.sql
@@ -0,0 +1,60 @@
+-- Delete old placeholder tables while keeping existing account records
+DROP TABLE Avatar;
+DROP TABLE Player;
+
+-- Initialize new tables for storing persistent entities
+CREATE TABLE "Player" (
+ "DbGuid" INTEGER NOT NULL UNIQUE,
+ "ArchiveData" BLOB,
+ "StartTarget" INTEGER,
+ "StartTargetRegionOverride" INTEGER,
+ "AOIVolume" INTEGER,
+ FOREIGN KEY("DbGuid") REFERENCES "Account"("Id") ON DELETE CASCADE,
+ PRIMARY KEY("DbGuid")
+);
+
+CREATE TABLE "Avatar" (
+ "DbGuid" INTEGER NOT NULL UNIQUE,
+ "ContainerDbGuid" INTEGER,
+ "InventoryProtoGuid" INTEGER,
+ "Slot" INTEGER,
+ "EntityProtoGuid" INTEGER,
+ "ArchiveData" BLOB,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
+ PRIMARY KEY("DbGuid")
+);
+
+CREATE TABLE "TeamUp" (
+ "DbGuid" INTEGER NOT NULL UNIQUE,
+ "ContainerDbGuid" INTEGER,
+ "InventoryProtoGuid" INTEGER,
+ "Slot" INTEGER,
+ "EntityProtoGuid" INTEGER,
+ "ArchiveData" BLOB,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
+ PRIMARY KEY("DbGuid")
+);
+
+CREATE TABLE "Item" (
+ "DbGuid" INTEGER NOT NULL UNIQUE,
+ "ContainerDbGuid" INTEGER,
+ "InventoryProtoGuid" INTEGER,
+ "Slot" INTEGER,
+ "EntityProtoGuid" INTEGER,
+ "ArchiveData" BLOB,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "Avatar"("DbGuid") ON DELETE CASCADE,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "TeamUp"("DbGuid") ON DELETE CASCADE,
+ PRIMARY KEY("DbGuid")
+);
+
+CREATE TABLE "ControlledEntity" (
+ "DbGuid" INTEGER NOT NULL UNIQUE,
+ "ContainerDbGuid" INTEGER,
+ "InventoryProtoGuid" INTEGER,
+ "Slot" INTEGER,
+ "EntityProtoGuid" INTEGER,
+ "ArchiveData" BLOB,
+ FOREIGN KEY("ContainerDbGuid") REFERENCES "Avatar"("DbGuid") ON DELETE CASCADE,
+ PRIMARY KEY("DbGuid")
+);
diff --git a/src/MHServerEmu.DatabaseAccess/IDBManager.cs b/src/MHServerEmu.DatabaseAccess/IDBManager.cs
index ef1398a7..2af1e505 100644
--- a/src/MHServerEmu.DatabaseAccess/IDBManager.cs
+++ b/src/MHServerEmu.DatabaseAccess/IDBManager.cs
@@ -35,7 +35,7 @@ namespace MHServerEmu.DatabaseAccess
public bool UpdateAccount(DBAccount account);
///
- /// Updates the Player and Avatar tables in the database with the data from the provided .
+ /// Updates persistent game data for the provided .
///
public bool UpdateAccountData(DBAccount account);
diff --git a/src/MHServerEmu.DatabaseAccess/MHServerEmu.DatabaseAccess.csproj b/src/MHServerEmu.DatabaseAccess/MHServerEmu.DatabaseAccess.csproj
index 4be7efcd..7f01e441 100644
--- a/src/MHServerEmu.DatabaseAccess/MHServerEmu.DatabaseAccess.csproj
+++ b/src/MHServerEmu.DatabaseAccess/MHServerEmu.DatabaseAccess.csproj
@@ -27,7 +27,10 @@
-
+
+ PreserveNewest
+
+
PreserveNewest
diff --git a/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteDBManager.cs b/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteDBManager.cs
index f61b6068..8893ef3c 100644
--- a/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteDBManager.cs
+++ b/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteDBManager.cs
@@ -8,6 +8,8 @@ namespace MHServerEmu.DatabaseAccess.SQLite
{
public class SQLiteDBManager : IDBManager
{
+ private const int CurrentSchemaVersion = 1;
+
private static readonly Logger Logger = LogManager.CreateLogger();
private string _connectionString;
@@ -19,10 +21,20 @@ namespace MHServerEmu.DatabaseAccess.SQLite
public bool Initialize()
{
string dbPath = Path.Combine(FileHelper.DataDirectory, "Account.db");
- if (File.Exists(dbPath) == false) return Logger.FatalReturn(false, $"Initialize(): {dbPath} not found");
-
_connectionString = $"Data Source={dbPath}";
- Logger.Info("Established database connection");
+
+ if (File.Exists(dbPath) == false)
+ {
+ if (InitializeDatabaseFile(dbPath) == false)
+ return false;
+ }
+ else
+ {
+ if (MigrateDatabaseFileToCurrentSchema(dbPath) == false)
+ return false;
+ }
+
+ Logger.Info($"Initialized database");
return true;
}
@@ -44,7 +56,8 @@ namespace MHServerEmu.DatabaseAccess.SQLite
public bool QueryIsPlayerNameTaken(string playerName)
{
- using SQLiteConnection connection = new(_connectionString);
+ using SQLiteConnection connection = GetConnection();
+
// This check is case insensitive (COLLATE NOCASE)
var results = connection.Query("SELECT PlayerName FROM Account WHERE PlayerName = @PlayerName COLLATE NOCASE", new { PlayerName = playerName });
return results.Any();
@@ -52,8 +65,7 @@ namespace MHServerEmu.DatabaseAccess.SQLite
public bool InsertAccount(DBAccount account)
{
- using SQLiteConnection connection = new(_connectionString);
- connection.Open();
+ using SQLiteConnection connection = GetConnection();
try
{
@@ -70,7 +82,7 @@ namespace MHServerEmu.DatabaseAccess.SQLite
public bool UpdateAccount(DBAccount account)
{
- using SQLiteConnection connection = new(_connectionString);
+ using SQLiteConnection connection = GetConnection();
try
{
@@ -87,8 +99,7 @@ namespace MHServerEmu.DatabaseAccess.SQLite
public bool UpdateAccountData(DBAccount account)
{
- using SQLiteConnection connection = new(_connectionString);
- connection.Open();
+ using SQLiteConnection connection = GetConnection();
// Use a transaction to make sure all data is saved
using (var transaction = connection.BeginTransaction())
@@ -143,6 +154,103 @@ namespace MHServerEmu.DatabaseAccess.SQLite
}
}
+ ///
+ /// Creates and opens a new .
+ ///
+ private SQLiteConnection GetConnection()
+ {
+ SQLiteConnection connection = new(_connectionString);
+ connection.Open();
+ return connection;
+ }
+
+ private bool InitializeDatabaseFile(string dbPath)
+ {
+ // Create a new database file if it does not exist
+ string initializationScript = SQLiteScripts.GetInitializationScript();
+ if (initializationScript == string.Empty)
+ return Logger.ErrorReturn(false, "InitializeDatabaseFile(): Failed to get database initialization script");
+
+ SQLiteConnection.CreateFile(dbPath);
+ using SQLiteConnection connection = GetConnection();
+ connection.Execute(initializationScript);
+
+ Logger.Info($"Initialized a new database file at {Path.GetRelativePath(FileHelper.ServerRoot, dbPath)} using schema version {CurrentSchemaVersion}");
+ return true;
+ }
+
+ private bool MigrateDatabaseFileToCurrentSchema(string dbPath)
+ {
+ // Migrate existing database if needed
+ int schemaVersion = GetSchemaVersion();
+ if (schemaVersion > CurrentSchemaVersion)
+ return Logger.ErrorReturn(false, $"Initialize(): Existing database file uses unsupported schema version {schemaVersion} (current = {CurrentSchemaVersion})");
+
+ Logger.Info($"Found existing database file with schema version {schemaVersion} (current = {CurrentSchemaVersion})");
+
+ if (schemaVersion == CurrentSchemaVersion)
+ return true;
+
+ // Create a back to fall back to if something goes wrong
+ string backupDbPath = $"{dbPath}.backup";
+ File.Copy(dbPath, backupDbPath);
+
+ using SQLiteConnection connection = GetConnection();
+ bool success = true;
+
+ while (schemaVersion < CurrentSchemaVersion)
+ {
+ Logger.Info($"Migrating version {schemaVersion} => {schemaVersion + 1}...");
+
+ string migrationScript = SQLiteScripts.GetMigrationScript(schemaVersion);
+ if (migrationScript == string.Empty)
+ {
+ Logger.Error($"MigrateDatabaseFileToCurrentSchema(): Failed to get database migration script for version {schemaVersion}");
+ success = false;
+ break;
+ }
+
+ connection.Execute(migrationScript);
+ connection.Execute($"PRAGMA user_version = {++schemaVersion}");
+ }
+
+ success &= GetSchemaVersion() == CurrentSchemaVersion;
+
+ if (success == false)
+ {
+ // Restore backup
+ File.Delete(dbPath);
+ File.Move(backupDbPath, dbPath);
+ return Logger.ErrorReturn(false, "MigrateDatabaseFileToCurrentSchema(): Migration failed, backup restored");
+ }
+
+ Logger.Info($"Successfully migrated to schema version {CurrentSchemaVersion}");
+ return true;
+ }
+
+ ///
+ /// Returns the user_version value of the current database.
+ ///
+ private int GetSchemaVersion()
+ {
+ using SQLiteConnection connection = GetConnection();
+
+ var queryResult = connection.Query("PRAGMA user_version");
+ if (queryResult.Any())
+ return queryResult.First();
+
+ return Logger.WarnReturn(-1, "GetSchemaVersion(): Failed to query user_version from the DB");
+ }
+
+ ///
+ /// Sets the user_version value of the current database.
+ ///
+ private void SetSchemaVersion(int version)
+ {
+ using SQLiteConnection connection = GetConnection();
+ connection.Execute($"PRAGMA user_version = {version}");
+ }
+
///
/// Loads account data for the specified and maps relations.
///
@@ -177,12 +285,18 @@ namespace MHServerEmu.DatabaseAccess.SQLite
}
}
+ ///
+ /// Loads instances belonging to the specified container from the specified table.
+ ///
private static IEnumerable LoadEntitiesFromTable(SQLiteConnection connection, string tableName, long containerDbGuid)
{
var @params = new { ContainerDbGuid = containerDbGuid };
return connection.Query($"SELECT * FROM {tableName} WHERE ContainerDbGuid = @ContainerDbGuid", @params);
}
+ ///
+ /// Updates instances belonging to the specified container in the specified table using the provided .
+ ///
private static void UpdateEntityTable(SQLiteConnection connection, SQLiteTransaction transaction, string tableName,
long containerDbGuid, DBEntityCollection dbEntityCollection)
{
diff --git a/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteScripts.cs b/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteScripts.cs
new file mode 100644
index 00000000..585e611f
--- /dev/null
+++ b/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteScripts.cs
@@ -0,0 +1,28 @@
+using MHServerEmu.Core.Helpers;
+using MHServerEmu.Core.Logging;
+
+namespace MHServerEmu.DatabaseAccess.SQLite
+{
+ public static class SQLiteScripts
+ {
+ private static readonly Logger Logger = LogManager.CreateLogger();
+
+ public static string GetInitializationScript()
+ {
+ string filePath = Path.Combine(FileHelper.DataDirectory, "SQLite", "InitializeDatabase.sql");
+ if (File.Exists(filePath) == false)
+ return Logger.WarnReturn(string.Empty, $"GetDatabaseInitializationScript(): Initialization script file not found at {FileHelper.GetRelativePath(filePath)}");
+
+ return File.ReadAllText(filePath);
+ }
+
+ public static string GetMigrationScript(int currentVersion)
+ {
+ string filePath = Path.Combine(FileHelper.DataDirectory, "SQLite", "Migrations", $"{currentVersion}.sql");
+ if (File.Exists(filePath) == false)
+ return Logger.WarnReturn(string.Empty, $"GetMigrationScript(): Migration script for version {currentVersion} not found at {FileHelper.GetRelativePath(filePath)}");
+
+ return File.ReadAllText(filePath);
+ }
+ }
+}