From e852ada6c86574ee84dec5f7e365ac2debff4bb3 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Sun, 7 Aug 2022 08:00:42 +0200 Subject: [PATCH] Play time tracking: Job timers 3: more titles: when the (#9978) Co-authored-by: Veritius Co-authored-by: metalgearsloth --- Content.Client/Entry/EntryPoint.cs | 2 + Content.Client/IoC/ClientContentIoC.cs | 2 + Content.Client/LateJoin/LateJoinGui.cs | 93 +- .../PlayTimeTrackingManager.cs | 88 ++ .../Preferences/UI/CharacterSetupGui.xaml.cs | 2 +- .../UI/HumanoidProfileEditor.xaml.cs | 140 +- .../Tests/Station/StationJobsTest.cs | 11 + .../20220724000132_PlayTime.Designer.cs | 1292 +++++++++++++++++ .../Postgres/20220724000132_PlayTime.cs | 41 + .../PostgresServerDbContextModelSnapshot.cs | 33 +- .../20220724000127_PlayTime.Designer.cs | 1228 ++++++++++++++++ .../Sqlite/20220724000127_PlayTime.cs | 40 + .../SqliteServerDbContextModelSnapshot.cs | 34 +- Content.Server.Database/Model.cs | 19 + .../Commands/PlayTimeCommands.cs | 336 +++++ Content.Server/Afk/AFKSystem.cs | 96 ++ Content.Server/Afk/Events/AFKEvent.cs | 17 + Content.Server/Afk/Events/UnAFKEvent.cs | 17 + Content.Server/Afk/IsAfkCommand.cs | 17 +- Content.Server/Database/ServerDbBase.cs | 67 +- Content.Server/Database/ServerDbManager.cs | 104 +- Content.Server/Database/UserDbDataManager.cs | 70 + Content.Server/Entry/EntryPoint.cs | 14 +- .../GameTicking/GameTicker.Lobby.cs | 9 +- .../GameTicking/GameTicker.Player.cs | 12 +- .../GameTicking/GameTicker.RoundFlow.cs | 7 + .../GameTicking/GameTicker.Spawning.cs | 29 +- Content.Server/GameTicking/GameTicker.cs | 3 + Content.Server/IoC/ServerContentIoC.cs | 3 + Content.Server/Mind/Mind.cs | 4 +- .../PlayTimeTrackingManager.cs | 430 ++++++ .../PlayTimeTrackingSystem.cs | 230 +++ .../Managers/IServerPreferencesManager.cs | 6 +- .../Managers/ServerPreferencesManager.cs | 22 +- Content.Server/Roles/Job.cs | 6 +- Content.Server/Roles/RoleAddedEvent.cs | 2 +- Content.Server/Roles/RoleEvent.cs | 4 +- Content.Server/Roles/RoleRemovedEvent.cs | 2 +- .../Systems/StationJobsSystem.Roundstart.cs | 10 +- .../Station/Systems/StationJobsSystem.cs | 1 + Content.Shared/CCVar/CCVars.cs | 17 + .../Players/PlayTimeTracking/MsgPlayTime.cs | 34 + .../PlayTimeTrackerPrototype.cs | 12 + .../PlayTimeTrackingShared.cs | 9 + Content.Shared/Roles/DepartmentPrototype.cs | 14 + Content.Shared/Roles/IRoleTimer.cs | 6 + Content.Shared/Roles/JobPrototype.cs | 12 +- Content.Shared/Roles/JobRequirements.cs | 145 ++ Resources/Locale/en-US/job/department.ftl | 7 + Resources/Locale/en-US/job/job-names.ftl | 7 +- Resources/Locale/en-US/job/role-timers.ftl | 5 + .../players/play-time/play-time-commands.ftl | 56 + .../Roles/Jobs/Cargo/cargo_technician.yml | 5 +- .../Roles/Jobs/Cargo/quartermaster.yml | 10 +- .../Roles/Jobs/Cargo/salvage_specialist.yml | 7 +- .../Roles/Jobs/Civilian/assistant.yml | 3 +- .../Roles/Jobs/Civilian/bartender.yml | 7 +- .../Roles/Jobs/Civilian/botanist.yml | 3 +- .../Roles/Jobs/Civilian/chaplain.yml | 3 +- .../Prototypes/Roles/Jobs/Civilian/chef.yml | 7 +- .../Prototypes/Roles/Jobs/Civilian/clown.yml | 3 +- .../Roles/Jobs/Civilian/janitor.yml | 3 +- .../Prototypes/Roles/Jobs/Civilian/lawyer.yml | 3 +- .../Roles/Jobs/Civilian/librarian.yml | 3 +- .../Prototypes/Roles/Jobs/Civilian/mime.yml | 3 +- .../Roles/Jobs/Civilian/musician.yml | 3 +- .../Roles/Jobs/Civilian/service_worker.yml | 3 +- .../Prototypes/Roles/Jobs/Command/captain.yml | 15 +- .../Roles/Jobs/Command/centcom_official.yml | 3 +- .../Roles/Jobs/Command/head_of_personnel.yml | 16 +- .../Engineering/atmospheric_technician.yml | 7 +- .../Roles/Jobs/Engineering/chief_engineer.yml | 11 +- .../Jobs/Engineering/station_engineer.yml | 7 +- .../Jobs/Engineering/technical_assistant.yml | 4 +- .../Roles/Jobs/Fun/emergencyresponseteam.yml | 15 +- .../Prototypes/Roles/Jobs/Medical/chemist.yml | 7 +- .../Jobs/Medical/chief_medical_officer.yml | 8 +- .../Roles/Jobs/Medical/medical_doctor.yml | 7 +- .../Roles/Jobs/Medical/medical_intern.yml | 4 +- .../Roles/Jobs/Science/research_director.yml | 8 +- .../Roles/Jobs/Science/scientist.yml | 3 +- .../Roles/Jobs/Security/detective.yml | 3 +- .../Roles/Jobs/Security/head_of_security.yml | 11 +- .../Roles/Jobs/Security/security_cadet.yml | 4 +- .../Roles/Jobs/Security/security_officer.yml | 7 +- .../Prototypes/Roles/Jobs/Security/warden.yml | 7 +- .../Prototypes/Roles/Jobs/Wildcards/boxer.yml | 3 +- .../Roles/Jobs/Wildcards/psychologist.yml | 3 +- .../Roles/Jobs/Wildcards/reporter.yml | 3 +- .../Prototypes/Roles/Jobs/departments.yml | 74 + .../Prototypes/Roles/play_time_trackers.yml | 128 ++ 91 files changed, 5044 insertions(+), 247 deletions(-) create mode 100644 Content.Client/Players/PlayTimeTracking/PlayTimeTrackingManager.cs create mode 100644 Content.Server.Database/Migrations/Postgres/20220724000132_PlayTime.Designer.cs create mode 100644 Content.Server.Database/Migrations/Postgres/20220724000132_PlayTime.cs create mode 100644 Content.Server.Database/Migrations/Sqlite/20220724000127_PlayTime.Designer.cs create mode 100644 Content.Server.Database/Migrations/Sqlite/20220724000127_PlayTime.cs create mode 100644 Content.Server/Administration/Commands/PlayTimeCommands.cs create mode 100644 Content.Server/Afk/AFKSystem.cs create mode 100644 Content.Server/Afk/Events/AFKEvent.cs create mode 100644 Content.Server/Afk/Events/UnAFKEvent.cs create mode 100644 Content.Server/Database/UserDbDataManager.cs create mode 100644 Content.Server/Players/PlayTimeTracking/PlayTimeTrackingManager.cs create mode 100644 Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs create mode 100644 Content.Shared/Players/PlayTimeTracking/MsgPlayTime.cs create mode 100644 Content.Shared/Players/PlayTimeTracking/PlayTimeTrackerPrototype.cs create mode 100644 Content.Shared/Players/PlayTimeTracking/PlayTimeTrackingShared.cs create mode 100644 Content.Shared/Roles/DepartmentPrototype.cs create mode 100644 Content.Shared/Roles/IRoleTimer.cs create mode 100644 Content.Shared/Roles/JobRequirements.cs create mode 100644 Resources/Locale/en-US/job/department.ftl create mode 100644 Resources/Locale/en-US/job/role-timers.ftl create mode 100644 Resources/Locale/en-US/players/play-time/play-time-commands.ftl create mode 100644 Resources/Prototypes/Roles/Jobs/departments.yml create mode 100644 Resources/Prototypes/Roles/play_time_trackers.yml diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index 79a4f34aee..17f2f66bb5 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -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().Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); IoCManager.InjectDependencies(this); diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs index db2db9b101..c66a5603e1 100644 --- a/Content.Client/IoC/ClientContentIoC.cs +++ b/Content.Client/IoC/ClientContentIoC.cs @@ -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(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Client/LateJoin/LateJoinGui.cs b/Content.Client/LateJoin/LateJoinGui.cs index 030eabc33e..efbdaa542e 100644 --- a/Content.Client/LateJoin/LateJoinGui.cs +++ b/Content.Client/LateJoin/LateJoinGui.cs @@ -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()) { - var prototype = _prototypeManager.Index(job.Key); - foreach (var department in prototype.Departments) + var departmentName = Loc.GetString($"department-{department.ID}"); + _jobCategories[id] = new Dictionary(); + _jobButtons[id] = new Dictionary(); + var stationAvailable = gameTicker.JobsAvailable[id]; + + var category = new BoxContainer { - if (!_jobCategories.TryGetValue(id, out var _)) - _jobCategories[id] = new Dictionary(); - if (!_jobButtons.TryGetValue(id, out var _)) - _jobButtons[id] = new Dictionary(); - 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(); + + foreach (var jobId in department.Roles) + { + if (!stationAvailable.ContainsKey(jobId)) continue; + jobsAvailable.Add(_prototypeManager.Index(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; } diff --git a/Content.Client/Players/PlayTimeTracking/PlayTimeTrackingManager.cs b/Content.Client/Players/PlayTimeTracking/PlayTimeTrackingManager.cs new file mode 100644 index 0000000000..3f13c2983b --- /dev/null +++ b/Content.Client/Players/PlayTimeTracking/PlayTimeTrackingManager.cs @@ -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 _roles = new(); + + public void Initialize() + { + _net.RegisterNetMessage(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; + } +} diff --git a/Content.Client/Preferences/UI/CharacterSetupGui.xaml.cs b/Content.Client/Preferences/UI/CharacterSetupGui.xaml.cs index 24022e192d..fee09b371d 100644 --- a/Content.Client/Preferences/UI/CharacterSetupGui.xaml.cs +++ b/Content.Client/Preferences/UI/CharacterSetupGui.xaml.cs @@ -235,7 +235,7 @@ namespace Content.Client.Preferences.UI if (!disposing) return; - IoCManager.Resolve().DeleteEntity((EntityUid) _previewDummy); + IoCManager.Resolve().DeleteEntity(_previewDummy); _previewDummy = default; } } diff --git a/Content.Client/Preferences/UI/HumanoidProfileEditor.xaml.cs b/Content.Client/Preferences/UI/HumanoidProfileEditor.xaml.cs index 8feabc1611..dab7fbf1be 100644 --- a/Content.Client/Preferences/UI/HumanoidProfileEditor.xaml.cs +++ b/Content.Client/Preferences/UI/HumanoidProfileEditor.xaml.cs @@ -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(); _jobCategories = new Dictionary(); - var spriteSystem = IoCManager.Resolve().GetEntitySystem(); var firstCategory = true; + var playTime = IoCManager.Resolve(); - foreach (var job in prototypeManager.EnumeratePrototypes().OrderBy(j => j.LocalizedName)) + foreach (var department in _prototypeManager.EnumeratePrototypes()) { - 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(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? 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() diff --git a/Content.IntegrationTests/Tests/Station/StationJobsTest.cs b/Content.IntegrationTests/Tests/Station/StationJobsTest.cs index ac869002c2..11b09c1552 100644 --- a/Content.IntegrationTests/Tests/Station/StationJobsTest.cs +++ b/Content.IntegrationTests/Tests/Station/StationJobsTest.cs @@ -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; diff --git a/Content.Server.Database/Migrations/Postgres/20220724000132_PlayTime.Designer.cs b/Content.Server.Database/Migrations/Postgres/20220724000132_PlayTime.Designer.cs new file mode 100644 index 0000000000..9fe51c0af6 --- /dev/null +++ b/Content.Server.Database/Migrations/Postgres/20220724000132_PlayTime.Designer.cs @@ -0,0 +1,1292 @@ +// +using System; +using System.Net; +using System.Text.Json; +using Content.Server.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Content.Server.Database.Migrations.Postgres +{ + [DbContext(typeof(PostgresServerDbContext))] + [Migration("20220724000132_PlayTime")] + partial class PlayTime + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("AdminRankId") + .HasColumnType("integer") + .HasColumnName("admin_rank_id"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("UserId") + .HasName("PK_admin"); + + b.HasIndex("AdminRankId") + .HasDatabaseName("IX_admin_admin_rank_id"); + + b.ToTable("admin", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_flag_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdminId") + .HasColumnType("uuid") + .HasColumnName("admin_id"); + + b.Property("Flag") + .IsRequired() + .HasColumnType("text") + .HasColumnName("flag"); + + b.Property("Negative") + .HasColumnType("boolean") + .HasColumnName("negative"); + + b.HasKey("Id") + .HasName("PK_admin_flag"); + + b.HasIndex("AdminId") + .HasDatabaseName("IX_admin_flag_admin_id"); + + b.HasIndex("Flag", "AdminId") + .IsUnique(); + + b.ToTable("admin_flag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_log_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasColumnName("date"); + + b.Property("Impact") + .HasColumnType("smallint") + .HasColumnName("impact"); + + b.Property("Json") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("json"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id", "RoundId") + .HasName("PK_admin_log"); + + b.HasIndex("Date"); + + b.HasIndex("Message") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Message"), "GIN"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_log_round_id"); + + b.HasIndex("Type") + .HasDatabaseName("IX_admin_log_type"); + + b.ToTable("admin_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogEntity", b => + { + b.Property("Uid") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("uid"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Uid")); + + b.Property("AdminLogId") + .HasColumnType("integer") + .HasColumnName("admin_log_id"); + + b.Property("AdminLogRoundId") + .HasColumnType("integer") + .HasColumnName("admin_log_round_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Uid") + .HasName("PK_admin_log_entity"); + + b.HasIndex("AdminLogId", "AdminLogRoundId") + .HasDatabaseName("IX_admin_log_entity_admin_log_id_admin_log_round_id"); + + b.ToTable("admin_log_entity", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b => + { + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("LogId") + .HasColumnType("integer") + .HasColumnName("log_id"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.HasKey("PlayerUserId", "LogId", "RoundId") + .HasName("PK_admin_log_player"); + + b.HasIndex("LogId", "RoundId"); + + b.ToTable("admin_log_player", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_notes_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("LastEditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("uuid") + .HasColumnName("last_edited_by_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("message"); + + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("ShownToPlayer") + .HasColumnType("boolean") + .HasColumnName("shown_to_player"); + + b.HasKey("Id") + .HasName("PK_admin_notes"); + + b.HasIndex("CreatedById"); + + b.HasIndex("DeletedById"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_notes_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_notes_round_id"); + + b.ToTable("admin_notes", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRank", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_rank_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("PK_admin_rank"); + + b.ToTable("admin_rank", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_rank_flag_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdminRankId") + .HasColumnType("integer") + .HasColumnName("admin_rank_id"); + + b.Property("Flag") + .IsRequired() + .HasColumnType("text") + .HasColumnName("flag"); + + b.HasKey("Id") + .HasName("PK_admin_rank_flag"); + + b.HasIndex("AdminRankId") + .HasDatabaseName("IX_admin_rank_flag_admin_rank_id"); + + b.HasIndex("Flag", "AdminRankId") + .IsUnique(); + + b.ToTable("admin_rank_flag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Antag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("antag_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AntagName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("antag_name"); + + b.Property("ProfileId") + .HasColumnType("integer") + .HasColumnName("profile_id"); + + b.HasKey("Id") + .HasName("PK_antag"); + + b.HasIndex("ProfileId", "AntagName") + .IsUnique(); + + b.ToTable("antag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AssignedUserId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("assigned_user_id_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("PK_assigned_user_id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("assigned_user_id", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("connection_log_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("address"); + + b.Property("Denied") + .HasColumnType("smallint") + .HasColumnName("denied"); + + b.Property("HWId") + .HasColumnType("bytea") + .HasColumnName("hwid"); + + b.Property("Time") + .HasColumnType("timestamp with time zone") + .HasColumnName("time"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("PK_connection_log"); + + b.HasIndex("UserId"); + + b.ToTable("connection_log", (string)null); + + b.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); + }); + + modelBuilder.Entity("Content.Server.Database.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("job_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("ProfileId") + .HasColumnType("integer") + .HasColumnName("profile_id"); + + b.HasKey("Id") + .HasName("PK_job"); + + b.HasIndex("ProfileId") + .HasDatabaseName("IX_job_profile_id"); + + b.HasIndex("ProfileId", "JobName") + .IsUnique(); + + b.HasIndex(new[] { "ProfileId" }, "IX_job_one_high_priority") + .IsUnique() + .HasFilter("priority = 3"); + + b.ToTable("job", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("player_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FirstSeenTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_time"); + + b.Property("LastReadRules") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_read_rules"); + + b.Property("LastSeenAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("last_seen_address"); + + b.Property("LastSeenHWId") + .HasColumnType("bytea") + .HasColumnName("last_seen_hwid"); + + b.Property("LastSeenTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_time"); + + b.Property("LastSeenUserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_seen_user_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_player"); + + b.HasAlternateKey("UserId") + .HasName("ak_player_user_id"); + + b.HasIndex("LastSeenUserName"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("player", (string)null); + + b.HasCheckConstraint("LastSeenAddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= last_seen_address"); + }); + + modelBuilder.Entity("Content.Server.Database.PlayTime", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("play_time_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PlayerId") + .HasColumnType("uuid") + .HasColumnName("player_id"); + + b.Property("TimeSpent") + .HasColumnType("interval") + .HasColumnName("time_spent"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("preference_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdminOOCColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("admin_ooc_color"); + + b.Property("SelectedCharacterSlot") + .HasColumnType("integer") + .HasColumnName("selected_character_slot"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_preference"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("preference", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("profile_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Age") + .HasColumnType("integer") + .HasColumnName("age"); + + b.Property("Backpack") + .IsRequired() + .HasColumnType("text") + .HasColumnName("backpack"); + + b.Property("CharacterName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("char_name"); + + b.Property("Clothing") + .IsRequired() + .HasColumnType("text") + .HasColumnName("clothing"); + + b.Property("EyeColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("eye_color"); + + b.Property("FacialHairColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("facial_hair_color"); + + b.Property("FacialHairName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("facial_hair_name"); + + b.Property("FlavorText") + .IsRequired() + .HasColumnType("text") + .HasColumnName("flavor_text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text") + .HasColumnName("gender"); + + b.Property("HairColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hair_color"); + + b.Property("HairName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hair_name"); + + b.Property("Markings") + .HasColumnType("jsonb") + .HasColumnName("markings"); + + b.Property("PreferenceId") + .HasColumnType("integer") + .HasColumnName("preference_id"); + + b.Property("PreferenceUnavailable") + .HasColumnType("integer") + .HasColumnName("pref_unavailable"); + + b.Property("Sex") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sex"); + + b.Property("SkinColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("skin_color"); + + b.Property("Slot") + .HasColumnType("integer") + .HasColumnName("slot"); + + b.Property("Species") + .IsRequired() + .HasColumnType("text") + .HasColumnName("species"); + + b.HasKey("Id") + .HasName("PK_profile"); + + b.HasIndex("PreferenceId") + .HasDatabaseName("IX_profile_preference_id"); + + b.HasIndex("Slot", "PreferenceId") + .IsUnique(); + + b.ToTable("profile", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("round_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ServerId") + .HasColumnType("integer") + .HasColumnName("server_id"); + + b.HasKey("Id") + .HasName("PK_round"); + + b.HasIndex("ServerId") + .HasDatabaseName("IX_round_server_id"); + + b.ToTable("round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Server", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("server_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("PK_server"); + + b.ToTable("server", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("server_ban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property?>("Address") + .HasColumnType("inet") + .HasColumnName("address"); + + b.Property("BanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("ban_time"); + + b.Property("BanningAdmin") + .HasColumnType("uuid") + .HasColumnName("banning_admin"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("HWId") + .HasColumnType("bytea") + .HasColumnName("hwid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_server_ban"); + + b.HasIndex("Address"); + + b.HasIndex("UserId"); + + b.ToTable("server_ban", (string)null); + + b.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); + + b.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR user_id IS NOT NULL OR hwid IS NOT NULL"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("server_ban_hit_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("ConnectionId") + .HasColumnType("integer") + .HasColumnName("connection_id"); + + b.HasKey("Id") + .HasName("PK_server_ban_hit"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_server_ban_hit_ban_id"); + + b.HasIndex("ConnectionId") + .HasDatabaseName("IX_server_ban_hit_connection_id"); + + b.ToTable("server_ban_hit", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("server_role_ban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property?>("Address") + .HasColumnType("inet") + .HasColumnName("address"); + + b.Property("BanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("ban_time"); + + b.Property("BanningAdmin") + .HasColumnType("uuid") + .HasColumnName("banning_admin"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("HWId") + .HasColumnType("bytea") + .HasColumnName("hwid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_server_role_ban"); + + b.HasIndex("Address"); + + b.HasIndex("UserId"); + + b.ToTable("server_role_ban", (string)null); + + b.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); + + b.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR user_id IS NOT NULL OR hwid IS NOT NULL"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("role_unban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("UnbanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("unban_time"); + + b.Property("UnbanningAdmin") + .HasColumnType("uuid") + .HasColumnName("unbanning_admin"); + + b.HasKey("Id") + .HasName("PK_server_role_unban"); + + b.HasIndex("BanId") + .IsUnique(); + + b.ToTable("server_role_unban", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ServerUnban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("unban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("UnbanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("unban_time"); + + b.Property("UnbanningAdmin") + .HasColumnType("uuid") + .HasColumnName("unbanning_admin"); + + b.HasKey("Id") + .HasName("PK_server_unban"); + + b.HasIndex("BanId") + .IsUnique(); + + b.ToTable("server_unban", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.UploadedResourceLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("uploaded_resource_log_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("data"); + + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasColumnName("date"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_uploaded_resource_log"); + + b.ToTable("uploaded_resource_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Whitelist", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("UserId") + .HasName("PK_whitelist"); + + b.ToTable("whitelist", (string)null); + }); + + modelBuilder.Entity("PlayerRound", b => + { + b.Property("PlayersId") + .HasColumnType("integer") + .HasColumnName("players_id"); + + b.Property("RoundsId") + .HasColumnType("integer") + .HasColumnName("rounds_id"); + + b.HasKey("PlayersId", "RoundsId") + .HasName("PK_player_round"); + + b.HasIndex("RoundsId") + .HasDatabaseName("IX_player_round_rounds_id"); + + b.ToTable("player_round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.HasOne("Content.Server.Database.AdminRank", "AdminRank") + .WithMany("Admins") + .HasForeignKey("AdminRankId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_admin_rank_admin_rank_id"); + + b.Navigation("AdminRank"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminFlag", b => + { + b.HasOne("Content.Server.Database.Admin", "Admin") + .WithMany("Flags") + .HasForeignKey("AdminId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_flag_admin_admin_id"); + + b.Navigation("Admin"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany("AdminLogs") + .HasForeignKey("RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_round_round_id"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogEntity", b => + { + b.HasOne("Content.Server.Database.AdminLog", null) + .WithMany("Entities") + .HasForeignKey("AdminLogId", "AdminLogRoundId") + .HasConstraintName("FK_admin_log_entity_admin_log_admin_log_id_admin_log_round_id"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b => + { + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminLogs") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_player_player_player_user_id"); + + b.HasOne("Content.Server.Database.AdminLog", "Log") + .WithMany("Players") + .HasForeignKey("LogId", "RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_player_admin_log_log_id_round_id"); + + b.Navigation("Log"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminNote", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminNotesCreated") + .HasForeignKey("CreatedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_notes_player_created_by_id"); + + b.HasOne("Content.Server.Database.Player", "DeletedBy") + .WithMany("AdminNotesDeleted") + .HasForeignKey("DeletedById") + .HasPrincipalKey("UserId") + .HasConstraintName("FK_admin_notes_player_deleted_by_id"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminNotesLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_notes_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminNotesReceived") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_notes_player_player_user_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_admin_notes_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Player"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b => + { + b.HasOne("Content.Server.Database.AdminRank", "Rank") + .WithMany("Flags") + .HasForeignKey("AdminRankId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_rank_flag_admin_rank_admin_rank_id"); + + b.Navigation("Rank"); + }); + + modelBuilder.Entity("Content.Server.Database.Antag", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Antags") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_antag_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.Job", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Jobs") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_job_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.HasOne("Content.Server.Database.Preference", "Preference") + .WithMany("Profiles") + .HasForeignKey("PreferenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_preference_preference_id"); + + b.Navigation("Preference"); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.HasOne("Content.Server.Database.Server", "Server") + .WithMany("Rounds") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_round_server_server_id"); + + b.Navigation("Server"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => + { + b.HasOne("Content.Server.Database.ServerBan", "Ban") + .WithMany("BanHits") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_ban_hit_server_ban_ban_id"); + + b.HasOne("Content.Server.Database.ConnectionLog", "Connection") + .WithMany("BanHits") + .HasForeignKey("ConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_ban_hit_connection_log_connection_id"); + + b.Navigation("Ban"); + + b.Navigation("Connection"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b => + { + b.HasOne("Content.Server.Database.ServerRoleBan", "Ban") + .WithOne("Unban") + .HasForeignKey("Content.Server.Database.ServerRoleUnban", "BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_role_unban_server_role_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerUnban", b => + { + b.HasOne("Content.Server.Database.ServerBan", "Ban") + .WithOne("Unban") + .HasForeignKey("Content.Server.Database.ServerUnban", "BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_unban_server_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("PlayerRound", b => + { + b.HasOne("Content.Server.Database.Player", null) + .WithMany() + .HasForeignKey("PlayersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_player_round_player_players_id"); + + b.HasOne("Content.Server.Database.Round", null) + .WithMany() + .HasForeignKey("RoundsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_player_round_round_rounds_id"); + }); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.Navigation("Flags"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.Navigation("Entities"); + + b.Navigation("Players"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRank", b => + { + b.Navigation("Admins"); + + b.Navigation("Flags"); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.Navigation("BanHits"); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.Navigation("AdminLogs"); + + b.Navigation("AdminNotesCreated"); + + b.Navigation("AdminNotesDeleted"); + + b.Navigation("AdminNotesLastEdited"); + + b.Navigation("AdminNotesReceived"); + }); + + modelBuilder.Entity("Content.Server.Database.Preference", b => + { + b.Navigation("Profiles"); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.Navigation("Antags"); + + b.Navigation("Jobs"); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.Navigation("AdminLogs"); + }); + + modelBuilder.Entity("Content.Server.Database.Server", b => + { + b.Navigation("Rounds"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBan", b => + { + b.Navigation("BanHits"); + + b.Navigation("Unban"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => + { + b.Navigation("Unban"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Content.Server.Database/Migrations/Postgres/20220724000132_PlayTime.cs b/Content.Server.Database/Migrations/Postgres/20220724000132_PlayTime.cs new file mode 100644 index 0000000000..811bee45cc --- /dev/null +++ b/Content.Server.Database/Migrations/Postgres/20220724000132_PlayTime.cs @@ -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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + player_id = table.Column(type: "uuid", nullable: false), + tracker = table.Column(type: "text", nullable: false), + time_spent = table.Column(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"); + } + } +} diff --git a/Content.Server.Database/Migrations/Postgres/PostgresServerDbContextModelSnapshot.cs b/Content.Server.Database/Migrations/Postgres/PostgresServerDbContextModelSnapshot.cs index 7facbc0de1..9a4e65be9d 100644 --- a/Content.Server.Database/Migrations/Postgres/PostgresServerDbContextModelSnapshot.cs +++ b/Content.Server.Database/Migrations/Postgres/PostgresServerDbContextModelSnapshot.cs @@ -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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("play_time_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PlayerId") + .HasColumnType("uuid") + .HasColumnName("player_id"); + + b.Property("TimeSpent") + .HasColumnType("interval") + .HasColumnName("time_spent"); + + b.Property("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("Id") diff --git a/Content.Server.Database/Migrations/Sqlite/20220724000127_PlayTime.Designer.cs b/Content.Server.Database/Migrations/Sqlite/20220724000127_PlayTime.Designer.cs new file mode 100644 index 0000000000..0278a01762 --- /dev/null +++ b/Content.Server.Database/Migrations/Sqlite/20220724000127_PlayTime.Designer.cs @@ -0,0 +1,1228 @@ +// +using System; +using Content.Server.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Content.Server.Database.Migrations.Sqlite +{ + [DbContext(typeof(SqliteServerDbContext))] + [Migration("20220724000127_PlayTime")] + partial class PlayTime + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.5"); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("AdminRankId") + .HasColumnType("INTEGER") + .HasColumnName("admin_rank_id"); + + b.Property("Title") + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("UserId") + .HasName("PK_admin"); + + b.HasIndex("AdminRankId") + .HasDatabaseName("IX_admin_admin_rank_id"); + + b.ToTable("admin", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("admin_flag_id"); + + b.Property("AdminId") + .HasColumnType("TEXT") + .HasColumnName("admin_id"); + + b.Property("Flag") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("flag"); + + b.Property("Negative") + .HasColumnType("INTEGER") + .HasColumnName("negative"); + + b.HasKey("Id") + .HasName("PK_admin_flag"); + + b.HasIndex("AdminId") + .HasDatabaseName("IX_admin_flag_admin_id"); + + b.HasIndex("Flag", "AdminId") + .IsUnique(); + + b.ToTable("admin_flag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("admin_log_id"); + + b.Property("RoundId") + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("Impact") + .HasColumnType("INTEGER") + .HasColumnName("impact"); + + b.Property("Json") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("json"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("Type") + .HasColumnType("INTEGER") + .HasColumnName("type"); + + b.HasKey("Id", "RoundId") + .HasName("PK_admin_log"); + + b.HasIndex("Date"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_log_round_id"); + + b.HasIndex("Type") + .HasDatabaseName("IX_admin_log_type"); + + b.ToTable("admin_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogEntity", b => + { + b.Property("Uid") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("uid"); + + b.Property("AdminLogId") + .HasColumnType("INTEGER") + .HasColumnName("admin_log_id"); + + b.Property("AdminLogRoundId") + .HasColumnType("INTEGER") + .HasColumnName("admin_log_round_id"); + + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Uid") + .HasName("PK_admin_log_entity"); + + b.HasIndex("AdminLogId", "AdminLogRoundId") + .HasDatabaseName("IX_admin_log_entity_admin_log_id_admin_log_round_id"); + + b.ToTable("admin_log_entity", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b => + { + b.Property("PlayerUserId") + .HasColumnType("TEXT") + .HasColumnName("player_user_id"); + + b.Property("LogId") + .HasColumnType("INTEGER") + .HasColumnName("log_id"); + + b.Property("RoundId") + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.HasKey("PlayerUserId", "LogId", "RoundId") + .HasName("PK_admin_log_player"); + + b.HasIndex("LogId", "RoundId"); + + b.ToTable("admin_log_player", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("admin_notes_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("TEXT") + .HasColumnName("created_by_id"); + + b.Property("Deleted") + .HasColumnType("INTEGER") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("TEXT") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("TEXT") + .HasColumnName("deleted_by_id"); + + b.Property("LastEditedAt") + .HasColumnType("TEXT") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("TEXT") + .HasColumnName("last_edited_by_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("PlayerUserId") + .HasColumnType("TEXT") + .HasColumnName("player_user_id"); + + b.Property("RoundId") + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.Property("ShownToPlayer") + .HasColumnType("INTEGER") + .HasColumnName("shown_to_player"); + + b.HasKey("Id") + .HasName("PK_admin_notes"); + + b.HasIndex("CreatedById"); + + b.HasIndex("DeletedById"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_notes_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_notes_round_id"); + + b.ToTable("admin_notes", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRank", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("admin_rank_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("PK_admin_rank"); + + b.ToTable("admin_rank", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("admin_rank_flag_id"); + + b.Property("AdminRankId") + .HasColumnType("INTEGER") + .HasColumnName("admin_rank_id"); + + b.Property("Flag") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("flag"); + + b.HasKey("Id") + .HasName("PK_admin_rank_flag"); + + b.HasIndex("AdminRankId") + .HasDatabaseName("IX_admin_rank_flag_admin_rank_id"); + + b.HasIndex("Flag", "AdminRankId") + .IsUnique(); + + b.ToTable("admin_rank_flag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Antag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("antag_id"); + + b.Property("AntagName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("antag_name"); + + b.Property("ProfileId") + .HasColumnType("INTEGER") + .HasColumnName("profile_id"); + + b.HasKey("Id") + .HasName("PK_antag"); + + b.HasIndex("ProfileId", "AntagName") + .IsUnique(); + + b.ToTable("antag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AssignedUserId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("assigned_user_id_id"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("PK_assigned_user_id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("assigned_user_id", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("connection_log_id"); + + b.Property("Address") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("address"); + + b.Property("Denied") + .HasColumnType("INTEGER") + .HasColumnName("denied"); + + b.Property("HWId") + .HasColumnType("BLOB") + .HasColumnName("hwid"); + + b.Property("Time") + .HasColumnType("TEXT") + .HasColumnName("time"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("PK_connection_log"); + + b.HasIndex("UserId"); + + b.ToTable("connection_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("job_id"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ProfileId") + .HasColumnType("INTEGER") + .HasColumnName("profile_id"); + + b.HasKey("Id") + .HasName("PK_job"); + + b.HasIndex("ProfileId") + .HasDatabaseName("IX_job_profile_id"); + + b.HasIndex("ProfileId", "JobName") + .IsUnique(); + + b.HasIndex(new[] { "ProfileId" }, "IX_job_one_high_priority") + .IsUnique() + .HasFilter("priority = 3"); + + b.ToTable("job", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("player_id"); + + b.Property("FirstSeenTime") + .HasColumnType("TEXT") + .HasColumnName("first_seen_time"); + + b.Property("LastReadRules") + .HasColumnType("TEXT") + .HasColumnName("last_read_rules"); + + b.Property("LastSeenAddress") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_seen_address"); + + b.Property("LastSeenHWId") + .HasColumnType("BLOB") + .HasColumnName("last_seen_hwid"); + + b.Property("LastSeenTime") + .HasColumnType("TEXT") + .HasColumnName("last_seen_time"); + + b.Property("LastSeenUserName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_seen_user_name"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_player"); + + b.HasAlternateKey("UserId") + .HasName("ak_player_user_id"); + + b.HasIndex("LastSeenUserName"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("player", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.PlayTime", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("play_time_id"); + + b.Property("PlayerId") + .HasColumnType("TEXT") + .HasColumnName("player_id"); + + b.Property("TimeSpent") + .HasColumnType("TEXT") + .HasColumnName("time_spent"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("preference_id"); + + b.Property("AdminOOCColor") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("admin_ooc_color"); + + b.Property("SelectedCharacterSlot") + .HasColumnType("INTEGER") + .HasColumnName("selected_character_slot"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_preference"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("preference", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("profile_id"); + + b.Property("Age") + .HasColumnType("INTEGER") + .HasColumnName("age"); + + b.Property("Backpack") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("backpack"); + + b.Property("CharacterName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("char_name"); + + b.Property("Clothing") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("clothing"); + + b.Property("EyeColor") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("eye_color"); + + b.Property("FacialHairColor") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("facial_hair_color"); + + b.Property("FacialHairName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("facial_hair_name"); + + b.Property("FlavorText") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("flavor_text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("gender"); + + b.Property("HairColor") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hair_color"); + + b.Property("HairName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hair_name"); + + b.Property("Markings") + .HasColumnType("jsonb") + .HasColumnName("markings"); + + b.Property("PreferenceId") + .HasColumnType("INTEGER") + .HasColumnName("preference_id"); + + b.Property("PreferenceUnavailable") + .HasColumnType("INTEGER") + .HasColumnName("pref_unavailable"); + + b.Property("Sex") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sex"); + + b.Property("SkinColor") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skin_color"); + + b.Property("Slot") + .HasColumnType("INTEGER") + .HasColumnName("slot"); + + b.Property("Species") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("species"); + + b.HasKey("Id") + .HasName("PK_profile"); + + b.HasIndex("PreferenceId") + .HasDatabaseName("IX_profile_preference_id"); + + b.HasIndex("Slot", "PreferenceId") + .IsUnique(); + + b.ToTable("profile", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.Property("ServerId") + .HasColumnType("INTEGER") + .HasColumnName("server_id"); + + b.HasKey("Id") + .HasName("PK_round"); + + b.HasIndex("ServerId") + .HasDatabaseName("IX_round_server_id"); + + b.ToTable("round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Server", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("server_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("PK_server"); + + b.ToTable("server", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("server_ban_id"); + + b.Property("Address") + .HasColumnType("TEXT") + .HasColumnName("address"); + + b.Property("BanTime") + .HasColumnType("TEXT") + .HasColumnName("ban_time"); + + b.Property("BanningAdmin") + .HasColumnType("TEXT") + .HasColumnName("banning_admin"); + + b.Property("ExpirationTime") + .HasColumnType("TEXT") + .HasColumnName("expiration_time"); + + b.Property("HWId") + .HasColumnType("BLOB") + .HasColumnName("hwid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("reason"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_server_ban"); + + b.HasIndex("Address"); + + b.HasIndex("UserId"); + + b.ToTable("server_ban", (string)null); + + b.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR user_id IS NOT NULL OR hwid IS NOT NULL"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("server_ban_hit_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("ConnectionId") + .HasColumnType("INTEGER") + .HasColumnName("connection_id"); + + b.HasKey("Id") + .HasName("PK_server_ban_hit"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_server_ban_hit_ban_id"); + + b.HasIndex("ConnectionId") + .HasDatabaseName("IX_server_ban_hit_connection_id"); + + b.ToTable("server_ban_hit", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("server_role_ban_id"); + + b.Property("Address") + .HasColumnType("TEXT") + .HasColumnName("address"); + + b.Property("BanTime") + .HasColumnType("TEXT") + .HasColumnName("ban_time"); + + b.Property("BanningAdmin") + .HasColumnType("TEXT") + .HasColumnName("banning_admin"); + + b.Property("ExpirationTime") + .HasColumnType("TEXT") + .HasColumnName("expiration_time"); + + b.Property("HWId") + .HasColumnType("BLOB") + .HasColumnName("hwid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("reason"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("role_id"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_server_role_ban"); + + b.HasIndex("Address"); + + b.HasIndex("UserId"); + + b.ToTable("server_role_ban", (string)null); + + b.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR user_id IS NOT NULL OR hwid IS NOT NULL"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("role_unban_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("UnbanTime") + .HasColumnType("TEXT") + .HasColumnName("unban_time"); + + b.Property("UnbanningAdmin") + .HasColumnType("TEXT") + .HasColumnName("unbanning_admin"); + + b.HasKey("Id") + .HasName("PK_server_role_unban"); + + b.HasIndex("BanId") + .IsUnique(); + + b.ToTable("server_role_unban", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ServerUnban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("unban_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("UnbanTime") + .HasColumnType("TEXT") + .HasColumnName("unban_time"); + + b.Property("UnbanningAdmin") + .HasColumnType("TEXT") + .HasColumnName("unbanning_admin"); + + b.HasKey("Id") + .HasName("PK_server_unban"); + + b.HasIndex("BanId") + .IsUnique(); + + b.ToTable("server_unban", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.UploadedResourceLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("uploaded_resource_log_id"); + + b.Property("Data") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("data"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_uploaded_resource_log"); + + b.ToTable("uploaded_resource_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Whitelist", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("UserId") + .HasName("PK_whitelist"); + + b.ToTable("whitelist", (string)null); + }); + + modelBuilder.Entity("PlayerRound", b => + { + b.Property("PlayersId") + .HasColumnType("INTEGER") + .HasColumnName("players_id"); + + b.Property("RoundsId") + .HasColumnType("INTEGER") + .HasColumnName("rounds_id"); + + b.HasKey("PlayersId", "RoundsId") + .HasName("PK_player_round"); + + b.HasIndex("RoundsId") + .HasDatabaseName("IX_player_round_rounds_id"); + + b.ToTable("player_round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.HasOne("Content.Server.Database.AdminRank", "AdminRank") + .WithMany("Admins") + .HasForeignKey("AdminRankId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_admin_rank_admin_rank_id"); + + b.Navigation("AdminRank"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminFlag", b => + { + b.HasOne("Content.Server.Database.Admin", "Admin") + .WithMany("Flags") + .HasForeignKey("AdminId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_flag_admin_admin_id"); + + b.Navigation("Admin"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany("AdminLogs") + .HasForeignKey("RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_round_round_id"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogEntity", b => + { + b.HasOne("Content.Server.Database.AdminLog", null) + .WithMany("Entities") + .HasForeignKey("AdminLogId", "AdminLogRoundId") + .HasConstraintName("FK_admin_log_entity_admin_log_admin_log_id_admin_log_round_id"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b => + { + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminLogs") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_player_player_player_user_id"); + + b.HasOne("Content.Server.Database.AdminLog", "Log") + .WithMany("Players") + .HasForeignKey("LogId", "RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_player_admin_log_log_id_round_id"); + + b.Navigation("Log"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminNote", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminNotesCreated") + .HasForeignKey("CreatedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_notes_player_created_by_id"); + + b.HasOne("Content.Server.Database.Player", "DeletedBy") + .WithMany("AdminNotesDeleted") + .HasForeignKey("DeletedById") + .HasPrincipalKey("UserId") + .HasConstraintName("FK_admin_notes_player_deleted_by_id"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminNotesLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_notes_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminNotesReceived") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_notes_player_player_user_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_admin_notes_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Player"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b => + { + b.HasOne("Content.Server.Database.AdminRank", "Rank") + .WithMany("Flags") + .HasForeignKey("AdminRankId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_rank_flag_admin_rank_admin_rank_id"); + + b.Navigation("Rank"); + }); + + modelBuilder.Entity("Content.Server.Database.Antag", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Antags") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_antag_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.Job", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Jobs") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_job_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.HasOne("Content.Server.Database.Preference", "Preference") + .WithMany("Profiles") + .HasForeignKey("PreferenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_preference_preference_id"); + + b.Navigation("Preference"); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.HasOne("Content.Server.Database.Server", "Server") + .WithMany("Rounds") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_round_server_server_id"); + + b.Navigation("Server"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => + { + b.HasOne("Content.Server.Database.ServerBan", "Ban") + .WithMany("BanHits") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_ban_hit_server_ban_ban_id"); + + b.HasOne("Content.Server.Database.ConnectionLog", "Connection") + .WithMany("BanHits") + .HasForeignKey("ConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_ban_hit_connection_log_connection_id"); + + b.Navigation("Ban"); + + b.Navigation("Connection"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b => + { + b.HasOne("Content.Server.Database.ServerRoleBan", "Ban") + .WithOne("Unban") + .HasForeignKey("Content.Server.Database.ServerRoleUnban", "BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_role_unban_server_role_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerUnban", b => + { + b.HasOne("Content.Server.Database.ServerBan", "Ban") + .WithOne("Unban") + .HasForeignKey("Content.Server.Database.ServerUnban", "BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_unban_server_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("PlayerRound", b => + { + b.HasOne("Content.Server.Database.Player", null) + .WithMany() + .HasForeignKey("PlayersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_player_round_player_players_id"); + + b.HasOne("Content.Server.Database.Round", null) + .WithMany() + .HasForeignKey("RoundsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_player_round_round_rounds_id"); + }); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.Navigation("Flags"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.Navigation("Entities"); + + b.Navigation("Players"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRank", b => + { + b.Navigation("Admins"); + + b.Navigation("Flags"); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.Navigation("BanHits"); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.Navigation("AdminLogs"); + + b.Navigation("AdminNotesCreated"); + + b.Navigation("AdminNotesDeleted"); + + b.Navigation("AdminNotesLastEdited"); + + b.Navigation("AdminNotesReceived"); + }); + + modelBuilder.Entity("Content.Server.Database.Preference", b => + { + b.Navigation("Profiles"); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.Navigation("Antags"); + + b.Navigation("Jobs"); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.Navigation("AdminLogs"); + }); + + modelBuilder.Entity("Content.Server.Database.Server", b => + { + b.Navigation("Rounds"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBan", b => + { + b.Navigation("BanHits"); + + b.Navigation("Unban"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => + { + b.Navigation("Unban"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Content.Server.Database/Migrations/Sqlite/20220724000127_PlayTime.cs b/Content.Server.Database/Migrations/Sqlite/20220724000127_PlayTime.cs new file mode 100644 index 0000000000..1005ce866f --- /dev/null +++ b/Content.Server.Database/Migrations/Sqlite/20220724000127_PlayTime.cs @@ -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(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + player_id = table.Column(type: "TEXT", nullable: false), + tracker = table.Column(type: "TEXT", nullable: false), + time_spent = table.Column(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"); + } + } +} diff --git a/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs b/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs index 44d481a669..3aaa308b35 100644 --- a/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs +++ b/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs @@ -1,6 +1,5 @@ // 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("play_time_id"); + + b.Property("PlayerId") + .HasColumnType("TEXT") + .HasColumnName("player_id"); + + b.Property("TimeSpent") + .HasColumnType("TEXT") + .HasColumnName("time_spent"); + + b.Property("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("Id") @@ -559,7 +587,7 @@ namespace Content.Server.Database.Migrations.Sqlite .HasColumnType("TEXT") .HasColumnName("hair_name"); - b.Property("Markings") + b.Property("Markings") .HasColumnType("jsonb") .HasColumnName("markings"); diff --git a/Content.Server.Database/Model.cs b/Content.Server.Database/Model.cs index 0dde35eb81..b53e25473f 100644 --- a/Content.Server.Database/Model.cs +++ b/Content.Server.Database/Model.cs @@ -33,6 +33,7 @@ namespace Content.Server.Database public DbSet ServerBanHit { get; set; } = default!; public DbSet RoleBan { get; set; } = default!; public DbSet RoleUnban { get; set; } = default!; + public DbSet PlayTime { get; set; } = default!; public DbSet UploadedResourceLog { get; set; } = default!; public DbSet AdminNotes { get; set; } = null!; @@ -94,6 +95,10 @@ namespace Content.Server.Database modelBuilder.Entity() .HasIndex(log => log.Date); + modelBuilder.Entity() + .HasIndex(v => new { v.PlayerId, Role = v.Tracker }) + .IsUnique(); + modelBuilder.Entity() .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 { diff --git a/Content.Server/Administration/Commands/PlayTimeCommands.cs b/Content.Server/Administration/Commands/PlayTimeCommands.cs new file mode 100644 index 0000000000..cd4804ff38 --- /dev/null +++ b/Content.Server/Administration/Commands/PlayTimeCommands.cs @@ -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(), + 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(), + Loc.GetString("cmd-playtime_getrole-arg-role")); + } + + return CompletionResult.Empty; + } +} + +/// +/// Saves the timers for a particular player immediately +/// +[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; + } +} diff --git a/Content.Server/Afk/AFKSystem.cs b/Content.Server/Afk/AFKSystem.cs new file mode 100644 index 0000000000..f4226ee6e6 --- /dev/null +++ b/Content.Server/Afk/AFKSystem.cs @@ -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; + +/// +/// Actively checks for AFK players regularly and issues an event whenever they go afk. +/// +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 _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; + } + } + } +} diff --git a/Content.Server/Afk/Events/AFKEvent.cs b/Content.Server/Afk/Events/AFKEvent.cs new file mode 100644 index 0000000000..6adb950e47 --- /dev/null +++ b/Content.Server/Afk/Events/AFKEvent.cs @@ -0,0 +1,17 @@ +using Robust.Server.Player; + +namespace Content.Server.Afk.Events; + +/// +/// Raised whenever a player goes afk. +/// +[ByRefEvent] +public readonly struct AFKEvent +{ + public readonly IPlayerSession Session; + + public AFKEvent(IPlayerSession playerSession) + { + Session = playerSession; + } +} diff --git a/Content.Server/Afk/Events/UnAFKEvent.cs b/Content.Server/Afk/Events/UnAFKEvent.cs new file mode 100644 index 0000000000..3dd034583c --- /dev/null +++ b/Content.Server/Afk/Events/UnAFKEvent.cs @@ -0,0 +1,17 @@ +using Robust.Server.Player; + +namespace Content.Server.Afk.Events; + +/// +/// Raised whenever a player is no longer AFK. +/// +[ByRefEvent] +public readonly struct UnAFKEvent +{ + public readonly IPlayerSession Session; + + public UnAFKEvent(IPlayerSession playerSession) + { + Session = playerSession; + } +} diff --git a/Content.Server/Afk/IsAfkCommand.cs b/Content.Server/Afk/IsAfkCommand.cs index cad348bf8e..ebeb200d89 100644 --- a/Content.Server/Afk/IsAfkCommand.cs +++ b/Content.Server/Afk/IsAfkCommand.cs @@ -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 "; public void Execute(IConsoleShell shell, string argStr, string[] args) { - var playerManager = IoCManager.Resolve(); var afkManager = IoCManager.Resolve(); 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), + ""); + } + + return CompletionResult.Empty; + } } } diff --git a/Content.Server/Database/ServerDbBase.cs b/Content.Server/Database/ServerDbBase.cs index 3ca6c7af69..ad6737031a 100644 --- a/Content.Server/Database/ServerDbBase.cs +++ b/Content.Server/Database/ServerDbBase.cs @@ -359,6 +359,59 @@ namespace Content.Server.Database public abstract Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverRoleUnban); #endregion + #region Playtime + public async Task> 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 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 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 logs) @@ -921,5 +975,6 @@ namespace Content.Server.Database public abstract ValueTask DisposeAsync(); } + } } diff --git a/Content.Server/Database/ServerDbManager.cs b/Content.Server/Database/ServerDbManager.cs index bcb4c18953..b1858fb77b 100644 --- a/Content.Server/Database/ServerDbManager.cs +++ b/Content.Server/Database/ServerDbManager.cs @@ -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 + + /// + /// Look up a player's role timers. + /// + /// The player to get the role timer information from. + /// All role timers belonging to the player. + Task> GetPlayTimes(Guid player); + + /// + /// Update play time information in bulk. + /// + /// The list of all updates to apply to the database. + Task UpdatePlayTimes(IReadOnlyCollection 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 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 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 GetAssignedUserIdAsync(string name) { + DbReadOpsMetric.Inc(); return _db.GetAssignedUserIdAsync(name); } public Task GetServerBanAsync(int id) { + DbReadOpsMetric.Inc(); return _db.GetServerBanAsync(id); } @@ -292,6 +327,7 @@ namespace Content.Server.Database NetUserId? userId, ImmutableArray? hwId) { + DbReadOpsMetric.Inc(); return _db.GetServerBanAsync(address, userId, hwId); } @@ -301,22 +337,26 @@ namespace Content.Server.Database ImmutableArray? 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 GetServerRoleBanAsync(int id) { + DbReadOpsMetric.Inc(); return _db.GetServerRoleBanAsync(id); } @@ -326,36 +366,58 @@ namespace Content.Server.Database ImmutableArray? 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> GetPlayTimes(Guid player) + { + DbReadOpsMetric.Inc(); + return _db.GetPlayTimes(player); + } + + public Task UpdatePlayTimes(IReadOnlyCollection updates) + { + DbWriteOpsMetric.Inc(); + return _db.UpdatePlayTimes(updates); + } + + #endregion + public Task UpdatePlayerRecordAsync( NetUserId userId, string userName, IPAddress address, ImmutableArray hwId) { + DbWriteOpsMetric.Inc(); return _db.UpdatePlayerRecord(userId, userName, address, hwId); } public Task GetPlayerRecordByUserName(string userName, CancellationToken cancel = default) { + DbReadOpsMetric.Inc(); return _db.GetPlayerRecordByUserName(userName, cancel); } public Task GetPlayerRecordByUserId(NetUserId userId, CancellationToken cancel = default) { + DbReadOpsMetric.Inc(); return _db.GetPlayerRecordByUserId(userId, cancel); } @@ -366,137 +428,169 @@ namespace Content.Server.Database ImmutableArray hwId, ConnectionDenyReason? denied) { + DbWriteOpsMetric.Inc(); return _db.AddConnectionLogAsync(userId, userName, address, hwId, denied); } public Task AddServerBanHitsAsync(int connection, IEnumerable bans) { + DbWriteOpsMetric.Inc(); return _db.AddServerBanHitsAsync(connection, bans); } public Task GetAdminDataForAsync(NetUserId userId, CancellationToken cancel = default) { + DbReadOpsMetric.Inc(); return _db.GetAdminDataForAsync(userId, cancel); } public Task 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 AddNewRound(Server server, params Guid[] playerIds) { + DbWriteOpsMetric.Inc(); return _db.AddNewRound(server, playerIds); } public Task 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 AddOrGetServer(string serverName) + public async Task 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 logs) { + DbWriteOpsMetric.Inc(); return _db.AddAdminLogs(logs); } public IAsyncEnumerable GetAdminLogMessages(LogFilter? filter = null) { + DbReadOpsMetric.Inc(); return _db.GetAdminLogMessages(filter); } public IAsyncEnumerable GetAdminLogs(LogFilter? filter = null) { + DbReadOpsMetric.Inc(); return _db.GetAdminLogs(filter); } public IAsyncEnumerable GetAdminLogsJson(LogFilter? filter = null) { + DbReadOpsMetric.Inc(); return _db.GetAdminLogsJson(filter); } public Task 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 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 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 GetAdminNote(int id) { + DbReadOpsMetric.Inc(); return _db.GetAdminNote(id); } public Task> 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); } diff --git a/Content.Server/Database/UserDbDataManager.cs b/Content.Server/Database/UserDbDataManager.cs new file mode 100644 index 0000000000..549758d801 --- /dev/null +++ b/Content.Server/Database/UserDbDataManager.cs @@ -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; + +/// +/// 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. +/// +/// +/// Actual loading code is handled by separate managers such as . +/// This manager is simply a centralized "is loading done" controller for other code to rely on. +/// +public sealed class UserDbDataManager +{ + [Dependency] private readonly IServerPreferencesManager _prefs = default!; + [Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!; + + private readonly Dictionary _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); +} diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index f8bc19cd43..1e6713d315 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -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; /// public override void Init() @@ -78,8 +78,7 @@ namespace Content.Server.Entry _euiManager = IoCManager.Resolve(); _voteManager = IoCManager.Resolve(); _updateManager = IoCManager.Resolve(); - - var playerManager = IoCManager.Resolve(); + _playTimeTracking = IoCManager.Resolve(); var logManager = IoCManager.Resolve(); 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(); + } } } diff --git a/Content.Server/GameTicking/GameTicker.Lobby.cs b/Content.Server/GameTicking/GameTicker.Lobby.cs index 6869d1d090..54e7f7d33d 100644 --- a/Content.Server/GameTicking/GameTicker.Lobby.cs +++ b/Content.Server/GameTicking/GameTicker.Lobby.cs @@ -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; diff --git a/Content.Server/GameTicking/GameTicker.Player.cs b/Content.Server/GameTicking/GameTicker.Player.cs index ad64bf0bc4..7e9dc9dd08 100644 --- a/Content.Server/GameTicking/GameTicker.Player.cs +++ b/Content.Server/GameTicking/GameTicker.Player.cs @@ -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); } diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index cddf725f45..649299fd2b 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -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) diff --git a/Content.Server/GameTicking/GameTicker.Spawning.cs b/Content.Server/GameTicking/GameTicker.Spawning.cs index 4707f252f3..10f318af5b 100644 --- a/Content.Server/GameTicking/GameTicker.Spawning.cs +++ b/Content.Server/GameTicking/GameTicker.Spawning.cs @@ -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(); + + 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); } diff --git a/Content.Server/GameTicking/GameTicker.cs b/Content.Server/GameTicking/GameTicker.cs index eb00d11216..861a10e98c 100644 --- a/Content.Server/GameTicking/GameTicker.cs +++ b/Content.Server/GameTicking/GameTicker.cs @@ -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!; } } diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index 876cb7b7be..7b7449925a 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -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(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Server/Mind/Mind.cs b/Content.Server/Mind/Mind.cs index 17baf12cf2..c89d396c14 100644 --- a/Content.Server/Mind/Mind.cs +++ b/Content.Server/Mind/Mind.cs @@ -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().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) { diff --git a/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingManager.cs b/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingManager.cs new file mode 100644 index 0000000000..b1eb1d9b69 --- /dev/null +++ b/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingManager.cs @@ -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 trackers); + +/// +/// Tracks play time for players, across all roles. +/// +/// +/// +/// Play time is tracked in distinct "trackers" (defined in ). +/// Most jobs correspond to one such tracker, but there are also more trackers like "Overall" which tracks cumulative playtime across all roles. +/// +/// +/// To actually figure out what trackers are active, 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 , and should be raised through events such as players' roles changing. +/// +/// +/// Because the calculation system does not persistently keep ticking timers, +/// APIs like will not see live-updating information. +/// A light-weight form of refresh is a "flush" through . +/// 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. +/// +/// +/// 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. +/// +/// +/// Tracker data is loaded from the database when the client connects as part of . +/// +/// +/// Timing logic in this manager is ran **out** of simulation. +/// This means that we use real time, not simulation time, for timing everything here. +/// +/// +/// Operations like refreshing and sending play time info to clients are deferred until the next frame (note: not tick). +/// +/// +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 _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 _pendingSaveTasks = new(); + + private readonly Dictionary _playTimeData = new(); + + public event CalcPlayTimeTrackersCallback? CalcTrackers; + + public void Initialize() + { + _sawmill = Logger.GetSawmill("play_time"); + + _net.RegisterNetMessage(); + + _cfg.OnValueChanged(CCVars.PlayTimeSaveInterval, f => _saveInterval = TimeSpan.FromSeconds(f), true); + } + + 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(); + } + } + + /// + /// Flush all trackers for all players. + /// + /// + public void FlushAllTrackers() + { + var time = _timing.RealTime; + + foreach (var data in _playTimeData.Values) + { + FlushSingleTracker(data, time); + } + } + + /// + /// Flush time tracker information for a player, + /// so APIs like return up-to-date info. + /// + /// + public void FlushTracker(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); + } + + /// + /// Save all modified time trackers for all players to the database. + /// + public async void Save() + { + FlushAllTrackers(); + + _lastSave = _timing.RealTime; + + TrackPending(DoSaveAsync()); + } + + /// + /// Save all modified time trackers for a player to the database. + /// + public async void SaveSession(IPlayerSession session) + { + // This causes all trackers to refresh, ah well. + FlushAllTrackers(); + + TrackPending(DoSaveSessionAsync(session)); + } + + /// + /// Track a database save task to make sure we block server shutdown on it. + /// + private async void TrackPending(Task task) + { + _pendingSaveTasks.Add(task); + + try + { + await task; + } + finally + { + _pendingSaveTasks.Remove(task); + } + } + + private async Task DoSaveAsync() + { + var log = new List(); + + foreach (var (player, data) in _playTimeData) + { + foreach (var tracker in data.DbTrackersDirty) + { + log.Add(new PlayTimeUpdate(player.UserId, tracker, data.TrackerTimes[tracker])); + } + + data.DbTrackersDirty.Clear(); + } + + if (log.Count == 0) + return; + + // NOTE: we do replace updates here, not incremental additions. + // This means that if you're playing on two servers at the same time, they'll step on each other's feet. + // This is considered fine. + await _db.UpdatePlayTimes(log); + + _sawmill.Debug($"Saved {log.Count} trackers"); + } + + private async Task DoSaveSessionAsync(IPlayerSession session) + { + var log = new List(); + + var data = _playTimeData[session]; + + foreach (var tracker in data.DbTrackersDirty) + { + log.Add(new PlayTimeUpdate(session.UserId, tracker, data.TrackerTimes[tracker])); + } + + data.DbTrackersDirty.Clear(); + + // NOTE: we do replace updates here, not incremental additions. + // This means that if you're playing on two servers at the same time, they'll step on each other's feet. + // This is considered fine. + await _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 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); + } + + /// + /// Queue for play time trackers to be refreshed on a player, in case the set of active trackers may have changed. + /// + public void QueueRefreshTrackers(IPlayerSession player) + { + if (DirtyPlayer(player) is { } data) + data.NeedRefreshTackers = true; + } + + /// + /// Queue for play time information to be sent to a client, for showing in UIs etc. + /// + public void QueueSendTimers(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; + } + + /// + /// Play time info for a particular player. + /// + private sealed class PlayTimeData + { + // Queued update flags + public bool IsDirty; + public bool NeedRefreshTackers; + public bool NeedSendTimers; + + // Active tracking info + public readonly HashSet ActiveTrackers = new(); + public TimeSpan LastUpdate; + + // Stored tracked time info. + + /// + /// Have we finished retrieving our data from the DB? + /// + public bool Initialized; + + public readonly Dictionary TrackerTimes = new(); + + /// + /// Set of trackers which are different from their DB values and need to be saved to DB. + /// + public readonly HashSet DbTrackersDirty = new(); + } +} diff --git a/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs b/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs new file mode 100644 index 0000000000..c4e5773c1f --- /dev/null +++ b/Content.Server/Players/PlayTimeTracking/PlayTimeTrackingSystem.cs @@ -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; + +/// +/// Connects to the simulation state. Reports trackers and such. +/// +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(OnRoundEnd); + SubscribeLocalEvent(OnPlayerAttached); + SubscribeLocalEvent(OnPlayerDetached); + SubscribeLocalEvent(OnRoleAdd); + SubscribeLocalEvent(OnRoleRemove); + SubscribeLocalEvent(OnAFK); + SubscribeLocalEvent(OnUnAFK); + SubscribeLocalEvent(OnMobStateChanged); + SubscribeLocalEvent(OnPlayerJoinedLobby); + } + + public override void Shutdown() + { + base.Shutdown(); + + _tracking.CalcTrackers -= CalcTrackers; + } + + private void CalcTrackers(IPlayerSession player, HashSet 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(attached, out var state)) + return false; + + return state.CurrentState is DamageState.Alive or DamageState.Critical; + } + + public IEnumerable GetTimedRoles(Mind.Mind mind) + { + foreach (var role in mind.AllRoles) + { + if (role is not IRoleTimer timer) + continue; + + yield return _prototypes.Index(timer.Timer).ID; + } + } + + private IEnumerable GetTimedRoles(IPlayerSession session) + { + var contentData = _playerManager.GetPlayerData(session.UserId).ContentData(); + + if (contentData?.Mind == null) + return Enumerable.Empty(); + + 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(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 GetDisallowedJobs(IPlayerSession player) + { + var roles = new HashSet(); + if (!_cfg.GetCVar(CCVars.GameRoleTimers)) + return roles; + + var playTimes = _tracking.GetTrackerTimes(player); + + foreach (var job in _prototypes.EnumeratePrototypes()) + { + 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 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(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); + } +} diff --git a/Content.Server/Preferences/Managers/IServerPreferencesManager.cs b/Content.Server/Preferences/Managers/IServerPreferencesManager.cs index 95e81f3cac..dde606663c 100644 --- a/Content.Server/Preferences/Managers/IServerPreferencesManager.cs +++ b/Content.Server/Preferences/Managers/IServerPreferencesManager.cs @@ -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> GetSelectedProfilesForPlayers(List userIds); } diff --git a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs index bb0a0d9478..6c9d8d0244 100644 --- a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs +++ b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs @@ -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(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; - } - /// /// 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; } } diff --git a/Content.Server/Roles/Job.cs b/Content.Server/Roles/Job.cs index cb37da26cc..514cd1ad85 100644 --- a/Content.Server/Roles/Job.cs +++ b/Content.Server/Roles/Job.cs @@ -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; } diff --git a/Content.Server/Roles/RoleAddedEvent.cs b/Content.Server/Roles/RoleAddedEvent.cs index 2c163c7134..789ea25869 100644 --- a/Content.Server/Roles/RoleAddedEvent.cs +++ b/Content.Server/Roles/RoleAddedEvent.cs @@ -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) { } } } diff --git a/Content.Server/Roles/RoleEvent.cs b/Content.Server/Roles/RoleEvent.cs index 2c195087ff..6bd5a34d9d 100644 --- a/Content.Server/Roles/RoleEvent.cs +++ b/Content.Server/Roles/RoleEvent.cs @@ -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; } } diff --git a/Content.Server/Roles/RoleRemovedEvent.cs b/Content.Server/Roles/RoleRemovedEvent.cs index cfa4fb845f..dcacdae6ad 100644 --- a/Content.Server/Roles/RoleRemovedEvent.cs +++ b/Content.Server/Roles/RoleRemovedEvent.cs @@ -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) { } } } diff --git a/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs b/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs index 554c40cece..4d30330dbe 100644 --- a/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs +++ b/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs @@ -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> _jobsByWeight = default!; private List _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? 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(profile.JobPriorities.Count); - availableJobs.Add(jobId); } diff --git a/Content.Server/Station/Systems/StationJobsSystem.cs b/Content.Server/Station/Systems/StationJobsSystem.cs index 750f06ed2b..5c2865d8e4 100644 --- a/Content.Server/Station/Systems/StationJobsSystem.cs +++ b/Content.Server/Station/Systems/StationJobsSystem.cs @@ -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; diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index b5a6c8599f..f7148a2114 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -176,6 +176,12 @@ namespace Content.Shared.CCVar public static readonly CVarDef GameMapRotation = CVarDef.Create("game.map_rotation", true, CVar.SERVERONLY); + /// + /// If roles should be restricted based on time. + /// + public static readonly CVarDef + GameRoleTimers = CVarDef.Create("game.role_timers", false, CVar.SERVER | CVar.REPLICATED); + /// /// Whether a random position offset will be applied to the station on roundstart. /// @@ -1091,5 +1097,16 @@ namespace Content.Shared.CCVar /// public static readonly CVarDef GhostRoleTime = CVarDef.Create("ghost.role_time", 3f, CVar.REPLICATED); + + /* + * PLAYTIME + */ + + + /// + /// Time between play time autosaves, in seconds. + /// + public static readonly CVarDef + PlayTimeSaveInterval = CVarDef.Create("playtime.save_interval", 900f, CVar.SERVERONLY); } } diff --git a/Content.Shared/Players/PlayTimeTracking/MsgPlayTime.cs b/Content.Shared/Players/PlayTimeTracking/MsgPlayTime.cs new file mode 100644 index 0000000000..915a78bb90 --- /dev/null +++ b/Content.Shared/Players/PlayTimeTracking/MsgPlayTime.cs @@ -0,0 +1,34 @@ +using Lidgren.Network; +using Robust.Shared.Network; + +namespace Content.Shared.Players.PlayTimeTracking; + +/// +/// Sent server -> client to inform the client of their play times. +/// +public sealed class MsgPlayTime : NetMessage +{ + public override MsgGroups MsgGroup => MsgGroups.EntityEvent; + + public Dictionary 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); + } + } +} diff --git a/Content.Shared/Players/PlayTimeTracking/PlayTimeTrackerPrototype.cs b/Content.Shared/Players/PlayTimeTracking/PlayTimeTrackerPrototype.cs new file mode 100644 index 0000000000..3b9aac97fd --- /dev/null +++ b/Content.Shared/Players/PlayTimeTracking/PlayTimeTrackerPrototype.cs @@ -0,0 +1,12 @@ +using Robust.Shared.Prototypes; + +namespace Content.Shared.Players.PlayTimeTracking; + +/// +/// Given to a role to specify its ID for role-timer tracking purposes. That's it. +/// +[Prototype("playTimeTracker")] +public sealed class PlayTimeTrackerPrototype : IPrototype +{ + [IdDataField] public string ID { get; } = default!; +} diff --git a/Content.Shared/Players/PlayTimeTracking/PlayTimeTrackingShared.cs b/Content.Shared/Players/PlayTimeTracking/PlayTimeTrackingShared.cs new file mode 100644 index 0000000000..e300524d87 --- /dev/null +++ b/Content.Shared/Players/PlayTimeTracking/PlayTimeTrackingShared.cs @@ -0,0 +1,9 @@ +namespace Content.Shared.Players.PlayTimeTracking; + +public static class PlayTimeTrackingShared +{ + /// + /// The prototype ID of the play time tracker that represents overall playtime, i.e. not tied to any one role. + /// + public const string TrackerOverall = "Overall"; +} diff --git a/Content.Shared/Roles/DepartmentPrototype.cs b/Content.Shared/Roles/DepartmentPrototype.cs new file mode 100644 index 0000000000..91232be950 --- /dev/null +++ b/Content.Shared/Roles/DepartmentPrototype.cs @@ -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))] + public List Roles = new(); +} diff --git a/Content.Shared/Roles/IRoleTimer.cs b/Content.Shared/Roles/IRoleTimer.cs new file mode 100644 index 0000000000..5361139bb0 --- /dev/null +++ b/Content.Shared/Roles/IRoleTimer.cs @@ -0,0 +1,6 @@ +namespace Content.Shared.Roles; + +public interface IRoleTimer +{ + string Timer { get; } +} diff --git a/Content.Shared/Roles/JobPrototype.cs b/Content.Shared/Roles/JobPrototype.cs index b435dcf812..300870bcaa 100644 --- a/Content.Shared/Roles/JobPrototype.cs +++ b/Content.Shared/Roles/JobPrototype.cs @@ -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))] + 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? 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(); - [DataField("departments")] - public IReadOnlyCollection Departments { get; } = Array.Empty(); - [DataField("access", customTypeSerializer: typeof(PrototypeIdListSerializer))] public IReadOnlyCollection Access { get; } = Array.Empty(); diff --git a/Content.Shared/Roles/JobRequirements.cs b/Content.Shared/Roles/JobRequirements.cs new file mode 100644 index 0000000000..8658c7e959 --- /dev/null +++ b/Content.Shared/Roles/JobRequirements.cs @@ -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 +{ + /// + /// Abstract class for playtime and other requirements for role gates. + /// + [ImplicitDataDefinitionForInheritors] + public abstract class JobRequirement + { + } + + [UsedImplicitly] + public sealed class DepartmentTimeRequirement : JobRequirement + { + [DataField("department", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string Department = default!; + + /// + /// How long (in seconds) this requirement is. + /// + [DataField("time")] public TimeSpan Time; + } + + [UsedImplicitly] + public sealed class RoleTimeRequirement : JobRequirement + { + /// + /// What particular role they need the time requirement with. + /// + [DataField("role", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string Role = default!; + + /// + /// How long (in seconds) this requirement is. + /// + [DataField("time")] public TimeSpan Time; + } + + [UsedImplicitly] + public sealed class OverallPlaytimeRequirement : JobRequirement + { + /// + /// How long (in seconds) this requirement is. + /// + [DataField("time")] public TimeSpan Time; + } + + public static class JobRequirements + { + public static bool TryRequirementsMet( + JobPrototype job, + Dictionary 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; + } + + /// + /// Returns a string with the reason why a particular requirement may not be met. + /// + public static bool TryRequirementMet( + JobRequirement requirement, + Dictionary 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(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(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(); + } + } + } +} diff --git a/Resources/Locale/en-US/job/department.ftl b/Resources/Locale/en-US/job/department.ftl new file mode 100644 index 0000000000..2511ef17af --- /dev/null +++ b/Resources/Locale/en-US/job/department.ftl @@ -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 diff --git a/Resources/Locale/en-US/job/job-names.ftl b/Resources/Locale/en-US/job/job-names.ftl index e65cc3144e..9017b3083c 100644 --- a/Resources/Locale/en-US/job/job-names.ftl +++ b/Resources/Locale/en-US/job/job-names.ftl @@ -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 diff --git a/Resources/Locale/en-US/job/role-timers.ftl b/Resources/Locale/en-US/job/role-timers.ftl new file mode 100644 index 0000000000..1cecd41fbc --- /dev/null +++ b/Resources/Locale/en-US/job/role-timers.ftl @@ -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) diff --git a/Resources/Locale/en-US/players/play-time/play-time-commands.ftl b/Resources/Locale/en-US/players/play-time/play-time-commands.ftl new file mode 100644 index 0000000000..759aa36f86 --- /dev/null +++ b/Resources/Locale/en-US/players/play-time/play-time-commands.ftl @@ -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} +cmd-playtime_addoverall-succeed = Increased overall time for {$username} to {TOSTRING($time, "0")} +cmd-playtime_addoverall-arg-user = +cmd-playtime_addoverall-arg-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} +cmd-playtime_addrole-succeed = Increased role playtime for {$username} / \'{$role}\' to {TOSTRING($time, "0")} +cmd-playtime_addrole-arg-user = +cmd-playtime_addrole-arg-role = +cmd-playtime_addrole-arg-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} +cmd-playtime_getoverall-success = Overall time for {$username} is {TOSTRING($time, "0")} minutes +cmd-playtime_getoverall-arg-user = +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} [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 = +cmd-playtime_getrole-arg-role = +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} +cmd-playtime_save-succeed = Saved playtime for {$username} +cmd-playtime_save-arg-user = +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] diff --git a/Resources/Prototypes/Roles/Jobs/Cargo/cargo_technician.yml b/Resources/Prototypes/Roles/Jobs/Cargo/cargo_technician.yml index 0dd5de2999..90145db641 100644 --- a/Resources/Prototypes/Roles/Jobs/Cargo/cargo_technician.yml +++ b/Resources/Prototypes/Roles/Jobs/Cargo/cargo_technician.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Cargo/quartermaster.yml b/Resources/Prototypes/Roles/Jobs/Cargo/quartermaster.yml index f19f5309b5..4c2ca3df35 100644 --- a/Resources/Prototypes/Roles/Jobs/Cargo/quartermaster.yml +++ b/Resources/Prototypes/Roles/Jobs/Cargo/quartermaster.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Cargo/salvage_specialist.yml b/Resources/Prototypes/Roles/Jobs/Cargo/salvage_specialist.yml index 2bc4f2a6c1..00139f92b9 100644 --- a/Resources/Prototypes/Roles/Jobs/Cargo/salvage_specialist.yml +++ b/Resources/Prototypes/Roles/Jobs/Cargo/salvage_specialist.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/assistant.yml b/Resources/Prototypes/Roles/Jobs/Civilian/assistant.yml index b2cafe8678..4cf71fa489 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/assistant.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/assistant.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/bartender.yml b/Resources/Prototypes/Roles/Jobs/Civilian/bartender.yml index 2a3c19df9b..c7be4202a9 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/bartender.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/bartender.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/botanist.yml b/Resources/Prototypes/Roles/Jobs/Civilian/botanist.yml index 35855b4d26..cd38bfa138 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/botanist.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/botanist.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/chaplain.yml b/Resources/Prototypes/Roles/Jobs/Civilian/chaplain.yml index edb8b12857..519607b444 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/chaplain.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/chaplain.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/chef.yml b/Resources/Prototypes/Roles/Jobs/Civilian/chef.yml index 310a5d5086..e1b666d044 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/chef.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/chef.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/clown.yml b/Resources/Prototypes/Roles/Jobs/Civilian/clown.yml index d1a9a534bb..4fe123a717 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/clown.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/clown.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/janitor.yml b/Resources/Prototypes/Roles/Jobs/Civilian/janitor.yml index d27e7543fb..2a430603fb 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/janitor.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/janitor.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/lawyer.yml b/Resources/Prototypes/Roles/Jobs/Civilian/lawyer.yml index 2a181380fe..20f74a946f 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/lawyer.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/lawyer.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/librarian.yml b/Resources/Prototypes/Roles/Jobs/Civilian/librarian.yml index 97095033d6..2863d92f06 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/librarian.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/librarian.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/mime.yml b/Resources/Prototypes/Roles/Jobs/Civilian/mime.yml index d728c5d60f..f4619ba434 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/mime.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/mime.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/musician.yml b/Resources/Prototypes/Roles/Jobs/Civilian/musician.yml index 1718fafaa3..d3dc43046c 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/musician.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/musician.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/service_worker.yml b/Resources/Prototypes/Roles/Jobs/Civilian/service_worker.yml index ac39a5f281..7456870e2f 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/service_worker.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/service_worker.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Command/captain.yml b/Resources/Prototypes/Roles/Jobs/Command/captain.yml index 8f1695fad1..9201a85000 100644 --- a/Resources/Prototypes/Roles/Jobs/Command/captain.yml +++ b/Resources/Prototypes/Roles/Jobs/Command/captain.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Command/centcom_official.yml b/Resources/Prototypes/Roles/Jobs/Command/centcom_official.yml index 7b1aa25e39..7521452732 100644 --- a/Resources/Prototypes/Roles/Jobs/Command/centcom_official.yml +++ b/Resources/Prototypes/Roles/Jobs/Command/centcom_official.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Command/head_of_personnel.yml b/Resources/Prototypes/Roles/Jobs/Command/head_of_personnel.yml index ed4e14d354..e5f33d21e1 100644 --- a/Resources/Prototypes/Roles/Jobs/Command/head_of_personnel.yml +++ b/Resources/Prototypes/Roles/Jobs/Command/head_of_personnel.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Engineering/atmospheric_technician.yml b/Resources/Prototypes/Roles/Jobs/Engineering/atmospheric_technician.yml index 5a6896efe2..738fa1417a 100644 --- a/Resources/Prototypes/Roles/Jobs/Engineering/atmospheric_technician.yml +++ b/Resources/Prototypes/Roles/Jobs/Engineering/atmospheric_technician.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Engineering/chief_engineer.yml b/Resources/Prototypes/Roles/Jobs/Engineering/chief_engineer.yml index 598a7b7c75..b962efbd74 100644 --- a/Resources/Prototypes/Roles/Jobs/Engineering/chief_engineer.yml +++ b/Resources/Prototypes/Roles/Jobs/Engineering/chief_engineer.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Engineering/station_engineer.yml b/Resources/Prototypes/Roles/Jobs/Engineering/station_engineer.yml index e1712dfe1a..9d1f19dec8 100644 --- a/Resources/Prototypes/Roles/Jobs/Engineering/station_engineer.yml +++ b/Resources/Prototypes/Roles/Jobs/Engineering/station_engineer.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Engineering/technical_assistant.yml b/Resources/Prototypes/Roles/Jobs/Engineering/technical_assistant.yml index 81a63fcdac..2d64eb79b1 100644 --- a/Resources/Prototypes/Roles/Jobs/Engineering/technical_assistant.yml +++ b/Resources/Prototypes/Roles/Jobs/Engineering/technical_assistant.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Fun/emergencyresponseteam.yml b/Resources/Prototypes/Roles/Jobs/Fun/emergencyresponseteam.yml index 831ce8890e..953e54a977 100644 --- a/Resources/Prototypes/Roles/Jobs/Fun/emergencyresponseteam.yml +++ b/Resources/Prototypes/Roles/Jobs/Fun/emergencyresponseteam.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Medical/chemist.yml b/Resources/Prototypes/Roles/Jobs/Medical/chemist.yml index feef347e90..370b5fc2cc 100644 --- a/Resources/Prototypes/Roles/Jobs/Medical/chemist.yml +++ b/Resources/Prototypes/Roles/Jobs/Medical/chemist.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Medical/chief_medical_officer.yml b/Resources/Prototypes/Roles/Jobs/Medical/chief_medical_officer.yml index d5f9dbec79..df9166cdaf 100644 --- a/Resources/Prototypes/Roles/Jobs/Medical/chief_medical_officer.yml +++ b/Resources/Prototypes/Roles/Jobs/Medical/chief_medical_officer.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Medical/medical_doctor.yml b/Resources/Prototypes/Roles/Jobs/Medical/medical_doctor.yml index 8615c489e4..1f8f4a2527 100644 --- a/Resources/Prototypes/Roles/Jobs/Medical/medical_doctor.yml +++ b/Resources/Prototypes/Roles/Jobs/Medical/medical_doctor.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Medical/medical_intern.yml b/Resources/Prototypes/Roles/Jobs/Medical/medical_intern.yml index d438c47f2f..d15e35d6f2 100644 --- a/Resources/Prototypes/Roles/Jobs/Medical/medical_intern.yml +++ b/Resources/Prototypes/Roles/Jobs/Medical/medical_intern.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Science/research_director.yml b/Resources/Prototypes/Roles/Jobs/Science/research_director.yml index ee5788b397..3483f5242b 100644 --- a/Resources/Prototypes/Roles/Jobs/Science/research_director.yml +++ b/Resources/Prototypes/Roles/Jobs/Science/research_director.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Science/scientist.yml b/Resources/Prototypes/Roles/Jobs/Science/scientist.yml index 47180ebf70..ab497fc231 100644 --- a/Resources/Prototypes/Roles/Jobs/Science/scientist.yml +++ b/Resources/Prototypes/Roles/Jobs/Science/scientist.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Security/detective.yml b/Resources/Prototypes/Roles/Jobs/Security/detective.yml index 36544fa84e..37144d91a3 100644 --- a/Resources/Prototypes/Roles/Jobs/Security/detective.yml +++ b/Resources/Prototypes/Roles/Jobs/Security/detective.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Security/head_of_security.yml b/Resources/Prototypes/Roles/Jobs/Security/head_of_security.yml index 3d5cadd51e..d1743dee7f 100644 --- a/Resources/Prototypes/Roles/Jobs/Security/head_of_security.yml +++ b/Resources/Prototypes/Roles/Jobs/Security/head_of_security.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Security/security_cadet.yml b/Resources/Prototypes/Roles/Jobs/Security/security_cadet.yml index 3c11fd67a1..5064fa4887 100644 --- a/Resources/Prototypes/Roles/Jobs/Security/security_cadet.yml +++ b/Resources/Prototypes/Roles/Jobs/Security/security_cadet.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Security/security_officer.yml b/Resources/Prototypes/Roles/Jobs/Security/security_officer.yml index d7dda60666..d3112de22b 100644 --- a/Resources/Prototypes/Roles/Jobs/Security/security_officer.yml +++ b/Resources/Prototypes/Roles/Jobs/Security/security_officer.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Security/warden.yml b/Resources/Prototypes/Roles/Jobs/Security/warden.yml index dbe7213c14..31d6caa2de 100644 --- a/Resources/Prototypes/Roles/Jobs/Security/warden.yml +++ b/Resources/Prototypes/Roles/Jobs/Security/warden.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Wildcards/boxer.yml b/Resources/Prototypes/Roles/Jobs/Wildcards/boxer.yml index a13e928d5d..1d551d0265 100644 --- a/Resources/Prototypes/Roles/Jobs/Wildcards/boxer.yml +++ b/Resources/Prototypes/Roles/Jobs/Wildcards/boxer.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Wildcards/psychologist.yml b/Resources/Prototypes/Roles/Jobs/Wildcards/psychologist.yml index 80344d8b2e..46ca4c92bd 100644 --- a/Resources/Prototypes/Roles/Jobs/Wildcards/psychologist.yml +++ b/Resources/Prototypes/Roles/Jobs/Wildcards/psychologist.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml b/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml index 5333b04e2f..d4fa71fc7e 100644 --- a/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml +++ b/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml @@ -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: diff --git a/Resources/Prototypes/Roles/Jobs/departments.yml b/Resources/Prototypes/Roles/Jobs/departments.yml new file mode 100644 index 0000000000..c421648ae7 --- /dev/null +++ b/Resources/Prototypes/Roles/Jobs/departments.yml @@ -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 \ No newline at end of file diff --git a/Resources/Prototypes/Roles/play_time_trackers.yml b/Resources/Prototypes/Roles/play_time_trackers.yml new file mode 100644 index 0000000000..1095ae0123 --- /dev/null +++ b/Resources/Prototypes/Roles/play_time_trackers.yml @@ -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