Added preferences backend (#465)

* Added preferences backend

* Gender -> Sex

* ClientPreferencesManager properties

* Username validation

* Fixed client init

* WIP db

* Actually working sqlite db

* Dropped shitty sqlite libraries, dropped DbUp, added MigrationManager

* Added profile deletion, test

* Docs, sanity, tests, cleanup

* Cleaned up profile and appearance, fixed running on .net core

Co-authored-by: Pieter-Jan Briers <pieterjan.briers@gmail.com>
This commit is contained in:
DamianX
2019-12-22 13:47:34 +01:00
committed by Pieter-Jan Briers
parent c1b9bb348d
commit f19795edaf
22 changed files with 1009 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS "HumanoidCharacterProfiles" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
"Player" INTEGER NOT NULL,
"Slot" INTEGER NOT NULL,
"Name" TEXT NOT NULL,
"Age" INTEGER NOT NULL,
"Sex" TEXT NOT NULL,
"HairStyleName" TEXT NOT NULL,
"HairColor" TEXT NOT NULL,
"FacialHairStyleName" TEXT NOT NULL,
"FacialHairColor" TEXT NOT NULL,
"EyeColor" TEXT NOT NULL,
"SkinColor" TEXT NOT NULL,
FOREIGN KEY("Player") REFERENCES "PlayerPreferences"("Id")
);
CREATE TABLE IF NOT EXISTS "PlayerPreferences" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
"Username" TEXT NOT NULL UNIQUE,
"SelectedCharacterIndex" INTEGER NOT NULL
);

View File

@@ -0,0 +1,169 @@
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Dapper;
using JetBrains.Annotations;
using Microsoft.Data.Sqlite;
using Robust.Shared.Log;
namespace Content.Server.Preferences.Migrations
{
/// <summary>
/// Ensures database schemas are up to date.
/// </summary>
public static class MigrationManager
{
/// <summary>
/// Ensures the database schema for the given connection string is up to date.
/// </summary>
public static void PerformUpgrade(string connectionString)
{
using (var connection = new SqliteConnection(connectionString))
{
EnsureSchemaVersionTableExists(connection);
foreach (var migrationToRun in MigrationsToRun(connection))
{
Logger.InfoS("db", "Running migration {0}", migrationToRun.Id);
migrationToRun.Run(connection);
}
}
}
/// <summary>
/// Generated for each SQL file found.
/// </summary>
private class Migration
{
public readonly string Id;
private readonly string _sql;
public Migration(string id, string sql)
{
Id = id;
_sql = sql;
}
/// <summary>
/// Executes the query in <see cref="_sql"/> and logs this in the SchemaVersion table.
/// </summary>
public void Run(IDbConnection connection)
{
connection.Execute(_sql);
InsertMigrationLog(connection, Id);
}
}
private const string InsertMigrationLogQuery =
@"INSERT INTO SchemaVersion (Id) VALUES (@Id)";
/// <summary>
/// Inserts a <see cref="MigrationLog"/> in the SchemaVersion table.
/// </summary>
private static void InsertMigrationLog(IDbConnection connection, string id)
{
Logger.InfoS("db", "Completing migration {0}", id);
connection.Execute(InsertMigrationLogQuery, new {Id = id});
}
/// <summary>
/// An entry in the SchemaVersion table.
/// </summary>
[UsedImplicitly]
private class MigrationLog
{
public string Id;
public string Timestamp;
}
private const string GetRanMigrationsQuery =
@"SELECT Id, Timestamp FROM SchemaVersion ORDER BY Id COLLATE NOCASE";
/// <summary>
/// Fetches a collection of <see cref="MigrationLog"/> from the SchemaVersion table and returns it.
/// </summary>
private static IEnumerable<MigrationLog> RanMigrations(IDbConnection connection)
{
return connection.Query<MigrationLog>(GetRanMigrationsQuery);
}
/// <summary>
/// Finds all available migrations, returns those that haven't been run yet.
/// </summary>
private static List<Migration> MigrationsToRun(IDbConnection connection)
{
var discoveredMigrations = DiscoverMigrations(connection);
if (discoveredMigrations.Count == 0)
{
// No migrations found.
return null;
}
var ranMigrations = RanMigrations(connection);
// Filter out migrations that have already been executed
discoveredMigrations
.RemoveAll(migration => ranMigrations.Any(ranMigration => migration.Id == ranMigration.Id));
return discoveredMigrations;
}
/// <summary>
/// Given an embedded resource's full path returns its contents as a string.
/// </summary>
private static string ResourceAssemblyToString(string resourceName)
{
using (var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream(resourceName))
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
/// <summary>
/// Searches the current assembly for SQL migration files.
/// TODO: Filter by subfolder so that different databases use different sets of migrations.
/// </summary>
[NotNull]
private static List<Migration> DiscoverMigrations(IDbConnection connection)
{
var results = new List<Migration>();
var assembly = Assembly.GetExecutingAssembly();
foreach (var sqlResourceName in assembly
.GetManifestResourceNames()
.Where(IsValidMigrationFileName))
{
var splitName = sqlResourceName.Split('.');
// The second to last string in the list is the actual file name without the final ".sql"
var migrationId = splitName[splitName.Length - 2];
var sqlContents = ResourceAssemblyToString(sqlResourceName);
results.Add(new Migration(migrationId, sqlContents));
}
return results;
}
/// <summary>
/// A valid file name is "000_Initial.sql". A dot (from the path, not to be included in the filename itself),
/// three digits, a mandatory underscore, any number of characters, a mandatory ".sql".
/// </summary>
private static bool IsValidMigrationFileName(string name)
{
return Regex.IsMatch(name, @"\.\d\d\d_[a-zA-Z]+\.sql$");
}
private const string EnsureSchemaVersionTableExistsQuery =
@"CREATE TABLE IF NOT EXISTS SchemaVersion (
Id TEXT NOT NULL UNIQUE,
Timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)";
/// <summary>
/// Creates the SchemaVersion table if it doesn't exist.
/// </summary>
private static void EnsureSchemaVersionTableExists(IDbConnection connection)
{
connection.Execute(EnsureSchemaVersionTableExistsQuery);
}
}
}

