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

@@ -17,6 +17,7 @@ using Content.Client.MainMenu;
using Content.Client.MobState.Overlays;
using Content.Client.Parallax;
using Content.Client.Parallax.Managers;
using Content.Client.Players.PlayTimeTracking;
using Content.Client.Preferences;
using Content.Client.Radiation;
using Content.Client.Sandbox;
@@ -125,6 +126,7 @@ namespace Content.Client.Entry
IoCManager.Resolve<ViewportManager>().Initialize();
IoCManager.Resolve<GhostKickManager>().Initialize();
IoCManager.Resolve<ExtendedDisconnectInformationManager>().Initialize();
IoCManager.Resolve<PlayTimeTrackingManager>().Initialize();
IoCManager.InjectDependencies(this);

View File

@@ -11,6 +11,7 @@ using Content.Client.Items.Managers;
using Content.Client.Launcher;
using Content.Client.Module;
using Content.Client.Parallax.Managers;
using Content.Client.Players.PlayTimeTracking;
using Content.Client.Preferences;
using Content.Client.Screenshot;
using Content.Client.Stylesheets;
@@ -47,6 +48,7 @@ namespace Content.Client.IoC
IoCManager.Register<ISharedAdminLogManager, SharedAdminLogManager>();
IoCManager.Register<GhostKickManager>();
IoCManager.Register<ExtendedDisconnectInformationManager>();
IoCManager.Register<PlayTimeTrackingManager>();
}
}
}

View File

@@ -134,54 +134,61 @@ namespace Content.Client.LateJoin
var firstCategory = true;
foreach (var job in gameTicker.JobsAvailable[id].OrderBy(x => x.Key))
foreach (var department in _prototypeManager.EnumeratePrototypes<DepartmentPrototype>())
{
var prototype = _prototypeManager.Index<JobPrototype>(job.Key);
foreach (var department in prototype.Departments)
var departmentName = Loc.GetString($"department-{department.ID}");
_jobCategories[id] = new Dictionary<string, BoxContainer>();
_jobButtons[id] = new Dictionary<string, JobButton>();
var stationAvailable = gameTicker.JobsAvailable[id];
var category = new BoxContainer
{
if (!_jobCategories.TryGetValue(id, out var _))
_jobCategories[id] = new Dictionary<string, BoxContainer>();
if (!_jobButtons.TryGetValue(id, out var _))
_jobButtons[id] = new Dictionary<string, JobButton>();
if (!_jobCategories[id].TryGetValue(department, out var category))
Orientation = LayoutOrientation.Vertical,
Name = department.ID,
ToolTip = Loc.GetString("late-join-gui-jobs-amount-in-department-tooltip",
("departmentName", departmentName))
};
if (firstCategory)
{
firstCategory = false;
}
else
{
category.AddChild(new Control
{
category = new BoxContainer
{
Orientation = LayoutOrientation.Vertical,
Name = department,
ToolTip = Loc.GetString("late-join-gui-jobs-amount-in-department-tooltip",
("departmentName", department))
};
MinSize = new Vector2(0, 23),
});
}
if (firstCategory)
category.AddChild(new PanelContainer
{
Children =
{
new Label
{
firstCategory = false;
StyleClasses = { "LabelBig" },
Text = Loc.GetString("late-join-gui-department-jobs-label", ("departmentName", departmentName))
}
else
{
category.AddChild(new Control
{
MinSize = new Vector2(0, 23),
});
}
category.AddChild(new PanelContainer
{
Children =
{
new Label
{
StyleClasses = { "LabelBig" },
Text = Loc.GetString("late-join-gui-department-jobs-label", ("departmentName", department))
}
}
});
_jobCategories[id][department] = category;
jobList.AddChild(category);
}
});
var jobButton = new JobButton(prototype.ID, job.Value);
_jobCategories[id][department.ID] = category;
jobList.AddChild(category);
var jobsAvailable = new List<JobPrototype>();
foreach (var jobId in department.Roles)
{
if (!stationAvailable.ContainsKey(jobId)) continue;
jobsAvailable.Add(_prototypeManager.Index<JobPrototype>(jobId));
}
jobsAvailable.Sort((x, y) => -string.Compare(x.LocalizedName, y.LocalizedName, StringComparison.CurrentCultureIgnoreCase));
foreach (var prototype in jobsAvailable)
{
var value = stationAvailable[prototype.ID];
var jobButton = new JobButton(prototype.ID, value);
var jobSelector = new BoxContainer
{
@@ -205,8 +212,8 @@ namespace Content.Client.LateJoin
var jobLabel = new Label
{
Text = job.Value != null ?
Loc.GetString("late-join-gui-job-slot-capped", ("jobName", prototype.LocalizedName), ("amount", job.Value)) :
Text = value != null ?
Loc.GetString("late-join-gui-job-slot-capped", ("jobName", prototype.LocalizedName), ("amount", value)) :
Loc.GetString("late-join-gui-job-slot-uncapped", ("jobName", prototype.LocalizedName))
};
@@ -219,7 +226,7 @@ namespace Content.Client.LateJoin
SelectedId?.Invoke((id, jobButton.JobId));
};
if (job.Value == 0)
if (value == 0)
{
jobButton.Disabled = true;
}

View File

@@ -0,0 +1,88 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using Content.Shared.CCVar;
using Content.Shared.Players.PlayTimeTracking;
using Content.Shared.Roles;
using Robust.Client;
using Robust.Client.Player;
using Robust.Shared.Configuration;
using Robust.Shared.Network;
using Robust.Shared.Prototypes;
namespace Content.Client.Players.PlayTimeTracking;
public sealed class PlayTimeTrackingManager
{
[Dependency] private readonly IBaseClient _client = default!;
[Dependency] private readonly IClientNetManager _net = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IPrototypeManager _prototypes = default!;
private readonly Dictionary<string, TimeSpan> _roles = new();
public void Initialize()
{
_net.RegisterNetMessage<MsgPlayTime>(RxPlayTime);
_client.RunLevelChanged += ClientOnRunLevelChanged;
}
private void ClientOnRunLevelChanged(object? sender, RunLevelChangedEventArgs e)
{
if (e.NewLevel == ClientRunLevel.Initialize)
{
// Reset on disconnect, just in case.
_roles.Clear();
}
}
private void RxPlayTime(MsgPlayTime message)
{
_roles.Clear();
// NOTE: do not assign _roles = message.Trackers due to implicit data sharing in integration tests.
foreach (var (tracker, time) in message.Trackers)
{
_roles[tracker] = time;
}
/*var sawmill = Logger.GetSawmill("play_time");
foreach (var (tracker, time) in _roles)
{
sawmill.Info($"{tracker}: {time}");
}*/
}
public bool IsAllowed(JobPrototype job, [NotNullWhen(false)] out string? reason)
{
reason = null;
if (job.Requirements == null ||
!_cfg.GetCVar(CCVars.GameRoleTimers))
return true;
var player = _playerManager.LocalPlayer?.Session;
if (player == null) return true;
var roles = _roles;
var reasonBuilder = new StringBuilder();
var first = true;
foreach (var requirement in job.Requirements)
{
if (JobRequirements.TryRequirementMet(requirement, roles, out reason, _prototypes))
continue;
if (!first)
reasonBuilder.Append('\n');
first = false;
reasonBuilder.AppendLine(reason);
}
reason = reasonBuilder.Length == 0 ? null : reasonBuilder.ToString();
return reason == null;
}
}

View File

@@ -235,7 +235,7 @@ namespace Content.Client.Preferences.UI
if (!disposing)
return;
IoCManager.Resolve<IEntityManager>().DeleteEntity((EntityUid) _previewDummy);
IoCManager.Resolve<IEntityManager>().DeleteEntity(_previewDummy);
_previewDummy = default;
}
}

