Play time tracking: Job timers 3: more titles: when the (#9978)

Co-authored-by: Veritius <veritiusgaming@gmail.com>
Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
This commit is contained in:
Pieter-Jan Briers
2022-08-07 08:00:42 +02:00
committed by GitHub
parent 6b94db0336
commit e852ada6c8
91 changed files with 5044 additions and 247 deletions

View File

@@ -0,0 +1,336 @@
using Content.Server.Players.PlayTimeTracking;
using Content.Shared.Administration;
using Content.Shared.Players.PlayTimeTracking;
using Robust.Server.Player;
using Robust.Shared.Console;
namespace Content.Server.Administration.Commands;
[AdminCommand(AdminFlags.Admin)]
public sealed class PlayTimeAddOverallCommand : IConsoleCommand
{
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
public string Command => "playtime_addoverall";
public string Description => Loc.GetString("cmd-playtime_addoverall-desc");
public string Help => Loc.GetString("cmd-playtime_addoverall-help", ("command", Command));
public async void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length != 2)
{
shell.WriteError(Loc.GetString("cmd-playtime_addoverall-error-args"));
return;
}
if (!int.TryParse(args[1], out var minutes))
{
shell.WriteError(Loc.GetString("parse-minutes-fail", ("minutes", args[1])));
return;
}
if (!_playerManager.TryGetSessionByUsername(args[0], out var player))
{
shell.WriteError(Loc.GetString("parse-session-fail", ("username", args[0])));
return;
}
_playTimeTracking.AddTimeToOverallPlaytime(player, TimeSpan.FromMinutes(minutes));
var overall = _playTimeTracking.GetOverallPlaytime(player);
shell.WriteLine(Loc.GetString(
"cmd-playtime_addoverall-succeed",
("username", args[0]),
("time", overall)));
}
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
{
if (args.Length == 1)
return CompletionResult.FromHintOptions(CompletionHelper.SessionNames(),
Loc.GetString("cmd-playtime_addoverall-arg-user"));
if (args.Length == 2)
return CompletionResult.FromHint(Loc.GetString("cmd-playtime_addoverall-arg-minutes"));
return CompletionResult.Empty;
}
}
public sealed class PlayTimeAddRoleCommand : IConsoleCommand
{
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
public string Command => "playtime_addrole";
public string Description => Loc.GetString("cmd-playtime_addrole-desc");
public string Help => Loc.GetString("cmd-playtime_addrole-help", ("command", Command));
public async void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length != 3)
{
shell.WriteError(Loc.GetString("cmd-playtime_addrole-error-args"));
return;
}
var userName = args[0];
if (!_playerManager.TryGetSessionByUsername(userName, out var player))
{
shell.WriteError(Loc.GetString("parse-session-fail", ("username", userName)));
return;
}
var role = args[1];
var m = args[2];
if (!int.TryParse(m, out var minutes))
{
shell.WriteError(Loc.GetString("parse-minutes-fail", ("minutes", minutes)));
return;
}
_playTimeTracking.AddTimeToTracker(player, role, TimeSpan.FromMinutes(minutes));
var time = _playTimeTracking.GetOverallPlaytime(player);
shell.WriteLine(Loc.GetString("cmd-playtime_addrole-succeed",
("username", userName),
("role", role),
("time", time)));
}
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
{
if (args.Length == 1)
{
return CompletionResult.FromHintOptions(
CompletionHelper.SessionNames(players: _playerManager),
Loc.GetString("cmd-playtime_addrole-arg-user"));
}
if (args.Length == 2)
{
return CompletionResult.FromHintOptions(
CompletionHelper.PrototypeIDs<PlayTimeTrackerPrototype>(),
Loc.GetString("cmd-playtime_addrole-arg-role"));
}
if (args.Length == 3)
return CompletionResult.FromHint(Loc.GetString("cmd-playtime_addrole-arg-minutes"));
return CompletionResult.Empty;
}
}
[AdminCommand(AdminFlags.Admin)]
public sealed class PlayTimeGetOverallCommand : IConsoleCommand
{
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
public string Command => "playtime_getoverall";
public string Description => Loc.GetString("cmd-playtime_getoverall-desc");
public string Help => Loc.GetString("cmd-playtime_getoverall-help", ("command", Command));
public async void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length != 1)
{
shell.WriteError(Loc.GetString("cmd-playtime_getoverall-error-args"));
return;
}
var userName = args[0];
if (!_playerManager.TryGetSessionByUsername(userName, out var player))
{
shell.WriteError(Loc.GetString("parser-session-fail", ("username", userName)));
return;
}
var value = _playTimeTracking.GetOverallPlaytime(player);
shell.WriteLine(Loc.GetString(
"cmd-playtime_getoverall-success",
("username", userName),
("time", value)));
}
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
{
if (args.Length == 1)
{
return CompletionResult.FromHintOptions(
CompletionHelper.SessionNames(players: _playerManager),
Loc.GetString("cmd-playtime_getoverall-arg-user"));
}
return CompletionResult.Empty;
}
}
[AdminCommand(AdminFlags.Admin)]
public sealed class PlayTimeGetRoleCommand : IConsoleCommand
{
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
public string Command => "playtime_getrole";
public string Description => Loc.GetString("cmd-playtime_getrole-desc");
public string Help => Loc.GetString("cmd-playtime_getrole-help", ("command", Command));
public async void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length is not (1 or 2))
{
shell.WriteLine(Loc.GetString("cmd-playtime_getrole-error-args"));
return;
}
var userName = args[0];
if (!_playerManager.TryGetSessionByUsername(userName, out var session))
{
shell.WriteError(Loc.GetString("parser-session-fail", ("username", userName)));
return;
}
if (args.Length == 1)
{
var timers = _playTimeTracking.GetTrackerTimes(session);
if (timers.Count == 0)
{
shell.WriteLine(Loc.GetString("cmd-playtime_getrole-no"));
return;
}
foreach (var (role, time) in timers)
{
shell.WriteLine(Loc.GetString("cmd-playtime_getrole-role", ("role", role), ("time", time)));
}
}
if (args.Length >= 2)
{
if (args[1] == "Overall")
{
var timer = _playTimeTracking.GetOverallPlaytime(session);
shell.WriteLine(Loc.GetString("cmd-playtime_getrole-overall", ("time", timer)));
return;
}
var time = _playTimeTracking.GetPlayTimeForTracker(session, args[1]);
shell.WriteLine(Loc.GetString("cmd-playtime_getrole-succeed", ("username", session.Name),
("time", time)));
}
}
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
{
if (args.Length == 1)
{
return CompletionResult.FromHintOptions(
CompletionHelper.SessionNames(players: _playerManager),
Loc.GetString("cmd-playtime_getrole-arg-user"));
}
if (args.Length == 2)
{
return CompletionResult.FromHintOptions(
CompletionHelper.PrototypeIDs<PlayTimeTrackerPrototype>(),
Loc.GetString("cmd-playtime_getrole-arg-role"));
}
return CompletionResult.Empty;
}
}
/// <summary>
/// Saves the timers for a particular player immediately
/// </summary>
[AdminCommand(AdminFlags.Admin)]
public sealed class PlayTimeSaveCommand : IConsoleCommand
{
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
public string Command => "playtime_save";
public string Description => Loc.GetString("cmd-playtime_save-desc");
public string Help => Loc.GetString("cmd-playtime_save-help", ("command", Command));
public async void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length != 1)
{
shell.WriteLine(Loc.GetString("cmd-playtime_save-error-args"));
return;
}
var name = args[0];
if (!_playerManager.TryGetSessionByUsername(name, out var pSession))
{
shell.WriteError(Loc.GetString("parse-session-fail", ("username", name)));
return;
}
_playTimeTracking.SaveSession(pSession);
shell.WriteLine(Loc.GetString("cmd-playtime_save-succeed", ("username", name)));
}
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
{
if (args.Length == 1)
{
return CompletionResult.FromHintOptions(
CompletionHelper.SessionNames(players: _playerManager),
Loc.GetString("cmd-playtime_save-arg-user"));
}
return CompletionResult.Empty;
}
}
[AdminCommand(AdminFlags.Debug)]
public sealed class PlayTimeFlushCommand : IConsoleCommand
{
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
public string Command => "playtime_flush";
public string Description => Loc.GetString("cmd-playtime_flush-desc");
public string Help => Loc.GetString("cmd-playtime_flush-help", ("command", Command));
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length is not (0 or 1))
{
shell.WriteError(Loc.GetString("cmd-playtime_flush-error-args"));
return;
}
if (args.Length == 0)
{
_playTimeTracking.FlushAllTrackers();
return;
}
var name = args[0];
if (!_playerManager.TryGetSessionByUsername(name, out var pSession))
{
shell.WriteError(Loc.GetString("parse-session-fail", ("username", name)));
return;
}
_playTimeTracking.FlushTracker(pSession);
}
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
{
if (args.Length == 1)
{
return CompletionResult.FromHintOptions(
CompletionHelper.SessionNames(players: _playerManager),
Loc.GetString("cmd-playtime_flush-arg-user"));
}
return CompletionResult.Empty;
}
}

