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

@@ -25,6 +25,7 @@ namespace Content.Client
IoCManager.Register<IEscapeMenuOwner, EscapeMenuOwner>();
IoCManager.Register<ISandboxManager, SandboxManager>();
IoCManager.Register<IModuleManager, ClientModuleManager>();
IoCManager.Register<IClientPreferencesManager, ClientPreferencesManager>();
}
}
}

View File

@@ -0,0 +1,48 @@
using Content.Client.Interfaces;
using Content.Shared.Preferences;
using Robust.Shared.Interfaces.Network;
using Robust.Shared.IoC;
namespace Content.Client
{
/// <summary>
/// Receives <see cref="PlayerPreferences"/> and <see cref="GameSettings"/> from the server during the initial connection.
/// Stores preferences on the server through <see cref="SelectCharacter"/> and <see cref="UpdateCharacter"/>.
/// </summary>
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<MsgPreferencesAndSettings>(nameof(MsgPreferencesAndSettings),
HandlePreferencesAndSettings);
}
private void HandlePreferencesAndSettings(MsgPreferencesAndSettings message)
{
Preferences = message.Preferences;
Settings = message.Settings;
}
public void SelectCharacter(int slot)
{
var msg = _netManager.CreateNetMessage<MsgSelectCharacter>();
msg.SelectedCharacterIndex = slot;
_netManager.ClientSendMessage(msg);
}
public void UpdateCharacter(ICharacterProfile profile, int slot)
{
var msg = _netManager.CreateNetMessage<MsgUpdateCharacter>();
msg.Profile = profile;
msg.Slot = slot;
_netManager.ClientSendMessage(msg);
}
}
}

View File

@@ -218,6 +218,7 @@ namespace Content.Client
IoCManager.Resolve<IOverlayManager>().AddOverlay(new ParallaxOverlay());
IoCManager.Resolve<IChatManager>().Initialize();
IoCManager.Resolve<ISandboxManager>().Initialize();
IoCManager.Resolve<IClientPreferencesManager>().Initialize();
}
public override void Update(ModUpdateLevel level, FrameEventArgs frameEventArgs)

View File

@@ -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);
}
}

View File

@@ -8,9 +8,12 @@
<Platforms>x64</Platforms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<OutputPath>..\bin\Content.Server\</OutputPath>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<Import Project="..\RobustToolbox\MSBuild\Robust.DefineConstants.targets" />
<ItemGroup>
<PackageReference Include="Dapper" Version="2.0.30" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="3.1.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="YamlDotNet" Version="6.1.2" />
</ItemGroup>
@@ -29,4 +32,7 @@
</ProjectReference>
<ProjectReference Include="..\Content.Shared\Content.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Preferences\Migrations\000_Initial.sql" />
</ItemGroup>
</Project>

View File

@@ -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<ISandboxManager>().Initialize();
IoCManager.Resolve<IServerPreferencesManager>().Initialize();
}
public override void Update(ModUpdateLevel level, FrameEventArgs frameEventArgs)

View File

@@ -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<MsgTickerJoinLobby>(), session.ConnectedClient);
_netManager.ServerSendMessage(_getStatusMsg(session), session.ConnectedClient);
_netManager.ServerSendMessage(GetInfoMsg(), session.ConnectedClient);

View File

@@ -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);
}
}

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++;
}
}
}
}

View File

@@ -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<IGalacticBankManager, GalacticBankManager>();
IoCManager.Register<ICargoOrderDataManager, CargoOrderDataManager>();
IoCManager.Register<IModuleManager, ServerModuleManager>();
IoCManager.Register<IServerPreferencesManager, ServerPreferencesManager>();
}
}
}

View File

@@ -0,0 +1,20 @@
using System;
using Robust.Shared.Serialization;
namespace Content.Shared.Preferences
{
/// <summary>
/// Information needed for character setup.
/// </summary>
[Serializable, NetSerializable]
public class GameSettings
{
private int _maxCharacterSlots;
public int MaxCharacterSlots
{
get => _maxCharacterSlots;
set => _maxCharacterSlots = value;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Content.Shared.Preferences
{
public interface ICharacterAppearance
{
bool MemberwiseEquals(ICharacterAppearance other);
}
}

View File

@@ -0,0 +1,7 @@
namespace Content.Shared.Preferences
{
public interface ICharacterProfile
{
bool MemberwiseEquals(ICharacterProfile other);
}
}

View File

@@ -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
{
/// <summary>
/// Contains all player characters and the index of the currently selected character.
/// Serialized both over the network and to disk.
/// </summary>
[Serializable, NetSerializable]
public class PlayerPreferences
{
public static PlayerPreferences Default()
{
return new PlayerPreferences
{
Characters = new List<ICharacterProfile>
{
HumanoidCharacterProfile.Default()
},
SelectedCharacterIndex = 0
};
}
private List<ICharacterProfile> _characters;
private int _selectedCharacterIndex;
/// <summary>
/// All player characters.
/// </summary>
public List<ICharacterProfile> Characters
{
get => _characters;
set => _characters = value;
}
/// <summary>
/// Index of the currently selected character.
/// </summary>
public int SelectedCharacterIndex
{
get => _selectedCharacterIndex;
set => _selectedCharacterIndex = value;
}
/// <summary>
/// Retrieves the currently selected character.
/// </summary>
public ICharacterProfile SelectedCharacter => Characters.ElementAtOrDefault(SelectedCharacterIndex);
}
}

View File

@@ -0,0 +1,8 @@
namespace Content.Shared.Preferences
{
public enum Sex
{
Male,
Female
}
}

View File

@@ -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
{
/// <summary>
/// The server sends this before the client joins the lobby.
/// </summary>
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<IRobustSerializer>();
var length = buffer.ReadInt32();
var bytes = buffer.ReadBytes(length);
using (var stream = new MemoryStream(bytes))
{
Preferences = serializer.Deserialize<PlayerPreferences>(stream);
}
length = buffer.ReadInt32();
bytes = buffer.ReadBytes(length);
using (var stream = new MemoryStream(bytes))
{
Settings = serializer.Deserialize<GameSettings>(stream);
}
}
public override void WriteToBuffer(NetOutgoingMessage buffer)
{
var serializer = IoCManager.Resolve<IRobustSerializer>();
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());
}
}
}
/// <summary>
/// The client sends this to select a character slot.
/// </summary>
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);
}
}
/// <summary>
/// The client sends this to update a character profile.
/// </summary>
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<IRobustSerializer>();
var length = buffer.ReadInt32();
var bytes = buffer.ReadBytes(length);
using (var stream = new MemoryStream(bytes))
{
Profile = serializer.Deserialize<ICharacterProfile>(stream);
}
}
public override void WriteToBuffer(NetOutgoingMessage buffer)
{
buffer.Write(Slot);
var serializer = IoCManager.Resolve<IRobustSerializer>();
using (var stream = new MemoryStream())
{
serializer.Serialize(stream, Profile);
buffer.Write((int)stream.Length);
buffer.Write(stream.ToArray());
}
}
}
}
}

View File

@@ -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);
}
}
}