View File

@@ -2,8 +2,10 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Content.Client.CharacterAppearance;
using Content.Client.HUD.UI;
using Content.Client.Lobby.UI;
using Content.Client.Message;
using Content.Client.Players.PlayTimeTracking;
using Content.Client.Stylesheets;
using Content.Shared.CCVar;
using Content.Shared.CharacterAppearance;
@@ -341,56 +343,65 @@ namespace Content.Client.Preferences.UI
_jobPriorities = new List<JobPrioritySelector>();
_jobCategories = new Dictionary<string, BoxContainer>();
var spriteSystem = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>();
var firstCategory = true;
var playTime = IoCManager.Resolve<PlayTimeTrackingManager>();
foreach (var job in prototypeManager.EnumeratePrototypes<JobPrototype>().OrderBy(j => j.LocalizedName))
foreach (var department in _prototypeManager.EnumeratePrototypes<DepartmentPrototype>())
{
if(!job.SetPreference) { continue; }
var departmentName = Loc.GetString($"department-{department.ID}");
foreach (var department in job.Departments)
if (!_jobCategories.TryGetValue(department.ID, out var category))
{
if (!_jobCategories.TryGetValue(department, out var category))
category = new BoxContainer
{
category = new BoxContainer
{
Orientation = LayoutOrientation.Vertical,
Name = department,
ToolTip = Loc.GetString("humanoid-profile-editor-jobs-amount-in-department-tooltip",
("departmentName", department))
};
Orientation = LayoutOrientation.Vertical,
Name = department.ID,
ToolTip = Loc.GetString("humanoid-profile-editor-jobs-amount-in-department-tooltip",
("departmentName", departmentName))
};
if (firstCategory)
{
firstCategory = false;
}
else
{
category.AddChild(new Control
{
MinSize = new Vector2(0, 23),
});
}
category.AddChild(new PanelContainer
if (firstCategory)
{
firstCategory = false;
}
else
{
category.AddChild(new Control
{
PanelOverride = new StyleBoxFlat {BackgroundColor = Color.FromHex("#464966")},
Children =
{
new Label
{
Text = Loc.GetString("humanoid-profile-editor-department-jobs-label",
("departmentName" ,department))
}
}
MinSize = new Vector2(0, 23),
});
_jobCategories[department] = category;
_jobList.AddChild(category);
}
var selector = new JobPrioritySelector(job, spriteSystem);
category.AddChild(new PanelContainer
{
PanelOverride = new StyleBoxFlat {BackgroundColor = Color.FromHex("#464966")},
Children =
{
new Label
{
Text = Loc.GetString("humanoid-profile-editor-department-jobs-label",
("departmentName", departmentName))
}
}
});
_jobCategories[department.ID] = category;
_jobList.AddChild(category);
}
var jobs = department.Roles.Select(o => _prototypeManager.Index<JobPrototype>(o)).Where(o => o.SetPreference).ToList();
jobs.Sort((x, y) => -string.Compare(x.LocalizedName, y.LocalizedName, StringComparison.CurrentCultureIgnoreCase));
foreach (var job in jobs)
{
var selector = new JobPrioritySelector(job);
if (!playTime.IsAllowed(job, out var reason))
{
selector.LockRequirements(reason);
}
category.AddChild(selector);
_jobPriorities.Add(selector);
@@ -418,6 +429,7 @@ namespace Content.Client.Preferences.UI
}
}
};
}
}
@@ -992,7 +1004,10 @@ namespace Content.Client.Preferences.UI
public event Action<JobPriority>? PriorityChanged;
public JobPrioritySelector(JobPrototype job, SpriteSystem sprites)
private StripeBack _lockStripe;
private Label _requirementsLabel;
public JobPrioritySelector(JobPrototype job)
{
Job = job;
@@ -1021,9 +1036,32 @@ namespace Content.Client.Preferences.UI
Stretch = TextureRect.StretchMode.KeepCentered
};
var specifier = new SpriteSpecifier.Rsi(new ResourcePath("/Textures/Interface/Misc/job_icons.rsi"),
job.Icon);
icon.Texture = sprites.Frame0(specifier);
if (job.Icon != null)
{
var specifier = new SpriteSpecifier.Rsi(new ResourcePath("/Textures/Interface/Misc/job_icons.rsi"),
job.Icon);
icon.Texture = specifier.Frame0();
}
_requirementsLabel = new Label()
{
Text = Loc.GetString("role-timer-locked"),
Visible = true,
HorizontalAlignment = HAlignment.Center,
StyleClasses = {StyleBase.StyleClassLabelSubText},
};
_lockStripe = new StripeBack()
{
Visible = false,
HorizontalExpand = true,
TooltipDelay = 0.2f,
MouseFilter = MouseFilterMode.Stop,
Children =
{
_requirementsLabel
}
};
AddChild(new BoxContainer
{
@@ -1032,10 +1070,26 @@ namespace Content.Client.Preferences.UI
{
icon,
new Label {Text = job.LocalizedName, MinSize = (175, 0)},
_optionButton
_optionButton,
_lockStripe,
}
});
}
public void LockRequirements(string requirements)
{
_lockStripe.ToolTip = requirements;
_lockStripe.Visible = true;
_optionButton.Visible = false;
}
// TODO: Subscribe to roletimers event. I am too lazy to do this RN But I doubt most people will notice fn
public void UnlockRequirements()
{
_requirementsLabel.Visible = false;
_lockStripe.Visible = false;
_optionButton.Visible = true;
}
}
private void UpdateAntagPreferences()

