From f19795edaf10cc9b51a91921f27d02cc4391aa42 Mon Sep 17 00:00:00 2001 From: DamianX Date: Sun, 22 Dec 2019 13:47:34 +0100 Subject: [PATCH] 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 --- Content.Client/ClientContentIoC.cs | 1 + Content.Client/ClientPreferencesManager.cs | 48 ++++ Content.Client/EntryPoint.cs | 1 + .../Interfaces/IClientPreferencesManager.cs | 13 ++ Content.Server/Content.Server.csproj | 6 + Content.Server/EntryPoint.cs | 2 + Content.Server/GameTicking/GameTicker.cs | 3 + .../Interfaces/IServerPreferencesManager.cs | 13 ++ .../Preferences/Migrations/000_Initial.sql | 20 ++ .../Migrations/MigrationManager.cs | 169 ++++++++++++++ .../Preferences/PreferencesDatabase.cs | 220 ++++++++++++++++++ .../Preferences/ServerPreferencesManager.cs | 103 ++++++++ Content.Server/ServerContentIoC.cs | 2 + Content.Shared/Preferences/GameSettings.cs | 20 ++ .../HumanoidCharacterAppearance.cs | 42 ++++ .../Preferences/HumanoidCharacterProfile.cs | 36 +++ .../Preferences/ICharacterAppearance.cs | 7 + .../Preferences/ICharacterProfile.cs | 7 + .../Preferences/PlayerPreferences.cs | 54 +++++ Content.Shared/Preferences/Sex.cs | 8 + .../Preferences/SharedPreferencesManager.cs | 133 +++++++++++ .../Preferences/PreferencesDatabaseTests.cs | 101 ++++++++ 22 files changed, 1009 insertions(+) create mode 100644 Content.Client/ClientPreferencesManager.cs create mode 100644 Content.Client/Interfaces/IClientPreferencesManager.cs create mode 100644 Content.Server/Interfaces/IServerPreferencesManager.cs create mode 100644 Content.Server/Preferences/Migrations/000_Initial.sql create mode 100644 Content.Server/Preferences/Migrations/MigrationManager.cs create mode 100644 Content.Server/Preferences/PreferencesDatabase.cs create mode 100644 Content.Server/Preferences/ServerPreferencesManager.cs create mode 100644 Content.Shared/Preferences/GameSettings.cs create mode 100644 Content.Shared/Preferences/HumanoidCharacterAppearance.cs create mode 100644 Content.Shared/Preferences/HumanoidCharacterProfile.cs create mode 100644 Content.Shared/Preferences/ICharacterAppearance.cs create mode 100644 Content.Shared/Preferences/ICharacterProfile.cs create mode 100644 Content.Shared/Preferences/PlayerPreferences.cs create mode 100644 Content.Shared/Preferences/Sex.cs create mode 100644 Content.Shared/Preferences/SharedPreferencesManager.cs create mode 100644 Content.Tests/Server/Preferences/PreferencesDatabaseTests.cs diff --git a/Content.Client/ClientContentIoC.cs b/Content.Client/ClientContentIoC.cs index d0c6a87ccb..ae3ee2503e 100644 --- a/Content.Client/ClientContentIoC.cs +++ b/Content.Client/ClientContentIoC.cs @@ -25,6 +25,7 @@ namespace Content.Client IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Client/ClientPreferencesManager.cs b/Content.Client/ClientPreferencesManager.cs new file mode 100644 index 0000000000..3b338ba70c --- /dev/null +++ b/Content.Client/ClientPreferencesManager.cs @@ -0,0 +1,48 @@ +using Content.Client.Interfaces; +using Content.Shared.Preferences; +using Robust.Shared.Interfaces.Network; +using Robust.Shared.IoC; + +namespace Content.Client +{ + /// + /// Receives and from the server during the initial connection. + /// Stores preferences on the server through and . + /// + public class ClientPreferencesManager : SharedPreferencesManager, IClientPreferencesManager + { +#pragma warning disable 649 + [Dependency] private readonly IClientNetManager _netManager; +#pragma warning restore 649 + + public GameSettings Settings { get; private set; } + public PlayerPreferences Preferences { get; private set; } + + public void Initialize() + { + _netManager.RegisterNetMessage(nameof(MsgPreferencesAndSettings), + HandlePreferencesAndSettings); + } + + private void HandlePreferencesAndSettings(MsgPreferencesAndSettings message) + { + Preferences = message.Preferences; + Settings = message.Settings; + } + + public void SelectCharacter(int slot) + { + var msg = _netManager.CreateNetMessage(); + msg.SelectedCharacterIndex = slot; + _netManager.ClientSendMessage(msg); + } + + public void UpdateCharacter(ICharacterProfile profile, int slot) + { + var msg = _netManager.CreateNetMessage(); + msg.Profile = profile; + msg.Slot = slot; + _netManager.ClientSendMessage(msg); + } + } +} diff --git a/Content.Client/EntryPoint.cs b/Content.Client/EntryPoint.cs index 2ad6132e91..cc0bb65e0c 100644 --- a/Content.Client/EntryPoint.cs +++ b/Content.Client/EntryPoint.cs @@ -218,6 +218,7 @@ namespace Content.Client IoCManager.Resolve().AddOverlay(new ParallaxOverlay()); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); } public override void Update(ModUpdateLevel level, FrameEventArgs frameEventArgs) diff --git a/Content.Client/Interfaces/IClientPreferencesManager.cs b/Content.Client/Interfaces/IClientPreferencesManager.cs new file mode 100644 index 0000000000..c983c4f933 --- /dev/null +++ b/Content.Client/Interfaces/IClientPreferencesManager.cs @@ -0,0 +1,13 @@ +using Content.Shared.Preferences; + +namespace Content.Client.Interfaces +{ + public interface IClientPreferencesManager + { + void Initialize(); + GameSettings Settings { get; } + PlayerPreferences Preferences { get; } + void SelectCharacter(int slot); + void UpdateCharacter(ICharacterProfile profile, int slot); + } +} diff --git a/Content.Server/Content.Server.csproj b/Content.Server/Content.Server.csproj index e70bc31abb..7d7ba55760 100644 --- a/Content.Server/Content.Server.csproj +++ b/Content.Server/Content.Server.csproj @@ -8,9 +8,12 @@ x64 false ..\bin\Content.Server\ + true + + @@ -29,4 +32,7 @@ + + + diff --git a/Content.Server/EntryPoint.cs b/Content.Server/EntryPoint.cs index 0530fe8102..06630a8198 100644 --- a/Content.Server/EntryPoint.cs +++ b/Content.Server/EntryPoint.cs @@ -2,6 +2,7 @@ using Content.Server.Interfaces; using Content.Server.Interfaces.Chat; using Content.Server.Interfaces.GameTicking; +using Content.Server.Preferences; using Content.Server.Sandbox; using Robust.Server.Interfaces.Player; using Robust.Shared.ContentPack; @@ -74,6 +75,7 @@ namespace Content.Server _gameTicker.Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); } public override void Update(ModUpdateLevel level, FrameEventArgs frameEventArgs) diff --git a/Content.Server/GameTicking/GameTicker.cs b/Content.Server/GameTicking/GameTicker.cs index 20fd66fcd6..033b884bd0 100644 --- a/Content.Server/GameTicking/GameTicker.cs +++ b/Content.Server/GameTicking/GameTicker.cs @@ -4,6 +4,7 @@ using System.Linq; using Content.Server.GameObjects; using Content.Server.GameObjects.Components.Markers; using Content.Server.GameTicking.GamePresets; +using Content.Server.Interfaces; using Content.Server.Interfaces.Chat; using Content.Server.Interfaces.GameTicking; using Content.Server.Mobs; @@ -97,6 +98,7 @@ namespace Content.Server.GameTicking [Dependency] private IPrototypeManager _prototypeManager; [Dependency] private readonly ILocalizationManager _localization; [Dependency] private readonly IRobustRandom _robustRandom; + [Dependency] private readonly IServerPreferencesManager _prefsManager; #pragma warning restore 649 public void Initialize() @@ -475,6 +477,7 @@ namespace Content.Server.GameTicking { _playersInLobby.Add(session, false); + _prefsManager.OnClientConnected(session); _netManager.ServerSendMessage(_netManager.CreateNetMessage(), session.ConnectedClient); _netManager.ServerSendMessage(_getStatusMsg(session), session.ConnectedClient); _netManager.ServerSendMessage(GetInfoMsg(), session.ConnectedClient); diff --git a/Content.Server/Interfaces/IServerPreferencesManager.cs b/Content.Server/Interfaces/IServerPreferencesManager.cs new file mode 100644 index 0000000000..0cf13fda53 --- /dev/null +++ b/Content.Server/Interfaces/IServerPreferencesManager.cs @@ -0,0 +1,13 @@ +using Content.Shared.Preferences; +using Robust.Server.Interfaces.Player; + +namespace Content.Server.Interfaces +{ + public interface IServerPreferencesManager + { + void Initialize(); + void OnClientConnected(IPlayerSession session); + PlayerPreferences GetPreferences(string username); + void SavePreferences(PlayerPreferences prefs, string username); + } +} diff --git a/Content.Server/Preferences/Migrations/000_Initial.sql b/Content.Server/Preferences/Migrations/000_Initial.sql new file mode 100644 index 0000000000..00c261a696 --- /dev/null +++ b/Content.Server/Preferences/Migrations/000_Initial.sql @@ -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 +); diff --git a/Content.Server/Preferences/Migrations/MigrationManager.cs b/Content.Server/Preferences/Migrations/MigrationManager.cs new file mode 100644 index 0000000000..9057d102dc --- /dev/null +++ b/Content.Server/Preferences/Migrations/MigrationManager.cs @@ -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 +{ + /// + /// Ensures database schemas are up to date. + /// + public static class MigrationManager + { + /// + /// Ensures the database schema for the given connection string is up to date. + /// + 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); + } + } + } + + /// + /// Generated for each SQL file found. + /// + private class Migration + { + public readonly string Id; + private readonly string _sql; + + public Migration(string id, string sql) + { + Id = id; + _sql = sql; + } + + /// + /// Executes the query in and logs this in the SchemaVersion table. + /// + public void Run(IDbConnection connection) + { + connection.Execute(_sql); + InsertMigrationLog(connection, Id); + } + } + + private const string InsertMigrationLogQuery = + @"INSERT INTO SchemaVersion (Id) VALUES (@Id)"; + /// + /// Inserts a in the SchemaVersion table. + /// + private static void InsertMigrationLog(IDbConnection connection, string id) + { + Logger.InfoS("db", "Completing migration {0}", id); + connection.Execute(InsertMigrationLogQuery, new {Id = id}); + } + + /// + /// An entry in the SchemaVersion table. + /// + [UsedImplicitly] + private class MigrationLog + { + public string Id; + public string Timestamp; + } + + private const string GetRanMigrationsQuery = + @"SELECT Id, Timestamp FROM SchemaVersion ORDER BY Id COLLATE NOCASE"; + /// + /// Fetches a collection of from the SchemaVersion table and returns it. + /// + private static IEnumerable RanMigrations(IDbConnection connection) + { + return connection.Query(GetRanMigrationsQuery); + } + + /// + /// Finds all available migrations, returns those that haven't been run yet. + /// + private static List 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; + } + + /// + /// Given an embedded resource's full path returns its contents as a string. + /// + private static string ResourceAssemblyToString(string resourceName) + { + using (var stream = Assembly.GetExecutingAssembly() + .GetManifestResourceStream(resourceName)) + using (var reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } + + /// + /// Searches the current assembly for SQL migration files. + /// TODO: Filter by subfolder so that different databases use different sets of migrations. + /// + [NotNull] + private static List DiscoverMigrations(IDbConnection connection) + { + var results = new List(); + 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; + } + + /// + /// 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". + /// + 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 + )"; + + /// + /// Creates the SchemaVersion table if it doesn't exist. + /// + private static void EnsureSchemaVersionTableExists(IDbConnection connection) + { + connection.Execute(EnsureSchemaVersionTableExistsQuery); + } + } +} diff --git a/Content.Server/Preferences/PreferencesDatabase.cs b/Content.Server/Preferences/PreferencesDatabase.cs new file mode 100644 index 0000000000..7e8eb0201c --- /dev/null +++ b/Content.Server/Preferences/PreferencesDatabase.cs @@ -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 +{ + /// + /// Provides methods to retrieve and update character preferences. + /// Don't use this directly, go through instead. + /// + 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( + 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}); + } + } + } +} diff --git a/Content.Server/Preferences/ServerPreferencesManager.cs b/Content.Server/Preferences/ServerPreferencesManager.cs new file mode 100644 index 0000000000..37d71a8646 --- /dev/null +++ b/Content.Server/Preferences/ServerPreferencesManager.cs @@ -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 +{ + /// + /// Sends before the client joins the lobby. + /// Receives and at any time. + /// + 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(nameof(MsgPreferencesAndSettings)); + _netManager.RegisterNetMessage(nameof(MsgSelectCharacter), + HandleSelectCharacterMessage); + _netManager.RegisterNetMessage(nameof(MsgUpdateCharacter), + HandleUpdateCharacterMessage); + + _configuration.RegisterCVar("game.maxcharacterslots", 10); + _configuration.RegisterCVar("game.preferencesdbpath", "preferences.db"); + + var configPreferencesDbPath = _configuration.GetCVar("game.preferencesdbpath"); + var finalPreferencesDbPath = Path.Combine(_resourceManager.UserData.RootDir, configPreferencesDbPath); + + var maxCharacterSlots = _configuration.GetCVar("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(); + msg.Preferences = GetPreferences(session.SessionId.Username); + msg.Settings = new GameSettings + { + MaxCharacterSlots = _configuration.GetCVar("game.maxcharacterslots") + }; + _netManager.ServerSendMessage(msg, session.ConnectedClient); + } + + /// + /// Returns the requested or null if not found. + /// + private PlayerPreferences GetFromSql(string username) + { + return _preferencesDb.GetPlayerPreferences(username); + } + + /// + /// Retrieves preferences for the given username from storage. + /// Creates and saves default preferences if they are not found, then returns them. + /// + 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; + } + + /// + /// Saves the given preferences to storage. + /// + 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++; + } + } + } +} diff --git a/Content.Server/ServerContentIoC.cs b/Content.Server/ServerContentIoC.cs index 352ef2d564..ec9ff2a876 100644 --- a/Content.Server/ServerContentIoC.cs +++ b/Content.Server/ServerContentIoC.cs @@ -4,6 +4,7 @@ using Content.Server.GameTicking; using Content.Server.Interfaces; using Content.Server.Interfaces.Chat; using Content.Server.Interfaces.GameTicking; +using Content.Server.Preferences; using Content.Server.Sandbox; using Content.Server.Utility; using Content.Shared.Interfaces; @@ -24,6 +25,7 @@ namespace Content.Server IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Shared/Preferences/GameSettings.cs b/Content.Shared/Preferences/GameSettings.cs new file mode 100644 index 0000000000..92e473035e --- /dev/null +++ b/Content.Shared/Preferences/GameSettings.cs @@ -0,0 +1,20 @@ +using System; +using Robust.Shared.Serialization; + +namespace Content.Shared.Preferences +{ + /// + /// Information needed for character setup. + /// + [Serializable, NetSerializable] + public class GameSettings + { + private int _maxCharacterSlots; + + public int MaxCharacterSlots + { + get => _maxCharacterSlots; + set => _maxCharacterSlots = value; + } + } +} diff --git a/Content.Shared/Preferences/HumanoidCharacterAppearance.cs b/Content.Shared/Preferences/HumanoidCharacterAppearance.cs new file mode 100644 index 0000000000..cf7a7b69df --- /dev/null +++ b/Content.Shared/Preferences/HumanoidCharacterAppearance.cs @@ -0,0 +1,42 @@ +using System; +using Robust.Shared.Maths; +using Robust.Shared.Serialization; + +namespace Content.Shared.Preferences +{ + [Serializable, NetSerializable] + public class HumanoidCharacterAppearance : ICharacterAppearance + { + public string HairStyleName { get; set; } + public Color HairColor { get; set; } + public string FacialHairStyleName { get; set; } + public Color FacialHairColor { get; set; } + public Color EyeColor { get; set; } + public Color SkinColor { get; set; } + + public static HumanoidCharacterAppearance Default() + { + return new HumanoidCharacterAppearance + { + HairStyleName = "Bald", + HairColor = Color.Black, + FacialHairStyleName = "Shaved", + FacialHairColor = Color.Black, + EyeColor = Color.Black, + SkinColor = Color.Black + }; + } + + public bool MemberwiseEquals(ICharacterAppearance maybeOther) + { + if (!(maybeOther is HumanoidCharacterAppearance other)) return false; + if (HairStyleName != other.HairStyleName) return false; + if (HairColor != other.HairColor) return false; + if (FacialHairStyleName != other.FacialHairStyleName) return false; + if (FacialHairColor != other.FacialHairColor) return false; + if (EyeColor != other.EyeColor) return false; + if (SkinColor != other.SkinColor) return false; + return true; + } + } +} diff --git a/Content.Shared/Preferences/HumanoidCharacterProfile.cs b/Content.Shared/Preferences/HumanoidCharacterProfile.cs new file mode 100644 index 0000000000..8dd87dfe91 --- /dev/null +++ b/Content.Shared/Preferences/HumanoidCharacterProfile.cs @@ -0,0 +1,36 @@ +using System; +using Robust.Shared.Serialization; + +namespace Content.Shared.Preferences +{ + [Serializable, NetSerializable] + public class HumanoidCharacterProfile : ICharacterProfile + { + public static HumanoidCharacterProfile Default() + { + return new HumanoidCharacterProfile + { + Name = "John Doe", + Age = 18, + Sex = Sex.Male, + CharacterAppearance = HumanoidCharacterAppearance.Default() + }; + } + + public string Name { get; set; } + public int Age { get; set; } + public Sex Sex { get; set; } + public ICharacterAppearance CharacterAppearance { get; set; } + + public bool MemberwiseEquals(ICharacterProfile maybeOther) + { + if (!(maybeOther is HumanoidCharacterProfile other)) return false; + if (Name != other.Name) return false; + if (Age != other.Age) return false; + if (Sex != other.Sex) return false; + if (CharacterAppearance is null) + return other.CharacterAppearance is null; + return CharacterAppearance.MemberwiseEquals(other.CharacterAppearance); + } + } +} diff --git a/Content.Shared/Preferences/ICharacterAppearance.cs b/Content.Shared/Preferences/ICharacterAppearance.cs new file mode 100644 index 0000000000..41526bca8a --- /dev/null +++ b/Content.Shared/Preferences/ICharacterAppearance.cs @@ -0,0 +1,7 @@ +namespace Content.Shared.Preferences +{ + public interface ICharacterAppearance + { + bool MemberwiseEquals(ICharacterAppearance other); + } +} diff --git a/Content.Shared/Preferences/ICharacterProfile.cs b/Content.Shared/Preferences/ICharacterProfile.cs new file mode 100644 index 0000000000..abd73ec7a0 --- /dev/null +++ b/Content.Shared/Preferences/ICharacterProfile.cs @@ -0,0 +1,7 @@ +namespace Content.Shared.Preferences +{ + public interface ICharacterProfile + { + bool MemberwiseEquals(ICharacterProfile other); + } +} diff --git a/Content.Shared/Preferences/PlayerPreferences.cs b/Content.Shared/Preferences/PlayerPreferences.cs new file mode 100644 index 0000000000..6472da8c6d --- /dev/null +++ b/Content.Shared/Preferences/PlayerPreferences.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Robust.Shared.Interfaces.Serialization; +using Robust.Shared.Serialization; + +namespace Content.Shared.Preferences +{ + /// + /// Contains all player characters and the index of the currently selected character. + /// Serialized both over the network and to disk. + /// + [Serializable, NetSerializable] + public class PlayerPreferences + { + public static PlayerPreferences Default() + { + return new PlayerPreferences + { + Characters = new List + { + HumanoidCharacterProfile.Default() + }, + SelectedCharacterIndex = 0 + }; + } + + private List _characters; + private int _selectedCharacterIndex; + + /// + /// All player characters. + /// + public List Characters + { + get => _characters; + set => _characters = value; + } + + /// + /// Index of the currently selected character. + /// + public int SelectedCharacterIndex + { + get => _selectedCharacterIndex; + set => _selectedCharacterIndex = value; + } + + /// + /// Retrieves the currently selected character. + /// + public ICharacterProfile SelectedCharacter => Characters.ElementAtOrDefault(SelectedCharacterIndex); + } +} diff --git a/Content.Shared/Preferences/Sex.cs b/Content.Shared/Preferences/Sex.cs new file mode 100644 index 0000000000..e8a4ff42ef --- /dev/null +++ b/Content.Shared/Preferences/Sex.cs @@ -0,0 +1,8 @@ +namespace Content.Shared.Preferences +{ + public enum Sex + { + Male, + Female + } +} diff --git a/Content.Shared/Preferences/SharedPreferencesManager.cs b/Content.Shared/Preferences/SharedPreferencesManager.cs new file mode 100644 index 0000000000..61be9e3f15 --- /dev/null +++ b/Content.Shared/Preferences/SharedPreferencesManager.cs @@ -0,0 +1,133 @@ +using System.IO; +using Lidgren.Network; +using Robust.Shared.Interfaces.Network; +using Robust.Shared.Interfaces.Serialization; +using Robust.Shared.IoC; +using Robust.Shared.Network; + +namespace Content.Shared.Preferences +{ + public abstract class SharedPreferencesManager + { + /// + /// The server sends this before the client joins the lobby. + /// + protected class MsgPreferencesAndSettings : NetMessage + { + #region REQUIRED + + public const MsgGroups GROUP = MsgGroups.Command; + public const string NAME = nameof(MsgPreferencesAndSettings); + + public MsgPreferencesAndSettings(INetChannel channel) : base(NAME, GROUP) { } + + #endregion + + public PlayerPreferences Preferences; + public GameSettings Settings; + + public override void ReadFromBuffer(NetIncomingMessage buffer) + { + var serializer = IoCManager.Resolve(); + var length = buffer.ReadInt32(); + var bytes = buffer.ReadBytes(length); + using (var stream = new MemoryStream(bytes)) + { + Preferences = serializer.Deserialize(stream); + } + length = buffer.ReadInt32(); + bytes = buffer.ReadBytes(length); + using (var stream = new MemoryStream(bytes)) + { + Settings = serializer.Deserialize(stream); + } + } + + public override void WriteToBuffer(NetOutgoingMessage buffer) + { + var serializer = IoCManager.Resolve(); + using (var stream = new MemoryStream()) + { + serializer.Serialize(stream, Preferences); + buffer.Write((int)stream.Length); + buffer.Write(stream.ToArray()); + } + using (var stream = new MemoryStream()) + { + serializer.Serialize(stream, Settings); + buffer.Write((int)stream.Length); + buffer.Write(stream.ToArray()); + } + } + } + + /// + /// The client sends this to select a character slot. + /// + protected class MsgSelectCharacter : NetMessage + { + #region REQUIRED + + public const MsgGroups GROUP = MsgGroups.Command; + public const string NAME = nameof(MsgSelectCharacter); + + public MsgSelectCharacter(INetChannel channel) : base(NAME, GROUP) { } + + #endregion + + public int SelectedCharacterIndex; + + public override void ReadFromBuffer(NetIncomingMessage buffer) + { + SelectedCharacterIndex = buffer.ReadInt32(); + } + + public override void WriteToBuffer(NetOutgoingMessage buffer) + { + buffer.Write(SelectedCharacterIndex); + } + } + + /// + /// The client sends this to update a character profile. + /// + protected class MsgUpdateCharacter : NetMessage + { + #region REQUIRED + + public const MsgGroups GROUP = MsgGroups.Command; + public const string NAME = nameof(MsgUpdateCharacter); + + public MsgUpdateCharacter(INetChannel channel) : base(NAME, GROUP) { } + + #endregion + + public int Slot; + public ICharacterProfile Profile; + + public override void ReadFromBuffer(NetIncomingMessage buffer) + { + Slot = buffer.ReadInt32(); + var serializer = IoCManager.Resolve(); + var length = buffer.ReadInt32(); + var bytes = buffer.ReadBytes(length); + using (var stream = new MemoryStream(bytes)) + { + Profile = serializer.Deserialize(stream); + } + } + + public override void WriteToBuffer(NetOutgoingMessage buffer) + { + buffer.Write(Slot); + var serializer = IoCManager.Resolve(); + using (var stream = new MemoryStream()) + { + serializer.Serialize(stream, Profile); + buffer.Write((int)stream.Length); + buffer.Write(stream.ToArray()); + } + } + } + } +} diff --git a/Content.Tests/Server/Preferences/PreferencesDatabaseTests.cs b/Content.Tests/Server/Preferences/PreferencesDatabaseTests.cs new file mode 100644 index 0000000000..66b82005bd --- /dev/null +++ b/Content.Tests/Server/Preferences/PreferencesDatabaseTests.cs @@ -0,0 +1,101 @@ +using System.IO; +using Content.Server.Preferences; +using Content.Shared.Preferences; +using NUnit.Framework; +using Robust.Shared.Maths; +using Robust.UnitTesting; + +namespace Content.Tests.Server.Preferences +{ + [TestFixture] + public class PreferencesDatabaseTests : RobustUnitTest + { + private const int MaxCharacterSlots = 10; + + private static ICharacterProfile CharlieCharlieson() + { + return new HumanoidCharacterProfile + { + Name = "Charlie Charlieson", + Age = 21, + Sex = Sex.Male, + CharacterAppearance = new HumanoidCharacterAppearance() + { + HairStyleName = "Afro", + HairColor = Color.Aqua, + FacialHairStyleName = "Shaved", + FacialHairColor = Color.Aquamarine, + EyeColor = Color.Azure, + SkinColor = Color.Beige + } + }; + } + + private static PreferencesDatabase GetDb() + { + return new PreferencesDatabase(Path.GetTempFileName(), MaxCharacterSlots); + } + + [Test] + public void TestUserDoesNotExist() + { + var db = GetDb(); + Assert.Null(db.GetPlayerPreferences("[The database should be empty so any string should do]")); + } + + [Test] + public void TestUserDoesExist() + { + var db = GetDb(); + const string username = "bobby"; + db.SaveSelectedCharacterIndex(username, 0); + var prefs = db.GetPlayerPreferences(username); + Assert.NotNull(prefs); + Assert.Zero(prefs.SelectedCharacterIndex); + Assert.That(prefs.Characters.TrueForAll(character => character is null)); + } + + [Test] + public void TestUpdateCharacter() + { + var db = GetDb(); + const string username = "charlie"; + const int slot = 0; + var originalProfile = CharlieCharlieson(); + db.SaveSelectedCharacterIndex(username, slot); + db.SaveCharacterSlot(username, originalProfile, slot); + var prefs = db.GetPlayerPreferences(username); + Assert.That(prefs.Characters[slot].MemberwiseEquals(originalProfile)); + } + + [Test] + public void TestDeleteCharacter() + { + var db = GetDb(); + const string username = "charlie"; + const int slot = 0; + db.SaveSelectedCharacterIndex(username, slot); + db.SaveCharacterSlot(username, CharlieCharlieson(), slot); + db.SaveCharacterSlot(username, null, slot); + var prefs = db.GetPlayerPreferences(username); + Assert.That(prefs.Characters.TrueForAll(character => character is null)); + } + + [Test] + public void TestInvalidSlot() + { + var db = GetDb(); + const string username = "charlie"; + const int slot = -1; + + db.SaveSelectedCharacterIndex(username, slot); + db.SaveCharacterSlot(username, CharlieCharlieson(), slot); + var prefs = db.GetPlayerPreferences(username); + Assert.AreEqual(prefs.SelectedCharacterIndex, 0); + + db.SaveSelectedCharacterIndex(username, MaxCharacterSlots); + prefs = db.GetPlayerPreferences(username); + Assert.AreEqual(prefs.SelectedCharacterIndex, MaxCharacterSlots-1); + } + } +}