| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471 |
- using System.Diagnostics.CodeAnalysis;
- using System.Runtime.InteropServices;
- using System.Threading;
- using System.Threading.Tasks;
- using Content.Server.Database;
- using Content.Shared.CCVar;
- using Content.Shared.Players.PlayTimeTracking;
- using Robust.Shared.Asynchronous;
- using Robust.Shared.Collections;
- using Robust.Shared.Configuration;
- using Robust.Shared.Exceptions;
- using Robust.Shared.Network;
- using Robust.Shared.Player;
- using Robust.Shared.Timing;
- using Robust.Shared.Utility;
- namespace Content.Server.Players.PlayTimeTracking;
- public delegate void CalcPlayTimeTrackersCallback(ICommonSession player, HashSet<string> trackers);
- /// <summary>
- /// Tracks play time for players, across all roles.
- /// </summary>
- /// <remarks>
- /// <para>
- /// Play time is tracked in distinct "trackers" (defined in <see cref="PlayTimeTrackerPrototype"/>).
- /// Most jobs correspond to one such tracker, but there are also more trackers like <c>"Overall"</c> which tracks cumulative playtime across all roles.
- /// </para>
- /// <para>
- /// To actually figure out what trackers are active, <see cref="CalcTrackers"/> is invoked in a "refresh".
- /// The next time the trackers are refreshed, these trackers all get the time since the last refresh added.
- /// Refreshes are triggered by <see cref="QueueRefreshTrackers"/>, and should be raised through events such as players' roles changing.
- /// </para>
- /// <para>
- /// Because the calculation system does not persistently keep ticking timers,
- /// APIs like <see cref="GetPlayTimeForTracker"/> will not see live-updating information.
- /// A light-weight form of refresh is a "flush" through <see cref="FlushTracker"/>.
- /// This will not cause active trackers to be re-calculated like a refresh,
- /// but it will ensure stored play time info is up to date.
- /// </para>
- /// <para>
- /// Trackers are auto-saved to DB on a cvar-configured interval. This interval is independent of refreshes,
- /// but does do a flush to get the latest info.
- /// Some things like round restarts and player disconnects cause immediate saving of one or all sessions.
- /// </para>
- /// <para>
- /// Tracker data is loaded from the database when the client connects as part of <see cref="UserDbDataManager"/>.
- /// </para>
- /// <para>
- /// Timing logic in this manager is ran **out** of simulation.
- /// This means that we use real time, not simulation time, for timing everything here.
- /// </para>
- /// <para>
- /// Operations like refreshing and sending play time info to clients are deferred until the next frame (note: not tick).
- /// </para>
- /// </remarks>
- public sealed class PlayTimeTrackingManager : ISharedPlaytimeManager, IPostInjectInit
- {
- [Dependency] private readonly IServerDbManager _db = default!;
- [Dependency] private readonly IServerNetManager _net = default!;
- [Dependency] private readonly IConfigurationManager _cfg = default!;
- [Dependency] private readonly IGameTiming _timing = default!;
- [Dependency] private readonly ITaskManager _task = default!;
- [Dependency] private readonly IRuntimeLog _runtimeLog = default!;
- [Dependency] private readonly UserDbDataManager _userDb = default!;
- private ISawmill _sawmill = default!;
- // List of players that need some kind of update (refresh timers or resend).
- private ValueList<ICommonSession> _playersDirty;
- // DB auto-saving logic.
- private TimeSpan _saveInterval;
- private TimeSpan _lastSave;
- // List of pending DB save operations.
- // We must block server shutdown on these to avoid losing data.
- private readonly List<Task> _pendingSaveTasks = new();
- private readonly Dictionary<ICommonSession, PlayTimeData> _playTimeData = new();
- public event CalcPlayTimeTrackersCallback? CalcTrackers;
- public event Action<ICommonSession>? SessionPlayTimeUpdated;
- public void Initialize()
- {
- _sawmill = Logger.GetSawmill("play_time");
- _net.RegisterNetMessage<MsgPlayTime>();
- _cfg.OnValueChanged(CCVars.PlayTimeSaveInterval, f => _saveInterval = TimeSpan.FromSeconds(f), true);
- }
- public void Shutdown()
- {
- Save();
- _task.BlockWaitOnTask(Task.WhenAll(_pendingSaveTasks));
- }
- public void Update()
- {
- // NOTE: This is run **out** of simulation. This is intentional.
- UpdateDirtyPlayers();
- if (_timing.RealTime < _lastSave + _saveInterval)
- return;
- Save();
- }
- private void UpdateDirtyPlayers()
- {
- if (_playersDirty.Count == 0)
- return;
- var time = _timing.RealTime;
- foreach (var player in _playersDirty)
- {
- if (!_playTimeData.TryGetValue(player, out var data))
- continue;
- DebugTools.Assert(data.IsDirty);
- if (data.NeedRefreshTackers)
- {
- RefreshSingleTracker(player, data, time);
- }
- if (data.NeedSendTimers)
- {
- SendPlayTimes(player);
- data.NeedSendTimers = false;
- }
- data.IsDirty = false;
- }
- _playersDirty.Clear();
- }
- private void RefreshSingleTracker(ICommonSession dirty, PlayTimeData data, TimeSpan time)
- {
- DebugTools.Assert(data.Initialized);
- FlushSingleTracker(data, time);
- data.NeedRefreshTackers = false;
- data.ActiveTrackers.Clear();
- // Fetch new trackers.
- // Inside try catch to avoid state corruption from bad callback code.
- try
- {
- CalcTrackers?.Invoke(dirty, data.ActiveTrackers);
- }
- catch (Exception e)
- {
- _runtimeLog.LogException(e, "PlayTime CalcTrackers");
- data.ActiveTrackers.Clear();
- }
- }
- /// <summary>
- /// Flush all trackers for all players.
- /// </summary>
- /// <seealso cref="FlushTracker"/>
- public void FlushAllTrackers()
- {
- var time = _timing.RealTime;
- foreach (var data in _playTimeData.Values)
- {
- FlushSingleTracker(data, time);
- }
- }
- /// <summary>
- /// Flush time tracker information for a player,
- /// so APIs like <see cref="GetPlayTimeForTracker"/> return up-to-date info.
- /// </summary>
- /// <seealso cref="FlushAllTrackers"/>
- public void FlushTracker(ICommonSession player)
- {
- var time = _timing.RealTime;
- var data = _playTimeData[player];
- FlushSingleTracker(data, time);
- }
- private static void FlushSingleTracker(PlayTimeData data, TimeSpan time)
- {
- var delta = time - data.LastUpdate;
- data.LastUpdate = time;
- // Flush active trackers into semi-permanent storage.
- foreach (var active in data.ActiveTrackers)
- {
- AddTimeToTracker(data, active, delta);
- }
- }
- public IReadOnlyDictionary<string, TimeSpan> GetPlayTimes(ICommonSession session)
- {
- return GetTrackerTimes(session);
- }
- private void SendPlayTimes(ICommonSession pSession)
- {
- var roles = GetTrackerTimes(pSession);
- var msg = new MsgPlayTime
- {
- Trackers = roles
- };
- _net.ServerSendMessage(msg, pSession.Channel);
- SessionPlayTimeUpdated?.Invoke(pSession);
- }
- /// <summary>
- /// Save all modified time trackers for all players to the database.
- /// </summary>
- public async void Save()
- {
- FlushAllTrackers();
- _lastSave = _timing.RealTime;
- TrackPending(DoSaveAsync());
- }
- /// <summary>
- /// Save all modified time trackers for a player to the database.
- /// </summary>
- public async void SaveSession(ICommonSession session)
- {
- // This causes all trackers to refresh, ah well.
- FlushAllTrackers();
- TrackPending(DoSaveSessionAsync(session));
- }
- /// <summary>
- /// Track a database save task to make sure we block server shutdown on it.
- /// </summary>
- private async void TrackPending(Task task)
- {
- _pendingSaveTasks.Add(task);
- try
- {
- await task;
- }
- finally
- {
- _pendingSaveTasks.Remove(task);
- }
- }
- private async Task DoSaveAsync()
- {
- var log = new List<PlayTimeUpdate>();
- foreach (var (player, data) in _playTimeData)
- {
- foreach (var tracker in data.DbTrackersDirty)
- {
- log.Add(new PlayTimeUpdate(player.UserId, tracker, data.TrackerTimes[tracker]));
- }
- data.DbTrackersDirty.Clear();
- }
- if (log.Count == 0)
- return;
- // NOTE: we do replace updates here, not incremental additions.
- // This means that if you're playing on two servers at the same time, they'll step on each other's feet.
- // This is considered fine.
- await _db.UpdatePlayTimes(log);
- _sawmill.Debug($"Saved {log.Count} trackers");
- }
- private async Task DoSaveSessionAsync(ICommonSession session)
- {
- var log = new List<PlayTimeUpdate>();
- var data = _playTimeData[session];
- foreach (var tracker in data.DbTrackersDirty)
- {
- log.Add(new PlayTimeUpdate(session.UserId, tracker, data.TrackerTimes[tracker]));
- }
- data.DbTrackersDirty.Clear();
- // NOTE: we do replace updates here, not incremental additions.
- // This means that if you're playing on two servers at the same time, they'll step on each other's feet.
- // This is considered fine.
- await _db.UpdatePlayTimes(log);
- _sawmill.Debug($"Saved {log.Count} trackers for {session.Name}");
- }
- public async Task LoadData(ICommonSession session, CancellationToken cancel)
- {
- var data = new PlayTimeData();
- _playTimeData.Add(session, data);
- var playTimes = await _db.GetPlayTimes(session.UserId, cancel);
- cancel.ThrowIfCancellationRequested();
- foreach (var timer in playTimes)
- {
- data.TrackerTimes.Add(timer.Tracker, timer.TimeSpent);
- }
- data.Initialized = true;
- QueueRefreshTrackers(session);
- QueueSendTimers(session);
- }
- public void ClientDisconnected(ICommonSession session)
- {
- SaveSession(session);
- _playTimeData.Remove(session);
- }
- public void AddTimeToTracker(ICommonSession id, string tracker, TimeSpan time)
- {
- if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized)
- throw new InvalidOperationException("Play time info is not yet loaded for this player!");
- AddTimeToTracker(data, tracker, time);
- }
- private static void AddTimeToTracker(PlayTimeData data, string tracker, TimeSpan time)
- {
- ref var timer = ref CollectionsMarshal.GetValueRefOrAddDefault(data.TrackerTimes, tracker, out _);
- timer += time;
- data.DbTrackersDirty.Add(tracker);
- }
- public void AddTimeToOverallPlaytime(ICommonSession id, TimeSpan time)
- {
- AddTimeToTracker(id, PlayTimeTrackingShared.TrackerOverall, time);
- }
- public TimeSpan GetOverallPlaytime(ICommonSession id)
- {
- return GetPlayTimeForTracker(id, PlayTimeTrackingShared.TrackerOverall);
- }
- public bool TryGetTrackerTimes(ICommonSession id, [NotNullWhen(true)] out Dictionary<string, TimeSpan>? time)
- {
- time = null;
- if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized)
- {
- return false;
- }
- time = data.TrackerTimes;
- return true;
- }
- public bool TryGetTrackerTime(ICommonSession id, string tracker, [NotNullWhen(true)] out TimeSpan? time)
- {
- time = null;
- if (!TryGetTrackerTimes(id, out var times))
- return false;
- if (!times.TryGetValue(tracker, out var t))
- return false;
- time = t;
- return true;
- }
- public Dictionary<string, TimeSpan> GetTrackerTimes(ICommonSession id)
- {
- if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized)
- throw new InvalidOperationException("Play time info is not yet loaded for this player!");
- return data.TrackerTimes;
- }
- public TimeSpan GetPlayTimeForTracker(ICommonSession id, string tracker)
- {
- if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized)
- throw new InvalidOperationException("Play time info is not yet loaded for this player!");
- return data.TrackerTimes.GetValueOrDefault(tracker);
- }
- /// <summary>
- /// Queue for play time trackers to be refreshed on a player, in case the set of active trackers may have changed.
- /// </summary>
- public void QueueRefreshTrackers(ICommonSession player)
- {
- if (DirtyPlayer(player) is { } data)
- data.NeedRefreshTackers = true;
- }
- /// <summary>
- /// Queue for play time information to be sent to a client, for showing in UIs etc.
- /// </summary>
- public void QueueSendTimers(ICommonSession player)
- {
- if (DirtyPlayer(player) is { } data)
- data.NeedSendTimers = true;
- }
- private PlayTimeData? DirtyPlayer(ICommonSession player)
- {
- if (!_playTimeData.TryGetValue(player, out var data) || !data.Initialized)
- return null;
- if (!data.IsDirty)
- {
- data.IsDirty = true;
- _playersDirty.Add(player);
- }
- return data;
- }
- /// <summary>
- /// Play time info for a particular player.
- /// </summary>
- private sealed class PlayTimeData
- {
- // Queued update flags
- public bool IsDirty;
- public bool NeedRefreshTackers;
- public bool NeedSendTimers;
- // Active tracking info
- public readonly HashSet<string> ActiveTrackers = new();
- public TimeSpan LastUpdate;
- // Stored tracked time info.
- /// <summary>
- /// Have we finished retrieving our data from the DB?
- /// </summary>
- public bool Initialized;
- public readonly Dictionary<string, TimeSpan> TrackerTimes = new();
- /// <summary>
- /// Set of trackers which are different from their DB values and need to be saved to DB.
- /// </summary>
- public readonly HashSet<string> DbTrackersDirty = new();
- }
- void IPostInjectInit.PostInject()
- {
- _userDb.AddOnLoadPlayer(LoadData);
- _userDb.AddOnPlayerDisconnect(ClientDisconnected);
- }
- }
|