Play time tracking: Job timers 3: more titles: when the (#9978)
Co-authored-by: Veritius <veritiusgaming@gmail.com> Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
This commit is contained in:
committed by
GitHub
parent
6b94db0336
commit
e852ada6c8
@@ -17,6 +17,7 @@ using Content.Client.MainMenu;
|
||||
using Content.Client.MobState.Overlays;
|
||||
using Content.Client.Parallax;
|
||||
using Content.Client.Parallax.Managers;
|
||||
using Content.Client.Players.PlayTimeTracking;
|
||||
using Content.Client.Preferences;
|
||||
using Content.Client.Radiation;
|
||||
using Content.Client.Sandbox;
|
||||
@@ -125,6 +126,7 @@ namespace Content.Client.Entry
|
||||
IoCManager.Resolve<ViewportManager>().Initialize();
|
||||
IoCManager.Resolve<GhostKickManager>().Initialize();
|
||||
IoCManager.Resolve<ExtendedDisconnectInformationManager>().Initialize();
|
||||
IoCManager.Resolve<PlayTimeTrackingManager>().Initialize();
|
||||
|
||||
IoCManager.InjectDependencies(this);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ using Content.Client.Items.Managers;
|
||||
using Content.Client.Launcher;
|
||||
using Content.Client.Module;
|
||||
using Content.Client.Parallax.Managers;
|
||||
using Content.Client.Players.PlayTimeTracking;
|
||||
using Content.Client.Preferences;
|
||||
using Content.Client.Screenshot;
|
||||
using Content.Client.Stylesheets;
|
||||
@@ -47,6 +48,7 @@ namespace Content.Client.IoC
|
||||
IoCManager.Register<ISharedAdminLogManager, SharedAdminLogManager>();
|
||||
IoCManager.Register<GhostKickManager>();
|
||||
IoCManager.Register<ExtendedDisconnectInformationManager>();
|
||||
IoCManager.Register<PlayTimeTrackingManager>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,54 +134,61 @@ namespace Content.Client.LateJoin
|
||||
|
||||
var firstCategory = true;
|
||||
|
||||
foreach (var job in gameTicker.JobsAvailable[id].OrderBy(x => x.Key))
|
||||
foreach (var department in _prototypeManager.EnumeratePrototypes<DepartmentPrototype>())
|
||||
{
|
||||
var prototype = _prototypeManager.Index<JobPrototype>(job.Key);
|
||||
foreach (var department in prototype.Departments)
|
||||
var departmentName = Loc.GetString($"department-{department.ID}");
|
||||
_jobCategories[id] = new Dictionary<string, BoxContainer>();
|
||||
_jobButtons[id] = new Dictionary<string, JobButton>();
|
||||
var stationAvailable = gameTicker.JobsAvailable[id];
|
||||
|
||||
var category = new BoxContainer
|
||||
{
|
||||
if (!_jobCategories.TryGetValue(id, out var _))
|
||||
_jobCategories[id] = new Dictionary<string, BoxContainer>();
|
||||
if (!_jobButtons.TryGetValue(id, out var _))
|
||||
_jobButtons[id] = new Dictionary<string, JobButton>();
|
||||
if (!_jobCategories[id].TryGetValue(department, out var category))
|
||||
Orientation = LayoutOrientation.Vertical,
|
||||
Name = department.ID,
|
||||
ToolTip = Loc.GetString("late-join-gui-jobs-amount-in-department-tooltip",
|
||||
("departmentName", departmentName))
|
||||
};
|
||||
|
||||
if (firstCategory)
|
||||
{
|
||||
firstCategory = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
category.AddChild(new Control
|
||||
{
|
||||
category = new BoxContainer
|
||||
{
|
||||
Orientation = LayoutOrientation.Vertical,
|
||||
Name = department,
|
||||
ToolTip = Loc.GetString("late-join-gui-jobs-amount-in-department-tooltip",
|
||||
("departmentName", department))
|
||||
};
|
||||
MinSize = new Vector2(0, 23),
|
||||
});
|
||||
}
|
||||
|
||||
if (firstCategory)
|
||||
category.AddChild(new PanelContainer
|
||||
{
|
||||
Children =
|
||||
{
|
||||
new Label
|
||||
{
|
||||
firstCategory = false;
|
||||
StyleClasses = { "LabelBig" },
|
||||
Text = Loc.GetString("late-join-gui-department-jobs-label", ("departmentName", departmentName))
|
||||
}
|
||||
else
|
||||
{
|
||||
category.AddChild(new Control
|
||||
{
|
||||
MinSize = new Vector2(0, 23),
|
||||
});
|
||||
}
|
||||
|
||||
category.AddChild(new PanelContainer
|
||||
{
|
||||
Children =
|
||||
{
|
||||
new Label
|
||||
{
|
||||
StyleClasses = { "LabelBig" },
|
||||
Text = Loc.GetString("late-join-gui-department-jobs-label", ("departmentName", department))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_jobCategories[id][department] = category;
|
||||
jobList.AddChild(category);
|
||||
}
|
||||
});
|
||||
|
||||
var jobButton = new JobButton(prototype.ID, job.Value);
|
||||
_jobCategories[id][department.ID] = category;
|
||||
jobList.AddChild(category);
|
||||
var jobsAvailable = new List<JobPrototype>();
|
||||
|
||||
foreach (var jobId in department.Roles)
|
||||
{
|
||||
if (!stationAvailable.ContainsKey(jobId)) continue;
|
||||
jobsAvailable.Add(_prototypeManager.Index<JobPrototype>(jobId));
|
||||
}
|
||||
|
||||
jobsAvailable.Sort((x, y) => -string.Compare(x.LocalizedName, y.LocalizedName, StringComparison.CurrentCultureIgnoreCase));
|
||||
|
||||
foreach (var prototype in jobsAvailable)
|
||||
{
|
||||
var value = stationAvailable[prototype.ID];
|
||||
var jobButton = new JobButton(prototype.ID, value);
|
||||
|
||||
var jobSelector = new BoxContainer
|
||||
{
|
||||
@@ -205,8 +212,8 @@ namespace Content.Client.LateJoin
|
||||
|
||||
var jobLabel = new Label
|
||||
{
|
||||
Text = job.Value != null ?
|
||||
Loc.GetString("late-join-gui-job-slot-capped", ("jobName", prototype.LocalizedName), ("amount", job.Value)) :
|
||||
Text = value != null ?
|
||||
Loc.GetString("late-join-gui-job-slot-capped", ("jobName", prototype.LocalizedName), ("amount", value)) :
|
||||
Loc.GetString("late-join-gui-job-slot-uncapped", ("jobName", prototype.LocalizedName))
|
||||
};
|
||||
|
||||
@@ -219,7 +226,7 @@ namespace Content.Client.LateJoin
|
||||
SelectedId?.Invoke((id, jobButton.JobId));
|
||||
};
|
||||
|
||||
if (job.Value == 0)
|
||||
if (value == 0)
|
||||
{
|
||||
jobButton.Disabled = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.Players.PlayTimeTracking;
|
||||
using Content.Shared.Roles;
|
||||
using Robust.Client;
|
||||
using Robust.Client.Player;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Prototypes;
|
||||
|
||||
namespace Content.Client.Players.PlayTimeTracking;
|
||||
|
||||
public sealed class PlayTimeTrackingManager
|
||||
{
|
||||
[Dependency] private readonly IBaseClient _client = default!;
|
||||
[Dependency] private readonly IClientNetManager _net = default!;
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
[Dependency] private readonly IPrototypeManager _prototypes = default!;
|
||||
|
||||
private readonly Dictionary<string, TimeSpan> _roles = new();
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_net.RegisterNetMessage<MsgPlayTime>(RxPlayTime);
|
||||
|
||||
_client.RunLevelChanged += ClientOnRunLevelChanged;
|
||||
}
|
||||
|
||||
private void ClientOnRunLevelChanged(object? sender, RunLevelChangedEventArgs e)
|
||||
{
|
||||
if (e.NewLevel == ClientRunLevel.Initialize)
|
||||
{
|
||||
// Reset on disconnect, just in case.
|
||||
_roles.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void RxPlayTime(MsgPlayTime message)
|
||||
{
|
||||
_roles.Clear();
|
||||
|
||||
// NOTE: do not assign _roles = message.Trackers due to implicit data sharing in integration tests.
|
||||
foreach (var (tracker, time) in message.Trackers)
|
||||
{
|
||||
_roles[tracker] = time;
|
||||
}
|
||||
|
||||
/*var sawmill = Logger.GetSawmill("play_time");
|
||||
foreach (var (tracker, time) in _roles)
|
||||
{
|
||||
sawmill.Info($"{tracker}: {time}");
|
||||
}*/
|
||||
}
|
||||
|
||||
public bool IsAllowed(JobPrototype job, [NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
reason = null;
|
||||
|
||||
if (job.Requirements == null ||
|
||||
!_cfg.GetCVar(CCVars.GameRoleTimers))
|
||||
return true;
|
||||
|
||||
var player = _playerManager.LocalPlayer?.Session;
|
||||
|
||||
if (player == null) return true;
|
||||
|
||||
var roles = _roles;
|
||||
var reasonBuilder = new StringBuilder();
|
||||
|
||||
var first = true;
|
||||
foreach (var requirement in job.Requirements)
|
||||
{
|
||||
if (JobRequirements.TryRequirementMet(requirement, roles, out reason, _prototypes))
|
||||
continue;
|
||||
|
||||
if (!first)
|
||||
reasonBuilder.Append('\n');
|
||||
first = false;
|
||||
|
||||
reasonBuilder.AppendLine(reason);
|
||||
}
|
||||
|
||||
reason = reasonBuilder.Length == 0 ? null : reasonBuilder.ToString();
|
||||
return reason == null;
|
||||
}
|
||||
}
|
||||
@@ -235,7 +235,7 @@ namespace Content.Client.Preferences.UI
|
||||
if (!disposing)
|
||||
return;
|
||||
|
||||
IoCManager.Resolve<IEntityManager>().DeleteEntity((EntityUid) _previewDummy);
|
||||
IoCManager.Resolve<IEntityManager>().DeleteEntity(_previewDummy);
|
||||
_previewDummy = default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Content.Client.CharacterAppearance;
|
||||
using Content.Client.HUD.UI;
|
||||
using Content.Client.Lobby.UI;
|
||||
using Content.Client.Message;
|
||||
using Content.Client.Players.PlayTimeTracking;
|
||||
using Content.Client.Stylesheets;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.CharacterAppearance;
|
||||
@@ -341,56 +343,65 @@ namespace Content.Client.Preferences.UI
|
||||
|
||||
_jobPriorities = new List<JobPrioritySelector>();
|
||||
_jobCategories = new Dictionary<string, BoxContainer>();
|
||||
var spriteSystem = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>();
|
||||
|
||||
var firstCategory = true;
|
||||
var playTime = IoCManager.Resolve<PlayTimeTrackingManager>();
|
||||
|
||||
foreach (var job in prototypeManager.EnumeratePrototypes<JobPrototype>().OrderBy(j => j.LocalizedName))
|
||||
foreach (var department in _prototypeManager.EnumeratePrototypes<DepartmentPrototype>())
|
||||
{
|
||||
if(!job.SetPreference) { continue; }
|
||||
var departmentName = Loc.GetString($"department-{department.ID}");
|
||||
|
||||
foreach (var department in job.Departments)
|
||||
if (!_jobCategories.TryGetValue(department.ID, out var category))
|
||||
{
|
||||
if (!_jobCategories.TryGetValue(department, out var category))
|
||||
category = new BoxContainer
|
||||
{
|
||||
category = new BoxContainer
|
||||
{
|
||||
Orientation = LayoutOrientation.Vertical,
|
||||
Name = department,
|
||||
ToolTip = Loc.GetString("humanoid-profile-editor-jobs-amount-in-department-tooltip",
|
||||
("departmentName", department))
|
||||
};
|
||||
Orientation = LayoutOrientation.Vertical,
|
||||
Name = department.ID,
|
||||
ToolTip = Loc.GetString("humanoid-profile-editor-jobs-amount-in-department-tooltip",
|
||||
("departmentName", departmentName))
|
||||
};
|
||||
|
||||
if (firstCategory)
|
||||
{
|
||||
firstCategory = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
category.AddChild(new Control
|
||||
{
|
||||
MinSize = new Vector2(0, 23),
|
||||
});
|
||||
}
|
||||
|
||||
category.AddChild(new PanelContainer
|
||||
if (firstCategory)
|
||||
{
|
||||
firstCategory = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
category.AddChild(new Control
|
||||
{
|
||||
PanelOverride = new StyleBoxFlat {BackgroundColor = Color.FromHex("#464966")},
|
||||
Children =
|
||||
{
|
||||
new Label
|
||||
{
|
||||
Text = Loc.GetString("humanoid-profile-editor-department-jobs-label",
|
||||
("departmentName" ,department))
|
||||
}
|
||||
}
|
||||
MinSize = new Vector2(0, 23),
|
||||
});
|
||||
|
||||
_jobCategories[department] = category;
|
||||
_jobList.AddChild(category);
|
||||
}
|
||||
|
||||
var selector = new JobPrioritySelector(job, spriteSystem);
|
||||
category.AddChild(new PanelContainer
|
||||
{
|
||||
PanelOverride = new StyleBoxFlat {BackgroundColor = Color.FromHex("#464966")},
|
||||
Children =
|
||||
{
|
||||
new Label
|
||||
{
|
||||
Text = Loc.GetString("humanoid-profile-editor-department-jobs-label",
|
||||
("departmentName", departmentName))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_jobCategories[department.ID] = category;
|
||||
_jobList.AddChild(category);
|
||||
}
|
||||
|
||||
var jobs = department.Roles.Select(o => _prototypeManager.Index<JobPrototype>(o)).Where(o => o.SetPreference).ToList();
|
||||
jobs.Sort((x, y) => -string.Compare(x.LocalizedName, y.LocalizedName, StringComparison.CurrentCultureIgnoreCase));
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
var selector = new JobPrioritySelector(job);
|
||||
|
||||
if (!playTime.IsAllowed(job, out var reason))
|
||||
{
|
||||
selector.LockRequirements(reason);
|
||||
}
|
||||
|
||||
category.AddChild(selector);
|
||||
_jobPriorities.Add(selector);
|
||||
|
||||
@@ -418,6 +429,7 @@ namespace Content.Client.Preferences.UI
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -992,7 +1004,10 @@ namespace Content.Client.Preferences.UI
|
||||
|
||||
public event Action<JobPriority>? PriorityChanged;
|
||||
|
||||
public JobPrioritySelector(JobPrototype job, SpriteSystem sprites)
|
||||
private StripeBack _lockStripe;
|
||||
private Label _requirementsLabel;
|
||||
|
||||
public JobPrioritySelector(JobPrototype job)
|
||||
{
|
||||
Job = job;
|
||||
|
||||
@@ -1021,9 +1036,32 @@ namespace Content.Client.Preferences.UI
|
||||
Stretch = TextureRect.StretchMode.KeepCentered
|
||||
};
|
||||
|
||||
var specifier = new SpriteSpecifier.Rsi(new ResourcePath("/Textures/Interface/Misc/job_icons.rsi"),
|
||||
job.Icon);
|
||||
icon.Texture = sprites.Frame0(specifier);
|
||||
if (job.Icon != null)
|
||||
{
|
||||
var specifier = new SpriteSpecifier.Rsi(new ResourcePath("/Textures/Interface/Misc/job_icons.rsi"),
|
||||
job.Icon);
|
||||
icon.Texture = specifier.Frame0();
|
||||
}
|
||||
|
||||
_requirementsLabel = new Label()
|
||||
{
|
||||
Text = Loc.GetString("role-timer-locked"),
|
||||
Visible = true,
|
||||
HorizontalAlignment = HAlignment.Center,
|
||||
StyleClasses = {StyleBase.StyleClassLabelSubText},
|
||||
};
|
||||
|
||||
_lockStripe = new StripeBack()
|
||||
{
|
||||
Visible = false,
|
||||
HorizontalExpand = true,
|
||||
TooltipDelay = 0.2f,
|
||||
MouseFilter = MouseFilterMode.Stop,
|
||||
Children =
|
||||
{
|
||||
_requirementsLabel
|
||||
}
|
||||
};
|
||||
|
||||
AddChild(new BoxContainer
|
||||
{
|
||||
@@ -1032,10 +1070,26 @@ namespace Content.Client.Preferences.UI
|
||||
{
|
||||
icon,
|
||||
new Label {Text = job.LocalizedName, MinSize = (175, 0)},
|
||||
_optionButton
|
||||
_optionButton,
|
||||
_lockStripe,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void LockRequirements(string requirements)
|
||||
{
|
||||
_lockStripe.ToolTip = requirements;
|
||||
_lockStripe.Visible = true;
|
||||
_optionButton.Visible = false;
|
||||
}
|
||||
|
||||
// TODO: Subscribe to roletimers event. I am too lazy to do this RN But I doubt most people will notice fn
|
||||
public void UnlockRequirements()
|
||||
{
|
||||
_requirementsLabel.Visible = false;
|
||||
_lockStripe.Visible = false;
|
||||
_optionButton.Visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAntagPreferences()
|
||||
|
||||
@@ -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;
|
||||
|
||||
1292
Content.Server.Database/Migrations/Postgres/20220724000132_PlayTime.Designer.cs
generated
Normal file
1292
Content.Server.Database/Migrations/Postgres/20220724000132_PlayTime.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Content.Server.Database.Migrations.Postgres
|
||||
{
|
||||
public partial class PlayTime : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "play_time",
|
||||
columns: table => new
|
||||
{
|
||||
play_time_id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
player_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
tracker = table.Column<string>(type: "text", nullable: false),
|
||||
time_spent = table.Column<TimeSpan>(type: "interval", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_play_time", x => x.play_time_id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_time_player_id_tracker",
|
||||
table: "play_time",
|
||||
columns: new[] { "player_id", "tracker" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "play_time");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ namespace Content.Server.Database.Migrations.Postgres
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "6.0.0")
|
||||
.HasAnnotation("ProductVersion", "6.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
@@ -506,6 +506,37 @@ namespace Content.Server.Database.Migrations.Postgres
|
||||
b.HasCheckConstraint("LastSeenAddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= last_seen_address");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Content.Server.Database.PlayTime", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("play_time_id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("player_id");
|
||||
|
||||
b.Property<TimeSpan>("TimeSpent")
|
||||
.HasColumnType("interval")
|
||||
.HasColumnName("time_spent");
|
||||
|
||||
b.Property<string>("Tracker")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("tracker");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("PK_play_time");
|
||||
|
||||
b.HasIndex("PlayerId", "Tracker")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("play_time", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Content.Server.Database.Preference", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
1228
Content.Server.Database/Migrations/Sqlite/20220724000127_PlayTime.Designer.cs
generated
Normal file
1228
Content.Server.Database/Migrations/Sqlite/20220724000127_PlayTime.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Content.Server.Database.Migrations.Sqlite
|
||||
{
|
||||
public partial class PlayTime : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "play_time",
|
||||
columns: table => new
|
||||
{
|
||||
play_time_id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
player_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
tracker = table.Column<string>(type: "TEXT", nullable: false),
|
||||
time_spent = table.Column<TimeSpan>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_play_time", x => x.play_time_id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_time_player_id_tracker",
|
||||
table: "play_time",
|
||||
columns: new[] { "player_id", "tracker" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "play_time");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Content.Server.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@@ -16,7 +15,7 @@ namespace Content.Server.Database.Migrations.Sqlite
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.0");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.5");
|
||||
|
||||
modelBuilder.Entity("Content.Server.Database.Admin", b =>
|
||||
{
|
||||
@@ -469,6 +468,35 @@ namespace Content.Server.Database.Migrations.Sqlite
|
||||
b.ToTable("player", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Content.Server.Database.PlayTime", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("play_time_id");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("player_id");
|
||||
|
||||
b.Property<TimeSpan>("TimeSpent")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_spent");
|
||||
|
||||
b.Property<string>("Tracker")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tracker");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("PK_play_time");
|
||||
|
||||
b.HasIndex("PlayerId", "Tracker")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("play_time", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Content.Server.Database.Preference", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -559,7 +587,7 @@ namespace Content.Server.Database.Migrations.Sqlite
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("hair_name");
|
||||
|
||||
b.Property<JsonDocument>("Markings")
|
||||
b.Property<byte[]>("Markings")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("markings");
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace Content.Server.Database
|
||||
public DbSet<ServerBanHit> ServerBanHit { get; set; } = default!;
|
||||
public DbSet<ServerRoleBan> RoleBan { get; set; } = default!;
|
||||
public DbSet<ServerRoleUnban> RoleUnban { get; set; } = default!;
|
||||
public DbSet<PlayTime> PlayTime { get; set; } = default!;
|
||||
public DbSet<UploadedResourceLog> UploadedResourceLog { get; set; } = default!;
|
||||
public DbSet<AdminNote> AdminNotes { get; set; } = null!;
|
||||
|
||||
@@ -94,6 +95,10 @@ namespace Content.Server.Database
|
||||
modelBuilder.Entity<AdminLog>()
|
||||
.HasIndex(log => log.Date);
|
||||
|
||||
modelBuilder.Entity<PlayTime>()
|
||||
.HasIndex(v => new { v.PlayerId, Role = v.Tracker })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<AdminLogPlayer>()
|
||||
.HasOne(player => player.Player)
|
||||
.WithMany(player => player.AdminLogs)
|
||||
@@ -501,6 +506,20 @@ namespace Content.Server.Database
|
||||
public DateTime UnbanTime { get; set; }
|
||||
}
|
||||
|
||||
[Table("play_time")]
|
||||
public sealed class PlayTime
|
||||
{
|
||||
[Required, Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required, ForeignKey("player")]
|
||||
public Guid PlayerId { get; set; }
|
||||
|
||||
public string Tracker { get; set; } = null!;
|
||||
|
||||
public TimeSpan TimeSpent { get; set; }
|
||||
}
|
||||
|
||||
[Table("uploaded_resource_log")]
|
||||
public sealed class UploadedResourceLog
|
||||
{
|
||||
|
||||
336
Content.Server/Administration/Commands/PlayTimeCommands.cs
Normal file
336
Content.Server/Administration/Commands/PlayTimeCommands.cs
Normal file
@@ -0,0 +1,336 @@
|
||||
using Content.Server.Players.PlayTimeTracking;
|
||||
using Content.Shared.Administration;
|
||||
using Content.Shared.Players.PlayTimeTracking;
|
||||
using Robust.Server.Player;
|
||||
using Robust.Shared.Console;
|
||||
|
||||
namespace Content.Server.Administration.Commands;
|
||||
|
||||
[AdminCommand(AdminFlags.Admin)]
|
||||
public sealed class PlayTimeAddOverallCommand : IConsoleCommand
|
||||
{
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
|
||||
|
||||
public string Command => "playtime_addoverall";
|
||||
public string Description => Loc.GetString("cmd-playtime_addoverall-desc");
|
||||
public string Help => Loc.GetString("cmd-playtime_addoverall-help", ("command", Command));
|
||||
|
||||
public async void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
if (args.Length != 2)
|
||||
{
|
||||
shell.WriteError(Loc.GetString("cmd-playtime_addoverall-error-args"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(args[1], out var minutes))
|
||||
{
|
||||
shell.WriteError(Loc.GetString("parse-minutes-fail", ("minutes", args[1])));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_playerManager.TryGetSessionByUsername(args[0], out var player))
|
||||
{
|
||||
shell.WriteError(Loc.GetString("parse-session-fail", ("username", args[0])));
|
||||
return;
|
||||
}
|
||||
|
||||
_playTimeTracking.AddTimeToOverallPlaytime(player, TimeSpan.FromMinutes(minutes));
|
||||
var overall = _playTimeTracking.GetOverallPlaytime(player);
|
||||
|
||||
shell.WriteLine(Loc.GetString(
|
||||
"cmd-playtime_addoverall-succeed",
|
||||
("username", args[0]),
|
||||
("time", overall)));
|
||||
}
|
||||
|
||||
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
|
||||
{
|
||||
if (args.Length == 1)
|
||||
return CompletionResult.FromHintOptions(CompletionHelper.SessionNames(),
|
||||
Loc.GetString("cmd-playtime_addoverall-arg-user"));
|
||||
|
||||
if (args.Length == 2)
|
||||
return CompletionResult.FromHint(Loc.GetString("cmd-playtime_addoverall-arg-minutes"));
|
||||
|
||||
return CompletionResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlayTimeAddRoleCommand : IConsoleCommand
|
||||
{
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
|
||||
|
||||
public string Command => "playtime_addrole";
|
||||
public string Description => Loc.GetString("cmd-playtime_addrole-desc");
|
||||
public string Help => Loc.GetString("cmd-playtime_addrole-help", ("command", Command));
|
||||
|
||||
public async void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
if (args.Length != 3)
|
||||
{
|
||||
shell.WriteError(Loc.GetString("cmd-playtime_addrole-error-args"));
|
||||
return;
|
||||
}
|
||||
|
||||
var userName = args[0];
|
||||
if (!_playerManager.TryGetSessionByUsername(userName, out var player))
|
||||
{
|
||||
shell.WriteError(Loc.GetString("parse-session-fail", ("username", userName)));
|
||||
return;
|
||||
}
|
||||
|
||||
var role = args[1];
|
||||
|
||||
var m = args[2];
|
||||
if (!int.TryParse(m, out var minutes))
|
||||
{
|
||||
shell.WriteError(Loc.GetString("parse-minutes-fail", ("minutes", minutes)));
|
||||
return;
|
||||
}
|
||||
|
||||
_playTimeTracking.AddTimeToTracker(player, role, TimeSpan.FromMinutes(minutes));
|
||||
var time = _playTimeTracking.GetOverallPlaytime(player);
|
||||
shell.WriteLine(Loc.GetString("cmd-playtime_addrole-succeed",
|
||||
("username", userName),
|
||||
("role", role),
|
||||
("time", time)));
|
||||
}
|
||||
|
||||
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
|
||||
{
|
||||
if (args.Length == 1)
|
||||
{
|
||||
return CompletionResult.FromHintOptions(
|
||||
CompletionHelper.SessionNames(players: _playerManager),
|
||||
Loc.GetString("cmd-playtime_addrole-arg-user"));
|
||||
}
|
||||
|
||||
if (args.Length == 2)
|
||||
{
|
||||
return CompletionResult.FromHintOptions(
|
||||
CompletionHelper.PrototypeIDs<PlayTimeTrackerPrototype>(),
|
||||
Loc.GetString("cmd-playtime_addrole-arg-role"));
|
||||
}
|
||||
|
||||
if (args.Length == 3)
|
||||
return CompletionResult.FromHint(Loc.GetString("cmd-playtime_addrole-arg-minutes"));
|
||||
|
||||
return CompletionResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
[AdminCommand(AdminFlags.Admin)]
|
||||
public sealed class PlayTimeGetOverallCommand : IConsoleCommand
|
||||
{
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
|
||||
|
||||
public string Command => "playtime_getoverall";
|
||||
public string Description => Loc.GetString("cmd-playtime_getoverall-desc");
|
||||
public string Help => Loc.GetString("cmd-playtime_getoverall-help", ("command", Command));
|
||||
|
||||
public async void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
if (args.Length != 1)
|
||||
{
|
||||
shell.WriteError(Loc.GetString("cmd-playtime_getoverall-error-args"));
|
||||
return;
|
||||
}
|
||||
|
||||
var userName = args[0];
|
||||
if (!_playerManager.TryGetSessionByUsername(userName, out var player))
|
||||
{
|
||||
shell.WriteError(Loc.GetString("parser-session-fail", ("username", userName)));
|
||||
return;
|
||||
}
|
||||
|
||||
var value = _playTimeTracking.GetOverallPlaytime(player);
|
||||
shell.WriteLine(Loc.GetString(
|
||||
"cmd-playtime_getoverall-success",
|
||||
("username", userName),
|
||||
("time", value)));
|
||||
}
|
||||
|
||||
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
|
||||
{
|
||||
if (args.Length == 1)
|
||||
{
|
||||
return CompletionResult.FromHintOptions(
|
||||
CompletionHelper.SessionNames(players: _playerManager),
|
||||
Loc.GetString("cmd-playtime_getoverall-arg-user"));
|
||||
}
|
||||
|
||||
return CompletionResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
[AdminCommand(AdminFlags.Admin)]
|
||||
public sealed class PlayTimeGetRoleCommand : IConsoleCommand
|
||||
{
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
|
||||
|
||||
public string Command => "playtime_getrole";
|
||||
public string Description => Loc.GetString("cmd-playtime_getrole-desc");
|
||||
public string Help => Loc.GetString("cmd-playtime_getrole-help", ("command", Command));
|
||||
|
||||
public async void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
if (args.Length is not (1 or 2))
|
||||
{
|
||||
shell.WriteLine(Loc.GetString("cmd-playtime_getrole-error-args"));
|
||||
return;
|
||||
}
|
||||
|
||||
var userName = args[0];
|
||||
if (!_playerManager.TryGetSessionByUsername(userName, out var session))
|
||||
{
|
||||
shell.WriteError(Loc.GetString("parser-session-fail", ("username", userName)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length == 1)
|
||||
{
|
||||
var timers = _playTimeTracking.GetTrackerTimes(session);
|
||||
|
||||
if (timers.Count == 0)
|
||||
{
|
||||
shell.WriteLine(Loc.GetString("cmd-playtime_getrole-no"));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (role, time) in timers)
|
||||
{
|
||||
shell.WriteLine(Loc.GetString("cmd-playtime_getrole-role", ("role", role), ("time", time)));
|
||||
}
|
||||
}
|
||||
|
||||
if (args.Length >= 2)
|
||||
{
|
||||
if (args[1] == "Overall")
|
||||
{
|
||||
var timer = _playTimeTracking.GetOverallPlaytime(session);
|
||||
shell.WriteLine(Loc.GetString("cmd-playtime_getrole-overall", ("time", timer)));
|
||||
return;
|
||||
}
|
||||
|
||||
var time = _playTimeTracking.GetPlayTimeForTracker(session, args[1]);
|
||||
shell.WriteLine(Loc.GetString("cmd-playtime_getrole-succeed", ("username", session.Name),
|
||||
("time", time)));
|
||||
}
|
||||
}
|
||||
|
||||
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
|
||||
{
|
||||
if (args.Length == 1)
|
||||
{
|
||||
return CompletionResult.FromHintOptions(
|
||||
CompletionHelper.SessionNames(players: _playerManager),
|
||||
Loc.GetString("cmd-playtime_getrole-arg-user"));
|
||||
}
|
||||
|
||||
if (args.Length == 2)
|
||||
{
|
||||
return CompletionResult.FromHintOptions(
|
||||
CompletionHelper.PrototypeIDs<PlayTimeTrackerPrototype>(),
|
||||
Loc.GetString("cmd-playtime_getrole-arg-role"));
|
||||
}
|
||||
|
||||
return CompletionResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the timers for a particular player immediately
|
||||
/// </summary>
|
||||
[AdminCommand(AdminFlags.Admin)]
|
||||
public sealed class PlayTimeSaveCommand : IConsoleCommand
|
||||
{
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
|
||||
|
||||
public string Command => "playtime_save";
|
||||
public string Description => Loc.GetString("cmd-playtime_save-desc");
|
||||
public string Help => Loc.GetString("cmd-playtime_save-help", ("command", Command));
|
||||
|
||||
public async void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
if (args.Length != 1)
|
||||
{
|
||||
shell.WriteLine(Loc.GetString("cmd-playtime_save-error-args"));
|
||||
return;
|
||||
}
|
||||
|
||||
var name = args[0];
|
||||
if (!_playerManager.TryGetSessionByUsername(name, out var pSession))
|
||||
{
|
||||
shell.WriteError(Loc.GetString("parse-session-fail", ("username", name)));
|
||||
return;
|
||||
}
|
||||
|
||||
_playTimeTracking.SaveSession(pSession);
|
||||
shell.WriteLine(Loc.GetString("cmd-playtime_save-succeed", ("username", name)));
|
||||
}
|
||||
|
||||
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
|
||||
{
|
||||
if (args.Length == 1)
|
||||
{
|
||||
return CompletionResult.FromHintOptions(
|
||||
CompletionHelper.SessionNames(players: _playerManager),
|
||||
Loc.GetString("cmd-playtime_save-arg-user"));
|
||||
}
|
||||
|
||||
return CompletionResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
[AdminCommand(AdminFlags.Debug)]
|
||||
public sealed class PlayTimeFlushCommand : IConsoleCommand
|
||||
{
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
|
||||
|
||||
public string Command => "playtime_flush";
|
||||
public string Description => Loc.GetString("cmd-playtime_flush-desc");
|
||||
public string Help => Loc.GetString("cmd-playtime_flush-help", ("command", Command));
|
||||
|
||||
public void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
if (args.Length is not (0 or 1))
|
||||
{
|
||||
shell.WriteError(Loc.GetString("cmd-playtime_flush-error-args"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
_playTimeTracking.FlushAllTrackers();
|
||||
return;
|
||||
}
|
||||
|
||||
var name = args[0];
|
||||
if (!_playerManager.TryGetSessionByUsername(name, out var pSession))
|
||||
{
|
||||
shell.WriteError(Loc.GetString("parse-session-fail", ("username", name)));
|
||||
return;
|
||||
}
|
||||
|
||||
_playTimeTracking.FlushTracker(pSession);
|
||||
}
|
||||
|
||||
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
|
||||
{
|
||||
if (args.Length == 1)
|
||||
{
|
||||
return CompletionResult.FromHintOptions(
|
||||
CompletionHelper.SessionNames(players: _playerManager),
|
||||
Loc.GetString("cmd-playtime_flush-arg-user"));
|
||||
}
|
||||
|
||||
return CompletionResult.Empty;
|
||||
}
|
||||
}
|
||||
96
Content.Server/Afk/AFKSystem.cs
Normal file
96
Content.Server/Afk/AFKSystem.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System.Linq;
|
||||
using Content.Server.Afk.Events;
|
||||
using Content.Server.GameTicking;
|
||||
using Content.Shared.CCVar;
|
||||
using Robust.Server.Player;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Enums;
|
||||
using Robust.Shared.Player;
|
||||
|
||||
namespace Content.Server.Afk;
|
||||
|
||||
/// <summary>
|
||||
/// Actively checks for AFK players regularly and issues an event whenever they go afk.
|
||||
/// </summary>
|
||||
public sealed class AFKSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly IAfkManager _afkManager = default!;
|
||||
[Dependency] private readonly IConfigurationManager _configManager = default!;
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
[Dependency] private readonly GameTicker _ticker = default!;
|
||||
|
||||
private float _checkDelay;
|
||||
private float _accumulator;
|
||||
|
||||
private readonly HashSet<IPlayerSession> _afkPlayers = new();
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
_playerManager.PlayerStatusChanged += OnPlayerChange;
|
||||
_configManager.OnValueChanged(CCVars.AfkTime, SetAfkDelay, true);
|
||||
}
|
||||
|
||||
private void SetAfkDelay(float obj)
|
||||
{
|
||||
_checkDelay = obj;
|
||||
}
|
||||
|
||||
private void OnPlayerChange(object? sender, SessionStatusEventArgs e)
|
||||
{
|
||||
switch (e.NewStatus)
|
||||
{
|
||||
case SessionStatus.Disconnected:
|
||||
_afkPlayers.Remove(e.Session);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Shutdown()
|
||||
{
|
||||
base.Shutdown();
|
||||
_afkPlayers.Clear();
|
||||
_accumulator = 0f;
|
||||
_playerManager.PlayerStatusChanged -= OnPlayerChange;
|
||||
_configManager.UnsubValueChanged(CCVars.AfkTime, SetAfkDelay);
|
||||
}
|
||||
|
||||
public override void Update(float frameTime)
|
||||
{
|
||||
base.Update(frameTime);
|
||||
|
||||
if (_ticker.RunLevel != GameRunLevel.InRound)
|
||||
{
|
||||
_afkPlayers.Clear();
|
||||
_accumulator = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
_accumulator += frameTime;
|
||||
|
||||
// TODO: Should also listen to the input events for more accurate timings.
|
||||
if (_accumulator < _checkDelay) return;
|
||||
|
||||
_accumulator -= _checkDelay;
|
||||
|
||||
foreach (var session in Filter.GetAllPlayers())
|
||||
{
|
||||
var pSession = (IPlayerSession) session;
|
||||
var isAfk = _afkManager.IsAfk(pSession);
|
||||
|
||||
if (isAfk && _afkPlayers.Add(pSession))
|
||||
{
|
||||
var ev = new AFKEvent(pSession);
|
||||
RaiseLocalEvent(ref ev);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isAfk && _afkPlayers.Remove(pSession))
|
||||
{
|
||||
var ev = new UnAFKEvent(pSession);
|
||||
RaiseLocalEvent(ref ev);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
Content.Server/Afk/Events/AFKEvent.cs
Normal file
17
Content.Server/Afk/Events/AFKEvent.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Robust.Server.Player;
|
||||
|
||||
namespace Content.Server.Afk.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Raised whenever a player goes afk.
|
||||
/// </summary>
|
||||
[ByRefEvent]
|
||||
public readonly struct AFKEvent
|
||||
{
|
||||
public readonly IPlayerSession Session;
|
||||
|
||||
public AFKEvent(IPlayerSession playerSession)
|
||||
{
|
||||
Session = playerSession;
|
||||
}
|
||||
}
|
||||
17
Content.Server/Afk/Events/UnAFKEvent.cs
Normal file
17
Content.Server/Afk/Events/UnAFKEvent.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Robust.Server.Player;
|
||||
|
||||
namespace Content.Server.Afk.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Raised whenever a player is no longer AFK.
|
||||
/// </summary>
|
||||
[ByRefEvent]
|
||||
public readonly struct UnAFKEvent
|
||||
{
|
||||
public readonly IPlayerSession Session;
|
||||
|
||||
public UnAFKEvent(IPlayerSession playerSession)
|
||||
{
|
||||
Session = playerSession;
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,14 @@ namespace Content.Server.Afk
|
||||
[AdminCommand(AdminFlags.Admin)]
|
||||
public sealed class IsAfkCommand : IConsoleCommand
|
||||
{
|
||||
[Dependency] private readonly IPlayerManager _players = default!;
|
||||
|
||||
public string Command => "isafk";
|
||||
public string Description => "Checks if a specified player is AFK";
|
||||
public string Help => "Usage: isafk <playerName>";
|
||||
|
||||
public void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
var playerManager = IoCManager.Resolve<IPlayerManager>();
|
||||
var afkManager = IoCManager.Resolve<IAfkManager>();
|
||||
|
||||
if (args.Length == 0)
|
||||
@@ -23,7 +24,7 @@ namespace Content.Server.Afk
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playerManager.TryGetSessionByUsername(args[0], out var player))
|
||||
if (!_players.TryGetSessionByUsername(args[0], out var player))
|
||||
{
|
||||
shell.WriteError("Unable to find that player");
|
||||
return;
|
||||
@@ -31,5 +32,17 @@ namespace Content.Server.Afk
|
||||
|
||||
shell.WriteLine(afkManager.IsAfk(player) ? "They are indeed AFK" : "They are not AFK");
|
||||
}
|
||||
|
||||
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
|
||||
{
|
||||
if (args.Length == 1)
|
||||
{
|
||||
return CompletionResult.FromHintOptions(
|
||||
CompletionHelper.SessionNames(players: _players),
|
||||
"<playerName>");
|
||||
}
|
||||
|
||||
return CompletionResult.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +359,59 @@ namespace Content.Server.Database
|
||||
public abstract Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverRoleUnban);
|
||||
#endregion
|
||||
|
||||
#region Playtime
|
||||
public async Task<List<PlayTime>> GetPlayTimes(Guid player)
|
||||
{
|
||||
await using var db = await GetDb();
|
||||
|
||||
return await db.DbContext.PlayTime
|
||||
.Where(p => p.PlayerId == player)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task UpdatePlayTimes(IReadOnlyCollection<PlayTimeUpdate> updates)
|
||||
{
|
||||
await using var db = await GetDb();
|
||||
|
||||
// Ideally I would just be able to send a bunch of UPSERT commands, but EFCore is a pile of garbage.
|
||||
// So... In the interest of not making this take forever at high update counts...
|
||||
// Bulk-load play time objects for all players involved.
|
||||
// This allows us to semi-efficiently load all entities we need in a single DB query.
|
||||
// Then we can update & insert without further round-trips to the DB.
|
||||
|
||||
var players = updates.Select(u => u.User.UserId).Distinct().ToArray();
|
||||
var dbTimes = (await db.DbContext.PlayTime
|
||||
.Where(p => players.Contains(p.PlayerId))
|
||||
.ToArrayAsync())
|
||||
.GroupBy(p => p.PlayerId)
|
||||
.ToDictionary(g => g.Key, g => g.ToDictionary(p => p.Tracker, p => p));
|
||||
|
||||
foreach (var (user, tracker, time) in updates)
|
||||
{
|
||||
if (dbTimes.TryGetValue(user.UserId, out var userTimes)
|
||||
&& userTimes.TryGetValue(tracker, out var ent))
|
||||
{
|
||||
// Already have a tracker in the database, update it.
|
||||
ent.TimeSpent = time;
|
||||
continue;
|
||||
}
|
||||
|
||||
// No tracker, make a new one.
|
||||
var playTime = new PlayTime
|
||||
{
|
||||
Tracker = tracker,
|
||||
PlayerId = user.UserId,
|
||||
TimeSpent = time
|
||||
};
|
||||
|
||||
db.DbContext.PlayTime.Add(playTime);
|
||||
}
|
||||
|
||||
await db.DbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Player Records
|
||||
/*
|
||||
* PLAYER RECORDS
|
||||
@@ -597,14 +650,15 @@ namespace Content.Server.Database
|
||||
|
||||
#region Admin Logs
|
||||
|
||||
public async Task<Server> AddOrGetServer(string serverName)
|
||||
public async Task<(Server, bool existed)> AddOrGetServer(string serverName)
|
||||
{
|
||||
await using var db = await GetDb();
|
||||
var server = await db.DbContext.Server.Where(server => server.Name.Equals(serverName)).SingleOrDefaultAsync();
|
||||
var server = await db.DbContext.Server
|
||||
.Where(server => server.Name.Equals(serverName))
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
if (server != default)
|
||||
{
|
||||
return server;
|
||||
}
|
||||
return (server, true);
|
||||
|
||||
server = new Server
|
||||
{
|
||||
@@ -615,7 +669,7 @@ namespace Content.Server.Database
|
||||
|
||||
await db.DbContext.SaveChangesAsync();
|
||||
|
||||
return server;
|
||||
return (server, false);
|
||||
}
|
||||
|
||||
public virtual async Task AddAdminLogs(List<QueuedLog> logs)
|
||||
@@ -921,5 +975,6 @@ namespace Content.Server.Database
|
||||
|
||||
public abstract ValueTask DisposeAsync();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using Prometheus;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.ContentPack;
|
||||
using Robust.Shared.Network;
|
||||
@@ -114,6 +115,23 @@ namespace Content.Server.Database
|
||||
Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverBan);
|
||||
#endregion
|
||||
|
||||
#region Playtime
|
||||
|
||||
/// <summary>
|
||||
/// Look up a player's role timers.
|
||||
/// </summary>
|
||||
/// <param name="player">The player to get the role timer information from.</param>
|
||||
/// <returns>All role timers belonging to the player.</returns>
|
||||
Task<List<PlayTime>> GetPlayTimes(Guid player);
|
||||
|
||||
/// <summary>
|
||||
/// Update play time information in bulk.
|
||||
/// </summary>
|
||||
/// <param name="updates">The list of all updates to apply to the database.</param>
|
||||
Task UpdatePlayTimes(IReadOnlyCollection<PlayTimeUpdate> updates);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Player Records
|
||||
Task UpdatePlayerRecordAsync(
|
||||
NetUserId userId,
|
||||
@@ -209,6 +227,14 @@ namespace Content.Server.Database
|
||||
|
||||
public sealed class ServerDbManager : IServerDbManager
|
||||
{
|
||||
public static readonly Counter DbReadOpsMetric = Metrics.CreateCounter(
|
||||
"db_read_ops",
|
||||
"Amount of read operations processed by the database manager.");
|
||||
|
||||
public static readonly Counter DbWriteOpsMetric = Metrics.CreateCounter(
|
||||
"db_write_ops",
|
||||
"Amount of write operations processed by the database manager.");
|
||||
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
[Dependency] private readonly IResourceManager _res = default!;
|
||||
[Dependency] private readonly ILogManager _logMgr = default!;
|
||||
@@ -244,46 +270,55 @@ namespace Content.Server.Database
|
||||
|
||||
public Task<PlayerPreferences> InitPrefsAsync(NetUserId userId, ICharacterProfile defaultProfile)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.InitPrefsAsync(userId, defaultProfile);
|
||||
}
|
||||
|
||||
public Task SaveSelectedCharacterIndexAsync(NetUserId userId, int index)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.SaveSelectedCharacterIndexAsync(userId, index);
|
||||
}
|
||||
|
||||
public Task SaveCharacterSlotAsync(NetUserId userId, ICharacterProfile? profile, int slot)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.SaveCharacterSlotAsync(userId, profile, slot);
|
||||
}
|
||||
|
||||
public Task DeleteSlotAndSetSelectedIndex(NetUserId userId, int deleteSlot, int newSlot)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.DeleteSlotAndSetSelectedIndex(userId, deleteSlot, newSlot);
|
||||
}
|
||||
|
||||
public Task SaveAdminOOCColorAsync(NetUserId userId, Color color)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.SaveAdminOOCColorAsync(userId, color);
|
||||
}
|
||||
|
||||
public Task<PlayerPreferences?> GetPlayerPreferencesAsync(NetUserId userId)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetPlayerPreferencesAsync(userId);
|
||||
}
|
||||
|
||||
public Task AssignUserIdAsync(string name, NetUserId userId)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AssignUserIdAsync(name, userId);
|
||||
}
|
||||
|
||||
public Task<NetUserId?> GetAssignedUserIdAsync(string name)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetAssignedUserIdAsync(name);
|
||||
}
|
||||
|
||||
public Task<ServerBanDef?> GetServerBanAsync(int id)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetServerBanAsync(id);
|
||||
}
|
||||
|
||||
@@ -292,6 +327,7 @@ namespace Content.Server.Database
|
||||
NetUserId? userId,
|
||||
ImmutableArray<byte>? hwId)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetServerBanAsync(address, userId, hwId);
|
||||
}
|
||||
|
||||
@@ -301,22 +337,26 @@ namespace Content.Server.Database
|
||||
ImmutableArray<byte>? hwId,
|
||||
bool includeUnbanned=true)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetServerBansAsync(address, userId, hwId, includeUnbanned);
|
||||
}
|
||||
|
||||
public Task AddServerBanAsync(ServerBanDef serverBan)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddServerBanAsync(serverBan);
|
||||
}
|
||||
|
||||
public Task AddServerUnbanAsync(ServerUnbanDef serverUnban)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddServerUnbanAsync(serverUnban);
|
||||
}
|
||||
|
||||
#region Role Ban
|
||||
public Task<ServerRoleBanDef?> GetServerRoleBanAsync(int id)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetServerRoleBanAsync(id);
|
||||
}
|
||||
|
||||
@@ -326,36 +366,58 @@ namespace Content.Server.Database
|
||||
ImmutableArray<byte>? hwId,
|
||||
bool includeUnbanned = true)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetServerRoleBansAsync(address, userId, hwId, includeUnbanned);
|
||||
}
|
||||
|
||||
public Task AddServerRoleBanAsync(ServerRoleBanDef serverRoleBan)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddServerRoleBanAsync(serverRoleBan);
|
||||
}
|
||||
|
||||
public Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverRoleUnban)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddServerRoleUnbanAsync(serverRoleUnban);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Playtime
|
||||
|
||||
public Task<List<PlayTime>> GetPlayTimes(Guid player)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetPlayTimes(player);
|
||||
}
|
||||
|
||||
public Task UpdatePlayTimes(IReadOnlyCollection<PlayTimeUpdate> updates)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.UpdatePlayTimes(updates);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public Task UpdatePlayerRecordAsync(
|
||||
NetUserId userId,
|
||||
string userName,
|
||||
IPAddress address,
|
||||
ImmutableArray<byte> hwId)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.UpdatePlayerRecord(userId, userName, address, hwId);
|
||||
}
|
||||
|
||||
public Task<PlayerRecord?> GetPlayerRecordByUserName(string userName, CancellationToken cancel = default)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetPlayerRecordByUserName(userName, cancel);
|
||||
}
|
||||
|
||||
public Task<PlayerRecord?> GetPlayerRecordByUserId(NetUserId userId, CancellationToken cancel = default)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetPlayerRecordByUserId(userId, cancel);
|
||||
}
|
||||
|
||||
@@ -366,137 +428,169 @@ namespace Content.Server.Database
|
||||
ImmutableArray<byte> hwId,
|
||||
ConnectionDenyReason? denied)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddConnectionLogAsync(userId, userName, address, hwId, denied);
|
||||
}
|
||||
|
||||
public Task AddServerBanHitsAsync(int connection, IEnumerable<ServerBanDef> bans)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddServerBanHitsAsync(connection, bans);
|
||||
}
|
||||
|
||||
public Task<Admin?> GetAdminDataForAsync(NetUserId userId, CancellationToken cancel = default)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetAdminDataForAsync(userId, cancel);
|
||||
}
|
||||
|
||||
public Task<AdminRank?> GetAdminRankAsync(int id, CancellationToken cancel = default)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetAdminRankDataForAsync(id, cancel);
|
||||
}
|
||||
|
||||
public Task<((Admin, string? lastUserName)[] admins, AdminRank[])> GetAllAdminAndRanksAsync(
|
||||
CancellationToken cancel = default)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetAllAdminAndRanksAsync(cancel);
|
||||
}
|
||||
|
||||
public Task RemoveAdminAsync(NetUserId userId, CancellationToken cancel = default)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.RemoveAdminAsync(userId, cancel);
|
||||
}
|
||||
|
||||
public Task AddAdminAsync(Admin admin, CancellationToken cancel = default)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddAdminAsync(admin, cancel);
|
||||
}
|
||||
|
||||
public Task UpdateAdminAsync(Admin admin, CancellationToken cancel = default)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.UpdateAdminAsync(admin, cancel);
|
||||
}
|
||||
|
||||
public Task RemoveAdminRankAsync(int rankId, CancellationToken cancel = default)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.RemoveAdminRankAsync(rankId, cancel);
|
||||
}
|
||||
|
||||
public Task AddAdminRankAsync(AdminRank rank, CancellationToken cancel = default)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddAdminRankAsync(rank, cancel);
|
||||
}
|
||||
|
||||
public Task<int> AddNewRound(Server server, params Guid[] playerIds)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddNewRound(server, playerIds);
|
||||
}
|
||||
|
||||
public Task<Round> GetRound(int id)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetRound(id);
|
||||
}
|
||||
|
||||
public Task AddRoundPlayers(int id, params Guid[] playerIds)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddRoundPlayers(id, playerIds);
|
||||
}
|
||||
|
||||
public Task UpdateAdminRankAsync(AdminRank rank, CancellationToken cancel = default)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.UpdateAdminRankAsync(rank, cancel);
|
||||
}
|
||||
|
||||
public Task<Server> AddOrGetServer(string serverName)
|
||||
public async Task<Server> AddOrGetServer(string serverName)
|
||||
{
|
||||
return _db.AddOrGetServer(serverName);
|
||||
var (server, existed) = await _db.AddOrGetServer(serverName);
|
||||
if (existed)
|
||||
DbReadOpsMetric.Inc();
|
||||
else
|
||||
DbWriteOpsMetric.Inc();
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
public Task AddAdminLogs(List<QueuedLog> logs)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddAdminLogs(logs);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<string> GetAdminLogMessages(LogFilter? filter = null)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetAdminLogMessages(filter);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<SharedAdminLog> GetAdminLogs(LogFilter? filter = null)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetAdminLogs(filter);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<JsonDocument> GetAdminLogsJson(LogFilter? filter = null)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetAdminLogsJson(filter);
|
||||
}
|
||||
|
||||
public Task<bool> GetWhitelistStatusAsync(NetUserId player)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetWhitelistStatusAsync(player);
|
||||
}
|
||||
|
||||
public Task AddToWhitelistAsync(NetUserId player)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddToWhitelistAsync(player);
|
||||
}
|
||||
|
||||
public Task RemoveFromWhitelistAsync(NetUserId player)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.RemoveFromWhitelistAsync(player);
|
||||
}
|
||||
|
||||
public Task AddUploadedResourceLogAsync(NetUserId user, DateTime date, string path, byte[] data)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.AddUploadedResourceLogAsync(user, date, path, data);
|
||||
}
|
||||
|
||||
public Task PurgeUploadedResourceLogAsync(int days)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.PurgeUploadedResourceLogAsync(days);
|
||||
}
|
||||
|
||||
public Task<DateTime?> GetLastReadRules(NetUserId player)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetLastReadRules(player);
|
||||
}
|
||||
|
||||
public Task SetLastReadRules(NetUserId player, DateTime time)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.SetLastReadRules(player, time);
|
||||
}
|
||||
|
||||
public Task<int> AddAdminNote(int? roundId, Guid player, string message, Guid createdBy, DateTime createdAt)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
var note = new AdminNote
|
||||
{
|
||||
RoundId = roundId,
|
||||
@@ -513,21 +607,25 @@ namespace Content.Server.Database
|
||||
|
||||
public Task<AdminNote?> GetAdminNote(int id)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetAdminNote(id);
|
||||
}
|
||||
|
||||
public Task<List<AdminNote>> GetAdminNotes(Guid player)
|
||||
{
|
||||
DbReadOpsMetric.Inc();
|
||||
return _db.GetAdminNotes(player);
|
||||
}
|
||||
|
||||
public Task DeleteAdminNote(int id, Guid deletedBy, DateTime deletedAt)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.DeleteAdminNote(id, deletedBy, deletedAt);
|
||||
}
|
||||
|
||||
public Task EditAdminNote(int id, string message, Guid editedBy, DateTime editedAt)
|
||||
{
|
||||
DbWriteOpsMetric.Inc();
|
||||
return _db.EditAdminNote(id, message, editedBy, editedAt);
|
||||
}
|
||||
|
||||
@@ -648,4 +746,6 @@ namespace Content.Server.Database
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PlayTimeUpdate(NetUserId User, string Tracker, TimeSpan Time);
|
||||
}
|
||||
|
||||
70
Content.Server/Database/UserDbDataManager.cs
Normal file
70
Content.Server/Database/UserDbDataManager.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Content.Server.Players.PlayTimeTracking;
|
||||
using Content.Server.Preferences.Managers;
|
||||
using Robust.Server.Player;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Server.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Manages per-user data that comes from the database. Ensures it is loaded efficiently on client connect,
|
||||
/// and ensures data is loaded before allowing players to spawn or such.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Actual loading code is handled by separate managers such as <see cref="IServerPreferencesManager"/>.
|
||||
/// This manager is simply a centralized "is loading done" controller for other code to rely on.
|
||||
/// </remarks>
|
||||
public sealed class UserDbDataManager
|
||||
{
|
||||
[Dependency] private readonly IServerPreferencesManager _prefs = default!;
|
||||
[Dependency] private readonly PlayTimeTrackingManager _playTimeTracking = default!;
|
||||
|
||||
private readonly Dictionary<NetUserId, UserData> _users = new();
|
||||
|
||||
// TODO: Ideally connected/disconnected would be subscribed to IPlayerManager directly,
|
||||
// but this runs into ordering issues with game ticker.
|
||||
public void ClientConnected(IPlayerSession session)
|
||||
{
|
||||
DebugTools.Assert(!_users.ContainsKey(session.UserId), "We should not have any cached data on client connect.");
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var task = Load(session, cts.Token);
|
||||
var data = new UserData(cts, task);
|
||||
|
||||
_users.Add(session.UserId, data);
|
||||
}
|
||||
|
||||
public void ClientDisconnected(IPlayerSession session)
|
||||
{
|
||||
_users.Remove(session.UserId, out var data);
|
||||
if (data == null)
|
||||
throw new InvalidOperationException("Did not have cached data in ClientDisconnect!");
|
||||
|
||||
data.Cancel.Cancel();
|
||||
data.Cancel.Dispose();
|
||||
|
||||
_prefs.OnClientDisconnected(session);
|
||||
_playTimeTracking.ClientDisconnected(session);
|
||||
}
|
||||
|
||||
private async Task Load(IPlayerSession session, CancellationToken cancel)
|
||||
{
|
||||
await Task.WhenAll(
|
||||
_prefs.LoadData(session, cancel),
|
||||
_playTimeTracking.LoadData(session, cancel));
|
||||
}
|
||||
|
||||
public Task WaitLoadComplete(IPlayerSession session)
|
||||
{
|
||||
return _users[session.UserId].Task;
|
||||
}
|
||||
|
||||
public bool IsLoadComplete(IPlayerSession session)
|
||||
{
|
||||
return _users[session.UserId].Task.IsCompleted;
|
||||
}
|
||||
|
||||
private sealed record UserData(CancellationTokenSource Cancel, Task Task);
|
||||
}
|
||||
@@ -14,9 +14,9 @@ using Content.Server.GhostKick;
|
||||
using Content.Server.GuideGenerator;
|
||||
using Content.Server.Info;
|
||||
using Content.Server.IoC;
|
||||
using Content.Server.LandMines;
|
||||
using Content.Server.Maps;
|
||||
using Content.Server.NodeContainer.NodeGroups;
|
||||
using Content.Server.Players.PlayTimeTracking;
|
||||
using Content.Server.Preferences.Managers;
|
||||
using Content.Server.ServerUpdates;
|
||||
using Content.Server.Voting.Managers;
|
||||
@@ -25,7 +25,6 @@ using Content.Shared.CCVar;
|
||||
using Content.Shared.Kitchen;
|
||||
using Robust.Server;
|
||||
using Robust.Server.Bql;
|
||||
using Robust.Server.Player;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Server.ServerStatus;
|
||||
using Robust.Shared.ContentPack;
|
||||
@@ -40,6 +39,7 @@ namespace Content.Server.Entry
|
||||
private EuiManager _euiManager = default!;
|
||||
private IVoteManager _voteManager = default!;
|
||||
private ServerUpdateManager _updateManager = default!;
|
||||
private PlayTimeTrackingManager? _playTimeTracking;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Init()
|
||||
@@ -78,8 +78,7 @@ namespace Content.Server.Entry
|
||||
_euiManager = IoCManager.Resolve<EuiManager>();
|
||||
_voteManager = IoCManager.Resolve<IVoteManager>();
|
||||
_updateManager = IoCManager.Resolve<ServerUpdateManager>();
|
||||
|
||||
var playerManager = IoCManager.Resolve<IPlayerManager>();
|
||||
_playTimeTracking = IoCManager.Resolve<PlayTimeTrackingManager>();
|
||||
|
||||
var logManager = IoCManager.Resolve<ILogManager>();
|
||||
logManager.GetSawmill("Storage").Level = LogLevel.Info;
|
||||
@@ -96,6 +95,7 @@ namespace Content.Server.Entry
|
||||
|
||||
_voteManager.Initialize();
|
||||
_updateManager.Initialize();
|
||||
_playTimeTracking.Initialize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,8 +151,14 @@ namespace Content.Server.Entry
|
||||
|
||||
case ModUpdateLevel.FramePostEngine:
|
||||
_updateManager.Update();
|
||||
_playTimeTracking?.Update();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
_playTimeTracking?.Shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Linq;
|
||||
using Content.Server.Ghost;
|
||||
using Content.Server.Ghost.Components;
|
||||
using Content.Server.Players;
|
||||
using Content.Server.Roles;
|
||||
using Content.Server.Spawners.Components;
|
||||
using Content.Server.Speech.Components;
|
||||
using Content.Server.Station.Components;
|
||||
@@ -18,6 +17,7 @@ using Robust.Shared.Map;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Random;
|
||||
using Robust.Shared.Utility;
|
||||
using Job = Content.Server.Roles.Job;
|
||||
|
||||
namespace Content.Server.GameTicking
|
||||
{
|
||||
@@ -92,7 +92,10 @@ namespace Content.Server.GameTicking
|
||||
var character = GetPlayerProfile(player);
|
||||
|
||||
var jobBans = _roleBanManager.GetJobBans(player.UserId);
|
||||
if (jobBans == null || (jobId != null && jobBans.Contains(jobId)))
|
||||
if (jobBans == null || jobId != null && jobBans.Contains(jobId))
|
||||
return;
|
||||
|
||||
if (jobId != null && !_playTimeTrackings.IsAllowed(player, jobId))
|
||||
return;
|
||||
SpawnPlayer(player, character, station, jobId, lateJoin);
|
||||
}
|
||||
@@ -130,9 +133,18 @@ namespace Content.Server.GameTicking
|
||||
return;
|
||||
}
|
||||
|
||||
// Figure out job restrictions
|
||||
var restrictedRoles = new HashSet<string>();
|
||||
|
||||
var getDisallowed = _playTimeTrackings.GetDisallowedJobs(player);
|
||||
restrictedRoles.UnionWith(getDisallowed);
|
||||
|
||||
var jobBans = _roleBanManager.GetJobBans(player.UserId);
|
||||
if(jobBans != null) restrictedRoles.UnionWith(jobBans);
|
||||
|
||||
// Pick best job best on prefs.
|
||||
jobId ??= _stationJobs.PickBestAvailableJobWithPriority(station, character.JobPriorities, true,
|
||||
_roleBanManager.GetJobBans(player.UserId));
|
||||
restrictedRoles);
|
||||
// If no job available, stay in lobby, or if no lobby spawn as observer
|
||||
if (jobId is null)
|
||||
{
|
||||
@@ -161,6 +173,8 @@ namespace Content.Server.GameTicking
|
||||
var job = new Job(newMind, jobPrototype);
|
||||
newMind.AddRole(job);
|
||||
|
||||
_playTimeTrackings.PlayerRolesChanged(player);
|
||||
|
||||
if (lateJoin)
|
||||
{
|
||||
_chatSystem.DispatchStationAnnouncement(station,
|
||||
@@ -217,12 +231,11 @@ namespace Content.Server.GameTicking
|
||||
|
||||
public void MakeJoinGame(IPlayerSession player, EntityUid station, string? jobId = null)
|
||||
{
|
||||
if (!_playersInLobby.ContainsKey(player)) return;
|
||||
|
||||
if (!_prefsManager.HavePreferencesLoaded(player))
|
||||
{
|
||||
if (!_playersInLobby.ContainsKey(player))
|
||||
return;
|
||||
|
||||
if (!_userDb.IsLoadComplete(player))
|
||||
return;
|
||||
}
|
||||
|
||||
SpawnPlayer(player, station, jobId);
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ using Content.Server.MoMMI;
|
||||
using Content.Server.NodeContainer.NodeGroups;
|
||||
using Content.Server.Objectives;
|
||||
using Content.Server.Objectives.Interfaces;
|
||||
using Content.Server.Players.PlayTimeTracking;
|
||||
using Content.Server.Preferences.Managers;
|
||||
using Content.Server.ServerUpdates;
|
||||
using Content.Server.Voting.Managers;
|
||||
@@ -59,6 +60,8 @@ namespace Content.Server.IoC
|
||||
IoCManager.Register<GhostKickManager>();
|
||||
IoCManager.Register<ISharedAdminLogManager, AdminLogManager>();
|
||||
IoCManager.Register<IAdminLogManager, AdminLogManager>();
|
||||
IoCManager.Register<PlayTimeTrackingManager>();
|
||||
IoCManager.Register<UserDbDataManager>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ namespace Content.Server.Mind
|
||||
_roles.Add(role);
|
||||
role.Greet();
|
||||
|
||||
var message = new RoleAddedEvent(role);
|
||||
var message = new RoleAddedEvent(this, role);
|
||||
if (OwnedEntity != null)
|
||||
{
|
||||
IoCManager.Resolve<IEntityManager>().EventBus.RaiseLocalEvent(OwnedEntity.Value, message, true);
|
||||
@@ -203,7 +203,7 @@ namespace Content.Server.Mind
|
||||
|
||||
_roles.Remove(role);
|
||||
|
||||
var message = new RoleRemovedEvent(role);
|
||||
var message = new RoleRemovedEvent(this, role);
|
||||
|
||||
if (OwnedEntity != null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Content.Server.Database;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.Players.PlayTimeTracking;
|
||||
using Robust.Server.Player;
|
||||
using Robust.Shared.Asynchronous;
|
||||
using Robust.Shared.Collections;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Exceptions;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Timing;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Server.Players.PlayTimeTracking;
|
||||
|
||||
public delegate void CalcPlayTimeTrackersCallback(IPlayerSession player, HashSet<string> trackers);
|
||||
|
||||
/// <summary>
|
||||
/// Tracks play time for players, across all roles.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Play time is tracked in distinct "trackers" (defined in <see cref="PlayTimeTrackerPrototype"/>).
|
||||
/// Most jobs correspond to one such tracker, but there are also more trackers like <c>"Overall"</c> which tracks cumulative playtime across all roles.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// To actually figure out what trackers are active, <see cref="CalcTrackers"/> is invoked in a "refresh".
|
||||
/// The next time the trackers are refreshed, these trackers all get the time since the last refresh added.
|
||||
/// Refreshes are triggered by <see cref="QueueRefreshTrackers"/>, and should be raised through events such as players' roles changing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Because the calculation system does not persistently keep ticking timers,
|
||||
/// APIs like <see cref="GetPlayTimeForTracker"/> will not see live-updating information.
|
||||
/// A light-weight form of refresh is a "flush" through <see cref="FlushTracker"/>.
|
||||
/// This will not cause active trackers to be re-calculated like a refresh,
|
||||
/// but it will ensure stored play time info is up to date.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Trackers are auto-saved to DB on a cvar-configured interval. This interval is independent of refreshes,
|
||||
/// but does do a flush to get the latest info.
|
||||
/// Some things like round restarts and player disconnects cause immediate saving of one or all sessions.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tracker data is loaded from the database when the client connects as part of <see cref="UserDbDataManager"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Timing logic in this manager is ran **out** of simulation.
|
||||
/// This means that we use real time, not simulation time, for timing everything here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Operations like refreshing and sending play time info to clients are deferred until the next frame (note: not tick).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PlayTimeTrackingManager
|
||||
{
|
||||
[Dependency] private readonly IServerDbManager _db = default!;
|
||||
[Dependency] private readonly IServerNetManager _net = default!;
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
[Dependency] private readonly IGameTiming _timing = default!;
|
||||
[Dependency] private readonly ITaskManager _task = default!;
|
||||
[Dependency] private readonly IRuntimeLog _runtimeLog = default!;
|
||||
|
||||
private ISawmill _sawmill = default!;
|
||||
|
||||
// List of players that need some kind of update (refresh timers or resend).
|
||||
private ValueList<IPlayerSession> _playersDirty;
|
||||
|
||||
// DB auto-saving logic.
|
||||
private TimeSpan _saveInterval;
|
||||
private TimeSpan _lastSave;
|
||||
|
||||
// List of pending DB save operations.
|
||||
// We must block server shutdown on these to avoid losing data.
|
||||
private readonly List<Task> _pendingSaveTasks = new();
|
||||
|
||||
private readonly Dictionary<IPlayerSession, PlayTimeData> _playTimeData = new();
|
||||
|
||||
public event CalcPlayTimeTrackersCallback? CalcTrackers;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_sawmill = Logger.GetSawmill("play_time");
|
||||
|
||||
_net.RegisterNetMessage<MsgPlayTime>();
|
||||
|
||||
_cfg.OnValueChanged(CCVars.PlayTimeSaveInterval, f => _saveInterval = TimeSpan.FromSeconds(f), true);
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
Save();
|
||||
|
||||
_task.BlockWaitOnTask(Task.WhenAll(_pendingSaveTasks));
|
||||
}
|
||||
|
||||
public void Update()
|
||||
{
|
||||
// NOTE: This is run **out** of simulation. This is intentional.
|
||||
|
||||
UpdateDirtyPlayers();
|
||||
|
||||
if (_timing.RealTime < _lastSave + _saveInterval)
|
||||
return;
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
private void UpdateDirtyPlayers()
|
||||
{
|
||||
if (_playersDirty.Count == 0)
|
||||
return;
|
||||
|
||||
var time = _timing.RealTime;
|
||||
|
||||
foreach (var player in _playersDirty)
|
||||
{
|
||||
if (!_playTimeData.TryGetValue(player, out var data))
|
||||
continue;
|
||||
|
||||
DebugTools.Assert(data.IsDirty);
|
||||
|
||||
if (data.NeedRefreshTackers)
|
||||
{
|
||||
RefreshSingleTracker(player, data, time);
|
||||
data.NeedRefreshTackers = true;
|
||||
}
|
||||
|
||||
if (data.NeedSendTimers)
|
||||
{
|
||||
SendPlayTimes(player);
|
||||
data.NeedSendTimers = false;
|
||||
}
|
||||
|
||||
data.IsDirty = false;
|
||||
}
|
||||
|
||||
_playersDirty.Clear();
|
||||
}
|
||||
|
||||
private void RefreshSingleTracker(IPlayerSession dirty, PlayTimeData data, TimeSpan time)
|
||||
{
|
||||
DebugTools.Assert(data.Initialized);
|
||||
|
||||
FlushSingleTracker(data, time);
|
||||
|
||||
data.NeedRefreshTackers = false;
|
||||
|
||||
data.ActiveTrackers.Clear();
|
||||
|
||||
// Fetch new trackers.
|
||||
// Inside try catch to avoid state corruption from bad callback code.
|
||||
try
|
||||
{
|
||||
CalcTrackers?.Invoke(dirty, data.ActiveTrackers);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_runtimeLog.LogException(e, "PlayTime CalcTrackers");
|
||||
data.ActiveTrackers.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flush all trackers for all players.
|
||||
/// </summary>
|
||||
/// <seealso cref="FlushTracker"/>
|
||||
public void FlushAllTrackers()
|
||||
{
|
||||
var time = _timing.RealTime;
|
||||
|
||||
foreach (var data in _playTimeData.Values)
|
||||
{
|
||||
FlushSingleTracker(data, time);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flush time tracker information for a player,
|
||||
/// so APIs like <see cref="GetPlayTimeForTracker"/> return up-to-date info.
|
||||
/// </summary>
|
||||
/// <seealso cref="FlushAllTrackers"/>
|
||||
public void FlushTracker(IPlayerSession player)
|
||||
{
|
||||
var time = _timing.RealTime;
|
||||
var data = _playTimeData[player];
|
||||
|
||||
FlushSingleTracker(data, time);
|
||||
}
|
||||
|
||||
private static void FlushSingleTracker(PlayTimeData data, TimeSpan time)
|
||||
{
|
||||
var delta = time - data.LastUpdate;
|
||||
data.LastUpdate = time;
|
||||
|
||||
// Flush active trackers into semi-permanent storage.
|
||||
foreach (var active in data.ActiveTrackers)
|
||||
{
|
||||
AddTimeToTracker(data, active, delta);
|
||||
}
|
||||
}
|
||||
|
||||
private void SendPlayTimes(IPlayerSession pSession)
|
||||
{
|
||||
var roles = GetTrackerTimes(pSession);
|
||||
|
||||
var msg = new MsgPlayTime
|
||||
{
|
||||
Trackers = roles
|
||||
};
|
||||
|
||||
_net.ServerSendMessage(msg, pSession.ConnectedClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save all modified time trackers for all players to the database.
|
||||
/// </summary>
|
||||
public async void Save()
|
||||
{
|
||||
FlushAllTrackers();
|
||||
|
||||
_lastSave = _timing.RealTime;
|
||||
|
||||
TrackPending(DoSaveAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save all modified time trackers for a player to the database.
|
||||
/// </summary>
|
||||
public async void SaveSession(IPlayerSession session)
|
||||
{
|
||||
// This causes all trackers to refresh, ah well.
|
||||
FlushAllTrackers();
|
||||
|
||||
TrackPending(DoSaveSessionAsync(session));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Track a database save task to make sure we block server shutdown on it.
|
||||
/// </summary>
|
||||
private async void TrackPending(Task task)
|
||||
{
|
||||
_pendingSaveTasks.Add(task);
|
||||
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingSaveTasks.Remove(task);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DoSaveAsync()
|
||||
{
|
||||
var log = new List<PlayTimeUpdate>();
|
||||
|
||||
foreach (var (player, data) in _playTimeData)
|
||||
{
|
||||
foreach (var tracker in data.DbTrackersDirty)
|
||||
{
|
||||
log.Add(new PlayTimeUpdate(player.UserId, tracker, data.TrackerTimes[tracker]));
|
||||
}
|
||||
|
||||
data.DbTrackersDirty.Clear();
|
||||
}
|
||||
|
||||
if (log.Count == 0)
|
||||
return;
|
||||
|
||||
// NOTE: we do replace updates here, not incremental additions.
|
||||
// This means that if you're playing on two servers at the same time, they'll step on each other's feet.
|
||||
// This is considered fine.
|
||||
await _db.UpdatePlayTimes(log);
|
||||
|
||||
_sawmill.Debug($"Saved {log.Count} trackers");
|
||||
}
|
||||
|
||||
private async Task DoSaveSessionAsync(IPlayerSession session)
|
||||
{
|
||||
var log = new List<PlayTimeUpdate>();
|
||||
|
||||
var data = _playTimeData[session];
|
||||
|
||||
foreach (var tracker in data.DbTrackersDirty)
|
||||
{
|
||||
log.Add(new PlayTimeUpdate(session.UserId, tracker, data.TrackerTimes[tracker]));
|
||||
}
|
||||
|
||||
data.DbTrackersDirty.Clear();
|
||||
|
||||
// NOTE: we do replace updates here, not incremental additions.
|
||||
// This means that if you're playing on two servers at the same time, they'll step on each other's feet.
|
||||
// This is considered fine.
|
||||
await _db.UpdatePlayTimes(log);
|
||||
|
||||
_sawmill.Debug($"Saved {log.Count} trackers for {session.Name}");
|
||||
}
|
||||
|
||||
public async Task LoadData(IPlayerSession session, CancellationToken cancel)
|
||||
{
|
||||
var data = new PlayTimeData();
|
||||
_playTimeData.Add(session, data);
|
||||
|
||||
var playTimes = await _db.GetPlayTimes(session.UserId);
|
||||
cancel.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var timer in playTimes)
|
||||
{
|
||||
data.TrackerTimes.Add(timer.Tracker, timer.TimeSpent);
|
||||
}
|
||||
|
||||
data.Initialized = true;
|
||||
|
||||
QueueRefreshTrackers(session);
|
||||
QueueSendTimers(session);
|
||||
}
|
||||
|
||||
public void ClientDisconnected(IPlayerSession session)
|
||||
{
|
||||
SaveSession(session);
|
||||
|
||||
_playTimeData.Remove(session);
|
||||
}
|
||||
|
||||
public void AddTimeToTracker(IPlayerSession id, string tracker, TimeSpan time)
|
||||
{
|
||||
if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized)
|
||||
throw new InvalidOperationException("Play time info is not yet loaded for this player!");
|
||||
|
||||
AddTimeToTracker(data, tracker, time);
|
||||
}
|
||||
|
||||
private static void AddTimeToTracker(PlayTimeData data, string tracker, TimeSpan time)
|
||||
{
|
||||
ref var timer = ref CollectionsMarshal.GetValueRefOrAddDefault(data.TrackerTimes, tracker, out _);
|
||||
timer += time;
|
||||
|
||||
data.DbTrackersDirty.Add(tracker);
|
||||
}
|
||||
|
||||
public void AddTimeToOverallPlaytime(IPlayerSession id, TimeSpan time)
|
||||
{
|
||||
AddTimeToTracker(id, PlayTimeTrackingShared.TrackerOverall, time);
|
||||
}
|
||||
|
||||
public TimeSpan GetOverallPlaytime(IPlayerSession id)
|
||||
{
|
||||
return GetPlayTimeForTracker(id, PlayTimeTrackingShared.TrackerOverall);
|
||||
}
|
||||
|
||||
public Dictionary<string, TimeSpan> GetTrackerTimes(IPlayerSession id)
|
||||
{
|
||||
if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized)
|
||||
throw new InvalidOperationException("Play time info is not yet loaded for this player!");
|
||||
|
||||
return data.TrackerTimes;
|
||||
}
|
||||
|
||||
public TimeSpan GetPlayTimeForTracker(IPlayerSession id, string tracker)
|
||||
{
|
||||
if (!_playTimeData.TryGetValue(id, out var data) || !data.Initialized)
|
||||
throw new InvalidOperationException("Play time info is not yet loaded for this player!");
|
||||
|
||||
return data.TrackerTimes.GetValueOrDefault(tracker);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue for play time trackers to be refreshed on a player, in case the set of active trackers may have changed.
|
||||
/// </summary>
|
||||
public void QueueRefreshTrackers(IPlayerSession player)
|
||||
{
|
||||
if (DirtyPlayer(player) is { } data)
|
||||
data.NeedRefreshTackers = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue for play time information to be sent to a client, for showing in UIs etc.
|
||||
/// </summary>
|
||||
public void QueueSendTimers(IPlayerSession player)
|
||||
{
|
||||
if (DirtyPlayer(player) is { } data)
|
||||
data.NeedSendTimers = true;
|
||||
}
|
||||
|
||||
private PlayTimeData? DirtyPlayer(IPlayerSession player)
|
||||
{
|
||||
if (!_playTimeData.TryGetValue(player, out var data) || !data.Initialized)
|
||||
return null;
|
||||
|
||||
if (!data.IsDirty)
|
||||
{
|
||||
data.IsDirty = true;
|
||||
_playersDirty.Add(player);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Play time info for a particular player.
|
||||
/// </summary>
|
||||
private sealed class PlayTimeData
|
||||
{
|
||||
// Queued update flags
|
||||
public bool IsDirty;
|
||||
public bool NeedRefreshTackers;
|
||||
public bool NeedSendTimers;
|
||||
|
||||
// Active tracking info
|
||||
public readonly HashSet<string> ActiveTrackers = new();
|
||||
public TimeSpan LastUpdate;
|
||||
|
||||
// Stored tracked time info.
|
||||
|
||||
/// <summary>
|
||||
/// Have we finished retrieving our data from the DB?
|
||||
/// </summary>
|
||||
public bool Initialized;
|
||||
|
||||
public readonly Dictionary<string, TimeSpan> TrackerTimes = new();
|
||||
|
||||
/// <summary>
|
||||
/// Set of trackers which are different from their DB values and need to be saved to DB.
|
||||
/// </summary>
|
||||
public readonly HashSet<string> DbTrackersDirty = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Linq;
|
||||
using Content.Server.Afk;
|
||||
using Content.Server.Afk.Events;
|
||||
using Content.Server.GameTicking;
|
||||
using Content.Server.Roles;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.GameTicking;
|
||||
using Content.Shared.MobState;
|
||||
using Content.Shared.MobState.Components;
|
||||
using Content.Shared.Players.PlayTimeTracking;
|
||||
using Content.Shared.Roles;
|
||||
using Robust.Server.GameObjects;
|
||||
using Robust.Server.Player;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Server.Players.PlayTimeTracking;
|
||||
|
||||
/// <summary>
|
||||
/// Connects <see cref="PlayTimeTrackingManager"/> to the simulation state. Reports trackers and such.
|
||||
/// </summary>
|
||||
public sealed class PlayTimeTrackingSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly IAfkManager _afk = default!;
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
[Dependency] private readonly IPrototypeManager _prototypes = default!;
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
[Dependency] private readonly PlayTimeTrackingManager _tracking = default!;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
_tracking.CalcTrackers += CalcTrackers;
|
||||
|
||||
SubscribeLocalEvent<RoundRestartCleanupEvent>(OnRoundEnd);
|
||||
SubscribeLocalEvent<PlayerAttachedEvent>(OnPlayerAttached);
|
||||
SubscribeLocalEvent<PlayerDetachedEvent>(OnPlayerDetached);
|
||||
SubscribeLocalEvent<RoleAddedEvent>(OnRoleAdd);
|
||||
SubscribeLocalEvent<RoleRemovedEvent>(OnRoleRemove);
|
||||
SubscribeLocalEvent<AFKEvent>(OnAFK);
|
||||
SubscribeLocalEvent<UnAFKEvent>(OnUnAFK);
|
||||
SubscribeLocalEvent<MobStateChangedEvent>(OnMobStateChanged);
|
||||
SubscribeLocalEvent<PlayerJoinedLobbyEvent>(OnPlayerJoinedLobby);
|
||||
}
|
||||
|
||||
public override void Shutdown()
|
||||
{
|
||||
base.Shutdown();
|
||||
|
||||
_tracking.CalcTrackers -= CalcTrackers;
|
||||
}
|
||||
|
||||
private void CalcTrackers(IPlayerSession player, HashSet<string> trackers)
|
||||
{
|
||||
if (_afk.IsAfk(player))
|
||||
return;
|
||||
|
||||
if (!IsPlayerAlive(player))
|
||||
return;
|
||||
|
||||
trackers.Add(PlayTimeTrackingShared.TrackerOverall);
|
||||
trackers.UnionWith(GetTimedRoles(player));
|
||||
}
|
||||
|
||||
private bool IsPlayerAlive(IPlayerSession session)
|
||||
{
|
||||
var attached = session.AttachedEntity;
|
||||
if (attached == null)
|
||||
return false;
|
||||
|
||||
if (!TryComp<MobStateComponent>(attached, out var state))
|
||||
return false;
|
||||
|
||||
return state.CurrentState is DamageState.Alive or DamageState.Critical;
|
||||
}
|
||||
|
||||
public IEnumerable<string> GetTimedRoles(Mind.Mind mind)
|
||||
{
|
||||
foreach (var role in mind.AllRoles)
|
||||
{
|
||||
if (role is not IRoleTimer timer)
|
||||
continue;
|
||||
|
||||
yield return _prototypes.Index<PlayTimeTrackerPrototype>(timer.Timer).ID;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetTimedRoles(IPlayerSession session)
|
||||
{
|
||||
var contentData = _playerManager.GetPlayerData(session.UserId).ContentData();
|
||||
|
||||
if (contentData?.Mind == null)
|
||||
return Enumerable.Empty<string>();
|
||||
|
||||
return GetTimedRoles(contentData.Mind);
|
||||
}
|
||||
|
||||
private void OnRoleRemove(RoleRemovedEvent ev)
|
||||
{
|
||||
if (ev.Mind.Session == null)
|
||||
return;
|
||||
|
||||
_tracking.QueueRefreshTrackers(ev.Mind.Session);
|
||||
}
|
||||
|
||||
private void OnRoleAdd(RoleAddedEvent ev)
|
||||
{
|
||||
if (ev.Mind.Session == null)
|
||||
return;
|
||||
|
||||
_tracking.QueueRefreshTrackers(ev.Mind.Session);
|
||||
}
|
||||
|
||||
private void OnRoundEnd(RoundRestartCleanupEvent ev)
|
||||
{
|
||||
_tracking.Save();
|
||||
}
|
||||
|
||||
private void OnUnAFK(ref UnAFKEvent ev)
|
||||
{
|
||||
_tracking.QueueRefreshTrackers(ev.Session);
|
||||
}
|
||||
|
||||
private void OnAFK(ref AFKEvent ev)
|
||||
{
|
||||
_tracking.QueueRefreshTrackers(ev.Session);
|
||||
}
|
||||
|
||||
private void OnPlayerAttached(PlayerAttachedEvent ev)
|
||||
{
|
||||
_tracking.QueueRefreshTrackers(ev.Player);
|
||||
}
|
||||
|
||||
private void OnPlayerDetached(PlayerDetachedEvent ev)
|
||||
{
|
||||
// This doesn't fire if the player doesn't leave their body. I guess it's fine?
|
||||
_tracking.QueueRefreshTrackers(ev.Player);
|
||||
}
|
||||
|
||||
private void OnMobStateChanged(MobStateChangedEvent ev)
|
||||
{
|
||||
if (!TryComp(ev.Entity, out ActorComponent? actor))
|
||||
return;
|
||||
|
||||
_tracking.QueueRefreshTrackers(actor.PlayerSession);
|
||||
}
|
||||
|
||||
private void OnPlayerJoinedLobby(PlayerJoinedLobbyEvent ev)
|
||||
{
|
||||
_tracking.QueueRefreshTrackers(ev.PlayerSession);
|
||||
// Send timers to client when they join lobby, so the UIs are up-to-date.
|
||||
_tracking.QueueSendTimers(ev.PlayerSession);
|
||||
}
|
||||
|
||||
public bool IsAllowed(IPlayerSession player, string role)
|
||||
{
|
||||
if (!_prototypes.TryIndex<JobPrototype>(role, out var job) ||
|
||||
job.Requirements == null ||
|
||||
!_cfg.GetCVar(CCVars.GameRoleTimers))
|
||||
return true;
|
||||
|
||||
var playTimes = _tracking.GetTrackerTimes(player);
|
||||
|
||||
return JobRequirements.TryRequirementsMet(job, playTimes, out _, _prototypes);
|
||||
}
|
||||
|
||||
public HashSet<string> GetDisallowedJobs(IPlayerSession player)
|
||||
{
|
||||
var roles = new HashSet<string>();
|
||||
if (!_cfg.GetCVar(CCVars.GameRoleTimers))
|
||||
return roles;
|
||||
|
||||
var playTimes = _tracking.GetTrackerTimes(player);
|
||||
|
||||
foreach (var job in _prototypes.EnumeratePrototypes<JobPrototype>())
|
||||
{
|
||||
if (job.Requirements != null)
|
||||
{
|
||||
foreach (var requirement in job.Requirements)
|
||||
{
|
||||
if (JobRequirements.TryRequirementMet(requirement, playTimes, out _, _prototypes))
|
||||
continue;
|
||||
|
||||
goto NoRole;
|
||||
}
|
||||
}
|
||||
|
||||
roles.Add(job.ID);
|
||||
NoRole:;
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
public void RemoveDisallowedJobs(NetUserId userId, ref List<string> jobs)
|
||||
{
|
||||
if (!_cfg.GetCVar(CCVars.GameRoleTimers))
|
||||
return;
|
||||
|
||||
var player = _playerManager.GetSessionByUserId(userId);
|
||||
var playTimes = _tracking.GetTrackerTimes(player);
|
||||
|
||||
for (var i = 0; i < jobs.Count; i++)
|
||||
{
|
||||
var job = jobs[i];
|
||||
|
||||
if (!_prototypes.TryIndex<JobPrototype>(job, out var jobber) ||
|
||||
jobber.Requirements == null ||
|
||||
jobber.Requirements.Count == 0)
|
||||
continue;
|
||||
|
||||
foreach (var requirement in jobber.Requirements)
|
||||
{
|
||||
if (JobRequirements.TryRequirementMet(requirement, playTimes, out _, _prototypes))
|
||||
continue;
|
||||
|
||||
jobs.RemoveSwap(i);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void PlayerRolesChanged(IPlayerSession player)
|
||||
{
|
||||
_tracking.QueueRefreshTrackers(player);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Content.Shared.Preferences;
|
||||
using Robust.Server.Player;
|
||||
@@ -9,12 +10,9 @@ namespace Content.Server.Preferences.Managers
|
||||
{
|
||||
void Init();
|
||||
|
||||
void OnClientConnected(IPlayerSession session);
|
||||
Task LoadData(IPlayerSession session, CancellationToken cancel);
|
||||
void OnClientDisconnected(IPlayerSession session);
|
||||
|
||||
bool HavePreferencesLoaded(IPlayerSession session);
|
||||
Task WaitPreferencesLoaded(IPlayerSession session);
|
||||
|
||||
PlayerPreferences GetPreferences(NetUserId userId);
|
||||
IEnumerable<KeyValuePair<NetUserId, ICharacterProfile>> GetSelectedProfilesForPlayers(List<NetUserId> userIds);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Content.Server.Database;
|
||||
using Content.Shared.CCVar;
|
||||
@@ -43,7 +44,7 @@ namespace Content.Server.Preferences.Managers
|
||||
var index = message.SelectedCharacterIndex;
|
||||
var userId = message.MsgChannel.UserId;
|
||||
|
||||
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded.IsCompleted)
|
||||
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded)
|
||||
{
|
||||
Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded.");
|
||||
return;
|
||||
@@ -83,7 +84,7 @@ namespace Content.Server.Preferences.Managers
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded.IsCompleted)
|
||||
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded)
|
||||
{
|
||||
Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded.");
|
||||
return;
|
||||
@@ -116,7 +117,7 @@ namespace Content.Server.Preferences.Managers
|
||||
var slot = message.Slot;
|
||||
var userId = message.MsgChannel.UserId;
|
||||
|
||||
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded.IsCompleted)
|
||||
if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded)
|
||||
{
|
||||
Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded.");
|
||||
return;
|
||||
@@ -163,14 +164,15 @@ namespace Content.Server.Preferences.Managers
|
||||
}
|
||||
}
|
||||
|
||||
public async void OnClientConnected(IPlayerSession session)
|
||||
// Should only be called via UserDbDataManager.
|
||||
public async Task LoadData(IPlayerSession session, CancellationToken cancel)
|
||||
{
|
||||
if (!ShouldStorePrefs(session.ConnectedClient.AuthType))
|
||||
{
|
||||
// Don't store data for guests.
|
||||
var prefsData = new PlayerPrefData
|
||||
{
|
||||
PrefsLoaded = Task.CompletedTask,
|
||||
PrefsLoaded = true,
|
||||
Prefs = new PlayerPreferences(
|
||||
new[] {new KeyValuePair<int, ICharacterProfile>(0, HumanoidCharacterProfile.Random())},
|
||||
0, Color.Transparent)
|
||||
@@ -182,7 +184,6 @@ namespace Content.Server.Preferences.Managers
|
||||
{
|
||||
var prefsData = new PlayerPrefData();
|
||||
var loadTask = LoadPrefs();
|
||||
prefsData.PrefsLoaded = loadTask;
|
||||
_cachedPlayerPrefs[session.UserId] = prefsData;
|
||||
|
||||
await loadTask;
|
||||
@@ -191,6 +192,7 @@ namespace Content.Server.Preferences.Managers
|
||||
{
|
||||
var prefs = await GetOrCreatePreferencesAsync(session.UserId);
|
||||
prefsData.Prefs = prefs;
|
||||
prefsData.PrefsLoaded = true;
|
||||
|
||||
var msg = new MsgPreferencesAndSettings();
|
||||
msg.Preferences = prefs;
|
||||
@@ -203,7 +205,6 @@ namespace Content.Server.Preferences.Managers
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void OnClientDisconnected(IPlayerSession session)
|
||||
{
|
||||
_cachedPlayerPrefs.Remove(session.UserId);
|
||||
@@ -214,11 +215,6 @@ namespace Content.Server.Preferences.Managers
|
||||
return _cachedPlayerPrefs.ContainsKey(session.UserId);
|
||||
}
|
||||
|
||||
public Task WaitPreferencesLoaded(IPlayerSession session)
|
||||
{
|
||||
return _cachedPlayerPrefs[session.UserId].PrefsLoaded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves preferences for the given username from storage.
|
||||
/// Creates and saves default preferences if they are not found, then returns them.
|
||||
@@ -303,7 +299,7 @@ namespace Content.Server.Preferences.Managers
|
||||
|
||||
private sealed class PlayerPrefData
|
||||
{
|
||||
public Task PrefsLoaded = default!;
|
||||
public bool PrefsLoaded;
|
||||
public PlayerPreferences? Prefs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Linq;
|
||||
using Content.Server.Administration.Managers;
|
||||
using Content.Server.Players.PlayTimeTracking;
|
||||
using Content.Server.Roles;
|
||||
using Content.Server.Station.Components;
|
||||
using Content.Shared.Preferences;
|
||||
using Content.Shared.Roles;
|
||||
@@ -15,6 +17,7 @@ public sealed partial class StationJobsSystem
|
||||
{
|
||||
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
|
||||
[Dependency] private readonly RoleBanManager _roleBanManager = default!;
|
||||
[Dependency] private readonly PlayTimeTrackingSystem _playTime = default!;
|
||||
|
||||
private Dictionary<int, HashSet<string>> _jobsByWeight = default!;
|
||||
private List<int> _orderedWeights = default!;
|
||||
@@ -339,11 +342,15 @@ public sealed partial class StationJobsSystem
|
||||
foreach (var (player, profile) in profiles)
|
||||
{
|
||||
var roleBans = _roleBanManager.GetJobBans(player);
|
||||
var profileJobs = profile.JobPriorities.Keys.ToList();
|
||||
_playTime.RemoveDisallowedJobs(player, ref profileJobs);
|
||||
|
||||
List<string>? availableJobs = null;
|
||||
|
||||
foreach (var (jobId, priority) in profile.JobPriorities)
|
||||
foreach (var jobId in profileJobs)
|
||||
{
|
||||
var priority = profile.JobPriorities[jobId];
|
||||
|
||||
if (!(priority == selectedPriority || selectedPriority is null))
|
||||
continue;
|
||||
|
||||
@@ -357,7 +364,6 @@ public sealed partial class StationJobsSystem
|
||||
continue;
|
||||
|
||||
availableJobs ??= new List<string>(profile.JobPriorities.Count);
|
||||
|
||||
availableJobs.Add(jobId);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -176,6 +176,12 @@ namespace Content.Shared.CCVar
|
||||
public static readonly CVarDef<bool>
|
||||
GameMapRotation = CVarDef.Create<bool>("game.map_rotation", true, CVar.SERVERONLY);
|
||||
|
||||
/// <summary>
|
||||
/// If roles should be restricted based on time.
|
||||
/// </summary>
|
||||
public static readonly CVarDef<bool>
|
||||
GameRoleTimers = CVarDef.Create("game.role_timers", false, CVar.SERVER | CVar.REPLICATED);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a random position offset will be applied to the station on roundstart.
|
||||
/// </summary>
|
||||
@@ -1091,5 +1097,16 @@ namespace Content.Shared.CCVar
|
||||
/// </summary>
|
||||
public static readonly CVarDef<float> GhostRoleTime =
|
||||
CVarDef.Create("ghost.role_time", 3f, CVar.REPLICATED);
|
||||
|
||||
/*
|
||||
* PLAYTIME
|
||||
*/
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Time between play time autosaves, in seconds.
|
||||
/// </summary>
|
||||
public static readonly CVarDef<float>
|
||||
PlayTimeSaveInterval = CVarDef.Create("playtime.save_interval", 900f, CVar.SERVERONLY);
|
||||
}
|
||||
}
|
||||
|
||||
34
Content.Shared/Players/PlayTimeTracking/MsgPlayTime.cs
Normal file
34
Content.Shared/Players/PlayTimeTracking/MsgPlayTime.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Lidgren.Network;
|
||||
using Robust.Shared.Network;
|
||||
|
||||
namespace Content.Shared.Players.PlayTimeTracking;
|
||||
|
||||
/// <summary>
|
||||
/// Sent server -> client to inform the client of their play times.
|
||||
/// </summary>
|
||||
public sealed class MsgPlayTime : NetMessage
|
||||
{
|
||||
public override MsgGroups MsgGroup => MsgGroups.EntityEvent;
|
||||
|
||||
public Dictionary<string, TimeSpan> Trackers = new();
|
||||
|
||||
public override void ReadFromBuffer(NetIncomingMessage buffer)
|
||||
{
|
||||
var count = buffer.ReadVariableInt32();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
Trackers.Add(buffer.ReadString(), buffer.ReadTimeSpan());
|
||||
}
|
||||
}
|
||||
|
||||
public override void WriteToBuffer(NetOutgoingMessage buffer)
|
||||
{
|
||||
buffer.WriteVariableInt32(Trackers.Count);
|
||||
|
||||
foreach (var (role, time) in Trackers)
|
||||
{
|
||||
buffer.Write(role);
|
||||
buffer.Write(time);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Robust.Shared.Prototypes;
|
||||
|
||||
namespace Content.Shared.Players.PlayTimeTracking;
|
||||
|
||||
/// <summary>
|
||||
/// Given to a role to specify its ID for role-timer tracking purposes. That's it.
|
||||
/// </summary>
|
||||
[Prototype("playTimeTracker")]
|
||||
public sealed class PlayTimeTrackerPrototype : IPrototype
|
||||
{
|
||||
[IdDataField] public string ID { get; } = default!;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Content.Shared.Players.PlayTimeTracking;
|
||||
|
||||
public static class PlayTimeTrackingShared
|
||||
{
|
||||
/// <summary>
|
||||
/// The prototype ID of the play time tracker that represents overall playtime, i.e. not tied to any one role.
|
||||
/// </summary>
|
||||
public const string TrackerOverall = "Overall";
|
||||
}
|
||||
14
Content.Shared/Roles/DepartmentPrototype.cs
Normal file
14
Content.Shared/Roles/DepartmentPrototype.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
|
||||
|
||||
namespace Content.Shared.Roles;
|
||||
|
||||
[Prototype("department")]
|
||||
public sealed class DepartmentPrototype : IPrototype
|
||||
{
|
||||
[IdDataFieldAttribute] public string ID { get; } = default!;
|
||||
|
||||
[ViewVariables(VVAccess.ReadWrite),
|
||||
DataField("roles", customTypeSerializer: typeof(PrototypeIdListSerializer<JobPrototype>))]
|
||||
public List<string> Roles = new();
|
||||
}
|
||||
6
Content.Shared/Roles/IRoleTimer.cs
Normal file
6
Content.Shared/Roles/IRoleTimer.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Content.Shared.Roles;
|
||||
|
||||
public interface IRoleTimer
|
||||
{
|
||||
string Timer { get; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Content.Shared.Access;
|
||||
using Content.Shared.Players.PlayTimeTracking;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
|
||||
@@ -11,12 +12,13 @@ namespace Content.Shared.Roles
|
||||
[Prototype("job")]
|
||||
public sealed class JobPrototype : IPrototype
|
||||
{
|
||||
private string _name = string.Empty;
|
||||
|
||||
[ViewVariables]
|
||||
[IdDataFieldAttribute]
|
||||
public string ID { get; } = default!;
|
||||
|
||||
[ViewVariables, DataField("playTimeTracker", required: true, customTypeSerializer: typeof(PrototypeIdSerializer<PlayTimeTrackerPrototype>))]
|
||||
public string PlayTimeTracker { get; } = string.Empty;
|
||||
|
||||
[DataField("supervisors")]
|
||||
public string Supervisors { get; } = "nobody";
|
||||
|
||||
@@ -29,6 +31,9 @@ namespace Content.Shared.Roles
|
||||
[ViewVariables(VVAccess.ReadOnly)]
|
||||
public string LocalizedName => Loc.GetString(Name);
|
||||
|
||||
[DataField("requirements")]
|
||||
public HashSet<JobRequirement>? Requirements;
|
||||
|
||||
[DataField("joinNotifyCrew")]
|
||||
public bool JoinNotifyCrew { get; } = false;
|
||||
|
||||
@@ -64,9 +69,6 @@ namespace Content.Shared.Roles
|
||||
[DataField("special", serverOnly:true)]
|
||||
public JobSpecial[] Special { get; private set; } = Array.Empty<JobSpecial>();
|
||||
|
||||
[DataField("departments")]
|
||||
public IReadOnlyCollection<string> Departments { get; } = Array.Empty<string>();
|
||||
|
||||
[DataField("access", customTypeSerializer: typeof(PrototypeIdListSerializer<AccessLevelPrototype>))]
|
||||
public IReadOnlyCollection<string> Access { get; } = Array.Empty<string>();
|
||||
|
||||
|
||||
145
Content.Shared/Roles/JobRequirements.cs
Normal file
145
Content.Shared/Roles/JobRequirements.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Content.Shared.Players.PlayTimeTracking;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
|
||||
|
||||
namespace Content.Shared.Roles
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstract class for playtime and other requirements for role gates.
|
||||
/// </summary>
|
||||
[ImplicitDataDefinitionForInheritors]
|
||||
public abstract class JobRequirement
|
||||
{
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public sealed class DepartmentTimeRequirement : JobRequirement
|
||||
{
|
||||
[DataField("department", customTypeSerializer: typeof(PrototypeIdSerializer<DepartmentPrototype>))]
|
||||
public string Department = default!;
|
||||
|
||||
/// <summary>
|
||||
/// How long (in seconds) this requirement is.
|
||||
/// </summary>
|
||||
[DataField("time")] public TimeSpan Time;
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public sealed class RoleTimeRequirement : JobRequirement
|
||||
{
|
||||
/// <summary>
|
||||
/// What particular role they need the time requirement with.
|
||||
/// </summary>
|
||||
[DataField("role", customTypeSerializer: typeof(PrototypeIdSerializer<PlayTimeTrackerPrototype>))]
|
||||
public string Role = default!;
|
||||
|
||||
/// <summary>
|
||||
/// How long (in seconds) this requirement is.
|
||||
/// </summary>
|
||||
[DataField("time")] public TimeSpan Time;
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public sealed class OverallPlaytimeRequirement : JobRequirement
|
||||
{
|
||||
/// <summary>
|
||||
/// How long (in seconds) this requirement is.
|
||||
/// </summary>
|
||||
[DataField("time")] public TimeSpan Time;
|
||||
}
|
||||
|
||||
public static class JobRequirements
|
||||
{
|
||||
public static bool TryRequirementsMet(
|
||||
JobPrototype job,
|
||||
Dictionary<string, TimeSpan> playTimes,
|
||||
[NotNullWhen(false)] out string? reason,
|
||||
IPrototypeManager prototypes)
|
||||
{
|
||||
reason = null;
|
||||
if (job.Requirements == null)
|
||||
return true;
|
||||
|
||||
foreach (var requirement in job.Requirements)
|
||||
{
|
||||
if (!TryRequirementMet(requirement, playTimes, out reason, prototypes))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string with the reason why a particular requirement may not be met.
|
||||
/// </summary>
|
||||
public static bool TryRequirementMet(
|
||||
JobRequirement requirement,
|
||||
Dictionary<string, TimeSpan> playTimes,
|
||||
[NotNullWhen(false)] out string? reason,
|
||||
IPrototypeManager prototypes)
|
||||
|
||||
{
|
||||
reason = null;
|
||||
|
||||
switch (requirement)
|
||||
{
|
||||
case DepartmentTimeRequirement deptRequirement:
|
||||
var playtime = TimeSpan.Zero;
|
||||
|
||||
// Check all jobs' departments
|
||||
var jobs = prototypes.Index<DepartmentPrototype>(deptRequirement.Department).Roles;
|
||||
string proto;
|
||||
|
||||
// Check all jobs' playtime
|
||||
foreach (var other in jobs)
|
||||
{
|
||||
// The schema is stored on the Job role but we want to explode if the timer isn't found anyway.
|
||||
proto = prototypes.Index<JobPrototype>(other).PlayTimeTracker;
|
||||
|
||||
playTimes.TryGetValue(proto, out var otherTime);
|
||||
playtime += otherTime;
|
||||
}
|
||||
|
||||
var deptDiff = deptRequirement.Time.TotalMinutes - playtime.TotalMinutes;
|
||||
|
||||
if (deptDiff <= 0)
|
||||
return true;
|
||||
|
||||
reason = Loc.GetString(
|
||||
"role-timer-department-insufficient",
|
||||
("time", deptDiff),
|
||||
("department", Loc.GetString(deptRequirement.Department)));
|
||||
return false;
|
||||
|
||||
case OverallPlaytimeRequirement overallRequirement:
|
||||
var overallTime = playTimes.GetValueOrDefault(PlayTimeTrackingShared.TrackerOverall);
|
||||
var overallDiff = overallRequirement.Time.TotalMinutes - overallTime.TotalMinutes;
|
||||
|
||||
if (overallDiff <= 0 || overallTime >= overallRequirement.Time)
|
||||
return true;
|
||||
|
||||
reason = Loc.GetString("role-timer-overall-insufficient", ("time", overallDiff));
|
||||
return false;
|
||||
|
||||
case RoleTimeRequirement roleRequirement:
|
||||
proto = roleRequirement.Role;
|
||||
|
||||
playTimes.TryGetValue(proto, out var roleTime);
|
||||
var roleDiff = roleRequirement.Time.TotalMinutes - roleTime.TotalMinutes;
|
||||
|
||||
if (roleDiff <= 0)
|
||||
return true;
|
||||
|
||||
reason = Loc.GetString(
|
||||
"role-timer-role-insufficient",
|
||||
("time", roleDiff),
|
||||
("job", Loc.GetString(proto)));
|
||||
return false;
|
||||
default:
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
Resources/Locale/en-US/job/department.ftl
Normal file
7
Resources/Locale/en-US/job/department.ftl
Normal file
@@ -0,0 +1,7 @@
|
||||
department-Cargo = cargo
|
||||
department-Civilian = civilian
|
||||
department-Command = command
|
||||
department-Engineering = engineering
|
||||
department-Medical = medical
|
||||
department-Security = security
|
||||
department-Science = science
|
||||
@@ -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
|
||||
|
||||
5
Resources/Locale/en-US/job/role-timers.ftl
Normal file
5
Resources/Locale/en-US/job/role-timers.ftl
Normal file
@@ -0,0 +1,5 @@
|
||||
role-timer-department-insufficient = Require {TOSTRING($time, "0")} more minutes in {$department} department.
|
||||
role-timer-overall-insufficient = Require {TOSTRING($time, "0")} more minutes of playtime.
|
||||
role-timer-role-insufficient = Require {TOSTRING($time, "0")} more minutes with {$job} for this role.
|
||||
|
||||
role-timer-locked = Locked (hover for details)
|
||||
@@ -0,0 +1,56 @@
|
||||
parse-minutes-fail = Unable to parse '{$minutes}' as minutes
|
||||
parse-session-fail = Did not find session for '{$username}'
|
||||
|
||||
## Role Timer Commands
|
||||
|
||||
# - playtime_addoverall
|
||||
cmd-playtime_addoverall-desc = Adds the specified minutes to a player's overall playtime
|
||||
cmd-playtime_addoverall-help = Usage: {$command} <user name> <minutes>
|
||||
cmd-playtime_addoverall-succeed = Increased overall time for {$username} to {TOSTRING($time, "0")}
|
||||
cmd-playtime_addoverall-arg-user = <user name>
|
||||
cmd-playtime_addoverall-arg-minutes = <minutes>
|
||||
cmd-playtime_addoverall-error-args = Expected exactly two arguments
|
||||
|
||||
# - playtime_addrole
|
||||
cmd-playtime_addrole-desc = Adds the specified minutes to a player's role playtime
|
||||
cmd-playtime_addrole-help = Usage: {$command} <user name> <role> <minutes>
|
||||
cmd-playtime_addrole-succeed = Increased role playtime for {$username} / \'{$role}\' to {TOSTRING($time, "0")}
|
||||
cmd-playtime_addrole-arg-user = <user name>
|
||||
cmd-playtime_addrole-arg-role = <role>
|
||||
cmd-playtime_addrole-arg-minutes = <minutes>
|
||||
cmd-playtime_addrole-error-args = Expected exactly three arguments
|
||||
|
||||
# - playtime_getoverall
|
||||
cmd-playtime_getoverall-desc = Gets the specified minutes for a player's overall playtime
|
||||
cmd-playtime_getoverall-help = Usage: {$command} <user name>
|
||||
cmd-playtime_getoverall-success = Overall time for {$username} is {TOSTRING($time, "0")} minutes
|
||||
cmd-playtime_getoverall-arg-user = <user name>
|
||||
cmd-playtime_getoverall-error-args = Expected exactly one argument
|
||||
|
||||
# - GetRoleTimer
|
||||
cmd-playtime_getrole-desc = Gets all or one role timers from a player
|
||||
cmd-playtime_getrole-help = Usage: {$command} <user name> [role]
|
||||
cmd-playtime_getrole-no = Found no role timers
|
||||
cmd-playtime_getrole-role = Role: {$role}, Playtime: {$time}
|
||||
cmd-playtime_getrole-overall = Overall playtime is {$time}
|
||||
cmd-playtime_getrole-succeed = Playtime for {$username} is: {TOSTRING($time, "0")}
|
||||
cmd-playtime_getrole-arg-user = <user name>
|
||||
cmd-playtime_getrole-arg-role = <role|'Overall'>
|
||||
cmd-playtime_getrole-error-args = Expected exactly one or two arguments
|
||||
|
||||
# - playtime_save
|
||||
cmd-playtime_save-desc = Saves the player's playtimes to the DB
|
||||
cmd-playtime_save-help = Usage: {$command} <user name>
|
||||
cmd-playtime_save-succeed = Saved playtime for {$username}
|
||||
cmd-playtime_save-arg-user = <user name>
|
||||
cmd-playtime_save-error-args = Expected exactly one argument
|
||||
|
||||
## 'playtime_flush' command'
|
||||
|
||||
cmd-playtime_flush-desc = Flush active trackers to stored in playtime tracking.
|
||||
cmd-playtime_flush-help = Usage: {$command} [user name]
|
||||
This causes a flush to the internal storage only, it does not flush to DB immediately.
|
||||
If a user is provided, only that user is flushed.
|
||||
|
||||
cmd-playtime_flush-error-args = Expected zero or one arguments
|
||||
cmd-playtime_flush-arg-user = [user name]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
74
Resources/Prototypes/Roles/Jobs/departments.yml
Normal file
74
Resources/Prototypes/Roles/Jobs/departments.yml
Normal file
@@ -0,0 +1,74 @@
|
||||
- type: department
|
||||
id: Cargo
|
||||
roles:
|
||||
- CargoTechnician
|
||||
- Quartermaster
|
||||
- SalvageSpecialist
|
||||
|
||||
- type: department
|
||||
id: Civilian
|
||||
roles:
|
||||
- Bartender
|
||||
- Botanist
|
||||
- Chaplain
|
||||
- Chef
|
||||
- Clown
|
||||
- HeadOfPersonnel
|
||||
- Janitor
|
||||
- Lawyer
|
||||
- Librarian
|
||||
- MedicalIntern
|
||||
- Mime
|
||||
- Musician
|
||||
- Passenger
|
||||
- Reporter
|
||||
- SecurityCadet
|
||||
- ServiceWorker
|
||||
- TechnicalAssistant
|
||||
|
||||
- type: department
|
||||
id: Command
|
||||
roles:
|
||||
- Captain
|
||||
- CentralCommandOfficial
|
||||
- ChiefEngineer
|
||||
- ChiefMedicalOfficer
|
||||
- ERTEngineer
|
||||
- ERTJanitor
|
||||
- ERTLeader
|
||||
- ERTMedical
|
||||
- ERTSecurity
|
||||
- HeadOfPersonnel
|
||||
- HeadOfSecurity
|
||||
- ResearchDirector
|
||||
|
||||
- type: department
|
||||
id: Engineering
|
||||
roles:
|
||||
- AtmosphericTechnician
|
||||
- ChiefEngineer
|
||||
- StationEngineer
|
||||
- TechnicalAssistant
|
||||
|
||||
- type: department
|
||||
id: Medical
|
||||
roles:
|
||||
- Chemist
|
||||
- ChiefMedicalOfficer
|
||||
- MedicalDoctor
|
||||
- MedicalIntern
|
||||
- Psychologist
|
||||
|
||||
- type: department
|
||||
id: Security
|
||||
roles:
|
||||
- HeadOfSecurity
|
||||
- SecurityCadet
|
||||
- SecurityOfficer
|
||||
- Warden
|
||||
|
||||
- type: department
|
||||
id: Science
|
||||
roles:
|
||||
- ResearchDirector
|
||||
- Scientist
|
||||
128
Resources/Prototypes/Roles/play_time_trackers.yml
Normal file
128
Resources/Prototypes/Roles/play_time_trackers.yml
Normal file
@@ -0,0 +1,128 @@
|
||||
# Overall play time, across all roles.
|
||||
# This tracker must exist, it is used directly by PlayTimeTrackingManager
|
||||
- type: playTimeTracker
|
||||
id: Overall
|
||||
|
||||
# Jobs
|
||||
- type: playTimeTracker
|
||||
id: JobAtmosphericTechnician
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobBartender
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobBotanist
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobCaptain
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobCargoTechnician
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobCentralCommandOfficial
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobChaplain
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobChef
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobChemist
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobChiefEngineer
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobChiefMedicalOfficer
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobClown
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobDetective
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobERTEngineer
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobERTJanitor
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobERTLeader
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobERTMedical
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobERTSecurity
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobHeadOfPersonnel
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobHeadOfSecurity
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobJanitor
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobLawyer
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobLibrarian
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobMedicalDoctor
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobMedicalIntern
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobMime
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobMusician
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobPassenger
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobPsychologist
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobQuartermaster
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobReporter
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobResearchDirector
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobSalvageSpecialist
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobScientist
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobSecurityCadet
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobSecurityOfficer
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobServiceWorker
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobStationEngineer
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobTechnicalAssistant
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobWarden
|
||||
|
||||
- type: playTimeTracker
|
||||
id: JobBoxer
|
||||
Reference in New Issue
Block a user