View File

@@ -0,0 +1,220 @@
using System;
using System.Linq;
using Content.Server.Preferences.Migrations;
using Content.Shared.Preferences;
using Dapper;
using Microsoft.Data.Sqlite;
using Robust.Shared.Maths;
using static Content.Shared.Preferences.Sex;
namespace Content.Server.Preferences
{
/// <summary>
/// Provides methods to retrieve and update character preferences.
/// Don't use this directly, go through <see cref="ServerPreferencesManager"/> instead.
/// </summary>
public class PreferencesDatabase
{
private readonly string _databaseFilePath;
private readonly int _maxCharacterSlots;
public PreferencesDatabase(string databaseFilePath, int maxCharacterSlots)
{
_databaseFilePath = databaseFilePath;
_maxCharacterSlots = maxCharacterSlots;
MigrationManager.PerformUpgrade(GetDbConnectionString());
}
private string GetDbConnectionString()
{
return new SqliteConnectionStringBuilder
{
DataSource = _databaseFilePath,
}.ToString();
}
private SqliteConnection GetDbConnection()
{
var connectionString = GetDbConnectionString();
var conn = new SqliteConnection(connectionString);
conn.Open();
return conn;
}
private const string PlayerPreferencesQuery =
@"SELECT Id, SelectedCharacterIndex FROM PlayerPreferences WHERE Username=@Username";
private const string HumanoidCharactersQuery =
@"SELECT Slot, Name, Age, Sex, HairStyleName, HairColor, FacialHairStyleName, FacialHairColor, EyeColor, SkinColor
FROM HumanoidCharacterProfiles
WHERE Player = @Id";
private sealed class PlayerPreferencesSql
{
public int Id { get; set; }
public int SelectedCharacterIndex { get; set; }
}
public PlayerPreferences GetPlayerPreferences(string username)
{
using (var connection = GetDbConnection())
{
var prefs = connection.QueryFirstOrDefault<PlayerPreferencesSql>(
PlayerPreferencesQuery,
new {Username = username});
if (prefs is null)
{
return null;
}
// Using Dapper for ICharacterProfile and ICharacterAppearance is annoying so
// we do it manually
var cmd = new SqliteCommand(HumanoidCharactersQuery, connection);
cmd.Parameters.AddWithValue("@Id", prefs.Id);
cmd.Prepare();
var reader = cmd.ExecuteReader();
var profiles = new ICharacterProfile[_maxCharacterSlots];
while (reader.Read())
{
profiles[reader.GetInt32(0)] = new HumanoidCharacterProfile
{
Name = reader.GetString(1),
Age = reader.GetInt32(2),
Sex = reader.GetString(3) == "Male" ? Male : Female,
CharacterAppearance = new HumanoidCharacterAppearance
{
HairStyleName = reader.GetString(4),
HairColor = Color.FromHex(reader.GetString(5)),
FacialHairStyleName = reader.GetString(6),
FacialHairColor = Color.FromHex(reader.GetString(7)),
EyeColor = Color.FromHex(reader.GetString(8)),
SkinColor = Color.FromHex(reader.GetString(9))
}
};
}
return new PlayerPreferences
{
SelectedCharacterIndex = prefs.SelectedCharacterIndex,
Characters = profiles.ToList()
};
}
}
private const string SaveSelectedCharacterIndexQuery =
@"UPDATE PlayerPreferences
SET SelectedCharacterIndex = @SelectedCharacterIndex
WHERE Username = @Username;
-- If no update happened (i.e. the row didn't exist) then insert one // https://stackoverflow.com/a/38463024
INSERT INTO PlayerPreferences
(SelectedCharacterIndex, Username)
SELECT
@SelectedCharacterIndex,
@Username
WHERE (SELECT Changes() = 0);";
public void SaveSelectedCharacterIndex(string username, int index)
{
index = index.Clamp(0, _maxCharacterSlots - 1);
using (var connection = GetDbConnection())
{
connection.Execute(SaveSelectedCharacterIndexQuery,
new {SelectedCharacterIndex = index, Username = username});
}
}
private const string SaveCharacterSlotQuery =
@"UPDATE HumanoidCharacterProfiles
SET
Name = @Name,
Age = @Age,
Sex = @Sex,
HairStyleName = @HairStyleName,
HairColor = @HairColor,
FacialHairStyleName = @FacialHairStyleName,
FacialHairColor = @FacialHairColor,
EyeColor = @EyeColor,
SkinColor = @SkinColor
WHERE Slot = @Slot AND Player = (SELECT Id FROM PlayerPreferences WHERE Username = @Username);
-- If no update happened (i.e. the row didn't exist) then insert one // https://stackoverflow.com/a/38463024
INSERT INTO HumanoidCharacterProfiles
(Slot,
Player,
Name,
Age,
Sex,
HairStyleName,
HairColor,
FacialHairStyleName,
FacialHairColor,
EyeColor,
SkinColor)
SELECT
@Slot,
(SELECT Id FROM PlayerPreferences WHERE Username = @Username),
@Name,
@Age,
@Sex,
@HairStyleName,
@HairColor,
@FacialHairStyleName,
@FacialHairColor,
@EyeColor,
@SkinColor
WHERE (SELECT Changes() = 0);";
public void SaveCharacterSlot(string username, ICharacterProfile profile, int slot)
{
if (slot < 0 || slot >= _maxCharacterSlots)
return;
if (profile is null)
{
DeleteCharacterSlot(username, slot);
return;
}
if (!(profile is HumanoidCharacterProfile humanoid))
{
// TODO: Handle other ICharacterProfile implementations properly
throw new NotImplementedException();
}
var appearance = (HumanoidCharacterAppearance) humanoid.CharacterAppearance;
using (var connection = GetDbConnection())
{
connection.Execute(SaveCharacterSlotQuery, new
{
Name = humanoid.Name,
Age = humanoid.Age,
Sex = humanoid.Sex.ToString(),
HairStyleName = appearance.HairStyleName,
HairColor = appearance.HairColor.ToHex(),
FacialHairStyleName = appearance.FacialHairStyleName,
FacialHairColor = appearance.FacialHairColor.ToHex(),
EyeColor = appearance.EyeColor.ToHex(),
SkinColor = appearance.SkinColor.ToHex(),
Slot = slot,
Username = username
});
}
}
private const string DeleteCharacterSlotQuery =
@"DELETE FROM HumanoidCharacterProfiles
WHERE
Player = (SELECT Id FROM PlayerPreferences WHERE Username = @Username)
AND
Slot = @Slot";
private void DeleteCharacterSlot(string username, int slot)
{
using (var connection = GetDbConnection())
{
connection.Execute(DeleteCharacterSlotQuery, new {Username = username, Slot = slot});
}
}
}
}

View File

@@ -0,0 +1,103 @@
using System.IO;
using Content.Server.Interfaces;
using Content.Shared.Preferences;
using Robust.Server.Interfaces.Player;
using Robust.Shared.Interfaces.Configuration;
using Robust.Shared.Interfaces.Network;
using Robust.Shared.Interfaces.Resources;
using Robust.Shared.IoC;
namespace Content.Server.Preferences
{
/// <summary>
/// Sends <see cref="SharedPreferencesManager.MsgPreferencesAndSettings"/> before the client joins the lobby.
/// Receives <see cref="SharedPreferencesManager.MsgSelectCharacter"/> and <see cref="SharedPreferencesManager.MsgUpdateCharacter"/> at any time.
/// </summary>
public class ServerPreferencesManager : SharedPreferencesManager, IServerPreferencesManager
{
#pragma warning disable 649
[Dependency] private readonly IServerNetManager _netManager;
[Dependency] private readonly IConfigurationManager _configuration;
[Dependency] private readonly IResourceManager _resourceManager;
#pragma warning restore 649
private PreferencesDatabase _preferencesDb;
public void Initialize()
{
_netManager.RegisterNetMessage<MsgPreferencesAndSettings>(nameof(MsgPreferencesAndSettings));
_netManager.RegisterNetMessage<MsgSelectCharacter>(nameof(MsgSelectCharacter),
HandleSelectCharacterMessage);
_netManager.RegisterNetMessage<MsgUpdateCharacter>(nameof(MsgUpdateCharacter),
HandleUpdateCharacterMessage);
_configuration.RegisterCVar("game.maxcharacterslots", 10);
_configuration.RegisterCVar("game.preferencesdbpath", "preferences.db");
var configPreferencesDbPath = _configuration.GetCVar<string>("game.preferencesdbpath");
var finalPreferencesDbPath = Path.Combine(_resourceManager.UserData.RootDir, configPreferencesDbPath);
var maxCharacterSlots = _configuration.GetCVar<int>("game.maxcharacterslots");
_preferencesDb = new PreferencesDatabase(finalPreferencesDbPath, maxCharacterSlots);
}
private void HandleSelectCharacterMessage(MsgSelectCharacter message)
{
_preferencesDb.SaveSelectedCharacterIndex(message.MsgChannel.SessionId.Username, message.SelectedCharacterIndex);
}
private void HandleUpdateCharacterMessage(MsgUpdateCharacter message)
{
_preferencesDb.SaveCharacterSlot(message.MsgChannel.SessionId.Username, message.Profile, message.Slot);
}
public void OnClientConnected(IPlayerSession session)
{
var msg = _netManager.CreateNetMessage<MsgPreferencesAndSettings>();
msg.Preferences = GetPreferences(session.SessionId.Username);
msg.Settings = new GameSettings
{
MaxCharacterSlots = _configuration.GetCVar<int>("game.maxcharacterslots")
};
_netManager.ServerSendMessage(msg, session.ConnectedClient);
}
/// <summary>
/// Returns the requested <see cref="PlayerPreferences"/> or null if not found.
/// </summary>
private PlayerPreferences GetFromSql(string username)
{
return _preferencesDb.GetPlayerPreferences(username);
}
/// <summary>
/// Retrieves preferences for the given username from storage.
/// Creates and saves default preferences if they are not found, then returns them.
/// </summary>
public PlayerPreferences GetPreferences(string username)
{
var prefs = GetFromSql(username);
if (prefs is null)
{
prefs = PlayerPreferences.Default(); // TODO: Create random character instead
SavePreferences(prefs, username);
}
return prefs;
}
/// <summary>
/// Saves the given preferences to storage.
/// </summary>
public void SavePreferences(PlayerPreferences prefs, string username)
{
_preferencesDb.SaveSelectedCharacterIndex(username, prefs.SelectedCharacterIndex);
var index = 0;
foreach (var character in prefs.Characters)
{
_preferencesDb.SaveCharacterSlot(username, character, index);
index++;
}
}
}
}