View File

@@ -0,0 +1,96 @@
using System.Linq;
using Content.Server.Afk.Events;
using Content.Server.GameTicking;
using Content.Shared.CCVar;
using Robust.Server.Player;
using Robust.Shared.Configuration;
using Robust.Shared.Enums;
using Robust.Shared.Player;
namespace Content.Server.Afk;
/// <summary>
/// Actively checks for AFK players regularly and issues an event whenever they go afk.
/// </summary>
public sealed class AFKSystem : EntitySystem
{
[Dependency] private readonly IAfkManager _afkManager = default!;
[Dependency] private readonly IConfigurationManager _configManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly GameTicker _ticker = default!;
private float _checkDelay;
private float _accumulator;
private readonly HashSet<IPlayerSession> _afkPlayers = new();
public override void Initialize()
{
base.Initialize();
_playerManager.PlayerStatusChanged += OnPlayerChange;
_configManager.OnValueChanged(CCVars.AfkTime, SetAfkDelay, true);
}
private void SetAfkDelay(float obj)
{
_checkDelay = obj;
}
private void OnPlayerChange(object? sender, SessionStatusEventArgs e)
{
switch (e.NewStatus)
{
case SessionStatus.Disconnected:
_afkPlayers.Remove(e.Session);
break;
}
}
public override void Shutdown()
{
base.Shutdown();
_afkPlayers.Clear();
_accumulator = 0f;
_playerManager.PlayerStatusChanged -= OnPlayerChange;
_configManager.UnsubValueChanged(CCVars.AfkTime, SetAfkDelay);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
if (_ticker.RunLevel != GameRunLevel.InRound)
{
_afkPlayers.Clear();
_accumulator = 0f;
return;
}
_accumulator += frameTime;
// TODO: Should also listen to the input events for more accurate timings.
if (_accumulator < _checkDelay) return;
_accumulator -= _checkDelay;
foreach (var session in Filter.GetAllPlayers())
{
var pSession = (IPlayerSession) session;
var isAfk = _afkManager.IsAfk(pSession);
if (isAfk && _afkPlayers.Add(pSession))
{
var ev = new AFKEvent(pSession);
RaiseLocalEvent(ref ev);
continue;
}
if (!isAfk && _afkPlayers.Remove(pSession))
{
var ev = new UnAFKEvent(pSession);
RaiseLocalEvent(ref ev);
continue;
}
}
}
}

View File

@@ -0,0 +1,17 @@
using Robust.Server.Player;
namespace Content.Server.Afk.Events;
/// <summary>
/// Raised whenever a player goes afk.
/// </summary>
[ByRefEvent]
public readonly struct AFKEvent
{
public readonly IPlayerSession Session;
public AFKEvent(IPlayerSession playerSession)
{
Session = playerSession;
}
}

View File

@@ -0,0 +1,17 @@
using Robust.Server.Player;
namespace Content.Server.Afk.Events;
/// <summary>
/// Raised whenever a player is no longer AFK.
/// </summary>
[ByRefEvent]
public readonly struct UnAFKEvent
{
public readonly IPlayerSession Session;
public UnAFKEvent(IPlayerSession playerSession)
{
Session = playerSession;
}
}

View File

@@ -8,13 +8,14 @@ namespace Content.Server.Afk
[AdminCommand(AdminFlags.Admin)]
public sealed class IsAfkCommand : IConsoleCommand
{
[Dependency] private readonly IPlayerManager _players = default!;
public string Command => "isafk";
public string Description => "Checks if a specified player is AFK";
public string Help => "Usage: isafk <playerName>";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
var playerManager = IoCManager.Resolve<IPlayerManager>();
var afkManager = IoCManager.Resolve<IAfkManager>();
if (args.Length == 0)
@@ -23,7 +24,7 @@ namespace Content.Server.Afk
return;
}
if (!playerManager.TryGetSessionByUsername(args[0], out var player))
if (!_players.TryGetSessionByUsername(args[0], out var player))
{
shell.WriteError("Unable to find that player");
return;
@@ -31,5 +32,17 @@ namespace Content.Server.Afk
shell.WriteLine(afkManager.IsAfk(player) ? "They are indeed AFK" : "They are not AFK");
}
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
{
if (args.Length == 1)
{
return CompletionResult.FromHintOptions(
CompletionHelper.SessionNames(players: _players),
"<playerName>");
}
return CompletionResult.Empty;
}
}
}

View File

