1
0

PlayTimeTrackingManager.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. using System.Diagnostics.CodeAnalysis;
  2. using System.Runtime.InteropServices;
  3. using System.Threading;
  4. using System.Threading.Tasks;
  5. using Content.Server.Database;
  6. using Content.Shared.CCVar;
  7. using Content.Shared.Players.PlayTimeTracking;
  8. using Robust.Shared.Asynchronous;
  9. using Robust.Shared.Collections;
  10. using Robust.Shared.Configuration;
  11. using Robust.Shared.Exceptions;
  12. using Robust.Shared.Network;
  13. using Robust.Shared.Player;
  14. using Robust.Shared.Timing;
  15. using Robust.Shared.Utility;
  16. namespace Content.Server.Players.PlayTimeTracking;
  17. public delegate void CalcPlayTimeTrackersCallback(ICommonSession player, HashSet<string> trackers);
  18. /// <summary>
  19. /// Tracks play time for players, across all roles.
  20. /// </summary>
  21. /// <remarks>
  22. /// <para>
  23. /// Play time is tracked in distinct "trackers" (defined in <see cref="PlayTimeTrackerPrototype"/>).
  24. /// Most jobs correspond to one such tracker, but there are also more trackers like <c>"Overall"</c> which tracks cumulative playtime across all roles.
  25. /// </para>
  26. /// <para>
  27. /// To actually figure out what trackers are active, <see cref="CalcTrackers"/> is invoked in a "refresh".
  28. /// The next time the trackers are refreshed, these trackers all get the time since the last refresh added.
  29. /// Refreshes are triggered by <see cref="QueueRefreshTrackers"/>, and should be raised through events such as players' roles changing.
  30. /// </para>
  31. /// <para>
  32. /// Because the calculation system does not persistently keep ticking timers,
  33. /// APIs like <see cref="GetPlayTimeForTracker"/> will not see live-updating information.
  34. /// A light-weight form of refresh is a "flush" through <see cref="FlushTracker"/>.
  35. /// This will not cause active trackers to be re-calculated like a refresh,
  36. /// but it will ensure stored play time info is up to date.
  37. /// </para>
  38. /// <para>
  39. /// Trackers are auto-saved to DB on a cvar-configured interval. This interval is independent of refreshes,
  40. /// but does do a flush to get the latest info.
  41. /// Some things like round restarts and player disconnects cause immediate saving of one or all sessions.
  42. /// </para>
  43. /// <para>
  44. /// Tracker data is loaded from the database when the client connects as part of <see cref="UserDbDataManager"/>.
  45. /// </para>
  46. /// <para>
  47. /// Timing logic in this manager is ran **out** of simulation.
  48. /// This means that we use real time, not simulation time, for timing everything here.
  49. /// </para>
  50. /// <para>
  51. /// Operations like refreshing and sending play time info to clients are deferred until the next frame (note: not tick).
  52. /// </para>
  53. /// </remarks>
  54. public sealed class PlayTimeTrackingManager : ISharedPlaytimeManager, IPostInjectInit
  55. {
  56. [Dependency] private readonly IServerDbManager _db = default!;
  57. [Dependency] private readonly IServerNetManager _net = default!;
  58. [Dependency] private readonly IConfigurationManager _cfg = default!;
  59. [Dependency] private readonly IGameTiming _timing = default!;
  60. [Dependency] private readonly ITaskManager _task = default!;
  61. [Dependency] private readonly IRuntimeLog _runtimeLog = default!;
  62. [Dependency] private readonly UserDbDataManager _userDb = default!;
  63. private ISawmill _sawmill = default!;
  64. // List of players that need some kind of update (refresh timers or resend).
  65. private ValueList<ICommonSession> _playersDirty;
  66. // DB auto-saving logic.
  67. private TimeSpan _saveInterval;
  68. private TimeSpan _lastSave;
  69. // List of pending DB save operations.
  70. // We must block server shutdown on these to avoid losing data.
  71. private readonly List<Task> _pendingSaveTasks = new();
  72. private readonly Dictionary<ICommonSession, PlayTimeData> _playTimeData = new();
  73. public event CalcPlayTimeTrackersCallback? CalcTrackers;
  74. public event Action<ICommonSession>? SessionPlayTimeUpdated;
  75. public void Initialize()
  76. {
  77. _sawmill = Logger.GetSawmill("play_time");
  78. _net.RegisterNetMessage<MsgPlayTime>();
  79. _cfg.OnValueChanged(CCVars.PlayTimeSaveInterval, f => _saveInterval = TimeSpan.FromSeconds(f), true);
  80. }
  81. public void Shutdown()
  82. {
  83. Save();
  84. _task.BlockWaitOnTask(Task.WhenAll(_pendingSaveTasks));
  85. }
  86. public void Update()
  87. {
  88. // NOTE: This is run **out** of simulation. This is intentional.
  89. UpdateDirtyPlayers();
  90. if (_timing.RealTime < _lastSave + _saveInterval)
  91. return;
  92. Save();
  93. }
  94. private void UpdateDirtyPlayers()
  95. {
  96. if (_playersDirty.Count == 0)
  97. return;
  98. var time = _timing.RealTime;
  99. foreach (var player in _playersDirty)
  100. {
  101. if (!_playTimeData.TryGetValue(player, out var data))
  102. continue;
  103. DebugTools.Assert(data.IsDirty);
  104. if (data.NeedRefreshTackers)
  105. {
  106. RefreshSingleTracker(player, data, time);
  107. }
  108. if (data.NeedSendTimers)
  109. {
  110. SendPlayTimes(player);
  111. data.NeedSendTimers = false;
  112. }
  113. data.IsDirty = false;
  114. }
  115. _playersDirty.Clear();
  116. }
  117. private void RefreshSingleTracker(ICommonSession dirty, PlayTimeData data, TimeSpan time)
  118. {
  119. DebugTools.Assert(data.Initialized);
  120. FlushSingleTracker(data, time);
  121. data.NeedRefreshTackers = false;
  122. data.ActiveTrackers.Clear();
  123. // Fetch new trackers.
  124. // Inside try catch to avoid state corruption from bad callback code.
  125. try
  126. {
  127. CalcTrackers?.Invoke(dirty, data.ActiveTrackers);
  128. }
  129. catch (Exception e)
  130. {
  131. _runtimeLog.LogException(e, "PlayTime CalcTrackers");
  132. data.ActiveTrackers.Clear();
  133. }
  134. }
  135. /// <summary>
  136. /// Flush all trackers for all players.
  137. /// </summary>
  138. /// <seealso cref="FlushTracker"/>
  139. public void FlushAllTrackers()
  140. {
  141. var time = _timing.RealTime;
  142. foreach (var data in _playTimeData.Values)
  143. {
  144. FlushSingleTracker(data, time);
  145. }
  146. }
  147. /// <summary>
  148. /// Flush time tracker information for a player,
  149. /// so APIs like <see cref="GetPlayTimeForTracker"/> return up-to-date info.
  150. /// </summary>
  151. /// <seealso cref="FlushAllTrackers"/>
  152. public void FlushTracker(ICommonSession player)
  153. {
  154. var time = _timing.RealTime;
  155. var data = _playTimeData[player];
  156. FlushSingleTracker(data, time);
  157. }
  158. private static void FlushSingleTracker(PlayTimeData data, TimeSpan time)
  159. {
  160. var delta = time - data.LastUpdate;
  161. data.LastUpdate = time;
  162. // Flush active trackers into semi-permanent storage.
  163. foreach (var active in data.ActiveTrackers)
  164. {
  165. AddTimeToTracker(data, active, delta);
  166. }
  167. }
  168. public IReadOnlyDictionary<string, TimeSpan> GetPlayTimes(ICommonSession session)
  169. {
  170. return GetTrackerTimes(session);
  171. }
  172. private void SendPlayTimes(ICommonSession pSession)
  173. {
  174. var roles = GetTrackerTimes(pSession);
  175. var msg = new MsgPlayTime
  176. {
  177. Trackers = roles
  178. };
  179. _net.ServerSendMessage(msg, pSession.Channel);
  180. SessionPlayTimeUpdated?.Invoke(pSession);
  181. }
  182. /// <summary>
  183. /// Save all modified time trackers for all players to the database.
  184. /// </summary>
  185. public async void Save()
  186. {
  187. FlushAllTrackers();
  188. _lastSave = _timing.RealTime;
  189. TrackPending(DoSaveAsync());
  190. }
  191. /// <summary>
  192. /// Save all modified time trackers for a player to the database.
  193. /// </summary>
  194. public async void SaveSession(ICommonSession session)
  195. {
  196. // This causes all trackers to refresh, ah well.
  197. FlushAllTrackers();
  198. TrackPending(DoSaveSessionAsync(session));
  199. }
  200. /// <summary>
  201. /// Track a database save task to make sure we block server shutdown on it.
  202. /// </summary>
  203. private async void TrackPending(Task task)
  204. {
  205. _pendingSaveTasks.Add(task);
  206. try
  207. {
  208. await task;
  209. }
  210. finally
  211. {
  212. _pendingSaveTasks.Remove(task);
  213. }
  214. }
  215. private async Task DoSaveAsync()
  216. {
  217. var log = new List<PlayTimeUpdate>();
  218. foreach (var (player, data) in _playTimeData)
  219. {
  220. foreach (var tracker in data.DbTrackersDirty)
  221. {
  222. log.Add(new PlayTimeUpdate(player.UserId, tracker, data.TrackerTimes[tracker]));
  223. }
  224. data.DbTrackersDirty.Clear();
  225. }
  226. if (log.Count == 0)
  227. return;
  228. // NOTE: we do replace updates here, not incremental additions.
  229. // This means that if you're playing on two servers at the same time, they'll step on each other's feet.
  230. // This is considered fine.
  231. await _db.UpdatePlayTimes(log);
  232. _sawmill.Debug($"Saved {log.Count} trackers");
  233. }
  234. private async Task DoSaveSessionAsync(ICommonSession session)
  235. {
  236. var log = new List<PlayTimeUpdate>();
  237. var data = _playTimeData[session];
  238. foreach (var tracker in data.DbTrackersDirty)
  239. {
  240. log.Add(new PlayTimeUpdate(session.UserId, tracker, data.TrackerTimes[tracker]));
  241. }
  242. data.DbTrackersDirty.Clear();
  243. // NOTE: we do replace updates here, not incremental additions.
  244. // This means that if you're playing on two servers at the same time, they'll step on each other's feet.
  245. // This is considered fine.
  246. await _db.UpdatePlayTimes(log);
  247. _sawmill.Debug($"Saved {log.Count} trackers for {session.Name}");
  248. }
  249. public async Task LoadData(ICommonSession session, CancellationToken cancel)
  250. {
  251. var data = new PlayTimeData();
  252. _playTimeData.Add(session, data);
  253. var playTimes = await _db.GetPlayTimes(session.UserId, cancel);
  254. cancel.ThrowIfCancellationRequested();
  255. foreach (var timer in playTimes)
  256. {
  257. data.TrackerTimes.Add(timer.Tracker, timer.TimeSpent);
  258. }
  259. data.Initialized = true;
  260. QueueRefreshTrackers(session);
  261. QueueSendTimers(session);
  262. }
  263. public void ClientDisconnected(ICommonSession session)
  264. {
  265. SaveSession(session);
  266. _playTimeData.Remove(session);
  267. }
  268. public void AddTimeToTracker(ICommonSession id, string tracker, TimeSpan time)
  269. {
  270. if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized)
  271. throw new InvalidOperationException("Play time info is not yet loaded for this player!");
  272. AddTimeToTracker(data, tracker, time);
  273. }
  274. private static void AddTimeToTracker(PlayTimeData data, string tracker, TimeSpan time)
  275. {
  276. ref var timer = ref CollectionsMarshal.GetValueRefOrAddDefault(data.TrackerTimes, tracker, out _);
  277. timer += time;
  278. data.DbTrackersDirty.Add(tracker);
  279. }
  280. public void AddTimeToOverallPlaytime(ICommonSession id, TimeSpan time)
  281. {
  282. AddTimeToTracker(id, PlayTimeTrackingShared.TrackerOverall, time);
  283. }
  284. public TimeSpan GetOverallPlaytime(ICommonSession id)
  285. {
  286. return GetPlayTimeForTracker(id, PlayTimeTrackingShared.TrackerOverall);
  287. }
  288. public bool TryGetTrackerTimes(ICommonSession id, [NotNullWhen(true)] out Dictionary<string, TimeSpan>? time)
  289. {
  290. time = null;
  291. if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized)
  292. {
  293. return false;
  294. }
  295. time = data.TrackerTimes;
  296. return true;
  297. }
  298. public bool TryGetTrackerTime(ICommonSession id, string tracker, [NotNullWhen(true)] out TimeSpan? time)
  299. {
  300. time = null;
  301. if (!TryGetTrackerTimes(id, out var times))
  302. return false;
  303. if (!times.TryGetValue(tracker, out var t))
  304. return false;
  305. time = t;
  306. return true;
  307. }
  308. public Dictionary<string, TimeSpan> GetTrackerTimes(ICommonSession id)
  309. {
  310. if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized)
  311. throw new InvalidOperationException("Play time info is not yet loaded for this player!");
  312. return data.TrackerTimes;
  313. }
  314. public TimeSpan GetPlayTimeForTracker(ICommonSession id, string tracker)
  315. {
  316. if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized)
  317. throw new InvalidOperationException("Play time info is not yet loaded for this player!");
  318. return data.TrackerTimes.GetValueOrDefault(tracker);
  319. }
  320. /// <summary>
  321. /// Queue for play time trackers to be refreshed on a player, in case the set of active trackers may have changed.
  322. /// </summary>
  323. public void QueueRefreshTrackers(ICommonSession player)
  324. {
  325. if (DirtyPlayer(player) is { } data)
  326. data.NeedRefreshTackers = true;
  327. }
  328. /// <summary>
  329. /// Queue for play time information to be sent to a client, for showing in UIs etc.
  330. /// </summary>
  331. public void QueueSendTimers(ICommonSession player)
  332. {
  333. if (DirtyPlayer(player) is { } data)
  334. data.NeedSendTimers = true;
  335. }
  336. private PlayTimeData? DirtyPlayer(ICommonSession player)
  337. {
  338. if (!_playTimeData.TryGetValue(player, out var data) || !data.Initialized)
  339. return null;
  340. if (!data.IsDirty)
  341. {
  342. data.IsDirty = true;
  343. _playersDirty.Add(player);
  344. }
  345. return data;
  346. }
  347. /// <summary>
  348. /// Play time info for a particular player.
  349. /// </summary>
  350. private sealed class PlayTimeData
  351. {
  352. // Queued update flags
  353. public bool IsDirty;
  354. public bool NeedRefreshTackers;
  355. public bool NeedSendTimers;
  356. // Active tracking info
  357. public readonly HashSet<string> ActiveTrackers = new();
  358. public TimeSpan LastUpdate;
  359. // Stored tracked time info.
  360. /// <summary>
  361. /// Have we finished retrieving our data from the DB?
  362. /// </summary>
  363. public bool Initialized;
  364. public readonly Dictionary<string, TimeSpan> TrackerTimes = new();
  365. /// <summary>
  366. /// Set of trackers which are different from their DB values and need to be saved to DB.
  367. /// </summary>
  368. public readonly HashSet<string> DbTrackersDirty = new();
  369. }
  370. void IPostInjectInit.PostInject()
  371. {
  372. _userDb.AddOnLoadPlayer(LoadData);
  373. _userDb.AddOnPlayerDisconnect(ClientDisconnected);
  374. }
  375. }