From 3b7f90d729648f2a74b4245179d49eb21576c472 Mon Sep 17 00:00:00 2001 From: RavMorgan <48182970+RavMorgan@users.noreply.github.com> Date: Fri, 29 Mar 2024 22:06:07 +0300 Subject: [PATCH] Added global time manager (#251) * Added global time manager * Created IPlayTimeTrackingManager * adds default api link --------- Co-authored-by: Mona Hmiza Co-authored-by: Valtos --- .../Commands/PlayTimeCommands.cs | 12 +- .../Administration/Systems/AdminSystem.cs | 2 +- Content.Server/Database/UserDbDataManager.cs | 2 +- Content.Server/Entry/EntryPoint.cs | 4 +- Content.Server/IoC/ServerContentIoC.cs | 9 +- .../GlobalPlayTimeTrackingManager.cs | 463 ++++++++++++++++++ .../IPlayTimeTrackingManager.cs | 39 ++ .../PlayTimeTrackingManager.cs | 3 +- .../PlayTimeTrackingSystem.cs | 4 +- Content.Shared/_White/WhiteCVars.cs | 10 + 10 files changed, 534 insertions(+), 14 deletions(-) create mode 100644 Content.Server/Players/PlayTimeTracking/GlobalPlayTimeTrackingManager.cs create mode 100644 Content.Server/Players/PlayTimeTracking/IPlayTimeTrackingManager.cs diff --git a/Content.Server/Administration/Commands/PlayTimeCommands.cs b/Content.Server/Administration/Commands/PlayTimeCommands.cs index 97d3f12e38..dd64528a93 100644 --- a/Content.Server/Administration/Commands/PlayTimeCommands.cs +++ b/Content.Server/Administration/Commands/PlayTimeCommands.cs @@ -10,7 +10,7 @@ namespace Content.Server.Administration.Commands; public sealed class PlayTimeAddOverallCommand : IConsoleCommand { [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!; + [Dependency] private readonly IPlayTimeTrackingManager _playTimeTracking = default!; public string Command => "playtime_addoverall"; public string Description => Loc.GetString("cmd-playtime_addoverall-desc"); @@ -62,7 +62,7 @@ public sealed class PlayTimeAddOverallCommand : IConsoleCommand public sealed class PlayTimeAddRoleCommand : IConsoleCommand { [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!; + [Dependency] private readonly IPlayTimeTrackingManager _playTimeTracking = default!; public string Command => "playtime_addrole"; public string Description => Loc.GetString("cmd-playtime_addrole-desc"); @@ -127,7 +127,7 @@ public sealed class PlayTimeAddRoleCommand : IConsoleCommand public sealed class PlayTimeGetOverallCommand : IConsoleCommand { [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!; + [Dependency] private readonly IPlayTimeTrackingManager _playTimeTracking = default!; public string Command => "playtime_getoverall"; public string Description => Loc.GetString("cmd-playtime_getoverall-desc"); @@ -172,7 +172,7 @@ public sealed class PlayTimeGetOverallCommand : IConsoleCommand public sealed class PlayTimeGetRoleCommand : IConsoleCommand { [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!; + [Dependency] private readonly IPlayTimeTrackingManager _playTimeTracking = default!; public string Command => "playtime_getrole"; public string Description => Loc.GetString("cmd-playtime_getrole-desc"); @@ -251,7 +251,7 @@ public sealed class PlayTimeGetRoleCommand : IConsoleCommand public sealed class PlayTimeSaveCommand : IConsoleCommand { [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!; + [Dependency] private readonly IPlayTimeTrackingManager _playTimeTracking = default!; public string Command => "playtime_save"; public string Description => Loc.GetString("cmd-playtime_save-desc"); @@ -293,7 +293,7 @@ public sealed class PlayTimeSaveCommand : IConsoleCommand public sealed class PlayTimeFlushCommand : IConsoleCommand { [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!; + [Dependency] private readonly IPlayTimeTrackingManager _playTimeTracking = default!; public string Command => "playtime_flush"; public string Description => Loc.GetString("cmd-playtime_flush-desc"); diff --git a/Content.Server/Administration/Systems/AdminSystem.cs b/Content.Server/Administration/Systems/AdminSystem.cs index 9d41e91819..dcabe8c74a 100644 --- a/Content.Server/Administration/Systems/AdminSystem.cs +++ b/Content.Server/Administration/Systems/AdminSystem.cs @@ -46,7 +46,7 @@ namespace Content.Server.Administration.Systems [Dependency] private readonly MindSystem _minds = default!; [Dependency] private readonly PopupSystem _popup = default!; [Dependency] private readonly PhysicsSystem _physics = default!; - [Dependency] private readonly PlayTimeTrackingManager _playTime = default!; + [Dependency] private readonly IPlayTimeTrackingManager _playTime = default!; [Dependency] private readonly SharedRoleSystem _role = default!; [Dependency] private readonly GameTicker _gameTicker = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; diff --git a/Content.Server/Database/UserDbDataManager.cs b/Content.Server/Database/UserDbDataManager.cs index f8b1611fd5..f024ce09f2 100644 --- a/Content.Server/Database/UserDbDataManager.cs +++ b/Content.Server/Database/UserDbDataManager.cs @@ -19,7 +19,7 @@ namespace Content.Server.Database; public sealed class UserDbDataManager { [Dependency] private readonly IServerPreferencesManager _prefs = default!; - [Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!; + [Dependency] private readonly IPlayTimeTrackingManager _playTimeTracking = default!; private readonly Dictionary _users = new(); diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index c0f37219d6..fe2671b542 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -47,7 +47,7 @@ namespace Content.Server.Entry private EuiManager _euiManager = default!; private IVoteManager _voteManager = default!; private ServerUpdateManager _updateManager = default!; - private PlayTimeTrackingManager? _playTimeTracking; + private IPlayTimeTrackingManager? _playTimeTracking; private IServerDbManager? _dbManager; /// @@ -94,7 +94,7 @@ namespace Content.Server.Entry _euiManager = IoCManager.Resolve(); _voteManager = IoCManager.Resolve(); _updateManager = IoCManager.Resolve(); - _playTimeTracking = IoCManager.Resolve(); + _playTimeTracking = IoCManager.Resolve(); IoCManager.Resolve(); _dbManager = IoCManager.Resolve(); diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index f6cd337e43..1b6624f750 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -31,6 +31,7 @@ using Content.Shared.Administration; using Content.Shared.Administration.Logs; using Content.Shared.Administration.Managers; using Content.Shared.Kitchen; +using Robust.Server.Player; namespace Content.Server.IoC { @@ -61,7 +62,13 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); - IoCManager.Register(); + + #if FULL_RELEASE + IoCManager.Register(); + #else + IoCManager.Register(); + #endif + IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); diff --git a/Content.Server/Players/PlayTimeTracking/GlobalPlayTimeTrackingManager.cs b/Content.Server/Players/PlayTimeTracking/GlobalPlayTimeTrackingManager.cs new file mode 100644 index 0000000000..ea79e9784d --- /dev/null +++ b/Content.Server/Players/PlayTimeTracking/GlobalPlayTimeTrackingManager.cs @@ -0,0 +1,463 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Content.Server.Database; +using Content.Shared._White; +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 sealed class GlobalPlayTimeTrackingManager : IPlayTimeTrackingManager +{ + [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!; + + private ISawmill _sawmill = default!; + + // List of players that need some kind of update (refresh timers or resend). + private ValueList _playersDirty; + + // DB auto-saving logic. + private TimeSpan _saveInterval; + private TimeSpan _lastSave; + + private HttpClient _httpClient = new(); + + private string _apiUrl = string.Empty; + private string _apiKey = string.Empty; + + // List of pending DB save operations. + // We must block server shutdown on these to avoid losing data. + private readonly List _pendingSaveTasks = new(); + + private readonly Dictionary _playTimeData = new(); + + public event CalcPlayTimeTrackersCallback? CalcTrackers; + + public void Initialize() + { + _sawmill = Logger.GetSawmill("play_time"); + + _net.RegisterNetMessage(); + + _cfg.OnValueChanged(CCVars.PlayTimeSaveInterval, f => _saveInterval = TimeSpan.FromSeconds(f), true); + + _cfg.OnValueChanged(WhiteCVars.TimeTrackerApiUrl, newValue => _apiUrl = newValue, true); + _cfg.OnValueChanged(WhiteCVars.TimeTrackerApiKey, newValue => _apiKey = newValue, true); + + _sawmill.Info("Using global PlayTimeTracker"); + } + + 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(); + } + } + + /// + /// Flush all trackers for all players. + /// + /// + public void FlushAllTrackers() + { + var time = _timing.RealTime; + + foreach (var data in _playTimeData.Values) + { + FlushSingleTracker(data, time); + } + } + + /// + /// Flush time tracker information for a player, + /// so APIs like return up-to-date info. + /// + /// + 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); + } + } + + private void SendPlayTimes(ICommonSession pSession) + { + var roles = GetTrackerTimes(pSession); + + var msg = new MsgPlayTime + { + Trackers = roles + }; + + _net.ServerSendMessage(msg, pSession.Channel); + } + + /// + /// Save all modified time trackers for all players to the database. + /// + public async void Save() + { + FlushAllTrackers(); + + _lastSave = _timing.RealTime; + + TrackPending(DoSaveAsync()); + } + + /// + /// Save all modified time trackers for a player to the database. + /// + public async void SaveSession(ICommonSession session) + { + // This causes all trackers to refresh, ah well. + FlushAllTrackers(); + + TrackPending(DoSaveSessionAsync(session)); + } + + /// + /// Track a database save task to make sure we block server shutdown on it. + /// + private async void TrackPending(Task task) + { + _pendingSaveTasks.Add(task); + + try + { + await task; + } + finally + { + _pendingSaveTasks.Remove(task); + } + } + + private async Task DoSaveAsync() + { + var log = new List(); + + 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 UpdatePlayTimes(log); + + _sawmill.Debug($"Saved {log.Count} trackers"); + } + + private async Task UpdatePlayTimes(List update) + { + foreach (var playTimeUpdate in update) + { + var query = $"{_apiUrl}set/?uid={playTimeUpdate.User}&key={_apiKey}&tracker={playTimeUpdate.Tracker}&newtime={playTimeUpdate.Time}"; + + await _httpClient.GetAsync(query); + } + } + + private async Task DoSaveSessionAsync(ICommonSession session) + { + var log = new List(); + + 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 UpdatePlayTimes(log); + + _sawmill.Debug($"Saved {log.Count} trackers for {session.Name}"); + } + + private sealed class PlayTimeDto + { + [JsonPropertyName("tracker")] + public string Tracker { get; set; } = default!; + + [JsonPropertyName("time_spent")] + public TimeSpan TimeSpent { get; set; } = default!; + } + + public async Task LoadData(ICommonSession session, CancellationToken cancel) + { + var data = new PlayTimeData(); + _playTimeData.Add(session, data); + + var query = $"{_apiUrl}get/?uid={session.UserId}&key={_apiKey}"; + query = WebUtility.UrlDecode(query); + + var response = await _httpClient.GetAsync(query, cancel); + + if (!response.IsSuccessStatusCode) + { + throw new Exception("Play time tracker api shits itself"); + } + + List? playTimes = null!; + + try + { + playTimes = await response.Content.ReadFromJsonAsync>(); + } + catch (JsonException) + { + // Ignore + } + + cancel.ThrowIfCancellationRequested(); + + if (playTimes != null) + { + 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? time) + { + time = null; + + if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized) + { + return false; + } + + time = data.TrackerTimes; + return true; + } + + public Dictionary 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); + } + + /// + /// Queue for play time trackers to be refreshed on a player, in case the set of active trackers may have changed. + /// + public void QueueRefreshTrackers(ICommonSession player) + { + if (DirtyPlayer(player) is { } data) + data.NeedRefreshTackers = true; + } + + /// + /// Queue for play time information to be sent to a client, for showing in UIs etc. + /// + 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; + } + + /// + /// Play time info for a particular player. + /// + private sealed class PlayTimeData + { + // Queued update flags + public bool IsDirty; + public bool NeedRefreshTackers; + public bool NeedSendTimers; + + // Active tracking info + public readonly HashSet ActiveTrackers = new(); + public TimeSpan LastUpdate; + + // Stored tracked time info. + + /// + /// Have we finished retrieving our data from the DB? + /// + public bool Initialized; + + public readonly Dictionary TrackerTimes = new(); + + /// + /// Set of trackers which are different from their DB values and need to be saved to DB. + /// + public readonly HashSet DbTrackersDirty = new(); + } +} diff --git a/Content.Server/Players/PlayTimeTracking/IPlayTimeTrackingManager.cs b/Content.Server/Players/PlayTimeTracking/IPlayTimeTrackingManager.cs new file mode 100644 index 0000000000..7559c8bf3f --- /dev/null +++ b/Content.Server/Players/PlayTimeTracking/IPlayTimeTrackingManager.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Robust.Shared.Player; + +namespace Content.Server.Players.PlayTimeTracking; + +public interface IPlayTimeTrackingManager +{ + event CalcPlayTimeTrackersCallback? CalcTrackers; + + void Initialize(); + + void Shutdown(); + + void Update(); + + void FlushAllTrackers(); + + void FlushTracker(ICommonSession player); + + void SaveSession(ICommonSession session); + + public Task LoadData(ICommonSession session, CancellationToken cancel); + + void ClientDisconnected(ICommonSession session); + void AddTimeToOverallPlaytime(ICommonSession id, TimeSpan time); + + TimeSpan GetOverallPlaytime(ICommonSession id); + + bool TryGetTrackerTimes(ICommonSession id, [NotNullWhen(true)] out Dictionary? time); + + Dictionary GetTrackerTimes(ICommonSession id); + TimeSpan GetPlayTimeForTracker(ICommonSession id, string tracker); + void AddTimeToTracker(ICommonSession id, string tracker, TimeSpan time); + public void QueueRefreshTrackers(ICommonSession player); + public void QueueSendTimers(ICommonSession player); + void Save(); +} diff --git a/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingManager.cs b/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingManager.cs index 5949d57197..9019d0f1a5 100644 --- a/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingManager.cs +++ b/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingManager.cs @@ -54,7 +54,7 @@ public delegate void CalcPlayTimeTrackersCallback(ICommonSession player, HashSet /// Operations like refreshing and sending play time info to clients are deferred until the next frame (note: not tick). /// /// -public sealed class PlayTimeTrackingManager +public sealed class PlayTimeTrackingManager : IPlayTimeTrackingManager { [Dependency] private readonly IServerDbManager _db = default!; [Dependency] private readonly IServerNetManager _net = default!; @@ -87,6 +87,7 @@ public sealed class PlayTimeTrackingManager _net.RegisterNetMessage(); _cfg.OnValueChanged(CCVars.PlayTimeSaveInterval, f => _saveInterval = TimeSpan.FromSeconds(f), true); + _sawmill.Info("Using default PlayTimeTracker"); } public void Shutdown() diff --git a/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs b/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs index a7ee8bd8b0..00e35623b7 100644 --- a/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs +++ b/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs @@ -22,7 +22,7 @@ using Robust.Shared.Utility; namespace Content.Server.Players.PlayTimeTracking; /// -/// Connects to the simulation state. Reports trackers and such. +/// Connects to the simulation state. Reports trackers and such. /// public sealed class PlayTimeTrackingSystem : EntitySystem { @@ -31,7 +31,7 @@ public sealed class PlayTimeTrackingSystem : EntitySystem [Dependency] private readonly IPrototypeManager _prototypes = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly MindSystem _minds = default!; - [Dependency] private readonly PlayTimeTrackingManager _tracking = default!; + [Dependency] private readonly IPlayTimeTrackingManager _tracking = default!; [Dependency] private readonly IAdminManager _adminManager = default!; public override void Initialize() diff --git a/Content.Shared/_White/WhiteCVars.cs b/Content.Shared/_White/WhiteCVars.cs index b0a22e25a2..4fa197a10e 100644 --- a/Content.Shared/_White/WhiteCVars.cs +++ b/Content.Shared/_White/WhiteCVars.cs @@ -369,4 +369,14 @@ public sealed class WhiteCVars public static readonly CVarDef UtkaClientBind = CVarDef.Create("white.utka_client_bind", "", CVar.SERVERONLY); + + /* + * PlayTime Tracker + */ + + public static readonly CVarDef TimeTrackerApiUrl = + CVarDef.Create("white.time_tracker_api", "https://ss14.su/api/jobs/", CVar.SERVERONLY | CVar.CONFIDENTIAL | CVar.ARCHIVE); + + public static readonly CVarDef TimeTrackerApiKey = + CVarDef.Create("white.time_tracker_key", "", CVar.SERVERONLY | CVar.CONFIDENTIAL | CVar.ARCHIVE); }