View File

@@ -20,6 +20,12 @@ namespace Content.IntegrationTests.Tests.Station;
public sealed class StationJobsTest
{
private const string Prototypes = @"
- type: playTimeTracker
id: Dummy
- type: playTimeTracker
id: Overall
- type: gameMap
id: FooStation
minPlayers: 0
@@ -38,21 +44,26 @@ public sealed class StationJobsTest
- type: job
id: TAssistant
playTimeTracker: Dummy
- type: job
id: TMime
weight: 20
playTimeTracker: Dummy
- type: job
id: TClown
weight: -10
playTimeTracker: Dummy
- type: job
id: TCaptain
weight: 10
playTimeTracker: Dummy
- type: job
id: TChaplain
playTimeTracker: Dummy
";
private const int StationCount = 100;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Content.Server.Database.Migrations.Postgres
{
public partial class PlayTime : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "play_time",
columns: table => new
{
play_time_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
player_id = table.Column<Guid>(type: "uuid", nullable: false),
tracker = table.Column<string>(type: "text", nullable: false),
time_spent = table.Column<TimeSpan>(type: "interval", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_play_time", x => x.play_time_id);
});
migrationBuilder.CreateIndex(
name: "IX_play_time_player_id_tracker",
table: "play_time",
columns: new[] { "player_id", "tracker" },
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "play_time");
}
}
}

View File

@@ -19,7 +19,7 @@ namespace Content.Server.Database.Migrations.Postgres
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.0")
.HasAnnotation("ProductVersion", "6.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -506,6 +506,37 @@ namespace Content.Server.Database.Migrations.Postgres
b.HasCheckConstraint("LastSeenAddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= last_seen_address");
});
modelBuilder.Entity("Content.Server.Database.PlayTime", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("play_time_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid>("PlayerId")
.HasColumnType("uuid")
.HasColumnName("player_id");
b.Property<TimeSpan>("TimeSpent")
.HasColumnType("interval")
.HasColumnName("time_spent");
b.Property<string>("Tracker")
.IsRequired()
.HasColumnType("text")
.HasColumnName("tracker");
b.HasKey("Id")
.HasName("PK_play_time");
b.HasIndex("PlayerId", "Tracker")
.IsUnique();
b.ToTable("play_time", (string)null);
});
modelBuilder.Entity("Content.Server.Database.Preference", b =>
{
b.Property<int>("Id")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Content.Server.Database.Migrations.Sqlite
{
public partial class PlayTime : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "play_time",
columns: table => new
{
play_time_id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
player_id = table.Column<Guid>(type: "TEXT", nullable: false),
tracker = table.Column<string>(type: "TEXT", nullable: false),
time_spent = table.Column<TimeSpan>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_play_time", x => x.play_time_id);
});
migrationBuilder.CreateIndex(
name: "IX_play_time_player_id_tracker",
table: "play_time",
columns: new[] { "player_id", "tracker" },
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "play_time");
}
}
}

View File

