First bug: if an error occured during pref loading code, it would fail. If the person then readied up, it would likely cause the round to fail to start. Why could they ready up? The code only checks that the prefs finished loading, not that they finished loading *successfully*. Whoops. Anyways, now people get kicked if their prefs fail to load. And I improved the error handling. Second bug: if a user disconnected while their prefs were loading, it would cause an exception. This exception would go unobserved on lobby servers or raise through gameticker on non-lobby servers. This happened even on a live server once and then triggered the first bug, but idk how. Fixed this by properly plumbing through cancellation into the preferences loading code. The stuff is now cancelled properly. Third bug: if somebody has a loadout item with a playtime requirement active, load-time sanitization of player prefs could run into a race condition because the sanitization can happen *before* play time was loaded. Fixed by moving pref sanitizations to a later stage in the load process.
331 lines
12 KiB
C#
331 lines
12 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Content.Server.Database;
|
|
using Content.Server.Humanoid;
|
|
using Content.Shared.CCVar;
|
|
using Content.Shared.Humanoid.Prototypes;
|
|
using Content.Shared.Preferences;
|
|
using Content.Shared.Roles;
|
|
using Robust.Server.Player;
|
|
using Robust.Shared.Configuration;
|
|
using Robust.Shared.Network;
|
|
using Robust.Shared.Player;
|
|
using Robust.Shared.Prototypes;
|
|
using Robust.Shared.Utility;
|
|
|
|
|
|
namespace Content.Server.Preferences.Managers
|
|
{
|
|
/// <summary>
|
|
/// Sends <see cref="MsgPreferencesAndSettings"/> before the client joins the lobby.
|
|
/// Receives <see cref="MsgSelectCharacter"/> and <see cref="MsgUpdateCharacter"/> at any time.
|
|
/// </summary>
|
|
public sealed class ServerPreferencesManager : IServerPreferencesManager
|
|
{
|
|
[Dependency] private readonly IServerNetManager _netManager = default!;
|
|
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
|
[Dependency] private readonly IServerDbManager _db = default!;
|
|
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
|
[Dependency] private readonly IDependencyCollection _dependencies = default!;
|
|
[Dependency] private readonly IPrototypeManager _protos = default!;
|
|
|
|
// Cache player prefs on the server so we don't need as much async hell related to them.
|
|
private readonly Dictionary<NetUserId, PlayerPrefData> _cachedPlayerPrefs =
|
|
new();
|
|
|
|
private int MaxCharacterSlots => _cfg.GetCVar(CCVars.GameMaxCharacterSlots);
|
|
|
|
public void Init()
|
|
{
|
|
_netManager.RegisterNetMessage<MsgPreferencesAndSettings>();
|
|
_netManager.RegisterNetMessage<MsgSelectCharacter>(HandleSelectCharacterMessage);
|
|
_netManager.RegisterNetMessage<MsgUpdateCharacter>(HandleUpdateCharacterMessage);
|
|
_netManager.RegisterNetMessage<MsgDeleteCharacter>(HandleDeleteCharacterMessage);
|
|
}
|
|
|
|
private async void HandleSelectCharacterMessage(MsgSelectCharacter message)
|
|
{
|
|
var index = message.SelectedCharacterIndex;
|
|
var userId = message.MsgChannel.UserId;
|
|
|
|
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded)
|
|
{
|
|
Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded.");
|
|
return;
|
|
}
|
|
|
|
if (index < 0 || index >= MaxCharacterSlots)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var curPrefs = prefsData.Prefs!;
|
|
|
|
if (!curPrefs.Characters.ContainsKey(index))
|
|
{
|
|
// Non-existent slot.
|
|
return;
|
|
}
|
|
|
|
prefsData.Prefs = new PlayerPreferences(curPrefs.Characters, index, curPrefs.AdminOOCColor);
|
|
|
|
if (ShouldStorePrefs(message.MsgChannel.AuthType))
|
|
{
|
|
await _db.SaveSelectedCharacterIndexAsync(message.MsgChannel.UserId, message.SelectedCharacterIndex);
|
|
}
|
|
}
|
|
|
|
private async void HandleUpdateCharacterMessage(MsgUpdateCharacter message)
|
|
{
|
|
var slot = message.Slot;
|
|
var profile = message.Profile;
|
|
var userId = message.MsgChannel.UserId;
|
|
|
|
if (profile == null)
|
|
{
|
|
Logger.WarningS("prefs",
|
|
$"User {userId} sent a {nameof(MsgUpdateCharacter)} with a null profile in slot {slot}.");
|
|
return;
|
|
}
|
|
|
|
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded)
|
|
{
|
|
Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded.");
|
|
return;
|
|
}
|
|
|
|
if (slot < 0 || slot >= MaxCharacterSlots)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var curPrefs = prefsData.Prefs!;
|
|
var session = _playerManager.GetSessionById(userId);
|
|
|
|
profile.EnsureValid(session, _dependencies);
|
|
|
|
var profiles = new Dictionary<int, ICharacterProfile>(curPrefs.Characters)
|
|
{
|
|
[slot] = profile
|
|
};
|
|
|
|
prefsData.Prefs = new PlayerPreferences(profiles, slot, curPrefs.AdminOOCColor);
|
|
|
|
if (ShouldStorePrefs(message.MsgChannel.AuthType))
|
|
{
|
|
await _db.SaveCharacterSlotAsync(message.MsgChannel.UserId, message.Profile, message.Slot);
|
|
}
|
|
}
|
|
|
|
private async void HandleDeleteCharacterMessage(MsgDeleteCharacter message)
|
|
{
|
|
var slot = message.Slot;
|
|
var userId = message.MsgChannel.UserId;
|
|
|
|
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded)
|
|
{
|
|
Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded.");
|
|
return;
|
|
}
|
|
|
|
if (slot < 0 || slot >= MaxCharacterSlots)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var curPrefs = prefsData.Prefs!;
|
|
|
|
// If they try to delete the slot they have selected then we switch to another one.
|
|
// Of course, that's only if they HAVE another slot.
|
|
int? nextSlot = null;
|
|
if (curPrefs.SelectedCharacterIndex == slot)
|
|
{
|
|
// That ! on the end is because Rider doesn't like .NET 5.
|
|
var (ns, profile) = curPrefs.Characters.FirstOrDefault(p => p.Key != message.Slot)!;
|
|
if (profile == null)
|
|
{
|
|
// Only slot left, can't delete.
|
|
return;
|
|
}
|
|
|
|
nextSlot = ns;
|
|
}
|
|
|
|
var arr = new Dictionary<int, ICharacterProfile>(curPrefs.Characters);
|
|
arr.Remove(slot);
|
|
|
|
prefsData.Prefs = new PlayerPreferences(arr, nextSlot ?? curPrefs.SelectedCharacterIndex, curPrefs.AdminOOCColor);
|
|
|
|
if (ShouldStorePrefs(message.MsgChannel.AuthType))
|
|
{
|
|
if (nextSlot != null)
|
|
{
|
|
await _db.DeleteSlotAndSetSelectedIndex(userId, slot, nextSlot.Value);
|
|
}
|
|
else
|
|
{
|
|
await _db.SaveCharacterSlotAsync(userId, null, slot);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Should only be called via UserDbDataManager.
|
|
public async Task LoadData(ICommonSession session, CancellationToken cancel)
|
|
{
|
|
if (!ShouldStorePrefs(session.Channel.AuthType))
|
|
{
|
|
// Don't store data for guests.
|
|
var prefsData = new PlayerPrefData
|
|
{
|
|
PrefsLoaded = true,
|
|
Prefs = new PlayerPreferences(
|
|
new[] {new KeyValuePair<int, ICharacterProfile>(0, HumanoidCharacterProfile.Random())},
|
|
0, Color.Transparent)
|
|
};
|
|
|
|
_cachedPlayerPrefs[session.UserId] = prefsData;
|
|
}
|
|
else
|
|
{
|
|
var prefsData = new PlayerPrefData();
|
|
var loadTask = LoadPrefs();
|
|
_cachedPlayerPrefs[session.UserId] = prefsData;
|
|
|
|
await loadTask;
|
|
|
|
async Task LoadPrefs()
|
|
{
|
|
var prefs = await GetOrCreatePreferencesAsync(session.UserId, cancel);
|
|
prefsData.Prefs = prefs;
|
|
prefsData.PrefsLoaded = true;
|
|
|
|
var msg = new MsgPreferencesAndSettings();
|
|
msg.Preferences = prefs;
|
|
msg.Settings = new GameSettings
|
|
{
|
|
MaxCharacterSlots = MaxCharacterSlots
|
|
};
|
|
_netManager.ServerSendMessage(msg, session.Channel);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void SanitizeData(ICommonSession session)
|
|
{
|
|
// This is a separate step from the actual database load.
|
|
// Sanitizing preferences requires play time info due to loadouts.
|
|
// And play time info is loaded concurrently from the DB with preferences.
|
|
var data = _cachedPlayerPrefs[session.UserId];
|
|
DebugTools.Assert(data.Prefs != null);
|
|
data.Prefs = SanitizePreferences(session, data.Prefs, _dependencies);
|
|
}
|
|
|
|
public void OnClientDisconnected(ICommonSession session)
|
|
{
|
|
_cachedPlayerPrefs.Remove(session.UserId);
|
|
}
|
|
|
|
public bool HavePreferencesLoaded(ICommonSession session)
|
|
{
|
|
return _cachedPlayerPrefs.ContainsKey(session.UserId);
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Tries to get the preferences from the cache
|
|
/// </summary>
|
|
/// <param name="userId">User Id to get preferences for</param>
|
|
/// <param name="playerPreferences">The user preferences if true, otherwise null</param>
|
|
/// <returns>If preferences are not null</returns>
|
|
public bool TryGetCachedPreferences(NetUserId userId,
|
|
[NotNullWhen(true)] out PlayerPreferences? playerPreferences)
|
|
{
|
|
if (_cachedPlayerPrefs.TryGetValue(userId, out var prefs))
|
|
{
|
|
playerPreferences = prefs.Prefs;
|
|
return prefs.Prefs != null;
|
|
}
|
|
|
|
playerPreferences = null;
|
|
return false;
|
|
}
|
|
|
|
/// <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(NetUserId userId)
|
|
{
|
|
var prefs = _cachedPlayerPrefs[userId].Prefs;
|
|
if (prefs == null)
|
|
{
|
|
throw new InvalidOperationException("Preferences for this player have not loaded yet.");
|
|
}
|
|
|
|
return prefs;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves preferences for the given username from storage or returns null.
|
|
/// Creates and saves default preferences if they are not found, then returns them.
|
|
/// </summary>
|
|
public PlayerPreferences? GetPreferencesOrNull(NetUserId? userId)
|
|
{
|
|
if (userId == null)
|
|
return null;
|
|
|
|
if (_cachedPlayerPrefs.TryGetValue(userId.Value, out var pref))
|
|
return pref.Prefs;
|
|
return null;
|
|
}
|
|
|
|
private async Task<PlayerPreferences> GetOrCreatePreferencesAsync(NetUserId userId, CancellationToken cancel)
|
|
{
|
|
var prefs = await _db.GetPlayerPreferencesAsync(userId, cancel);
|
|
if (prefs is null)
|
|
{
|
|
return await _db.InitPrefsAsync(userId, HumanoidCharacterProfile.Random(), cancel);
|
|
}
|
|
|
|
return prefs;
|
|
}
|
|
|
|
private PlayerPreferences SanitizePreferences(ICommonSession session, PlayerPreferences prefs, IDependencyCollection collection)
|
|
{
|
|
// Clean up preferences in case of changes to the game,
|
|
// such as removed jobs still being selected.
|
|
|
|
return new PlayerPreferences(prefs.Characters.Select(p =>
|
|
{
|
|
return new KeyValuePair<int, ICharacterProfile>(p.Key, p.Value.Validated(session, collection));
|
|
}), prefs.SelectedCharacterIndex, prefs.AdminOOCColor);
|
|
}
|
|
|
|
public IEnumerable<KeyValuePair<NetUserId, ICharacterProfile>> GetSelectedProfilesForPlayers(
|
|
List<NetUserId> usernames)
|
|
{
|
|
return usernames
|
|
.Select(p => (_cachedPlayerPrefs[p].Prefs, p))
|
|
.Where(p => p.Prefs != null)
|
|
.Select(p =>
|
|
{
|
|
var idx = p.Prefs!.SelectedCharacterIndex;
|
|
return new KeyValuePair<NetUserId, ICharacterProfile>(p.p, p.Prefs!.GetProfile(idx));
|
|
});
|
|
}
|
|
|
|
internal static bool ShouldStorePrefs(LoginType loginType)
|
|
{
|
|
return loginType.HasStaticUserId();
|
|
}
|
|
|
|
private sealed class PlayerPrefData
|
|
{
|
|
public bool PrefsLoaded;
|
|
public PlayerPreferences? Prefs;
|
|
}
|
|
}
|
|
}
|