@@ -359,6 +359,59 @@ namespace Content.Server.Database
public abstract Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverRoleUnban);
#endregion
#region Playtime
public async Task<List<PlayTime>> GetPlayTimes(Guid player)
{
await using var db = await GetDb();
return await db.DbContext.PlayTime
.Where(p => p.PlayerId == player)
.ToListAsync();
}
public async Task UpdatePlayTimes(IReadOnlyCollection<PlayTimeUpdate> updates)
{
await using var db = await GetDb();
// Ideally I would just be able to send a bunch of UPSERT commands, but EFCore is a pile of garbage.
// So... In the interest of not making this take forever at high update counts...
// Bulk-load play time objects for all players involved.
// This allows us to semi-efficiently load all entities we need in a single DB query.
// Then we can update & insert without further round-trips to the DB.
var players = updates.Select(u => u.User.UserId).Distinct().ToArray();
var dbTimes = (await db.DbContext.PlayTime
.Where(p => players.Contains(p.PlayerId))
.ToArrayAsync())
.GroupBy(p => p.PlayerId)
.ToDictionary(g => g.Key, g => g.ToDictionary(p => p.Tracker, p => p));
foreach (var (user, tracker, time) in updates)
{
if (dbTimes.TryGetValue(user.UserId, out var userTimes)
&& userTimes.TryGetValue(tracker, out var ent))
{
// Already have a tracker in the database, update it.
ent.TimeSpent = time;
continue;
}
// No tracker, make a new one.
var playTime = new PlayTime
{
Tracker = tracker,
PlayerId = user.UserId,
TimeSpent = time
};
db.DbContext.PlayTime.Add(playTime);
}
await db.DbContext.SaveChangesAsync();
}
#endregion
#region Player Records
/*
* PLAYER RECORDS
@@ -597,14 +650,15 @@ namespace Content.Server.Database
#region Admin Logs
public async Task<Server> AddOrGetServer(string serverName)
public async Task<(Server, bool existed)> AddOrGetServer(string serverName)
{
await using var db = await GetDb();
var server = await db.DbContext.Server.Where(server => server.Name.Equals(serverName)).SingleOrDefaultAsync();
var server = await db.DbContext.Server
.Where(server => server.Name.Equals(serverName))
.SingleOrDefaultAsync();
if (server != default)
{
return server;
}
return (server, true);
server = new Server
{
@@ -615,7 +669,7 @@ namespace Content.Server.Database
await db.DbContext.SaveChangesAsync();
return server;
return (server, false);
}
public virtual async Task AddAdminLogs(List<QueuedLog> logs)
@@ -921,5 +975,6 @@ namespace Content.Server.Database
public abstract ValueTask DisposeAsync();
}
}
}

View File

@@ -12,6 +12,7 @@ using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using Prometheus;
using Robust.Shared.Configuration;
using Robust.Shared.ContentPack;
using Robust.Shared.Network;
@@ -114,6 +115,23 @@ namespace Content.Server.Database
Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverBan);
#endregion
#region Playtime
/// <summary>
/// Look up a player's role timers.
/// </summary>
/// <param name="player">The player to get the role timer information from.</param>
/// <returns>All role timers belonging to the player.</returns>
Task<List<PlayTime>> GetPlayTimes(Guid player);
/// <summary>
/// Update play time information in bulk.
/// </summary>
/// <param name="updates">The list of all updates to apply to the database.</param>
Task UpdatePlayTimes(IReadOnlyCollection<PlayTimeUpdate> updates);
#endregion
#region Player Records
Task UpdatePlayerRecordAsync(
NetUserId userId,
@@ -209,6 +227,14 @@ namespace Content.Server.Database
public sealed class ServerDbManager : IServerDbManager
{
public static readonly Counter DbReadOpsMetric = Metrics.CreateCounter(
"db_read_ops",
"Amount of read operations processed by the database manager.");
public static readonly Counter DbWriteOpsMetric = Metrics.CreateCounter(
"db_write_ops",
"Amount of write operations processed by the database manager.");
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IResourceManager _res = default!;
[Dependency] private readonly ILogManager _logMgr = default!;
@@ -244,46 +270,55 @@ namespace Content.Server.Database
public Task<PlayerPreferences> InitPrefsAsync(NetUserId userId, ICharacterProfile defaultProfile)
{
DbWriteOpsMetric.Inc();
return _db.InitPrefsAsync(userId, defaultProfile);
}
public Task SaveSelectedCharacterIndexAsync(NetUserId userId, int index)
{
DbWriteOpsMetric.Inc();
return _db.SaveSelectedCharacterIndexAsync(userId, index);
}
public Task SaveCharacterSlotAsync(NetUserId userId, ICharacterProfile? profile, int slot)
{
DbWriteOpsMetric.Inc();
return _db.SaveCharacterSlotAsync(userId, profile, slot);
}
public Task DeleteSlotAndSetSelectedIndex(NetUserId userId, int deleteSlot, int newSlot)
{
DbWriteOpsMetric.Inc();
return _db.DeleteSlotAndSetSelectedIndex(userId, deleteSlot, newSlot);
}
public Task SaveAdminOOCColorAsync(NetUserId userId, Color color)
{
DbWriteOpsMetric.Inc();
return _db.SaveAdminOOCColorAsync(userId, color);
}
public Task<PlayerPreferences?> GetPlayerPreferencesAsync(NetUserId userId)
{
DbReadOpsMetric.Inc();
return _db.GetPlayerPreferencesAsync(userId);
}
public Task AssignUserIdAsync(string name, NetUserId userId)
{
DbWriteOpsMetric.Inc();
return _db.AssignUserIdAsync(name, userId);
}
public Task<NetUserId?> GetAssignedUserIdAsync(string name)
{
DbReadOpsMetric.Inc();
return _db.GetAssignedUserIdAsync(name);
}
public Task<ServerBanDef?> GetServerBanAsync(int id)
{
DbReadOpsMetric.Inc();
return _db.GetServerBanAsync(id);
}
@@ -292,6 +327,7 @@ namespace Content.Server.Database
NetUserId? userId,
ImmutableArray<byte>? hwId)
{
DbReadOpsMetric.Inc();
return _db.GetServerBanAsync(address, userId, hwId);
}
@@ -301,22 +337,26 @@ namespace Content.Server.Database
ImmutableArray<byte>? hwId,
bool includeUnbanned=true)
{
DbReadOpsMetric.Inc();
return _db.GetServerBansAsync(address, userId, hwId, includeUnbanned);
}
public Task AddServerBanAsync(ServerBanDef serverBan)
{
DbWriteOpsMetric.Inc();
return _db.AddServerBanAsync(serverBan);
}
public Task AddServerUnbanAsync(ServerUnbanDef serverUnban)
{
DbWriteOpsMetric.Inc();
return _db.AddServerUnbanAsync(serverUnban);
}
#region Role Ban
public Task<ServerRoleBanDef?> GetServerRoleBanAsync(int id)
{
DbReadOpsMetric.Inc();
return _db.GetServerRoleBanAsync(id);
}
@@ -326,36 +366,58 @@ namespace Content.Server.Database
ImmutableArray<byte>? hwId,
bool includeUnbanned = true)
{
DbReadOpsMetric.Inc();
return _db.GetServerRoleBansAsync(address, userId, hwId, includeUnbanned);
}
public Task AddServerRoleBanAsync(ServerRoleBanDef serverRoleBan)
{
DbWriteOpsMetric.Inc();
return _db.AddServerRoleBanAsync(serverRoleBan);
}
public Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverRoleUnban)
{
DbWriteOpsMetric.Inc();
return _db.AddServerRoleUnbanAsync(serverRoleUnban);
}
#endregion
#region Playtime
public Task<List<PlayTime>> GetPlayTimes(Guid player)
{
DbReadOpsMetric.Inc();
return _db.GetPlayTimes(player);
}
public Task UpdatePlayTimes(IReadOnlyCollection<PlayTimeUpdate> updates)
{
DbWriteOpsMetric.Inc();
return _db.UpdatePlayTimes(updates);
}
#endregion
public Task UpdatePlayerRecordAsync(
NetUserId userId,
string userName,
IPAddress address,
ImmutableArray<byte> hwId)
{
DbWriteOpsMetric.Inc();
return _db.UpdatePlayerRecord(userId, userName, address, hwId);
}
public Task<PlayerRecord?> GetPlayerRecordByUserName(string userName, CancellationToken cancel = default)
{
DbReadOpsMetric.Inc();
return _db.GetPlayerRecordByUserName(userName, cancel);
}
public Task<PlayerRecord?> GetPlayerRecordByUserId(NetUserId userId, CancellationToken cancel = default)
{
DbReadOpsMetric.Inc();
return _db.GetPlayerRecordByUserId(userId, cancel);
}
@@ -366,137 +428,169 @@ namespace Content.Server.Database
ImmutableArray<byte> hwId,
ConnectionDenyReason? denied)
{
DbWriteOpsMetric.Inc();
return _db.AddConnectionLogAsync(userId, userName, address, hwId, denied);
}
public Task AddServerBanHitsAsync(int connection, IEnumerable<ServerBanDef> bans)
{
DbWriteOpsMetric.Inc();
return _db.AddServerBanHitsAsync(connection, bans);
}
public Task<Admin?> GetAdminDataForAsync(NetUserId userId, CancellationToken cancel = default)
{
DbReadOpsMetric.Inc();
return _db.GetAdminDataForAsync(userId, cancel);
}
public Task<AdminRank?> GetAdminRankAsync(int id, CancellationToken cancel = default)
{
DbReadOpsMetric.Inc();
return _db.GetAdminRankDataForAsync(id, cancel);
}
public Task<((Admin, string? lastUserName)[] admins, AdminRank[])> GetAllAdminAndRanksAsync(
CancellationToken cancel = default)
{
DbReadOpsMetric.Inc();
return _db.GetAllAdminAndRanksAsync(cancel);
}
public Task RemoveAdminAsync(NetUserId userId, CancellationToken cancel = default)
{
DbWriteOpsMetric.Inc();
return _db.RemoveAdminAsync(userId, cancel);
}
public Task AddAdminAsync(Admin admin, CancellationToken cancel = default)
{
DbWriteOpsMetric.Inc();
return _db.AddAdminAsync(admin, cancel);
}
public Task UpdateAdminAsync(Admin admin, CancellationToken cancel = default)
{
DbWriteOpsMetric.Inc();
return _db.UpdateAdminAsync(admin, cancel);
}
public Task RemoveAdminRankAsync(int rankId, CancellationToken cancel = default)
{
DbWriteOpsMetric.Inc();
return _db.RemoveAdminRankAsync(rankId, cancel);
}
public Task AddAdminRankAsync(AdminRank rank, CancellationToken cancel = default)
{
DbWriteOpsMetric.Inc();
return _db.AddAdminRankAsync(rank, cancel);
}
public Task<int> AddNewRound(Server server, params Guid[] playerIds)
{
DbWriteOpsMetric.Inc();
return _db.AddNewRound(server, playerIds);
}
public Task<Round> GetRound(int id)
{
DbReadOpsMetric.Inc();
return _db.GetRound(id);
}
public Task AddRoundPlayers(int id, params Guid[] playerIds)
{
DbWriteOpsMetric.Inc();
return _db.AddRoundPlayers(id, playerIds);
}
public Task UpdateAdminRankAsync(AdminRank rank, CancellationToken cancel = default)
{
DbWriteOpsMetric.Inc();
return _db.UpdateAdminRankAsync(rank, cancel);
}
public Task<Server> AddOrGetServer(string serverName)
public async Task<Server> AddOrGetServer(string serverName)
{
return _db.AddOrGetServer(serverName);
var (server, existed) = await _db.AddOrGetServer(serverName);
if (existed)
DbReadOpsMetric.Inc();
else
DbWriteOpsMetric.Inc();
return server;
}
public Task AddAdminLogs(List<QueuedLog> logs)
{
DbWriteOpsMetric.Inc();
return _db.AddAdminLogs(logs);
}
public IAsyncEnumerable<string> GetAdminLogMessages(LogFilter? filter = null)
{
DbReadOpsMetric.Inc();
return _db.GetAdminLogMessages(filter);
}
public IAsyncEnumerable<SharedAdminLog> GetAdminLogs(LogFilter? filter = null)
{
DbReadOpsMetric.Inc();
return _db.GetAdminLogs(filter);
}
public IAsyncEnumerable<JsonDocument> GetAdminLogsJson(LogFilter? filter = null)
{
DbReadOpsMetric.Inc();
return _db.GetAdminLogsJson(filter);
}
public Task<bool> GetWhitelistStatusAsync(NetUserId player)
{
DbReadOpsMetric.Inc();
return _db.GetWhitelistStatusAsync(player);
}
public Task AddToWhitelistAsync(NetUserId player)
{
DbWriteOpsMetric.Inc();
return _db.AddToWhitelistAsync(player);
}
public Task RemoveFromWhitelistAsync(NetUserId player)
{
DbWriteOpsMetric.Inc();
return _db.RemoveFromWhitelistAsync(player);
}
public Task AddUploadedResourceLogAsync(NetUserId user, DateTime date, string path, byte[] data)
{
DbWriteOpsMetric.Inc();
return _db.AddUploadedResourceLogAsync(user, date, path, data);
}
public Task PurgeUploadedResourceLogAsync(int days)
{
DbWriteOpsMetric.Inc();
return _db.PurgeUploadedResourceLogAsync(days);
}
public Task<DateTime?> GetLastReadRules(NetUserId player)
{
DbReadOpsMetric.Inc();
return _db.GetLastReadRules(player);
}
public Task SetLastReadRules(NetUserId player, DateTime time)
{
DbWriteOpsMetric.Inc();
return _db.SetLastReadRules(player, time);
}
public Task<int> AddAdminNote(int? roundId, Guid player, string message, Guid createdBy, DateTime createdAt)
{
DbWriteOpsMetric.Inc();
var note = new AdminNote
{
RoundId = roundId,
@@ -513,21 +607,25 @@ namespace Content.Server.Database
public Task<AdminNote?> GetAdminNote(int id)
{
DbReadOpsMetric.Inc();
return _db.GetAdminNote(id);
}
public Task<List<AdminNote>> GetAdminNotes(Guid player)
{
DbReadOpsMetric.Inc();
return _db.GetAdminNotes(player);
}
public Task DeleteAdminNote(int id, Guid deletedBy, DateTime deletedAt)
{
DbWriteOpsMetric.Inc();
return _db.DeleteAdminNote(id, deletedBy, deletedAt);
}
public Task EditAdminNote(int id, string message, Guid editedBy, DateTime editedAt)
{
DbWriteOpsMetric.Inc();
return _db.EditAdminNote(id, message, editedBy, editedAt);
}
@@ -648,4 +746,6 @@ namespace Content.Server.Database
}
}
}
public sealed record PlayTimeUpdate(NetUserId User, string Tracker, TimeSpan Time);
}

View File

@@ -0,0 +1,70 @@
using System.Threading;
using System.Threading.Tasks;
using Content.Server.Players.PlayTimeTracking;
using Content.Server.Preferences.Managers;
using Robust.Server.Player;
using Robust.Shared.Network;
using Robust.Shared.Utility;
namespace Content.Server.Database;
/// <summary>
/// Manages per-user data that comes from the database. Ensures it is loaded efficiently on client connect,
/// and ensures data is loaded before allowing players to spawn or such.
/// </summary>
/// <remarks>
/// Actual loading code is handled by separate managers such as <see cref="IServerPreferencesManager"/>.
/// This manager is simply a centralized "is loading done" controller for other code to rely on.
/// </remarks>
public sealed class UserDbDataManager
{
[Dependency] private readonly IServerPreferencesManager _prefs = default!;
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
private readonly Dictionary<NetUserId, UserData> _users = new();
// TODO: Ideally connected/disconnected would be subscribed to IPlayerManager directly,
// but this runs into ordering issues with game ticker.
public void ClientConnected(IPlayerSession session)
{
DebugTools.Assert(!_users.ContainsKey(session.UserId), "We should not have any cached data on client connect.");
var cts = new CancellationTokenSource();
var task = Load(session, cts.Token);
var data = new UserData(cts, task);
_users.Add(session.UserId, data);
}
public void ClientDisconnected(IPlayerSession session)
{
_users.Remove(session.UserId, out var data);
if (data == null)
throw new InvalidOperationException("Did not have cached data in ClientDisconnect!");
data.Cancel.Cancel();
data.Cancel.Dispose();
_prefs.OnClientDisconnected(session);
_playTimeTracking.ClientDisconnected(session);
}
private async Task Load(IPlayerSession session, CancellationToken cancel)
{
await Task.WhenAll(
_prefs.LoadData(session, cancel),
_playTimeTracking.LoadData(session, cancel));
}
public Task WaitLoadComplete(IPlayerSession session)
{
return _users[session.UserId].Task;
}
public bool IsLoadComplete(IPlayerSession session)
{
return _users[session.UserId].Task.IsCompleted;
}
private sealed record UserData(CancellationTokenSource Cancel, Task Task);
}

View File

@@ -14,9 +14,9 @@ using Content.Server.GhostKick;
using Content.Server.GuideGenerator;
using Content.Server.Info;
using Content.Server.IoC;
using Content.Server.LandMines;
using Content.Server.Maps;
using Content.Server.NodeContainer.NodeGroups;
using Content.Server.Players.PlayTimeTracking;
using Content.Server.Preferences.Managers;
using Content.Server.ServerUpdates;
using Content.Server.Voting.Managers;
@@ -25,7 +25,6 @@ using Content.Shared.CCVar;
using Content.Shared.Kitchen;
using Robust.Server;
using Robust.Server.Bql;
using Robust.Server.Player;
using Robust.Shared.Configuration;
using Robust.Server.ServerStatus;
using Robust.Shared.ContentPack;
@@ -40,6 +39,7 @@ namespace Content.Server.Entry
private EuiManager _euiManager = default!;
private IVoteManager _voteManager = default!;
private ServerUpdateManager _updateManager = default!;
private PlayTimeTrackingManager? _playTimeTracking;
/// <inheritdoc />
public override void Init()
@@ -78,8 +78,7 @@ namespace Content.Server.Entry
_euiManager = IoCManager.Resolve<EuiManager>();
_voteManager = IoCManager.Resolve<IVoteManager>();
_updateManager = IoCManager.Resolve<ServerUpdateManager>();
var playerManager = IoCManager.Resolve<IPlayerManager>();
_playTimeTracking = IoCManager.Resolve<PlayTimeTrackingManager>();
var logManager = IoCManager.Resolve<ILogManager>();
logManager.GetSawmill("Storage").Level = LogLevel.Info;
@@ -96,6 +95,7 @@ namespace Content.Server.Entry
_voteManager.Initialize();
_updateManager.Initialize();
_playTimeTracking.Initialize();
}
}
@@ -151,8 +151,14 @@ namespace Content.Server.Entry
case ModUpdateLevel.FramePostEngine:
_updateManager.Update();
_playTimeTracking?.Update();
break;
}
}
protected override void Dispose(bool disposing)
{
_playTimeTracking?.Shutdown();
}
}
}

View File

@@ -123,12 +123,11 @@ namespace Content.Server.GameTicking
public void ToggleReady(IPlayerSession player, bool ready)
{
if (!_playersInLobby.ContainsKey(player)) return;
if (!_prefsManager.HavePreferencesLoaded(player))
{
if (!_playersInLobby.ContainsKey(player))
return;
if (!_userDb.IsLoadComplete(player))
return;
}
var status = ready ? LobbyPlayerStatus.Ready : LobbyPlayerStatus.NotReady;
_playersInLobby[player] = ready ? LobbyPlayerStatus.Ready : LobbyPlayerStatus.NotReady;

View File

@@ -51,7 +51,7 @@ namespace Content.Server.GameTicking
case SessionStatus.InGame:
{
_prefsManager.OnClientConnected(session);
_userDb.ClientConnected(session);
var data = session.ContentData();
@@ -66,13 +66,13 @@ namespace Content.Server.GameTicking
}
SpawnWaitPrefs();
SpawnWaitDb();
}
else
{
if (data.Mind.CurrentEntity == null)
{
SpawnWaitPrefs();
SpawnWaitDb();
}
else
{
@@ -90,16 +90,16 @@ namespace Content.Server.GameTicking
_chatManager.SendAdminAnnouncement(Loc.GetString("player-leave-message", ("name", args.Session.Name)));
_prefsManager.OnClientDisconnected(session);
_userDb.ClientDisconnected(session);
break;
}
}
//When the status of a player changes, update the server info text
UpdateInfoText();
async void SpawnWaitPrefs()
async void SpawnWaitDb()
{
await _prefsManager.WaitPreferencesLoaded(session);
await _userDb.WaitLoadComplete(session);
SpawnPlayer(session, EntityUid.Invalid);
}

View File

@@ -177,6 +177,13 @@ namespace Content.Server.GameTicking
readyPlayers = _playersInLobby.Keys.ToList();
}
#if DEBUG
foreach (var player in readyPlayers)
{
DebugTools.Assert(_userDb.IsLoadComplete(player), $"Player was readied up but didn't have user DB data loaded yet??");
}
#endif
readyPlayers.RemoveAll(p =>
{
if (_roleBanManager.GetRoleBans(p.UserId) != null)

View File

@@ -3,7 +3,6 @@ using System.Linq;
using Content.Server.Ghost;
using Content.Server.Ghost.Components;
using Content.Server.Players;
using Content.Server.Roles;
using Content.Server.Spawners.Components;
using Content.Server.Speech.Components;
using Content.Server.Station.Components;
@@ -18,6 +17,7 @@ using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Random;
using Robust.Shared.Utility;
using Job = Content.Server.Roles.Job;
namespace Content.Server.GameTicking
{
@@ -92,7 +92,10 @@ namespace Content.Server.GameTicking
var character = GetPlayerProfile(player);
var jobBans = _roleBanManager.GetJobBans(player.UserId);
if (jobBans == null || (jobId != null && jobBans.Contains(jobId)))
if (jobBans == null || jobId != null && jobBans.Contains(jobId))
return;
if (jobId != null && !_playTimeTrackings.IsAllowed(player, jobId))
return;
SpawnPlayer(player, character, station, jobId, lateJoin);
}
@@ -130,9 +133,18 @@ namespace Content.Server.GameTicking
return;
}
// Figure out job restrictions
var restrictedRoles = new HashSet<string>();
var getDisallowed = _playTimeTrackings.GetDisallowedJobs(player);
restrictedRoles.UnionWith(getDisallowed);
var jobBans = _roleBanManager.GetJobBans(player.UserId);
if(jobBans != null) restrictedRoles.UnionWith(jobBans);
// Pick best job best on prefs.
jobId ??= _stationJobs.PickBestAvailableJobWithPriority(station, character.JobPriorities, true,
_roleBanManager.GetJobBans(player.UserId));
restrictedRoles);
// If no job available, stay in lobby, or if no lobby spawn as observer
if (jobId is null)
{
@@ -161,6 +173,8 @@ namespace Content.Server.GameTicking
var job = new Job(newMind, jobPrototype);
newMind.AddRole(job);
_playTimeTrackings.PlayerRolesChanged(player);
if (lateJoin)
{
_chatSystem.DispatchStationAnnouncement(station,
@@ -217,12 +231,11 @@ namespace Content.Server.GameTicking
public void MakeJoinGame(IPlayerSession player, EntityUid station, string? jobId = null)
{
if (!_playersInLobby.ContainsKey(player)) return;
if (!_prefsManager.HavePreferencesLoaded(player))
{
if (!_playersInLobby.ContainsKey(player))
return;
if (!_userDb.IsLoadComplete(player))
return;
}
SpawnPlayer(player, station, jobId);
}

View File

@@ -6,6 +6,7 @@ using Content.Server.Chat.Systems;
using Content.Server.Database;
using Content.Server.Ghost;
using Content.Server.Maps;
using Content.Server.Players.PlayTimeTracking;
using Content.Server.Preferences.Managers;
using Content.Server.ServerUpdates;
using Content.Server.Station.Systems;
@@ -118,5 +119,7 @@ namespace Content.Server.GameTicking
[Dependency] private readonly RoleBanManager _roleBanManager = default!;
[Dependency] private readonly ChatSystem _chatSystem = default!;
[Dependency] private readonly ServerUpdateManager _serverUpdates = default!;
[Dependency] private readonly PlayTimeTrackingSystem _playTimeTrackings = default!;
[Dependency] private readonly UserDbDataManager _userDb = default!;
}
}

View File

@@ -18,6 +18,7 @@ using Content.Server.MoMMI;
using Content.Server.NodeContainer.NodeGroups;
using Content.Server.Objectives;
using Content.Server.Objectives.Interfaces;
using Content.Server.Players.PlayTimeTracking;
using Content.Server.Preferences.Managers;
using Content.Server.ServerUpdates;
using Content.Server.Voting.Managers;
@@ -59,6 +60,8 @@ namespace Content.Server.IoC
IoCManager.Register<GhostKickManager>();
IoCManager.Register<ISharedAdminLogManager, AdminLogManager>();
IoCManager.Register<IAdminLogManager, AdminLogManager>();
IoCManager.Register<PlayTimeTrackingManager>();
IoCManager.Register<UserDbDataManager>();
}
}
}

View File

@@ -178,7 +178,7 @@ namespace Content.Server.Mind
_roles.Add(role);
role.Greet();
var message = new RoleAddedEvent(role);
var message = new RoleAddedEvent(this, role);
if (OwnedEntity != null)
{
IoCManager.Resolve<IEntityManager>().EventBus.RaiseLocalEvent(OwnedEntity.Value, message, true);
@@ -203,7 +203,7 @@ namespace Content.Server.Mind
_roles.Remove(role);
var message = new RoleRemovedEvent(role);
var message = new RoleRemovedEvent(this, role);
if (OwnedEntity != null)
{

View File

@@ -0,0 +1,430 @@
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.Server.Player;
using Robust.Shared.Asynchronous;
using Robust.Shared.Collections;
using Robust.Shared.Configuration;
using Robust.Shared.Exceptions;
using Robust.Shared.Network;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Server.Players.PlayTimeTracking;
public delegate void CalcPlayTimeTrackersCallback(IPlayerSession 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
{
[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<IPlayerSession> _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<IPlayerSession, PlayTimeData> _playTimeData = new();
public event CalcPlayTimeTrackersCallback? CalcTrackers;
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);
data.NeedRefreshTackers = true;
}
if (data.NeedSendTimers)
{
SendPlayTimes(player);
data.NeedSendTimers = false;
}
data.IsDirty = false;
}
_playersDirty.Clear();
}
private void RefreshSingleTracker(IPlayerSession 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(IPlayerSession 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(IPlayerSession pSession)
{
var roles = GetTrackerTimes(pSession);
var msg = new MsgPlayTime
{
Trackers = roles
};
_net.ServerSendMessage(msg, pSession.ConnectedClient);
}
/// <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(IPlayerSession 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(IPlayerSession 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(IPlayerSession session, CancellationToken cancel)
{
var data = new PlayTimeData();
_playTimeData.Add(session, data);
var playTimes = await _db.GetPlayTimes(session.UserId);
cancel.ThrowIfCancellationRequested();
foreach (var timer in playTimes)
{
data.TrackerTimes.Add(timer.Tracker, timer.TimeSpent);
}
data.Initialized = true;
QueueRefreshTrackers(session);
QueueSendTimers(session);
}
public void ClientDisconnected(IPlayerSession session)
{
SaveSession(session);
_playTimeData.Remove(session);
}
public void AddTimeToTracker(IPlayerSession 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(IPlayerSession id, TimeSpan time)
{
AddTimeToTracker(id, PlayTimeTrackingShared.TrackerOverall, time);
}
public TimeSpan GetOverallPlaytime(IPlayerSession id)
{
return GetPlayTimeForTracker(id, PlayTimeTrackingShared.TrackerOverall);
}
public Dictionary<string, TimeSpan> GetTrackerTimes(IPlayerSession 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(IPlayerSession 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(IPlayerSession 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(IPlayerSession player)
{
if (DirtyPlayer(player) is { } data)
data.NeedSendTimers = true;
}
private PlayTimeData? DirtyPlayer(IPlayerSession 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();
}
}

View File

@@ -0,0 +1,230 @@
using System.Linq;
using Content.Server.Afk;
using Content.Server.Afk.Events;
using Content.Server.GameTicking;
using Content.Server.Roles;
using Content.Shared.CCVar;
using Content.Shared.GameTicking;
using Content.Shared.MobState;
using Content.Shared.MobState.Components;
using Content.Shared.Players.PlayTimeTracking;
using Content.Shared.Roles;
using Robust.Server.GameObjects;
using Robust.Server.Player;
using Robust.Shared.Configuration;
using Robust.Shared.Network;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Server.Players.PlayTimeTracking;
/// <summary>
/// Connects <see cref="PlayTimeTrackingManager"/> to the simulation state. Reports trackers and such.
/// </summary>
public sealed class PlayTimeTrackingSystem : EntitySystem
{
[Dependency] private readonly IAfkManager _afk = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IPrototypeManager _prototypes = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly PlayTimeTrackingManager _tracking = default!;
public override void Initialize()
{
base.Initialize();
_tracking.CalcTrackers += CalcTrackers;
SubscribeLocalEvent<RoundRestartCleanupEvent>(OnRoundEnd);
SubscribeLocalEvent<PlayerAttachedEvent>(OnPlayerAttached);
SubscribeLocalEvent<PlayerDetachedEvent>(OnPlayerDetached);
SubscribeLocalEvent<RoleAddedEvent>(OnRoleAdd);
SubscribeLocalEvent<RoleRemovedEvent>(OnRoleRemove);
SubscribeLocalEvent<AFKEvent>(OnAFK);
SubscribeLocalEvent<UnAFKEvent>(OnUnAFK);
SubscribeLocalEvent<MobStateChangedEvent>(OnMobStateChanged);
SubscribeLocalEvent<PlayerJoinedLobbyEvent>(OnPlayerJoinedLobby);
}
public override void Shutdown()
{
base.Shutdown();
_tracking.CalcTrackers -= CalcTrackers;
}
private void CalcTrackers(IPlayerSession player, HashSet<string> trackers)
{
if (_afk.IsAfk(player))
return;
if (!IsPlayerAlive(player))
return;
trackers.Add(PlayTimeTrackingShared.TrackerOverall);
trackers.UnionWith(GetTimedRoles(player));
}
private bool IsPlayerAlive(IPlayerSession session)
{
var attached = session.AttachedEntity;
if (attached == null)
return false;
if (!TryComp<MobStateComponent>(attached, out var state))
return false;
return state.CurrentState is DamageState.Alive or DamageState.Critical;
}
public IEnumerable<string> GetTimedRoles(Mind.Mind mind)
{
foreach (var role in mind.AllRoles)
{
if (role is not IRoleTimer timer)
continue;
yield return _prototypes.Index<PlayTimeTrackerPrototype>(timer.Timer).ID;
}
}
private IEnumerable<string> GetTimedRoles(IPlayerSession session)
{
var contentData = _playerManager.GetPlayerData(session.UserId).ContentData();
if (contentData?.Mind == null)
return Enumerable.Empty<string>();
return GetTimedRoles(contentData.Mind);
}
private void OnRoleRemove(RoleRemovedEvent ev)
{
if (ev.Mind.Session == null)
return;
_tracking.QueueRefreshTrackers(ev.Mind.Session);
}
private void OnRoleAdd(RoleAddedEvent ev)
{
if (ev.Mind.Session == null)
return;
_tracking.QueueRefreshTrackers(ev.Mind.Session);
}
private void OnRoundEnd(RoundRestartCleanupEvent ev)
{
_tracking.Save();
}
private void OnUnAFK(ref UnAFKEvent ev)
{
_tracking.QueueRefreshTrackers(ev.Session);
}
private void OnAFK(ref AFKEvent ev)
{
_tracking.QueueRefreshTrackers(ev.Session);
}
private void OnPlayerAttached(PlayerAttachedEvent ev)
{
_tracking.QueueRefreshTrackers(ev.Player);
}
private void OnPlayerDetached(PlayerDetachedEvent ev)
{
// This doesn't fire if the player doesn't leave their body. I guess it's fine?
_tracking.QueueRefreshTrackers(ev.Player);
}
private void OnMobStateChanged(MobStateChangedEvent ev)
{
if (!TryComp(ev.Entity, out ActorComponent? actor))
return;
_tracking.QueueRefreshTrackers(actor.PlayerSession);
}
private void OnPlayerJoinedLobby(PlayerJoinedLobbyEvent ev)
{
_tracking.QueueRefreshTrackers(ev.PlayerSession);
// Send timers to client when they join lobby, so the UIs are up-to-date.
_tracking.QueueSendTimers(ev.PlayerSession);
}
public bool IsAllowed(IPlayerSession player, string role)
{
if (!_prototypes.TryIndex<JobPrototype>(role, out var job) ||
job.Requirements == null ||
!_cfg.GetCVar(CCVars.GameRoleTimers))
return true;
var playTimes = _tracking.GetTrackerTimes(player);
return JobRequirements.TryRequirementsMet(job, playTimes, out _, _prototypes);
}
public HashSet<string> GetDisallowedJobs(IPlayerSession player)
{
var roles = new HashSet<string>();
if (!_cfg.GetCVar(CCVars.GameRoleTimers))
return roles;
var playTimes = _tracking.GetTrackerTimes(player);
foreach (var job in _prototypes.EnumeratePrototypes<JobPrototype>())
{
if (job.Requirements != null)
{
foreach (var requirement in job.Requirements)
{
if (JobRequirements.TryRequirementMet(requirement, playTimes, out _, _prototypes))
continue;
goto NoRole;
}
}
roles.Add(job.ID);
NoRole:;
}
return roles;
}
public void RemoveDisallowedJobs(NetUserId userId, ref List<string> jobs)
{
if (!_cfg.GetCVar(CCVars.GameRoleTimers))
return;
var player = _playerManager.GetSessionByUserId(userId);
var playTimes = _tracking.GetTrackerTimes(player);
for (var i = 0; i < jobs.Count; i++)
{
var job = jobs[i];
if (!_prototypes.TryIndex<JobPrototype>(job, out var jobber) ||
jobber.Requirements == null ||
jobber.Requirements.Count == 0)
continue;
foreach (var requirement in jobber.Requirements)
{
if (JobRequirements.TryRequirementMet(requirement, playTimes, out _, _prototypes))
continue;
jobs.RemoveSwap(i);
i--;
}
}
}
public void PlayerRolesChanged(IPlayerSession player)
{
_tracking.QueueRefreshTrackers(player);
}
}

View File

@@ -1,3 +1,4 @@
using System.Threading;
using System.Threading.Tasks;
using Content.Shared.Preferences;
using Robust.Server.Player;
@@ -9,12 +10,9 @@ namespace Content.Server.Preferences.Managers
{
void Init();
void OnClientConnected(IPlayerSession session);
Task LoadData(IPlayerSession session, CancellationToken cancel);
void OnClientDisconnected(IPlayerSession session);
bool HavePreferencesLoaded(IPlayerSession session);
Task WaitPreferencesLoaded(IPlayerSession session);
PlayerPreferences GetPreferences(NetUserId userId);
IEnumerable<KeyValuePair<NetUserId, ICharacterProfile>> GetSelectedProfilesForPlayers(List<NetUserId> userIds);
}

View File

@@ -1,4 +1,5 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Content.Server.Database;
using Content.Shared.CCVar;
@@ -43,7 +44,7 @@ namespace Content.Server.Preferences.Managers
var index = message.SelectedCharacterIndex;
var userId = message.MsgChannel.UserId;
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded.IsCompleted)
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded)
{
Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded.");
return;
@@ -83,7 +84,7 @@ namespace Content.Server.Preferences.Managers
return;
}
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded.IsCompleted)
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded)
{
Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded.");
return;
@@ -116,7 +117,7 @@ namespace Content.Server.Preferences.Managers
var slot = message.Slot;
var userId = message.MsgChannel.UserId;
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded.IsCompleted)
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded)
{
Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded.");
return;
@@ -163,14 +164,15 @@ namespace Content.Server.Preferences.Managers
}
}
public async void OnClientConnected(IPlayerSession session)
// Should only be called via UserDbDataManager.
public async Task LoadData(IPlayerSession session, CancellationToken cancel)
{
if (!ShouldStorePrefs(session.ConnectedClient.AuthType))
{
// Don't store data for guests.
var prefsData = new PlayerPrefData
{
PrefsLoaded = Task.CompletedTask,
PrefsLoaded = true,
Prefs = new PlayerPreferences(
new[] {new KeyValuePair<int, ICharacterProfile>(0, HumanoidCharacterProfile.Random())},
0, Color.Transparent)
@@ -182,7 +184,6 @@ namespace Content.Server.Preferences.Managers
{
var prefsData = new PlayerPrefData();
var loadTask = LoadPrefs();
prefsData.PrefsLoaded = loadTask;
_cachedPlayerPrefs[session.UserId] = prefsData;
await loadTask;
@@ -191,6 +192,7 @@ namespace Content.Server.Preferences.Managers
{
var prefs = await GetOrCreatePreferencesAsync(session.UserId);
prefsData.Prefs = prefs;
prefsData.PrefsLoaded = true;
var msg = new MsgPreferencesAndSettings();
msg.Preferences = prefs;
@@ -203,7 +205,6 @@ namespace Content.Server.Preferences.Managers
}
}
public void OnClientDisconnected(IPlayerSession session)
{
_cachedPlayerPrefs.Remove(session.UserId);
@@ -214,11 +215,6 @@ namespace Content.Server.Preferences.Managers
return _cachedPlayerPrefs.ContainsKey(session.UserId);
}
public Task WaitPreferencesLoaded(IPlayerSession session)
{
return _cachedPlayerPrefs[session.UserId].PrefsLoaded;
}
/// <summary>
/// Retrieves preferences for the given username from storage.
/// Creates and saves default preferences if they are not found, then returns them.
@@ -303,7 +299,7 @@ namespace Content.Server.Preferences.Managers
private sealed class PlayerPrefData
{
public Task PrefsLoaded = default!;
public bool PrefsLoaded;
public PlayerPreferences? Prefs;
}
}

View File

@@ -1,12 +1,14 @@
using Content.Server.Chat;
using Content.Server.Chat.Managers;
using Content.Server.Chat.Systems;
using Content.Shared.Roles;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Server.Roles
{
public sealed class Job : Role
public sealed class Job : Role, IRoleTimer
{
[ViewVariables] public string Timer => Prototype.PlayTimeTracker;
[ViewVariables]
public JobPrototype Prototype { get; }

View File

@@ -2,6 +2,6 @@
{
public sealed class RoleAddedEvent : RoleEvent
{
public RoleAddedEvent(Role role) : base(role) { }
public RoleAddedEvent(Mind.Mind mind, Role role) : base(mind, role) { }
}
}

View File

@@ -2,10 +2,12 @@
{
public abstract class RoleEvent : EntityEventArgs
{
public readonly Mind.Mind Mind;
public readonly Role Role;
public RoleEvent(Role role)
public RoleEvent(Mind.Mind mind, Role role)
{
Mind = mind;
Role = role;
}
}

View File

@@ -2,6 +2,6 @@
{
public sealed class RoleRemovedEvent : RoleEvent
{
public RoleRemovedEvent(Role role) : base(role) { }
public RoleRemovedEvent(Mind.Mind mind, Role role) : base(mind, role) { }
}
}

View File

@@ -1,5 +1,7 @@
using System.Linq;
using Content.Server.Administration.Managers;
using Content.Server.Players.PlayTimeTracking;
using Content.Server.Roles;
using Content.Server.Station.Components;
using Content.Shared.Preferences;
using Content.Shared.Roles;
@@ -15,6 +17,7 @@ public sealed partial class StationJobsSystem
{
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly RoleBanManager _roleBanManager = default!;
[Dependency] private readonly PlayTimeTrackingSystem _playTime = default!;
private Dictionary<int, HashSet<string>> _jobsByWeight = default!;
private List<int> _orderedWeights = default!;
@@ -339,11 +342,15 @@ public sealed partial class StationJobsSystem
foreach (var (player, profile) in profiles)
{
var roleBans = _roleBanManager.GetJobBans(player);
var profileJobs = profile.JobPriorities.Keys.ToList();
_playTime.RemoveDisallowedJobs(player, ref profileJobs);
List<string>? availableJobs = null;
foreach (var (jobId, priority) in profile.JobPriorities)
foreach (var jobId in profileJobs)
{
var priority = profile.JobPriorities[jobId];
if (!(priority == selectedPriority || selectedPriority is null))
continue;
@@ -357,7 +364,6 @@ public sealed partial class StationJobsSystem
continue;
availableJobs ??= new List<string>(profile.JobPriorities.Count);
availableJobs.Add(jobId);
}

View File

@@ -7,6 +7,7 @@ using Content.Shared.GameTicking;
using Content.Shared.Preferences;
using Content.Shared.Roles;
using JetBrains.Annotations;
using Robust.Server.Player;
using Robust.Shared.Configuration;
using Robust.Shared.Player;
using Robust.Shared.Random;