@@ -1,6 +1,5 @@
// <auto-generated />
using System;
using System.Text.Json;
using Content.Server.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -16,7 +15,7 @@ namespace Content.Server.Database.Migrations.Sqlite
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.0");
modelBuilder.HasAnnotation("ProductVersion", "6.0.5");
modelBuilder.Entity("Content.Server.Database.Admin", b =>
{
@@ -469,6 +468,35 @@ namespace Content.Server.Database.Migrations.Sqlite
b.ToTable("player", (string)null);
});
modelBuilder.Entity("Content.Server.Database.PlayTime", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("play_time_id");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT")
.HasColumnName("player_id");
b.Property<TimeSpan>("TimeSpent")
.HasColumnType("TEXT")
.HasColumnName("time_spent");
b.Property<string>("Tracker")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tracker");
b.HasKey("Id")
.HasName("PK_play_time");
b.HasIndex("PlayerId", "Tracker")
.IsUnique();
b.ToTable("play_time", (string)null);
});
modelBuilder.Entity("Content.Server.Database.Preference", b =>
{
b.Property<int>("Id")
@@ -559,7 +587,7 @@ namespace Content.Server.Database.Migrations.Sqlite
.HasColumnType("TEXT")
.HasColumnName("hair_name");
b.Property<JsonDocument>("Markings")
b.Property<byte[]>("Markings")
.HasColumnType("jsonb")
.HasColumnName("markings");

View File

@@ -33,6 +33,7 @@ namespace Content.Server.Database
public DbSet<ServerBanHit> ServerBanHit { get; set; } = default!;
public DbSet<ServerRoleBan> RoleBan { get; set; } = default!;
public DbSet<ServerRoleUnban> RoleUnban { get; set; } = default!;
public DbSet<PlayTime> PlayTime { get; set; } = default!;
public DbSet<UploadedResourceLog> UploadedResourceLog { get; set; } = default!;
public DbSet<AdminNote> AdminNotes { get; set; } = null!;
@@ -94,6 +95,10 @@ namespace Content.Server.Database
modelBuilder.Entity<AdminLog>()
.HasIndex(log => log.Date);
modelBuilder.Entity<PlayTime>()
.HasIndex(v => new { v.PlayerId, Role = v.Tracker })
.IsUnique();
modelBuilder.Entity<AdminLogPlayer>()
.HasOne(player => player.Player)
.WithMany(player => player.AdminLogs)
@@ -501,6 +506,20 @@ namespace Content.Server.Database
public DateTime UnbanTime { get; set; }
}
[Table("play_time")]
public sealed class PlayTime
{
[Required, Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required, ForeignKey("player")]
public Guid PlayerId { get; set; }
public string Tracker { get; set; } = null!;
public TimeSpan TimeSpent { get; set; }
}
[Table("uploaded_resource_log")]
public sealed class UploadedResourceLog
{

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;

View File

@@ -176,6 +176,12 @@ namespace Content.Shared.CCVar
public static readonly CVarDef<bool>
GameMapRotation = CVarDef.Create<bool>("game.map_rotation", true, CVar.SERVERONLY);
/// <summary>
/// If roles should be restricted based on time.
/// </summary>
public static readonly CVarDef<bool>
GameRoleTimers = CVarDef.Create("game.role_timers", false, CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Whether a random position offset will be applied to the station on roundstart.
/// </summary>
@@ -1091,5 +1097,16 @@ namespace Content.Shared.CCVar
/// </summary>
public static readonly CVarDef<float> GhostRoleTime =
CVarDef.Create("ghost.role_time", 3f, CVar.REPLICATED);
/*
* PLAYTIME
*/
/// <summary>
/// Time between play time autosaves, in seconds.
/// </summary>
public static readonly CVarDef<float>
PlayTimeSaveInterval = CVarDef.Create("playtime.save_interval", 900f, CVar.SERVERONLY);
}
}

View File

@@ -0,0 +1,34 @@
using Lidgren.Network;
using Robust.Shared.Network;
namespace Content.Shared.Players.PlayTimeTracking;
/// <summary>
/// Sent server -> client to inform the client of their play times.
/// </summary>
public sealed class MsgPlayTime : NetMessage
{
public override MsgGroups MsgGroup => MsgGroups.EntityEvent;
public Dictionary<string, TimeSpan> Trackers = new();
public override void ReadFromBuffer(NetIncomingMessage buffer)
{
var count = buffer.ReadVariableInt32();
for (var i = 0; i < count; i++)
{
Trackers.Add(buffer.ReadString(), buffer.ReadTimeSpan());
}
}
public override void WriteToBuffer(NetOutgoingMessage buffer)
{
buffer.WriteVariableInt32(Trackers.Count);
foreach (var (role, time) in Trackers)
{
buffer.Write(role);
buffer.Write(time);
}
}
}

View File

@@ -0,0 +1,12 @@
using Robust.Shared.Prototypes;
namespace Content.Shared.Players.PlayTimeTracking;
/// <summary>
/// Given to a role to specify its ID for role-timer tracking purposes. That's it.
/// </summary>
[Prototype("playTimeTracker")]
public sealed class PlayTimeTrackerPrototype : IPrototype
{
[IdDataField] public string ID { get; } = default!;
}

View File

@@ -0,0 +1,9 @@
namespace Content.Shared.Players.PlayTimeTracking;
public static class PlayTimeTrackingShared
{
/// <summary>
/// The prototype ID of the play time tracker that represents overall playtime, i.e. not tied to any one role.
/// </summary>
public const string TrackerOverall = "Overall";
}

View File

@@ -0,0 +1,14 @@
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
namespace Content.Shared.Roles;
[Prototype("department")]
public sealed class DepartmentPrototype : IPrototype
{
[IdDataFieldAttribute] public string ID { get; } = default!;
[ViewVariables(VVAccess.ReadWrite),
DataField("roles", customTypeSerializer: typeof(PrototypeIdListSerializer<JobPrototype>))]
public List<string> Roles = new();
}

View File

@@ -0,0 +1,6 @@
namespace Content.Shared.Roles;
public interface IRoleTimer
{
string Timer { get; }
}

View File

@@ -1,4 +1,5 @@
using Content.Shared.Access;
using Content.Shared.Players.PlayTimeTracking;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
@@ -11,12 +12,13 @@ namespace Content.Shared.Roles
[Prototype("job")]
public sealed class JobPrototype : IPrototype
{
private string _name = string.Empty;
[ViewVariables]
[IdDataFieldAttribute]
public string ID { get; } = default!;
[ViewVariables, DataField("playTimeTracker", required: true, customTypeSerializer: typeof(PrototypeIdSerializer<PlayTimeTrackerPrototype>))]
public string PlayTimeTracker { get; } = string.Empty;
[DataField("supervisors")]
public string Supervisors { get; } = "nobody";
@@ -29,6 +31,9 @@ namespace Content.Shared.Roles
[ViewVariables(VVAccess.ReadOnly)]
public string LocalizedName => Loc.GetString(Name);
[DataField("requirements")]
public HashSet<JobRequirement>? Requirements;
[DataField("joinNotifyCrew")]
public bool JoinNotifyCrew { get; } = false;
@@ -64,9 +69,6 @@ namespace Content.Shared.Roles
[DataField("special", serverOnly:true)]
public JobSpecial[] Special { get; private set; } = Array.Empty<JobSpecial>();
[DataField("departments")]
public IReadOnlyCollection<string> Departments { get; } = Array.Empty<string>();
[DataField("access", customTypeSerializer: typeof(PrototypeIdListSerializer<AccessLevelPrototype>))]
public IReadOnlyCollection<string> Access { get; } = Array.Empty<string>();

View File

@@ -0,0 +1,145 @@
using System.Diagnostics.CodeAnalysis;
using Content.Shared.Players.PlayTimeTracking;
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Shared.Roles
{
/// <summary>
/// Abstract class for playtime and other requirements for role gates.
/// </summary>
[ImplicitDataDefinitionForInheritors]
public abstract class JobRequirement
{
}
[UsedImplicitly]
public sealed class DepartmentTimeRequirement : JobRequirement
{
[DataField("department", customTypeSerializer: typeof(PrototypeIdSerializer<DepartmentPrototype>))]
public string Department = default!;
/// <summary>
/// How long (in seconds) this requirement is.
/// </summary>
[DataField("time")] public TimeSpan Time;
}
[UsedImplicitly]
public sealed class RoleTimeRequirement : JobRequirement
{
/// <summary>
/// What particular role they need the time requirement with.
/// </summary>
[DataField("role", customTypeSerializer: typeof(PrototypeIdSerializer<PlayTimeTrackerPrototype>))]
public string Role = default!;
/// <summary>
/// How long (in seconds) this requirement is.
/// </summary>
[DataField("time")] public TimeSpan Time;
}
[UsedImplicitly]
public sealed class OverallPlaytimeRequirement : JobRequirement
{
/// <summary>
/// How long (in seconds) this requirement is.
/// </summary>
[DataField("time")] public TimeSpan Time;
}
public static class JobRequirements
{
public static bool TryRequirementsMet(
JobPrototype job,
Dictionary<string, TimeSpan> playTimes,
[NotNullWhen(false)] out string? reason,
IPrototypeManager prototypes)
{
reason = null;
if (job.Requirements == null)
return true;
foreach (var requirement in job.Requirements)
{
if (!TryRequirementMet(requirement, playTimes, out reason, prototypes))
return false;
}
return true;
}
/// <summary>
/// Returns a string with the reason why a particular requirement may not be met.
/// </summary>
public static bool TryRequirementMet(
JobRequirement requirement,
Dictionary<string, TimeSpan> playTimes,
[NotNullWhen(false)] out string? reason,
IPrototypeManager prototypes)
{
reason = null;
switch (requirement)
{
case DepartmentTimeRequirement deptRequirement:
var playtime = TimeSpan.Zero;
// Check all jobs' departments
var jobs = prototypes.Index<DepartmentPrototype>(deptRequirement.Department).Roles;
string proto;
// Check all jobs' playtime
foreach (var other in jobs)
{
// The schema is stored on the Job role but we want to explode if the timer isn't found anyway.
proto = prototypes.Index<JobPrototype>(other).PlayTimeTracker;
playTimes.TryGetValue(proto, out var otherTime);
playtime += otherTime;
}
var deptDiff = deptRequirement.Time.TotalMinutes - playtime.TotalMinutes;
if (deptDiff <= 0)
return true;
reason = Loc.GetString(
"role-timer-department-insufficient",
("time", deptDiff),
("department", Loc.GetString(deptRequirement.Department)));
return false;
case OverallPlaytimeRequirement overallRequirement:
var overallTime = playTimes.GetValueOrDefault(PlayTimeTrackingShared.TrackerOverall);
var overallDiff = overallRequirement.Time.TotalMinutes - overallTime.TotalMinutes;
if (overallDiff <= 0 || overallTime >= overallRequirement.Time)
return true;
reason = Loc.GetString("role-timer-overall-insufficient", ("time", overallDiff));
return false;
case RoleTimeRequirement roleRequirement:
proto = roleRequirement.Role;
playTimes.TryGetValue(proto, out var roleTime);
var roleDiff = roleRequirement.Time.TotalMinutes - roleTime.TotalMinutes;
if (roleDiff <= 0)
return true;
reason = Loc.GetString(
"role-timer-role-insufficient",
("time", roleDiff),
("job", Loc.GetString(proto)));
return false;
default:
throw new NotImplementedException();
}
}
}
}

View File

@@ -0,0 +1,7 @@
department-Cargo = cargo
department-Civilian = civilian
department-Command = command
department-Engineering = engineering
department-Medical = medical
department-Security = security
department-Science = science

View File

@@ -30,7 +30,7 @@ job-name-bartender = bartender
job-name-passenger = passenger
job-name-salvagespec = salvage specialist
job-name-qm = quartermaster
job-name-cargoteh = cargo technician
job-name-cargotech = cargo technician
job-name-chef = chef
job-name-clown = clown
job-name-ertleader = ERT leader
@@ -38,3 +38,8 @@ job-name-ertengineer = ERT engineer
job-name-ertsecurity = ERT security
job-name-ertmedic = ERT medic
job-name-ertjanitor = ERT janitor
# Role timers - Make these alphabetical or I cut you
JobAtmosphericTechnician = atmospheric technician
JobSalvageSpecialist = salvage specialist
JobWarden = warden

View File

@@ -0,0 +1,5 @@
role-timer-department-insufficient = Require {TOSTRING($time, "0")} more minutes in {$department} department.
role-timer-overall-insufficient = Require {TOSTRING($time, "0")} more minutes of playtime.
role-timer-role-insufficient = Require {TOSTRING($time, "0")} more minutes with {$job} for this role.
role-timer-locked = Locked (hover for details)

View File

@@ -0,0 +1,56 @@
parse-minutes-fail = Unable to parse '{$minutes}' as minutes
parse-session-fail = Did not find session for '{$username}'
## Role Timer Commands
# - playtime_addoverall
cmd-playtime_addoverall-desc = Adds the specified minutes to a player's overall playtime
cmd-playtime_addoverall-help = Usage: {$command} <user name> <minutes>
cmd-playtime_addoverall-succeed = Increased overall time for {$username} to {TOSTRING($time, "0")}
cmd-playtime_addoverall-arg-user = <user name>
cmd-playtime_addoverall-arg-minutes = <minutes>
cmd-playtime_addoverall-error-args = Expected exactly two arguments
# - playtime_addrole
cmd-playtime_addrole-desc = Adds the specified minutes to a player's role playtime
cmd-playtime_addrole-help = Usage: {$command} <user name> <role> <minutes>
cmd-playtime_addrole-succeed = Increased role playtime for {$username} / \'{$role}\' to {TOSTRING($time, "0")}
cmd-playtime_addrole-arg-user = <user name>
cmd-playtime_addrole-arg-role = <role>
cmd-playtime_addrole-arg-minutes = <minutes>
cmd-playtime_addrole-error-args = Expected exactly three arguments
# - playtime_getoverall
cmd-playtime_getoverall-desc = Gets the specified minutes for a player's overall playtime
cmd-playtime_getoverall-help = Usage: {$command} <user name>
cmd-playtime_getoverall-success = Overall time for {$username} is {TOSTRING($time, "0")} minutes
cmd-playtime_getoverall-arg-user = <user name>
cmd-playtime_getoverall-error-args = Expected exactly one argument
# - GetRoleTimer
cmd-playtime_getrole-desc = Gets all or one role timers from a player
cmd-playtime_getrole-help = Usage: {$command} <user name> [role]
cmd-playtime_getrole-no = Found no role timers
cmd-playtime_getrole-role = Role: {$role}, Playtime: {$time}
cmd-playtime_getrole-overall = Overall playtime is {$time}
cmd-playtime_getrole-succeed = Playtime for {$username} is: {TOSTRING($time, "0")}
cmd-playtime_getrole-arg-user = <user name>
cmd-playtime_getrole-arg-role = <role|'Overall'>
cmd-playtime_getrole-error-args = Expected exactly one or two arguments
# - playtime_save
cmd-playtime_save-desc = Saves the player's playtimes to the DB
cmd-playtime_save-help = Usage: {$command} <user name>
cmd-playtime_save-succeed = Saved playtime for {$username}
cmd-playtime_save-arg-user = <user name>
cmd-playtime_save-error-args = Expected exactly one argument
## 'playtime_flush' command'
cmd-playtime_flush-desc = Flush active trackers to stored in playtime tracking.
cmd-playtime_flush-help = Usage: {$command} [user name]
This causes a flush to the internal storage only, it does not flush to DB immediately.
If a user is provided, only that user is flushed.
cmd-playtime_flush-error-args = Expected zero or one arguments
cmd-playtime_flush-arg-user = [user name]

View File

@@ -1,9 +1,8 @@
- type: job
id: CargoTechnician
name: job-name-cargoteh
name: job-name-cargotech
playTimeTracker: JobCargoTechnician
startingGear: CargoTechGear
departments:
- Cargo
icon: "CargoTechnician"
supervisors: job-supervisors-hop-qm
access:

View File

@@ -1,10 +1,16 @@
- type: job
id: Quartermaster
name: job-name-qm
playTimeTracker: JobQuartermaster
requirements:
- !type:RoleTimeRequirement
role: JobSalvageSpecialist
time: 3600
- !type:DepartmentTimeRequirement
department: Cargo
time: 18000
weight: 10
startingGear: QuartermasterGear
departments:
- Cargo
icon: "QuarterMaster"
supervisors: job-supervisors-hop
canBeAntag: false

View File

@@ -1,10 +1,13 @@
- type: job
id: SalvageSpecialist
name: job-name-salvagespec
playTimeTracker: JobSalvageSpecialist
requirements:
- !type:DepartmentTimeRequirement
department: Cargo
time: 3600
icon: "ShaftMiner"
startingGear: SalvageSpecialistGear
departments:
- Cargo
supervisors: job-supervisors-hop-qm
access:
- Cargo

View File

@@ -1,9 +1,8 @@
- type: job
id: Passenger
name: job-name-passenger
playTimeTracker: JobPassenger
startingGear: PassengerGear
departments:
- Civilian
icon: "Passenger"
supervisors: job-supervisors-everyone
access:

View File

@@ -1,9 +1,12 @@
- type: job
id: Bartender
name: job-name-bartender
playTimeTracker: JobBartender
requirements:
- !type:DepartmentTimeRequirement
department: Civilian
time: 1800
startingGear: BartenderGear
departments:
- Civilian
icon: "Bartender"
supervisors: job-supervisors-hop
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: Botanist
name: job-name-botanist
playTimeTracker: JobBotanist
startingGear: BotanistGear
departments:
- Civilian
icon: "Botanist"
supervisors: job-supervisors-hop
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: Chaplain
name: job-name-chaplain
playTimeTracker: JobChaplain
startingGear: ChaplainGear
departments:
- Civilian
icon: "Chaplain"
supervisors: job-supervisors-hop
access:

View File

@@ -1,9 +1,12 @@
- type: job
id: Chef
name: job-name-chef
playTimeTracker: JobChef
requirements:
- !type:DepartmentTimeRequirement
department: Civilian
time: 1800
startingGear: ChefGear
departments:
- Civilian
icon: "Chef"
supervisors: job-supervisors-hop
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: Clown
name: job-name-clown
playTimeTracker: JobClown
startingGear: ClownGear
departments:
- Civilian
icon: "Clown"
supervisors: job-supervisors-hop
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: Janitor
name: job-name-janitor
playTimeTracker: JobJanitor
startingGear: JanitorGear
departments:
- Civilian
icon: "Janitor"
supervisors: job-supervisors-hop
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: Lawyer
name: job-name-lawyer
playTimeTracker: JobLawyer
startingGear: LawyerGear
departments:
- Civilian
icon: "Lawyer"
supervisors: job-supervisors-hop
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: Librarian
name: job-name-librarian
playTimeTracker: JobLibrarian
startingGear: LibrarianGear
departments:
- Civilian
icon: "Librarian"
supervisors: job-supervisors-hop
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: Mime
name: job-name-mime
playTimeTracker: JobMime
startingGear: MimeGear
departments:
- Civilian
icon: "Mime"
supervisors: job-supervisors-hop
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: Musician
name: job-name-musician
playTimeTracker: JobMusician
startingGear: MusicianGear
departments:
- Civilian
icon: "Musician"
supervisors: job-supervisors-hire
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: ServiceWorker
name: job-name-serviceworker
playTimeTracker: JobServiceWorker
startingGear: ServiceWorkerGear
departments:
- Civilian
icon: "ServiceWorker"
supervisors: job-supervisors-service
canBeAntag: false

View File

@@ -1,10 +1,21 @@
- type: job
id: Captain
name: job-name-captain
playTimeTracker: JobCaptain
requirements:
- !type:DepartmentTimeRequirement
department: Engineering
time: 18000
- !type:DepartmentTimeRequirement
department: Medical
time: 18000
- !type:DepartmentTimeRequirement
department: Security
time: 18000
- !type:OverallPlaytimeRequirement
time: 108000
weight: 20
startingGear: CaptainGear
departments:
- Command
icon: "Captain"
requireAdminNotify: true
joinNotifyCrew: true

View File

@@ -1,10 +1,9 @@
- type: job
id: CentralCommandOfficial
name: job-name-centcomoff
playTimeTracker: JobCentralCommandOfficial
setPreference: false
startingGear: CentcomGear
departments:
- Command
icon: "Nanotrasen"
supervisors: job-supervisors-hos
canBeAntag: false

View File

@@ -1,11 +1,21 @@
- type: job
id: HeadOfPersonnel
name: job-name-hop
playTimeTracker: JobHeadOfPersonnel
requirements:
- !type:DepartmentTimeRequirement
department: Engineering
time: 3600
- !type:DepartmentTimeRequirement
department: Medical
time: 3600
- !type:DepartmentTimeRequirement
department: Security
time: 3600
- !type:OverallPlaytimeRequirement
time: 54000
weight: 20
startingGear: HoPGear
departments:
- Command
- Civilian
icon: "HeadOfPersonnel"
requireAdminNotify: true
supervisors: job-supervisors-captain

View File

@@ -1,9 +1,12 @@
- type: job
id: AtmosphericTechnician
name: job-name-atmostech
playTimeTracker: JobAtmosphericTechnician
requirements:
- !type:DepartmentTimeRequirement
department: Engineering
time: 3600
startingGear: AtmosphericTechnicianGear
departments:
- Engineering
icon: "AtmosphericTechnician"
supervisors: job-supervisors-ce
canBeAntag: false

View File

@@ -1,11 +1,16 @@
- type: job
id: ChiefEngineer
name: job-name-ce
playTimeTracker: JobChiefEngineer
requirements:
- !type:RoleTimeRequirement
role: JobAtmosphericTechnician
time: 3600
- !type:DepartmentTimeRequirement
department: Engineering
time: 18000
weight: 10
startingGear: ChiefEngineerGear
departments:
- Command
- Engineering
icon: "ChiefEngineer"
requireAdminNotify: true
supervisors: job-supervisors-captain

View File

@@ -1,9 +1,12 @@
- type: job
id: StationEngineer
name: job-name-engineer
playTimeTracker: JobStationEngineer
requirements:
- !type:DepartmentTimeRequirement
department: Engineering
time: 1800
startingGear: StationEngineerGear
departments:
- Engineering
icon: "StationEngineer"
supervisors: job-supervisors-ce
access:

View File

@@ -1,10 +1,8 @@
- type: job
id: TechnicalAssistant
name: job-name-assistant
playTimeTracker: JobTechnicalAssistant
startingGear: TechnicalAssistantGear
departments:
- Civilian
- Engineering
icon: "TechnicalAssistant"
supervisors: job-supervisors-engineering
canBeAntag: false

View File

@@ -2,10 +2,9 @@
- type: job
id: ERTLeader
name: job-name-ertleader
playTimeTracker: JobERTLeader
setPreference: false
startingGear: ERTLeaderGearEVA
departments:
- Command
icon: "Nanotrasen"
supervisors: job-supervisors-centcom
canBeAntag: false
@@ -49,10 +48,9 @@
- type: job
id: ERTEngineer
name: job-name-ertengineer
playTimeTracker: JobERTEngineer
setPreference: false
startingGear: ERTEngineerGearEVA
departments:
- Command
icon: "Nanotrasen"
supervisors: job-supervisors-centcom
canBeAntag: false
@@ -95,10 +93,9 @@
- type: job
id: ERTSecurity
name: job-name-ertsecurity
playTimeTracker: JobERTSecurity
setPreference: false
startingGear: ERTEngineerGearEVA
departments:
- Command
icon: "Nanotrasen"
supervisors: job-supervisors-centcom
canBeAntag: false
@@ -141,10 +138,9 @@
- type: job
id: ERTMedical
name: job-name-ertmedic
playTimeTracker: JobERTMedical
setPreference: false
startingGear: ERTMedicalGearEVA
departments:
- Command
icon: "Nanotrasen"
supervisors: job-supervisors-centcom
canBeAntag: false
@@ -188,10 +184,9 @@
- type: job
id: ERTJanitor
name: job-name-ertjanitor
playTimeTracker: JobERTJanitor
setPreference: false
startingGear: ERTJanitorGearEVA
departments:
- Command
icon: "Nanotrasen"
supervisors: job-supervisors-centcom
canBeAntag: false

View File

@@ -1,9 +1,12 @@
- type: job
id: Chemist
name: job-name-chemist
playTimeTracker: JobChemist
requirements:
- !type:DepartmentTimeRequirement
department: Medical
time: 1800
startingGear: ChemistGear
departments:
- Medical
icon: "Chemist"
supervisors: job-supervisors-cmo
access:

View File

@@ -3,11 +3,13 @@
- type: job
id: ChiefMedicalOfficer
name: job-name-cmo
playTimeTracker: JobChiefMedicalOfficer
requirements:
- !type:DepartmentTimeRequirement
department: Medical
time: 18000
weight: 10
startingGear: CMOGear
departments:
- Command
- Medical
icon: "ChiefMedicalOfficer"
requireAdminNotify: true
supervisors: job-supervisors-captain

View File

@@ -1,9 +1,12 @@
- type: job
id: MedicalDoctor
name: job-name-doctor
playTimeTracker: JobMedicalDoctor
requirements:
- !type:DepartmentTimeRequirement
department: Medical
time: 1800
startingGear: DoctorGear
departments:
- Medical
icon: "MedicalDoctor"
supervisors: job-supervisors-cmo
access:

View File

@@ -1,10 +1,8 @@
- type: job
id: MedicalIntern
name: job-name-intern
playTimeTracker: JobMedicalIntern
startingGear: MedicalInternGear
departments:
- Civilian
- Medical
icon: "MedicalIntern"
supervisors: job-supervisors-medicine
canBeAntag: false

View File

@@ -1,11 +1,13 @@
- type: job
id: ResearchDirector
name: job-name-rd
playTimeTracker: JobResearchDirector
requirements:
- !type:DepartmentTimeRequirement
department: Science
time: 18000
weight: 10
startingGear: ResearchDirectorGear
departments:
- Command
- Science
icon: "ResearchDirector"
requireAdminNotify: true
supervisors: job-supervisors-captain

View File

@@ -1,9 +1,8 @@
- type: job
id: Scientist
name: job-name-scientist
playTimeTracker: JobScientist
startingGear: ScientistGear
departments:
- Science
icon: "Scientist"
supervisors: job-supervisors-rd
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: Detective
name: job-name-detective
playTimeTracker: JobDetective
startingGear: DetectiveGear
departments:
- Security
icon: "Detective"
supervisors: job-supervisors-hos
canBeAntag: false

View File

@@ -1,11 +1,16 @@
- type: job
id: HeadOfSecurity
name: job-name-hos
playTimeTracker: JobHeadOfSecurity
requirements:
- !type:RoleTimeRequirement
role: JobWarden
time: 3600
- !type:DepartmentTimeRequirement
department: Security
time: 18000
weight: 10
startingGear: HoSGear
departments:
- Command
- Security
icon: "HeadOfSecurity"
requireAdminNotify: true
supervisors: job-supervisors-captain

View File

@@ -1,10 +1,8 @@
- type: job
id: SecurityCadet
name: job-name-cadet
playTimeTracker: JobSecurityCadet
startingGear: SecurityCadetGear
departments:
- Civilian
- Security
icon: "SecurityCadet"
supervisors: job-supervisors-security
canBeAntag: false

View File

@@ -1,9 +1,12 @@
- type: job
id: SecurityOfficer
name: job-name-security
playTimeTracker: JobSecurityOfficer
requirements:
- !type:DepartmentTimeRequirement
department: Security
time: 1800
startingGear: SecurityOfficerGear
departments:
- Security
icon: "SecurityOfficer"
supervisors: job-supervisors-hos
canBeAntag: false

View File

@@ -1,9 +1,12 @@
- type: job
id: Warden
name: job-name-warden
playTimeTracker: JobWarden
requirements:
- !type:DepartmentTimeRequirement
department: Security
time: 10800
startingGear: WardenGear
departments:
- Security
icon: "Warden"
supervisors: job-supervisors-hos
canBeAntag: false

View File

@@ -1,10 +1,9 @@
- type: job
id: Boxer
name: "boxer"
playTimeTracker: JobBoxer
startingGear: BoxerGear
setPreference: false
departments:
- Civilian
icon: "Boxer"
supervisors: "the head of personnel"
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: Psychologist
name: job-name-psychologist
playTimeTracker: JobPsychologist
startingGear: PsychologistGear
departments:
- Medical
icon: "Psychologist"
supervisors: job-supervisors-cmo
access:

View File

@@ -1,9 +1,8 @@
- type: job
id: Reporter
name: job-name-reporter
playTimeTracker: JobReporter
startingGear: ReporterGear
departments:
- Civilian
icon: "Reporter"
supervisors: job-supervisors-hop
access:

View File

@@ -0,0 +1,74 @@
- type: department
id: Cargo
roles:
- CargoTechnician
- Quartermaster
- SalvageSpecialist
- type: department
id: Civilian
roles:
- Bartender
- Botanist
- Chaplain
- Chef
- Clown
- HeadOfPersonnel
- Janitor
- Lawyer
- Librarian
- MedicalIntern
- Mime
- Musician
- Passenger
- Reporter
- SecurityCadet
- ServiceWorker
- TechnicalAssistant
- type: department
id: Command
roles:
- Captain
- CentralCommandOfficial
- ChiefEngineer
- ChiefMedicalOfficer
- ERTEngineer
- ERTJanitor
- ERTLeader
- ERTMedical
- ERTSecurity
- HeadOfPersonnel
- HeadOfSecurity
- ResearchDirector
- type: department
id: Engineering
roles:
- AtmosphericTechnician
- ChiefEngineer
- StationEngineer
- TechnicalAssistant
- type: department
id: Medical
roles:
- Chemist
- ChiefMedicalOfficer
- MedicalDoctor
- MedicalIntern
- Psychologist
- type: department
id: Security
roles:
- HeadOfSecurity
- SecurityCadet
- SecurityOfficer
- Warden
- type: department
id: Science
roles:
- ResearchDirector
- Scientist

View File

@@ -0,0 +1,128 @@
# Overall play time, across all roles.
# This tracker must exist, it is used directly by PlayTimeTrackingManager
- type: playTimeTracker
id: Overall
# Jobs
- type: playTimeTracker
id: JobAtmosphericTechnician
- type: playTimeTracker
id: JobBartender
- type: playTimeTracker
id: JobBotanist
- type: playTimeTracker
id: JobCaptain
- type: playTimeTracker
id: JobCargoTechnician
- type: playTimeTracker
id: JobCentralCommandOfficial
- type: playTimeTracker
id: JobChaplain
- type: playTimeTracker
id: JobChef
- type: playTimeTracker
id: JobChemist
- type: playTimeTracker
id: JobChiefEngineer
- type: playTimeTracker
id: JobChiefMedicalOfficer
- type: playTimeTracker
id: JobClown
- type: playTimeTracker
id: JobDetective
- type: playTimeTracker
id: JobERTEngineer
- type: playTimeTracker
id: JobERTJanitor
- type: playTimeTracker
id: JobERTLeader
- type: playTimeTracker
id: JobERTMedical
- type: playTimeTracker
id: JobERTSecurity
- type: playTimeTracker
id: JobHeadOfPersonnel
- type: playTimeTracker
id: JobHeadOfSecurity
- type: playTimeTracker
id: JobJanitor
- type: playTimeTracker
id: JobLawyer
- type: playTimeTracker
id: JobLibrarian
- type: playTimeTracker
id: JobMedicalDoctor
- type: playTimeTracker
id: JobMedicalIntern
- type: playTimeTracker
id: JobMime
- type: playTimeTracker
id: JobMusician
- type: playTimeTracker
id: JobPassenger
- type: playTimeTracker
id: JobPsychologist
- type: playTimeTracker
id: JobQuartermaster
- type: playTimeTracker
id: JobReporter
- type: playTimeTracker
id: JobResearchDirector
- type: playTimeTracker
id: JobSalvageSpecialist
- type: playTimeTracker
id: JobScientist
- type: playTimeTracker
id: JobSecurityCadet
- type: playTimeTracker
id: JobSecurityOfficer
- type: playTimeTracker
id: JobServiceWorker
- type: playTimeTracker
id: JobStationEngineer
- type: playTimeTracker
id: JobTechnicalAssistant
- type: playTimeTracker
id: JobWarden
- type: playTimeTracker
id: JobBoxer